refactor(editor): Normalize scopes field in workflow document state (no-changelog) (#26331)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Raúl Gómez Morales
2026-03-04 08:00:20 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 19147d7e44
commit ebca21f58d
25 changed files with 235 additions and 109 deletions
@@ -11,7 +11,7 @@ export type PermissionsRecord = {
[K in keyof typeof RESOURCES]: ActionBooleans<(typeof RESOURCES)[K]>;
};
export const getResourcePermissions = (resourceScopes: Scope[] = []): PermissionsRecord =>
export const getResourcePermissions = (resourceScopes: readonly Scope[] = []): PermissionsRecord =>
Object.keys(RESOURCES).reduce(
(permissions, key) => ({
...permissions,
@@ -288,7 +288,6 @@ async function onWorkflowDeactivated() {
:id="workflow.id"
:tags="workflowTags"
:name="workflow.name"
:scopes="workflow.scopes"
:current-folder="parentFolderForBreadcrumbs"
:is-archived="workflow.isArchived"
:description="workflow.description"
@@ -170,7 +170,6 @@ const defaultProps = {
id: workflow.id,
tags: ['1', '2'] as readonly string[],
name: workflow.name,
scopes: workflow.scopes,
isArchived: workflow.isArchived,
description: workflow.description,
};
@@ -181,6 +180,7 @@ describe('WorkflowDetails', () => {
setActivePinia(docPinia);
const workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(workflow.id));
workflowDocumentStore.setActiveState({ activeVersionId: null, activeVersion: null });
workflowDocumentStore.setScopes(workflow.scopes ?? []);
workflowDocumentStoreRef.value = workflowDocumentStore;
setActivePinia(pinia);
@@ -262,11 +262,11 @@ describe('WorkflowDetails', () => {
it('should not have workflow duplicate and import when branch is read-only', async () => {
sourceControlStore.preferences.branchReadOnly = true;
workflowDocumentStoreRef.value?.setScopes(['workflow:read']);
const { getByTestId, queryByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: false,
scopes: ['workflow:read'],
},
});
@@ -280,11 +280,11 @@ describe('WorkflowDetails', () => {
it('should not have workflow duplicate and import when collaboration is read-only', async () => {
collaborationStore.shouldBeReadOnly = true;
workflowDocumentStoreRef.value?.setScopes(['workflow:read']);
const { getByTestId, queryByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: false,
scopes: ['workflow:read'],
},
});
@@ -296,11 +296,11 @@ describe('WorkflowDetails', () => {
});
it('should have workflow duplicate and import options if permission update is true', async () => {
workflowDocumentStoreRef.value?.setScopes(['workflow:update']);
const { getByTestId, queryByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: false,
scopes: ['workflow:update'],
},
});
@@ -326,12 +326,12 @@ describe('WorkflowDetails', () => {
params: { name: 'test' },
} as unknown as ReturnType<typeof useRoute>);
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId, queryByTestId } = renderComponent({
props: {
...defaultProps,
id: 'new',
isArchived: false,
scopes: ['workflow:delete'],
},
});
@@ -343,11 +343,11 @@ describe('WorkflowDetails', () => {
});
it("should have 'Archive' option on non archived workflow", async () => {
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId, queryByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: false,
scopes: ['workflow:delete'],
},
});
@@ -361,11 +361,11 @@ describe('WorkflowDetails', () => {
it("should not have 'Archive' option on non archived workflow when branch is read-only", async () => {
sourceControlStore.preferences.branchReadOnly = true;
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId, queryByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: false,
scopes: ['workflow:delete'],
},
});
@@ -378,11 +378,11 @@ describe('WorkflowDetails', () => {
it("should not have 'Archive' option on non archived workflow when collaboration is read-only", async () => {
collaborationStore.shouldBeReadOnly = true;
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId, queryByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: false,
scopes: ['workflow:delete'],
},
});
@@ -393,11 +393,11 @@ describe('WorkflowDetails', () => {
});
it("should not have 'Archive' option on non archived workflow without permission", async () => {
workflowDocumentStoreRef.value?.setScopes(['workflow:update']);
const { getByTestId, queryByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: false,
scopes: ['workflow:update'],
},
});
@@ -408,11 +408,11 @@ describe('WorkflowDetails', () => {
});
it("should have 'Unarchive' and 'Delete' options on archived workflow", async () => {
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId, queryByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: true,
scopes: ['workflow:delete'],
},
});
@@ -427,11 +427,11 @@ describe('WorkflowDetails', () => {
it("should not have 'Unarchive' or 'Delete' options on archived workflow when branch is read-only", async () => {
sourceControlStore.preferences.branchReadOnly = true;
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId, queryByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: true,
scopes: ['workflow:delete'],
},
});
@@ -444,11 +444,11 @@ describe('WorkflowDetails', () => {
it("should not have 'Unarchive' or 'Delete' options on archived workflow when collaboration is read-only", async () => {
collaborationStore.shouldBeReadOnly = true;
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId, queryByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: true,
scopes: ['workflow:delete'],
},
});
@@ -459,11 +459,11 @@ describe('WorkflowDetails', () => {
});
it("should not have 'Unarchive' or 'Delete' options on archived workflow without permission", async () => {
workflowDocumentStoreRef.value?.setScopes(['workflow:update']);
const { getByTestId, queryByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: true,
scopes: ['workflow:update'],
},
});
@@ -474,11 +474,11 @@ describe('WorkflowDetails', () => {
});
it('should not have edit actions on archived workflow even with update permission', async () => {
workflowDocumentStoreRef.value?.setScopes(['workflow:update', 'workflow:delete']);
const { getByTestId, queryByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: true,
scopes: ['workflow:update', 'workflow:delete'],
},
});
@@ -489,11 +489,11 @@ describe('WorkflowDetails', () => {
});
it("should call onWorkflowMenuSelect on 'Archive' option click on nonactive workflow", async () => {
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: false,
scopes: ['workflow:delete'],
},
});
@@ -527,11 +527,11 @@ describe('WorkflowDetails', () => {
workflowsListStore.getWorkflowById.mockReturnValue(teamWorkflow as IWorkflowDb);
workflowsStore.archiveWorkflow.mockResolvedValue(undefined);
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: false,
scopes: ['workflow:delete'],
},
});
@@ -558,11 +558,11 @@ describe('WorkflowDetails', () => {
workflowsListStore.getWorkflowById.mockReturnValue(personalWorkflow as IWorkflowDb);
workflowsStore.archiveWorkflow.mockResolvedValue(undefined);
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: false,
scopes: ['workflow:delete'],
},
});
@@ -583,11 +583,11 @@ describe('WorkflowDetails', () => {
activeVersionId: 'v1',
activeVersion: null,
});
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: false,
scopes: ['workflow:delete'],
},
});
@@ -609,11 +609,11 @@ describe('WorkflowDetails', () => {
});
it("should call onWorkflowMenuSelect on 'Unarchive' option click", async () => {
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: true,
scopes: ['workflow:delete'],
},
});
@@ -627,11 +627,11 @@ describe('WorkflowDetails', () => {
});
it("should call onWorkflowMenuSelect on 'Delete' option click", async () => {
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: true,
scopes: ['workflow:delete'],
},
});
@@ -664,11 +664,11 @@ describe('WorkflowDetails', () => {
workflowsListStore.getWorkflowById.mockReturnValue(teamWorkflow as IWorkflowDb);
workflowsListStore.deleteWorkflow.mockResolvedValue(undefined);
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: true,
scopes: ['workflow:delete'],
},
});
@@ -696,11 +696,11 @@ describe('WorkflowDetails', () => {
workflowsListStore.getWorkflowById.mockReturnValue(personalWorkflow as IWorkflowDb);
workflowsListStore.deleteWorkflow.mockResolvedValue(undefined);
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: true,
scopes: ['workflow:delete'],
},
});
@@ -718,10 +718,10 @@ describe('WorkflowDetails', () => {
workflowsListStore.workflowsById = { [workflow.id]: workflow };
workflowDocumentStoreRef.value?.setScopes(['workflow:move']);
const { getByTestId } = renderComponent({
props: {
...defaultProps,
scopes: ['workflow:move'],
},
});
@@ -737,11 +737,11 @@ describe('WorkflowDetails', () => {
describe('Archived badge', () => {
it('should show badge on archived workflow', async () => {
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { getByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: true,
scopes: ['workflow:delete'],
},
});
@@ -749,11 +749,11 @@ describe('WorkflowDetails', () => {
});
it('should not show badge on non archived workflow', async () => {
workflowDocumentStoreRef.value?.setScopes(['workflow:delete']);
const { queryByTestId } = renderComponent({
props: {
...defaultProps,
isArchived: false,
scopes: ['workflow:delete'],
},
});
@@ -56,7 +56,6 @@ const props = defineProps<{
id: IWorkflowDb['id'];
tags: readonly string[];
name: IWorkflowDb['name'];
scopes: IWorkflowDb['scopes'];
currentFolder?: FolderShortInfo;
isArchived: IWorkflowDb['isArchived'];
description?: IWorkflowDb['description'];
@@ -104,7 +103,9 @@ const isNewWorkflow = computed(() => {
return !workflowsStore.isWorkflowSaved[props.id];
});
const workflowPermissions = computed(() => getResourcePermissions(props.scopes).workflow);
const workflowPermissions = computed(
() => getResourcePermissions(workflowDocumentStore?.value?.scopes).workflow,
);
const readOnly = computed(
() => sourceControlStore.preferences.branchReadOnly || collaborationStore.shouldBeReadOnly,
@@ -24,6 +24,10 @@ import { usePageRedirectionHelper } from '@/app/composables/usePageRedirectionHe
import { useI18n } from '@n8n/i18n';
import { telemetry } from '@/app/plugins/telemetry';
import { useWorkflowSaving } from '@/app/composables/useWorkflowSaving';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { I18nT } from 'vue-i18n';
import { N8nButton, N8nInfoTip, N8nText } from '@n8n/design-system';
@@ -87,7 +91,14 @@ const modalTitle = computed(() => {
);
});
const workflowPermissions = computed(() => getResourcePermissions(workflow.value?.scopes).workflow);
const workflowPermissions = computed(() => {
// For existing workflows, scopes come from the API response on the workflow object.
// For new unsaved workflows, scopes are only in the workflowDocument store.
const scopes =
workflow.value?.scopes ??
useWorkflowDocumentStore(createWorkflowDocumentId(workflow.value.id)).scopes;
return getResourcePermissions(scopes).workflow;
});
const isPersonalSpaceRestricted = computed(
() =>
@@ -4848,10 +4848,7 @@ describe('useCanvasOperations', () => {
},
};
const getNewWorkflowDataAndMakeShareable = vi.spyOn(
workflowState,
'getNewWorkflowDataAndMakeShareable',
);
const getNewWorkflowData = vi.spyOn(workflowState, 'getNewWorkflowData');
const { importTemplate } = useCanvasOperations();
@@ -4874,10 +4871,7 @@ describe('useCanvasOperations', () => {
disabled: false,
});
expect(workflowsStore.setNodePristine).toHaveBeenCalledWith(nodeB.name, true);
expect(getNewWorkflowDataAndMakeShareable).toHaveBeenCalledWith(
templateName,
projectsStore.currentProjectId,
);
expect(getNewWorkflowData).toHaveBeenCalledWith(templateName, projectsStore.currentProjectId);
});
});
describe('replaceNodeParameters', () => {
@@ -5521,19 +5515,13 @@ describe('useCanvasOperations', () => {
workflow: { nodes: [], connections: {} },
});
const getNewWorkflowDataAndMakeShareable = vi.spyOn(
workflowState,
'getNewWorkflowDataAndMakeShareable',
);
const getNewWorkflowData = vi.spyOn(workflowState, 'getNewWorkflowData');
const { openWorkflowTemplate } = useCanvasOperations();
await openWorkflowTemplate('template-id');
expect(templatesStore.getFixedWorkflowTemplate).toHaveBeenCalledWith('template-id');
expect(getNewWorkflowDataAndMakeShareable).toHaveBeenCalledWith(
'Template Name',
'test-project-id',
);
expect(getNewWorkflowData).toHaveBeenCalledWith('Template Name', 'test-project-id');
expect(telemetry.track).toHaveBeenCalledWith('User inserted workflow template', {
source: 'workflow',
@@ -5589,18 +5577,12 @@ describe('useCanvasOperations', () => {
meta: { templateId: 'template-id' },
};
const getNewWorkflowDataAndMakeShareable = vi.spyOn(
workflowState,
'getNewWorkflowDataAndMakeShareable',
);
const getNewWorkflowData = vi.spyOn(workflowState, 'getNewWorkflowData');
const { openWorkflowTemplateFromJSON } = useCanvasOperations();
await openWorkflowTemplateFromJSON(template);
expect(getNewWorkflowDataAndMakeShareable).toHaveBeenCalledWith(
'Template Name',
'test-project-id',
);
expect(getNewWorkflowData).toHaveBeenCalledWith('Template Name', 'test-project-id');
expect(router.replace).toHaveBeenCalledWith({
name: VIEWS.NEW_WORKFLOW,
@@ -2975,7 +2975,7 @@ export function useCanvasOperations() {
workflowsStore.setConnections(workflow.connections);
}
await addNodes(convertedNodes ?? [], { keepPristine: true });
await workflowState.getNewWorkflowDataAndMakeShareable(name, projectsStore.currentProjectId);
await workflowState.getNewWorkflowData(name, projectsStore.currentProjectId);
}
function tryToOpenSubworkflowInNewTab(nodeId: string): boolean {
@@ -249,7 +249,6 @@ describe('useWorkflowHelpers', () => {
const setWorkflowIdSpy = vi.spyOn(workflowState, 'setWorkflowId');
const setWorkflowNameSpy = vi.spyOn(workflowState, 'setWorkflowName');
const setWorkflowVersionDataSpy = vi.spyOn(workflowsStore, 'setWorkflowVersionData');
const setWorkflowScopesSpy = vi.spyOn(workflowState, 'setWorkflowScopes');
const setUsedCredentialsSpy = vi.spyOn(workflowsStore, 'setUsedCredentials');
const setWorkflowSharedWithSpy = vi.spyOn(workflowsEEStore, 'setWorkflowSharedWith');
const upsertTagsSpy = vi.spyOn(tagsStore, 'upsertTags');
@@ -267,7 +266,6 @@ describe('useWorkflowHelpers', () => {
name: null,
description: null,
});
expect(setWorkflowScopesSpy).toHaveBeenCalledWith(['workflow:create']);
expect(setUsedCredentialsSpy).toHaveBeenCalledWith([]);
expect(setWorkflowSharedWithSpy).toHaveBeenCalledWith({
workflowId: '1',
@@ -982,7 +982,6 @@ export function useWorkflowHelpers() {
name: null,
description: null,
});
ws.setWorkflowScopes(workflowData.scopes);
if ('activeVersion' in workflowData) {
workflowsStore.setWorkflowActiveVersion(workflowData.activeVersion ?? null);
@@ -1043,6 +1042,7 @@ export function useWorkflowHelpers() {
workflowDocumentStore.setChecksum(workflowData.checksum);
}
workflowDocumentStore.setMeta(workflowData.meta);
workflowDocumentStore.setScopes(workflowData.scopes ?? []);
tagsStore.upsertTags(tags);
return { workflowDocumentStore };
@@ -265,7 +265,7 @@ export function useWorkflowInitialization(workflowState: WorkflowState) {
const parentFolderId = route.query.parentFolderId as string | undefined;
await workflowState.getNewWorkflowDataAndMakeShareable(
await workflowState.getNewWorkflowData(
undefined,
projectsStore.currentProjectId,
parentFolderId,
@@ -280,6 +280,12 @@ export function useWorkflowInitialization(workflowState: WorkflowState) {
currentWorkflowDocumentStore.value.setHomeProject(homeProject);
await projectsStore.refreshCurrentProject();
const { currentProject, personalProject } = projectsStore;
currentWorkflowDocumentStore.value.setScopes(
currentProject?.scopes ?? personalProject?.scopes ?? [],
);
await fetchAndSetParentFolder(parentFolderId);
uiStore.nodeViewInitialized = true;
@@ -79,10 +79,13 @@ export function useWorkflowSaving({
cancel?: () => Promise<void>;
} = {},
) {
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(workflowsStore.workflowId),
);
if (
!uiStore.stateIsDirty ||
workflowsStore.workflow.isArchived ||
!getResourcePermissions(workflowsStore.workflow.scopes).workflow.update
!getResourcePermissions(workflowDocumentStore.scopes).workflow.update
) {
next();
return;
@@ -27,7 +27,6 @@ import { inject } from 'vue';
import * as workflowsApi from '@/app/api/workflows';
import { useRootStore } from '@n8n/stores/useRootStore';
import { isEmpty } from '@/app/utils/typesUtils';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { clearPopupWindowState } from '@/features/execution/executions/executions.utils';
import { DEFAULT_SETTINGS } from '@/app/stores/workflowDocument/useWorkflowDocumentSettings';
import { useDocumentTitle } from './useDocumentTitle';
@@ -164,27 +163,6 @@ export function useWorkflowState() {
return workflowData;
}
function makeNewWorkflowShareable() {
const { currentProject, personalProject } = useProjectsStore();
const scopes = currentProject?.scopes ?? personalProject?.scopes ?? [];
ws.workflow.scopes = scopes;
}
async function getNewWorkflowDataAndMakeShareable(
name?: string,
projectId?: string,
parentFolderId?: string,
): Promise<INewWorkflowData> {
const workflowData = await getNewWorkflowData(name, projectId, parentFolderId);
makeNewWorkflowShareable();
return workflowData;
}
function setWorkflowScopes(scopes: IWorkflowDb['scopes']): void {
ws.workflow.scopes = scopes;
}
////
// Execution
////
@@ -437,8 +415,7 @@ export function useWorkflowState() {
setWorkflowName,
setWorkflowProperty,
setActiveExecutionId,
getNewWorkflowDataAndMakeShareable,
setWorkflowScopes,
getNewWorkflowData,
// Execution
markExecutionAsStopped,
@@ -35,7 +35,7 @@ vi.mock('@/features/ai/assistant/assistant.store', () => ({
vi.mock('@/app/composables/useWorkflowState', () => ({
useWorkflowState: vi.fn(() => ({
getNewWorkflowDataAndMakeShareable: vi.fn(),
getNewWorkflowData: vi.fn(),
setWorkflowId: vi.fn(),
resetState: vi.fn(),
})),
@@ -7,6 +7,7 @@ import { useWorkflowDocumentHomeProject } from './workflowDocument/useWorkflowDo
import { useWorkflowDocumentChecksum } from './workflowDocument/useWorkflowDocumentChecksum';
import { useWorkflowDocumentMeta } from './workflowDocument/useWorkflowDocumentMeta';
import { useWorkflowDocumentPinData } from './workflowDocument/useWorkflowDocumentPinData';
import { useWorkflowDocumentScopes } from './workflowDocument/useWorkflowDocumentScopes';
import { useWorkflowDocumentSettings } from './workflowDocument/useWorkflowDocumentSettings';
import { useWorkflowDocumentTags } from './workflowDocument/useWorkflowDocumentTags';
import { useWorkflowDocumentTimestamps } from './workflowDocument/useWorkflowDocumentTimestamps';
@@ -57,6 +58,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
const workflowDocumentMeta = useWorkflowDocumentMeta();
const workflowDocumentTags = useWorkflowDocumentTags();
const workflowDocumentPinData = useWorkflowDocumentPinData();
const workflowDocumentScopes = useWorkflowDocumentScopes();
const workflowDocumentTimestamps = useWorkflowDocumentTimestamps();
const workflowDocumentSettings = useWorkflowDocumentSettings();
@@ -70,6 +72,7 @@ export function useWorkflowDocumentStore(id: WorkflowDocumentId) {
...workflowDocumentSettings,
...workflowDocumentTags,
...workflowDocumentPinData,
...workflowDocumentScopes,
...workflowDocumentTimestamps,
};
})();
@@ -0,0 +1,60 @@
import { describe, it, expect, vi } from 'vitest';
import { useWorkflowDocumentScopes } from './useWorkflowDocumentScopes';
function createScopes() {
return useWorkflowDocumentScopes();
}
describe('useWorkflowDocumentScopes', () => {
describe('initial state', () => {
it('should start with empty scopes', () => {
const { scopes } = createScopes();
expect(scopes.value).toEqual([]);
});
});
describe('setScopes', () => {
it('should set scopes and fire event hook', () => {
const { scopes, setScopes, onScopesChange } = createScopes();
const hookSpy = vi.fn();
onScopesChange(hookSpy);
setScopes(['workflow:read', 'workflow:update']);
expect(scopes.value).toEqual(['workflow:read', 'workflow:update']);
expect(hookSpy).toHaveBeenCalledWith({
action: 'update',
payload: { scopes: ['workflow:read', 'workflow:update'] },
});
});
it('should clear scopes with empty array', () => {
const { scopes, setScopes } = createScopes();
setScopes(['workflow:read']);
setScopes([]);
expect(scopes.value).toEqual([]);
});
it('should replace existing scopes', () => {
const { scopes, setScopes } = createScopes();
setScopes(['workflow:read']);
setScopes(['workflow:update', 'workflow:delete']);
expect(scopes.value).toEqual(['workflow:update', 'workflow:delete']);
});
it('should fire event hook on every call', () => {
const { setScopes, onScopesChange } = createScopes();
const hookSpy = vi.fn();
onScopesChange(hookSpy);
setScopes(['workflow:read']);
setScopes([]);
expect(hookSpy).toHaveBeenCalledTimes(2);
});
});
});
@@ -0,0 +1,32 @@
import { readonly, ref } from 'vue';
import { createEventHook } from '@vueuse/core';
import type { Scope } from '@n8n/permissions';
import { CHANGE_ACTION } from './types';
import type { ChangeAction, ChangeEvent } from './types';
export type ScopesPayload = {
scopes: Scope[];
};
export type ScopesChangeEvent = ChangeEvent<ScopesPayload>;
export function useWorkflowDocumentScopes() {
const scopes = ref<Scope[]>([]);
const onScopesChange = createEventHook<ScopesChangeEvent>();
function applyScopes(newScopes: Scope[], action: ChangeAction = CHANGE_ACTION.UPDATE) {
scopes.value = newScopes;
void onScopesChange.trigger({ action, payload: { scopes: newScopes } });
}
function setScopes(newScopes: Scope[]) {
applyScopes(newScopes);
}
return {
scopes: readonly(scopes),
setScopes,
onScopesChange: onScopesChange.on,
};
}
@@ -2219,8 +2219,12 @@ describe('useWorkflowsStore', () => {
// Add workflow to workflowsById to simulate it being loaded from backend
workflowsListStore.addWorkflow(testWorkflow);
// Verify the mock is set up correctly
expect(workflowsStore.workflow.scopes).toContain('workflow:update');
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(testWorkflow.id),
);
workflowDocumentStore.setScopes(testWorkflow.scopes ?? []);
expect(workflowDocumentStore.scopes).toContain('workflow:update');
expect(workflowsStore.workflow.id).toBe('workflow-123');
expect(workflowsStore.workflow.isArchived).toBe(false);
@@ -2248,6 +2252,11 @@ describe('useWorkflowsStore', () => {
// Add workflow to workflowsById to simulate it being loaded from backend
workflowsListStore.addWorkflow(testWorkflow);
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(testWorkflow.id),
);
workflowDocumentStore.setScopes(testWorkflow.scopes ?? []);
vi.mocked(workflowsApi).getLastSuccessfulExecution.mockResolvedValue(null);
await workflowsStore.fetchLastSuccessfulExecution();
@@ -2272,6 +2281,11 @@ describe('useWorkflowsStore', () => {
// Add workflow to workflowsById to simulate it being loaded from backend
workflowsListStore.addWorkflow(testWorkflow);
const workflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(testWorkflow.id),
);
workflowDocumentStore.setScopes(testWorkflow.scopes ?? []);
const error = new Error('API Error');
vi.mocked(workflowsApi).getLastSuccessfulExecution.mockRejectedValue(error);
@@ -599,7 +599,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
}
async function fetchLastSuccessfulExecution() {
const workflowPermissions = getResourcePermissions(workflow.value.scopes).workflow;
const workflowDocumentStore = workflowId.value
? useWorkflowDocumentStore(createWorkflowDocumentId(workflowId.value))
: undefined;
const workflowPermissions = getResourcePermissions(workflowDocumentStore?.scopes).workflow;
try {
if (
@@ -1640,7 +1640,7 @@ onBeforeRouteLeave(async (to, from, next) => {
return;
}
await useWorkflowSaving({ router }).promptSaveUnsavedWorkflowChanges(next, {
await workflowSaving.promptSaveUnsavedWorkflowChanges(next, {
async confirm() {
if (from.name === VIEWS.NEW_WORKFLOW) {
const savedWorkflowId = workflowsStore.workflowId;
@@ -36,6 +36,11 @@ vi.mock('@/features/workflows/canvas/canvas.eventBus', () => ({
},
}));
vi.mock('@/app/stores/workflowDocument.store', async (importOriginal) => ({
...(await importOriginal()),
injectWorkflowDocumentStore: vi.fn().mockReturnValue(null),
}));
const mockGenerateMergedNodesAndActionsFn = vi.fn().mockReturnValue({ mergedNodes: [] });
vi.mock('@/features/shared/nodeCreator/composables/useActionsGeneration', () => ({
@@ -10,6 +10,10 @@ import { type CommandBarItem } from '@n8n/design-system/components/N8nCommandBar
import type { CommandGroup } from '../types';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import { useCollaborationStore } from '@/features/collaboration/collaboration/collaboration.store';
import { getResourcePermissions } from '@n8n/permissions';
import NodeIcon from '@/app/components/NodeIcon.vue';
@@ -37,13 +41,17 @@ export function useNodeCommands(options: {
const collaborationStore = useCollaborationStore();
const { generateMergedNodesAndActions } = useActionsGenerator();
const workflowDocumentStore = computed(() =>
useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId)),
);
const isReadOnly = computed(
() => sourceControlStore.preferences.branchReadOnly || collaborationStore.shouldBeReadOnly,
);
const isArchived = computed(() => workflowsStore.workflow.isArchived);
const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.workflow.scopes).workflow,
() => getResourcePermissions(workflowDocumentStore.value.scopes).workflow,
);
const hasPermission = (permission: keyof typeof workflowPermissions.value) =>
@@ -7,16 +7,25 @@ import { useUIStore } from '@/app/stores/ui.store';
import { useSourceControlStore } from '@/features/integrations/sourceControl.ee/sourceControl.store';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
injectWorkflowDocumentStore,
} from '@/app/stores/workflowDocument.store';
import { canvasEventBus } from '@/features/workflows/canvas/canvas.eventBus';
import { nodeViewEventBus } from '@/app/event-bus';
import { createTestWorkflow } from '@/__tests__/mocks';
import type { IWorkflowDb, INodeUi } from '@/Interface';
import type { Ref } from 'vue';
import { shallowRef, type Ref } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
vi.mock('@/app/composables/useCanvasOperations');
vi.mock('@/app/composables/useWorkflowHelpers');
vi.mock('@/app/stores/workflowDocument.store', async (importOriginal) => ({
...(await importOriginal()),
injectWorkflowDocumentStore: vi.fn(),
}));
vi.mock('@/app/composables/useTelemetry');
vi.mock('@/app/composables/useWorkflowSaving');
vi.mock('@/app/composables/useRunWorkflow');
@@ -61,6 +70,7 @@ vi.mock('@/app/composables/useWorkflowSaving', () => ({
describe('useWorkflowCommands', () => {
let mockWorkflow: Ref<IWorkflowDb>;
let mockWorkflowDocumentStore: ReturnType<typeof useWorkflowDocumentStore>;
let mockUIStore: ReturnType<typeof useUIStore>;
let mockTagsStore: ReturnType<typeof useTagsStore>;
let mockWorkflowsStore: ReturnType<typeof useWorkflowsStore>;
@@ -68,7 +78,7 @@ describe('useWorkflowCommands', () => {
let mockSourceControlStore: ReturnType<typeof useSourceControlStore>;
beforeEach(() => {
setActivePinia(createTestingPinia());
setActivePinia(createTestingPinia({ stubActions: false }));
mockWorkflow = ref(
createTestWorkflow({
@@ -95,6 +105,12 @@ describe('useWorkflowCommands', () => {
// Mark workflow as existing by adding it to workflowsById
mockWorkflowsListStore.workflowsById = { [mockWorkflow.value.id]: mockWorkflow.value };
mockWorkflowDocumentStore = useWorkflowDocumentStore(
createWorkflowDocumentId(mockWorkflow.value.id),
);
mockWorkflowDocumentStore.setScopes(mockWorkflow.value.scopes ?? []);
vi.mocked(injectWorkflowDocumentStore).mockReturnValue(shallowRef(mockWorkflowDocumentStore));
Object.defineProperty(mockUIStore, 'isActionActive', {
value: { workflowSaving: false } as unknown as typeof mockUIStore.isActionActive,
});
@@ -230,7 +246,7 @@ describe('useWorkflowCommands', () => {
describe('duplicate workflow', () => {
it('should not include duplicate command when user lacks create permission', () => {
mockWorkflowsStore.workflow.scopes = ['workflow:read', 'workflow:update'];
mockWorkflowDocumentStore.setScopes(['workflow:read', 'workflow:update']);
const { commands } = useWorkflowCommands();
const duplicateCommand = commands.value.find((cmd) => cmd.id === 'duplicate-workflow');
@@ -402,7 +418,7 @@ describe('useWorkflowCommands', () => {
});
it('should not show lifecycle commands without delete permission', () => {
mockWorkflowsStore.workflow.scopes = ['workflow:read', 'workflow:update'];
mockWorkflowDocumentStore.setScopes(['workflow:read', 'workflow:update']);
const { commands } = useWorkflowCommands();
const archiveCommand = commands.value.find((cmd) => cmd.id === 'archive-workflow');
@@ -457,7 +473,7 @@ describe('useWorkflowCommands', () => {
it('should allow actions for new workflows regardless of permissions', () => {
// For new workflows, remove from workflowsById so isNewWorkflow returns true
mockWorkflowsListStore.workflowsById = {};
mockWorkflowsStore.workflow.scopes = ['workflow:read'];
mockWorkflowDocumentStore.setScopes(['workflow:read']);
const { commands } = useWorkflowCommands();
const saveCommand = commands.value.find((cmd) => cmd.id === 'rename-workflow');
@@ -24,6 +24,10 @@ import { canvasEventBus } from '@/features/workflows/canvas/canvas.eventBus';
import type { IWorkflowToShare } from '@/Interface';
import { saveAs } from 'file-saver';
import { useWorkflowsStore } from '@/app/stores/workflows.store';
import {
useWorkflowDocumentStore,
createWorkflowDocumentId,
} from '@/app/stores/workflowDocument.store';
import type { CommandGroup, CommandBarItem } from '../types';
import uniqBy from 'lodash/uniqBy';
import { nodeViewEventBus } from '@/app/event-bus';
@@ -59,6 +63,10 @@ export function useWorkflowCommands(): CommandGroup {
const sourceControlStore = useSourceControlStore();
const collaborationStore = useCollaborationStore();
const workflowDocumentStore = computed(() =>
useWorkflowDocumentStore(createWorkflowDocumentId(workflowsStore.workflowId)),
);
const router = useRouter();
const runWorkflow = useRunWorkflow({ router });
@@ -72,7 +80,7 @@ export function useWorkflowCommands(): CommandGroup {
const isArchived = computed(() => workflowsStore.workflow.isArchived);
const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.workflow.scopes).workflow,
() => getResourcePermissions(workflowDocumentStore.value.scopes).workflow,
);
const hasPermission = (permission: keyof typeof workflowPermissions.value) =>
@@ -44,7 +44,7 @@ describe('useContextMenu', () => {
let sourceControlStore: ReturnType<typeof useSourceControlStore>;
let uiStore: ReturnType<typeof useUIStore>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let documentStore: ReturnType<typeof useWorkflowDocumentStore>;
let workflowDocumentStore: ReturnType<typeof useWorkflowDocumentStore>;
const nodes = [nodeFactory(), nodeFactory(), nodeFactory()];
const selectedNodes = nodes.slice(0, 2);
const testWorkflowId = 'test-workflow-id';
@@ -62,9 +62,9 @@ describe('useContextMenu', () => {
workflowsStore = useWorkflowsStore();
workflowsStore.workflow.id = testWorkflowId;
workflowsStore.workflow.nodes = nodes;
workflowsStore.workflow.scopes = ['workflow:update'];
documentStore = useWorkflowDocumentStore(createWorkflowDocumentId(testWorkflowId));
vi.mocked(injectWorkflowDocumentStore).mockReturnValue(shallowRef(documentStore));
workflowDocumentStore = useWorkflowDocumentStore(createWorkflowDocumentId(testWorkflowId));
workflowDocumentStore.setScopes(['workflow:update']);
vi.mocked(injectWorkflowDocumentStore).mockReturnValue(shallowRef(workflowDocumentStore));
workflowsStore.workflowObject = {
nodes,
getNode: (_: string) => {
@@ -235,7 +235,7 @@ describe('useContextMenu', () => {
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
const webhookNode = nodeFactory({ type: WEBHOOK_NODE_TYPE, webhookId: 'test-webhook' });
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(webhookNode);
documentStore.setActiveState({ activeVersionId: null, activeVersion: null });
workflowDocumentStore.setActiveState({ activeVersionId: null, activeVersion: null });
open(mockEvent, { source: 'node-right-click', nodeId: webhookNode.id });
@@ -255,7 +255,7 @@ describe('useContextMenu', () => {
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
const webhookNode = nodeFactory({ type: WEBHOOK_NODE_TYPE, webhookId: 'test-webhook' });
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(webhookNode);
documentStore.setActiveState({ activeVersionId: 'v1', activeVersion: null });
workflowDocumentStore.setActiveState({ activeVersionId: 'v1', activeVersion: null });
open(mockEvent, { source: 'node-right-click', nodeId: webhookNode.id });
@@ -280,7 +280,7 @@ describe('useContextMenu', () => {
webhookId: 'chat-webhook',
});
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(chatTriggerNode);
documentStore.setActiveState({ activeVersionId: null, activeVersion: null });
workflowDocumentStore.setActiveState({ activeVersionId: null, activeVersion: null });
open(mockEvent, { source: 'node-right-click', nodeId: chatTriggerNode.id });
@@ -301,7 +301,7 @@ describe('useContextMenu', () => {
webhookId: 'chat-webhook',
});
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(chatTriggerNode);
documentStore.setActiveState({ activeVersionId: 'v1', activeVersion: null });
workflowDocumentStore.setActiveState({ activeVersionId: 'v1', activeVersion: null });
open(mockEvent, { source: 'node-right-click', nodeId: chatTriggerNode.id });
@@ -321,7 +321,7 @@ describe('useContextMenu', () => {
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
const regularNode = nodeFactory({ type: NO_OP_NODE_TYPE });
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(regularNode);
documentStore.setActiveState({ activeVersionId: 'v1', activeVersion: null });
workflowDocumentStore.setActiveState({ activeVersionId: 'v1', activeVersion: null });
open(mockEvent, { source: 'node-right-click', nodeId: regularNode.id });
@@ -350,7 +350,7 @@ describe('useContextMenu', () => {
describe('Read-only mode', () => {
it('should return the correct actions when right clicking a sticky', () => {
vi.spyOn(uiStore, 'isReadOnlyView', 'get').mockReturnValue(true);
workflowsStore.workflow.scopes = ['workflow:read'];
workflowDocumentStore.setScopes(['workflow:read']);
const { open, isOpen, actions, targetNodeIds } = useContextMenu();
const sticky = nodeFactory({ type: STICKY_NODE_TYPE });
vi.spyOn(workflowsStore, 'getNodeById').mockReturnValue(sticky);
@@ -51,7 +51,7 @@ export function useContextMenuItems(targetNodeIds: ComputedRef<string[]>): Compu
const uiStore = useUIStore();
const nodeTypesStore = useNodeTypesStore();
const workflowsStore = useWorkflowsStore();
const documentStore = injectWorkflowDocumentStore();
const workflowDocumentStore = injectWorkflowDocumentStore();
const sourceControlStore = useSourceControlStore();
const collaborationStore = useCollaborationStore();
const focusedNodesStore = useFocusedNodesStore();
@@ -60,7 +60,7 @@ export function useContextMenuItems(targetNodeIds: ComputedRef<string[]>): Compu
const workflowObject = computed(() => workflowsStore.workflowObject as Workflow);
const workflowPermissions = computed(
() => getResourcePermissions(workflowsStore.workflow.scopes).workflow,
() => getResourcePermissions(workflowDocumentStore?.value?.scopes).workflow,
);
const isReadOnly = computed(
@@ -252,7 +252,7 @@ export function useContextMenuItems(targetNodeIds: ComputedRef<string[]>): Compu
if (isWebhookNode(nodes[0])) {
const isProductionOnly = PRODUCTION_ONLY_TRIGGER_NODE_TYPES.includes(nodes[0].type);
const isWorkflowActive = documentStore?.value?.active ?? false;
const isWorkflowActive = workflowDocumentStore?.value?.active ?? false;
if (!isProductionOnly) {
copyWebhookActions.push({
divided: true,