mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-03 18:27:09 +02:00
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:
parent
25f2d3cf32
commit
e65b4abea1
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user