Skip to content

Commit 5bc4e5d

Browse files
authored
feat(editor): Add "Change owner" option to editor (#15792)
1 parent e76c45d commit 5bc4e5d

File tree

7 files changed

+113
-50
lines changed

7 files changed

+113
-50
lines changed

cypress/pages/credentials.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export class CredentialsPage extends BasePage {
3131
credentialDeleteButton: () =>
3232
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'),
3333
credentialMoveButton: () =>
34-
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Move'),
34+
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Change owner'),
3535
sort: () => cy.getByTestId('resources-list-sort').first(),
3636
sortOption: (label: string) =>
3737
cy.getByTestId('resources-list-sort-item').contains(label).first(),

packages/frontend/@n8n/i18n/src/locales/en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -644,7 +644,7 @@
644644
"credentials.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create credentials",
645645
"credentials.item.open": "Open",
646646
"credentials.item.delete": "Delete",
647-
"credentials.item.move": "Move",
647+
"credentials.item.move": "Change owner",
648648
"credentials.item.updated": "Last updated",
649649
"credentials.item.created": "Created",
650650
"credentials.item.owner": "Owner",

packages/frontend/editor-ui/src/components/CredentialCard.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ describe('CredentialCard', () => {
6363
expect(badge).toHaveTextContent('John Doe');
6464
});
6565

66-
it('should show Move action only if there is resource permission and not on community plan', async () => {
66+
it('should show Change owner action only if there is resource permission and not on community plan', async () => {
6767
vi.spyOn(projectsStore, 'isTeamProjectFeatureEnabled', 'get').mockReturnValue(true);
6868

6969
const data = createCredential({
@@ -84,7 +84,7 @@ describe('CredentialCard', () => {
8484
if (!actions) {
8585
throw new Error('Actions menu not found');
8686
}
87-
expect(actions).toHaveTextContent('Move');
87+
expect(actions).toHaveTextContent('Change owner');
8888
});
8989

9090
it('should set readOnly variant based on prop', () => {

packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import {
66
MODAL_CONFIRM,
77
VIEWS,
88
WORKFLOW_SHARE_MODAL_KEY,
9+
PROJECT_MOVE_RESOURCE_MODAL,
910
} from '@/constants';
11+
import type { IWorkflowDb } from '@/Interface';
1012
import { STORES } from '@n8n/stores';
1113
import { createTestingPinia } from '@pinia/testing';
1214
import userEvent from '@testing-library/user-event';
@@ -59,6 +61,11 @@ const initialState = {
5961
enterprise: {
6062
[EnterpriseEditionFeature.Sharing]: true,
6163
[EnterpriseEditionFeature.WorkflowHistory]: true,
64+
projects: {
65+
team: {
66+
limit: -1,
67+
},
68+
},
6269
},
6370
},
6471
areTagsEnabled: true,
@@ -394,6 +401,27 @@ describe('WorkflowDetails', () => {
394401
name: VIEWS.WORKFLOWS,
395402
});
396403
});
404+
405+
it("should call onWorkflowMenuSelect on 'Change owner' option click", async () => {
406+
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
407+
408+
workflowsStore.workflowsById = { [workflow.id]: workflow as IWorkflowDb };
409+
410+
const { getByTestId } = renderComponent({
411+
props: {
412+
...workflow,
413+
scopes: ['workflow:move'],
414+
},
415+
});
416+
417+
await userEvent.click(getByTestId('workflow-menu'));
418+
await userEvent.click(getByTestId('workflow-menu-item-change-owner'));
419+
420+
expect(openModalSpy).toHaveBeenCalledWith({
421+
name: PROJECT_MOVE_RESOURCE_MODAL,
422+
data: expect.objectContaining({ resource: expect.objectContaining({ id: workflow.id }) }),
423+
});
424+
});
397425
});
398426

399427
describe('Archived badge', () => {

packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.vue

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,66 @@
11
<script lang="ts" setup>
2+
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
3+
import InlineTextEdit from '@/components/InlineTextEdit.vue';
4+
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
5+
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
6+
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
7+
import SaveButton from '@/components/SaveButton.vue';
8+
import ShortenName from '@/components/ShortenName.vue';
9+
import WorkflowActivator from '@/components/WorkflowActivator.vue';
10+
import WorkflowTagsContainer from '@/components/WorkflowTagsContainer.vue';
11+
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
212
import {
313
DUPLICATE_MODAL_KEY,
414
EnterpriseEditionFeature,
15+
IMPORT_WORKFLOW_URL_MODAL_KEY,
516
MAX_WORKFLOW_NAME_LENGTH,
617
MODAL_CONFIRM,
718
PLACEHOLDER_EMPTY_WORKFLOW_ID,
19+
PROJECT_MOVE_RESOURCE_MODAL,
820
SOURCE_CONTROL_PUSH_MODAL_KEY,
921
VIEWS,
1022
WORKFLOW_MENU_ACTIONS,
1123
WORKFLOW_SETTINGS_MODAL_KEY,
1224
WORKFLOW_SHARE_MODAL_KEY,
13-
IMPORT_WORKFLOW_URL_MODAL_KEY,
1425
} from '@/constants';
15-
import ShortenName from '@/components/ShortenName.vue';
16-
import WorkflowTagsContainer from '@/components/WorkflowTagsContainer.vue';
17-
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
18-
import WorkflowActivator from '@/components/WorkflowActivator.vue';
19-
import SaveButton from '@/components/SaveButton.vue';
20-
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
21-
import InlineTextEdit from '@/components/InlineTextEdit.vue';
22-
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
23-
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
24-
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
26+
import { ResourceType } from '@/utils/projects.utils';
2527
26-
import { useRootStore } from '@n8n/stores/useRootStore';
28+
import { useProjectsStore } from '@/stores/projects.store';
2729
import { useSettingsStore } from '@/stores/settings.store';
2830
import { useSourceControlStore } from '@/stores/sourceControl.store';
2931
import { useTagsStore } from '@/stores/tags.store';
3032
import { useUIStore } from '@/stores/ui.store';
3133
import { useUsersStore } from '@/stores/users.store';
3234
import { useWorkflowsStore } from '@/stores/workflows.store';
33-
import { useProjectsStore } from '@/stores/projects.store';
35+
import { useRootStore } from '@n8n/stores/useRootStore';
3436
35-
import { saveAs } from 'file-saver';
36-
import { useDocumentTitle } from '@/composables/useDocumentTitle';
37-
import { useMessage } from '@/composables/useMessage';
38-
import { useToast } from '@/composables/useToast';
39-
import { getResourcePermissions } from '@/permissions';
40-
import { createEventBus } from '@n8n/utils/event-bus';
41-
import { nodeViewEventBus } from '@/event-bus';
42-
import { hasPermission } from '@/utils/rbac/permissions';
43-
import { useCanvasStore } from '@/stores/canvas.store';
44-
import { useRoute, useRouter } from 'vue-router';
45-
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
46-
import { computed, ref, useCssModule, watch } from 'vue';
4737
import type {
4838
ActionDropdownItem,
4939
FolderShortInfo,
5040
IWorkflowDataUpdate,
5141
IWorkflowDb,
5242
IWorkflowToShare,
5343
} from '@/Interface';
54-
import { useI18n } from '@n8n/i18n';
44+
import { useDocumentTitle } from '@/composables/useDocumentTitle';
45+
import { useMessage } from '@/composables/useMessage';
46+
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
5547
import { useTelemetry } from '@/composables/useTelemetry';
56-
import type { BaseTextKey } from '@n8n/i18n';
48+
import { useToast } from '@/composables/useToast';
49+
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
50+
import { nodeViewEventBus } from '@/event-bus';
51+
import { getResourcePermissions } from '@/permissions';
52+
import { useCanvasStore } from '@/stores/canvas.store';
53+
import { useFoldersStore } from '@/stores/folders.store';
5754
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
58-
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
5955
import { ProjectTypes } from '@/types/projects.types';
60-
import { useFoldersStore } from '@/stores/folders.store';
56+
import { hasPermission } from '@/utils/rbac/permissions';
6157
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
58+
import type { BaseTextKey } from '@n8n/i18n';
59+
import { useI18n } from '@n8n/i18n';
60+
import { createEventBus } from '@n8n/utils/event-bus';
61+
import { saveAs } from 'file-saver';
62+
import { computed, ref, useCssModule, watch } from 'vue';
63+
import { useRoute, useRouter } from 'vue-router';
6264
6365
const props = defineProps<{
6466
readOnly?: boolean;
@@ -106,6 +108,7 @@ const importFileRef = ref<HTMLInputElement | undefined>();
106108
107109
const tagsEventBus = createEventBus();
108110
const sourceControlModalEventBus = createEventBus();
111+
const changeOwnerEventBus = createEventBus();
109112
110113
const hasChanged = (prev: string[], curr: string[]) => {
111114
if (prev.length !== curr.length) {
@@ -147,6 +150,14 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
147150
},
148151
];
149152
153+
if (workflowPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) {
154+
actions.push({
155+
id: WORKFLOW_MENU_ACTIONS.CHANGE_OWNER,
156+
label: locale.baseText('workflows.item.changeOwner'),
157+
disabled: isNewWorkflow.value,
158+
});
159+
}
160+
150161
if (!props.readOnly && !props.isArchived) {
151162
actions.push({
152163
id: WORKFLOW_MENU_ACTIONS.RENAME,
@@ -609,6 +620,27 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
609620
await router.push({ name: VIEWS.WORKFLOWS });
610621
break;
611622
}
623+
case WORKFLOW_MENU_ACTIONS.CHANGE_OWNER: {
624+
const workflowId = getWorkflowId();
625+
if (!workflowId) {
626+
return;
627+
}
628+
changeOwnerEventBus.once(
629+
'resource-moved',
630+
async () => await router.push({ name: VIEWS.WORKFLOWS }),
631+
);
632+
633+
uiStore.openModalWithData({
634+
name: PROJECT_MOVE_RESOURCE_MODAL,
635+
data: {
636+
resource: workflowsStore.workflowsById[workflowId],
637+
resourceType: ResourceType.Workflow,
638+
resourceTypeLabel: locale.baseText('generic.workflow').toLowerCase(),
639+
eventBus: changeOwnerEventBus,
640+
},
641+
});
642+
break;
643+
}
612644
default:
613645
break;
614646
}

packages/frontend/editor-ui/src/components/Projects/ProjectMoveResourceModal.vue

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,28 @@
11
<script lang="ts" setup>
2-
import { ref, computed, onMounted, h } from 'vue';
3-
import { truncate } from '@n8n/utils/string/truncate';
4-
import type { ICredentialsResponse, IUsedCredential, IWorkflowDb } from '@/Interface';
5-
import { useI18n } from '@n8n/i18n';
6-
import { useUIStore } from '@/stores/ui.store';
7-
import { useProjectsStore } from '@/stores/projects.store';
82
import Modal from '@/components/Modal.vue';
3+
import ProjectMoveResourceModalCredentialsList from '@/components/Projects/ProjectMoveResourceModalCredentialsList.vue';
4+
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
5+
import { useTelemetry } from '@/composables/useTelemetry';
6+
import { useToast } from '@/composables/useToast';
97
import { VIEWS } from '@/constants';
8+
import type { ICredentialsResponse, IUsedCredential, IWorkflowDb } from '@/Interface';
9+
import { getResourcePermissions } from '@/permissions';
10+
import { useCredentialsStore } from '@/stores/credentials.store';
11+
import { useProjectsStore } from '@/stores/projects.store';
12+
import { useUIStore } from '@/stores/ui.store';
13+
import { useWorkflowsStore } from '@/stores/workflows.store';
14+
import { ProjectTypes } from '@/types/projects.types';
1015
import {
11-
splitName,
1216
getTruncatedProjectName,
13-
ResourceType,
1417
MAX_NAME_LENGTH,
18+
ResourceType,
19+
splitName,
1520
} from '@/utils/projects.utils';
16-
import { useTelemetry } from '@/composables/useTelemetry';
17-
import { ProjectTypes } from '@/types/projects.types';
18-
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
19-
import ProjectMoveResourceModalCredentialsList from '@/components/Projects/ProjectMoveResourceModalCredentialsList.vue';
20-
import { useToast } from '@/composables/useToast';
21-
import { getResourcePermissions } from '@/permissions';
22-
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
21+
import { useI18n } from '@n8n/i18n';
2322
import type { EventBus } from '@n8n/utils/event-bus';
24-
import { useWorkflowsStore } from '@/stores/workflows.store';
25-
import { useCredentialsStore } from '@/stores/credentials.store';
23+
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
24+
import { truncate } from '@n8n/utils/string/truncate';
25+
import { computed, h, onMounted, ref } from 'vue';
2626
2727
const props = defineProps<{
2828
modalName: string;
@@ -177,6 +177,8 @@ onMounted(async () => {
177177
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
178178
});
179179
180+
await projectsStore.getAvailableProjects();
181+
180182
if (isResourceWorkflow.value) {
181183
const [workflow, credentials] = await Promise.all([
182184
workflowsStore.fetchWorkflow(props.data.resource.id),

packages/frontend/editor-ui/src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,7 @@ export const enum WORKFLOW_MENU_ACTIONS {
628628
ARCHIVE = 'archive',
629629
UNARCHIVE = 'unarchive',
630630
RENAME = 'rename',
631+
CHANGE_OWNER = 'change-owner',
631632
}
632633

633634
/**

0 commit comments

Comments
 (0)