mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 09:36:44 +00:00
feat(editor): Add data store grid component (no-changelog) (#18392)
This commit is contained in:
committed by
GitHub
parent
20bbc7f80b
commit
55776f5cf4
@@ -179,9 +179,11 @@ import IconLucideTags from '~icons/lucide/tags';
|
|||||||
import IconLucideTerminal from '~icons/lucide/terminal';
|
import IconLucideTerminal from '~icons/lucide/terminal';
|
||||||
import IconLucideThumbsDown from '~icons/lucide/thumbs-down';
|
import IconLucideThumbsDown from '~icons/lucide/thumbs-down';
|
||||||
import IconLucideThumbsUp from '~icons/lucide/thumbs-up';
|
import IconLucideThumbsUp from '~icons/lucide/thumbs-up';
|
||||||
|
import IconLucideToggleRight from '~icons/lucide/toggle-right';
|
||||||
import IconLucideTrash2 from '~icons/lucide/trash-2';
|
import IconLucideTrash2 from '~icons/lucide/trash-2';
|
||||||
import IconLucideTreePine from '~icons/lucide/tree-pine';
|
import IconLucideTreePine from '~icons/lucide/tree-pine';
|
||||||
import IconLucideTriangleAlert from '~icons/lucide/triangle-alert';
|
import IconLucideTriangleAlert from '~icons/lucide/triangle-alert';
|
||||||
|
import IconLucideType from '~icons/lucide/type';
|
||||||
import IconLucideUndo2 from '~icons/lucide/undo-2';
|
import IconLucideUndo2 from '~icons/lucide/undo-2';
|
||||||
import IconLucideUnlink from '~icons/lucide/unlink';
|
import IconLucideUnlink from '~icons/lucide/unlink';
|
||||||
import IconLucideUser from '~icons/lucide/user';
|
import IconLucideUser from '~icons/lucide/user';
|
||||||
@@ -593,6 +595,8 @@ export const updatedIconSet = {
|
|||||||
'trash-2': IconLucideTrash2,
|
'trash-2': IconLucideTrash2,
|
||||||
'tree-pine': IconLucideTreePine,
|
'tree-pine': IconLucideTreePine,
|
||||||
'triangle-alert': IconLucideTriangleAlert,
|
'triangle-alert': IconLucideTriangleAlert,
|
||||||
|
type: IconLucideType,
|
||||||
|
'toggle-right': IconLucideToggleRight,
|
||||||
'undo-2': IconLucideUndo2,
|
'undo-2': IconLucideUndo2,
|
||||||
unlink: IconLucideUnlink,
|
unlink: IconLucideUnlink,
|
||||||
user: IconLucideUser,
|
user: IconLucideUser,
|
||||||
|
|||||||
@@ -19,7 +19,8 @@
|
|||||||
"activate": "Activate",
|
"activate": "Activate",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled"
|
"disabled": "Disabled",
|
||||||
|
"type": "Type"
|
||||||
},
|
},
|
||||||
"_reusableDynamicText": {
|
"_reusableDynamicText": {
|
||||||
"readMore": "Read more",
|
"readMore": "Read more",
|
||||||
@@ -2858,6 +2859,13 @@
|
|||||||
"dataStore.noColumns.heading": "No columns yet",
|
"dataStore.noColumns.heading": "No columns yet",
|
||||||
"dataStore.noColumns.description": "Add columns to start storing data in this data store.",
|
"dataStore.noColumns.description": "Add columns to start storing data in this data store.",
|
||||||
"dataStore.noColumns.button.label": "Add first column",
|
"dataStore.noColumns.button.label": "Add first column",
|
||||||
|
"dataStore.addColumn.label": "Add Column",
|
||||||
|
"dataStore.addColumn.nameInput.label": "@:_reusableBaseText.name",
|
||||||
|
"dataStore.addColumn.nameInput.placeholder": "Enter column name",
|
||||||
|
"dataStore.addColumn.typeInput.label": "@:_reusableBaseText.type",
|
||||||
|
"dataStore.addColumn.error": "Error adding column",
|
||||||
|
"dataStore.addColumn.invalidName.error": "Invalid column name",
|
||||||
|
"dataStore.addColumn.invalidName.description": "Only alphanumeric characters and non-leading dashes are allowed for column names",
|
||||||
"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>",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@
|
|||||||
"@vue-flow/node-resizer": "^1.4.0",
|
"@vue-flow/node-resizer": "^1.4.0",
|
||||||
"@vueuse/components": "^10.11.0",
|
"@vueuse/components": "^10.11.0",
|
||||||
"@vueuse/core": "catalog:frontend",
|
"@vueuse/core": "catalog:frontend",
|
||||||
|
"ag-grid-vue3": "^34.1.1",
|
||||||
"array.prototype.tosorted": "1.1.4",
|
"array.prototype.tosorted": "1.1.4",
|
||||||
"axios": "catalog:",
|
"axios": "catalog:",
|
||||||
"bowser": "2.11.0",
|
"bowser": "2.11.0",
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import DataStoreDetailsView from '@/features/dataStore/DataStoreDetailsView.vue';
|
||||||
|
import { createTestingPinia } from '@pinia/testing';
|
||||||
|
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import type { DataStore } from '@/features/dataStore/datastore.types';
|
||||||
|
import { waitFor } from '@testing-library/vue';
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast');
|
||||||
|
vi.mock('vue-router');
|
||||||
|
vi.mock('@/composables/useDocumentTitle', () => ({
|
||||||
|
useDocumentTitle: vi.fn(() => ({
|
||||||
|
set: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
vi.mock('@n8n/i18n', () => {
|
||||||
|
const baseText = (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'dataStore.getDetails.error': 'Error fetching data store details',
|
||||||
|
'dataStore.notFound': 'Data store not found',
|
||||||
|
'dataStore.dataStores': 'Data Stores',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
useI18n: () => ({ baseText }),
|
||||||
|
i18n: { baseText },
|
||||||
|
i18nInstance: {
|
||||||
|
global: {
|
||||||
|
t: baseText,
|
||||||
|
te: () => true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRouter = {
|
||||||
|
push: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockToast = {
|
||||||
|
showError: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_DATA_STORE: DataStore = {
|
||||||
|
id: 'ds1',
|
||||||
|
name: 'Test Data Store',
|
||||||
|
sizeBytes: 2048,
|
||||||
|
recordCount: 50,
|
||||||
|
columns: [
|
||||||
|
{ id: '1', name: 'id', type: 'string', index: 0 },
|
||||||
|
{ id: '2', name: 'name', type: 'string', index: 1 },
|
||||||
|
],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
projectId: 'proj1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderComponent = createComponentRenderer(DataStoreDetailsView, {
|
||||||
|
props: {
|
||||||
|
id: 'ds1',
|
||||||
|
projectId: 'proj1',
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
stubs: {
|
||||||
|
DataStoreBreadcrumbs: true,
|
||||||
|
DataStoreTable: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DataStoreDetailsView', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(useToast as ReturnType<typeof vi.fn>).mockReturnValue(mockToast);
|
||||||
|
(useRouter as ReturnType<typeof vi.fn>).mockReturnValue(mockRouter);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Loading states', () => {
|
||||||
|
it('should show loading state initially', async () => {
|
||||||
|
const pinia = createTestingPinia({ stubActions: false });
|
||||||
|
const dataStoreStore = useDataStoreStore();
|
||||||
|
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockImplementation(
|
||||||
|
async () => await new Promise(() => {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { getByTestId } = renderComponent({ pinia });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByTestId('data-store-details-loading')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hide loading state after successful data fetch', async () => {
|
||||||
|
const pinia = createTestingPinia({ stubActions: false });
|
||||||
|
const dataStoreStore = useDataStoreStore();
|
||||||
|
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(DEFAULT_DATA_STORE);
|
||||||
|
|
||||||
|
const { queryByTestId } = renderComponent({ pinia });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByTestId('data-store-details-loading')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should hide loading state after error', async () => {
|
||||||
|
const pinia = createTestingPinia({ stubActions: false });
|
||||||
|
const dataStoreStore = useDataStoreStore();
|
||||||
|
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockRejectedValue(new Error('Failed'));
|
||||||
|
|
||||||
|
const { queryByTestId } = renderComponent({ pinia });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockToast.showError).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByTestId('data-store-details-loading')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Data rendering', () => {
|
||||||
|
it('should render breadcrumbs and table when data is loaded', async () => {
|
||||||
|
const pinia = createTestingPinia({ stubActions: false });
|
||||||
|
const dataStoreStore = useDataStoreStore();
|
||||||
|
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(DEFAULT_DATA_STORE);
|
||||||
|
|
||||||
|
const { container } = renderComponent({ pinia });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(container.querySelector('data-store-breadcrumbs-stub')).toBeInTheDocument();
|
||||||
|
expect(container.querySelector('data-store-table-stub')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not render content when data store is null', async () => {
|
||||||
|
const pinia = createTestingPinia({ stubActions: false });
|
||||||
|
const dataStoreStore = useDataStoreStore();
|
||||||
|
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { container } = renderComponent({ pinia });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockToast.showError).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.querySelector('data-store-breadcrumbs-stub')).not.toBeInTheDocument();
|
||||||
|
expect(container.querySelector('data-store-table-stub')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Error handling', () => {
|
||||||
|
it('should show error and redirect when data store not found', async () => {
|
||||||
|
const pinia = createTestingPinia({ stubActions: false });
|
||||||
|
const dataStoreStore = useDataStoreStore();
|
||||||
|
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockResolvedValue(null);
|
||||||
|
|
||||||
|
renderComponent({ pinia });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockToast.showError).toHaveBeenCalled();
|
||||||
|
expect(mockRouter.push).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle API errors', async () => {
|
||||||
|
const pinia = createTestingPinia({ stubActions: false });
|
||||||
|
const dataStoreStore = useDataStoreStore();
|
||||||
|
const error = new Error('API Error');
|
||||||
|
vi.spyOn(dataStoreStore, 'fetchOrFindDataStore').mockRejectedValue(error);
|
||||||
|
|
||||||
|
renderComponent({ pinia });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockToast.showError).toHaveBeenCalledWith(
|
||||||
|
error,
|
||||||
|
'Error fetching data store details',
|
||||||
|
);
|
||||||
|
expect(mockRouter.push).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import { useRouter } from 'vue-router';
|
|||||||
import { DATA_STORE_VIEW } from '@/features/dataStore/constants';
|
import { DATA_STORE_VIEW } from '@/features/dataStore/constants';
|
||||||
import DataStoreBreadcrumbs from '@/features/dataStore/components/DataStoreBreadcrumbs.vue';
|
import DataStoreBreadcrumbs from '@/features/dataStore/components/DataStoreBreadcrumbs.vue';
|
||||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||||
|
import DataStoreTable from './components/dataGrid/DataStoreTable.vue';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -50,14 +51,6 @@ const initialize = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onAddColumnClick = () => {
|
|
||||||
toast.showMessage({
|
|
||||||
type: 'warning',
|
|
||||||
message: 'Coming soon',
|
|
||||||
duration: 3000,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
documentTitle.set(i18n.baseText('dataStore.dataStores'));
|
documentTitle.set(i18n.baseText('dataStore.dataStores'));
|
||||||
await initialize();
|
await initialize();
|
||||||
@@ -66,7 +59,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style['data-store-details-view']">
|
<div :class="$style['data-store-details-view']">
|
||||||
<div v-if="loading" class="loading">
|
<div v-if="loading" data-test-id="data-store-details-loading">
|
||||||
<n8n-loading
|
<n8n-loading
|
||||||
variant="h1"
|
variant="h1"
|
||||||
:loading="true"
|
:loading="true"
|
||||||
@@ -81,15 +74,7 @@ onMounted(async () => {
|
|||||||
<DataStoreBreadcrumbs :data-store="dataStore" />
|
<DataStoreBreadcrumbs :data-store="dataStore" />
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.content">
|
<div :class="$style.content">
|
||||||
<n8n-action-box
|
<DataStoreTable :data-store="dataStore" />
|
||||||
v-if="dataStore.columns.length === 0"
|
|
||||||
data-test-id="empty-shared-action-box"
|
|
||||||
:heading="i18n.baseText('dataStore.noColumns.heading')"
|
|
||||||
:description="i18n.baseText('dataStore.noColumns.description')"
|
|
||||||
:button-text="i18n.baseText('dataStore.noColumns.button.label')"
|
|
||||||
button-type="secondary"
|
|
||||||
@click:button="onAddColumnClick"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +93,7 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-loading {
|
.header-loading {
|
||||||
margin-bottom: var(--spacing-xl);
|
margin-bottom: var(--spacing-2xl);
|
||||||
|
|
||||||
div {
|
div {
|
||||||
height: 2em;
|
height: 2em;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useI18n } from '@n8n/i18n';
|
|||||||
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
|
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import DataStoreActions from '@/features/dataStore/components/DataStoreActions.vue';
|
import DataStoreActions from '@/features/dataStore/components/DataStoreActions.vue';
|
||||||
import { DATA_STORE_VIEW } from '@/features/dataStore/constants';
|
import { PROJECT_DATA_STORES } from '@/features/dataStore/constants';
|
||||||
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
@@ -51,7 +51,10 @@ const onItemClicked = async (item: PathItem) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onDelete = async () => {
|
const onDelete = async () => {
|
||||||
await router.push({ name: DATA_STORE_VIEW, params: { projectId: props.dataStore.projectId } });
|
await router.push({
|
||||||
|
name: PROJECT_DATA_STORES,
|
||||||
|
params: { projectId: props.dataStore.projectId },
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onRename = async () => {
|
const onRename = async () => {
|
||||||
|
|||||||
@@ -49,7 +49,17 @@ const renderComponent = createComponentRenderer(DataStoreCard, {
|
|||||||
global: {
|
global: {
|
||||||
stubs: {
|
stubs: {
|
||||||
N8nLink: {
|
N8nLink: {
|
||||||
template: '<div data-test-id="data-store-card-link"><slot /></div>',
|
template: '<a :href="href" data-test-id="data-store-card-link"><slot /></a>',
|
||||||
|
props: ['to'],
|
||||||
|
computed: {
|
||||||
|
href() {
|
||||||
|
// Generate href from the route object
|
||||||
|
if (this.to && typeof this.to === 'object') {
|
||||||
|
return `/projects/${this.to.params.projectId}/datastores/${this.to.params.id}`;
|
||||||
|
}
|
||||||
|
return '#';
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
TimeAgo: {
|
TimeAgo: {
|
||||||
template: '<span>just now</span>',
|
template: '<span>just now</span>',
|
||||||
@@ -98,29 +108,23 @@ describe('DataStoreCard', () => {
|
|||||||
const wrapper = renderComponent();
|
const wrapper = renderComponent();
|
||||||
const link = wrapper.getByTestId('data-store-card-link');
|
const link = wrapper.getByTestId('data-store-card-link');
|
||||||
expect(link).toBeInTheDocument();
|
expect(link).toBeInTheDocument();
|
||||||
|
expect(link).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
`/projects/${DEFAULT_DATA_STORE.projectId}/datastores/${DEFAULT_DATA_STORE.id}`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display record count information', () => {
|
it('should display record count information', () => {
|
||||||
const { getByTestId } = renderComponent();
|
const { getByTestId } = renderComponent();
|
||||||
const recordCountElement = getByTestId('data-store-card-record-count');
|
const recordCountElement = getByTestId('data-store-card-record-count');
|
||||||
expect(recordCountElement).toBeInTheDocument();
|
expect(recordCountElement).toBeInTheDocument();
|
||||||
|
expect(recordCountElement).toHaveTextContent(`${DEFAULT_DATA_STORE.recordCount}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should display column count information', () => {
|
it('should display column count information', () => {
|
||||||
const { getByTestId } = renderComponent();
|
const { getByTestId } = renderComponent();
|
||||||
const columnCountElement = getByTestId('data-store-card-column-count');
|
const columnCountElement = getByTestId('data-store-card-column-count');
|
||||||
expect(columnCountElement).toBeInTheDocument();
|
expect(columnCountElement).toBeInTheDocument();
|
||||||
});
|
expect(columnCountElement).toHaveTextContent(`${DEFAULT_DATA_STORE.columns.length + 1}`);
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ const onNameSubmit = (name: string) => {
|
|||||||
>
|
>
|
||||||
{{
|
{{
|
||||||
i18n.baseText('dataStore.card.column.count', {
|
i18n.baseText('dataStore.card.column.count', {
|
||||||
interpolate: { count: props.dataStore.columns.length },
|
interpolate: { count: props.dataStore.columns.length + 1 },
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
</N8nText>
|
</N8nText>
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumnPopover.vue';
|
||||||
|
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { MAX_COLUMN_NAME_LENGTH } from '@/features/dataStore/constants';
|
||||||
|
|
||||||
|
vi.mock('@/features/dataStore/composables/useDataStoreTypes', () => ({
|
||||||
|
useDataStoreTypes: () => ({
|
||||||
|
getIconForType: (type: string) => {
|
||||||
|
const iconMap: Record<string, string> = {
|
||||||
|
string: 'abc',
|
||||||
|
number: '123',
|
||||||
|
boolean: 'toggle-off',
|
||||||
|
date: 'calendar',
|
||||||
|
};
|
||||||
|
return iconMap[type] || 'abc';
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/composables/useDebounce', () => ({
|
||||||
|
useDebounce: () => ({
|
||||||
|
debounce: (fn: Function) => fn,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||||
|
...(await importOriginal()),
|
||||||
|
useI18n: () => ({
|
||||||
|
baseText: (key: string) => {
|
||||||
|
const translations: Record<string, string> = {
|
||||||
|
'dataStore.addColumn.label': 'Add column',
|
||||||
|
'dataStore.addColumn.nameInput.label': 'Column name',
|
||||||
|
'dataStore.addColumn.nameInput.placeholder': 'Enter column name',
|
||||||
|
'dataStore.addColumn.typeInput.label': 'Column type',
|
||||||
|
'dataStore.addColumn.invalidName.error': 'Invalid column name',
|
||||||
|
'dataStore.addColumn.invalidName.description':
|
||||||
|
'Column names must start with a letter and contain only letters, numbers, and hyphens',
|
||||||
|
};
|
||||||
|
return translations[key] || key;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AddColumnPopover', () => {
|
||||||
|
const renderComponent = createComponentRenderer(AddColumnPopover);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the add column button', () => {
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
expect(getByTestId('data-store-add-column-trigger-button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should focus name input when popover opens', async () => {
|
||||||
|
const { getByTestId, getByPlaceholderText } = renderComponent();
|
||||||
|
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||||
|
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const nameInput = getByPlaceholderText('Enter column name');
|
||||||
|
expect(nameInput).toHaveFocus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit addColumn event with correct payload', async () => {
|
||||||
|
const { getByTestId, getByPlaceholderText, emitted } = renderComponent();
|
||||||
|
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||||
|
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const nameInput = getByPlaceholderText('Enter column name');
|
||||||
|
await fireEvent.update(nameInput, 'newColumn');
|
||||||
|
|
||||||
|
const submitButton = getByTestId('data-store-add-column-submit-button');
|
||||||
|
expect(submitButton).not.toBeDisabled();
|
||||||
|
await fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
expect(emitted().addColumn).toBeTruthy();
|
||||||
|
expect(emitted().addColumn[0]).toEqual([
|
||||||
|
{
|
||||||
|
column: {
|
||||||
|
name: 'newColumn',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable submit button when name is empty', async () => {
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||||
|
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const submitButton = getByTestId('data-store-add-column-submit-button');
|
||||||
|
expect(submitButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show error for invalid column names', async () => {
|
||||||
|
const { getByPlaceholderText, getByText, getByTestId } = renderComponent();
|
||||||
|
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||||
|
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const nameInput = getByPlaceholderText('Enter column name');
|
||||||
|
|
||||||
|
// Test invalid name starting with hyphen
|
||||||
|
await fireEvent.update(nameInput, '-invalid');
|
||||||
|
await fireEvent.blur(nameInput);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Invalid column name')).toBeInTheDocument();
|
||||||
|
const submitButton = getByTestId('data-store-add-column-submit-button');
|
||||||
|
expect(submitButton).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow valid column names', async () => {
|
||||||
|
const { getByTestId, getByPlaceholderText, queryByText } = renderComponent();
|
||||||
|
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||||
|
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const nameInput = getByPlaceholderText('Enter column name');
|
||||||
|
|
||||||
|
// Test valid names
|
||||||
|
const validNames = ['column1', 'my-column', 'Column123', 'a1b2c3'];
|
||||||
|
|
||||||
|
for (const name of validNames) {
|
||||||
|
await fireEvent.update(nameInput, name);
|
||||||
|
await fireEvent.blur(nameInput);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByText('Invalid column name')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear error when correcting invalid name', async () => {
|
||||||
|
const { getByTestId, getByPlaceholderText, getByText, queryByText } = renderComponent();
|
||||||
|
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||||
|
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const nameInput = getByPlaceholderText('Enter column name');
|
||||||
|
|
||||||
|
// Enter invalid name
|
||||||
|
await fireEvent.update(nameInput, '-invalid');
|
||||||
|
await fireEvent.blur(nameInput);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Invalid column name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Correct the name
|
||||||
|
await fireEvent.update(nameInput, 'valid');
|
||||||
|
await fireEvent.blur(nameInput);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByText('Invalid column name')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should respect max column name length', async () => {
|
||||||
|
const { getByTestId, getByPlaceholderText } = renderComponent();
|
||||||
|
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||||
|
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const nameInput = getByPlaceholderText('Enter column name') as HTMLInputElement;
|
||||||
|
|
||||||
|
expect(nameInput.maxLength).toBe(MAX_COLUMN_NAME_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow selecting different column types', async () => {
|
||||||
|
const { getByPlaceholderText, getByRole, getByText, getByTestId, emitted } = renderComponent();
|
||||||
|
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||||
|
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const nameInput = getByPlaceholderText('Enter column name');
|
||||||
|
await fireEvent.update(nameInput, 'numberColumn');
|
||||||
|
|
||||||
|
// Click on the select to open dropdown
|
||||||
|
const selectElement = getByRole('combobox');
|
||||||
|
await fireEvent.click(selectElement);
|
||||||
|
|
||||||
|
// Select 'number' type
|
||||||
|
const numberOption = getByText('number');
|
||||||
|
await fireEvent.click(numberOption);
|
||||||
|
|
||||||
|
const submitButton = getByTestId('data-store-add-column-submit-button');
|
||||||
|
await fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
expect(emitted().addColumn).toBeTruthy();
|
||||||
|
expect(emitted().addColumn[0]).toEqual([
|
||||||
|
{
|
||||||
|
column: {
|
||||||
|
name: 'numberColumn',
|
||||||
|
type: 'number',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset form after successful submission', async () => {
|
||||||
|
const { getByPlaceholderText, getByTestId } = renderComponent();
|
||||||
|
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||||
|
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const nameInput = getByPlaceholderText('Enter column name') as HTMLInputElement;
|
||||||
|
await fireEvent.update(nameInput, 'testColumn');
|
||||||
|
|
||||||
|
const submitButton = getByTestId('data-store-add-column-submit-button');
|
||||||
|
await fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
// Click button again to open popover
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const resetNameInput = getByPlaceholderText('Enter column name') as HTMLInputElement;
|
||||||
|
expect(resetNameInput.value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should close popover after successful submission', async () => {
|
||||||
|
const { getByPlaceholderText, getByTestId, queryByText } = renderComponent();
|
||||||
|
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||||
|
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const nameInput = getByPlaceholderText('Enter column name');
|
||||||
|
await fireEvent.update(nameInput, 'testColumn');
|
||||||
|
|
||||||
|
const submitButton = getByTestId('data-store-add-column-submit-button');
|
||||||
|
await fireEvent.click(submitButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(queryByText('Column name')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow submission with Enter key', async () => {
|
||||||
|
const { getByTestId, getByPlaceholderText, emitted } = renderComponent();
|
||||||
|
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||||
|
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const nameInput = getByPlaceholderText('Enter column name');
|
||||||
|
await fireEvent.update(nameInput, 'enterColumn');
|
||||||
|
await fireEvent.keyUp(nameInput, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(emitted().addColumn).toBeTruthy();
|
||||||
|
expect(emitted().addColumn[0]).toEqual([
|
||||||
|
{
|
||||||
|
column: {
|
||||||
|
name: 'enterColumn',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display all column type options', async () => {
|
||||||
|
const { getByTestId, getByRole, getByText } = renderComponent();
|
||||||
|
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||||
|
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const selectElement = getByRole('combobox');
|
||||||
|
await fireEvent.click(selectElement);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('string')).toBeInTheDocument();
|
||||||
|
expect(getByText('number')).toBeInTheDocument();
|
||||||
|
expect(getByText('boolean')).toBeInTheDocument();
|
||||||
|
expect(getByText('date')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show tooltip with error description', async () => {
|
||||||
|
const { getByPlaceholderText, getByText, getByTestId } = renderComponent();
|
||||||
|
const addButton = getByTestId('data-store-add-column-trigger-button');
|
||||||
|
|
||||||
|
await fireEvent.click(addButton);
|
||||||
|
|
||||||
|
const nameInput = getByPlaceholderText('Enter column name');
|
||||||
|
await fireEvent.update(nameInput, '-invalid');
|
||||||
|
await fireEvent.blur(nameInput);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(getByText('Invalid column name')).toBeInTheDocument();
|
||||||
|
// Check for help icon that shows tooltip
|
||||||
|
const helpIcon = getByTestId('add-column-error-help-icon');
|
||||||
|
expect(helpIcon).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref } from 'vue';
|
||||||
|
import type {
|
||||||
|
DataStoreColumnCreatePayload,
|
||||||
|
DataStoreColumnType,
|
||||||
|
} from '@/features/dataStore/datastore.types';
|
||||||
|
import { useI18n } from '@n8n/i18n';
|
||||||
|
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
|
||||||
|
import { COLUMN_NAME_REGEX, MAX_COLUMN_NAME_LENGTH } from '@/features/dataStore/constants';
|
||||||
|
import Tooltip from '@n8n/design-system/components/N8nTooltip/Tooltip.vue';
|
||||||
|
import { useDebounce } from '@/composables/useDebounce';
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
addColumn: [
|
||||||
|
value: {
|
||||||
|
column: DataStoreColumnCreatePayload;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const { getIconForType } = useDataStoreTypes();
|
||||||
|
const { debounce } = useDebounce();
|
||||||
|
|
||||||
|
const nameInputRef = ref<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const columnName = ref('');
|
||||||
|
const columnType = ref<DataStoreColumnType>('string');
|
||||||
|
|
||||||
|
const columnTypes: DataStoreColumnType[] = ['string', 'number', 'boolean', 'date'];
|
||||||
|
|
||||||
|
const error = ref<string | null>(null);
|
||||||
|
|
||||||
|
// Handling popover state manually to prevent it closing when interacting with dropdown
|
||||||
|
const popoverOpen = ref(false);
|
||||||
|
const isSelectOpen = ref(false);
|
||||||
|
|
||||||
|
const onAddButtonClicked = () => {
|
||||||
|
if (!columnName.value || !columnType.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emit('addColumn', {
|
||||||
|
column: {
|
||||||
|
name: columnName.value,
|
||||||
|
type: columnType.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
columnName.value = '';
|
||||||
|
columnType.value = 'string';
|
||||||
|
popoverOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePopoverOpenChange = async (open: boolean) => {
|
||||||
|
// Don't close the popover if the select is open
|
||||||
|
if (!open && isSelectOpen.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
popoverOpen.value = open;
|
||||||
|
// Focus name input when opening popover
|
||||||
|
if (open) {
|
||||||
|
await nextTick(() => {
|
||||||
|
nameInputRef.value?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateName = () => {
|
||||||
|
if (error.value) {
|
||||||
|
error.value = null;
|
||||||
|
}
|
||||||
|
if (columnName.value && !COLUMN_NAME_REGEX.test(columnName.value)) {
|
||||||
|
error.value = i18n.baseText('dataStore.addColumn.invalidName.error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onInput = debounce(validateName, { debounceTime: 300 });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<N8nTooltip :disabled="popoverOpen" :content="i18n.baseText('dataStore.addColumn.label')">
|
||||||
|
<div :class="$style.wrapper">
|
||||||
|
<N8nPopoverReka
|
||||||
|
id="add-column-popover"
|
||||||
|
:open="popoverOpen"
|
||||||
|
:popper-options="{ strategy: 'fixed' }"
|
||||||
|
:show-arrow="false"
|
||||||
|
@update:open="handlePopoverOpenChange"
|
||||||
|
>
|
||||||
|
<template #trigger>
|
||||||
|
<N8nIconButton
|
||||||
|
data-test-id="data-store-add-column-trigger-button"
|
||||||
|
icon="plus"
|
||||||
|
type="tertiary"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #content>
|
||||||
|
<div :class="$style['popover-content']">
|
||||||
|
<div :class="$style['popover-body']">
|
||||||
|
<N8nInputLabel
|
||||||
|
:label="i18n.baseText('dataStore.addColumn.nameInput.label')"
|
||||||
|
:required="true"
|
||||||
|
:class="error ? '' : 'mb-s'"
|
||||||
|
>
|
||||||
|
<N8nInput
|
||||||
|
ref="nameInputRef"
|
||||||
|
v-model="columnName"
|
||||||
|
:placeholder="i18n.baseText('dataStore.addColumn.nameInput.placeholder')"
|
||||||
|
:maxlength="MAX_COLUMN_NAME_LENGTH"
|
||||||
|
@keyup.enter="onAddButtonClicked"
|
||||||
|
@input="onInput"
|
||||||
|
/>
|
||||||
|
<div v-if="error" :class="$style['error-message']">
|
||||||
|
<n8n-text size="small" color="danger" tag="span">
|
||||||
|
{{ error }}
|
||||||
|
</n8n-text>
|
||||||
|
<Tooltip :content="i18n.baseText('dataStore.addColumn.invalidName.description')">
|
||||||
|
<N8nIcon
|
||||||
|
icon="circle-help"
|
||||||
|
size="small"
|
||||||
|
:class="$style['error-tooltip']"
|
||||||
|
color="text-base"
|
||||||
|
data-test-id="add-column-error-help-icon"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</N8nInputLabel>
|
||||||
|
<N8nInputLabel
|
||||||
|
:label="i18n.baseText('dataStore.addColumn.typeInput.label')"
|
||||||
|
:required="true"
|
||||||
|
:class="$style['type-label']"
|
||||||
|
>
|
||||||
|
<N8nSelect
|
||||||
|
v-model="columnType"
|
||||||
|
append-to="#add-column-popover"
|
||||||
|
@visible-change="isSelectOpen = $event"
|
||||||
|
>
|
||||||
|
<N8nOption v-for="type in columnTypes" :key="type" :value="type">
|
||||||
|
<div :class="$style['option-content']">
|
||||||
|
<N8nIcon :icon="getIconForType(type)" />
|
||||||
|
<N8nText>{{ type }}</N8nText>
|
||||||
|
</div>
|
||||||
|
</N8nOption>
|
||||||
|
</N8nSelect>
|
||||||
|
</N8nInputLabel>
|
||||||
|
<N8nButton
|
||||||
|
data-test-id="data-store-add-column-submit-button"
|
||||||
|
type="primary"
|
||||||
|
class="mt-m"
|
||||||
|
size="large"
|
||||||
|
:disabled="!columnName || !columnType || !!error"
|
||||||
|
@click="onAddButtonClicked"
|
||||||
|
>
|
||||||
|
{{ i18n.baseText('dataStore.addColumn.label') }}
|
||||||
|
</N8nButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</N8nPopoverReka>
|
||||||
|
</div>
|
||||||
|
</N8nTooltip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-background-base);
|
||||||
|
padding: var(--spacing-2xs);
|
||||||
|
border: var(--border-base);
|
||||||
|
border-left: none;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-header {
|
||||||
|
padding: var(--spacing-2xs);
|
||||||
|
border-bottom: var(--border-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.popover-body {
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-4xs);
|
||||||
|
color: var(--color-text-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-tooltip {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { createComponentRenderer } from '@/__tests__/render';
|
||||||
|
import DataStoreTable from '@/features/dataStore/components/dataGrid/DataStoreTable.vue';
|
||||||
|
import { fireEvent, waitFor } from '@testing-library/vue';
|
||||||
|
import { createPinia, setActivePinia } from 'pinia';
|
||||||
|
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
||||||
|
import type { DataStore } from '@/features/dataStore/datastore.types';
|
||||||
|
|
||||||
|
// Mock ag-grid-vue3
|
||||||
|
interface MockComponentInstance {
|
||||||
|
$emit: (event: string, payload: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock('ag-grid-vue3', () => ({
|
||||||
|
AgGridVue: {
|
||||||
|
name: 'AgGridVue',
|
||||||
|
template: '<div data-test-id="ag-grid-vue" />',
|
||||||
|
props: ['rowData', 'columnDefs', 'defaultColDef', 'domLayout', 'animateRows', 'theme'],
|
||||||
|
emits: ['gridReady'],
|
||||||
|
mounted(this: MockComponentInstance) {
|
||||||
|
this.$emit('gridReady', {
|
||||||
|
api: {
|
||||||
|
// Mock API methods
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock ag-grid-community modules
|
||||||
|
vi.mock('ag-grid-community', () => ({
|
||||||
|
ModuleRegistry: {
|
||||||
|
registerModules: vi.fn(),
|
||||||
|
},
|
||||||
|
ClientSideRowModelModule: {},
|
||||||
|
TextEditorModule: {},
|
||||||
|
LargeTextEditorModule: {},
|
||||||
|
ColumnAutoSizeModule: {},
|
||||||
|
CheckboxEditorModule: {},
|
||||||
|
NumberEditorModule: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the n8n theme
|
||||||
|
vi.mock('@/features/dataStore/components/dataGrid/n8nTheme', () => ({
|
||||||
|
n8nTheme: 'n8n-theme',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock AddColumnPopover
|
||||||
|
vi.mock('@/features/dataStore/components/dataGrid/AddColumnPopover.vue', () => ({
|
||||||
|
default: {
|
||||||
|
name: 'AddColumnPopover',
|
||||||
|
template:
|
||||||
|
'<div data-test-id="add-column-popover"><button data-test-id="data-store-add-column-button" @click="$emit(\'add-column\', { column: { name: \'newColumn\', type: \'string\' } })">Add Column</button></div>',
|
||||||
|
props: ['dataStore'],
|
||||||
|
emits: ['add-column'],
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock composables
|
||||||
|
vi.mock('@/composables/useToast', () => ({
|
||||||
|
useToast: () => ({
|
||||||
|
showError: vi.fn(),
|
||||||
|
showSuccess: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/features/dataStore/composables/useDataStoreTypes', () => ({
|
||||||
|
useDataStoreTypes: () => ({
|
||||||
|
mapToAGCellType: (type: string) => {
|
||||||
|
const typeMap: Record<string, string> = {
|
||||||
|
string: 'text',
|
||||||
|
number: 'number',
|
||||||
|
boolean: 'boolean',
|
||||||
|
date: 'date',
|
||||||
|
};
|
||||||
|
return typeMap[type] || 'text';
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockDataStore: DataStore = {
|
||||||
|
id: 'test-datastore-1',
|
||||||
|
name: 'Test DataStore',
|
||||||
|
projectId: 'project-1',
|
||||||
|
columns: [
|
||||||
|
{ id: 'col1', name: 'firstName', type: 'string', index: 1 },
|
||||||
|
{ id: 'col2', name: 'age', type: 'number', index: 2 },
|
||||||
|
{ id: 'col3', name: 'isActive', type: 'boolean', index: 3 },
|
||||||
|
],
|
||||||
|
createdAt: '2024-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2024-01-01T00:00:00Z',
|
||||||
|
sizeBytes: 0,
|
||||||
|
recordCount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DataStoreTable', () => {
|
||||||
|
const renderComponent = createComponentRenderer(DataStoreTable, {
|
||||||
|
props: {
|
||||||
|
dataStore: mockDataStore,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let dataStoreStore: ReturnType<typeof useDataStoreStore>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
dataStoreStore = useDataStoreStore();
|
||||||
|
dataStoreStore.addDataStoreColumn = vi.fn().mockResolvedValue({
|
||||||
|
id: 'new-col',
|
||||||
|
name: 'newColumn',
|
||||||
|
type: 'string',
|
||||||
|
index: 4,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Component Initialization', () => {
|
||||||
|
it('should render the component with AG Grid and AddColumnPopover', () => {
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
|
||||||
|
expect(getByTestId('ag-grid-vue')).toBeInTheDocument();
|
||||||
|
expect(getByTestId('add-column-popover')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render pagination controls', () => {
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
|
||||||
|
expect(getByTestId('data-store-content-pagination')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render add row button', () => {
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
|
||||||
|
expect(getByTestId('data-store-add-row-button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Add Column Functionality', () => {
|
||||||
|
it('should handle add column event from AddColumnPopover', async () => {
|
||||||
|
const { getByTestId } = renderComponent();
|
||||||
|
|
||||||
|
const addColumnPopover = getByTestId('add-column-popover');
|
||||||
|
const addButton = addColumnPopover.querySelector(
|
||||||
|
'[data-test-id="data-store-add-column-button"]',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(addButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
await fireEvent.click(addButton!);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(dataStoreStore.addDataStoreColumn).toHaveBeenCalledWith(
|
||||||
|
mockDataStore.id,
|
||||||
|
mockDataStore.projectId,
|
||||||
|
{ name: 'newColumn', type: 'string' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Empty Data Store', () => {
|
||||||
|
it('should show grid for empty data store', () => {
|
||||||
|
const emptyDataStore: DataStore = {
|
||||||
|
...mockDataStore,
|
||||||
|
columns: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const { getByTestId } = renderComponent({
|
||||||
|
props: {
|
||||||
|
dataStore: emptyDataStore,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getByTestId('ag-grid-vue')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import type {
|
||||||
|
DataStore,
|
||||||
|
DataStoreColumn,
|
||||||
|
DataStoreColumnCreatePayload,
|
||||||
|
DataStoreRow,
|
||||||
|
} from '@/features/dataStore/datastore.types';
|
||||||
|
import { AgGridVue } from 'ag-grid-vue3';
|
||||||
|
import {
|
||||||
|
ModuleRegistry,
|
||||||
|
ClientSideRowModelModule,
|
||||||
|
TextEditorModule,
|
||||||
|
LargeTextEditorModule,
|
||||||
|
ColumnAutoSizeModule,
|
||||||
|
CheckboxEditorModule,
|
||||||
|
NumberEditorModule,
|
||||||
|
} from 'ag-grid-community';
|
||||||
|
import type { GridApi, GridReadyEvent, ColDef } from 'ag-grid-community';
|
||||||
|
import { n8nTheme } from '@/features/dataStore/components/dataGrid/n8nTheme';
|
||||||
|
import AddColumnPopover from '@/features/dataStore/components/dataGrid/AddColumnPopover.vue';
|
||||||
|
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
||||||
|
import { useI18n } from '@n8n/i18n';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { DEFAULT_ID_COLUMN_NAME } from '@/features/dataStore/constants';
|
||||||
|
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
|
||||||
|
|
||||||
|
// Register only the modules we actually use
|
||||||
|
ModuleRegistry.registerModules([
|
||||||
|
ClientSideRowModelModule,
|
||||||
|
TextEditorModule,
|
||||||
|
LargeTextEditorModule,
|
||||||
|
ColumnAutoSizeModule,
|
||||||
|
CheckboxEditorModule,
|
||||||
|
NumberEditorModule,
|
||||||
|
]);
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dataStore: DataStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
const i18n = useI18n();
|
||||||
|
const toast = useToast();
|
||||||
|
const dataStoreTypes = useDataStoreTypes();
|
||||||
|
|
||||||
|
const dataStoreStore = useDataStoreStore();
|
||||||
|
|
||||||
|
// AG Grid State
|
||||||
|
const gridApi = ref<GridApi | null>(null);
|
||||||
|
const colDefs = ref<ColDef[]>([]);
|
||||||
|
const rowData = ref<DataStoreRow[]>([]);
|
||||||
|
|
||||||
|
// Shared config for all columns
|
||||||
|
const defaultColumnDef = {
|
||||||
|
flex: 1,
|
||||||
|
sortable: false,
|
||||||
|
filter: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const currentPage = ref(1);
|
||||||
|
const pageSize = ref(20);
|
||||||
|
const pageSizeOptions = ref([10, 20, 50]);
|
||||||
|
const totalItems = ref(0);
|
||||||
|
|
||||||
|
const onGridReady = (params: GridReadyEvent) => {
|
||||||
|
gridApi.value = params.api;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCurrentPage = (page: number) => {
|
||||||
|
currentPage.value = page;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPageSize = (size: number) => {
|
||||||
|
pageSize.value = size;
|
||||||
|
currentPage.value = 1; // Reset to first page on page size change
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAddColumn = async ({ column }: { column: DataStoreColumnCreatePayload }) => {
|
||||||
|
try {
|
||||||
|
const newColumn = await dataStoreStore.addDataStoreColumn(
|
||||||
|
props.dataStore.id,
|
||||||
|
props.dataStore.projectId,
|
||||||
|
column,
|
||||||
|
);
|
||||||
|
if (!newColumn) {
|
||||||
|
throw new Error(i18n.baseText('generic.unknownError'));
|
||||||
|
}
|
||||||
|
colDefs.value = [...colDefs.value, createColumnDef(newColumn)];
|
||||||
|
} catch (error) {
|
||||||
|
toast.showError(error, i18n.baseText('dataStore.addColumn.error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createColumnDef = (col: DataStoreColumn) => {
|
||||||
|
const columnDef: ColDef = {
|
||||||
|
colId: col.id,
|
||||||
|
field: col.name,
|
||||||
|
headerName: col.name,
|
||||||
|
editable: col.name !== DEFAULT_ID_COLUMN_NAME,
|
||||||
|
cellDataType: dataStoreTypes.mapToAGCellType(col.type),
|
||||||
|
};
|
||||||
|
// Enable large text editor for text columns
|
||||||
|
if (col.type === 'string') {
|
||||||
|
columnDef.cellEditor = 'agLargeTextCellEditor';
|
||||||
|
columnDef.cellEditorPopup = true;
|
||||||
|
}
|
||||||
|
return columnDef;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initColumnDefinitions = () => {
|
||||||
|
colDefs.value = [
|
||||||
|
// Always add the ID column, it's not returned by the back-end but all data stores have it
|
||||||
|
// We use it as a placeholder for new datastores
|
||||||
|
createColumnDef({
|
||||||
|
index: 0,
|
||||||
|
id: DEFAULT_ID_COLUMN_NAME,
|
||||||
|
name: DEFAULT_ID_COLUMN_NAME,
|
||||||
|
type: 'string',
|
||||||
|
}),
|
||||||
|
// Append other columns
|
||||||
|
...props.dataStore.columns.map(createColumnDef),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialize = () => {
|
||||||
|
initColumnDefinitions();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initialize();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="$style.wrapper">
|
||||||
|
<div :class="$style['grid-container']" data-test-id="data-store-grid">
|
||||||
|
<AgGridVue
|
||||||
|
style="width: 100%"
|
||||||
|
:row-data="rowData"
|
||||||
|
:column-defs="colDefs"
|
||||||
|
:default-col-def="defaultColumnDef"
|
||||||
|
:dom-layout="'autoHeight'"
|
||||||
|
:animate-rows="false"
|
||||||
|
:theme="n8nTheme"
|
||||||
|
@grid-ready="onGridReady"
|
||||||
|
/>
|
||||||
|
<AddColumnPopover
|
||||||
|
:data-store="props.dataStore"
|
||||||
|
:class="$style['add-column-popover']"
|
||||||
|
@add-column="onAddColumn"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.footer">
|
||||||
|
<n8n-icon-button
|
||||||
|
icon="plus"
|
||||||
|
class="mb-xl"
|
||||||
|
type="secondary"
|
||||||
|
data-test-id="data-store-add-row-button"
|
||||||
|
/>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
background
|
||||||
|
:total="totalItems"
|
||||||
|
:page-sizes="pageSizeOptions"
|
||||||
|
layout="total, prev, pager, next, sizes"
|
||||||
|
data-test-id="data-store-content-pagination"
|
||||||
|
@update:current-page="setCurrentPage"
|
||||||
|
@size-change="setPageSize"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style module lang="scss">
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-m);
|
||||||
|
width: calc(100% - var(--spacing-m) * 2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// AG Grid style overrides
|
||||||
|
--ag-foreground-color: var(--color-text-base);
|
||||||
|
--ag-accent-color: var(--color-primary);
|
||||||
|
--ag-background-color: var(--color-background-xlight);
|
||||||
|
--ag-border-color: var(--border-color-base);
|
||||||
|
--ag-border-radius: var(--border-radius-base);
|
||||||
|
--ag-wrapper-border-radius: 0;
|
||||||
|
--ag-font-family: var(--font-family);
|
||||||
|
--ag-font-size: var(--font-size-xs);
|
||||||
|
--ag-row-height: calc(var(--ag-grid-size) * 0.8 + 32px);
|
||||||
|
|
||||||
|
--ag-header-background-color: var(--color-background-base);
|
||||||
|
--ag-header-font-size: var(--font-size-xs);
|
||||||
|
--ag-header-font-weight: var(--font-weight-bold);
|
||||||
|
--ag-header-foreground-color: var(--color-text-dark);
|
||||||
|
--ag-cell-horizontal-padding: calc(var(--ag-grid-size) * 0.7);
|
||||||
|
--ag-header-column-resize-handle-color: var(--border-color-base);
|
||||||
|
--ag-header-column-resize-handle-height: 100%;
|
||||||
|
--ag-header-height: calc(var(--ag-grid-size) * 0.8 + 32px);
|
||||||
|
|
||||||
|
:global(.ag-header-cell-resize) {
|
||||||
|
width: var(--spacing-4xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-column-popover {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
right: -47px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--spacing-l);
|
||||||
|
padding-right: var(--spacing-xl);
|
||||||
|
|
||||||
|
:global(.el-pagination__sizes) {
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.el-input__suffix) {
|
||||||
|
width: var(--spacing-m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { iconSetAlpine, themeQuartz } from 'ag-grid-community';
|
||||||
|
|
||||||
|
export const n8nTheme = themeQuartz.withPart(iconSetAlpine).withParams({
|
||||||
|
columnBorder: true,
|
||||||
|
rowBorder: true,
|
||||||
|
rowVerticalPaddingScale: 0.8,
|
||||||
|
sidePanelBorder: true,
|
||||||
|
wrapperBorder: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { IconName } from '@n8n/design-system/components/N8nIcon/icons';
|
||||||
|
import type { AGGridCellType, DataStoreColumnType } from '@/features/dataStore/datastore.types';
|
||||||
|
|
||||||
|
/* eslint-disable id-denylist */
|
||||||
|
const COLUMN_TYPE_ICONS: Record<DataStoreColumnType, IconName> = {
|
||||||
|
string: 'type',
|
||||||
|
number: 'hash',
|
||||||
|
boolean: 'toggle-right',
|
||||||
|
date: 'calendar',
|
||||||
|
} as const;
|
||||||
|
/* eslint-enable id-denylist */
|
||||||
|
|
||||||
|
export const useDataStoreTypes = () => {
|
||||||
|
const getIconForType = (type: DataStoreColumnType) => COLUMN_TYPE_ICONS[type];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a DataStoreColumnType to an AGGridCellType.
|
||||||
|
* For now the only mismatch is our 'string' type,
|
||||||
|
* which needs to be mapped manually.
|
||||||
|
* @param colType The DataStoreColumnType to map.
|
||||||
|
* @returns The corresponding AGGridCellType.
|
||||||
|
*/
|
||||||
|
const mapToAGCellType = (colType: DataStoreColumnType): AGGridCellType => {
|
||||||
|
if (colType === 'string') {
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
return colType;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getIconForType,
|
||||||
|
mapToAGCellType,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -13,3 +13,9 @@ export const DATA_STORE_CARD_ACTIONS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const ADD_DATA_STORE_MODAL_KEY = 'addDataStoreModal';
|
export const ADD_DATA_STORE_MODAL_KEY = 'addDataStoreModal';
|
||||||
|
|
||||||
|
export const DEFAULT_ID_COLUMN_NAME = 'id';
|
||||||
|
|
||||||
|
export const MAX_COLUMN_NAME_LENGTH = 128;
|
||||||
|
|
||||||
|
export const COLUMN_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/;
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { makeRestApiRequest } from '@n8n/rest-api-client';
|
import { makeRestApiRequest } from '@n8n/rest-api-client';
|
||||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||||
|
|
||||||
import { type DataStore } from '@/features/dataStore/datastore.types';
|
import type {
|
||||||
|
DataStoreColumnCreatePayload,
|
||||||
|
DataStore,
|
||||||
|
DataStoreColumn,
|
||||||
|
} from '@/features/dataStore/datastore.types';
|
||||||
|
|
||||||
export const fetchDataStoresApi = async (
|
export const fetchDataStoresApi = async (
|
||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
@@ -32,6 +36,7 @@ export const createDataStoreApi = async (
|
|||||||
context: IRestApiContext,
|
context: IRestApiContext,
|
||||||
name: string,
|
name: string,
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
|
columns?: DataStoreColumnCreatePayload[],
|
||||||
) => {
|
) => {
|
||||||
return await makeRestApiRequest<DataStore>(
|
return await makeRestApiRequest<DataStore>(
|
||||||
context,
|
context,
|
||||||
@@ -39,7 +44,7 @@ export const createDataStoreApi = async (
|
|||||||
`/projects/${projectId}/data-stores`,
|
`/projects/${projectId}/data-stores`,
|
||||||
{
|
{
|
||||||
name,
|
name,
|
||||||
columns: [],
|
columns: columns ?? [],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -75,3 +80,19 @@ export const updateDataStoreApi = async (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const addDataStoreColumnApi = async (
|
||||||
|
context: IRestApiContext,
|
||||||
|
dataStoreId: string,
|
||||||
|
projectId: string,
|
||||||
|
column: DataStoreColumnCreatePayload,
|
||||||
|
) => {
|
||||||
|
return await makeRestApiRequest<DataStoreColumn>(
|
||||||
|
context,
|
||||||
|
'POST',
|
||||||
|
`/projects/${projectId}/data-stores/${dataStoreId}/columns`,
|
||||||
|
{
|
||||||
|
...column,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ import {
|
|||||||
createDataStoreApi,
|
createDataStoreApi,
|
||||||
deleteDataStoreApi,
|
deleteDataStoreApi,
|
||||||
updateDataStoreApi,
|
updateDataStoreApi,
|
||||||
|
addDataStoreColumnApi,
|
||||||
} from '@/features/dataStore/dataStore.api';
|
} from '@/features/dataStore/dataStore.api';
|
||||||
import type { DataStore } from '@/features/dataStore/datastore.types';
|
import type { DataStore, DataStoreColumnCreatePayload } from '@/features/dataStore/datastore.types';
|
||||||
import { useProjectsStore } from '@/stores/projects.store';
|
import { useProjectsStore } from '@/stores/projects.store';
|
||||||
|
|
||||||
export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
||||||
@@ -83,6 +84,26 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
|||||||
return await fetchDataStoreDetails(datastoreId, projectId);
|
return await fetchDataStoreDetails(datastoreId, projectId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addDataStoreColumn = async (
|
||||||
|
datastoreId: string,
|
||||||
|
projectId: string,
|
||||||
|
column: DataStoreColumnCreatePayload,
|
||||||
|
) => {
|
||||||
|
const newColumn = await addDataStoreColumnApi(
|
||||||
|
rootStore.restApiContext,
|
||||||
|
datastoreId,
|
||||||
|
projectId,
|
||||||
|
column,
|
||||||
|
);
|
||||||
|
if (newColumn) {
|
||||||
|
const index = dataStores.value.findIndex((store) => store.id === datastoreId);
|
||||||
|
if (index !== -1) {
|
||||||
|
dataStores.value[index].columns.push(newColumn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newColumn;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dataStores,
|
dataStores,
|
||||||
totalCount,
|
totalCount,
|
||||||
@@ -92,5 +113,6 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
|||||||
updateDataStore,
|
updateDataStore,
|
||||||
fetchDataStoreDetails,
|
fetchDataStoreDetails,
|
||||||
fetchOrFindDataStore,
|
fetchOrFindDataStore,
|
||||||
|
addDataStoreColumn,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,13 +8,23 @@ export type DataStore = {
|
|||||||
columns: DataStoreColumn[];
|
columns: DataStoreColumn[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
projectId?: string;
|
projectId: string;
|
||||||
project?: Project;
|
project?: Project;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DataStoreColumnType = 'string' | 'number' | 'boolean' | 'date';
|
||||||
|
|
||||||
|
export type AGGridCellType = 'text' | 'number' | 'boolean' | 'date' | 'dateString' | 'object';
|
||||||
|
|
||||||
export type DataStoreColumn = {
|
export type DataStoreColumn = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: DataStoreColumnType;
|
||||||
index: number;
|
index: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DataStoreColumnCreatePayload = Pick<DataStoreColumn, 'name' | 'type'>;
|
||||||
|
|
||||||
|
export type DataStoreValue = string | number | boolean | Date | null;
|
||||||
|
|
||||||
|
export type DataStoreRow = Record<string, DataStoreValue>;
|
||||||
|
|||||||
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
@@ -2523,6 +2523,9 @@ importers:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: catalog:frontend
|
specifier: catalog:frontend
|
||||||
version: 10.11.0(vue@3.5.13(typescript@5.9.2))
|
version: 10.11.0(vue@3.5.13(typescript@5.9.2))
|
||||||
|
ag-grid-vue3:
|
||||||
|
specifier: ^34.1.1
|
||||||
|
version: 34.1.1(vue@3.5.13(typescript@5.9.2))
|
||||||
array.prototype.tosorted:
|
array.prototype.tosorted:
|
||||||
specifier: 1.1.4
|
specifier: 1.1.4
|
||||||
version: 1.1.4
|
version: 1.1.4
|
||||||
@@ -8140,6 +8143,17 @@ packages:
|
|||||||
resolution: {integrity: sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==}
|
resolution: {integrity: sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
|
|
||||||
|
ag-charts-types@12.1.1:
|
||||||
|
resolution: {integrity: sha512-VbAOfp1E7+Z/TBufJOIjUYdx70kQRDg49WoluiVKVSB0r0el/uvxARp9xjzx4ByBq9+Xq/V23tJVsRj1MS2A/g==}
|
||||||
|
|
||||||
|
ag-grid-community@34.1.1:
|
||||||
|
resolution: {integrity: sha512-ODVvGoMTkyGvMT8b5lzvum5r93bG6CKdJdNrk6u/aYS7oqZ5rUEXJJHC8n8Zq+o76KhFiXMBQrU39xuhz8i+Tg==}
|
||||||
|
|
||||||
|
ag-grid-vue3@34.1.1:
|
||||||
|
resolution: {integrity: sha512-+O3nDlInu4RZJ82zkqVD/DCfps0OsfO1MUi453zEJ+CbJudhq+l4f9hy+l+OYWH10XOEdr6+HL9j6toONWyS8g==}
|
||||||
|
peerDependencies:
|
||||||
|
vue: ^3.5.0
|
||||||
|
|
||||||
agent-base@5.1.1:
|
agent-base@5.1.1:
|
||||||
resolution: {integrity: sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g==}
|
resolution: {integrity: sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g==}
|
||||||
engines: {node: '>= 6.0.0'}
|
engines: {node: '>= 6.0.0'}
|
||||||
@@ -23079,6 +23093,17 @@ snapshots:
|
|||||||
|
|
||||||
adm-zip@0.5.10: {}
|
adm-zip@0.5.10: {}
|
||||||
|
|
||||||
|
ag-charts-types@12.1.1: {}
|
||||||
|
|
||||||
|
ag-grid-community@34.1.1:
|
||||||
|
dependencies:
|
||||||
|
ag-charts-types: 12.1.1
|
||||||
|
|
||||||
|
ag-grid-vue3@34.1.1(vue@3.5.13(typescript@5.9.2)):
|
||||||
|
dependencies:
|
||||||
|
ag-grid-community: 34.1.1
|
||||||
|
vue: 3.5.13(typescript@5.9.2)
|
||||||
|
|
||||||
agent-base@5.1.1: {}
|
agent-base@5.1.1: {}
|
||||||
|
|
||||||
agent-base@6.0.2:
|
agent-base@6.0.2:
|
||||||
@@ -25523,7 +25548,7 @@ snapshots:
|
|||||||
|
|
||||||
eslint-import-resolver-node@0.3.9:
|
eslint-import-resolver-node@0.3.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7(supports-color@5.5.0)
|
debug: 3.2.7(supports-color@8.1.1)
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
resolve: 1.22.10
|
resolve: 1.22.10
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -25547,7 +25572,7 @@ snapshots:
|
|||||||
|
|
||||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.3)(eslint@9.29.0(jiti@1.21.7)):
|
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.3)(eslint@9.29.0(jiti@1.21.7)):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7(supports-color@5.5.0)
|
debug: 3.2.7(supports-color@8.1.1)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@typescript-eslint/parser': 8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2)
|
'@typescript-eslint/parser': 8.35.0(eslint@9.29.0(jiti@1.21.7))(typescript@5.9.2)
|
||||||
eslint: 9.29.0(jiti@1.21.7)
|
eslint: 9.29.0(jiti@1.21.7)
|
||||||
@@ -25586,7 +25611,7 @@ snapshots:
|
|||||||
array.prototype.findlastindex: 1.2.6
|
array.prototype.findlastindex: 1.2.6
|
||||||
array.prototype.flat: 1.3.3
|
array.prototype.flat: 1.3.3
|
||||||
array.prototype.flatmap: 1.3.3
|
array.prototype.flatmap: 1.3.3
|
||||||
debug: 3.2.7(supports-color@5.5.0)
|
debug: 3.2.7(supports-color@8.1.1)
|
||||||
doctrine: 2.1.0
|
doctrine: 2.1.0
|
||||||
eslint: 9.29.0(jiti@1.21.7)
|
eslint: 9.29.0(jiti@1.21.7)
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
@@ -26545,7 +26570,7 @@ snapshots:
|
|||||||
array-parallel: 0.1.3
|
array-parallel: 0.1.3
|
||||||
array-series: 0.1.5
|
array-series: 0.1.5
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
debug: 3.2.7(supports-color@5.5.0)
|
debug: 3.2.7(supports-color@8.1.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -29950,7 +29975,7 @@ snapshots:
|
|||||||
|
|
||||||
pdf-parse@1.1.1:
|
pdf-parse@1.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7(supports-color@5.5.0)
|
debug: 3.2.7(supports-color@8.1.1)
|
||||||
node-ensure: 0.0.0
|
node-ensure: 0.0.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -30938,7 +30963,7 @@ snapshots:
|
|||||||
|
|
||||||
rhea@1.0.24:
|
rhea@1.0.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 3.2.7(supports-color@5.5.0)
|
debug: 3.2.7(supports-color@8.1.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user