mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 10:02:05 +00:00
feat: Add nested search in folders (#14372)
Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 & {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user