mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-30 18:01:23 +08:00
refine frontend
This commit is contained in:
@@ -32,6 +32,7 @@ import {
|
||||
} from '@/features/collaboration/projects/projects.types';
|
||||
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
|
||||
import { useFoldersStore } from '@/features/core/folders/folders.store';
|
||||
import { useFavoritesStore } from '@/app/stores/favorites.store';
|
||||
|
||||
import {
|
||||
N8nActionToggle,
|
||||
@@ -61,6 +62,7 @@ const WORKFLOW_LIST_ITEM_ACTIONS = {
|
||||
ENABLE_MCP_ACCESS: 'enableMCPAccess',
|
||||
REMOVE_MCP_ACCESS: 'removeMCPAccess',
|
||||
UNPUBLISH: 'unpublish',
|
||||
TOGGLE_FAVORITE: 'toggleFavorite',
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -118,6 +120,7 @@ const workflowsListStore = useWorkflowsListStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const foldersStore = useFoldersStore();
|
||||
const mcpStore = useMCPStore();
|
||||
const favoritesStore = useFavoritesStore();
|
||||
const workflowActivate = useWorkflowActivate();
|
||||
const hiddenBreadcrumbsItemsAsync = ref<Promise<PathItem[]>>(new Promise(() => {}));
|
||||
const cachedHiddenBreadcrumbsItems = ref<PathItem[]>([]);
|
||||
@@ -188,6 +191,13 @@ const actions = computed(() => {
|
||||
},
|
||||
];
|
||||
|
||||
items.push({
|
||||
label: favoritesStore.isFavorite(props.data.id)
|
||||
? locale.baseText('favorites.remove')
|
||||
: locale.baseText('favorites.add'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.TOGGLE_FAVORITE,
|
||||
});
|
||||
|
||||
if (
|
||||
workflowPermissions.value.read &&
|
||||
canCreateWorkflow.value &&
|
||||
@@ -392,6 +402,9 @@ async function onAction(action: string) {
|
||||
case WORKFLOW_LIST_ITEM_ACTIONS.UNPUBLISH:
|
||||
await unpublishWorkflow();
|
||||
break;
|
||||
case WORKFLOW_LIST_ITEM_ACTIONS.TOGGLE_FAVORITE:
|
||||
await favoritesStore.toggleFavorite(props.data.id, 'workflow');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import { N8nIcon, N8nMenuItem, N8nPopover, N8nText } from '@n8n/design-system';
|
||||
import type { IMenuItem } from '@n8n/design-system/types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { VIEWS } from '@/app/constants';
|
||||
import { useFavoritesStore } from '@/app/stores/favorites.store';
|
||||
import { useProjectsStore } from '../projects.store';
|
||||
import { DATA_TABLE_DETAILS } from '@/features/core/dataTable/constants';
|
||||
|
||||
const locale = useI18n();
|
||||
const favoritesStore = useFavoritesStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
|
||||
const show = ref(false);
|
||||
let closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function onMouseEnter() {
|
||||
if (closeTimer !== null) {
|
||||
clearTimeout(closeTimer);
|
||||
closeTimer = null;
|
||||
}
|
||||
show.value = true;
|
||||
}
|
||||
|
||||
function onMouseLeave() {
|
||||
closeTimer = setTimeout(() => {
|
||||
show.value = false;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (closeTimer !== null) clearTimeout(closeTimer);
|
||||
});
|
||||
|
||||
const favoriteWorkflowItems = computed<IMenuItem[]>(() =>
|
||||
favoritesStore.favorites
|
||||
.filter((f) => f.resourceType === 'workflow')
|
||||
.map((f) => ({
|
||||
id: `favorite-workflow-${f.resourceId}`,
|
||||
label: f.resourceName,
|
||||
icon: 'log-in' as IMenuItem['icon'],
|
||||
route: { to: { name: VIEWS.WORKFLOW, params: { name: f.resourceId } } },
|
||||
})),
|
||||
);
|
||||
|
||||
const favoriteProjectItems = computed<IMenuItem[]>(() =>
|
||||
favoritesStore.favorites
|
||||
.filter((f) => f.resourceType === 'project')
|
||||
.map((f) => {
|
||||
const project = projectsStore.myProjects.find((p) => p.id === f.resourceId);
|
||||
return {
|
||||
id: `favorite-project-${f.resourceId}`,
|
||||
label: f.resourceName,
|
||||
icon: (project?.icon as IMenuItem['icon']) ?? ('layers' as IMenuItem['icon']),
|
||||
route: {
|
||||
to: { name: VIEWS.PROJECTS_WORKFLOWS, params: { projectId: f.resourceId } },
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const favoriteDataTableItems = computed<IMenuItem[]>(() =>
|
||||
favoritesStore.favorites
|
||||
.filter((f) => f.resourceType === 'dataTable' && f.resourceProjectId)
|
||||
.map((f) => ({
|
||||
id: `favorite-datatable-${f.resourceId}`,
|
||||
label: f.resourceName,
|
||||
icon: 'table' as IMenuItem['icon'],
|
||||
route: {
|
||||
to: {
|
||||
name: DATA_TABLE_DETAILS,
|
||||
params: { projectId: f.resourceProjectId, id: f.resourceId },
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
type FavoriteGroup = {
|
||||
type: string;
|
||||
items: IMenuItem[];
|
||||
showIndividualIcons: boolean;
|
||||
};
|
||||
|
||||
const favoriteGroups = computed<FavoriteGroup[]>(() => {
|
||||
const groups: FavoriteGroup[] = [];
|
||||
if (favoriteProjectItems.value.length > 0) {
|
||||
groups.push({
|
||||
type: 'project',
|
||||
items: favoriteProjectItems.value,
|
||||
showIndividualIcons: true,
|
||||
});
|
||||
}
|
||||
if (favoriteWorkflowItems.value.length > 0) {
|
||||
groups.push({
|
||||
type: 'workflow',
|
||||
items: favoriteWorkflowItems.value,
|
||||
showIndividualIcons: false,
|
||||
});
|
||||
}
|
||||
if (favoriteDataTableItems.value.length > 0) {
|
||||
groups.push({
|
||||
type: 'dataTable',
|
||||
items: favoriteDataTableItems.value,
|
||||
showIndividualIcons: false,
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
const activeTabId = computed(() => {
|
||||
const id = projectsStore.projectNavActiveId;
|
||||
return (Array.isArray(id) ? id[0] : id) ?? undefined;
|
||||
});
|
||||
|
||||
const hasFavorites = computed(() => favoritesStore.favorites.length > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nPopover
|
||||
v-if="hasFavorites"
|
||||
side="right"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:open="show"
|
||||
width="220px"
|
||||
:suppress-auto-focus="true"
|
||||
@update:open="show = $event"
|
||||
>
|
||||
<template #trigger>
|
||||
<div :class="$style.trigger" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave">
|
||||
<N8nIcon icon="star" size="medium" />
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style.content" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave">
|
||||
<N8nText :class="$style.title" size="small" bold color="text-light">
|
||||
{{ locale.baseText('favorites.menu.title') }}
|
||||
</N8nText>
|
||||
<template v-for="(group, groupIndex) in favoriteGroups" :key="group.type">
|
||||
<div v-if="groupIndex > 0" :class="$style.groupSpacer" />
|
||||
<N8nMenuItem
|
||||
v-for="(item, itemIndex) in group.items"
|
||||
:key="item.id"
|
||||
:item="
|
||||
!group.showIndividualIcons && itemIndex > 0
|
||||
? { ...item, icon: { type: 'icon', value: '' } as IMenuItem['icon'] }
|
||||
: item
|
||||
"
|
||||
:compact="false"
|
||||
:active="activeTabId === item.id"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</N8nPopover>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing--4xs);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--spacing--3xs);
|
||||
}
|
||||
|
||||
.title {
|
||||
display: block;
|
||||
padding: 0 var(--spacing--xs);
|
||||
margin-bottom: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.groupSpacer {
|
||||
height: var(--spacing--4xs);
|
||||
}
|
||||
</style>
|
||||
+17
-70
@@ -4,7 +4,7 @@ import { VIEWS } from '@/app/constants';
|
||||
import { sourceControlEventBus } from '@/features/integrations/sourceControl.ee/sourceControl.eventBus';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import { type IconName, N8nIcon, N8nMenuItem, N8nText } from '@n8n/design-system';
|
||||
import { N8nMenuItem, N8nText } from '@n8n/design-system';
|
||||
import type { IMenuItem } from '@n8n/design-system/types';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { computed, onBeforeMount, onBeforeUnmount } from 'vue';
|
||||
@@ -13,6 +13,7 @@ import type { ProjectListItem } from '../projects.types';
|
||||
import { CHAT_VIEW } from '@/features/ai/chatHub/constants';
|
||||
import { useFavoritesStore } from '@/app/stores/favorites.store';
|
||||
import { DATA_TABLE_DETAILS } from '@/features/core/dataTable/constants';
|
||||
import FavoritesSidebarCompact from './FavoritesSidebarCompact.vue';
|
||||
|
||||
import { hasPermission } from '@/app/utils/rbac/permissions';
|
||||
|
||||
@@ -127,7 +128,6 @@ const favoriteDataTableItems = computed<IMenuItem[]>(() =>
|
||||
|
||||
type FavoriteGroup = {
|
||||
type: string;
|
||||
icon: IconName;
|
||||
items: IMenuItem[];
|
||||
showIndividualIcons: boolean;
|
||||
};
|
||||
@@ -138,7 +138,6 @@ const favoriteGroups = computed<FavoriteGroup[]>(() => {
|
||||
if (favoriteProjectItems.value.length > 0) {
|
||||
groups.push({
|
||||
type: 'project',
|
||||
icon: 'layers' as IconName,
|
||||
items: favoriteProjectItems.value,
|
||||
showIndividualIcons: true,
|
||||
});
|
||||
@@ -146,7 +145,6 @@ const favoriteGroups = computed<FavoriteGroup[]>(() => {
|
||||
if (favoriteWorkflowItems.value.length > 0) {
|
||||
groups.push({
|
||||
type: 'workflow',
|
||||
icon: 'log-in' as IconName,
|
||||
items: favoriteWorkflowItems.value,
|
||||
showIndividualIcons: false,
|
||||
});
|
||||
@@ -154,7 +152,6 @@ const favoriteGroups = computed<FavoriteGroup[]>(() => {
|
||||
if (favoriteDataTableItems.value.length > 0) {
|
||||
groups.push({
|
||||
type: 'dataTable',
|
||||
icon: 'table' as IconName,
|
||||
items: favoriteDataTableItems.value,
|
||||
showIndividualIcons: false,
|
||||
});
|
||||
@@ -162,13 +159,7 @@ const favoriteGroups = computed<FavoriteGroup[]>(() => {
|
||||
return groups;
|
||||
});
|
||||
|
||||
const allFavoriteItems = computed<IMenuItem[]>(() => [
|
||||
...favoriteProjectItems.value,
|
||||
...favoriteWorkflowItems.value,
|
||||
...favoriteDataTableItems.value,
|
||||
]);
|
||||
|
||||
const hasFavorites = computed(() => allFavoriteItems.value.length > 0);
|
||||
const hasFavorites = computed(() => favoritesStore.favorites.length > 0);
|
||||
|
||||
const activeTabId = computed(() => {
|
||||
return (
|
||||
@@ -249,47 +240,26 @@ onBeforeUnmount(() => {
|
||||
{{ locale.baseText('favorites.menu.title') }}
|
||||
</N8nText>
|
||||
<div :class="$style.projectItems">
|
||||
<!-- Expanded: grouped layout -->
|
||||
<!-- Expanded: flat list, icon hidden (but space preserved) on non-first items per group -->
|
||||
<template v-if="!props.collapsed">
|
||||
<template v-for="(group, groupIndex) in favoriteGroups" :key="group.type">
|
||||
<div v-if="groupIndex > 0" :class="$style.groupSpacer" />
|
||||
<!-- Projects: each item keeps its own icon -->
|
||||
<template v-if="group.showIndividualIcons">
|
||||
<N8nMenuItem
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
:item="item"
|
||||
:compact="false"
|
||||
:active="activeTabId === item.id"
|
||||
/>
|
||||
</template>
|
||||
<!-- Workflows / data tables: standalone group icon + icon-less items -->
|
||||
<div v-else :class="$style.favoriteGroup">
|
||||
<div :class="$style.groupIcon">
|
||||
<N8nIcon :icon="group.icon" size="medium" />
|
||||
</div>
|
||||
<div :class="$style.groupItems">
|
||||
<N8nMenuItem
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
:item="{ ...item, icon: undefined }"
|
||||
:compact="false"
|
||||
:active="activeTabId === item.id"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<N8nMenuItem
|
||||
v-for="(item, itemIndex) in group.items"
|
||||
:key="item.id"
|
||||
:item="
|
||||
!group.showIndividualIcons && itemIndex > 0
|
||||
? { ...item, icon: { type: 'icon', value: '' } as IMenuItem['icon'] }
|
||||
: item
|
||||
"
|
||||
:compact="false"
|
||||
:active="activeTabId === item.id"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- Collapsed: flat list, icons on all items -->
|
||||
<!-- Collapsed: single star trigger with hover popover -->
|
||||
<template v-else>
|
||||
<N8nMenuItem
|
||||
v-for="item in allFavoriteItems"
|
||||
:key="item.id"
|
||||
:class="$style.collapsed"
|
||||
:item="item"
|
||||
:compact="true"
|
||||
:active="activeTabId === item.id"
|
||||
/>
|
||||
<FavoritesSidebarCompact />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -389,27 +359,4 @@ onBeforeUnmount(() => {
|
||||
.groupSpacer {
|
||||
height: var(--spacing--4xs);
|
||||
}
|
||||
|
||||
.favoriteGroup {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.groupIcon {
|
||||
// Same width as N8nMenuItem's icon slot; same height as one menu item row
|
||||
// (icon height: --spacing--lg=24px + top/bottom padding: 2×--spacing--4xs=8px)
|
||||
width: var(--spacing--lg);
|
||||
height: calc(var(--spacing--lg) + 2 * var(--spacing--4xs));
|
||||
flex-shrink: 0;
|
||||
margin-right: var(--spacing--4xs);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color--text--tint-1);
|
||||
}
|
||||
|
||||
.groupItems {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user