feat: Allow sorting columns in data tables (no-changelog) (#18969)

Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
Ricardo Espinoza
2025-09-01 06:19:05 -04:00
committed by GitHub
parent 56ead93265
commit a622f73a74
5 changed files with 131 additions and 17 deletions

View File

@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { IHeaderParams } from 'ag-grid-community'; import type { IHeaderParams, SortDirection } from 'ag-grid-community';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes'; import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { useI18n } from '@n8n/i18n'; import { useI18n } from '@n8n/i18n';
import { isAGGridCellType } from '@/features/dataStore/typeGuards'; import { isAGGridCellType } from '@/features/dataStore/typeGuards';
import { N8nActionDropdown } from '@n8n/design-system';
type HeaderParamsWithDelete = IHeaderParams & { type HeaderParamsWithDelete = IHeaderParams & {
onDelete: (columnId: string) => void; onDelete: (columnId: string) => void;
@@ -19,6 +20,7 @@ const i18n = useI18n();
const isHovered = ref(false); const isHovered = ref(false);
const isDropdownOpen = ref(false); const isDropdownOpen = ref(false);
const dropdownRef = ref<InstanceType<typeof N8nActionDropdown>>();
const enum ItemAction { const enum ItemAction {
Delete = 'delete', Delete = 'delete',
@@ -62,22 +64,63 @@ const columnActionItems = [
customClass: 'data-store-column-header-action-item', customClass: 'data-store-column-header-action-item',
} as const, } as const,
]; ];
const currentSort = computed(() => {
return props.params.column.getSort();
});
const isSortable = computed(() => {
return props.params.column.getColDef().sortable;
});
const showSortIndicator = computed(() => {
return isSortable.value && Boolean(currentSort.value);
});
const onHeaderClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (dropdownRef.value?.$el?.contains(target)) {
return;
}
if (isSortable.value) {
const currentSortDirection = currentSort.value;
let nextSort: SortDirection = null;
if (!currentSortDirection) {
nextSort = 'asc';
} else if (currentSortDirection === 'asc') {
nextSort = 'desc';
}
props.params.setSort(nextSort, false);
}
};
</script> </script>
<template> <template>
<div <div
class="ag-header-cell-label data-store-column-header-wrapper" :class="['ag-header-cell-label', 'data-store-column-header-wrapper', { sortable: isSortable }]"
data-test-id="data-store-column-header" data-test-id="data-store-column-header"
@mouseenter="onMouseEnter" @mouseenter="onMouseEnter"
@mouseleave="onMouseLeave" @mouseleave="onMouseLeave"
@click="onHeaderClick"
> >
<div class="data-store-column-header-icon-wrapper"> <div class="data-store-column-header-icon-wrapper">
<N8nIcon v-if="typeIcon" :icon="typeIcon" /> <N8nIcon v-if="typeIcon" :icon="typeIcon" />
<span class="ag-header-cell-text" data-test-id="data-store-column-header-text">{{ <span class="ag-header-cell-text" data-test-id="data-store-column-header-text">{{
props.params.displayName props.params.displayName
}}</span> }}</span>
<div v-if="showSortIndicator" class="sort-indicator">
<N8nIcon v-if="currentSort === 'asc'" icon="arrow-up" class="sort-icon-active" />
<N8nIcon v-else-if="currentSort === 'desc'" icon="arrow-down" class="sort-icon-active" />
</div>
</div> </div>
<N8nActionDropdown <N8nActionDropdown
v-show="isDropdownVisible" v-show="isDropdownVisible"
ref="dropdownRef"
data-test-id="data-store-column-header-actions" data-test-id="data-store-column-header-actions"
:items="columnActionItems" :items="columnActionItems"
:placement="'bottom-start'" :placement="'bottom-start'"
@@ -94,6 +137,12 @@ const columnActionItems = [
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
width: 100%;
cursor: default;
&.sortable {
cursor: pointer;
}
} }
.data-store-column-header-action-item { .data-store-column-header-action-item {
@@ -116,6 +165,19 @@ const columnActionItems = [
.ag-header-cell-text { .ag-header-cell-text {
@include mixins.utils-ellipsis; @include mixins.utils-ellipsis;
min-width: 0; min-width: 0;
flex: 1; }
.sort-indicator {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
.sort-icon-active {
font-size: var(--font-size-2xs);
line-height: 1;
color: var(--color-text-base);
font-weight: var(--font-weight-bold);
}
} }
</style> </style>

View File

@@ -46,6 +46,7 @@ vi.mock('ag-grid-community', () => ({
UndoRedoEditModule: {}, UndoRedoEditModule: {},
CellStyleModule: {}, CellStyleModule: {},
ScrollApiModule: {}, ScrollApiModule: {},
PinnedRowModule: {},
})); }));
// Mock the n8n theme // Mock the n8n theme

View File

@@ -25,6 +25,7 @@ import type {
CellEditingStoppedEvent, CellEditingStoppedEvent,
CellKeyDownEvent, CellKeyDownEvent,
ValueFormatterParams, ValueFormatterParams,
SortDirection,
} from 'ag-grid-community'; } from 'ag-grid-community';
import { import {
ModuleRegistry, ModuleRegistry,
@@ -42,6 +43,7 @@ import {
UndoRedoEditModule, UndoRedoEditModule,
CellStyleModule, CellStyleModule,
ScrollApiModule, ScrollApiModule,
PinnedRowModule,
} 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';
@@ -66,6 +68,7 @@ import AddRowButton from '@/features/dataStore/components/dataGrid/AddRowButton.
import { isDataStoreValue } from '@/features/dataStore/typeGuards'; import { isDataStoreValue } from '@/features/dataStore/typeGuards';
import NullEmptyCellRenderer from '@/features/dataStore/components/dataGrid/NullEmptyCellRenderer.vue'; import NullEmptyCellRenderer from '@/features/dataStore/components/dataGrid/NullEmptyCellRenderer.vue';
import { onClickOutside } from '@vueuse/core'; import { onClickOutside } from '@vueuse/core';
import type { SortChangedEvent } from 'ag-grid-community';
import { useClipboard } from '@/composables/useClipboard'; import { useClipboard } from '@/composables/useClipboard';
import { reorderItem } from '@/features/dataStore/utils'; import { reorderItem } from '@/features/dataStore/utils';
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter'; import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
@@ -85,6 +88,7 @@ ModuleRegistry.registerModules([
ClientSideRowModelApiModule, ClientSideRowModelApiModule,
UndoRedoEditModule, UndoRedoEditModule,
CellStyleModule, CellStyleModule,
PinnedRowModule,
ScrollApiModule, ScrollApiModule,
]); ]);
@@ -118,6 +122,8 @@ const rowSelection: RowSelectionOptions | 'single' | 'multiple' = {
isRowSelectable: (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); const contentLoading = ref(false);
// Track the last focused cell so we can start editing when users click on it // Track the last focused cell so we can start editing when users click on it
@@ -139,6 +145,8 @@ const rows = ref<DataStoreRow[]>([]);
const selectedRowIds = ref<Set<number>>(new Set()); const selectedRowIds = ref<Set<number>>(new Set());
const selectedCount = computed(() => selectedRowIds.value.size); const selectedCount = computed(() => selectedRowIds.value.size);
const hasRecords = computed(() => rowData.value.length > 0);
const onGridReady = (params: GridReadyEvent) => { const onGridReady = (params: GridReadyEvent) => {
gridApi.value = params.api; gridApi.value = params.api;
// Ensure popups (e.g., agLargeTextCellEditor) are positioned relative to the grid container // Ensure popups (e.g., agLargeTextCellEditor) are positioned relative to the grid container
@@ -149,15 +157,15 @@ const onGridReady = (params: GridReadyEvent) => {
}; };
const refreshGridData = () => { const refreshGridData = () => {
if (gridApi.value) { if (!gridApi.value) return;
gridApi.value.setGridOption('columnDefs', colDefs.value);
gridApi.value.setGridOption('rowData', [ gridApi.value.setGridOption('columnDefs', colDefs.value);
...rowData.value,
{ // only real rows here
id: ADD_ROW_ROW_ID, 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) => { const focusFirstEditableCell = (rowId: number) => {
@@ -258,7 +266,7 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {})
colId: col.id, colId: col.id,
field: col.name, field: col.name,
headerName: col.name, headerName: col.name,
sortable: false, sortable: true,
flex: 1, flex: 1,
editable: (params) => params.data?.id !== ADD_ROW_ROW_ID, editable: (params) => params.data?.id !== ADD_ROW_ROW_ID,
resizable: true, resizable: true,
@@ -443,6 +451,7 @@ const initColumnDefinitions = () => {
}, },
{ {
editable: false, editable: false,
sortable: false,
suppressMovable: true, suppressMovable: true,
headerComponent: null, headerComponent: null,
lockPosition: true, lockPosition: true,
@@ -454,9 +463,7 @@ const initColumnDefinitions = () => {
if (params.value === ADD_ROW_ROW_ID) { if (params.value === ADD_ROW_ROW_ID) {
return { return {
component: AddRowButton, component: AddRowButton,
params: { params: { onClick: onAddRowClick },
onClick: onAddRowClick,
},
}; };
} }
return undefined; return undefined;
@@ -570,11 +577,13 @@ const onCellClicked = (params: CellClickedEvent<DataStoreRow>) => {
const fetchDataStoreContent = async () => { const fetchDataStoreContent = async () => {
try { try {
contentLoading.value = true; contentLoading.value = true;
const fetchedRows = await dataStoreStore.fetchDataStoreContent( const fetchedRows = await dataStoreStore.fetchDataStoreContent(
props.dataStore.id, props.dataStore.id,
props.dataStore.projectId, props.dataStore.projectId,
currentPage.value, currentPage.value,
pageSize.value, pageSize.value,
`${currentSortBy.value}:${currentSortOrder.value}`,
); );
rowData.value = fetchedRows.data; rowData.value = fetchedRows.data;
totalItems.value = fetchedRows.count; totalItems.value = fetchedRows.count;
@@ -632,6 +641,29 @@ const initialize = async () => {
await fetchDataStoreContent(); 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 () => { onMounted(async () => {
await initialize(); await initialize();
}); });
@@ -754,7 +786,11 @@ defineExpose({
<template> <template>
<div :class="$style.wrapper"> <div :class="$style.wrapper">
<div ref="gridContainer" :class="$style['grid-container']" data-test-id="data-store-grid"> <div
ref="gridContainer"
:class="[$style['grid-container'], { [$style['has-records']]: hasRecords }]"
data-test-id="data-store-grid"
>
<AgGridVue <AgGridVue
style="width: 100%" style="width: 100%"
:dom-layout="'autoHeight'" :dom-layout="'autoHeight'"
@@ -768,6 +804,7 @@ defineExpose({
: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"
@grid-ready="onGridReady" @grid-ready="onGridReady"
@cell-value-changed="onCellValueChanged" @cell-value-changed="onCellValueChanged"
@column-moved="onColumnMoved" @column-moved="onColumnMoved"
@@ -776,6 +813,7 @@ defineExpose({
@cell-editing-stopped="onCellEditingStopped" @cell-editing-stopped="onCellEditingStopped"
@column-header-clicked="resetLastFocusedCell" @column-header-clicked="resetLastFocusedCell"
@selection-changed="onSelectionChanged" @selection-changed="onSelectionChanged"
@sort-changed="onSortChanged"
@cell-key-down="onCellKeyDown" @cell-key-down="onCellKeyDown"
/> />
</div> </div>
@@ -941,6 +979,16 @@ defineExpose({
:global(.ag-cell-focus) { :global(.ag-cell-focus) {
background-color: var(--grid-row-selected-background); background-color: var(--grid-row-selected-background);
} }
&.has-records {
:global(.ag-floating-bottom) {
border-top: var(--border-width-base) var(--border-style-base) var(--ag-border-color);
}
}
:global(.ag-row[row-id='__n8n_add_row__']) {
border-bottom: none;
}
} }
.footer { .footer {

View File

@@ -135,6 +135,7 @@ export const getDataStoreRowsApi = async (
options?: { options?: {
skip?: number; skip?: number;
take?: number; take?: number;
sortBy?: string;
}, },
) => { ) => {
return await makeRestApiRequest<{ return await makeRestApiRequest<{

View File

@@ -169,10 +169,12 @@ export const useDataStoreStore = defineStore(DATA_STORE_STORE, () => {
projectId: string, projectId: string,
page: number, page: number,
pageSize: number, pageSize: number,
sortBy: string,
) => { ) => {
return await getDataStoreRowsApi(rootStore.restApiContext, datastoreId, projectId, { return await getDataStoreRowsApi(rootStore.restApiContext, datastoreId, projectId, {
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
sortBy,
}); });
}; };