= {}) =>
+ 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')
- }}
-
+
+
+
+
+ {{ publicationIndicator.label }}
+
+
+ {{ publicationIndicator.tooltip }}
+
+
+
+ {{ publicationIndicator.label }}
+
+
+
(() => {
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 => {