Skip to content

Commit e2db258

Browse files
committed
Merge origin/main into claude/project-thread-a0vh3h (objectstack#25711)
main now carries ui/ (cutover #25825) and the dual-mode sdui gates (#25833). .changeset/config.json keeps this branch's side ("object-ui" in the fixed group, which the root PRIVATE_FIXED_MEMBERS rule accepts); scripts/ui-import/README.md (added on both sides) keeps the cutover's splice procedure first and 4a's record after it. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149BHSXfCdQywGaLdpRAtWe
2 parents 50bd417 + fd40831 commit e2db258

9,311 files changed

Lines changed: 2602665 additions & 1737 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
feat(spec,lint): a list-view grouping level can order its groups by another field (`grouping.fields[].orderBy`)
7+
8+
Clause-②: yes
9+
Metadata-protocol: added
10+
11+
- **Before.** A grouping level ordered its groups only by its own key. A period label grouped as text (`Nov 2026`, `Oct 2026`, `Q1 2027`, `Q4 2025`), so putting periods in time order took a second, outer grouping level on the period start, which showed two header rows for every group.
12+
- **Now.** `GroupingFieldSchema` gains `orderBy`, the name of a field on the same object whose value orders this level's groups: `{ field: 'period_label', orderBy: 'period_start', order: 'desc' }`. When it is absent, or names the grouping field itself, the groups order by their own key as before. Nothing is defaulted in.
13+
- **What the header query carries.** The consumer still sorts the header rows. `compileListViewGroupQuery` now adds the value to sort by: one `min` node over `orderBy` for `order: 'asc'`, or one `max` node for `'desc'`, under `min_<field>` / `max_<field>`. Each group sits where its first row would sit if the rows were sorted by `orderBy` in `order`. The grouping key stays the only `groupBy` column, so there is one header per label. A column summary that already declares the same `min` is the same node and the same column. In a multi-level grouping the outer level's value folds exactly from the leaf rows (the `min` of mins, the `max` of maxes).
14+
- **`listViewGroupOrderColumn(grouping, level)`** (`@objectstack/spec/ui`) names the header-row column a level sorts by: the grouping field itself, or the order node's alias. A consumer reads this instead of rebuilding the alias. A group whose order value is `null` sorts last in both directions, and equal values fall back to the group key in the same direction.
15+
- **Refused, at compile and at authoring.** An empty `orderBy` is refused by the schema (`too_small`), and a padded one, whitespace-only included, by name, as a padded `field` is, because the name is compiled verbatim. The compiler refuses a blank one in-process (`grouping_order_by_blank`, `INVALID_QUERY` / 400). An order alias that lands on a grouped field's own column is `alias_collision`. `os validate`, `os build` and the runtime save door judge `orderBy` the way the server doors do. An unknown name is `list-view-field-unknown`. A dotted path is `list-view-field-dotted`, because the header query compiles no join. A field whose type `min` / `max` cannot order on every backend is the new `list-view-group-order-unorderable` (`error`). That covers the string classes, whose order depends on the collation, the virtual `formula`, and every other type outside the numeric, temporal and boolean classes. Each of these is refused `400 INVALID_FIELD` on the server, so the grouped view would render no group: an unknown or dotted name by the data door's ingress (`findData`), and an unorderable type by `engine.aggregate`'s type door.
16+
- **Unchanged.** `order` and `collapsed` keep their meaning and defaults. The row page query (`compileListViewGroupRowsQuery`) does not change. `EngineAggregateOptions` gains no `orderBy`, because the group sort stays the consumer's. That sort is right only over the whole header set, which is what the compiler asks for: a `limit` on the header query is sliced by `findData` before any sort, so a paged header set honours neither `order` nor `orderBy`, the limit already recorded on `GroupingConfigSchema`. The new key is purely additive, so a grouping declared today compiles to exactly the same query.

‎content/docs/references/ui/component.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -887,7 +887,7 @@ Sort field and direction pair
887887

888888
| Property | Type | Required | Description |
889889
| :--- | :--- | :--- | :--- |
890-
| **fields** | `{ field: string; order?: Enum<'asc' \| 'desc'>; collapsed?: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |
890+
| **fields** | `{ field: string; order?: Enum<'asc' \| 'desc'>; orderBy?: string; collapsed?: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |
891891

892892
### Nested Shape: `ObjectGridProps.aggregations[number]`
893893

@@ -1037,7 +1037,7 @@ View filter rule
10371037

10381038
| Property | Type | Required | Description |
10391039
| :--- | :--- | :--- | :--- |
1040-
| **fields** | `{ field: string; order?: Enum<'asc' \| 'desc'>; collapsed?: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |
1040+
| **fields** | `{ field: string; order?: Enum<'asc' \| 'desc'>; orderBy?: string; collapsed?: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |
10411041

10421042
### Nested Shape: `ObjectKanbanProps.conditionalFormatting[number]`
10431043

‎content/docs/references/ui/view.mdx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -613,14 +613,15 @@ Record grouping configuration — SERVER-SIDE: the set of groups and every numbe
613613

614614
| Property | Type | Required | Description |
615615
| :--- | :--- | :--- | :--- |
616-
| **fields** | `{ field: string; order?: Enum<'asc' \| 'desc'>; collapsed?: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |
616+
| **fields** | `{ field: string; order?: Enum<'asc' \| 'desc'>; orderBy?: string; collapsed?: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |
617617

618618
### Nested Shape: `GroupingConfig.fields[number]`
619619

620620
| Property | Type | Required | Description |
621621
| :--- | :--- | :--- | :--- |
622622
| **field** | `string` | ✅ | Field name to group by — one `groupBy` column of the group header query; the header row carries its raw stored value (null for the empty group). NO leading or trailing whitespace: the name is compiled into the aggregate query verbatim, so a padded spelling buckets every row into one empty group instead of grouping them. |
623-
| **order** | `Enum<'asc' \| 'desc'>` | optional (default: `"asc"`) | Group sort order — applied by the consumer over the header rows (the aggregate query carries no orderBy) |
623+
| **order** | `Enum<'asc' \| 'desc'>` | optional (default: `"asc"`) | Group sort order — applied by the consumer over the header rows (the aggregate query itself sorts nothing): by the group key, or by the `orderBy` value each header row carries when `orderBy` is set |
624+
| **orderBy** | `string` | optional | Field whose value orders this level's groups, when that is not the grouping field — a field of the same object; default: the grouping field itself, so the groups order by their own key. The group header query then carries, per group, the smallest value of this field for `order: asc` and the largest for `desc` (each group sits where its first row would if the rows were sorted by this field), and the consumer sorts the header rows by it; a group with no value here sorts last in both directions, and equal values fall back to the group key in the same direction. NO leading or trailing whitespace (compiled verbatim, as `field` is). A numeric, temporal or boolean field: min/max over a string orders by collation, which differs between backends, so the server refuses it |
624625
| **collapsed** | `boolean` | optional (default: `false`) | Collapse groups by default (presentation only) |
625626

626627

@@ -633,7 +634,8 @@ Record grouping configuration — SERVER-SIDE: the set of groups and every numbe
633634
| Property | Type | Required | Description |
634635
| :--- | :--- | :--- | :--- |
635636
| **field** | `string` | ✅ | Field name to group by — one `groupBy` column of the group header query; the header row carries its raw stored value (null for the empty group). NO leading or trailing whitespace: the name is compiled into the aggregate query verbatim, so a padded spelling buckets every row into one empty group instead of grouping them. |
636-
| **order** | `Enum<'asc' \| 'desc'>` | optional (default: `"asc"`) | Group sort order — applied by the consumer over the header rows (the aggregate query carries no orderBy) |
637+
| **order** | `Enum<'asc' \| 'desc'>` | optional (default: `"asc"`) | Group sort order — applied by the consumer over the header rows (the aggregate query itself sorts nothing): by the group key, or by the `orderBy` value each header row carries when `orderBy` is set |
638+
| **orderBy** | `string` | optional | Field whose value orders this level's groups, when that is not the grouping field — a field of the same object; default: the grouping field itself, so the groups order by their own key. The group header query then carries, per group, the smallest value of this field for `order: asc` and the largest for `desc` (each group sits where its first row would if the rows were sorted by this field), and the consumer sorts the header rows by it; a group with no value here sorts last in both directions, and equal values fall back to the group key in the same direction. NO leading or trailing whitespace (compiled verbatim, as `field` is). A numeric, temporal or boolean field: min/max over a string orders by collation, which differs between backends, so the server refuses it |
637639
| **collapsed** | `boolean` | optional (default: `false`) | Collapse groups by default (presentation only) |
638640

639641

@@ -1018,7 +1020,7 @@ View filter rule
10181020

10191021
| Property | Type | Required | Description |
10201022
| :--- | :--- | :--- | :--- |
1021-
| **fields** | `{ field: string; order?: Enum<'asc' \| 'desc'>; collapsed?: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |
1023+
| **fields** | `{ field: string; order?: Enum<'asc' \| 'desc'>; orderBy?: string; collapsed?: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |
10221024

10231025
### Nested Shape: `ListView.rowColor`
10241026

@@ -1395,7 +1397,7 @@ View filter rule
13951397

13961398
| Property | Type | Required | Description |
13971399
| :--- | :--- | :--- | :--- |
1398-
| **fields** | `{ field: string; order?: Enum<'asc' \| 'desc'>; collapsed?: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |
1400+
| **fields** | `{ field: string; order?: Enum<'asc' \| 'desc'>; orderBy?: string; collapsed?: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` |
13991401

14001402
### Nested Shape: `ObjectListView.rowColor`
14011403

‎eslint.config.mjs‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -912,6 +912,30 @@ const NEVER_LINTED = Object.freeze([
912912
'**/.turbo/**',
913913
]);
914914

915+
// ───────────────────────────────────────────────────────────────────────────
916+
// Source trees linted by THEIR OWN flat config, not by this one. Global-only:
917+
// unlike NEVER_LINTED this is authored source, so it is not spread into the
918+
// per-object `ignores` below — the one global object removes it from this
919+
// config's population, and nothing here may ever start matching it by accident
920+
// of a `files` glob.
921+
//
922+
// `ui/**` is the objectui tree imported at the cutover (decision G1 on #25752,
923+
// verbatim 「17e · 17f · 17g · 同意」): `ui/eslint.config.js` lints it, the
924+
// `Lint UI` CI job runs that config, and the two configs converge in the
925+
// aftercare card #25753. Census of what this one line stops, measured over the
926+
// merged tree on the verify branch (9e37acd126, `pnpm lint` exactly as
927+
// package.json spells it): 121 errors and 16,511 warnings, every one of them
928+
// under `ui/**` (dominant rule `react-hooks/static-components`, then
929+
// `react-hooks/purity`, `no-restricted-imports`,
930+
// `@typescript-eslint/no-empty-object-type`, `no-console`). Outside `ui/` the
931+
// population this config lints is byte-for-byte the pre-import one, so its
932+
// verdict there does not move. Delete the entry in the PR that adopts this
933+
// config across `ui/` — never by widening a per-object `ignores` instead.
934+
// ───────────────────────────────────────────────────────────────────────────
935+
const LINTED_BY_OWN_CONFIG = Object.freeze([
936+
'ui/**',
937+
]);
938+
915939
export default [
916940
// ───────────────────────────────────────────────────────────────────────────
917941
// GLOBAL ignore. This is the ONLY object in this array with no `files` key,
@@ -965,7 +989,7 @@ export default [
965989
// either of them becoming the next `.next`. See #12334 / #12304.
966990
// ───────────────────────────────────────────────────────────────────────────
967991
{
968-
ignores: [...NEVER_LINTED],
992+
ignores: [...NEVER_LINTED, ...LINTED_BY_OWN_CONFIG],
969993
},
970994
{
971995
files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'],

0 commit comments

Comments
 (0)