mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
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:
@@ -373,7 +373,6 @@ export type BaseFolderItem = BaseResource & {
|
||||
subFolderCount: number;
|
||||
parentFolder?: ResourceParentFolder;
|
||||
homeProject?: ProjectSharingData;
|
||||
sharedWithProjects?: ProjectSharingData[];
|
||||
tags?: ITag[];
|
||||
};
|
||||
|
||||
@@ -387,12 +386,16 @@ export interface FolderListItem extends BaseFolderItem {
|
||||
resource: 'folder';
|
||||
}
|
||||
|
||||
export interface ChangeLocationSearchResult extends BaseFolderItem {
|
||||
resource: 'folder' | 'project';
|
||||
export interface ChangeLocationSearchResponseItem extends BaseFolderItem {
|
||||
path: string[];
|
||||
}
|
||||
|
||||
export type FolderPathItem = PathItem & { parentFolder?: string };
|
||||
|
||||
export interface ChangeLocationSearchResult extends ChangeLocationSearchResponseItem {
|
||||
resource: 'folder' | 'project';
|
||||
}
|
||||
|
||||
export type WorkflowListResource = WorkflowListItem | FolderListItem;
|
||||
|
||||
export type FolderCreateResponse = Omit<
|
||||
|
||||
@@ -23,3 +23,23 @@ export async function moveWorkflowToProject(
|
||||
): Promise<void> {
|
||||
return await makeRestApiRequest(context, 'PUT', `/workflows/${id}/transfer`, body);
|
||||
}
|
||||
|
||||
export async function moveFolderToProject(
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
folderId: string,
|
||||
destinationProjectId: string,
|
||||
destinationParentFolderId?: string,
|
||||
shareCredentials?: string[],
|
||||
): Promise<void> {
|
||||
return await makeRestApiRequest(
|
||||
context,
|
||||
'PUT',
|
||||
`/projects/${projectId}/folders/${folderId}/transfer`,
|
||||
{
|
||||
destinationProjectId,
|
||||
destinationParentFolderId: destinationParentFolderId ?? '0',
|
||||
shareCredentials,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type {
|
||||
ChangeLocationSearchResult,
|
||||
ChangeLocationSearchResponseItem,
|
||||
FolderCreateResponse,
|
||||
FolderTreeResponseItem,
|
||||
IExecutionResponse,
|
||||
IExecutionsCurrentSummaryExtended,
|
||||
IRestApiContext,
|
||||
IUsedCredential,
|
||||
IWorkflowDb,
|
||||
NewWorkflowResponse,
|
||||
WorkflowListResource,
|
||||
@@ -146,16 +147,34 @@ export async function getProjectFolders(
|
||||
excludeFolderIdAndDescendants?: string;
|
||||
name?: string;
|
||||
},
|
||||
): Promise<ChangeLocationSearchResult[]> {
|
||||
const res = await getFullApiResponse<ChangeLocationSearchResult[]>(
|
||||
select?: string[],
|
||||
): Promise<{ data: ChangeLocationSearchResponseItem[]; count: number }> {
|
||||
const res = await getFullApiResponse<ChangeLocationSearchResponseItem[]>(
|
||||
context,
|
||||
'GET',
|
||||
`/projects/${projectId}/folders`,
|
||||
{
|
||||
...(filter ? { filter } : {}),
|
||||
...(options ? options : {}),
|
||||
...(select ? { select: JSON.stringify(select) } : {}),
|
||||
},
|
||||
);
|
||||
return {
|
||||
data: res.data,
|
||||
count: res.count,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFolderUsedCredentials(
|
||||
context: IRestApiContext,
|
||||
projectId: string,
|
||||
folderId: string,
|
||||
): Promise<IUsedCredential[]> {
|
||||
const res = await getFullApiResponse<IUsedCredential[]>(
|
||||
context,
|
||||
'GET',
|
||||
`/projects/${projectId}/folders/${folderId}/credentials`,
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -235,6 +235,7 @@ describe('ProjectMoveResourceModal', () => {
|
||||
'workflow',
|
||||
movedWorkflow.id,
|
||||
destinationProject.id,
|
||||
undefined,
|
||||
['1', '2'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -927,7 +927,7 @@
|
||||
"forms.resourceFiltersDropdown.reset": "Reset all",
|
||||
"folders.actions.create": "Create folder inside",
|
||||
"folders.actions.create.workflow": "Create workflow inside",
|
||||
"folders.actions.moveToFolder": "Move to folder",
|
||||
"folders.actions.moveToFolder": "Move",
|
||||
"folders.add": "Add folder",
|
||||
"folders.add.here.message": "Create a new folder here",
|
||||
"folders.add.to.parent.message": "Create folder in \"{parent}\"",
|
||||
@@ -961,18 +961,26 @@
|
||||
"folders.rename.success.message": "Folder renamed",
|
||||
"folders.rename.placeholder": "Enter new folder name",
|
||||
"folders.not.found.message": "Folder not found",
|
||||
"folders.move.modal.title": "Move \"{folderName}\" to another folder",
|
||||
"folders.move.modal.description": "Note: Moving this folder will also move all workflows and subfolders within it.",
|
||||
"folders.move.modal.select.placeholder": "Search for a folder",
|
||||
"folders.move.modal.confirm": "Move to folder",
|
||||
"folders.move.modal.no.data.label": "No folders found",
|
||||
"folders.move.modal.folder.count": "{count} folder | {count} folders",
|
||||
"folders.move.modal.workflow.count": "{count} workflow | {count} workflows",
|
||||
"folders.move.modal.title": "Move {resourceTypeLabel} {folderName}",
|
||||
"folders.move.modal.description": "This will also move{folders}{workflows}.",
|
||||
"folders.move.modal.confirm": "Move {resourceTypeLabel}",
|
||||
"folders.move.modal.project.label": "Project or user",
|
||||
"folders.move.modal.project.placeholder": "Select a project or user",
|
||||
"folders.move.modal.folder.label": "Folder",
|
||||
"folders.move.modal.folder.placeholder": "Select a folder",
|
||||
"folders.move.modal.folder.noData.label": "No folders found",
|
||||
"folders.move.modal.message.usedCredentials.workflow": "Also share the {usedCredentials} used by this workflow to ensure it will continue to run correctly",
|
||||
"folders.move.modal.message.usedCredentials.folder": "Also share the {usedCredentials} used by these workflows to keep them running correctly",
|
||||
"folders.move.modal.message.usedCredentials.warning": "Workflow may not execute correctly if you choose not to share the credentials.",
|
||||
"folders.move.success.title": "Successfully moved folder",
|
||||
"folders.move.success.message": "<b>{folderName}</b> has been moved to <b>{newFolderName}</b>, along with all its workflows and subfolders.<br/><br/><a href=\"{link}\">View {newFolderName}</a>",
|
||||
"folders.move.error.title": "Problem moving folder",
|
||||
"folders.move.workflow.error.title": "Problem moving workflow",
|
||||
"folders.move.workflow.success.title": "Successfully moved workflow",
|
||||
"folders.move.workflow.success.message": "<b>{workflowName}</b> has been moved to <b>{newFolderName}</b>.<br/><br/><a href=\"{link}\">View {newFolderName}</a>",
|
||||
"folders.move.project.root.name": "{projectName} (Project root)",
|
||||
"folders.move.project.root.name": "No folder (project root)",
|
||||
"folders.open.error.title": "Problem opening folder",
|
||||
"folders.create.error.title": "Problem creating folder",
|
||||
"folders.empty.actionbox.title": "Nothing in folder \"{folderName}\" yet",
|
||||
|
||||
208
packages/frontend/editor-ui/src/stores/folders.store.test.ts
Normal file
208
packages/frontend/editor-ui/src/stores/folders.store.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { vi } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { faker } from '@faker-js/faker';
|
||||
|
||||
import { useFoldersStore } from '@/stores/folders.store';
|
||||
import * as workflowsApi from '@/api/workflows';
|
||||
import type { ChangeLocationSearchResponseItem, IUsedCredential } from '@/Interface';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
|
||||
vi.mock('@/utils/apiUtils', () => ({
|
||||
makeRestApiRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
const createFolder = (
|
||||
overrides: Partial<ChangeLocationSearchResponseItem> = {},
|
||||
): ChangeLocationSearchResponseItem => ({
|
||||
createdAt: faker.date.recent().toISOString(),
|
||||
updatedAt: faker.date.recent().toISOString(),
|
||||
id: faker.string.alphanumeric(10),
|
||||
name: faker.lorem.words(3),
|
||||
tags: [],
|
||||
parentFolder: {
|
||||
id: faker.string.alphanumeric(10),
|
||||
name: faker.lorem.words(2),
|
||||
parentFolderId: null,
|
||||
},
|
||||
workflowCount: 2,
|
||||
subFolderCount: 2,
|
||||
path: [faker.lorem.word(), faker.lorem.word()],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('folders.store', () => {
|
||||
let foldersStore: ReturnType<typeof useFoldersStore>;
|
||||
let rootStore: ReturnType<typeof useRootStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
rootStore = useRootStore();
|
||||
foldersStore = useFoldersStore();
|
||||
});
|
||||
|
||||
describe('fetchFoldersAvailableForMove', () => {
|
||||
const projectId = faker.string.alphanumeric(10);
|
||||
const folderId = faker.string.alphanumeric(10);
|
||||
const selectFields = [
|
||||
'id',
|
||||
'name',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'project',
|
||||
'tags',
|
||||
'parentFolder',
|
||||
'workflowCount',
|
||||
'subFolderCount',
|
||||
'path',
|
||||
];
|
||||
|
||||
it('should fetch folders in a single page, empty filter', async () => {
|
||||
const folder = createFolder();
|
||||
vi.spyOn(workflowsApi, 'getProjectFolders').mockResolvedValue({
|
||||
count: 1,
|
||||
data: [folder],
|
||||
});
|
||||
|
||||
const available = await foldersStore.fetchFoldersAvailableForMove(projectId, folderId);
|
||||
expect(available).toEqual([{ ...folder, resource: 'folder' }]);
|
||||
expect(workflowsApi.getProjectFolders).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsApi.getProjectFolders).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
projectId,
|
||||
{
|
||||
skip: 0,
|
||||
take: 100,
|
||||
sortBy: 'updatedAt:desc',
|
||||
},
|
||||
{
|
||||
excludeFolderIdAndDescendants: folderId,
|
||||
},
|
||||
selectFields,
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch folders in a single page with filter', async () => {
|
||||
const folder = createFolder({ name: 'Test Folder' });
|
||||
vi.spyOn(workflowsApi, 'getProjectFolders').mockResolvedValue({
|
||||
count: 1,
|
||||
data: [folder],
|
||||
});
|
||||
|
||||
const available = await foldersStore.fetchFoldersAvailableForMove(projectId, folderId, {
|
||||
name: 'Test',
|
||||
});
|
||||
expect(available).toEqual([{ ...folder, resource: 'folder' }]);
|
||||
expect(workflowsApi.getProjectFolders).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsApi.getProjectFolders).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
projectId,
|
||||
{
|
||||
skip: 0,
|
||||
take: 100,
|
||||
sortBy: 'updatedAt:desc',
|
||||
},
|
||||
{
|
||||
excludeFolderIdAndDescendants: folderId,
|
||||
name: 'Test',
|
||||
},
|
||||
selectFields,
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch folders in multiple pages', async () => {
|
||||
const folders = Array.from({ length: 150 }, (_, i) =>
|
||||
createFolder({ name: `Folder ${i + 1}` }),
|
||||
);
|
||||
vi.spyOn(workflowsApi, 'getProjectFolders')
|
||||
.mockResolvedValueOnce({
|
||||
count: 150,
|
||||
data: folders.slice(0, 100),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
count: 150,
|
||||
data: folders.slice(100),
|
||||
});
|
||||
|
||||
const available = await foldersStore.fetchFoldersAvailableForMove(projectId, folderId, {
|
||||
name: 'Test',
|
||||
});
|
||||
|
||||
expect(available).toHaveLength(150);
|
||||
expect(workflowsApi.getProjectFolders).toHaveBeenCalledTimes(2);
|
||||
expect(workflowsApi.getProjectFolders).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
rootStore.restApiContext,
|
||||
projectId,
|
||||
{
|
||||
skip: 0,
|
||||
take: 100,
|
||||
sortBy: 'updatedAt:desc',
|
||||
},
|
||||
{
|
||||
excludeFolderIdAndDescendants: folderId,
|
||||
name: 'Test',
|
||||
},
|
||||
selectFields,
|
||||
);
|
||||
expect(workflowsApi.getProjectFolders).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
rootStore.restApiContext,
|
||||
projectId,
|
||||
{
|
||||
skip: 100,
|
||||
take: 100,
|
||||
sortBy: 'updatedAt:desc',
|
||||
},
|
||||
{
|
||||
excludeFolderIdAndDescendants: folderId,
|
||||
name: 'Test',
|
||||
},
|
||||
selectFields,
|
||||
);
|
||||
});
|
||||
|
||||
it('should cache the results on breadcrumbs cache', async () => {
|
||||
const folder = createFolder();
|
||||
vi.spyOn(workflowsApi, 'getProjectFolders').mockResolvedValue({
|
||||
count: 1,
|
||||
data: [folder],
|
||||
});
|
||||
|
||||
const available = await foldersStore.fetchFoldersAvailableForMove(projectId, folderId);
|
||||
expect(available).toEqual([{ ...folder, resource: 'folder' }]);
|
||||
expect(foldersStore.breadcrumbsCache).toEqual({
|
||||
[folder.id]: {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentFolder: folder.parentFolder?.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchFolderUsedCredentials', () => {
|
||||
const projectId = faker.string.alphanumeric(10);
|
||||
const folderId = faker.string.alphanumeric(10);
|
||||
|
||||
it('should fetch credentials used within a folder', async () => {
|
||||
const usedCredential: IUsedCredential = {
|
||||
id: faker.string.alphanumeric(10),
|
||||
name: faker.lorem.words(2),
|
||||
credentialType: faker.lorem.word(),
|
||||
currentUserHasAccess: true,
|
||||
sharedWithProjects: [],
|
||||
};
|
||||
|
||||
vi.spyOn(workflowsApi, 'getFolderUsedCredentials').mockResolvedValue([usedCredential]);
|
||||
|
||||
const credentials = await foldersStore.fetchFolderUsedCredentials(projectId, folderId);
|
||||
|
||||
expect(credentials).toEqual([usedCredential]);
|
||||
expect(workflowsApi.getFolderUsedCredentials).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
projectId,
|
||||
folderId,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,10 @@ import type {
|
||||
FolderCreateResponse,
|
||||
FolderShortInfo,
|
||||
FolderTreeResponseItem,
|
||||
IUsedCredential,
|
||||
} from '@/Interface';
|
||||
import * as workflowsApi from '@/api/workflows';
|
||||
import * as workflowsEEApi from '@/api/workflows.ee';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
@@ -131,24 +133,82 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
||||
name?: string;
|
||||
},
|
||||
): Promise<ChangeLocationSearchResult[]> {
|
||||
const folders = await workflowsApi.getProjectFolders(
|
||||
rootStore.restApiContext,
|
||||
projectId,
|
||||
{
|
||||
sortBy: 'updatedAt:desc',
|
||||
},
|
||||
{
|
||||
excludeFolderIdAndDescendants: folderId,
|
||||
name: filter?.name ? filter.name : undefined,
|
||||
},
|
||||
);
|
||||
const forCache: FolderShortInfo[] = folders.map((folder) => ({
|
||||
const PAGE_SIZE = 100;
|
||||
let skip = 0;
|
||||
let totalAvailable = 0;
|
||||
let allFolders: ChangeLocationSearchResult[] = [];
|
||||
|
||||
do {
|
||||
const { data: folders, count } = await workflowsApi.getProjectFolders(
|
||||
rootStore.restApiContext,
|
||||
projectId,
|
||||
{
|
||||
skip,
|
||||
take: PAGE_SIZE,
|
||||
sortBy: 'updatedAt:desc',
|
||||
},
|
||||
{
|
||||
excludeFolderIdAndDescendants: folderId,
|
||||
name: filter?.name,
|
||||
},
|
||||
[
|
||||
'id',
|
||||
'name',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'project',
|
||||
'tags',
|
||||
'parentFolder',
|
||||
'workflowCount',
|
||||
'subFolderCount',
|
||||
'path',
|
||||
],
|
||||
);
|
||||
|
||||
allFolders = allFolders.concat(
|
||||
folders.map((folder) => ({
|
||||
...folder,
|
||||
resource: 'folder',
|
||||
})),
|
||||
);
|
||||
totalAvailable = count;
|
||||
skip += folders.length;
|
||||
} while (allFolders.length < totalAvailable && skip < totalAvailable);
|
||||
|
||||
const forCache: FolderShortInfo[] = allFolders.map((folder) => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentFolder: folder.parentFolder?.id,
|
||||
}));
|
||||
cacheFolders(forCache);
|
||||
return folders;
|
||||
|
||||
allFolders.sort((a, b) => {
|
||||
// Shorter paths first
|
||||
if (a.path.length !== b.path.length) return a.path.length - b.path.length;
|
||||
|
||||
for (let i = 0; i < a.path.length; i++) {
|
||||
// Each segment of the path is compared
|
||||
const cmp = a.path[i].localeCompare(b.path[i]);
|
||||
if (cmp !== 0) return cmp;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
return allFolders;
|
||||
}
|
||||
|
||||
async function fetchFolderUsedCredentials(
|
||||
projectId: string,
|
||||
folderId: string,
|
||||
): Promise<IUsedCredential[]> {
|
||||
const usedCredentials = await workflowsApi.getFolderUsedCredentials(
|
||||
rootStore.restApiContext,
|
||||
projectId,
|
||||
folderId,
|
||||
);
|
||||
|
||||
return usedCredentials;
|
||||
}
|
||||
|
||||
async function moveFolder(
|
||||
@@ -159,12 +219,26 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
||||
await workflowsApi.moveFolder(rootStore.restApiContext, projectId, folderId, parentFolderId);
|
||||
// Update the cache after moving the folder
|
||||
delete breadcrumbsCache.value[folderId];
|
||||
if (parentFolderId) {
|
||||
const folder = breadcrumbsCache.value[folderId];
|
||||
if (folder) {
|
||||
folder.parentFolder = parentFolderId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFolderToProject(
|
||||
projectId: string,
|
||||
folderId: string,
|
||||
destinationProjectId: string,
|
||||
destinationParentFolderId?: string,
|
||||
shareCredentials?: string[],
|
||||
): Promise<void> {
|
||||
await workflowsEEApi.moveFolderToProject(
|
||||
rootStore.restApiContext,
|
||||
projectId,
|
||||
folderId,
|
||||
destinationProjectId,
|
||||
destinationParentFolderId,
|
||||
shareCredentials,
|
||||
);
|
||||
|
||||
// Update the cache after moving the folder
|
||||
delete breadcrumbsCache.value[folderId];
|
||||
}
|
||||
|
||||
async function fetchFolderContent(
|
||||
@@ -280,9 +354,11 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
||||
fetchProjectFolders,
|
||||
fetchFoldersAvailableForMove,
|
||||
moveFolder,
|
||||
moveFolderToProject,
|
||||
fetchFolderContent,
|
||||
getHiddenBreadcrumbsItems,
|
||||
draggedElement,
|
||||
activeDropTarget,
|
||||
fetchFolderUsedCredentials,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -172,11 +172,13 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
|
||||
resourceType: 'workflow' | 'credential',
|
||||
resourceId: string,
|
||||
projectId: string,
|
||||
parentFolderId?: string,
|
||||
shareCredentials?: string[],
|
||||
) => {
|
||||
if (resourceType === 'workflow') {
|
||||
await workflowsEEApi.moveWorkflowToProject(rootStore.restApiContext, resourceId, {
|
||||
destinationProjectId: projectId,
|
||||
destinationParentFolderId: parentFolderId,
|
||||
shareCredentials,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -71,6 +71,7 @@ import { computed, ref } from 'vue';
|
||||
import type { Connection } from '@vue-flow/core';
|
||||
import { useLocalStorage } from '@vueuse/core';
|
||||
import type { EventBus } from '@n8n/utils/event-bus';
|
||||
import type { ProjectSharingData } from '@/types/projects.types';
|
||||
|
||||
let savedTheme: ThemeOption = 'system';
|
||||
|
||||
@@ -454,7 +455,12 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||
|
||||
const openMoveToFolderModal = (
|
||||
resourceType: 'folder' | 'workflow',
|
||||
resource: { id: string; name: string; parentFolderId?: string },
|
||||
resource: {
|
||||
id: string;
|
||||
name: string;
|
||||
parentFolderId?: string;
|
||||
sharedWithProjects?: ProjectSharingData[];
|
||||
},
|
||||
workflowListEventBus: EventBus,
|
||||
) => {
|
||||
openModalWithData({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { truncate } from '@n8n/utils/string/truncate';
|
||||
|
||||
// Splits a project name into first name, last name, and email when it is in the format "First Last <email@domain.com>"
|
||||
export const splitName = (
|
||||
projectName = '',
|
||||
@@ -11,6 +13,13 @@ export const splitName = (
|
||||
return { name: name.trim() || undefined, email };
|
||||
};
|
||||
|
||||
export const MAX_NAME_LENGTH = 25;
|
||||
|
||||
export const getTruncatedProjectName = (projectName: string | null | undefined): string => {
|
||||
const { name, email } = splitName(projectName ?? '');
|
||||
return truncate(name ?? email ?? '', MAX_NAME_LENGTH);
|
||||
};
|
||||
|
||||
export const enum ResourceType {
|
||||
Credential = 'credential',
|
||||
Workflow = 'workflow',
|
||||
|
||||
@@ -282,7 +282,6 @@ const workflowListResources = computed<Resource[]>(() => {
|
||||
createdAt: resource.createdAt.toString(),
|
||||
updatedAt: resource.updatedAt.toString(),
|
||||
homeProject: resource.homeProject,
|
||||
sharedWithProjects: resource.sharedWithProjects,
|
||||
workflowCount: resource.workflowCount,
|
||||
subFolderCount: resource.subFolderCount,
|
||||
parentFolder: resource.parentFolder,
|
||||
@@ -435,7 +434,9 @@ onMounted(async () => {
|
||||
workflowListEventBus.on('workflow-duplicated', fetchWorkflows);
|
||||
workflowListEventBus.on('folder-deleted', onFolderDeleted);
|
||||
workflowListEventBus.on('folder-moved', moveFolder);
|
||||
workflowListEventBus.on('folder-transferred', onFolderTransferred);
|
||||
workflowListEventBus.on('workflow-moved', onWorkflowMoved);
|
||||
workflowListEventBus.on('workflow-transferred', onWorkflowTransferred);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -443,7 +444,9 @@ onBeforeUnmount(() => {
|
||||
workflowListEventBus.off('workflow-duplicated', fetchWorkflows);
|
||||
workflowListEventBus.off('folder-deleted', onFolderDeleted);
|
||||
workflowListEventBus.off('folder-moved', moveFolder);
|
||||
workflowListEventBus.off('folder-transferred', onFolderTransferred);
|
||||
workflowListEventBus.off('workflow-moved', onWorkflowMoved);
|
||||
workflowListEventBus.off('workflow-transferred', onWorkflowTransferred);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -1082,7 +1085,6 @@ const createFolder = async (
|
||||
createdAt: newFolder.createdAt,
|
||||
updatedAt: newFolder.updatedAt,
|
||||
homeProject: projectsStore.currentProject as ProjectSharingData,
|
||||
sharedWithProjects: [],
|
||||
workflowCount: 0,
|
||||
subFolderCount: 0,
|
||||
},
|
||||
@@ -1184,7 +1186,7 @@ const moveFolder = async (payload: {
|
||||
await foldersStore.moveFolder(
|
||||
route.params.projectId as string,
|
||||
payload.folder.id,
|
||||
payload.newParent.type === 'project' ? '0' : payload.newParent.id,
|
||||
payload.newParent.type === 'folder' ? payload.newParent.id : '0',
|
||||
);
|
||||
const isCurrentFolder = currentFolderId.value === payload.folder.id;
|
||||
|
||||
@@ -1192,7 +1194,7 @@ const moveFolder = async (payload: {
|
||||
name: VIEWS.PROJECTS_FOLDERS,
|
||||
params: {
|
||||
projectId: route.params.projectId,
|
||||
folderId: payload.newParent.type === 'project' ? undefined : payload.newParent.id,
|
||||
folderId: payload.newParent.type === 'folder' ? payload.newParent.id : undefined,
|
||||
},
|
||||
}).href;
|
||||
if (isCurrentFolder && !payload.options?.skipNavigation) {
|
||||
@@ -1203,7 +1205,10 @@ const moveFolder = async (payload: {
|
||||
toast.showToast({
|
||||
title: i18n.baseText('folders.move.success.title'),
|
||||
message: i18n.baseText('folders.move.success.message', {
|
||||
interpolate: { folderName: payload.folder.name, newFolderName: payload.newParent.name },
|
||||
interpolate: {
|
||||
folderName: payload.folder.name,
|
||||
newFolderName: payload.newParent.name,
|
||||
},
|
||||
}),
|
||||
onClick: (event: MouseEvent | undefined) => {
|
||||
if (event?.target instanceof HTMLAnchorElement) {
|
||||
@@ -1222,10 +1227,64 @@ const moveFolder = async (payload: {
|
||||
}
|
||||
};
|
||||
|
||||
const onFolderTransferred = async (payload: {
|
||||
folder: { id: string; name: string };
|
||||
projectId: string;
|
||||
destinationProjectId: string;
|
||||
newParent: { id: string; name: string; type: 'folder' | 'project' };
|
||||
shareCredentials?: string[];
|
||||
}) => {
|
||||
const destinationParentFolderId =
|
||||
payload.newParent.type === 'folder' ? payload.newParent.id : undefined;
|
||||
|
||||
await foldersStore.moveFolderToProject(
|
||||
payload.projectId,
|
||||
payload.folder.id,
|
||||
payload.destinationProjectId,
|
||||
destinationParentFolderId,
|
||||
payload.shareCredentials,
|
||||
);
|
||||
|
||||
const isCurrentFolder = currentFolderId.value === payload.folder.id;
|
||||
const newFolderURL = router.resolve({
|
||||
name: VIEWS.PROJECTS_FOLDERS,
|
||||
params: {
|
||||
projectId: payload.destinationProjectId,
|
||||
folderId: destinationParentFolderId,
|
||||
},
|
||||
}).href;
|
||||
|
||||
if (isCurrentFolder) {
|
||||
// If we just moved the current folder, automatically navigate to the new folder
|
||||
void router.push(newFolderURL);
|
||||
} else {
|
||||
// Else show success message and update the list
|
||||
toast.showToast({
|
||||
title: i18n.baseText('folders.move.success.title'),
|
||||
message: i18n.baseText('folders.move.success.message', {
|
||||
interpolate: {
|
||||
folderName: payload.folder.name,
|
||||
newFolderName: payload.newParent.name,
|
||||
},
|
||||
}),
|
||||
onClick: (event: MouseEvent | undefined) => {
|
||||
if (event?.target instanceof HTMLAnchorElement) {
|
||||
event.preventDefault();
|
||||
void router.push(newFolderURL);
|
||||
}
|
||||
},
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
await fetchWorkflows();
|
||||
}
|
||||
};
|
||||
|
||||
const moveWorkflowToFolder = async (payload: {
|
||||
id: string;
|
||||
name: string;
|
||||
parentFolderId?: string;
|
||||
sharedWithProjects?: ProjectSharingData[];
|
||||
}) => {
|
||||
if (showRegisteredCommunityCTA.value) {
|
||||
uiStore.openModalWithData({
|
||||
@@ -1236,11 +1295,62 @@ const moveWorkflowToFolder = async (payload: {
|
||||
}
|
||||
uiStore.openMoveToFolderModal(
|
||||
'workflow',
|
||||
{ id: payload.id, name: payload.name, parentFolderId: payload.parentFolderId },
|
||||
{
|
||||
id: payload.id,
|
||||
name: payload.name,
|
||||
parentFolderId: payload.parentFolderId,
|
||||
sharedWithProjects: payload.sharedWithProjects,
|
||||
},
|
||||
workflowListEventBus,
|
||||
);
|
||||
};
|
||||
|
||||
const onWorkflowTransferred = async (payload: {
|
||||
projectId: string;
|
||||
workflow: { id: string; name: string; oldParentId: string };
|
||||
newParent: { id: string; name: string; type: 'folder' | 'project' };
|
||||
shareCredentials?: string[];
|
||||
}) => {
|
||||
const parentFolderId = payload.newParent.type === 'folder' ? payload.newParent.id : undefined;
|
||||
|
||||
await projectsStore.moveResourceToProject(
|
||||
'workflow',
|
||||
payload.workflow.id,
|
||||
payload.projectId,
|
||||
parentFolderId,
|
||||
payload.shareCredentials,
|
||||
);
|
||||
|
||||
await fetchWorkflows();
|
||||
|
||||
try {
|
||||
toast.showToast({
|
||||
title: i18n.baseText('folders.move.workflow.success.title'),
|
||||
message: i18n.baseText('folders.move.workflow.success.message', {
|
||||
interpolate: {
|
||||
workflowName: payload.workflow.name,
|
||||
newFolderName: payload.newParent.name,
|
||||
},
|
||||
}),
|
||||
onClick: (event: MouseEvent | undefined) => {
|
||||
if (event?.target instanceof HTMLAnchorElement) {
|
||||
event.preventDefault();
|
||||
void router.push({
|
||||
name: VIEWS.PROJECTS_FOLDERS,
|
||||
params: {
|
||||
projectId: payload.projectId,
|
||||
folderId: payload.newParent.type === 'folder' ? payload.newParent.id : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('folders.move.workflow.error.title'));
|
||||
}
|
||||
};
|
||||
|
||||
const onWorkflowMoved = async (payload: {
|
||||
workflow: { id: string; name: string; oldParentId: string };
|
||||
newParent: { id: string; name: string; type: 'folder' | 'project' };
|
||||
@@ -1254,14 +1364,14 @@ const onWorkflowMoved = async (payload: {
|
||||
name: VIEWS.PROJECTS_FOLDERS,
|
||||
params: {
|
||||
projectId: route.params.projectId,
|
||||
folderId: payload.newParent.type === 'project' ? undefined : payload.newParent.id,
|
||||
folderId: payload.newParent.type === 'folder' ? payload.newParent.id : undefined,
|
||||
},
|
||||
}).href;
|
||||
const workflowResource = workflowsAndFolders.value.find(
|
||||
(resource): resource is WorkflowListItem => resource.id === payload.workflow.id,
|
||||
);
|
||||
await workflowsStore.updateWorkflow(payload.workflow.id, {
|
||||
parentFolderId: payload.newParent.type === 'project' ? '0' : payload.newParent.id,
|
||||
parentFolderId: payload.newParent.type === 'folder' ? payload.newParent.id : '0',
|
||||
versionId: workflowResource?.versionId,
|
||||
});
|
||||
if (!payload.options?.skipFetch) {
|
||||
@@ -1270,7 +1380,10 @@ const onWorkflowMoved = async (payload: {
|
||||
toast.showToast({
|
||||
title: i18n.baseText('folders.move.workflow.success.title'),
|
||||
message: i18n.baseText('folders.move.workflow.success.message', {
|
||||
interpolate: { workflowName: payload.workflow.name, newFolderName: payload.newParent.name },
|
||||
interpolate: {
|
||||
workflowName: payload.workflow.name,
|
||||
newFolderName: payload.newParent.name,
|
||||
},
|
||||
}),
|
||||
onClick: (event: MouseEvent | undefined) => {
|
||||
if (event?.target instanceof HTMLAnchorElement) {
|
||||
|
||||
Reference in New Issue
Block a user