mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(editor): Refactor data store table component (no-changelog) (#19131)
This commit is contained in:
@@ -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: () => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
Reference in New Issue
Block a user