diff --git a/SCHEMA_DEVIATIONS.md b/SCHEMA_DEVIATIONS.md index 8dbcf9e..5e9d2cf 100644 --- a/SCHEMA_DEVIATIONS.md +++ b/SCHEMA_DEVIATIONS.md @@ -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 diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index 96bcc53..fec17b9 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -272,6 +272,20 @@ 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. + */ +const CLIENT_SORT_ACCESSORS: Record) => 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) ?? {}).length, +}; + function getFieldsRecord(resolvedSchema: ResolvedSchema): Record { if (resolvedSchema.type === 'single') { return resolvedSchema.fields.properties; @@ -553,6 +567,13 @@ export function DynamicList({ viewName }: DynamicListProps) { const [appliedFilters, setAppliedFilters] = useState>(readUrlFilters); 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; // 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 (