refactor(sort): make client-side column sorting generic, not view-hardcoded
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.
This commit is contained in:
@@ -76,10 +76,10 @@ itself stays byte-for-byte alignable with upstream's version of the file.
|
|||||||
|
|
||||||
### `account-client-sort` 🟡
|
### `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`
|
- **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**: 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.
|
- **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.
|
- **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)
|
## Not a deviation (for reference)
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ import {
|
|||||||
import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema';
|
import type { Schema, Field, MassAction, ItemAction, Filter as FilterDef } from '@/types/schema';
|
||||||
import type { JmapSetResponse, JmapSetError } from '@/types/jmap';
|
import type { JmapSetResponse, JmapSetError } from '@/types/jmap';
|
||||||
import type { ResolvedSchema } from '@/lib/schemaResolver';
|
import type { ResolvedSchema } from '@/lib/schemaResolver';
|
||||||
import { isClientOnlyFilterEnum } from '@/lib/schemaDeviationTypes';
|
import { isClientOnlyFilterEnum, isClientSortableColumn } from '@/lib/schemaDeviationTypes';
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
const MAX_REPORTED_ERRORS = 3;
|
const MAX_REPORTED_ERRORS = 3;
|
||||||
@@ -275,17 +275,28 @@ function renderQuotaUsage(item: Record<string, unknown>, t: TFn): React.ReactNod
|
|||||||
/**
|
/**
|
||||||
* SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
|
* SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
|
||||||
*
|
*
|
||||||
* Columns sortable client-side (fetch-all + in-memory sort) on the
|
* Generic, table-level client-sort mechanism: any column tagged
|
||||||
* Accounts/Groups lists, keyed by column name, each mapped to a
|
* `clientSortable` in the schema (see ClientSortableColumn /
|
||||||
* comparable value extracted from the fetched item.
|
* 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, (item: Record<string, unknown>) => string | number> = {
|
const CLIENT_SORT_VALUE_OVERRIDES: 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),
|
quotaUsage: (item) => (typeof item.usedDiskQuota === 'number' ? item.usedDiskQuota : 0),
|
||||||
aliasCount: (item) => Object.keys((item.aliases as Record<string, unknown>) ?? {}).length,
|
aliasCount: (item) => Object.keys((item.aliases as Record<string, unknown>) ?? {}).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getClientSortValue(colName: string, item: Record<string, unknown>): 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<string, Field> {
|
function getFieldsRecord(resolvedSchema: ResolvedSchema): Record<string, Field> {
|
||||||
if (resolvedSchema.type === 'single') {
|
if (resolvedSchema.type === 'single') {
|
||||||
return resolvedSchema.fields.properties;
|
return resolvedSchema.fields.properties;
|
||||||
@@ -568,12 +579,16 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
|
|
||||||
const [sort, setSort] = useState<SortState | null>(readUrlSort);
|
const [sort, setSort] = useState<SortState | null>(readUrlSort);
|
||||||
// SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
|
// SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
|
||||||
// The server doesn't declare (or accept) a `sort` for any property on
|
// Any column the schema tags `clientSortable` (currently Email/Full Name/
|
||||||
// either list, so Email/Full Name/Usage/Aliases are sorted client-side
|
// Usage/Aliases on Accounts and Groups — see withAccountListColumns) is
|
||||||
// instead, only when the user actually picks one of them.
|
// sorted client-side instead of via a JMAP `sort`, only when the user
|
||||||
const supportsClientSort = isAccountsList || viewName === 'x:Account/Group';
|
// actually picks one of them. Not list-specific: any list whose columns
|
||||||
const clientSortField =
|
// carry the flag gets this for free.
|
||||||
supportsClientSort && sort && CLIENT_SORT_ACCESSORS[sort.field] ? sort.field : null;
|
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
|
// Filters marked `clientOnly` (currently Level/Event on the Logs list) are
|
||||||
// not supported by the server's query engine, so they narrow an
|
// not supported by the server's query engine, so they narrow an
|
||||||
@@ -733,11 +748,10 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
setMailboxDepths(depths);
|
setMailboxDepths(depths);
|
||||||
}
|
}
|
||||||
if (clientSortField) {
|
if (clientSortField) {
|
||||||
const accessor = CLIENT_SORT_ACCESSORS[clientSortField];
|
|
||||||
const direction = sort!.ascending ? 1 : -1;
|
const direction = sort!.ascending ? 1 : -1;
|
||||||
matched = [...matched].sort((a, b) => {
|
matched = [...matched].sort((a, b) => {
|
||||||
const av = accessor(a);
|
const av = getClientSortValue(clientSortField, a);
|
||||||
const bv = accessor(b);
|
const bv = getClientSortValue(clientSortField, b);
|
||||||
const cmp = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv));
|
const cmp = typeof av === 'number' && typeof bv === 'number' ? av - bv : String(av).localeCompare(String(bv));
|
||||||
return cmp * direction;
|
return cmp * direction;
|
||||||
});
|
});
|
||||||
@@ -1379,8 +1393,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSortIndicator(colName: string): React.ReactNode {
|
function renderSortIndicator(colName: string): React.ReactNode {
|
||||||
const isClientSortable = supportsClientSort && colName in CLIENT_SORT_ACCESSORS;
|
if (!sortableFields.has(colName) && !clientSortableColumns.has(colName)) return null;
|
||||||
if (!sortableFields.has(colName) && !isClientSortable) return null;
|
|
||||||
const isActive = sort?.field === colName;
|
const isActive = sort?.field === colName;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Schema } from '@/types/schema';
|
import type { Schema } from '@/types/schema';
|
||||||
|
import type { ClientSortableColumn } from './schemaDeviationTypes';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SCHEMA-DEVIATION: account-quota-usage-column (see SCHEMA_DEVIATIONS.md)
|
* SCHEMA-DEVIATION: account-quota-usage-column (see SCHEMA_DEVIATIONS.md)
|
||||||
@@ -25,17 +26,31 @@ import type { Schema } from '@/types/schema';
|
|||||||
* Both lists also get a synthetic `aliasCount` column — DynamicList
|
* Both lists also get a synthetic `aliasCount` column — DynamicList
|
||||||
* resolves it from the real `aliases` objectList property (an id-keyed
|
* resolves it from the real `aliases` objectList property (an id-keyed
|
||||||
* map, per JMAP's objectList wire format) and renders its entry count.
|
* map, per JMAP's objectList wire format) and renders its entry count.
|
||||||
|
*
|
||||||
|
* SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
|
||||||
|
*
|
||||||
|
* Email Address, Full Name (`description`), `quotaUsage`, and
|
||||||
|
* `aliasCount` are tagged `clientSortable` — DynamicList's generic
|
||||||
|
* client-sort mechanism picks this flag up from the column definition
|
||||||
|
* itself, the same way it already resolves `quotaUsage`/`aliasCount`,
|
||||||
|
* instead of hardcoding which lists/columns support it.
|
||||||
*/
|
*/
|
||||||
|
function sortable(column: { name: string; label: string }): ClientSortableColumn {
|
||||||
|
return { ...column, clientSortable: true };
|
||||||
|
}
|
||||||
|
|
||||||
export function withAccountListColumns(schema: Schema): Schema {
|
export function withAccountListColumns(schema: Schema): Schema {
|
||||||
let lists = schema.lists;
|
let lists = schema.lists;
|
||||||
|
|
||||||
const userList = lists['x:Account/User'];
|
const userList = lists['x:Account/User'];
|
||||||
if (userList) {
|
if (userList) {
|
||||||
const columns = [
|
const columns = [
|
||||||
...userList.columns.filter((c) => c.name !== 'createdAt'),
|
...userList.columns
|
||||||
|
.filter((c) => c.name !== 'createdAt')
|
||||||
|
.map((c) => (c.name === 'emailAddress' || c.name === 'description' ? sortable(c) : c)),
|
||||||
{ name: 'roles', label: 'Role' },
|
{ name: 'roles', label: 'Role' },
|
||||||
{ name: 'quotaUsage', label: 'Usage / Quota' },
|
sortable({ name: 'quotaUsage', label: 'Usage / Quota' }),
|
||||||
{ name: 'aliasCount', label: 'Aliases' },
|
sortable({ name: 'aliasCount', label: 'Aliases' }),
|
||||||
];
|
];
|
||||||
lists = { ...lists, 'x:Account/User': { ...userList, columns } };
|
lists = { ...lists, 'x:Account/User': { ...userList, columns } };
|
||||||
}
|
}
|
||||||
@@ -43,9 +58,9 @@ export function withAccountListColumns(schema: Schema): Schema {
|
|||||||
const groupList = lists['x:Account/Group'];
|
const groupList = lists['x:Account/Group'];
|
||||||
if (groupList) {
|
if (groupList) {
|
||||||
const columns = [
|
const columns = [
|
||||||
...groupList.columns,
|
...groupList.columns.map((c) => (c.name === 'emailAddress' || c.name === 'description' ? sortable(c) : c)),
|
||||||
{ name: 'quotaUsage', label: 'Usage / Quota' },
|
sortable({ name: 'quotaUsage', label: 'Usage / Quota' }),
|
||||||
{ name: 'aliasCount', label: 'Aliases' },
|
sortable({ name: 'aliasCount', label: 'Aliases' }),
|
||||||
];
|
];
|
||||||
lists = { ...lists, 'x:Account/Group': { ...groupList, columns } };
|
lists = { ...lists, 'x:Account/Group': { ...groupList, columns } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { FilterEnum } from '@/types/schema';
|
import type { Column, FilterEnum } from '@/types/schema';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type augmentations for tracked schema deviations (see SCHEMA_DEVIATIONS.md).
|
* Type augmentations for tracked schema deviations (see SCHEMA_DEVIATIONS.md).
|
||||||
@@ -23,3 +23,10 @@ export type ClientOnlyFilterEnum = FilterEnum & { clientOnly?: boolean };
|
|||||||
export function isClientOnlyFilterEnum(f: FilterEnum): f is ClientOnlyFilterEnum {
|
export function isClientOnlyFilterEnum(f: FilterEnum): f is ClientOnlyFilterEnum {
|
||||||
return (f as ClientOnlyFilterEnum).clientOnly === true;
|
return (f as ClientOnlyFilterEnum).clientOnly === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SCHEMA-DEVIATION: account-client-sort (see SCHEMA_DEVIATIONS.md)
|
||||||
|
export type ClientSortableColumn = Column & { clientSortable?: boolean };
|
||||||
|
|
||||||
|
export function isClientSortableColumn(c: Column): boolean {
|
||||||
|
return (c as ClientSortableColumn).clientSortable === true;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user