Skip to content

Commit b89c254

Browse files
feat(editor): Add front-end for Data Store feature (#17590)
1 parent b745cad commit b89c254

35 files changed

+1820
-99
lines changed

packages/frontend/@n8n/i18n/src/locales/en.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"generic.cancel": "Cancel",
3737
"generic.open": "Open",
3838
"generic.close": "Close",
39+
"generic.clear": "Clear",
3940
"generic.confirm": "Confirm",
4041
"generic.create": "Create",
4142
"generic.create.workflow": "Create Workflow",
@@ -2788,6 +2789,19 @@
27882789
"contextual.users.settings.unavailable.button.cloud": "Upgrade now",
27892790
"contextual.feature.unavailable.title": "Available on the Enterprise Plan",
27902791
"contextual.feature.unavailable.title.cloud": "Available on the Pro Plan",
2792+
"dataStore.tab.label": "Data Store",
2793+
"dataStore.empty.label": "You don't have any data stores yet",
2794+
"dataStore.empty.description": "Once you create data stores for your projects, they will appear here",
2795+
"dataStore.empty.button.label": "Create data store in \"{projectName}\"",
2796+
"dataStore.card.size": "{size}MB",
2797+
"dataStore.card.column.count": "{count} column | {count} columns",
2798+
"dataStore.card.row.count": "{count} record | {count} records",
2799+
"dataStore.sort.lastUpdated": "Sort by last updated",
2800+
"dataStore.sort.lastCreated": "Sort by last created",
2801+
"dataStore.sort.nameAsc": "Sort by name (A-Z)",
2802+
"dataStore.sort.nameDesc": "Sort by name (Z-A)",
2803+
"dataStore.search.placeholder": "Search",
2804+
"dataStore.error.fetching": "Error loading data stores",
27912805
"settings.ldap": "LDAP",
27922806
"settings.ldap.note": "LDAP allows users to authenticate with their centralized account. It's compatible with services that provide an LDAP interface like Active Directory, Okta and Jumpcloud.",
27932807
"settings.ldap.infoTip": "Learn more about <a href='https://docs.n8n.io/user-management/ldap/' target='_blank'>LDAP in the Docs</a>",

packages/frontend/editor-ui/src/Interface.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,19 @@ export type CredentialsResource = BaseResource & {
327327
needsSetup: boolean;
328328
};
329329

330-
export type Resource = WorkflowResource | FolderResource | CredentialsResource | VariableResource;
330+
// Base resource types that are always available
331+
export type CoreResource =
332+
| WorkflowResource
333+
| FolderResource
334+
| CredentialsResource
335+
| VariableResource;
336+
337+
// This interface can be extended by modules to add their own resource types
338+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
339+
export interface ModuleResources {}
340+
341+
// The Resource type is the union of core resources and any module resources
342+
export type Resource = CoreResource | ModuleResources[keyof ModuleResources];
331343

332344
export type BaseFilters = {
333345
search: string;

packages/frontend/editor-ui/src/components/MainSidebar.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ const mainMenuItems = computed<IMenuItem[]>(() => [
120120
position: 'bottom',
121121
route: { to: { name: VIEWS.INSIGHTS } },
122122
available:
123-
settingsStore.settings.activeModules.includes('insights') &&
123+
settingsStore.isModuleActive('insights') &&
124124
hasPermission(['rbac'], { rbac: { scope: 'insights:list' } }),
125125
},
126126
{

packages/frontend/editor-ui/src/components/Projects/ProjectHeader.test.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import userEvent from '@testing-library/user-event';
1313
import { waitFor, within } from '@testing-library/vue';
1414
import { useSettingsStore } from '@/stores/settings.store';
1515
import { useProjectPages } from '@/composables/useProjectPages';
16+
import { useUIStore } from '@/stores/ui.store';
1617

1718
const mockPush = vi.fn();
1819
vi.mock('vue-router', async () => {
@@ -54,6 +55,7 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
5455
let route: ReturnType<typeof router.useRoute>;
5556
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
5657
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
58+
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
5759
let projectPages: ReturnType<typeof useProjectPages>;
5860

5961
describe('ProjectHeader', () => {
@@ -62,10 +64,18 @@ describe('ProjectHeader', () => {
6264
route = router.useRoute();
6365
projectsStore = mockedStore(useProjectsStore);
6466
settingsStore = mockedStore(useSettingsStore);
67+
uiStore = mockedStore(useUIStore);
6568
projectPages = useProjectPages();
6669

6770
projectsStore.teamProjectsLimit = -1;
6871
settingsStore.settings.folders = { enabled: false };
72+
73+
// Setup default moduleTabs structure
74+
uiStore.moduleTabs = {
75+
shared: {},
76+
overview: {},
77+
project: {},
78+
};
6979
});
7080

7181
afterEach(() => {
@@ -256,4 +266,174 @@ describe('ProjectHeader', () => {
256266
const { queryByTestId } = renderComponent();
257267
expect(queryByTestId('add-resource-buttons')).not.toBeInTheDocument();
258268
});
269+
270+
describe('customProjectTabs', () => {
271+
it('should pass tabs for shared page type when on shared sub page', () => {
272+
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(true);
273+
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
274+
275+
const mockTabs = [
276+
{ value: 'shared-tab-1', label: 'Shared Tab 1' },
277+
{ value: 'shared-tab-2', label: 'Shared Tab 2' },
278+
];
279+
280+
uiStore.moduleTabs.shared = {
281+
module1: mockTabs,
282+
module2: [],
283+
};
284+
285+
settingsStore.isModuleActive = vi
286+
.fn()
287+
.mockReturnValueOnce(true) // module1 is active
288+
.mockReturnValueOnce(false); // module2 is inactive
289+
290+
renderComponent();
291+
292+
expect(projectTabsSpy).toHaveBeenCalledWith(
293+
expect.objectContaining({
294+
'additional-tabs': mockTabs,
295+
}),
296+
null,
297+
);
298+
});
299+
300+
it('should pass tabs for overview page type when on overview sub page', () => {
301+
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
302+
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(true);
303+
304+
const mockTabs = [{ value: 'overview-tab-1', label: 'Overview Tab 1' }];
305+
306+
uiStore.moduleTabs.overview = {
307+
overviewModule: mockTabs,
308+
};
309+
310+
settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
311+
312+
renderComponent();
313+
314+
expect(projectTabsSpy).toHaveBeenCalledWith(
315+
expect.objectContaining({
316+
'additional-tabs': mockTabs,
317+
}),
318+
null,
319+
);
320+
});
321+
322+
it('should pass tabs for project page type when not on shared or overview sub pages', () => {
323+
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
324+
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
325+
326+
const mockTabs = [
327+
{ value: 'project-tab-1', label: 'Project Tab 1' },
328+
{ value: 'project-tab-2', label: 'Project Tab 2' },
329+
];
330+
331+
uiStore.moduleTabs.project = {
332+
projectModule: mockTabs,
333+
};
334+
335+
settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
336+
337+
renderComponent();
338+
339+
expect(projectTabsSpy).toHaveBeenCalledWith(
340+
expect.objectContaining({
341+
'additional-tabs': mockTabs,
342+
}),
343+
null,
344+
);
345+
});
346+
347+
it('should filter out tabs from inactive modules', () => {
348+
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
349+
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
350+
351+
const activeTabs = [{ value: 'active-tab', label: 'Active Tab' }];
352+
const inactiveTabs = [{ value: 'inactive-tab', label: 'Inactive Tab' }];
353+
354+
uiStore.moduleTabs.project = {
355+
activeModule: activeTabs,
356+
inactiveModule: inactiveTabs,
357+
};
358+
359+
settingsStore.isModuleActive = vi
360+
.fn()
361+
.mockImplementation((module: string) => module === 'activeModule');
362+
363+
renderComponent();
364+
365+
expect(projectTabsSpy).toHaveBeenCalledWith(
366+
expect.objectContaining({
367+
'additional-tabs': activeTabs,
368+
}),
369+
null,
370+
);
371+
});
372+
373+
it('should flatten tabs from multiple active modules', () => {
374+
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
375+
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
376+
377+
const module1Tabs = [
378+
{ value: 'module1-tab1', label: 'Module 1 Tab 1' },
379+
{ value: 'module1-tab2', label: 'Module 1 Tab 2' },
380+
];
381+
const module2Tabs = [{ value: 'module2-tab1', label: 'Module 2 Tab 1' }];
382+
383+
uiStore.moduleTabs.project = {
384+
module1: module1Tabs,
385+
module2: module2Tabs,
386+
module3: [], // Empty tabs array
387+
};
388+
389+
settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
390+
391+
renderComponent();
392+
393+
expect(projectTabsSpy).toHaveBeenCalledWith(
394+
expect.objectContaining({
395+
'additional-tabs': [...module1Tabs, ...module2Tabs],
396+
}),
397+
null,
398+
);
399+
expect(settingsStore.isModuleActive).toHaveBeenCalledTimes(3);
400+
});
401+
402+
it('should pass empty array when no modules are active', () => {
403+
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
404+
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
405+
406+
uiStore.moduleTabs.project = {
407+
module1: [{ value: 'tab1', label: 'Tab 1' }],
408+
module2: [{ value: 'tab2', label: 'Tab 2' }],
409+
};
410+
411+
settingsStore.isModuleActive = vi.fn().mockReturnValue(false);
412+
413+
renderComponent();
414+
415+
expect(projectTabsSpy).toHaveBeenCalledWith(
416+
expect.objectContaining({
417+
'additional-tabs': [],
418+
}),
419+
null,
420+
);
421+
});
422+
423+
it('should pass empty array when no modules exist for the tab type', () => {
424+
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
425+
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
426+
427+
uiStore.moduleTabs.project = {}; // No modules
428+
429+
renderComponent();
430+
431+
expect(projectTabsSpy).toHaveBeenCalledWith(
432+
expect.objectContaining({
433+
'additional-tabs': [],
434+
}),
435+
null,
436+
);
437+
});
438+
});
259439
});

packages/frontend/editor-ui/src/components/Projects/ProjectHeader.vue

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { computed, ref } from 'vue';
33
import { useRoute, useRouter } from 'vue-router';
44
import { useElementSize, useResizeObserver } from '@vueuse/core';
5-
import type { UserAction } from '@n8n/design-system';
5+
import type { TabOptions, UserAction } from '@n8n/design-system';
66
import { N8nButton, N8nTooltip } from '@n8n/design-system';
77
import { useI18n } from '@n8n/i18n';
88
import { ProjectTypes } from '@/types/projects.types';
@@ -19,13 +19,16 @@ import { truncateTextToFitWidth } from '@/utils/formatters/textFormatter';
1919
import { type IconName } from '@n8n/design-system/components/N8nIcon/icons';
2020
import type { IUser } from 'n8n-workflow';
2121
import { type IconOrEmoji, isIconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
22+
import { useUIStore } from '@/stores/ui.store';
2223
2324
const route = useRoute();
2425
const router = useRouter();
2526
const i18n = useI18n();
2627
const projectsStore = useProjectsStore();
2728
const sourceControlStore = useSourceControlStore();
2829
const settingsStore = useSettingsStore();
30+
const uiStore = useUIStore();
31+
2932
const projectPages = useProjectPages();
3033
3134
const emit = defineEmits<{
@@ -83,6 +86,23 @@ const showFolders = computed(() => {
8386
);
8487
});
8588
89+
const customProjectTabs = computed((): Array<TabOptions<string>> => {
90+
// Determine the type of tab based on the current project page
91+
let tabType: 'shared' | 'overview' | 'project';
92+
if (projectPages.isSharedSubPage) {
93+
tabType = 'shared';
94+
} else if (projectPages.isOverviewSubPage) {
95+
tabType = 'overview';
96+
} else {
97+
tabType = 'project';
98+
}
99+
// Only pick up tabs from active modules
100+
const activeModules = Object.keys(uiStore.moduleTabs[tabType]).filter(
101+
settingsStore.isModuleActive,
102+
);
103+
return activeModules.flatMap((module) => uiStore.moduleTabs[tabType][module]);
104+
});
105+
86106
const ACTION_TYPES = {
87107
WORKFLOW: 'workflow',
88108
CREDENTIAL: 'credential',
@@ -278,6 +298,7 @@ const onSelect = (action: string) => {
278298
:page-type="pageType"
279299
:show-executions="!projectPages.isSharedSubPage"
280300
:show-settings="showSettings"
301+
:additional-tabs="customProjectTabs"
281302
/>
282303
</div>
283304
</div>

packages/frontend/editor-ui/src/components/Projects/ProjectTabs.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,20 @@ import { VIEWS } from '@/constants';
66
import { useI18n } from '@n8n/i18n';
77
import type { BaseTextKey } from '@n8n/i18n';
88
import type { TabOptions } from '@n8n/design-system';
9+
import { processDynamicTabs, type DynamicTabOptions } from '@/utils/modules/tabUtils';
910
1011
type Props = {
1112
showSettings?: boolean;
1213
showExecutions?: boolean;
1314
pageType?: 'overview' | 'shared' | 'project';
15+
additionalTabs?: DynamicTabOptions[];
1416
};
1517
1618
const props = withDefaults(defineProps<Props>(), {
1719
showSettings: false,
1820
showExecutions: true,
1921
pageType: 'project',
22+
additionalTabs: () => [],
2023
});
2124
2225
const locale = useI18n();
@@ -93,6 +96,11 @@ const options = computed<Array<TabOptions<string>>>(() => {
9396
tabs.push(createTab('mainSidebar.executions', 'executions', routes));
9497
}
9598
99+
if (props.additionalTabs?.length) {
100+
const processedAdditionalTabs = processDynamicTabs(props.additionalTabs, projectId.value);
101+
tabs.push(...processedAdditionalTabs);
102+
}
103+
96104
if (props.showSettings) {
97105
tabs.push({
98106
label: locale.baseText('projects.settings'),

0 commit comments

Comments
 (0)