diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 2855c3296a..98e87b5643 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -250,11 +250,11 @@ export interface IWorkflowDataUpdate { pinData?: IPinData; versionId?: string; meta?: WorkflowMetadata; + parentFolderId?: string; } export interface IWorkflowDataCreate extends IWorkflowDataUpdate { projectId?: string; - parentFolderId?: string; } /** @@ -336,7 +336,7 @@ export type BaseResource = { export type WorkflowListItem = Omit< IWorkflowDb, - 'nodes' | 'connections' | 'settings' | 'pinData' | 'versionId' | 'usedCredentials' | 'meta' + 'nodes' | 'connections' | 'settings' | 'pinData' | 'usedCredentials' | 'meta' > & { resource: 'workflow'; parentFolder?: { id: string; name: string }; diff --git a/packages/frontend/editor-ui/src/api/workflows.ts b/packages/frontend/editor-ui/src/api/workflows.ts index 5e97417ccd..9d2bd5c0c6 100644 --- a/packages/frontend/editor-ui/src/api/workflows.ts +++ b/packages/frontend/editor-ui/src/api/workflows.ts @@ -137,6 +137,48 @@ export async function renameFolder( export async function getProjectFolders( context: IRestApiContext, projectId: string, + options?: { + skip?: number; + take?: number; + sortBy?: string; + }, + filter?: { + excludeFolderIdAndDescendants?: string; + name?: string; + }, ): Promise { - return await makeRestApiRequest(context, 'GET', `/projects/${projectId}/folders`); + const res = await getFullApiResponse( + 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 { + 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; } diff --git a/packages/frontend/editor-ui/src/components/Folders/DeleteFolderModal.vue b/packages/frontend/editor-ui/src/components/Folders/DeleteFolderModal.vue index ffed4bc890..840792fed6 100644 --- a/packages/frontend/editor-ui/src/components/Folders/DeleteFolderModal.vue +++ b/packages/frontend/editor-ui/src/components/Folders/DeleteFolderModal.vue @@ -1,5 +1,5 @@ + + + + diff --git a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue index e4b8aac4ad..b0d053a3a5 100644 --- a/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue +++ b/packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue @@ -56,8 +56,9 @@ const showSettings = computed( ); const homeProject = computed(() => projectsStore.currentProject ?? projectsStore.personalProject); -const isFoldersFeatureEnabled = computed(() => settingsStore.settings.folders.enabled); -const isProjectPage = computed(() => route.name === VIEWS.PROJECTS_WORKFLOWS); +const showFolders = computed(() => { + return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS; +}); const ACTION_TYPES = { WORKFLOW: 'workflow', @@ -85,7 +86,7 @@ const menu = computed(() => { !getResourcePermissions(homeProject.value?.scopes).credential.create, }, ]; - if (isFoldersFeatureEnabled.value && isProjectPage.value) { + if (showFolders.value) { items.push({ value: ACTION_TYPES.FOLDER, label: i18n.baseText('projects.header.create.folder'), diff --git a/packages/frontend/editor-ui/src/components/WorkflowCard.vue b/packages/frontend/editor-ui/src/components/WorkflowCard.vue index a58f987a4e..fb83a70ed6 100644 --- a/packages/frontend/editor-ui/src/components/WorkflowCard.vue +++ b/packages/frontend/editor-ui/src/components/WorkflowCard.vue @@ -20,7 +20,7 @@ import TimeAgo from '@/components/TimeAgo.vue'; import { useProjectsStore } from '@/stores/projects.store'; import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue'; import { useI18n } from '@/composables/useI18n'; -import { useRouter } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; import { useTelemetry } from '@/composables/useTelemetry'; import { ResourceType } from '@/utils/projects.utils'; import type { EventBus } from '@n8n/utils/event-bus'; @@ -33,6 +33,7 @@ const WORKFLOW_LIST_ITEM_ACTIONS = { DUPLICATE: 'duplicate', DELETE: 'delete', MOVE: 'move', + MOVE_TO_FOLDER: 'moveToFolder', }; const props = withDefaults( @@ -52,12 +53,14 @@ const emit = defineEmits<{ 'click:tag': [tagId: string, e: PointerEvent]; 'workflow:deleted': []; 'workflow:active-toggle': [value: { id: string; active: boolean }]; + 'action:move-to-folder': [value: { id: string; name: string; parentFolderId?: string }]; }>(); const toast = useToast(); const message = useMessage(); const locale = useI18n(); const router = useRouter(); +const route = useRoute(); const telemetry = useTelemetry(); const settingsStore = useSettingsStore(); @@ -69,6 +72,11 @@ const projectsStore = useProjectsStore(); const resourceTypeLabel = computed(() => locale.baseText('generic.workflow').toLowerCase()); const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser)); const workflowPermissions = computed(() => getResourcePermissions(props.data.scopes).workflow); + +const showFolders = computed(() => { + return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS; +}); + const actions = computed(() => { 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) { items.push({ label: locale.baseText('workflows.item.changeOwner'), @@ -175,6 +190,13 @@ async function onAction(action: string) { case WORKFLOW_LIST_ITEM_ACTIONS.MOVE: moveResource(); 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; } } diff --git a/packages/frontend/editor-ui/src/components/layouts/ResourcesListLayout.vue b/packages/frontend/editor-ui/src/components/layouts/ResourcesListLayout.vue index d5ccc2ee7e..cbb9e3ebd7 100644 --- a/packages/frontend/editor-ui/src/components/layouts/ResourcesListLayout.vue +++ b/packages/frontend/editor-ui/src/components/layouts/ResourcesListLayout.vue @@ -541,7 +541,7 @@ const loadPaginationFromQueryString = async () => { | + * Invalid name: empty or only dots + */ +export const VALID_FOLDER_NAME_REGEX = /^(?!\.+$)(?!\s+$)[^\\/:*?"<>|]{1,100}$/; 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,}))$/; export const VALID_WORKFLOW_IMPORT_URL_REGEX = /^http[s]?:\/\/.*\.json$/i; diff --git a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json index 4bd6a36452..542c2f1cea 100644 --- a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json @@ -905,7 +905,7 @@ "folders.delete.typeToConfirm": "delete {folderName}", "folders.delete.confirm.message": "Are to sure you want to delete this folder?", "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", "workflow.count": "the {count} workflow | the {count} workflows", "folder.and.workflow.separator": "and", @@ -920,6 +920,18 @@ "folders.rename.error.title": "Problem renaming folder", "folders.rename.success.message": "Folder renamed", "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": "{folderName} has been moved to {newFolderName}, along with all its workflows and subfolders.

View {newFolderName}", + "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": "{workflowName} has been moved to {newFolderName}.

View {newFolderName}", + "folders.open.error.title": "Problem opening folder", + "folders.create.error.title": "Problem creating folder", "generic.oauth1Api": "OAuth1 API", "generic.oauth2Api": "OAuth2 API", "genericHelpers.loading": "Loading", diff --git a/packages/frontend/editor-ui/src/stores/folders.store.ts b/packages/frontend/editor-ui/src/stores/folders.store.ts index f49eed5168..70367e5a02 100644 --- a/packages/frontend/editor-ui/src/stores/folders.store.ts +++ b/packages/frontend/editor-ui/src/stores/folders.store.ts @@ -1,6 +1,11 @@ import { defineStore } from 'pinia'; 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 { useRootStore } from './root.store'; import { ref } from 'vue'; @@ -108,6 +113,48 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => { return await workflowsApi.getProjectFolders(rootStore.restApiContext, projectId); } + async function fetchFoldersAvailableForMove( + projectId: string, + folderId?: string, + filter?: { + name?: string; + }, + ): Promise { + 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 { + 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 { fetchTotalWorkflowsAndFoldersCount, breadcrumbsCache, @@ -120,5 +167,8 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => { deleteFoldersFromCache, renameFolder, fetchProjectFolders, + fetchFoldersAvailableForMove, + moveFolder, + fetchFolderContent, }; }); diff --git a/packages/frontend/editor-ui/src/stores/ui.store.ts b/packages/frontend/editor-ui/src/stores/ui.store.ts index 24da0f0e72..6aafad597d 100644 --- a/packages/frontend/editor-ui/src/stores/ui.store.ts +++ b/packages/frontend/editor-ui/src/stores/ui.store.ts @@ -38,6 +38,7 @@ import { COMMUNITY_PLUS_ENROLLMENT_MODAL, API_KEY_CREATE_OR_EDIT_MODAL_KEY, DELETE_FOLDER_MODAL_KEY, + MOVE_FOLDER_MODAL_KEY, } from '@/constants'; import type { INodeUi, @@ -162,12 +163,20 @@ export const useUIStore = defineStore(STORES.UI, () => { open: false, activeId: null, data: { + workflowListEventBus: undefined, content: { workflowCount: 0, subFolderCount: 0, }, }, }, + [MOVE_FOLDER_MODAL_KEY]: { + open: false, + activeId: null, + data: { + workflowListEventBus: undefined, + }, + }, }); const modalStack = ref([]); @@ -498,6 +507,17 @@ export const useUIStore = defineStore(STORES.UI, () => { 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) => { if (!activeActions.value.includes(action)) { activeActions.value.push(action); @@ -670,6 +690,7 @@ export const useUIStore = defineStore(STORES.UI, () => { resetLastInteractedWith, setProcessingExecutionResults, openDeleteFolderModal, + openMoveToFolderModal, }; }); diff --git a/packages/frontend/editor-ui/src/views/WorkflowsView.test.ts b/packages/frontend/editor-ui/src/views/WorkflowsView.test.ts index f7a7f356e7..fec84bcd28 100644 --- a/packages/frontend/editor-ui/src/views/WorkflowsView.test.ts +++ b/packages/frontend/editor-ui/src/views/WorkflowsView.test.ts @@ -295,6 +295,7 @@ describe('Folders', () => { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), active: true, + versionId: '1', homeProject: { id: '1', name: 'Project 1', diff --git a/packages/frontend/editor-ui/src/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/views/WorkflowsView.vue index e56ae72662..893cb567cc 100644 --- a/packages/frontend/editor-ui/src/views/WorkflowsView.vue +++ b/packages/frontend/editor-ui/src/views/WorkflowsView.vue @@ -16,6 +16,7 @@ import { VIEWS, DEFAULT_WORKFLOW_PAGE_SIZE, MODAL_CONFIRM, + VALID_FOLDER_NAME_REGEX, } from '@/constants'; import type { IUser, @@ -174,13 +175,14 @@ const mainBreadcrumbsActions = computed(() => ); const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly); -const foldersEnabled = computed(() => settingsStore.settings.folders.enabled); const isOverviewPage = computed(() => route.name === VIEWS.WORKFLOWS); const currentUser = computed(() => usersStore.currentUser ?? ({} as IUser)); const isShareable = computed( () => settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Sharing], ); -const showFolders = computed(() => foldersEnabled.value && !isOverviewPage.value); +const showFolders = computed(() => { + return settingsStore.isFoldersFeatureEnabled && !isOverviewPage.value; +}); const currentFolder = computed(() => { return currentFolderId.value ? foldersStore.breadcrumbsCache[currentFolderId.value] : null; @@ -320,7 +322,11 @@ sourceControlStore.$onAction(({ name, after }) => { 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); foldersStore.deleteFoldersFromCache([payload.folderId, folderInfo?.parentFolder ?? '']); // If the deleted folder is the current folder, navigate to the parent folder @@ -333,6 +339,11 @@ const onFolderDeleted = async (payload: { folderId: string }) => { } else { 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('workflow-duplicated', fetchWorkflows); workflowListEventBus.on('folder-deleted', onFolderDeleted); + workflowListEventBus.on('folder-moved', moveFolder); + workflowListEventBus.on('workflow-moved', onWorkflowMoved); }); onBeforeUnmount(() => { workflowListEventBus.off('resource-moved', fetchWorkflows); workflowListEventBus.off('workflow-duplicated', fetchWorkflows); 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, ); }; -// 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 folders = workflowsAndFolders.value.filter( - (resource): resource is FolderListItem => resource.resource === 'folder', - ); - return folders.length; -}; - -const getFolderContent = (folderId: string) => { +const getFolderContent = async (folderId: string) => { 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({ title: i18n.baseText('folders.delete.error.message'), message: i18n.baseText('folders.not.found.message'), @@ -695,10 +705,6 @@ const getFolderContent = (folderId: string) => { }); return { workflowCount: 0, subFolderCount: 0 }; } - return { - workflowCount: folderListItem.workflowCount, - subFolderCount: folderListItem.subFolderCount, - }; }; // Breadcrumbs methods @@ -770,7 +776,7 @@ const onBreadcrumbItemClick = (item: PathItem) => { loading.value = false; }) .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; case FOLDER_LIST_ITEM_ACTIONS.DELETE: if (!route.params.folderId) return; - const subFolderCount = getCurrentFolderSubFolderCount(); - const workflowCount = getCurrentFolderWorkflowCount(); - await deleteFolder(route.params.folderId as string, workflowCount, subFolderCount); + const content = await getFolderContent(route.params.folderId as string); + await deleteFolder( + route.params.folderId as string, + content.workflowCount, + content.subFolderCount, + ); break; case FOLDER_LIST_ITEM_ACTIONS.RENAME: if (!route.params.folderId) return; await renameFolder(route.params.folderId as string); 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: break; } @@ -830,13 +851,24 @@ const onFolderCardAction = async (payload: { action: string; folderId: string }) }); break; 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); break; } case FOLDER_LIST_ITEM_ACTIONS.RENAME: await renameFolder(clickedFolder.id); break; + case FOLDER_LIST_ITEM_ACTIONS.MOVE: + uiStore.openMoveToFolderModal( + 'folder', + { + id: clickedFolder.id, + name: clickedFolder.name, + parentFolderId: clickedFolder.parentFolder, + }, + workflowListEventBus, + ); + break; default: break; } @@ -845,18 +877,13 @@ const onFolderCardAction = async (payload: { action: string; folderId: string }) // Reusable action handlers // 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' }) => { - // Rules for folder name: - // - Invalid characters: \/:*?"<>| - // - Invalid name: empty or only dots - const validFolderNameRegex = /^(?!\.+$)(?!\s+$)[^\\/:*?"<>|]{1,100}$/; - const promptResponsePromise = message.prompt( i18n.baseText('folders.add.to.parent.message', { interpolate: { parent: parent.name } }), { confirmButtonText: i18n.baseText('generic.create'), cancelButtonText: i18n.baseText('generic.cancel'), inputErrorMessage: i18n.baseText('folders.invalidName.message'), - inputPattern: validFolderNameRegex, + inputPattern: VALID_FOLDER_NAME_REGEX, customClass: 'add-folder-modal', }, ); @@ -870,10 +897,10 @@ const createFolder = async (parent: { id: string; name: string; type: 'project' parent.type === 'folder' ? parent.id : undefined, ); - let newFolderURL = `/projects/${route.params.projectId}/folders/${newFolder.id}/workflows`; - if (newFolder.parentFolder) { - newFolderURL = `/projects/${route.params.projectId}/folders/${newFolder.id}/workflows`; - } + const newFolderURL = router.resolve({ + name: VIEWS.PROJECTS_FOLDERS, + params: { projectId: route.params.projectId, folderId: newFolder.id }, + }).href; toast.showToast({ title: i18n.baseText('folders.add.success.title'), 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 await fetchWorkflows(); } + telemetry.track('User created folder', { + folder_id: newFolder.id, + }); } 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'), inputErrorMessage: i18n.baseText('folders.invalidName.message'), inputValue: folder.name, - inputPattern: /^[a-zA-Z0-9-_ ]{1,100}$/, + inputPattern: VALID_FOLDER_NAME_REGEX, customClass: 'rename-folder-modal', }, ); @@ -945,6 +975,9 @@ const renameFolder = async (folderId: string) => { type: 'success', }); await fetchWorkflows(); + telemetry.track('User renamed folder', { + folder_id: folderId, + }); } catch (error) { 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'), 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')); } }; @@ -1094,6 +1221,7 @@ const deleteFolder = async (folderId: string, workflowCount: number, subFolderCo @workflow:moved="fetchWorkflows" @workflow:duplicated="fetchWorkflows" @workflow:active-toggle="onWorkflowActiveToggle" + @action:move-to-folder="moveWorkflowToFolder" />