From 41cbd81d54fae2ff2390cbe00d8841b674247bf2 Mon Sep 17 00:00:00 2001 From: Marko Stijak Date: Mon, 22 Jun 2026 11:28:56 +0200 Subject: [PATCH 1/3] fix(NumberField): reserve right padding for the clear button NumberField never applied the `clear` state class nor reserved right padding, so the value sat under the clear icon when showClear was on. Mirror TextField's handling. --- packages/cx/src/widgets/form/NumberField.scss | 4 ++++ packages/cx/src/widgets/form/NumberField.tsx | 1 + 2 files changed, 5 insertions(+) diff --git a/packages/cx/src/widgets/form/NumberField.scss b/packages/cx/src/widgets/form/NumberField.scss index 92a3c638f..33c51fe90 100644 --- a/packages/cx/src/widgets/form/NumberField.scss +++ b/packages/cx/src/widgets/form/NumberField.scss @@ -42,6 +42,10 @@ .#{$state}icon > & { padding-left: cx-calc(cx-top($padding), $cx-default-input-left-tool-size, $cx-default-input-left-tool-spacing); } + + .#{$state}clear > & { + padding-right: cx-calc(cx-top($padding), $cx-default-clear-size, $cx-default-clear-spacing); + } } .#{$element}#{$name}-tool { diff --git a/packages/cx/src/widgets/form/NumberField.tsx b/packages/cx/src/widgets/form/NumberField.tsx index 40780a71d..58e1c8e18 100644 --- a/packages/cx/src/widgets/form/NumberField.tsx +++ b/packages/cx/src/widgets/form/NumberField.tsx @@ -312,6 +312,7 @@ class Input extends VDOM.Component { visited: state.visited, focus: this.state.focus, icon: !!icon, + clear: insideButton != null, empty: empty && !data.placeholder, error: data.error && From 024307617661c25badba543954ef77320552383f Mon Sep 17 00:00:00 2001 From: Marko Stijak Date: Mon, 22 Jun 2026 13:24:03 +0200 Subject: [PATCH 2/3] feat(Grid/GroupAdapter): sort groups by aggregate, key, or column Group sorting could only order by key, ascending. This adds declarative group sorting and column-driven group sorting. - GroupAdapter grouping levels accept `sortField`/`sortDirection` or a `sorters` array, resolved against the group key fields, aggregate aliases and name. An explicit `comparer` still wins. - New `sortGroups` flag on Grid (maps to GroupAdapter `sortGroupsBySorters`): sorting a column whose field is a group key or aggregate reorders the groups; other columns leave group order intact. - Sort fields read straight off the GroupResult, no per-comparison cloning. - Docs and the grouping example updated; tests added. Closes #1303 --- .../src/examples/tables/GroupingExample.tsx | 3 + homedocs/src/pages/docs/tables/grid.mdx | 6 +- .../src/pages/docs/tables/group-adapter.mdx | 3 +- homedocs/src/pages/docs/tables/grouping.mdx | 30 ++++- .../cx/src/ui/adapter/GroupAdapter.spec.ts | 75 ++++++++++++ packages/cx/src/ui/adapter/GroupAdapter.ts | 113 ++++++++++++++++-- packages/cx/src/widgets/grid/Grid.tsx | 20 ++++ 7 files changed, 237 insertions(+), 13 deletions(-) diff --git a/homedocs/src/examples/tables/GroupingExample.tsx b/homedocs/src/examples/tables/GroupingExample.tsx index 64b341f7c..84ac33724 100644 --- a/homedocs/src/examples/tables/GroupingExample.tsx +++ b/homedocs/src/examples/tables/GroupingExample.tsx @@ -111,11 +111,13 @@ export default ( style="height: 500px" scrollable border + sortGroups grouping={[{ key: {}, showFooter: true }, "continent"]} columns={[ { header: "Name", field: "fullName", + sortable: true, aggregate: "count", footer: tpl(m.$group.fullName, "{0} people"), }, @@ -136,6 +138,7 @@ export default ( header: "Visits", field: "visits", align: "right", + sortable: true, aggregate: "sum", }, ]} diff --git a/homedocs/src/pages/docs/tables/grid.mdx b/homedocs/src/pages/docs/tables/grid.mdx index 6a1b47538..e28fc5018 100644 --- a/homedocs/src/pages/docs/tables/grid.mdx +++ b/homedocs/src/pages/docs/tables/grid.mdx @@ -154,7 +154,10 @@ Each grouping level supports the following options: | `showHeader` | `boolean` | Show a header row within each group. Useful for long printable reports. | | `showCaption` | `boolean` | Show a caption row at the start of each group. Caption content is defined in the column's `caption` property. | | `text` | `string` | A selector for text available as `$group.$name` in templates. | -| `comparer` | `function` | A function to determine group ordering. | +| `sortField` | `string` | Sort groups by this field, resolved against the group's key fields, aggregate aliases and `$name`. | +| `sortDirection` | `string` | Sort direction for `sortField` (`ASC` or `DESC`). Defaults to `ASC`. | +| `sorters` | `array` | Multi-field group sorting: `[{ field, direction }, ...]`. | +| `comparer` | `function` | A function to determine group ordering. Takes precedence over `sortField`/`sorters`. | Column aggregates (`sum`, `count`, `avg`, `distinct`) are automatically calculated and available in footer/caption templates via `$group`. @@ -266,6 +269,7 @@ See also: [Row Drag and Drop](/docs/tables/row-drag-and-drop) | `groupingParams` | `any` | Parameters passed to `onGetGrouping` for dynamic grouping. | | `onGetGrouping` | `function` | Callback to dynamically generate grouping when params change. | | `preserveGroupOrder`| `boolean` | Keep groups in the same order as source records. | +| `sortGroups` | `boolean` | Reorder groups when a column is sorted, if its field is a group key or aggregate. Ignored with `preserveGroupOrder`. | ### Filtering diff --git a/homedocs/src/pages/docs/tables/group-adapter.mdx b/homedocs/src/pages/docs/tables/group-adapter.mdx index 6b06ad1d2..d9e8ed348 100644 --- a/homedocs/src/pages/docs/tables/group-adapter.mdx +++ b/homedocs/src/pages/docs/tables/group-adapter.mdx @@ -81,8 +81,9 @@ The `$group` object exposes the following fields in headers and footers: | `indexName` | `string` | Alias used to expose record index. Default is `$index`. | | `keyField` | `string` | Field used as the unique record key. | | `groupName` | `string` | Alias used to expose group data. Default is `$group`. | -| `groupings` | `array` | Defines criteria for grouping records. Supports header and footer configuration. | +| `groupings` | `array` | Defines criteria for grouping records. Each level also accepts `sortField`/`sortDirection`, a `sorters` array, or a custom `comparer` to order groups by key, aggregate or name. | | `aggregates` | `object` | Defines computed values based on grouped records (e.g., count, sum, avg). | +| `sortGroupsBySorters` | `boolean` | When enabled, the active record sorters also reorder groups (resolved against each group's key/aggregates/name). The Grid's `sortGroups` prop sets this. | | `groupRecordsName` | `string` | Alias used to expose records within a group. Default is `$records`. | | `groupRecordsAlias` | `string` | Alias used to expose group records outside the group. | | `sortOptions` | `object` | Options for data sorting. See [Intl.Collator options](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Collator). | diff --git a/homedocs/src/pages/docs/tables/grouping.mdx b/homedocs/src/pages/docs/tables/grouping.mdx index 5139761a6..d9dd3faa1 100644 --- a/homedocs/src/pages/docs/tables/grouping.mdx +++ b/homedocs/src/pages/docs/tables/grouping.mdx @@ -27,7 +27,7 @@ Grid supports grouping records by one or more fields with aggregation and custom -Records are grouped by continent with aggregate calculations shown in the footer. +Records are grouped by continent with aggregate calculations shown in the footer. Because the grid sets `sortGroups`, clicking the **Visits** header also reorders the continents by their total visits, and **Name** orders them by head count. ## Basic Grouping @@ -118,6 +118,30 @@ Add a column with the built-in `cxe-grid-row-number` class to display automatic The counter resets on the group caption row, so the grouping level needs a caption (`showCaption` or `caption`) for the reset to take effect. +## Sorting Groups + +By default groups are ordered by their key, ascending. There are three ways to change that. + +**Sort by an aggregate or key declaratively.** Set `sortField` (and optionally `sortDirection`) on a grouping level. The field is resolved against the group's key fields, aggregate aliases and `$name`, so an aggregate alias sorts groups by that aggregate: + +```tsx +grouping={[ + { + key: { continent: { bind: "$record.continent" } }, + sortField: "visits", // aggregate alias + sortDirection: "DESC", // highest total first + }, +]} +``` + +Use a `sorters` array for multi-field group sorting (`[{ field, direction }, ...]`). A custom `comparer` still takes precedence over both. + +**Sort groups by clicking column headers.** Set `sortGroups` on the grid. When the user sorts a column whose field is a group key or aggregate, the groups reorder to match; sorting any other column leaves the group order untouched. This is ignored when `preserveGroupOrder` is set. + +```tsx + +``` + ## Group Data The `$group` object contains: @@ -141,5 +165,9 @@ The `$group` object contains: | `resetRowNumbers` | `boolean` | Restart `cxe-grid-row-number` numbering from 1 at the start of each group at this level. Requires a caption. | | `caption` | `StringProp` | Caption template. | | `text` | `StringProp` | Group name template. | +| `sortField` | `string` | Sort groups by this field, resolved against the group's key fields, aggregate aliases and `$name`. | +| `sortDirection` | `string` | Sort direction for `sortField` (`ASC` or `DESC`). Defaults to `ASC`. | +| `sorters` | `array` | Multi-field group sorting: `[{ field, direction }, ...]`. | +| `comparer` | `function` | Custom group comparer. Takes precedence over `sortField`/`sorters`. | See also: [Dynamic Grouping](/docs/tables/dynamic-grouping), [GroupAdapter](/docs/tables/group-adapter) diff --git a/packages/cx/src/ui/adapter/GroupAdapter.spec.ts b/packages/cx/src/ui/adapter/GroupAdapter.spec.ts index 29ff0a7c4..48536c80d 100644 --- a/packages/cx/src/ui/adapter/GroupAdapter.spec.ts +++ b/packages/cx/src/ui/adapter/GroupAdapter.spec.ts @@ -39,4 +39,79 @@ describe("GroupAdapter", () => { assert.deepStrictEqual(newKeys, [], `Grouping config was mutated. New keys: ${newKeys.join(", ")}`); }); + + // Region totals after aggregation: A=10, B=30, C=20. + const records = [ + { region: "A", sales: 10, product: "p3" }, + { region: "B", sales: 30, product: "p1" }, + { region: "C", sales: 20, product: "p2" }, + ]; + + const baseGrouping: GroupingConfig = { + key: { region: { bind: "$record.region" } }, + aggregates: { total: { type: "sum", value: { bind: "$record.sales" } } }, + }; + + // Runs the full grouping pipeline and returns the resulting group order (region keys). + function groupOrder(grouping: GroupingConfig[], options?: { sortGroupsBySorters?: boolean }, sorters?: any[]) { + const adapter = new GroupAdapter({ groupings: grouping, ...options }); + adapter.init(); + if (sorters) adapter.sort(sorters); + const result = adapter.getRecords({} as any, {} as any, records, new Store({ data: {} })); + return result.filter((r) => r.type === "group-header").map((r: any) => r.group.region); + } + + it("sorts groups by an aggregate via sortField/sortDirection", () => { + assert.deepStrictEqual(groupOrder([{ ...baseGrouping, sortField: "total", sortDirection: "DESC" }]), [ + "B", + "C", + "A", + ]); + }); + + it("sorts groups by a key field via sortField", () => { + assert.deepStrictEqual(groupOrder([{ ...baseGrouping, sortField: "region", sortDirection: "DESC" }]), [ + "C", + "B", + "A", + ]); + }); + + it("supports a multi-field sorters array", () => { + assert.deepStrictEqual(groupOrder([{ ...baseGrouping, sorters: [{ field: "total", direction: "ASC" }] }]), [ + "A", + "C", + "B", + ]); + }); + + it("defaults to ascending sort by key", () => { + assert.deepStrictEqual(groupOrder([baseGrouping]), ["A", "B", "C"]); + }); + + it("lets an explicit comparer win over sortField", () => { + // Custom comparer sorts groups by total ascending, despite sortField asking for DESC. + const comparer = (a: any, b: any) => a.aggregates.total - b.aggregates.total; + assert.deepStrictEqual(groupOrder([{ ...baseGrouping, comparer, sortField: "total", sortDirection: "DESC" }]), [ + "A", + "C", + "B", + ]); + }); + + it("reorders groups by the active column sort when sortGroupsBySorters is set", () => { + // The record-level value selector must be ignored; the column field drives group order. + const order = groupOrder([baseGrouping], { sortGroupsBySorters: true }, [ + { field: "total", direction: "DESC", value: () => 999 }, + ]); + assert.deepStrictEqual(order, ["B", "C", "A"]); + }); + + it("keeps configured group order when the sorted column is not a group field", () => { + // `product` is neither a key nor an aggregate, so groups keep their default (key ASC) order. + const order = groupOrder([baseGrouping], { sortGroupsBySorters: true }, [ + { field: "product", direction: "DESC" }, + ]); + assert.deepStrictEqual(order, ["A", "B", "C"]); + }); }); diff --git a/packages/cx/src/ui/adapter/GroupAdapter.ts b/packages/cx/src/ui/adapter/GroupAdapter.ts index cd480a91d..581b23905 100644 --- a/packages/cx/src/ui/adapter/GroupAdapter.ts +++ b/packages/cx/src/ui/adapter/GroupAdapter.ts @@ -4,11 +4,13 @@ import { ReadOnlyDataView } from "../../data/ReadOnlyDataView"; import { View } from "../../data/View"; import { isDataRecord } from "../../util"; import { isArray } from "../../util/isArray"; +import { isDefined } from "../../util/isDefined"; +import { isNonEmptyArray } from "../../util/isNonEmptyArray"; import { Culture } from "../Culture"; import { Instance } from "../Instance"; import { Prop, SortDirection, Sorter, StructuredProp } from "../Prop"; import { RenderingContext } from "../RenderingContext"; -import { ArrayAdapter, ArrayAdapterConfig, RecordStoreCache } from "./ArrayAdapter"; +import { ArrayAdapter, ArrayAdapterConfig, ExtendedSorter, RecordStoreCache } from "./ArrayAdapter"; import { DataAdapterRecord } from "./DataAdapter"; export interface GroupKey { @@ -21,7 +23,17 @@ export interface GroupingConfig { text?: Prop; includeHeader?: boolean; includeFooter?: boolean; + /** Custom group comparer. Takes precedence over `sortField`/`sortDirection`/`sorters`. */ comparer?: ((a: any, b: any) => number) | null; + /** + * Sort groups by a single field resolved against the group's `key`, `aggregates` and `name`. + * Use an aggregate alias (e.g. `"total"`) to sort by an aggregate, or a key field to sort by key. + */ + sortField?: string; + /** Sort direction used together with `sortField`. Defaults to `"ASC"`. */ + sortDirection?: SortDirection; + /** Multi-field group sorting. Each sorter's `field` is resolved against the group's `key`, `aggregates` and `name`. */ + sorters?: Sorter[]; header?: any; footer?: any; } @@ -50,6 +62,8 @@ export interface GroupAdapterConfig extends ArrayAdapterConfig { groupRecordsName?: string; groupings?: GroupingConfig[] | null; groupName?: string; + /** When enabled, the active record sorters also reorder groups (resolved against each group's key/aggregates/name). */ + sortGroupsBySorters?: boolean; } export class GroupAdapter extends ArrayAdapter { @@ -58,11 +72,41 @@ export class GroupAdapter extends ArrayAdapter { declare public groupRecordsName?: string; declare public groupings?: ResolvedGrouping[] | null; declare public groupName: string; + declare public sortGroupsBySorters?: boolean; + + /** + * Per-level comparer derived from the active record sorters, used to reorder groups when + * `sortGroupsBySorters` is set. A level's entry is `null` when no active sorter maps to that + * level's key/aggregate, so the grouping's own configured order is kept. + */ + protected groupSortComparers?: (((a: any, b: any) => number) | null)[]; constructor(config?: GroupAdapterConfig) { super(config); } + public sort(sorters?: Sorter[] | ExtendedSorter[]): void { + super.sort(sorters); + + // When enabled, derive a group comparer from the active record sorters so that interactive + // column sorting also reorders the groups. A column only reorders groups at levels where its + // field is a group key or aggregate; other levels keep their configured order. The record-level + // value selector is ignored — the field is resolved against the group's key/aggregates/name. + if (this.sortGroupsBySorters && this.groupings) { + const cultureComparer = this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined; + const colSorters: ExtendedSorter[] = isNonEmptyArray(sorters) + ? (sorters as ExtendedSorter[]).map((s) => ({ field: s.field, direction: s.direction, comparer: s.comparer })) + : []; + + this.groupSortComparers = this.groupings.map((g) => { + if (colSorters.length === 0) return null; + const fields = groupFieldNames(g, this.aggregates); + const applicable = colSorters.filter((s) => s.field && fields.has(s.field)); + return applicable.length > 0 ? buildGroupComparer(applicable, cultureComparer) : null; + }); + } + } + public init(): void { super.init(); @@ -116,8 +160,13 @@ export class GroupAdapter extends ArrayAdapter { grouper.processAll(records); let results = grouper.getResults(); - if (grouping.comparer && !this.preserveOrder) { - results.sort(grouping.comparer); + // An active column sort that maps to this level's key/aggregate (when `sortGroupsBySorters` is + // enabled) takes over; otherwise the grouping's configured comparer (or implicit key order) applies. + const dynamicComparer = this.sortGroupsBySorters ? this.groupSortComparers?.[level] : undefined; + const comparer = dynamicComparer ?? grouping.comparer; + + if (comparer && !this.preserveOrder) { + results.sort(comparer); } results.forEach((gr) => { @@ -208,15 +257,22 @@ export class GroupAdapter extends ArrayAdapter { g.text, ); + const cultureComparer = this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined; + + // Sort groups by an aggregate/key/name through `sortField`/`sortDirection` or a `sorters` array. + let sortSorters: Sorter[] | null = null; + if (isNonEmptyArray(g.sorters)) sortSorters = g.sorters!; + else if (g.sortField) sortSorters = [{ field: g.sortField, direction: g.sortDirection ?? "ASC" }]; + + // `comparer`, `sortField`/`sorters` and the implicit key order are equivalent alternatives; an + // explicit `comparer` wins, then declarative sorters, then sorting by key in declaration order. const comparer = g.comparer ?? - (groupSorters.length > 0 - ? getComparer( - groupSorters, - (x: any) => x.key, - this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined, - ) - : null); + (sortSorters + ? buildGroupComparer(sortSorters, cultureComparer) + : groupSorters.length > 0 + ? getComparer(groupSorters, (x: any) => x.key, cultureComparer) + : null); return { ...g, @@ -233,6 +289,43 @@ export class GroupAdapter extends ArrayAdapter { GroupAdapter.prototype.groupName = "$group"; GroupAdapter.prototype.preserveOrder = false; +GroupAdapter.prototype.sortGroupsBySorters = false; + +// The set of fields a group can be sorted by at a given level: its key fields, its aggregate aliases +// and the group name. Used to decide whether an active column sort applies to that grouping level. +function groupFieldNames(grouping: ResolvedGrouping, adapterAggregates?: StructuredProp): Set { + const names = new Set(); + for (const k of grouping.grouper.keys) names.add(k.name); + const aggregates = { ...adapterAggregates, ...grouping.aggregates }; + for (const a in aggregates) names.add(a); + names.add("name"); + names.add("$name"); + return names; +} + +// Reads a sort field straight from the GroupResult — first its key fields, then its aggregates, then +// its name. Returns a plain selector so groups can be sorted without cloning the result per comparison. +function groupFieldSelector(field: string): (gr: any) => any { + if (field === "name" || field === "$name") return (gr) => gr?.name; + return (gr) => { + if (gr?.key && field in gr.key) return gr.key[field]; + if (gr?.aggregates && field in gr.aggregates) return gr.aggregates[field]; + return undefined; + }; +} + +// Builds a group comparer from a list of sorters. Each sorter resolves by its explicit `value` +// selector, or by `field` looked up against the group's key/aggregates/name. +function buildGroupComparer( + sorters: ExtendedSorter[], + cultureComparer?: (a: any, b: any) => number, +): (a: any, b: any) => number { + return getComparer( + sorters.map((s) => (isDefined(s.value) || !s.field ? s : { ...s, value: groupFieldSelector(s.field) })), + undefined, + cultureComparer, + ); +} function serializeKey(data: any): string { if (isDataRecord(data)) { diff --git a/packages/cx/src/widgets/grid/Grid.tsx b/packages/cx/src/widgets/grid/Grid.tsx index 441851b77..5565160fe 100644 --- a/packages/cx/src/widgets/grid/Grid.tsx +++ b/packages/cx/src/widgets/grid/Grid.tsx @@ -23,6 +23,7 @@ import { NumberProp, Prop, RecordAlias, + Sorter, SortDirection, SortersProp, StringProp, @@ -165,6 +166,15 @@ export interface GridGroupingConfig { name?: StringProp; text?: StringProp; comparer?: (a: GroupingResult, b: GroupingResult) => number; + /** + * Sort groups by a single field resolved against the group's key, aggregates and name. + * Use an aggregate alias to sort by an aggregate, or a key field to sort by key. + */ + sortField?: string; + /** Sort direction used together with `sortField`. Defaults to `"ASC"`. */ + sortDirection?: SortDirection; + /** Multi-field group sorting. Each sorter's `field` is resolved against the group's key, aggregates and name. */ + sorters?: Sorter[]; } export interface GridColumnHeaderConfig { @@ -502,6 +512,13 @@ export interface GridConfig extends StyledContainerConfig { /** When enabled, groups are shown in the same order as the source records. */ preserveGroupOrder?: boolean; + + /** + * When enabled, sorting by a column header also reorders groups. The active sort field is resolved + * against each group's key fields, aggregates and name, so it works for both grouping columns and + * aggregate columns. Ignored when `preserveGroupOrder` is set. + */ + sortGroups?: boolean; } export interface GridCellInfo { @@ -635,6 +652,7 @@ export class Grid extends ContainerBase, GridInstance declare onCreateIsRecordDraggable?: any; declare onRef?: any; declare preserveGroupOrder: boolean; + declare sortGroups: boolean; declare styled: boolean; declare selectable?: boolean; declare recordsAccessor: any; @@ -844,6 +862,7 @@ export class Grid extends ContainerBase, GridInstance sortOptions: this.sortOptions, groupings: grouping, preserveOrder: this.preserveGroupOrder, + sortGroupsBySorters: this.sortGroups, }, this.dataAdapter, ); @@ -1903,6 +1922,7 @@ Grid.prototype.hoverChannel = "default"; Grid.prototype.focusable = null; // automatically resolved Grid.prototype.allowsFileDrops = false; Grid.prototype.preserveGroupOrder = false; +Grid.prototype.sortGroups = false; Widget.alias("grid", Grid); Localization.registerPrototype("cx/widgets/Grid", Grid); From 2f38ef5c40c7e81d8f96e3d467e03ecacc1b710e Mon Sep 17 00:00:00 2001 From: Marko Stijak Date: Mon, 22 Jun 2026 13:55:07 +0200 Subject: [PATCH 3/3] refactor(adapter): consolidate Sorter/ExtendedSorter types - Move `ExtendedSorter` to `data/comparer` (the module that defines the sorting contract), re-exporting it from `ArrayAdapter` so `cx/ui` consumers are unaffected. It is now also available from `cx/data`. - Drop `comparer.ts`'s private, duplicate `Sorter` interface; the helpers now use the shared `ExtendedSorter`. - Simplify `sort()` to take `ExtendedSorter[]` instead of the redundant `Sorter[] | ExtendedSorter[]` union (a plain Sorter is already assignable). - Widen `Sorter.value` to `Prop`: it is resolved via `getSelector` and is commonly a binding, not just an accessor function. --- packages/cx/src/data/comparer.ts | 13 ++++++------- packages/cx/src/ui/Prop.ts | 2 +- packages/cx/src/ui/adapter/ArrayAdapter.ts | 14 ++++++-------- packages/cx/src/ui/adapter/GroupAdapter.ts | 4 ++-- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/packages/cx/src/data/comparer.ts b/packages/cx/src/data/comparer.ts index 6e7fdf6cc..011bdda7a 100644 --- a/packages/cx/src/data/comparer.ts +++ b/packages/cx/src/data/comparer.ts @@ -1,17 +1,16 @@ import { getSelector } from "./getSelector"; import { isDefined } from "../util/isDefined"; import { defaultCompare } from "./defaultCompare"; +import type { CollatorOptions, Sorter } from "../ui/Prop"; -interface Sorter { - value?: any; - field?: string; - direction?: string; +export interface ExtendedSorter extends Sorter { comparer?: (a: any, b: any) => number; compare?: (a: any, b: any) => number; + sortOptions?: CollatorOptions; } export function getComparer( - sorters: Sorter[], + sorters: ExtendedSorter[], dataAccessor?: (x: any) => any, comparer?: (a: any, b: any) => number, ): (a: any, b: any) => number { @@ -51,7 +50,7 @@ export function getComparer( } export function indexSorter( - sorters: Sorter[], + sorters: ExtendedSorter[], dataAccessor?: (x: any) => any, compare?: (a: any, b: any) => number, ): (data: any[]) => number[] { @@ -64,7 +63,7 @@ export function indexSorter( } export function sorter( - sorters: Sorter[], + sorters: ExtendedSorter[], dataAccessor?: (x: any) => any, compare?: (a: any, b: any) => number, ): (data: any[]) => any[] { diff --git a/packages/cx/src/ui/Prop.ts b/packages/cx/src/ui/Prop.ts index 69514dc04..fd4015b47 100644 --- a/packages/cx/src/ui/Prop.ts +++ b/packages/cx/src/ui/Prop.ts @@ -127,7 +127,7 @@ export type SortDirection = "ASC" | "DESC"; export interface Sorter { field?: string; - value?: (record: DataRecord) => any; + value?: Prop; direction: SortDirection; } diff --git a/packages/cx/src/ui/adapter/ArrayAdapter.ts b/packages/cx/src/ui/adapter/ArrayAdapter.ts index d1f23f305..6e84de59a 100644 --- a/packages/cx/src/ui/adapter/ArrayAdapter.ts +++ b/packages/cx/src/ui/adapter/ArrayAdapter.ts @@ -1,6 +1,6 @@ import { DataAdapter, DataAdapterRecord, DataAdapterConfig } from "./DataAdapter"; import { ReadOnlyDataView } from "../../data/ReadOnlyDataView"; -import { sorter } from "../../data/comparer"; +import { sorter, type ExtendedSorter } from "../../data/comparer"; import { isArray } from "../../util/isArray"; import { ArrayElementView } from "../../data/ArrayElementView"; import { Accessor, getAccessor } from "../../data/getAccessor"; @@ -9,7 +9,7 @@ import { isDefined, isObject } from "../../util"; import { RenderingContext } from "../RenderingContext"; import { Instance } from "../Instance"; import { View } from "../../data/View"; -import { Prop, Sorter, CollatorOptions } from "../Prop"; +import { Prop, CollatorOptions } from "../Prop"; export interface RecordStoreCache { recordStoreCache: WeakMap; @@ -26,10 +26,8 @@ export interface ArrayAdapterConfig extends DataAdapterConfig { preserveOrder?: boolean; } -export interface ExtendedSorter extends Sorter { - comparer?: (a: any, b: any) => number; - sortOptions?: CollatorOptions; -} +// Re-exported for back-compat; the canonical definition lives in `data/comparer`. +export type { ExtendedSorter } from "../../data/comparer"; export interface ResolvedSorter { getter: (x: any) => any; @@ -213,9 +211,9 @@ export class ArrayAdapter extends DataAdapter { } } - public sort(sorters?: Sorter[] | ExtendedSorter[]): void { + public sort(sorters?: ExtendedSorter[]): void { if (sorters) { - this.buildSorter(sorters as ExtendedSorter[]); + this.buildSorter(sorters); } } } diff --git a/packages/cx/src/ui/adapter/GroupAdapter.ts b/packages/cx/src/ui/adapter/GroupAdapter.ts index 581b23905..c22da6305 100644 --- a/packages/cx/src/ui/adapter/GroupAdapter.ts +++ b/packages/cx/src/ui/adapter/GroupAdapter.ts @@ -85,7 +85,7 @@ export class GroupAdapter extends ArrayAdapter { super(config); } - public sort(sorters?: Sorter[] | ExtendedSorter[]): void { + public sort(sorters?: ExtendedSorter[]): void { super.sort(sorters); // When enabled, derive a group comparer from the active record sorters so that interactive @@ -95,7 +95,7 @@ export class GroupAdapter extends ArrayAdapter { if (this.sortGroupsBySorters && this.groupings) { const cultureComparer = this.sortOptions ? Culture.getComparer(this.sortOptions) : undefined; const colSorters: ExtendedSorter[] = isNonEmptyArray(sorters) - ? (sorters as ExtendedSorter[]).map((s) => ({ field: s.field, direction: s.direction, comparer: s.comparer })) + ? sorters.map((s) => ({ field: s.field, direction: s.direction, comparer: s.comparer })) : []; this.groupSortComparers = this.groupings.map((g) => {