Skip to content
112 changes: 106 additions & 6 deletions adminforth/spa/src/afcl/Table.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,28 @@
<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 indicator -->
<svg v-if="!isSorted(column)" 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"></path></svg>
<!-- Sorted ascending indicator -->
<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"></path></svg>
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 SVG path for ascending sort indicator is incomplete. The path ends with '1.847 0z' but should end with '1.086Zm6.852...' to match the complete path from the unsorted indicator. This will result in an incorrect visual representation of the ascending arrow.

Suggested change
<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"></path></svg>
<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 1.086Z"></path></svg>

Copilot uses AI. Check for mistakes.
<!-- Sorted descending indicator (rotated) -->
<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"></path></svg>
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 SVG path for descending sort indicator has the same incomplete path issue as the ascending indicator. The path ends with '1.847 0z' but should include the complete path definition to properly render the arrow icon.

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"></path></svg>
<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 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"></path></svg>

Copilot uses AI. Check for mistakes.
</span>
</span>
</th>
</tr>
Expand Down Expand Up @@ -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,
}
);

Expand All @@ -163,6 +183,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 +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);
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 as any type assertion bypasses TypeScript's type checking. Consider properly typing the emit parameter or using a union type like 'asc' | 'desc' | undefined in the emit definition instead of forcing the type with as any.

Suggested change
emit('update:sortDirection', currentSortField.value ? currentSortDirection.value : undefined as any);
emit('update:sortDirection', currentSortField.value ? currentSortDirection.value : undefined);

Copilot uses AI. Check for mistakes.
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 +227,9 @@

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

function onPageInput(event: any) {
Expand Down Expand Up @@ -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,
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.

When sorting is cleared (sortField is undefined), the function still passes currentSortDirection.value which will be 'asc' by default. This could confuse server-side implementations. Consider only passing sortDirection when sortField has a value, e.g., ...(currentSortField.value ? { sortDirection: currentSortDirection.value } : {}).

Suggested change
sortDirection: currentSortDirection.value,
...(currentSortField.value ? { sortDirection: currentSortDirection.value } : {}),

Copilot uses AI. Check for mistakes.
});
isAtLeastOneLoading.value[currentLoadingIndex] = false;
if (isAtLeastOneLoading.value.every(v => v === false)) {
isLoading.value = false;
Expand All @@ -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 };
}
}

Expand All @@ -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;
}

</script>