Skip to content

Commit 94bab5a

Browse files
fix(ui): lay the stage path and kanban lanes out by the declared option kind (#26689)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbQFYwXjGdbCRo4TV4yeyKWa)_ Fixes #26612 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:阶段条(record:path)和看板现在按选项声明的生命周期 `kind`(open/won/lost)排布。字段一旦声明了 kind,阶段条不再按 `closed_won`/`closed_lost` 拼写猜,未声明 kind 的选项按 spec 读作 open;阶段按 kind 排:open 步骤在前,won 终点收尾,lost 单独放在右侧分组。看板从选项派生的列按 open → won → lost 排,lost 列默认折叠,只显示列名和卡片数,可点开。 - **为什么改**:lost 选项写在列表中间时,阶段条把它后面的步骤和 won 终点全部挤进 lost 分组;看板完全不读 kind,lost 列夹在进行中的列之间。分诊(6440222656)要求声明 kind 后不再保留按名字猜的回退。 - **风险与回滚**:低。只改两个渲染器,无新键、无公开导出变化。没声明 kind 的字段,阶段条照旧按拼写猜、照旧按位置排,只多一条开发构建下的 console 警告;看板对没声明 kind 的字段和作者显式写的 `columns` 完全不变。回滚即 revert 本 PR。 - **先看**:`ui/packages/plugin-detail/src/renderers/record-path.tsx` 的 `classify` / `placed` / `stageTerminals` 三段;`ui/packages/plugin-kanban/src/kanbanLaneLifecycle.ts`;两个新测试文件。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/plugin-detail/src/renderers/__tests__/record-path.declaredKindLayout-26612.test.tsx packages/plugin-kanban/src/ObjectKanban.laneLifecycle-26612.test.tsx`,预期 13 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes `SelectOption.kind` (`open | won | lost`, objectstack#23809) is published, and the spec reads an absent kind as `open`. On `origin/main` (`a76f811e`): - `record:path` read a declared kind (objectui#12907) but still classified every option WITHOUT one by its spelling, and laid stages out by position: everything from the first lost-class stage on went into the separated group. A lost option authored mid-list pulled the open and won options after it out of the forward path, and the won option there lost its terminus mark. - The kanban read no kind at all: lanes derived from the lane field's options ran in authored order, all expanded. - Nothing warned when the spelling fallback decided a stage. Now: **`record:path`** (`ui/packages/plugin-detail/src/renderers/record-path.tsx`) - Once the status field declares a kind on any option, `classify` reads only the stage's explicit `terminal` and the declared kind; an option without a kind reads as `open`. ⛔ No name-based fallback once a kind is declared (triage 6440222656). - With declared kinds the stages are laid out by kind, a stable partition: open steps in authored order, then the won option(s) as the forward terminus, then the lost option(s) in the separated group. Both rows (desktop and mobile) index the one laid-out array, as before. - Every declared won option is a terminus. The last-forward-stage restriction stays for the spelling guess only, where it guards `完成` mid-path. - A field that declares no kind keeps the spelling fallback and its positional layout unchanged. A dev build (`NODE_ENV !== 'production'`, the package's established guard spelling) warns once per object, field and guessed set, naming the object, the field and the guessed stage values, and saying to declare `kind`. Nothing warns when the fallback guessed nothing. **Kanban** (`ui/packages/plugin-kanban/src/ObjectKanban.tsx`, new module-internal `kanbanLaneLifecycle.ts`) - Lanes derived from the lane field's options go through `lanesFromOptions`: when any option declares a kind, they run open → won → lost (stable, authored order within each class) and every lost lane carries `collapsed: true`, so `KanbanImpl` draws it as a spine with its count and a disclosure the viewer can open (`KanbanColumn.collapsed`, honoured since objectui#9628). - The kind is read off the raw options by value, so the i18n hop cannot drop it. - Authored `columns`, data-derived lanes and fields with no declared kind are untouched; the board guesses nothing from spelling. No new key, prop or export: `lanesFromOptions` is not re-exported from the package entry, and the helper `declaredStageKinds` in `synth/buildDefaultPageSchema.ts` is reused unchanged. `ui/packages/app-shell` needed no change: the kanban reads the object definition through `useSettledSchema` and the path through the record context's `objectSchema`, both unnormalised, so `kind` reaches both readers. ## Tests - New `ui/packages/plugin-detail/src/renderers/__tests__/record-path.declaredKindLayout-26612.test.tsx` (7 tests): (a) a lost option authored mid-list sits apart with the open steps and won terminus forward, progress follows the laid-out order, and a lost record marks the lost stage current; (b) an option without a kind on a field that declares kinds reads as open though spelled `closed_lost`; (c) two declared won options are both termini; (d) the fallback warns once naming field and object, re-render does not repeat it, a declaring field never warns, and a fallback that guessed nothing does not warn. - New `ui/packages/plugin-kanban/src/ObjectKanban.laneLifecycle-26612.test.tsx` (6 tests): `lanesFromOptions` order and collapse, no-kind control; the rendered board's lane order; the lost lane collapsed with its count, opening on click; open and won lanes carry no disclosure; CONTROL authored `columns` keep their order and state. - The existing record-path suites (objectui#12907 declared-kind pins and the spelling-fallback controls included) pass unchanged. <details><summary>Ablations (one-off, not kept; each through scripts/ablation-replace.mjs, restored to the HEAD blob with git diff HEAD empty)</summary> The tests import the renderers from source (`../../index`, `./kanbanLaneLifecycle`), so no `dist/` sits between mutation and run. | # | Mutation | Result | |---|---|---| | A1 | `if (declaredKinds) return …` → only for options that declare one (the old per-option gate) | Tests 1 failed / 6 passed: (b) | | A2 | `placed = declaredKinds ? sort : classified` → never sort | 3 failed / 4 passed: (a) ×2, (c) | | A3 | every declared won → last-forward-stage only | 1 failed / 6 passed: (c) | | A4 | the warning effect always returns early | 1 failed / 6 passed: (d) first case. A first attempt appended to the anchor line and was refused by the tool as a no-op (anchor count 1 → 1); rerun with a distinct replacement. | | A5 | `lanesFromOptions` ignores kinds | 3 failed / 3 passed in the kanban file | </details> ## Acceptance notes - **A lost record's path.** The card asks that a record in a lost state show "the path stopped at its last open step". The record carries only its current value, not the open step it left, so this PR draws what is known: the lost stage current, with ✗, apart from the rail, and the forward steps untravelled (unchanged from main). Drawing the last open step needs a stage-history read; reported to the seat as an open question, not built here. - **Screenshots** of a HotCRM lead (未通过) and opportunity (Closed Lost) at 1280 px are not taken: HotCRM declares its kinds in its own card, and this container runs no HotCRM. The DOM pins above assert the same layout through `data-stage-terminal`, `data-stage-state` and the two desktop groups. - **Lane paging.** The paged-lane key (`pagedLaneIdsKey`) still lists the option values in authored order; it keys the per-lane fetch, not the layout, so the order there does not matter. - `#26581` (same seat) also edits `record-path.tsx` and rebases after this lands. ## Verification Readings at head `ba8cc73a` unless named. <details><summary>Touched packages: @object-ui/plugin-detail, @object-ui/plugin-kanban</summary> - `bash scripts/pm/os-verify-lock.sh -c 'cd ui && pnpm exec vitest run --maxWorkers=2 packages/plugin-detail/ packages/plugin-kanban/'` (after the `^build` closure): Test Files 394 passed, 1 skipped (395); Tests 3441 passed, 18 skipped (3459); VERDICT command-exit 0. - The two new files alone with every existing `record-path` suite: Test Files 12 passed (12), Tests 90 passed (90). - `pnpm exec turbo run type-check --filter=@object-ui/plugin-detail --filter=@object-ui/plugin-kanban --concurrency=2` (builds the `^build` closure, 49 tasks): plugin-kanban green; plugin-detail red on two TS7006 in the new test's spy reader, fixed in `ba8cc73a`, then `pnpm run type-check` in `ui/packages/plugin-detail` (`tsc --noEmit && tsc -p tsconfig.test.json`, tests covered): exit 0. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands from 6 changed paths (merge base `a76f811e`). Each ran with its exit code captured before any pipe; all 34 exit 0. The PR-body gates (`check-adr-0087-registration --base`, `check-changeset-no-major --base`, `check-closing-keyword-parity`) read this body through `GITHUB_EVENT_PATH`; `check:type-check-debt` and `pnpm --dir ui exec vitest run scripts/` ran under the lock, the latter with `--maxWorkers=2` added (Test Files 178 passed, 2 skipped; Tests 5065 passed, 2 skipped). Reconciliation: `--ran` answered "34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED (a DERIVED zero — all 34 recorded an exit code and none of them is 3)". Also `node ui/scripts/check-new-cross-file-line-citations.mjs`: 0 new citations, exit 0. `origin/main` moved 17 commits since the base, none under `ui/packages/plugin-detail`, `plugin-kanban`, `react` or `types`, and `git merge-tree` against it is clean. </details> Dogfood battery: not owed (no `!` in the title, no `(narrowing)` arm). --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSjdwziSeaPzNdLFi1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1685a06 commit 94bab5a

6 files changed

Lines changed: 555 additions & 20 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
'@object-ui/plugin-kanban': patch
4+
---
5+
6+
fix(ui): the stage path and the kanban lay a select's options out by their declared lifecycle `kind` (#26612)
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A select option may declare `kind: 'open' | 'won' | 'lost'` (`SelectOption.kind` in `@objectstack/spec`). Two readers now follow it instead of the option order or the value's spelling.
12+
13+
- **`record:path`.** Once the status field declares a kind on any option, an option without one reads as `open`, the spec's rule for an absent kind, and is never classified from a `closed_won` / `closed_lost` spelling. The stages are laid out by kind: the open options as the steps in their authored order, then every won option as the forward terminus, then the lost options apart from the rail. A lost option authored mid-list no longer pulls the options after it into the lost group. A field that declares no kind keeps the spelling fallback and its positional layout as before, and a dev build now warns once, naming the object, the field and the stages it guessed.
14+
- **Kanban.** Lanes the board derives from the lane field's options run open → won → lost when the field declares a kind, each class in its authored order, and every lost lane starts collapsed: a spine with its card count that the viewer can open. Authored `columns` and fields that declare no kind keep their order; the board guesses nothing from spelling.
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*
8+
* ══════════════════════════════════════════════════════════════════════════
9+
* Once a field declares an option `kind`, the path is laid out by kind and
10+
* nothing is guessed from spelling (objectstack#26612)
11+
* ══════════════════════════════════════════════════════════════════════════
12+
*
13+
* objectui#12907 taught `record:path` to read a declared `kind`, but it still
14+
* (1) filled any option WITHOUT a kind from the `closed_won` / `closed_lost`
15+
* spelling heuristic, though the spec reads an absent kind as `open`, and
16+
* (2) laid the stages out by POSITION: everything from the first lost option
17+
* on went into the separated group, so a lost option authored mid-list pulled
18+
* the open and won options after it out of the forward path.
19+
*
20+
* Rows:
21+
* (a) a lost option authored mid-list sits apart, and the open and won
22+
* options authored after it stay on the forward path, won last;
23+
* (b) an option without a kind on a field that declares kinds reads as
24+
* open, whatever its spelling;
25+
* (c) every declared won option is a terminus, not only the last one;
26+
* (d) the spelling fallback (no kind on any option) warns once in a dev
27+
* build, naming the field and the guessed stages; nothing warns once a
28+
* kind is declared, nor when the fallback guessed nothing.
29+
*
30+
* Resolution: `../../index` is this package's own source, and
31+
* `@object-ui/react` / `@object-ui/i18n` map to their `src` through the root
32+
* `vitest.config.mts` alias table, so no `dist/` sits between this file and
33+
* the renderer.
34+
*/
35+
36+
import * as React from 'react';
37+
import { describe, it, expect, afterEach, vi } from 'vitest';
38+
import '@testing-library/jest-dom';
39+
import { render, cleanup, within, type RenderResult } from '@testing-library/react';
40+
import type { SelectOptionKind } from '@objectstack/spec/data';
41+
import { I18nProvider } from '@object-ui/i18n';
42+
import { RecordContextProvider, SchemaRenderer } from '@object-ui/react';
43+
// Registers `record:path`, the block under test.
44+
import '../../index';
45+
46+
type Option = { value: string; label: string; kind?: SelectOptionKind };
47+
48+
const defOf = (name: string, options: Option[]) => ({
49+
name,
50+
label: name,
51+
fields: { status: { name: 'status', label: 'Status', type: 'select', options } },
52+
});
53+
54+
/** The spec's own lead order would be fine; this one authors the lost option mid-list. */
55+
const LEAD_LOST_MIDLIST = defOf('kind_lead', [
56+
{ value: 'new', label: 'New', kind: 'open' },
57+
{ value: 'unqualified', label: 'Unqualified', kind: 'lost' },
58+
{ value: 'qualified', label: 'Qualified', kind: 'open' },
59+
{ value: 'converted', label: 'Converted', kind: 'won' },
60+
]);
61+
62+
/** Kinds declared on two options; `closed_lost` declares none. */
63+
const PARTIAL = defOf('kind_partial', [
64+
{ value: 'prospecting', label: 'Prospecting', kind: 'open' },
65+
{ value: 'closed_lost', label: 'Closed Lost' },
66+
{ value: 'activated', label: 'Activated', kind: 'won' },
67+
]);
68+
69+
const TWO_WON = defOf('kind_two_won', [
70+
{ value: 'open_case', label: 'Open', kind: 'open' },
71+
{ value: 'resolved', label: 'Resolved', kind: 'won' },
72+
{ value: 'cancelled', label: 'Cancelled', kind: 'lost' },
73+
{ value: 'workaround', label: 'Workaround', kind: 'won' },
74+
]);
75+
76+
const SPELLED_UNDECLARED = defOf('spelled_deal', [
77+
{ value: 'prospecting', label: 'Prospecting' },
78+
{ value: 'closed_won', label: 'Closed Won' },
79+
{ value: 'closed_lost', label: 'Closed Lost' },
80+
]);
81+
82+
const PLAIN_UNDECLARED = defOf('plain_loan', [
83+
{ value: 'borrowed', label: 'Borrowed' },
84+
{ value: 'returned', label: 'Returned' },
85+
]);
86+
87+
const NODE = { type: 'record:path', properties: { statusField: 'status' } };
88+
89+
function mount(def: { name: string }, status: string): RenderResult {
90+
return render(
91+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
92+
<RecordContextProvider
93+
objectName={def.name}
94+
recordId="r1"
95+
data={{ id: 'r1', status }}
96+
objectSchema={def as never}
97+
>
98+
<SchemaRenderer schema={NODE as never} />
99+
</RecordContextProvider>
100+
</I18nProvider>,
101+
);
102+
}
103+
104+
/** Both rows (desktop and mobile), in DOM order. */
105+
function rowsOf(r: RenderResult): [HTMLElement, HTMLElement] {
106+
const text = r.container.textContent ?? '';
107+
expect(text).not.toContain('failed to render');
108+
expect(text).not.toContain('no stages configured');
109+
const rows = Array.from(r.container.querySelectorAll('[role="list"]')) as HTMLElement[];
110+
expect(rows).toHaveLength(2);
111+
return [rows[0], rows[1]];
112+
}
113+
114+
const stagesOf = (row: HTMLElement): HTMLElement[] => within(row).getAllByRole('listitem');
115+
const textOf = (stage: HTMLElement): string => (stage.textContent ?? '').replace(/[✓✗]/g, '').trim();
116+
const terminalsOf = (row: HTMLElement): Array<string | null> =>
117+
stagesOf(row).map((s) => s.getAttribute('data-stage-terminal'));
118+
119+
/** The desktop row's forward rail (the first stage's box) and separated group. */
120+
function desktopGroups(desktop: HTMLElement): { forward: string[]; separated: string[] } {
121+
const stages = stagesOf(desktop);
122+
const forwardBox = stages[0].parentElement;
123+
return {
124+
forward: stages.filter((s) => s.parentElement === forwardBox).map(textOf),
125+
separated: stages.filter((s) => s.parentElement !== forwardBox).map(textOf),
126+
};
127+
}
128+
129+
afterEach(() => {
130+
cleanup();
131+
vi.restoreAllMocks();
132+
});
133+
134+
describe('(a) a declared kind lays the path out, not the option order (objectstack#26612)', () => {
135+
it('a lost option authored mid-list sits apart; open steps, then the won terminus', () => {
136+
const [desktop, mobile] = rowsOf(mount(LEAD_LOST_MIDLIST, 'qualified'));
137+
expect(desktopGroups(desktop)).toEqual({
138+
forward: ['New', 'Qualified', 'Converted'],
139+
separated: ['Unqualified'],
140+
});
141+
expect(terminalsOf(desktop)).toEqual([null, null, 'won', 'lost']);
142+
// The mobile row reads the same laid-out array, in the same order.
143+
expect(stagesOf(mobile).map(textOf)).toEqual(['New', 'Qualified', 'Converted', 'Unqualified']);
144+
expect(terminalsOf(mobile)).toEqual([null, null, 'won', 'lost']);
145+
// Progress follows the laid-out order: New is travelled, Qualified current.
146+
expect(stagesOf(desktop).map((s) => s.getAttribute('data-stage-state'))).toEqual([
147+
'completed',
148+
'current',
149+
'upcoming',
150+
'upcoming',
151+
]);
152+
});
153+
154+
it('a record in the lost state marks the lost stage current, apart from the rail', () => {
155+
const [desktop, mobile] = rowsOf(mount(LEAD_LOST_MIDLIST, 'unqualified'));
156+
const lost = stagesOf(desktop)[3];
157+
expect(textOf(lost)).toBe('Unqualified');
158+
expect(lost).toHaveAttribute('data-stage-state', 'current');
159+
expect(lost).toHaveAttribute('data-stage-terminal', 'lost');
160+
expect(stagesOf(mobile)[3]).toHaveAttribute('data-stage-state', 'current');
161+
});
162+
});
163+
164+
describe('(b) once the field declares a kind, nothing is guessed from spelling (objectstack#26612)', () => {
165+
it('an option without a kind reads as open, though it is spelled closed_lost', () => {
166+
const [desktop, mobile] = rowsOf(mount(PARTIAL, 'prospecting'));
167+
expect(desktopGroups(desktop)).toEqual({
168+
forward: ['Prospecting', 'Closed Lost', 'Activated'],
169+
separated: [],
170+
});
171+
expect(terminalsOf(desktop)).toEqual([null, null, 'won']);
172+
expect(terminalsOf(mobile)).toEqual([null, null, 'won']);
173+
});
174+
});
175+
176+
describe('(c) every declared won option is a terminus (objectstack#26612)', () => {
177+
it('two won options both end the forward path, in their authored order', () => {
178+
const [desktop, mobile] = rowsOf(mount(TWO_WON, 'open_case'));
179+
expect(desktopGroups(desktop)).toEqual({
180+
forward: ['Open', 'Resolved', 'Workaround'],
181+
separated: ['Cancelled'],
182+
});
183+
expect(terminalsOf(desktop)).toEqual([null, 'won', 'won', 'lost']);
184+
expect(terminalsOf(mobile)).toEqual([null, 'won', 'won', 'lost']);
185+
});
186+
});
187+
188+
describe('(d) the spelling fallback warns in a dev build, naming the field (objectstack#26612)', () => {
189+
const pathWarnings = (spy: { mock: { calls: unknown[][] } }): string[] =>
190+
spy.mock.calls.map((c: unknown[]) => String(c[0])).filter((m: string) => m.startsWith('record:path'));
191+
192+
it('no kind on any option: the guess still lays the path out, and warns once', () => {
193+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
194+
const r = mount(SPELLED_UNDECLARED, 'prospecting');
195+
const [desktop] = rowsOf(r);
196+
// The fallback renders as before.
197+
expect(desktopGroups(desktop)).toEqual({ forward: ['Prospecting', 'Closed Won'], separated: ['Closed Lost'] });
198+
expect(terminalsOf(desktop)).toEqual([null, 'won', 'lost']);
199+
200+
const messages = pathWarnings(warn);
201+
expect(messages).toHaveLength(1);
202+
expect(messages[0]).toContain('field "status"');
203+
expect(messages[0]).toContain('object "spelled_deal"');
204+
expect(messages[0]).toContain('"closed_won", "closed_lost"');
205+
206+
// Re-rendering the same picklist does not repeat it.
207+
cleanup();
208+
rowsOf(mount(SPELLED_UNDECLARED, 'closed_won'));
209+
expect(pathWarnings(warn)).toHaveLength(1);
210+
});
211+
212+
it('a field that declares kinds never warns', () => {
213+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
214+
rowsOf(mount(PARTIAL, 'prospecting'));
215+
rowsOf(mount(LEAD_LOST_MIDLIST, 'new'));
216+
expect(pathWarnings(warn)).toEqual([]);
217+
});
218+
219+
it('no kind declared and nothing guessed: no warning', () => {
220+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
221+
const [desktop] = rowsOf(mount(PLAIN_UNDECLARED, 'borrowed'));
222+
expect(terminalsOf(desktop)).toEqual([null, null]);
223+
expect(pathWarnings(warn)).toEqual([]);
224+
});
225+
});

‎ui/packages/plugin-detail/src/renderers/record-path.tsx‎

Lines changed: 83 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,24 @@ const splitDesigner = (props: Record<string, any>) => {
6969
return { designer: { 'data-obj-id': id, 'data-obj-type': type, style }, rest };
7070
};
7171

72+
/**
73+
* Dev mode, in this repository's established spelling for it — the build's
74+
* `NODE_ENV`, read per call — the guard `record-related-list.tsx`,
75+
* `plugin-gantt` and `plugin-map` put on their authoring diagnostics. The
76+
* spelling-guess warning below is feedback for whoever wrote the picklist; a
77+
* production bundle does not pay for it.
78+
*/
79+
const isDevBuild = (): boolean =>
80+
(globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env
81+
?.NODE_ENV !== 'production';
82+
83+
/**
84+
* The (object, field, guessed stages) triples already warned about, so a path
85+
* re-rendering with the same picklist warns once, while a new guess on the
86+
* same field (an option added) is reported afresh.
87+
*/
88+
const warnedSpellingGuesses = new Set<string>();
89+
7290
/**
7391
* The label of a terminal stage on the desktop row: centred, and WRAPPING
7492
* rather than truncated or held on one line, so the stage that ends the path
@@ -243,28 +261,74 @@ export const RecordPathRenderer: React.FC<RecordPathRendererProps> = ({
243261
// — `open` included, which is how a declared open stage spelled like a
244262
// terminal stays a middle step: the spec's strict `stages[]` row can
245263
// carry `won` / `lost`, so step 1 cannot say "open";
246-
// 3. only for options that declare no kind, a spelling heuristic, so CRM
247-
// examples / Salesforce-style picklists ("closed_won", "closed_lost",
248-
// "失败", "流失") get the right treatment without a migration.
264+
// 3. only when the status field declares NO kind on any of its options, a
265+
// spelling heuristic, so CRM examples / Salesforce-style picklists
266+
// ("closed_won", "closed_lost", "失败", "流失") get the right treatment
267+
// without a migration — with a dev-build warning naming the field, so
268+
// the author learns the layout is a guess (objectstack#26612).
269+
//
270+
// Once the field declares a kind, an option that declares none reads as
271+
// `open` — the spec's own rule for an absent kind ("Absent reads as 'open'",
272+
// `SelectOption.kind`) — and is never guessed from its spelling: a declared
273+
// vocabulary with a guess filling its gaps would be two contracts at once.
249274
const LOST_TOKENS = /(^|[_-\s])(closed_)?(lost|failed?|cancell?ed|失败|流失|丢单|败)([_-\s]|$)/i;
250275
const WON_TOKENS = /(^|[_-\s])(closed_)?(won|success|成交|赢|完成)([_-\s]|$)/i;
251-
const classify = (s: { value: any; label?: string; terminal?: 'won' | 'lost' }): 'won' | 'lost' | undefined => {
252-
if (s.terminal) return s.terminal;
253-
const declared = declaredKinds?.get(s.value);
254-
if (declared !== undefined) return stageTerminalOfKind(declared);
276+
const guessTerminal = (s: { value: any; label?: string }): 'won' | 'lost' | undefined => {
255277
const probe = `${String(s.value ?? '')} ${String(s.label ?? '')}`;
256278
if (LOST_TOKENS.test(probe)) return 'lost';
257279
if (WON_TOKENS.test(probe)) return 'won';
258280
return undefined;
259281
};
260-
const stageKinds = stages.map(classify);
282+
const classify = (s: { value: any; label?: string; terminal?: 'won' | 'lost' }): 'won' | 'lost' | undefined => {
283+
if (s.terminal) return s.terminal;
284+
if (declaredKinds) return stageTerminalOfKind(declaredKinds.get(s.value));
285+
return guessTerminal(s);
286+
};
287+
288+
// The stages the spelling heuristic classified — what the dev-build warning
289+
// below names. Always empty once the field declares a kind.
290+
const guessedStages: string[] = declaredKinds
291+
? []
292+
: stages.filter((s) => !s.terminal && guessTerminal(s) !== undefined).map((s) => String(s.value));
293+
const guessWarningKey = guessedStages.length > 0
294+
? JSON.stringify([ctx?.objectName ?? null, statusField ?? null, guessedStages])
295+
: null;
296+
React.useEffect(() => {
297+
if (guessWarningKey === null || !isDevBuild() || warnedSpellingGuesses.has(guessWarningKey)) return;
298+
warnedSpellingGuesses.add(guessWarningKey);
299+
const [objectName, field, guessed] = JSON.parse(guessWarningKey) as [string | null, string | null, string[]];
300+
const subject = field
301+
? `field "${field}"${objectName ? ` of object "${objectName}"` : ''} declares no option \`kind\``
302+
: 'the authored stages name no field that declares an option `kind`';
303+
console.warn(
304+
`record:path — ${subject}, so stage(s) ${guessed.map((v) => `"${v}"`).join(', ')} ` +
305+
`were classified won/lost from their spelling. Declare \`kind: 'open' | 'won' | 'lost'\` ` +
306+
'on every option of the field to lay the path out by lifecycle instead of by name.',
307+
);
308+
}, [guessWarningKey]);
309+
310+
// WHERE each stage sits. A field that declares kinds is laid out BY kind:
311+
// its open options as the steps, in their authored order, then its won
312+
// option(s) as the forward terminus, then its lost option(s) in the
313+
// separated group — so a lost option authored mid-list never reads as step
314+
// N, and a won option authored after a lost one never lands in the lost
315+
// group (objectstack#26612). The sort is stable, so each class keeps its
316+
// authored order. Without declared kinds the layout stays POSITIONAL, as
317+
// the spelling fallback has always drawn it.
318+
const KIND_RANK = { open: 0, won: 1, lost: 2 } as const;
319+
const classified = stages.map((stage) => ({ stage, kind: classify(stage) }));
320+
const placed = declaredKinds
321+
? [...classified].sort((a, b) => KIND_RANK[a.kind ?? 'open'] - KIND_RANK[b.kind ?? 'open'])
322+
: classified;
323+
const pathStages: PathStage[] = placed.map((p) => p.stage);
324+
const stageKinds = placed.map((p) => p.kind);
261325
// Find the index of the FIRST lost-class stage so we can render it
262326
// (and any subsequent lost terminals) as a visually separated alt
263327
// group. Won-class stages stay inside the forward path — they're the
264328
// successful terminus.
265329
const firstLostIdx = stageKinds.findIndex((k) => k === 'lost');
266-
const forwardStages = firstLostIdx === -1 ? stages : stages.slice(0, firstLostIdx);
267-
const lostStages = firstLostIdx === -1 ? [] : stages.slice(firstLostIdx);
330+
const forwardStages = firstLostIdx === -1 ? pathStages : pathStages.slice(0, firstLostIdx);
331+
const lostStages = firstLostIdx === -1 ? [] : pathStages.slice(firstLostIdx);
268332
// ── ONE classification, computed once, read by BOTH rows (objectui#5998) ──
269333
//
270334
// The desktop and the mobile row below render the same `stages[]`, and each
@@ -293,16 +357,21 @@ export const RecordPathRenderer: React.FC<RecordPathRendererProps> = ({
293357
// Conservative on both axes: this can only ever STOP marking a stage as a
294358
// terminus, never start. No stage gains a `terminal` on either row that it
295359
// did not already carry on that row.
360+
//
361+
// The `idx === last` restriction guards the SPELLING guess (`完成` mid-path).
362+
// A declared `won` is no guess, and the by-kind layout above has already put
363+
// every won option at the forward tail, so each one is a terminus
364+
// (objectstack#26612).
296365
const lastForwardIdx = forwardStages.length - 1;
297366
const stageTerminals: Array<'won' | 'lost' | undefined> = stageKinds.map((kind, idx) =>
298-
kind === 'won' ? (idx === lastForwardIdx ? 'won' : undefined) : kind,
367+
kind === 'won' ? (declaredKinds || idx === lastForwardIdx ? 'won' : undefined) : kind,
299368
);
300369

301-
let currentIdx = stages.findIndex((s) => s.value === current);
370+
let currentIdx = pathStages.findIndex((s) => s.value === current);
302371
if (currentIdx < 0) currentIdx = -1;
303372
const currentInLost = firstLostIdx !== -1 && currentIdx >= firstLostIdx;
304373

305-
if (stages.length === 0) {
374+
if (pathStages.length === 0) {
306375
// Names the field when the derivation had an object definition to read
307376
// and found no picklist options on `statusField` (objectui#11106): the
308377
// author learns which field to fix, or that `stages` must be authored.
@@ -508,7 +577,7 @@ export const RecordPathRenderer: React.FC<RecordPathRendererProps> = ({
508577
className="flex sm:hidden w-full items-start gap-2 overflow-x-auto pb-1 -mx-1 px-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
509578
{...railAria}
510579
>
511-
{stages.map((stage, idx) => {
580+
{pathStages.map((stage, idx) => {
512581
const terminal = stageTerminals[idx];
513582
const isLost = terminal === 'lost';
514583
const isCompleted = !isLost && !currentInLost && currentIdx >= 0 && idx < currentIdx;

0 commit comments

Comments
 (0)