fix(editor): Show actions on published version in agent history (no-changelog) (#31545)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eugene 2026-06-02 13:50:02 +02:00 committed by GitHub
parent 25f2d3cf32
commit e65b4abea1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 265 additions and 20 deletions

View File

@ -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",

View File

@ -9,7 +9,7 @@ const ensureLoadedMock = vi.fn();
const agentsListRef = ref<AgentResource[] | null>(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' },
});
});

View File

@ -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: '<div><slot /></div>' },
N8nIconButton: {
@ -44,15 +55,15 @@ const STUBS = {
AgentVersionList: {
name: 'AgentVersionList',
template: '<div data-testid="stub-version-list" />',
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<string, unknown> = {}) {
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');
});
});

View File

@ -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: '<div />', props: ['loading', 'rows', 'animated'] },
N8nText: { name: 'N8nText', template: '<span><slot /></span>', props: ['size', 'color'] },
}));
vi.mock('../components/VersionHistory/AgentVersionListItem.vue', () => ({
default: {
name: 'AgentVersionListItem',
template: '<li data-testid="stub-item" />',
props: ['item', 'actions'],
},
}));
import AgentVersionList from '../components/VersionHistory/AgentVersionList.vue';
function makeItem(overrides: Partial<AgentVersionListItemDto> = {}): 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);
});
});

View File

@ -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<RouteLocationRaw>(() => ({
name: VIEWS.PROJECTS_WORKFLOWS,
name: PROJECT_AGENTS,
params: { projectId: props.projectId },
}));

View File

@ -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<Array<UserAction<IUser>>>(() => {
const result: Array<UserAction<IUser>> = [];
if (canUpdate.value) {
@ -57,6 +62,29 @@ const actions = computed<Array<UserAction<IUser>>>(() => {
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<Array<UserAction<IUser>>>(() => {
const result: Array<UserAction<IUser>> = [];
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({
<AgentVersionList
:items="items"
:actions="actions"
:active-actions="activeActions"
:has-more="hasMore"
:is-initial-load="isInitialLoad"
:is-loading="isLoading"

View File

@ -11,6 +11,7 @@ import AgentVersionListItem, { type AgentVersionAction } from './AgentVersionLis
const props = defineProps<{
items: AgentVersionListItemDto[];
actions: Array<UserAction<IUser>>;
activeActions: Array<UserAction<IUser>>;
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;
}
</script>

View File

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

View File

@ -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"
/>
</div>
</div>