From e860dd6d2eb6a2dac5126bc60006d53b53115a68 Mon Sep 17 00:00:00 2001 From: Jaakko Husso Date: Wed, 28 May 2025 23:41:07 +0300 Subject: [PATCH] feat(editor): Combine 'Move to Folder' and 'Change owner' modals (#15756) This new modal also allows transferring entire folders to other projects and users. --- cypress/composables/folders.ts | 39 +- cypress/composables/projects.ts | 3 + cypress/e2e/39-projects.cy.ts | 104 +-- cypress/e2e/49-folders.cy.ts | 2 +- cypress/pages/workflows.ts | 2 +- packages/frontend/editor-ui/src/Interface.ts | 9 +- .../editor-ui/src/api/workflows.ee.ts | 20 + .../frontend/editor-ui/src/api/workflows.ts | 25 +- .../components/Folders/DeleteFolderModal.vue | 11 +- .../Folders/MoveToFolderDropdown.vue | 213 +++-- .../Folders/MoveToFolderModal.test.ts | 728 +++++++++++++++++- .../components/Folders/MoveToFolderModal.vue | 423 +++++++++- .../editor-ui/src/components/Modal.vue | 8 + .../components/Projects/ProjectCardBadge.vue | 17 +- .../Projects/ProjectMoveResourceModal.test.ts | 1 + .../Projects/ProjectMoveResourceModal.vue | 13 +- .../ProjectMoveSuccessToastMessage.vue | 6 +- .../components/Projects/ProjectSharing.vue | 29 +- .../src/components/WorkflowCard.test.ts | 108 ++- .../editor-ui/src/components/WorkflowCard.vue | 26 +- .../src/plugins/i18n/locales/en.json | 22 +- .../src/stores/folders.store.test.ts | 208 +++++ .../editor-ui/src/stores/folders.store.ts | 114 ++- .../editor-ui/src/stores/projects.store.ts | 2 + .../frontend/editor-ui/src/stores/ui.store.ts | 8 +- .../editor-ui/src/utils/projects.utils.ts | 9 + .../editor-ui/src/views/WorkflowsView.vue | 131 +++- 27 files changed, 1989 insertions(+), 292 deletions(-) create mode 100644 packages/frontend/editor-ui/src/stores/folders.store.test.ts diff --git a/cypress/composables/folders.ts b/cypress/composables/folders.ts index 41e94f96114..ae53499d8b0 100644 --- a/cypress/composables/folders.ts +++ b/cypress/composables/folders.ts @@ -206,6 +206,14 @@ export function getMoveToFolderInput() { return getMoveToFolderDropdown().find('input'); } +export function getProjectSharingInput() { + return cy.getByTestId('project-sharing-select'); +} + +export function getProjectSharingOption(name: string) { + return cy.getByTestId('project-sharing-info').contains(name); +} + export function getEmptyFolderDropdownMessage(text: string) { return cy.get('.el-select-dropdown__empty').contains(text); } @@ -500,7 +508,12 @@ function deleteFolderAndMoveContents(folderName: string, destinationName: string function moveFolder(folderName: string, destinationName: string) { cy.intercept('PATCH', '/rest/projects/**').as('moveFolder'); getMoveFolderModal().should('be.visible'); - getMoveFolderModal().find('h1').first().contains(`Move "${folderName}" to another folder`); + getMoveFolderModal().find('h1').first().contains(`Move folder ${folderName}`); + + // The dropdown focuses after a small delay (once modal's slide in animation is done). + // On the component we listen for an event, but here the wait should be very predictable. + cy.wait(500); + // Try to find current folder in the dropdown // This tests that auto-focus worked as expected cy.focused().type(folderName, { delay: 50 }); @@ -514,3 +527,27 @@ function moveFolder(folderName: string, destinationName: string) { getMoveFolderConfirmButton().should('be.enabled').click(); cy.wait('@moveFolder'); } + +export function transferWorkflow( + workflowName: string, + projectName: string, + destinationFolder?: string, +) { + getMoveFolderModal().should('be.visible'); + getMoveFolderModal().find('h1').first().contains(`Move workflow ${workflowName}`); + + cy.wait(500); + + getProjectSharingInput().should('be.visible').click(); + cy.focused().type(projectName, { delay: 50 }); + getProjectSharingOption(projectName).should('be.visible').click(); + + if (destinationFolder) { + getMoveToFolderInput().click(); + // Select destination folder + cy.focused().type(destinationFolder, { delay: 50 }); + getMoveToFolderOption(destinationFolder).should('be.visible').click(); + } + + getMoveFolderConfirmButton().should('be.enabled').click(); +} diff --git a/cypress/composables/projects.ts b/cypress/composables/projects.ts index 09d7a341cea..1a87e1bc7ca 100644 --- a/cypress/composables/projects.ts +++ b/cypress/composables/projects.ts @@ -5,6 +5,7 @@ const workflowPage = new WorkflowPage(); const credentialsModal = new CredentialsModal(); export const getHomeButton = () => cy.getByTestId('project-home-menu-item'); +export const getPersonalProjectsButton = () => cy.getByTestId('project-personal-menu-item'); export const getMenuItems = () => cy.getByTestId('project-menu-item'); export const getAddProjectButton = () => { cy.getByTestId('universal-add').should('be.visible').click(); @@ -62,6 +63,8 @@ export const addProjectMember = (email: string, role?: string) => { }; export const getResourceMoveModal = () => cy.getByTestId('project-move-resource-modal'); export const getProjectMoveSelect = () => cy.getByTestId('project-move-resource-modal-select'); +export const getProjectSharingSelect = () => cy.getByTestId('project-sharing-select'); +export const getMoveToFolderSelect = () => cy.getByTestId('move-to-folder-dropdown'); export function createProject(name: string) { getAddProjectButton().click(); diff --git a/cypress/e2e/39-projects.cy.ts b/cypress/e2e/39-projects.cy.ts index 7c54b367dac..cd67dcff4fc 100644 --- a/cypress/e2e/39-projects.cy.ts +++ b/cypress/e2e/39-projects.cy.ts @@ -1,4 +1,5 @@ import { createResource } from '../composables/create'; +import { transferWorkflow } from '../composables/folders'; import { setCredentialValues } from '../composables/modals/credential-modal'; import { clickCreateNewCredential, @@ -37,6 +38,7 @@ describe('Projects', { disableAutoLogin: true }, () => { beforeEach(() => { cy.resetDatabase(); cy.enableFeature('sharing'); + cy.enableFeature('folders'); cy.enableFeature('advancedPermissions'); cy.enableFeature('projectRole:admin'); cy.enableFeature('projectRole:editor'); @@ -246,21 +248,14 @@ describe('Projects', { disableAutoLogin: true }, () => { projects.getProjectSettingsSaveButton().click(); // Move the workflow from Home to Project 1 - projects.getHomeButton().click(); + projects.getPersonalProjectsButton().click(); workflowsPage.getters.workflowCards().should('have.length', 1); - workflowsPage.getters.workflowCards().filter(':contains("Personal")').should('exist'); workflowsPage.getters.workflowCardActions('My workflow').click(); workflowsPage.getters.workflowMoveButton().click(); - projects - .getResourceMoveModal() - .should('be.visible') - .contains('button', 'Move workflow') - .should('be.disabled'); - projects.getProjectMoveSelect().click(); - getVisibleSelect().find('li').should('have.length', 4); - getVisibleSelect().find('li').filter(':contains("Project 1")').click(); - projects.getResourceMoveModal().contains('button', 'Move workflow').click(); + transferWorkflow('My workflow', 'Project 1', 'No folder (project root)'); + + projects.getMenuItems().filter(':contains("Project 1")').click(); clearNotifications(); cy.wait('@getResources'); @@ -396,6 +391,7 @@ describe('Projects', { disableAutoLogin: true }, () => { before(() => { cy.resetDatabase(); cy.enableFeature('sharing'); + cy.enableFeature('folders'); cy.enableFeature('advancedPermissions'); cy.enableFeature('projectRole:admin'); cy.enableFeature('projectRole:editor'); @@ -453,93 +449,35 @@ describe('Projects', { disableAutoLogin: true }, () => { cy.signinAsOwner(); cy.visit(workflowsPage.url); - projects.getHomeButton().click(); - workflowsPage.getters.workflowCards().should('have.length', 3); - workflowsPage.getters.workflowCards().filter(':contains("Personal")').should('exist'); + projects.getPersonalProjectsButton().click(); + workflowsPage.getters.workflowCards().should('have.length', 1); workflowsPage.getters.workflowCardActions('Workflow in Home project').click(); workflowsPage.getters.workflowMoveButton().click(); - projects - .getResourceMoveModal() - .should('be.visible') - .contains('button', 'Move workflow') - .should('be.disabled'); - projects.getProjectMoveSelect().click(); - getVisibleSelect().find('li').should('have.length', 5); - getVisibleSelect().find('li').filter(':contains("Project 1")').click(); - projects.getResourceMoveModal().contains('button', 'Move workflow').click(); + transferWorkflow('Workflow in Home project', 'Project 2', 'No folder (project root)'); clearNotifications(); - cy.wait('@getResources'); - workflowsPage.getters.workflowCards().should('have.length', 3); - workflowsPage.getters.workflowCards().filter(':contains("Personal")').should('not.exist'); + workflowsPage.getters.workflowCards().should('have.length', 0); cy.log('Move the workflow from Project 1 to Project 2'); projects.getMenuItems().first().click(); - workflowsPage.getters.workflowCards().should('have.length', 2); - workflowsPage.getters.workflowCardActions('Workflow in Home project').click(); + workflowsPage.getters.workflowCards().should('have.length', 1); + workflowsPage.getters.workflowCardActions('Workflow in Project 1').click(); workflowsPage.getters.workflowMoveButton().click(); - projects - .getResourceMoveModal() - .should('be.visible') - .contains('button', 'Move workflow') - .should('be.disabled'); - projects.getProjectMoveSelect().click(); - getVisibleSelect().find('li').should('have.length', 5); - getVisibleSelect().find('li').filter(':contains("Project 2")').click(); - projects.getResourceMoveModal().contains('button', 'Move workflow').click(); + transferWorkflow('Workflow in Project 1', 'Project 2', 'No folder (project root)'); clearNotifications(); cy.log('Move the workflow from Project 2 to a member user'); projects.getMenuItems().last().click(); + workflowsPage.getters.workflowCards().should('have.length', 3); + workflowsPage.getters.workflowCardActions('Workflow in Home project').click(); + workflowsPage.getters.workflowMoveButton().click(); + + transferWorkflow('Workflow in Home project', INSTANCE_MEMBERS[0].email); + clearNotifications(); + workflowsPage.getters.workflowCards().should('have.length', 2); - workflowsPage.getters.workflowCardActions('Workflow in Home project').click(); - workflowsPage.getters.workflowMoveButton().click(); - - projects - .getResourceMoveModal() - .should('be.visible') - .contains('button', 'Move workflow') - .should('be.disabled'); - projects.getProjectMoveSelect().click(); - getVisibleSelect().find('li').should('have.length', 5); - getVisibleSelect().find('li').filter(`:contains("${INSTANCE_MEMBERS[0].email}")`).click(); - - projects.getResourceMoveModal().contains('button', 'Move workflow').click(); - clearNotifications(); - cy.wait('@getResources'); - - workflowsPage.getters.workflowCards().should('have.length', 1); - - cy.log('Move the workflow from member user back to Home'); - projects.getHomeButton().click(); - workflowsPage.getters.workflowCards().should('have.length', 3); - workflowsPage.getters - .workflowCards() - .filter(':has([data-test-id="card-badge"]:contains("Project"))') - .should('have.length', 2); - workflowsPage.getters.workflowCardActions('Workflow in Home project').click(); - workflowsPage.getters.workflowMoveButton().click(); - - projects - .getResourceMoveModal() - .should('be.visible') - .contains('button', 'Move workflow') - .should('be.disabled'); - projects.getProjectMoveSelect().click(); - getVisibleSelect().find('li').should('have.length', 5); - getVisibleSelect().find('li').filter(`:contains("${INSTANCE_OWNER.email}")`).click(); - - projects.getResourceMoveModal().contains('button', 'Move workflow').click(); - clearNotifications(); - cy.wait('@getResources'); - - workflowsPage.getters.workflowCards().should('have.length', 3); - workflowsPage.getters - .workflowCards() - .filter(':contains("Personal")') - .should('have.length', 1); }); it('should move the credential to expected projects', () => { diff --git a/cypress/e2e/49-folders.cy.ts b/cypress/e2e/49-folders.cy.ts index 061ddcb7dc3..3d71258fb41 100644 --- a/cypress/e2e/49-folders.cy.ts +++ b/cypress/e2e/49-folders.cy.ts @@ -465,7 +465,7 @@ describe('Folders', () => { goToPersonalProject(); createFolderFromProjectHeader('Test parent'); createFolderInsideFolder('Move me to root', 'Test parent'); - moveFolderFromFolderCardActions('Move me to root', 'Personal'); + moveFolderFromFolderCardActions('Move me to root', 'No folder (project root)'); // Parent folder should be empty getFolderEmptyState().should('exist'); // Child folder should be in the root diff --git a/cypress/pages/workflows.ts b/cypress/pages/workflows.ts index ab88e656468..644b596f8cd 100644 --- a/cypress/pages/workflows.ts +++ b/cypress/pages/workflows.ts @@ -43,7 +43,7 @@ export class WorkflowsPage extends BasePage { workflowDeleteButton: () => cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'), workflowMoveButton: () => - cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Change owner'), + cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Move'), workflowFilterButton: () => cy.getByTestId('resources-list-filters-trigger').filter(':visible'), workflowTagsDropdown: () => cy.getByTestId('tags-dropdown'), workflowTagItem: (tag: string) => cy.getByTestId('tag').contains(tag), diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 680e6a9eef6..598339ddb2a 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -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< diff --git a/packages/frontend/editor-ui/src/api/workflows.ee.ts b/packages/frontend/editor-ui/src/api/workflows.ee.ts index 7ebfe38aaae..93a1e8f6795 100644 --- a/packages/frontend/editor-ui/src/api/workflows.ee.ts +++ b/packages/frontend/editor-ui/src/api/workflows.ee.ts @@ -23,3 +23,23 @@ export async function moveWorkflowToProject( ): Promise { 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 { + return await makeRestApiRequest( + context, + 'PUT', + `/projects/${projectId}/folders/${folderId}/transfer`, + { + destinationProjectId, + destinationParentFolderId: destinationParentFolderId ?? '0', + shareCredentials, + }, + ); +} diff --git a/packages/frontend/editor-ui/src/api/workflows.ts b/packages/frontend/editor-ui/src/api/workflows.ts index f9848f43427..d25019d2f10 100644 --- a/packages/frontend/editor-ui/src/api/workflows.ts +++ b/packages/frontend/editor-ui/src/api/workflows.ts @@ -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 { - const res = await getFullApiResponse( + select?: string[], +): Promise<{ data: ChangeLocationSearchResponseItem[]; count: number }> { + const res = await getFullApiResponse( 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 { + const res = await getFullApiResponse( + context, + 'GET', + `/projects/${projectId}/folders/${folderId}/credentials`, + ); 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 daa9b3eead0..f4cbbe08e07 100644 --- a/packages/frontend/editor-ui/src/components/Folders/DeleteFolderModal.vue +++ b/packages/frontend/editor-ui/src/components/Folders/DeleteFolderModal.vue @@ -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(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; }; @@ -180,8 +181,10 @@ const onFolderSelected = (payload: { id: string; name: string; type: 'folder' | }} diff --git a/packages/frontend/editor-ui/src/components/Folders/MoveToFolderDropdown.vue b/packages/frontend/editor-ui/src/components/Folders/MoveToFolderDropdown.vue index aafa70a0865..662a3ff92c4 100644 --- a/packages/frontend/editor-ui/src/components/Folders/MoveToFolderDropdown.vue +++ b/packages/frontend/editor-ui/src/components/Folders/MoveToFolderDropdown.vue @@ -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(), { - 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>(); -const selectedFolderId = ref(null); const availableLocations = ref([]); +const moveFolderDropdown = ref>(); +const selectedLocationId = computed({ + 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(() => { - 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; +};