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:
@@ -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.
|
- **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.
|
- **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)
|
## Not a deviation (for reference)
|
||||||
|
|
||||||
A few other `viewName === '...'` / `objectName === '...'` checks exist in
|
A few other `viewName === '...'` / `objectName === '...'` checks exist in
|
||||||
|
|||||||
@@ -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> {
|
function getFieldsRecord(resolvedSchema: ResolvedSchema): Record<string, Field> {
|
||||||
if (resolvedSchema.type === 'single') {
|
if (resolvedSchema.type === 'single') {
|
||||||
return resolvedSchema.fields.properties;
|
return resolvedSchema.fields.properties;
|
||||||
@@ -553,6 +567,13 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>(readUrlFilters);
|
const [appliedFilters, setAppliedFilters] = useState<Record<string, string>>(readUrlFilters);
|
||||||
|
|
||||||
const [sort, setSort] = useState<SortState | null>(readUrlSort);
|
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
|
// 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
|
||||||
@@ -685,7 +706,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
const filter = buildFilter();
|
const filter = buildFilter();
|
||||||
const sortArr = buildSort();
|
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
|
// No server-side pagination possible once a client-only filter is
|
||||||
// active (SCHEMA-DEVIATION: log-client-filters): fetch every
|
// active (SCHEMA-DEVIATION: log-client-filters): fetch every
|
||||||
// server-matching row up front, narrow it in the browser, then
|
// 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
|
// this too (SCHEMA-DEVIATION: mailbox-client-hierarchy-sort) — a
|
||||||
// mailbox's parent can land on a different server page than the
|
// mailbox's parent can land on a different server page than the
|
||||||
// mailbox itself, so the full set is required to place each row
|
// 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(
|
const { list: fullList } = await jmapQueryAllAndGet(
|
||||||
obj.objectName,
|
obj.objectName,
|
||||||
accountId,
|
accountId,
|
||||||
{ filter: Object.keys(filter).length > 0 ? filter : undefined, sort: sortArr },
|
{ filter: Object.keys(filter).length > 0 ? filter : undefined, sort: clientSortField ? undefined : sortArr },
|
||||||
properties,
|
properties,
|
||||||
);
|
);
|
||||||
let matched = fullList.filter((item) =>
|
let matched = fullList.filter((item) =>
|
||||||
@@ -708,6 +732,16 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
matched = ordered;
|
matched = ordered;
|
||||||
setMailboxDepths(depths);
|
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);
|
setClientAllItems(matched);
|
||||||
setClientPage(0);
|
setClientPage(0);
|
||||||
setTotal(matched.length);
|
setTotal(matched.length);
|
||||||
@@ -773,6 +807,8 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
hasQuotaUsageColumn,
|
hasQuotaUsageColumn,
|
||||||
hasAliasCountColumn,
|
hasAliasCountColumn,
|
||||||
isMailboxList,
|
isMailboxList,
|
||||||
|
clientSortField,
|
||||||
|
sort,
|
||||||
activeClientFilters,
|
activeClientFilters,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1343,7 +1379,8 @@ export function DynamicList({ viewName }: DynamicListProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderSortIndicator(colName: string): React.ReactNode {
|
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;
|
const isActive = sort?.field === colName;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user