Skip to content
Open
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
104 changes: 104 additions & 0 deletions adminforth/documentation/docs/tutorial/03-Customization/15-afcl.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Table
:columns="[
{ label: 'Name', fieldName: 'name' },
{ label: 'Age', fieldName: 'age' },
// 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' },
]"
:sortable="true"
:pageSize="3"
/>
```

You can also predefine a default sort:

```html
<Table
:columns="[
{ label: 'Name', fieldName: 'name' },
{ label: 'Age', fieldName: 'age' },
{ 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.

<Button @click="doSmth"
:loader="false" class="w-full">
Your button text
Expand Down Expand Up @@ -973,6 +1024,59 @@ async function loadPageData(data) {
```
> 👆 The page size is used as the limit for pagination.

### 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 res = await fetch(url.toString(), { credentials: 'include' });
const json = await res.json();
return { data: json.data, total: json.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
98 changes: 92 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>
</span>
</span>
</th>
</tr>
Expand Down Expand Up @@ -141,16 +158,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: false,
}
);

Expand All @@ -163,6 +185,8 @@
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(() => {
refresh();
Expand All @@ -181,6 +205,15 @@
emit('update:tableLoading', isLoading.value || props.isLoading);
});

watch([() => currentSortField.value, () => currentSortDirection.value], () => {
if (!props.sortable) return;
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 });
});
Copy link

Copilot AI Nov 4, 2025

Choose a reason for hiding this comment

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

The watcher triggers on initial component mount when currentSortField is initialized with props.defaultSortField, causing an unnecessary refresh() call after the onMounted hook already calls refresh(). Consider using { immediate: false } option or checking if this is the initial load to avoid duplicate data fetching.

Suggested change
});
}, { immediate: false });

Copilot uses AI. Check for mistakes.

const totalPages = computed(() => {
return dataResult.value?.total ? Math.ceil(dataResult.value.total / props.pageSize) : 1;
});
Expand All @@ -196,6 +229,9 @@

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

function onPageInput(event: any) {
Expand Down Expand Up @@ -231,7 +267,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 +281,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 +295,47 @@
}
}

function isColumnSortable(col:{fieldName:string; sortable?:boolean}) {
return props.sortable === true && 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';
}
}

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 (!props.sortable || !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);
// 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;
});
}
</script>