feat: Add nested search in folders (#14372)

Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
Ricardo Espinoza
2025-04-11 19:17:28 -04:00
committed by GitHub
parent f38bd84fd1
commit cade309d3b
13 changed files with 354 additions and 46 deletions

View File

@@ -490,9 +490,9 @@ describe('Folders', () => {
}); });
}); });
describe('Workflow card breadcrumbs', () => { describe('Card breadcrumbs', () => {
it('should correctly show workflow card breadcrumbs', () => { it('should correctly show workflow card breadcrumbs in overview page', () => {
createNewProject('Test workflow breadcrumbs', { openAfterCreate: true }); createNewProject('Test card breadcrumbs', { openAfterCreate: true });
createFolderFromProjectHeader('Parent Folder'); createFolderFromProjectHeader('Parent Folder');
createFolderInsideFolder('Child Folder', 'Parent Folder'); createFolderInsideFolder('Child Folder', 'Parent Folder');
getFolderCard('Child Folder').click(); getFolderCard('Child Folder').click();
@@ -508,8 +508,31 @@ describe('Folders', () => {
cy.get('[role=tooltip]').should('exist'); cy.get('[role=tooltip]').should('exist');
cy.get('[role=tooltip]').should( cy.get('[role=tooltip]').should(
'contain.text', 'contain.text',
'est workflow breadcrumbs / Parent Folder / Child Folder / Child Folder 2', 'Test card breadcrumbs / Parent Folder / Child Folder / Child Folder 2',
); );
}); });
it('should correctly toggle folder and workflow card breadcrumbs in projects and folders', () => {
createNewProject('Test nested search', { openAfterCreate: true });
createFolderFromProjectHeader('Parent Folder');
getFolderCard('Parent Folder').click();
createWorkflowFromEmptyState('Child - Workflow');
getProjectMenuItem('Test nested search').click();
createFolderInsideFolder('Child Folder', 'Parent Folder');
// Should not show breadcrumbs in the folder if there is no search term
cy.getByTestId('card-badge').should('not.exist');
// Back to project root
getHomeProjectBreadcrumb().click();
// Should not show breadcrumbs in the project if there is no search term
cy.getByTestId('card-badge').should('not.exist');
// Search for something
cy.getByTestId('resources-list-search').type('child', { delay: 20 });
// Both folder and workflow from child folder should be in the results - nested search works
getFolderCards().should('have.length', 1);
getWorkflowCards().should('have.length', 1);
// Card badges with breadcrumbs should be shown
getFolderCard('Child Folder').findChildByTestId('card-badge').should('exist');
getWorkflowCard('Child - Workflow').findChildByTestId('card-badge').should('exist');
});
}); });
}); });

View File

@@ -23,6 +23,9 @@ export class Folder extends WithTimestampsAndStringId {
@Column() @Column()
name: string; name: string;
@Column({ nullable: true, select: false })
parentFolderId: string | null;
@ManyToOne(() => Folder, { nullable: true, onDelete: 'CASCADE' }) @ManyToOne(() => Folder, { nullable: true, onDelete: 'CASCADE' })
@JoinColumn({ name: 'parentFolderId' }) @JoinColumn({ name: 'parentFolderId' })
parentFolder: Folder | null; parentFolder: Folder | null;

View File

@@ -345,6 +345,7 @@ describe('FolderRepository', () => {
expect(childFolder?.parentFolder).toEqual({ expect(childFolder?.parentFolder).toEqual({
id: expect.any(String), id: expect.any(String),
name: 'Parent Folder', name: 'Parent Folder',
parentFolderId: null,
}); });
}); });

View File

@@ -125,7 +125,7 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
} }
private getParentFolderFields(alias: string): string[] { private getParentFolderFields(alias: string): string[] {
return [`${alias}.id`, `${alias}.name`]; return [`${alias}.id`, `${alias}.name`, `${alias}.parentFolderId`];
} }
private applyFilters( private applyFilters(
@@ -334,4 +334,47 @@ export class FolderRepository extends Repository<FolderWithWorkflowAndSubFolderC
// Exclude all children of the specified folder // Exclude all children of the specified folder
query.andWhere(`folder.id NOT IN (${subQuery.getQuery()})`); query.andWhere(`folder.id NOT IN (${subQuery.getQuery()})`);
} }
/**
* Get all folder and subfolder IDs in a hierarchy (including direct children and all descendants)
* @param parentFolderId The ID of the parent folder
* @param projectId Optional project ID to restrict search to a project
* @returns Array of unique folder IDs including direct children and all descendants
*/
async getAllFolderIdsInHierarchy(parentFolderId: string, projectId?: string): Promise<string[]> {
// Start with direct children as the base case
const baseQuery = this.createQueryBuilder('f')
.select('f.id', 'id')
.where('f.parentFolderId = :parentFolderId', { parentFolderId });
// Add project filter if provided
if (projectId) {
baseQuery.andWhere('f.projectId = :projectId', { projectId });
}
// Create the recursive query for descendants
const recursiveQuery = this.createQueryBuilder('child')
.select('child.id', 'id')
.innerJoin('folder_tree', 'parent', 'child.parentFolderId = parent.id');
// Add project filter if provided
if (projectId) {
recursiveQuery.andWhere('child.projectId = :projectId', { projectId });
}
// Create the main query with CTE
const query = this.createQueryBuilder()
.addCommonTableExpression(
`${baseQuery.getQuery()} UNION ALL ${recursiveQuery.getQuery()}`,
'folder_tree',
{ recursive: true },
)
.select('DISTINCT tree.id', 'id')
.from('folder_tree', 'tree')
.setParameters(baseQuery.getParameters());
// Execute the query and extract IDs
const result = await query.getRawMany<{ id: string }>();
return result.map((row) => row.id);
}
} }

View File

@@ -264,6 +264,23 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
} }
async getWorkflowsAndFoldersWithCount(workflowIds: string[], options: ListQuery.Options = {}) { async getWorkflowsAndFoldersWithCount(workflowIds: string[], options: ListQuery.Options = {}) {
if (
options.filter?.parentFolderId &&
typeof options.filter?.parentFolderId === 'string' &&
options.filter.parentFolderId !== PROJECT_ROOT &&
typeof options.filter?.projectId === 'string' &&
options.filter.name
) {
const folderIds = await this.folderRepository.getAllFolderIdsInHierarchy(
options.filter.parentFolderId,
options.filter.projectId,
);
options.filter.parentFolderIds = [options.filter.parentFolderId, ...folderIds];
options.filter.folderIds = folderIds;
delete options.filter.parentFolderId;
}
const [workflowsAndFolders, count] = await Promise.all([ const [workflowsAndFolders, count] = await Promise.all([
this.getWorkflowsAndFoldersUnion(workflowIds, options), this.getWorkflowsAndFoldersUnion(workflowIds, options),
this.getWorkflowsAndFoldersCount(workflowIds, options), this.getWorkflowsAndFoldersCount(workflowIds, options),
@@ -402,6 +419,14 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
qb.andWhere('workflow.parentFolderId = :parentFolderId', { qb.andWhere('workflow.parentFolderId = :parentFolderId', {
parentFolderId: filter.parentFolderId, parentFolderId: filter.parentFolderId,
}); });
} else if (
filter?.parentFolderIds &&
Array.isArray(filter.parentFolderIds) &&
filter.parentFolderIds.length > 0
) {
qb.andWhere('workflow.parentFolderId IN (:...parentFolderIds)', {
parentFolderIds: filter.parentFolderIds,
});
} }
} }
@@ -519,7 +544,11 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
const isParentFolderIncluded = isDefaultSelect || select?.parentFolder; const isParentFolderIncluded = isDefaultSelect || select?.parentFolder;
if (isParentFolderIncluded) { if (isParentFolderIncluded) {
qb.leftJoinAndSelect('workflow.parentFolder', 'parentFolder'); qb.leftJoin('workflow.parentFolder', 'parentFolder').addSelect([
'parentFolder.id',
'parentFolder.name',
'parentFolder.parentFolderId',
]);
} }
if (areTagsEnabled && areTagsRequested) { if (areTagsEnabled && areTagsRequested) {

View File

@@ -1056,8 +1056,7 @@ describe('GET /workflows', () => {
parentFolder: { parentFolder: {
id: folder.id, id: folder.id,
name: folder.name, name: folder.name,
createdAt: expect.any(String), parentFolderId: null,
updatedAt: expect.any(String),
}, },
}, },
{ {
@@ -1615,6 +1614,66 @@ describe('GET /workflows?includeFolders=true', () => {
expect(response2.body.data).toHaveLength(0); expect(response2.body.data).toHaveLength(0);
}); });
test('should filter workflows by parentFolderId and its descendants when filtering by name', async () => {
const pp = await Container.get(ProjectRepository).getPersonalProjectForUserOrFail(owner.id);
await createFolder(pp, {
name: 'Root Folder 1',
});
const rootFolder2 = await createFolder(pp, {
name: 'Root Folder 2',
});
await createFolder(pp, {
name: 'Root Folder 3',
});
const subfolder1 = await createFolder(pp, {
name: 'Root folder 2 subfolder 1 key',
parentFolder: rootFolder2,
});
await createWorkflow(
{
name: 'Workflow 1 key',
parentFolder: rootFolder2,
},
pp,
);
await createWorkflow(
{
name: 'workflow 2 key',
parentFolder: rootFolder2,
},
pp,
);
await createWorkflow(
{
name: 'workflow 3 key',
parentFolder: subfolder1,
},
pp,
);
const filter2Response = await authOwnerAgent
.get('/workflows')
.query(
`filter={ "projectId": "${pp.id}", "parentFolderId": "${rootFolder2.id}", "name": "key" }&includeFolders=true`,
);
expect(filter2Response.body.count).toBe(4);
expect(filter2Response.body.data).toHaveLength(4);
expect(
filter2Response.body.data.filter((w: WorkflowFolderUnionFull) => w.resource === 'workflow'),
).toHaveLength(3);
expect(
filter2Response.body.data.filter((w: WorkflowFolderUnionFull) => w.resource === 'folder'),
).toHaveLength(1);
});
test('should return homeProject when filtering workflows and folders by projectId', async () => { test('should return homeProject when filtering workflows and folders by projectId', async () => {
const workflow = await createWorkflow({ name: 'First' }, owner); const workflow = await createWorkflow({ name: 'First' }, owner);
await shareWorkflowWithUsers(workflow, [member]); await shareWorkflowWithUsers(workflow, [member]);

View File

@@ -348,6 +348,7 @@ export type FolderShortInfo = {
id: string; id: string;
name: string; name: string;
parentFolder?: string; parentFolder?: string;
parentFolderId?: string | null;
}; };
export type BaseFolderItem = BaseResource & { export type BaseFolderItem = BaseResource & {

View File

@@ -1,41 +1,80 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, ref } from 'vue';
import { FOLDER_LIST_ITEM_ACTIONS } from './constants'; import { FOLDER_LIST_ITEM_ACTIONS } from './constants';
import type { FolderResource } from '../layouts/ResourcesListLayout.vue'; import type { FolderResource } from '../layouts/ResourcesListLayout.vue';
import type { Project } from '@/types/projects.types'; import { ProjectTypes, type Project } from '@/types/projects.types';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
import type { UserAction } from '@/Interface'; import type { UserAction } from '@/Interface';
import { ResourceType } from '@/utils/projects.utils'; import { ResourceType } from '@/utils/projects.utils';
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
import { useFoldersStore } from '@/stores/folders.store';
type Props = { type Props = {
data: FolderResource; data: FolderResource;
personalProject: Project | null; personalProject: Project | null;
actions: UserAction[]; actions: UserAction[];
readOnly?: boolean; readOnly?: boolean;
showOwnershipBadge?: boolean;
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
actions: () => [], actions: () => [],
readOnly: true, readOnly: true,
showOwnershipBadge: false,
}); });
const i18n = useI18n(); const i18n = useI18n();
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const foldersStore = useFoldersStore();
const emit = defineEmits<{ const emit = defineEmits<{
action: [{ action: string; folderId: string }]; action: [{ action: string; folderId: string }];
folderOpened: [{ folder: FolderResource }]; folderOpened: [{ folder: FolderResource }];
}>(); }>();
const hiddenBreadcrumbsItemsAsync = ref<Promise<PathItem[]>>(new Promise(() => {}));
const cachedHiddenBreadcrumbsItems = ref<PathItem[]>([]);
const resourceTypeLabel = computed(() => i18n.baseText('generic.folder').toLowerCase()); const resourceTypeLabel = computed(() => i18n.baseText('generic.folder').toLowerCase());
const cardUrl = computed(() => { const cardUrl = computed(() => {
return getFolderUrl(props.data.id); return getFolderUrl(props.data.id);
}); });
const projectName = computed(() => {
if (props.data.homeProject?.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
}
return props.data.homeProject?.name;
});
const cardBreadcrumbs = computed<PathItem[]>(() => {
if (props.data.parentFolder) {
return [
{
id: props.data.parentFolder.id,
name: props.data.parentFolder.name,
label: props.data.parentFolder.name,
href: router.resolve({
name: VIEWS.PROJECTS_FOLDERS,
params: {
projectId: props.data.homeProject?.id,
folderId: props.data.parentFolder.id,
},
}).href,
},
];
}
return [];
});
const showCardBreadcrumbs = computed(() => {
return props.showOwnershipBadge && cardBreadcrumbs.value.length;
});
const getFolderUrl = (folderId: string) => { const getFolderUrl = (folderId: string) => {
return router.resolve({ return router.resolve({
name: VIEWS.PROJECTS_FOLDERS, name: VIEWS.PROJECTS_FOLDERS,
@@ -55,6 +94,29 @@ const onAction = async (action: string) => {
} }
emit('action', { action, folderId: props.data.id }); emit('action', { action, folderId: props.data.id });
}; };
const fetchHiddenBreadCrumbsItems = async () => {
if (!props.data.homeProject?.id || !projectName.value || !props.data.parentFolder) {
hiddenBreadcrumbsItemsAsync.value = Promise.resolve([]);
} else {
if (cachedHiddenBreadcrumbsItems.value.length) {
hiddenBreadcrumbsItemsAsync.value = Promise.resolve(cachedHiddenBreadcrumbsItems.value);
return;
}
const loadedItem = foldersStore.getHiddenBreadcrumbsItems(
{ id: props.data.homeProject.id, name: projectName.value },
props.data.parentFolder.id,
);
hiddenBreadcrumbsItemsAsync.value = loadedItem;
cachedHiddenBreadcrumbsItems.value = await loadedItem;
}
};
const onBreadcrumbItemClick = async (item: PathItem) => {
if (item.href) {
await router.push(item.href);
}
};
</script> </script>
<template> <template>
@@ -127,15 +189,38 @@ const onAction = async (action: string) => {
</template> </template>
<template #append> <template #append>
<div :class="$style['card-actions']" @click.prevent> <div :class="$style['card-actions']" @click.prevent>
<div v-if="data.homeProject" :class="$style['project-pill']"> <div v-if="data.homeProject && showOwnershipBadge">
<ProjectCardBadge <ProjectCardBadge
:class="{ [$style.cardBadge]: true, [$style['with-breadcrumbs']]: false }" :class="{
[$style.cardBadge]: true,
[$style['with-breadcrumbs']]: showCardBreadcrumbs,
}"
:resource="data" :resource="data"
:resource-type="ResourceType.Workflow" :resource-type="ResourceType.Workflow"
:resource-type-label="resourceTypeLabel" :resource-type-label="resourceTypeLabel"
:personal-project="personalProject" :personal-project="personalProject"
:show-badge-border="true" :show-badge-border="false"
/> >
<div v-if="showCardBreadcrumbs" :class="$style.breadcrumbs">
<n8n-breadcrumbs
:items="cardBreadcrumbs"
:hidden-items="
data.parentFolder?.parentFolderId !== null
? hiddenBreadcrumbsItemsAsync
: undefined
"
:path-truncated="data.parentFolder?.parentFolderId !== null"
:highlight-last-item="false"
hidden-items-trigger="hover"
theme="small"
data-test-id="folder-card-breadcrumbs"
@tooltip-opened="fetchHiddenBreadCrumbsItems"
@item-selected="onBreadcrumbItemClick"
>
<template #prepend></template>
</n8n-breadcrumbs>
</div>
</ProjectCardBadge>
</div> </div>
<n8n-action-toggle <n8n-action-toggle
v-if="actions.length" v-if="actions.length"
@@ -191,6 +276,15 @@ const onAction = async (action: string) => {
} }
} }
.cardBadge.with-breadcrumbs {
:global(.n8n-badge) {
padding-right: 0;
}
:global(.n8n-breadcrumbs) {
padding-left: var(--spacing-5xs);
}
}
.card-actions { .card-actions {
display: flex; display: flex;
gap: var(--spacing-xs); gap: var(--spacing-xs);

View File

@@ -118,7 +118,9 @@ describe('WorkflowCard', () => {
name: projectName, name: projectName,
}, },
}); });
const { getByRole, getByTestId } = renderComponent({ props: { data } }); const { getByRole, getByTestId } = renderComponent({
props: { data, showOwnershipBadge: true },
});
const heading = getByRole('heading'); const heading = getByRole('heading');
const badge = getByTestId('card-badge'); const badge = getByTestId('card-badge');
@@ -134,7 +136,9 @@ describe('WorkflowCard', () => {
name: projectName, name: projectName,
}, },
}); });
const { getByRole, getByTestId } = renderComponent({ props: { data } }); const { getByRole, getByTestId } = renderComponent({
props: { data, showOwnershipBadge: true },
});
const heading = getByRole('heading'); const heading = getByRole('heading');
const badge = getByTestId('card-badge'); const badge = getByTestId('card-badge');

View File

@@ -44,10 +44,12 @@ const props = withDefaults(
data: WorkflowResource; data: WorkflowResource;
readOnly?: boolean; readOnly?: boolean;
workflowListEventBus?: EventBus; workflowListEventBus?: EventBus;
showOwnershipBadge?: boolean;
}>(), }>(),
{ {
readOnly: false, readOnly: false,
workflowListEventBus: undefined, workflowListEventBus: undefined,
showOwnershipBadge: false,
}, },
); );
@@ -74,18 +76,18 @@ const projectsStore = useProjectsStore();
const foldersStore = useFoldersStore(); const foldersStore = useFoldersStore();
const hiddenBreadcrumbsItemsAsync = ref<Promise<PathItem[]>>(new Promise(() => {})); const hiddenBreadcrumbsItemsAsync = ref<Promise<PathItem[]>>(new Promise(() => {}));
const cachedHiddenBreadcrumbsItems = ref<PathItem[]>([]);
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 isOverviewPage = computed(() => route.name === VIEWS.WORKFLOWS);
const showFolders = computed(() => { const showFolders = computed(() => {
return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS; return settingsStore.isFoldersFeatureEnabled && route.name !== VIEWS.WORKFLOWS;
}); });
const showCardBreadcrumbs = computed(() => { const showCardBreadcrumbs = computed(() => {
return isOverviewPage.value && !isSomeoneElsesWorkflow.value && cardBreadcrumbs.value.length; return props.showOwnershipBadge && !isSomeoneElsesWorkflow.value && cardBreadcrumbs.value.length;
}); });
const projectName = computed(() => { const projectName = computed(() => {
@@ -289,10 +291,16 @@ const fetchHiddenBreadCrumbsItems = async () => {
if (!props.data.homeProject?.id || !projectName.value || !props.data.parentFolder) { if (!props.data.homeProject?.id || !projectName.value || !props.data.parentFolder) {
hiddenBreadcrumbsItemsAsync.value = Promise.resolve([]); hiddenBreadcrumbsItemsAsync.value = Promise.resolve([]);
} else { } else {
hiddenBreadcrumbsItemsAsync.value = foldersStore.getHiddenBreadcrumbsItems( if (cachedHiddenBreadcrumbsItems.value.length) {
hiddenBreadcrumbsItemsAsync.value = Promise.resolve(cachedHiddenBreadcrumbsItems.value);
return;
}
const loadedItem = foldersStore.getHiddenBreadcrumbsItems(
{ id: props.data.homeProject.id, name: projectName.value }, { id: props.data.homeProject.id, name: projectName.value },
props.data.parentFolder.id, props.data.parentFolder.id,
); );
hiddenBreadcrumbsItemsAsync.value = loadedItem;
cachedHiddenBreadcrumbsItems.value = await loadedItem;
} }
}; };
@@ -356,6 +364,7 @@ const onBreadcrumbItemClick = async (item: PathItem) => {
<template #append> <template #append>
<div :class="$style.cardActions" @click.stop> <div :class="$style.cardActions" @click.stop>
<ProjectCardBadge <ProjectCardBadge
v-if="showOwnershipBadge"
:class="{ [$style.cardBadge]: true, [$style['with-breadcrumbs']]: showCardBreadcrumbs }" :class="{ [$style.cardBadge]: true, [$style['with-breadcrumbs']]: showCardBreadcrumbs }"
:resource="data" :resource="data"
:resource-type="ResourceType.Workflow" :resource-type="ResourceType.Workflow"
@@ -366,8 +375,10 @@ const onBreadcrumbItemClick = async (item: PathItem) => {
<div v-if="showCardBreadcrumbs" :class="$style.breadcrumbs"> <div v-if="showCardBreadcrumbs" :class="$style.breadcrumbs">
<n8n-breadcrumbs <n8n-breadcrumbs
:items="cardBreadcrumbs" :items="cardBreadcrumbs"
:hidden-items="hiddenBreadcrumbsItemsAsync" :hidden-items="
:path-truncated="true" data.parentFolder?.parentFolderId !== null ? hiddenBreadcrumbsItemsAsync : undefined
"
:path-truncated="data.parentFolder?.parentFolderId !== null"
:highlight-last-item="false" :highlight-last-item="false"
hidden-items-trigger="hover" hidden-items-trigger="hover"
theme="small" theme="small"

View File

@@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, nextTick, ref, onMounted, watch } from 'vue'; import { computed, nextTick, ref, onMounted, watch, onBeforeUnmount } from 'vue';
import { type ProjectSharingData } from '@/types/projects.types'; import { type ProjectSharingData } from '@/types/projects.types';
import PageViewLayout from '@/components/layouts/PageViewLayout.vue'; import PageViewLayout from '@/components/layouts/PageViewLayout.vue';
@@ -320,9 +320,22 @@ onMounted(async () => {
if (hasAppliedFilters()) { if (hasAppliedFilters()) {
hasFilters.value = true; hasFilters.value = true;
} }
window.addEventListener('keydown', captureSearchHotKey);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', captureSearchHotKey);
}); });
//methods //methods
const captureSearchHotKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
focusSearchInput();
}
};
const focusSearchInput = () => { const focusSearchInput = () => {
if (search.value) { if (search.value) {
search.value.focus(); search.value.focus();

View File

@@ -11,6 +11,8 @@ import { useRootStore } from './root.store';
import { ref } from 'vue'; import { ref } from 'vue';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
const BREADCRUMBS_MIN_LOADING_TIME = 300;
export const useFoldersStore = defineStore(STORES.FOLDERS, () => { export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
const rootStore = useRootStore(); const rootStore = useRootStore();
const i18n = useI18n(); const i18n = useI18n();
@@ -175,22 +177,9 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
project: { id: string; name: string }, project: { id: string; name: string },
folderId: string, folderId: string,
) { ) {
const startTime = Date.now();
const path = await getFolderPath(project.id, folderId); const path = await getFolderPath(project.id, folderId);
if (path.length === 0) {
// Even when path is empty, include the project item
return [
{
id: project.id,
label: project.name,
},
{
id: '-1',
label: i18n.baseText('folders.breadcrumbs.noTruncated.message'),
},
];
}
// Process a folder and all its nested children recursively // Process a folder and all its nested children recursively
const processFolderWithChildren = ( const processFolderWithChildren = (
folder: FolderTreeResponseItem, folder: FolderTreeResponseItem,
@@ -223,18 +212,44 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
result.push(...childItems); result.push(...childItems);
} }
return result; return result;
}; };
// Start with the project item, then add all processed folders // Prepare the result
return [ let result;
{ if (path.length === 0) {
id: project.id, // Even when path is empty, include the project item
label: project.name, result = [
}, {
...path.flatMap(processFolderWithChildren), id: project.id,
]; label: project.name,
},
{
id: '-1',
label: i18n.baseText('folders.breadcrumbs.noTruncated.message'),
},
];
} else {
// Start with the project item, then add all processed folders
result = [
{
id: project.id,
label: project.name,
},
...path.flatMap(processFolderWithChildren),
];
}
// Calculate how much time has elapsed
const elapsedTime = Date.now() - startTime;
const remainingTime = Math.max(0, BREADCRUMBS_MIN_LOADING_TIME - elapsedTime);
// Add a delay if needed to ensure minimum loading time
if (remainingTime > 0) {
await new Promise((resolve) => setTimeout(resolve, remainingTime));
}
return result;
} }
return { return {

View File

@@ -64,6 +64,7 @@ import { useUsageStore } from '@/stores/usage.store';
import { useInsightsStore } from '@/features/insights/insights.store'; import { useInsightsStore } from '@/features/insights/insights.store';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue'; import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useOverview } from '@/composables/useOverview'; import { useOverview } from '@/composables/useOverview';
import { PROJECT_ROOT } from 'n8n-workflow';
const SEARCH_DEBOUNCE_TIME = 300; const SEARCH_DEBOUNCE_TIME = 300;
const FILTERS_DEBOUNCE_TIME = 100; const FILTERS_DEBOUNCE_TIME = 100;
@@ -132,6 +133,8 @@ const currentSort = ref('updatedAt:desc');
const currentFolderId = ref<string | null>(null); const currentFolderId = ref<string | null>(null);
const showCardsBadge = ref(false);
/** /**
* Folder actions * Folder actions
* These can appear on the list header, and then they are applied to current folder * These can appear on the list header, and then they are applied to current folder
@@ -352,6 +355,7 @@ watch(
() => route.params?.folderId, () => route.params?.folderId,
async (newVal) => { async (newVal) => {
currentFolderId.value = newVal as string; currentFolderId.value = newVal as string;
filters.value.search = '';
await fetchWorkflows(); await fetchWorkflows();
}, },
); );
@@ -478,7 +482,9 @@ const fetchWorkflows = async () => {
name: filters.value.search || undefined, name: filters.value.search || undefined,
active: activeFilter, active: activeFilter,
tags: tags.length ? tags : undefined, tags: tags.length ? tags : undefined,
parentFolderId: parentFolder ?? (isOverviewPage.value ? undefined : '0'), // Sending 0 will only show one level of folders parentFolderId:
parentFolder ??
(isOverviewPage.value ? undefined : filters?.value.search ? undefined : PROJECT_ROOT), // Sending 0 will only show one level of folders
}, },
fetchFolders, fetchFolders,
); );
@@ -499,6 +505,10 @@ const fetchWorkflows = async () => {
} }
workflowsAndFolders.value = fetchedResources; workflowsAndFolders.value = fetchedResources;
// Toggle ownership cards visibility only after we have fetched the workflows
showCardsBadge.value = isOverviewPage.value || filters.value.search !== '';
return fetchedResources; return fetchedResources;
} catch (error) { } catch (error) {
toast.showError(error, i18n.baseText('workflows.list.error.fetching')); toast.showError(error, i18n.baseText('workflows.list.error.fetching'));
@@ -1320,6 +1330,7 @@ const onCreateWorkflowClick = () => {
:actions="folderCardActions" :actions="folderCardActions"
:read-only="readOnlyEnv || (!hasPermissionToDeleteFolders && !hasPermissionToCreateFolders)" :read-only="readOnlyEnv || (!hasPermissionToDeleteFolders && !hasPermissionToCreateFolders)"
:personal-project="projectsStore.personalProject" :personal-project="projectsStore.personalProject"
:show-ownership-badge="showCardsBadge"
class="mb-2xs" class="mb-2xs"
@action="onFolderCardAction" @action="onFolderCardAction"
/> />
@@ -1331,6 +1342,7 @@ const onCreateWorkflowClick = () => {
:data="data as WorkflowResource" :data="data as WorkflowResource"
:workflow-list-event-bus="workflowListEventBus" :workflow-list-event-bus="workflowListEventBus"
:read-only="readOnlyEnv" :read-only="readOnlyEnv"
:show-ownership-badge="showCardsBadge"
@click:tag="onClickTag" @click:tag="onClickTag"
@workflow:deleted="onWorkflowDeleted" @workflow:deleted="onWorkflowDeleted"
@workflow:moved="fetchWorkflows" @workflow:moved="fetchWorkflows"