From a80bc2795199dca4957bd20e877ee5daa8baef95 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Tue, 4 Nov 2025 13:46:13 +0200 Subject: [PATCH 1/6] feat: implement sortable columns with visual indicators and sorting logic --- adminforth/spa/src/afcl/Table.vue | 112 ++++++++++++++++++++++++++++-- 1 file changed, 106 insertions(+), 6 deletions(-) diff --git a/adminforth/spa/src/afcl/Table.vue b/adminforth/spa/src/afcl/Table.vue index 93e057f1..394bb4fc 100644 --- a/adminforth/spa/src/afcl/Table.vue +++ b/adminforth/spa/src/afcl/Table.vue @@ -4,13 +4,28 @@ - @@ -141,16 +156,21 @@ columns: { label: string, fieldName: string, + sortable?: boolean, }[], data: { [key: string]: any, - }[] | ((params: { offset: number, limit: number }) => Promise<{data: {[key: string]: any}[], total: number}>), + }[] | ((params: { offset: number, limit: number, sortField?: string, sortDirection?: 'asc' | 'desc' }) => Promise<{data: {[key: string]: any}[], total: number}>), evenHighlights?: boolean, pageSize?: number, isLoading?: boolean, + sortable?: boolean, // enable/disable sorting globally + defaultSortField?: string, + defaultSortDirection?: 'asc' | 'desc', }>(), { evenHighlights: true, pageSize: 5, + sortable: true, } ); @@ -163,6 +183,8 @@ const isLoading = ref(false); const dataResult = ref<{data: {[key: string]: any}[], total: number}>({data: [], total: 0}); const isAtLeastOneLoading = ref([false]); + const currentSortField = ref(props.defaultSortField); + const currentSortDirection = ref<'asc' | 'desc'>(props.defaultSortDirection ?? 'asc'); onMounted(() => { refresh(); @@ -181,6 +203,15 @@ emit('update:tableLoading', isLoading.value || props.isLoading); }); + watch([() => currentSortField.value, () => currentSortDirection.value], () => { + // reset to first page on sort change + if (currentPage.value !== 1) currentPage.value = 1; + refresh(); + emit('update:sortField', currentSortField.value); + emit('update:sortDirection', currentSortField.value ? currentSortDirection.value : undefined as any); + emit('sort-change', { field: currentSortField.value, direction: currentSortDirection.value }); + }); + const totalPages = computed(() => { return dataResult.value?.total ? Math.ceil(dataResult.value.total / props.pageSize) : 1; }); @@ -196,6 +227,9 @@ const emit = defineEmits([ 'update:tableLoading', + 'update:sortField', + 'update:sortDirection', + 'sort-change', ]); function onPageInput(event: any) { @@ -231,7 +265,12 @@ isLoading.value = true; const currentLoadingIndex = currentPage.value; isAtLeastOneLoading.value[currentLoadingIndex] = true; - const result = await props.data({ offset: (currentLoadingIndex - 1) * props.pageSize, limit: props.pageSize }); + const result = await props.data({ + offset: (currentLoadingIndex - 1) * props.pageSize, + limit: props.pageSize, + sortField: currentSortField.value, + sortDirection: currentSortDirection.value, + }); isAtLeastOneLoading.value[currentLoadingIndex] = false; if (isAtLeastOneLoading.value.every(v => v === false)) { isLoading.value = false; @@ -240,7 +279,9 @@ } else if (typeof props.data === 'object' && Array.isArray(props.data)) { const start = (currentPage.value - 1) * props.pageSize; const end = start + props.pageSize; - dataResult.value = { data: props.data.slice(start, end), total: props.data.length }; + const total = props.data.length; + const sorted = sortArrayData(props.data, currentSortField.value, currentSortDirection.value); + dataResult.value = { data: sorted.slice(start, end), total }; } } @@ -252,4 +293,63 @@ } } + function isColumnSortable(column: { fieldName: string; sortable?: boolean }) { + return !!props.sortable && column.sortable !== false; + } + + function isSorted(column: { fieldName: string }) { + return currentSortField.value === column.fieldName; + } + + function getAriaSort(column: { fieldName: string; sortable?: boolean }) { + if (!isColumnSortable(column)) return undefined; + if (!isSorted(column)) return 'none'; + return currentSortDirection.value === 'asc' ? 'ascending' : 'descending'; + } + + function onHeaderClick(column: { fieldName: string; sortable?: boolean }) { + if (!isColumnSortable(column)) return; + if (currentSortField.value !== column.fieldName) { + currentSortField.value = column.fieldName; + currentSortDirection.value = props.defaultSortDirection ?? 'asc'; + } else { + if (currentSortDirection.value === 'asc') { + currentSortDirection.value = 'desc'; + } else if (currentSortDirection.value === 'desc') { + currentSortField.value = undefined; + currentSortDirection.value = props.defaultSortDirection ?? 'asc'; + } else { + currentSortDirection.value = 'asc'; + } + } + } + + function getValueByPath(obj: any, path: string | undefined) { + if (!path) return undefined; + return path.split('.').reduce((acc: any, key: string) => (acc == null ? acc : acc[key]), obj); + } + + function compareValues(a: any, b: any) { + if (a == null && b == null) return 0; + if (a == null) return 1; + if (b == null) return -1; + if (typeof a === 'number' && typeof b === 'number') return a - b; + const aDate = a instanceof Date ? a : undefined; + const bDate = b instanceof Date ? b : undefined; + if (aDate && bDate) return aDate.getTime() - bDate.getTime(); + return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); + } + + function sortArrayData(data: { [key: string]: any }[], sortField?: string, sortDirection: 'asc' | 'desc' = 'asc') { + if (!props.sortable || !sortField) return data; + const copy = data.slice(); + copy.sort((rowA, rowB) => { + const aVal = getValueByPath(rowA, sortField); + const bVal = getValueByPath(rowB, sortField); + const cmp = compareValues(aVal, bVal); + return sortDirection === 'asc' ? cmp : -cmp; + }); + return copy; + } + \ No newline at end of file From 91ef36b678b13829b1cb5f51585c4e505bf081db Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Tue, 4 Nov 2025 14:04:05 +0200 Subject: [PATCH 2/6] fix: update sortDirection emission to handle undefined values correctly --- adminforth/spa/src/afcl/Table.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/adminforth/spa/src/afcl/Table.vue b/adminforth/spa/src/afcl/Table.vue index 394bb4fc..4ea45da4 100644 --- a/adminforth/spa/src/afcl/Table.vue +++ b/adminforth/spa/src/afcl/Table.vue @@ -208,7 +208,7 @@ if (currentPage.value !== 1) currentPage.value = 1; refresh(); emit('update:sortField', currentSortField.value); - emit('update:sortDirection', currentSortField.value ? currentSortDirection.value : undefined as any); + emit('update:sortDirection', currentSortField.value ? currentSortDirection.value : undefined); emit('sort-change', { field: currentSortField.value, direction: currentSortDirection.value }); }); @@ -269,7 +269,7 @@ offset: (currentLoadingIndex - 1) * props.pageSize, limit: props.pageSize, sortField: currentSortField.value, - sortDirection: currentSortDirection.value, + ...(currentSortField.value ? { sortDirection: currentSortDirection.value } : {}), }); isAtLeastOneLoading.value[currentLoadingIndex] = false; if (isAtLeastOneLoading.value.every(v => v === false)) { From b963cde091a1278a208d55a93ca8c1413228bb86 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Tue, 4 Nov 2025 14:13:17 +0200 Subject: [PATCH 3/6] fix: enhance sorting indicators and logic for table columns --- adminforth/spa/src/afcl/Table.vue | 101 ++++++++++++------------------ 1 file changed, 41 insertions(+), 60 deletions(-) diff --git a/adminforth/spa/src/afcl/Table.vue b/adminforth/spa/src/afcl/Table.vue index 4ea45da4..0b7f1f3e 100644 --- a/adminforth/spa/src/afcl/Table.vue +++ b/adminforth/spa/src/afcl/Table.vue @@ -19,12 +19,14 @@ {{ column.label }} - - - - - - + + + + + + + + @@ -293,63 +295,42 @@ } } - function isColumnSortable(column: { fieldName: string; sortable?: boolean }) { - return !!props.sortable && column.sortable !== false; - } +function isColumnSortable(col:{fieldName:string; sortable?:boolean}) { + return !!props.sortable && col.sortable !== false; +} - function isSorted(column: { fieldName: string }) { - return currentSortField.value === column.fieldName; +function onHeaderClick(col:{fieldName:string; sortable?:boolean}) { + if (!isColumnSortable(col)) return; + if (currentSortField.value !== col.fieldName) { + currentSortField.value = col.fieldName; + currentSortDirection.value = props.defaultSortDirection ?? 'asc'; + } else { + currentSortDirection.value = + currentSortDirection.value === 'asc' ? 'desc' : + currentSortField.value ? (currentSortField.value = undefined, props.defaultSortDirection ?? 'asc') : + 'asc'; } +} - function getAriaSort(column: { fieldName: string; sortable?: boolean }) { - if (!isColumnSortable(column)) return undefined; - if (!isSorted(column)) return 'none'; - return currentSortDirection.value === 'asc' ? 'ascending' : 'descending'; - } +function getAriaSort(col:{fieldName:string; sortable?:boolean}) { + if (!isColumnSortable(col)) return undefined; + if (currentSortField.value !== col.fieldName) return 'none'; + return currentSortDirection.value === 'asc' ? 'ascending' : 'descending'; +} - function onHeaderClick(column: { fieldName: string; sortable?: boolean }) { - if (!isColumnSortable(column)) return; - if (currentSortField.value !== column.fieldName) { - currentSortField.value = column.fieldName; - currentSortDirection.value = props.defaultSortDirection ?? 'asc'; - } else { - if (currentSortDirection.value === 'asc') { - currentSortDirection.value = 'desc'; - } else if (currentSortDirection.value === 'desc') { - currentSortField.value = undefined; - currentSortDirection.value = props.defaultSortDirection ?? 'asc'; - } else { - currentSortDirection.value = 'asc'; - } - } - } - - function getValueByPath(obj: any, path: string | undefined) { - if (!path) return undefined; - return path.split('.').reduce((acc: any, key: string) => (acc == null ? acc : acc[key]), obj); - } - - function compareValues(a: any, b: any) { - if (a == null && b == null) return 0; - if (a == null) return 1; - if (b == null) return -1; - if (typeof a === 'number' && typeof b === 'number') return a - b; - const aDate = a instanceof Date ? a : undefined; - const bDate = b instanceof Date ? b : undefined; - if (aDate && bDate) return aDate.getTime() - bDate.getTime(); - return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); - } - - function sortArrayData(data: { [key: string]: any }[], sortField?: string, sortDirection: 'asc' | 'desc' = 'asc') { - if (!props.sortable || !sortField) return data; - const copy = data.slice(); - copy.sort((rowA, rowB) => { - const aVal = getValueByPath(rowA, sortField); - const bVal = getValueByPath(rowB, sortField); - const cmp = compareValues(aVal, bVal); - return sortDirection === 'asc' ? cmp : -cmp; - }); - return copy; - } +const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); +function sortArrayData(data:any[], sortField?:string, dir:'asc'|'desc'='asc') { + if (!props.sortable || !sortField) return data; + const get = (o:any, p:string) => p.split('.').reduce((a:any,k)=>a?.[k], o); + return [...data].sort((a,b) => { + let av = get(a, sortField), bv = get(b, sortField); + if (av == null && bv == null) return 0; + if (av == null) return 1; if (bv == null) return -1; + if (av instanceof Date && bv instanceof Date) return dir === 'asc' ? av.getTime() - bv.getTime() : bv.getTime() - av.getTime(); + if (typeof av === 'number' && typeof bv === 'number') return dir === 'asc' ? av - bv : bv - av; + const cmp = collator.compare(String(av), String(bv)); + return dir === 'asc' ? cmp : -cmp; + }); +} \ No newline at end of file From 3a66c2c5b4e4c5497b40f2cb48108b54c19ac23a Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Tue, 4 Nov 2025 16:23:24 +0200 Subject: [PATCH 4/6] feat: update table component to support configurable sortable columns and enhance sorting logic --- .../docs/tutorial/03-Customization/15-afcl.md | 104 ++++++++++++++++++ adminforth/spa/src/afcl/Table.vue | 15 ++- 2 files changed, 114 insertions(+), 5 deletions(-) diff --git a/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md b/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md index e2b77bcb..40e58ef0 100644 --- a/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md +++ b/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md @@ -22,6 +22,57 @@ import { Button } from '@/afcl' ``` ```html + +### Sorting + +Table supports column sorting out of the box. + +- Sorting is enabled globally by default. You can disable it entirely with `:sortable="false"`. +- Per column, sorting can be disabled with `sortable: false` inside the column definition. +- Clicking a sortable header cycles sorting in a tri‑state order: + - none → ascending → descending → none + - When it returns to "none", the sorting is cleared. + +Basic example (client-side sorting when `data` is an array): + +```html +
- - + + {{ column.label }} + + + + + + + +
+``` + +You can also predefine a default sort: + +```html +
+``` + +Notes: +- Client-side sorting supports nested field paths using dot-notation, e.g. `user.name`. +- When a column is not currently sorted, a subtle double-arrow icon is shown; arrows switch up/down for ascending/descending. +
+``` + +Events you can listen to: + +```html +
+``` + ### Table loading states For tables where you load data externally and pass them to `data` prop as array (including case with front-end pagination) you might want to show skeleton loaders in table externaly using `isLoading` props. diff --git a/adminforth/spa/src/afcl/Table.vue b/adminforth/spa/src/afcl/Table.vue index 0b7f1f3e..8d0f0654 100644 --- a/adminforth/spa/src/afcl/Table.vue +++ b/adminforth/spa/src/afcl/Table.vue @@ -172,7 +172,7 @@ }>(), { evenHighlights: true, pageSize: 5, - sortable: true, + sortable: false, } ); @@ -206,7 +206,7 @@ }); watch([() => currentSortField.value, () => currentSortDirection.value], () => { - // reset to first page on sort change + if (!props.sortable) return; if (currentPage.value !== 1) currentPage.value = 1; refresh(); emit('update:sortField', currentSortField.value); @@ -296,7 +296,7 @@ } function isColumnSortable(col:{fieldName:string; sortable?:boolean}) { - return !!props.sortable && col.sortable !== false; + return props.sortable === true && col.sortable === true; } function onHeaderClick(col:{fieldName:string; sortable?:boolean}) { @@ -322,12 +322,17 @@ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'bas function sortArrayData(data:any[], sortField?:string, dir:'asc'|'desc'='asc') { if (!props.sortable || !sortField) return data; - const get = (o:any, p:string) => p.split('.').reduce((a:any,k)=>a?.[k], o); + // Helper function to get nested properties by path + const getByPath = (o:any, p:string) => p.split('.').reduce((a:any,k)=>a?.[k], o); return [...data].sort((a,b) => { - let av = get(a, sortField), bv = get(b, sortField); + let av = getByPath(a, sortField), bv = getByPath(b, sortField); + // Handle null/undefined values if (av == null && bv == null) return 0; + // Handle null/undefined values if (av == null) return 1; if (bv == null) return -1; + // Data types if (av instanceof Date && bv instanceof Date) return dir === 'asc' ? av.getTime() - bv.getTime() : bv.getTime() - av.getTime(); + // Strings and numbers if (typeof av === 'number' && typeof bv === 'number') return dir === 'asc' ? av - bv : bv - av; const cmp = collator.compare(String(av), String(bv)); return dir === 'asc' ? cmp : -cmp; From a8f54b69b844edfce2ea672993aedcb2463bb65a Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Mon, 10 Nov 2025 15:02:31 +0200 Subject: [PATCH 5/6] feat: enhance table sorting functionality with per-column control and default sort handling --- .../docs/tutorial/03-Customization/15-afcl.md | 139 +++++++++++------- adminforth/spa/src/afcl/Table.vue | 17 ++- 2 files changed, 96 insertions(+), 60 deletions(-) diff --git a/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md b/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md index 40e58ef0..5fb1e3c5 100644 --- a/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md +++ b/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md @@ -22,57 +22,6 @@ import { Button } from '@/afcl' ``` ```html - -### Sorting - -Table supports column sorting out of the box. - -- Sorting is enabled globally by default. You can disable it entirely with `:sortable="false"`. -- Per column, sorting can be disabled with `sortable: false` inside the column definition. -- Clicking a sortable header cycles sorting in a tri‑state order: - - none → ascending → descending → none - - When it returns to "none", the sorting is cleared. - -Basic example (client-side sorting when `data` is an array): - -```html -
-``` - -You can also predefine a default sort: - -```html -
-``` - -Notes: -- Client-side sorting supports nested field paths using dot-notation, e.g. `user.name`. -- When a column is not currently sorted, a subtle double-arrow icon is shown; arrows switch up/down for ascending/descending. -
+``` + +You can also predefine a default sort: + +```html +
+``` + +Notes: +- Client-side sorting supports nested field paths using dot-notation, e.g. `user.name`. +- When a column is not currently sorted, a subtle double-arrow icon is shown; arrows switch up/down for ascending/descending. + +#### Nested field path sorting + +You can sort by nested properties of objects in rows using dot-notation in `fieldName`. + +Example: + +```html +
+``` + +Behavior details: +- The path is split on dots and resolved step by step: `user.address.city`. +- Missing or `null` nested values are pushed to the bottom for ascending order (and top for descending) because `null/undefined` are treated as greater than defined values in asc ordering. +- Works the same for client-side sorting and for server-side loaders: when using an async loader the same `sortField` (e.g. `user.address.city`) is passed so you can implement equivalent ordering on the backend. +- Date objects at nested paths are detected and compared chronologically. +- Numeric comparison is stable for mixed numeric strings via Intl.Collator with numeric option. + +Edge cases to consider in your own data: +- Deeply missing branches like `user.profile.settings.locale` simply result in `undefined` and will follow the null ordering logic above. +- Arrays are not traversed; if you need array-specific sorting you should pre-normalize data into scalar fields before passing to the table. + ### Server-side sorting When you provide an async function to `data`, the table will pass the current sort along with pagination params. @@ -1049,9 +1081,8 @@ async function loadPageData({ offset, limit, sortField, sortDirection }) { if (sortField) url.searchParams.set('sortField', sortField); if (sortField && sortDirection) url.searchParams.set('sortDirection', sortDirection); - const res = await fetch(url.toString(), { credentials: 'include' }); - const json = await res.json(); - return { data: json.data, total: json.total }; + const { data, total } = callAdminForthApi('getProducts', {limit, offset, sortField, sortDirection}); + return { data, total }; }
(), { evenHighlights: true, pageSize: 5, - sortable: false, } ); @@ -189,6 +187,13 @@ const currentSortDirection = ref<'asc' | 'desc'>(props.defaultSortDirection ?? 'asc'); onMounted(() => { + // If defaultSortField points to a non-sortable column, ignore it + if (currentSortField.value) { + const col = props.columns?.find(c => c.fieldName === currentSortField.value); + if (!col || !isColumnSortable(col)) { + currentSortField.value = undefined; + } + } refresh(); }); @@ -206,7 +211,6 @@ }); watch([() => currentSortField.value, () => currentSortDirection.value], () => { - if (!props.sortable) return; if (currentPage.value !== 1) currentPage.value = 1; refresh(); emit('update:sortField', currentSortField.value); @@ -296,7 +300,8 @@ } function isColumnSortable(col:{fieldName:string; sortable?:boolean}) { - return props.sortable === true && col.sortable === true; + // Sorting is controlled per column; default is NOT sortable. Enable with `sortable: true`. + return col.sortable === true; } function onHeaderClick(col:{fieldName:string; sortable?:boolean}) { @@ -321,11 +326,11 @@ function getAriaSort(col:{fieldName:string; sortable?:boolean}) { const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }); function sortArrayData(data:any[], sortField?:string, dir:'asc'|'desc'='asc') { - if (!props.sortable || !sortField) return data; + if (!sortField) return data; // Helper function to get nested properties by path const getByPath = (o:any, p:string) => p.split('.').reduce((a:any,k)=>a?.[k], o); return [...data].sort((a,b) => { - let av = getByPath(a, sortField), bv = getByPath(b, sortField); + const av = getByPath(a, sortField), bv = getByPath(b, sortField); // Handle null/undefined values if (av == null && bv == null) return 0; // Handle null/undefined values From df06a7bdc8efe6bf5fdc40d44247d4d3171bbc66 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Mon, 10 Nov 2025 15:33:04 +0200 Subject: [PATCH 6/6] fix: correct tri-state sorting description in documentation and update sortDirection emission behavior --- .../documentation/docs/tutorial/03-Customization/15-afcl.md | 2 +- adminforth/spa/src/afcl/Table.vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md b/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md index 5fb1e3c5..2132235d 100644 --- a/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md +++ b/adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md @@ -978,7 +978,7 @@ async function loadPageData(data) { Table supports column sorting out of the box. - By default, columns are NOT sortable. Enable sorting per column with `sortable: true`. -- Clicking a sortable header cycles sorting in a tri‑state order: +- Clicking a sortable header cycles sorting in a tri-state order: - none → ascending → descending → none - When it returns to "none", the sorting is cleared. diff --git a/adminforth/spa/src/afcl/Table.vue b/adminforth/spa/src/afcl/Table.vue index 111199aa..0155d56e 100644 --- a/adminforth/spa/src/afcl/Table.vue +++ b/adminforth/spa/src/afcl/Table.vue @@ -216,7 +216,7 @@ emit('update:sortField', currentSortField.value); emit('update:sortDirection', currentSortField.value ? currentSortDirection.value : undefined); emit('sort-change', { field: currentSortField.value, direction: currentSortDirection.value }); - }); + }, { immediate: false }); const totalPages = computed(() => { return dataResult.value?.total ? Math.ceil(dataResult.value.total / props.pageSize) : 1;