Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 206505c

Browse files
fix(plugin-timeline): group an explicit groupByField by its display value, one group per key (objectui#12104) (#12118)
Fixes #12104 Clause-②: no ## What changed An object timeline with a `groupByField` now draws one group per value, headed by the value's display name. Measured on HotCRM (objectstack-ai/hotcrm#2058, 17.7.0): the `owner_id` and `crm_account` timelines drew one "[object Object]" group, and `channel` drew DIGITAL, EMAIL, DIGITAL, and so on. The pins below reproduce both shapes red against `main`. - **The group key** (`effectiveItems` in `ObjectTimeline.tsx`): the option's label when the value names an option of the field (as before), else `recordDisplayValueAt(item, groupByField)`, the resolver the title and the description already use (objectui#10530). An empty display value groups under the existing `timeline.bucket.unassigned`. When no row has a display value, the feed keeps its date buckets, as it did when every raw value was empty. An array value (a multi-valued field) keeps the key it had on `main` (see Acceptance notes). - **The bucketing** (`renderer.tsx`): `groupAdjacent` became `bucketByGroup`, which draws one section per key. Sections come in the order of each key's first item, and items keep their input order inside a section. `ObjectTimeline` hands the items over sorted by start date, so a group sits where its earliest entry falls and its entries stay in date order. ## Where the bucketing lives (measured) The route left two options: order by key, then date, in `effectiveItems`, or change `groupAdjacent`. The composer route is not enough. The vertical and horizontal branches of `TimelineRenderer` draw the same item array, and the horizontal branch draws no groups. Ablation leg 3 below implements the composer route (key-first order in `effectiveItems`, adjacent merge kept in the renderer). Every vertical arm passes, and the horizontal control fails: it reads First, Third, Second, Fourth where the date order is First, Second, Third, Fourth. So the bucketing lives in the vertical branch, and the composer keeps the date order. The claim's file surface names `groupAdjacent` for this case. ## Group order across keys (mechanism assumption 4) The card leaves this open. This PR orders groups by **each key's earliest entry** (first occurrence in date order). It is the order the renderer already drew whenever a key's rows were contiguous, so a timeline that rendered correctly before renders byte-identically now (DOM-hash proof below). It also matches the sort's stated intent, upcoming work first. I considered option order for a select field and did not take it, because it would reorder timelines that render correctly today. The pins arrange their rows so that this order differs from both the alphabetical order and the input order. ## Lookup values (mechanism assumption 3) Measured with `recordDisplayValueAt` from the built `@object-ui/core`: - An expanded `{ id, name }` reads as `Alice`. This is the shape the timeline's own fetch produces through `buildExpandFields`, and the reach-path pin asserts that the query asked for `$expand` on `owner_id`. - An unexpanded id `u1` reads as `u1`, so the group header is the id. No name is invented for it (AGENTS.md #0.1). Pinned as a control; `main` renders it the same way. - An expanded record with no name-ish key (`{ id: 'u9' }`) reads as empty, so it groups under Unassigned. - Every array (`['a', 'b']`, `['a']`, `[]`, or an array of expanded records) reads as empty. That is why arrays keep their old key. ## Pins: `ObjectTimeline.groupKey-12104.test.tsx` | Arm | sources at `a21ff9a` | `c7a4c09` | |---|---|---| | lookup over the timeline's own expanded fetch: `Alice: First, Third` / `Bob: Second, Fourth` | red: `[object Object]: First, Second, Third, Fourth` | green | | text key interleaving in date order: `SMS: First, Third` / `EMAIL: Second, Fourth` | red: four runs | green | | empty values share one Unassigned group | red: two Unassigned runs | green | | a select key is its option's label, and its options are bucketed | red: four runs | green | | no row has a display value: the date buckets | red | green | | CONTROL: an unexpanded id groups under the id | green | green | | CONTROL: a multi-valued field keeps its key | green | green | | CONTROL: no `groupByField`: the date buckets | green | green | | CONTROL: the horizontal rail keeps its date order | green | green | Result: with `main`'s two sources, 5 failed and 4 passed. At `c7a4c09`, 9 passed. Ablations at `c7a4c09`. Each went through `ablation-replace.mjs`, which requires the anchor to hit and the blob hash to change, and proves the restore by blob equal to HEAD and an empty `git diff HEAD`: 1. Adjacent merging restored in the renderer: the 4 bucketing arms fail. 2. The key stringifies the raw value again: the lookup arm and the no-display-value arm fail. The first attempt at this head did nothing. The second commit had changed the anchor line, so `ablation-replace` refused with ANCHOR MISS and wrote nothing. It was rerun against the current line. 3. The composer route (above): only the horizontal control fails. 4. The multi-valued carve-out deleted: only the multi-valued control fails. ## Date buckets are unchanged, byte for byte I hashed the rendered canvas `innerHTML` (frozen clock, same rows) with `main`'s two sources and with this head's. Four shapes hash identically on both: the date buckets, contiguous text keys, contiguous multi-valued keys, and the horizontal rail (sha256 `92d0eed3`, `d5749e2a`, `b7c5fc0d`, `22cc4e45`). The scratch test was not committed. ## Clause-② holds The fix adds no export, no prop and no locale key. `bucketByGroup` is module-private, as `groupAdjacent` was. To check the package entry, I built plugin-timeline with `main`'s two sources and with this head's. The five emitted `.d.ts` files are byte-identical (`diff -r` is empty). The JS bundles differ (`80cdf112` vs `0fb277fe`), so the two builds really did compile different input. The diff touches nothing under `packages/types`, no translation pack and no spec row, and `timeline.bucket.unassigned` already existed. No accept set moves. ## Gates, head `c7a4c09` | Command | Exit | Verdict | |---|---|---| | `pnpm --filter '@object-ui/plugin-timeline^...' run build` (dependency closure; `test-support` has no build) | 0 | built 10 of 11 | | `pnpm --filter @object-ui/plugin-timeline type-check` | 0 | echoes `type-check`: `tsc --noEmit && tsc -p tsconfig.test.json`; `--listFiles` shows the test project compiles the new pin | | `pnpm exec vitest run packages/plugin-timeline/` | 0 | 46 files, 568 passed, 37 skipped (the zone cases, run by the driver below) | | the 5 tests outside the package that import `@object-ui/plugin-timeline`, plus `scripts/__tests__/date-only-zone-pins-10183.test.ts` | 0 | 6 files, 956 passed; the driver fails on any skipped zone case | | `pnpm --filter @object-ui/plugin-timeline exec eslint . --format json` | 0 | 52 files, 0 errors; warnings on the two edited sources equal `main`'s (25 and 14, same rules) | | `pnpm check:eager-closure` after `pnpm --filter @object-ui/console exec vite build` | 0 | 3170.2 KB across 290 of 2474 chunks, the same on both sides; `main` to head: same eager chunk set, raw +0 bytes, gzip -3 bytes, all in the `index` entry chunk, which names the lazy `plugin-timeline` chunk by its content hash; no timeline chunk is eager | | `pnpm check:new-line-citations` | 0 | 0 new citations | | `pnpm check:control-bytes` | 0 | OK | | `node scripts/check-changeset-presence.mjs` | 0 | 4 source files of 1 released package, 1 changeset | | `node scripts/check-changeset-no-major.mjs` | 0 | no major | | `node scripts/check-changeset-overwrite.mjs` | 0 | no pre-existing changeset modified | | `node scripts/check-changeset-fixed.mjs` | 0 | all in the fixed group | | `pnpm check:changeset-claims` | 0 | no pending changeset names a touched file | | `pnpm check:pending-changeset-literals` | 0 | no test names a pending changeset | | `pnpm check:test-path-roots` | 0 | OK | | `pnpm check:vi-mock-specifiers` / `vi-mock-inherit` / `vi-mock-override-shape` | 0 | OK | | `pnpm check:esm-specifiers` | 0 | no extensionless relative specifier (the load leg was not run) | | `pnpm check:self-import` / `check:phantom-deps` | 0 | OK | | `pnpm check:i18n-keys` | 0 | every call-site key resolves | | `pnpm check:unreferenced-sources` | 0 | OK, but it covers one package and plugin-timeline is not it, so it measured nothing here | Lint narrowing, proved in three parts. (1) The population is what ESLint's own config gives the package's `eslint .`, which is the command CI's `turbo run lint` runs; its JSON lists the four touched TS files. (2) The JSON reports 52 files. (3) Untouched files are unaffected: `eslint.config.js` has no type-aware setting (0 hits for `projectService`, `parserOptions`, `TypeChecked` or `tsconfigRootDir`, against 1 control hit for `tseslint.configs`), and no rule under `eslint-rules/` reads the filesystem or parser services (0 files, against 9 control files that define `create(`). So this diff cannot change the verdict on an untouched file. Left to CI: repo-wide `pnpm lint`, the `pnpm test` shards, `test:dist`, and the published-dist, readme-exports and node-esm-load gates. ## Acceptance notes - **Multi-valued group keys are out of this change.** An array keeps `main`'s key. A multiselect with two values heads its group with the raw values joined (`urgent,vip`), not the option labels. An empty array heads a group with an empty header. A lookup with `multiple` reads `[object Object],[object Object]`. Whether a multi-valued row groups once under the combined value or once per value is a metadata-semantics decision the card does not make. No producer of a multi-valued timeline `groupByField` was measured, so nothing is filed. - **A lookup whose referenced record has no name-ish key groups under Unassigned** (before: "[object Object]"). The resolver's embedded-record chain (name, full_name, display_name, label, title, subject, then the first and last name, then email) does not know the referenced object's declared name field. The title slot gives the same answer: it renders no line. Not reached at a public door here. HotCRM's `owner_id` and `crm_account` targets carry `name`. - **Docs**: the README and `plugin-timeline.mdx` describe `groupByField` as the field "whose value heads each group", which stays true. Neither states the group order. No doc sentence became false, so no doc file is in this diff. Written by session `session_01AswpQDLCKiZos2jCXknwKz` (`https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz`). --- _Generated by [Claude Code](https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 16c8810 commit 206505c

5 files changed

Lines changed: 373 additions & 30 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/plugin-timeline': patch
3+
---
4+
5+
An object timeline with a `groupByField` draws one group per value, headed by the value's display name (objectui#12104).
6+
7+
- **A lookup groups by the referenced record's name.** The timeline's own query expands every declared relation, so a lookup value arrives as the related record, and the group header used to read "[object Object]" for every row: a timeline grouped by an owner or an account drew one group. The group key now reads the field's display value through the same resolver the entry title and description use. A select field still groups by its option's label. An empty value groups under "Unassigned", and when no row has a display value the timeline keeps its date buckets. A lookup that arrives unexpanded groups under its id, which is what the title shows for it too.
8+
- **A key's rows are gathered into one group.** Entries are sorted by start date, and a group used to end wherever the next entry had another value, so a field whose values alternate in date order (a channel, say) drew the same header many times. Each value now heads exactly one group. Groups appear in the order of their earliest entry, and entries stay in date order inside each group, so a timeline whose values were already contiguous renders as before.
9+
10+
Unchanged: a timeline with no `groupByField` (its date buckets), the horizontal timeline, which draws no groups and keeps its date order, and the group key of a multi-valued field (a multiselect, tags, or a lookup with `multiple`), which reads as before. Nothing is added to the package entry: no export, prop or language-pack key.

‎packages/plugin-timeline/src/ObjectTimeline.tsx‎

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -727,8 +727,9 @@ export const ObjectTimeline: React.FC<ObjectTimelineProps> = ({
727727
// wrong hour. The shared step tells the two shapes apart by the value: a
728728
// date-only string is local midnight of the day it names, and a value
729729
// with a time part keeps its instant. The three must read alike: the
730-
// vertical renderer groups ADJACENT items, so a sort that disagreed with
731-
// the buckets would split a bucket or order it wrongly.
730+
// vertical renderer orders its groups by each group's earliest entry
731+
// (objectui#12104), so a sort that disagreed with the buckets would order
732+
// them, and the entries inside each, wrongly.
732733
//
733734
// A value the shared step refuses (unparsable, or a day its month does
734735
// not have, objectui#10026) sorts with the dateless items, as it buckets
@@ -748,8 +749,9 @@ export const ObjectTimeline: React.FC<ObjectTimelineProps> = ({
748749
});
749750

750751
// Decide on a final group label for each item:
751-
// - explicit groupBy → use the localized field-option label (or
752-
// "Unassigned" when null);
752+
// - explicit groupBy → the field's display value: the localized
753+
// field-option label, else the resolver's display string (or
754+
// "Unassigned" when it has none);
753755
// - otherwise → date bucket (Earlier / Today / Tomorrow / This week
754756
// / Next week / Later / No date) so the timeline doesn't render
755757
// as one undifferentiated stripe.
@@ -800,24 +802,44 @@ export const ObjectTimeline: React.FC<ObjectTimelineProps> = ({
800802
};
801803

802804
if (groupByField) {
803-
const allEmpty = mapped.every((m) => m.group == null || m.group === '');
804-
if (!allEmpty) {
805-
const groupSelectOptions = optionMap(groupByField);
806-
return mapped.map((m) => {
807-
const meta = resolveOptionMeta(groupByField, m.group, groupSelectOptions);
808-
return {
809-
...m,
810-
group: meta
811-
? meta.label
812-
: (m.group != null && m.group !== ''
813-
? String(m.group)
814-
: t('timeline.bucket.unassigned')),
815-
};
816-
});
805+
// The group key is the field's DISPLAY value (objectui#12104): the
806+
// localized label of the option the value names, else what
807+
// `recordDisplayValueAt` reads, the resolver the title and the
808+
// description above already use. This read used to stringify the raw
809+
// value, and the object fetch above expands every declared relation, so
810+
// a lookup arrived as `{ id, name }` and every row keyed on
811+
// "[object Object]": one group for the whole feed. An empty display
812+
// value is the resolver's own definition (null, a blank string, an
813+
// expanded record with no name) and groups under "Unassigned".
814+
//
815+
// ⛔ No name is invented for a lookup the resolver cannot name: an
816+
// unexpanded foreign key groups under the id itself, which is what the
817+
// title slot shows for it too (AGENTS.md #0.1).
818+
//
819+
// An ARRAY (a multi-valued field: `multiselect`, `tags`, a lookup with
820+
// `multiple`) keeps the key it had, its stringified value. The resolver
821+
// reads no display value off an array, so routing one through it would
822+
// move every such row under "Unassigned", and how a multi-valued row
823+
// should group (one combined group, or one group per value) is a
824+
// decision this change does not make.
825+
//
826+
// One group per key is drawn by the renderer's vertical branch, which
827+
// buckets by this key in the order of each key's earliest entry. The
828+
// order is NOT changed here: the horizontal branch draws the same array,
829+
// draws no groups, and must keep its date order.
830+
const groupSelectOptions = optionMap(groupByField);
831+
const groupKeys = mapped.map((m): string | undefined => {
832+
const opt = m.group != null && m.group !== '' ? groupSelectOptions[String(m.group)] : undefined;
833+
if (opt?.label) return fieldOptionLabel(objectName, groupByField, String(m.group), opt.label);
834+
if (Array.isArray(m.group)) return String(m.group);
835+
return recordDisplayValueAt(m._data, groupByField);
836+
});
837+
if (groupKeys.some((key) => key !== undefined)) {
838+
return mapped.map((m, i) => ({ ...m, group: groupKeys[i] ?? t('timeline.bucket.unassigned') }));
817839
}
818840
// Fall through to date bucketing — explicit groupBy field exists
819-
// but every record's value is empty, so a single empty lane would
820-
// be useless.
841+
// but no record has a display value for it, so a single
842+
// "Unassigned" lane would be useless.
821843
}
822844

823845
return mapped.map((m) => ({ ...m, group: dateBucket(m.startDate) }));

‎packages/plugin-timeline/src/__tests__/ObjectTimeline.dateOnlyZone-10866.test.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@
2727
* a date-only string is local midnight of the day it names, and a value with
2828
* a time part keeps its instant. The sort case holds one of each, because
2929
* only a mix can show the sort and the buckets disagreeing: the vertical
30-
* renderer groups ADJACENT items, so an order that disagrees with the buckets
31-
* reads as "Today" above "Earlier" (the past-day bucket, objectui#11676).
30+
* renderer orders its groups by each group's earliest entry (objectui#12104),
31+
* so an order that disagrees with the buckets reads as "Today" above
32+
* "Earlier" (the past-day bucket, objectui#11676).
3233
*
3334
* ── ⚠️ The zone cases run ONLY when driven, in a FORKS child ────────────────
3435
* `process.env.TZ` written inside a test of the normal run does not move the

0 commit comments

Comments
 (0)