mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +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: {},
|
CellStyleModule: {},
|
||||||
ScrollApiModule: {},
|
ScrollApiModule: {},
|
||||||
PinnedRowModule: {},
|
PinnedRowModule: {},
|
||||||
|
ColumnApiModule: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the n8n theme
|
// 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) => ({
|
vi.mock('@n8n/i18n', async (importOriginal) => ({
|
||||||
...(await importOriginal()),
|
...(await importOriginal()),
|
||||||
useI18n: () => ({
|
useI18n: () => ({
|
||||||
|
|||||||
@@ -1,32 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, nextTick, useTemplateRef } from 'vue';
|
import { computed, ref, useTemplateRef, watch } from 'vue';
|
||||||
import orderBy from 'lodash/orderBy';
|
|
||||||
import type {
|
import type {
|
||||||
DataStore,
|
DataStore,
|
||||||
DataStoreColumn,
|
|
||||||
DataStoreColumnCreatePayload,
|
DataStoreColumnCreatePayload,
|
||||||
DataStoreRow,
|
DataStoreRow,
|
||||||
} from '@/features/dataStore/datastore.types';
|
} from '@/features/dataStore/datastore.types';
|
||||||
import { AgGridVue } from 'ag-grid-vue3';
|
import { AgGridVue } from 'ag-grid-vue3';
|
||||||
import type {
|
import type { GetRowIdParams, GridReadyEvent } from 'ag-grid-community';
|
||||||
GridApi,
|
|
||||||
GridReadyEvent,
|
|
||||||
ColDef,
|
|
||||||
ColumnMovedEvent,
|
|
||||||
ValueGetterParams,
|
|
||||||
RowSelectionOptions,
|
|
||||||
CellValueChangedEvent,
|
|
||||||
GetRowIdParams,
|
|
||||||
ICellRendererParams,
|
|
||||||
CellEditRequestEvent,
|
|
||||||
CellClickedEvent,
|
|
||||||
ValueSetterParams,
|
|
||||||
CellEditingStartedEvent,
|
|
||||||
CellEditingStoppedEvent,
|
|
||||||
CellKeyDownEvent,
|
|
||||||
SortDirection,
|
|
||||||
SortChangedEvent,
|
|
||||||
} from 'ag-grid-community';
|
|
||||||
import {
|
import {
|
||||||
ModuleRegistry,
|
ModuleRegistry,
|
||||||
ClientSideRowModelModule,
|
ClientSideRowModelModule,
|
||||||
@@ -44,34 +24,15 @@ import {
|
|||||||
CellStyleModule,
|
CellStyleModule,
|
||||||
ScrollApiModule,
|
ScrollApiModule,
|
||||||
PinnedRowModule,
|
PinnedRowModule,
|
||||||
|
ColumnApiModule,
|
||||||
} from 'ag-grid-community';
|
} from 'ag-grid-community';
|
||||||
import { n8nTheme } from '@/features/dataStore/components/dataGrid/n8nTheme';
|
import { n8nTheme } from '@/features/dataStore/components/dataGrid/n8nTheme';
|
||||||
import SelectedItemsInfo from '@/components/common/SelectedItemsInfo.vue';
|
import SelectedItemsInfo from '@/components/common/SelectedItemsInfo.vue';
|
||||||
import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
|
import { DATA_STORE_HEADER_HEIGHT, DATA_STORE_ROW_HEIGHT } from '@/features/dataStore/constants';
|
||||||
import { useI18n } from '@n8n/i18n';
|
import { useDataStorePagination } from '@/features/dataStore/composables/useDataStorePagination';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useDataStoreGridBase } from '@/features/dataStore/composables/useDataStoreGridBase';
|
||||||
import {
|
import { useDataStoreSelection } from '@/features/dataStore/composables/useDataStoreSelection';
|
||||||
DEFAULT_ID_COLUMN_NAME,
|
import { useDataStoreOperations } from '@/features/dataStore/composables/useDataStoreOperations';
|
||||||
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';
|
|
||||||
|
|
||||||
// Register only the modules we actually use
|
// Register only the modules we actually use
|
||||||
ModuleRegistry.registerModules([
|
ModuleRegistry.registerModules([
|
||||||
@@ -90,6 +51,7 @@ ModuleRegistry.registerModules([
|
|||||||
CellStyleModule,
|
CellStyleModule,
|
||||||
PinnedRowModule,
|
PinnedRowModule,
|
||||||
ScrollApiModule,
|
ScrollApiModule,
|
||||||
|
ColumnApiModule,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -102,718 +64,84 @@ const emit = defineEmits<{
|
|||||||
toggleSave: [value: boolean];
|
toggleSave: [value: boolean];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const i18n = useI18n();
|
const gridContainerRef = useTemplateRef<HTMLDivElement>('gridContainerRef');
|
||||||
const toast = useToast();
|
|
||||||
const message = useMessage();
|
|
||||||
const { mapToAGCellType } = useDataStoreTypes();
|
|
||||||
const telemetry = useTelemetry();
|
|
||||||
|
|
||||||
const dataStoreStore = useDataStoreStore();
|
const dataStoreGridBase = useDataStoreGridBase({
|
||||||
|
gridContainerRef,
|
||||||
const { copy: copyToClipboard } = useClipboard({ onPaste: onClipboardPaste });
|
onDeleteColumn: onDeleteColumnFunction,
|
||||||
|
onAddRowClick: onAddRowClickFunction,
|
||||||
// AG Grid State
|
onAddColumn: onAddColumnFunction,
|
||||||
const gridApi = ref<GridApi | null>(null);
|
});
|
||||||
const colDefs = ref<ColDef[]>([]);
|
|
||||||
const rowData = ref<DataStoreRow[]>([]);
|
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 hasRecords = computed(() => rowData.value.length > 0);
|
||||||
|
|
||||||
const onGridReady = (params: GridReadyEvent) => {
|
const pagination = useDataStorePagination({ onChange: fetchDataStoreRowsFunction });
|
||||||
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 refreshGridData = () => {
|
const selection = useDataStoreSelection({
|
||||||
if (!gridApi.value) return;
|
gridApi: dataStoreGridBase.gridApi,
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function onClipboardPaste(data: string) {
|
const dataStoreOperations = useDataStoreOperations({
|
||||||
if (!gridApi.value) return;
|
colDefs: dataStoreGridBase.colDefs,
|
||||||
const focusedCell = gridApi.value.getFocusedCell();
|
rowData,
|
||||||
const isEditing = gridApi.value.getEditingCells().length > 0;
|
deleteGridColumn: dataStoreGridBase.deleteColumn,
|
||||||
if (!focusedCell || isEditing) return;
|
setGridData: dataStoreGridBase.setGridData,
|
||||||
const row = gridApi.value.getDisplayedRowAtIndex(focusedCell.rowIndex);
|
insertGridColumnAtIndex: dataStoreGridBase.insertColumnAtIndex,
|
||||||
if (!row) return;
|
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();
|
async function onDeleteColumnFunction(columnId: string) {
|
||||||
if (colDef.cellDataType === 'text') {
|
await dataStoreOperations.onDeleteColumn(columnId);
|
||||||
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 resetLastFocusedCell = () => {
|
async function onAddColumnFunction(column: DataStoreColumnCreatePayload) {
|
||||||
lastFocusedCell.value = null;
|
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 () => {
|
watch([dataStoreGridBase.currentSortBy, dataStoreGridBase.currentSortOrder], async () => {
|
||||||
initColumnDefinitions();
|
await pagination.setCurrentPage(1);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
defineExpose({
|
||||||
addRow: onAddRowClick,
|
addRow: dataStoreOperations.onAddRowClick,
|
||||||
addColumn: onAddColumn,
|
addColumn: dataStoreOperations.onAddColumn,
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="$style.wrapper">
|
<div :class="$style.wrapper">
|
||||||
<div
|
<div
|
||||||
ref="gridContainer"
|
ref="gridContainerRef"
|
||||||
:class="[$style['grid-container'], { [$style['has-records']]: hasRecords }]"
|
:class="[$style['grid-container'], { [$style['has-records']]: hasRecords }]"
|
||||||
data-test-id="data-store-grid"
|
data-test-id="data-store-grid"
|
||||||
>
|
>
|
||||||
@@ -825,41 +153,41 @@ defineExpose({
|
|||||||
:animate-rows="false"
|
:animate-rows="false"
|
||||||
:theme="n8nTheme"
|
:theme="n8nTheme"
|
||||||
:suppress-drag-leave-hides-columns="true"
|
:suppress-drag-leave-hides-columns="true"
|
||||||
:loading="contentLoading"
|
:loading="dataStoreOperations.contentLoading.value"
|
||||||
:row-selection="rowSelection"
|
:row-selection="selection.rowSelection"
|
||||||
:get-row-id="(params: GetRowIdParams) => String(params.data.id)"
|
:get-row-id="(params: GetRowIdParams) => String(params.data.id)"
|
||||||
:stop-editing-when-cells-lose-focus="true"
|
:stop-editing-when-cells-lose-focus="true"
|
||||||
:undo-redo-cell-editing="true"
|
:undo-redo-cell-editing="true"
|
||||||
:suppress-multi-sort="true"
|
:suppress-multi-sort="true"
|
||||||
@grid-ready="onGridReady"
|
@grid-ready="initialize"
|
||||||
@cell-value-changed="onCellValueChanged"
|
@cell-value-changed="dataStoreOperations.onCellValueChanged"
|
||||||
@column-moved="onColumnMoved"
|
@column-moved="dataStoreOperations.onColumnMoved"
|
||||||
@cell-clicked="onCellClicked"
|
@cell-clicked="dataStoreGridBase.onCellClicked"
|
||||||
@cell-editing-started="onCellEditingStarted"
|
@cell-editing-started="dataStoreGridBase.onCellEditingStarted"
|
||||||
@cell-editing-stopped="onCellEditingStopped"
|
@cell-editing-stopped="dataStoreGridBase.onCellEditingStopped"
|
||||||
@column-header-clicked="resetLastFocusedCell"
|
@column-header-clicked="dataStoreGridBase.resetLastFocusedCell"
|
||||||
@selection-changed="onSelectionChanged"
|
@selection-changed="selection.onSelectionChanged"
|
||||||
@sort-changed="onSortChanged"
|
@sort-changed="dataStoreGridBase.onSortChanged"
|
||||||
@cell-key-down="onCellKeyDown"
|
@cell-key-down="dataStoreOperations.onCellKeyDown"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.footer">
|
<div :class="$style.footer">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
v-model:current-page="currentPage"
|
v-model:current-page="pagination.currentPage"
|
||||||
v-model:page-size="pageSize"
|
v-model:page-size="pagination.pageSize"
|
||||||
data-test-id="data-store-content-pagination"
|
data-test-id="data-store-content-pagination"
|
||||||
background
|
background
|
||||||
:total="totalItems"
|
:total="pagination.totalItems"
|
||||||
:page-sizes="pageSizeOptions"
|
:page-sizes="pagination.pageSizeOptions"
|
||||||
layout="total, prev, pager, next, sizes"
|
layout="total, prev, pager, next, sizes"
|
||||||
@update:current-page="setCurrentPage"
|
@update:current-page="pagination.setCurrentPage"
|
||||||
@size-change="setPageSize"
|
@size-change="pagination.setPageSize"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SelectedItemsInfo
|
<SelectedItemsInfo
|
||||||
:selected-count="selectedCount"
|
:selected-count="selection.selectedCount.value"
|
||||||
@delete-selected="handleDeleteSelected"
|
@delete-selected="dataStoreOperations.handleDeleteSelected"
|
||||||
@clear-selection="handleClearSelection"
|
@clear-selection="selection.handleClearSelection"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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(
|
const inserted = await insertDataStoreRowApi(
|
||||||
rootStore.restApiContext,
|
rootStore.restApiContext,
|
||||||
dataStore.id,
|
dataStoreId,
|
||||||
{},
|
{},
|
||||||
dataStore.projectId,
|
projectId,
|
||||||
);
|
);
|
||||||
return inserted[0];
|
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