refine frontend

This commit is contained in:
Charlie Kolb
2026-02-25 00:55:16 +01:00
parent 563f937fbb
commit 8a532e063c
3 changed files with 213 additions and 70 deletions
@@ -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;
}
}
@@ -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>
@@ -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>