From a80bc2795199dca4957bd20e877ee5daa8baef95 Mon Sep 17 00:00:00 2001 From: Maksym Pipkun Date: Tue, 4 Nov 2025 13:46:13 +0200 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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;