mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(editor): Add front-end for Data Store feature (#17590)
This commit is contained in:
committed by
GitHub
parent
b745cad72c
commit
b89c254394
@@ -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>",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' } }),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export type DataStoreEntity = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
recordCount: number;
|
||||||
|
columnCount: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
projectId?: string;
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
21
packages/frontend/editor-ui/src/features/dataStore/types.ts
Normal file
21
packages/frontend/editor-ui/src/features/dataStore/types.ts
Normal 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 {};
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
43
packages/frontend/editor-ui/src/utils/modules/tabUtils.ts
Normal file
43
packages/frontend/editor-ui/src/utils/modules/tabUtils.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user