From e65b4abea1d36836afe07351d65cab042d2ef07b Mon Sep 17 00:00:00 2001 From: Eugene Date: Tue, 2 Jun 2026 13:50:02 +0200 Subject: [PATCH] fix(editor): Show actions on published version in agent history (no-changelog) (#31545) Co-authored-by: Claude Opus 4.8 (1M context) --- .../frontend/@n8n/i18n/src/locales/en.json | 2 +- .../__tests__/AgentBuilderHeader.test.ts | 8 +- .../AgentVersionHistoryPanel.test.ts | 133 +++++++++++++++++- .../agents/__tests__/AgentVersionList.test.ts | 78 ++++++++++ .../agents/components/AgentBuilderHeader.vue | 5 +- .../AgentVersionHistoryPanel.vue | 47 ++++++- .../VersionHistory/AgentVersionList.vue | 5 +- .../VersionHistory/AgentVersionListItem.vue | 2 +- .../agents/views/AgentBuilderView.vue | 5 + 9 files changed, 265 insertions(+), 20 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/agents/__tests__/AgentVersionList.test.ts diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index fa388edec5e..3f4ec6e7da5 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -6138,7 +6138,6 @@ "agents.revertToPublished.modal.description": "This will permanently remove all changes made since the latest published version.", "agents.revertToPublished.modal.button.revert": "Revert changes", "agents.versionHistory.title": "Publish history", - "agents.versionHistory.button.tooltip": "Version history", "agents.versionHistory.button.tooltip.empty": "This agent currently has no history to view. Once you've published it for the first time, you'll be able to view previous versions", "agents.versionHistory.button.ariaLabel": "Open version history", "agents.versionHistory.close": "Close", @@ -6148,6 +6147,7 @@ "agents.versionHistory.item.publishedBadge": "Currently published", "agents.versionHistory.item.actions.revert": "Revert to this version", "agents.versionHistory.item.actions.publish": "Publish this version", + "agents.versionHistory.item.actions.unpublish": "Unpublish", "agents.versionHistory.revert.modal.title": "Revert to this version?", "agents.versionHistory.revert.modal.description": "This will replace your current draft with the contents of this version. Unsaved changes will be lost.", "agents.versionHistory.revert.modal.button.revert": "Revert draft", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts index af0ba0f8843..0aee013cbb4 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts @@ -9,7 +9,7 @@ const ensureLoadedMock = vi.fn(); const agentsListRef = ref(null); const routerPush = vi.fn(); const routerResolve = vi.fn((to: { params?: { projectId?: string } }) => ({ - href: `/projects/${to.params?.projectId ?? ''}/workflows`, + href: `/projects/${to.params?.projectId ?? ''}/agents`, })); vi.mock('../composables/useProjectAgentsList', () => ({ @@ -168,16 +168,16 @@ describe('AgentBuilderHeader', () => { expect(wrapper.text()).toContain('Darwin'); }); - it('links the project breadcrumb to the project workflows page', () => { + it('links the project breadcrumb to the project agents page', () => { const wrapper = mountHeader(); const bc = wrapper.findComponent({ name: 'N8nBreadcrumbs' }); const items = bc.props('items') as Array<{ href: string }>; - expect(items[0].href).toBe('/projects/p1/workflows'); + expect(items[0].href).toBe('/projects/p1/agents'); bc.vm.$emit('itemSelected', { id: 'p1' }); expect(routerPush).toHaveBeenCalledWith({ - name: 'ProjectsWorkflows', + name: 'ProjectAgents', params: { projectId: 'p1' }, }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVersionHistoryPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVersionHistoryPanel.test.ts index 66c496954b5..0f29a9358ca 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVersionHistoryPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVersionHistoryPanel.test.ts @@ -34,6 +34,17 @@ vi.mock('../composables/useAgentPermissions', () => ({ useAgentPermissions: () => agentPermissionsMock, })); +const unpublishMock = vi.fn().mockResolvedValue({ id: 'agent-1', name: 'My Agent' }); + +vi.mock('../composables/useAgentPublish', () => ({ + useAgentPublish: () => ({ + publish: vi.fn(), + unpublish: unpublishMock, + revertToPublished: vi.fn(), + publishing: ref(false), + }), +})); + const STUBS = { N8nHeading: { template: '
' }, N8nIconButton: { @@ -44,15 +55,15 @@ const STUBS = { AgentVersionList: { name: 'AgentVersionList', template: '
', - props: ['items', 'actions', 'hasMore', 'isInitialLoad', 'isLoading'], + props: ['items', 'actions', 'activeActions', 'hasMore', 'isInitialLoad', 'isLoading'], }, }; import AgentVersionHistoryPanel from '../components/VersionHistory/AgentVersionHistoryPanel.vue'; -function mountPanel() { +function mountPanel(props: Record = {}) { return mount(AgentVersionHistoryPanel, { - props: { projectId: 'project-1', agentId: 'agent-1' }, + props: { projectId: 'project-1', agentId: 'agent-1', ...props }, global: { stubs: STUBS }, }); } @@ -104,3 +115,119 @@ describe('AgentVersionHistoryPanel — RBAC gating of row actions', () => { expect(actions).toEqual([]); }); }); + +describe('AgentVersionHistoryPanel — published-row actions', () => { + beforeEach(() => { + agentPermissionsMock.canUpdate.value = true; + agentPermissionsMock.canPublish.value = true; + agentPermissionsMock.canUnpublish.value = true; + vi.clearAllMocks(); + }); + + it('offers Revert and Unpublish on the published row, never Publish', async () => { + const wrapper = mountPanel(); + await flushPromises(); + + const list = wrapper.findComponent({ name: 'AgentVersionList' }); + const activeActions = list.props('activeActions') as Array<{ value: string }>; + expect(activeActions.map((a) => a.value)).toEqual(['revert', 'unpublish']); + }); + + it('disables Revert when the draft already matches the published version', async () => { + const wrapper = mountPanel({ hasUnpublishedChanges: false }); + await flushPromises(); + + const list = wrapper.findComponent({ name: 'AgentVersionList' }); + const activeActions = list.props('activeActions') as Array<{ + value: string; + disabled: boolean; + }>; + expect(activeActions.find((a) => a.value === 'revert')?.disabled).toBe(true); + }); + + it('enables Revert when the draft has unpublished changes', async () => { + const wrapper = mountPanel({ hasUnpublishedChanges: true }); + await flushPromises(); + + const list = wrapper.findComponent({ name: 'AgentVersionList' }); + const activeActions = list.props('activeActions') as Array<{ + value: string; + disabled: boolean; + }>; + expect(activeActions.find((a) => a.value === 'revert')?.disabled).toBe(false); + }); + + it('omits Unpublish on the published row when the user cannot unpublish', async () => { + agentPermissionsMock.canUnpublish.value = false; + const wrapper = mountPanel(); + await flushPromises(); + + const list = wrapper.findComponent({ name: 'AgentVersionList' }); + const activeActions = list.props('activeActions') as Array<{ value: string }>; + expect(activeActions.map((a) => a.value)).toEqual(['revert']); + }); + + it('omits Revert on the published row when the user cannot update', async () => { + agentPermissionsMock.canUpdate.value = false; + const wrapper = mountPanel(); + await flushPromises(); + + const list = wrapper.findComponent({ name: 'AgentVersionList' }); + const activeActions = list.props('activeActions') as Array<{ value: string }>; + expect(activeActions.map((a) => a.value)).toEqual(['unpublish']); + }); + + it('reverts the published row through the same version-revert flow with its own version id', async () => { + const updated = { id: 'agent-1', name: 'My Agent' }; + versionHistoryMock.revertToVersion.mockResolvedValueOnce(updated); + const wrapper = mountPanel({ hasUnpublishedChanges: true }); + await flushPromises(); + + const list = wrapper.findComponent({ name: 'AgentVersionList' }); + list.vm.$emit('action', { action: 'revert', versionId: 'v-active' }); + await flushPromises(); + + expect(versionHistoryMock.revertToVersion).toHaveBeenCalledWith( + 'project-1', + 'agent-1', + 'v-active', + ); + expect(wrapper.emitted('reverted')?.[0]).toEqual([updated]); + }); +}); + +describe('AgentVersionHistoryPanel — unpublish action', () => { + beforeEach(() => { + agentPermissionsMock.canUpdate.value = true; + agentPermissionsMock.canPublish.value = true; + agentPermissionsMock.canUnpublish.value = true; + vi.clearAllMocks(); + }); + + it('runs the unpublish flow and emits the updated agent', async () => { + const updated = { id: 'agent-1', name: 'My Agent' }; + unpublishMock.mockResolvedValueOnce(updated); + const wrapper = mountPanel({ agentName: 'My Agent' }); + await flushPromises(); + + const list = wrapper.findComponent({ name: 'AgentVersionList' }); + list.vm.$emit('action', { action: 'unpublish', versionId: 'v-active' }); + await flushPromises(); + + expect(unpublishMock).toHaveBeenCalledWith('project-1', 'agent-1', 'My Agent'); + expect(wrapper.emitted('unpublished')?.[0]).toEqual([updated]); + }); + + it('refreshes the version list after a successful unpublish', async () => { + unpublishMock.mockResolvedValueOnce({ id: 'agent-1', name: 'My Agent' }); + const wrapper = mountPanel({ agentName: 'My Agent' }); + await flushPromises(); + versionHistoryMock.refresh.mockClear(); + + const list = wrapper.findComponent({ name: 'AgentVersionList' }); + list.vm.$emit('action', { action: 'unpublish', versionId: 'v-active' }); + await flushPromises(); + + expect(versionHistoryMock.refresh).toHaveBeenCalledWith('project-1', 'agent-1'); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVersionList.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVersionList.test.ts new file mode 100644 index 00000000000..2766e8128b1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentVersionList.test.ts @@ -0,0 +1,78 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- test-only patterns */ +import { describe, it, expect, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import type { AgentVersionListItemDto } from '@n8n/api-types'; + +vi.mock('@n8n/i18n', () => ({ + useI18n: () => ({ baseText: (k: string) => k }), +})); + +vi.mock('@/app/composables/useIntersectionObserver', () => ({ + useIntersectionObserver: () => ({ observe: vi.fn() }), +})); + +vi.mock('@n8n/design-system', () => ({ + N8nLoading: { name: 'N8nLoading', template: '
', props: ['loading', 'rows', 'animated'] }, + N8nText: { name: 'N8nText', template: '', props: ['size', 'color'] }, +})); + +vi.mock('../components/VersionHistory/AgentVersionListItem.vue', () => ({ + default: { + name: 'AgentVersionListItem', + template: '
  • ', + props: ['item', 'actions'], + }, +})); + +import AgentVersionList from '../components/VersionHistory/AgentVersionList.vue'; + +function makeItem(overrides: Partial = {}): AgentVersionListItemDto { + return { + versionId: 'v1', + agentId: 'agent-1', + createdAt: '2026-01-02T10:11:12.000Z', + updatedAt: '2026-01-02T10:11:12.000Z', + author: 'Ada', + isActive: false, + ...overrides, + }; +} + +const defaultActions = [ + { label: 'revert', value: 'revert', disabled: false }, + { label: 'publish', value: 'publish', disabled: false }, +]; + +const activeActions = [ + { label: 'revert', value: 'revert', disabled: true }, + { label: 'unpublish', value: 'unpublish', disabled: false }, +]; + +function mountList() { + return mount(AgentVersionList, { + props: { + items: [ + makeItem({ versionId: 'inactive', isActive: false }), + makeItem({ versionId: 'active', isActive: true }), + ], + actions: defaultActions, + activeActions, + hasMore: false, + isInitialLoad: false, + isLoading: false, + }, + }); +} + +describe('AgentVersionList — per-row action routing', () => { + it('routes the active-row actions to the published version and default actions to the rest', () => { + const wrapper = mountList(); + const items = wrapper.findAllComponents({ name: 'AgentVersionListItem' }); + + const inactive = items.find((c) => c.props('item').versionId === 'inactive'); + const active = items.find((c) => c.props('item').versionId === 'active'); + + expect(inactive?.props('actions')).toEqual(defaultActions); + expect(active?.props('actions')).toEqual(activeActions); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue index 7badabce055..52e78d6f2c7 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue @@ -21,8 +21,7 @@ import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Brea import type { DropdownMenuItemProps } from '@n8n/design-system'; import type { ActionDropdownItem } from '@n8n/design-system/types/action-dropdown'; import { useI18n, type BaseTextKey } from '@n8n/i18n'; -import { VIEWS } from '@/app/constants'; -import { NEW_AGENT_VIEW } from '@/features/agents/constants'; +import { NEW_AGENT_VIEW, PROJECT_AGENTS } from '@/features/agents/constants'; import AgentPublishButton from './AgentPublishButton.vue'; import { useProjectAgentsList } from '../composables/useProjectAgentsList'; @@ -66,7 +65,7 @@ onMounted(() => { }); const projectRoute = computed(() => ({ - name: VIEWS.PROJECTS_WORKFLOWS, + name: PROJECT_AGENTS, params: { projectId: props.projectId }, })); diff --git a/packages/frontend/editor-ui/src/features/agents/components/VersionHistory/AgentVersionHistoryPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/VersionHistory/AgentVersionHistoryPanel.vue index f7de501c272..36ac50c68ce 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/VersionHistory/AgentVersionHistoryPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/VersionHistory/AgentVersionHistoryPanel.vue @@ -6,6 +6,7 @@ import type { IUser } from 'n8n-workflow'; import { useI18n } from '@n8n/i18n'; import { useAgentVersionHistory } from '../../composables/useAgentVersionHistory'; import { useAgentPermissions } from '../../composables/useAgentPermissions'; +import { useAgentPublish } from '../../composables/useAgentPublish'; import type { AgentResource } from '../../types'; import AgentVersionList from './AgentVersionList.vue'; import type { AgentVersionAction } from './AgentVersionListItem.vue'; @@ -13,12 +14,17 @@ import type { AgentVersionAction } from './AgentVersionListItem.vue'; const props = defineProps<{ projectId: string; agentId: string; + // Whether the draft has diverged from the published version. + hasUnpublishedChanges?: boolean; + // Used only for the unpublish confirmation modal copy. + agentName?: string; }>(); const emit = defineEmits<{ close: []; reverted: [agent: AgentResource]; published: [agent: AgentResource]; + unpublished: [agent: AgentResource]; }>(); const i18n = useI18n(); @@ -32,12 +38,11 @@ const { revertToVersion, publishVersion, } = useAgentVersionHistory(); +const { unpublish } = useAgentPublish(); -const { canUpdate, canPublish } = useAgentPermissions(toRef(props, 'projectId')); +const { canUpdate, canPublish, canUnpublish } = useAgentPermissions(toRef(props, 'projectId')); -// Hide actions the user can't perform server-side. Matches the gating in -// AgentPublishButton so viewers with `agent:read` only don't see options that -// would 403 on click. +// Hide actions the user can't perform server-side. const actions = computed>>(() => { const result: Array> = []; if (canUpdate.value) { @@ -57,6 +62,29 @@ const actions = computed>>(() => { return result; }); +// Actions for the currently-published row. Publish is dropped (the version is +// already active); Revert is disabled while the draft is still in sync, since +// it would be a no-op; Unpublish clears the active version. Same RBAC gating as +// the header dropdown. +const activeActions = computed>>(() => { + const result: Array> = []; + if (canUpdate.value) { + result.push({ + label: i18n.baseText('agents.versionHistory.item.actions.revert'), + value: 'revert', + disabled: !props.hasUnpublishedChanges, + }); + } + if (canUnpublish.value) { + result.push({ + label: i18n.baseText('agents.versionHistory.item.actions.unpublish'), + value: 'unpublish', + disabled: false, + }); + } + return result; +}); + onMounted(() => { void refresh(props.projectId, props.agentId); }); @@ -72,9 +100,17 @@ async function onAction({ action, versionId }: { action: AgentVersionAction; ver if (action === 'revert') { const result = await revertToVersion(props.projectId, props.agentId, versionId); if (result) emit('reverted', result); - } else { + } else if (action === 'publish') { const result = await publishVersion(props.projectId, props.agentId, versionId); if (result) emit('published', result); + } else { + // Unpublish reuses the shared publish flow (confirmation modal + toast + + // telemetry); refresh the list ourselves since that flow doesn't own it. + const result = await unpublish(props.projectId, props.agentId, props.agentName); + if (result) { + await refresh(props.projectId, props.agentId); + emit('unpublished', result); + } } } @@ -116,6 +152,7 @@ defineExpose({ >; + activeActions: Array>; hasMore: boolean; isInitialLoad: boolean; isLoading: boolean; @@ -46,9 +47,7 @@ const isEmpty = computed( ); function getActions(item: AgentVersionListItemDto) { - // Hide both Revert and Publish on the currently-active row — neither does - // anything meaningful (revert would be a no-op, publish is already active). - return item.isActive ? [] : props.actions; + return item.isActive ? props.activeActions : props.actions; } diff --git a/packages/frontend/editor-ui/src/features/agents/components/VersionHistory/AgentVersionListItem.vue b/packages/frontend/editor-ui/src/features/agents/components/VersionHistory/AgentVersionListItem.vue index 176012970c7..2170590d6b2 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/VersionHistory/AgentVersionListItem.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/VersionHistory/AgentVersionListItem.vue @@ -8,7 +8,7 @@ import { useI18n } from '@n8n/i18n'; import AgentVersionStatusIndicator from './AgentVersionStatusIndicator.vue'; import { formatTimestamp, generateVersionLabel } from './agentVersionHistory.utils'; -export type AgentVersionAction = 'revert' | 'publish'; +export type AgentVersionAction = 'revert' | 'publish' | 'unpublish'; const props = defineProps<{ item: AgentVersionListItemDto; diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue index 79f02ec207d..94e245c54d7 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue @@ -1208,9 +1208,14 @@ function onSwitchAgent(nextAgentId: string) { ref="versionHistoryPanel" :project-id="projectId" :agent-id="agentId" + :has-unpublished-changes=" + Boolean(agent?.activeVersionId) && agent?.versionId !== agent?.activeVersionId + " + :agent-name="agent?.name ?? agentName" @close="onCloseVersionHistory" @reverted="onReverted" @published="onPublished" + @unpublished="onUnpublished" />