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

@@ -1,41 +1,80 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { FOLDER_LIST_ITEM_ACTIONS } from './constants';
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 { useRoute, useRouter } from 'vue-router';
import { VIEWS } from '@/constants';
import type { UserAction } from '@/Interface';
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 = {
data: FolderResource;
personalProject: Project | null;
actions: UserAction[];
readOnly?: boolean;
showOwnershipBadge?: boolean;
};
const props = withDefaults(defineProps<Props>(), {
actions: () => [],
readOnly: true,
showOwnershipBadge: false,
});
const i18n = useI18n();
const route = useRoute();
const router = useRouter();
const foldersStore = useFoldersStore();
const emit = defineEmits<{
action: [{ action: string; folderId: string }];
folderOpened: [{ folder: FolderResource }];
}>();
const hiddenBreadcrumbsItemsAsync = ref<Promise<PathItem[]>>(new Promise(() => {}));
const cachedHiddenBreadcrumbsItems = ref<PathItem[]>([]);
const resourceTypeLabel = computed(() => i18n.baseText('generic.folder').toLowerCase());
const cardUrl = computed(() => {
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) => {
return router.resolve({
name: VIEWS.PROJECTS_FOLDERS,
@@ -55,6 +94,29 @@ const onAction = async (action: string) => {
}
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>
<template>
@@ -127,15 +189,38 @@ const onAction = async (action: string) => {
</template>
<template #append>
<div :class="$style['card-actions']" @click.prevent>
<div v-if="data.homeProject" :class="$style['project-pill']">
<div v-if="data.homeProject && showOwnershipBadge">
<ProjectCardBadge
:class="{ [$style.cardBadge]: true, [$style['with-breadcrumbs']]: false }"
:class="{
[$style.cardBadge]: true,
[$style['with-breadcrumbs']]: showCardBreadcrumbs,
}"
:resource="data"
:resource-type="ResourceType.Workflow"
:resource-type-label="resourceTypeLabel"
: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>
<n8n-action-toggle
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 {
display: flex;
gap: var(--spacing-xs);

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
<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 PageViewLayout from '@/components/layouts/PageViewLayout.vue';
@@ -320,9 +320,22 @@ onMounted(async () => {
if (hasAppliedFilters()) {
hasFilters.value = true;
}
window.addEventListener('keydown', captureSearchHotKey);
});
onBeforeUnmount(() => {
window.removeEventListener('keydown', captureSearchHotKey);
});
//methods
const captureSearchHotKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
e.preventDefault();
focusSearchInput();
}
};
const focusSearchInput = () => {
if (search.value) {
search.value.focus();