Skip to content

Commit 775a000

Browse files
fix(plugin-detail): empty related lists fold into one line; record:path terminal stages share the rail (#26414)
Fixes #25895 Clause-②: no Metadata-protocol: none ## 维护者速读 改了什么:记录页「相关」页签里没有记录的相关列表不再各占一整行,而是在有记录的列表之后合成一行,列出它们的名字,每个保留「新建」;阶段条(record:path)上的终止阶段(已取消 / 客户未到、成交 / 失败)与其它阶段等宽分配,标签换行而不是挤成一格。 为什么改:HotCRM 客户页「客户 0 · 暂无相关记录」夹在有数据的列表之间;活动页的「× 已取消 客户未到」挤在右端一个窄格里(#25895 两张截图)。 风险与回滚:只改显示,不加不删任何元数据键;全空的栈、单个列表、带「添加」选择器或工具栏动作的列表保持原样;有新记录时折叠的列表自动回到原位。回滚 = revert 本 PR。 先看:`ui/packages/plugin-detail/src/RelatedListEmptyFold.tsx`(规则与折叠行)→ `renderers/record-related-list.tsx`(接入)→ `renderers/record-path.tsx`(等分宽度)。 两分钟验证:`cd ui && pnpm exec vitest run packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyFold-25895.test.tsx packages/plugin-detail/src/renderers/__tests__/record-path.terminalWidth-25895.test.tsx`,预期 12 个测试全过。 需要你做的:无,绿了即可落地;新文案的语言包键是后续工作(见下文)。 ## What **Item 1 — empty related lists fold into one line.** On a record's Related tab, the lists with zero records leave their place once every list of the stack has answered, and the last list of the stack draws one line after itself: the empty-state text, then each folded list's title with its own **New** (same handler, same `createDisabled` verdict). A folded list stays mounted (hidden), so when it gains a record it answers "not empty" and returns to its place. Unchanged by design: a stack whose lists are all empty, a list on its own, and an empty list that offers more than New (an `add` picker, child toolbar actions, an authoring notice) draw exactly as before. **Item 2 — terminal stages get legible width.** On the desktop rail both stage groups now start from zero and grow by the number of stages they hold, and every stage fills its share, so a terminal stage is exactly as wide as any other. A terminal stage's label (the won terminus and the separated lost group) wraps instead of being truncated or held on one line. Ordinary forward stages keep their one truncated line; the mobile row (horizontal scroll) is unchanged. ## Which seam, and why (item 1) Who stacks the lists: `buildDefaultTabs` (`synth/buildDefaultPageSchema.ts`) only emits the Related tab's `record:related_list` children; `page:tabs` (`PageTabsRenderer` in `@object-ui/components`) draws them as plain siblings through `renderChildren`; `RecordDetailView` only hands `related` to the synthesizer. None of the three knows how many rows a list has — each list learns that from its own read — and an authored page can stack lists in any container. So the seam is the block itself: `record:related_list` lists that share a DOM container coordinate through a small store keyed by that container (`ui/packages/plugin-detail/src/RelatedListEmptyFold.tsx`). `RelatedList` gains one optional callback, `onEmptyChange(empty)`, reporting its settled answer (`null` while a read is in flight); the block keeps the last answer during a refetch so a write elsewhere does not unfold and refold the stack. This reaches synthesized and authored pages alike, adds no node type and no spec key, and leaves the synthesizer, app-shell and components untouched. `RelatedList.tsx` and the new `RelatedListEmptyFold.tsx` are outside the claim's listed file surface (one optional prop plus one effect, and the new module); both sit in the same package beside the claimed files. ## Strings and locale packs No new translation key is introduced. The fold line reuses `detail.noRelatedRecords` and `detail.new`, both already in every pack; each folded entry is a `role="group"` named by the list's title, so each **New** is announced inside its list's name. A dedicated phrase such as 「其他 2 个为空:客户、线索」 needs a new key in the locale packs, which this PR does not touch (that line is busy); it is a follow-up once the i18n line drains. Note for that follow-up: `check:i18n-keys` refuses a `t()` key absent from the `en` pack and its baseline is empty, so an inline `defaultValue` alone would not pass CI — the key has to land in the packs. ## Tests - `ui/packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyFold-25895.test.tsx` (6 tests) and `ui/packages/plugin-detail/src/renderers/__tests__/record-path.terminalWidth-25895.test.tsx` (6 tests), committed red first (`aba3b5ca`). - Red leg, implementation files restored to the base `f02e6443` in the tree (markers `onEmptyChange` / `shareOf` counted 0): 9 failed / 3 passed — the three that pass are CONTROL legs that hold before and after (all-empty stack, single list, ordinary forward stage). Restore proven by blob hashes equal to HEAD. Green leg: 12/12. - `pnpm exec vitest run packages/plugin-detail --maxWorkers=2` (from `ui/`, at `5cf6e03e`): 306 files passed, 1 failed — `spec-symbol-batch7.test.ts` names every props type in `renderers/` that is not spelled `…RendererProps`; the fold module was moved out of `renderers/` to `src/` (beside `EmptySectionsFold.tsx`) in `47121545`, and the post-merge run below covers that file. - Consumers of the changed rendering, run as a targeted set (not the full app-shell suite): 34 files that mention an empty related list or `record:related_list` under `app-shell`, `apps/console` and `components`, plus the `RecordDetailView` related-list files: 34/34 files, 293/293 tests. - `pnpm --filter @object-ui/plugin-detail type-check`: exit 0. - `sdui.manifest.json`: regenerated under the lock before the merge, byte-unchanged (106 components, sha256 `1f6e1ccb925a…`); no record is committed (the sources records were retired on `main`). `node scripts/check-sdui-manifest.mjs`: exit 0. After merging `origin/main` (merge `8271adf9`, which retired `scripts/sdui-manifest.sources/`; no plugin-detail or lockfile overlap), at head `8271adf9`: - `pnpm --filter @objectstack/spec build` (main moved spec), then `spec-symbol-batch7.test.ts` plus every `record-path*`, `record-related-list*`, `recordComponentAria*` and `RelatedList*` test in plugin-detail: 59/59 files, 396/396 tests. `pnpm --filter @object-ui/plugin-detail type-check`: exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 35 derived families, all run, each exit 0 (`check-sdui-manifest.mjs` among them); `--ran` reconciled 35/35 with exit codes recorded, 0 NOT-MEASURED. ## Acceptance notes - A related list whose read FAILS shows today's empty state ("No related records found") because `RelatedList` only logs the error; with this change such a list is also folded as empty. That is unchanged behaviour of the read path, noted, not filed here. - The stack is the DOM container: lists an authored page wraps in separate containers never fold together, by design. - The terminal-stage share is equal up to the alt group's divider gap and padding. --- _Generated by [Claude Code](https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f16f16 commit 775a000

7 files changed

Lines changed: 680 additions & 11 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
---
4+
5+
fix(plugin-detail): empty related lists fold into one line after the lists with records; `record:path` terminal stages share the rail's width
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **Related tab.** Stacked `record:related_list` blocks whose lists have no records no longer take a full row each wherever they were declared. Once every list of the stack has answered, the empty ones fold into one line after the last list, naming each one and keeping its **New** action. A list that gains a record returns to its place. A stack whose lists are all empty, a list on its own, and an empty list that offers more than New (an `add` picker, child toolbar actions) draw exactly as before.
11+
- **Path bar.** On the desktop rail every stage now takes an equal share of the width, terminal stages included, and a terminal stage's label wraps instead of being cut. Before, the separated lost group was only as wide as its words, so a short path drew its terminal stages in one narrow cell at the right edge. The mobile row is unchanged.
12+
- **`RelatedList`** takes an optional `onEmptyChange(empty)` callback, told whether the list is empty once its read answers (`null` while a read is in flight). Nothing else about the component changes.
13+
- No key is added to, removed from or reinterpreted on any block. Nothing to do.

‎ui/packages/plugin-detail/src/RelatedList.tsx‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,14 @@ export interface RelatedListProps {
275275
parentId?: string | number;
276276
/** Lucide icon name (kebab-case) to render next to the section title. */
277277
icon?: string;
278+
/**
279+
* Told whether this list is EMPTY once its read has answered: `true` for no
280+
* records, `false` for records (or a refused filter, which draws its own
281+
* state), `null` while a read is in flight. `record:related_list` reads it
282+
* to fold the empty lists of a stack into one line (#25895); the list
283+
* itself draws exactly the same either way.
284+
*/
285+
onEmptyChange?: (empty: boolean | null) => void;
278286
}
279287

280288
/**
@@ -535,6 +543,7 @@ export const RelatedList: React.FC<RelatedListProps> = ({
535543
referenceField,
536544
parentId,
537545
icon,
546+
onEmptyChange,
538547
}) => {
539548
// Distinguish "caller did not provide data" (auto-fetch) from
540549
// "caller passed an empty array" (no related records — do not fetch).
@@ -2274,6 +2283,12 @@ export const RelatedList: React.FC<RelatedListProps> = ({
22742283
// In windowed mode an empty page beyond page 0 is a transient overflow (the
22752284
// step-back effect above corrects it), not an empty collection.
22762285
const isEmpty = !loading && relatedData.length === 0 && (!windowed || currentPage === 0);
2286+
// The same answer, for a caller that arranges several lists (#25895). Before
2287+
// the early return below, so the hook order never changes.
2288+
const emptyAnswer: boolean | null = filterRefusal ? false : loading ? null : isEmpty;
2289+
React.useEffect(() => {
2290+
onEmptyChange?.(emptyAnswer);
2291+
}, [emptyAnswer, onEmptyChange]);
22772292
// When the consumer explicitly enables `filterable`, always render the
22782293
// filter input — they're opting in. (data-table's own auto-search is
22792294
// suppressed via the viewSchema below to avoid a duplicate input.)
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
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+
* The empty-list fold of a related-list STACK (#25895).
9+
*
10+
* A record's Related tab stacks one `record:related_list` per relationship, and
11+
* an empty one used to keep a full row of its own wherever it was declared
12+
* (「客户 0 · 暂无相关记录 · + 新建」 between two lists with records). Here the
13+
* lists of one stack fold their empty members into ONE line, drawn after the
14+
* last list of the stack, naming each folded list and keeping its New action.
15+
*
16+
* ## Who the stack is
17+
*
18+
* The lists that share a container: the DOM parent of each block's root. That
19+
* is the seam because the container is not this package's: on the record page
20+
* `page:tabs` draws a tab's children as plain siblings, and an authored page
21+
* may draw them from any container. Neither the container nor the page
22+
* synthesizer knows how many rows a list has; each list does, once its read
23+
* answers. So the lists coordinate among themselves, through the small store
24+
* below, keyed by that container. A list placed alone, or wrapped in its own
25+
* container, is a stack of one and never folds.
26+
*
27+
* ## The rule (`decideEmptyFold`)
28+
*
29+
* - Nothing folds until EVERY list of the stack has answered, so a stack does
30+
* not fold in steps while reads land one at a time.
31+
* - Nothing folds unless at least one list has records: a stack whose lists
32+
* are all empty draws every list as it always has.
33+
* - A list folds when it is empty and offers nothing beyond New. A list with
34+
* an `add` picker, child toolbar actions or an authoring notice keeps its
35+
* own row, because the line carries New alone.
36+
* - The LAST list of the stack, in document order, draws the line, after its
37+
* own card (or in place of it, when it is folded too).
38+
*
39+
* A folded list stays MOUNTED, hidden, so it keeps listening for writes: when
40+
* it gains a record it answers "not empty" and returns to its place. A refetch
41+
* keeps the list's last answer while it is in flight (the block's `reportListEmpty`), so a
42+
* write elsewhere does not unfold and refold the whole stack around it.
43+
*/
44+
45+
import * as React from 'react';
46+
import { Plus } from 'lucide-react';
47+
import { Button, cn } from '@object-ui/components';
48+
import { useDetailTranslation } from './useDetailTranslation';
49+
50+
/** What one list of a stack tells the others. */
51+
export interface EmptyFoldMemberState {
52+
/** The list's resolved title, as its card shows it. */
53+
title: string;
54+
/** `true` empty, `false` has records, `null` not answered yet. */
55+
empty: boolean | null;
56+
/** The list offers nothing the fold line cannot carry (New alone, or nothing). */
57+
foldable: boolean;
58+
/** The list offers New. */
59+
hasNew: boolean;
60+
/** Its New is drawn disabled (the host's `createDisabled`). */
61+
newDisabled: boolean;
62+
}
63+
64+
interface FoldStackEntry extends EmptyFoldMemberState {
65+
id: string;
66+
el: Element;
67+
/** The list's current New handler, read at click time. */
68+
onNew: { current: (() => void) | undefined };
69+
}
70+
71+
/** The verdict for one stack: which lists fold, and which list draws the line. */
72+
interface EmptyFoldVerdict {
73+
folded: string[];
74+
host: string | null;
75+
}
76+
77+
const NO_FOLD: EmptyFoldVerdict = { folded: [], host: null };
78+
79+
/** The fold rule, over the stack's lists in document order; the file header states it in words. */
80+
function decideEmptyFold(
81+
ordered: ReadonlyArray<Pick<EmptyFoldMemberState, 'empty' | 'foldable'> & { id: string }>,
82+
): EmptyFoldVerdict {
83+
if (ordered.length < 2) return NO_FOLD;
84+
if (ordered.some((m) => m.empty === null)) return NO_FOLD;
85+
if (!ordered.some((m) => m.empty === false)) return NO_FOLD;
86+
const folded = ordered.filter((m) => m.empty === true && m.foldable).map((m) => m.id);
87+
if (folded.length === 0) return NO_FOLD;
88+
return { folded, host: ordered[ordered.length - 1].id };
89+
}
90+
91+
class Stack {
92+
readonly members = new Map<string, FoldStackEntry>();
93+
private readonly listeners = new Set<() => void>();
94+
private version = 0;
95+
subscribe = (listener: () => void): (() => void) => {
96+
this.listeners.add(listener);
97+
return () => {
98+
this.listeners.delete(listener);
99+
};
100+
};
101+
getVersion = (): number => this.version;
102+
emit(): void {
103+
this.version += 1;
104+
for (const listener of [...this.listeners]) listener();
105+
}
106+
/** The members in document order. */
107+
ordered(): FoldStackEntry[] {
108+
return [...this.members.values()].sort((a, b) =>
109+
a.el === b.el ? 0 : a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1,
110+
);
111+
}
112+
}
113+
114+
/** One stack per container element; it goes with the element. */
115+
const stacks = new WeakMap<Element, Stack>();
116+
117+
const stackOf = (container: Element): Stack => {
118+
let stack = stacks.get(container);
119+
if (!stack) {
120+
stack = new Stack();
121+
stacks.set(container, stack);
122+
}
123+
return stack;
124+
};
125+
126+
const noSubscribe = (): (() => void) => () => {};
127+
const noVersion = (): number => 0;
128+
129+
export interface RelatedListStackSlotProps extends EmptyFoldMemberState {
130+
/** The list's New handler; absent when the list offers no New. */
131+
onNew?: () => void;
132+
/** The block's root attributes (class, designer keys, `aria`). */
133+
className?: string;
134+
rootProps?: Record<string, unknown>;
135+
/** The block's content: its notices and its `RelatedList`. */
136+
children: React.ReactNode;
137+
}
138+
139+
/**
140+
* The root of one `record:related_list` block, as a member of its stack. It
141+
* draws the block's content (hidden while folded) and, on the stack's last
142+
* list, the fold line.
143+
*/
144+
export const RelatedListStackSlot: React.FC<RelatedListStackSlotProps> = ({
145+
title,
146+
empty,
147+
foldable,
148+
hasNew,
149+
newDisabled,
150+
onNew,
151+
className,
152+
rootProps,
153+
children,
154+
}) => {
155+
const id = React.useId();
156+
const [root, setRoot] = React.useState<HTMLDivElement | null>(null);
157+
158+
const onNewRef = React.useRef(onNew);
159+
React.useLayoutEffect(() => {
160+
onNewRef.current = onNew;
161+
});
162+
163+
const container = root?.parentElement ?? null;
164+
const stack = container ? stackOf(container) : null;
165+
166+
React.useLayoutEffect(() => {
167+
if (!stack || !root) return undefined;
168+
stack.members.set(id, {
169+
id,
170+
el: root,
171+
onNew: onNewRef,
172+
title: '',
173+
empty: null,
174+
foldable: false,
175+
hasNew: false,
176+
newDisabled: false,
177+
});
178+
stack.emit();
179+
return () => {
180+
stack.members.delete(id);
181+
stack.emit();
182+
};
183+
}, [stack, root, id]);
184+
185+
React.useLayoutEffect(() => {
186+
const member = stack?.members.get(id);
187+
if (!stack || !member) return;
188+
if (
189+
member.title === title &&
190+
member.empty === empty &&
191+
member.foldable === foldable &&
192+
member.hasNew === hasNew &&
193+
member.newDisabled === newDisabled
194+
) {
195+
return;
196+
}
197+
Object.assign(member, { title, empty, foldable, hasNew, newDisabled });
198+
stack.emit();
199+
}, [stack, id, title, empty, foldable, hasNew, newDisabled]);
200+
201+
// Re-render whenever any list of the stack changes.
202+
React.useSyncExternalStore(stack ? stack.subscribe : noSubscribe, stack ? stack.getVersion : noVersion, noVersion);
203+
204+
const ordered = stack ? stack.ordered() : [];
205+
const verdict = decideEmptyFold(ordered);
206+
const folded = verdict.folded.includes(id);
207+
const line =
208+
verdict.host === id ? ordered.filter((m) => verdict.folded.includes(m.id)) : null;
209+
210+
return (
211+
<div ref={setRoot} className={className} {...rootProps}>
212+
<div hidden={folded || undefined}>{children}</div>
213+
{line && line.length > 0 && <EmptyFoldLine entries={line} standalone={folded} />}
214+
</div>
215+
);
216+
};
217+
218+
/** The one line naming a stack's folded lists, each with its New action. */
219+
const EmptyFoldLine: React.FC<{ entries: FoldStackEntry[]; standalone: boolean }> = ({ entries, standalone }) => {
220+
const { t } = useDetailTranslation();
221+
return (
222+
<div
223+
data-testid="record-related-list-empty-fold"
224+
className={cn(
225+
'flex flex-wrap items-center gap-x-4 gap-y-1 rounded-lg border border-border/60 bg-muted/10 px-4 py-2',
226+
!standalone && 'mt-3',
227+
)}
228+
>
229+
<span className="text-xs italic text-muted-foreground/70">{t('detail.noRelatedRecords')}</span>
230+
{entries.map((entry) => (
231+
<div key={entry.id} role="group" aria-label={entry.title} className="inline-flex items-center gap-1">
232+
<span className="text-sm font-medium text-muted-foreground">{entry.title}</span>
233+
{entry.hasNew && (
234+
<Button
235+
type="button"
236+
variant="ghost"
237+
size="sm"
238+
disabled={entry.newDisabled}
239+
data-testid="related-list-empty-fold-new"
240+
onClick={() => entry.onNew.current?.()}
241+
className="gap-1 h-9 sm:h-7 px-2 text-xs shadow-none"
242+
>
243+
<Plus className="h-3.5 w-3.5" aria-hidden />
244+
{t('detail.new')}
245+
</Button>
246+
)}
247+
</div>
248+
))}
249+
</div>
250+
);
251+
};

0 commit comments

Comments
 (0)