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 IconLucideThumbsDown from '~icons/lucide/thumbs-down';
|
||||
import IconLucideThumbsUp from '~icons/lucide/thumbs-up';
|
||||
import IconLucideToggleRight from '~icons/lucide/toggle-right';
|
||||
import IconLucideTrash2 from '~icons/lucide/trash-2';
|
||||
import IconLucideTreePine from '~icons/lucide/tree-pine';
|
||||
import IconLucideTriangleAlert from '~icons/lucide/triangle-alert';
|
||||
import IconLucideType from '~icons/lucide/type';
|
||||
import IconLucideUndo2 from '~icons/lucide/undo-2';
|
||||
import IconLucideUnlink from '~icons/lucide/unlink';
|
||||
import IconLucideUser from '~icons/lucide/user';
|
||||
@@ -593,6 +595,8 @@ export const updatedIconSet = {
|
||||
'trash-2': IconLucideTrash2,
|
||||
'tree-pine': IconLucideTreePine,
|
||||
'triangle-alert': IconLucideTriangleAlert,
|
||||
type: IconLucideType,
|
||||
'toggle-right': IconLucideToggleRight,
|
||||
'undo-2': IconLucideUndo2,
|
||||
unlink: IconLucideUnlink,
|
||||
user: IconLucideUser,
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"activate": "Activate",
|
||||
"user": "User",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
"disabled": "Disabled",
|
||||
"type": "Type"
|
||||
},
|
||||
"_reusableDynamicText": {
|
||||
"readMore": "Read more",
|
||||
@@ -2858,6 +2859,13 @@
|
||||
"dataStore.noColumns.heading": "No columns yet",
|
||||
"dataStore.noColumns.description": "Add columns to start storing data in this data store.",
|
||||
"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.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>",
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
"@vue-flow/node-resizer": "^1.4.0",
|
||||
"@vueuse/components": "^10.11.0",
|
||||
"@vueuse/core": "catalog:frontend",
|
||||
"ag-grid-vue3": "^34.1.1",
|
||||
"array.prototype.tosorted": "1.1.4",
|
||||
"axios": "catalog:",
|
||||
"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 DataStoreBreadcrumbs from '@/features/dataStore/components/DataStoreBreadcrumbs.vue';
|
||||
import { useDocumentTitle } from '@/composables/useDocumentTitle';
|
||||
import DataStoreTable from './components/dataGrid/DataStoreTable.vue';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
@@ -50,14 +51,6 @@ const initialize = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onAddColumnClick = () => {
|
||||
toast.showMessage({
|
||||
type: 'warning',
|
||||
message: 'Coming soon',
|
||||
duration: 3000,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
documentTitle.set(i18n.baseText('dataStore.dataStores'));
|
||||
await initialize();
|
||||
@@ -66,7 +59,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<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
|
||||
variant="h1"
|
||||
:loading="true"
|
||||
@@ -81,15 +74,7 @@ onMounted(async () => {
|
||||
<DataStoreBreadcrumbs :data-store="dataStore" />
|
||||
</div>
|
||||
<div :class="$style.content">
|
||||
<n8n-action-box
|
||||
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"
|
||||
/>
|
||||
<DataStoreTable :data-store="dataStore" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,7 +93,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.header-loading {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
|
||||
div {
|
||||
height: 2em;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useI18n } from '@n8n/i18n';
|
||||
import type { PathItem } from '@n8n/design-system/components/N8nBreadcrumbs/Breadcrumbs.vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
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 { useToast } from '@/composables/useToast';
|
||||
|
||||
@@ -51,7 +51,10 @@ const onItemClicked = async (item: PathItem) => {
|
||||
};
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -49,7 +49,17 @@ const renderComponent = createComponentRenderer(DataStoreCard, {
|
||||
global: {
|
||||
stubs: {
|
||||
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: {
|
||||
template: '<span>just now</span>',
|
||||
@@ -98,29 +108,23 @@ describe('DataStoreCard', () => {
|
||||
const wrapper = renderComponent();
|
||||
const link = wrapper.getByTestId('data-store-card-link');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
`/projects/${DEFAULT_DATA_STORE.projectId}/datastores/${DEFAULT_DATA_STORE.id}`,
|
||||
);
|
||||
});
|
||||
|
||||
it('should display record count information', () => {
|
||||
const { getByTestId } = renderComponent();
|
||||
const recordCountElement = getByTestId('data-store-card-record-count');
|
||||
expect(recordCountElement).toBeInTheDocument();
|
||||
expect(recordCountElement).toHaveTextContent(`${DEFAULT_DATA_STORE.recordCount}`);
|
||||
});
|
||||
|
||||
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();
|
||||
expect(columnCountElement).toHaveTextContent(`${DEFAULT_DATA_STORE.columns.length + 1}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,7 +110,7 @@ const onNameSubmit = (name: string) => {
|
||||
>
|
||||
{{
|
||||
i18n.baseText('dataStore.card.column.count', {
|
||||
interpolate: { count: props.dataStore.columns.length },
|
||||
interpolate: { count: props.dataStore.columns.length + 1 },
|
||||
})
|
||||
}}
|
||||
</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 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 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 (
|
||||
context: IRestApiContext,
|
||||
@@ -32,6 +36,7 @@ export const createDataStoreApi = async (
|
||||
context: IRestApiContext,
|
||||
name: string,
|
||||
projectId?: string,
|
||||
columns?: DataStoreColumnCreatePayload[],
|
||||
) => {
|
||||
return await makeRestApiRequest<DataStore>(
|
||||
context,
|
||||
@@ -39,7 +44,7 @@ export const createDataStoreApi = async (
|
||||
`/projects/${projectId}/data-stores`,
|
||||
{
|
||||
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,
|
||||
deleteDataStoreApi,
|
||||
updateDataStoreApi,
|
||||
addDataStoreColumnApi,
|
||||
} 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';
|
||||
|
||||
export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
||||
@@ -83,6 +84,26 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
||||
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 {
|
||||
dataStores,
|
||||
totalCount,
|
||||
@@ -92,5 +113,6 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
|
||||
updateDataStore,
|
||||
fetchDataStoreDetails,
|
||||
fetchOrFindDataStore,
|
||||
addDataStoreColumn,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -8,13 +8,23 @@ export type DataStore = {
|
||||
columns: DataStoreColumn[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
projectId?: string;
|
||||
projectId: string;
|
||||
project?: Project;
|
||||
};
|
||||
|
||||
export type DataStoreColumnType = 'string' | 'number' | 'boolean' | 'date';
|
||||
|
||||
export type AGGridCellType = 'text' | 'number' | 'boolean' | 'date' | 'dateString' | 'object';
|
||||
|
||||
export type DataStoreColumn = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
type: DataStoreColumnType;
|
||||
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':
|
||||
specifier: catalog:frontend
|
||||
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:
|
||||
specifier: 1.1.4
|
||||
version: 1.1.4
|
||||
@@ -8140,6 +8143,17 @@ packages:
|
||||
resolution: {integrity: sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
@@ -23079,6 +23093,17 @@ snapshots:
|
||||
|
||||
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@6.0.2:
|
||||
@@ -25523,7 +25548,7 @@ snapshots:
|
||||
|
||||
eslint-import-resolver-node@0.3.9:
|
||||
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
|
||||
resolve: 1.22.10
|
||||
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)):
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
optionalDependencies:
|
||||
'@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)
|
||||
@@ -25586,7 +25611,7 @@ snapshots:
|
||||
array.prototype.findlastindex: 1.2.6
|
||||
array.prototype.flat: 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
|
||||
eslint: 9.29.0(jiti@1.21.7)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
@@ -26545,7 +26570,7 @@ snapshots:
|
||||
array-parallel: 0.1.3
|
||||
array-series: 0.1.5
|
||||
cross-spawn: 7.0.6
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -29950,7 +29975,7 @@ snapshots:
|
||||
|
||||
pdf-parse@1.1.1:
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
node-ensure: 0.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -30938,7 +30963,7 @@ snapshots:
|
||||
|
||||
rhea@1.0.24:
|
||||
dependencies:
|
||||
debug: 3.2.7(supports-color@5.5.0)
|
||||
debug: 3.2.7(supports-color@8.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
||||
Reference in New Issue
Block a user