Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md
Original file line number Diff line number Diff line change
Expand Up @@ -973,6 +973,141 @@ async function loadPageData(data) {
```
> 👆 The page size is used as the limit for pagination.

### Sorting

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:
- none → ascending → descending → none
- When it returns to "none", the sorting is cleared.

Basic example (client-side sorting when `data` is an array):
```html
<Table
:columns="[
{ label: 'Name', fieldName: 'name', sortable: true },
{ label: 'Age', fieldName: 'age', sortable: true },
// disable sort for a specific column
{ label: 'Country', fieldName: 'country', sortable: false },
]"
:data="[
{ name: 'John', age: 30, country: 'US' },
{ name: 'Rick', age: 25, country: 'CA' },
{ name: 'Alice', age: 35, country: 'BR' },
{ name: 'Colin', age: 40, country: 'AU' },
]"
:pageSize="3"
/>
```

You can also predefine a default sort:

```html
<Table
:columns="[
{ label: 'Name', fieldName: 'name', sortable: true },
{ label: 'Age', fieldName: 'age', sortable: true },
{ label: 'Country', fieldName: 'country' },
]"
:data="rows"
defaultSortField="age"
defaultSortDirection="desc"
/>
```

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
<Table
:columns="[
{ label: 'User Name', fieldName: 'user.name', sortable: true },
{ label: 'User Email', fieldName: 'user.email', sortable: true },
{ label: 'City', fieldName: 'user.address.city', sortable: true },
{ label: 'Country', fieldName: 'user.address.country' },
]"
:data="[
{ user: { name: 'Alice', email: 'alice@example.com', address: { city: 'Berlin', country: 'DE' } } },
{ user: { name: 'Bob', email: 'bob@example.com', address: { city: 'Paris', country: 'FR' } } },
{ user: { name: 'Carlos', email: 'carlos@example.com', address: { city: 'Madrid', country: 'ES' } } },
{ user: { name: 'Dana', email: 'dana@example.com', address: { city: 'Rome', country: 'IT' } } },
{ user: { name: 'Eve', email: 'eve@example.com', address: { city: null, country: 'US' } } },
]"
:pageSize="3"
/>
```

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.

Signature of the loader receives:

```ts
type LoaderArgs = {
offset: number;
limit: number;
sortField?: string; // undefined when unsorted
sortDirection?: 'asc' | 'desc'; // only when sortField is set
}
```

Example using `fetch`:

```ts
async function loadPageData({ offset, limit, sortField, sortDirection }) {
const url = new URL('/api/products', window.location.origin);
url.searchParams.set('offset', String(offset));
url.searchParams.set('limit', String(limit));
if (sortField) url.searchParams.set('sortField', sortField);
if (sortField && sortDirection) url.searchParams.set('sortDirection', sortDirection);

const { data, total } = callAdminForthApi('getProducts', {limit, offset, sortField, sortDirection});
return { data, total };
}

<Table
:columns="[
{ label: 'ID', fieldName: 'id' },
{ label: 'Title', fieldName: 'title' },
{ label: 'Price', fieldName: 'price' },
]"
:data="loadPageData"
:pageSize="10"
/>
```

Events you can listen to:

```html
<Table
:columns="columns"
:data="loadPageData"
@update:sortField="(f) => currentSortField = f"
@update:sortDirection="(d) => currentSortDirection = d"
@sort-change="({ field, direction }) => console.log('sort changed', field, direction)"
/>
```

### 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.
Expand Down
103 changes: 97 additions & 6 deletions adminforth/spa/src/afcl/Table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,30 @@
<table class="afcl-table w-full text-sm text-left rtl:text-right text-lightTableText dark:text-darkTableText overflow-x-auto">
<thead class="afcl-table-thread text-xs text-lightTableHeadingText uppercase bg-lightTableHeadingBackground dark:bg-darkTableHeadingBackground dark:text-darkTableHeadingText">
<tr>
<th scope="col" class="px-6 py-3" ref="headerRefs" :key="`header-${column.fieldName}`"
<th
scope="col"
class="px-6 py-3"
ref="headerRefs"
:key="`header-${column.fieldName}`"
v-for="column in columns"
:aria-sort="getAriaSort(column)"
:class="{ 'cursor-pointer select-none afcl-table-header-sortable': isColumnSortable(column) }"
@click="onHeaderClick(column)"
>
<slot v-if="$slots[`header:${column.fieldName}`]" :name="`header:${column.fieldName}`" :column="column" />
<span v-else>

<span v-else class="inline-flex items-center">
{{ column.label }}
<span v-if="isColumnSortable(column)" class="text-lightTableHeadingText dark:text-darkTableHeadingText">
<!-- Unsorted -->
<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>

<!-- Sorted ascending -->
<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>

<!-- Sorted descending -->
<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>
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SVG path for the descending sort icon is incomplete. It ends with 1.847 0z but should have a complete path. This matches the same truncated path as line 26, resulting in an incorrect/incomplete icon rendering.

Suggested change
<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>
<svg v-else class="w-3 h-3 ms-1.5" fill="currentColor" viewBox="0 0 24 24"><path d="M15.426 12.976H8.574a2.075 2.075 0 0 1-1.847 1.086 1.9 1.9 0 0 1 .11 1.986l3.427 5.05a2.122 2.122 0 0 0 3.472 0l3.426-5.05a1.9 1.9 0 0 1 .11-1.986 2.075 2.075 0 0 1-1.846-1.086z"/></svg>

Copilot uses AI. Check for mistakes.
</span>
</span>
</th>
</tr>
Expand Down Expand Up @@ -141,13 +158,16 @@
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,
defaultSortField?: string,
defaultSortDirection?: 'asc' | 'desc',
}>(), {
evenHighlights: true,
pageSize: 5,
Expand All @@ -163,8 +183,17 @@
const isLoading = ref(false);
const dataResult = ref<{data: {[key: string]: any}[], total: number}>({data: [], total: 0});
const isAtLeastOneLoading = ref<boolean[]>([false]);
const currentSortField = ref<string | undefined>(props.defaultSortField);
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();
});

Expand All @@ -181,6 +210,14 @@
emit('update:tableLoading', isLoading.value || props.isLoading);
});

watch([() => currentSortField.value, () => currentSortDirection.value], () => {
if (currentPage.value !== 1) currentPage.value = 1;
refresh();
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;
});
Expand All @@ -196,6 +233,9 @@

const emit = defineEmits([
'update:tableLoading',
'update:sortField',
'update:sortDirection',
'sort-change',
]);

function onPageInput(event: any) {
Expand Down Expand Up @@ -231,7 +271,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,
...(currentSortField.value ? { sortDirection: currentSortDirection.value } : {}),
});
isAtLeastOneLoading.value[currentLoadingIndex] = false;
if (isAtLeastOneLoading.value.every(v => v === false)) {
isLoading.value = false;
Expand All @@ -240,7 +285,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 };
}
}

Expand All @@ -252,4 +299,48 @@
}
}

function isColumnSortable(col:{fieldName:string; sortable?:boolean}) {
// Sorting is controlled per column; default is NOT sortable. Enable with `sortable: true`.
return col.sortable === true;
}

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';
Comment on lines +313 to +316
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] This tri-state toggle logic uses the comma operator to both clear currentSortField.value and return defaultSortDirection, which is difficult to read and reason about. Consider using an if-else block or separate the side effect from the assignment for better clarity.

Suggested change
currentSortDirection.value =
currentSortDirection.value === 'asc' ? 'desc' :
currentSortField.value ? (currentSortField.value = undefined, props.defaultSortDirection ?? 'asc') :
'asc';
if (currentSortDirection.value === 'asc') {
currentSortDirection.value = 'desc';
} else if (currentSortField.value) {
currentSortField.value = undefined;
currentSortDirection.value = props.defaultSortDirection ?? 'asc';
} else {
currentSortDirection.value = 'asc';
}

Copilot uses AI. Check for mistakes.
}
}

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';
}

const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });

function sortArrayData(data:any[], sortField?:string, dir:'asc'|'desc'='asc') {
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) => {
const av = getByPath(a, sortField), bv = getByPath(b, sortField);
// Handle null/undefined values
if (av == null && bv == null) return 0;
// Handle null/undefined values
Copy link

Copilot AI Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate comment 'Handle null/undefined values' appears on consecutive lines. Remove the duplicate on line 336 to improve code clarity.

Suggested change
// Handle null/undefined values

Copilot uses AI. Check for mistakes.
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;
});
}
</script>