feat(editor): Combine 'Move to Folder' and 'Change owner' modals (#15756)

This new modal also allows transferring entire folders to other projects and users.
This commit is contained in:
Jaakko Husso
2025-05-28 23:41:07 +03:00
committed by GitHub
parent ba70cab9d5
commit e860dd6d2e
27 changed files with 1989 additions and 292 deletions

View File

@@ -8,6 +8,7 @@ import { useFoldersStore } from '@/stores/folders.store';
import { useRoute } from 'vue-router';
import { useProjectsStore } from '@/stores/projects.store';
import { ProjectTypes } from '@/types/projects.types';
import type { ChangeLocationSearchResult } from '@/Interface';
const props = defineProps<{
modalName: string;
@@ -32,7 +33,7 @@ const projectsStore = useProjectsStore();
const loading = ref(false);
const operation = ref('');
const deleteConfirmText = ref('');
const selectedFolder = ref<{ id: string; name: string; type: 'folder' | 'project' } | null>(null);
const selectedFolder = ref<ChangeLocationSearchResult | null>(null);
const folderToDelete = computed(() => {
if (!props.activeId) return null;
@@ -106,7 +107,7 @@ async function onSubmit() {
loading.value = true;
const newParentId =
selectedFolder.value?.type === 'project' ? '0' : (selectedFolder.value?.id ?? undefined);
selectedFolder.value?.resource === 'project' ? '0' : (selectedFolder.value?.id ?? undefined);
await foldersStore.deleteFolder(route.params.projectId as string, props.activeId, newParentId);
@@ -134,7 +135,7 @@ async function onSubmit() {
}
}
const onFolderSelected = (payload: { id: string; name: string; type: 'folder' | 'project' }) => {
const onFolderSelected = (payload: ChangeLocationSearchResult) => {
selectedFolder.value = payload;
};
</script>
@@ -180,8 +181,10 @@ const onFolderSelected = (payload: { id: string; name: string; type: 'folder' |
}}</n8n-text>
<MoveToFolderDropdown
v-if="projectsStore.currentProject"
:current-folder-id="props.activeId"
:selected-location="selectedFolder"
:selected-project-id="projectsStore.currentProject?.id"
:current-project-id="projectsStore.currentProject?.id"
:current-folder-id="props.activeId"
:parent-folder-id="folderToDelete?.parentFolder"
@location:selected="onFolderSelected"
/>

View File

@@ -2,72 +2,59 @@
import { useI18n } from '@/composables/useI18n';
import type { ChangeLocationSearchResult } from '@/Interface';
import { useFoldersStore } from '@/stores/folders.store';
import { useProjectsStore } from '@/stores/projects.store';
import { type ProjectIcon as ItemProjectIcon, ProjectTypes } from '@/types/projects.types';
import { N8nSelect } from '@n8n/design-system';
import { computed, onMounted, ref } from 'vue';
import { computed, ref, watch } from 'vue';
/**
* This component is used to select a folder to move a resource (folder or workflow) to.
* Based on the provided resource type, it fetches the available folders and displays them in a dropdown.
* For folders, it filters out current folder parent and all off it's children (done in the back-end)
* For workflows, it only filters out the current workflows's folder.
* This component is used to select a folder within a project.
* If parentFolderId is provided it will filter out the parent folder from the results.
* If currentFolderId is provided it will filter out the current folder and all its children from the results (done in the back-end).
* Root folder of the project is included in the results unless it is the current folder or parent folder.
*/
type Props = {
currentProjectId: string;
selectedLocation: ChangeLocationSearchResult | null;
selectedProjectId: string; // The project where the resource is being moved to
currentProjectId?: string; // The project where the resource is currently located
currentFolderId?: string;
parentFolderId?: string;
};
const props = withDefaults(defineProps<Props>(), {
currentFolderId: '',
parentFolderId: '',
selectedLocation: null,
currentProjectId: undefined,
currentFolderId: undefined,
parentFolderId: undefined,
});
const emit = defineEmits<{
'location:selected': [value: { id: string; name: string; type: 'folder' | 'project' }];
'location:selected': [value: ChangeLocationSearchResult];
}>();
const i18n = useI18n();
const foldersStore = useFoldersStore();
const projectsStore = useProjectsStore();
const moveFolderDropdown = ref<InstanceType<typeof N8nSelect>>();
const selectedFolderId = ref<string | null>(null);
const availableLocations = ref<ChangeLocationSearchResult[]>([]);
const moveFolderDropdown = ref<InstanceType<typeof N8nSelect>>();
const selectedLocationId = computed<string | null>({
get: () => props.selectedLocation?.id ?? null,
set: (id) => {
const location = availableLocations.value.find((f) => f.id === id);
if (!location) {
return;
}
emit('location:selected', location);
},
});
const loading = ref(false);
const currentProject = computed(() => {
return projectsStore.currentProject;
});
const projectName = computed(() => {
if (currentProject.value?.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
}
return currentProject.value?.name;
});
const projectIcon = computed<ItemProjectIcon>(() => {
const defaultIcon: ItemProjectIcon = { type: 'icon', value: 'layer-group' };
if (currentProject.value?.type === ProjectTypes.Personal) {
return { type: 'icon', value: 'user' };
} else if (currentProject.value?.type === ProjectTypes.Team) {
return currentProject.value.icon ?? defaultIcon;
}
return defaultIcon;
});
const fetchAvailableLocations = async (query?: string) => {
if (!query) {
availableLocations.value = [];
return;
}
loading.value = true;
const folders = await foldersStore.fetchFoldersAvailableForMove(
props.currentProjectId,
props.selectedProjectId,
props.currentFolderId,
{ name: query ?? undefined },
);
@@ -76,58 +63,68 @@ const fetchAvailableLocations = async (query?: string) => {
} else {
availableLocations.value = folders.filter((folder) => folder.id !== props.parentFolderId);
}
// Finally add project root if project name contains query (only if folder is not already in root)
if (
projectName.value &&
projectName.value.toLowerCase().includes(query.toLowerCase()) &&
props.parentFolderId !== ''
) {
const rootFolderName = i18n.baseText('folders.move.project.root.name');
const isQueryMatchesRoot = !query || rootFolderName.toLowerCase().includes(query?.toLowerCase());
const isTransfer = props.selectedProjectId !== props.currentProjectId;
// Finally always add project root to the results (if folder is not already in root)
if (isQueryMatchesRoot && (!!props.parentFolderId || isTransfer)) {
availableLocations.value.unshift({
id: props.currentProjectId,
name: i18n.baseText('folders.move.project.root.name', {
interpolate: { projectName: projectName.value },
}),
id: props.selectedProjectId,
name: rootFolderName,
resource: 'project',
createdAt: '',
updatedAt: '',
workflowCount: 0,
subFolderCount: 0,
path: [],
});
}
loading.value = false;
};
const onFolderSelected = (folderId: string) => {
const selectedFolder = availableLocations.value.find((folder) => folder.id === folderId);
if (!selectedFolder) {
return;
}
emit('location:selected', {
id: folderId,
name: selectedFolder.name,
type: selectedFolder.resource,
});
};
watch(
() => [props.selectedProjectId, props.currentFolderId, props.parentFolderId],
() => {
availableLocations.value = [];
void fetchAvailableLocations();
},
{ immediate: true },
);
onMounted(() => {
void setTimeout(() => moveFolderDropdown.value?.focusOnInput());
function focusOnInput() {
// To make the dropdown automatically open focused and positioned correctly
// we must wait till the modal opening animation is done. ElModal triggers an 'opened' event
// when the animation is done, and once that happens, we can focus on the input.
moveFolderDropdown.value?.focusOnInput();
}
defineExpose({
focusOnInput,
});
const maxPathLength = 4;
const separator = '/';
const isTopLevelFolder = (location: ChangeLocationSearchResult, index: number) => {
return index === location.path.length - 1 || index >= 3;
};
</script>
<template>
<div :class="$style['move-folder-dropdown']" data-test-id="move-to-folder-dropdown">
<N8nSelect
ref="moveFolderDropdown"
v-model="selectedFolderId"
v-model="selectedLocationId"
:filterable="true"
:remote="true"
:remote-method="fetchAvailableLocations"
:loading="loading"
:placeholder="i18n.baseText('folders.move.modal.select.placeholder')"
:no-data-text="i18n.baseText('folders.move.modal.no.data.label')"
:placeholder="i18n.baseText('folders.move.modal.folder.placeholder')"
:no-data-text="i18n.baseText('folders.move.modal.folder.noData.label')"
option-label="name"
option-value="id"
@update:model-value="onFolderSelected"
>
<template #prefix>
<N8nIcon icon="search" />
@@ -140,16 +137,41 @@ onMounted(() => {
data-test-id="move-to-folder-option"
>
<div :class="$style['folder-select-item']">
<ProjectIcon
v-if="location.resource === 'project' && currentProject"
:class="$style['folder-icon']"
:icon="projectIcon"
:border-less="true"
size="mini"
color="text-dark"
/>
<n8n-icon v-else :class="$style['folder-icon']" icon="folder" />
<span :class="$style['folder-name']"> {{ location.name }}</span>
<ul :class="$style.list">
<li v-if="location.resource === 'project'" :class="$style.current">
<n8n-text>{{ location.name }}</n8n-text>
</li>
<template v-else>
<li v-if="location.path.length > maxPathLength" :class="$style.item">
<n8n-text>...</n8n-text>
</li>
<li v-if="location.path.length > 0" :class="$style.separator">
<n8n-text>{{ separator }}</n8n-text>
</li>
<template
v-for="(fragment, index) in location.path.slice(-maxPathLength)"
:key="`${location.id}-${index}`"
>
<li
:class="{
[$style.item]: true,
[$style.current]: isTopLevelFolder(location, index),
}"
:title="fragment"
:data-resourceid="fragment"
data-test-id="breadcrumbs-item"
data-target="folder-breadcrumb-item"
>
<n8n-text>
{{ fragment }}
</n8n-text>
</li>
<li v-if="!isTopLevelFolder(location, index)" :class="$style.separator">
<n8n-text>{{ separator }}</n8n-text>
</li>
</template>
</template>
</ul>
</div>
</N8nOption>
</N8nSelect>
@@ -159,6 +181,7 @@ onMounted(() => {
<style module lang="scss">
.move-folder-dropdown {
display: flex;
padding-top: var(--spacing-2xs);
}
.folder-select-item {
@@ -169,9 +192,39 @@ onMounted(() => {
overflow: hidden;
white-space: nowrap;
.folder-name {
text-overflow: ellipsis;
overflow: hidden;
li {
padding: var(--spacing-4xs) var(--spacing-5xs) var(--spacing-5xs);
}
}
.list {
display: flex;
list-style: none;
align-items: center;
}
.item,
.item * {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--color-text-light);
font-size: var(--font-size-s);
line-height: var(--font-line-height-xsmall);
}
.item {
max-width: var(--spacing-4xl);
}
.item.current span {
color: var(--color-text-dark);
}
.separator {
font-size: var(--font-size-s);
color: var(--color-text-light);
}
</style>

View File

@@ -1,11 +1,32 @@
import { createComponentRenderer } from '@/__tests__/render';
import MoveToFolderModal from './MoveToFolderModal.vue';
import userEvent from '@testing-library/user-event';
import { createTestingPinia } from '@pinia/testing';
import { cleanupAppModals, createAppModals, mockedStore } from '@/__tests__/utils';
import { waitFor, screen, within } from '@testing-library/vue';
import { faker } from '@faker-js/faker';
import { createComponentRenderer } from '@/__tests__/render';
import { createProjectListItem, createProjectSharingData } from '@/__tests__/data/projects';
import {
cleanupAppModals,
createAppModals,
getDropdownItems,
getSelectedDropdownValue,
mockedStore,
type MockedStore,
} from '@/__tests__/utils';
import { useUIStore } from '@/stores/ui.store';
import { MOVE_FOLDER_MODAL_KEY } from '@/constants';
import { waitFor } from '@testing-library/vue';
import { screen } from '@testing-library/vue';
import { useSettingsStore } from '@/stores/settings.store';
import type { FrontendSettings } from '@n8n/api-types';
import type { Project } from '@/types/projects.types';
import type {
ChangeLocationSearchResult,
ICredentialsResponse,
IUsedCredential,
} from '@/Interface';
import { useCredentialsStore } from '@/stores/credentials.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useFoldersStore } from '@/stores/folders.store';
import { useProjectsStore } from '@/stores/projects.store';
import MoveToFolderModal from './MoveToFolderModal.vue';
vi.mock('vue-router', () => {
const push = vi.fn();
@@ -45,7 +66,78 @@ const TEST_WORKFLOW_RESOURCE = {
parentFolderId: 'test-parent-folder-id',
};
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
let uiStore: MockedStore<typeof useUIStore>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let credentialsStore: MockedStore<typeof useCredentialsStore>;
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let foldersStore: MockedStore<typeof useFoldersStore>;
let projectsStore: MockedStore<typeof useProjectsStore>;
const personalProject = createProjectListItem('personal');
const anotherUser = createProjectListItem('personal');
const teamProjects = Array.from({ length: 3 }, () => createProjectListItem('team'));
const projects = [personalProject, ...teamProjects, anotherUser];
const homeProject = createProjectSharingData();
const enableSharing = {
enterprise: {
sharing: true,
},
} as FrontendSettings;
const createCredential = (overrides = {}): ICredentialsResponse => ({
id: faker.string.alphanumeric(10),
createdAt: faker.date.recent().toISOString(),
updatedAt: faker.date.recent().toISOString(),
type: 'generic',
name: faker.lorem.words(2),
sharedWithProjects: [],
isManaged: false,
homeProject,
...overrides,
});
const readableCredential = createCredential({
scopes: ['credential:read'],
});
const readableUsedCredential: IUsedCredential = {
id: readableCredential.id,
name: readableCredential.name,
credentialType: readableCredential.type,
currentUserHasAccess: true,
homeProject,
sharedWithProjects: [],
};
const shareableCredential = createCredential({
scopes: ['credential:share', 'credential:read'],
});
const shareableUsedCredential: IUsedCredential = {
id: shareableCredential.id,
name: shareableCredential.name,
credentialType: shareableCredential.type,
currentUserHasAccess: true,
homeProject,
sharedWithProjects: [],
};
const folder: ChangeLocationSearchResult = {
createdAt: faker.date.recent().toISOString(),
updatedAt: faker.date.recent().toISOString(),
id: faker.string.alphanumeric(10),
name: 'test',
homeProject,
tags: [],
workflowCount: 0,
subFolderCount: 0,
path: ['test'],
resource: 'folder',
};
const mockEventBus = {
emit: vi.fn(),
};
describe('MoveToFolderModal', () => {
beforeEach(() => {
createAppModals();
@@ -56,6 +148,38 @@ describe('MoveToFolderModal', () => {
open: true,
},
};
settingsStore = mockedStore(useSettingsStore);
settingsStore.settings = {
enterprise: {},
} as FrontendSettings;
credentialsStore = mockedStore(useCredentialsStore);
credentialsStore.fetchAllCredentials = vi.fn().mockResolvedValue([]);
workflowsStore = mockedStore(useWorkflowsStore);
workflowsStore.fetchWorkflow = vi.fn().mockResolvedValue({
id: TEST_WORKFLOW_RESOURCE.id,
name: TEST_WORKFLOW_RESOURCE.name,
parentFolderId: TEST_WORKFLOW_RESOURCE.parentFolderId,
usedCredentials: [],
});
foldersStore = mockedStore(useFoldersStore);
foldersStore.fetchFolderUsedCredentials = vi.fn().mockResolvedValue([]);
foldersStore.fetchFoldersAvailableForMove = vi.fn().mockResolvedValue([]);
foldersStore.fetchFolderContent = vi.fn().mockResolvedValue({
totalWorkflows: 0,
totalSubFolders: 0,
});
projectsStore = mockedStore(useProjectsStore);
projectsStore.currentProject = personalProject as unknown as Project;
projectsStore.currentProjectId = personalProject.id;
projectsStore.personalProject = personalProject as unknown as Project;
projectsStore.projects = projects;
projectsStore.availableProjects = projects;
});
afterEach(() => {
@@ -63,20 +187,457 @@ describe('MoveToFolderModal', () => {
vi.clearAllMocks();
});
it('should render for folder resource', async () => {
it('should render for folder resource with no workflows or subfolders', async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
expect(screen.getByText(`Move folder ${TEST_FOLDER_RESOURCE.name}`)).toBeInTheDocument();
expect(queryByTestId('move-modal-description')).not.toBeInTheDocument();
expect(getByTestId('move-to-folder-dropdown')).toBeInTheDocument();
});
it('should render for folder resource with workflows and subfolders', async () => {
foldersStore.fetchFolderContent = vi.fn().mockResolvedValue({
totalWorkflows: 1,
totalSubFolders: 1,
});
const { getByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
expect(
screen.getByText(`Move "${TEST_FOLDER_RESOURCE.name}" to another folder`),
).toBeInTheDocument();
expect(screen.getByTestId('move-folder-description')).toBeInTheDocument();
expect(screen.getByText(`Move folder ${TEST_FOLDER_RESOURCE.name}`)).toBeInTheDocument();
expect(screen.getByTestId('move-modal-description')).toBeInTheDocument();
});
it('should not see the project sharing select without sharing license', async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
expect(queryByTestId('project-sharing-select')).not.toBeInTheDocument();
});
it('should see the project sharing select with sharing license', async () => {
settingsStore.settings = enableSharing;
const { getByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
expect(getByTestId('project-sharing-select')).toBeInTheDocument();
});
it('should select a project with the picker', async () => {
settingsStore.settings = enableSharing;
const { getByTestId, queryByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
const projectSelect = getByTestId('project-sharing-select');
expect(projectSelect).toBeVisible();
await userEvent.click(getByTestId('project-sharing-select'));
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
expect(projectSelectDropdownItems).toHaveLength(5);
let selectedValue = await getSelectedDropdownValue(projectSelectDropdownItems);
expect(selectedValue).toBe(personalProject.name);
await userEvent.click(projectSelectDropdownItems[0]);
expect(queryByTestId('project-sharing-list-item')).not.toBeInTheDocument();
selectedValue = await getSelectedDropdownValue(projectSelectDropdownItems);
const selectedProject = projects.find((p) => p.name === selectedValue);
expect(selectedProject).toBeDefined();
});
it('should not render credentials sharing checkbox for folder without shareable resources', async () => {
settingsStore.settings = enableSharing;
credentialsStore.fetchAllCredentials = vi
.fn()
.mockResolvedValue([readableCredential, shareableCredential]);
foldersStore.fetchFolderUsedCredentials = vi.fn().mockResolvedValue([readableUsedCredential]);
const { getByTestId, queryByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
// Select another project to share from Personal -> Team 1
const projectSelect = getByTestId('project-sharing-select');
await userEvent.click(getByTestId('project-sharing-select'));
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
const teamProject = [...projectSelectDropdownItems].find(
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
);
expect(teamProject).toBeDefined();
await userEvent.click(teamProject as Element);
expect(queryByTestId('move-modal-share-credentials-checkbox')).not.toBeInTheDocument();
});
it('should render credentials sharing checkbox for folder with shareable resources', async () => {
settingsStore.settings = enableSharing;
credentialsStore.fetchAllCredentials = vi.fn().mockResolvedValue([shareableCredential]);
foldersStore.fetchFolderUsedCredentials = vi.fn().mockResolvedValue([shareableUsedCredential]);
const { getByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
// Select another project to share from Personal -> Team 1
const projectSelect = getByTestId('project-sharing-select');
await userEvent.click(getByTestId('project-sharing-select'));
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
const teamProject = [...projectSelectDropdownItems].find(
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
);
expect(teamProject).toBeDefined();
await userEvent.click(teamProject as Element);
await waitFor(() =>
expect(getByTestId('move-modal-share-credentials-checkbox')).toBeInTheDocument(),
);
expect(getByTestId('move-modal-share-credentials-checkbox')).not.toBeChecked();
expect(getByTestId('move-modal-used-credentials-warning')).toBeInTheDocument();
});
it('should select a folder with the picker', async () => {
foldersStore.fetchFoldersAvailableForMove = vi.fn().mockResolvedValue([folder]);
const { getByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
const folderSelect = getByTestId('move-to-folder-dropdown');
expect(folderSelect).toBeVisible();
expect(within(folderSelect).getByRole('combobox')).toHaveValue('');
const folderSelectDropdownItems = await getDropdownItems(folderSelect);
expect(folderSelectDropdownItems).toHaveLength(2); // root, test
await userEvent.click(folderSelectDropdownItems[1]);
expect(within(folderSelect).getByRole('combobox')).toHaveValue('test');
});
it('should clear selected folder when switching projects', async () => {
settingsStore.settings = enableSharing;
foldersStore.fetchFoldersAvailableForMove = vi.fn().mockResolvedValue([folder]);
const { getByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
const folderSelect = getByTestId('move-to-folder-dropdown');
const folderSelectDropdownItems = await getDropdownItems(folderSelect);
await userEvent.click(folderSelectDropdownItems[1]);
expect(within(folderSelect).getByRole('combobox')).toHaveValue('test');
const projectSelect = getByTestId('project-sharing-select');
await userEvent.click(getByTestId('project-sharing-select'));
const teamProject = [...(await getDropdownItems(projectSelect))].find(
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
);
await userEvent.click(teamProject as Element);
expect(within(folderSelect).getByRole('combobox')).toHaveValue('');
});
it('should move selected folder on submit', async () => {
foldersStore.fetchFoldersAvailableForMove = vi.fn().mockResolvedValue([folder]);
const { getByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
const submitButton = getByTestId('confirm-move-folder-button');
expect(submitButton).toBeDisabled();
const folderSelect = getByTestId('move-to-folder-dropdown');
const folderSelectDropdownItems = await getDropdownItems(folderSelect);
await userEvent.click(folderSelectDropdownItems[1]);
expect(submitButton).toBeEnabled();
await userEvent.click(submitButton);
expect(mockEventBus.emit).toHaveBeenCalledWith('folder-moved', {
newParent: {
id: folder.id,
name: folder.name,
type: folder.resource,
},
folder: { id: TEST_FOLDER_RESOURCE.id, name: TEST_FOLDER_RESOURCE.name },
});
});
it('should transfer selected folder on submit', async () => {
foldersStore.fetchFoldersAvailableForMove = vi.fn().mockResolvedValue([folder]);
settingsStore.settings = enableSharing;
const { getByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
const projectSelect = getByTestId('project-sharing-select');
await userEvent.click(getByTestId('project-sharing-select'));
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
const teamProject = [...projectSelectDropdownItems].find(
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
);
await userEvent.click(teamProject as Element);
const submitButton = getByTestId('confirm-move-folder-button');
expect(submitButton).toBeDisabled();
const folderSelect = getByTestId('move-to-folder-dropdown');
const folderSelectDropdownItems = await getDropdownItems(folderSelect);
await userEvent.click(folderSelectDropdownItems[1]);
expect(submitButton).toBeEnabled();
await userEvent.click(submitButton);
expect(mockEventBus.emit).toHaveBeenCalledWith('folder-transferred', {
newParent: {
id: folder.id,
name: folder.name,
type: folder.resource,
},
folder: { id: TEST_FOLDER_RESOURCE.id, name: TEST_FOLDER_RESOURCE.name },
projectId: personalProject.id,
destinationProjectId: teamProjects[0].id,
shareCredentials: undefined,
});
});
it('should transfer selected folder and used credentials on submit', async () => {
settingsStore.settings = enableSharing;
foldersStore.fetchFoldersAvailableForMove = vi.fn().mockResolvedValue([folder]);
credentialsStore.fetchAllCredentials = vi.fn().mockResolvedValue([shareableCredential]);
foldersStore.fetchFolderUsedCredentials = vi.fn().mockResolvedValue([shareableUsedCredential]);
const { getByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
const projectSelect = getByTestId('project-sharing-select');
await userEvent.click(getByTestId('project-sharing-select'));
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
const teamProject = [...projectSelectDropdownItems].find(
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
);
expect(teamProject).toBeDefined();
await userEvent.click(teamProject as Element);
const submitButton = getByTestId('confirm-move-folder-button');
const folderSelect = getByTestId('move-to-folder-dropdown');
const folderSelectDropdownItems = await getDropdownItems(folderSelect);
await userEvent.click(folderSelectDropdownItems[1]);
await userEvent.click(getByTestId('move-modal-share-credentials-checkbox'));
await userEvent.click(submitButton);
expect(mockEventBus.emit).toHaveBeenCalledWith('folder-transferred', {
newParent: {
id: folder.id,
name: folder.name,
type: folder.resource,
},
folder: { id: TEST_FOLDER_RESOURCE.id, name: TEST_FOLDER_RESOURCE.name },
projectId: personalProject.id,
destinationProjectId: teamProjects[0].id,
shareCredentials: [shareableUsedCredential.id],
});
});
it('should transfer selected folder and not pass used credentials if unchecked on submit', async () => {
settingsStore.settings = enableSharing;
foldersStore.fetchFoldersAvailableForMove = vi.fn().mockResolvedValue([folder]);
credentialsStore.fetchAllCredentials = vi.fn().mockResolvedValue([shareableCredential]);
foldersStore.fetchFolderUsedCredentials = vi.fn().mockResolvedValue([shareableUsedCredential]);
const { getByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
const projectSelect = getByTestId('project-sharing-select');
await userEvent.click(getByTestId('project-sharing-select'));
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
const teamProject = [...projectSelectDropdownItems].find(
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
);
expect(teamProject).toBeDefined();
await userEvent.click(teamProject as Element);
const submitButton = getByTestId('confirm-move-folder-button');
const folderSelect = getByTestId('move-to-folder-dropdown');
const folderSelectDropdownItems = await getDropdownItems(folderSelect);
await userEvent.click(folderSelectDropdownItems[1]);
await userEvent.click(submitButton);
expect(mockEventBus.emit).toHaveBeenCalledWith('folder-transferred', {
newParent: {
id: folder.id,
name: folder.name,
type: folder.resource,
},
folder: { id: TEST_FOLDER_RESOURCE.id, name: TEST_FOLDER_RESOURCE.name },
projectId: personalProject.id,
destinationProjectId: teamProjects[0].id,
shareCredentials: undefined,
});
});
it('should transfer selected folder to personal project on submit', async () => {
settingsStore.settings = enableSharing;
const { getByTestId } = renderComponent({
props: {
data: {
resource: TEST_FOLDER_RESOURCE,
resourceType: 'folder',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
const projectSelect = getByTestId('project-sharing-select');
await userEvent.click(getByTestId('project-sharing-select'));
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
const anotherUserPersonalProject = [...projectSelectDropdownItems].find(
(item) => item.querySelector('p')?.textContent?.trim() === anotherUser.name,
);
expect(anotherUserPersonalProject).toBeDefined();
await userEvent.click(anotherUserPersonalProject as Element);
const submitButton = getByTestId('confirm-move-folder-button');
expect(submitButton).toBeEnabled();
expect(submitButton).toBeEnabled();
await userEvent.click(submitButton);
expect(mockEventBus.emit).toHaveBeenCalledWith('folder-transferred', {
newParent: {
id: anotherUser.id,
name: anotherUser.name,
type: 'project',
},
folder: { id: TEST_FOLDER_RESOURCE.id, name: TEST_FOLDER_RESOURCE.name },
projectId: personalProject.id,
destinationProjectId: anotherUser.id,
shareCredentials: undefined,
});
});
it('should render for workflow resource', async () => {
@@ -85,13 +646,150 @@ describe('MoveToFolderModal', () => {
data: {
resource: TEST_WORKFLOW_RESOURCE,
resourceType: 'workflow',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
expect(
screen.getByText(`Move "${TEST_WORKFLOW_RESOURCE.name}" to another folder`),
).toBeInTheDocument();
expect(screen.queryByTestId('move-folder-description')).not.toBeInTheDocument();
expect(screen.getByText(`Move workflow ${TEST_WORKFLOW_RESOURCE.name}`)).toBeInTheDocument();
expect(workflowsStore.fetchWorkflow).toHaveBeenCalledWith(TEST_WORKFLOW_RESOURCE.id);
});
it('should move selected workflow on submit', async () => {
foldersStore.fetchFoldersAvailableForMove = vi.fn().mockResolvedValue([folder]);
const { getByTestId } = renderComponent({
props: {
data: {
resource: TEST_WORKFLOW_RESOURCE,
resourceType: 'workflow',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
const submitButton = getByTestId('confirm-move-folder-button');
expect(submitButton).toBeDisabled();
const folderSelect = getByTestId('move-to-folder-dropdown');
const folderSelectDropdownItems = await getDropdownItems(folderSelect);
await userEvent.click(folderSelectDropdownItems[1]);
expect(submitButton).toBeEnabled();
await userEvent.click(submitButton);
expect(mockEventBus.emit).toHaveBeenCalledWith('workflow-moved', {
newParent: {
id: folder.id,
name: folder.name,
type: folder.resource,
},
workflow: {
id: TEST_WORKFLOW_RESOURCE.id,
name: TEST_WORKFLOW_RESOURCE.name,
oldParentId: TEST_WORKFLOW_RESOURCE.parentFolderId,
},
});
});
it('should transfer selected workflow on submit', async () => {
foldersStore.fetchFoldersAvailableForMove = vi.fn().mockResolvedValue([folder]);
settingsStore.settings = enableSharing;
const { getByTestId } = renderComponent({
props: {
data: {
resource: TEST_WORKFLOW_RESOURCE,
resourceType: 'workflow',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
const projectSelect = getByTestId('project-sharing-select');
await userEvent.click(getByTestId('project-sharing-select'));
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
const teamProject = [...projectSelectDropdownItems].find(
(item) => item.querySelector('p')?.textContent?.trim() === teamProjects[0].name,
);
expect(teamProject).toBeDefined();
await userEvent.click(teamProject as Element);
const submitButton = getByTestId('confirm-move-folder-button');
expect(submitButton).toBeDisabled();
const folderSelect = getByTestId('move-to-folder-dropdown');
const folderSelectDropdownItems = await getDropdownItems(folderSelect);
await userEvent.click(folderSelectDropdownItems[1]);
expect(submitButton).toBeEnabled();
await userEvent.click(submitButton);
expect(mockEventBus.emit).toHaveBeenCalledWith('workflow-transferred', {
newParent: {
id: folder.id,
name: folder.name,
type: folder.resource,
},
workflow: {
id: TEST_WORKFLOW_RESOURCE.id,
name: TEST_WORKFLOW_RESOURCE.name,
oldParentId: TEST_WORKFLOW_RESOURCE.parentFolderId,
},
projectId: teamProjects[0].id,
shareCredentials: undefined,
});
});
it('should transfer selected workflow to personal project on submit', async () => {
settingsStore.settings = enableSharing;
const { getByTestId } = renderComponent({
props: {
data: {
resource: TEST_WORKFLOW_RESOURCE,
resourceType: 'workflow',
workflowListEventBus: mockEventBus,
},
},
});
await waitFor(() => expect(getByTestId('moveFolder-modal')).toBeInTheDocument());
const projectSelect = getByTestId('project-sharing-select');
await userEvent.click(getByTestId('project-sharing-select'));
const projectSelectDropdownItems = await getDropdownItems(projectSelect);
const anotherUserPersonalProject = [...projectSelectDropdownItems].find(
(item) => item.querySelector('p')?.textContent?.trim() === anotherUser.name,
);
expect(anotherUserPersonalProject).toBeDefined();
await userEvent.click(anotherUserPersonalProject as Element);
const submitButton = getByTestId('confirm-move-folder-button');
expect(submitButton).toBeEnabled();
expect(submitButton).toBeEnabled();
await userEvent.click(submitButton);
expect(mockEventBus.emit).toHaveBeenCalledWith('workflow-transferred', {
newParent: {
id: anotherUser.id,
name: anotherUser.name,
type: 'project',
},
workflow: {
id: TEST_WORKFLOW_RESOURCE.id,
name: TEST_WORKFLOW_RESOURCE.name,
oldParentId: TEST_WORKFLOW_RESOURCE.parentFolderId,
},
projectId: anotherUser.id,
shareCredentials: undefined,
});
});
});

View File

@@ -1,10 +1,27 @@
<script lang="ts" setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { MOVE_FOLDER_MODAL_KEY } from '@/constants';
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
import { EnterpriseEditionFeature, MOVE_FOLDER_MODAL_KEY } from '@/constants';
import { useFoldersStore } from '@/stores/folders.store';
import { useProjectsStore } from '@/stores/projects.store';
import { useUIStore } from '@/stores/ui.store';
import { type EventBus } from '@n8n/utils/event-bus';
import { computed, ref } from 'vue';
import { useCredentialsStore } from '@/stores/credentials.store';
import { type EventBus, createEventBus } from '@n8n/utils/event-bus';
import {
ProjectTypes,
type ProjectListItem,
type ProjectSharingData,
} from '@/types/projects.types';
import type {
ChangeLocationSearchResult,
ICredentialsResponse,
IUsedCredential,
} from '@/Interface';
import { getResourcePermissions } from '@/permissions';
import MoveToFolderDropdown from './MoveToFolderDropdown.vue';
import { ResourceType, getTruncatedProjectName } from '@/utils/projects.utils';
import { useWorkflowsStore } from '@/stores/workflows.store';
/**
* This modal is used to move a resource (folder or workflow) to a different folder.
@@ -18,23 +35,96 @@ type Props = {
id: string;
name: string;
parentFolderId?: string;
sharedWithProjects?: ProjectSharingData[];
};
workflowListEventBus: EventBus;
};
};
export interface SimpleFolder {
id: string;
name: string;
type: string;
}
const props = defineProps<Props>();
const i18n = useI18n();
const modalBus = createEventBus();
const moveToFolderDropdown = ref<InstanceType<typeof MoveToFolderDropdown>>();
const foldersStore = useFoldersStore();
const projectsStore = useProjectsStore();
const uiStore = useUIStore();
const credentialsStore = useCredentialsStore();
const workflowsStore = useWorkflowsStore();
const selectedFolder = ref<{ id: string; name: string } | null>(null);
const selectedFolder = ref<ChangeLocationSearchResult | null>(null);
const selectedProject = ref<ProjectSharingData | null>(projectsStore.currentProject ?? null);
const isPersonalProject = computed(() => {
return selectedProject.value?.type === ProjectTypes.Personal;
});
const isOwnPersonalProject = computed(() => {
return (
selectedProject.value?.type === ProjectTypes.Personal &&
selectedProject.value.id === projectsStore.personalProject?.id
);
});
const isTransferringOwnership = computed(() => {
return selectedProject.value && selectedProject.value?.id !== projectsStore.currentProject?.id;
});
const workflowCount = ref(0);
const subFolderCount = ref(0);
const shareUsedCredentials = ref(false);
const usedCredentials = ref<IUsedCredential[]>([]);
const allCredentials = ref<ICredentialsResponse[]>([]);
const shareableCredentials = computed(() =>
allCredentials.value.filter(
(credential) =>
isTransferringOwnership.value &&
getResourcePermissions(credential.scopes).credential.share &&
usedCredentials.value.find((uc) => uc.id === credential.id),
),
);
const unShareableCredentials = computed(() =>
usedCredentials.value.reduce(
(acc, uc) => {
const credential = credentialsStore.getCredentialById(uc.id);
const credentialPermissions = getResourcePermissions(credential?.scopes).credential;
if (!credentialPermissions.share) {
if (credentialPermissions.read) {
acc.push(credential);
} else {
acc.push(uc);
}
}
return acc;
},
[] as Array<IUsedCredential | ICredentialsResponse>,
),
);
const availableProjects = computed<ProjectListItem[]>(() =>
sortByProperty(
'name',
projectsStore.availableProjects.filter(
(p) => !p.scopes || getResourcePermissions(p.scopes)[props.data.resourceType].create,
),
),
);
const resourceTypeLabel = computed(() => {
return i18n.baseText(`generic.${props.data.resourceType}`).toLowerCase();
});
const title = computed(() => {
return i18n.baseText('folders.move.modal.title', {
interpolate: { folderName: props.data.resource.name },
interpolate: {
folderName: props.data.resource.name,
resourceTypeLabel: resourceTypeLabel.value,
},
});
});
@@ -48,48 +138,299 @@ const currentFolder = computed(() => {
};
});
const onFolderSelected = (payload: { id: string; name: string; type: string }) => {
const fetchCurrentFolderContents = async () => {
if (!currentFolder.value || !projectsStore.currentProject) {
return;
}
const { totalWorkflows, totalSubFolders } = await foldersStore.fetchFolderContent(
projectsStore.currentProject.id,
currentFolder.value.id,
);
workflowCount.value = totalWorkflows;
subFolderCount.value = totalSubFolders;
};
watch(
() => [selectedProject.value],
() => {
selectedFolder.value = null;
},
);
watch(
() => [currentFolder.value, selectedProject.value],
() => {
void fetchCurrentFolderContents();
},
{ immediate: true },
);
const onFolderSelected = (payload: ChangeLocationSearchResult) => {
selectedFolder.value = payload;
};
const targetProjectName = computed(() => {
return getTruncatedProjectName(selectedProject.value?.name);
});
const onSubmit = () => {
if (!selectedProject.value) {
return;
}
const newParent = selectedFolder.value
? {
id: selectedFolder.value.id,
name: selectedFolder.value.name,
type: selectedFolder.value.resource,
}
: {
// When transferring resource to another user the folder selection is empty,
// as we can't select a folder in another user's personal project.
// Use project name as the fallback display name.
id: selectedProject.value.id,
name: targetProjectName.value,
type: 'project',
};
if (props.data.resourceType === 'folder') {
props.data.workflowListEventBus.emit('folder-moved', {
newParent: selectedFolder.value,
folder: { id: props.data.resource.id, name: props.data.resource.name },
});
if (selectedProject.value?.id !== projectsStore.currentProject?.id) {
props.data.workflowListEventBus.emit('folder-transferred', {
newParent,
folder: { id: props.data.resource.id, name: props.data.resource.name },
projectId: projectsStore.currentProject?.id,
destinationProjectId: selectedProject.value.id,
shareCredentials: shareUsedCredentials.value
? shareableCredentials.value.map((c) => c.id)
: undefined,
});
} else {
props.data.workflowListEventBus.emit('folder-moved', {
newParent,
folder: { id: props.data.resource.id, name: props.data.resource.name },
});
}
} else {
props.data.workflowListEventBus.emit('workflow-moved', {
newParent: selectedFolder.value,
workflow: {
id: props.data.resource.id,
name: props.data.resource.name,
oldParentId: props.data.resource.parentFolderId,
},
});
if (isTransferringOwnership.value) {
props.data.workflowListEventBus.emit('workflow-transferred', {
newParent,
projectId: selectedProject.value.id,
workflow: {
id: props.data.resource.id,
name: props.data.resource.name,
oldParentId: props.data.resource.parentFolderId,
},
shareCredentials: shareUsedCredentials.value
? shareableCredentials.value.map((c) => c.id)
: undefined,
});
} else {
props.data.workflowListEventBus.emit('workflow-moved', {
newParent,
workflow: {
id: props.data.resource.id,
name: props.data.resource.name,
oldParentId: props.data.resource.parentFolderId,
},
});
}
}
uiStore.closeModal(MOVE_FOLDER_MODAL_KEY);
};
modalBus.on('opened', () => {
moveToFolderDropdown.value?.focusOnInput();
});
const descriptionMessage = computed(() => {
let folderText = '';
let workflowText = '';
if (subFolderCount.value > 0) {
folderText = i18n.baseText('folders.move.modal.folder.count', {
interpolate: { count: subFolderCount.value },
});
}
if (workflowCount.value > 0) {
workflowText = i18n.baseText('folders.move.modal.workflow.count', {
interpolate: { count: workflowCount.value },
});
}
if (subFolderCount.value > 0 && workflowCount.value > 0) {
folderText += ` ${i18n.baseText('folder.and.workflow.separator')} `;
}
return i18n.baseText('folders.move.modal.description', {
interpolate: {
folders: folderText ? ` ${folderText}` : '',
workflows: workflowText ? ` ${workflowText}` : '',
},
});
});
const isResourceWorkflow = computed(() => props.data.resourceType === ResourceType.Workflow);
const isFolderSelectable = computed(() => {
return isOwnPersonalProject.value || !isPersonalProject.value;
});
onMounted(async () => {
if (isResourceWorkflow.value) {
const [workflow, credentials] = await Promise.all([
workflowsStore.fetchWorkflow(props.data.resource.id),
credentialsStore.fetchAllCredentials(),
]);
usedCredentials.value = workflow?.usedCredentials ?? [];
allCredentials.value = credentials;
} else {
if (projectsStore.currentProject?.id && currentFolder.value?.id) {
const [used, credentials] = await Promise.all([
await foldersStore.fetchFolderUsedCredentials(
projectsStore.currentProject.id,
currentFolder.value.id,
),
credentialsStore.fetchAllCredentials(),
]);
usedCredentials.value = used;
allCredentials.value = credentials;
}
}
});
</script>
<template>
<Modal :name="modalName" :title="title" width="500" :class="$style.container">
<Modal
:name="modalName"
:title="title"
width="500"
:class="$style.container"
:event-bus="modalBus"
>
<template #content>
<MoveToFolderDropdown
v-if="projectsStore.currentProject"
:current-folder-id="currentFolder?.id"
:current-project-id="projectsStore.currentProject?.id"
:parent-folder-id="props.data.resource.parentFolderId"
:exclude-only-parent="props.data.resourceType === 'workflow'"
@location:selected="onFolderSelected"
/>
<p
v-if="props.data.resourceType === 'folder'"
v-if="props.data.resourceType === 'folder' && (workflowCount > 0 || subFolderCount > 0)"
:class="$style.description"
data-test-id="move-folder-description"
data-test-id="move-modal-description"
>
{{ i18n.baseText('folders.move.modal.description') }}
{{ descriptionMessage }}
</p>
<enterprise-edition :features="[EnterpriseEditionFeature.Sharing]" :class="$style.content">
<div :class="$style.block">
<n8n-text color="text-dark">
{{ i18n.baseText('folders.move.modal.project.label') }}
</n8n-text>
<ProjectSharing
v-model="selectedProject"
class="pt-2xs"
:projects="availableProjects"
:placeholder="i18n.baseText('folders.move.modal.project.placeholder')"
/>
</div>
<div v-if="isTransferringOwnership" :class="$style.block">
<N8nText>
<i18n-t keypath="projects.move.resource.modal.message.sharingNote">
<template #note
><strong>{{
i18n.baseText('projects.move.resource.modal.message.note')
}}</strong></template
>
<template #resourceTypeLabel>{{ resourceTypeLabel }}</template>
</i18n-t>
<span
v-if="props.data.resource.sharedWithProjects?.length ?? 0 > 0"
:class="$style.textBlock"
>
{{
i18n.baseText('projects.move.resource.modal.message.sharingInfo', {
adjustToNumber: props.data.resource.sharedWithProjects?.length,
interpolate: {
count: props.data.resource.sharedWithProjects?.length ?? 0,
},
})
}}
</span>
</N8nText>
</div>
</enterprise-edition>
<template v-if="selectedProject && isFolderSelectable">
<div :class="$style.block">
<n8n-text color="text-dark">
{{ i18n.baseText('folders.move.modal.folder.label') }}
</n8n-text>
<MoveToFolderDropdown
ref="moveToFolderDropdown"
:selected-location="selectedFolder"
:selected-project-id="selectedProject.id"
:current-project-id="projectsStore.currentProject?.id"
:current-folder-id="currentFolder?.id"
:parent-folder-id="props.data.resource.parentFolderId"
:exclude-only-parent="props.data.resourceType === 'workflow'"
@location:selected="onFolderSelected"
/>
</div>
</template>
<N8nCheckbox
v-if="shareableCredentials.length"
v-model="shareUsedCredentials"
:class="$style.textBlock"
data-test-id="move-modal-share-credentials-checkbox"
>
<i18n-t
:keypath="
data.resourceType === 'workflow'
? 'folders.move.modal.message.usedCredentials.workflow'
: 'folders.move.modal.message.usedCredentials.folder'
"
>
<template #usedCredentials>
<N8nTooltip placement="top">
<span :class="$style.tooltipText">
{{
i18n.baseText('projects.move.resource.modal.message.usedCredentials.number', {
adjustToNumber: shareableCredentials.length,
interpolate: { count: shareableCredentials.length },
})
}}
</span>
<template #content>
<ProjectMoveResourceModalCredentialsList
:current-project-id="projectsStore.currentProjectId"
:credentials="shareableCredentials"
/>
</template>
</N8nTooltip>
</template>
</i18n-t>
</N8nCheckbox>
<N8nCallout
v-if="shareableCredentials.length && !shareUsedCredentials"
:class="$style.credentialsCallout"
theme="warning"
data-test-id="move-modal-used-credentials-warning"
>
{{ i18n.baseText('folders.move.modal.message.usedCredentials.warning') }}
</N8nCallout>
<span v-if="unShareableCredentials.length" :class="$style.textBlock">
<i18n-t keypath="projects.move.resource.modal.message.unAccessibleCredentials.note">
<template #credentials>
<N8nTooltip placement="top">
<span :class="$style.tooltipText">{{
i18n.baseText('projects.move.resource.modal.message.unAccessibleCredentials')
}}</span>
<template #content>
<ProjectMoveResourceModalCredentialsList
:current-project-id="projectsStore.currentProjectId"
:credentials="unShareableCredentials"
/>
</template>
</N8nTooltip>
</template>
</i18n-t>
</span>
</template>
<template #footer="{ close }">
<div :class="$style.footer">
@@ -101,8 +442,14 @@ const onSubmit = () => {
@click="close"
/>
<n8n-button
:disabled="!selectedFolder"
:label="i18n.baseText('folders.move.modal.confirm')"
:disabled="!selectedFolder && isFolderSelectable"
:label="
i18n.baseText('folders.move.modal.confirm', {
interpolate: {
resourceTypeLabel: resourceTypeLabel,
},
})
"
float="right"
data-test-id="confirm-move-folder-button"
@click="onSubmit"
@@ -124,9 +471,21 @@ const onSubmit = () => {
margin: var(--spacing-s) 0;
}
.block {
margin-bottom: var(--spacing-s);
}
.footer {
display: flex;
gap: var(--spacing-2xs);
justify-content: flex-end;
}
.tooltipText {
text-decoration: underline;
}
.credentialsCallout {
margin-top: var(--spacing-s);
}
</style>

View File

@@ -98,6 +98,13 @@ function handleEnter() {
emit('enter');
}
function onOpened() {
// Triggers when the Dialog opening animation ends.
// This can be helpful at positioning dropdowns etc correctly,
// as the dialog doesn't now move anymore at this point.
props.eventBus?.emit('opened');
}
function onWindowKeydown(event: KeyboardEvent) {
if (event?.keyCode === 13) handleEnter();
}
@@ -150,6 +157,7 @@ function getCustomClass() {
:data-test-id="`${name}-modal`"
:modal-class="center ? $style.center : ''"
:z-index="APP_Z_INDEXES.MODALS"
@opened="onOpened"
>
<template v-if="$slots.header" #header>
<slot v-if="!loading" name="header" />

View File

@@ -1,8 +1,7 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from '@/composables/useI18n';
import { ResourceType } from '@/utils/projects.utils';
import { splitName } from '@/utils/projects.utils';
import { ResourceType, splitName } from '@/utils/projects.utils';
import type { Project, ProjectIcon as BadgeIcon } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import type {
@@ -36,6 +35,10 @@ const props = withDefaults(defineProps<Props>(), {
const i18n = useI18n();
const isShared = computed(() => {
return 'sharedWithProjects' in props.resource && props.resource.sharedWithProjects?.length;
});
const projectState = computed(() => {
if (
(props.resource.homeProject &&
@@ -43,17 +46,17 @@ const projectState = computed(() => {
props.resource.homeProject.id === props.personalProject.id) ||
!props.resource.homeProject
) {
if (props.resource.sharedWithProjects?.length) {
if (isShared.value) {
return ProjectState.SharedOwned;
}
return ProjectState.Owned;
} else if (props.resource.homeProject?.type !== ProjectTypes.Team) {
if (props.resource.sharedWithProjects?.length) {
if (isShared.value) {
return ProjectState.SharedPersonal;
}
return ProjectState.Personal;
} else if (props.resource.homeProject?.type === ProjectTypes.Team) {
if (props.resource.sharedWithProjects?.length) {
if (isShared.value) {
return ProjectState.SharedTeam;
}
return ProjectState.Team;
@@ -61,8 +64,8 @@ const projectState = computed(() => {
return ProjectState.Unknown;
});
const numberOfMembersInHomeTeamProject = computed(
() => props.resource.sharedWithProjects?.length ?? 0,
const numberOfMembersInHomeTeamProject = computed(() =>
'sharedWithProjects' in props.resource ? (props.resource.sharedWithProjects?.length ?? 0) : 0,
);
const badgeText = computed(() => {

View File

@@ -235,6 +235,7 @@ describe('ProjectMoveResourceModal', () => {
'workflow',
movedWorkflow.id,
destinationProject.id,
undefined,
['1', '2'],
);
});

View File

@@ -7,7 +7,12 @@ import { useUIStore } from '@/stores/ui.store';
import { useProjectsStore } from '@/stores/projects.store';
import Modal from '@/components/Modal.vue';
import { VIEWS } from '@/constants';
import { ResourceType, splitName } from '@/utils/projects.utils';
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';
@@ -88,10 +93,9 @@ const selectedProject = computed(() =>
const isResourceInTeamProject = computed(() => isHomeProjectTeam(props.data.resource));
const isResourceWorkflow = computed(() => props.data.resourceType === ResourceType.Workflow);
const targetProjectName = computed(() => {
const { name, email } = splitName(selectedProject.value?.name ?? '');
return truncate(name ?? email ?? '', 25);
return getTruncatedProjectName(selectedProject.value?.name);
});
const resourceName = computed(() => truncate(props.data.resource.name, 25));
const resourceName = computed(() => truncate(props.data.resource.name, MAX_NAME_LENGTH));
const isHomeProjectTeam = (resource: IWorkflowDb | ICredentialsResponse) =>
resource.homeProject?.type === ProjectTypes.Team;
@@ -120,6 +124,7 @@ const moveResource = async () => {
props.data.resourceType,
props.data.resource.id,
selectedProject.value.id,
undefined,
shareUsedCredentials.value ? shareableCredentials.value.map((c) => c.id) : undefined,
);
closeModal();

View File

@@ -1,7 +1,6 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { truncate } from '@n8n/utils/string/truncate';
import { ResourceType, splitName } from '@/utils/projects.utils';
import { ResourceType, getTruncatedProjectName } from '@/utils/projects.utils';
import type { ProjectListItem } from '@/types/projects.types';
import { ProjectTypes } from '@/types/projects.types';
import { useI18n } from '@/composables/useI18n';
@@ -19,8 +18,7 @@ const i18n = useI18n();
const isWorkflow = computed(() => props.resourceType === ResourceType.Workflow);
const isTargetProjectTeam = computed(() => props.targetProject.type === ProjectTypes.Team);
const targetProjectName = computed(() => {
const { name, email } = splitName(props.targetProject?.name ?? '');
return truncate(name ?? email ?? '', 25);
return getTruncatedProjectName(props.targetProject?.name);
});
</script>
<template>

View File

@@ -2,7 +2,12 @@
import type { AllRolesMap } from '@n8n/permissions';
import { computed, ref, watch } from 'vue';
import { useI18n } from '@/composables/useI18n';
import type { ProjectListItem, ProjectSharingData } from '@/types/projects.types';
import {
ProjectTypes,
type ProjectIcon as ProjectIconItem,
type ProjectListItem,
type ProjectSharingData,
} from '@/types/projects.types';
import ProjectSharingInfo from '@/components/Projects/ProjectSharingInfo.vue';
import { sortByProperty } from '@n8n/utils/sort/sortByProperty';
@@ -46,6 +51,19 @@ const filteredProjects = computed(() =>
),
);
const projectIcon = computed<ProjectIconItem>(() => {
const defaultIcon: ProjectIconItem = { type: 'icon', value: 'layer-group' };
const project = props.projects.find((p) => p.id === selectedProject.value);
if (project?.type === ProjectTypes.Personal) {
return { type: 'icon', value: 'user' };
} else if (project?.type === ProjectTypes.Team) {
return project.icon ?? defaultIcon;
}
return defaultIcon;
});
const setFilter = (query: string) => {
filter.value = query;
};
@@ -109,7 +127,10 @@ watch(
@update:model-value="onProjectSelected"
>
<template #prefix>
<n8n-icon icon="search" />
<N8nIcon v-if="projectIcon.type === 'icon'" :icon="projectIcon.value" color="text-dark" />
<N8nText v-else-if="projectIcon.type === 'emoji'" color="text-light" :class="$style.emoji">
{{ projectIcon.value }}
</N8nText>
</template>
<N8nOption
v-for="project in filteredProjects"
@@ -187,4 +208,8 @@ watch(
.projectRoleSelect {
width: auto;
}
.emoji {
font-size: var(--font-size-s);
}
</style>

View File

@@ -6,12 +6,13 @@ import { type MockedStore, mockedStore } from '@/__tests__/utils';
import { MODAL_CONFIRM, VIEWS } from '@/constants';
import WorkflowCard from '@/components/WorkflowCard.vue';
import type { IWorkflowDb } from '@/Interface';
import { useRouter } from 'vue-router';
import * as vueRouter from 'vue-router';
import { useProjectsStore } from '@/stores/projects.store';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createTestingPinia } from '@pinia/testing';
import { useSettingsStore } from '@/stores/settings.store';
vi.mock('vue-router', () => {
const push = vi.fn();
@@ -21,7 +22,10 @@ vi.mock('vue-router', () => {
push,
resolve,
}),
useRoute: () => ({}),
useRoute: () => ({
params: {},
location: {},
}),
RouterLink: vi.fn(),
};
});
@@ -65,15 +69,17 @@ const createWorkflow = (overrides = {}): IWorkflowDb => ({
describe('WorkflowCard', () => {
let windowOpenSpy: MockInstance;
let router: ReturnType<typeof useRouter>;
let router: ReturnType<typeof vueRouter.useRouter>;
let projectsStore: MockedStore<typeof useProjectsStore>;
let settingsStore: MockedStore<typeof useSettingsStore>;
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let message: ReturnType<typeof useMessage>;
let toast: ReturnType<typeof useToast>;
beforeEach(async () => {
router = useRouter();
router = vueRouter.useRouter();
projectsStore = mockedStore(useProjectsStore);
settingsStore = mockedStore(useSettingsStore);
workflowsStore = mockedStore(useWorkflowsStore);
message = useMessage();
toast = useToast();
@@ -178,8 +184,12 @@ describe('WorkflowCard', () => {
expect(badge).toHaveTextContent('John Doe');
});
it('should show Move action only if there is resource permission and team projects available', async () => {
it("should show 'Move' action if there is move resource permission and team projects available", async () => {
vi.spyOn(projectsStore, 'isTeamProjectFeatureEnabled', 'get').mockReturnValue(true);
vi.spyOn(settingsStore, 'isFoldersFeatureEnabled', 'get').mockReturnValue(true);
vi.spyOn(vueRouter, 'useRoute').mockReturnValueOnce({
name: VIEWS.PROJECTS,
} as vueRouter.RouteLocationNormalizedLoadedGeneric);
const data = createWorkflow({
scopes: ['workflow:move'],
@@ -199,7 +209,93 @@ describe('WorkflowCard', () => {
if (!actions) {
throw new Error('Actions menu not found');
}
expect(actions).toHaveTextContent('Change owner');
expect(actions).toHaveTextContent('Move');
});
it("should show 'Move' action if there is update resource permission and folders available", async () => {
vi.spyOn(settingsStore, 'isFoldersFeatureEnabled', 'get').mockReturnValue(true);
vi.spyOn(vueRouter, 'useRoute').mockReturnValueOnce({
name: VIEWS.PROJECTS,
} as vueRouter.RouteLocationNormalizedLoadedGeneric);
const data = createWorkflow({
scopes: ['workflow:update'],
});
const { getByTestId } = renderComponent({ props: { data } });
const cardActions = getByTestId('workflow-card-actions');
expect(cardActions).toBeInTheDocument();
const cardActionsOpener = within(cardActions).getByRole('button');
expect(cardActionsOpener).toBeInTheDocument();
const controllingId = cardActionsOpener.getAttribute('aria-controls');
await userEvent.click(cardActions);
const actions = document.querySelector(`#${controllingId}`);
if (!actions) {
throw new Error('Actions menu not found');
}
expect(actions).toHaveTextContent('Move');
});
it("should not show 'Move' action on the 'Shared with you' page", async () => {
vi.spyOn(settingsStore, 'isFoldersFeatureEnabled', 'get').mockReturnValue(true);
const data = createWorkflow({
scopes: ['workflow:update'],
});
vi.spyOn(vueRouter, 'useRoute').mockReturnValueOnce({
name: VIEWS.SHARED_WORKFLOWS,
} as vueRouter.RouteLocationNormalizedLoadedGeneric);
const { getByTestId } = renderComponent({ props: { data } });
const cardActions = getByTestId('workflow-card-actions');
expect(cardActions).toBeInTheDocument();
const cardActionsOpener = within(cardActions).getByRole('button');
expect(cardActionsOpener).toBeInTheDocument();
const controllingId = cardActionsOpener.getAttribute('aria-controls');
await userEvent.click(cardActions);
const actions = document.querySelector(`#${controllingId}`);
if (!actions) {
throw new Error('Actions menu not found');
}
expect(actions).not.toHaveTextContent('Move');
});
it("should not show 'Move' action on the 'Workflows' page", async () => {
vi.spyOn(settingsStore, 'isFoldersFeatureEnabled', 'get').mockReturnValue(true);
const data = createWorkflow({
scopes: ['workflow:update'],
});
vi.spyOn(vueRouter, 'useRoute').mockReturnValueOnce({
name: VIEWS.WORKFLOWS,
} as vueRouter.RouteLocationNormalizedLoadedGeneric);
const { getByTestId } = renderComponent({ props: { data } });
const cardActions = getByTestId('workflow-card-actions');
expect(cardActions).toBeInTheDocument();
const cardActionsOpener = within(cardActions).getByRole('button');
expect(cardActionsOpener).toBeInTheDocument();
const controllingId = cardActionsOpener.getAttribute('aria-controls');
await userEvent.click(cardActions);
const actions = document.querySelector(`#${controllingId}`);
if (!actions) {
throw new Error('Actions menu not found');
}
expect(actions).not.toHaveTextContent('Move');
});
it("should have 'Archive' action on non archived nonactive workflows", async () => {

View File

@@ -26,7 +26,7 @@ import { ResourceType } from '@/utils/projects.utils';
import type { EventBus } from '@n8n/utils/event-bus';
import type { WorkflowResource } from './layouts/ResourcesListLayout.vue';
import type { IUser } from 'n8n-workflow';
import { ProjectTypes } from '@/types/projects.types';
import { type ProjectSharingData, ProjectTypes } from '@/types/projects.types';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { useFoldersStore } from '@/stores/folders.store';
@@ -62,7 +62,14 @@ const emit = defineEmits<{
'workflow:archived': [];
'workflow:unarchived': [];
'workflow:active-toggle': [value: { id: string; active: boolean }];
'action:move-to-folder': [value: { id: string; name: string; parentFolderId?: string }];
'action:move-to-folder': [
value: {
id: string;
name: string;
parentFolderId?: string;
sharedWithProjects?: ProjectSharingData[];
},
];
}>();
const toast = useToast();
@@ -140,20 +147,18 @@ const actions = computed(() => {
});
}
if (workflowPermissions.value.update && showFolders.value && !props.readOnly) {
if (
((workflowPermissions.value.update && !props.readOnly) ||
(workflowPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled)) &&
showFolders.value &&
route.name !== VIEWS.SHARED_WORKFLOWS
) {
items.push({
label: locale.baseText('folders.actions.moveToFolder'),
value: WORKFLOW_LIST_ITEM_ACTIONS.MOVE_TO_FOLDER,
});
}
if (workflowPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) {
items.push({
label: locale.baseText('workflows.item.changeOwner'),
value: WORKFLOW_LIST_ITEM_ACTIONS.MOVE,
});
}
if (workflowPermissions.value.delete && !props.readOnly) {
if (!props.data.isArchived) {
items.push({
@@ -263,6 +268,7 @@ async function onAction(action: string) {
id: props.data.id,
name: props.data.name,
parentFolderId: props.data.parentFolder?.id,
sharedWithProjects: props.data.sharedWithProjects,
});
break;
}