mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat: Allow sorting columns in data tables (no-changelog) (#18969)
Co-authored-by: Milorad FIlipović <milorad@n8n.io>
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ vi.mock('ag-grid-community', () => ({
|
|||||||
UndoRedoEditModule: {},
|
UndoRedoEditModule: {},
|
||||||
CellStyleModule: {},
|
CellStyleModule: {},
|
||||||
ScrollApiModule: {},
|
ScrollApiModule: {},
|
||||||
|
PinnedRowModule: {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the n8n theme
|
// Mock the n8n theme
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user