feat(editor): Show partial/failed publication status on workflow list cards (no-changelog) (#34069)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mfsiega 2026-09-03 07:27:43 +00:00 committed by GitHub
parent a3aadec2a2
commit 496b81b750
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 120 additions and 18 deletions

View File

@ -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';

View File

@ -4875,6 +4875,10 @@
"resourceActions.mcpAccess.disabled.message": "Turn on instance-level MCP access before managing workflow access<br><br><a href=\"{link}\">Manage MCP settings</a>",
"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…",

View File

@ -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<IWorkflowSettings>;
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;

View File

@ -1436,4 +1436,47 @@ describe('WorkflowCard', () => {
});
});
});
describe('Publication status indicator', () => {
const renderCard = (overrides: Partial<WorkflowResource> = {}) =>
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();
});
});
});

View File

@ -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') }}
</N8nText>
<div
v-else-if="isWorkflowPublished"
:class="$style.publishIndicator"
data-test-id="workflow-card-publish-indicator"
>
<span :class="$style.publishIndicatorDot" />
<N8nText size="small" color="text-base">{{
locale.baseText('workflows.published')
}}</N8nText>
</div>
<!-- The tooltip is only mounted for the rare partial/failed states; the
common published case keeps the plain markup. -->
<template v-else-if="publicationIndicator">
<div
v-if="!publicationIndicator.tooltip"
:class="$style.publishIndicator"
:data-state="publicationIndicator.state"
data-test-id="workflow-card-publish-indicator"
>
<N8nStatusDot :variant="publicationIndicator.variant" />
<N8nText size="small" color="text-base">{{ publicationIndicator.label }}</N8nText>
</div>
<N8nTooltip v-else placement="top" as-child>
<template #content>{{ publicationIndicator.tooltip }}</template>
<!-- tabindex makes the explanation keyboard-reachable: the tooltip opens on focus. -->
<div
:class="$style.publishIndicator"
:data-state="publicationIndicator.state"
data-test-id="workflow-card-publish-indicator"
tabindex="0"
>
<N8nStatusDot :variant="publicationIndicator.variant" />
<N8nText size="small" color="text-base">{{ publicationIndicator.label }}</N8nText>
</div>
</N8nTooltip>
</template>
<WorkflowCardMcpToggle
v-if="props.isWorkflowCardMcpToggleEnabled"
:workflow-id="data.id"
@ -857,13 +909,6 @@ const tags = computed(
}
}
.publishIndicatorDot {
width: var(--spacing--2xs);
height: var(--spacing--2xs);
border-radius: 50%;
background-color: var(--color--mint-600);
}
@include mixins.breakpoint('sm-and-down') {
.cardLink {
--card--padding: 0 var(--spacing--sm) var(--spacing--sm);

View File

@ -537,6 +537,7 @@ const workflowListResources = computed<Resource[]>(() => {
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 => {