chore(editor): Add unit tests for data tables FE code (no-changelog) (#20511)

This commit is contained in:
Svetoslav Dekov
2025-10-10 17:49:21 +03:00
committed by GitHub
parent 693547e93f
commit b32a094bef
25 changed files with 4672 additions and 921 deletions
@@ -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');
});
});
});
@@ -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(),
};
});
@@ -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();
@@ -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"
/>
@@ -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"
/>
@@ -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;
};
@@ -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,
};
};
@@ -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();
});
});
});
@@ -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,
@@ -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);
});
});
});
@@ -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,
};
};
@@ -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,
};
};
@@ -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();
});
});
});
@@ -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,
@@ -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);
});
});
});
@@ -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,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,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);
});
});