mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat(editor): Implement folder move functionality (no-changelog) (#13922)
This commit is contained in:
committed by
GitHub
parent
042aa39024
commit
1c17d12209
@@ -250,11 +250,11 @@ export interface IWorkflowDataUpdate {
|
|||||||
pinData?: IPinData;
|
pinData?: IPinData;
|
||||||
versionId?: string;
|
versionId?: string;
|
||||||
meta?: WorkflowMetadata;
|
meta?: WorkflowMetadata;
|
||||||
|
parentFolderId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkflowDataCreate extends IWorkflowDataUpdate {
|
export interface IWorkflowDataCreate extends IWorkflowDataUpdate {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
parentFolderId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -336,7 +336,7 @@ export type BaseResource = {
|
|||||||
|
|
||||||
export type WorkflowListItem = Omit<
|
export type WorkflowListItem = Omit<
|
||||||
IWorkflowDb,
|
IWorkflowDb,
|
||||||
'nodes' | 'connections' | 'settings' | 'pinData' | 'versionId' | 'usedCredentials' | 'meta'
|
'nodes' | 'connections' | 'settings' | 'pinData' | 'usedCredentials' | 'meta'
|
||||||
> & {
|
> & {
|
||||||
resource: 'workflow';
|
resource: 'workflow';
|
||||||
parentFolder?: { id: string; name: string };
|
parentFolder?: { id: string; name: string };
|
||||||
|
|||||||
@@ -137,6 +137,48 @@ export async function renameFolder(
|
|||||||
export async function getProjectFolders(
|
export async function getProjectFolders(
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
options?: {
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
},
|
||||||
|
filter?: {
|
||||||
|
excludeFolderIdAndDescendants?: string;
|
||||||
|
name?: string;
|
||||||
|
},
|
||||||
): Promise<FolderListItem[]> {
|
): Promise<FolderListItem[]> {
|
||||||
return await makeRestApiRequest(context, 'GET', `/projects/${projectId}/folders`);
|
const res = await getFullApiResponse<FolderListItem[]>(
|
||||||
|
context,
|
||||||
|
'GET',
|
||||||
|
`/projects/${projectId}/folders`,
|
||||||
|
{
|
||||||
|
...(filter ? { filter } : {}),
|
||||||
|
...(options ? options : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveFolder(
|
||||||
|
context: IRestApiContext,
|
||||||
|
projectId: string,
|
||||||
|
folderId: string,
|
||||||
|
parentFolderId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return await makeRestApiRequest(context, 'PATCH', `/projects/${projectId}/folders/${folderId}`, {
|
||||||
|
parentFolderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getFolderContent(
|
||||||
|
context: IRestApiContext,
|
||||||
|
projectId: string,
|
||||||
|
folderId: string,
|
||||||
|
): Promise<{ totalSubFolders: number; totalWorkflows: number }> {
|
||||||
|
const res = await getFullApiResponse<{ totalSubFolders: number; totalWorkflows: number }>(
|
||||||
|
context,
|
||||||
|
'GET',
|
||||||
|
`/projects/${projectId}/folders/${folderId}/content`,
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import Modal from '@/components/Modal.vue';
|
import Modal from '@/components/Modal.vue';
|
||||||
import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
|
import { createEventBus, type EventBus } from '@n8n/utils/event-bus';
|
||||||
@@ -7,6 +7,7 @@ import { useI18n } from '@/composables/useI18n';
|
|||||||
import { useFoldersStore } from '@/stores/folders.store';
|
import { useFoldersStore } from '@/stores/folders.store';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import type { FolderListItem } from '@/Interface';
|
import type { FolderListItem } from '@/Interface';
|
||||||
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modalName: string;
|
modalName: string;
|
||||||
@@ -26,34 +27,25 @@ const i18n = useI18n();
|
|||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const foldersStore = useFoldersStore();
|
const foldersStore = useFoldersStore();
|
||||||
|
const projectsStore = useProjectsStore();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const operation = ref('');
|
const operation = ref('');
|
||||||
const deleteConfirmText = ref('');
|
const deleteConfirmText = ref('');
|
||||||
const selectedFolderId = ref<string | null>(null);
|
const selectedFolder = ref<{ id: string; name: string } | null>(null);
|
||||||
const projectFolders = ref<FolderListItem[]>([]);
|
const projectFolders = ref<FolderListItem[]>([]);
|
||||||
|
|
||||||
const currentFolder = computed(() => {
|
const currentFolder = computed(() => {
|
||||||
return projectFolders.value.find((folder) => folder.id === props.activeId);
|
return projectFolders.value.find((folder) => folder.id === props.activeId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Available folders to transfer are all folders except the current folder, it's parent and its children
|
|
||||||
const availableFolders = computed(() => {
|
|
||||||
return projectFolders.value.filter(
|
|
||||||
(folder) =>
|
|
||||||
folder.id !== props.activeId &&
|
|
||||||
folder.parentFolder?.id !== props.activeId &&
|
|
||||||
folder.id !== currentFolder.value?.parentFolder?.id,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const folderToDelete = computed(() => {
|
const folderToDelete = computed(() => {
|
||||||
if (!props.activeId) return null;
|
if (!props.activeId) return null;
|
||||||
return foldersStore.breadcrumbsCache[props.activeId];
|
return foldersStore.breadcrumbsCache[props.activeId];
|
||||||
});
|
});
|
||||||
|
|
||||||
const isPending = computed(() => {
|
const isPending = computed(() => {
|
||||||
return folderToDelete.value ? !folderToDelete.value.name : false;
|
return selectedFolder.value ? !selectedFolder.value.name : false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const title = computed(() => {
|
const title = computed(() => {
|
||||||
@@ -74,12 +66,35 @@ const enabled = computed(() => {
|
|||||||
) {
|
) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (operation.value === 'transfer' && selectedFolderId.value) {
|
if (operation.value === 'transfer' && selectedFolder.value) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const folderContentWarningMessage = computed(() => {
|
||||||
|
const folderCount = props.data.content.subFolderCount ?? 0;
|
||||||
|
const workflowCount = props.data.content.workflowCount ?? 0;
|
||||||
|
|
||||||
|
let folderText = '';
|
||||||
|
let workflowText = '';
|
||||||
|
if (folderCount > 0) {
|
||||||
|
folderText = i18n.baseText('folder.count', { interpolate: { count: folderCount } });
|
||||||
|
}
|
||||||
|
if (workflowCount > 0) {
|
||||||
|
workflowText = i18n.baseText('workflow.count', { interpolate: { count: workflowCount } });
|
||||||
|
}
|
||||||
|
if (folderCount > 0 && workflowCount > 0) {
|
||||||
|
folderText += ` ${i18n.baseText('folder.and.workflow.separator')} `;
|
||||||
|
}
|
||||||
|
return i18n.baseText('folder.delete.modal.confirmation', {
|
||||||
|
interpolate: {
|
||||||
|
folders: folderText,
|
||||||
|
workflows: workflowText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function onSubmit() {
|
async function onSubmit() {
|
||||||
if (!enabled.value) {
|
if (!enabled.value) {
|
||||||
return;
|
return;
|
||||||
@@ -90,16 +105,13 @@ async function onSubmit() {
|
|||||||
await foldersStore.deleteFolder(
|
await foldersStore.deleteFolder(
|
||||||
route.params.projectId as string,
|
route.params.projectId as string,
|
||||||
props.activeId,
|
props.activeId,
|
||||||
selectedFolderId.value ?? undefined,
|
selectedFolder.value?.id ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
let message = '';
|
let message = '';
|
||||||
if (selectedFolderId.value) {
|
if (selectedFolder.value) {
|
||||||
const selectedFolder = availableFolders.value.find(
|
|
||||||
(folder) => folder.id === selectedFolderId.value,
|
|
||||||
);
|
|
||||||
message = i18n.baseText('folders.transfer.confirm.message', {
|
message = i18n.baseText('folders.transfer.confirm.message', {
|
||||||
interpolate: { folderName: selectedFolder?.name ?? '' },
|
interpolate: { folderName: selectedFolder.value.name ?? '' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
showMessage({
|
showMessage({
|
||||||
@@ -107,7 +119,11 @@ async function onSubmit() {
|
|||||||
title: i18n.baseText('folders.delete.success.message'),
|
title: i18n.baseText('folders.delete.success.message'),
|
||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
props.data.workflowListEventBus.emit('folder-deleted', { folderId: props.activeId });
|
props.data.workflowListEventBus.emit('folder-deleted', {
|
||||||
|
folderId: props.activeId,
|
||||||
|
workflowCount: props.data.content.workflowCount,
|
||||||
|
folderCount: props.data.content.subFolderCount,
|
||||||
|
});
|
||||||
modalBus.emit('close');
|
modalBus.emit('close');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(error, i18n.baseText('folders.delete.error.message'));
|
showError(error, i18n.baseText('folders.delete.error.message'));
|
||||||
@@ -116,9 +132,9 @@ async function onSubmit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
const onFolderSelected = (payload: { id: string; name: string }) => {
|
||||||
projectFolders.value = await foldersStore.fetchProjectFolders(route.params.projectId as string);
|
selectedFolder.value = payload;
|
||||||
});
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -139,9 +155,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-else :class="$style.content">
|
<div v-else :class="$style.content">
|
||||||
<div>
|
<div>
|
||||||
<n8n-text color="text-base">{{
|
<n8n-text color="text-base">{{ folderContentWarningMessage }}</n8n-text>
|
||||||
i18n.baseText('folder.delete.modal.confirmation')
|
|
||||||
}}</n8n-text>
|
|
||||||
</div>
|
</div>
|
||||||
<el-radio
|
<el-radio
|
||||||
v-model="operation"
|
v-model="operation"
|
||||||
@@ -155,24 +169,13 @@ onMounted(async () => {
|
|||||||
<n8n-text color="text-dark">{{
|
<n8n-text color="text-dark">{{
|
||||||
i18n.baseText('folders.transfer.selectFolder')
|
i18n.baseText('folders.transfer.selectFolder')
|
||||||
}}</n8n-text>
|
}}</n8n-text>
|
||||||
<N8nSelect
|
<MoveToFolderDropdown
|
||||||
v-model="selectedFolderId"
|
v-if="projectsStore.currentProject"
|
||||||
option-label="name"
|
:current-folder-id="props.activeId"
|
||||||
option-value="id"
|
:current-project-id="projectsStore.currentProject?.id"
|
||||||
:placeholder="i18n.baseText('folders.transfer.selectFolder')"
|
:parent-folder-id="currentFolder?.parentFolder?.id"
|
||||||
>
|
@folder:selected="onFolderSelected"
|
||||||
<N8nOption
|
/>
|
||||||
v-for="folder in availableFolders"
|
|
||||||
:key="folder.id"
|
|
||||||
:value="folder.id"
|
|
||||||
:label="folder.name"
|
|
||||||
>
|
|
||||||
<div :class="$style['folder-select-item']">
|
|
||||||
<n8n-icon icon="folder" />
|
|
||||||
<span> {{ folder.name }}</span>
|
|
||||||
</div>
|
|
||||||
</N8nOption>
|
|
||||||
</N8nSelect>
|
|
||||||
</div>
|
</div>
|
||||||
<el-radio
|
<el-radio
|
||||||
v-model="operation"
|
v-model="operation"
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import type { FolderListItem } from '@/Interface';
|
||||||
|
import { useFoldersStore } from '@/stores/folders.store';
|
||||||
|
import { N8nSelect } from '@n8n/design-system';
|
||||||
|
import { ref } 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
currentProjectId: string;
|
||||||
|
currentFolderId?: string;
|
||||||
|
parentFolderId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
currentFolderId: '',
|
||||||
|
parentFolderId: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'folder:selected': [value: { id: string; name: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const foldersStore = useFoldersStore();
|
||||||
|
|
||||||
|
const moveFolderDropdown = ref<InstanceType<typeof N8nSelect>>();
|
||||||
|
const selectedFolderId = ref<string | null>(null);
|
||||||
|
const availableFolders = ref<FolderListItem[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const fetchAvailableFolders = async (query?: string) => {
|
||||||
|
if (!query) {
|
||||||
|
availableFolders.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
const folders = await foldersStore.fetchFoldersAvailableForMove(
|
||||||
|
props.currentProjectId,
|
||||||
|
props.currentFolderId,
|
||||||
|
{ name: query ?? undefined },
|
||||||
|
);
|
||||||
|
if (!props.parentFolderId) {
|
||||||
|
availableFolders.value = folders;
|
||||||
|
} else {
|
||||||
|
availableFolders.value = folders.filter((folder) => folder.id !== props.parentFolderId);
|
||||||
|
}
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFolderSelected = (folderId: string) => {
|
||||||
|
const selectedFolder = availableFolders.value.find((folder) => folder.id === folderId);
|
||||||
|
if (!selectedFolder) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('folder:selected', { id: folderId, name: selectedFolder.name });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style['move-folder-dropdown']" data-test-id="move-to-folder-dropdown">
|
||||||
|
<N8nSelect
|
||||||
|
ref="moveFolderDropdown"
|
||||||
|
v-model="selectedFolderId"
|
||||||
|
:filterable="true"
|
||||||
|
:remote="true"
|
||||||
|
:remote-method="fetchAvailableFolders"
|
||||||
|
:loading="loading"
|
||||||
|
:placeholder="i18n.baseText('folders.move.modal.select.placeholder')"
|
||||||
|
option-label="name"
|
||||||
|
option-value="id"
|
||||||
|
@update:model-value="onFolderSelected"
|
||||||
|
>
|
||||||
|
<N8nOption
|
||||||
|
v-for="folder in availableFolders"
|
||||||
|
:key="folder.id"
|
||||||
|
:value="folder.id"
|
||||||
|
:label="folder.name"
|
||||||
|
>
|
||||||
|
<div :class="$style['folder-select-item']">
|
||||||
|
<n8n-icon :class="$style['folder-icon']" icon="folder" />
|
||||||
|
<span :class="$style['folder-name']"> {{ folder.name }}</span>
|
||||||
|
</div>
|
||||||
|
</N8nOption>
|
||||||
|
</N8nSelect>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.move-folder-dropdown {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-select-item {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
align-items: center;
|
||||||
|
max-width: 90%;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
.folder-name {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import MoveToFolderModal from './MoveToFolderModal.vue';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { cleanupAppModals, createAppModals, 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';
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => {
|
||||||
|
const push = vi.fn();
|
||||||
|
const resolve = vi.fn().mockReturnValue({ href: '/projects/1/folders/1' });
|
||||||
|
return {
|
||||||
|
useRouter: vi.fn().mockReturnValue({
|
||||||
|
push,
|
||||||
|
resolve,
|
||||||
|
}),
|
||||||
|
useRoute: vi.fn().mockReturnValue({
|
||||||
|
params: {
|
||||||
|
projectId: '1',
|
||||||
|
|
||||||
|
folderId: '1',
|
||||||
|
},
|
||||||
|
query: {},
|
||||||
|
}),
|
||||||
|
RouterLink: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(MoveToFolderModal, {
|
||||||
|
props: {
|
||||||
|
modalName: MOVE_FOLDER_MODAL_KEY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const TEST_FOLDER_RESOURCE = {
|
||||||
|
id: 'test-folder-id',
|
||||||
|
name: 'Test Folder',
|
||||||
|
parentFolderId: 'test-parent-folder-id',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEST_WORKFLOW_RESOURCE = {
|
||||||
|
id: 'test-workflow-id',
|
||||||
|
name: 'Test Workflow',
|
||||||
|
parentFolderId: 'test-parent-folder-id',
|
||||||
|
};
|
||||||
|
|
||||||
|
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
|
||||||
|
describe('MoveToFolderModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
createAppModals();
|
||||||
|
createTestingPinia();
|
||||||
|
uiStore = mockedStore(useUIStore);
|
||||||
|
uiStore.modalsById = {
|
||||||
|
[MOVE_FOLDER_MODAL_KEY]: {
|
||||||
|
open: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanupAppModals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render for folder resource', async () => {
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
resource: TEST_FOLDER_RESOURCE,
|
||||||
|
resourceType: 'folder',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render for workflow resource', async () => {
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
resource: TEST_WORKFLOW_RESOURCE,
|
||||||
|
resourceType: 'workflow',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useI18n } from '@/composables/useI18n';
|
||||||
|
import { MOVE_FOLDER_MODAL_KEY } from '@/constants';
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This modal is used to move a resource (folder or workflow) to a different folder.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
modalName: string;
|
||||||
|
data: {
|
||||||
|
resourceType: 'folder' | 'workflow';
|
||||||
|
resource: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentFolderId?: string;
|
||||||
|
};
|
||||||
|
workflowListEventBus: EventBus;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
|
||||||
|
const projectsStore = useProjectsStore();
|
||||||
|
const uiStore = useUIStore();
|
||||||
|
|
||||||
|
const selectedFolder = ref<{ id: string; name: string } | null>(null);
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
return i18n.baseText('folders.move.modal.title', {
|
||||||
|
interpolate: { folderName: props.data.resource.name },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentFolder = computed(() => {
|
||||||
|
if (props.data.resourceType === 'workflow') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: props.data.resource.id,
|
||||||
|
name: props.data.resource.name,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const onFolderSelected = (payload: { id: string; name: string }) => {
|
||||||
|
selectedFolder.value = payload;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = () => {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
} 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
uiStore.closeModal(MOVE_FOLDER_MODAL_KEY);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal :name="modalName" :title="title" width="500" :class="$style.container">
|
||||||
|
<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'"
|
||||||
|
@folder:selected="onFolderSelected"
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
v-if="props.data.resourceType === 'folder'"
|
||||||
|
:class="$style.description"
|
||||||
|
data-test-id="move-folder-description"
|
||||||
|
>
|
||||||
|
{{ i18n.baseText('folders.move.modal.description') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
<template #footer="{ close }">
|
||||||
|
<div :class="$style.footer">
|
||||||
|
<n8n-button
|
||||||
|
type="secondary"
|
||||||
|
:label="i18n.baseText('generic.cancel')"
|
||||||
|
float="right"
|
||||||
|
data-test-id="cancel-move-folder-button"
|
||||||
|
@click="close"
|
||||||
|
/>
|
||||||
|
<n8n-button
|
||||||
|
:disabled="!selectedFolder"
|
||||||
|
:label="i18n.baseText('folders.move.modal.confirm')"
|
||||||
|
float="right"
|
||||||
|
data-test-id="confirm-move-folder-button"
|
||||||
|
@click="onSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.container {
|
||||||
|
h1 {
|
||||||
|
max-width: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
margin: var(--spacing-s) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
PROMPT_MFA_CODE_MODAL_KEY,
|
PROMPT_MFA_CODE_MODAL_KEY,
|
||||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
DELETE_FOLDER_MODAL_KEY,
|
DELETE_FOLDER_MODAL_KEY,
|
||||||
|
MOVE_FOLDER_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
|
|
||||||
import AboutModal from '@/components/AboutModal.vue';
|
import AboutModal from '@/components/AboutModal.vue';
|
||||||
@@ -287,5 +288,11 @@ import type { EventBus } from '@n8n/utils/event-bus';
|
|||||||
<DeleteFolderModal :modal-name="modalName" :active-id="activeId" :data="data" />
|
<DeleteFolderModal :modal-name="modalName" :active-id="activeId" :data="data" />
|
||||||
</template>
|
</template>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|
||||||
|
<ModalRoot :name="MOVE_FOLDER_MODAL_KEY">
|
||||||
|
<template #default="{ modalName, activeId, data }">
|
||||||
|
<MoveToFolderModal :modal-name="modalName" :active-id="activeId" :data="data" />
|
||||||
|
</template>
|
||||||
|
</ModalRoot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ const showSettings = computed(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject);
|
||||||
const isFoldersFeatureEnabled = computed(() => settingsStore.settings.folders.enabled);
|
const showFolders = computed(() => {
|
||||||
const isProjectPage = computed(() => route.name === VIEWS.PROJECTS_WORKFLOWS);
|
return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS;
|
||||||
|
});
|
||||||
|
|
||||||
const ACTION_TYPES = {
|
const ACTION_TYPES = {
|
||||||
WORKFLOW: 'workflow',
|
WORKFLOW: 'workflow',
|
||||||
@@ -85,7 +86,7 @@ const menu = computed(() => {
|
|||||||
!getResourcePermissions(homeProject.value?.scopes).credential.create,
|
!getResourcePermissions(homeProject.value?.scopes).credential.create,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
if (isFoldersFeatureEnabled.value && isProjectPage.value) {
|
if (showFolders.value) {
|
||||||
items.push({
|
items.push({
|
||||||
value: ACTION_TYPES.FOLDER,
|
value: ACTION_TYPES.FOLDER,
|
||||||
label: i18n.baseText('projects.header.create.folder'),
|
label: i18n.baseText('projects.header.create.folder'),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import TimeAgo from '@/components/TimeAgo.vue';
|
|||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
|
||||||
import { useI18n } from '@/composables/useI18n';
|
import { useI18n } from '@/composables/useI18n';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { useTelemetry } from '@/composables/useTelemetry';
|
import { useTelemetry } from '@/composables/useTelemetry';
|
||||||
import { ResourceType } from '@/utils/projects.utils';
|
import { ResourceType } from '@/utils/projects.utils';
|
||||||
import type { EventBus } from '@n8n/utils/event-bus';
|
import type { EventBus } from '@n8n/utils/event-bus';
|
||||||
@@ -33,6 +33,7 @@ const WORKFLOW_LIST_ITEM_ACTIONS = {
|
|||||||
DUPLICATE: 'duplicate',
|
DUPLICATE: 'duplicate',
|
||||||
DELETE: 'delete',
|
DELETE: 'delete',
|
||||||
MOVE: 'move',
|
MOVE: 'move',
|
||||||
|
MOVE_TO_FOLDER: 'moveToFolder',
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -52,12 +53,14 @@ const emit = defineEmits<{
|
|||||||
'click:tag': [tagId: string, e: PointerEvent];
|
'click:tag': [tagId: string, e: PointerEvent];
|
||||||
'workflow:deleted': [];
|
'workflow:deleted': [];
|
||||||
'workflow:active-toggle': [value: { id: string; active: boolean }];
|
'workflow:active-toggle': [value: { id: string; active: boolean }];
|
||||||
|
'action:move-to-folder': [value: { id: string; name: string; parentFolderId?: string }];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const locale = useI18n();
|
const locale = useI18n();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
const telemetry = useTelemetry();
|
const telemetry = useTelemetry();
|
||||||
|
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
@@ -69,6 +72,11 @@ const projectsStore = useProjectsStore();
|
|||||||
const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase());
|
const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase());
|
||||||
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
||||||
const workflowPermissions = computed(() => getResourcePermissions(props.data.scopes).workflow);
|
const workflowPermissions = computed(() => getResourcePermissions(props.data.scopes).workflow);
|
||||||
|
|
||||||
|
const showFolders = computed(() => {
|
||||||
|
return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS;
|
||||||
|
});
|
||||||
|
|
||||||
const actions = computed(() => {
|
const actions = computed(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
@@ -88,6 +96,13 @@ const actions = computed(() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (workflowPermissions.value.update && !props.readOnly && showFolders.value) {
|
||||||
|
items.push({
|
||||||
|
label: locale.baseText('folders.actions.moveToFolder'),
|
||||||
|
value: WORKFLOW_LIST_ITEM_ACTIONS.MOVE_TO_FOLDER,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (workflowPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) {
|
if (workflowPermissions.value.move && projectsStore.isTeamProjectFeatureEnabled) {
|
||||||
items.push({
|
items.push({
|
||||||
label: locale.baseText('workflows.item.changeOwner'),
|
label: locale.baseText('workflows.item.changeOwner'),
|
||||||
@@ -175,6 +190,13 @@ async function onAction(action: string) {
|
|||||||
case WORKFLOW_LIST_ITEM_ACTIONS.MOVE:
|
case WORKFLOW_LIST_ITEM_ACTIONS.MOVE:
|
||||||
moveResource();
|
moveResource();
|
||||||
break;
|
break;
|
||||||
|
case WORKFLOW_LIST_ITEM_ACTIONS.MOVE_TO_FOLDER:
|
||||||
|
emit('action:move-to-folder', {
|
||||||
|
id: props.data.id,
|
||||||
|
name: props.data.name,
|
||||||
|
parentFolderId: props.data.parentFolder?.id,
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ const loadPaginationFromQueryString = async () => {
|
|||||||
<n8n-input
|
<n8n-input
|
||||||
ref="search"
|
ref="search"
|
||||||
:model-value="filtersModel.search"
|
:model-value="filtersModel.search"
|
||||||
:class="[$style['search'], 'mr-2xs']"
|
:class="$style.search"
|
||||||
:placeholder="i18n.baseText(`${resourceKey}.search.placeholder` as BaseTextKey)"
|
:placeholder="i18n.baseText(`${resourceKey}.search.placeholder` as BaseTextKey)"
|
||||||
clearable
|
clearable
|
||||||
data-test-id="resources-list-search"
|
data-test-id="resources-list-search"
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export const NEW_ASSISTANT_SESSION_MODAL = 'newAssistantSession';
|
|||||||
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
|
export const EXTERNAL_SECRETS_PROVIDER_MODAL_KEY = 'externalSecretsProvider';
|
||||||
export const COMMUNITY_PLUS_ENROLLMENT_MODAL = 'communityPlusEnrollment';
|
export const COMMUNITY_PLUS_ENROLLMENT_MODAL = 'communityPlusEnrollment';
|
||||||
export const DELETE_FOLDER_MODAL_KEY = 'deleteFolder';
|
export const DELETE_FOLDER_MODAL_KEY = 'deleteFolder';
|
||||||
|
export const MOVE_FOLDER_MODAL_KEY = 'moveFolder';
|
||||||
|
|
||||||
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
|
||||||
UNINSTALL: 'uninstall',
|
UNINSTALL: 'uninstall',
|
||||||
@@ -429,6 +430,11 @@ export const MODAL_CANCEL = 'cancel';
|
|||||||
export const MODAL_CONFIRM = 'confirm';
|
export const MODAL_CONFIRM = 'confirm';
|
||||||
export const MODAL_CLOSE = 'close';
|
export const MODAL_CLOSE = 'close';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalid characters: \/:*?"<>|
|
||||||
|
* Invalid name: empty or only dots
|
||||||
|
*/
|
||||||
|
export const VALID_FOLDER_NAME_REGEX = /^(?!\.+$)(?!\s+$)[^\\/:*?"<>|]{1,100}$/;
|
||||||
export const VALID_EMAIL_REGEX =
|
export const VALID_EMAIL_REGEX =
|
||||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||||
export const VALID_WORKFLOW_IMPORT_URL_REGEX = /^http[s]?:\/\/.*\.json$/i;
|
export const VALID_WORKFLOW_IMPORT_URL_REGEX = /^http[s]?:\/\/.*\.json$/i;
|
||||||
|
|||||||
@@ -905,7 +905,7 @@
|
|||||||
"folders.delete.typeToConfirm": "delete {folderName}",
|
"folders.delete.typeToConfirm": "delete {folderName}",
|
||||||
"folders.delete.confirm.message": "Are to sure you want to delete this folder?",
|
"folders.delete.confirm.message": "Are to sure you want to delete this folder?",
|
||||||
"folders.delete.success.message": "Folder deleted",
|
"folders.delete.success.message": "Folder deleted",
|
||||||
"folder.delete.modal.confirmation": "What should we do with the folders and workflows within this folder?",
|
"folder.delete.modal.confirmation": "What should we do with {folders} {workflows} in this folder?",
|
||||||
"folder.count": "the {count} folder | the {count} folders",
|
"folder.count": "the {count} folder | the {count} folders",
|
||||||
"workflow.count": "the {count} workflow | the {count} workflows",
|
"workflow.count": "the {count} workflow | the {count} workflows",
|
||||||
"folder.and.workflow.separator": "and",
|
"folder.and.workflow.separator": "and",
|
||||||
@@ -920,6 +920,18 @@
|
|||||||
"folders.rename.error.title": "Problem renaming folder",
|
"folders.rename.error.title": "Problem renaming folder",
|
||||||
"folders.rename.success.message": "Folder renamed",
|
"folders.rename.success.message": "Folder renamed",
|
||||||
"folders.not.found.message": "Folder not found",
|
"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.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.open.error.title": "Problem opening folder",
|
||||||
|
"folders.create.error.title": "Problem creating folder",
|
||||||
"generic.oauth1Api": "OAuth1 API",
|
"generic.oauth1Api": "OAuth1 API",
|
||||||
"generic.oauth2Api": "OAuth2 API",
|
"generic.oauth2Api": "OAuth2 API",
|
||||||
"genericHelpers.loading": "Loading",
|
"genericHelpers.loading": "Loading",
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { STORES } from '@/constants';
|
import { STORES } from '@/constants';
|
||||||
import type { FolderCreateResponse, FolderShortInfo, FolderTreeResponseItem } from '@/Interface';
|
import type {
|
||||||
|
FolderCreateResponse,
|
||||||
|
FolderListItem,
|
||||||
|
FolderShortInfo,
|
||||||
|
FolderTreeResponseItem,
|
||||||
|
} from '@/Interface';
|
||||||
import * as workflowsApi from '@/api/workflows';
|
import * as workflowsApi from '@/api/workflows';
|
||||||
import { useRootStore } from './root.store';
|
import { useRootStore } from './root.store';
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
@@ -108,6 +113,48 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
|||||||
return await workflowsApi.getProjectFolders(rootStore.restApiContext, projectId);
|
return await workflowsApi.getProjectFolders(rootStore.restApiContext, projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchFoldersAvailableForMove(
|
||||||
|
projectId: string,
|
||||||
|
folderId?: string,
|
||||||
|
filter?: {
|
||||||
|
name?: string;
|
||||||
|
},
|
||||||
|
): Promise<FolderListItem[]> {
|
||||||
|
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) => ({
|
||||||
|
id: folder.id,
|
||||||
|
name: folder.name,
|
||||||
|
parentFolder: folder.parentFolder?.id,
|
||||||
|
}));
|
||||||
|
cacheFolders(forCache);
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveFolder(
|
||||||
|
projectId: string,
|
||||||
|
folderId: string,
|
||||||
|
parentFolderId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await workflowsApi.moveFolder(rootStore.restApiContext, projectId, folderId, parentFolderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchFolderContent(
|
||||||
|
projectId: string,
|
||||||
|
folderId: string,
|
||||||
|
): Promise<{ totalWorkflows: number; totalSubFolders: number }> {
|
||||||
|
return await workflowsApi.getFolderContent(rootStore.restApiContext, projectId, folderId);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetchTotalWorkflowsAndFoldersCount,
|
fetchTotalWorkflowsAndFoldersCount,
|
||||||
breadcrumbsCache,
|
breadcrumbsCache,
|
||||||
@@ -120,5 +167,8 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
|||||||
deleteFoldersFromCache,
|
deleteFoldersFromCache,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
fetchProjectFolders,
|
fetchProjectFolders,
|
||||||
|
fetchFoldersAvailableForMove,
|
||||||
|
moveFolder,
|
||||||
|
fetchFolderContent,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
COMMUNITY_PLUS_ENROLLMENT_MODAL,
|
||||||
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
|
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
|
||||||
DELETE_FOLDER_MODAL_KEY,
|
DELETE_FOLDER_MODAL_KEY,
|
||||||
|
MOVE_FOLDER_MODAL_KEY,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type {
|
import type {
|
||||||
INodeUi,
|
INodeUi,
|
||||||
@@ -162,12 +163,20 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
open: false,
|
open: false,
|
||||||
activeId: null,
|
activeId: null,
|
||||||
data: {
|
data: {
|
||||||
|
workflowListEventBus: undefined,
|
||||||
content: {
|
content: {
|
||||||
workflowCount: 0,
|
workflowCount: 0,
|
||||||
subFolderCount: 0,
|
subFolderCount: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
[MOVE_FOLDER_MODAL_KEY]: {
|
||||||
|
open: false,
|
||||||
|
activeId: null,
|
||||||
|
data: {
|
||||||
|
workflowListEventBus: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const modalStack = ref<string[]>([]);
|
const modalStack = ref<string[]>([]);
|
||||||
@@ -498,6 +507,17 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
openModalWithData({ name: DELETE_FOLDER_MODAL_KEY, data: { workflowListEventBus, content } });
|
openModalWithData({ name: DELETE_FOLDER_MODAL_KEY, data: { workflowListEventBus, content } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openMoveToFolderModal = (
|
||||||
|
resourceType: 'folder' | 'workflow',
|
||||||
|
resource: { id: string; name: string; parentFolderId?: string },
|
||||||
|
workflowListEventBus: EventBus,
|
||||||
|
) => {
|
||||||
|
openModalWithData({
|
||||||
|
name: MOVE_FOLDER_MODAL_KEY,
|
||||||
|
data: { resourceType, resource, workflowListEventBus },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const addActiveAction = (action: string) => {
|
const addActiveAction = (action: string) => {
|
||||||
if (!activeActions.value.includes(action)) {
|
if (!activeActions.value.includes(action)) {
|
||||||
activeActions.value.push(action);
|
activeActions.value.push(action);
|
||||||
@@ -670,6 +690,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
|||||||
resetLastInteractedWith,
|
resetLastInteractedWith,
|
||||||
setProcessingExecutionResults,
|
setProcessingExecutionResults,
|
||||||
openDeleteFolderModal,
|
openDeleteFolderModal,
|
||||||
|
openMoveToFolderModal,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ describe('Folders', () => {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
active: true,
|
active: true,
|
||||||
|
versionId: '1',
|
||||||
homeProject: {
|
homeProject: {
|
||||||
id: '1',
|
id: '1',
|
||||||
name: 'Project 1',
|
name: 'Project 1',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
VIEWS,
|
VIEWS,
|
||||||
DEFAULT_WORKFLOW_PAGE_SIZE,
|
DEFAULT_WORKFLOW_PAGE_SIZE,
|
||||||
MODAL_CONFIRM,
|
MODAL_CONFIRM,
|
||||||
|
VALID_FOLDER_NAME_REGEX,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import type {
|
import type {
|
||||||
IUser,
|
IUser,
|
||||||
@@ -174,13 +175,14 @@ const mainBreadcrumbsActions = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
|
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
|
||||||
const foldersEnabled = computed(() => settingsStore.settings.folders.enabled);
|
|
||||||
const isOverviewPage = computed(() => route.name === VIEWS.WORKFLOWS);
|
const isOverviewPage = computed(() => route.name === VIEWS.WORKFLOWS);
|
||||||
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser));
|
||||||
const isShareable = computed(
|
const isShareable = computed(
|
||||||
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing],
|
() => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing],
|
||||||
);
|
);
|
||||||
const showFolders = computed(() => foldersEnabled.value && !isOverviewPage.value);
|
const showFolders = computed(() => {
|
||||||
|
return settingsStore.isFoldersFeatureEnabled && !isOverviewPage.value;
|
||||||
|
});
|
||||||
|
|
||||||
const currentFolder = computed(() => {
|
const currentFolder = computed(() => {
|
||||||
return currentFolderId.value ? foldersStore.breadcrumbsCache[currentFolderId.value] : null;
|
return currentFolderId.value ? foldersStore.breadcrumbsCache[currentFolderId.value] : null;
|
||||||
@@ -320,7 +322,11 @@ sourceControlStore.$onAction(({ name, after }) => {
|
|||||||
after(async () => await initialize());
|
after(async () => await initialize());
|
||||||
});
|
});
|
||||||
|
|
||||||
const onFolderDeleted = async (payload: { folderId: string }) => {
|
const onFolderDeleted = async (payload: {
|
||||||
|
folderId: string;
|
||||||
|
workflowCount: number;
|
||||||
|
folderCount: number;
|
||||||
|
}) => {
|
||||||
const folderInfo = foldersStore.getCachedFolder(payload.folderId);
|
const folderInfo = foldersStore.getCachedFolder(payload.folderId);
|
||||||
foldersStore.deleteFoldersFromCache([payload.folderId, folderInfo?.parentFolder ?? '']);
|
foldersStore.deleteFoldersFromCache([payload.folderId, folderInfo?.parentFolder ?? '']);
|
||||||
// If the deleted folder is the current folder, navigate to the parent folder
|
// If the deleted folder is the current folder, navigate to the parent folder
|
||||||
@@ -333,6 +339,11 @@ const onFolderDeleted = async (payload: { folderId: string }) => {
|
|||||||
} else {
|
} else {
|
||||||
await fetchWorkflows();
|
await fetchWorkflows();
|
||||||
}
|
}
|
||||||
|
telemetry.track('User deleted folder', {
|
||||||
|
folder_id: payload.folderId,
|
||||||
|
deleted_sub_folders: payload.folderCount,
|
||||||
|
deleted_sub_workflows: payload.workflowCount,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -346,12 +357,16 @@ onMounted(async () => {
|
|||||||
workflowListEventBus.on('resource-moved', fetchWorkflows);
|
workflowListEventBus.on('resource-moved', fetchWorkflows);
|
||||||
workflowListEventBus.on('workflow-duplicated', fetchWorkflows);
|
workflowListEventBus.on('workflow-duplicated', fetchWorkflows);
|
||||||
workflowListEventBus.on('folder-deleted', onFolderDeleted);
|
workflowListEventBus.on('folder-deleted', onFolderDeleted);
|
||||||
|
workflowListEventBus.on('folder-moved', moveFolder);
|
||||||
|
workflowListEventBus.on('workflow-moved', onWorkflowMoved);
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
workflowListEventBus.off('resource-moved', fetchWorkflows);
|
workflowListEventBus.off('resource-moved', fetchWorkflows);
|
||||||
workflowListEventBus.off('workflow-duplicated', fetchWorkflows);
|
workflowListEventBus.off('workflow-duplicated', fetchWorkflows);
|
||||||
workflowListEventBus.off('folder-deleted', onFolderDeleted);
|
workflowListEventBus.off('folder-deleted', onFolderDeleted);
|
||||||
|
workflowListEventBus.off('folder-moved', moveFolder);
|
||||||
|
workflowListEventBus.off('workflow-moved', onWorkflowMoved);
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -669,25 +684,20 @@ const getFolderListItem = (folderId: string): FolderListItem | undefined => {
|
|||||||
resource.resource === 'folder' && resource.id === folderId,
|
resource.resource === 'folder' && resource.id === folderId,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
// TODO: This will only count the workflows and folders in the current page
|
|
||||||
// Check if we need to add counts to /tree endpoint or not show them in modal
|
|
||||||
const getCurrentFolderWorkflowCount = () => {
|
|
||||||
const workflows = workflowsAndFolders.value.filter(
|
|
||||||
(resource): resource is WorkflowListItem => resource.resource === 'workflow',
|
|
||||||
);
|
|
||||||
return workflows.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCurrentFolderSubFolderCount = () => {
|
const getFolderContent = async (folderId: string) => {
|
||||||
const folders = workflowsAndFolders.value.filter(
|
|
||||||
(resource): resource is FolderListItem => resource.resource === 'folder',
|
|
||||||
);
|
|
||||||
return folders.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFolderContent = (folderId: string) => {
|
|
||||||
const folderListItem = getFolderListItem(folderId);
|
const folderListItem = getFolderListItem(folderId);
|
||||||
if (!folderListItem) {
|
if (folderListItem) {
|
||||||
|
return {
|
||||||
|
workflowCount: folderListItem.workflowCount,
|
||||||
|
subFolderCount: folderListItem.subFolderCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Fetch the folder content from API
|
||||||
|
const content = await foldersStore.fetchFolderContent(currentProject.value?.id ?? '', folderId);
|
||||||
|
return { workflowCount: content.totalWorkflows, subFolderCount: content.totalSubFolders };
|
||||||
|
} catch (error) {
|
||||||
toast.showMessage({
|
toast.showMessage({
|
||||||
title: i18n.baseText('folders.delete.error.message'),
|
title: i18n.baseText('folders.delete.error.message'),
|
||||||
message: i18n.baseText('folders.not.found.message'),
|
message: i18n.baseText('folders.not.found.message'),
|
||||||
@@ -695,10 +705,6 @@ const getFolderContent = (folderId: string) => {
|
|||||||
});
|
});
|
||||||
return { workflowCount: 0, subFolderCount: 0 };
|
return { workflowCount: 0, subFolderCount: 0 };
|
||||||
}
|
}
|
||||||
return {
|
|
||||||
workflowCount: folderListItem.workflowCount,
|
|
||||||
subFolderCount: folderListItem.subFolderCount,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Breadcrumbs methods
|
// Breadcrumbs methods
|
||||||
@@ -770,7 +776,7 @@ const onBreadcrumbItemClick = (item: PathItem) => {
|
|||||||
loading.value = false;
|
loading.value = false;
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
toast.showError(error, 'Error navigating to folder');
|
toast.showError(error, i18n.baseText('folders.open.error.title'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -796,14 +802,29 @@ const onBreadCrumbsAction = async (action: string) => {
|
|||||||
break;
|
break;
|
||||||
case FOLDER_LIST_ITEM_ACTIONS.DELETE:
|
case FOLDER_LIST_ITEM_ACTIONS.DELETE:
|
||||||
if (!route.params.folderId) return;
|
if (!route.params.folderId) return;
|
||||||
const subFolderCount = getCurrentFolderSubFolderCount();
|
const content = await getFolderContent(route.params.folderId as string);
|
||||||
const workflowCount = getCurrentFolderWorkflowCount();
|
await deleteFolder(
|
||||||
await deleteFolder(route.params.folderId as string, workflowCount, subFolderCount);
|
route.params.folderId as string,
|
||||||
|
content.workflowCount,
|
||||||
|
content.subFolderCount,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case FOLDER_LIST_ITEM_ACTIONS.RENAME:
|
case FOLDER_LIST_ITEM_ACTIONS.RENAME:
|
||||||
if (!route.params.folderId) return;
|
if (!route.params.folderId) return;
|
||||||
await renameFolder(route.params.folderId as string);
|
await renameFolder(route.params.folderId as string);
|
||||||
break;
|
break;
|
||||||
|
case FOLDER_LIST_ITEM_ACTIONS.MOVE:
|
||||||
|
if (!currentFolder.value) return;
|
||||||
|
uiStore.openMoveToFolderModal(
|
||||||
|
'folder',
|
||||||
|
{
|
||||||
|
id: currentFolder.value?.id,
|
||||||
|
name: currentFolder.value?.name,
|
||||||
|
parentFolderId: currentFolder.value?.parentFolder,
|
||||||
|
},
|
||||||
|
workflowListEventBus,
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -830,13 +851,24 @@ const onFolderCardAction = async (payload: { action: string; folderId: string })
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case FOLDER_LIST_ITEM_ACTIONS.DELETE: {
|
case FOLDER_LIST_ITEM_ACTIONS.DELETE: {
|
||||||
const content = getFolderContent(clickedFolder.id);
|
const content = await getFolderContent(clickedFolder.id);
|
||||||
await deleteFolder(clickedFolder.id, content.workflowCount, content.subFolderCount);
|
await deleteFolder(clickedFolder.id, content.workflowCount, content.subFolderCount);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case FOLDER_LIST_ITEM_ACTIONS.RENAME:
|
case FOLDER_LIST_ITEM_ACTIONS.RENAME:
|
||||||
await renameFolder(clickedFolder.id);
|
await renameFolder(clickedFolder.id);
|
||||||
break;
|
break;
|
||||||
|
case FOLDER_LIST_ITEM_ACTIONS.MOVE:
|
||||||
|
uiStore.openMoveToFolderModal(
|
||||||
|
'folder',
|
||||||
|
{
|
||||||
|
id: clickedFolder.id,
|
||||||
|
name: clickedFolder.name,
|
||||||
|
parentFolderId: clickedFolder.parentFolder,
|
||||||
|
},
|
||||||
|
workflowListEventBus,
|
||||||
|
);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -845,18 +877,13 @@ const onFolderCardAction = async (payload: { action: string; folderId: string })
|
|||||||
// Reusable action handlers
|
// Reusable action handlers
|
||||||
// Both action handlers ultimately call these methods once folder to apply action to is determined
|
// Both action handlers ultimately call these methods once folder to apply action to is determined
|
||||||
const createFolder = async (parent: { id: string; name: string; type: 'project' | 'folder' }) => {
|
const createFolder = async (parent: { id: string; name: string; type: 'project' | 'folder' }) => {
|
||||||
// Rules for folder name:
|
|
||||||
// - Invalid characters: \/:*?"<>|
|
|
||||||
// - Invalid name: empty or only dots
|
|
||||||
const validFolderNameRegex = /^(?!\.+$)(?!\s+$)[^\\/:*?"<>|]{1,100}$/;
|
|
||||||
|
|
||||||
const promptResponsePromise = message.prompt(
|
const promptResponsePromise = message.prompt(
|
||||||
i18n.baseText('folders.add.to.parent.message', { interpolate: { parent: parent.name } }),
|
i18n.baseText('folders.add.to.parent.message', { interpolate: { parent: parent.name } }),
|
||||||
{
|
{
|
||||||
confirmButtonText: i18n.baseText('generic.create'),
|
confirmButtonText: i18n.baseText('generic.create'),
|
||||||
cancelButtonText: i18n.baseText('generic.cancel'),
|
cancelButtonText: i18n.baseText('generic.cancel'),
|
||||||
inputErrorMessage: i18n.baseText('folders.invalidName.message'),
|
inputErrorMessage: i18n.baseText('folders.invalidName.message'),
|
||||||
inputPattern: validFolderNameRegex,
|
inputPattern: VALID_FOLDER_NAME_REGEX,
|
||||||
customClass: 'add-folder-modal',
|
customClass: 'add-folder-modal',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -870,10 +897,10 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
|
|||||||
parent.type === 'folder' ? parent.id : undefined,
|
parent.type === 'folder' ? parent.id : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
let newFolderURL = `/projects/${route.params.projectId}/folders/${newFolder.id}/workflows`;
|
const newFolderURL = router.resolve({
|
||||||
if (newFolder.parentFolder) {
|
name: VIEWS.PROJECTS_FOLDERS,
|
||||||
newFolderURL = `/projects/${route.params.projectId}/folders/${newFolder.id}/workflows`;
|
params: { projectId: route.params.projectId, folderId: newFolder.id },
|
||||||
}
|
}).href;
|
||||||
toast.showToast({
|
toast.showToast({
|
||||||
title: i18n.baseText('folders.add.success.title'),
|
title: i18n.baseText('folders.add.success.title'),
|
||||||
message: i18n.baseText('folders.add.success.message', {
|
message: i18n.baseText('folders.add.success.message', {
|
||||||
@@ -912,8 +939,11 @@ const createFolder = async (parent: { id: string; name: string; type: 'project'
|
|||||||
// Else fetch again with same filters & pagination applied
|
// Else fetch again with same filters & pagination applied
|
||||||
await fetchWorkflows();
|
await fetchWorkflows();
|
||||||
}
|
}
|
||||||
|
telemetry.track('User created folder', {
|
||||||
|
folder_id: newFolder.id,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(error, 'Error creating folder');
|
toast.showError(error, i18n.baseText('folders.create.error.title'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -928,7 +958,7 @@ const renameFolder = async (folderId: string) => {
|
|||||||
cancelButtonText: i18n.baseText('generic.cancel'),
|
cancelButtonText: i18n.baseText('generic.cancel'),
|
||||||
inputErrorMessage: i18n.baseText('folders.invalidName.message'),
|
inputErrorMessage: i18n.baseText('folders.invalidName.message'),
|
||||||
inputValue: folder.name,
|
inputValue: folder.name,
|
||||||
inputPattern: /^[a-zA-Z0-9-_ ]{1,100}$/,
|
inputPattern: VALID_FOLDER_NAME_REGEX,
|
||||||
customClass: 'rename-folder-modal',
|
customClass: 'rename-folder-modal',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -945,6 +975,9 @@ const renameFolder = async (folderId: string) => {
|
|||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
await fetchWorkflows();
|
await fetchWorkflows();
|
||||||
|
telemetry.track('User renamed folder', {
|
||||||
|
folder_id: folderId,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.showError(error, i18n.baseText('folders.rename.error.title'));
|
toast.showError(error, i18n.baseText('folders.rename.error.title'));
|
||||||
}
|
}
|
||||||
@@ -974,7 +1007,101 @@ const deleteFolder = async (folderId: string, workflowCount: number, subFolderCo
|
|||||||
title: i18n.baseText('folders.delete.success.message'),
|
title: i18n.baseText('folders.delete.success.message'),
|
||||||
type: 'success',
|
type: 'success',
|
||||||
});
|
});
|
||||||
await onFolderDeleted({ folderId });
|
await onFolderDeleted({ folderId, workflowCount, folderCount: subFolderCount });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveFolder = async (payload: {
|
||||||
|
folder: { id: string; name: string };
|
||||||
|
newParent: { id: string; name: string };
|
||||||
|
}) => {
|
||||||
|
if (!route.params.projectId) return;
|
||||||
|
try {
|
||||||
|
await foldersStore.moveFolder(
|
||||||
|
route.params.projectId as string,
|
||||||
|
payload.folder.id,
|
||||||
|
payload.newParent.id,
|
||||||
|
);
|
||||||
|
const isCurrentFolder = currentFolderId.value === payload.folder.id;
|
||||||
|
const newFolderURL = router.resolve({
|
||||||
|
name: VIEWS.PROJECTS_FOLDERS,
|
||||||
|
params: { projectId: route.params.projectId, folderId: payload.newParent.id },
|
||||||
|
}).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();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, i18n.baseText('folders.move.error.title'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveWorkflowToFolder = async (payload: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
parentFolderId?: string;
|
||||||
|
}) => {
|
||||||
|
uiStore.openMoveToFolderModal(
|
||||||
|
'workflow',
|
||||||
|
{ id: payload.id, name: payload.name, parentFolderId: payload.parentFolderId },
|
||||||
|
workflowListEventBus,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWorkflowMoved = async (payload: {
|
||||||
|
workflow: { id: string; name: string; oldParentId: string };
|
||||||
|
newParent: { id: string; name: string };
|
||||||
|
}) => {
|
||||||
|
if (!route.params.projectId) return;
|
||||||
|
try {
|
||||||
|
const newFolderURL = router.resolve({
|
||||||
|
name: VIEWS.PROJECTS_FOLDERS,
|
||||||
|
params: { projectId: route.params.projectId, folderId: payload.newParent.id },
|
||||||
|
}).href;
|
||||||
|
const workflowResource = workflowsAndFolders.value.find(
|
||||||
|
(resource): resource is WorkflowListItem => resource.id === payload.workflow.id,
|
||||||
|
);
|
||||||
|
await workflowsStore.updateWorkflow(payload.workflow.id, {
|
||||||
|
parentFolderId: payload.newParent.id,
|
||||||
|
versionId: workflowResource?.versionId,
|
||||||
|
});
|
||||||
|
await fetchWorkflows();
|
||||||
|
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(newFolderURL);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
type: 'success',
|
||||||
|
});
|
||||||
|
telemetry.track('User moved content', {
|
||||||
|
workflow_id: payload.workflow.id,
|
||||||
|
source_folder_id: payload.workflow.oldParentId,
|
||||||
|
destination_folder_id: payload.newParent.id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, i18n.baseText('folders.move.workflow.error.title'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -1094,6 +1221,7 @@ const deleteFolder = async (folderId: string, workflowCount: number, subFolderCo
|
|||||||
@workflow:moved="fetchWorkflows"
|
@workflow:moved="fetchWorkflows"
|
||||||
@workflow:duplicated="fetchWorkflows"
|
@workflow:duplicated="fetchWorkflows"
|
||||||
@workflow:active-toggle="onWorkflowActiveToggle"
|
@workflow:active-toggle="onWorkflowActiveToggle"
|
||||||
|
@action:move-to-folder="moveWorkflowToFolder"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #empty>
|
<template #empty>
|
||||||
|
|||||||
Reference in New Issue
Block a user