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
49 changes: 49 additions & 0 deletions .changeset/17499-groupbyfield-non-padded.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
"@objectstack/spec": minor
---

fix(spec)!: `groupByField` refuses a padded field name on kanban, gantt and timeline instead of handing the renderer a lookup that always misses (#17499)

**BREAKING** — an accept-set narrowing on three published authoring keys. `KanbanConfigSchema.groupByField` (**required**), `GanttConfigSchema.groupByField` and `TimelineConfigSchema.groupByField` were bare `z.string()`, so `' stage'` was valid authored metadata; all three are now refused at parse. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings, the same as the sibling axis in #17360. Stored metadata carrying a padded `groupByField` now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `ui-list-view-groupbyfield-padded-refused`.

## What was wrong

The padded name never failed anywhere. It failed to *group*.

These three keys name a field the consumer looks up on **every row, by that name**. Measured in objectui at `dda8f3815`: the kanban board resolves its lane as `laneField = groupByField || groupField || detectStatusField(objectDef)` and buckets cards by `card[laneField]`; `ObjectGantt`'s `groupByAccessor` splits the name on `.` and walks the backing record (`resolvePath(task.data, field)`); the timeline groups its rows the same way. The server answers under the unpadded name, so a padded spelling reads `undefined` on every row and the board collapses into one `Uncategorized` lane — the gantt and the timeline into one ungrouped bucket — holding every record.

That is a silent wrong answer that reads as a true statement about the data: a user looking at one giant lane cannot tell it apart from a dataset where the field genuinely is empty. Nothing weaker than a parse refusal is honest about it.

`packages/lint`'s `validate-list-view-field-refs` already calls this consequence out for `kanban.groupByField` (*"collapses every card into the uncolumned bucket"*), and grades that position `error` — but that rule only runs where an app is validated against its object definitions. The producer accepted the value regardless, which is the hole this closes.

## What it does now

Each of the three carries the **non-padded** pattern — no leading and no trailing whitespace — and the refusal is addressed to the offending key (`kanban.groupByField`, `gantt.groupByField`, `timeline.groupByField`), names the offending spelling verbatim so the whitespace an author cannot see in an editor is visible in the message, and carries the name to write instead.

⛔ **Not a `.trim()`.** A trimming schema makes `' stage'` and `'stage'` silently equivalent, which is the consumer-tolerance direction AGENTS.md #0.1 refuses: the padded spelling is a mistake the author should be told about, not a dialect the producer quietly normalises away. On the **required** kanban key this is sharper than on the sibling axis — an author cannot withdraw the value by omitting the key, so a normalising producer would be the author's only feedback channel and it would say nothing.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `kanban: { groupByField: ' stage' }` | `kanban: { groupByField: 'stage' }` |
| `gantt: { groupByField: 'owner ' }` | `gantt: { groupByField: 'owner' }` |
| `timeline: { groupByField: 'team\n' }` | `timeline: { groupByField: 'team' }` |

The remedy is always the same: write the field name exactly as the object declares it and the server answers under. If a board has been silently showing one `Uncategorized` lane, re-authoring the name is also the fix for that.

## Scope — what is deliberately NOT narrowed

- **The empty string is unchanged.** It still parses, exactly as before, on all three keys. This narrowing exists for the **silent** case; widening the pattern to catch `''` would be a second, undeclared narrowing riding on this one.
- **This is not the snake_case machine-name grammar.** `packages/spec` spells `/^[a-z_][a-z0-9_]*$/` inline for object, field and tool **names**, and these keys deliberately do not take it: a `groupByField` holds a field **reference**, and a dotted relationship path (`owner.name`) is an in-tree spelling of one — `packages/lint`'s `validate-list-view-field-refs.test.ts` carries `kanban: { groupByField: 'owner.name' }` in a case asserting no findings.
- **The sibling axis `grouping.fields[].field`** already landed this rule in #17360 / PR #17498; this change reuses that pattern rather than declaring a second one.

## Who is affected, measured

Every `groupByField` spelling in this repo parses unchanged. Harvested across every `.ts` / `.tsx` / `.mdx` / `.json` / `.mjs` outside `node_modules`: **14 distinct literals, zero of them padded** (`'warning'` / `'error'` are severity-map values in `packages/lint` and `'<select_or_status_field>'` is prose inside a completeness hint, so neither is an authored name). Nothing in the tree reddens, and no fixture had to be rewritten to keep it green.

Outside the repo, only metadata that was already grouping wrongly is affected: a padded `groupByField` has never produced a correct board, gantt or timeline on any renderer.

Clause-②: no (narrowing) — no key is added, removed or renamed, no exported symbol moves (`check:api-surface` clean with no regeneration), and no registry row is added. The accept set narrows back to what the key's description already claimed.

<!-- adr-0087: registered ui-list-view-groupbyfield-padded-refused -->
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ Sort field and direction pair
| **typeField** | `string` | optional | Field whose value maps to task/summary/milestone |
| **baselineStartField** | `string` | optional | Baseline (planned) start field |
| **baselineEndField** | `string` | optional | Baseline (planned) end field |
| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows) |
| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows). NO leading or trailing whitespace: the group accessor reads this name off every task verbatim, so a padded spelling drops every task into one ungrouped bucket. |
| **resourceView** | `boolean` | optional | Render a per-resource workload histogram instead of the timeline |
| **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) |
| **effortField** | `string` | optional | Per-task load units (resource view; default 1) |
Expand Down
18 changes: 9 additions & 9 deletions content/docs/references/ui/view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,7 @@ Gallery/card view configuration
| **typeField** | `string` | optional | Field whose value maps to task/summary/milestone |
| **baselineStartField** | `string` | optional | Baseline (planned) start field |
| **baselineEndField** | `string` | optional | Baseline (planned) end field |
| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows) |
| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows). NO leading or trailing whitespace: the group accessor reads this name off every task verbatim, so a padded spelling drops every task into one ungrouped bucket. |
| **resourceView** | `boolean` | optional | Render a per-resource workload histogram instead of the timeline |
| **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) |
| **effortField** | `string` | optional | Per-task load units (resource view; default 1) |
Expand Down Expand Up @@ -696,7 +696,7 @@ HTTP methods a view data source may request — the subset of `HttpMethod` witho

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select) |
| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select). NO leading or trailing whitespace: the board reads this name off every card verbatim, so a padded spelling collapses the whole board into one `Uncategorized` lane. |
| **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) |
| **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
| **columns** | `string[]` | ✅ | Fields to show on cards |
Expand Down Expand Up @@ -933,7 +933,7 @@ View filter rule

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select) |
| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select). NO leading or trailing whitespace: the board reads this name off every card verbatim, so a padded spelling collapses the whole board into one `Uncategorized` lane. |
| **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) |
| **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
| **columns** | `string[]` | ✅ | Fields to show on cards |
Expand Down Expand Up @@ -962,7 +962,7 @@ View filter rule
| **typeField** | `string` | optional | Field whose value maps to task/summary/milestone |
| **baselineStartField** | `string` | optional | Baseline (planned) start field |
| **baselineEndField** | `string` | optional | Baseline (planned) end field |
| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows) |
| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows). NO leading or trailing whitespace: the group accessor reads this name off every task verbatim, so a padded spelling drops every task into one ungrouped bucket. |
| **resourceView** | `boolean` | optional | Render a per-resource workload histogram instead of the timeline |
| **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) |
| **effortField** | `string` | optional | Per-task load units (resource view; default 1) |
Expand Down Expand Up @@ -999,7 +999,7 @@ View filter rule
| **startDateField** | `string` | ✅ | Field for timeline item start date |
| **endDateField** | `string` | optional | Field for timeline item end date |
| **titleField** | `string` | ✅ | Field to display as timeline item title |
| **groupByField** | `string` | optional | Field to group timeline rows |
| **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. |
| **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color |
| **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale |

Expand Down Expand Up @@ -1331,7 +1331,7 @@ View filter rule

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select) |
| **groupByField** | `string` | ✅ | Field to group columns by (usually status/select). NO leading or trailing whitespace: the board reads this name off every card verbatim, so a padded spelling collapses the whole board into one `Uncategorized` lane. |
| **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) |
| **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) |
| **columns** | `string[]` | ✅ | Fields to show on cards |
Expand Down Expand Up @@ -1360,7 +1360,7 @@ View filter rule
| **typeField** | `string` | optional | Field whose value maps to task/summary/milestone |
| **baselineStartField** | `string` | optional | Baseline (planned) start field |
| **baselineEndField** | `string` | optional | Baseline (planned) end field |
| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows) |
| **groupByField** | `string` | optional | Field to group leaf tasks by (synthesized summary rows). NO leading or trailing whitespace: the group accessor reads this name off every task verbatim, so a padded spelling drops every task into one ungrouped bucket. |
| **resourceView** | `boolean` | optional | Render a per-resource workload histogram instead of the timeline |
| **assigneeField** | `string` | optional | Resource field to bucket load by (resource view) |
| **effortField** | `string` | optional | Per-task load units (resource view; default 1) |
Expand Down Expand Up @@ -1397,7 +1397,7 @@ View filter rule
| **startDateField** | `string` | ✅ | Field for timeline item start date |
| **endDateField** | `string` | optional | Field for timeline item end date |
| **titleField** | `string` | ✅ | Field to display as timeline item title |
| **groupByField** | `string` | optional | Field to group timeline rows |
| **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. |
| **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color |
| **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale |

Expand Down Expand Up @@ -1649,7 +1649,7 @@ Timeline view configuration
| **startDateField** | `string` | ✅ | Field for timeline item start date |
| **endDateField** | `string` | optional | Field for timeline item end date |
| **titleField** | `string` | ✅ | Field to display as timeline item title |
| **groupByField** | `string` | optional | Field to group timeline rows |
| **groupByField** | `string` | optional | Field to group timeline rows. NO leading or trailing whitespace: the renderer reads this name off every row verbatim, so a padded spelling drops every row into one ungrouped band. |
| **colorField** | `string` | optional | Field to derive each item color from (it names a field, not a color): the option color declared on that field for the record value, else the value itself when it already is a color literal (hex, rgb() or hsl()), else the timeline default marker color |
| **scale** | `Enum<'hour' \| 'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional (default: `"week"`) | Default timeline scale |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// The sibling axis of `ui-list-view-grouping-field-padded-refused` (#17360),
// which scoped this one out by name. Same defect, same refusal, one difference
// that changes the author's options: `kanban.groupByField` is REQUIRED, so a
// padded value there cannot be withdrawn by omitting the key.
export const entry: SemanticMigration = {
id: 'ui-list-view-groupbyfield-padded-refused',
surface: 'list-view group-by field names — `kanban.groupByField` (`KanbanConfigSchema`, '
+ 'REQUIRED), `gantt.groupByField` and `timeline.groupByField` (`GanttConfigSchema` / '
+ '`TimelineConfigSchema`, both optional) — values carrying leading or trailing whitespace',
replacement: 'the field name written with no leading and no trailing whitespace — the same '
+ 'spelling the object declares and the server answers under. A padded value is RE-AUTHORED, '
+ 'never trimmed on the author\'s behalf: `\' stage\'` becomes `\'stage\'`. The refusal names '
+ 'the offending spelling verbatim, so the whitespace an author cannot see in an editor is '
+ 'visible in the message, next to the name to write instead.',
reason:
'#17499. All three keys were a bare `z.string()`, so a padded group-by name was valid '
+ 'authored metadata all the way to the renderers. The name is a LOOKUP KEY on every row, '
+ 'measured in objectui at `dda8f3815`: the kanban board resolves its lane as '
+ '`laneField = groupByField || groupField || detectStatusField(objectDef)` and buckets cards '
+ 'by `card[laneField]`; `ObjectGantt`\'s `groupByAccessor` splits the name on `.` and walks '
+ 'the backing record (`resolvePath(task.data, field)`); the timeline groups its rows the same '
+ 'way. The server answers under the unpadded name, so every per-row lookup reads `undefined` '
+ 'and the board collapses into one `Uncategorized` lane — the gantt and the timeline into one '
+ 'ungrouped bucket — holding every record. That is a silent wrong answer that reads as a true '
+ 'statement about the data: one giant bucket is indistinguishable from a dataset where the '
+ 'field genuinely is empty, which is why nothing weaker than a parse refusal is honest here. '
+ '`packages/lint`\'s `validate-list-view-field-refs` already grades this position `error` for '
+ 'the same consequence, but it only runs where an app is validated against its object '
+ 'definitions; the producer accepted the value regardless. ⛔ NOT a `.trim()`: a trimming '
+ 'schema makes `\' stage\'` and `\'stage\'` silently equivalent, the consumer-tolerance '
+ 'direction AGENTS.md #0.1 refuses — and on the REQUIRED kanban key the author cannot '
+ 'withdraw the value by omitting the key, so a normalising producer would be their only '
+ 'feedback channel and it would say nothing. The narrowing is non-padded ONLY and '
+ 'deliberately not the snake_case machine-name grammar `/^[a-z_][a-z0-9_]*$/` this package '
+ 'spells inline for object/field/tool NAMES: a `groupByField` is authored as a field '
+ 'REFERENCE and a dotted relationship path (`owner.name`) is an in-tree spelling of one. '
+ 'Ships at once, no deprecation window (2026-08-27 maintainer ruling 「短期不考虑渐进」).',
acceptanceCriteria:
'Measured against the shipped schemas, not restated from the card. Every stored view whose '
+ '`kanban.groupByField`, `gantt.groupByField` or `timeline.groupByField` carries leading or '
+ 'trailing whitespace is refused on its next authoring-path save, with a `custom` issue at '
+ 'that key\'s own path (`groupByField`, or `kanban.groupByField` when the view is parsed '
+ 'whole) naming the offending spelling verbatim and the trimmed name to write instead; a '
+ 'value that is nothing but whitespace is refused with the remedy "Name the field to group '
+ 'by" rather than a trimmed name, since there is none. Refused: leading, trailing and both; '
+ 'a tab, a newline and a non-breaking space in those positions; whitespace-only. NOT refused, '
+ 'on purpose: whitespace INSIDE the name (`\'Group by field\'` parses), and the EMPTY string '
+ '(unchanged on all three keys, this narrowing covers the silent case only). Nothing is '
+ 'normalised on the way through — an accepted name arrives byte-identical, `\'owner.name\'` '
+ 'included — so a consumer proves the migration by re-saving each view and seeing either a '
+ 'refusal naming the field or a value it can compare byte-for-byte with what it wrote. Every '
+ '`groupByField` spelling in the repo at the time of the change parses unchanged: 14 distinct '
+ 'literals harvested across every `.ts` / `.tsx` / `.mdx` / `.json` / `.mjs` outside '
+ '`node_modules`, zero of them padded, so no fixture had to be rewritten to keep the tree '
+ 'green.',
};
Loading
Loading