feat(editor): Add "Change owner" option to editor (#15792)

This commit is contained in:
Raúl Gómez Morales
2025-06-03 09:59:07 +02:00
committed by GitHub
parent e76c45d46d
commit 5bc4e5d846
7 changed files with 116 additions and 53 deletions

View File

@@ -31,7 +31,7 @@ export class CredentialsPage extends BasePage {
credentialDeleteButton: () => credentialDeleteButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'), cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'),
credentialMoveButton: () => credentialMoveButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Move'), cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Change owner'),
sort: () => cy.getByTestId('resources-list-sort').first(), sort: () => cy.getByTestId('resources-list-sort').first(),
sortOption: (label: string) => sortOption: (label: string) =>
cy.getByTestId('resources-list-sort-item').contains(label).first(), cy.getByTestId('resources-list-sort-item').contains(label).first(),

View File

@@ -644,7 +644,7 @@
"credentials.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create credentials", "credentials.empty.button.disabled.tooltip": "Your current role in the project does not allow you to create credentials",
"credentials.item.open": "Open", "credentials.item.open": "Open",
"credentials.item.delete": "Delete", "credentials.item.delete": "Delete",
"credentials.item.move": "Move", "credentials.item.move": "Change owner",
"credentials.item.updated": "Last updated", "credentials.item.updated": "Last updated",
"credentials.item.created": "Created", "credentials.item.created": "Created",
"credentials.item.owner": "Owner", "credentials.item.owner": "Owner",

View File

@@ -63,7 +63,7 @@ describe('CredentialCard', () => {
expect(badge).toHaveTextContent('John Doe'); expect(badge).toHaveTextContent('John Doe');
}); });
it('should show Move action only if there is resource permission and not on community plan', async () => { it('should show Change owner action only if there is resource permission and not on community plan', async () => {
vi.spyOn(projectsStore, 'isTeamProjectFeatureEnabled', 'get').mockReturnValue(true); vi.spyOn(projectsStore, 'isTeamProjectFeatureEnabled', 'get').mockReturnValue(true);
const data = createCredential({ const data = createCredential({
@@ -84,7 +84,7 @@ describe('CredentialCard', () => {
if (!actions) { if (!actions) {
throw new Error('Actions menu not found'); throw new Error('Actions menu not found');
} }
expect(actions).toHaveTextContent('Move'); expect(actions).toHaveTextContent('Change owner');
}); });
it('should set readOnly variant based on prop', () => { it('should set readOnly variant based on prop', () => {

View File

@@ -6,7 +6,9 @@ import {
MODAL_CONFIRM, MODAL_CONFIRM,
VIEWS, VIEWS,
WORKFLOW_SHARE_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
} from '@/constants'; } from '@/constants';
import type { IWorkflowDb } from '@/Interface';
import { STORES } from '@n8n/stores'; import { STORES } from '@n8n/stores';
import { createTestingPinia } from '@pinia/testing'; import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
@@ -59,6 +61,11 @@ const initialState = {
enterprise: { enterprise: {
[EnterpriseEditionFeature.Sharing]: true, [EnterpriseEditionFeature.Sharing]: true,
[EnterpriseEditionFeature.WorkflowHistory]: true, [EnterpriseEditionFeature.WorkflowHistory]: true,
projects: {
team: {
limit: -1,
},
},
}, },
}, },
areTagsEnabled: true, areTagsEnabled: true,
@@ -394,6 +401,27 @@ describe('WorkflowDetails', () => {
name: VIEWS.WORKFLOWS, name: VIEWS.WORKFLOWS,
}); });
}); });
it("should call onWorkflowMenuSelect on 'Change owner' option click", async () => {
const openModalSpy = vi.spyOn(uiStore, 'openModalWithData');
workflowsStore.workflowsById = { [workflow.id]: workflow as IWorkflowDb };
const { getByTestId } = renderComponent({
props: {
...workflow,
scopes: ['workflow:move'],
},
});
await userEvent.click(getByTestId('workflow-menu'));
await userEvent.click(getByTestId('workflow-menu-item-change-owner'));
expect(openModalSpy).toHaveBeenCalledWith({
name: PROJECT_MOVE_RESOURCE_MODAL,
data: expect.objectContaining({ resource: expect.objectContaining({ id: workflow.id }) }),
});
});
}); });
describe('Archived badge', () => { describe('Archived badge', () => {

View File

@@ -1,49 +1,39 @@
<script lang="ts" setup> <script lang="ts" setup>
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
import InlineTextEdit from '@/components/InlineTextEdit.vue';
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
import SaveButton from '@/components/SaveButton.vue';
import ShortenName from '@/components/ShortenName.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import WorkflowTagsContainer from '@/components/WorkflowTagsContainer.vue';
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import { import {
DUPLICATE_MODAL_KEY, DUPLICATE_MODAL_KEY,
EnterpriseEditionFeature, EnterpriseEditionFeature,
IMPORT_WORKFLOW_URL_MODAL_KEY,
MAX_WORKFLOW_NAME_LENGTH, MAX_WORKFLOW_NAME_LENGTH,
MODAL_CONFIRM, MODAL_CONFIRM,
PLACEHOLDER_EMPTY_WORKFLOW_ID, PLACEHOLDER_EMPTY_WORKFLOW_ID,
PROJECT_MOVE_RESOURCE_MODAL,
SOURCE_CONTROL_PUSH_MODAL_KEY, SOURCE_CONTROL_PUSH_MODAL_KEY,
VIEWS, VIEWS,
WORKFLOW_MENU_ACTIONS, WORKFLOW_MENU_ACTIONS,
WORKFLOW_SETTINGS_MODAL_KEY, WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY, WORKFLOW_SHARE_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY,
} from '@/constants'; } from '@/constants';
import ShortenName from '@/components/ShortenName.vue'; import { ResourceType } from '@/utils/projects.utils';
import WorkflowTagsContainer from '@/components/WorkflowTagsContainer.vue';
import PushConnectionTracker from '@/components/PushConnectionTracker.vue';
import WorkflowActivator from '@/components/WorkflowActivator.vue';
import SaveButton from '@/components/SaveButton.vue';
import WorkflowTagsDropdown from '@/components/WorkflowTagsDropdown.vue';
import InlineTextEdit from '@/components/InlineTextEdit.vue';
import BreakpointsObserver from '@/components/BreakpointsObserver.vue';
import WorkflowHistoryButton from '@/components/MainHeader/WorkflowHistoryButton.vue';
import CollaborationPane from '@/components/MainHeader/CollaborationPane.vue';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useProjectsStore } from '@/stores/projects.store';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useSourceControlStore } from '@/stores/sourceControl.store'; import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useTagsStore } from '@/stores/tags.store'; import { useTagsStore } from '@/stores/tags.store';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import { useWorkflowsStore } from '@/stores/workflows.store'; import { useWorkflowsStore } from '@/stores/workflows.store';
import { useProjectsStore } from '@/stores/projects.store'; import { useRootStore } from '@n8n/stores/useRootStore';
import { saveAs } from 'file-saver';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { getResourcePermissions } from '@/permissions';
import { createEventBus } from '@n8n/utils/event-bus';
import { nodeViewEventBus } from '@/event-bus';
import { hasPermission } from '@/utils/rbac/permissions';
import { useCanvasStore } from '@/stores/canvas.store';
import { useRoute, useRouter } from 'vue-router';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { computed, ref, useCssModule, watch } from 'vue';
import type { import type {
ActionDropdownItem, ActionDropdownItem,
FolderShortInfo, FolderShortInfo,
@@ -51,14 +41,26 @@ import type {
IWorkflowDb, IWorkflowDb,
IWorkflowToShare, IWorkflowToShare,
} from '@/Interface'; } from '@/Interface';
import { useI18n } from '@n8n/i18n'; import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useTelemetry } from '@/composables/useTelemetry'; import { useMessage } from '@/composables/useMessage';
import type { BaseTextKey } from '@n8n/i18n';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper'; import { usePageRedirectionHelper } from '@/composables/usePageRedirectionHelper';
import { ProjectTypes } from '@/types/projects.types'; import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
import { nodeViewEventBus } from '@/event-bus';
import { getResourcePermissions } from '@/permissions';
import { useCanvasStore } from '@/stores/canvas.store';
import { useFoldersStore } from '@/stores/folders.store'; import { useFoldersStore } from '@/stores/folders.store';
import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { ProjectTypes } from '@/types/projects.types';
import { hasPermission } from '@/utils/rbac/permissions';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue'; import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import type { BaseTextKey } from '@n8n/i18n';
import { useI18n } from '@n8n/i18n';
import { createEventBus } from '@n8n/utils/event-bus';
import { saveAs } from 'file-saver';
import { computed, ref, useCssModule, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const props = defineProps<{ const props = defineProps<{
readOnly?: boolean; readOnly?: boolean;
@@ -106,6 +108,7 @@ const importFileRef = ref<HTMLInputElement | undefined>();
const tagsEventBus = createEventBus(); const tagsEventBus = createEventBus();
const sourceControlModalEventBus = createEventBus(); const sourceControlModalEventBus = createEventBus();
const changeOwnerEventBus = createEventBus();
const hasChanged = (prev: string[], curr: string[]) => { const hasChanged = (prev: string[], curr: string[]) => {
if (prev.length !== curr.length) { if (prev.length !== curr.length) {
@@ -147,6 +150,14 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
}, },
]; ];
if (workflowPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.CHANGE_OWNER,
label: locale.baseText('workflows.item.changeOwner'),
disabled: isNewWorkflow.value,
});
}
if (!props.readOnly && !props.isArchived) { if (!props.readOnly && !props.isArchived) {
actions.push({ actions.push({
id: WORKFLOW_MENU_ACTIONS.RENAME, id: WORKFLOW_MENU_ACTIONS.RENAME,
@@ -609,6 +620,27 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
await router.push({ name: VIEWS.WORKFLOWS }); await router.push({ name: VIEWS.WORKFLOWS });
break; break;
} }
case WORKFLOW_MENU_ACTIONS.CHANGE_OWNER: {
const workflowId = getWorkflowId();
if (!workflowId) {
return;
}
changeOwnerEventBus.once(
'resource-moved',
async () => await router.push({ name: VIEWS.WORKFLOWS }),
);
uiStore.openModalWithData({
name: PROJECT_MOVE_RESOURCE_MODAL,
data: {
resource: workflowsStore.workflowsById[workflowId],
resourceType: ResourceType.Workflow,
resourceTypeLabel: locale.baseText('generic.workflow').toLowerCase(),
eventBus: changeOwnerEventBus,
},
});
break;
}
default: default:
break; break;
} }

View File

@@ -1,28 +1,28 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, onMounted, h } from 'vue';
import { truncate } from '@n8n/utils/string/truncate';
import type { ICredentialsResponse, IUsedCredential, IWorkflowDb } from '@/Interface';
import { useI18n } from '@n8n/i18n';
import { useUIStore } from '@/stores/ui.store';
import { useProjectsStore } from '@/stores/projects.store';
import Modal from '@/components/Modal.vue'; import Modal from '@/components/Modal.vue';
import { VIEWS } from '@/constants';
import {
splitName,
getTruncatedProjectName,
ResourceType,
MAX_NAME_LENGTH,
} from '@/utils/projects.utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { ProjectTypes } from '@/types/projects.types';
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
import ProjectMoveResourceModalCredentialsList from '@/components/Projects/ProjectMoveResourceModalCredentialsList.vue'; import ProjectMoveResourceModalCredentialsList from '@/components/Projects/ProjectMoveResourceModalCredentialsList.vue';
import ProjectMoveSuccessToastMessage from '@/components/Projects/ProjectMoveSuccessToastMessage.vue';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { VIEWS } from '@/constants';
import type { ICredentialsResponse, IUsedCredential, IWorkflowDb } from '@/Interface';
import { getResourcePermissions } from '@/permissions'; import { getResourcePermissions } from '@/permissions';
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
import type { EventBus } from '@n8n/utils/event-bus';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useCredentialsStore } from '@/stores/credentials.store'; import { useCredentialsStore } from '@/stores/credentials.store';
import { useProjectsStore } from '@/stores/projects.store';
import { useUIStore } from '@/stores/ui.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { ProjectTypes } from '@/types/projects.types';
import {
getTruncatedProjectName,
MAX_NAME_LENGTH,
ResourceType,
splitName,
} from '@/utils/projects.utils';
import { useI18n } from '@n8n/i18n';
import type { EventBus } from '@n8n/utils/event-bus';
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
import { truncate } from '@n8n/utils/string/truncate';
import { computed, h, onMounted, ref } from 'vue';
const props = defineProps<{ const props = defineProps<{
modalName: string; modalName: string;
@@ -177,6 +177,8 @@ onMounted(async () => {
project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type, project_from_type: projectsStore.currentProject?.type ?? projectsStore.personalProject?.type,
}); });
await projectsStore.getAvailableProjects();
if (isResourceWorkflow.value) { if (isResourceWorkflow.value) {
const [workflow, credentials] = await Promise.all([ const [workflow, credentials] = await Promise.all([
workflowsStore.fetchWorkflow(props.data.resource.id), workflowsStore.fetchWorkflow(props.data.resource.id),

View File

@@ -628,6 +628,7 @@ export const enum WORKFLOW_MENU_ACTIONS {
ARCHIVE = 'archive', ARCHIVE = 'archive',
UNARCHIVE = 'unarchive', UNARCHIVE = 'unarchive',
RENAME = 'rename', RENAME = 'rename',
CHANGE_OWNER = 'change-owner',
} }
/** /**