mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +00:00
feat: Add nested search in folders (#14372)
Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
@@ -348,6 +348,7 @@ export type FolderShortInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
parentFolder?: string;
|
||||
parentFolderId?: string | null;
|
||||
};
|
||||
|
||||
export type BaseFolderItem = BaseResource & {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useRootStore } from './root.store';
|
||||
import { ref } from 'vue';
|
||||
import { useI18n } from '@/composables/useI18n';
|
||||
|
||||
const BREADCRUMBS_MIN_LOADING_TIME = 300;
|
||||
|
||||
export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
||||
const rootStore = useRootStore();
|
||||
const i18n = useI18n();
|
||||
@@ -175,22 +177,9 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
||||
project: { id: string; name: string },
|
||||
folderId: string,
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
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
|
||||
const processFolderWithChildren = (
|
||||
folder: FolderTreeResponseItem,
|
||||
@@ -223,18 +212,44 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
||||
|
||||
result.push(...childItems);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Start with the project item, then add all processed folders
|
||||
return [
|
||||
{
|
||||
id: project.id,
|
||||
label: project.name,
|
||||
},
|
||||
...path.flatMap(processFolderWithChildren),
|
||||
];
|
||||
// Prepare the result
|
||||
let result;
|
||||
if (path.length === 0) {
|
||||
// Even when path is empty, include the project item
|
||||
result = [
|
||||
{
|
||||
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 {
|
||||
|
||||
@@ -64,6 +64,7 @@ import { useUsageStore } from '@/stores/usage.store';
|
||||
import { useInsightsStore } from '@/features/insights/insights.store';
|
||||
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
|
||||
import { useOverview } from '@/composables/useOverview';
|
||||
import { PROJECT_ROOT } from 'n8n-workflow';
|
||||
|
||||
const SEARCH_DEBOUNCE_TIME = 300;
|
||||
const FILTERS_DEBOUNCE_TIME = 100;
|
||||
@@ -132,6 +133,8 @@ const currentSort = ref('updatedAt:desc');
|
||||
|
||||
const currentFolderId = ref<string | null>(null);
|
||||
|
||||
const showCardsBadge = ref(false);
|
||||
|
||||
/**
|
||||
* Folder actions
|
||||
* These can appear on the list header, and then they are applied to current folder
|
||||
@@ -352,6 +355,7 @@ watch(
|
||||
() => route.params?.folderId,
|
||||
async (newVal) => {
|
||||
currentFolderId.value = newVal as string;
|
||||
filters.value.search = '';
|
||||
await fetchWorkflows();
|
||||
},
|
||||
);
|
||||
@@ -478,7 +482,9 @@ const fetchWorkflows = async () => {
|
||||
name: filters.value.search || undefined,
|
||||
active: activeFilter,
|
||||
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,
|
||||
);
|
||||
@@ -499,6 +505,10 @@ const fetchWorkflows = async () => {
|
||||
}
|
||||
|
||||
workflowsAndFolders.value = fetchedResources;
|
||||
|
||||
// Toggle ownership cards visibility only after we have fetched the workflows
|
||||
showCardsBadge.value = isOverviewPage.value || filters.value.search !== '';
|
||||
|
||||
return fetchedResources;
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('workflows.list.error.fetching'));
|
||||
@@ -1320,6 +1330,7 @@ const onCreateWorkflowClick = () => {
|
||||
:actions="folderCardActions"
|
||||
:read-only="readOnlyEnv || (!hasPermissionToDeleteFolders && !hasPermissionToCreateFolders)"
|
||||
:personal-project="projectsStore.personalProject"
|
||||
:show-ownership-badge="showCardsBadge"
|
||||
class="mb-2xs"
|
||||
@action="onFolderCardAction"
|
||||
/>
|
||||
@@ -1331,6 +1342,7 @@ const onCreateWorkflowClick = () => {
|
||||
:data="data as WorkflowResource"
|
||||
:workflow-list-event-bus="workflowListEventBus"
|
||||
:read-only="readOnlyEnv"
|
||||
:show-ownership-badge="showCardsBadge"
|
||||
@click:tag="onClickTag"
|
||||
@workflow:deleted="onWorkflowDeleted"
|
||||
@workflow:moved="fetchWorkflows"
|
||||
|
||||
Reference in New Issue
Block a user