diff --git a/packages/frontend/@n8n/design-system/src/components/index.ts b/packages/frontend/@n8n/design-system/src/components/index.ts index 0013c161dbd..aa33b3ef663 100644 --- a/packages/frontend/@n8n/design-system/src/components/index.ts +++ b/packages/frontend/@n8n/design-system/src/components/index.ts @@ -122,6 +122,7 @@ export { default as N8nResizeWrapper } from './N8nResizeWrapper'; export { default as N8nSelect } from './N8nSelect'; export { default as N8nSpinner } from './N8nSpinner'; export { default as N8nStatusDot } from './N8nStatusDot'; +export type { StatusDotProps, StatusDotVariant } from './N8nStatusDot'; export { default as N8nStepper } from './N8nStepper/Stepper.vue'; export { default as N8nSticky } from './N8nSticky'; export { default as N8nResizeableSticky } from './N8nResizeableSticky'; diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 01e27e7a4ed..efcf4556355 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -4875,6 +4875,10 @@ "resourceActions.mcpAccess.disabled.message": "Turn on instance-level MCP access before managing workflow access

Manage MCP settings", "workflows.publish": "Publish", "workflows.published": "Published", + "workflows.publicationStatus.partial": "Partial publish", + "workflows.publicationStatus.failed": "Failed publish", + "workflows.publicationStatus.partial.tooltip": "Some triggers failed to activate — open the workflow to see which.", + "workflows.publicationStatus.failed.tooltip": "This workflow isn't running — open the workflow for details.", "workflows.publish.permissionDenied": "You don't have permission to publish this workflow", "workflows.publish.personalSpaceRestricted": "Workflow publishing is disabled in personal spaces. Move the workflow to a team space to activate", "workflows.publish.publishing": "Publishing…", diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 75d2f3971d1..e0588c462ce 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -5,6 +5,7 @@ import type { IUserManagementSettings, IVersionNotificationSettings, Role, + WorkflowListPublicationStatus, } from '@n8n/api-types'; import type { ILogInStatus } from '@/features/settings/users/users.types'; import type { NodeViewItemSection } from '@/features/shared/nodeCreator/views/viewsData'; @@ -329,6 +330,7 @@ export type WorkflowResource = BaseResource & { parentFolder?: ResourceParentFolder; settings?: Partial; hasResolvableCredentials?: boolean; + publicationStatus?: WorkflowListPublicationStatus; }; export type VariableResource = BaseResource & { @@ -387,6 +389,7 @@ export type WorkflowListItem = Omit< resource?: 'workflow'; // only included if list may contain folders description?: string; hasResolvableCredentials?: boolean; + publicationStatus?: WorkflowListPublicationStatus; }; export type WorkflowListResource = WorkflowListItem | FolderListItem; diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts index 134e970af59..15e45505765 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts @@ -1436,4 +1436,47 @@ describe('WorkflowCard', () => { }); }); }); + + describe('Publication status indicator', () => { + const renderCard = (overrides: Partial = {}) => + renderComponent({ props: { data: createWorkflow(overrides) } }); + + it.each([ + ['partial', 'Partial publish'], + ['failed', 'Failed publish'], + ] as const)('shows the %s indicator with its label and state', (status, label) => { + const { getByTestId, getByText } = renderCard({ + publicationStatus: status, + activeVersionId: 'v1', + }); + const indicator = getByTestId('workflow-card-publish-indicator'); + expect(indicator).toBeVisible(); + expect(indicator).toHaveAttribute('data-state', status); + expect(getByText(label)).toBeVisible(); + // The explanation lives in a focus-openable tooltip; the trigger must be tabbable. + expect(indicator).toHaveAttribute('tabindex', '0'); + }); + + it('lets the server status win over activeVersionId', () => { + const { getByTestId } = renderCard({ publicationStatus: 'failed', activeVersionId: null }); + expect(getByTestId('workflow-card-publish-indicator')).toHaveAttribute( + 'data-state', + 'failed', + ); + }); + + it('falls back to the legacy "Published" indicator when publicationStatus is absent', () => { + const { getByTestId, getByText } = renderCard({ activeVersionId: 'v1' }); + const indicator = getByTestId('workflow-card-publish-indicator'); + expect(indicator).toHaveAttribute('data-state', 'published'); + expect(getByText('Published')).toBeVisible(); + // The published state has no tooltip, so it must not be a focus stop. + expect(indicator).not.toHaveAttribute('tabindex'); + }); + + it('shows no indicator when not published and no publicationStatus', () => { + const { queryByTestId } = renderCard({ activeVersionId: null }); + expect(queryByTestId('workflow-card-publish-indicator')).toBeNull(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue index aa292d65e99..60bd60918b1 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue @@ -19,7 +19,9 @@ import TimeAgo from '@/app/components/TimeAgo.vue'; import { useProjectsStore } from '@/features/collaboration/projects/projects.store'; import ProjectCardBadge from '@/features/collaboration/projects/components/ProjectCardBadge.vue'; import DependencyPill from '@/app/components/DependencyPill.vue'; -import { useI18n } from '@n8n/i18n'; +import { type BaseTextKey, useI18n } from '@n8n/i18n'; +import type { WorkflowListPublicationStatus } from '@n8n/api-types'; +import type { StatusDotVariant } from '@n8n/design-system'; import { useRoute, useRouter } from 'vue-router'; import { useTelemetry } from '@n8n/composables/useTelemetry'; import { ResourceType } from '@/features/collaboration/projects/projects.utils'; @@ -40,6 +42,7 @@ import { N8nBreadcrumbs, N8nCard, N8nIcon, + N8nStatusDot, N8nTags, N8nText, N8nTooltip, @@ -324,6 +327,39 @@ const isWorkflowPublished = computed(() => { return props.data.activeVersionId !== null; }); +// Keyed by the full status union so a new backend status is a compile error +// here instead of silently rendering as the green 'Published' dot. +const publicationIndicators: Record< + WorkflowListPublicationStatus, + { variant: StatusDotVariant; labelKey: BaseTextKey; tooltipKey?: BaseTextKey } +> = { + published: { variant: 'success', labelKey: 'workflows.published' }, + partial: { + variant: 'warning', + labelKey: 'workflows.publicationStatus.partial', + tooltipKey: 'workflows.publicationStatus.partial.tooltip', + }, + failed: { + variant: 'danger', + labelKey: 'workflows.publicationStatus.failed', + tooltipKey: 'workflows.publicationStatus.failed.tooltip', + }, +}; + +// The server-derived status is authoritative when present; workflows with no +// settled trigger rows omit it and fall back to the legacy activeVersionId indicator. +const publicationIndicator = computed(() => { + const state = props.data.publicationStatus ?? (isWorkflowPublished.value ? 'published' : null); + if (state === null) return null; + const { variant, labelKey, tooltipKey } = publicationIndicators[state]; + return { + state, + variant, + label: locale.baseText(labelKey), + tooltip: tooltipKey ? locale.baseText(tooltipKey) : null, + }; +}); + const hasDynamicCredentials = computed(() => { return isPrivateCredentialsEnabled.value && props.data.hasResolvableCredentials; }); @@ -722,16 +758,32 @@ const tags = computed( > {{ locale.baseText('workflows.item.archived') }} -
- - {{ - locale.baseText('workflows.published') - }} -
+ + (() => { parentFolder: resource.parentFolder, settings: resource.settings, hasResolvableCredentials: resource.hasResolvableCredentials, + publicationStatus: resource.publicationStatus, } satisfies WorkflowResource; } }); @@ -1254,6 +1255,9 @@ const onWorkflowActiveToggle = async (data: { id: string; active: boolean }) => if (!workflow) return; workflow.active = data.active; workflow.activeVersionId = data.active ? workflow.versionId : null; + // The server-derived status outranks activeVersionId on the card and is now + // stale either way; clearing it falls back to the legacy indicator until a refetch. + workflow.publicationStatus = undefined; // Fetch the updated workflow to get the latest settings try { @@ -1274,6 +1278,8 @@ const onWorkflowUnpublished = async (data: { id: string }) => { // Update the workflow to reflect unpublished state workflow.activeVersionId = null; + // A stale server-derived status would keep the card indicator lit; clear it too. + workflow.publicationStatus = undefined; }; const getFolderListItem = (folderId: string): FolderListItem | undefined => {