feat(editor): Refactor data store table component (no-changelog) (#19131)

This commit is contained in:
Svetoslav Dekov
2025-09-05 10:47:07 +03:00
committed by GitHub
parent 48efb4c865
commit 2ce0911186
10 changed files with 1186 additions and 762 deletions

View File

@@ -47,6 +47,7 @@ vi.mock('ag-grid-community', () => ({
CellStyleModule: {},
ScrollApiModule: {},
PinnedRowModule: {},
ColumnApiModule: {},
}));
// Mock the n8n theme
@@ -73,6 +74,16 @@ vi.mock('@/composables/useToast', () => ({
}),
}));
vi.mock('@/features/dataStore/composables/useDataStorePagination', () => ({
useDataStorePagination: () => ({
totalItems: 0,
setTotalItems: vi.fn(),
ensureItemOnPage: vi.fn(),
currentPage: 1,
setCurrentPage: vi.fn(),
}),
}));
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
useI18n: () => ({

View File

@@ -1,32 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, ref, nextTick, useTemplateRef } from 'vue';
import orderBy from 'lodash/orderBy';
import { computed, ref, useTemplateRef, watch } from 'vue';
import type {
DataStore,
DataStoreColumn,
DataStoreColumnCreatePayload,
DataStoreRow,
} from '@/features/dataStore/datastore.types';
import { AgGridVue } from 'ag-grid-vue3';
import type {
GridApi,
GridReadyEvent,
ColDef,
ColumnMovedEvent,
ValueGetterParams,
RowSelectionOptions,
CellValueChangedEvent,
GetRowIdParams,
ICellRendererParams,
CellEditRequestEvent,
CellClickedEvent,
ValueSetterParams,
CellEditingStartedEvent,
CellEditingStoppedEvent,
CellKeyDownEvent,
SortDirection,
SortChangedEvent,
} from 'ag-grid-community';
import type { GetRowIdParams, GridReadyEvent } from 'ag-grid-community';
import {
ModuleRegistry,
ClientSideRowModelModule,
@@ -44,34 +24,15 @@ import {
CellStyleModule,
ScrollApiModule,
PinnedRowModule,
ColumnApiModule,
} from 'ag-grid-community';
import { n8nTheme } from '@/features/dataStore/components/dataGrid/n8nTheme';
import SelectedItemsInfo from '@/components/common/SelectedItemsInfo.vue';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@/composables/useToast';
import {
DEFAULT_ID_COLUMN_NAME,
EMPTY_VALUE,
NULL_VALUE,
DATA_STORE_ID_COLUMN_WIDTH,
DATA_STORE_HEADER_HEIGHT,
DATA_STORE_ROW_HEIGHT,
ADD_ROW_ROW_ID,
} from '@/features/dataStore/constants';
import { useMessage } from '@/composables/useMessage';
import { MODAL_CONFIRM } from '@/constants';
import ColumnHeader from '@/features/dataStore/components/dataGrid/ColumnHeader.vue';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
import AddColumnButton from '@/features/dataStore/components/dataGrid/AddColumnButton.vue';
import AddRowButton from '@/features/dataStore/components/dataGrid/AddRowButton.vue';
import { isDataStoreValue } from '@/features/dataStore/typeGuards';
import NullEmptyCellRenderer from '@/features/dataStore/components/dataGrid/NullEmptyCellRenderer.vue';
import ElDatePickerCellEditor from '@/features/dataStore/components/dataGrid/ElDatePickerCellEditor.vue';
import { onClickOutside } from '@vueuse/core';
import { useClipboard } from '@/composables/useClipboard';
import { reorderItem } from '@/features/dataStore/utils';
import { useTelemetry } from '@/composables/useTelemetry';
import { DATA_STORE_HEADER_HEIGHT, DATA_STORE_ROW_HEIGHT } from '@/features/dataStore/constants';
import { useDataStorePagination } from '@/features/dataStore/composables/useDataStorePagination';
import { useDataStoreGridBase } from '@/features/dataStore/composables/useDataStoreGridBase';
import { useDataStoreSelection } from '@/features/dataStore/composables/useDataStoreSelection';
import { useDataStoreOperations } from '@/features/dataStore/composables/useDataStoreOperations';
// Register only the modules we actually use
ModuleRegistry.registerModules([
@@ -90,6 +51,7 @@ ModuleRegistry.registerModules([
CellStyleModule,
PinnedRowModule,
ScrollApiModule,
ColumnApiModule,
]);
type Props = {
@@ -102,718 +64,84 @@ const emit = defineEmits<{
toggleSave: [value: boolean];
}>();
const i18n = useI18n();
const toast = useToast();
const message = useMessage();
const { mapToAGCellType } = useDataStoreTypes();
const telemetry = useTelemetry();
const gridContainerRef = useTemplateRef<HTMLDivElement>('gridContainerRef');
const dataStoreStore = useDataStoreStore();
const { copy: copyToClipboard } = useClipboard({ onPaste: onClipboardPaste });
// AG Grid State
const gridApi = ref<GridApi | null>(null);
const colDefs = ref<ColDef[]>([]);
const dataStoreGridBase = useDataStoreGridBase({
gridContainerRef,
onDeleteColumn: onDeleteColumnFunction,
onAddRowClick: onAddRowClickFunction,
onAddColumn: onAddColumnFunction,
});
const rowData = ref<DataStoreRow[]>([]);
const rowSelection: RowSelectionOptions | 'single' | 'multiple' = {
mode: 'multiRow',
enableClickSelection: false,
checkboxes: (params) => params.data?.id !== ADD_ROW_ROW_ID,
isRowSelectable: (params) => params.data?.id !== ADD_ROW_ROW_ID,
};
const currentSortBy = ref<string>(DEFAULT_ID_COLUMN_NAME);
const currentSortOrder = ref<SortDirection>('asc');
const contentLoading = ref(false);
// 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 isTextEditorOpen = ref(false);
const gridContainer = useTemplateRef<HTMLDivElement>('gridContainer');
// Pagination
const pageSizeOptions = [10, 20, 50];
const currentPage = ref(1);
const pageSize = ref(20);
const totalItems = ref(0);
// Data store content
const rows = ref<DataStoreRow[]>([]);
const selectedRowIds = ref<Set<number>>(new Set());
const selectedCount = computed(() => selectedRowIds.value.size);
const hasRecords = computed(() => rowData.value.length > 0);
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 (gridContainer?.value) {
params.api.setGridOption('popupParent', gridContainer.value as unknown as HTMLElement);
}
};
const pagination = useDataStorePagination({ onChange: fetchDataStoreRowsFunction });
const refreshGridData = () => {
if (!gridApi.value) return;
gridApi.value.setGridOption('columnDefs', colDefs.value);
// only real rows here
gridApi.value.setGridOption('rowData', rowData.value);
// special "add row" pinned to the bottom
gridApi.value.setGridOption('pinnedBottomRowData', [{ id: ADD_ROW_ROW_ID }]);
};
const focusFirstEditableCell = (rowId: number) => {
if (!gridApi.value) return;
const rowNode = gridApi.value.getRowNode(String(rowId));
if (rowNode?.rowIndex === null) return;
const firstEditableCol = colDefs.value[1];
if (!firstEditableCol?.colId) return;
gridApi.value.ensureIndexVisible(rowNode!.rowIndex);
gridApi.value.setFocusedCell(rowNode!.rowIndex, firstEditableCol.colId);
gridApi.value.startEditingCell({ rowIndex: rowNode!.rowIndex, colKey: firstEditableCol.colId });
};
const setCurrentPage = async (page: number) => {
currentPage.value = page;
await fetchDataStoreContent();
};
const setPageSize = async (size: number) => {
pageSize.value = size;
currentPage.value = 1; // Reset to first page on page size change
await fetchDataStoreContent();
};
const onDeleteColumn = async (columnId: string) => {
if (!gridApi.value) return;
const columnToDelete = colDefs.value.find((col) => col.colId === columnId);
if (!columnToDelete) return;
const promptResponse = await message.confirm(
i18n.baseText('dataStore.deleteColumn.confirm.message', {
interpolate: { name: columnToDelete.headerName ?? '' },
}),
i18n.baseText('dataStore.deleteColumn.confirm.title'),
{
confirmButtonText: i18n.baseText('generic.delete'),
cancelButtonText: i18n.baseText('generic.cancel'),
},
);
if (promptResponse !== MODAL_CONFIRM) {
return;
}
const columnToDeleteIndex = colDefs.value.findIndex((col) => col.colId === columnId);
colDefs.value = colDefs.value.filter((def) => def.colId !== columnId);
const rowDataOldValue = [...rowData.value];
rowData.value = rowData.value.map((row) => {
const { [columnToDelete.field!]: _, ...rest } = row;
return rest;
});
refreshGridData();
try {
await dataStoreStore.deleteDataStoreColumn(
props.dataStore.id,
props.dataStore.projectId,
columnId,
);
telemetry.track('User deleted data table column', {
column_id: columnId,
column_type: columnToDelete.cellDataType,
data_table_id: props.dataStore.id,
});
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.deleteColumn.error'));
colDefs.value.splice(columnToDeleteIndex, 0, columnToDelete);
rowData.value = rowDataOldValue;
refreshGridData();
}
};
const onAddColumn = async (column: DataStoreColumnCreatePayload) => {
try {
const newColumn = await dataStoreStore.addDataStoreColumn(
props.dataStore.id,
props.dataStore.projectId,
column,
);
if (!newColumn) {
throw new Error(i18n.baseText('generic.unknownError'));
}
colDefs.value = [
...colDefs.value.slice(0, -1),
createColumnDef(newColumn),
...colDefs.value.slice(-1),
];
rowData.value = rowData.value.map((row) => {
return { ...row, [newColumn.name]: null };
});
refreshGridData();
telemetry.track('User added data table column', {
column_id: newColumn.id,
column_type: newColumn.type,
data_table_id: props.dataStore.id,
});
return true;
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.addColumn.error'));
return false;
}
};
const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {}) => {
const columnDef: ColDef = {
colId: col.id,
field: col.name,
headerName: col.name,
sortable: true,
flex: 1,
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: (params) => {
if (params.data?.id === ADD_ROW_ROW_ID) {
return 'add-row-cell';
}
if (params.column.getUserProvidedColDef()?.cellDataType === 'boolean') {
return 'boolean-cell';
}
return '';
},
valueGetter: (params: ValueGetterParams<DataStoreRow>) => {
// If the value is null, return null to show empty cell
if (params.data?.[col.name] === null || params.data?.[col.name] === undefined) {
return null;
}
// Parse dates
if (col.type === 'date') {
const value = params.data?.[col.name];
if (typeof value === 'string') {
return new Date(value);
}
}
return params.data?.[col.name];
},
cellRendererSelector: (params: ICellRendererParams) => {
if (params.data?.id === ADD_ROW_ROW_ID || col.id === 'add-column') {
return {};
}
let rowValue = params.data?.[col.name];
// When adding new column, rowValue is undefined (same below, in string cell editor)
if (rowValue === undefined) {
rowValue = null;
}
// Custom renderer for null or empty values
if (rowValue === null) {
return { component: NullEmptyCellRenderer, params: { value: NULL_VALUE } };
}
if (rowValue === '') {
return { component: NullEmptyCellRenderer, params: { value: EMPTY_VALUE } };
}
// Fallback to default cell renderer
return undefined;
},
};
// Enable large text editor for text columns
if (col.type === 'string') {
columnDef.cellEditor = 'agLargeTextCellEditor';
// Use popup editor so it is not clipped by the grid viewport and positions correctly
columnDef.cellEditorPopup = true;
columnDef.cellEditorPopupPosition = 'over';
// Provide initial value for the editor, otherwise agLargeTextCellEditor breaks
columnDef.cellEditorParams = (params: CellEditRequestEvent<DataStoreRow>) => ({
value: params.value ?? '',
// Rely on the backend to limit the length of the value
maxLength: 999999999,
});
columnDef.valueSetter = (params: ValueSetterParams<DataStoreRow>) => {
let originalValue = params.data[col.name];
if (originalValue === undefined) {
originalValue = null;
}
let newValue = params.newValue;
if (!isDataStoreValue(newValue)) {
return false;
}
// Make sure not to trigger update if cell content is not set and value was null
if (originalValue === null && newValue === '') {
return false;
}
// When clearing editor content, set value to empty string
if (isTextEditorOpen.value && newValue === null) {
newValue = '';
}
// Otherwise update the value
params.data[col.name] = newValue;
return true;
};
}
// Setup date editor
if (col.type === 'date') {
columnDef.cellEditorSelector = () => ({
component: ElDatePickerCellEditor,
});
columnDef.valueFormatter = (params) => {
const value = params.value as Date | null | undefined;
if (value === null || value === undefined) return '';
return value.toISOString();
};
}
return {
...columnDef,
...extraProps,
};
};
const onColumnMoved = async (moveEvent: ColumnMovedEvent) => {
if (
!moveEvent.finished ||
moveEvent.source !== 'uiColumnMoved' ||
moveEvent.toIndex === undefined ||
!moveEvent.column
) {
return;
}
const oldIndex = colDefs.value.findIndex((col) => col.colId === moveEvent.column!.getColId());
try {
await dataStoreStore.moveDataStoreColumn(
props.dataStore.id,
props.dataStore.projectId,
moveEvent.column.getColId(),
moveEvent.toIndex - 2, // ag grid index start from 1 and also we need to account for the id column
);
// Compute positions within the movable middle section (exclude selection + ID + Add Column)
const fromIndex = oldIndex - 1; // exclude ID column
const toIndex = moveEvent.toIndex - 2; // exclude selection + ID columns used by AG Grid indices
const middleWithIndex = colDefs.value.slice(1, -1).map((col, index) => ({ ...col, index }));
const reorderedMiddle = reorderItem(middleWithIndex, fromIndex, toIndex)
.sort((a, b) => a.index - b.index)
.map(({ index, ...col }) => col);
colDefs.value = [colDefs.value[0], ...reorderedMiddle, colDefs.value[colDefs.value.length - 1]];
refreshGridData();
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.moveColumn.error'));
gridApi.value?.moveColumnByIndex(moveEvent.toIndex, oldIndex);
}
};
const onAddRowClick = async () => {
try {
// Go to last page if we are not there already
if (currentPage.value * pageSize.value < totalItems.value + 1) {
await setCurrentPage(Math.ceil((totalItems.value + 1) / pageSize.value));
}
contentLoading.value = true;
emit('toggleSave', true);
const insertedRow = await dataStoreStore.insertEmptyRow(props.dataStore);
const newRow: DataStoreRow = insertedRow;
rowData.value.push(newRow);
totalItems.value += 1;
refreshGridData();
await nextTick();
focusFirstEditableCell(newRow.id as number);
telemetry.track('User added row to data table', {
data_table_id: props.dataStore.id,
});
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.addRow.error'));
} finally {
emit('toggleSave', false);
contentLoading.value = false;
}
};
const initColumnDefinitions = () => {
const systemDateColumnOptions: Partial<ColDef> = {
editable: false,
suppressMovable: true,
lockPinned: true,
lockPosition: 'right',
headerComponentParams: {
allowMenuActions: false,
},
};
colDefs.value = [
// Always add the ID column, it's not returned by the back-end but all data stores have it
// We use it as a placeholder for new datastores
createColumnDef(
{
index: 0,
id: DEFAULT_ID_COLUMN_NAME,
name: DEFAULT_ID_COLUMN_NAME,
type: 'string',
},
{
editable: false,
sortable: false,
suppressMovable: true,
headerComponent: null,
lockPosition: true,
minWidth: DATA_STORE_ID_COLUMN_WIDTH,
maxWidth: DATA_STORE_ID_COLUMN_WIDTH,
resizable: 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(props.dataStore.columns, 'index').map((col) => createColumnDef(col)),
createColumnDef(
{
index: props.dataStore.columns.length + 1,
id: 'createdAt',
name: 'createdAt',
type: 'date',
},
systemDateColumnOptions,
),
createColumnDef(
{
index: props.dataStore.columns.length + 2,
id: 'updatedAt',
name: 'updatedAt',
type: 'date',
},
systemDateColumnOptions,
),
createColumnDef(
{
index: props.dataStore.columns.length + 3,
id: 'add-column',
name: 'Add Column',
type: 'string',
},
{
editable: false,
suppressMovable: true,
lockPinned: true,
lockPosition: 'right',
resizable: false,
headerComponent: AddColumnButton,
headerComponentParams: { onAddColumn },
},
),
];
};
const onCellValueChanged = async (params: CellValueChangedEvent<DataStoreRow>) => {
const { data, api, oldValue, colDef } = params;
const value = params.data[colDef.field!];
if (value === undefined || value === oldValue) {
return;
}
if (typeof data.id !== 'number') {
throw new Error('Expected row id to be a number');
}
const fieldName = String(colDef.field);
const id = data.id;
try {
emit('toggleSave', true);
await dataStoreStore.updateRow(props.dataStore.id, props.dataStore.projectId, id, {
[fieldName]: value,
});
telemetry.track('User edited data table content', {
data_table_id: props.dataStore.id,
column_id: colDef.colId,
column_type: colDef.cellDataType,
});
} catch (error) {
// Revert cell to original value if the update fails
const validOldValue = isDataStoreValue(oldValue) ? oldValue : null;
const revertedData: DataStoreRow = { ...data, [fieldName]: validOldValue };
api.applyTransaction({
update: [revertedData],
});
toast.showError(error, i18n.baseText('dataStore.updateRow.error'));
} finally {
emit('toggleSave', false);
}
};
// Start editing when users click on already focused cells
const onCellClicked = (params: CellClickedEvent<DataStoreRow>) => {
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 fetchDataStoreContent = async () => {
try {
contentLoading.value = true;
const fetchedRows = await dataStoreStore.fetchDataStoreContent(
props.dataStore.id,
props.dataStore.projectId,
currentPage.value,
pageSize.value,
`${currentSortBy.value}:${currentSortOrder.value}`,
);
rowData.value = fetchedRows.data;
totalItems.value = fetchedRows.count;
refreshGridData();
handleClearSelection();
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.fetchContent.error'));
} finally {
contentLoading.value = false;
if (gridApi.value) {
gridApi.value.refreshHeader();
}
}
};
onClickOutside(gridContainer, () => {
resetLastFocusedCell();
gridApi.value?.clearFocusedCell();
const selection = useDataStoreSelection({
gridApi: dataStoreGridBase.gridApi,
});
function onClipboardPaste(data: string) {
if (!gridApi.value) return;
const focusedCell = gridApi.value.getFocusedCell();
const isEditing = gridApi.value.getEditingCells().length > 0;
if (!focusedCell || isEditing) return;
const row = gridApi.value.getDisplayedRowAtIndex(focusedCell.rowIndex);
if (!row) return;
const dataStoreOperations = useDataStoreOperations({
colDefs: dataStoreGridBase.colDefs,
rowData,
deleteGridColumn: dataStoreGridBase.deleteColumn,
setGridData: dataStoreGridBase.setGridData,
insertGridColumnAtIndex: dataStoreGridBase.insertColumnAtIndex,
dataStoreId: props.dataStore.id,
projectId: props.dataStore.projectId,
addGridColumn: dataStoreGridBase.addColumn,
moveGridColumn: dataStoreGridBase.moveColumn,
gridApi: dataStoreGridBase.gridApi,
totalItems: pagination.totalItems,
setTotalItems: pagination.setTotalItems,
ensureItemOnPage: pagination.ensureItemOnPage,
focusFirstEditableCell: dataStoreGridBase.focusFirstEditableCell,
toggleSave: emit.bind(null, 'toggleSave'),
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
currentSortBy: dataStoreGridBase.currentSortBy,
currentSortOrder: dataStoreGridBase.currentSortOrder,
handleClearSelection: selection.handleClearSelection,
selectedRowIds: selection.selectedRowIds,
handleCopyFocusedCell: dataStoreGridBase.handleCopyFocusedCell,
});
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);
}
}
async function onDeleteColumnFunction(columnId: string) {
await dataStoreOperations.onDeleteColumn(columnId);
}
const resetLastFocusedCell = () => {
lastFocusedCell.value = null;
async function onAddColumnFunction(column: DataStoreColumnCreatePayload) {
return await dataStoreOperations.onAddColumn(column);
}
async function onAddRowClickFunction() {
await dataStoreOperations.onAddRowClick();
}
async function fetchDataStoreRowsFunction() {
await dataStoreOperations.fetchDataStoreRows();
}
const initialize = async (params: GridReadyEvent) => {
dataStoreGridBase.onGridReady(params);
dataStoreGridBase.loadColumns(props.dataStore.columns);
await dataStoreOperations.fetchDataStoreRows();
};
const initialize = async () => {
initColumnDefinitions();
await fetchDataStoreContent();
};
const onSortChanged = async (event: SortChangedEvent) => {
const oldSortBy = currentSortBy.value;
const oldSortOrder = currentSortOrder.value;
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';
}
if (oldSortBy !== currentSortBy.value || oldSortOrder !== currentSortOrder.value) {
currentPage.value = 1;
await fetchDataStoreContent();
}
};
onMounted(async () => {
await initialize();
watch([dataStoreGridBase.currentSortBy, dataStoreGridBase.currentSortOrder], async () => {
await pagination.setCurrentPage(1);
});
const onCellEditingStarted = (params: CellEditingStartedEvent<DataStoreRow>) => {
if (params.column.getColDef().cellDataType === 'text') {
isTextEditorOpen.value = true;
} else {
isTextEditorOpen.value = false;
}
};
const onCellEditingStopped = (params: CellEditingStoppedEvent<DataStoreRow>) => {
if (params.column.getColDef().cellDataType === 'text') {
isTextEditorOpen.value = false;
}
};
const onSelectionChanged = () => {
if (!gridApi.value) return;
const selectedNodes = gridApi.value.getSelectedNodes();
const newSelectedIds = new Set<number>();
selectedNodes.forEach((node) => {
if (typeof node.data?.id === 'number') {
newSelectedIds.add(node.data.id);
}
});
selectedRowIds.value = newSelectedIds;
};
const onCellKeyDown = async (params: CellKeyDownEvent<DataStoreRow>) => {
if (params.api.getEditingCells().length > 0) {
return;
}
const event = params.event as KeyboardEvent;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c') {
event.preventDefault();
await handleCopyFocusedCell(params);
return;
}
if ((event.key !== 'Delete' && event.key !== 'Backspace') || selectedRowIds.value.size === 0) {
return;
}
event.preventDefault();
await handleDeleteSelected();
};
const handleCopyFocusedCell = async (params: CellKeyDownEvent<DataStoreRow>) => {
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 handleDeleteSelected = async () => {
if (selectedRowIds.value.size === 0) return;
const confirmResponse = await message.confirm(
i18n.baseText('dataStore.deleteRows.confirmation', {
adjustToNumber: selectedRowIds.value.size,
interpolate: { count: selectedRowIds.value.size },
}),
i18n.baseText('dataStore.deleteRows.title'),
{
confirmButtonText: i18n.baseText('generic.delete'),
cancelButtonText: i18n.baseText('generic.cancel'),
},
);
if (confirmResponse !== MODAL_CONFIRM) {
return;
}
try {
emit('toggleSave', true);
const idsToDelete = Array.from(selectedRowIds.value);
await dataStoreStore.deleteRows(props.dataStore.id, props.dataStore.projectId, idsToDelete);
rows.value = rows.value.filter((row) => !selectedRowIds.value.has(row.id as number));
rowData.value = rows.value;
await fetchDataStoreContent();
toast.showToast({
title: i18n.baseText('dataStore.deleteRows.success'),
message: '',
type: 'success',
});
telemetry.track('User deleted rows in data table', {
data_table_id: props.dataStore.id,
deleted_row_count: idsToDelete.length,
});
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.deleteRows.error'));
} finally {
emit('toggleSave', false);
}
};
const handleClearSelection = () => {
selectedRowIds.value = new Set();
if (gridApi.value) {
gridApi.value.deselectAll();
}
};
defineExpose({
addRow: onAddRowClick,
addColumn: onAddColumn,
addRow: dataStoreOperations.onAddRowClick,
addColumn: dataStoreOperations.onAddColumn,
});
</script>
<template>
<div :class="$style.wrapper">
<div
ref="gridContainer"
ref="gridContainerRef"
:class="[$style['grid-container'], { [$style['has-records']]: hasRecords }]"
data-test-id="data-store-grid"
>
@@ -825,41 +153,41 @@ defineExpose({
:animate-rows="false"
:theme="n8nTheme"
:suppress-drag-leave-hides-columns="true"
:loading="contentLoading"
:row-selection="rowSelection"
:loading="dataStoreOperations.contentLoading.value"
:row-selection="selection.rowSelection"
:get-row-id="(params: GetRowIdParams) => String(params.data.id)"
:stop-editing-when-cells-lose-focus="true"
:undo-redo-cell-editing="true"
:suppress-multi-sort="true"
@grid-ready="onGridReady"
@cell-value-changed="onCellValueChanged"
@column-moved="onColumnMoved"
@cell-clicked="onCellClicked"
@cell-editing-started="onCellEditingStarted"
@cell-editing-stopped="onCellEditingStopped"
@column-header-clicked="resetLastFocusedCell"
@selection-changed="onSelectionChanged"
@sort-changed="onSortChanged"
@cell-key-down="onCellKeyDown"
@grid-ready="initialize"
@cell-value-changed="dataStoreOperations.onCellValueChanged"
@column-moved="dataStoreOperations.onColumnMoved"
@cell-clicked="dataStoreGridBase.onCellClicked"
@cell-editing-started="dataStoreGridBase.onCellEditingStarted"
@cell-editing-stopped="dataStoreGridBase.onCellEditingStopped"
@column-header-clicked="dataStoreGridBase.resetLastFocusedCell"
@selection-changed="selection.onSelectionChanged"
@sort-changed="dataStoreGridBase.onSortChanged"
@cell-key-down="dataStoreOperations.onCellKeyDown"
/>
</div>
<div :class="$style.footer">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
data-test-id="data-store-content-pagination"
background
:total="totalItems"
:page-sizes="pageSizeOptions"
:total="pagination.totalItems"
:page-sizes="pagination.pageSizeOptions"
layout="total, prev, pager, next, sizes"
@update:current-page="setCurrentPage"
@size-change="setPageSize"
@update:current-page="pagination.setCurrentPage"
@size-change="pagination.setPageSize"
/>
</div>
<SelectedItemsInfo
:selected-count="selectedCount"
@delete-selected="handleDeleteSelected"
@clear-selection="handleClearSelection"
:selected-count="selection.selectedCount.value"
@delete-selected="dataStoreOperations.handleDeleteSelected"
@clear-selection="selection.handleClearSelection"
/>
</div>
</template>

View File

@@ -0,0 +1,401 @@
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 {
DataStoreColumn,
DataStoreColumnCreatePayload,
DataStoreRow,
} from '@/features/dataStore/datastore.types';
import {
ADD_ROW_ROW_ID,
DATA_STORE_ID_COLUMN_WIDTH,
DEFAULT_ID_COLUMN_NAME,
} from '@/features/dataStore/constants';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
import ColumnHeader from '@/features/dataStore/components/dataGrid/ColumnHeader.vue';
import ElDatePickerCellEditor from '@/features/dataStore/components/dataGrid/ElDatePickerCellEditor.vue';
import orderBy from 'lodash/orderBy';
import AddColumnButton from '@/features/dataStore/components/dataGrid/AddColumnButton.vue';
import AddRowButton from '@/features/dataStore/components/dataGrid/AddRowButton.vue';
import { reorderItem } from '@/features/dataStore/utils';
import { useClipboard } from '@/composables/useClipboard';
import { onClickOutside } from '@vueuse/core';
import {
getCellClass,
createValueGetter,
createCellRendererSelector,
createStringValueSetter,
stringCellEditorParams,
dateValueFormatter,
} from '@/features/dataStore/utils/columnUtils';
export const useDataStoreGridBase = ({
gridContainerRef,
onDeleteColumn,
onAddRowClick,
onAddColumn,
}: {
gridContainerRef: Ref<HTMLElement | null>;
onDeleteColumn: (columnId: string) => void;
onAddRowClick: () => void;
onAddColumn: (column: DataStoreColumnCreatePayload) => Promise<boolean>;
}) => {
const gridApi = ref<GridApi | null>(null);
const colDefs = ref<ColDef[]>([]);
const isTextEditorOpen = ref(false);
const { mapToAGCellType } = useDataStoreTypes();
const { copy: copyToClipboard } = useClipboard({ onPaste: onClipboardPaste });
const currentSortBy = ref<string>(DEFAULT_ID_COLUMN_NAME);
const currentSortOrder = ref<SortDirection>('asc');
// Track the last focused cell so we can start editing when users click on it
// AG Grid doesn't provide cell blur event so we need to reset this manually
const lastFocusedCell = ref<{ rowIndex: number; colId: string } | null>(null);
const initializedGridApi = computed(() => {
if (!gridApi.value) {
throw new Error('Grid API is not initialized');
}
return gridApi.value;
});
const onGridReady = (params: GridReadyEvent) => {
gridApi.value = params.api;
// Ensure popups (e.g., agLargeTextCellEditor) are positioned relative to the grid container
// to avoid misalignment when the page scrolls.
if (gridContainerRef.value) {
params.api.setGridOption('popupParent', gridContainerRef.value);
}
};
const setGridData = ({
colDefs,
rowData,
}: {
colDefs?: ColDef[];
rowData?: DataStoreRow[];
}) => {
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 firstEditableCol = colDefs.value[1];
if (!firstEditableCol?.colId) return;
initializedGridApi.value.ensureIndexVisible(rowNode!.rowIndex);
initializedGridApi.value.setFocusedCell(rowNode!.rowIndex, firstEditableCol.colId);
initializedGridApi.value.startEditingCell({
rowIndex: rowNode!.rowIndex,
colKey: firstEditableCol.colId,
});
};
const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {}) => {
const columnDef: ColDef = {
colId: col.id,
field: col.name,
headerName: col.name,
sortable: true,
flex: 1,
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),
};
if (col.type === 'string') {
columnDef.cellEditor = 'agLargeTextCellEditor';
columnDef.cellEditorPopup = true;
columnDef.cellEditorPopupPosition = 'over';
columnDef.cellEditorParams = stringCellEditorParams;
columnDef.valueSetter = createStringValueSetter(col, isTextEditorOpen);
} else if (col.type === 'date') {
columnDef.cellEditorSelector = () => ({
component: ElDatePickerCellEditor,
});
columnDef.valueFormatter = dateValueFormatter;
}
return {
...columnDef,
...extraProps,
};
};
const onCellEditingStarted = (params: CellEditingStartedEvent<DataStoreRow>) => {
if (params.column.getColDef().cellDataType === 'text') {
isTextEditorOpen.value = true;
} else {
isTextEditorOpen.value = false;
}
};
const onCellEditingStopped = (params: CellEditingStoppedEvent<DataStoreRow>) => {
if (params.column.getColDef().cellDataType === 'text') {
isTextEditorOpen.value = false;
}
};
const getColumnDefinitions = (dataStoreColumns: DataStoreColumn[]) => {
const systemDateColumnOptions: Partial<ColDef> = {
editable: false,
suppressMovable: true,
lockPinned: true,
lockPosition: 'right',
headerComponentParams: {
allowMenuActions: false,
},
};
return [
// Always add the ID column, it's not returned by the back-end but all data stores have it
// We use it as a placeholder for new datastores
createColumnDef(
{
index: 0,
id: DEFAULT_ID_COLUMN_NAME,
name: DEFAULT_ID_COLUMN_NAME,
type: 'string',
},
{
editable: false,
sortable: false,
suppressMovable: true,
headerComponent: null,
lockPosition: true,
minWidth: DATA_STORE_ID_COLUMN_WIDTH,
maxWidth: DATA_STORE_ID_COLUMN_WIDTH,
resizable: 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(dataStoreColumns, 'index').map((col) => createColumnDef(col)),
createColumnDef(
{
index: dataStoreColumns.length + 1,
id: 'createdAt',
name: 'createdAt',
type: 'date',
},
systemDateColumnOptions,
),
createColumnDef(
{
index: dataStoreColumns.length + 2,
id: 'updatedAt',
name: 'updatedAt',
type: 'date',
},
systemDateColumnOptions,
),
createColumnDef(
{
index: dataStoreColumns.length + 3,
id: 'add-column',
name: 'Add Column',
type: 'string',
},
{
editable: false,
suppressMovable: true,
lockPinned: true,
lockPosition: 'right',
resizable: false,
headerComponent: AddColumnButton,
headerComponentParams: { onAddColumn },
},
),
];
};
const loadColumns = (dataStoreColumns: DataStoreColumn[]) => {
colDefs.value = getColumnDefinitions(dataStoreColumns);
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: DataStoreColumn) => {
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<DataStoreRow>) => {
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<DataStoreRow>) => {
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,
};
};

View File

@@ -0,0 +1,86 @@
import {
useDataStoreOperations,
type UseDataStoreOperationsParams,
} from '@/features/dataStore/composables/useDataStoreOperations';
import { ref } from 'vue';
import type { GridApi } from 'ag-grid-community';
import { setActivePinia } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
vi.mock('@/features/dataStore/dataStore.store', () => ({
useDataStoreStore: vi.fn(() => ({})),
}));
describe('useDataStoreOperations', () => {
let params: UseDataStoreOperationsParams;
let dataStoreStore: ReturnType<typeof useDataStoreStore>;
beforeEach(() => {
setActivePinia(createTestingPinia());
dataStoreStore = {
addDataStoreColumn: vi.fn(),
deleteDataStoreColumn: vi.fn(),
moveDataStoreColumn: vi.fn(),
deleteRows: vi.fn(),
insertEmptyRow: vi.fn(),
} as unknown as ReturnType<typeof useDataStoreStore>;
vi.mocked(useDataStoreStore).mockReturnValue(dataStoreStore);
params = {
colDefs: ref([]),
rowData: ref([]),
deleteGridColumn: vi.fn(),
addGridColumn: vi.fn(),
setGridData: vi.fn(),
insertGridColumnAtIndex: vi.fn(),
moveGridColumn: vi.fn(),
dataStoreId: 'test',
projectId: 'test',
gridApi: ref(null as unknown as GridApi),
totalItems: ref(0),
setTotalItems: vi.fn(),
ensureItemOnPage: vi.fn(),
focusFirstEditableCell: vi.fn(),
toggleSave: vi.fn(),
currentPage: ref(1),
pageSize: ref(10),
currentSortBy: ref(''),
currentSortOrder: ref(null),
handleClearSelection: vi.fn(),
selectedRowIds: ref(new Set()),
handleCopyFocusedCell: vi.fn(),
};
});
afterEach(() => {
vi.clearAllMocks();
});
describe('onAddColumn', () => {
it('should raise error when column is not added', async () => {
vi.mocked(useDataStoreStore).mockReturnValue({
...dataStoreStore,
addDataStoreColumn: vi.fn().mockRejectedValue(new Error('test')),
});
const { onAddColumn } = useDataStoreOperations(params);
const result = await onAddColumn({ name: 'test', type: 'string' });
expect(result).toBe(false);
});
it('should add column when column is added', async () => {
const returnedColumn = { name: 'test', type: 'string' } as const;
vi.mocked(useDataStoreStore).mockReturnValue({
...dataStoreStore,
addDataStoreColumn: vi.fn().mockResolvedValue(returnedColumn),
});
const rowData = ref([{ id: 1 }]);
const { onAddColumn } = useDataStoreOperations({ ...params, rowData });
const result = await onAddColumn({ name: returnedColumn.name, type: returnedColumn.type });
expect(result).toBe(true);
expect(params.setGridData).toHaveBeenCalledWith({ rowData: [{ id: 1, test: null }] });
expect(params.addGridColumn).toHaveBeenCalledWith(returnedColumn);
});
});
});

View File

@@ -0,0 +1,319 @@
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { useTelemetry } from '@/composables/useTelemetry';
import type {
DataStoreColumn,
DataStoreColumnCreatePayload,
DataStoreRow,
} from '@/features/dataStore/datastore.types';
import { ref, type Ref } from 'vue';
import { useI18n } from '@n8n/i18n';
import type {
CellKeyDownEvent,
CellValueChangedEvent,
ColDef,
ColumnMovedEvent,
GridApi,
} from 'ag-grid-community';
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
import { MODAL_CONFIRM } from '@/constants';
import { isDataStoreValue } from '@/features/dataStore/typeGuards';
export type UseDataStoreOperationsParams = {
colDefs: Ref<ColDef[]>;
rowData: Ref<DataStoreRow[]>;
deleteGridColumn: (columnId: string) => void;
addGridColumn: (column: DataStoreColumn) => void;
setGridData: (params: { rowData?: DataStoreRow[]; colDefs?: ColDef[] }) => void;
insertGridColumnAtIndex: (column: ColDef, index: number) => void;
moveGridColumn: (oldIndex: number, newIndex: number) => void;
dataStoreId: string;
projectId: string;
gridApi: Ref<GridApi>;
totalItems: Ref<number>;
setTotalItems: (count: number) => void;
ensureItemOnPage: (itemIndex: number) => Promise<void>;
focusFirstEditableCell: (rowId: number) => void;
toggleSave: (value: boolean) => void;
currentPage: Ref<number>;
pageSize: Ref<number>;
currentSortBy: Ref<string>;
currentSortOrder: Ref<string | null>;
handleClearSelection: () => void;
selectedRowIds: Ref<Set<number>>;
handleCopyFocusedCell: (params: CellKeyDownEvent<DataStoreRow>) => Promise<void>;
};
export const useDataStoreOperations = ({
colDefs,
rowData,
deleteGridColumn,
addGridColumn,
setGridData,
insertGridColumnAtIndex,
moveGridColumn,
dataStoreId,
projectId,
gridApi,
totalItems,
setTotalItems,
ensureItemOnPage,
focusFirstEditableCell,
toggleSave,
currentPage,
pageSize,
currentSortBy,
currentSortOrder,
handleClearSelection,
selectedRowIds,
handleCopyFocusedCell,
}: UseDataStoreOperationsParams) => {
const i18n = useI18n();
const toast = useToast();
const message = useMessage();
const dataStoreStore = useDataStoreStore();
const contentLoading = ref(false);
const telemetry = useTelemetry();
async function onDeleteColumn(columnId: string) {
const columnToDelete = colDefs.value.find((col) => col.colId === columnId);
if (!columnToDelete) return;
const promptResponse = await message.confirm(
i18n.baseText('dataStore.deleteColumn.confirm.message', {
interpolate: { name: columnToDelete.headerName ?? '' },
}),
i18n.baseText('dataStore.deleteColumn.confirm.title'),
{
confirmButtonText: i18n.baseText('generic.delete'),
cancelButtonText: i18n.baseText('generic.cancel'),
},
);
if (promptResponse !== MODAL_CONFIRM) {
return;
}
const columnToDeleteIndex = colDefs.value.findIndex((col) => col.colId === columnId);
deleteGridColumn(columnId);
const rowDataOldValue = [...rowData.value];
rowData.value = rowData.value.map((row) => {
const { [columnToDelete.field!]: _, ...rest } = row;
return rest;
});
setGridData({ rowData: rowData.value });
try {
await dataStoreStore.deleteDataStoreColumn(dataStoreId, projectId, columnId);
telemetry.track('User deleted data table column', {
column_id: columnId,
column_type: columnToDelete.cellDataType,
data_table_id: dataStoreId,
});
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.deleteColumn.error'));
insertGridColumnAtIndex(columnToDelete, columnToDeleteIndex);
rowData.value = rowDataOldValue;
setGridData({ rowData: rowData.value });
}
}
async function onAddColumn(column: DataStoreColumnCreatePayload) {
try {
const newColumn = await dataStoreStore.addDataStoreColumn(dataStoreId, projectId, column);
addGridColumn(newColumn);
rowData.value = rowData.value.map((row) => {
return { ...row, [newColumn.name]: null };
});
setGridData({ rowData: rowData.value });
telemetry.track('User added data table column', {
column_id: newColumn.id,
column_type: newColumn.type,
data_table_id: dataStoreId,
});
return true;
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.addColumn.error'));
return false;
}
}
const onColumnMoved = async (moveEvent: ColumnMovedEvent) => {
if (
!moveEvent.finished ||
moveEvent.source !== 'uiColumnMoved' ||
moveEvent.toIndex === undefined ||
!moveEvent.column
) {
return;
}
const oldIndex = colDefs.value.findIndex((col) => col.colId === moveEvent.column!.getColId());
const newIndex = moveEvent.toIndex - 2; // selection and id columns are included here
try {
await dataStoreStore.moveDataStoreColumn(
dataStoreId,
projectId,
moveEvent.column.getColId(),
newIndex,
);
moveGridColumn(oldIndex, newIndex);
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.moveColumn.error'));
gridApi.value.moveColumnByIndex(moveEvent.toIndex, oldIndex + 1);
}
};
async function onAddRowClick() {
try {
await ensureItemOnPage(totalItems.value + 1);
contentLoading.value = true;
toggleSave(true);
const insertedRow = await dataStoreStore.insertEmptyRow(dataStoreId, projectId);
const newRow: DataStoreRow = insertedRow;
rowData.value.push(newRow);
setTotalItems(totalItems.value + 1);
setGridData({ rowData: rowData.value });
focusFirstEditableCell(newRow.id as number);
telemetry.track('User added row to data table', {
data_table_id: dataStoreId,
});
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.addRow.error'));
} finally {
toggleSave(false);
contentLoading.value = false;
}
}
const onCellValueChanged = async (params: CellValueChangedEvent<DataStoreRow>) => {
const { data, api, oldValue, colDef } = params;
const value = params.data[colDef.field!];
if (value === undefined || value === oldValue) {
return;
}
if (typeof data.id !== 'number') {
throw new Error('Expected row id to be a number');
}
const fieldName = String(colDef.field);
const id = data.id;
try {
toggleSave(true);
await dataStoreStore.updateRow(dataStoreId, projectId, id, {
[fieldName]: value,
});
telemetry.track('User edited data table content', {
data_table_id: dataStoreId,
column_id: colDef.colId,
column_type: colDef.cellDataType,
});
} catch (error) {
// Revert cell to original value if the update fails
const validOldValue = isDataStoreValue(oldValue) ? oldValue : null;
const revertedData: DataStoreRow = { ...data, [fieldName]: validOldValue };
api.applyTransaction({
update: [revertedData],
});
toast.showError(error, i18n.baseText('dataStore.updateRow.error'));
} finally {
toggleSave(false);
}
};
async function fetchDataStoreRows() {
try {
contentLoading.value = true;
const fetchedRows = await dataStoreStore.fetchDataStoreContent(
dataStoreId,
projectId,
currentPage.value,
pageSize.value,
`${currentSortBy.value}:${currentSortOrder.value}`,
);
rowData.value = fetchedRows.data;
setTotalItems(fetchedRows.count);
setGridData({ rowData: rowData.value, colDefs: colDefs.value });
handleClearSelection();
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.fetchContent.error'));
} finally {
contentLoading.value = false;
}
}
const handleDeleteSelected = async () => {
if (selectedRowIds.value.size === 0) return;
const confirmResponse = await message.confirm(
i18n.baseText('dataStore.deleteRows.confirmation', {
adjustToNumber: selectedRowIds.value.size,
interpolate: { count: selectedRowIds.value.size },
}),
i18n.baseText('dataStore.deleteRows.title'),
{
confirmButtonText: i18n.baseText('generic.delete'),
cancelButtonText: i18n.baseText('generic.cancel'),
},
);
if (confirmResponse !== MODAL_CONFIRM) {
return;
}
try {
toggleSave(true);
const idsToDelete = Array.from(selectedRowIds.value);
await dataStoreStore.deleteRows(dataStoreId, projectId, idsToDelete);
await fetchDataStoreRows();
toast.showToast({
title: i18n.baseText('dataStore.deleteRows.success'),
message: '',
type: 'success',
});
telemetry.track('User deleted rows in data table', {
data_table_id: dataStoreId,
deleted_row_count: idsToDelete.length,
});
} catch (error) {
toast.showError(error, i18n.baseText('dataStore.deleteRows.error'));
} finally {
toggleSave(false);
}
};
const onCellKeyDown = async (params: CellKeyDownEvent<DataStoreRow>) => {
if (params.api.getEditingCells().length > 0) {
return;
}
const event = params.event as KeyboardEvent;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'c') {
event.preventDefault();
await handleCopyFocusedCell(params);
return;
}
if ((event.key !== 'Delete' && event.key !== 'Backspace') || selectedRowIds.value.size === 0) {
return;
}
event.preventDefault();
await handleDeleteSelected();
};
return {
onDeleteColumn,
onAddColumn,
onColumnMoved,
onAddRowClick,
contentLoading,
onCellValueChanged,
fetchDataStoreRows,
handleDeleteSelected,
onCellKeyDown,
};
};

View File

@@ -0,0 +1,50 @@
import { ref } from 'vue';
export type PageSize = 10 | 20 | 50;
export type UseDataStorePaginationOptions = {
initialPage?: number;
initialPageSize?: PageSize;
pageSizeOptions?: PageSize[];
onChange?: (page: number, pageSize: number) => Promise<void> | void;
};
export const useDataStorePagination = (options: UseDataStorePaginationOptions = {}) => {
const currentPage = ref<number>(options.initialPage ?? 1);
const pageSize = ref<PageSize>(options.initialPageSize ?? 20);
const totalItems = ref<number>(0);
const pageSizeOptions = options.pageSizeOptions ?? [10, 20, 50];
const setTotalItems = (count: number) => {
totalItems.value = count;
};
const setCurrentPage = async (page: number) => {
currentPage.value = page;
if (options.onChange) await options.onChange(currentPage.value, pageSize.value);
};
const setPageSize = async (size: PageSize) => {
pageSize.value = size;
currentPage.value = 1;
if (options.onChange) await options.onChange(currentPage.value, pageSize.value);
};
const ensureItemOnPage = async (itemIndex: number) => {
const itemPage = Math.max(1, Math.ceil(itemIndex / pageSize.value));
if (currentPage.value !== itemPage) {
currentPage.value = itemPage;
if (options.onChange) await options.onChange(currentPage.value, pageSize.value);
}
};
return {
currentPage,
pageSize,
pageSizeOptions,
totalItems,
setTotalItems,
setCurrentPage,
setPageSize,
ensureItemOnPage,
};
};

View File

@@ -0,0 +1,89 @@
import { ref } from 'vue';
import type { GridApi, IRowNode } from 'ag-grid-community';
import { useDataStoreSelection } from './useDataStoreSelection';
const createMockGridApi = () =>
({
getSelectedNodes: vi.fn(),
deselectAll: vi.fn(),
}) as unknown as GridApi;
describe('useDataStoreSelection', () => {
let mockGridApi: GridApi;
beforeEach(() => {
mockGridApi = createMockGridApi();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('onSelectionChanged', () => {
it('should update selectedRowIds with numeric IDs from selected nodes', () => {
const gridApi = ref(mockGridApi);
const { selectedRowIds, onSelectionChanged } = useDataStoreSelection({ gridApi });
const mockSelectedNodes = [{ data: { id: 1 } }, { data: { id: 2 } }, { data: { id: 3 } }];
vi.mocked(mockGridApi.getSelectedNodes).mockReturnValue(mockSelectedNodes as IRowNode[]);
onSelectionChanged();
expect(selectedRowIds.value).toEqual(new Set([1, 2, 3]));
});
it('should filter out non-numeric IDs', () => {
const gridApi = ref(mockGridApi);
const { selectedRowIds, onSelectionChanged } = useDataStoreSelection({ gridApi });
const mockSelectedNodes = [
{ data: { id: 1 } },
{ data: { id: 'string-id' } },
{ data: { id: 2 } },
{ data: { id: null } },
{ data: { id: undefined } },
];
vi.mocked(mockGridApi.getSelectedNodes).mockReturnValue(mockSelectedNodes as IRowNode[]);
onSelectionChanged();
expect(selectedRowIds.value).toEqual(new Set([1, 2]));
});
it('should update selectedCount reactively', () => {
const gridApi = ref(mockGridApi);
const { selectedCount, onSelectionChanged } = useDataStoreSelection({ gridApi });
const mockSelectedNodes = [{ data: { id: 1 } }, { data: { id: 2 } }, { data: { id: 3 } }];
vi.mocked(mockGridApi.getSelectedNodes).mockReturnValue(mockSelectedNodes as IRowNode[]);
onSelectionChanged();
expect(selectedCount.value).toBe(3);
});
});
describe('handleClearSelection', () => {
it('should clear selectedRowIds and call deselectAll on grid', () => {
const gridApi = ref(mockGridApi);
const { selectedRowIds, handleClearSelection, onSelectionChanged } = useDataStoreSelection({
gridApi,
});
// First select some rows
const mockSelectedNodes = [{ data: { id: 1 } }, { data: { id: 2 } }];
vi.mocked(mockGridApi.getSelectedNodes).mockReturnValue(mockSelectedNodes as IRowNode[]);
onSelectionChanged();
expect(selectedRowIds.value).toEqual(new Set([1, 2]));
// Clear selection
handleClearSelection();
expect(selectedRowIds.value).toEqual(new Set());
expect(mockGridApi.deselectAll).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,45 @@
import { computed, ref, type Ref } from 'vue';
import type { GridApi, RowSelectionOptions } from 'ag-grid-community';
import { ADD_ROW_ROW_ID } from '@/features/dataStore/constants';
export const useDataStoreSelection = ({
gridApi,
}: {
gridApi: Ref<GridApi>;
}) => {
const selectedRowIds = ref<Set<number>>(new Set());
const selectedCount = computed(() => selectedRowIds.value.size);
const rowSelection: RowSelectionOptions | 'single' | 'multiple' = {
mode: 'multiRow',
enableClickSelection: false,
checkboxes: (params) => params.data?.id !== ADD_ROW_ROW_ID,
isRowSelectable: (params) => params.data?.id !== ADD_ROW_ROW_ID,
};
const onSelectionChanged = () => {
const selectedNodes = gridApi.value.getSelectedNodes();
const newSelectedIds = new Set<number>();
selectedNodes.forEach((node) => {
if (typeof node.data?.id === 'number') {
newSelectedIds.add(node.data.id);
}
});
selectedRowIds.value = newSelectedIds;
};
const handleClearSelection = () => {
selectedRowIds.value = new Set();
gridApi.value.deselectAll();
};
return {
selectedRowIds,
selectedCount,
rowSelection,
onSelectionChanged,
handleClearSelection,
};
};

View File

@@ -178,12 +178,12 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
});
};
const insertEmptyRow = async (dataStore: DataStore) => {
const insertEmptyRow = async (dataStoreId: string, projectId: string) => {
const inserted = await insertDataStoreRowApi(
rootStore.restApiContext,
dataStore.id,
dataStoreId,
{},
dataStore.projectId,
projectId,
);
return inserted[0];
};

View File

@@ -0,0 +1,95 @@
import type {
CellClassParams,
ICellRendererParams,
ValueGetterParams,
ValueSetterParams,
CellEditRequestEvent,
ValueFormatterParams,
} from 'ag-grid-community';
import type { Ref } from 'vue';
import type { DataStoreColumn, DataStoreRow } from '@/features/dataStore/datastore.types';
import { ADD_ROW_ROW_ID, EMPTY_VALUE, NULL_VALUE } from '@/features/dataStore/constants';
import NullEmptyCellRenderer from '@/features/dataStore/components/dataGrid/NullEmptyCellRenderer.vue';
import { isDataStoreValue } from '@/features/dataStore/typeGuards';
export const getCellClass = (params: CellClassParams): string => {
if (params.data?.id === ADD_ROW_ROW_ID) {
return 'add-row-cell';
}
if (params.column.getUserProvidedColDef()?.cellDataType === 'boolean') {
return 'boolean-cell';
}
return '';
};
export const createValueGetter =
(col: DataStoreColumn) => (params: ValueGetterParams<DataStoreRow>) => {
if (params.data?.[col.name] === null || params.data?.[col.name] === undefined) {
return null;
}
if (col.type === 'date') {
const value = params.data?.[col.name];
if (typeof value === 'string') {
return new Date(value);
}
}
return params.data?.[col.name];
};
export const createCellRendererSelector =
(col: DataStoreColumn) => (params: ICellRendererParams) => {
if (params.data?.id === ADD_ROW_ROW_ID || col.id === 'add-column') {
return {};
}
let rowValue = (params.data as DataStoreRow | undefined)?.[col.name];
if (rowValue === undefined) {
rowValue = null;
}
if (rowValue === null) {
return { component: NullEmptyCellRenderer, params: { value: NULL_VALUE } };
}
if (rowValue === '') {
return { component: NullEmptyCellRenderer, params: { value: EMPTY_VALUE } };
}
return undefined;
};
export const createStringValueSetter =
(col: DataStoreColumn, isTextEditorOpen: Ref<boolean>) =>
(params: ValueSetterParams<DataStoreRow>) => {
let originalValue = params.data[col.name];
if (originalValue === undefined) {
originalValue = null;
}
let newValue = params.newValue as unknown as DataStoreRow[keyof DataStoreRow];
if (!isDataStoreValue(newValue)) {
return false;
}
if (originalValue === null && newValue === '') {
return false;
}
if (isTextEditorOpen.value && newValue === null) {
newValue = '';
}
params.data[col.name] = newValue;
return true;
};
export const stringCellEditorParams = (
params: CellEditRequestEvent<DataStoreRow>,
): { value: string; maxLength: number } => ({
value: (params.value as string | null | undefined) ?? '',
maxLength: 999999999,
});
export const dateValueFormatter = (
params: ValueFormatterParams<DataStoreRow, Date | null | undefined>,
): string => {
const value = params.value;
if (value === null || value === undefined) return '';
return value.toISOString();
};