mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 12:51:16 +08:00
chore(editor): Add unit tests for data tables FE code (no-changelog) (#20511)
This commit is contained in:
+173
-13
@@ -156,7 +156,7 @@ describe('DataTableBreadcrumbs', () => {
|
||||
});
|
||||
|
||||
it('should render DataTableActions component that can trigger navigation', () => {
|
||||
const { container } = renderComponent({
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {},
|
||||
stubActions: false,
|
||||
@@ -164,55 +164,178 @@ describe('DataTableBreadcrumbs', () => {
|
||||
});
|
||||
|
||||
// Verify DataTableActions component is rendered
|
||||
const actionsComponent = container.querySelector('[data-test-id="data-table-card-actions"]');
|
||||
const actionsComponent = getByTestId('data-table-card-actions');
|
||||
expect(actionsComponent).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Name editing', () => {
|
||||
it('should show current data table name', () => {
|
||||
const { getByDisplayValue } = renderComponent({
|
||||
it('should show current data table name in preview', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {},
|
||||
stubActions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(getByDisplayValue('Test DataTable')).toBeInTheDocument();
|
||||
const preview = getByTestId('inline-edit-preview');
|
||||
expect(preview).toBeInTheDocument();
|
||||
expect(preview).toHaveTextContent('Test DataTable');
|
||||
});
|
||||
|
||||
it('should have editable name input with correct attributes', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {},
|
||||
stubActions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const input = getByTestId('inline-edit-input');
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveAttribute('maxlength', '30');
|
||||
expect(input).toHaveValue('Test DataTable');
|
||||
});
|
||||
|
||||
it('should render placeholder for name input', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {},
|
||||
stubActions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const input = getByTestId('inline-edit-input');
|
||||
expect(input).toHaveAttribute('placeholder', 'Data table name');
|
||||
});
|
||||
|
||||
it('should call updateDataTable when name is changed and submitted', async () => {
|
||||
mockUpdateDataTable.mockResolvedValue({ id: '1', name: 'Renamed Table' });
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {},
|
||||
stubActions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
// Click to activate edit mode
|
||||
const editableArea = getByTestId('inline-editable-area');
|
||||
await userEvent.click(editableArea);
|
||||
|
||||
// Type new name
|
||||
const input = getByTestId('inline-edit-input');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Renamed Table{Enter}');
|
||||
|
||||
// Check that updateDataTable was called
|
||||
expect(mockUpdateDataTable).toHaveBeenCalledWith('1', 'Renamed Table', 'project-1');
|
||||
});
|
||||
|
||||
it('should show error toast when rename fails', async () => {
|
||||
mockUpdateDataTable.mockRejectedValue(new Error('Update failed'));
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {},
|
||||
stubActions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const editableArea = getByTestId('inline-editable-area');
|
||||
await userEvent.click(editableArea);
|
||||
|
||||
const input = getByTestId('inline-edit-input');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Failed Name{Enter}');
|
||||
|
||||
expect(mockToast.showError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should revert to original name when update returns null', async () => {
|
||||
mockUpdateDataTable.mockResolvedValue(null);
|
||||
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {},
|
||||
stubActions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const editableArea = getByTestId('inline-editable-area');
|
||||
await userEvent.click(editableArea);
|
||||
|
||||
const input = getByTestId('inline-edit-input');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, 'Invalid Name{Enter}');
|
||||
|
||||
expect(mockToast.showError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call updateDataTable when name is empty', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {},
|
||||
stubActions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const editableArea = getByTestId('inline-editable-area');
|
||||
await userEvent.click(editableArea);
|
||||
|
||||
const input = getByTestId('inline-edit-input');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '{Enter}');
|
||||
|
||||
expect(mockUpdateDataTable).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not call updateDataTable when name is unchanged', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {},
|
||||
stubActions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const editableArea = getByTestId('inline-editable-area');
|
||||
await userEvent.click(editableArea);
|
||||
|
||||
const input = getByTestId('inline-edit-input');
|
||||
await userEvent.type(input, '{Enter}');
|
||||
|
||||
expect(mockUpdateDataTable).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component integration', () => {
|
||||
it('should render component structure correctly', () => {
|
||||
const { container, getByTestId } = renderComponent({
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {},
|
||||
stubActions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
// Check main structure
|
||||
const breadcrumbsContainer = container.querySelector('.data-table-breadcrumbs');
|
||||
expect(breadcrumbsContainer).toBeInTheDocument();
|
||||
|
||||
// Check name input
|
||||
const nameInput = getByTestId('data-table-header-name-input');
|
||||
expect(nameInput).toBeInTheDocument();
|
||||
|
||||
// Check actions component
|
||||
const actionsComponent = container.querySelector('[data-test-id="data-table-card-actions"]');
|
||||
const actionsComponent = getByTestId('data-table-card-actions');
|
||||
expect(actionsComponent).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display correct data table name', () => {
|
||||
const { getByDisplayValue } = renderComponent({
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {},
|
||||
stubActions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(getByDisplayValue('Test DataTable')).toBeInTheDocument();
|
||||
const preview = getByTestId('inline-edit-preview');
|
||||
expect(preview).toHaveTextContent('Test DataTable');
|
||||
});
|
||||
|
||||
it('should show breadcrumbs separator', () => {
|
||||
@@ -227,4 +350,41 @@ describe('DataTableBreadcrumbs', () => {
|
||||
expect(separators.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delete functionality', () => {
|
||||
it('should render delete action component', () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {},
|
||||
stubActions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const actionsComponent = getByTestId('data-table-card-actions');
|
||||
expect(actionsComponent).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Props watching', () => {
|
||||
it('should update editableName when dataTable name prop changes', async () => {
|
||||
const { rerender, getByTestId } = renderComponent({
|
||||
pinia: createTestingPinia({
|
||||
initialState: {},
|
||||
stubActions: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const preview = getByTestId('inline-edit-preview');
|
||||
expect(preview).toHaveTextContent('Test DataTable');
|
||||
|
||||
const updatedDataTable = {
|
||||
...mockDataTable,
|
||||
name: 'Updated Name',
|
||||
};
|
||||
|
||||
await rerender({ dataTable: updatedDataTable });
|
||||
|
||||
expect(preview).toHaveTextContent('Updated Name');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+13
-8
@@ -3,7 +3,18 @@ import DataTableCard from '@/features/dataTable/components/DataTableCard.vue';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import type { DataTableResource } from '@/features/dataTable/types';
|
||||
|
||||
vi.mock('vue-router', () => {
|
||||
vi.mock('@/features/projects/projects.store');
|
||||
|
||||
vi.mock('vue-router', async () => {
|
||||
const { reactive } = await import('vue');
|
||||
const mockRoute = reactive({
|
||||
params: {
|
||||
projectId: '1',
|
||||
id: '1',
|
||||
},
|
||||
query: {},
|
||||
});
|
||||
|
||||
const push = vi.fn();
|
||||
const resolve = vi.fn().mockReturnValue({ href: '/projects/1/datatables/1' });
|
||||
return {
|
||||
@@ -11,13 +22,7 @@ vi.mock('vue-router', () => {
|
||||
push,
|
||||
resolve,
|
||||
}),
|
||||
useRoute: vi.fn().mockReturnValue({
|
||||
params: {
|
||||
projectId: '1',
|
||||
id: '1',
|
||||
},
|
||||
query: {},
|
||||
}),
|
||||
useRoute: vi.fn(() => mockRoute),
|
||||
RouterLink: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
+184
-59
@@ -20,14 +20,22 @@ vi.mock('@/features/dataTable/composables/useDataTableTypes', () => ({
|
||||
|
||||
vi.mock('@/composables/useDebounce', () => ({
|
||||
useDebounce: () => ({
|
||||
debounce: (fn: Function) => fn,
|
||||
debounce:
|
||||
(fn: Function) =>
|
||||
(...args: unknown[]) =>
|
||||
fn(...args),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/dataTable/constants', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
COLUMN_NAME_REGEX: /^[a-zA-Z][a-zA-Z0-9_-]*$/,
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => {
|
||||
baseText: (key: string, options?: { interpolate?: Record<string, string> }) => {
|
||||
const translations: Record<string, string> = {
|
||||
'dataTable.addColumn.label': 'Add column',
|
||||
'dataTable.addColumn.nameInput.label': 'Column name',
|
||||
@@ -36,6 +44,11 @@ vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||
'dataTable.addColumn.invalidName.error': 'Invalid column name',
|
||||
'dataTable.addColumn.invalidName.description':
|
||||
'Column names must start with a letter and contain only letters, numbers, and hyphens',
|
||||
'dataTable.addColumn.error': 'Error adding column',
|
||||
'dataTable.addColumn.alreadyExistsError': `Column "${options?.interpolate?.name}" already exists`,
|
||||
'dataTable.addColumn.systemColumnDescription': 'This is a system column',
|
||||
'dataTable.addColumn.testingColumnDescription': 'This is a testing column',
|
||||
'dataTable.addColumn.alreadyExistsDescription': 'Column already exists',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -54,6 +67,8 @@ describe('AddColumnButton', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
addColumnHandler.mockClear();
|
||||
addColumnHandler.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
it('should render the add column button', () => {
|
||||
@@ -62,33 +77,46 @@ describe('AddColumnButton', () => {
|
||||
});
|
||||
|
||||
it('should focus name input when popover opens', async () => {
|
||||
const { getByTestId, getByPlaceholderText } = renderComponent();
|
||||
const { getByTestId } = renderComponent();
|
||||
const addButton = getByTestId('data-table-add-column-trigger-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const nameInput = getByPlaceholderText('Enter column name');
|
||||
const nameInput = getByTestId('add-column-name-input');
|
||||
expect(nameInput).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call addColumn with correct payload', async () => {
|
||||
const { getByTestId, getByPlaceholderText } = renderComponent();
|
||||
const { getByTestId } = renderComponent();
|
||||
const addButton = getByTestId('data-table-add-column-trigger-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
const nameInput = getByPlaceholderText('Enter column name');
|
||||
await fireEvent.update(nameInput, 'newColumn');
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = getByTestId('add-column-name-input');
|
||||
|
||||
// Properly trigger v-model binding by setting value and firing input event
|
||||
(nameInput as HTMLInputElement).value = 'newColumn';
|
||||
await fireEvent.input(nameInput);
|
||||
|
||||
await waitFor(() => {
|
||||
const submitButton = getByTestId('data-table-add-column-submit-button');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const submitButton = getByTestId('data-table-add-column-submit-button');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
await fireEvent.click(submitButton);
|
||||
|
||||
expect(addColumnHandler).toHaveBeenCalledWith({
|
||||
name: 'newColumn',
|
||||
type: 'string',
|
||||
await waitFor(() => {
|
||||
expect(addColumnHandler).toHaveBeenCalledWith({
|
||||
name: 'newColumn',
|
||||
type: 'string',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,16 +133,20 @@ describe('AddColumnButton', () => {
|
||||
});
|
||||
|
||||
it('should show error for invalid column names', async () => {
|
||||
const { getByPlaceholderText, getByText, getByTestId } = renderComponent();
|
||||
const { getByText, getByTestId } = renderComponent();
|
||||
const addButton = getByTestId('data-table-add-column-trigger-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
const nameInput = getByPlaceholderText('Enter column name');
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = getByTestId('add-column-name-input');
|
||||
|
||||
// Test invalid name starting with hyphen
|
||||
await fireEvent.update(nameInput, '-invalid');
|
||||
await fireEvent.blur(nameInput);
|
||||
(nameInput as HTMLInputElement).value = '-invalid';
|
||||
await fireEvent.input(nameInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Invalid column name')).toBeInTheDocument();
|
||||
@@ -124,19 +156,23 @@ describe('AddColumnButton', () => {
|
||||
});
|
||||
|
||||
it('should allow valid column names', async () => {
|
||||
const { getByTestId, getByPlaceholderText, queryByText } = renderComponent();
|
||||
const { getByTestId, queryByText } = renderComponent();
|
||||
const addButton = getByTestId('data-table-add-column-trigger-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
const nameInput = getByPlaceholderText('Enter column name');
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = getByTestId('add-column-name-input');
|
||||
|
||||
// Test valid names
|
||||
const validNames = ['column1', 'my_column', 'Column123', 'a1b2c3'];
|
||||
|
||||
for (const name of validNames) {
|
||||
await fireEvent.update(nameInput, name);
|
||||
await fireEvent.blur(nameInput);
|
||||
(nameInput as HTMLInputElement).value = name;
|
||||
await fireEvent.input(nameInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('Invalid column name')).not.toBeInTheDocument();
|
||||
@@ -145,24 +181,28 @@ describe('AddColumnButton', () => {
|
||||
});
|
||||
|
||||
it('should clear error when correcting invalid name', async () => {
|
||||
const { getByTestId, getByPlaceholderText, getByText, queryByText } = renderComponent();
|
||||
const { getByTestId, getByText, queryByText } = renderComponent();
|
||||
const addButton = getByTestId('data-table-add-column-trigger-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
const nameInput = getByPlaceholderText('Enter column name');
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = getByTestId('add-column-name-input');
|
||||
|
||||
// Enter invalid name
|
||||
await fireEvent.update(nameInput, '-invalid');
|
||||
await fireEvent.blur(nameInput);
|
||||
(nameInput as HTMLInputElement).value = '-invalid';
|
||||
await fireEvent.input(nameInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Invalid column name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Correct the name
|
||||
await fireEvent.update(nameInput, 'valid');
|
||||
await fireEvent.blur(nameInput);
|
||||
(nameInput as HTMLInputElement).value = 'valid';
|
||||
await fireEvent.input(nameInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByText('Invalid column name')).not.toBeInTheDocument();
|
||||
@@ -170,71 +210,115 @@ describe('AddColumnButton', () => {
|
||||
});
|
||||
|
||||
it('should respect max column name length', async () => {
|
||||
const { getByTestId, getByPlaceholderText } = renderComponent();
|
||||
const { getByTestId } = renderComponent();
|
||||
const addButton = getByTestId('data-table-add-column-trigger-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
const nameInput = getByPlaceholderText('Enter column name');
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = getByTestId('add-column-name-input');
|
||||
|
||||
expect(nameInput.getAttribute('maxlength')).toBe(MAX_COLUMN_NAME_LENGTH.toString());
|
||||
});
|
||||
|
||||
it('should allow selecting different column types', async () => {
|
||||
const { getByPlaceholderText, getByRole, getByText, getByTestId } = renderComponent();
|
||||
const { getByRole, getByText, getByTestId } = renderComponent();
|
||||
const addButton = getByTestId('data-table-add-column-trigger-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
const nameInput = getByPlaceholderText('Enter column name');
|
||||
await fireEvent.update(nameInput, 'numberColumn');
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = getByTestId('add-column-name-input');
|
||||
(nameInput as HTMLInputElement).value = 'numberColumn';
|
||||
await fireEvent.input(nameInput);
|
||||
|
||||
// Click on the select to open dropdown
|
||||
const selectElement = getByRole('combobox');
|
||||
await fireEvent.click(selectElement);
|
||||
|
||||
// Select 'number' type
|
||||
await waitFor(() => {
|
||||
expect(getByText('number')).toBeInTheDocument();
|
||||
});
|
||||
const numberOption = getByText('number');
|
||||
await fireEvent.click(numberOption);
|
||||
|
||||
await waitFor(() => {
|
||||
const submitButton = getByTestId('data-table-add-column-submit-button');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const submitButton = getByTestId('data-table-add-column-submit-button');
|
||||
await fireEvent.click(submitButton);
|
||||
|
||||
expect(addColumnHandler).toHaveBeenCalledWith({
|
||||
name: 'numberColumn',
|
||||
type: 'number',
|
||||
await waitFor(() => {
|
||||
expect(addColumnHandler).toHaveBeenCalledWith({
|
||||
name: 'numberColumn',
|
||||
type: 'number',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should reset form after successful submission', async () => {
|
||||
const { getByPlaceholderText, getByTestId } = renderComponent();
|
||||
const { getByTestId } = renderComponent();
|
||||
const addButton = getByTestId('data-table-add-column-trigger-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
const nameInput = getByPlaceholderText('Enter column name');
|
||||
await fireEvent.update(nameInput, 'testColumn');
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = getByTestId('add-column-name-input');
|
||||
(nameInput as HTMLInputElement).value = 'testColumn';
|
||||
await fireEvent.input(nameInput);
|
||||
|
||||
await waitFor(() => {
|
||||
const submitButton = getByTestId('data-table-add-column-submit-button');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const submitButton = getByTestId('data-table-add-column-submit-button');
|
||||
await fireEvent.click(submitButton);
|
||||
|
||||
// Wait for popover to close
|
||||
await waitFor(() => {
|
||||
expect(addColumnHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Click button again to open popover
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const resetNameInput = getByPlaceholderText('Enter column name');
|
||||
const resetNameInput = getByTestId('add-column-name-input');
|
||||
expect((resetNameInput as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('should close popover after successful submission', async () => {
|
||||
const { getByPlaceholderText, getByTestId, queryByTestId } = renderComponent();
|
||||
const { getByTestId, queryByTestId } = renderComponent();
|
||||
const addButton = getByTestId('data-table-add-column-trigger-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
const nameInput = getByPlaceholderText('Enter column name');
|
||||
await fireEvent.update(nameInput, 'testColumn');
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = getByTestId('add-column-name-input');
|
||||
(nameInput as HTMLInputElement).value = 'testColumn';
|
||||
await fireEvent.input(nameInput);
|
||||
|
||||
await waitFor(() => {
|
||||
const submitButton = getByTestId('data-table-add-column-submit-button');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const submitButton = getByTestId('data-table-add-column-submit-button');
|
||||
await fireEvent.click(submitButton);
|
||||
@@ -245,7 +329,7 @@ describe('AddColumnButton', () => {
|
||||
});
|
||||
|
||||
it('should not close popover if submission fails', async () => {
|
||||
const { getByPlaceholderText, getByTestId } = renderComponent();
|
||||
const { getByTestId } = renderComponent();
|
||||
addColumnHandler.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Column name already exists',
|
||||
@@ -254,8 +338,18 @@ describe('AddColumnButton', () => {
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
const nameInput = getByPlaceholderText('Enter column name');
|
||||
await fireEvent.update(nameInput, 'testColumn');
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = getByTestId('add-column-name-input');
|
||||
(nameInput as HTMLInputElement).value = 'testColumn';
|
||||
await fireEvent.input(nameInput);
|
||||
|
||||
await waitFor(() => {
|
||||
const submitButton = getByTestId('data-table-add-column-submit-button');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const submitButton = getByTestId('data-table-add-column-submit-button');
|
||||
await fireEvent.click(submitButton);
|
||||
@@ -266,18 +360,25 @@ describe('AddColumnButton', () => {
|
||||
});
|
||||
|
||||
it('should allow submission with Enter key', async () => {
|
||||
const { getByTestId, getByPlaceholderText } = renderComponent();
|
||||
const { getByTestId } = renderComponent();
|
||||
const addButton = getByTestId('data-table-add-column-trigger-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
const nameInput = getByPlaceholderText('Enter column name');
|
||||
await fireEvent.update(nameInput, 'enterColumn');
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = getByTestId('add-column-name-input');
|
||||
(nameInput as HTMLInputElement).value = 'enterColumn';
|
||||
await fireEvent.input(nameInput);
|
||||
await fireEvent.keyUp(nameInput, { key: 'Enter' });
|
||||
|
||||
expect(addColumnHandler).toHaveBeenCalledWith({
|
||||
name: 'enterColumn',
|
||||
type: 'string',
|
||||
await waitFor(() => {
|
||||
expect(addColumnHandler).toHaveBeenCalledWith({
|
||||
name: 'enterColumn',
|
||||
type: 'string',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -287,6 +388,10 @@ describe('AddColumnButton', () => {
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const selectElement = getByRole('combobox');
|
||||
await fireEvent.click(selectElement);
|
||||
|
||||
@@ -299,38 +404,58 @@ describe('AddColumnButton', () => {
|
||||
});
|
||||
|
||||
it('should set value to "date" when selecting "datetime" option', async () => {
|
||||
const { getByTestId, getByRole, getByText, getByPlaceholderText } = renderComponent();
|
||||
const { getByTestId, getByRole, getByText } = renderComponent();
|
||||
const addButton = getByTestId('data-table-add-column-trigger-button');
|
||||
|
||||
await fireEvent.click(addButton);
|
||||
|
||||
const nameInput = getByPlaceholderText('Enter column name');
|
||||
await fireEvent.update(nameInput, 'dateColumn');
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = getByTestId('add-column-name-input');
|
||||
(nameInput as HTMLInputElement).value = 'dateColumn';
|
||||
await fireEvent.input(nameInput);
|
||||
|
||||
const selectElement = getByRole('combobox');
|
||||
await fireEvent.click(selectElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('datetime')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const dateOption = getByText('datetime');
|
||||
await fireEvent.click(dateOption);
|
||||
|
||||
await waitFor(() => {
|
||||
const submitButton = getByTestId('data-table-add-column-submit-button');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const submitButton = getByTestId('data-table-add-column-submit-button');
|
||||
await fireEvent.click(submitButton);
|
||||
|
||||
expect(addColumnHandler).toHaveBeenCalledWith({
|
||||
name: 'dateColumn',
|
||||
type: 'date',
|
||||
await waitFor(() => {
|
||||
expect(addColumnHandler).toHaveBeenCalledWith({
|
||||
name: 'dateColumn',
|
||||
type: 'date',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show tooltip with error description', async () => {
|
||||
const { getByPlaceholderText, getByText, getByTestId } = renderComponent();
|
||||
const { getByText, getByTestId } = renderComponent();
|
||||
const addButton = getByTestId('data-table-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(getByTestId('add-column-popover-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nameInput = getByTestId('add-column-name-input');
|
||||
(nameInput as HTMLInputElement).value = '-invalid';
|
||||
await fireEvent.input(nameInput);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByText('Invalid column name')).toBeInTheDocument();
|
||||
|
||||
+1
@@ -171,6 +171,7 @@ const onInput = debounce(validateName, { debounceTime: 100 });
|
||||
v-model="columnName"
|
||||
:placeholder="i18n.baseText('dataTable.addColumn.nameInput.placeholder')"
|
||||
:maxlength="MAX_COLUMN_NAME_LENGTH"
|
||||
data-test-id="add-column-name-input"
|
||||
@keyup.enter="onAddButtonClicked"
|
||||
@input="onInput"
|
||||
/>
|
||||
|
||||
+52
-81
@@ -6,64 +6,27 @@ import type {
|
||||
DataTableRow,
|
||||
} from '@/features/dataTable/dataTable.types';
|
||||
import { AgGridVue } from 'ag-grid-vue3';
|
||||
import type { GetRowIdParams, GridReadyEvent } from 'ag-grid-community';
|
||||
import {
|
||||
ModuleRegistry,
|
||||
ClientSideRowModelModule,
|
||||
TextEditorModule,
|
||||
LargeTextEditorModule,
|
||||
ColumnAutoSizeModule,
|
||||
CheckboxEditorModule,
|
||||
NumberEditorModule,
|
||||
RowSelectionModule,
|
||||
RenderApiModule,
|
||||
DateEditorModule,
|
||||
ClientSideRowModelApiModule,
|
||||
ValidationModule,
|
||||
UndoRedoEditModule,
|
||||
CellStyleModule,
|
||||
ScrollApiModule,
|
||||
PinnedRowModule,
|
||||
ColumnApiModule,
|
||||
TextFilterModule,
|
||||
NumberFilterModule,
|
||||
DateFilterModule,
|
||||
EventApiModule,
|
||||
} from 'ag-grid-community';
|
||||
import type { GetRowIdParams, GridReadyEvent, SortChangedEvent } from 'ag-grid-community';
|
||||
import { n8nTheme } from '@/features/dataTable/components/dataGrid/n8nTheme';
|
||||
import { registerAgGridModulesOnce } from '@/features/dataTable/components/dataGrid/registerAgGridModulesOnce';
|
||||
import SelectedItemsInfo from '@/components/common/SelectedItemsInfo.vue';
|
||||
import { DATA_TABLE_HEADER_HEIGHT, DATA_TABLE_ROW_HEIGHT } from '@/features/dataTable/constants';
|
||||
import {
|
||||
DATA_TABLE_HEADER_HEIGHT,
|
||||
DATA_TABLE_ROW_HEIGHT,
|
||||
DEFAULT_ID_COLUMN_NAME,
|
||||
ADD_ROW_ROW_ID,
|
||||
} from '@/features/dataTable/constants';
|
||||
import { useDataTablePagination } from '@/features/dataTable/composables/useDataTablePagination';
|
||||
import { useDataTableGridBase } from '@/features/dataTable/composables/useDataTableGridBase';
|
||||
import { useAgGrid } from '@/features/dataTable/composables/useAgGrid';
|
||||
import { useDataTableColumns } from '@/features/dataTable/composables/useDataTableColumns';
|
||||
import { useDataTableSelection } from '@/features/dataTable/composables/useDataTableSelection';
|
||||
import { useDataTableOperations } from '@/features/dataTable/composables/useDataTableOperations';
|
||||
import { useDataTableColumnFilters } from '@/features/dataTable/composables/useDataTableColumnFilters';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { GRID_FILTER_CONFIG } from '@/features/dataTable/utils/filterMappings';
|
||||
|
||||
import { ElPagination } from 'element-plus';
|
||||
// Register only the modules we actually use
|
||||
ModuleRegistry.registerModules([
|
||||
ValidationModule, // This module allows us to see AG Grid errors in browser console
|
||||
ClientSideRowModelModule,
|
||||
TextEditorModule,
|
||||
LargeTextEditorModule,
|
||||
ColumnAutoSizeModule,
|
||||
CheckboxEditorModule,
|
||||
NumberEditorModule,
|
||||
RowSelectionModule,
|
||||
RenderApiModule,
|
||||
DateEditorModule,
|
||||
ClientSideRowModelApiModule,
|
||||
UndoRedoEditModule,
|
||||
CellStyleModule,
|
||||
PinnedRowModule,
|
||||
ScrollApiModule,
|
||||
ColumnApiModule,
|
||||
TextFilterModule,
|
||||
NumberFilterModule,
|
||||
DateFilterModule,
|
||||
EventApiModule,
|
||||
]);
|
||||
registerAgGridModulesOnce();
|
||||
|
||||
type Props = {
|
||||
dataTable: DataTable;
|
||||
@@ -78,20 +41,26 @@ const emit = defineEmits<{
|
||||
const gridContainerRef = useTemplateRef<HTMLDivElement>('gridContainerRef');
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const dataTableGridBase = useDataTableGridBase({
|
||||
gridContainerRef,
|
||||
onDeleteColumn: onDeleteColumnFunction,
|
||||
onAddRowClick: onAddRowClickFunction,
|
||||
onAddColumn: onAddColumnFunction,
|
||||
});
|
||||
const rowData = ref<DataTableRow[]>([]);
|
||||
const hasRecords = computed(() => rowData.value.length > 0);
|
||||
|
||||
const { initializeFilters, onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: dataTableGridBase.gridApi,
|
||||
colDefs: dataTableGridBase.colDefs,
|
||||
setGridData: dataTableGridBase.setGridData,
|
||||
const agGrid = useAgGrid<DataTableRow>({
|
||||
gridContainerRef,
|
||||
defaultSortColumn: DEFAULT_ID_COLUMN_NAME,
|
||||
pinnedBottomRowId: ADD_ROW_ROW_ID,
|
||||
defaultColDef: GRID_FILTER_CONFIG.defaultColDef,
|
||||
});
|
||||
|
||||
const dataTableColumns = useDataTableColumns({
|
||||
onDeleteColumn: onDeleteColumnFunction,
|
||||
onAddRowClick: onAddRowClickFunction,
|
||||
onAddColumn: onAddColumnFunction,
|
||||
isTextEditorOpen: agGrid.isTextEditorOpen,
|
||||
});
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: agGrid.gridApi,
|
||||
colDefs: dataTableColumns.colDefs,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -106,32 +75,32 @@ const {
|
||||
} = useDataTablePagination({ onChange: fetchDataTableRowsFunction });
|
||||
|
||||
const selection = useDataTableSelection({
|
||||
gridApi: dataTableGridBase.gridApi,
|
||||
gridApi: agGrid.gridApi,
|
||||
});
|
||||
|
||||
const dataTableOperations = useDataTableOperations({
|
||||
colDefs: dataTableGridBase.colDefs,
|
||||
colDefs: dataTableColumns.colDefs,
|
||||
rowData,
|
||||
deleteGridColumn: dataTableGridBase.deleteColumn,
|
||||
setGridData: dataTableGridBase.setGridData,
|
||||
insertGridColumnAtIndex: dataTableGridBase.insertColumnAtIndex,
|
||||
deleteGridColumn: dataTableColumns.deleteColumn,
|
||||
setGridData: agGrid.setGridData,
|
||||
insertGridColumnAtIndex: dataTableColumns.insertColumnAtIndex,
|
||||
dataTableId: props.dataTable.id,
|
||||
projectId: props.dataTable.projectId,
|
||||
addGridColumn: dataTableGridBase.addColumn,
|
||||
moveGridColumn: dataTableGridBase.moveColumn,
|
||||
gridApi: dataTableGridBase.gridApi,
|
||||
addGridColumn: dataTableColumns.addColumn,
|
||||
moveGridColumn: dataTableColumns.moveColumn,
|
||||
gridApi: agGrid.gridApi,
|
||||
totalItems,
|
||||
setTotalItems,
|
||||
ensureItemOnPage,
|
||||
focusFirstEditableCell: dataTableGridBase.focusFirstEditableCell,
|
||||
focusFirstEditableCell: agGrid.focusFirstEditableCell,
|
||||
toggleSave: emit.bind(null, 'toggleSave'),
|
||||
currentPage,
|
||||
pageSize,
|
||||
currentSortBy: dataTableGridBase.currentSortBy,
|
||||
currentSortOrder: dataTableGridBase.currentSortOrder,
|
||||
currentSortBy: agGrid.currentSortBy,
|
||||
currentSortOrder: agGrid.currentSortOrder,
|
||||
handleClearSelection: selection.handleClearSelection,
|
||||
selectedRowIds: selection.selectedRowIds,
|
||||
handleCopyFocusedCell: dataTableGridBase.handleCopyFocusedCell,
|
||||
handleCopyFocusedCell: agGrid.handleCopyFocusedCell,
|
||||
currentFilterJSON,
|
||||
});
|
||||
|
||||
@@ -152,15 +121,15 @@ async function fetchDataTableRowsFunction() {
|
||||
}
|
||||
|
||||
const initialize = async (params: GridReadyEvent) => {
|
||||
dataTableGridBase.onGridReady(params);
|
||||
dataTableGridBase.loadColumns(props.dataTable.columns);
|
||||
agGrid.onGridReady(params);
|
||||
dataTableColumns.loadColumns(props.dataTable.columns);
|
||||
agGrid.setGridData({ colDefs: dataTableColumns.colDefs.value });
|
||||
await dataTableOperations.fetchDataTableRows();
|
||||
initializeFilters();
|
||||
};
|
||||
|
||||
const customNoRowsOverlay = `<div class="no-rows-overlay ag-overlay-no-rows-center" data-test-id="data-table-no-rows-overlay">${i18n.baseText('dataTable.noRows')}</div>`;
|
||||
|
||||
watch([dataTableGridBase.currentSortBy, dataTableGridBase.currentSortOrder], async () => {
|
||||
watch([agGrid.currentSortBy, agGrid.currentSortOrder], async () => {
|
||||
await setCurrentPage(1);
|
||||
});
|
||||
|
||||
@@ -199,12 +168,14 @@ defineExpose({
|
||||
@grid-ready="initialize"
|
||||
@cell-value-changed="dataTableOperations.onCellValueChanged"
|
||||
@column-moved="dataTableOperations.onColumnMoved"
|
||||
@cell-clicked="dataTableGridBase.onCellClicked"
|
||||
@cell-editing-started="dataTableGridBase.onCellEditingStarted"
|
||||
@cell-editing-stopped="dataTableGridBase.onCellEditingStopped"
|
||||
@column-header-clicked="dataTableGridBase.resetLastFocusedCell"
|
||||
@cell-clicked="agGrid.onCellClicked"
|
||||
@cell-editing-started="agGrid.onCellEditingStarted"
|
||||
@cell-editing-stopped="agGrid.onCellEditingStopped"
|
||||
@column-header-clicked="agGrid.resetLastFocusedCell"
|
||||
@selection-changed="selection.onSelectionChanged"
|
||||
@sort-changed="dataTableGridBase.onSortChanged"
|
||||
@sort-changed="
|
||||
(e: SortChangedEvent) => agGrid.onSortChanged(e, dataTableColumns.colDefs.value)
|
||||
"
|
||||
@cell-key-down="dataTableOperations.onCellKeyDown"
|
||||
@filter-changed="onFilterChanged"
|
||||
/>
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
ModuleRegistry,
|
||||
ClientSideRowModelModule,
|
||||
TextEditorModule,
|
||||
LargeTextEditorModule,
|
||||
ColumnAutoSizeModule,
|
||||
CheckboxEditorModule,
|
||||
NumberEditorModule,
|
||||
RowSelectionModule,
|
||||
RenderApiModule,
|
||||
DateEditorModule,
|
||||
ClientSideRowModelApiModule,
|
||||
ValidationModule,
|
||||
UndoRedoEditModule,
|
||||
CellStyleModule,
|
||||
ScrollApiModule,
|
||||
PinnedRowModule,
|
||||
ColumnApiModule,
|
||||
TextFilterModule,
|
||||
NumberFilterModule,
|
||||
DateFilterModule,
|
||||
EventApiModule,
|
||||
} from 'ag-grid-community';
|
||||
|
||||
let modulesRegistered = false;
|
||||
|
||||
export const registerAgGridModulesOnce = () => {
|
||||
if (modulesRegistered) return;
|
||||
ModuleRegistry.registerModules([
|
||||
ValidationModule,
|
||||
ClientSideRowModelModule,
|
||||
TextEditorModule,
|
||||
LargeTextEditorModule,
|
||||
ColumnAutoSizeModule,
|
||||
CheckboxEditorModule,
|
||||
NumberEditorModule,
|
||||
RowSelectionModule,
|
||||
RenderApiModule,
|
||||
DateEditorModule,
|
||||
ClientSideRowModelApiModule,
|
||||
UndoRedoEditModule,
|
||||
CellStyleModule,
|
||||
PinnedRowModule,
|
||||
ScrollApiModule,
|
||||
ColumnApiModule,
|
||||
TextFilterModule,
|
||||
NumberFilterModule,
|
||||
DateFilterModule,
|
||||
EventApiModule,
|
||||
]);
|
||||
modulesRegistered = true;
|
||||
};
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ref, type Ref } from 'vue';
|
||||
import { useDataTableColumnFilters } from '../useDataTableColumnFilters';
|
||||
import type { ColDef, GridApi } from 'ag-grid-community';
|
||||
|
||||
describe('useDataTableColumnFilters', () => {
|
||||
let mockGridApi: GridApi;
|
||||
let mockSetGridData: ReturnType<typeof vi.fn>;
|
||||
let colDefs: Ref<ColDef[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockGridApi = {
|
||||
setGridOption: vi.fn(),
|
||||
getFilterModel: vi.fn(),
|
||||
} as unknown as GridApi;
|
||||
|
||||
mockSetGridData = vi.fn();
|
||||
|
||||
colDefs = ref([
|
||||
{ field: 'name', colId: 'name' },
|
||||
{ field: 'age', colId: 'age' },
|
||||
{ field: 'add-column', colId: 'add-column' },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('initializeFilters', () => {
|
||||
it('should disable filters for special columns', () => {
|
||||
const gridApi = ref(mockGridApi);
|
||||
const { initializeFilters } = useDataTableColumnFilters({
|
||||
gridApi,
|
||||
colDefs,
|
||||
setGridData: mockSetGridData,
|
||||
});
|
||||
|
||||
initializeFilters();
|
||||
|
||||
const expectedColDefs = [
|
||||
{ field: 'name', colId: 'name' },
|
||||
{ field: 'age', colId: 'age' },
|
||||
{ field: 'add-column', colId: 'add-column', filter: false },
|
||||
];
|
||||
|
||||
expect(colDefs.value).toEqual(expectedColDefs);
|
||||
expect(mockSetGridData).toHaveBeenCalledWith({ colDefs: expectedColDefs });
|
||||
});
|
||||
});
|
||||
|
||||
describe('onFilterChanged', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should process text filters', () => {
|
||||
const gridApi = ref(mockGridApi);
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi,
|
||||
colDefs,
|
||||
setGridData: mockSetGridData,
|
||||
});
|
||||
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue({
|
||||
name: {
|
||||
filterType: 'text',
|
||||
type: 'contains',
|
||||
filter: 'john',
|
||||
},
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
expect(currentFilterJSON.value).toBe(
|
||||
JSON.stringify({
|
||||
type: 'and',
|
||||
filters: [
|
||||
{
|
||||
columnName: 'name',
|
||||
condition: 'ilike',
|
||||
value: 'john',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return undefined when no filters', () => {
|
||||
const gridApi = ref(mockGridApi);
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi,
|
||||
colDefs,
|
||||
setGridData: mockSetGridData,
|
||||
});
|
||||
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue({});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
expect(currentFilterJSON.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasActiveFilters', () => {
|
||||
it('should be false when no filters are active', () => {
|
||||
const gridApi = ref(mockGridApi);
|
||||
const { hasActiveFilters } = useDataTableColumnFilters({
|
||||
gridApi,
|
||||
colDefs,
|
||||
setGridData: mockSetGridData,
|
||||
});
|
||||
|
||||
expect(hasActiveFilters.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should be true when filters are active', () => {
|
||||
const gridApi = ref(mockGridApi);
|
||||
const { onFilterChanged, hasActiveFilters } = useDataTableColumnFilters({
|
||||
gridApi,
|
||||
colDefs,
|
||||
setGridData: mockSetGridData,
|
||||
});
|
||||
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue({
|
||||
name: {
|
||||
filterType: 'text',
|
||||
type: 'contains',
|
||||
filter: 'john',
|
||||
},
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
expect(hasActiveFilters.value).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,868 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { computed, ref, type Ref } from 'vue';
|
||||
import { useAgGrid } from './useAgGrid';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import type {
|
||||
GridApi,
|
||||
GridReadyEvent,
|
||||
CellEditingStartedEvent,
|
||||
CellEditingStoppedEvent,
|
||||
CellKeyDownEvent,
|
||||
CellClickedEvent,
|
||||
SortChangedEvent,
|
||||
ColDef,
|
||||
Column,
|
||||
IRowNode,
|
||||
} from 'ag-grid-community';
|
||||
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: vi.fn((options) => {
|
||||
return {
|
||||
copy: vi.fn(async (text: string) => text),
|
||||
onPaste: options?.onPaste || vi.fn(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
onClickOutside: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useAgGrid', () => {
|
||||
let gridContainerRef: Ref<HTMLElement | null>;
|
||||
let mockGridApi: Partial<GridApi>;
|
||||
|
||||
beforeEach(() => {
|
||||
gridContainerRef = ref(document.createElement('div'));
|
||||
mockGridApi = {
|
||||
setGridOption: vi.fn(),
|
||||
getFocusedCell: vi.fn(),
|
||||
getEditingCells: vi.fn(() => []),
|
||||
getDisplayedRowAtIndex: vi.fn(),
|
||||
getRowNode: vi.fn(),
|
||||
getAllDisplayedColumns: vi.fn(() => []),
|
||||
ensureIndexVisible: vi.fn(),
|
||||
setFocusedCell: vi.fn(),
|
||||
startEditingCell: vi.fn(),
|
||||
isEditing: vi.fn(() => false),
|
||||
clearFocusedCell: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const createComposable = (options?: Partial<Parameters<typeof useAgGrid>[0]>) => {
|
||||
return useAgGrid({
|
||||
gridContainerRef,
|
||||
defaultSortColumn: 'id',
|
||||
pinnedBottomRowId: '__add_row__',
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const { currentSortBy, currentSortOrder, isTextEditorOpen } = createComposable();
|
||||
|
||||
expect(currentSortBy.value).toBe('id');
|
||||
expect(currentSortOrder.value).toBe('asc');
|
||||
expect(isTextEditorOpen.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw error when accessing gridApi before initialization', () => {
|
||||
const { gridApi } = createComposable();
|
||||
|
||||
expect(() => gridApi.value).toThrow('Grid API is not initialized');
|
||||
});
|
||||
});
|
||||
|
||||
describe('onGridReady', () => {
|
||||
it('should set grid API', () => {
|
||||
const { onGridReady, gridApi } = createComposable();
|
||||
const event = {
|
||||
api: mockGridApi as GridApi,
|
||||
} as GridReadyEvent;
|
||||
|
||||
onGridReady(event);
|
||||
|
||||
expect(() => gridApi.value).not.toThrow();
|
||||
});
|
||||
|
||||
it('should set popup parent to grid container', () => {
|
||||
const { onGridReady } = createComposable();
|
||||
const event = {
|
||||
api: mockGridApi as GridApi,
|
||||
} as GridReadyEvent;
|
||||
|
||||
onGridReady(event);
|
||||
|
||||
expect(mockGridApi.setGridOption).toHaveBeenCalledWith('popupParent', gridContainerRef.value);
|
||||
});
|
||||
|
||||
it('should set default column definition if provided', () => {
|
||||
const defaultColDef: ColDef = { sortable: true, filter: true };
|
||||
const { onGridReady } = createComposable({ defaultColDef });
|
||||
const event = {
|
||||
api: mockGridApi as GridApi,
|
||||
} as GridReadyEvent;
|
||||
|
||||
onGridReady(event);
|
||||
|
||||
expect(mockGridApi.setGridOption).toHaveBeenCalledWith('defaultColDef', defaultColDef);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setGridData', () => {
|
||||
it('should set column definitions', () => {
|
||||
const { onGridReady, setGridData } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const colDefs: ColDef[] = [{ field: 'name' }, { field: 'age' }];
|
||||
setGridData({ colDefs });
|
||||
|
||||
expect(mockGridApi.setGridOption).toHaveBeenCalledWith('columnDefs', colDefs);
|
||||
});
|
||||
|
||||
it('should set row data', () => {
|
||||
const { onGridReady, setGridData } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const rowData = [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
];
|
||||
setGridData({ rowData });
|
||||
|
||||
expect(mockGridApi.setGridOption).toHaveBeenCalledWith('rowData', rowData);
|
||||
});
|
||||
|
||||
it('should set pinned bottom row data', () => {
|
||||
const { onGridReady, setGridData } = createComposable({
|
||||
pinnedBottomRowId: '__add_row__',
|
||||
});
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
setGridData({ rowData: [] });
|
||||
|
||||
expect(mockGridApi.setGridOption).toHaveBeenCalledWith('pinnedBottomRowData', [
|
||||
{ id: '__add_row__' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not set pinned row if pinnedBottomRowId is undefined', () => {
|
||||
const { onGridReady, setGridData } = createComposable({
|
||||
pinnedBottomRowId: undefined,
|
||||
});
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
setGridData({ rowData: [] });
|
||||
|
||||
expect(mockGridApi.setGridOption).not.toHaveBeenCalledWith(
|
||||
'pinnedBottomRowData',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('focusFirstEditableCell', () => {
|
||||
it('should focus and start editing first editable cell', () => {
|
||||
vi.useFakeTimers();
|
||||
const { onGridReady, focusFirstEditableCell } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockRowNode = { rowIndex: 0 } as IRowNode;
|
||||
const mockColumn = {
|
||||
getColId: () => 'name',
|
||||
getColDef: () => ({ editable: true }),
|
||||
} as unknown as Column;
|
||||
|
||||
mockGridApi.getRowNode = vi.fn(() => mockRowNode);
|
||||
mockGridApi.getAllDisplayedColumns = vi.fn(() => [mockColumn]);
|
||||
|
||||
focusFirstEditableCell(1);
|
||||
|
||||
// Need to wait for requestAnimationFrame calls
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(mockGridApi.ensureIndexVisible).toHaveBeenCalledWith(0);
|
||||
expect(mockGridApi.setFocusedCell).toHaveBeenCalledWith(0, 'name');
|
||||
expect(mockGridApi.startEditingCell).toHaveBeenCalledWith({
|
||||
rowIndex: 0,
|
||||
colKey: 'name',
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should exclude specified column', () => {
|
||||
vi.useFakeTimers();
|
||||
const { onGridReady, focusFirstEditableCell } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockRowNode = { rowIndex: 0 } as IRowNode;
|
||||
const excludedColumn = {
|
||||
getColId: () => 'id',
|
||||
getColDef: () => ({ editable: true, colId: 'id' }),
|
||||
} as unknown as Column;
|
||||
const editableColumn = {
|
||||
getColId: () => 'name',
|
||||
getColDef: () => ({ editable: true, colId: 'name' }),
|
||||
} as unknown as Column;
|
||||
|
||||
mockGridApi.getRowNode = vi.fn(() => mockRowNode);
|
||||
mockGridApi.getAllDisplayedColumns = vi.fn(() => [excludedColumn, editableColumn]);
|
||||
|
||||
focusFirstEditableCell(1, 'id');
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(mockGridApi.setFocusedCell).toHaveBeenCalledWith(0, 'name');
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return early if row node not found', () => {
|
||||
const { onGridReady, focusFirstEditableCell } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
mockGridApi.getRowNode = vi.fn(() => undefined);
|
||||
|
||||
focusFirstEditableCell(999);
|
||||
|
||||
expect(mockGridApi.ensureIndexVisible).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early if no editable column found', () => {
|
||||
vi.useFakeTimers();
|
||||
const { onGridReady, focusFirstEditableCell } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockRowNode = { rowIndex: 0 } as IRowNode;
|
||||
const nonEditableColumn = {
|
||||
getColId: () => 'id',
|
||||
getColDef: () => ({ editable: false }),
|
||||
} as unknown as Column;
|
||||
|
||||
mockGridApi.getRowNode = vi.fn(() => mockRowNode);
|
||||
mockGridApi.getAllDisplayedColumns = vi.fn(() => [nonEditableColumn]);
|
||||
|
||||
focusFirstEditableCell(1);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(mockGridApi.startEditingCell).not.toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCellEditingStarted', () => {
|
||||
it('should set isTextEditorOpen to true for text cells', () => {
|
||||
const { onGridReady, onCellEditingStarted, isTextEditorOpen } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const event = {
|
||||
column: {
|
||||
getColDef: () => ({ cellDataType: 'text' }),
|
||||
},
|
||||
} as unknown as CellEditingStartedEvent;
|
||||
|
||||
onCellEditingStarted(event);
|
||||
|
||||
expect(isTextEditorOpen.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isTextEditorOpen to false for non-text cells', () => {
|
||||
const { onGridReady, onCellEditingStarted, isTextEditorOpen } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const event = {
|
||||
column: {
|
||||
getColDef: () => ({ cellDataType: 'number' }),
|
||||
},
|
||||
} as unknown as CellEditingStartedEvent;
|
||||
|
||||
onCellEditingStarted(event);
|
||||
|
||||
expect(isTextEditorOpen.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCellEditingStopped', () => {
|
||||
it('should set isTextEditorOpen to false for text cells', () => {
|
||||
const { onGridReady, onCellEditingStopped, isTextEditorOpen } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
isTextEditorOpen.value = true;
|
||||
|
||||
const event = {
|
||||
column: {
|
||||
getColDef: () => ({ cellDataType: 'text' }),
|
||||
},
|
||||
} as unknown as CellEditingStoppedEvent;
|
||||
|
||||
onCellEditingStopped(event);
|
||||
|
||||
expect(isTextEditorOpen.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleCopyFocusedCell', () => {
|
||||
it('should copy cell value to clipboard', async () => {
|
||||
const mockCopy = vi.fn();
|
||||
vi.mocked(useClipboard).mockReturnValue({
|
||||
copy: mockCopy,
|
||||
onPaste: ref(null),
|
||||
copied: computed(() => false),
|
||||
isSupported: ref(true),
|
||||
text: computed(() => ''),
|
||||
});
|
||||
|
||||
const { onGridReady, handleCopyFocusedCell } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColDef: () => ({ field: 'name' }),
|
||||
} as unknown as Column;
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => ({
|
||||
rowIndex: 0,
|
||||
column: mockColumn,
|
||||
rowPinned: null,
|
||||
}));
|
||||
mockGridApi.getDisplayedRowAtIndex = vi.fn(
|
||||
() =>
|
||||
({
|
||||
data: { name: 'John Doe' },
|
||||
}) as unknown as IRowNode,
|
||||
);
|
||||
|
||||
const event = {
|
||||
api: mockGridApi as GridApi,
|
||||
} as unknown as CellKeyDownEvent;
|
||||
|
||||
await handleCopyFocusedCell(event);
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('John Doe');
|
||||
});
|
||||
|
||||
it('should copy empty string for null values', async () => {
|
||||
const mockCopy = vi.fn();
|
||||
vi.mocked(useClipboard).mockReturnValue({
|
||||
copy: mockCopy,
|
||||
onPaste: ref(null),
|
||||
copied: computed(() => false),
|
||||
isSupported: ref(true),
|
||||
text: computed(() => ''),
|
||||
});
|
||||
|
||||
const { onGridReady, handleCopyFocusedCell } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColDef: () => ({ field: 'name' }),
|
||||
} as unknown as Column;
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => ({
|
||||
rowIndex: 0,
|
||||
column: mockColumn,
|
||||
rowPinned: null,
|
||||
}));
|
||||
mockGridApi.getDisplayedRowAtIndex = vi.fn(
|
||||
() =>
|
||||
({
|
||||
data: { name: null },
|
||||
}) as unknown as IRowNode,
|
||||
);
|
||||
|
||||
const event = {
|
||||
api: mockGridApi as GridApi,
|
||||
} as unknown as CellKeyDownEvent;
|
||||
|
||||
await handleCopyFocusedCell(event);
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('should not copy if no cell is focused', async () => {
|
||||
const mockCopy = vi.fn();
|
||||
vi.mocked(useClipboard).mockReturnValue({
|
||||
copy: mockCopy,
|
||||
onPaste: ref(null),
|
||||
copied: computed(() => false),
|
||||
isSupported: ref(true),
|
||||
text: computed(() => ''),
|
||||
});
|
||||
|
||||
const { onGridReady, handleCopyFocusedCell } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => null);
|
||||
|
||||
const event = {
|
||||
api: mockGridApi as GridApi,
|
||||
} as unknown as CellKeyDownEvent;
|
||||
|
||||
await handleCopyFocusedCell(event);
|
||||
|
||||
expect(mockCopy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onClipboardPaste', () => {
|
||||
it('should paste text data to text cell', () => {
|
||||
const { onGridReady } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'name',
|
||||
getColDef: () => ({ cellDataType: 'text' }),
|
||||
} as unknown as Column;
|
||||
|
||||
const mockRow = {
|
||||
setDataValue: vi.fn(),
|
||||
};
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => ({
|
||||
rowIndex: 0,
|
||||
column: mockColumn,
|
||||
rowPinned: null,
|
||||
}));
|
||||
mockGridApi.getEditingCells = vi.fn(() => []);
|
||||
mockGridApi.getDisplayedRowAtIndex = vi.fn(() => mockRow as unknown as IRowNode);
|
||||
|
||||
const mockUseClipboard = vi.mocked(useClipboard);
|
||||
const onPasteCallback =
|
||||
mockUseClipboard.mock.calls[mockUseClipboard.mock.calls.length - 1]?.[0]?.onPaste;
|
||||
onPasteCallback?.('Hello World');
|
||||
|
||||
expect(mockRow.setDataValue).toHaveBeenCalledWith('name', 'Hello World');
|
||||
});
|
||||
|
||||
it('should paste valid number to number cell', () => {
|
||||
const { onGridReady } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'age',
|
||||
getColDef: () => ({ cellDataType: 'number' }),
|
||||
} as unknown as Column;
|
||||
|
||||
const mockRow = {
|
||||
setDataValue: vi.fn(),
|
||||
};
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => ({
|
||||
rowIndex: 0,
|
||||
column: mockColumn,
|
||||
rowPinned: null,
|
||||
}));
|
||||
mockGridApi.getEditingCells = vi.fn(() => []);
|
||||
mockGridApi.getDisplayedRowAtIndex = vi.fn(() => mockRow as unknown as IRowNode);
|
||||
|
||||
const mockUseClipboard = vi.mocked(useClipboard);
|
||||
const onPasteCallback =
|
||||
mockUseClipboard.mock.calls[mockUseClipboard.mock.calls.length - 1]?.[0]?.onPaste;
|
||||
onPasteCallback?.('42');
|
||||
|
||||
expect(mockRow.setDataValue).toHaveBeenCalledWith('age', 42);
|
||||
});
|
||||
|
||||
it('should not paste invalid number to number cell', () => {
|
||||
const { onGridReady } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'age',
|
||||
getColDef: () => ({ cellDataType: 'number' }),
|
||||
} as unknown as Column;
|
||||
|
||||
const mockRow = {
|
||||
setDataValue: vi.fn(),
|
||||
};
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => ({
|
||||
rowIndex: 0,
|
||||
column: mockColumn,
|
||||
rowPinned: null,
|
||||
}));
|
||||
mockGridApi.getEditingCells = vi.fn(() => []);
|
||||
mockGridApi.getDisplayedRowAtIndex = vi.fn(() => mockRow as unknown as IRowNode);
|
||||
|
||||
const mockUseClipboard = vi.mocked(useClipboard);
|
||||
const onPasteCallback =
|
||||
mockUseClipboard.mock.calls[mockUseClipboard.mock.calls.length - 1]?.[0]?.onPaste;
|
||||
onPasteCallback?.('not a number');
|
||||
|
||||
expect(mockRow.setDataValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should paste valid date to date cell', () => {
|
||||
const { onGridReady } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'createdAt',
|
||||
getColDef: () => ({ cellDataType: 'date' }),
|
||||
} as unknown as Column;
|
||||
|
||||
const mockRow = {
|
||||
setDataValue: vi.fn(),
|
||||
};
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => ({
|
||||
rowIndex: 0,
|
||||
column: mockColumn,
|
||||
rowPinned: null,
|
||||
}));
|
||||
mockGridApi.getEditingCells = vi.fn(() => []);
|
||||
mockGridApi.getDisplayedRowAtIndex = vi.fn(() => mockRow as unknown as IRowNode);
|
||||
|
||||
const mockUseClipboard = vi.mocked(useClipboard);
|
||||
const onPasteCallback =
|
||||
mockUseClipboard.mock.calls[mockUseClipboard.mock.calls.length - 1]?.[0]?.onPaste;
|
||||
onPasteCallback?.('2024-01-15');
|
||||
|
||||
expect(mockRow.setDataValue).toHaveBeenCalledWith('createdAt', new Date('2024-01-15'));
|
||||
});
|
||||
|
||||
it('should not paste invalid date to date cell', () => {
|
||||
const { onGridReady } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'createdAt',
|
||||
getColDef: () => ({ cellDataType: 'date' }),
|
||||
} as unknown as Column;
|
||||
|
||||
const mockRow = {
|
||||
setDataValue: vi.fn(),
|
||||
};
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => ({
|
||||
rowIndex: 0,
|
||||
column: mockColumn,
|
||||
rowPinned: null,
|
||||
}));
|
||||
mockGridApi.getEditingCells = vi.fn(() => []);
|
||||
mockGridApi.getDisplayedRowAtIndex = vi.fn(() => mockRow as unknown as IRowNode);
|
||||
|
||||
const mockUseClipboard = vi.mocked(useClipboard);
|
||||
const onPasteCallback =
|
||||
mockUseClipboard.mock.calls[mockUseClipboard.mock.calls.length - 1]?.[0]?.onPaste;
|
||||
onPasteCallback?.('not a date');
|
||||
|
||||
expect(mockRow.setDataValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should paste true to boolean cell', () => {
|
||||
const { onGridReady } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'active',
|
||||
getColDef: () => ({ cellDataType: 'boolean' }),
|
||||
} as unknown as Column;
|
||||
|
||||
const mockRow = {
|
||||
setDataValue: vi.fn(),
|
||||
};
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => ({
|
||||
rowIndex: 0,
|
||||
column: mockColumn,
|
||||
rowPinned: null,
|
||||
}));
|
||||
mockGridApi.getEditingCells = vi.fn(() => []);
|
||||
mockGridApi.getDisplayedRowAtIndex = vi.fn(() => mockRow as unknown as IRowNode);
|
||||
|
||||
const mockUseClipboard = vi.mocked(useClipboard);
|
||||
const onPasteCallback =
|
||||
mockUseClipboard.mock.calls[mockUseClipboard.mock.calls.length - 1]?.[0]?.onPaste;
|
||||
onPasteCallback?.('true');
|
||||
|
||||
expect(mockRow.setDataValue).toHaveBeenCalledWith('active', true);
|
||||
});
|
||||
|
||||
it('should paste false to boolean cell', () => {
|
||||
const { onGridReady } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'active',
|
||||
getColDef: () => ({ cellDataType: 'boolean' }),
|
||||
} as unknown as Column;
|
||||
|
||||
const mockRow = {
|
||||
setDataValue: vi.fn(),
|
||||
};
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => ({
|
||||
rowIndex: 0,
|
||||
column: mockColumn,
|
||||
rowPinned: null,
|
||||
}));
|
||||
mockGridApi.getEditingCells = vi.fn(() => []);
|
||||
mockGridApi.getDisplayedRowAtIndex = vi.fn(() => mockRow as unknown as IRowNode);
|
||||
|
||||
const mockUseClipboard = vi.mocked(useClipboard);
|
||||
const onPasteCallback =
|
||||
mockUseClipboard.mock.calls[mockUseClipboard.mock.calls.length - 1]?.[0]?.onPaste;
|
||||
onPasteCallback?.('false');
|
||||
|
||||
expect(mockRow.setDataValue).toHaveBeenCalledWith('active', false);
|
||||
});
|
||||
|
||||
it('should not paste invalid boolean to boolean cell', () => {
|
||||
const { onGridReady } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'active',
|
||||
getColDef: () => ({ cellDataType: 'boolean' }),
|
||||
} as unknown as Column;
|
||||
|
||||
const mockRow = {
|
||||
setDataValue: vi.fn(),
|
||||
};
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => ({
|
||||
rowIndex: 0,
|
||||
column: mockColumn,
|
||||
rowPinned: null,
|
||||
}));
|
||||
mockGridApi.getEditingCells = vi.fn(() => []);
|
||||
mockGridApi.getDisplayedRowAtIndex = vi.fn(() => mockRow as unknown as IRowNode);
|
||||
|
||||
const mockUseClipboard = vi.mocked(useClipboard);
|
||||
const onPasteCallback =
|
||||
mockUseClipboard.mock.calls[mockUseClipboard.mock.calls.length - 1]?.[0]?.onPaste;
|
||||
onPasteCallback?.('yes');
|
||||
|
||||
expect(mockRow.setDataValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not paste when no cell is focused', () => {
|
||||
const { onGridReady } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockRow = {
|
||||
setDataValue: vi.fn(),
|
||||
};
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => null);
|
||||
mockGridApi.getEditingCells = vi.fn(() => []);
|
||||
mockGridApi.getDisplayedRowAtIndex = vi.fn(() => mockRow as unknown as IRowNode);
|
||||
|
||||
const mockUseClipboard = vi.mocked(useClipboard);
|
||||
const onPasteCallback =
|
||||
mockUseClipboard.mock.calls[mockUseClipboard.mock.calls.length - 1]?.[0]?.onPaste;
|
||||
onPasteCallback?.('some data');
|
||||
|
||||
expect(mockRow.setDataValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not paste when cell is being edited', () => {
|
||||
const { onGridReady } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'name',
|
||||
getColDef: () => ({ cellDataType: 'text' }),
|
||||
} as unknown as Column;
|
||||
|
||||
const mockRow = {
|
||||
setDataValue: vi.fn(),
|
||||
};
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => ({
|
||||
rowIndex: 0,
|
||||
column: mockColumn,
|
||||
rowPinned: null,
|
||||
}));
|
||||
mockGridApi.getEditingCells = vi.fn(() => [{ rowIndex: 0, colId: 'name', rowPinned: null }]);
|
||||
mockGridApi.getDisplayedRowAtIndex = vi.fn(() => mockRow as unknown as IRowNode);
|
||||
|
||||
const mockUseClipboard = vi.mocked(useClipboard);
|
||||
const onPasteCallback =
|
||||
mockUseClipboard.mock.calls[mockUseClipboard.mock.calls.length - 1]?.[0]?.onPaste;
|
||||
onPasteCallback?.('some data');
|
||||
|
||||
expect(mockRow.setDataValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not paste when row is not found', () => {
|
||||
const { onGridReady } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'name',
|
||||
getColDef: () => ({ cellDataType: 'text' }),
|
||||
} as unknown as Column;
|
||||
|
||||
mockGridApi.getFocusedCell = vi.fn(() => ({
|
||||
rowIndex: 0,
|
||||
column: mockColumn,
|
||||
rowPinned: null,
|
||||
}));
|
||||
mockGridApi.getEditingCells = vi.fn(() => []);
|
||||
mockGridApi.getDisplayedRowAtIndex = vi.fn(() => undefined);
|
||||
|
||||
const mockUseClipboard = vi.mocked(useClipboard);
|
||||
const onPasteCallback =
|
||||
mockUseClipboard.mock.calls[mockUseClipboard.mock.calls.length - 1]?.[0]?.onPaste;
|
||||
onPasteCallback?.('some data');
|
||||
|
||||
// No assertion needed, just ensuring no error is thrown
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCellClicked', () => {
|
||||
it('should start editing on second click of same cell', () => {
|
||||
const { onGridReady, onCellClicked } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'name',
|
||||
getColDef: () => ({ editable: true }),
|
||||
} as unknown as Column;
|
||||
|
||||
const click = {
|
||||
api: mockGridApi as GridApi,
|
||||
column: mockColumn,
|
||||
rowIndex: 0,
|
||||
} as unknown as CellClickedEvent;
|
||||
|
||||
onCellClicked(click);
|
||||
expect(mockGridApi.startEditingCell).not.toHaveBeenCalled();
|
||||
|
||||
onCellClicked(click);
|
||||
expect(mockGridApi.startEditingCell).toHaveBeenCalledWith({
|
||||
rowIndex: 0,
|
||||
colKey: 'name',
|
||||
});
|
||||
});
|
||||
|
||||
it('should not start editing if cell is not editable', () => {
|
||||
const { onGridReady, onCellClicked } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'id',
|
||||
getColDef: () => ({ editable: false }),
|
||||
} as unknown as Column;
|
||||
|
||||
const event = {
|
||||
api: mockGridApi as GridApi,
|
||||
column: mockColumn,
|
||||
rowIndex: 0,
|
||||
} as unknown as CellClickedEvent;
|
||||
|
||||
onCellClicked(event);
|
||||
onCellClicked(event);
|
||||
|
||||
expect(mockGridApi.startEditingCell).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not start editing if cell is already being edited', () => {
|
||||
const { onGridReady, onCellClicked } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
mockGridApi.isEditing = vi.fn(() => true);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'name',
|
||||
getColDef: () => ({ editable: true }),
|
||||
} as unknown as Column;
|
||||
|
||||
const event = {
|
||||
api: mockGridApi as GridApi,
|
||||
column: mockColumn,
|
||||
rowIndex: 0,
|
||||
} as unknown as CellClickedEvent;
|
||||
|
||||
onCellClicked(event);
|
||||
|
||||
expect(mockGridApi.startEditingCell).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetLastFocusedCell', () => {
|
||||
it('should clear last focused cell', () => {
|
||||
const { onGridReady, onCellClicked, resetLastFocusedCell } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'name',
|
||||
getColDef: () => ({ editable: true }),
|
||||
} as unknown as Column;
|
||||
|
||||
const event = {
|
||||
api: mockGridApi as GridApi,
|
||||
column: mockColumn,
|
||||
rowIndex: 0,
|
||||
} as unknown as CellClickedEvent;
|
||||
|
||||
onCellClicked(event);
|
||||
resetLastFocusedCell();
|
||||
onCellClicked(event);
|
||||
|
||||
expect(mockGridApi.startEditingCell).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onSortChanged', () => {
|
||||
it('should update sort state when column is sorted', () => {
|
||||
const { onGridReady, onSortChanged, currentSortBy, currentSortOrder } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'col1',
|
||||
getSort: () => 'desc' as const,
|
||||
} as unknown as Column;
|
||||
|
||||
const colDefs: ColDef[] = [{ colId: 'col1', field: 'name' }];
|
||||
|
||||
const event = {
|
||||
columns: [mockColumn],
|
||||
} as unknown as SortChangedEvent;
|
||||
|
||||
onSortChanged(event, colDefs);
|
||||
|
||||
expect(currentSortBy.value).toBe('name');
|
||||
expect(currentSortOrder.value).toBe('desc');
|
||||
});
|
||||
|
||||
it('should use colId if field is not defined', () => {
|
||||
const { onGridReady, onSortChanged, currentSortBy } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const mockColumn = {
|
||||
getColId: () => 'col1',
|
||||
getSort: () => 'asc' as const,
|
||||
} as unknown as Column;
|
||||
|
||||
const colDefs: ColDef[] = [{ colId: 'col1' }];
|
||||
|
||||
const event = {
|
||||
columns: [mockColumn],
|
||||
} as unknown as SortChangedEvent;
|
||||
|
||||
onSortChanged(event, colDefs);
|
||||
|
||||
expect(currentSortBy.value).toBe('col1');
|
||||
});
|
||||
|
||||
it('should reset to default sort when no column is sorted', () => {
|
||||
const { onGridReady, onSortChanged, currentSortBy, currentSortOrder } = createComposable();
|
||||
onGridReady({ api: mockGridApi as GridApi } as GridReadyEvent);
|
||||
|
||||
const event = {
|
||||
columns: [],
|
||||
} as unknown as SortChangedEvent;
|
||||
|
||||
onSortChanged(event, []);
|
||||
|
||||
expect(currentSortBy.value).toBe('id');
|
||||
expect(currentSortOrder.value).toBe('asc');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { computed, ref, type Ref } from 'vue';
|
||||
import type {
|
||||
CellClickedEvent,
|
||||
CellEditingStartedEvent,
|
||||
CellEditingStoppedEvent,
|
||||
CellKeyDownEvent,
|
||||
GridApi,
|
||||
GridReadyEvent,
|
||||
SortChangedEvent,
|
||||
SortDirection,
|
||||
ColDef,
|
||||
} from 'ag-grid-community';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
|
||||
export type UseAgGridOptions = {
|
||||
gridContainerRef: Ref<HTMLElement | null>;
|
||||
defaultSortColumn: string;
|
||||
pinnedBottomRowId?: string | number;
|
||||
defaultColDef?: ColDef;
|
||||
};
|
||||
|
||||
export const useAgGrid = <TRowData extends Record<string, unknown> = Record<string, unknown>>({
|
||||
gridContainerRef,
|
||||
defaultSortColumn,
|
||||
pinnedBottomRowId,
|
||||
defaultColDef,
|
||||
}: UseAgGridOptions) => {
|
||||
const gridApi = ref<GridApi | null>(null);
|
||||
const isTextEditorOpen = ref(false);
|
||||
const currentSortBy = ref<string>(defaultSortColumn);
|
||||
const currentSortOrder = ref<SortDirection>('asc');
|
||||
|
||||
const initializedGridApi = computed(() => {
|
||||
if (!gridApi.value) {
|
||||
throw new Error('Grid API is not initialized');
|
||||
}
|
||||
return gridApi.value;
|
||||
});
|
||||
|
||||
const onClipboardPaste = (data: string) => {
|
||||
if (!gridApi.value) return;
|
||||
const focusedCell = initializedGridApi.value.getFocusedCell();
|
||||
const isEditing = initializedGridApi.value.getEditingCells().length > 0;
|
||||
if (!focusedCell || isEditing) return;
|
||||
const row = initializedGridApi.value.getDisplayedRowAtIndex(focusedCell.rowIndex);
|
||||
if (!row) return;
|
||||
|
||||
const colDef = focusedCell.column.getColDef();
|
||||
if (colDef.cellDataType === 'text') {
|
||||
row.setDataValue(focusedCell.column.getColId(), data);
|
||||
} else if (colDef.cellDataType === 'number') {
|
||||
if (!Number.isNaN(Number(data))) {
|
||||
row.setDataValue(focusedCell.column.getColId(), Number(data));
|
||||
}
|
||||
} else if (colDef.cellDataType === 'date') {
|
||||
if (!Number.isNaN(Date.parse(data))) {
|
||||
row.setDataValue(focusedCell.column.getColId(), new Date(data));
|
||||
}
|
||||
} else if (colDef.cellDataType === 'boolean') {
|
||||
if (data.toLowerCase() === 'true') {
|
||||
row.setDataValue(focusedCell.column.getColId(), true);
|
||||
} else if (data.toLowerCase() === 'false') {
|
||||
row.setDataValue(focusedCell.column.getColId(), false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { copy: copyToClipboard } = useClipboard({
|
||||
onPaste: onClipboardPaste,
|
||||
});
|
||||
|
||||
// Track the last focused cell so we can start editing when users click on it
|
||||
// AG Grid doesn't provide cell blur event so we need to reset this manually
|
||||
const lastFocusedCell = ref<{ rowIndex: number; colId: string } | null>(null);
|
||||
|
||||
const onGridReady = (params: GridReadyEvent) => {
|
||||
gridApi.value = params.api;
|
||||
// Ensure popups (e.g., agLargeTextCellEditor) are positioned relative to the grid container
|
||||
// to avoid misalignment when the page scrolls.
|
||||
if (gridContainerRef.value) {
|
||||
params.api.setGridOption('popupParent', gridContainerRef.value);
|
||||
}
|
||||
if (defaultColDef) {
|
||||
params.api.setGridOption('defaultColDef', defaultColDef);
|
||||
}
|
||||
};
|
||||
|
||||
const setGridData = ({
|
||||
colDefs,
|
||||
rowData,
|
||||
}: {
|
||||
colDefs?: ColDef[];
|
||||
rowData?: TRowData[];
|
||||
}) => {
|
||||
if (colDefs) {
|
||||
initializedGridApi.value.setGridOption('columnDefs', colDefs);
|
||||
}
|
||||
|
||||
if (rowData) {
|
||||
initializedGridApi.value.setGridOption('rowData', rowData);
|
||||
}
|
||||
|
||||
if (pinnedBottomRowId !== undefined) {
|
||||
initializedGridApi.value.setGridOption('pinnedBottomRowData', [{ id: pinnedBottomRowId }]);
|
||||
}
|
||||
};
|
||||
|
||||
const focusFirstEditableCell = (rowId: number, excludeColumnId?: string) => {
|
||||
const rowNode = initializedGridApi.value.getRowNode(String(rowId));
|
||||
if (rowNode?.rowIndex === null || rowNode?.rowIndex === undefined) return;
|
||||
const rowIndex = rowNode.rowIndex;
|
||||
|
||||
const displayed = initializedGridApi.value.getAllDisplayedColumns();
|
||||
const firstEditable = displayed.find((col) => {
|
||||
const def = col.getColDef();
|
||||
if (!def) return false;
|
||||
if (excludeColumnId && def.colId === excludeColumnId) return false;
|
||||
return !!def.editable;
|
||||
});
|
||||
if (!firstEditable) return;
|
||||
const columnId = firstEditable.getColId();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
initializedGridApi.value.ensureIndexVisible(rowIndex);
|
||||
requestAnimationFrame(() => {
|
||||
initializedGridApi.value.setFocusedCell(rowIndex, columnId);
|
||||
initializedGridApi.value.startEditingCell({
|
||||
rowIndex,
|
||||
colKey: columnId,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onCellEditingStarted = (params: CellEditingStartedEvent<TRowData>) => {
|
||||
if (params.column.getColDef().cellDataType === 'text') {
|
||||
isTextEditorOpen.value = true;
|
||||
} else {
|
||||
isTextEditorOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onCellEditingStopped = (params: CellEditingStoppedEvent<TRowData>) => {
|
||||
if (params.column.getColDef().cellDataType === 'text') {
|
||||
isTextEditorOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyFocusedCell = async (params: CellKeyDownEvent<TRowData>) => {
|
||||
const focused = params.api.getFocusedCell();
|
||||
if (!focused) {
|
||||
return;
|
||||
}
|
||||
const row = params.api.getDisplayedRowAtIndex(focused.rowIndex);
|
||||
const colDef = focused.column.getColDef();
|
||||
if (row?.data && colDef.field) {
|
||||
const rawValue = row.data[colDef.field];
|
||||
const text = rawValue === null || rawValue === undefined ? '' : String(rawValue);
|
||||
await copyToClipboard(text);
|
||||
}
|
||||
};
|
||||
|
||||
const onCellClicked = (params: CellClickedEvent<TRowData>) => {
|
||||
const clickedCellColumn = params.column.getColId();
|
||||
const clickedCellRow = params.rowIndex;
|
||||
|
||||
if (
|
||||
clickedCellRow === null ||
|
||||
params.api.isEditing({
|
||||
rowIndex: clickedCellRow,
|
||||
column: params.column,
|
||||
rowPinned: null,
|
||||
})
|
||||
)
|
||||
return;
|
||||
|
||||
// Check if this is the same cell that was focused before this click
|
||||
const wasAlreadyFocused =
|
||||
lastFocusedCell.value &&
|
||||
lastFocusedCell.value.rowIndex === clickedCellRow &&
|
||||
lastFocusedCell.value.colId === clickedCellColumn;
|
||||
|
||||
if (wasAlreadyFocused && params.column.getColDef()?.editable) {
|
||||
// Cell was already selected, start editing
|
||||
params.api.startEditingCell({
|
||||
rowIndex: clickedCellRow,
|
||||
colKey: clickedCellColumn,
|
||||
});
|
||||
}
|
||||
|
||||
// Update the last focused cell for next click
|
||||
lastFocusedCell.value = {
|
||||
rowIndex: clickedCellRow,
|
||||
colId: clickedCellColumn,
|
||||
};
|
||||
};
|
||||
|
||||
const resetLastFocusedCell = () => {
|
||||
lastFocusedCell.value = null;
|
||||
};
|
||||
|
||||
const onSortChanged = (event: SortChangedEvent, colDefs: ColDef[]) => {
|
||||
const sortedColumn = event.columns?.filter((col) => col.getSort() !== null).pop() ?? null;
|
||||
|
||||
if (sortedColumn) {
|
||||
const colId = sortedColumn.getColId();
|
||||
const columnDef = colDefs.find((col) => col.colId === colId);
|
||||
|
||||
currentSortBy.value = columnDef?.field ?? colId;
|
||||
currentSortOrder.value = sortedColumn.getSort() ?? 'asc';
|
||||
} else {
|
||||
currentSortBy.value = defaultSortColumn;
|
||||
currentSortOrder.value = 'asc';
|
||||
}
|
||||
};
|
||||
|
||||
onClickOutside(gridContainerRef, () => {
|
||||
resetLastFocusedCell();
|
||||
initializedGridApi.value.clearFocusedCell();
|
||||
});
|
||||
|
||||
return {
|
||||
gridApi: initializedGridApi,
|
||||
onGridReady,
|
||||
setGridData,
|
||||
focusFirstEditableCell,
|
||||
onCellEditingStarted,
|
||||
onCellEditingStopped,
|
||||
handleCopyFocusedCell,
|
||||
onCellClicked,
|
||||
resetLastFocusedCell,
|
||||
currentSortBy,
|
||||
currentSortOrder,
|
||||
onSortChanged,
|
||||
isTextEditorOpen,
|
||||
};
|
||||
};
|
||||
+493
@@ -0,0 +1,493 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ref, type Ref } from 'vue';
|
||||
import type { GridApi, ColDef } from 'ag-grid-community';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
import { useDataTableColumnFilters } from './useDataTableColumnFilters';
|
||||
import type { FilterModel, BackendFilter } from '../types/dataTableFilters.types';
|
||||
|
||||
describe('useDataTableColumnFilters', () => {
|
||||
let mockGridApi: Partial<GridApi>;
|
||||
let gridApiRef: Ref<GridApi>;
|
||||
let colDefsRef: Ref<ColDef[]>;
|
||||
|
||||
const parseFilterJSON = (json: string | undefined): BackendFilter | undefined => {
|
||||
if (!json) return undefined;
|
||||
return jsonParse<BackendFilter>(json);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockGridApi = {
|
||||
getFilterModel: vi.fn().mockReturnValue({}),
|
||||
};
|
||||
gridApiRef = ref(mockGridApi as GridApi);
|
||||
colDefsRef = ref([
|
||||
{ field: 'name', colId: 'name' },
|
||||
{ field: 'age', colId: 'age' },
|
||||
{ field: 'createdAt', colId: 'createdAt' },
|
||||
]);
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('should initialize with undefined filter', () => {
|
||||
const { currentFilterJSON, hasActiveFilters } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
expect(currentFilterJSON.value).toBeUndefined();
|
||||
expect(hasActiveFilters.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text filters', () => {
|
||||
it('should process contains filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
name: { filterType: 'text', type: 'contains', filter: 'test' },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result).toEqual({
|
||||
type: 'and',
|
||||
filters: [{ columnName: 'name', condition: 'ilike', value: 'test' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should process startsWith filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
name: { filterType: 'text', type: 'startsWith', filter: 'test' },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters[0]).toEqual({
|
||||
columnName: 'name',
|
||||
condition: 'ilike',
|
||||
value: 'test%',
|
||||
});
|
||||
});
|
||||
|
||||
it('should process endsWith filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
name: { filterType: 'text', type: 'endsWith', filter: 'test' },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters[0]).toEqual({
|
||||
columnName: 'name',
|
||||
condition: 'ilike',
|
||||
value: '%test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should process equals filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
name: { filterType: 'text', type: 'equals', filter: 'exact' },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters[0]).toEqual({
|
||||
columnName: 'name',
|
||||
condition: 'eq',
|
||||
value: 'exact',
|
||||
});
|
||||
});
|
||||
|
||||
it('should process notEqual filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
name: { filterType: 'text', type: 'notEqual', filter: 'test' },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters[0]).toEqual({
|
||||
columnName: 'name',
|
||||
condition: 'neq',
|
||||
value: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should process isEmpty filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
name: { filterType: 'text', type: 'isEmpty' },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters[0]).toEqual({ columnName: 'name', condition: 'eq', value: '' });
|
||||
});
|
||||
|
||||
it('should process notEmpty filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
name: { filterType: 'text', type: 'notEmpty' },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters[0]).toEqual({ columnName: 'name', condition: 'neq', value: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('number filters', () => {
|
||||
it('should process equals filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
age: { filterType: 'number', type: 'equals', filter: 25 },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters[0]).toEqual({ columnName: 'age', condition: 'eq', value: 25 });
|
||||
});
|
||||
|
||||
it('should process lessThan filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
age: { filterType: 'number', type: 'lessThan', filter: 30 },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters[0]).toEqual({ columnName: 'age', condition: 'lt', value: 30 });
|
||||
});
|
||||
|
||||
it('should process greaterThan filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
age: { filterType: 'number', type: 'greaterThan', filter: 20 },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters[0]).toEqual({ columnName: 'age', condition: 'gt', value: 20 });
|
||||
});
|
||||
|
||||
it('should process between filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
age: { filterType: 'number', type: 'between', filter: 20, filterTo: 30 },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters).toEqual([
|
||||
{ columnName: 'age', condition: 'gte', value: 20 },
|
||||
{ columnName: 'age', condition: 'lte', value: 30 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('date filters', () => {
|
||||
it('should process equals filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
createdAt: {
|
||||
filterType: 'date',
|
||||
type: 'equals',
|
||||
dateFrom: '2024-01-01',
|
||||
},
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters[0]).toEqual({
|
||||
columnName: 'createdAt',
|
||||
condition: 'eq',
|
||||
value: new Date('2024-01-01').toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should process inRange filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
createdAt: {
|
||||
filterType: 'date',
|
||||
type: 'inRange',
|
||||
dateFrom: '2024-01-01',
|
||||
dateTo: '2024-12-31',
|
||||
},
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters).toEqual([
|
||||
{ columnName: 'createdAt', condition: 'gte', value: new Date('2024-01-01').toISOString() },
|
||||
{ columnName: 'createdAt', condition: 'lte', value: new Date('2024-12-31').toISOString() },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should process greaterThan filter', () => {
|
||||
const filterModel: FilterModel = {
|
||||
createdAt: {
|
||||
filterType: 'date',
|
||||
type: 'greaterThan',
|
||||
dateFrom: '2024-01-01',
|
||||
},
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters[0]).toEqual({
|
||||
columnName: 'createdAt',
|
||||
condition: 'gt',
|
||||
value: new Date('2024-01-01').toISOString(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple filters', () => {
|
||||
it('should process multiple filters from different columns', () => {
|
||||
const filterModel: FilterModel = {
|
||||
name: { filterType: 'text', type: 'contains', filter: 'john' },
|
||||
age: { filterType: 'number', type: 'greaterThan', filter: 25 },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.type).toBe('and');
|
||||
expect(result?.filters).toHaveLength(2);
|
||||
expect(result?.filters).toContainEqual({
|
||||
columnName: 'name',
|
||||
condition: 'ilike',
|
||||
value: 'john',
|
||||
});
|
||||
expect(result?.filters).toContainEqual({ columnName: 'age', condition: 'gt', value: 25 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('colId to field mapping', () => {
|
||||
it('should map colId to field correctly', () => {
|
||||
colDefsRef.value = [{ field: 'userName', colId: 'user_name_col' }];
|
||||
const filterModel: FilterModel = {
|
||||
user_name_col: { filterType: 'text', type: 'contains', filter: 'test' },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters[0].columnName).toBe('userName');
|
||||
});
|
||||
|
||||
it('should use field as colId if colId is not provided', () => {
|
||||
colDefsRef.value = [{ field: 'name' }];
|
||||
const filterModel: FilterModel = {
|
||||
name: { filterType: 'text', type: 'contains', filter: 'test' },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
const result = parseFilterJSON(currentFilterJSON.value);
|
||||
expect(result?.filters[0].columnName).toBe('name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasActiveFilters', () => {
|
||||
it('should return false when no filters are active', () => {
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue({});
|
||||
|
||||
const { onFilterChanged, hasActiveFilters } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
expect(hasActiveFilters.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when filters are active', () => {
|
||||
const filterModel: FilterModel = {
|
||||
name: { filterType: 'text', type: 'contains', filter: 'test' },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, hasActiveFilters } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
expect(hasActiveFilters.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should update when filters are cleared', () => {
|
||||
const filterModel: FilterModel = {
|
||||
name: { filterType: 'text', type: 'contains', filter: 'test' },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, hasActiveFilters } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
expect(hasActiveFilters.value).toBe(true);
|
||||
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue({});
|
||||
onFilterChanged();
|
||||
expect(hasActiveFilters.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return undefined when filter model is empty', () => {
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue({});
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
expect(currentFilterJSON.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip filters without filterType', () => {
|
||||
const filterModel: FilterModel = {
|
||||
name: { type: 'contains', filter: 'test' },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
expect(currentFilterJSON.value).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle unknown filter types gracefully', () => {
|
||||
const filterModel: FilterModel = {
|
||||
name: { filterType: 'unknown' as 'text', type: 'contains', filter: 'test' },
|
||||
};
|
||||
mockGridApi.getFilterModel = vi.fn().mockReturnValue(filterModel);
|
||||
|
||||
const { onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({
|
||||
gridApi: gridApiRef,
|
||||
colDefs: colDefsRef,
|
||||
});
|
||||
|
||||
onFilterChanged();
|
||||
|
||||
expect(currentFilterJSON.value).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
-23
@@ -1,12 +1,10 @@
|
||||
import { computed, ref, type Ref } from 'vue';
|
||||
import type { ColDef, GridApi } from 'ag-grid-community';
|
||||
import type { DataTableRow } from '@/features/dataTable/dataTable.types';
|
||||
import type {
|
||||
BackendFilter,
|
||||
BackendFilterRecord,
|
||||
FilterModel,
|
||||
} from '../types/dataTableFilters.types';
|
||||
import { GRID_FILTER_CONFIG, isSpecialColumn } from '../utils/filterMappings';
|
||||
import {
|
||||
processTextFilter,
|
||||
processNumberFilter,
|
||||
@@ -16,34 +14,14 @@ import {
|
||||
export type UseDataTableColumnFiltersParams = {
|
||||
gridApi: Ref<GridApi>;
|
||||
colDefs: Ref<ColDef[]>;
|
||||
setGridData: (params: {
|
||||
rowData?: DataTableRow[];
|
||||
colDefs?: ColDef[];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export const useDataTableColumnFilters = ({
|
||||
gridApi,
|
||||
colDefs,
|
||||
setGridData,
|
||||
}: UseDataTableColumnFiltersParams) => {
|
||||
const currentFilterJSON = ref<string | undefined>(undefined);
|
||||
|
||||
const initializeFilters = () => {
|
||||
gridApi.value.setGridOption('defaultColDef', GRID_FILTER_CONFIG.defaultColDef);
|
||||
|
||||
const updated = colDefs.value.map((def) => {
|
||||
const colId = def.colId ?? def.field;
|
||||
if (!colId) return def;
|
||||
if (isSpecialColumn(colId)) {
|
||||
return { ...def, filter: false };
|
||||
}
|
||||
return def;
|
||||
});
|
||||
colDefs.value = updated;
|
||||
setGridData({ colDefs: updated });
|
||||
};
|
||||
|
||||
function convertAgModelToBackend(model: FilterModel, defs: ColDef[]): BackendFilter | undefined {
|
||||
const allFilters: BackendFilterRecord[] = [];
|
||||
|
||||
@@ -86,7 +64,6 @@ export const useDataTableColumnFilters = ({
|
||||
const hasActiveFilters = computed(() => Boolean(currentFilterJSON.value));
|
||||
|
||||
return {
|
||||
initializeFilters,
|
||||
onFilterChanged,
|
||||
currentFilterJSON,
|
||||
hasActiveFilters,
|
||||
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useDataTableColumns } from './useDataTableColumns';
|
||||
import type { DataTableColumn } from '@/features/dataTable/dataTable.types';
|
||||
|
||||
vi.mock('@/features/dataTable/composables/useDataTableTypes', () => ({
|
||||
useDataTableTypes: () => ({
|
||||
mapToAGCellType: (type: string) => (type === 'string' ? 'text' : type),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: () => ({
|
||||
baseText: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/dataTable/components/dataGrid/ColumnHeader.vue', () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/dataTable/components/dataGrid/ElDatePickerCellEditor.vue', () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/dataTable/components/dataGrid/ElDatePickerFilter.vue', () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/dataTable/components/dataGrid/AddColumnButton.vue', () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/dataTable/components/dataGrid/AddRowButton.vue', () => ({
|
||||
default: {},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/dataTable/utils/columnUtils', () => ({
|
||||
getCellClass: vi.fn(),
|
||||
createValueGetter: vi.fn(),
|
||||
createCellRendererSelector: vi.fn(),
|
||||
createStringValueSetter: vi.fn(),
|
||||
stringCellEditorParams: {},
|
||||
dateValueFormatter: vi.fn(),
|
||||
numberValueFormatter: vi.fn(),
|
||||
getStringColumnFilterOptions: vi.fn(() => []),
|
||||
getDateColumnFilterOptions: vi.fn(() => []),
|
||||
getNumberColumnFilterOptions: vi.fn(() => []),
|
||||
getBooleanColumnFilterOptions: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
describe('useDataTableColumns', () => {
|
||||
const mockOnDeleteColumn = vi.fn();
|
||||
const mockOnAddRowClick = vi.fn();
|
||||
const mockOnAddColumn = vi.fn();
|
||||
const isTextEditorOpen = ref(false);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const createComposable = () => {
|
||||
return useDataTableColumns({
|
||||
onDeleteColumn: mockOnDeleteColumn,
|
||||
onAddRowClick: mockOnAddRowClick,
|
||||
onAddColumn: mockOnAddColumn,
|
||||
isTextEditorOpen,
|
||||
});
|
||||
};
|
||||
|
||||
describe('createColumnDef', () => {
|
||||
it('should create basic column definition', () => {
|
||||
const { createColumnDef } = createComposable();
|
||||
const column: DataTableColumn = {
|
||||
id: 'col1',
|
||||
name: 'Column 1',
|
||||
type: 'string',
|
||||
index: 0,
|
||||
};
|
||||
|
||||
const colDef = createColumnDef(column);
|
||||
|
||||
expect(colDef.colId).toBe('col1');
|
||||
expect(colDef.field).toBe('Column 1');
|
||||
expect(colDef.headerName).toBe('Column 1');
|
||||
expect(colDef.sortable).toBe(true);
|
||||
expect(colDef.resizable).toBe(true);
|
||||
});
|
||||
|
||||
it('should create string column with text editor', () => {
|
||||
const { createColumnDef } = createComposable();
|
||||
const column: DataTableColumn = {
|
||||
id: 'col1',
|
||||
name: 'Text Column',
|
||||
type: 'string',
|
||||
index: 0,
|
||||
};
|
||||
|
||||
const colDef = createColumnDef(column);
|
||||
|
||||
expect(colDef.cellEditor).toBe('agLargeTextCellEditor');
|
||||
expect(colDef.cellEditorPopup).toBe(true);
|
||||
expect(colDef.cellEditorPopupPosition).toBe('over');
|
||||
});
|
||||
|
||||
it('should create date column with custom editor', () => {
|
||||
const { createColumnDef } = createComposable();
|
||||
const column: DataTableColumn = {
|
||||
id: 'col1',
|
||||
name: 'Date Column',
|
||||
type: 'date',
|
||||
index: 0,
|
||||
};
|
||||
|
||||
const colDef = createColumnDef(column);
|
||||
|
||||
expect(colDef.cellEditorPopup).toBe(true);
|
||||
expect(colDef.cellEditorSelector).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create number column with formatter', () => {
|
||||
const { createColumnDef } = createComposable();
|
||||
const column: DataTableColumn = {
|
||||
id: 'col1',
|
||||
name: 'Number Column',
|
||||
type: 'number',
|
||||
index: 0,
|
||||
};
|
||||
|
||||
const colDef = createColumnDef(column);
|
||||
|
||||
expect(colDef.valueFormatter).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create boolean column', () => {
|
||||
const { createColumnDef } = createComposable();
|
||||
const column: DataTableColumn = {
|
||||
id: 'col1',
|
||||
name: 'Boolean Column',
|
||||
type: 'boolean',
|
||||
index: 0,
|
||||
};
|
||||
|
||||
const colDef = createColumnDef(column);
|
||||
|
||||
expect(colDef.colId).toBe('col1');
|
||||
expect(colDef.field).toBe('Boolean Column');
|
||||
});
|
||||
|
||||
it('should merge extra props', () => {
|
||||
const { createColumnDef } = createComposable();
|
||||
const column: DataTableColumn = {
|
||||
id: 'col1',
|
||||
name: 'Column 1',
|
||||
type: 'string',
|
||||
index: 0,
|
||||
};
|
||||
|
||||
const colDef = createColumnDef(column, { editable: false, width: 100 });
|
||||
|
||||
expect(colDef.editable).toBe(false);
|
||||
expect(colDef.width).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadColumns', () => {
|
||||
it('should load columns and create column definitions', () => {
|
||||
const { loadColumns, colDefs } = createComposable();
|
||||
const columns: DataTableColumn[] = [
|
||||
{ id: 'col1', name: 'Column 1', type: 'string', index: 0 },
|
||||
{ id: 'col2', name: 'Column 2', type: 'number', index: 1 },
|
||||
];
|
||||
|
||||
loadColumns(columns);
|
||||
|
||||
expect(colDefs.value.length).toBeGreaterThan(2);
|
||||
const userColumns = colDefs.value.filter(
|
||||
(col) =>
|
||||
col.colId !== 'id' &&
|
||||
col.colId !== 'createdAt' &&
|
||||
col.colId !== 'updatedAt' &&
|
||||
col.colId !== 'add-column',
|
||||
);
|
||||
expect(userColumns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should include ID column as first column', () => {
|
||||
const { loadColumns, colDefs } = createComposable();
|
||||
const columns: DataTableColumn[] = [
|
||||
{ id: 'col1', name: 'Column 1', type: 'string', index: 0 },
|
||||
];
|
||||
|
||||
loadColumns(columns);
|
||||
|
||||
expect(colDefs.value[0]?.colId).toBe('id');
|
||||
});
|
||||
|
||||
it('should include system columns (createdAt, updatedAt)', () => {
|
||||
const { loadColumns, colDefs } = createComposable();
|
||||
const columns: DataTableColumn[] = [
|
||||
{ id: 'col1', name: 'Column 1', type: 'string', index: 0 },
|
||||
];
|
||||
|
||||
loadColumns(columns);
|
||||
|
||||
const systemColumns = colDefs.value.filter(
|
||||
(col) => col.colId === 'createdAt' || col.colId === 'updatedAt',
|
||||
);
|
||||
expect(systemColumns).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should include add-column button as last column', () => {
|
||||
const { loadColumns, colDefs } = createComposable();
|
||||
const columns: DataTableColumn[] = [
|
||||
{ id: 'col1', name: 'Column 1', type: 'string', index: 0 },
|
||||
];
|
||||
|
||||
loadColumns(columns);
|
||||
|
||||
expect(colDefs.value[colDefs.value.length - 1]?.colId).toBe('add-column');
|
||||
});
|
||||
|
||||
it('should order columns by index', () => {
|
||||
const { loadColumns, colDefs } = createComposable();
|
||||
const columns: DataTableColumn[] = [
|
||||
{ id: 'col2', name: 'Column 2', type: 'string', index: 2 },
|
||||
{ id: 'col1', name: 'Column 1', type: 'string', index: 1 },
|
||||
{ id: 'col3', name: 'Column 3', type: 'string', index: 3 },
|
||||
];
|
||||
|
||||
loadColumns(columns);
|
||||
|
||||
const userColumns = colDefs.value.filter(
|
||||
(col) =>
|
||||
col.colId !== 'id' &&
|
||||
col.colId !== 'createdAt' &&
|
||||
col.colId !== 'updatedAt' &&
|
||||
col.colId !== 'add-column',
|
||||
);
|
||||
expect(userColumns[0]?.colId).toBe('col1');
|
||||
expect(userColumns[1]?.colId).toBe('col2');
|
||||
expect(userColumns[2]?.colId).toBe('col3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteColumn', () => {
|
||||
it('should remove column by id', () => {
|
||||
const { loadColumns, deleteColumn, colDefs } = createComposable();
|
||||
const columns: DataTableColumn[] = [
|
||||
{ id: 'col1', name: 'Column 1', type: 'string', index: 0 },
|
||||
{ id: 'col2', name: 'Column 2', type: 'string', index: 1 },
|
||||
];
|
||||
|
||||
loadColumns(columns);
|
||||
const initialLength = colDefs.value.length;
|
||||
|
||||
deleteColumn('col1');
|
||||
|
||||
expect(colDefs.value.length).toBe(initialLength - 1);
|
||||
expect(colDefs.value.find((col) => col.colId === 'col1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not affect other columns', () => {
|
||||
const { loadColumns, deleteColumn, colDefs } = createComposable();
|
||||
const columns: DataTableColumn[] = [
|
||||
{ id: 'col1', name: 'Column 1', type: 'string', index: 0 },
|
||||
{ id: 'col2', name: 'Column 2', type: 'string', index: 1 },
|
||||
];
|
||||
|
||||
loadColumns(columns);
|
||||
deleteColumn('col1');
|
||||
|
||||
expect(colDefs.value.find((col) => col.colId === 'col2')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertColumnAtIndex', () => {
|
||||
it('should insert column at specified index', () => {
|
||||
const { loadColumns, insertColumnAtIndex, colDefs } = createComposable();
|
||||
const columns: DataTableColumn[] = [
|
||||
{ id: 'col1', name: 'Column 1', type: 'string', index: 0 },
|
||||
];
|
||||
|
||||
loadColumns(columns);
|
||||
const newColDef = { colId: 'new-col', field: 'New Column' };
|
||||
insertColumnAtIndex(newColDef, 0);
|
||||
|
||||
expect(colDefs.value[0]).toEqual(newColDef);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addColumn', () => {
|
||||
it('should add column before the last column', () => {
|
||||
const { loadColumns, addColumn, colDefs } = createComposable();
|
||||
const columns: DataTableColumn[] = [
|
||||
{ id: 'col1', name: 'Column 1', type: 'string', index: 0 },
|
||||
];
|
||||
|
||||
loadColumns(columns);
|
||||
const newColumn: DataTableColumn = {
|
||||
id: 'col2',
|
||||
name: 'Column 2',
|
||||
type: 'number',
|
||||
index: 1,
|
||||
};
|
||||
addColumn(newColumn);
|
||||
|
||||
expect(colDefs.value[colDefs.value.length - 1]?.colId).toBe('add-column');
|
||||
expect(colDefs.value[colDefs.value.length - 2]?.colId).toBe('col2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveColumn', () => {
|
||||
it('should reorder columns correctly', () => {
|
||||
const { loadColumns, moveColumn, colDefs } = createComposable();
|
||||
const columns: DataTableColumn[] = [
|
||||
{ id: 'col1', name: 'Column 1', type: 'string', index: 0 },
|
||||
{ id: 'col2', name: 'Column 2', type: 'string', index: 1 },
|
||||
{ id: 'col3', name: 'Column 3', type: 'string', index: 2 },
|
||||
];
|
||||
|
||||
loadColumns(columns);
|
||||
moveColumn(1, 2);
|
||||
|
||||
const userColumns = colDefs.value.filter(
|
||||
(col) =>
|
||||
col.colId !== 'id' &&
|
||||
col.colId !== 'createdAt' &&
|
||||
col.colId !== 'updatedAt' &&
|
||||
col.colId !== 'add-column',
|
||||
);
|
||||
|
||||
expect(userColumns[0]?.colId).toBe('col2');
|
||||
expect(userColumns[1]?.colId).toBe('col3');
|
||||
expect(userColumns[2]?.colId).toBe('col1');
|
||||
});
|
||||
|
||||
it('should preserve ID column at first position', () => {
|
||||
const { loadColumns, moveColumn, colDefs } = createComposable();
|
||||
const columns: DataTableColumn[] = [
|
||||
{ id: 'col1', name: 'Column 1', type: 'string', index: 0 },
|
||||
{ id: 'col2', name: 'Column 2', type: 'string', index: 1 },
|
||||
];
|
||||
|
||||
loadColumns(columns);
|
||||
moveColumn(1, 2);
|
||||
|
||||
expect(colDefs.value[0]?.colId).toBe('id');
|
||||
});
|
||||
|
||||
it('should preserve add-column button at last position', () => {
|
||||
const { loadColumns, moveColumn, colDefs } = createComposable();
|
||||
const columns: DataTableColumn[] = [
|
||||
{ id: 'col1', name: 'Column 1', type: 'string', index: 0 },
|
||||
{ id: 'col2', name: 'Column 2', type: 'string', index: 1 },
|
||||
];
|
||||
|
||||
loadColumns(columns);
|
||||
moveColumn(1, 2);
|
||||
|
||||
expect(colDefs.value[colDefs.value.length - 1]?.colId).toBe('add-column');
|
||||
});
|
||||
|
||||
it('should handle invalid index gracefully', () => {
|
||||
const { loadColumns, moveColumn, colDefs } = createComposable();
|
||||
const columns: DataTableColumn[] = [
|
||||
{ id: 'col1', name: 'Column 1', type: 'string', index: 0 },
|
||||
];
|
||||
|
||||
loadColumns(columns);
|
||||
const initialColDefs = [...colDefs.value];
|
||||
|
||||
moveColumn(999, 1);
|
||||
|
||||
expect(colDefs.value).toEqual(initialColDefs);
|
||||
});
|
||||
});
|
||||
});
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
import { ref, type Ref } from 'vue';
|
||||
import type { ColDef, ICellRendererParams } from 'ag-grid-community';
|
||||
import type {
|
||||
AddColumnResponse,
|
||||
DataTableColumn,
|
||||
DataTableColumnCreatePayload,
|
||||
} from '@/features/dataTable/dataTable.types';
|
||||
import {
|
||||
ADD_ROW_ROW_ID,
|
||||
DATA_TABLE_ID_COLUMN_WIDTH,
|
||||
DEFAULT_COLUMN_WIDTH,
|
||||
DEFAULT_ID_COLUMN_NAME,
|
||||
} from '@/features/dataTable/constants';
|
||||
import { useDataTableTypes } from '@/features/dataTable/composables/useDataTableTypes';
|
||||
import ColumnHeader from '@/features/dataTable/components/dataGrid/ColumnHeader.vue';
|
||||
import ElDatePickerCellEditor from '@/features/dataTable/components/dataGrid/ElDatePickerCellEditor.vue';
|
||||
import ElDatePickerFilter from '@/features/dataTable/components/dataGrid/ElDatePickerFilter.vue';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import AddColumnButton from '@/features/dataTable/components/dataGrid/AddColumnButton.vue';
|
||||
import AddRowButton from '@/features/dataTable/components/dataGrid/AddRowButton.vue';
|
||||
import { reorderItem } from '@/features/dataTable/utils';
|
||||
import {
|
||||
getCellClass,
|
||||
createValueGetter,
|
||||
createCellRendererSelector,
|
||||
createStringValueSetter,
|
||||
stringCellEditorParams,
|
||||
dateValueFormatter,
|
||||
numberValueFormatter,
|
||||
getStringColumnFilterOptions,
|
||||
getDateColumnFilterOptions,
|
||||
getNumberColumnFilterOptions,
|
||||
getBooleanColumnFilterOptions,
|
||||
} from '@/features/dataTable/utils/columnUtils';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { GRID_FILTER_CONFIG } from '@/features/dataTable/utils/filterMappings';
|
||||
|
||||
export const useDataTableColumns = ({
|
||||
onDeleteColumn,
|
||||
onAddRowClick,
|
||||
onAddColumn,
|
||||
isTextEditorOpen,
|
||||
}: {
|
||||
onDeleteColumn: (columnId: string) => void;
|
||||
onAddRowClick: () => void;
|
||||
onAddColumn: (column: DataTableColumnCreatePayload) => Promise<AddColumnResponse>;
|
||||
isTextEditorOpen: Ref<boolean>;
|
||||
}) => {
|
||||
const colDefs = ref<ColDef[]>([]);
|
||||
const { mapToAGCellType } = useDataTableTypes();
|
||||
const i18n = useI18n();
|
||||
|
||||
const createColumnDef = (col: DataTableColumn, extraProps: Partial<ColDef> = {}) => {
|
||||
const columnDef: ColDef = {
|
||||
colId: col.id,
|
||||
field: col.name,
|
||||
filter: !GRID_FILTER_CONFIG.excludedColumns.includes(col.id),
|
||||
headerName: col.name,
|
||||
sortable: true,
|
||||
editable: (params) => params.data?.id !== ADD_ROW_ROW_ID,
|
||||
resizable: true,
|
||||
lockPinned: true,
|
||||
headerComponent: ColumnHeader,
|
||||
headerComponentParams: {
|
||||
onDelete: onDeleteColumn,
|
||||
allowMenuActions: true,
|
||||
},
|
||||
cellEditorPopup: false,
|
||||
cellDataType: mapToAGCellType(col.type),
|
||||
cellClass: getCellClass,
|
||||
valueGetter: createValueGetter(col),
|
||||
cellRendererSelector: createCellRendererSelector(col),
|
||||
width: DEFAULT_COLUMN_WIDTH,
|
||||
};
|
||||
|
||||
if (col.type === 'string') {
|
||||
columnDef.cellEditor = 'agLargeTextCellEditor';
|
||||
columnDef.cellEditorPopup = true;
|
||||
columnDef.cellEditorPopupPosition = 'over';
|
||||
columnDef.cellEditorParams = stringCellEditorParams;
|
||||
columnDef.valueSetter = createStringValueSetter(col, isTextEditorOpen);
|
||||
columnDef.filterParams = {
|
||||
filterOptions: getStringColumnFilterOptions(i18n),
|
||||
};
|
||||
} else if (col.type === 'date') {
|
||||
columnDef.cellEditorSelector = () => ({
|
||||
component: ElDatePickerCellEditor,
|
||||
});
|
||||
columnDef.valueFormatter = dateValueFormatter;
|
||||
columnDef.cellEditorPopup = true;
|
||||
columnDef.dateComponent = ElDatePickerFilter;
|
||||
columnDef.filterParams = {
|
||||
filterOptions: getDateColumnFilterOptions(i18n),
|
||||
};
|
||||
} else if (col.type === 'number') {
|
||||
columnDef.valueFormatter = numberValueFormatter;
|
||||
columnDef.filterParams = {
|
||||
filterOptions: getNumberColumnFilterOptions(i18n),
|
||||
};
|
||||
} else if (col.type === 'boolean') {
|
||||
columnDef.filterParams = {
|
||||
filterOptions: getBooleanColumnFilterOptions(i18n),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...columnDef,
|
||||
...extraProps,
|
||||
};
|
||||
};
|
||||
|
||||
const getColumnDefinitions = (dataTableColumns: DataTableColumn[]) => {
|
||||
const systemDateColumnOptions: Partial<ColDef> = {
|
||||
editable: false,
|
||||
suppressMovable: true,
|
||||
lockPinned: true,
|
||||
lockPosition: 'right',
|
||||
headerComponentParams: {
|
||||
allowMenuActions: false,
|
||||
},
|
||||
cellClass: (params) => (params.data?.id === ADD_ROW_ROW_ID ? 'add-row-cell' : 'system-cell'),
|
||||
headerClass: 'system-column',
|
||||
width: DEFAULT_COLUMN_WIDTH,
|
||||
};
|
||||
return [
|
||||
// Always add the ID column, it's not returned by the back-end but all data tables have it
|
||||
// We use it as a placeholder for new data tables
|
||||
createColumnDef(
|
||||
{
|
||||
index: 0,
|
||||
id: DEFAULT_ID_COLUMN_NAME,
|
||||
name: DEFAULT_ID_COLUMN_NAME,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
editable: false,
|
||||
sortable: true,
|
||||
suppressMovable: true,
|
||||
lockPosition: true,
|
||||
minWidth: DATA_TABLE_ID_COLUMN_WIDTH,
|
||||
maxWidth: DATA_TABLE_ID_COLUMN_WIDTH,
|
||||
resizable: false,
|
||||
headerClass: 'system-column',
|
||||
headerComponentParams: {
|
||||
allowMenuActions: false,
|
||||
showTypeIcon: false,
|
||||
},
|
||||
cellClass: (params) =>
|
||||
params.data?.id === ADD_ROW_ROW_ID ? 'add-row-cell' : 'id-column',
|
||||
cellRendererSelector: (params: ICellRendererParams) => {
|
||||
if (params.value === ADD_ROW_ROW_ID) {
|
||||
return {
|
||||
component: AddRowButton,
|
||||
params: { onClick: onAddRowClick },
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
),
|
||||
// Append other columns
|
||||
...orderBy(dataTableColumns, 'index').map((col) => createColumnDef(col)),
|
||||
createColumnDef(
|
||||
{
|
||||
index: dataTableColumns.length + 1,
|
||||
id: 'createdAt',
|
||||
name: 'createdAt',
|
||||
type: 'date',
|
||||
},
|
||||
systemDateColumnOptions,
|
||||
),
|
||||
createColumnDef(
|
||||
{
|
||||
index: dataTableColumns.length + 2,
|
||||
id: 'updatedAt',
|
||||
name: 'updatedAt',
|
||||
type: 'date',
|
||||
},
|
||||
systemDateColumnOptions,
|
||||
),
|
||||
createColumnDef(
|
||||
{
|
||||
index: dataTableColumns.length + 3,
|
||||
id: 'add-column',
|
||||
name: 'Add Column',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
editable: false,
|
||||
suppressMovable: true,
|
||||
lockPinned: true,
|
||||
lockPosition: 'right',
|
||||
resizable: false,
|
||||
flex: 1,
|
||||
headerComponent: AddColumnButton,
|
||||
headerComponentParams: { onAddColumn },
|
||||
},
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const loadColumns = (dataTableColumns: DataTableColumn[]) => {
|
||||
colDefs.value = getColumnDefinitions(dataTableColumns);
|
||||
};
|
||||
|
||||
const deleteColumn = (columnId: string) => {
|
||||
colDefs.value = colDefs.value.filter((col) => col.colId !== columnId);
|
||||
};
|
||||
|
||||
const insertColumnAtIndex = (column: ColDef, index: number) => {
|
||||
colDefs.value.splice(index, 0, column);
|
||||
};
|
||||
|
||||
const addColumn = (column: DataTableColumn) => {
|
||||
colDefs.value = [
|
||||
...colDefs.value.slice(0, -1),
|
||||
createColumnDef(column),
|
||||
...colDefs.value.slice(-1),
|
||||
];
|
||||
};
|
||||
|
||||
const moveColumn = (oldIndex: number, newIndex: number) => {
|
||||
const fromIndex = oldIndex - 1; // exclude ID column
|
||||
const columnToBeMoved = colDefs.value[fromIndex];
|
||||
if (!columnToBeMoved) {
|
||||
return;
|
||||
}
|
||||
const middleWithIndex = colDefs.value
|
||||
.slice(1, -1)
|
||||
.map((col, idx) => ({ column: col, index: idx }));
|
||||
const reorderedMiddle = reorderItem(middleWithIndex, fromIndex, newIndex)
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map(({ column }) => column);
|
||||
colDefs.value = [colDefs.value[0], ...reorderedMiddle, colDefs.value[colDefs.value.length - 1]];
|
||||
};
|
||||
|
||||
return {
|
||||
colDefs,
|
||||
createColumnDef,
|
||||
loadColumns,
|
||||
deleteColumn,
|
||||
insertColumnAtIndex,
|
||||
addColumn,
|
||||
moveColumn,
|
||||
};
|
||||
};
|
||||
-456
@@ -1,456 +0,0 @@
|
||||
import { computed, ref, type Ref } from 'vue';
|
||||
import type {
|
||||
CellClickedEvent,
|
||||
CellEditingStartedEvent,
|
||||
CellEditingStoppedEvent,
|
||||
CellKeyDownEvent,
|
||||
ColDef,
|
||||
GridApi,
|
||||
GridReadyEvent,
|
||||
ICellRendererParams,
|
||||
SortChangedEvent,
|
||||
SortDirection,
|
||||
} from 'ag-grid-community';
|
||||
import type {
|
||||
AddColumnResponse,
|
||||
DataTableColumn,
|
||||
DataTableColumnCreatePayload,
|
||||
DataTableRow,
|
||||
} from '@/features/dataTable/dataTable.types';
|
||||
import {
|
||||
ADD_ROW_ROW_ID,
|
||||
DATA_TABLE_ID_COLUMN_WIDTH,
|
||||
DEFAULT_COLUMN_WIDTH,
|
||||
DEFAULT_ID_COLUMN_NAME,
|
||||
} from '@/features/dataTable/constants';
|
||||
import { useDataTableTypes } from '@/features/dataTable/composables/useDataTableTypes';
|
||||
import ColumnHeader from '@/features/dataTable/components/dataGrid/ColumnHeader.vue';
|
||||
import ElDatePickerCellEditor from '@/features/dataTable/components/dataGrid/ElDatePickerCellEditor.vue';
|
||||
import ElDatePickerFilter from '@/features/dataTable/components/dataGrid/ElDatePickerFilter.vue';
|
||||
import orderBy from 'lodash/orderBy';
|
||||
import AddColumnButton from '@/features/dataTable/components/dataGrid/AddColumnButton.vue';
|
||||
import AddRowButton from '@/features/dataTable/components/dataGrid/AddRowButton.vue';
|
||||
import { reorderItem } from '@/features/dataTable/utils';
|
||||
import { useClipboard } from '@/composables/useClipboard';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import {
|
||||
getCellClass,
|
||||
createValueGetter,
|
||||
createCellRendererSelector,
|
||||
createStringValueSetter,
|
||||
stringCellEditorParams,
|
||||
dateValueFormatter,
|
||||
numberValueFormatter,
|
||||
getStringColumnFilterOptions,
|
||||
getDateColumnFilterOptions,
|
||||
getNumberColumnFilterOptions,
|
||||
getBooleanColumnFilterOptions,
|
||||
} from '@/features/dataTable/utils/columnUtils';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
export const useDataTableGridBase = ({
|
||||
gridContainerRef,
|
||||
onDeleteColumn,
|
||||
onAddRowClick,
|
||||
onAddColumn,
|
||||
}: {
|
||||
gridContainerRef: Ref<HTMLElement | null>;
|
||||
onDeleteColumn: (columnId: string) => void;
|
||||
onAddRowClick: () => void;
|
||||
onAddColumn: (column: DataTableColumnCreatePayload) => Promise<AddColumnResponse>;
|
||||
}) => {
|
||||
const gridApi = ref<GridApi | null>(null);
|
||||
const colDefs = ref<ColDef[]>([]);
|
||||
const isTextEditorOpen = ref(false);
|
||||
const { mapToAGCellType } = useDataTableTypes();
|
||||
const { copy: copyToClipboard } = useClipboard({ onPaste: onClipboardPaste });
|
||||
const i18n = useI18n();
|
||||
const currentSortBy = ref<string>(DEFAULT_ID_COLUMN_NAME);
|
||||
const currentSortOrder = ref<SortDirection>('asc');
|
||||
|
||||
// Track the last focused cell so we can start editing when users click on it
|
||||
// AG Grid doesn't provide cell blur event so we need to reset this manually
|
||||
const lastFocusedCell = ref<{ rowIndex: number; colId: string } | null>(null);
|
||||
const initializedGridApi = computed(() => {
|
||||
if (!gridApi.value) {
|
||||
throw new Error('Grid API is not initialized');
|
||||
}
|
||||
return gridApi.value;
|
||||
});
|
||||
|
||||
const onGridReady = (params: GridReadyEvent) => {
|
||||
gridApi.value = params.api;
|
||||
// Ensure popups (e.g., agLargeTextCellEditor) are positioned relative to the grid container
|
||||
// to avoid misalignment when the page scrolls.
|
||||
if (gridContainerRef.value) {
|
||||
params.api.setGridOption('popupParent', gridContainerRef.value);
|
||||
}
|
||||
};
|
||||
|
||||
const setGridData = ({
|
||||
colDefs,
|
||||
rowData,
|
||||
}: {
|
||||
colDefs?: ColDef[];
|
||||
rowData?: DataTableRow[];
|
||||
}) => {
|
||||
if (colDefs) {
|
||||
initializedGridApi.value.setGridOption('columnDefs', colDefs);
|
||||
}
|
||||
|
||||
if (rowData) {
|
||||
initializedGridApi.value.setGridOption('rowData', rowData);
|
||||
}
|
||||
|
||||
initializedGridApi.value.setGridOption('pinnedBottomRowData', [{ id: ADD_ROW_ROW_ID }]);
|
||||
};
|
||||
|
||||
const focusFirstEditableCell = (rowId: number) => {
|
||||
const rowNode = initializedGridApi.value.getRowNode(String(rowId));
|
||||
if (rowNode?.rowIndex === null) return;
|
||||
const rowIndex = rowNode!.rowIndex;
|
||||
|
||||
const displayed = initializedGridApi.value.getAllDisplayedColumns();
|
||||
const firstEditable = displayed.find((col) => {
|
||||
const def = col.getColDef();
|
||||
if (!def) return false;
|
||||
if (def.colId === DEFAULT_ID_COLUMN_NAME) return false;
|
||||
return !!def.editable;
|
||||
});
|
||||
if (!firstEditable) return;
|
||||
const columnId = firstEditable.getColId();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
initializedGridApi.value.ensureIndexVisible(rowIndex);
|
||||
requestAnimationFrame(() => {
|
||||
initializedGridApi.value.setFocusedCell(rowIndex, columnId);
|
||||
initializedGridApi.value.startEditingCell({
|
||||
rowIndex,
|
||||
colKey: columnId,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const createColumnDef = (col: DataTableColumn, extraProps: Partial<ColDef> = {}) => {
|
||||
const columnDef: ColDef = {
|
||||
colId: col.id,
|
||||
field: col.name,
|
||||
headerName: col.name,
|
||||
sortable: true,
|
||||
editable: (params) => params.data?.id !== ADD_ROW_ROW_ID,
|
||||
resizable: true,
|
||||
lockPinned: true,
|
||||
headerComponent: ColumnHeader,
|
||||
headerComponentParams: {
|
||||
onDelete: onDeleteColumn,
|
||||
allowMenuActions: true,
|
||||
},
|
||||
cellEditorPopup: false,
|
||||
cellDataType: mapToAGCellType(col.type),
|
||||
cellClass: getCellClass,
|
||||
valueGetter: createValueGetter(col),
|
||||
cellRendererSelector: createCellRendererSelector(col),
|
||||
width: DEFAULT_COLUMN_WIDTH,
|
||||
};
|
||||
|
||||
if (col.type === 'string') {
|
||||
columnDef.cellEditor = 'agLargeTextCellEditor';
|
||||
columnDef.cellEditorPopup = true;
|
||||
columnDef.cellEditorPopupPosition = 'over';
|
||||
columnDef.cellEditorParams = stringCellEditorParams;
|
||||
columnDef.valueSetter = createStringValueSetter(col, isTextEditorOpen);
|
||||
columnDef.filterParams = {
|
||||
filterOptions: getStringColumnFilterOptions(i18n),
|
||||
};
|
||||
} else if (col.type === 'date') {
|
||||
columnDef.cellEditorSelector = () => ({
|
||||
component: ElDatePickerCellEditor,
|
||||
});
|
||||
columnDef.valueFormatter = dateValueFormatter;
|
||||
columnDef.cellEditorPopup = true;
|
||||
columnDef.dateComponent = ElDatePickerFilter;
|
||||
columnDef.filterParams = {
|
||||
filterOptions: getDateColumnFilterOptions(i18n),
|
||||
};
|
||||
} else if (col.type === 'number') {
|
||||
columnDef.valueFormatter = numberValueFormatter;
|
||||
columnDef.filterParams = {
|
||||
filterOptions: getNumberColumnFilterOptions(i18n),
|
||||
};
|
||||
} else if (col.type === 'boolean') {
|
||||
columnDef.filterParams = {
|
||||
filterOptions: getBooleanColumnFilterOptions(i18n),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...columnDef,
|
||||
...extraProps,
|
||||
};
|
||||
};
|
||||
|
||||
const onCellEditingStarted = (params: CellEditingStartedEvent<DataTableRow>) => {
|
||||
if (params.column.getColDef().cellDataType === 'text') {
|
||||
isTextEditorOpen.value = true;
|
||||
} else {
|
||||
isTextEditorOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onCellEditingStopped = (params: CellEditingStoppedEvent<DataTableRow>) => {
|
||||
if (params.column.getColDef().cellDataType === 'text') {
|
||||
isTextEditorOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const getColumnDefinitions = (dataTableColumns: DataTableColumn[]) => {
|
||||
const systemDateColumnOptions: Partial<ColDef> = {
|
||||
editable: false,
|
||||
suppressMovable: true,
|
||||
lockPinned: true,
|
||||
lockPosition: 'right',
|
||||
headerComponentParams: {
|
||||
allowMenuActions: false,
|
||||
},
|
||||
cellClass: (params) => (params.data?.id === ADD_ROW_ROW_ID ? 'add-row-cell' : 'system-cell'),
|
||||
headerClass: 'system-column',
|
||||
width: DEFAULT_COLUMN_WIDTH,
|
||||
};
|
||||
return [
|
||||
// Always add the ID column, it's not returned by the back-end but all data tables have it
|
||||
// We use it as a placeholder for new data tables
|
||||
createColumnDef(
|
||||
{
|
||||
index: 0,
|
||||
id: DEFAULT_ID_COLUMN_NAME,
|
||||
name: DEFAULT_ID_COLUMN_NAME,
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
editable: false,
|
||||
sortable: true,
|
||||
filter: false,
|
||||
suppressMovable: true,
|
||||
lockPosition: true,
|
||||
minWidth: DATA_TABLE_ID_COLUMN_WIDTH,
|
||||
maxWidth: DATA_TABLE_ID_COLUMN_WIDTH,
|
||||
resizable: false,
|
||||
headerClass: 'system-column',
|
||||
headerComponentParams: {
|
||||
allowMenuActions: false,
|
||||
showTypeIcon: false,
|
||||
},
|
||||
cellClass: (params) =>
|
||||
params.data?.id === ADD_ROW_ROW_ID ? 'add-row-cell' : 'id-column',
|
||||
cellRendererSelector: (params: ICellRendererParams) => {
|
||||
if (params.value === ADD_ROW_ROW_ID) {
|
||||
return {
|
||||
component: AddRowButton,
|
||||
params: { onClick: onAddRowClick },
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
),
|
||||
// Append other columns
|
||||
...orderBy(dataTableColumns, 'index').map((col) => createColumnDef(col)),
|
||||
createColumnDef(
|
||||
{
|
||||
index: dataTableColumns.length + 1,
|
||||
id: 'createdAt',
|
||||
name: 'createdAt',
|
||||
type: 'date',
|
||||
},
|
||||
systemDateColumnOptions,
|
||||
),
|
||||
createColumnDef(
|
||||
{
|
||||
index: dataTableColumns.length + 2,
|
||||
id: 'updatedAt',
|
||||
name: 'updatedAt',
|
||||
type: 'date',
|
||||
},
|
||||
systemDateColumnOptions,
|
||||
),
|
||||
createColumnDef(
|
||||
{
|
||||
index: dataTableColumns.length + 3,
|
||||
id: 'add-column',
|
||||
name: 'Add Column',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
editable: false,
|
||||
suppressMovable: true,
|
||||
lockPinned: true,
|
||||
lockPosition: 'right',
|
||||
resizable: false,
|
||||
flex: 1,
|
||||
headerComponent: AddColumnButton,
|
||||
headerComponentParams: { onAddColumn },
|
||||
},
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
const loadColumns = (dataTableColumns: DataTableColumn[]) => {
|
||||
colDefs.value = getColumnDefinitions(dataTableColumns);
|
||||
setGridData({ colDefs: colDefs.value });
|
||||
};
|
||||
|
||||
const deleteColumn = (columnId: string) => {
|
||||
colDefs.value = colDefs.value.filter((col) => col.colId !== columnId);
|
||||
setGridData({ colDefs: colDefs.value });
|
||||
};
|
||||
|
||||
const insertColumnAtIndex = (column: ColDef, index: number) => {
|
||||
colDefs.value.splice(index, 0, column);
|
||||
setGridData({ colDefs: colDefs.value });
|
||||
};
|
||||
|
||||
const addColumn = (column: DataTableColumn) => {
|
||||
colDefs.value = [
|
||||
...colDefs.value.slice(0, -1),
|
||||
createColumnDef(column),
|
||||
...colDefs.value.slice(-1),
|
||||
];
|
||||
setGridData({ colDefs: colDefs.value });
|
||||
};
|
||||
|
||||
const moveColumn = (oldIndex: number, newIndex: number) => {
|
||||
const fromIndex = oldIndex - 1; // exclude ID column
|
||||
const columnToBeMoved = colDefs.value[fromIndex];
|
||||
if (!columnToBeMoved) {
|
||||
return;
|
||||
}
|
||||
const middleWithIndex = colDefs.value.slice(1, -1).map((col, index) => ({ ...col, index }));
|
||||
const reorderedMiddle = reorderItem(middleWithIndex, fromIndex, newIndex)
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map(({ index, ...col }) => col);
|
||||
colDefs.value = [colDefs.value[0], ...reorderedMiddle, colDefs.value[colDefs.value.length - 1]];
|
||||
};
|
||||
|
||||
const handleCopyFocusedCell = async (params: CellKeyDownEvent<DataTableRow>) => {
|
||||
const focused = params.api.getFocusedCell();
|
||||
if (!focused) {
|
||||
return;
|
||||
}
|
||||
const row = params.api.getDisplayedRowAtIndex(focused.rowIndex);
|
||||
const colDef = focused.column.getColDef();
|
||||
if (row?.data && colDef.field) {
|
||||
const rawValue = row.data[colDef.field];
|
||||
const text = rawValue === null || rawValue === undefined ? '' : String(rawValue);
|
||||
await copyToClipboard(text);
|
||||
}
|
||||
};
|
||||
|
||||
function onClipboardPaste(data: string) {
|
||||
const focusedCell = initializedGridApi.value.getFocusedCell();
|
||||
const isEditing = initializedGridApi.value.getEditingCells().length > 0;
|
||||
if (!focusedCell || isEditing) return;
|
||||
const row = initializedGridApi.value.getDisplayedRowAtIndex(focusedCell.rowIndex);
|
||||
if (!row) return;
|
||||
|
||||
const colDef = focusedCell.column.getColDef();
|
||||
if (colDef.cellDataType === 'text') {
|
||||
row.setDataValue(focusedCell.column.getColId(), data);
|
||||
} else if (colDef.cellDataType === 'number') {
|
||||
if (!Number.isNaN(Number(data))) {
|
||||
row.setDataValue(focusedCell.column.getColId(), Number(data));
|
||||
}
|
||||
} else if (colDef.cellDataType === 'date') {
|
||||
if (!Number.isNaN(Date.parse(data))) {
|
||||
row.setDataValue(focusedCell.column.getColId(), new Date(data));
|
||||
}
|
||||
} else if (colDef.cellDataType === 'boolean') {
|
||||
if (data === 'true') {
|
||||
row.setDataValue(focusedCell.column.getColId(), true);
|
||||
} else if (data === 'false') {
|
||||
row.setDataValue(focusedCell.column.getColId(), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onCellClicked = (params: CellClickedEvent<DataTableRow>) => {
|
||||
const clickedCellColumn = params.column.getColId();
|
||||
const clickedCellRow = params.rowIndex;
|
||||
|
||||
if (
|
||||
clickedCellRow === null ||
|
||||
params.api.isEditing({
|
||||
rowIndex: clickedCellRow,
|
||||
column: params.column,
|
||||
rowPinned: null,
|
||||
})
|
||||
)
|
||||
return;
|
||||
|
||||
// Check if this is the same cell that was focused before this click
|
||||
const wasAlreadyFocused =
|
||||
lastFocusedCell.value &&
|
||||
lastFocusedCell.value.rowIndex === clickedCellRow &&
|
||||
lastFocusedCell.value.colId === clickedCellColumn;
|
||||
|
||||
if (wasAlreadyFocused && params.column.getColDef()?.editable) {
|
||||
// Cell was already selected, start editing
|
||||
params.api.startEditingCell({
|
||||
rowIndex: clickedCellRow,
|
||||
colKey: clickedCellColumn,
|
||||
});
|
||||
}
|
||||
|
||||
// Update the last focused cell for next click
|
||||
lastFocusedCell.value = {
|
||||
rowIndex: clickedCellRow,
|
||||
colId: clickedCellColumn,
|
||||
};
|
||||
};
|
||||
|
||||
const resetLastFocusedCell = () => {
|
||||
lastFocusedCell.value = null;
|
||||
};
|
||||
|
||||
const onSortChanged = async (event: SortChangedEvent) => {
|
||||
const sortedColumn = event.columns?.filter((col) => col.getSort() !== null).pop() ?? null;
|
||||
|
||||
if (sortedColumn) {
|
||||
const colId = sortedColumn.getColId();
|
||||
const columnDef = colDefs.value.find((col) => col.colId === colId);
|
||||
|
||||
currentSortBy.value = columnDef?.field || colId;
|
||||
currentSortOrder.value = sortedColumn.getSort() ?? 'asc';
|
||||
} else {
|
||||
currentSortBy.value = DEFAULT_ID_COLUMN_NAME;
|
||||
currentSortOrder.value = 'asc';
|
||||
}
|
||||
};
|
||||
|
||||
onClickOutside(gridContainerRef, () => {
|
||||
resetLastFocusedCell();
|
||||
initializedGridApi.value.clearFocusedCell();
|
||||
});
|
||||
|
||||
return {
|
||||
onGridReady,
|
||||
setGridData,
|
||||
focusFirstEditableCell,
|
||||
onCellEditingStarted,
|
||||
onCellEditingStopped,
|
||||
createColumnDef,
|
||||
loadColumns,
|
||||
colDefs,
|
||||
deleteColumn,
|
||||
insertColumnAtIndex,
|
||||
addColumn,
|
||||
moveColumn,
|
||||
gridApi: initializedGridApi,
|
||||
handleCopyFocusedCell,
|
||||
onCellClicked,
|
||||
resetLastFocusedCell,
|
||||
currentSortBy,
|
||||
currentSortOrder,
|
||||
onSortChanged,
|
||||
};
|
||||
};
|
||||
+900
-13
@@ -3,18 +3,69 @@ import {
|
||||
type UseDataTableOperationsParams,
|
||||
} from '@/features/dataTable/composables/useDataTableOperations';
|
||||
import { ref } from 'vue';
|
||||
import type { GridApi } from 'ag-grid-community';
|
||||
import type {
|
||||
GridApi,
|
||||
ColumnMovedEvent,
|
||||
CellValueChangedEvent,
|
||||
CellKeyDownEvent,
|
||||
} from 'ag-grid-community';
|
||||
import { setActivePinia } from 'pinia';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { useDataTableStore } from '@/features/dataTable/dataTable.store';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useTelemetry } from '@/composables/useTelemetry';
|
||||
import { MODAL_CONFIRM } from '@/constants';
|
||||
import type { DataTableRow } from '@/features/dataTable/dataTable.types';
|
||||
|
||||
vi.mock('@/features/dataTable/dataTable.store', () => ({
|
||||
useDataTableStore: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useMessage', () => ({
|
||||
useMessage: vi.fn(() => ({
|
||||
confirm: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
showError: vi.fn(),
|
||||
showMessage: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useTelemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
track: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
baseText: vi.fn((key: string) => key),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/dataTable/composables/useDataTableTypes', () => ({
|
||||
useDataTableTypes: vi.fn(() => ({
|
||||
mapToDataTableColumnType: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/dataTable/typeGuards', () => ({
|
||||
isDataTableValue: vi.fn((value: unknown) => value !== undefined && value !== null),
|
||||
isAGGridCellType: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
describe('useDataTableOperations', () => {
|
||||
let params: UseDataTableOperationsParams;
|
||||
let dataTableStore: ReturnType<typeof useDataTableStore>;
|
||||
let confirmMock: ReturnType<typeof vi.fn>;
|
||||
let showErrorMock: ReturnType<typeof vi.fn>;
|
||||
let telemetryTrackMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia());
|
||||
|
||||
@@ -28,6 +79,21 @@ describe('useDataTableOperations', () => {
|
||||
|
||||
vi.mocked(useDataTableStore).mockReturnValue(dataTableStore);
|
||||
|
||||
confirmMock = vi.fn();
|
||||
vi.mocked(useMessage).mockReturnValue({
|
||||
confirm: confirmMock,
|
||||
} as unknown as ReturnType<typeof useMessage>);
|
||||
|
||||
showErrorMock = vi.fn();
|
||||
vi.mocked(useToast).mockReturnValue({
|
||||
showError: showErrorMock,
|
||||
} as unknown as ReturnType<typeof useToast>);
|
||||
|
||||
telemetryTrackMock = vi.fn();
|
||||
vi.mocked(useTelemetry).mockReturnValue({
|
||||
track: telemetryTrackMock,
|
||||
} as unknown as ReturnType<typeof useTelemetry>);
|
||||
|
||||
params = {
|
||||
colDefs: ref([]),
|
||||
rowData: ref([]),
|
||||
@@ -48,6 +114,7 @@ describe('useDataTableOperations', () => {
|
||||
pageSize: ref(10),
|
||||
currentSortBy: ref(''),
|
||||
currentSortOrder: ref(null),
|
||||
currentFilterJSON: ref(undefined),
|
||||
handleClearSelection: vi.fn(),
|
||||
selectedRowIds: ref(new Set()),
|
||||
handleCopyFocusedCell: vi.fn(),
|
||||
@@ -59,17 +126,7 @@ describe('useDataTableOperations', () => {
|
||||
});
|
||||
|
||||
describe('onAddColumn', () => {
|
||||
it('should raise error when column is not added', async () => {
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
addDataTableColumn: vi.fn().mockRejectedValue(new Error('test')),
|
||||
});
|
||||
const { onAddColumn } = useDataTableOperations(params);
|
||||
const result = await onAddColumn({ name: 'test', type: 'string' });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('should add column when column is added', async () => {
|
||||
it('should add column when column is added successfully', async () => {
|
||||
const returnedColumn = { name: 'test', type: 'string' } as const;
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
@@ -79,8 +136,838 @@ describe('useDataTableOperations', () => {
|
||||
const { onAddColumn } = useDataTableOperations({ ...params, rowData });
|
||||
const result = await onAddColumn({ name: returnedColumn.name, type: returnedColumn.type });
|
||||
expect(result.success).toBe(true);
|
||||
expect(params.setGridData).toHaveBeenCalledWith({ rowData: [{ id: 1, test: null }] });
|
||||
expect(result.httpStatus).toBe(200);
|
||||
expect(params.setGridData).toHaveBeenCalledWith({
|
||||
rowData: [{ id: 1, test: null }],
|
||||
colDefs: [],
|
||||
});
|
||||
expect(params.addGridColumn).toHaveBeenCalledWith(returnedColumn);
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle ResponseError with httpStatusCode', async () => {
|
||||
const responseError = new ResponseError('Conflict error', { httpStatusCode: 409 });
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
addDataTableColumn: vi.fn().mockRejectedValue(responseError),
|
||||
});
|
||||
const { onAddColumn } = useDataTableOperations(params);
|
||||
const result = await onAddColumn({ name: 'test', type: 'string' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(409);
|
||||
expect(result.errorMessage).toBe('Conflict error');
|
||||
});
|
||||
|
||||
it('should handle ResponseError without httpStatusCode', async () => {
|
||||
const responseError = new ResponseError('Unknown response error');
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
addDataTableColumn: vi.fn().mockRejectedValue(responseError),
|
||||
});
|
||||
const { onAddColumn } = useDataTableOperations(params);
|
||||
const result = await onAddColumn({ name: 'test', type: 'string' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(500);
|
||||
expect(result.errorMessage).toBe('Unknown response error');
|
||||
});
|
||||
|
||||
it('should handle regular Error', async () => {
|
||||
const error = new Error('Regular error message');
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
addDataTableColumn: vi.fn().mockRejectedValue(error),
|
||||
});
|
||||
const { onAddColumn } = useDataTableOperations(params);
|
||||
const result = await onAddColumn({ name: 'test', type: 'string' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(500);
|
||||
expect(result.errorMessage).toBe('Regular error message');
|
||||
});
|
||||
|
||||
it('should handle unknown error type', async () => {
|
||||
const unknownError = 'string error';
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
addDataTableColumn: vi.fn().mockRejectedValue(unknownError),
|
||||
});
|
||||
const { onAddColumn } = useDataTableOperations(params);
|
||||
const result = await onAddColumn({ name: 'test', type: 'string' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.httpStatus).toBe(500);
|
||||
expect(result.errorMessage).toBe('generic.unknownError');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('onDeleteColumn', () => {
|
||||
it('should return early when column is not found', async () => {
|
||||
const colDefs = ref([]);
|
||||
const { onDeleteColumn } = useDataTableOperations({ ...params, colDefs });
|
||||
await onDeleteColumn('non-existent-column');
|
||||
|
||||
expect(confirmMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early when user cancels confirmation', async () => {
|
||||
confirmMock.mockResolvedValue('cancel');
|
||||
|
||||
const colDefs = ref([
|
||||
{ colId: 'col1', field: 'name', headerName: 'Name', cellDataType: 'text' },
|
||||
]);
|
||||
|
||||
const { onDeleteColumn } = useDataTableOperations({ ...params, colDefs });
|
||||
await onDeleteColumn('col1');
|
||||
|
||||
expect(confirmMock).toHaveBeenCalled();
|
||||
expect(params.deleteGridColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete column successfully when user confirms', async () => {
|
||||
confirmMock.mockResolvedValue(MODAL_CONFIRM);
|
||||
|
||||
const deleteDataTableColumnMock = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
deleteDataTableColumn: deleteDataTableColumnMock,
|
||||
});
|
||||
|
||||
const colDefs = ref([
|
||||
{ colId: 'col1', field: 'name', headerName: 'Name', cellDataType: 'text' },
|
||||
]);
|
||||
const rowData = ref([
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
]);
|
||||
|
||||
const { onDeleteColumn } = useDataTableOperations({ ...params, colDefs, rowData });
|
||||
await onDeleteColumn('col1');
|
||||
|
||||
expect(confirmMock).toHaveBeenCalled();
|
||||
expect(params.deleteGridColumn).toHaveBeenCalledWith('col1');
|
||||
expect(params.setGridData).toHaveBeenCalledWith({
|
||||
colDefs: colDefs.value,
|
||||
rowData: [{ id: 1 }, { id: 2 }],
|
||||
});
|
||||
expect(deleteDataTableColumnMock).toHaveBeenCalledWith('test', 'test', 'col1');
|
||||
expect(telemetryTrackMock).toHaveBeenCalledWith('User deleted data table column', {
|
||||
column_id: 'col1',
|
||||
column_type: 'text',
|
||||
data_table_id: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('should rollback changes when deletion fails', async () => {
|
||||
confirmMock.mockResolvedValue(MODAL_CONFIRM);
|
||||
|
||||
const deleteError = new Error('Delete failed');
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
deleteDataTableColumn: vi.fn().mockRejectedValue(deleteError),
|
||||
});
|
||||
|
||||
const colDefToDelete = {
|
||||
colId: 'col1',
|
||||
field: 'name',
|
||||
headerName: 'Name',
|
||||
cellDataType: 'text',
|
||||
};
|
||||
const colDefs = ref([colDefToDelete]);
|
||||
const rowData = ref([
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
]);
|
||||
|
||||
const { onDeleteColumn } = useDataTableOperations({ ...params, colDefs, rowData });
|
||||
await onDeleteColumn('col1');
|
||||
|
||||
expect(params.deleteGridColumn).toHaveBeenCalledWith('col1');
|
||||
expect(showErrorMock).toHaveBeenCalledWith(deleteError, 'dataTable.deleteColumn.error');
|
||||
expect(params.insertGridColumnAtIndex).toHaveBeenCalledWith(colDefToDelete, 0);
|
||||
expect(rowData.value).toEqual([
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
]);
|
||||
expect(params.setGridData).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onColumnMoved', () => {
|
||||
const createMockColumn = (colId: string) => ({
|
||||
getColId: () => colId,
|
||||
});
|
||||
|
||||
it('should return early when event is not finished', async () => {
|
||||
const { onColumnMoved } = useDataTableOperations(params);
|
||||
const moveEvent = {
|
||||
finished: false,
|
||||
source: 'uiColumnMoved',
|
||||
toIndex: 2,
|
||||
column: createMockColumn('col1'),
|
||||
} as unknown as ColumnMovedEvent;
|
||||
|
||||
await onColumnMoved(moveEvent);
|
||||
|
||||
expect(params.moveGridColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early when source is not uiColumnMoved', async () => {
|
||||
const { onColumnMoved } = useDataTableOperations(params);
|
||||
const moveEvent = {
|
||||
finished: true,
|
||||
source: 'api',
|
||||
toIndex: 2,
|
||||
column: createMockColumn('col1'),
|
||||
} as unknown as ColumnMovedEvent;
|
||||
|
||||
await onColumnMoved(moveEvent);
|
||||
|
||||
expect(params.moveGridColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early when toIndex is undefined', async () => {
|
||||
const { onColumnMoved } = useDataTableOperations(params);
|
||||
const moveEvent = {
|
||||
finished: true,
|
||||
source: 'uiColumnMoved',
|
||||
toIndex: undefined,
|
||||
column: createMockColumn('col1'),
|
||||
} as unknown as ColumnMovedEvent;
|
||||
|
||||
await onColumnMoved(moveEvent);
|
||||
|
||||
expect(params.moveGridColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early when column is not provided', async () => {
|
||||
const { onColumnMoved } = useDataTableOperations(params);
|
||||
const moveEvent = {
|
||||
finished: true,
|
||||
source: 'uiColumnMoved',
|
||||
toIndex: 2,
|
||||
column: null,
|
||||
} as unknown as ColumnMovedEvent;
|
||||
|
||||
await onColumnMoved(moveEvent);
|
||||
|
||||
expect(params.moveGridColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should move column successfully', async () => {
|
||||
const moveDataTableColumnMock = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
moveDataTableColumn: moveDataTableColumnMock,
|
||||
});
|
||||
|
||||
const colDefs = ref([
|
||||
{ colId: 'col1', field: 'name' },
|
||||
{ colId: 'col2', field: 'age' },
|
||||
{ colId: 'col3', field: 'email' },
|
||||
]);
|
||||
|
||||
const { onColumnMoved } = useDataTableOperations({ ...params, colDefs });
|
||||
const moveEvent = {
|
||||
finished: true,
|
||||
source: 'uiColumnMoved',
|
||||
toIndex: 4, // AG Grid includes selection and id columns, so actual index is 4-2=2
|
||||
column: createMockColumn('col1'),
|
||||
} as unknown as ColumnMovedEvent;
|
||||
|
||||
await onColumnMoved(moveEvent);
|
||||
|
||||
expect(moveDataTableColumnMock).toHaveBeenCalledWith('test', 'test', 'col1', 2);
|
||||
expect(params.moveGridColumn).toHaveBeenCalledWith(0, 2);
|
||||
});
|
||||
|
||||
it('should rollback move when API call fails', async () => {
|
||||
const moveError = new Error('Move failed');
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
moveDataTableColumn: vi.fn().mockRejectedValue(moveError),
|
||||
});
|
||||
|
||||
const moveColumnByIndex = vi.fn();
|
||||
const gridApi = ref({
|
||||
moveColumnByIndex,
|
||||
} as unknown as GridApi);
|
||||
|
||||
const colDefs = ref([
|
||||
{ colId: 'col1', field: 'name' },
|
||||
{ colId: 'col2', field: 'age' },
|
||||
]);
|
||||
|
||||
const { onColumnMoved } = useDataTableOperations({ ...params, colDefs, gridApi });
|
||||
const moveEvent = {
|
||||
finished: true,
|
||||
source: 'uiColumnMoved',
|
||||
toIndex: 3,
|
||||
column: createMockColumn('col1'),
|
||||
} as unknown as ColumnMovedEvent;
|
||||
|
||||
await onColumnMoved(moveEvent);
|
||||
|
||||
expect(showErrorMock).toHaveBeenCalledWith(moveError, 'dataTable.moveColumn.error');
|
||||
expect(moveColumnByIndex).toHaveBeenCalledWith(3, 1); // oldIndex (0) + 1
|
||||
expect(params.moveGridColumn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onAddRowClick', () => {
|
||||
it('should add row successfully', async () => {
|
||||
const insertEmptyRowMock = vi.fn().mockResolvedValue({ id: 123, name: null });
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
insertEmptyRow: insertEmptyRowMock,
|
||||
});
|
||||
|
||||
const rowData = ref([{ id: 1 }, { id: 2 }]);
|
||||
const totalItems = ref(2);
|
||||
|
||||
const { onAddRowClick, contentLoading } = useDataTableOperations({
|
||||
...params,
|
||||
rowData,
|
||||
totalItems,
|
||||
});
|
||||
|
||||
await onAddRowClick();
|
||||
|
||||
expect(params.ensureItemOnPage).toHaveBeenCalledWith(3);
|
||||
expect(params.toggleSave).toHaveBeenCalledWith(true);
|
||||
expect(insertEmptyRowMock).toHaveBeenCalledWith('test', 'test');
|
||||
expect(rowData.value).toHaveLength(3);
|
||||
expect(rowData.value[2]).toEqual({ id: 123, name: null });
|
||||
expect(params.setTotalItems).toHaveBeenCalledWith(3);
|
||||
expect(params.setGridData).toHaveBeenCalledWith({ rowData: rowData.value });
|
||||
expect(params.focusFirstEditableCell).toHaveBeenCalledWith(123);
|
||||
expect(telemetryTrackMock).toHaveBeenCalledWith('User added row to data table', {
|
||||
data_table_id: 'test',
|
||||
});
|
||||
expect(params.toggleSave).toHaveBeenCalledWith(false);
|
||||
expect(contentLoading.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle error and show toast', async () => {
|
||||
const addRowError = new Error('Failed to add row');
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
insertEmptyRow: vi.fn().mockRejectedValue(addRowError),
|
||||
});
|
||||
|
||||
const rowData = ref([{ id: 1 }]);
|
||||
const totalItems = ref(1);
|
||||
|
||||
const { onAddRowClick } = useDataTableOperations({
|
||||
...params,
|
||||
rowData,
|
||||
totalItems,
|
||||
});
|
||||
|
||||
await onAddRowClick();
|
||||
|
||||
expect(showErrorMock).toHaveBeenCalledWith(addRowError, 'dataTable.addRow.error');
|
||||
expect(rowData.value).toHaveLength(1);
|
||||
expect(params.setTotalItems).not.toHaveBeenCalled();
|
||||
expect(params.focusFirstEditableCell).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should always reset loading state in finally block', async () => {
|
||||
const addRowError = new Error('Failed to add row');
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
insertEmptyRow: vi.fn().mockRejectedValue(addRowError),
|
||||
});
|
||||
|
||||
const { onAddRowClick, contentLoading } = useDataTableOperations(params);
|
||||
|
||||
await onAddRowClick();
|
||||
|
||||
expect(params.toggleSave).toHaveBeenCalledWith(true);
|
||||
expect(params.toggleSave).toHaveBeenCalledWith(false);
|
||||
expect(contentLoading.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCellValueChanged', () => {
|
||||
it('should return early when field name is empty', async () => {
|
||||
const { onCellValueChanged } = useDataTableOperations(params);
|
||||
const event = {
|
||||
data: { id: 1 },
|
||||
api: { applyTransaction: vi.fn() },
|
||||
oldValue: 'old',
|
||||
colDef: { field: '', cellDataType: 'text' },
|
||||
} as unknown as CellValueChangedEvent<DataTableRow>;
|
||||
|
||||
await onCellValueChanged(event);
|
||||
|
||||
expect(params.toggleSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early when value is undefined', async () => {
|
||||
const { onCellValueChanged } = useDataTableOperations(params);
|
||||
const event = {
|
||||
data: { id: 1 },
|
||||
api: { applyTransaction: vi.fn() },
|
||||
oldValue: 'old',
|
||||
colDef: { field: 'name', cellDataType: 'text' },
|
||||
} as unknown as CellValueChangedEvent<DataTableRow>;
|
||||
|
||||
await onCellValueChanged(event);
|
||||
|
||||
expect(params.toggleSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early when values are equal', async () => {
|
||||
const { onCellValueChanged } = useDataTableOperations(params);
|
||||
const event = {
|
||||
data: { id: 1, name: 'John' },
|
||||
api: { applyTransaction: vi.fn() },
|
||||
oldValue: 'John',
|
||||
colDef: { field: 'name', cellDataType: 'text' },
|
||||
} as unknown as CellValueChangedEvent<DataTableRow>;
|
||||
|
||||
await onCellValueChanged(event);
|
||||
|
||||
expect(params.toggleSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error when row id is not a number', async () => {
|
||||
const { onCellValueChanged } = useDataTableOperations(params);
|
||||
const event = {
|
||||
data: { id: 'not-a-number', name: 'Jane' },
|
||||
api: { applyTransaction: vi.fn() },
|
||||
oldValue: 'John',
|
||||
colDef: { field: 'name', cellDataType: 'text' },
|
||||
} as unknown as CellValueChangedEvent<DataTableRow>;
|
||||
|
||||
await expect(onCellValueChanged(event)).rejects.toThrow('Expected row id to be a number');
|
||||
});
|
||||
|
||||
it('should update cell value successfully', async () => {
|
||||
const updateRowMock = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
updateRow: updateRowMock,
|
||||
});
|
||||
|
||||
const { onCellValueChanged } = useDataTableOperations(params);
|
||||
const event = {
|
||||
data: { id: 1, name: 'Jane' },
|
||||
api: { applyTransaction: vi.fn() },
|
||||
oldValue: 'John',
|
||||
colDef: { field: 'name', cellDataType: 'text', colId: 'col1' },
|
||||
} as unknown as CellValueChangedEvent<DataTableRow>;
|
||||
|
||||
await onCellValueChanged(event);
|
||||
|
||||
expect(params.toggleSave).toHaveBeenCalledWith(true);
|
||||
expect(updateRowMock).toHaveBeenCalledWith('test', 'test', 1, { name: 'Jane' });
|
||||
expect(telemetryTrackMock).toHaveBeenCalledWith('User edited data table content', {
|
||||
data_table_id: 'test',
|
||||
column_id: 'col1',
|
||||
column_type: 'text',
|
||||
});
|
||||
expect(params.toggleSave).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should revert cell value when update fails', async () => {
|
||||
const isDataTableValue = await import('@/features/dataTable/typeGuards').then(
|
||||
(m) => m.isDataTableValue,
|
||||
);
|
||||
vi.mocked(isDataTableValue).mockReturnValue(true);
|
||||
|
||||
const updateError = new Error('Update failed');
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
updateRow: vi.fn().mockRejectedValue(updateError),
|
||||
});
|
||||
|
||||
const applyTransaction = vi.fn();
|
||||
const { onCellValueChanged } = useDataTableOperations(params);
|
||||
const event = {
|
||||
data: { id: 1, name: 'Jane' },
|
||||
api: { applyTransaction },
|
||||
oldValue: 'John',
|
||||
colDef: { field: 'name', cellDataType: 'text', colId: 'col1' },
|
||||
} as unknown as CellValueChangedEvent<DataTableRow>;
|
||||
|
||||
await onCellValueChanged(event);
|
||||
|
||||
expect(applyTransaction).toHaveBeenCalledWith({
|
||||
update: [{ id: 1, name: 'John' }],
|
||||
});
|
||||
expect(showErrorMock).toHaveBeenCalledWith(updateError, 'dataTable.updateRow.error');
|
||||
expect(params.toggleSave).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should revert cell value to null when old value is invalid', async () => {
|
||||
const isDataTableValue = await import('@/features/dataTable/typeGuards').then(
|
||||
(m) => m.isDataTableValue,
|
||||
);
|
||||
vi.mocked(isDataTableValue).mockReturnValue(false);
|
||||
|
||||
const updateError = new Error('Update failed');
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
updateRow: vi.fn().mockRejectedValue(updateError),
|
||||
});
|
||||
|
||||
const applyTransaction = vi.fn();
|
||||
const { onCellValueChanged } = useDataTableOperations(params);
|
||||
const event = {
|
||||
data: { id: 1, name: 'Jane' },
|
||||
api: { applyTransaction },
|
||||
oldValue: undefined,
|
||||
colDef: { field: 'name', cellDataType: 'text', colId: 'col1' },
|
||||
} as unknown as CellValueChangedEvent<DataTableRow>;
|
||||
|
||||
await onCellValueChanged(event);
|
||||
|
||||
expect(applyTransaction).toHaveBeenCalledWith({
|
||||
update: [{ id: 1, name: null }],
|
||||
});
|
||||
expect(showErrorMock).toHaveBeenCalledWith(updateError, 'dataTable.updateRow.error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchDataTableRows', () => {
|
||||
it('should fetch rows successfully', async () => {
|
||||
const fetchedData = {
|
||||
data: [
|
||||
{ id: 1, name: 'John' },
|
||||
{ id: 2, name: 'Jane' },
|
||||
],
|
||||
count: 10,
|
||||
};
|
||||
|
||||
const fetchDataTableContentMock = vi.fn().mockResolvedValue(fetchedData);
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
fetchDataTableContent: fetchDataTableContentMock,
|
||||
});
|
||||
|
||||
const rowData = ref([]);
|
||||
const currentPage = ref(2);
|
||||
const pageSize = ref(20);
|
||||
const currentSortBy = ref('name');
|
||||
const currentSortOrder = ref('asc');
|
||||
const currentFilterJSON = ref('{"status":"active"}');
|
||||
|
||||
const { fetchDataTableRows, contentLoading } = useDataTableOperations({
|
||||
...params,
|
||||
rowData,
|
||||
currentPage,
|
||||
pageSize,
|
||||
currentSortBy,
|
||||
currentSortOrder,
|
||||
currentFilterJSON,
|
||||
});
|
||||
|
||||
await fetchDataTableRows();
|
||||
|
||||
expect(fetchDataTableContentMock).toHaveBeenCalledWith(
|
||||
'test',
|
||||
'test',
|
||||
2,
|
||||
20,
|
||||
'name:asc',
|
||||
'{"status":"active"}',
|
||||
);
|
||||
expect(rowData.value).toEqual(fetchedData.data);
|
||||
expect(params.setTotalItems).toHaveBeenCalledWith(10);
|
||||
expect(params.setGridData).toHaveBeenCalledWith({ rowData: fetchedData.data });
|
||||
expect(params.handleClearSelection).toHaveBeenCalled();
|
||||
expect(contentLoading.value).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle undefined currentFilterJSON', async () => {
|
||||
const fetchedData = {
|
||||
data: [{ id: 1 }],
|
||||
count: 1,
|
||||
};
|
||||
|
||||
const fetchDataTableContentMock = vi.fn().mockResolvedValue(fetchedData);
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
fetchDataTableContent: fetchDataTableContentMock,
|
||||
});
|
||||
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const currentSortBy = ref('id');
|
||||
const currentSortOrder = ref('desc');
|
||||
|
||||
const { fetchDataTableRows } = useDataTableOperations({
|
||||
...params,
|
||||
currentPage,
|
||||
pageSize,
|
||||
currentSortBy,
|
||||
currentSortOrder,
|
||||
});
|
||||
|
||||
await fetchDataTableRows();
|
||||
|
||||
expect(fetchDataTableContentMock).toHaveBeenCalledWith(
|
||||
'test',
|
||||
'test',
|
||||
1,
|
||||
10,
|
||||
'id:desc',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle error and show toast', async () => {
|
||||
const fetchError = new Error('Failed to fetch');
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
fetchDataTableContent: vi.fn().mockRejectedValue(fetchError),
|
||||
});
|
||||
|
||||
const rowData = ref([{ id: 1 }]);
|
||||
|
||||
const { fetchDataTableRows } = useDataTableOperations({ ...params, rowData });
|
||||
|
||||
await fetchDataTableRows();
|
||||
|
||||
expect(showErrorMock).toHaveBeenCalledWith(fetchError, 'dataTable.fetchContent.error');
|
||||
expect(rowData.value).toEqual([{ id: 1 }]);
|
||||
expect(params.setTotalItems).not.toHaveBeenCalled();
|
||||
expect(params.handleClearSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should always reset loading state in finally block', async () => {
|
||||
const fetchError = new Error('Failed to fetch');
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
fetchDataTableContent: vi.fn().mockRejectedValue(fetchError),
|
||||
});
|
||||
|
||||
const { fetchDataTableRows, contentLoading } = useDataTableOperations(params);
|
||||
|
||||
await fetchDataTableRows();
|
||||
|
||||
expect(contentLoading.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleDeleteSelected', () => {
|
||||
it('should return early when no rows are selected', async () => {
|
||||
const selectedRowIds = ref(new Set<number>());
|
||||
const { handleDeleteSelected } = useDataTableOperations({ ...params, selectedRowIds });
|
||||
|
||||
await handleDeleteSelected();
|
||||
|
||||
expect(confirmMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early when user cancels confirmation', async () => {
|
||||
confirmMock.mockResolvedValue('cancel');
|
||||
|
||||
const selectedRowIds = ref(new Set([1, 2, 3]));
|
||||
const { handleDeleteSelected } = useDataTableOperations({ ...params, selectedRowIds });
|
||||
|
||||
await handleDeleteSelected();
|
||||
|
||||
expect(confirmMock).toHaveBeenCalled();
|
||||
expect(params.toggleSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should delete selected rows successfully', async () => {
|
||||
confirmMock.mockResolvedValue(MODAL_CONFIRM);
|
||||
|
||||
const deleteRowsMock = vi.fn().mockResolvedValue(undefined);
|
||||
const fetchDataTableContentMock = vi.fn().mockResolvedValue({
|
||||
data: [],
|
||||
count: 0,
|
||||
});
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
deleteRows: deleteRowsMock,
|
||||
fetchDataTableContent: fetchDataTableContentMock,
|
||||
});
|
||||
|
||||
const selectedRowIds = ref(new Set([1, 2, 3]));
|
||||
const { handleDeleteSelected } = useDataTableOperations({ ...params, selectedRowIds });
|
||||
|
||||
await handleDeleteSelected();
|
||||
|
||||
expect(params.toggleSave).toHaveBeenCalledWith(true);
|
||||
expect(deleteRowsMock).toHaveBeenCalledWith('test', 'test', [1, 2, 3]);
|
||||
expect(fetchDataTableContentMock).toHaveBeenCalled();
|
||||
expect(telemetryTrackMock).toHaveBeenCalledWith('User deleted rows in data table', {
|
||||
data_table_id: 'test',
|
||||
deleted_row_count: 3,
|
||||
});
|
||||
expect(params.toggleSave).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should handle error and show toast', async () => {
|
||||
confirmMock.mockResolvedValue(MODAL_CONFIRM);
|
||||
|
||||
const deleteError = new Error('Delete failed');
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
deleteRows: vi.fn().mockRejectedValue(deleteError),
|
||||
});
|
||||
|
||||
const selectedRowIds = ref(new Set([1, 2]));
|
||||
const { handleDeleteSelected } = useDataTableOperations({ ...params, selectedRowIds });
|
||||
|
||||
await handleDeleteSelected();
|
||||
|
||||
expect(showErrorMock).toHaveBeenCalledWith(deleteError, 'dataTable.deleteRows.error');
|
||||
expect(params.toggleSave).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should always reset save state in finally block', async () => {
|
||||
confirmMock.mockResolvedValue(MODAL_CONFIRM);
|
||||
|
||||
const deleteError = new Error('Delete failed');
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
deleteRows: vi.fn().mockRejectedValue(deleteError),
|
||||
});
|
||||
|
||||
const selectedRowIds = ref(new Set([1]));
|
||||
const { handleDeleteSelected } = useDataTableOperations({ ...params, selectedRowIds });
|
||||
|
||||
await handleDeleteSelected();
|
||||
|
||||
expect(params.toggleSave).toHaveBeenCalledWith(true);
|
||||
expect(params.toggleSave).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onCellKeyDown', () => {
|
||||
const createKeyDownEvent = (
|
||||
key: string,
|
||||
options: { metaKey?: boolean; ctrlKey?: boolean; target?: HTMLElement } = {},
|
||||
): CellKeyDownEvent<DataTableRow> => {
|
||||
const preventDefault = vi.fn();
|
||||
return {
|
||||
event: {
|
||||
key,
|
||||
metaKey: options.metaKey || false,
|
||||
ctrlKey: options.ctrlKey || false,
|
||||
target: options.target || document.createElement('div'),
|
||||
preventDefault,
|
||||
} as unknown as KeyboardEvent,
|
||||
column: { getColId: () => 'col1' },
|
||||
api: { getEditingCells: () => [] },
|
||||
} as unknown as CellKeyDownEvent<DataTableRow>;
|
||||
};
|
||||
|
||||
it('should return early when cells are being edited', async () => {
|
||||
const { onCellKeyDown } = useDataTableOperations(params);
|
||||
const event = createKeyDownEvent('Delete');
|
||||
event.api.getEditingCells = () => [
|
||||
{
|
||||
rowIndex: 0,
|
||||
column: {},
|
||||
} as unknown as ReturnType<GridApi['getEditingCells']>[0],
|
||||
];
|
||||
|
||||
await onCellKeyDown(event);
|
||||
|
||||
expect(params.handleCopyFocusedCell).not.toHaveBeenCalled();
|
||||
expect(params.handleClearSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return early when target is input element in non-selection column', async () => {
|
||||
const { onCellKeyDown } = useDataTableOperations(params);
|
||||
const input = document.createElement('input');
|
||||
const event = createKeyDownEvent('Delete', { target: input });
|
||||
|
||||
await onCellKeyDown(event);
|
||||
|
||||
expect(params.handleClearSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Cmd+C copy shortcut', async () => {
|
||||
const { onCellKeyDown } = useDataTableOperations(params);
|
||||
const event = createKeyDownEvent('c', { metaKey: true });
|
||||
|
||||
await onCellKeyDown(event);
|
||||
|
||||
expect(event.event?.preventDefault).toHaveBeenCalled();
|
||||
expect(params.handleCopyFocusedCell).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('should handle Ctrl+C copy shortcut', async () => {
|
||||
const { onCellKeyDown } = useDataTableOperations(params);
|
||||
const event = createKeyDownEvent('c', { ctrlKey: true });
|
||||
|
||||
await onCellKeyDown(event);
|
||||
|
||||
expect(event.event?.preventDefault).toHaveBeenCalled();
|
||||
expect(params.handleCopyFocusedCell).toHaveBeenCalledWith(event);
|
||||
});
|
||||
|
||||
it('should handle Escape key', async () => {
|
||||
const { onCellKeyDown } = useDataTableOperations(params);
|
||||
const event = createKeyDownEvent('Escape');
|
||||
|
||||
await onCellKeyDown(event);
|
||||
|
||||
expect(params.handleClearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Delete key when rows are selected', async () => {
|
||||
confirmMock.mockResolvedValue(MODAL_CONFIRM);
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
deleteRows: vi.fn().mockResolvedValue(undefined),
|
||||
fetchDataTableContent: vi.fn().mockResolvedValue({ data: [], count: 0 }),
|
||||
});
|
||||
|
||||
const selectedRowIds = ref(new Set([1, 2]));
|
||||
const { onCellKeyDown } = useDataTableOperations({ ...params, selectedRowIds });
|
||||
const event = createKeyDownEvent('Delete');
|
||||
|
||||
await onCellKeyDown(event);
|
||||
|
||||
expect(event.event?.preventDefault).toHaveBeenCalled();
|
||||
expect(confirmMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Backspace key when rows are selected', async () => {
|
||||
confirmMock.mockResolvedValue(MODAL_CONFIRM);
|
||||
vi.mocked(useDataTableStore).mockReturnValue({
|
||||
...dataTableStore,
|
||||
deleteRows: vi.fn().mockResolvedValue(undefined),
|
||||
fetchDataTableContent: vi.fn().mockResolvedValue({ data: [], count: 0 }),
|
||||
});
|
||||
|
||||
const selectedRowIds = ref(new Set([3]));
|
||||
const { onCellKeyDown } = useDataTableOperations({ ...params, selectedRowIds });
|
||||
const event = createKeyDownEvent('Backspace');
|
||||
|
||||
await onCellKeyDown(event);
|
||||
|
||||
expect(event.event?.preventDefault).toHaveBeenCalled();
|
||||
expect(confirmMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not handle Delete key when no rows are selected', async () => {
|
||||
const selectedRowIds = ref(new Set<number>());
|
||||
const { onCellKeyDown } = useDataTableOperations({ ...params, selectedRowIds });
|
||||
const event = createKeyDownEvent('Delete');
|
||||
|
||||
await onCellKeyDown(event);
|
||||
|
||||
expect(event.event?.preventDefault).not.toHaveBeenCalled();
|
||||
expect(confirmMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not handle other keys', async () => {
|
||||
const { onCellKeyDown } = useDataTableOperations(params);
|
||||
const event = createKeyDownEvent('a');
|
||||
|
||||
await onCellKeyDown(event);
|
||||
|
||||
expect(params.handleCopyFocusedCell).not.toHaveBeenCalled();
|
||||
expect(params.handleClearSelection).not.toHaveBeenCalled();
|
||||
expect(confirmMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+27
-4
@@ -21,6 +21,7 @@ import { MODAL_CONFIRM } from '@/constants';
|
||||
import { isDataTableValue, isAGGridCellType } from '@/features/dataTable/typeGuards';
|
||||
import { useDataTableTypes } from '@/features/dataTable/composables/useDataTableTypes';
|
||||
import { areValuesEqual } from '@/features/dataTable/utils/typeUtils';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
|
||||
export type UseDataTableOperationsParams = {
|
||||
colDefs: Ref<ColDef[]>;
|
||||
@@ -84,6 +85,28 @@ export const useDataTableOperations = ({
|
||||
const telemetry = useTelemetry();
|
||||
const dataTableTypes = useDataTableTypes();
|
||||
|
||||
const getAddColumnError = (error: unknown): { httpStatus: number; message: string } => {
|
||||
const DEFAULT_HTTP_STATUS = 500;
|
||||
const DEFAULT_MESSAGE = i18n.baseText('generic.unknownError');
|
||||
|
||||
if (error instanceof ResponseError) {
|
||||
return {
|
||||
httpStatus: error.httpStatusCode ?? 500,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
httpStatus: DEFAULT_HTTP_STATUS,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
httpStatus: DEFAULT_HTTP_STATUS,
|
||||
message: DEFAULT_MESSAGE,
|
||||
};
|
||||
};
|
||||
|
||||
async function onDeleteColumn(columnId: string) {
|
||||
const columnToDelete = colDefs.value.find((col) => col.colId === columnId);
|
||||
if (!columnToDelete) return;
|
||||
@@ -110,7 +133,7 @@ export const useDataTableOperations = ({
|
||||
const { [columnToDelete.field!]: _, ...rest } = row;
|
||||
return rest;
|
||||
});
|
||||
setGridData({ rowData: rowData.value });
|
||||
setGridData({ colDefs: colDefs.value, rowData: rowData.value });
|
||||
try {
|
||||
await dataTableStore.deleteDataTableColumn(dataTableId, projectId, columnId);
|
||||
telemetry.track('User deleted data table column', {
|
||||
@@ -122,7 +145,7 @@ export const useDataTableOperations = ({
|
||||
toast.showError(error, i18n.baseText('dataTable.deleteColumn.error'));
|
||||
insertGridColumnAtIndex(columnToDelete, columnToDeleteIndex);
|
||||
rowData.value = rowDataOldValue;
|
||||
setGridData({ rowData: rowData.value });
|
||||
setGridData({ colDefs: colDefs.value, rowData: rowData.value });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,7 +156,7 @@ export const useDataTableOperations = ({
|
||||
rowData.value = rowData.value.map((row) => {
|
||||
return { ...row, [newColumn.name]: null };
|
||||
});
|
||||
setGridData({ rowData: rowData.value });
|
||||
setGridData({ colDefs: colDefs.value, rowData: rowData.value });
|
||||
telemetry.track('User added data table column', {
|
||||
column_id: newColumn.id,
|
||||
column_type: newColumn.type,
|
||||
@@ -141,7 +164,7 @@ export const useDataTableOperations = ({
|
||||
});
|
||||
return { success: true, httpStatus: 200 };
|
||||
} catch (error) {
|
||||
const addColumnError = dataTableTypes.getAddColumnError(error);
|
||||
const addColumnError = getAddColumnError(error);
|
||||
return {
|
||||
success: false,
|
||||
httpStatus: addColumnError.httpStatus,
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { useDataTablePagination } from './useDataTablePagination';
|
||||
|
||||
describe('useDataTablePagination', () => {
|
||||
describe('initialization', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const pagination = useDataTablePagination();
|
||||
|
||||
expect(pagination.currentPage.value).toBe(1);
|
||||
expect(pagination.pageSize.value).toBe(20);
|
||||
expect(pagination.totalItems.value).toBe(0);
|
||||
expect(pagination.pageSizeOptions).toEqual([10, 20, 50]);
|
||||
});
|
||||
|
||||
it('should initialize with custom values', () => {
|
||||
const pagination = useDataTablePagination({
|
||||
initialPage: 3,
|
||||
initialPageSize: 50,
|
||||
pageSizeOptions: [10, 20],
|
||||
});
|
||||
|
||||
expect(pagination.currentPage.value).toBe(3);
|
||||
expect(pagination.pageSize.value).toBe(50);
|
||||
expect(pagination.pageSizeOptions).toEqual([10, 20]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTotalItems', () => {
|
||||
it('should update total items', () => {
|
||||
const pagination = useDataTablePagination();
|
||||
|
||||
pagination.setTotalItems(100);
|
||||
|
||||
expect(pagination.totalItems.value).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCurrentPage', () => {
|
||||
it('should update current page', async () => {
|
||||
const pagination = useDataTablePagination();
|
||||
|
||||
await pagination.setCurrentPage(3);
|
||||
|
||||
expect(pagination.currentPage.value).toBe(3);
|
||||
});
|
||||
|
||||
it('should call onChange callback when provided', async () => {
|
||||
const onChange = vi.fn();
|
||||
const pagination = useDataTablePagination({ onChange });
|
||||
|
||||
await pagination.setCurrentPage(2);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(2, 20);
|
||||
});
|
||||
|
||||
it('should not throw when onChange is not provided', async () => {
|
||||
const pagination = useDataTablePagination();
|
||||
|
||||
await expect(pagination.setCurrentPage(2)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setPageSize', () => {
|
||||
it('should update page size and reset to page 1', async () => {
|
||||
const pagination = useDataTablePagination({ initialPage: 3 });
|
||||
|
||||
await pagination.setPageSize(50);
|
||||
|
||||
expect(pagination.pageSize.value).toBe(50);
|
||||
expect(pagination.currentPage.value).toBe(1);
|
||||
});
|
||||
|
||||
it('should call onChange callback with new page size and page 1', async () => {
|
||||
const onChange = vi.fn();
|
||||
const pagination = useDataTablePagination({ onChange, initialPage: 5 });
|
||||
|
||||
await pagination.setPageSize(10);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(1, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureItemOnPage', () => {
|
||||
it('should not change page if item is already on current page', async () => {
|
||||
const onChange = vi.fn();
|
||||
const pagination = useDataTablePagination({ onChange, initialPageSize: 20 });
|
||||
|
||||
await pagination.ensureItemOnPage(15);
|
||||
|
||||
expect(pagination.currentPage.value).toBe(1);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should change to correct page for item index', async () => {
|
||||
const onChange = vi.fn();
|
||||
const pagination = useDataTablePagination({ onChange, initialPageSize: 20 });
|
||||
|
||||
await pagination.ensureItemOnPage(25);
|
||||
|
||||
expect(pagination.currentPage.value).toBe(2);
|
||||
expect(onChange).toHaveBeenCalledWith(2, 20);
|
||||
});
|
||||
|
||||
it('should calculate correct page for various item indices', async () => {
|
||||
const pagination = useDataTablePagination({ initialPageSize: 10 });
|
||||
|
||||
await pagination.ensureItemOnPage(1);
|
||||
expect(pagination.currentPage.value).toBe(1);
|
||||
|
||||
await pagination.ensureItemOnPage(10);
|
||||
expect(pagination.currentPage.value).toBe(1);
|
||||
|
||||
await pagination.ensureItemOnPage(11);
|
||||
expect(pagination.currentPage.value).toBe(2);
|
||||
|
||||
await pagination.ensureItemOnPage(21);
|
||||
expect(pagination.currentPage.value).toBe(3);
|
||||
});
|
||||
|
||||
it('should handle edge case of index 0', async () => {
|
||||
const pagination = useDataTablePagination({ initialPageSize: 10 });
|
||||
|
||||
await pagination.ensureItemOnPage(0);
|
||||
|
||||
expect(pagination.currentPage.value).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { useDataTableTypes } from './useDataTableTypes';
|
||||
import type { AGGridCellType } from '@/features/dataTable/dataTable.types';
|
||||
|
||||
describe('useDataTableTypes', () => {
|
||||
const { getIconForType, mapToAGCellType, mapToDataTableColumnType } = useDataTableTypes();
|
||||
|
||||
describe('getIconForType', () => {
|
||||
it('should return correct icon for string type', () => {
|
||||
expect(getIconForType('string')).toBe('type');
|
||||
});
|
||||
|
||||
it('should return correct icon for number type', () => {
|
||||
expect(getIconForType('number')).toBe('hash');
|
||||
});
|
||||
|
||||
it('should return correct icon for boolean type', () => {
|
||||
expect(getIconForType('boolean')).toBe('square-check');
|
||||
});
|
||||
|
||||
it('should return correct icon for date type', () => {
|
||||
expect(getIconForType('date')).toBe('calendar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapToAGCellType', () => {
|
||||
it('should map string to text', () => {
|
||||
expect(mapToAGCellType('string')).toBe('text');
|
||||
});
|
||||
|
||||
it('should map number to number', () => {
|
||||
expect(mapToAGCellType('number')).toBe('number');
|
||||
});
|
||||
|
||||
it('should map boolean to boolean', () => {
|
||||
expect(mapToAGCellType('boolean')).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should map date to date', () => {
|
||||
expect(mapToAGCellType('date')).toBe('date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapToDataTableColumnType', () => {
|
||||
it('should map text to string', () => {
|
||||
expect(mapToDataTableColumnType('text')).toBe('string');
|
||||
});
|
||||
|
||||
it('should map number to number', () => {
|
||||
expect(mapToDataTableColumnType('number')).toBe('number');
|
||||
});
|
||||
|
||||
it('should map boolean to boolean', () => {
|
||||
expect(mapToDataTableColumnType('boolean')).toBe('boolean');
|
||||
});
|
||||
|
||||
it('should map date to date', () => {
|
||||
expect(mapToDataTableColumnType('date')).toBe('date');
|
||||
});
|
||||
|
||||
it('should preserve dateString type', () => {
|
||||
expect(mapToDataTableColumnType('dateString')).toBe('dateString');
|
||||
});
|
||||
|
||||
it('should preserve object type', () => {
|
||||
expect(mapToDataTableColumnType('object')).toBe('object');
|
||||
});
|
||||
|
||||
it('should map unknown type to string', () => {
|
||||
expect(mapToDataTableColumnType('unknown' as AGGridCellType)).toBe('string');
|
||||
});
|
||||
});
|
||||
});
|
||||
+1
-47
@@ -1,16 +1,9 @@
|
||||
import type {
|
||||
AGGridCellType,
|
||||
DataTableColumnType,
|
||||
DataTableValue,
|
||||
} from '@/features/dataTable/dataTable.types';
|
||||
import type { AGGridCellType, DataTableColumnType } from '@/features/dataTable/dataTable.types';
|
||||
import { isAGGridCellType } from '@/features/dataTable/typeGuards';
|
||||
import { ResponseError } from '@n8n/rest-api-client';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import { DATA_TYPE_ICON_MAP } from '@/constants';
|
||||
|
||||
export const useDataTableTypes = () => {
|
||||
const getIconForType = (type: DataTableColumnType) => DATA_TYPE_ICON_MAP[type];
|
||||
const i18n = useI18n();
|
||||
|
||||
/**
|
||||
* Maps a DataTableColumnType to an AGGridCellType.
|
||||
@@ -36,48 +29,9 @@ export const useDataTableTypes = () => {
|
||||
return colType as DataTableColumnType;
|
||||
};
|
||||
|
||||
const getDefaultValueForType = (colType: DataTableColumnType): DataTableValue => {
|
||||
switch (colType) {
|
||||
case 'string':
|
||||
return '';
|
||||
case 'number':
|
||||
return 0;
|
||||
case 'boolean':
|
||||
return false;
|
||||
case 'date':
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getAddColumnError = (error: unknown): { httpStatus: number; message: string } => {
|
||||
const DEFAULT_HTTP_STATUS = 500;
|
||||
const DEFAULT_MESSAGE = i18n.baseText('generic.unknownError');
|
||||
|
||||
if (error instanceof ResponseError) {
|
||||
return {
|
||||
httpStatus: error.httpStatusCode ?? 500,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
httpStatus: DEFAULT_HTTP_STATUS,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
httpStatus: DEFAULT_HTTP_STATUS,
|
||||
message: DEFAULT_MESSAGE,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
getIconForType,
|
||||
mapToAGCellType,
|
||||
mapToDataTableColumnType,
|
||||
getDefaultValueForType,
|
||||
getAddColumnError,
|
||||
};
|
||||
};
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { nextTick } from 'vue';
|
||||
import { describe, it, vi, beforeEach } from 'vitest';
|
||||
import { type DatePickerCallbacks, useDatePickerCommon } from '../useDatePickerCommon';
|
||||
import { type DatePickerCallbacks, useDatePickerCommon } from './useDatePickerCommon';
|
||||
|
||||
vi.mock('vue', async () => {
|
||||
const actual = await vi.importActual('vue');
|
||||
@@ -3,6 +3,26 @@ import { faker } from '@faker-js/faker';
|
||||
import { useRootStore } from '@n8n/stores/useRootStore';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import * as dataTableApi from '@/features/dataTable/dataTable.api';
|
||||
import { useProjectsStore } from '@/features/projects/projects.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import type { DataTable } from '@/features/dataTable/dataTable.types';
|
||||
|
||||
vi.mock('@/features/projects/projects.store');
|
||||
vi.mock('@/stores/settings.store');
|
||||
|
||||
function createTable(data: Partial<DataTable>) {
|
||||
return {
|
||||
id: faker.string.alphanumeric(10),
|
||||
name: faker.lorem.word(),
|
||||
columns: [],
|
||||
sizeBytes: 0,
|
||||
createdAt: '2021-01-01',
|
||||
updatedAt: '2021-01-01',
|
||||
projectId: 'project-1',
|
||||
projectName: 'Project',
|
||||
...data,
|
||||
};
|
||||
}
|
||||
|
||||
describe('dataTable.store', () => {
|
||||
let dataTableStore: ReturnType<typeof useDataTableStore>;
|
||||
@@ -10,6 +30,19 @@ describe('dataTable.store', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
|
||||
vi.mocked(useSettingsStore).mockReturnValue({
|
||||
settings: {
|
||||
dataTables: {
|
||||
maxSize: 10485760, // 10MB in bytes
|
||||
},
|
||||
},
|
||||
} as ReturnType<typeof useSettingsStore>);
|
||||
|
||||
vi.mocked(useProjectsStore).mockReturnValue({
|
||||
fetchProject: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useProjectsStore>);
|
||||
|
||||
rootStore = useRootStore();
|
||||
dataTableStore = useDataTableStore();
|
||||
});
|
||||
@@ -18,92 +51,376 @@ describe('dataTable.store', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('can move a column', async () => {
|
||||
const dataTableId = faker.string.alphanumeric(10);
|
||||
const columnId = 'phone';
|
||||
const targetIndex = 3;
|
||||
const projectId = 'p1';
|
||||
dataTableStore.$patch({
|
||||
dataTables: [
|
||||
describe('fetchDataTables', () => {
|
||||
it('should fetch data tables with pagination', async () => {
|
||||
const mockResponse = {
|
||||
count: 50,
|
||||
data: [
|
||||
createTable({ id: 'dt-1', name: 'Table 1' }),
|
||||
createTable({ id: 'dt-2', name: 'Table 2' }),
|
||||
],
|
||||
};
|
||||
vi.spyOn(dataTableApi, 'fetchDataTablesApi').mockResolvedValue(mockResponse);
|
||||
|
||||
await dataTableStore.fetchDataTables('project-1', 2, 10);
|
||||
|
||||
expect(dataTableApi.fetchDataTablesApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
'project-1',
|
||||
{ skip: 10, take: 10 },
|
||||
);
|
||||
expect(dataTableStore.dataTables).toEqual(mockResponse.data);
|
||||
expect(dataTableStore.totalCount).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDataTable', () => {
|
||||
it('should create data table and update state', async () => {
|
||||
const mockTable = createTable({ id: 'dt-1', name: 'New Table' });
|
||||
vi.spyOn(dataTableApi, 'createDataTableApi').mockResolvedValue(mockTable);
|
||||
|
||||
const result = await dataTableStore.createDataTable('New Table', 'p1');
|
||||
|
||||
expect(dataTableApi.createDataTableApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
'New Table',
|
||||
'p1',
|
||||
);
|
||||
expect(dataTableStore.dataTables[0]).toEqual(mockTable);
|
||||
expect(dataTableStore.totalCount).toBe(1);
|
||||
expect(result).toBe(mockTable);
|
||||
});
|
||||
|
||||
it('should fetch and attach project if missing', async () => {
|
||||
const mockTable = createTable({ id: 'dt-1', name: 'Table' });
|
||||
const mockProject = {
|
||||
id: 'p1',
|
||||
name: 'Project 1',
|
||||
icon: null,
|
||||
type: 'team' as const,
|
||||
createdAt: '2024-01-01',
|
||||
updatedAt: '2024-01-01',
|
||||
relations: [],
|
||||
scopes: [],
|
||||
};
|
||||
vi.spyOn(dataTableApi, 'createDataTableApi').mockResolvedValue(mockTable);
|
||||
|
||||
const projectStore = useProjectsStore();
|
||||
vi.mocked(projectStore.fetchProject).mockResolvedValue(mockProject);
|
||||
|
||||
await dataTableStore.createDataTable('Table', 'p1');
|
||||
|
||||
expect(projectStore.fetchProject).toHaveBeenCalledWith('p1');
|
||||
expect(dataTableStore.dataTables[0].project).toEqual(mockProject);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDataTable', () => {
|
||||
it('should delete data table and update state', async () => {
|
||||
dataTableStore.$patch({
|
||||
dataTables: [{ id: 'dt-1', name: 'Table 1', columns: [] }],
|
||||
totalCount: 1,
|
||||
});
|
||||
vi.spyOn(dataTableApi, 'deleteDataTableApi').mockResolvedValue(true);
|
||||
|
||||
const result = await dataTableStore.deleteDataTable('dt-1', 'p1');
|
||||
|
||||
expect(dataTableApi.deleteDataTableApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
'dt-1',
|
||||
'p1',
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(dataTableStore.dataTables).toHaveLength(0);
|
||||
expect(dataTableStore.totalCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateDataTable', () => {
|
||||
it('should update data table name', async () => {
|
||||
dataTableStore.$patch({
|
||||
dataTables: [createTable({ id: 'dt-1', name: 'Old Name' })],
|
||||
});
|
||||
const mockUpdated = createTable({ id: 'dt-1', name: 'New Name' });
|
||||
vi.spyOn(dataTableApi, 'updateDataTableApi').mockResolvedValue(mockUpdated);
|
||||
|
||||
const result = await dataTableStore.updateDataTable('dt-1', 'New Name', 'p1');
|
||||
|
||||
expect(dataTableApi.updateDataTableApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
'dt-1',
|
||||
'New Name',
|
||||
'p1',
|
||||
);
|
||||
expect(result).toBe(mockUpdated);
|
||||
expect(dataTableStore.dataTables[0].name).toBe('New Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchDataTableDetails', () => {
|
||||
it('should fetch single data table by id', async () => {
|
||||
const mockTable = createTable({ id: 'dt-1', name: 'Table' });
|
||||
vi.spyOn(dataTableApi, 'fetchDataTablesApi').mockResolvedValue({
|
||||
count: 1,
|
||||
data: [mockTable],
|
||||
});
|
||||
|
||||
const result = await dataTableStore.fetchDataTableDetails('dt-1', 'p1');
|
||||
|
||||
expect(dataTableApi.fetchDataTablesApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
'p1',
|
||||
undefined,
|
||||
{ projectId: 'p1', id: 'dt-1' },
|
||||
);
|
||||
expect(result).toBe(mockTable);
|
||||
expect(dataTableStore.dataTables).toEqual([mockTable]);
|
||||
});
|
||||
|
||||
it('should return null if not found', async () => {
|
||||
vi.spyOn(dataTableApi, 'fetchDataTablesApi').mockResolvedValue({ count: 0, data: [] });
|
||||
|
||||
const result = await dataTableStore.fetchDataTableDetails('dt-1', 'p1');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchOrFindDataTable', () => {
|
||||
it('should return existing table from state', async () => {
|
||||
const mockTable = createTable({ id: 'dt-1', name: 'Table' });
|
||||
dataTableStore.$patch({ dataTables: [mockTable] });
|
||||
|
||||
const result = await dataTableStore.fetchOrFindDataTable('dt-1', 'p1');
|
||||
|
||||
expect(result).toEqual(mockTable);
|
||||
});
|
||||
|
||||
it('should fetch table if not in state', async () => {
|
||||
const mockTable = createTable({ id: 'dt-1', name: 'Table' });
|
||||
vi.spyOn(dataTableApi, 'fetchDataTablesApi').mockResolvedValue({
|
||||
count: 1,
|
||||
data: [mockTable],
|
||||
});
|
||||
|
||||
const result = await dataTableStore.fetchOrFindDataTable('dt-1', 'p1');
|
||||
|
||||
expect(result).toEqual(mockTable);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addDataTableColumn', () => {
|
||||
it('should add column to data table', async () => {
|
||||
const mockColumn = { id: 'col-1', name: 'newCol', type: 'string' as const, index: 0 };
|
||||
dataTableStore.$patch({
|
||||
dataTables: [{ id: 'dt-1', name: 'Table', columns: [] }],
|
||||
});
|
||||
vi.spyOn(dataTableApi, 'addDataTableColumnApi').mockResolvedValue(mockColumn);
|
||||
|
||||
const result = await dataTableStore.addDataTableColumn('dt-1', 'p1', {
|
||||
name: 'newCol',
|
||||
type: 'string',
|
||||
});
|
||||
|
||||
expect(dataTableApi.addDataTableColumnApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
'dt-1',
|
||||
'p1',
|
||||
{ name: 'newCol', type: 'string' },
|
||||
);
|
||||
expect(result).toBe(mockColumn);
|
||||
expect(dataTableStore.dataTables[0].columns).toEqual([mockColumn]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDataTableColumn', () => {
|
||||
it('should delete column from data table', async () => {
|
||||
const dataTableId = faker.string.alphanumeric(10);
|
||||
const columnId = 'phone';
|
||||
const projectId = 'p1';
|
||||
dataTableStore.$patch({
|
||||
dataTables: [
|
||||
{ id: dataTableId, columns: [{ id: columnId, index: 0, name: 'phone', type: 'string' }] },
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
vi.spyOn(dataTableApi, 'deleteDataTableColumnApi').mockResolvedValue(true);
|
||||
|
||||
const deleted = await dataTableStore.deleteDataTableColumn(dataTableId, projectId, columnId);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(dataTableApi.deleteDataTableColumnApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
dataTableId,
|
||||
projectId,
|
||||
columnId,
|
||||
);
|
||||
expect(dataTableStore.dataTables[0].columns.find((c) => c.id === columnId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveDataTableColumn', () => {
|
||||
it('should move column to target index', async () => {
|
||||
const dataTableId = faker.string.alphanumeric(10);
|
||||
const columnId = 'phone';
|
||||
const targetIndex = 3;
|
||||
const projectId = 'p1';
|
||||
dataTableStore.$patch({
|
||||
dataTables: [
|
||||
{
|
||||
id: dataTableId,
|
||||
name: 'Test',
|
||||
sizeBytes: 0,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
projectId,
|
||||
columns: [
|
||||
{ id: 'name', index: 0, name: 'name', type: 'string' },
|
||||
{ id: columnId, index: 1, name: 'phone', type: 'string' },
|
||||
{ id: 'email', index: 2, name: 'email', type: 'string' },
|
||||
{ id: 'col4', index: 3, name: 'col4', type: 'string' },
|
||||
{ id: 'col5', index: 4, name: 'col5', type: 'string' },
|
||||
],
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
vi.spyOn(dataTableApi, 'moveDataTableColumnApi').mockResolvedValue(true);
|
||||
|
||||
const moved = await dataTableStore.moveDataTableColumn(
|
||||
dataTableId,
|
||||
projectId,
|
||||
columnId,
|
||||
targetIndex,
|
||||
);
|
||||
|
||||
expect(moved).toBe(true);
|
||||
expect(dataTableApi.moveDataTableColumnApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
dataTableId,
|
||||
projectId,
|
||||
columnId,
|
||||
targetIndex,
|
||||
);
|
||||
expect(dataTableStore.dataTables[0].columns.find((c) => c.id === columnId)?.index).toBe(
|
||||
targetIndex,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchDataTableContent', () => {
|
||||
it('should fetch rows with pagination and filters', async () => {
|
||||
const mockResponse = { count: 100, data: [{ id: 1, name: 'Row 1' }] };
|
||||
vi.spyOn(dataTableApi, 'getDataTableRowsApi').mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await dataTableStore.fetchDataTableContent(
|
||||
'dt-1',
|
||||
'p1',
|
||||
3,
|
||||
20,
|
||||
'name',
|
||||
'{"type":"and","filters":[]}',
|
||||
);
|
||||
|
||||
expect(dataTableApi.getDataTableRowsApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
'dt-1',
|
||||
'p1',
|
||||
{
|
||||
id: dataTableId,
|
||||
name: 'Test',
|
||||
sizeBytes: 0,
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
projectId,
|
||||
columns: [
|
||||
{ id: 'name', index: 0, name: 'name', type: 'string' },
|
||||
{ id: columnId, index: 1, name: 'phone', type: 'string' },
|
||||
{ id: 'email', index: 2, name: 'email', type: 'string' },
|
||||
{ id: 'col4', index: 3, name: 'col4', type: 'string' },
|
||||
{ id: 'col5', index: 4, name: 'col5', type: 'string' },
|
||||
],
|
||||
skip: 40,
|
||||
take: 20,
|
||||
sortBy: 'name',
|
||||
filter: '{"type":"and","filters":[]}',
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
);
|
||||
expect(result).toBe(mockResponse);
|
||||
});
|
||||
vi.spyOn(dataTableApi, 'moveDataTableColumnApi').mockResolvedValue(true);
|
||||
|
||||
const moved = await dataTableStore.moveDataTableColumn(
|
||||
dataTableId,
|
||||
projectId,
|
||||
columnId,
|
||||
targetIndex,
|
||||
);
|
||||
|
||||
expect(moved).toBe(true);
|
||||
expect(dataTableApi.moveDataTableColumnApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
dataTableId,
|
||||
projectId,
|
||||
columnId,
|
||||
targetIndex,
|
||||
);
|
||||
expect(dataTableStore.dataTables[0].columns.find((c) => c.id === columnId)?.index).toBe(
|
||||
targetIndex,
|
||||
);
|
||||
});
|
||||
|
||||
it('can delete a column', async () => {
|
||||
const dataTableId = faker.string.alphanumeric(10);
|
||||
const columnId = 'phone';
|
||||
const projectId = 'p1';
|
||||
dataTableStore.$patch({
|
||||
dataTables: [
|
||||
{ id: dataTableId, columns: [{ id: columnId, index: 0, name: 'phone', type: 'string' }] },
|
||||
],
|
||||
totalCount: 1,
|
||||
describe('insertEmptyRow', () => {
|
||||
it('should insert empty row', async () => {
|
||||
const mockRow = { id: 1 };
|
||||
vi.spyOn(dataTableApi, 'insertDataTableRowApi').mockResolvedValue([mockRow]);
|
||||
|
||||
const result = await dataTableStore.insertEmptyRow('dt-1', 'p1');
|
||||
|
||||
expect(dataTableApi.insertDataTableRowApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
'dt-1',
|
||||
{},
|
||||
'p1',
|
||||
);
|
||||
expect(result).toBe(mockRow);
|
||||
});
|
||||
vi.spyOn(dataTableApi, 'deleteDataTableColumnApi').mockResolvedValue(true);
|
||||
|
||||
const deleted = await dataTableStore.deleteDataTableColumn(dataTableId, projectId, columnId);
|
||||
|
||||
expect(deleted).toBe(true);
|
||||
expect(dataTableApi.deleteDataTableColumnApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
dataTableId,
|
||||
projectId,
|
||||
columnId,
|
||||
);
|
||||
expect(dataTableStore.dataTables[0].columns.find((c) => c.id === columnId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('can delete rows', async () => {
|
||||
const dataTableId = faker.string.alphanumeric(10);
|
||||
const projectId = 'p1';
|
||||
const rowIds = [1, 2, 3];
|
||||
describe('updateRow', () => {
|
||||
it('should update row data', async () => {
|
||||
vi.spyOn(dataTableApi, 'updateDataTableRowsApi').mockResolvedValue(true);
|
||||
|
||||
vi.spyOn(dataTableApi, 'deleteDataTableRowsApi').mockResolvedValue(true);
|
||||
const result = await dataTableStore.updateRow('dt-1', 'p1', 42, { name: 'Updated' });
|
||||
|
||||
const result = await dataTableStore.deleteRows(dataTableId, projectId, rowIds);
|
||||
expect(dataTableApi.updateDataTableRowsApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
'dt-1',
|
||||
42,
|
||||
{ name: 'Updated' },
|
||||
'p1',
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(dataTableApi.deleteDataTableRowsApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
dataTableId,
|
||||
rowIds,
|
||||
projectId,
|
||||
);
|
||||
describe('deleteRows', () => {
|
||||
it('should delete multiple rows', async () => {
|
||||
const dataTableId = faker.string.alphanumeric(10);
|
||||
const projectId = 'p1';
|
||||
const rowIds = [1, 2, 3];
|
||||
|
||||
vi.spyOn(dataTableApi, 'deleteDataTableRowsApi').mockResolvedValue(true);
|
||||
|
||||
const result = await dataTableStore.deleteRows(dataTableId, projectId, rowIds);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(dataTableApi.deleteDataTableRowsApi).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
dataTableId,
|
||||
rowIds,
|
||||
projectId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchDataTableSize', () => {
|
||||
it('should fetch and format size data', async () => {
|
||||
const mockResult = {
|
||||
totalBytes: 5242880, // 5MB in bytes
|
||||
quotaStatus: 'ok' as const,
|
||||
dataTables: {
|
||||
'dt-1': createTable({ id: 'dt-1', name: 'Table 1', sizeBytes: 1048576 }), // 1MB
|
||||
'dt-2': createTable({ id: 'dt-2', name: 'Table 2', sizeBytes: 2097152 }), // 2MB
|
||||
},
|
||||
};
|
||||
vi.spyOn(dataTableApi, 'fetchDataTableGlobalLimitInBytes').mockResolvedValue(mockResult);
|
||||
|
||||
const result = await dataTableStore.fetchDataTableSize();
|
||||
|
||||
expect(dataTableApi.fetchDataTableGlobalLimitInBytes).toHaveBeenCalledWith(
|
||||
rootStore.restApiContext,
|
||||
);
|
||||
expect(dataTableStore.dataTableSize).toBe(5); // 5MB
|
||||
expect(dataTableStore.dataTableSizeLimitState).toBe('ok');
|
||||
expect(dataTableStore.dataTableSizes).toEqual({
|
||||
'dt-1': 1,
|
||||
'dt-2': 2,
|
||||
});
|
||||
expect(result).toBe(mockResult);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computed properties', () => {
|
||||
it('should compute maxSizeMB from settings', () => {
|
||||
expect(dataTableStore.maxSizeMB).toBe(10);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isDataTableValue, isAGGridCellType, isDataTableColumnType } from './typeGuards';
|
||||
|
||||
describe('dataTable typeGuards', () => {
|
||||
describe('isDataTableValue', () => {
|
||||
it('should return true for null', () => {
|
||||
expect(isDataTableValue(null)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for strings', () => {
|
||||
expect(isDataTableValue('test')).toBe(true);
|
||||
expect(isDataTableValue('')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for numbers', () => {
|
||||
expect(isDataTableValue(123)).toBe(true);
|
||||
expect(isDataTableValue(0)).toBe(true);
|
||||
expect(isDataTableValue(-1)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for booleans', () => {
|
||||
expect(isDataTableValue(true)).toBe(true);
|
||||
expect(isDataTableValue(false)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for Date objects', () => {
|
||||
expect(isDataTableValue(new Date())).toBe(true);
|
||||
expect(isDataTableValue(new Date('2024-01-01'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid values', () => {
|
||||
expect(isDataTableValue(undefined)).toBe(false);
|
||||
expect(isDataTableValue({})).toBe(false);
|
||||
expect(isDataTableValue([])).toBe(false);
|
||||
expect(isDataTableValue(Symbol('test'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAGGridCellType', () => {
|
||||
it('should return true for valid AG Grid cell types', () => {
|
||||
expect(isAGGridCellType('text')).toBe(true);
|
||||
expect(isAGGridCellType('number')).toBe(true);
|
||||
expect(isAGGridCellType('boolean')).toBe(true);
|
||||
expect(isAGGridCellType('date')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid cell types', () => {
|
||||
expect(isAGGridCellType('invalid')).toBe(false);
|
||||
expect(isAGGridCellType('')).toBe(false);
|
||||
expect(isAGGridCellType(null)).toBe(false);
|
||||
expect(isAGGridCellType(123)).toBe(false);
|
||||
expect(isAGGridCellType(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDataTableColumnType', () => {
|
||||
it('should return true for valid column types', () => {
|
||||
expect(isDataTableColumnType('string')).toBe(true);
|
||||
expect(isDataTableColumnType('number')).toBe(true);
|
||||
expect(isDataTableColumnType('boolean')).toBe(true);
|
||||
expect(isDataTableColumnType('date')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for invalid column types', () => {
|
||||
expect(isDataTableColumnType('invalid')).toBe(false);
|
||||
expect(isDataTableColumnType('')).toBe(false);
|
||||
expect(isDataTableColumnType(null)).toBe(false);
|
||||
expect(isDataTableColumnType(123)).toBe(false);
|
||||
expect(isDataTableColumnType(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type {
|
||||
CellClassParams,
|
||||
ICellRendererParams,
|
||||
ValueGetterParams,
|
||||
ValueSetterParams,
|
||||
CellEditRequestEvent,
|
||||
ValueFormatterParams,
|
||||
} from 'ag-grid-community';
|
||||
import { ref } from 'vue';
|
||||
import type { I18nClass } from '@n8n/i18n';
|
||||
import type { DataTableColumn, DataTableRow } from '@/features/dataTable/dataTable.types';
|
||||
import {
|
||||
getCellClass,
|
||||
createValueGetter,
|
||||
createCellRendererSelector,
|
||||
createStringValueSetter,
|
||||
stringCellEditorParams,
|
||||
dateValueFormatter,
|
||||
numberValueFormatter,
|
||||
getStringColumnFilterOptions,
|
||||
getBooleanColumnFilterOptions,
|
||||
getNumberColumnFilterOptions,
|
||||
getDateColumnFilterOptions,
|
||||
} from './columnUtils';
|
||||
import { ADD_ROW_ROW_ID, NULL_VALUE, EMPTY_VALUE } from '@/features/dataTable/constants';
|
||||
import NullEmptyCellRenderer from '@/features/dataTable/components/dataGrid/NullEmptyCellRenderer.vue';
|
||||
|
||||
describe('columnUtils', () => {
|
||||
let mockI18n: I18nClass;
|
||||
|
||||
beforeEach(() => {
|
||||
mockI18n = {
|
||||
baseText: vi.fn((key: string) => key),
|
||||
} as unknown as I18nClass;
|
||||
});
|
||||
|
||||
describe('getCellClass', () => {
|
||||
it('should return "add-row-cell" for add row', () => {
|
||||
const params = {
|
||||
data: { id: ADD_ROW_ROW_ID },
|
||||
column: {
|
||||
getUserProvidedColDef: () => ({}),
|
||||
},
|
||||
} as unknown as CellClassParams;
|
||||
|
||||
expect(getCellClass(params)).toBe('add-row-cell');
|
||||
});
|
||||
|
||||
it('should return "boolean-cell" for boolean columns', () => {
|
||||
const params = {
|
||||
data: { id: 1 },
|
||||
column: {
|
||||
getUserProvidedColDef: () => ({ cellDataType: 'boolean' }),
|
||||
},
|
||||
} as unknown as CellClassParams;
|
||||
|
||||
expect(getCellClass(params)).toBe('boolean-cell');
|
||||
});
|
||||
|
||||
it('should return empty string for regular cells', () => {
|
||||
const params = {
|
||||
data: { id: 1 },
|
||||
column: {
|
||||
getUserProvidedColDef: () => ({ cellDataType: 'text' }),
|
||||
},
|
||||
} as unknown as CellClassParams;
|
||||
|
||||
expect(getCellClass(params)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createValueGetter', () => {
|
||||
it('should return null for undefined values', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'test', type: 'string', index: 0 };
|
||||
const params = { data: {} } as unknown as ValueGetterParams<DataTableRow>;
|
||||
|
||||
const getter = createValueGetter(col);
|
||||
expect(getter(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for null values', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'test', type: 'string', index: 0 };
|
||||
const params = { data: { test: null } } as unknown as ValueGetterParams<DataTableRow>;
|
||||
|
||||
const getter = createValueGetter(col);
|
||||
expect(getter(params)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return value for string columns', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'test', type: 'string', index: 0 };
|
||||
const params = { data: { test: 'hello' } } as unknown as ValueGetterParams<DataTableRow>;
|
||||
|
||||
const getter = createValueGetter(col);
|
||||
expect(getter(params)).toBe('hello');
|
||||
});
|
||||
|
||||
it('should convert string to Date for date columns', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'date', type: 'date', index: 0 };
|
||||
const params = {
|
||||
data: { date: '2024-01-01T00:00:00.000Z' },
|
||||
} as unknown as ValueGetterParams<DataTableRow>;
|
||||
|
||||
const getter = createValueGetter(col);
|
||||
const result = getter(params);
|
||||
expect(result).toBeInstanceOf(Date);
|
||||
expect((result as Date).toISOString()).toBe('2024-01-01T00:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should return value as-is for non-string date values', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'date', type: 'date', index: 0 };
|
||||
const date = new Date('2024-01-01');
|
||||
const params = { data: { date } } as unknown as ValueGetterParams<DataTableRow>;
|
||||
|
||||
const getter = createValueGetter(col);
|
||||
expect(getter(params)).toBe(date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCellRendererSelector', () => {
|
||||
it('should return empty object for add row cells', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'test', type: 'string', index: 0 };
|
||||
const params = { data: { id: ADD_ROW_ROW_ID } } as ICellRendererParams;
|
||||
|
||||
const selector = createCellRendererSelector(col);
|
||||
expect(selector(params)).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object for add column', () => {
|
||||
const col: DataTableColumn = { id: 'add-column', name: 'test', type: 'string', index: 0 };
|
||||
const params = { data: { id: 1 } } as ICellRendererParams;
|
||||
|
||||
const selector = createCellRendererSelector(col);
|
||||
expect(selector(params)).toEqual({});
|
||||
});
|
||||
|
||||
it('should return NullEmptyCellRenderer for null values', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'test', type: 'string', index: 0 };
|
||||
const params = { data: { test: null } } as ICellRendererParams;
|
||||
|
||||
const selector = createCellRendererSelector(col);
|
||||
const result = selector(params);
|
||||
expect(result).toEqual({
|
||||
component: NullEmptyCellRenderer,
|
||||
params: { value: NULL_VALUE },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return NullEmptyCellRenderer for undefined values', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'test', type: 'string', index: 0 };
|
||||
const params = { data: {} } as ICellRendererParams;
|
||||
|
||||
const selector = createCellRendererSelector(col);
|
||||
const result = selector(params);
|
||||
expect(result).toEqual({
|
||||
component: NullEmptyCellRenderer,
|
||||
params: { value: NULL_VALUE },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return NullEmptyCellRenderer for empty strings', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'test', type: 'string', index: 0 };
|
||||
const params = { data: { test: '' } } as ICellRendererParams;
|
||||
|
||||
const selector = createCellRendererSelector(col);
|
||||
const result = selector(params);
|
||||
expect(result).toEqual({
|
||||
component: NullEmptyCellRenderer,
|
||||
params: { value: EMPTY_VALUE },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return undefined for regular values', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'test', type: 'string', index: 0 };
|
||||
const params = { data: { test: 'value' } } as ICellRendererParams;
|
||||
|
||||
const selector = createCellRendererSelector(col);
|
||||
expect(selector(params)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStringValueSetter', () => {
|
||||
it('should set new value for valid strings', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'test', type: 'string', index: 0 };
|
||||
const isTextEditorOpen = ref(false);
|
||||
const data = { test: 'old' };
|
||||
const params = {
|
||||
data,
|
||||
newValue: 'new',
|
||||
} as unknown as ValueSetterParams<DataTableRow>;
|
||||
|
||||
const setter = createStringValueSetter(col, isTextEditorOpen);
|
||||
expect(setter(params)).toBe(true);
|
||||
expect(data.test).toBe('new');
|
||||
});
|
||||
|
||||
it('should return false for invalid values', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'test', type: 'string', index: 0 };
|
||||
const isTextEditorOpen = ref(false);
|
||||
const data = { test: 'old' };
|
||||
const params = {
|
||||
data,
|
||||
newValue: {},
|
||||
} as unknown as ValueSetterParams<DataTableRow>;
|
||||
|
||||
const setter = createStringValueSetter(col, isTextEditorOpen);
|
||||
expect(setter(params)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when original is null and new is empty string', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'test', type: 'string', index: 0 };
|
||||
const isTextEditorOpen = ref(false);
|
||||
const data = {};
|
||||
const params = {
|
||||
data,
|
||||
newValue: '',
|
||||
} as unknown as ValueSetterParams<DataTableRow>;
|
||||
|
||||
const setter = createStringValueSetter(col, isTextEditorOpen);
|
||||
expect(setter(params)).toBe(false);
|
||||
});
|
||||
|
||||
it('should convert null to empty string when text editor is open', () => {
|
||||
const col: DataTableColumn = { id: 'col1', name: 'test', type: 'string', index: 0 };
|
||||
const isTextEditorOpen = ref(true);
|
||||
const data = { test: 'old' };
|
||||
const params = {
|
||||
data,
|
||||
newValue: null,
|
||||
} as unknown as ValueSetterParams<DataTableRow>;
|
||||
|
||||
const setter = createStringValueSetter(col, isTextEditorOpen);
|
||||
expect(setter(params)).toBe(true);
|
||||
expect(data.test).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringCellEditorParams', () => {
|
||||
it('should return value and maxLength for string value', () => {
|
||||
const params = {
|
||||
value: 'test',
|
||||
} as CellEditRequestEvent<DataTableRow>;
|
||||
|
||||
const result = stringCellEditorParams(params);
|
||||
expect(result).toEqual({
|
||||
value: 'test',
|
||||
maxLength: 999999999,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty string for null value', () => {
|
||||
const params = {
|
||||
value: null,
|
||||
} as CellEditRequestEvent<DataTableRow>;
|
||||
|
||||
const result = stringCellEditorParams(params);
|
||||
expect(result).toEqual({
|
||||
value: '',
|
||||
maxLength: 999999999,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty string for undefined value', () => {
|
||||
const params = {
|
||||
value: undefined,
|
||||
} as CellEditRequestEvent<DataTableRow>;
|
||||
|
||||
const result = stringCellEditorParams(params);
|
||||
expect(result).toEqual({
|
||||
value: '',
|
||||
maxLength: 999999999,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('dateValueFormatter', () => {
|
||||
it('should format Date to ISO string', () => {
|
||||
const date = new Date('2024-01-01T12:00:00.000Z');
|
||||
const params = {
|
||||
value: date,
|
||||
} as unknown as ValueFormatterParams<DataTableRow, Date | null | undefined>;
|
||||
|
||||
const result = dateValueFormatter(params);
|
||||
expect(result).toBe('2024-01-01T12:00:00.000+00:00');
|
||||
});
|
||||
|
||||
it('should return empty string for null', () => {
|
||||
const params = {
|
||||
value: null,
|
||||
} as unknown as ValueFormatterParams<DataTableRow, Date | null | undefined>;
|
||||
|
||||
const result = dateValueFormatter(params);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for undefined', () => {
|
||||
const params = {
|
||||
value: undefined,
|
||||
} as unknown as ValueFormatterParams<DataTableRow, Date | null | undefined>;
|
||||
|
||||
const result = dateValueFormatter(params);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('numberValueFormatter', () => {
|
||||
it('should format number with thousand separators', () => {
|
||||
const params = {
|
||||
value: 1234567,
|
||||
} as unknown as ValueFormatterParams<DataTableRow, number | null | undefined>;
|
||||
|
||||
const result = numberValueFormatter(params);
|
||||
expect(result).toBe('1 234 567');
|
||||
});
|
||||
|
||||
it('should format number with decimals', () => {
|
||||
const params = {
|
||||
value: 1234.56,
|
||||
} as unknown as ValueFormatterParams<DataTableRow, number | null | undefined>;
|
||||
|
||||
const result = numberValueFormatter(params);
|
||||
expect(result).toBe('1 234.56');
|
||||
});
|
||||
|
||||
it('should return empty string for null', () => {
|
||||
const params = {
|
||||
value: null,
|
||||
} as unknown as ValueFormatterParams<DataTableRow, number | null | undefined>;
|
||||
|
||||
const result = numberValueFormatter(params);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should return empty string for undefined', () => {
|
||||
const params = {
|
||||
value: undefined,
|
||||
} as unknown as ValueFormatterParams<DataTableRow, number | null | undefined>;
|
||||
|
||||
const result = numberValueFormatter(params);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStringColumnFilterOptions', () => {
|
||||
it('should return array of filter options', () => {
|
||||
const options = getStringColumnFilterOptions(mockI18n);
|
||||
|
||||
expect(options).toHaveLength(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBooleanColumnFilterOptions', () => {
|
||||
it('should return array of filter options', () => {
|
||||
const options = getBooleanColumnFilterOptions(mockI18n);
|
||||
|
||||
expect(options).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNumberColumnFilterOptions', () => {
|
||||
it('should return array of filter options', () => {
|
||||
const options = getNumberColumnFilterOptions(mockI18n);
|
||||
|
||||
expect(options).toHaveLength(9);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDateColumnFilterOptions', () => {
|
||||
it('should return array of filter options', () => {
|
||||
const options = getDateColumnFilterOptions(mockI18n);
|
||||
expect(options).toHaveLength(9);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { DEFAULT_ID_COLUMN_NAME } from '@/features/dataTable/constants';
|
||||
import type { BackendFilterCondition, FilterOperation } from '../types/dataTableFilters.types';
|
||||
|
||||
export const SPECIAL_COLUMNS = ['add-column', 'ag-Grid-SelectionColumn'] as const;
|
||||
export const isSpecialColumn = (value: unknown): value is (typeof SPECIAL_COLUMNS)[number] =>
|
||||
typeof value === 'string' && (SPECIAL_COLUMNS as readonly string[]).includes(value);
|
||||
export const SPECIAL_COLUMNS = [DEFAULT_ID_COLUMN_NAME, 'add-column', 'ag-Grid-SelectionColumn'];
|
||||
export const MAX_CONDITIONS = 1;
|
||||
|
||||
export const GRID_FILTER_CONFIG = {
|
||||
|
||||
@@ -472,12 +472,12 @@ test.describe('Data Table details view', () => {
|
||||
expect(emailIndexAfter).toBeLessThan(ageIndexAfter);
|
||||
expect(emailIndex).toBeGreaterThan(initialOrder.indexOf(COLUMN_NAMES.age));
|
||||
|
||||
await n8n.dataTableDetails.dragColumnToPosition(COLUMN_NAMES.birthday, COLUMN_NAMES.active);
|
||||
await n8n.dataTableDetails.dragColumnToPosition(COLUMN_NAMES.birthday, COLUMN_NAMES.name);
|
||||
|
||||
const finalOrder = await n8n.dataTableDetails.getColumnOrder();
|
||||
const birthdayFinalIndex = finalOrder.indexOf(COLUMN_NAMES.birthday);
|
||||
const activeFinalIndex = finalOrder.indexOf(COLUMN_NAMES.active);
|
||||
const nameFinalIndex = finalOrder.indexOf(COLUMN_NAMES.name);
|
||||
|
||||
expect(birthdayFinalIndex).toBeLessThan(activeFinalIndex);
|
||||
expect(birthdayFinalIndex).toBeLessThan(nameFinalIndex);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user