Skip to content

Commit f11fd8c

Browse files
authored
Merge pull request #414 from devforth/feature/AdminForth/976/add-the-sortable-option
feat: implement sortable columns with visual indicators and sorting l…
2 parents fd2f927 + df06a7b commit f11fd8c

File tree

2 files changed

+232
-6
lines changed

2 files changed

+232
-6
lines changed

adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,141 @@ async function loadPageData(data) {
973973
```
974974
> 👆 The page size is used as the limit for pagination.
975975
976+
### Sorting
977+
978+
Table supports column sorting out of the box.
979+
980+
- By default, columns are NOT sortable. Enable sorting per column with `sortable: true`.
981+
- Clicking a sortable header cycles sorting in a tri-state order:
982+
- none → ascending → descending → none
983+
- When it returns to "none", the sorting is cleared.
984+
985+
Basic example (client-side sorting when `data` is an array):
986+
```html
987+
<Table
988+
:columns="[
989+
{ label: 'Name', fieldName: 'name', sortable: true },
990+
{ label: 'Age', fieldName: 'age', sortable: true },
991+
// disable sort for a specific column
992+
{ label: 'Country', fieldName: 'country', sortable: false },
993+
]"
994+
:data="[
995+
{ name: 'John', age: 30, country: 'US' },
996+
{ name: 'Rick', age: 25, country: 'CA' },
997+
{ name: 'Alice', age: 35, country: 'BR' },
998+
{ name: 'Colin', age: 40, country: 'AU' },
999+
]"
1000+
:pageSize="3"
1001+
/>
1002+
```
1003+
1004+
You can also predefine a default sort:
1005+
1006+
```html
1007+
<Table
1008+
:columns="[
1009+
{ label: 'Name', fieldName: 'name', sortable: true },
1010+
{ label: 'Age', fieldName: 'age', sortable: true },
1011+
{ label: 'Country', fieldName: 'country' },
1012+
]"
1013+
:data="rows"
1014+
defaultSortField="age"
1015+
defaultSortDirection="desc"
1016+
/>
1017+
```
1018+
1019+
Notes:
1020+
- Client-side sorting supports nested field paths using dot-notation, e.g. `user.name`.
1021+
- When a column is not currently sorted, a subtle double-arrow icon is shown; arrows switch up/down for ascending/descending.
1022+
1023+
#### Nested field path sorting
1024+
1025+
You can sort by nested properties of objects in rows using dot-notation in `fieldName`.
1026+
1027+
Example:
1028+
1029+
```html
1030+
<Table
1031+
:columns="[
1032+
{ label: 'User Name', fieldName: 'user.name', sortable: true },
1033+
{ label: 'User Email', fieldName: 'user.email', sortable: true },
1034+
{ label: 'City', fieldName: 'user.address.city', sortable: true },
1035+
{ label: 'Country', fieldName: 'user.address.country' },
1036+
]"
1037+
:data="[
1038+
{ user: { name: 'Alice', email: 'alice@example.com', address: { city: 'Berlin', country: 'DE' } } },
1039+
{ user: { name: 'Bob', email: 'bob@example.com', address: { city: 'Paris', country: 'FR' } } },
1040+
{ user: { name: 'Carlos', email: 'carlos@example.com', address: { city: 'Madrid', country: 'ES' } } },
1041+
{ user: { name: 'Dana', email: 'dana@example.com', address: { city: 'Rome', country: 'IT' } } },
1042+
{ user: { name: 'Eve', email: 'eve@example.com', address: { city: null, country: 'US' } } },
1043+
]"
1044+
:pageSize="3"
1045+
/>
1046+
```
1047+
1048+
Behavior details:
1049+
- The path is split on dots and resolved step by step: `user.address.city`.
1050+
- 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.
1051+
- 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.
1052+
- Date objects at nested paths are detected and compared chronologically.
1053+
- Numeric comparison is stable for mixed numeric strings via Intl.Collator with numeric option.
1054+
1055+
Edge cases to consider in your own data:
1056+
- Deeply missing branches like `user.profile.settings.locale` simply result in `undefined` and will follow the null ordering logic above.
1057+
- Arrays are not traversed; if you need array-specific sorting you should pre-normalize data into scalar fields before passing to the table.
1058+
1059+
### Server-side sorting
1060+
1061+
When you provide an async function to `data`, the table will pass the current sort along with pagination params.
1062+
1063+
Signature of the loader receives:
1064+
1065+
```ts
1066+
type LoaderArgs = {
1067+
offset: number;
1068+
limit: number;
1069+
sortField?: string; // undefined when unsorted
1070+
sortDirection?: 'asc' | 'desc'; // only when sortField is set
1071+
}
1072+
```
1073+
1074+
Example using `fetch`:
1075+
1076+
```ts
1077+
async function loadPageData({ offset, limit, sortField, sortDirection }) {
1078+
const url = new URL('/api/products', window.location.origin);
1079+
url.searchParams.set('offset', String(offset));
1080+
url.searchParams.set('limit', String(limit));
1081+
if (sortField) url.searchParams.set('sortField', sortField);
1082+
if (sortField && sortDirection) url.searchParams.set('sortDirection', sortDirection);
1083+
1084+
const { data, total } = callAdminForthApi('getProducts', {limit, offset, sortField, sortDirection});
1085+
return { data, total };
1086+
}
1087+
1088+
<Table
1089+
:columns="[
1090+
{ label: 'ID', fieldName: 'id' },
1091+
{ label: 'Title', fieldName: 'title' },
1092+
{ label: 'Price', fieldName: 'price' },
1093+
]"
1094+
:data="loadPageData"
1095+
:pageSize="10"
1096+
/>
1097+
```
1098+
1099+
Events you can listen to:
1100+
1101+
```html
1102+
<Table
1103+
:columns="columns"
1104+
:data="loadPageData"
1105+
@update:sortField="(f) => currentSortField = f"
1106+
@update:sortDirection="(d) => currentSortDirection = d"
1107+
@sort-change="({ field, direction }) => console.log('sort changed', field, direction)"
1108+
/>
1109+
```
1110+
9761111
### Table loading states
9771112

9781113
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.

adminforth/spa/src/afcl/Table.vue

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,30 @@
44
<table class="afcl-table w-full text-sm text-left rtl:text-right text-lightTableText dark:text-darkTableText overflow-x-auto">
55
<thead class="afcl-table-thread text-xs text-lightTableHeadingText uppercase bg-lightTableHeadingBackground dark:bg-darkTableHeadingBackground dark:text-darkTableHeadingText">
66
<tr>
7-
<th scope="col" class="px-6 py-3" ref="headerRefs" :key="`header-${column.fieldName}`"
7+
<th
8+
scope="col"
9+
class="px-6 py-3"
10+
ref="headerRefs"
11+
:key="`header-${column.fieldName}`"
812
v-for="column in columns"
13+
:aria-sort="getAriaSort(column)"
14+
:class="{ 'cursor-pointer select-none afcl-table-header-sortable': isColumnSortable(column) }"
15+
@click="onHeaderClick(column)"
916
>
1017
<slot v-if="$slots[`header:${column.fieldName}`]" :name="`header:${column.fieldName}`" :column="column" />
11-
12-
<span v-else>
18+
19+
<span v-else class="inline-flex items-center">
1320
{{ column.label }}
21+
<span v-if="isColumnSortable(column)" class="text-lightTableHeadingText dark:text-darkTableHeadingText">
22+
<!-- Unsorted -->
23+
<svg v-if="currentSortField !== column.fieldName" class="w-3 h-3 ms-1.5 opacity-30" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24"><path d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 1.086Zm6.852 1.952H8.574a2.072 2.072 0 0 0-1.847 1.087 1.9 1.9 0 0 0 .11 1.985l3.426 5.05a2.123 2.123 0 0 0 3.472 0l3.427-5.05a1.9 1.9 0 0 0 .11-1.985 2.074 2.074 0 0 0-1.846-1.087Z"/></svg>
24+
25+
<!-- Sorted ascending -->
26+
<svg v-else-if="currentSortDirection === 'asc'" class="w-3 h-3 ms-1.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 0z"/></svg>
27+
28+
<!-- Sorted descending -->
29+
<svg v-else class="w-3 h-3 ms-1.5 rotate-180" fill="currentColor" viewBox="0 0 24 24"><path d="M8.574 11.024h6.852a2.075 2.075 0 0 0 1.847-1.086 1.9 1.9 0 0 0-.11-1.986L13.736 2.9a2.122 2.122 0 0 0-3.472 0L6.837 7.952a1.9 1.9 0 0 0-.11 1.986 2.074 2.074 0 0 0 1.847 0z"/></svg>
30+
</span>
1431
</span>
1532
</th>
1633
</tr>
@@ -141,13 +158,16 @@
141158
columns: {
142159
label: string,
143160
fieldName: string,
161+
sortable?: boolean,
144162
}[],
145163
data: {
146164
[key: string]: any,
147-
}[] | ((params: { offset: number, limit: number }) => Promise<{data: {[key: string]: any}[], total: number}>),
165+
}[] | ((params: { offset: number, limit: number, sortField?: string, sortDirection?: 'asc' | 'desc' }) => Promise<{data: {[key: string]: any}[], total: number}>),
148166
evenHighlights?: boolean,
149167
pageSize?: number,
150168
isLoading?: boolean,
169+
defaultSortField?: string,
170+
defaultSortDirection?: 'asc' | 'desc',
151171
}>(), {
152172
evenHighlights: true,
153173
pageSize: 5,
@@ -163,8 +183,17 @@
163183
const isLoading = ref(false);
164184
const dataResult = ref<{data: {[key: string]: any}[], total: number}>({data: [], total: 0});
165185
const isAtLeastOneLoading = ref<boolean[]>([false]);
186+
const currentSortField = ref<string | undefined>(props.defaultSortField);
187+
const currentSortDirection = ref<'asc' | 'desc'>(props.defaultSortDirection ?? 'asc');
166188
167189
onMounted(() => {
190+
// If defaultSortField points to a non-sortable column, ignore it
191+
if (currentSortField.value) {
192+
const col = props.columns?.find(c => c.fieldName === currentSortField.value);
193+
if (!col || !isColumnSortable(col)) {
194+
currentSortField.value = undefined;
195+
}
196+
}
168197
refresh();
169198
});
170199
@@ -181,6 +210,14 @@
181210
emit('update:tableLoading', isLoading.value || props.isLoading);
182211
});
183212
213+
watch([() => currentSortField.value, () => currentSortDirection.value], () => {
214+
if (currentPage.value !== 1) currentPage.value = 1;
215+
refresh();
216+
emit('update:sortField', currentSortField.value);
217+
emit('update:sortDirection', currentSortField.value ? currentSortDirection.value : undefined);
218+
emit('sort-change', { field: currentSortField.value, direction: currentSortDirection.value });
219+
}, { immediate: false });
220+
184221
const totalPages = computed(() => {
185222
return dataResult.value?.total ? Math.ceil(dataResult.value.total / props.pageSize) : 1;
186223
});
@@ -196,6 +233,9 @@
196233
197234
const emit = defineEmits([
198235
'update:tableLoading',
236+
'update:sortField',
237+
'update:sortDirection',
238+
'sort-change',
199239
]);
200240
201241
function onPageInput(event: any) {
@@ -231,7 +271,12 @@
231271
isLoading.value = true;
232272
const currentLoadingIndex = currentPage.value;
233273
isAtLeastOneLoading.value[currentLoadingIndex] = true;
234-
const result = await props.data({ offset: (currentLoadingIndex - 1) * props.pageSize, limit: props.pageSize });
274+
const result = await props.data({
275+
offset: (currentLoadingIndex - 1) * props.pageSize,
276+
limit: props.pageSize,
277+
sortField: currentSortField.value,
278+
...(currentSortField.value ? { sortDirection: currentSortDirection.value } : {}),
279+
});
235280
isAtLeastOneLoading.value[currentLoadingIndex] = false;
236281
if (isAtLeastOneLoading.value.every(v => v === false)) {
237282
isLoading.value = false;
@@ -240,7 +285,9 @@
240285
} else if (typeof props.data === 'object' && Array.isArray(props.data)) {
241286
const start = (currentPage.value - 1) * props.pageSize;
242287
const end = start + props.pageSize;
243-
dataResult.value = { data: props.data.slice(start, end), total: props.data.length };
288+
const total = props.data.length;
289+
const sorted = sortArrayData(props.data, currentSortField.value, currentSortDirection.value);
290+
dataResult.value = { data: sorted.slice(start, end), total };
244291
}
245292
}
246293
@@ -252,4 +299,48 @@
252299
}
253300
}
254301
302+
function isColumnSortable(col:{fieldName:string; sortable?:boolean}) {
303+
// Sorting is controlled per column; default is NOT sortable. Enable with `sortable: true`.
304+
return col.sortable === true;
305+
}
306+
307+
function onHeaderClick(col:{fieldName:string; sortable?:boolean}) {
308+
if (!isColumnSortable(col)) return;
309+
if (currentSortField.value !== col.fieldName) {
310+
currentSortField.value = col.fieldName;
311+
currentSortDirection.value = props.defaultSortDirection ?? 'asc';
312+
} else {
313+
currentSortDirection.value =
314+
currentSortDirection.value === 'asc' ? 'desc' :
315+
currentSortField.value ? (currentSortField.value = undefined, props.defaultSortDirection ?? 'asc') :
316+
'asc';
317+
}
318+
}
319+
320+
function getAriaSort(col:{fieldName:string; sortable?:boolean}) {
321+
if (!isColumnSortable(col)) return undefined;
322+
if (currentSortField.value !== col.fieldName) return 'none';
323+
return currentSortDirection.value === 'asc' ? 'ascending' : 'descending';
324+
}
325+
326+
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
327+
328+
function sortArrayData(data:any[], sortField?:string, dir:'asc'|'desc'='asc') {
329+
if (!sortField) return data;
330+
// Helper function to get nested properties by path
331+
const getByPath = (o:any, p:string) => p.split('.').reduce((a:any,k)=>a?.[k], o);
332+
return [...data].sort((a,b) => {
333+
const av = getByPath(a, sortField), bv = getByPath(b, sortField);
334+
// Handle null/undefined values
335+
if (av == null && bv == null) return 0;
336+
// Handle null/undefined values
337+
if (av == null) return 1; if (bv == null) return -1;
338+
// Data types
339+
if (av instanceof Date && bv instanceof Date) return dir === 'asc' ? av.getTime() - bv.getTime() : bv.getTime() - av.getTime();
340+
// Strings and numbers
341+
if (typeof av === 'number' && typeof bv === 'number') return dir === 'asc' ? av - bv : bv - av;
342+
const cmp = collator.compare(String(av), String(bv));
343+
return dir === 'asc' ? cmp : -cmp;
344+
});
345+
}
255346
</script>

0 commit comments

Comments
 (0)