Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions homedocs/src/examples/tables/GroupingExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
},
Expand All @@ -136,6 +138,7 @@ export default (
header: "Visits",
field: "visits",
align: "right",
sortable: true,
aggregate: "sum",
},
]}
Expand Down
6 changes: 5 additions & 1 deletion homedocs/src/pages/docs/tables/grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion homedocs/src/pages/docs/tables/group-adapter.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
30 changes: 29 additions & 1 deletion homedocs/src/pages/docs/tables/grouping.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Grid supports grouping records by one or more fields with aggregation and custom
<GroupingExample client:load />
</CodeExample>

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

Expand Down Expand Up @@ -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
<Grid sortGroups grouping={["continent"]} columns={[ /* sortable columns */ ]} />
```

## Group Data

The `$group` object contains:
Expand All @@ -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)
13 changes: 6 additions & 7 deletions packages/cx/src/data/comparer.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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[] {
Expand All @@ -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[] {
Expand Down
2 changes: 1 addition & 1 deletion packages/cx/src/ui/Prop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export type SortDirection = "ASC" | "DESC";

export interface Sorter {
field?: string;
value?: (record: DataRecord) => any;
value?: Prop<any>;
direction: SortDirection;
}

Expand Down
14 changes: 6 additions & 8 deletions packages/cx/src/ui/adapter/ArrayAdapter.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<any, View>;
Expand All @@ -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;
Expand Down Expand Up @@ -213,9 +211,9 @@ export class ArrayAdapter<T = any> extends DataAdapter<T> {
}
}

public sort(sorters?: Sorter[] | ExtendedSorter[]): void {
public sort(sorters?: ExtendedSorter[]): void {
if (sorters) {
this.buildSorter(sorters as ExtendedSorter[]);
this.buildSorter(sorters);
}
}
}
Expand Down
75 changes: 75 additions & 0 deletions packages/cx/src/ui/adapter/GroupAdapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});
Loading
Loading