From 93b10c3ed17d96b019aaf2885292b621dad60542 Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Sat, 1 Aug 2026 20:32:04 +0200 Subject: [PATCH] refactor(sort): make client-side column sorting generic, not view-hardcoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous commit hardcoded which lists (viewName === 'x:Account/User' / 'x:Account/Group') and columns (a fixed accessor map) got client-side sort. Moved the "which columns" decision into the schema itself instead: - New ClientSortableColumn type in schemaDeviationTypes.ts (intersection with the official Column type, same pattern as ClientOnlyFilterEnum — src/types/schema.ts stays untouched). - withAccountListColumns tags Email Address, Full Name, quotaUsage, and aliasCount with clientSortable: true when it builds the Accounts/ Groups column lists — the deviation-specific knowledge lives where the columns themselves are defined. - DynamicList reads that flag generically (clientSortableColumns, a useMemo over resolved.list.columns) with no viewName check at all. getClientSortValue() replaces the old per-list accessor map: real columns compare their own property directly, only the two synthetic columns need a value override. Net effect for Accounts/Groups is unchanged (verified: sort indicators still only on Email/Full Name/Usage/Aliases, ascending/descending still works). Any other list can now opt into the same client-sort mechanism by tagging a column clientSortable, without touching DynamicList.tsx. --- SCHEMA_DEVIATIONS.md | 6 ++-- src/components/lists/DynamicList.tsx | 49 ++++++++++++++++++---------- src/lib/accountColumns.ts | 27 +++++++++++---- src/lib/schemaDeviationTypes.ts | 9 ++++- 4 files changed, 63 insertions(+), 28 deletions(-) diff --git a/SCHEMA_DEVIATIONS.md b/SCHEMA_DEVIATIONS.md index 5e9d2cf..4ba290f 100644 --- a/SCHEMA_DEVIATIONS.md +++ b/SCHEMA_DEVIATIONS.md @@ -76,10 +76,10 @@ itself stays byte-for-byte alignable with upstream's version of the file. ### `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. +- **Where**: table-level mechanism in [`src/components/lists/DynamicList.tsx`](src/components/lists/DynamicList.tsx) (`clientSortableColumns`, `getClientSortValue`, the `fetchData` branch triggered by `clientSortField`) reading a `clientSortable` flag set per-column in [`src/lib/accountColumns.ts`](src/lib/accountColumns.ts) (type: [`ClientSortableColumn`](src/lib/schemaDeviationTypes.ts)) +- **What**: any column tagged `clientSortable` in the schema gets fetch-all-then-sort-in-memory on click (bypassing server pagination, same mechanism as `mailbox-client-hierarchy-sort`), instead of sending a JMAP `sort` to the server. The mechanism itself is generic and not tied to any specific list — currently only `withAccountListColumns` tags columns with it, for Email Address, Full Name, Usage, and Aliases on `x:Account/User` and `x:Account/Group`. - **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. +- **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`; `withAccountListColumns` stops tagging those columns `clientSortable` and they fall through to the normal server-paginated `sortableFields` path already used elsewhere. The generic mechanism itself only goes away once nothing tags any column `clientSortable` anymore. ## Not a deviation (for reference) diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index fec17b9..b7386a1 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -76,7 +76,7 @@ import { import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema'; import type { JmapSetResponse, JmapSetError } from '@/types/jmap'; import type { ResolvedSchema } from '@/lib/schemaResolver'; -import { isClientOnlyFilterEnum } from '@/lib/schemaDeviationTypes'; +import { isClientOnlyFilterEnum, isClientSortableColumn } from '@/lib/schemaDeviationTypes'; const PAGE_SIZE = 25; const MAX_REPORTED_ERRORS = 3; @@ -275,17 +275,28 @@ function renderQuotaUsage(item: Record, 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. + * Generic, table-level client-sort mechanism: any column tagged + * `clientSortable` in the schema (see ClientSortableColumn / + * withAccountListColumns) gets fetch-all-then-sort-in-memory behavior on + * click, for whichever list declares it — this isn't specific to Accounts + * or Groups, and needs no per-list wiring in this component. + * + * Real columns compare their own property directly; synthetic deviation + * columns (quotaUsage, aliasCount) aren't real properties, so they need an + * override to compute a comparable value from what's actually on the item. */ -const CLIENT_SORT_ACCESSORS: Record) => string | number> = { - emailAddress: (item) => String(item.emailAddress ?? ''), - description: (item) => String(item.description ?? ''), +const CLIENT_SORT_VALUE_OVERRIDES: Record) => string | number> = { quotaUsage: (item) => (typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0), aliasCount: (item) => Object.keys((item.aliases as Record) ?? {}).length, }; +function getClientSortValue(colName: string, item: Record): string | number { + const override = CLIENT_SORT_VALUE_OVERRIDES[colName]; + if (override) return override(item); + const raw = item[colName]; + return typeof raw === 'number' ? raw : String(raw ?? ''); +} + function getFieldsRecord(resolvedSchema: ResolvedSchema): Record { if (resolvedSchema.type === 'single') { return resolvedSchema.fields.properties; @@ -568,12 +579,16 @@ export function DynamicList({ viewName }: DynamicListProps) { const [sort, setSort] = useState(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; + // Any column the schema tags `clientSortable` (currently Email/Full Name/ + // Usage/Aliases on Accounts and Groups — see withAccountListColumns) is + // sorted client-side instead of via a JMAP `sort`, only when the user + // actually picks one of them. Not list-specific: any list whose columns + // carry the flag gets this for free. + const clientSortableColumns = useMemo( + () => new Set((resolved?.list?.columns ?? []).filter(isClientSortableColumn).map((c) => c.name)), + [resolved?.list?.columns], + ); + const clientSortField = sort && clientSortableColumns.has(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 @@ -733,11 +748,10 @@ export function DynamicList({ viewName }: DynamicListProps) { 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 av = getClientSortValue(clientSortField, a); + const bv = getClientSortValue(clientSortField, b); const cmp = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv)); return cmp * direction; }); @@ -1379,8 +1393,7 @@ export function DynamicList({ viewName }: DynamicListProps) { } function renderSortIndicator(colName: string): React.ReactNode { - const isClientSortable = supportsClientSort && colName in CLIENT_SORT_ACCESSORS; - if (!sortableFields.has(colName) && !isClientSortable) return null; + if (!sortableFields.has(colName) && !clientSortableColumns.has(colName)) return null; const isActive = sort?.field === colName; return (