feat(editor): Add front-end for Data Store feature (#17590)

This commit is contained in:
Milorad FIlipović
2025-07-31 13:07:00 +02:00
committed by GitHub
parent b745cad72c
commit b89c254394
35 changed files with 1820 additions and 99 deletions

View File

@@ -36,6 +36,7 @@
"generic.cancel": "Cancel", "generic.cancel": "Cancel",
"generic.open": "Open", "generic.open": "Open",
"generic.close": "Close", "generic.close": "Close",
"generic.clear": "Clear",
"generic.confirm": "Confirm", "generic.confirm": "Confirm",
"generic.create": "Create", "generic.create": "Create",
"generic.create.workflow": "Create Workflow", "generic.create.workflow": "Create Workflow",
@@ -2788,6 +2789,19 @@
"contextual.users.settings.unavailable.button.cloud": "Upgrade now", "contextual.users.settings.unavailable.button.cloud": "Upgrade now",
"contextual.feature.unavailable.title": "Available on the Enterprise Plan", "contextual.feature.unavailable.title": "Available on the Enterprise Plan",
"contextual.feature.unavailable.title.cloud": "Available on the Pro Plan", "contextual.feature.unavailable.title.cloud": "Available on the Pro Plan",
"dataStore.tab.label": "Data Store",
"dataStore.empty.label": "You don't have any data stores yet",
"dataStore.empty.description": "Once you create data stores for your projects, they will appear here",
"dataStore.empty.button.label": "Create data store in \"{projectName}\"",
"dataStore.card.size": "{size}MB",
"dataStore.card.column.count": "{count} column | {count} columns",
"dataStore.card.row.count": "{count} record | {count} records",
"dataStore.sort.lastUpdated": "Sort by last updated",
"dataStore.sort.lastCreated": "Sort by last created",
"dataStore.sort.nameAsc": "Sort by name (A-Z)",
"dataStore.sort.nameDesc": "Sort by name (Z-A)",
"dataStore.search.placeholder": "Search",
"dataStore.error.fetching": "Error loading data stores",
"settings.ldap": "LDAP", "settings.ldap": "LDAP",
"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.", "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.",
"settings.ldap.infoTip": "Learn more about <a href='https://docs.n8n.io/user-management/ldap/' target='_blank'>LDAP in the Docs</a>", "settings.ldap.infoTip": "Learn more about <a href='https://docs.n8n.io/user-management/ldap/' target='_blank'>LDAP in the Docs</a>",

View File

@@ -327,7 +327,19 @@ export type CredentialsResource = BaseResource & {
needsSetup: boolean; needsSetup: boolean;
}; };
export type Resource = WorkflowResource | FolderResource | CredentialsResource | VariableResource; // Base resource types that are always available
export type CoreResource =
| WorkflowResource
| FolderResource
| CredentialsResource
| VariableResource;
// This interface can be extended by modules to add their own resource types
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ModuleResources {}
// The Resource type is the union of core resources and any module resources
export type Resource = CoreResource | ModuleResources[keyof ModuleResources];
export type BaseFilters = { export type BaseFilters = {
search: string; search: string;

View File

@@ -120,7 +120,7 @@ const mainMenuItems = computed<IMenuItem[]>(() => [
position: 'bottom', position: 'bottom',
route: { to: { name: VIEWS.INSIGHTS } }, route: { to: { name: VIEWS.INSIGHTS } },
available: available:
settingsStore.settings.activeModules.includes('insights') && settingsStore.isModuleActive('insights') &&
hasPermission(['rbac'], { rbac: { scope: 'insights:list' } }), hasPermission(['rbac'], { rbac: { scope: 'insights:list' } }),
}, },
{ {

View File

@@ -13,6 +13,7 @@ import userEvent from '@testing-library/user-event';
import { waitFor, within } from '@testing-library/vue'; import { waitFor, within } from '@testing-library/vue';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
import { useProjectPages } from '@/composables/useProjectPages'; import { useProjectPages } from '@/composables/useProjectPages';
import { useUIStore } from '@/stores/ui.store';
const mockPush = vi.fn(); const mockPush = vi.fn();
vi.mock('vue-router', async () => { vi.mock('vue-router', async () => {
@@ -54,6 +55,7 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
let route: ReturnType<typeof router.useRoute>; let route: ReturnType<typeof router.useRoute>;
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>; let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>; let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
let projectPages: ReturnType<typeof useProjectPages>; let projectPages: ReturnType<typeof useProjectPages>;
describe('ProjectHeader', () => { describe('ProjectHeader', () => {
@@ -62,10 +64,18 @@ describe('ProjectHeader', () => {
route = router.useRoute(); route = router.useRoute();
projectsStore = mockedStore(useProjectsStore); projectsStore = mockedStore(useProjectsStore);
settingsStore = mockedStore(useSettingsStore); settingsStore = mockedStore(useSettingsStore);
uiStore = mockedStore(useUIStore);
projectPages = useProjectPages(); projectPages = useProjectPages();
projectsStore.teamProjectsLimit = -1; projectsStore.teamProjectsLimit = -1;
settingsStore.settings.folders = { enabled: false }; settingsStore.settings.folders = { enabled: false };
// Setup default moduleTabs structure
uiStore.moduleTabs = {
shared: {},
overview: {},
project: {},
};
}); });
afterEach(() => { afterEach(() => {
@@ -256,4 +266,174 @@ describe('ProjectHeader', () => {
const { queryByTestId } = renderComponent(); const { queryByTestId } = renderComponent();
expect(queryByTestId('add-resource-buttons')).not.toBeInTheDocument(); expect(queryByTestId('add-resource-buttons')).not.toBeInTheDocument();
}); });
describe('customProjectTabs', () => {
it('should pass tabs for shared page type when on shared sub page', () => {
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(true);
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
const mockTabs = [
{ value: 'shared-tab-1', label: 'Shared Tab 1' },
{ value: 'shared-tab-2', label: 'Shared Tab 2' },
];
uiStore.moduleTabs.shared = {
module1: mockTabs,
module2: [],
};
settingsStore.isModuleActive = vi
.fn()
.mockReturnValueOnce(true) // module1 is active
.mockReturnValueOnce(false); // module2 is inactive
renderComponent();
expect(projectTabsSpy).toHaveBeenCalledWith(
expect.objectContaining({
'additional-tabs': mockTabs,
}),
null,
);
});
it('should pass tabs for overview page type when on overview sub page', () => {
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(true);
const mockTabs = [{ value: 'overview-tab-1', label: 'Overview Tab 1' }];
uiStore.moduleTabs.overview = {
overviewModule: mockTabs,
};
settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
renderComponent();
expect(projectTabsSpy).toHaveBeenCalledWith(
expect.objectContaining({
'additional-tabs': mockTabs,
}),
null,
);
});
it('should pass tabs for project page type when not on shared or overview sub pages', () => {
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
const mockTabs = [
{ value: 'project-tab-1', label: 'Project Tab 1' },
{ value: 'project-tab-2', label: 'Project Tab 2' },
];
uiStore.moduleTabs.project = {
projectModule: mockTabs,
};
settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
renderComponent();
expect(projectTabsSpy).toHaveBeenCalledWith(
expect.objectContaining({
'additional-tabs': mockTabs,
}),
null,
);
});
it('should filter out tabs from inactive modules', () => {
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
const activeTabs = [{ value: 'active-tab', label: 'Active Tab' }];
const inactiveTabs = [{ value: 'inactive-tab', label: 'Inactive Tab' }];
uiStore.moduleTabs.project = {
activeModule: activeTabs,
inactiveModule: inactiveTabs,
};
settingsStore.isModuleActive = vi
.fn()
.mockImplementation((module: string) => module === 'activeModule');
renderComponent();
expect(projectTabsSpy).toHaveBeenCalledWith(
expect.objectContaining({
'additional-tabs': activeTabs,
}),
null,
);
});
it('should flatten tabs from multiple active modules', () => {
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
const module1Tabs = [
{ value: 'module1-tab1', label: 'Module 1 Tab 1' },
{ value: 'module1-tab2', label: 'Module 1 Tab 2' },
];
const module2Tabs = [{ value: 'module2-tab1', label: 'Module 2 Tab 1' }];
uiStore.moduleTabs.project = {
module1: module1Tabs,
module2: module2Tabs,
module3: [], // Empty tabs array
};
settingsStore.isModuleActive = vi.fn().mockReturnValue(true);
renderComponent();
expect(projectTabsSpy).toHaveBeenCalledWith(
expect.objectContaining({
'additional-tabs': [...module1Tabs, ...module2Tabs],
}),
null,
);
expect(settingsStore.isModuleActive).toHaveBeenCalledTimes(3);
});
it('should pass empty array when no modules are active', () => {
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
uiStore.moduleTabs.project = {
module1: [{ value: 'tab1', label: 'Tab 1' }],
module2: [{ value: 'tab2', label: 'Tab 2' }],
};
settingsStore.isModuleActive = vi.fn().mockReturnValue(false);
renderComponent();
expect(projectTabsSpy).toHaveBeenCalledWith(
expect.objectContaining({
'additional-tabs': [],
}),
null,
);
});
it('should pass empty array when no modules exist for the tab type', () => {
vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false);
vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false);
uiStore.moduleTabs.project = {}; // No modules
renderComponent();
expect(projectTabsSpy).toHaveBeenCalledWith(
expect.objectContaining({
'additional-tabs': [],
}),
null,
);
});
});
}); });

View File

@@ -2,7 +2,7 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useElementSize, useResizeObserver } from '@vueuse/core'; import { useElementSize, useResizeObserver } from '@vueuse/core';
import type { UserAction } from '@n8n/design-system'; import type { TabOptions, UserAction } from '@n8n/design-system';
import { N8nButton, N8nTooltip } from '@n8n/design-system'; import { N8nButton, N8nTooltip } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { ProjectTypes } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types';
@@ -19,6 +19,7 @@ import { truncateTextToFitWidth } from '@/utils/formatters/textFormatter';
import { type IconName } from '@n8n/design-system/components/N8nIcon/icons'; import { type IconName } from '@n8n/design-system/components/N8nIcon/icons';
import type { IUser } from 'n8n-workflow'; import type { IUser } from 'n8n-workflow';
import { type IconOrEmoji, isIconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types'; import { type IconOrEmoji, isIconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
import { useUIStore } from '@/stores/ui.store';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -26,6 +27,8 @@ const i18n = useI18n();
const projectsStore = useProjectsStore(); const projectsStore = useProjectsStore();
const sourceControlStore = useSourceControlStore(); const sourceControlStore = useSourceControlStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const uiStore = useUIStore();
const projectPages = useProjectPages(); const projectPages = useProjectPages();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -83,6 +86,23 @@ const showFolders = computed(() => {
); );
}); });
const customProjectTabs = computed((): Array<TabOptions<string>> => {
// Determine the type of tab based on the current project page
let tabType: 'shared' | 'overview' | 'project';
if (projectPages.isSharedSubPage) {
tabType = 'shared';
} else if (projectPages.isOverviewSubPage) {
tabType = 'overview';
} else {
tabType = 'project';
}
// Only pick up tabs from active modules
const activeModules = Object.keys(uiStore.moduleTabs[tabType]).filter(
settingsStore.isModuleActive,
);
return activeModules.flatMap((module) => uiStore.moduleTabs[tabType][module]);
});
const ACTION_TYPES = { const ACTION_TYPES = {
WORKFLOW: 'workflow', WORKFLOW: 'workflow',
CREDENTIAL: 'credential', CREDENTIAL: 'credential',
@@ -278,6 +298,7 @@ const onSelect = (action: string) => {
:page-type="pageType" :page-type="pageType"
:show-executions="!projectPages.isSharedSubPage" :show-executions="!projectPages.isSharedSubPage"
:show-settings="showSettings" :show-settings="showSettings"
:additional-tabs="customProjectTabs"
/> />
</div> </div>
</div> </div>

View File

@@ -6,17 +6,20 @@ import { VIEWS } from '@/constants';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n'; import type { BaseTextKey } from '@n8n/i18n';
import type { TabOptions } from '@n8n/design-system'; import type { TabOptions } from '@n8n/design-system';
import { processDynamicTabs, type DynamicTabOptions } from '@/utils/modules/tabUtils';
type Props = { type Props = {
showSettings?: boolean; showSettings?: boolean;
showExecutions?: boolean; showExecutions?: boolean;
pageType?: 'overview' | 'shared' | 'project'; pageType?: 'overview' | 'shared' | 'project';
additionalTabs?: DynamicTabOptions[];
}; };
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
showSettings: false, showSettings: false,
showExecutions: true, showExecutions: true,
pageType: 'project', pageType: 'project',
additionalTabs: () => [],
}); });
const locale = useI18n(); const locale = useI18n();
@@ -93,6 +96,11 @@ const options = computed<Array<TabOptions<string>>>(() => {
tabs.push(createTab('mainSidebar.executions', 'executions', routes)); tabs.push(createTab('mainSidebar.executions', 'executions', routes));
} }
if (props.additionalTabs?.length) {
const processedAdditionalTabs = processDynamicTabs(props.additionalTabs, projectId.value);
tabs.push(...processedAdditionalTabs);
}
if (props.showSettings) { if (props.showSettings) {
tabs.push({ tabs.push({
label: locale.baseText('projects.settings'), label: locale.baseText('projects.settings'),

View File

@@ -158,7 +158,11 @@ describe('ResourcesListLayout', () => {
props: { props: {
resources: TEST_WORKFLOWS, resources: TEST_WORKFLOWS,
type: 'list-paginated', type: 'list-paginated',
showFiltersDropdown: true, uiConfig: {
searchEnabled: true,
showFiltersDropdown: true,
sortEnabled: true,
},
filters: { filters: {
search: '', search: '',
homeProject: '', homeProject: '',
@@ -181,7 +185,11 @@ describe('ResourcesListLayout', () => {
props: { props: {
resources: TEST_WORKFLOWS, resources: TEST_WORKFLOWS,
type: 'list-paginated', type: 'list-paginated',
showFiltersDropdown: true, uiConfig: {
searchEnabled: true,
showFiltersDropdown: true,
sortEnabled: true,
},
filters: { filters: {
search: '', search: '',
homeProject: '', homeProject: '',
@@ -198,4 +206,139 @@ describe('ResourcesListLayout', () => {
'Filters are applied.', 'Filters are applied.',
); );
}); });
describe('uiConfig prop', () => {
it('should not render search input when searchEnabled is false', () => {
const { queryByTestId } = renderComponent({
props: {
resources: TEST_WORKFLOWS,
type: 'list-paginated',
uiConfig: {
searchEnabled: false,
showFiltersDropdown: true,
sortEnabled: true,
},
},
});
expect(queryByTestId('resources-list-search')).not.toBeInTheDocument();
});
it('should render search input when searchEnabled is true', () => {
const { getByTestId } = renderComponent({
props: {
resources: TEST_WORKFLOWS,
type: 'list-paginated',
uiConfig: {
searchEnabled: true,
showFiltersDropdown: true,
sortEnabled: true,
},
},
});
expect(getByTestId('resources-list-search')).toBeInTheDocument();
});
it('should not render sort dropdown when sortEnabled is false', () => {
const { queryByTestId } = renderComponent({
props: {
resources: TEST_WORKFLOWS,
type: 'list-paginated',
uiConfig: {
searchEnabled: true,
showFiltersDropdown: true,
sortEnabled: false,
},
},
});
expect(queryByTestId('resources-list-sort')).not.toBeInTheDocument();
});
it('should render sort dropdown when sortEnabled is true', () => {
const { getByTestId } = renderComponent({
props: {
resources: TEST_WORKFLOWS,
type: 'list-paginated',
uiConfig: {
searchEnabled: true,
showFiltersDropdown: true,
sortEnabled: true,
},
},
});
expect(getByTestId('resources-list-sort')).toBeInTheDocument();
});
it('should render filters dropdown trigger when showFiltersDropdown is true', () => {
const { getByTestId } = renderComponent({
props: {
resources: TEST_WORKFLOWS,
type: 'list-paginated',
uiConfig: {
searchEnabled: true,
showFiltersDropdown: true,
sortEnabled: true,
},
},
});
// Check if filters dropdown trigger is rendered
expect(getByTestId('resources-list-filters-trigger')).toBeInTheDocument();
});
it('should not render filters dropdown section when showFiltersDropdown is false', () => {
const { queryByTestId } = renderComponent({
props: {
resources: TEST_WORKFLOWS,
type: 'list-paginated',
uiConfig: {
searchEnabled: true,
showFiltersDropdown: false,
sortEnabled: true,
},
},
});
// The filters dropdown trigger should not be rendered
expect(queryByTestId('resources-list-filters-trigger')).not.toBeInTheDocument();
});
it('should respect all uiConfig options when all are disabled', () => {
const { queryByTestId } = renderComponent({
props: {
resources: TEST_WORKFLOWS,
type: 'list-paginated',
uiConfig: {
searchEnabled: false,
showFiltersDropdown: false,
sortEnabled: false,
},
},
});
expect(queryByTestId('resources-list-search')).not.toBeInTheDocument();
expect(queryByTestId('resources-list-sort')).not.toBeInTheDocument();
});
it('should respect all uiConfig options when all are enabled', () => {
const { getByTestId } = renderComponent({
props: {
resources: TEST_WORKFLOWS,
type: 'list-paginated',
uiConfig: {
searchEnabled: true,
showFiltersDropdown: true,
sortEnabled: true,
},
},
});
expect(getByTestId('resources-list-search')).toBeInTheDocument();
expect(getByTestId('resources-list-sort')).toBeInTheDocument();
expect(getByTestId('resources-list-filters-trigger')).toBeInTheDocument();
});
});
}); });

View File

@@ -6,21 +6,23 @@ import PageViewLayoutList from '@/components/layouts/PageViewLayoutList.vue';
import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue'; import ResourceFiltersDropdown from '@/components/forms/ResourceFiltersDropdown.vue';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import type { DatatableColumn } from '@n8n/design-system'; import type { DatatableColumn } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useDebounce } from '@/composables/useDebounce'; import { useDebounce } from '@/composables/useDebounce';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import type { BaseTextKey } from '@n8n/i18n';
import type { BaseFilters, Resource, SortingAndPaginationUpdates } from '@/Interface'; import type { BaseFilters, Resource, SortingAndPaginationUpdates } from '@/Interface';
import { isSharedResource, isResourceSortableByDate } from '@/utils/typeGuards'; import { isSharedResource, isResourceSortableByDate } from '@/utils/typeGuards';
import { useN8nLocalStorage } from '@/composables/useN8nLocalStorage'; import { useN8nLocalStorage } from '@/composables/useN8nLocalStorage';
import { useResourcesListI18n } from '@/composables/useResourcesListI18n';
type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders'; type UIConfig = {
searchEnabled: boolean;
showFiltersDropdown: boolean;
sortEnabled: boolean;
};
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const i18n = useI18n();
const { callDebounced } = useDebounce(); const { callDebounced } = useDebounce();
const usersStore = useUsersStore(); const usersStore = useUsersStore();
const telemetry = useTelemetry(); const telemetry = useTelemetry();
@@ -28,7 +30,7 @@ const n8nLocalStorage = useN8nLocalStorage();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
resourceKey: ResourceKeyType; resourceKey: string;
displayName?: (resource: ResourceType) => string; displayName?: (resource: ResourceType) => string;
resources: ResourceType[]; resources: ResourceType[];
disabled: boolean; disabled: boolean;
@@ -40,7 +42,6 @@ const props = withDefaults(
matches: boolean, matches: boolean,
) => boolean; ) => boolean;
shareable?: boolean; shareable?: boolean;
showFiltersDropdown?: boolean;
sortFns?: Record<string, (a: ResourceType, b: ResourceType) => number>; sortFns?: Record<string, (a: ResourceType, b: ResourceType) => number>;
sortOptions?: string[]; sortOptions?: string[];
type?: 'datatable' | 'list-full' | 'list-paginated'; type?: 'datatable' | 'list-full' | 'list-paginated';
@@ -53,6 +54,7 @@ const props = withDefaults(
// Set to true if sorting and filtering is done outside of the component // Set to true if sorting and filtering is done outside of the component
dontPerformSortingAndFiltering?: boolean; dontPerformSortingAndFiltering?: boolean;
hasEmptyState?: boolean; hasEmptyState?: boolean;
uiConfig?: UIConfig;
}>(), }>(),
{ {
displayName: (resource: ResourceType) => resource.name || '', displayName: (resource: ResourceType) => resource.name || '',
@@ -64,7 +66,6 @@ const props = withDefaults(
typeProps: () => ({ itemSize: 80 }), typeProps: () => ({ itemSize: 80 }),
loading: true, loading: true,
additionalFiltersHandler: undefined, additionalFiltersHandler: undefined,
showFiltersDropdown: true,
shareable: true, shareable: true,
customPageSize: 25, customPageSize: 25,
availablePageSizeOptions: () => [10, 25, 50, 100], availablePageSizeOptions: () => [10, 25, 50, 100],
@@ -72,9 +73,16 @@ const props = withDefaults(
dontPerformSortingAndFiltering: false, dontPerformSortingAndFiltering: false,
resourcesRefreshing: false, resourcesRefreshing: false,
hasEmptyState: true, hasEmptyState: true,
uiConfig: () => ({
searchEnabled: true,
showFiltersDropdown: true,
sortEnabled: true,
}),
}, },
); );
const { getResourceText } = useResourcesListI18n(props.resourceKey);
const sortBy = ref(props.sortOptions[0]); const sortBy = ref(props.sortOptions[0]);
const hasFilters = ref(false); const hasFilters = ref(false);
const currentPage = ref(1); const currentPage = ref(1);
@@ -564,23 +572,20 @@ defineExpose({
data-test-id="empty-resources-list" data-test-id="empty-resources-list"
emoji="👋" emoji="👋"
:heading=" :heading="
i18n.baseText( getResourceText(
usersStore.currentUser?.firstName usersStore.currentUser?.firstName ? 'empty.heading' : 'empty.heading.userNotSetup',
? (`${resourceKey}.empty.heading` as BaseTextKey) usersStore.currentUser?.firstName ? 'empty.heading' : 'empty.heading.userNotSetup',
: (`${resourceKey}.empty.heading.userNotSetup` as BaseTextKey), { name: usersStore.currentUser?.firstName ?? '' },
{
interpolate: { name: usersStore.currentUser?.firstName ?? '' },
},
) )
" "
:description="i18n.baseText(`${resourceKey}.empty.description` as BaseTextKey)" :description="getResourceText('empty.description')"
:button-text="i18n.baseText(`${resourceKey}.empty.button` as BaseTextKey)" :button-text="getResourceText('empty.button')"
button-type="secondary" button-type="secondary"
:button-disabled="disabled" :button-disabled="disabled"
@click:button="onAddButtonClick" @click:button="onAddButtonClick"
> >
<template #disabledButtonTooltip> <template #disabledButtonTooltip>
{{ i18n.baseText(`${resourceKey}.empty.button.disabled.tooltip` as BaseTextKey) }} {{ getResourceText('empty.button.disabled.tooltip') }}
</template> </template>
</n8n-action-box> </n8n-action-box>
</slot> </slot>
@@ -591,10 +596,11 @@ defineExpose({
<div :class="$style.filters"> <div :class="$style.filters">
<slot name="breadcrumbs"></slot> <slot name="breadcrumbs"></slot>
<n8n-input <n8n-input
v-if="props.uiConfig.searchEnabled"
ref="search" ref="search"
:model-value="filtersModel.search" :model-value="filtersModel.search"
:class="$style.search" :class="$style.search"
:placeholder="i18n.baseText(`${resourceKey}.search.placeholder` as BaseTextKey)" :placeholder="getResourceText('search.placeholder', 'search.placeholder')"
size="small" size="small"
clearable clearable
data-test-id="resources-list-search" data-test-id="resources-list-search"
@@ -604,7 +610,7 @@ defineExpose({
<n8n-icon icon="search" /> <n8n-icon icon="search" />
</template> </template>
</n8n-input> </n8n-input>
<div :class="$style['sort-and-filter']"> <div v-if="props.uiConfig.sortEnabled" :class="$style['sort-and-filter']">
<n8n-select <n8n-select
v-model="sortBy" v-model="sortBy"
size="small" size="small"
@@ -616,13 +622,12 @@ defineExpose({
:key="sortOption" :key="sortOption"
data-test-id="resources-list-sort-item" data-test-id="resources-list-sort-item"
:value="sortOption" :value="sortOption"
:label="i18n.baseText(`${resourceKey}.sort.${sortOption}` as BaseTextKey)" :label="getResourceText(`sort.${sortOption}`, `sort.${sortOption}`)"
/> />
</n8n-select> </n8n-select>
</div> </div>
<div :class="$style['sort-and-filter']"> <div v-if="props.uiConfig.showFiltersDropdown" :class="$style['sort-and-filter']">
<ResourceFiltersDropdown <ResourceFiltersDropdown
v-if="showFiltersDropdown"
:keys="filterKeys" :keys="filterKeys"
:reset="resetFilters" :reset="resetFilters"
:model-value="filtersModel" :model-value="filtersModel"
@@ -643,7 +648,7 @@ defineExpose({
<slot name="callout"></slot> <slot name="callout"></slot>
<div <div
v-if="showFiltersDropdown" v-if="props.uiConfig.showFiltersDropdown"
v-show="hasFilters" v-show="hasFilters"
class="mt-xs" class="mt-xs"
data-test-id="resources-list-filters-applied-info" data-test-id="resources-list-filters-applied-info"
@@ -651,11 +656,11 @@ defineExpose({
<n8n-info-tip :bold="false"> <n8n-info-tip :bold="false">
{{ {{
hasOnlyFiltersThatShowMoreResults hasOnlyFiltersThatShowMoreResults
? i18n.baseText(`${resourceKey}.filters.active.shortText` as BaseTextKey) ? getResourceText('filters.active.shortText', 'filters.active.shortText')
: i18n.baseText(`${resourceKey}.filters.active` as BaseTextKey) : getResourceText('filters.active', 'filters.active')
}} }}
<n8n-link data-test-id="workflows-filter-reset" size="small" @click="resetFilters"> <n8n-link data-test-id="workflows-filter-reset" size="small" @click="resetFilters">
{{ i18n.baseText(`${resourceKey}.filters.active.reset` as BaseTextKey) }} {{ getResourceText('filters.active.reset', 'filters.active.reset') }}
</n8n-link> </n8n-link>
</n8n-info-tip> </n8n-info-tip>
</div> </div>
@@ -737,7 +742,7 @@ defineExpose({
size="medium" size="medium"
data-test-id="resources-list-empty" data-test-id="resources-list-empty"
> >
{{ i18n.baseText(`${resourceKey}.noResults` as BaseTextKey) }} {{ getResourceText('noResults', 'noResults') }}
</n8n-text> </n8n-text>
<slot name="postamble" /> <slot name="postamble" />

View File

@@ -1,22 +1,19 @@
import { computed, reactive } from 'vue'; import { computed, reactive } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
/** /**
* This composable holds reusable logic that detects the current page type * This composable holds reusable logic that detects the current page type
*/ */
export const useProjectPages = () => { export const useProjectPages = () => {
const route = useRoute(); const route = useRoute();
const isOverviewSubPage = computed( // Project pages have a projectId in the route params
() => const isProjectsSubPage = computed(() => route.params?.projectId !== undefined);
route.name === VIEWS.WORKFLOWS ||
route.name === VIEWS.HOMEPAGE ||
route.name === VIEWS.CREDENTIALS ||
route.name === VIEWS.EXECUTIONS ||
route.name === VIEWS.FOLDERS,
);
// Overview pages don't
const isOverviewSubPage = computed(() => route.params?.projectId === undefined);
// Shared pages are identified by specific route names
const isSharedSubPage = computed( const isSharedSubPage = computed(
() => () =>
route.name === VIEWS.SHARED_WITH_ME || route.name === VIEWS.SHARED_WITH_ME ||
@@ -24,15 +21,6 @@ export const useProjectPages = () => {
route.name === VIEWS.SHARED_CREDENTIALS, route.name === VIEWS.SHARED_CREDENTIALS,
); );
const isProjectsSubPage = computed(
() =>
route.name === VIEWS.PROJECTS_WORKFLOWS ||
route.name === VIEWS.PROJECTS_CREDENTIALS ||
route.name === VIEWS.PROJECTS_EXECUTIONS ||
route.name === VIEWS.PROJECT_SETTINGS ||
route.name === VIEWS.PROJECTS_FOLDERS,
);
return reactive({ return reactive({
isOverviewSubPage, isOverviewSubPage,
isSharedSubPage, isSharedSubPage,

View File

@@ -0,0 +1,144 @@
import type { I18nClass } from '@n8n/i18n';
import { useI18n } from '@n8n/i18n';
import { useResourcesListI18n } from './useResourcesListI18n';
import { isBaseTextKey } from '@/utils/typeGuards';
vi.mock('@n8n/i18n', () => ({
useI18n: vi.fn(),
}));
vi.mock('@/utils/typeGuards', () => ({
isBaseTextKey: vi.fn(),
}));
const mockUseI18n = vi.mocked(useI18n);
const mockIsBaseTextKey = vi.mocked(isBaseTextKey);
describe('useResourcesListI18n', () => {
let mockBaseText: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockBaseText = vi.fn();
mockUseI18n.mockReturnValue({
baseText: mockBaseText,
} as Partial<I18nClass> as I18nClass);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('getResourceText', () => {
it('should return specific resource key translation when it exists', () => {
const resourceKey = 'projects';
const keySuffix = 'list.empty';
mockIsBaseTextKey.mockReturnValueOnce(true); // specific key exists
mockBaseText.mockReturnValue('No projects found');
const { getResourceText } = useResourcesListI18n(resourceKey);
const result = getResourceText(keySuffix);
expect(mockIsBaseTextKey).toHaveBeenCalledWith('projects.list.empty');
expect(mockBaseText).toHaveBeenCalledWith('projects.list.empty', { interpolate: undefined });
expect(result).toBe('No projects found');
});
it('should return generic resource key translation when specific key does not exist', () => {
const resourceKey = 'workflows';
const keySuffix = 'list.empty';
mockIsBaseTextKey
.mockReturnValueOnce(false) // specific key doesn't exist
.mockReturnValueOnce(true); // generic key exists
mockBaseText.mockReturnValue('No resources found');
const { getResourceText } = useResourcesListI18n(resourceKey);
const result = getResourceText(keySuffix);
expect(mockIsBaseTextKey).toHaveBeenCalledWith('workflows.list.empty');
expect(mockIsBaseTextKey).toHaveBeenCalledWith('resources.list.empty');
expect(mockBaseText).toHaveBeenCalledWith('resources.list.empty', { interpolate: undefined });
expect(result).toBe('No resources found');
});
it('should return fallback key translation when neither specific nor generic key exists', () => {
const resourceKey = 'credentials';
const keySuffix = 'list.empty';
const fallbackKeySuffix = 'list.noItems';
mockIsBaseTextKey
.mockReturnValueOnce(false) // specific key doesn't exist
.mockReturnValueOnce(false) // generic key doesn't exist
.mockReturnValueOnce(true); // fallback key exists
mockBaseText.mockReturnValue('No items available');
const { getResourceText } = useResourcesListI18n(resourceKey);
const result = getResourceText(keySuffix, fallbackKeySuffix);
expect(mockIsBaseTextKey).toHaveBeenCalledWith('credentials.list.empty');
expect(mockIsBaseTextKey).toHaveBeenCalledWith('resources.list.empty');
expect(mockIsBaseTextKey).toHaveBeenCalledWith('resources.list.noItems');
expect(mockBaseText).toHaveBeenCalledWith('resources.list.noItems', {
interpolate: undefined,
});
expect(result).toBe('No items available');
});
it('should return formatted fallback text when no translation keys exist', () => {
const resourceKey = 'templates';
const keySuffix = 'list.emptyState';
mockIsBaseTextKey.mockReturnValue(false); // all keys don't exist
const { getResourceText } = useResourcesListI18n(resourceKey);
const result = getResourceText(keySuffix);
expect(result).toBe('empty State'); // camelCase to readable format (doesn't capitalize first letter)
});
it('should return original keySuffix when formatting fails', () => {
const resourceKey = 'projects';
const keySuffix = 'complex.key.name';
mockIsBaseTextKey.mockReturnValue(false); // all keys don't exist
const { getResourceText } = useResourcesListI18n(resourceKey);
const result = getResourceText(keySuffix);
expect(result).toBe('name'); // last part of the key
});
it('should return keySuffix when split result is empty', () => {
const resourceKey = 'projects';
const keySuffix = '';
mockIsBaseTextKey.mockReturnValue(false); // all keys don't exist
const { getResourceText } = useResourcesListI18n(resourceKey);
const result = getResourceText(keySuffix);
expect(result).toBe('');
});
it('should pass interpolation parameters to translation', () => {
const resourceKey = 'projects';
const keySuffix = 'list.count';
const interpolateParams = { count: '5' };
mockIsBaseTextKey.mockReturnValue(true); // key exists
mockBaseText.mockReturnValue('Found 5 projects');
const { getResourceText } = useResourcesListI18n(resourceKey);
getResourceText(keySuffix, undefined, interpolateParams);
expect(mockBaseText).toHaveBeenCalledWith('projects.list.count', {
interpolate: interpolateParams,
});
});
it('should handle complex camelCase to readable format conversion', () => {
const resourceKey = 'workflows';
const keySuffix = 'status.isActiveAndRunning';
mockIsBaseTextKey.mockReturnValue(false); // all keys don't exist
const { getResourceText } = useResourcesListI18n(resourceKey);
const result = getResourceText(keySuffix);
expect(result).toBe('is Active And Running'); // doesn't capitalize first letter
});
});
});

View File

@@ -0,0 +1,52 @@
import { isBaseTextKey } from '@/utils/typeGuards';
import { useI18n } from '@n8n/i18n';
/**
* Composable for handling i18n in ResourcesListLayout with dynamic resource keys
* It provides fallback functionality for translation keys
*/
export function useResourcesListI18n(resourceKey: string) {
const i18n = useI18n();
/**
* Get a translated text with fallback support for dynamic resource keys
* First tries the specific resource key, then falls back to generic keys
*/
const getResourceText = (
keySuffix: string,
fallbackKeySuffix?: string,
interpolate?: Record<string, string>,
) => {
const specificKey = `${resourceKey}.${keySuffix}`;
const genericKey = `resources.${keySuffix}`;
const fallbackKey = fallbackKeySuffix ? `resources.${fallbackKeySuffix}` : undefined;
// Check if the specific key exists
if (isBaseTextKey(specificKey)) {
return i18n.baseText(specificKey, { interpolate });
}
// Check if the generic key exists
if (isBaseTextKey(genericKey)) {
return i18n.baseText(genericKey, { interpolate });
}
// Use fallback key if provided
if (fallbackKey && isBaseTextKey(fallbackKey)) {
return i18n.baseText(fallbackKey, { interpolate });
}
// If no translation found, return a readable fallback
return (
keySuffix
.split('.')
.pop()
?.replace(/([A-Z])/g, ' $1')
.trim() || keySuffix
);
};
return {
getResourceText,
};
}

View File

@@ -575,6 +575,7 @@ export const enum VIEWS {
WORKFLOW_HISTORY = 'WorkflowHistory', WORKFLOW_HISTORY = 'WorkflowHistory',
WORKER_VIEW = 'WorkerView', WORKER_VIEW = 'WorkerView',
PROJECTS = 'Projects', PROJECTS = 'Projects',
PROJECT_DETAILS = 'ProjectDetails',
PROJECTS_WORKFLOWS = 'ProjectsWorkflows', PROJECTS_WORKFLOWS = 'ProjectsWorkflows',
PROJECTS_CREDENTIALS = 'ProjectsCredentials', PROJECTS_CREDENTIALS = 'ProjectsCredentials',
PROJECT_SETTINGS = 'ProjectSettings', PROJECT_SETTINGS = 'ProjectSettings',

View File

@@ -0,0 +1,241 @@
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, waitAllPromises } from '@/__tests__/utils';
import { useProjectPages } from '@/composables/useProjectPages';
import { useProjectsStore } from '@/stores/projects.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import DataStoreView from '@/features/dataStore/DataStoreView.vue';
import { STORES } from '@n8n/stores';
import { createTestingPinia } from '@pinia/testing';
import { createRouter, createWebHistory } from 'vue-router';
import type { DataStoreResource } from '@/features/dataStore/types';
import { fetchDataStores } from '@/features/dataStore/datastore.api';
vi.mock('@/features/dataStore/datastore.api');
vi.mock('@/composables/useProjectPages', () => ({
useProjectPages: vi.fn().mockReturnValue({
isOverviewSubPage: false,
isSharedSubPage: false,
}),
}));
vi.mock('@n8n/i18n', async (importOriginal) => {
const actual = await importOriginal();
const actualObj = typeof actual === 'object' && actual !== null ? actual : {};
return {
...actualObj,
useI18n: vi.fn(() => ({
baseText: vi.fn((key: string) => {
if (key === 'dataStore.tab.label') return 'Data Store';
if (key === 'projects.menu.personal') return 'Personal';
if (key === 'dataStore.empty.label') return 'No data stores';
if (key === 'dataStore.empty.description') return 'No data stores description';
if (key === 'dataStore.empty.button.label') return 'Create data store';
if (key === 'generic.rename') return 'Rename';
if (key === 'generic.delete') return 'Delete';
if (key === 'generic.clear') return 'Clear';
return key;
}),
})),
i18n: {
baseText: vi.fn((key: string) => {
if (key === 'parameterOverride.descriptionTooltip') return 'Override tooltip';
return key;
}),
},
};
});
const mockToast = {
showError: vi.fn(),
};
vi.mock('@/composables/useToast', () => ({
useToast: vi.fn(() => mockToast),
}));
const mockDocumentTitle = {
set: vi.fn(),
};
vi.mock('@/composables/useDocumentTitle', () => ({
useDocumentTitle: vi.fn(() => mockDocumentTitle),
}));
const mockDebounce = {
callDebounced: vi.fn((fn) => fn()),
};
vi.mock('@/composables/useDebounce', () => ({
useDebounce: vi.fn(() => mockDebounce),
}));
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/projects/:projectId/data-stores',
component: { template: '<div></div>' },
},
{
path: '/projects/:projectId',
component: { template: '<div></div>' },
},
],
});
let pinia: ReturnType<typeof createTestingPinia>;
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
let sourceControlStore: ReturnType<typeof mockedStore<typeof useSourceControlStore>>;
const renderComponent = createComponentRenderer(DataStoreView, {
global: {
plugins: [router],
},
});
const initialState = {
[STORES.SETTINGS]: {
settings: {
enterprise: { sharing: false, projects: { team: { limit: 5 } } },
},
},
};
const mockFetchDataStores = vi.mocked(fetchDataStores);
const TEST_DATA_STORE: DataStoreResource = {
id: '1',
name: 'Test Data Store',
size: 1024,
recordCount: 100,
columnCount: 5,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
resourceType: 'datastore',
projectId: '1',
};
describe('DataStoreView', () => {
beforeEach(async () => {
vi.clearAllMocks();
await router.push('/projects/test-project/data-stores');
await router.isReady();
pinia = createTestingPinia({ initialState });
projectsStore = mockedStore(useProjectsStore);
sourceControlStore = mockedStore(useSourceControlStore);
mockFetchDataStores.mockResolvedValue({
data: [TEST_DATA_STORE],
count: 1,
});
projectsStore.getCurrentProjectId = vi.fn(() => 'test-project');
sourceControlStore.isProjectShared = vi.fn(() => false);
});
describe('initialization', () => {
it('should initialize and fetch data stores', async () => {
const { getByTestId } = renderComponent({ pinia });
await waitAllPromises();
expect(mockFetchDataStores).toHaveBeenCalledWith(expect.any(Object), 'test-project', {
page: 1,
pageSize: 25,
});
expect(getByTestId('resources-list-wrapper')).toBeInTheDocument();
});
it('should set document title on mount', async () => {
renderComponent({ pinia });
await waitAllPromises();
expect(mockDocumentTitle.set).toHaveBeenCalledWith('Data Store');
});
it('should handle initialization error', async () => {
const error = new Error('API Error');
mockFetchDataStores.mockRejectedValue(error);
renderComponent({ pinia });
await waitAllPromises();
expect(mockToast.showError).toHaveBeenCalledWith(error, 'Error loading data stores');
});
});
describe('empty state', () => {
beforeEach(() => {
mockFetchDataStores.mockResolvedValue({
data: [],
count: 0,
});
});
it('should show empty state when no data stores exist', async () => {
const { getByTestId } = renderComponent({ pinia });
await waitAllPromises();
expect(getByTestId('empty-shared-action-box')).toBeInTheDocument();
});
it('should show description for overview sub page', async () => {
const mockProjectPages = useProjectPages as ReturnType<typeof vi.fn>;
mockProjectPages.mockReturnValue({
isOverviewSubPage: true,
isSharedSubPage: false,
});
const { getByTestId } = renderComponent({ pinia });
await waitAllPromises();
const emptyBox = getByTestId('empty-shared-action-box');
expect(emptyBox).toBeInTheDocument();
});
});
describe('data store cards', () => {
it('should render data store cards', async () => {
const { container } = renderComponent({ pinia });
await waitAllPromises();
// Check if DataStoreCard components are rendered
const cards = container.querySelectorAll('.mb-2xs');
expect(cards.length).toBeGreaterThan(0);
});
});
describe('pagination', () => {
it('should handle pagination updates', async () => {
renderComponent({ pinia });
await waitAllPromises();
// Clear the initial call
mockFetchDataStores.mockClear();
mockDebounce.callDebounced.mockClear();
// The component should be rendered and ready to handle pagination
// The debounce function will be called when pagination updates occur
// Since we can't directly trigger the pagination event in this test setup,
// we'll verify that the debounce mock is available for use
expect(mockDebounce.callDebounced).toBeDefined();
});
it('should update page size on pagination change', async () => {
mockFetchDataStores.mockResolvedValue({
data: Array.from({ length: 20 }, (_, i) => ({
...TEST_DATA_STORE,
id: `${i + 1}`,
name: `Data Store ${i + 1}`,
})),
count: 20,
});
renderComponent({ pinia });
await waitAllPromises();
// Initial call should use default page size of 25
expect(mockFetchDataStores).toHaveBeenCalledWith(expect.any(Object), 'test-project', {
page: 1,
pageSize: 25,
});
});
});
});

View File

@@ -0,0 +1,174 @@
<script setup lang="ts">
import ProjectHeader from '@/components/Projects/ProjectHeader.vue';
import ResourcesListLayout from '@/components/layouts/ResourcesListLayout.vue';
import InsightsSummary from '@/features/insights/components/InsightsSummary.vue';
import { useProjectPages } from '@/composables/useProjectPages';
import { useInsightsStore } from '@/features/insights/insights.store';
import { useI18n } from '@n8n/i18n';
import { computed, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { ProjectTypes } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store';
import { fetchDataStores } from '@/features/dataStore/datastore.api';
import { useRootStore } from '@n8n/stores/useRootStore';
import type { IUser, SortingAndPaginationUpdates, UserAction } from '@/Interface';
import type { DataStoreResource } from '@/features/dataStore/types';
import DataStoreCard from '@/features/dataStore/components/DataStoreCard.vue';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import {
DATA_STORE_CARD_ACTIONS,
DEFAULT_DATA_STORE_PAGE_SIZE,
} from '@/features/dataStore/constants';
import { useDebounce } from '@/composables/useDebounce';
import { useDocumentTitle } from '@/composables/useDocumentTitle';
import { useToast } from '@/composables/useToast';
const i18n = useI18n();
const route = useRoute();
const projectPages = useProjectPages();
const { callDebounced } = useDebounce();
const documentTitle = useDocumentTitle();
const toast = useToast();
const insightsStore = useInsightsStore();
const projectsStore = useProjectsStore();
const rootStore = useRootStore();
const sourceControlStore = useSourceControlStore();
const loading = ref(true);
const dataStores = ref<DataStoreResource[]>([]);
const totalCount = ref(0);
const currentPage = ref(1);
const pageSize = ref(DEFAULT_DATA_STORE_PAGE_SIZE);
const currentProject = computed(() => projectsStore.currentProject);
const projectName = computed(() => {
if (currentProject.value?.type === ProjectTypes.Personal) {
return i18n.baseText('projects.menu.personal');
}
return currentProject.value?.name;
});
const emptyCalloutDescription = computed(() => {
return projectPages.isOverviewSubPage ? i18n.baseText('dataStore.empty.description') : '';
});
const emptyCalloutButtonText = computed(() => {
if (projectPages.isOverviewSubPage || !projectName.value) {
return '';
}
return i18n.baseText('dataStore.empty.button.label', {
interpolate: { projectName: projectName.value },
});
});
const readOnlyEnv = computed(() => sourceControlStore.preferences.branchReadOnly);
const cardActions = computed<Array<UserAction<IUser>>>(() => [
{
label: i18n.baseText('generic.rename'),
value: DATA_STORE_CARD_ACTIONS.RENAME,
disabled: readOnlyEnv.value,
},
{
label: i18n.baseText('generic.delete'),
value: DATA_STORE_CARD_ACTIONS.DELETE,
disabled: readOnlyEnv.value,
},
{
label: i18n.baseText('generic.clear'),
value: DATA_STORE_CARD_ACTIONS.CLEAR,
disabled: readOnlyEnv.value,
},
]);
const initialize = async () => {
loading.value = true;
const projectId = Array.isArray(route.params.projectId)
? route.params.projectId[0]
: route.params.projectId;
try {
const response = await fetchDataStores(rootStore.restApiContext, projectId, {
page: currentPage.value,
pageSize: pageSize.value,
});
dataStores.value = response.data.map((item) => ({
...item,
resourceType: 'datastore',
}));
totalCount.value = response.count;
} catch (error) {
toast.showError(error, 'Error loading data stores');
} finally {
loading.value = false;
}
};
const onPaginationUpdate = async (payload: SortingAndPaginationUpdates) => {
if (payload.page) {
currentPage.value = payload.page;
}
if (payload.pageSize) {
pageSize.value = payload.pageSize;
}
if (!loading.value) {
await callDebounced(initialize, { debounceTime: 200, trailing: true });
}
};
onMounted(() => {
documentTitle.set(i18n.baseText('dataStore.tab.label'));
});
</script>
<template>
<ResourcesListLayout
ref="layout"
resource-key="dataStore"
type="list-paginated"
:resources="dataStores"
:initialize="initialize"
:type-props="{ itemSize: 80 }"
:loading="loading"
:disabled="false"
:total-items="totalCount"
:dont-perform-sorting-and-filtering="true"
:ui-config="{
searchEnabled: false,
showFiltersDropdown: false,
sortEnabled: false,
}"
@update:pagination-and-sort="onPaginationUpdate"
>
<template #header>
<ProjectHeader>
<InsightsSummary
v-if="projectPages.isOverviewSubPage && insightsStore.isSummaryEnabled"
:loading="insightsStore.weeklySummary.isLoading"
:summary="insightsStore.weeklySummary.state"
time-range="week"
/>
</ProjectHeader>
</template>
<template #empty>
<n8n-action-box
data-test-id="empty-shared-action-box"
:heading="i18n.baseText('dataStore.empty.label')"
:description="emptyCalloutDescription"
:button-text="emptyCalloutButtonText"
button-type="secondary"
/>
</template>
<template #item="{ item: data }">
<DataStoreCard
class="mb-2xs"
:data-store="data as DataStoreResource"
:show-ownership-badge="projectPages.isOverviewSubPage"
:actions="cardActions"
:read-only="readOnlyEnv"
/>
</template>
</ResourcesListLayout>
</template>

View File

@@ -0,0 +1,145 @@
import { createComponentRenderer } from '@/__tests__/render';
import DataStoreCard from './DataStoreCard.vue';
import { createPinia, setActivePinia } from 'pinia';
import type { DataStoreResource } from '@/features/dataStore/types';
import type { UserAction, IUser } from '@/Interface';
vi.mock('vue-router', () => {
const push = vi.fn();
const resolve = vi.fn().mockReturnValue({ href: '/projects/1/datastores/1' });
return {
useRouter: vi.fn().mockReturnValue({
push,
resolve,
}),
useRoute: vi.fn().mockReturnValue({
params: {
projectId: '1',
id: '1',
},
query: {},
}),
RouterLink: vi.fn(),
};
});
const DEFAULT_DATA_STORE: DataStoreResource = {
id: '1',
name: 'Test Data Store',
size: 1024,
recordCount: 100,
columnCount: 5,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
resourceType: 'datastore',
projectId: '1',
} as const satisfies DataStoreResource;
const renderComponent = createComponentRenderer(DataStoreCard, {
props: {
dataStore: DEFAULT_DATA_STORE,
actions: [
{ label: 'Open', value: 'open', disabled: false },
{ label: 'Delete', value: 'delete', disabled: false },
] as const satisfies Array<UserAction<IUser>>,
readOnly: false,
showOwnershipBadge: false,
},
global: {
stubs: {
N8nLink: {
template: '<div data-test-id="data-store-card-link"><slot /></div>',
},
TimeAgo: {
template: '<span>just now</span>',
},
},
},
});
describe('DataStoreCard', () => {
let pinia: ReturnType<typeof createPinia>;
beforeEach(async () => {
pinia = createPinia();
setActivePinia(pinia);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should render data store info correctly', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('data-store-card-icon')).toBeInTheDocument();
expect(getByTestId('folder-card-name')).toHaveTextContent(DEFAULT_DATA_STORE.name);
expect(getByTestId('data-store-card-record-count')).toBeInTheDocument();
expect(getByTestId('data-store-card-column-count')).toBeInTheDocument();
expect(getByTestId('data-store-card-last-updated')).toHaveTextContent('Last updated');
expect(getByTestId('data-store-card-created')).toHaveTextContent('Created');
});
it('should not render readonly badge when not readonly', () => {
const { queryByText } = renderComponent();
expect(queryByText('Read only')).not.toBeInTheDocument();
});
it('should render readonly badge when readonly', () => {
const { getByText } = renderComponent({
props: {
readOnly: true,
},
});
expect(getByText('Read only')).toBeInTheDocument();
});
it('should not render action dropdown if no actions are provided', () => {
const { queryByTestId } = renderComponent({
props: {
actions: [],
},
});
expect(queryByTestId('folder-card-actions')).not.toBeInTheDocument();
});
it('should render action dropdown if actions are provided', () => {
const { getByTestId } = renderComponent();
expect(getByTestId('folder-card-actions')).toBeInTheDocument();
});
it('should render correct route to data store details', () => {
const wrapper = renderComponent();
const link = wrapper.getByTestId('data-store-card-link');
expect(link).toBeInTheDocument();
});
it('should display size information', () => {
const { getByTestId } = renderComponent();
const sizeElement = getByTestId('folder-card-folder-count');
expect(sizeElement).toBeInTheDocument();
});
it('should display record count information', () => {
const { getByTestId } = renderComponent();
const recordCountElement = getByTestId('data-store-card-record-count');
expect(recordCountElement).toBeInTheDocument();
});
it('should display column count information', () => {
const { getByTestId } = renderComponent();
const columnCountElement = getByTestId('data-store-card-column-count');
expect(columnCountElement).toBeInTheDocument();
});
it('should display last updated information', () => {
const { getByTestId } = renderComponent();
const lastUpdatedElement = getByTestId('data-store-card-last-updated');
expect(lastUpdatedElement).toBeInTheDocument();
});
it('should display created information', () => {
const { getByTestId } = renderComponent();
const createdElement = getByTestId('data-store-card-created');
expect(createdElement).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,173 @@
<script setup lang="ts">
import type { IUser, UserAction } from '@/Interface';
import type { DataStoreResource } from '@/features/dataStore/types';
import { DATA_STORE_DETAILS } from '../constants';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
type Props = {
dataStore: DataStoreResource;
actions?: Array<UserAction<IUser>>;
readOnly?: boolean;
showOwnershipBadge?: boolean;
};
const i18n = useI18n();
const props = withDefaults(defineProps<Props>(), {
actions: () => [],
readOnly: false,
showOwnershipBadge: false,
});
const dataStoreRoute = computed(() => {
return {
name: DATA_STORE_DETAILS,
params: {
projectId: props.dataStore.projectId,
id: props.dataStore.id,
},
};
});
</script>
<template>
<div data-test-id="data-store-card">
<N8nLink :to="dataStoreRoute" class="data-store-card" data-test-id="data-store-card-link">
<N8nCard :class="$style.card">
<template #prepend>
<N8nIcon
data-test-id="data-store-card-icon"
:class="$style['card-icon']"
icon="database"
size="xlarge"
:stroke-width="1"
/>
</template>
<template #header>
<div :class="$style['card-header']">
<N8nHeading tag="h2" bold size="small" data-test-id="folder-card-name">
{{ props.dataStore.name }}
</N8nHeading>
<N8nBadge v-if="props.readOnly" class="ml-3xs" theme="tertiary" bold>
{{ i18n.baseText('workflows.item.readonly') }}
</N8nBadge>
</div>
</template>
<template #footer>
<div :class="$style['card-footer']">
<N8nText
size="small"
color="text-light"
:class="[$style['info-cell'], $style['info-cell--size']]"
data-test-id="folder-card-folder-count"
>
{{ i18n.baseText('dataStore.card.size', { interpolate: { size: dataStore.size } }) }}
</N8nText>
<N8nText
size="small"
color="text-light"
:class="[$style['info-cell'], $style['info-cell--record-count']]"
data-test-id="data-store-card-record-count"
>
{{
i18n.baseText('dataStore.card.row.count', {
interpolate: { count: props.dataStore.recordCount },
})
}}
</N8nText>
<N8nText
size="small"
color="text-light"
:class="[$style['info-cell'], $style['info-cell--column-count']]"
data-test-id="data-store-card-column-count"
>
{{
i18n.baseText('dataStore.card.column.count', {
interpolate: { count: props.dataStore.columnCount },
})
}}
</N8nText>
<N8nText
size="small"
color="text-light"
:class="[$style['info-cell'], $style['info-cell--updated']]"
data-test-id="data-store-card-last-updated"
>
{{ i18n.baseText('workerList.item.lastUpdated') }}
<TimeAgo :date="String(props.dataStore.updatedAt)" />
</N8nText>
<N8nText
size="small"
color="text-light"
:class="[$style['info-cell'], $style['info-cell--created']]"
data-test-id="data-store-card-created"
>
{{ i18n.baseText('workflows.item.created') }}
<TimeAgo :date="String(props.dataStore.createdAt)" />
</N8nText>
</div>
</template>
<template #append>
<div :class="$style['card-actions']" @click.prevent>
<N8nActionToggle
v-if="props.actions.length"
:actions="props.actions"
theme="dark"
data-test-id="folder-card-actions"
/>
</div>
</template>
</N8nCard>
</N8nLink>
</div>
</template>
<style lang="scss" module>
.card {
transition: box-shadow 0.3s ease;
cursor: pointer;
&:hover {
box-shadow: 0 2px 8px rgba(#441c17, 0.1);
}
}
.card-icon {
flex-shrink: 0;
color: var(--color-text-base);
align-content: center;
text-align: center;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-right: var(--spacing-xs);
margin-bottom: var(--spacing-5xs);
}
.card-footer {
display: flex;
}
.info-cell {
& + & {
&::before {
content: '|';
margin: 0 var(--spacing-4xs);
}
}
}
@include mixins.breakpoint('sm-and-down') {
.card {
flex-wrap: wrap;
}
.info-cell--created,
.info-cell--record-count,
.info-cell--column-count {
display: none;
}
}
</style>

View File

@@ -0,0 +1,12 @@
// Route and view identifiers
export const DATA_STORE_VIEW = 'data-stores';
export const PROJECT_DATA_STORES = 'project-data-stores';
export const DATA_STORE_DETAILS = 'data-store-details';
export const DEFAULT_DATA_STORE_PAGE_SIZE = 10;
export const DATA_STORE_CARD_ACTIONS = {
RENAME: 'rename',
DELETE: 'delete',
CLEAR: 'clear',
};

View File

@@ -0,0 +1,18 @@
import { getFullApiResponse } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import { type DataStoreEntity } from '@/features/dataStore/datastore.types';
export const fetchDataStores = async (
context: IRestApiContext,
projectId?: string,
options?: {
page?: number;
pageSize?: number;
},
) => {
return await getFullApiResponse<DataStoreEntity[]>(context, 'GET', '/data-stores', {
projectId,
options,
});
};

View File

@@ -0,0 +1,10 @@
export type DataStoreEntity = {
id: string;
name: string;
size: number;
recordCount: number;
columnCount: number;
createdAt: string;
updatedAt: string;
projectId?: string;
};

View File

@@ -0,0 +1,85 @@
import { useI18n } from '@n8n/i18n';
import { type FrontendModuleDescription } from '@/moduleInitializer/module.types';
import {
DATA_STORE_DETAILS,
DATA_STORE_VIEW,
PROJECT_DATA_STORES,
} from '@/features/dataStore/constants';
const i18n = useI18n();
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
const DataStoreView = async () => await import('@/features/dataStore/DataStoreView.vue');
export const DataStoreModule: FrontendModuleDescription = {
id: 'data-store',
name: 'Data Store',
description: 'Manage and store data efficiently with the Data Store module.',
icon: 'database',
routes: [
{
name: DATA_STORE_VIEW,
path: '/home/datastores',
components: {
default: DataStoreView,
sidebar: MainSidebar,
},
meta: {
middleware: ['authenticated', 'custom'],
},
},
{
name: PROJECT_DATA_STORES,
path: 'datastores',
props: true,
components: {
default: DataStoreView,
sidebar: MainSidebar,
},
meta: {
projectRoute: true,
middleware: ['authenticated', 'custom'],
},
},
{
name: DATA_STORE_DETAILS,
path: 'datastores/:id',
props: true,
components: {
default: DataStoreView,
sidebar: MainSidebar,
},
meta: {
projectRoute: true,
middleware: ['authenticated', 'custom'],
},
},
],
projectTabs: {
overview: [
{
label: i18n.baseText('dataStore.tab.label'),
value: DATA_STORE_VIEW,
to: {
name: DATA_STORE_VIEW,
},
},
],
project: [
{
label: i18n.baseText('dataStore.tab.label'),
value: PROJECT_DATA_STORES,
dynamicRoute: {
name: PROJECT_DATA_STORES,
includeProjectId: true,
},
},
],
},
resources: [
{
key: 'dataStore',
displayName: 'Data Store',
},
],
};

View File

@@ -0,0 +1,21 @@
import type { BaseResource } from '@/Interface';
import type { DataStoreEntity } from './datastore.types';
/**
* Data Store resource type definition
* This extends the ModuleResources interface to add DataStore as a resource type
*/
export type DataStoreResource = BaseResource &
DataStoreEntity & {
resourceType: 'datastore';
};
// Extend the ModuleResources interface to include DataStore
declare module '@/Interface' {
interface ModuleResources {
dataStore: DataStoreResource;
}
}
// Export to make this a module
export {};

View File

@@ -1,41 +0,0 @@
import { RouterView, type RouteRecordRaw } from 'vue-router';
import { VIEWS } from '@/constants';
import { useInsightsStore } from '@/features/insights/insights.store';
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
const InsightsDashboard = async () =>
await import('@/features/insights/components/InsightsDashboard.vue');
export const insightsRoutes: RouteRecordRaw[] = [
{
path: '/insights',
beforeEnter() {
const insightsStore = useInsightsStore();
return insightsStore.isInsightsEnabled || { name: VIEWS.NOT_FOUND };
},
components: {
default: RouterView,
sidebar: MainSidebar,
},
meta: {
middleware: ['authenticated', 'rbac'],
middlewareOptions: {
rbac: {
scope: ['insights:list'],
},
},
},
children: [
{
path: ':insightType?',
name: VIEWS.INSIGHTS,
beforeEnter(to) {
if (to.params.insightType) return true;
return Object.assign(to, { params: { ...to.params, insightType: 'total' } });
},
component: InsightsDashboard,
props: true,
},
],
},
];

View File

@@ -18,9 +18,7 @@ export const useInsightsStore = defineStore('insights', () => {
() => getResourcePermissions(usersStore.currentUser?.globalScopes).insights, () => getResourcePermissions(usersStore.currentUser?.globalScopes).insights,
); );
const isInsightsEnabled = computed(() => const isInsightsEnabled = computed(() => settingsStore.isModuleActive('insights'));
settingsStore.settings.activeModules.includes('insights'),
);
const isDashboardEnabled = computed(() => !!settingsStore.moduleSettings.insights?.dashboard); const isDashboardEnabled = computed(() => !!settingsStore.moduleSettings.insights?.dashboard);

View File

@@ -0,0 +1,48 @@
import { RouterView } from 'vue-router';
import type { FrontendModuleDescription } from '@/moduleInitializer/module.types';
import { useInsightsStore } from '@/features/insights/insights.store';
import { VIEWS } from '@/constants';
const MainSidebar = async () => await import('@/components/MainSidebar.vue');
const InsightsDashboard = async () =>
await import('@/features/insights/components/InsightsDashboard.vue');
export const InsightsModule: FrontendModuleDescription = {
id: 'insights',
name: 'Insights',
description: 'Provides insights and analytics features for projects.',
icon: 'chart-column-decreasing',
routes: [
{
path: '/insights',
beforeEnter() {
const insightsStore = useInsightsStore();
return insightsStore.isInsightsEnabled || { name: VIEWS.NOT_FOUND };
},
components: {
default: RouterView,
sidebar: MainSidebar,
},
meta: {
middleware: ['authenticated', 'rbac'],
middlewareOptions: {
rbac: {
scope: ['insights:list'],
},
},
},
children: [
{
path: ':insightType?',
name: VIEWS.INSIGHTS,
beforeEnter(to) {
if (to.params.insightType) return true;
return Object.assign(to, { params: { ...to.params, insightType: 'total' } });
},
component: InsightsDashboard,
props: true,
},
],
},
],
};

View File

@@ -22,6 +22,10 @@ import { useNpsSurveyStore } from '@/stores/npsSurvey.store';
import { usePostHog } from '@/stores/posthog.store'; import { usePostHog } from '@/stores/posthog.store';
import { useTelemetry } from '@/composables/useTelemetry'; import { useTelemetry } from '@/composables/useTelemetry';
import { useRBACStore } from '@/stores/rbac.store'; import { useRBACStore } from '@/stores/rbac.store';
import {
registerModuleProjectTabs,
registerModuleResources,
} from '@/moduleInitializer/moduleInitializer';
export const state = { export const state = {
initialized: false, initialized: false,
@@ -181,6 +185,10 @@ export async function initializeAuthenticatedFeatures(
rolesStore.fetchRoles(), rolesStore.fetchRoles(),
]); ]);
// Initialize modules
registerModuleResources();
registerModuleProjectTabs();
authenticatedFeaturesInitialized = true; authenticatedFeaturesInitialized = true;
} }

View File

@@ -24,6 +24,7 @@ import { FontAwesomePlugin } from './plugins/icons';
import { createPinia, PiniaVuePlugin } from 'pinia'; import { createPinia, PiniaVuePlugin } from 'pinia';
import { ChartJSPlugin } from '@/plugins/chartjs'; import { ChartJSPlugin } from '@/plugins/chartjs';
import { SentryPlugin } from '@/plugins/sentry'; import { SentryPlugin } from '@/plugins/sentry';
import { registerModuleRoutes } from '@/moduleInitializer/moduleInitializer';
import type { VueScanOptions } from 'z-vue-scan'; import type { VueScanOptions } from 'z-vue-scan';
@@ -32,6 +33,11 @@ const pinia = createPinia();
const app = createApp(App); const app = createApp(App);
app.use(SentryPlugin); app.use(SentryPlugin);
// Register module routes
// We do this here so landing straight on a module page works
registerModuleRoutes(router);
app.use(TelemetryPlugin); app.use(TelemetryPlugin);
app.use(PiniaVuePlugin); app.use(PiniaVuePlugin);
app.use(FontAwesomePlugin); app.use(FontAwesomePlugin);

View File

@@ -0,0 +1,22 @@
import type { DynamicTabOptions } from '@/utils/modules/tabUtils';
import type { RouteRecordRaw } from 'vue-router';
export type ResourceMetadata = {
key: string;
displayName: string;
i18nKeys?: Record<string, string>;
};
export type FrontendModuleDescription = {
id: string;
name: string;
description: string;
icon: string;
routes?: RouteRecordRaw[];
projectTabs?: {
overview?: DynamicTabOptions[];
project?: DynamicTabOptions[];
shared?: DynamicTabOptions[];
};
resources?: ResourceMetadata[];
};

View File

@@ -0,0 +1,86 @@
import { type Router } from 'vue-router';
import { VIEWS } from '@/constants';
import { DataStoreModule } from '@/features/dataStore/module.descriptor';
import { registerResource } from '@/moduleInitializer/resourceRegistry';
import { useUIStore } from '@/stores/ui.store';
import { useSettingsStore } from '@/stores/settings.store';
import { InsightsModule } from '../features/insights/module.descriptor';
import type { FrontendModuleDescription } from '@/moduleInitializer/module.types';
/**
* Hard-coding modules list until we have a dynamic way to load modules.
*/
const modules: FrontendModuleDescription[] = [InsightsModule, DataStoreModule];
/**
* Initialize modules resources (used in ResourcesListLayout), done in init.ts
*/
export const registerModuleResources = () => {
modules.forEach((module) => {
module.resources?.forEach((resource) => {
registerResource(resource);
});
});
};
/**
* Initialize modules project tabs (used in ProjectHeader), done in init.ts
*/
export const registerModuleProjectTabs = () => {
const uiStore = useUIStore();
modules.forEach((module) => {
if (module.projectTabs) {
if (module.projectTabs.overview) {
uiStore.registerCustomTabs('overview', module.id, module.projectTabs.overview);
}
if (module.projectTabs.project) {
uiStore.registerCustomTabs('project', module.id, module.projectTabs.project);
}
if (module.projectTabs.shared) {
uiStore.registerCustomTabs('shared', module.id, module.projectTabs.shared);
}
}
});
};
/**
* Middleware function to check if a module is available
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const checkModuleAvailability = (options: any) => {
if (!options?.to?.meta?.moduleName || typeof options.to.meta.moduleName !== 'string') {
return true;
}
return useSettingsStore().isModuleActive(options.to.meta.moduleName);
};
/**
* Initialize module routes, done in main.ts
*/
export const registerModuleRoutes = (router: Router) => {
modules.forEach((module) => {
module.routes?.forEach((route) => {
// Prepare the enhanced route with module metadata and custom middleware that checks module availability
const enhancedRoute = {
...route,
meta: {
...route.meta,
moduleName: module.id,
// Merge middleware options if custom middleware is present
...(route.meta?.middleware?.includes('custom') && {
middlewareOptions: {
...route.meta?.middlewareOptions,
custom: checkModuleAvailability,
},
}),
},
};
if (route.meta?.projectRoute) {
router.addRoute(VIEWS.PROJECT_DETAILS, enhancedRoute);
} else {
router.addRoute(enhancedRoute);
}
});
});
};

View File

@@ -0,0 +1,48 @@
/**
* Allows features to register their resource types without modifying core components
* These resources can then be used in ResourcesListLayout.
*
* Apart from this, new resource types can be registered like this:
1. Create a types.d.ts file in your feature directory
2. Define your resource type extending BaseResource
3. Use module augmentation to extend ModuleResources:
declare module '@/Interface' {
interface ModuleResources {
myFeature: MyFeatureResource;
}
}
4. Import your resource type from the local types file in your components
*/
import { type ResourceMetadata } from './module.types';
// Private module state
const resources: Map<string, ResourceMetadata> = new Map();
/**
* Register a new resource type
*/
export function registerResource(metadata: ResourceMetadata): void {
resources.set(metadata.key, metadata);
}
/**
* Get resource metadata by key
*/
export function getResource(key: string): ResourceMetadata | undefined {
return resources.get(key);
}
/**
* Check if a resource is registered
*/
export function hasResource(key: string): boolean {
return resources.has(key);
}
/**
* Get all registered resource keys
*/
export function getAllResourceKeys(): string[] {
return Array.from(resources.keys());
}

View File

@@ -18,7 +18,6 @@ import type { RouterMiddleware } from '@/types/router';
import { initializeAuthenticatedFeatures, initializeCore } from '@/init'; import { initializeAuthenticatedFeatures, initializeCore } from '@/init';
import { tryToParseNumber } from '@/utils/typesUtils'; import { tryToParseNumber } from '@/utils/typesUtils';
import { projectsRoutes } from '@/routes/projects.routes'; import { projectsRoutes } from '@/routes/projects.routes';
import { insightsRoutes } from '@/features/insights/insights.router';
import TestRunDetailView from '@/views/Evaluations.ee/TestRunDetailView.vue'; import TestRunDetailView from '@/views/Evaluations.ee/TestRunDetailView.vue';
import { MfaRequiredError } from '@n8n/rest-api-client'; import { MfaRequiredError } from '@n8n/rest-api-client';
@@ -719,7 +718,6 @@ export const routes: RouteRecordRaw[] = [
}, },
}, },
...projectsRoutes, ...projectsRoutes,
...insightsRoutes,
{ {
path: '/entity-not-found/:entityType(credential|workflow)', path: '/entity-not-found/:entityType(credential|workflow)',
props: true, props: true,

View File

@@ -114,6 +114,7 @@ export const projectsRoutes: RouteRecordRaw[] = [
redirect: '/home/workflows', redirect: '/home/workflows',
children: [ children: [
{ {
name: VIEWS.PROJECT_DETAILS,
path: ':projectId', path: ':projectId',
meta: { meta: {
middleware: ['authenticated'], middleware: ['authenticated'],

View File

@@ -96,6 +96,12 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const isCloudDeployment = computed(() => settings.value.deployment?.type === 'cloud'); const isCloudDeployment = computed(() => settings.value.deployment?.type === 'cloud');
const activeModules = computed(() => settings.value.activeModules);
const isModuleActive = (moduleName: string) => {
return activeModules.value.includes(moduleName);
};
const partialExecutionVersion = computed<1 | 2>(() => { const partialExecutionVersion = computed<1 | 2>(() => {
const defaultVersion = settings.value.partialExecution?.version ?? 1; const defaultVersion = settings.value.partialExecution?.version ?? 1;
// -1 means we pick the defaultVersion // -1 means we pick the defaultVersion
@@ -178,8 +184,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
const isDevRelease = computed(() => settings.value.releaseChannel === 'dev'); const isDevRelease = computed(() => settings.value.releaseChannel === 'dev');
const activeModules = computed(() => settings.value.activeModules);
const setSettings = (newSettings: FrontendSettings) => { const setSettings = (newSettings: FrontendSettings) => {
settings.value = newSettings; settings.value = newSettings;
userManagement.value = newSettings.userManagement; userManagement.value = newSettings.userManagement;
@@ -395,10 +399,11 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
getSettings, getSettings,
setSettings, setSettings,
initialize, initialize,
activeModules,
getModuleSettings, getModuleSettings,
moduleSettings, moduleSettings,
isMFAEnforcementLicensed, isMFAEnforcementLicensed,
isMFAEnforced, isMFAEnforced,
activeModules,
isModuleActive,
}; };
}); });

View File

@@ -55,6 +55,7 @@ import type {
ModalState, ModalState,
ModalKey, ModalKey,
AppliedThemeOption, AppliedThemeOption,
TabOptions,
} from '@/Interface'; } from '@/Interface';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useRootStore } from '@n8n/stores/useRootStore'; import { useRootStore } from '@n8n/stores/useRootStore';
@@ -233,6 +234,30 @@ export const useUIStore = defineStore(STORES.UI, () => {
const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({}); const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({});
const processingExecutionResults = ref<boolean>(false); const processingExecutionResults = ref<boolean>(false);
/**
* Modules can register their ProjectHeader tabs here
* Since these tabs are specific to the page they are on,
* we add them to separate arrays so pages can pick the right ones
* at render time.
* Module name is also added to the key so that we can check if the module is active
* when tabs are rendered.\
* @example
* uiStore.registerCustomTabs('overview', 'data-store', [
* {
* label: 'Data Store',
* value: 'data-store',
* to: { name: 'data-store' },
* },
* ]);
*/
const moduleTabs = ref<
Record<'overview' | 'project' | 'shared', Record<string, Array<TabOptions<string>>>>
>({
overview: {},
project: {},
shared: {},
});
const appGridDimensions = ref<{ width: number; height: number }>({ width: 0, height: 0 }); const appGridDimensions = ref<{ width: number; height: number }>({ width: 0, height: 0 });
// Last interacted with - Canvas v2 specific // Last interacted with - Canvas v2 specific
@@ -529,6 +554,17 @@ export const useUIStore = defineStore(STORES.UI, () => {
lastCancelledConnectionPosition.value = undefined; lastCancelledConnectionPosition.value = undefined;
} }
const registerCustomTabs = (
page: 'overview' | 'project' | 'shared',
moduleName: string,
tabs: Array<TabOptions<string>>,
) => {
if (!moduleTabs.value[page]) {
throw new Error(`Invalid page type: ${page}`);
}
moduleTabs.value[page][moduleName] = tabs;
};
/** /**
* Set whether we are currently in the process of fetching and deserializing * Set whether we are currently in the process of fetching and deserializing
* the full execution data and loading it to the store. * the full execution data and loading it to the store.
@@ -595,6 +631,8 @@ export const useUIStore = defineStore(STORES.UI, () => {
openDeleteFolderModal, openDeleteFolderModal,
openMoveToFolderModal, openMoveToFolderModal,
initialize, initialize,
moduleTabs,
registerCustomTabs,
}; };
}); });

View File

@@ -0,0 +1,43 @@
import type { RouteLocationRaw } from 'vue-router';
import type { TabOptions } from '@n8n/design-system';
export type DynamicTabOptions = TabOptions<string> & {
dynamicRoute?: {
name: string;
includeProjectId?: boolean;
};
};
/**
* Process dynamic route configuration for tabs
* Resolves dynamic routes with project IDs and other parameters
*/
export function processDynamicTab(tab: DynamicTabOptions, projectId?: string): TabOptions<string> {
if (!tab.dynamicRoute) {
return tab;
}
const tabRoute: RouteLocationRaw = {
name: tab.dynamicRoute.name,
};
if (tab.dynamicRoute.includeProjectId && projectId) {
tabRoute.params = { projectId };
}
const { dynamicRoute, ...tabWithoutDynamic } = tab;
return {
...tabWithoutDynamic,
to: tabRoute,
};
}
/**
* Process an array of tabs with dynamic route resolution
*/
export function processDynamicTabs(
tabs: DynamicTabOptions[],
projectId?: string,
): Array<TabOptions<string>> {
return tabs.map((tab) => processDynamicTab(tab, projectId));
}

View File

@@ -19,6 +19,7 @@ import type { RouteLocationRaw } from 'vue-router';
import type { CanvasConnectionMode } from '@/types'; import type { CanvasConnectionMode } from '@/types';
import { canvasConnectionModes } from '@/types'; import { canvasConnectionModes } from '@/types';
import type { ComponentPublicInstance } from 'vue'; import type { ComponentPublicInstance } from 'vue';
import { type BaseTextKey, useI18n } from '@n8n/i18n';
/* /*
Type guards used in editor-ui project Type guards used in editor-ui project
@@ -130,3 +131,16 @@ export function isResourceSortableByDate(
): value is WorkflowResource | FolderResource | CredentialsResource { ): value is WorkflowResource | FolderResource | CredentialsResource {
return isWorkflowResource(value) || isFolderResource(value) || isCredentialsResource(value); return isWorkflowResource(value) || isFolderResource(value) || isCredentialsResource(value);
} }
// Check if i18n key is a valid BaseTextKey
export function isBaseTextKey(key: string): key is BaseTextKey {
const i18n = useI18n();
try {
// Attempt to access the base text to check if the key is valid
i18n.baseText(key as BaseTextKey);
return true;
} catch {
// If an error is thrown, the key is not valid
return false;
}
}