mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-20 11:22:15 +00:00
feat(editor): Add front-end for Data Store feature (#17590)
This commit is contained in:
committed by
GitHub
parent
b745cad72c
commit
b89c254394
@@ -327,7 +327,19 @@ export type CredentialsResource = BaseResource & {
|
||||
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 = {
|
||||
search: string;
|
||||
|
||||
@@ -120,7 +120,7 @@ const mainMenuItems = computed<IMenuItem[]>(() => [
|
||||
position: 'bottom',
|
||||
route: { to: { name: VIEWS.INSIGHTS } },
|
||||
available:
|
||||
settingsStore.settings.activeModules.includes('insights') &&
|
||||
settingsStore.isModuleActive('insights') &&
|
||||
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 { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useProjectPages } from '@/composables/useProjectPages';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
||||
const mockPush = vi.fn();
|
||||
vi.mock('vue-router', async () => {
|
||||
@@ -54,6 +55,7 @@ const renderComponent = createComponentRenderer(ProjectHeader, {
|
||||
let route: ReturnType<typeof router.useRoute>;
|
||||
let projectsStore: ReturnType<typeof mockedStore<typeof useProjectsStore>>;
|
||||
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
|
||||
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
|
||||
let projectPages: ReturnType<typeof useProjectPages>;
|
||||
|
||||
describe('ProjectHeader', () => {
|
||||
@@ -62,10 +64,18 @@ describe('ProjectHeader', () => {
|
||||
route = router.useRoute();
|
||||
projectsStore = mockedStore(useProjectsStore);
|
||||
settingsStore = mockedStore(useSettingsStore);
|
||||
uiStore = mockedStore(useUIStore);
|
||||
projectPages = useProjectPages();
|
||||
|
||||
projectsStore.teamProjectsLimit = -1;
|
||||
settingsStore.settings.folders = { enabled: false };
|
||||
|
||||
// Setup default moduleTabs structure
|
||||
uiStore.moduleTabs = {
|
||||
shared: {},
|
||||
overview: {},
|
||||
project: {},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -256,4 +266,174 @@ describe('ProjectHeader', () => {
|
||||
const { queryByTestId } = renderComponent();
|
||||
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 { useRoute, useRouter } from 'vue-router';
|
||||
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 { useI18n } from '@n8n/i18n';
|
||||
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 { IUser } from 'n8n-workflow';
|
||||
import { type IconOrEmoji, isIconOrEmoji } from '@n8n/design-system/components/N8nIconPicker/types';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -26,6 +27,8 @@ const i18n = useI18n();
|
||||
const projectsStore = useProjectsStore();
|
||||
const sourceControlStore = useSourceControlStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const projectPages = useProjectPages();
|
||||
|
||||
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 = {
|
||||
WORKFLOW: 'workflow',
|
||||
CREDENTIAL: 'credential',
|
||||
@@ -278,6 +298,7 @@ const onSelect = (action: string) => {
|
||||
:page-type="pageType"
|
||||
:show-executions="!projectPages.isSharedSubPage"
|
||||
:show-settings="showSettings"
|
||||
:additional-tabs="customProjectTabs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,17 +6,20 @@ import { VIEWS } from '@/constants';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import type { TabOptions } from '@n8n/design-system';
|
||||
import { processDynamicTabs, type DynamicTabOptions } from '@/utils/modules/tabUtils';
|
||||
|
||||
type Props = {
|
||||
showSettings?: boolean;
|
||||
showExecutions?: boolean;
|
||||
pageType?: 'overview' | 'shared' | 'project';
|
||||
additionalTabs?: DynamicTabOptions[];
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showSettings: false,
|
||||
showExecutions: true,
|
||||
pageType: 'project',
|
||||
additionalTabs: () => [],
|
||||
});
|
||||
|
||||
const locale = useI18n();
|
||||
@@ -93,6 +96,11 @@ const options = computed<Array<TabOptions<string>>>(() => {
|
||||
tabs.push(createTab('mainSidebar.executions', 'executions', routes));
|
||||
}
|
||||
|
||||
if (props.additionalTabs?.length) {
|
||||
const processedAdditionalTabs = processDynamicTabs(props.additionalTabs, projectId.value);
|
||||
tabs.push(...processedAdditionalTabs);
|
||||
}
|
||||
|
||||
if (props.showSettings) {
|
||||
tabs.push({
|
||||
label: locale.baseText('projects.settings'),
|
||||
|
||||
@@ -158,7 +158,11 @@ describe('ResourcesListLayout', () => {
|
||||
props: {
|
||||
resources: TEST_WORKFLOWS,
|
||||
type: 'list-paginated',
|
||||
showFiltersDropdown: true,
|
||||
uiConfig: {
|
||||
searchEnabled: true,
|
||||
showFiltersDropdown: true,
|
||||
sortEnabled: true,
|
||||
},
|
||||
filters: {
|
||||
search: '',
|
||||
homeProject: '',
|
||||
@@ -181,7 +185,11 @@ describe('ResourcesListLayout', () => {
|
||||
props: {
|
||||
resources: TEST_WORKFLOWS,
|
||||
type: 'list-paginated',
|
||||
showFiltersDropdown: true,
|
||||
uiConfig: {
|
||||
searchEnabled: true,
|
||||
showFiltersDropdown: true,
|
||||
sortEnabled: true,
|
||||
},
|
||||
filters: {
|
||||
search: '',
|
||||
homeProject: '',
|
||||
@@ -198,4 +206,139 @@ describe('ResourcesListLayout', () => {
|
||||
'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 { useUsersStore } from '@/stores/users.store';
|
||||
import type { DatatableColumn } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { useDebounce } from '@/composables/useDebounce';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import type { BaseFilters, Resource, SortingAndPaginationUpdates } from '@/Interface';
|
||||
import { isSharedResource, isResourceSortableByDate } from '@/utils/typeGuards';
|
||||
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 router = useRouter();
|
||||
const i18n = useI18n();
|
||||
const { callDebounced } = useDebounce();
|
||||
const usersStore = useUsersStore();
|
||||
const telemetry = useTelemetry();
|
||||
@@ -28,7 +30,7 @@ const n8nLocalStorage = useN8nLocalStorage();
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
resourceKey: ResourceKeyType;
|
||||
resourceKey: string;
|
||||
displayName?: (resource: ResourceType) => string;
|
||||
resources: ResourceType[];
|
||||
disabled: boolean;
|
||||
@@ -40,7 +42,6 @@ const props = withDefaults(
|
||||
matches: boolean,
|
||||
) => boolean;
|
||||
shareable?: boolean;
|
||||
showFiltersDropdown?: boolean;
|
||||
sortFns?: Record<string, (a: ResourceType, b: ResourceType) => number>;
|
||||
sortOptions?: string[];
|
||||
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
|
||||
dontPerformSortingAndFiltering?: boolean;
|
||||
hasEmptyState?: boolean;
|
||||
uiConfig?: UIConfig;
|
||||
}>(),
|
||||
{
|
||||
displayName: (resource: ResourceType) => resource.name || '',
|
||||
@@ -64,7 +66,6 @@ const props = withDefaults(
|
||||
typeProps: () => ({ itemSize: 80 }),
|
||||
loading: true,
|
||||
additionalFiltersHandler: undefined,
|
||||
showFiltersDropdown: true,
|
||||
shareable: true,
|
||||
customPageSize: 25,
|
||||
availablePageSizeOptions: () => [10, 25, 50, 100],
|
||||
@@ -72,9 +73,16 @@ const props = withDefaults(
|
||||
dontPerformSortingAndFiltering: false,
|
||||
resourcesRefreshing: false,
|
||||
hasEmptyState: true,
|
||||
uiConfig: () => ({
|
||||
searchEnabled: true,
|
||||
showFiltersDropdown: true,
|
||||
sortEnabled: true,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const { getResourceText } = useResourcesListI18n(props.resourceKey);
|
||||
|
||||
const sortBy = ref(props.sortOptions[0]);
|
||||
const hasFilters = ref(false);
|
||||
const currentPage = ref(1);
|
||||
@@ -564,23 +572,20 @@ defineExpose({
|
||||
data-test-id="empty-resources-list"
|
||||
emoji="👋"
|
||||
:heading="
|
||||
i18n.baseText(
|
||||
usersStore.currentUser?.firstName
|
||||
? (`${resourceKey}.empty.heading` as BaseTextKey)
|
||||
: (`${resourceKey}.empty.heading.userNotSetup` as BaseTextKey),
|
||||
{
|
||||
interpolate: { name: usersStore.currentUser?.firstName ?? '' },
|
||||
},
|
||||
getResourceText(
|
||||
usersStore.currentUser?.firstName ? 'empty.heading' : 'empty.heading.userNotSetup',
|
||||
usersStore.currentUser?.firstName ? 'empty.heading' : 'empty.heading.userNotSetup',
|
||||
{ name: usersStore.currentUser?.firstName ?? '' },
|
||||
)
|
||||
"
|
||||
:description="i18n.baseText(`${resourceKey}.empty.description` as BaseTextKey)"
|
||||
:button-text="i18n.baseText(`${resourceKey}.empty.button` as BaseTextKey)"
|
||||
:description="getResourceText('empty.description')"
|
||||
:button-text="getResourceText('empty.button')"
|
||||
button-type="secondary"
|
||||
:button-disabled="disabled"
|
||||
@click:button="onAddButtonClick"
|
||||
>
|
||||
<template #disabledButtonTooltip>
|
||||
{{ i18n.baseText(`${resourceKey}.empty.button.disabled.tooltip` as BaseTextKey) }}
|
||||
{{ getResourceText('empty.button.disabled.tooltip') }}
|
||||
</template>
|
||||
</n8n-action-box>
|
||||
</slot>
|
||||
@@ -591,10 +596,11 @@ defineExpose({
|
||||
<div :class="$style.filters">
|
||||
<slot name="breadcrumbs"></slot>
|
||||
<n8n-input
|
||||
v-if="props.uiConfig.searchEnabled"
|
||||
ref="search"
|
||||
:model-value="filtersModel.search"
|
||||
:class="$style.search"
|
||||
:placeholder="i18n.baseText(`${resourceKey}.search.placeholder` as BaseTextKey)"
|
||||
:placeholder="getResourceText('search.placeholder', 'search.placeholder')"
|
||||
size="small"
|
||||
clearable
|
||||
data-test-id="resources-list-search"
|
||||
@@ -604,7 +610,7 @@ defineExpose({
|
||||
<n8n-icon icon="search" />
|
||||
</template>
|
||||
</n8n-input>
|
||||
<div :class="$style['sort-and-filter']">
|
||||
<div v-if="props.uiConfig.sortEnabled" :class="$style['sort-and-filter']">
|
||||
<n8n-select
|
||||
v-model="sortBy"
|
||||
size="small"
|
||||
@@ -616,13 +622,12 @@ defineExpose({
|
||||
:key="sortOption"
|
||||
data-test-id="resources-list-sort-item"
|
||||
:value="sortOption"
|
||||
:label="i18n.baseText(`${resourceKey}.sort.${sortOption}` as BaseTextKey)"
|
||||
:label="getResourceText(`sort.${sortOption}`, `sort.${sortOption}`)"
|
||||
/>
|
||||
</n8n-select>
|
||||
</div>
|
||||
<div :class="$style['sort-and-filter']">
|
||||
<div v-if="props.uiConfig.showFiltersDropdown" :class="$style['sort-and-filter']">
|
||||
<ResourceFiltersDropdown
|
||||
v-if="showFiltersDropdown"
|
||||
:keys="filterKeys"
|
||||
:reset="resetFilters"
|
||||
:model-value="filtersModel"
|
||||
@@ -643,7 +648,7 @@ defineExpose({
|
||||
<slot name="callout"></slot>
|
||||
|
||||
<div
|
||||
v-if="showFiltersDropdown"
|
||||
v-if="props.uiConfig.showFiltersDropdown"
|
||||
v-show="hasFilters"
|
||||
class="mt-xs"
|
||||
data-test-id="resources-list-filters-applied-info"
|
||||
@@ -651,11 +656,11 @@ defineExpose({
|
||||
<n8n-info-tip :bold="false">
|
||||
{{
|
||||
hasOnlyFiltersThatShowMoreResults
|
||||
? i18n.baseText(`${resourceKey}.filters.active.shortText` as BaseTextKey)
|
||||
: i18n.baseText(`${resourceKey}.filters.active` as BaseTextKey)
|
||||
? getResourceText('filters.active.shortText', 'filters.active.shortText')
|
||||
: getResourceText('filters.active', 'filters.active')
|
||||
}}
|
||||
<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-info-tip>
|
||||
</div>
|
||||
@@ -737,7 +742,7 @@ defineExpose({
|
||||
size="medium"
|
||||
data-test-id="resources-list-empty"
|
||||
>
|
||||
{{ i18n.baseText(`${resourceKey}.noResults` as BaseTextKey) }}
|
||||
{{ getResourceText('noResults', 'noResults') }}
|
||||
</n8n-text>
|
||||
|
||||
<slot name="postamble" />
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { computed, reactive } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { VIEWS } from '@/constants';
|
||||
|
||||
/**
|
||||
* This composable holds reusable logic that detects the current page type
|
||||
*/
|
||||
export const useProjectPages = () => {
|
||||
const route = useRoute();
|
||||
|
||||
const isOverviewSubPage = computed(
|
||||
() =>
|
||||
route.name === VIEWS.WORKFLOWS ||
|
||||
route.name === VIEWS.HOMEPAGE ||
|
||||
route.name === VIEWS.CREDENTIALS ||
|
||||
route.name === VIEWS.EXECUTIONS ||
|
||||
route.name === VIEWS.FOLDERS,
|
||||
);
|
||||
// Project pages have a projectId in the route params
|
||||
const isProjectsSubPage = computed(() => route.params?.projectId !== undefined);
|
||||
|
||||
// Overview pages don't
|
||||
const isOverviewSubPage = computed(() => route.params?.projectId === undefined);
|
||||
|
||||
// Shared pages are identified by specific route names
|
||||
const isSharedSubPage = computed(
|
||||
() =>
|
||||
route.name === VIEWS.SHARED_WITH_ME ||
|
||||
@@ -24,15 +21,6 @@ export const useProjectPages = () => {
|
||||
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({
|
||||
isOverviewSubPage,
|
||||
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',
|
||||
WORKER_VIEW = 'WorkerView',
|
||||
PROJECTS = 'Projects',
|
||||
PROJECT_DETAILS = 'ProjectDetails',
|
||||
PROJECTS_WORKFLOWS = 'ProjectsWorkflows',
|
||||
PROJECTS_CREDENTIALS = 'ProjectsCredentials',
|
||||
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,
|
||||
);
|
||||
|
||||
const isInsightsEnabled = computed(() =>
|
||||
settingsStore.settings.activeModules.includes('insights'),
|
||||
);
|
||||
const isInsightsEnabled = computed(() => settingsStore.isModuleActive('insights'));
|
||||
|
||||
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 { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { useRBACStore } from '@/stores/rbac.store';
|
||||
import {
|
||||
registerModuleProjectTabs,
|
||||
registerModuleResources,
|
||||
} from '@/moduleInitializer/moduleInitializer';
|
||||
|
||||
export const state = {
|
||||
initialized: false,
|
||||
@@ -181,6 +185,10 @@ export async function initializeAuthenticatedFeatures(
|
||||
rolesStore.fetchRoles(),
|
||||
]);
|
||||
|
||||
// Initialize modules
|
||||
registerModuleResources();
|
||||
registerModuleProjectTabs();
|
||||
|
||||
authenticatedFeaturesInitialized = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import { FontAwesomePlugin } from './plugins/icons';
|
||||
import { createPinia, PiniaVuePlugin } from 'pinia';
|
||||
import { ChartJSPlugin } from '@/plugins/chartjs';
|
||||
import { SentryPlugin } from '@/plugins/sentry';
|
||||
import { registerModuleRoutes } from '@/moduleInitializer/moduleInitializer';
|
||||
|
||||
import type { VueScanOptions } from 'z-vue-scan';
|
||||
|
||||
@@ -32,6 +33,11 @@ const pinia = createPinia();
|
||||
const app = createApp(App);
|
||||
|
||||
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(PiniaVuePlugin);
|
||||
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 { tryToParseNumber } from '@/utils/typesUtils';
|
||||
import { projectsRoutes } from '@/routes/projects.routes';
|
||||
import { insightsRoutes } from '@/features/insights/insights.router';
|
||||
import TestRunDetailView from '@/views/Evaluations.ee/TestRunDetailView.vue';
|
||||
import { MfaRequiredError } from '@n8n/rest-api-client';
|
||||
|
||||
@@ -719,7 +718,6 @@ export const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
},
|
||||
...projectsRoutes,
|
||||
...insightsRoutes,
|
||||
{
|
||||
path: '/entity-not-found/:entityType(credential|workflow)',
|
||||
props: true,
|
||||
|
||||
@@ -114,6 +114,7 @@ export const projectsRoutes: RouteRecordRaw[] = [
|
||||
redirect: '/home/workflows',
|
||||
children: [
|
||||
{
|
||||
name: VIEWS.PROJECT_DETAILS,
|
||||
path: ':projectId',
|
||||
meta: {
|
||||
middleware: ['authenticated'],
|
||||
|
||||
@@ -96,6 +96,12 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
|
||||
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 defaultVersion = settings.value.partialExecution?.version ?? 1;
|
||||
// -1 means we pick the defaultVersion
|
||||
@@ -178,8 +184,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
|
||||
const isDevRelease = computed(() => settings.value.releaseChannel === 'dev');
|
||||
|
||||
const activeModules = computed(() => settings.value.activeModules);
|
||||
|
||||
const setSettings = (newSettings: FrontendSettings) => {
|
||||
settings.value = newSettings;
|
||||
userManagement.value = newSettings.userManagement;
|
||||
@@ -395,10 +399,11 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
|
||||
getSettings,
|
||||
setSettings,
|
||||
initialize,
|
||||
activeModules,
|
||||
getModuleSettings,
|
||||
moduleSettings,
|
||||
isMFAEnforcementLicensed,
|
||||
isMFAEnforced,
|
||||
activeModules,
|
||||
isModuleActive,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -55,6 +55,7 @@ import type {
|
||||
ModalState,
|
||||
ModalKey,
|
||||
AppliedThemeOption,
|
||||
TabOptions,
|
||||
} from '@/Interface';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
@@ -233,6 +234,30 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||
const pendingNotificationsForViews = ref<{ [key in VIEWS]?: NotificationOptions[] }>({});
|
||||
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 });
|
||||
|
||||
// Last interacted with - Canvas v2 specific
|
||||
@@ -529,6 +554,17 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||
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
|
||||
* the full execution data and loading it to the store.
|
||||
@@ -595,6 +631,8 @@ export const useUIStore = defineStore(STORES.UI, () => {
|
||||
openDeleteFolderModal,
|
||||
openMoveToFolderModal,
|
||||
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 { canvasConnectionModes } from '@/types';
|
||||
import type { ComponentPublicInstance } from 'vue';
|
||||
import { type BaseTextKey, useI18n } from '@n8n/i18n';
|
||||
|
||||
/*
|
||||
Type guards used in editor-ui project
|
||||
@@ -130,3 +131,16 @@ export function isResourceSortableByDate(
|
||||
): value is WorkflowResource | FolderResource | CredentialsResource {
|
||||
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