feat(accounts): add client-side column sorting to Accounts and Groups

Verified against a live server: x:Account/query rejects sort on every
property tried, including real ones like emailAddress, with
unsupportedSort — the schema's empty list.sort is accurate, this is a
systemic server gap, not something specific to this fork's synthetic
columns.

Added client-side sorting (SCHEMA-DEVIATION: account-client-sort) for
Email Address, Full Name, Usage/Quota, and Aliases on both lists,
reusing the fetch-all-then-sort-locally mechanism already established
for mailbox-client-hierarchy-sort: clicking a sortable header switches
that one query to an unpaginated fetch, sorts the results in memory by
the appropriate accessor (numeric for Usage/Aliases, string compare
otherwise), then paginates client-side. Role isn't included (not a
sortable scalar). Normal server-paginated lists are unaffected — this
only activates when a client-sortable column is actually clicked.

Verified end-to-end: sort indicators appear only on the intended
columns, clicking Email Address/Aliases correctly reorders rows and
toggles ascending/descending, Groups behaves the same as Accounts.
This commit is contained in:
Steven RYDELL
2026-08-01 20:23:42 +02:00
parent 3241dea5e4
commit 518ec4059a
2 changed files with 48 additions and 4 deletions
+7
View File
@@ -74,6 +74,13 @@ itself stays byte-for-byte alignable with upstream's version of the file.
- **Why**: neither list's schema exposes alias count as a column, only the full `aliases` list on the detail view.
- **Ideal fix**: the server's `x:Account/User` and `x:Account/Group` list schemas include a computed alias-count column natively; this column definition is deleted.
### `account-client-sort` 🟡
- **Where**: [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx) — `CLIENT_SORT_ACCESSORS`, `clientSortField`, and the `fetchData` branch that also triggers on `clientSortField`
- **What**: on the `x:Account/User` and `x:Account/Group` lists, clicking the Email/Full Name/Usage/Aliases column headers sorts by fetching every matching row (bypassing server pagination, same mechanism as `mailbox-client-hierarchy-sort`) and sorting it client-side, instead of sending a JMAP `sort` to the server.
- **Why**: neither list's schema declares any sortable property at all (`list.sort` is absent) — confirmed against a live server: `x:Account/query` with `sort: [{"property":"emailAddress",...}]` returns `unsupportedSort` for every property tried, including the real ones. This is a systemic gap in the current Stalwart server, not specific to this fork's synthetic columns.
- **Ideal fix**: the server's `x:Account/User`/`x:Account/Group` query methods accept `sort` on at least `emailAddress`, `description`, `usedDiskQuota`, and the schema declares them in `list.sort`; the client-sort branch and `CLIENT_SORT_ACCESSORS` are deleted in favor of the normal server-paginated `sortableFields` path already used elsewhere.
## Not a deviation (for reference)
A few other `viewName === '...'` / `objectName === '...'` checks exist in
+41 -4
View File
@@ -272,6 +272,20 @@ function renderQuotaUsage(item: Record<string, unknown>, t: TFn): React.ReactNod
);
}
/**
* SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
*
* Columns sortable client-side (fetch-all + in-memory sort) on the
* Accounts/Groups lists, keyed by column name, each mapped to a
* comparable value extracted from the fetched item.
*/
const CLIENT_SORT_ACCESSORS: Record<string, (item: Record<string, unknown>) => string | number> = {
emailAddress: (item) => String(item.emailAddress ?? ''),
description: (item) => String(item.description ?? ''),
quotaUsage: (item) => (typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0),
aliasCount: (item) => Object.keys((item.aliases as Record<string, unknown>) ?? {}).length,
};
function getFieldsRecord(resolvedSchema: ResolvedSchema): Record<string, Field> {
if (resolvedSchema.type === 'single') {
return resolvedSchema.fields.properties;
@@ -553,6 +567,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>(readUrlFilters);
const [sort, setSort] = useState<SortState | null>(readUrlSort);
// SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
// The server doesn't declare (or accept) a `sort` for any property on
// either list, so Email/Full Name/Usage/Aliases are sorted client-side
// instead, only when the user actually picks one of them.
const supportsClientSort = isAccountsList || viewName === 'x:Account/Group';
const clientSortField =
supportsClientSort && sort && CLIENT_SORT_ACCESSORS[sort.field] ? sort.field : null;
// Filters marked `clientOnly` (currently Level/Event on the Logs list) are
// not supported by the server's query engine, so they narrow an
@@ -685,7 +706,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
const filter = buildFilter();
const sortArr = buildSort();
if (activeClientFilters.length > 0 || isMailboxList) {
if (activeClientFilters.length > 0 || isMailboxList || clientSortField) {
// No server-side pagination possible once a client-only filter is
// active (SCHEMA-DEVIATION: log-client-filters): fetch every
// server-matching row up front, narrow it in the browser, then
@@ -693,11 +714,14 @@ export function DynamicList({ viewName }: DynamicListProps) {
// this too (SCHEMA-DEVIATION: mailbox-client-hierarchy-sort) — a
// mailbox's parent can land on a different server page than the
// mailbox itself, so the full set is required to place each row
// under its parent correctly.
// under its parent correctly. Sorting by Email/Full Name/Usage/
// Aliases on Accounts/Groups needs it too (SCHEMA-DEVIATION:
// account-client-sort) since the server doesn't support sorting
// on any property of either list.
const { list: fullList } = await jmapQueryAllAndGet(
obj.objectName,
accountId,
{ filter: Object.keys(filter).length > 0 ? filter : undefined, sort: sortArr },
{ filter: Object.keys(filter).length > 0 ? filter : undefined, sort: clientSortField ? undefined : sortArr },
properties,
);
let matched = fullList.filter((item) =>
@@ -708,6 +732,16 @@ export function DynamicList({ viewName }: DynamicListProps) {
matched = ordered;
setMailboxDepths(depths);
}
if (clientSortField) {
const accessor = CLIENT_SORT_ACCESSORS[clientSortField];
const direction = sort!.ascending ? 1 : -1;
matched = [...matched].sort((a, b) => {
const av = accessor(a);
const bv = accessor(b);
const cmp = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv));
return cmp * direction;
});
}
setClientAllItems(matched);
setClientPage(0);
setTotal(matched.length);
@@ -773,6 +807,8 @@ export function DynamicList({ viewName }: DynamicListProps) {
hasQuotaUsageColumn,
hasAliasCountColumn,
isMailboxList,
clientSortField,
sort,
activeClientFilters,
],
);
@@ -1343,7 +1379,8 @@ export function DynamicList({ viewName }: DynamicListProps) {
}
function renderSortIndicator(colName: string): React.ReactNode {
if (!sortableFields.has(colName)) return null;
const isClientSortable = supportsClientSort && colName in CLIENT_SORT_ACCESSORS;
if (!sortableFields.has(colName) && !isClientSortable) return null;
const isActive = sort?.field === colName;
return (
<button