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">
import type { IHeaderParams } from 'ag-grid-community';
import type { IHeaderParams, SortDirection } from 'ag-grid-community';
import { useDataStoreTypes } from '@/features/dataStore/composables/useDataStoreTypes';
import { ref, computed } from 'vue';
import { useI18n } from '@n8n/i18n';
import { isAGGridCellType } from '@/features/dataStore/typeGuards';
import { N8nActionDropdown } from '@n8n/design-system';
type HeaderParamsWithDelete = IHeaderParams & {
onDelete: (columnId: string) => void;
@@ -19,6 +20,7 @@ const i18n = useI18n();
const isHovered = ref(false);
const isDropdownOpen = ref(false);
const dropdownRef = ref<InstanceType<typeof N8nActionDropdown>>();
const enum ItemAction {
Delete = 'delete',
@@ -62,22 +64,63 @@ const columnActionItems = [
customClass: 'data-store-column-header-action-item',
} 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>
<template>
<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"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
@click="onHeaderClick"
>
<div class="data-store-column-header-icon-wrapper">
<N8nIcon v-if="typeIcon" :icon="typeIcon" />
<span class="ag-header-cell-text" data-test-id="data-store-column-header-text">{{
props.params.displayName
}}</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>
<N8nActionDropdown
v-show="isDropdownVisible"
ref="dropdownRef"
data-test-id="data-store-column-header-actions"
:items="columnActionItems"
:placement="'bottom-start'"
@@ -94,6 +137,12 @@ const columnActionItems = [
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
cursor: default;
&.sortable {
cursor: pointer;
}
}
.data-store-column-header-action-item {
@@ -116,6 +165,19 @@ const columnActionItems = [
.ag-header-cell-text {
@include mixins.utils-ellipsis;
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>

View File

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

View File

@@ -25,6 +25,7 @@ import type {
CellEditingStoppedEvent,
CellKeyDownEvent,
ValueFormatterParams,
SortDirection,
} from 'ag-grid-community';
import {
ModuleRegistry,
@@ -42,6 +43,7 @@ import {
UndoRedoEditModule,
CellStyleModule,
ScrollApiModule,
PinnedRowModule,
} from 'ag-grid-community';
import { n8nTheme } from '@/features/dataStore/components/dataGrid/n8nTheme';
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 NullEmptyCellRenderer from '@/features/dataStore/components/dataGrid/NullEmptyCellRenderer.vue';
import { onClickOutside } from '@vueuse/core';
import type { SortChangedEvent } from 'ag-grid-community';
import { useClipboard } from '@/composables/useClipboard';
import { reorderItem } from '@/features/dataStore/utils';
import { convertToDisplayDate } from '@/utils/formatters/dateFormatter';
@@ -85,6 +88,7 @@ ModuleRegistry.registerModules([
ClientSideRowModelApiModule,
UndoRedoEditModule,
CellStyleModule,
PinnedRowModule,
ScrollApiModule,
]);
@@ -118,6 +122,8 @@ const rowSelection: RowSelectionOptions | 'single' | 'multiple' = {
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
@@ -139,6 +145,8 @@ const rows = ref<DataStoreRow[]>([]);
const selectedRowIds = ref<Set<number>>(new Set());
const selectedCount = computed(() => selectedRowIds.value.size);
const hasRecords = computed(() => rowData.value.length > 0);
const onGridReady = (params: GridReadyEvent) => {
gridApi.value = params.api;
// Ensure popups (e.g., agLargeTextCellEditor) are positioned relative to the grid container
@@ -149,15 +157,15 @@ const onGridReady = (params: GridReadyEvent) => {
};
const refreshGridData = () => {
if (gridApi.value) {
gridApi.value.setGridOption('columnDefs', colDefs.value);
gridApi.value.setGridOption('rowData', [
...rowData.value,
{
id: ADD_ROW_ROW_ID,
},
]);
}
if (!gridApi.value) return;
gridApi.value.setGridOption('columnDefs', colDefs.value);
// only real rows here
gridApi.value.setGridOption('rowData', rowData.value);
// special "add row" pinned to the bottom
gridApi.value.setGridOption('pinnedBottomRowData', [{ id: ADD_ROW_ROW_ID }]);
};
const focusFirstEditableCell = (rowId: number) => {
@@ -258,7 +266,7 @@ const createColumnDef = (col: DataStoreColumn, extraProps: Partial<ColDef> = {})
colId: col.id,
field: col.name,
headerName: col.name,
sortable: false,
sortable: true,
flex: 1,
editable: (params) => params.data?.id !== ADD_ROW_ROW_ID,
resizable: true,
@@ -443,6 +451,7 @@ const initColumnDefinitions = () => {
},
{
editable: false,
sortable: false,
suppressMovable: true,
headerComponent: null,
lockPosition: true,
@@ -454,9 +463,7 @@ const initColumnDefinitions = () => {
if (params.value === ADD_ROW_ROW_ID) {
return {
component: AddRowButton,
params: {
onClick: onAddRowClick,
},
params: { onClick: onAddRowClick },
};
}
return undefined;
@@ -570,11 +577,13 @@ const onCellClicked = (params: CellClickedEvent<DataStoreRow>) => {
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;
@@ -632,6 +641,29 @@ const initialize = async () => {
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();
});
@@ -754,7 +786,11 @@ defineExpose({
<template>
<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
style="width: 100%"
:dom-layout="'autoHeight'"
@@ -768,6 +804,7 @@ defineExpose({
:get-row-id="(params: GetRowIdParams) => String(params.data.id)"
:stop-editing-when-cells-lose-focus="true"
:undo-redo-cell-editing="true"
:suppress-multi-sort="true"
@grid-ready="onGridReady"
@cell-value-changed="onCellValueChanged"
@column-moved="onColumnMoved"
@@ -776,6 +813,7 @@ defineExpose({
@cell-editing-stopped="onCellEditingStopped"
@column-header-clicked="resetLastFocusedCell"
@selection-changed="onSelectionChanged"
@sort-changed="onSortChanged"
@cell-key-down="onCellKeyDown"
/>
</div>
@@ -941,6 +979,16 @@ defineExpose({
:global(.ag-cell-focus) {
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 {

View File

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

View File

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