diff --git a/packages/frontend/editor-ui/src/features/dataTable/components/DataTableBreadcrumbs.test.ts b/packages/frontend/editor-ui/src/features/dataTable/components/DataTableBreadcrumbs.test.ts index f81971b7c29..010a5276eb6 100644 --- a/packages/frontend/editor-ui/src/features/dataTable/components/DataTableBreadcrumbs.test.ts +++ b/packages/frontend/editor-ui/src/features/dataTable/components/DataTableBreadcrumbs.test.ts @@ -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'); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/dataTable/components/DataTableCard.test.ts b/packages/frontend/editor-ui/src/features/dataTable/components/DataTableCard.test.ts index 7bb6489b997..152fac59e9a 100644 --- a/packages/frontend/editor-ui/src/features/dataTable/components/DataTableCard.test.ts +++ b/packages/frontend/editor-ui/src/features/dataTable/components/DataTableCard.test.ts @@ -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(), }; }); diff --git a/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/AddColumnButton.test.ts b/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/AddColumnButton.test.ts index d7a32297b07..b672cde1db5 100644 --- a/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/AddColumnButton.test.ts +++ b/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/AddColumnButton.test.ts @@ -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 }) => { const translations: Record = { '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(); diff --git a/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/AddColumnButton.vue b/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/AddColumnButton.vue index cbe98c0102e..f9d69bf0363 100644 --- a/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/AddColumnButton.vue +++ b/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/AddColumnButton.vue @@ -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" /> diff --git a/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/DataTableTable.vue b/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/DataTableTable.vue index 17c151b25b9..73e75f880fe 100644 --- a/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/DataTableTable.vue +++ b/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/DataTableTable.vue @@ -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('gridContainerRef'); const i18n = useI18n(); - -const dataTableGridBase = useDataTableGridBase({ - gridContainerRef, - onDeleteColumn: onDeleteColumnFunction, - onAddRowClick: onAddRowClickFunction, - onAddColumn: onAddColumnFunction, -}); const rowData = ref([]); const hasRecords = computed(() => rowData.value.length > 0); -const { initializeFilters, onFilterChanged, currentFilterJSON } = useDataTableColumnFilters({ - gridApi: dataTableGridBase.gridApi, - colDefs: dataTableGridBase.colDefs, - setGridData: dataTableGridBase.setGridData, +const agGrid = useAgGrid({ + 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 = `
${i18n.baseText('dataTable.noRows')}
`; -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" /> diff --git a/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/registerAgGridModulesOnce.ts b/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/registerAgGridModulesOnce.ts new file mode 100644 index 00000000000..ff36fd17d32 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataTable/components/dataGrid/registerAgGridModulesOnce.ts @@ -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; +}; diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/__tests__/useDataTableColumnFilters.test.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/__tests__/useDataTableColumnFilters.test.ts deleted file mode 100644 index 8082eb2ea3d..00000000000 --- a/packages/frontend/editor-ui/src/features/dataTable/composables/__tests__/useDataTableColumnFilters.test.ts +++ /dev/null @@ -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; - let colDefs: Ref; - - 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); - }); - }); -}); diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/useAgGrid.test.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/useAgGrid.test.ts new file mode 100644 index 00000000000..8a214a6d0e1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataTable/composables/useAgGrid.test.ts @@ -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; + let mockGridApi: Partial; + + 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[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'); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/useAgGrid.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/useAgGrid.ts new file mode 100644 index 00000000000..5043db08e10 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataTable/composables/useAgGrid.ts @@ -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; + defaultSortColumn: string; + pinnedBottomRowId?: string | number; + defaultColDef?: ColDef; +}; + +export const useAgGrid = = Record>({ + gridContainerRef, + defaultSortColumn, + pinnedBottomRowId, + defaultColDef, +}: UseAgGridOptions) => { + const gridApi = ref(null); + const isTextEditorOpen = ref(false); + const currentSortBy = ref(defaultSortColumn); + const currentSortOrder = ref('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) => { + if (params.column.getColDef().cellDataType === 'text') { + isTextEditorOpen.value = true; + } else { + isTextEditorOpen.value = false; + } + }; + + const onCellEditingStopped = (params: CellEditingStoppedEvent) => { + if (params.column.getColDef().cellDataType === 'text') { + isTextEditorOpen.value = false; + } + }; + + const handleCopyFocusedCell = async (params: CellKeyDownEvent) => { + 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) => { + 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, + }; +}; diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableColumnFilters.test.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableColumnFilters.test.ts new file mode 100644 index 00000000000..c69944fa8b9 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableColumnFilters.test.ts @@ -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; + let gridApiRef: Ref; + let colDefsRef: Ref; + + const parseFilterJSON = (json: string | undefined): BackendFilter | undefined => { + if (!json) return undefined; + return jsonParse(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(); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableColumnFilters.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableColumnFilters.ts index 9e811bcfa39..14625803f97 100644 --- a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableColumnFilters.ts +++ b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableColumnFilters.ts @@ -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; colDefs: Ref; - setGridData: (params: { - rowData?: DataTableRow[]; - colDefs?: ColDef[]; - }) => void; }; export const useDataTableColumnFilters = ({ gridApi, colDefs, - setGridData, }: UseDataTableColumnFiltersParams) => { const currentFilterJSON = ref(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, diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableColumns.test.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableColumns.test.ts new file mode 100644 index 00000000000..c94d28302a7 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableColumns.test.ts @@ -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); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableColumns.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableColumns.ts new file mode 100644 index 00000000000..28f22b0698d --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableColumns.ts @@ -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; + isTextEditorOpen: Ref; +}) => { + const colDefs = ref([]); + const { mapToAGCellType } = useDataTableTypes(); + const i18n = useI18n(); + + const createColumnDef = (col: DataTableColumn, extraProps: Partial = {}) => { + 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 = { + 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, + }; +}; diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableGridBase.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableGridBase.ts deleted file mode 100644 index 5d47fd436a2..00000000000 --- a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableGridBase.ts +++ /dev/null @@ -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; - onDeleteColumn: (columnId: string) => void; - onAddRowClick: () => void; - onAddColumn: (column: DataTableColumnCreatePayload) => Promise; -}) => { - const gridApi = ref(null); - const colDefs = ref([]); - const isTextEditorOpen = ref(false); - const { mapToAGCellType } = useDataTableTypes(); - const { copy: copyToClipboard } = useClipboard({ onPaste: onClipboardPaste }); - const i18n = useI18n(); - const currentSortBy = ref(DEFAULT_ID_COLUMN_NAME); - const currentSortOrder = ref('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 = {}) => { - 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) => { - if (params.column.getColDef().cellDataType === 'text') { - isTextEditorOpen.value = true; - } else { - isTextEditorOpen.value = false; - } - }; - - const onCellEditingStopped = (params: CellEditingStoppedEvent) => { - if (params.column.getColDef().cellDataType === 'text') { - isTextEditorOpen.value = false; - } - }; - - const getColumnDefinitions = (dataTableColumns: DataTableColumn[]) => { - const systemDateColumnOptions: Partial = { - 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) => { - 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) => { - 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, - }; -}; diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableOperations.test.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableOperations.test.ts index 06b24b1de2c..2d77c2f596c 100644 --- a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableOperations.test.ts +++ b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableOperations.test.ts @@ -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; + let confirmMock: ReturnType; + let showErrorMock: ReturnType; + let telemetryTrackMock: ReturnType; + 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); + + showErrorMock = vi.fn(); + vi.mocked(useToast).mockReturnValue({ + showError: showErrorMock, + } as unknown as ReturnType); + + telemetryTrackMock = vi.fn(); + vi.mocked(useTelemetry).mockReturnValue({ + track: telemetryTrackMock, + } as unknown as ReturnType); + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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()); + 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 => { + 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; + }; + + 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[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()); + 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(); + }); }); }); diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableOperations.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableOperations.ts index 039bfbd0f7e..0b823b1e0d1 100644 --- a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableOperations.ts +++ b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableOperations.ts @@ -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; @@ -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, diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTablePagination.test.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTablePagination.test.ts new file mode 100644 index 00000000000..4f7105546c8 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTablePagination.test.ts @@ -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); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableTypes.test.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableTypes.test.ts new file mode 100644 index 00000000000..b0468410502 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableTypes.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableTypes.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableTypes.ts index d4f14fb53f1..f37330b90a9 100644 --- a/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableTypes.ts +++ b/packages/frontend/editor-ui/src/features/dataTable/composables/useDataTableTypes.ts @@ -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, }; }; diff --git a/packages/frontend/editor-ui/src/features/dataTable/composables/__tests__/useDatePickerCommon.test.ts b/packages/frontend/editor-ui/src/features/dataTable/composables/useDatePickerCommon.test.ts similarity index 99% rename from packages/frontend/editor-ui/src/features/dataTable/composables/__tests__/useDatePickerCommon.test.ts rename to packages/frontend/editor-ui/src/features/dataTable/composables/useDatePickerCommon.test.ts index 36cd5ca1a37..2af807916be 100644 --- a/packages/frontend/editor-ui/src/features/dataTable/composables/__tests__/useDatePickerCommon.test.ts +++ b/packages/frontend/editor-ui/src/features/dataTable/composables/useDatePickerCommon.test.ts @@ -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'); diff --git a/packages/frontend/editor-ui/src/features/dataTable/dataTable.store.test.ts b/packages/frontend/editor-ui/src/features/dataTable/dataTable.store.test.ts index 0e1b53dc2d9..25889188b1c 100644 --- a/packages/frontend/editor-ui/src/features/dataTable/dataTable.store.test.ts +++ b/packages/frontend/editor-ui/src/features/dataTable/dataTable.store.test.ts @@ -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) { + 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; @@ -10,6 +30,19 @@ describe('dataTable.store', () => { beforeEach(() => { setActivePinia(createPinia()); + + vi.mocked(useSettingsStore).mockReturnValue({ + settings: { + dataTables: { + maxSize: 10485760, // 10MB in bytes + }, + }, + } as ReturnType); + + vi.mocked(useProjectsStore).mockReturnValue({ + fetchProject: vi.fn(), + } as unknown as ReturnType); + 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); + }); }); }); diff --git a/packages/frontend/editor-ui/src/features/dataTable/typeGuards.test.ts b/packages/frontend/editor-ui/src/features/dataTable/typeGuards.test.ts new file mode 100644 index 00000000000..5bcc97c3faa --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataTable/typeGuards.test.ts @@ -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); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/dataTable/utils/columnUtils.test.ts b/packages/frontend/editor-ui/src/features/dataTable/utils/columnUtils.test.ts new file mode 100644 index 00000000000..08c68c485a3 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/dataTable/utils/columnUtils.test.ts @@ -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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + + 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; + + const result = stringCellEditorParams(params); + expect(result).toEqual({ + value: '', + maxLength: 999999999, + }); + }); + + it('should return empty string for undefined value', () => { + const params = { + value: undefined, + } as CellEditRequestEvent; + + 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; + + 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; + + const result = dateValueFormatter(params); + expect(result).toBe(''); + }); + + it('should return empty string for undefined', () => { + const params = { + value: undefined, + } as unknown as ValueFormatterParams; + + const result = dateValueFormatter(params); + expect(result).toBe(''); + }); + }); + + describe('numberValueFormatter', () => { + it('should format number with thousand separators', () => { + const params = { + value: 1234567, + } as unknown as ValueFormatterParams; + + 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; + + 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; + + const result = numberValueFormatter(params); + expect(result).toBe(''); + }); + + it('should return empty string for undefined', () => { + const params = { + value: undefined, + } as unknown as ValueFormatterParams; + + 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); + }); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/dataTable/utils/filterMappings.ts b/packages/frontend/editor-ui/src/features/dataTable/utils/filterMappings.ts index 95df210878d..193c6eb9471 100644 --- a/packages/frontend/editor-ui/src/features/dataTable/utils/filterMappings.ts +++ b/packages/frontend/editor-ui/src/features/dataTable/utils/filterMappings.ts @@ -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 = { diff --git a/packages/testing/playwright/tests/ui/data-table-details.spec.ts b/packages/testing/playwright/tests/ui/data-table-details.spec.ts index 8184bffe554..b6bf5bfa835 100644 --- a/packages/testing/playwright/tests/ui/data-table-details.spec.ts +++ b/packages/testing/playwright/tests/ui/data-table-details.spec.ts @@ -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); }); });