Skip to content

Commit 829fcf2

Browse files
objectstack-fleet[bot]hotlongclaude
authored
feat(app-shell,i18n): Studio orders an object's related lists, and a field group can place a related table (#27303)
Fixes #27054 Clause-②: no Metadata-protocol: none Round 2 of the card, its fifth and last item: Studio authors `relatedLists` (the key #27069 shipped in PR #27215) as a drag-to-order list of the object's inbound relationships, and a field group can place a related table (`fieldGroups[].relatedList`). The other four items landed in PR #27163. ## 维护者速读 - **改了什么**:Studio 对象「设置」页新增「关联列表顺序(relatedLists)」:列出所有指向本对象的查找/主从关系,可拖拽或用上下按钮排序,未列出的关系以灰色「按默认顺序」尾段显示且不可拖;下方预览记录页的最终顺序(正文表格 / 侧栏卡片)。表单布局设计器的分组属性新增「在此放置关联表」选择器,写 `fieldGroups[].relatedList`;放置了关联表的分组在设计器里显示为该表,不再接受拖入字段。 - **为什么改**:#27069 落地了 `relatedLists` 与分组 `relatedList` 两个键,但 Studio 没有控件,作者只能手写,而且手写的错名会被 `defineStack` 拒绝。 - **风险与回滚**:只动 Studio(app-shell)与 studio 文案包;不改 spec、不碰记录页渲染。`deriveRelatedLists` 只加了一个记录页从不传的可选项 `includeHidden`,以及把条目匹配抽成一个导出函数(行为不变,#27069 的顺序 pin 照绿)。回滚 = revert 本 PR。 - **先看**:`ui/packages/app-shell/src/views/studio-design/RelatedListsEditor.tsx`(写入前校验 `write`)、`relatedListVocabulary.ts`(词表 = `deriveRelatedLists` 加 `includeHidden`,不另写关系枚举)、`ObjectGroupInspector.tsx` 的 `RelatedTablePlacement`。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/ObjectSettingsPanel.relatedListsOrder-27054.test.tsx`,预期 5 个用例全绿;截图见下方证据分支。 - **需要你做的**:无,席位落地。 ## What changes 1. **Object settings, `relatedLists`** (`RelatedListsEditor`, mounted in the Semantic roles card under highlight fields). - The vocabulary is the record page's own derivation: `deriveRelatedLists` asked with a new `includeHidden` option, so the editor offers exactly what the record page reads, plus the FKs a `relatedList: false` hides (an entry may still name those: `defineStack` and `packages/lint` `validate-related-list-refs` count them, and all three skip the audit FKs). No second relationship enumeration exists. - Rows are the stored entries in order: drag one, or use its up / down buttons (the keyboard path). Removing the last one removes the key. - Every relationship no entry names trails, greyed, with `aria-disabled` and no drag, in the derived order (owned `master_detail` children, then lookups). The add picker offers exactly that tail. Each item is spelled the child name, or `child.field` when that child points here through more than one FK. - Order only, never visibility: a hidden FK is listed and badged `hidden`, and the switch stays on the field. A primary list is badged `in body`. - **Pre-write check.** Every write runs through `acceptRelatedListEntries`. It keeps, in order and once, each entry that names at least one inbound relationship. A stored entry that names none keeps its row, shown in red with a sentence saying the next change leaves it out. The add picker goes through the same check. - Nothing is written while the object list is still loading (`getTypeStatus('object')` is not `ready`). At that point a stored entry naming another object's FK cannot be told from a misspelling, so a write would drop it. - A preview draws the record page's resulting order through `deriveRelatedLists` itself, split into body tables and side cards (the order applies within each). 2. **Field groups, `relatedList`** (`ObjectGroupInspector` → `RelatedTablePlacement`). A "Place a related table here" picker over the same vocabulary, checked by the same function before it writes. The picker is withheld while the group still holds fields, because the object parse refuses a field whose group is a table group. A relationship another group already places is not offered again. Clearing the picker removes the key. 3. **Form layout designer** (`ObjectFormDesigner`). A group that carries `relatedList` is drawn in its place as a "Related table" card. It is no field container: its droppable is disabled, and it is left out of the container map that drags, keyboard steps and the commit read. Without this the new picker would create a group that invites a drop the save then refuses. 4. **Shared rows** (`OrderedRowList`). The drag + up / down + remove rows are extracted from `HighlightFieldsEditor`, which now uses them too, so the code is not copied. The round-1 highlight pins pass unchanged. 5. `deriveRelatedLists.ts`: `relatedListEntryNames(entry, list)` is now the one reading of an entry, shared by `resolveRelatedListOrder` and the editors, and the optional `includeHidden` flags hidden FKs `isHidden`. The record page passes neither, so its output is byte-identical. ## Tests (head `5c2b715589`) - New pins `ObjectSettingsPanel.relatedListsOrder-27054.test.tsx` (5) and `ObjectGroupInspector.relatedListPlacement-27054.test.tsx` (5). These cover the three acceptance pins: - a drag writes `['event.related_to', 'task']`, and `deriveRelatedLists` over the real fixture registry changes its order to match; - a stored `'nope'` / `'event.nope'` never reaches a written value; - the unlisted relationships trail in the derived order, greyed and not draggable. The files also cover the add picker, removing the last entry, writing nothing while loading, group placement, the withheld picker, de-duplication across groups, and the designer's table card. - Focused re-run on the final head: ObjectSettingsPanel*, ObjectGroupInspector*, ObjectFormDesigner*, deriveRelatedLists* gave `Test Files 21 passed (21) / Tests 181 passed (181)`. - Wide run (before the merge of `0d45c0e71f`): `packages/app-shell/src/views/studio-design/`, `packages/app-shell/src/utils/__tests__/`, `packages/app-shell/src/views/metadata-admin/inspectors/` and `packages/i18n/` gave `Test Files 567 passed | 1 skipped (568) / Tests 6360 passed | 22 skipped`. - `pnpm --filter @object-ui/app-shell --filter @object-ui/i18n run type-check`: exit 0 on the final head. `--listFilesOnly` shows both new pins in the app-shell test program. - **Reverse verification**, after the fix was committed, through `scripts/ablation-replace.mjs`. The anchor `const accepted = acceptRelatedListEntries(next, relationships);` was replaced with `const accepted = [...next];`, and the tool reported anchor 1 → 0, replacement 0 → 1, blob `eb202671ad73` → `6d9ac4f61fbd`. Result: `Tests 1 failed | 4 passed (5)`, and the one red is exactly "a name that matches no inbound relationship never enters a written value" (`expected [ Array(4) ] to deeply equal [ 'task', 'note' ]`). Restore: blob == HEAD `eb202671ad73`, `git diff HEAD` empty, `grep -c` after: anchor 1, mutant 0. The pin imports `src` by relative path, so there is no dist leg. - **Live**: showcase `--fresh` backend and the Console dev server, both on private ports, on this branch. In a scratch writable package (Customer + Ticket / Contract (master-detail) / Visit (two FKs) / Survey (`relatedList: false`)) I authored an order and placed a table. The publish door accepted the result: `relatedLists: ['desk_visit.referred_by', 'desk_ticket']`, `fieldGroups[0].relatedList: 'desk_contract'`. A stored `'nope'` showed as unmatched, and one move-up wrote `['desk_ticket']`. Screenshots, on the never-merged branch `claude/issue-27054-r2-evidence` at `20360a7f0f`: - https://github.com/objectstack-ai/objectstack/blob/20360a7f0f51501cc574dddf0ff0c33b198ff626/r2-1-related-lists-editor.jpg (the ordered list, the greyed tail with a hidden FK, the record-page preview) - https://github.com/objectstack-ai/objectstack/blob/20360a7f0f51501cc574dddf0ff0c33b198ff626/r2-2-group-related-table.jpg (group inspector picker, the designer's related-table card) - https://github.com/objectstack-ai/objectstack/blob/20360a7f0f51501cc574dddf0ff0c33b198ff626/r2-3-unmatched-entry.jpg (a stored entry naming no relationship) ## Gates (head `5c2b715589`) - I ran `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, which derived 34 families from 14 paths against merge base `0d45c0e71`. I ran each one and captured its exit code before any pipe. 33 exited 0. - `pnpm --dir ui exec vitest run scripts/` exited 1 for host-only reasons: - two `select-affected-tests-wiring` cases fail on `mapfile: command not found` (macOS bash 3.2); - the rest were `Test timed out in 15000ms` under batch load. Re-run alone, `check-i18n-call-site-keys`, `check-vi-mock-inherit`, `markdown-test-inputs` and `check-hand-rolled-comment-mask` gave `Tests 259 passed (259)`. The `check-i18n-dead-keys` real-tree case stays NOT MEASURED locally (it timed out). The gate itself (`pnpm --dir ui run check:i18n-dead-keys`) exits 0, and none of the new keys is listed. - `--ran` reconciliation: `Run reconciliation — 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN`. - Extra, outside the derived list, all exit 0: `pnpm check:changeset-fixed` (roster under `.changeset`), `pnpm check:i18n-stale-fill`, and `pnpm --dir ui run` with `check:new-line-citations` (0 new), `check:i18n-keys`, `check:i18n-drift` (18 keys added, 0 en values changed) and `check:i18n-dead-keys`. - `eslint --no-inline-config` over the changed files: 0 new findings. The one error, `react-hooks/static-components` in `ObjectSettingsPanel.tsx`, and the warnings in `deriveRelatedLists.ts` / `ObjectFormDesigner.tsx` are on `main` already. - Every locked command ran DECLARED UNLOCKED: this host has no usable `flock`, so `os-verify-lock.sh` never took the shared lock. - CI families: read from this PR's check-runs, not re-run locally. ## Acceptance notes - **The placement has no runtime reader yet.** No reader of `fieldGroups[].relatedList` is on `main`; the record page that draws it is #27055, which is in flight (#27055 remains open and is not addressed here). The key is spec-declared and passes the publish door, so authoring it now is what the ruling asked for. - **The publish door judges an unknown child name only on a whole collection.** In the live run, `relatedLists: ['nope', 'desk_ticket']` published from a package-scoped Studio publish with no refusal. `validate-related-list-refs` documents why in its header: a named child not in the stack is refused only when `wholeCollection` is set, and stays unjudged under a package closure. The `.describe()` on `relatedLists` says such an entry is refused. The editor's pre-write check closes this for Studio. Raised in the report for the seat to judge, not filed from here. - The metadata-admin object designer (`ObjectFormCanvas`) also edits `fieldGroups` and has no placement picker. The ruling named Studio's field-group editor, so it is left as it is. - The served object catalog is the vocabulary, with this object's draft put in its place. A child object that exists only as an unpublished draft in the same package is not offered until it is published, which matches what the record page can draw. --- _Generated by [Claude Code](https://claude.ai/code/session_cdb7a717-def7-4e71-9988-f089fbaa18ff)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2655ebd commit 829fcf2

15 files changed

Lines changed: 1075 additions & 82 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
feat(app-shell): Studio orders an object's related lists, and a field group can place a related table
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- **Object settings, `relatedLists`.** The Settings tab lists the object's inbound relationships (each `lookup` / `master_detail` field of a child object that points at it) as an ordered list: drag a row or use the move buttons to reorder, and add a relationship from the picker. An entry is the child object's name, or `object.field` when that child points at the object through more than one field. Relationships no entry names follow, greyed and not draggable, in the default order (owned children first, then lookups). The list orders only: a relationship hidden with `relatedList: false` is listed and marked hidden, and the switch stays on the field. A preview shows the record page's resulting order, split into body tables and side cards. Removing the last entry removes the key.
13+
- **Every write is checked first.** An entry that names no inbound relationship is never written, since `defineStack` and the publish door refuse it. A stored one keeps its row, marked, and the next change to the list leaves it out. Nothing is written until the object list has loaded.
14+
- **Field groups.** The group inspector in the form layout designer has a "Place a related table here" picker that writes the group's `relatedList` from the same relationships. It is withheld while the group still holds fields, and it does not offer a relationship another group already places. In the designer, a group that places a table is drawn in its place as that table and accepts no dropped fields.
15+
- **Shared ordered list.** The highlight fields editor and the related lists editor draw the same draggable, keyboard-movable rows.

‎ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ const OBJECTS_READERS: Record<string, { kind: Kind; why: string }> = {
100100
'packages/app-shell/src/views/metadata-admin/previews/ScreenPreview.tsx': { kind: 'off', why: "the metadata admin's form-screen preview" },
101101
'packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx': { kind: 'off', why: 'Studio' },
102102
'packages/app-shell/src/views/studio-design/studio-canvas-preview.tsx': { kind: 'off', why: 'Studio' },
103+
'packages/app-shell/src/views/studio-design/relatedListVocabulary.ts': {
104+
kind: 'off',
105+
why: "Studio: an object's inbound relationships, read while its Settings relatedLists editor or a field group's related-table picker is mounted",
106+
},
103107
'packages/plugin-designer/src/pages/CreateAppPage.tsx': { kind: 'off', why: "the app designer's object pickers" },
104108
'packages/plugin-designer/src/pages/EditAppPage.tsx': { kind: 'off', why: "the app designer's object pickers" },
105109
'apps/console/src/pages/developer/FlowRunsPage.tsx': { kind: 'off', why: 'the flow runs page' },

‎ui/packages/app-shell/src/utils/deriveRelatedLists.ts‎

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,12 @@ export interface DerivedRelatedList {
140140
* the related list's query stays byte-identical to what it sent before.
141141
*/
142142
sort?: Array<{ field: string; order: 'asc' | 'desc' }>;
143+
/**
144+
* True when the FK declares `relatedList: false` — present only when the
145+
* caller asked for hidden relationships ({@link DeriveRelatedListsOptions.includeHidden}).
146+
* The record page never asks, so a hidden list never reaches it.
147+
*/
148+
isHidden?: true;
143149
}
144150

145151
/**
@@ -269,19 +275,30 @@ export function resolveRelatedListOrder<T extends { childObject: string; referen
269275
const head: T[] = [];
270276
for (const entry of declared) {
271277
if (typeof entry !== 'string') continue;
272-
const dot = entry.indexOf('.');
273-
const child = dot === -1 ? entry : entry.slice(0, dot);
274-
const field = dot === -1 ? undefined : entry.slice(dot + 1);
275278
for (const list of lists) {
276-
if (placed.has(list) || list.childObject !== child) continue;
277-
if (field !== undefined && list.referenceField !== field) continue;
279+
if (placed.has(list) || !relatedListEntryNames(entry, list)) continue;
278280
placed.add(list);
279281
head.push(list);
280282
}
281283
}
282284
return [...head, ...lists.filter((list) => !placed.has(list))];
283285
}
284286

287+
/**
288+
* Does one `relatedLists` entry (or a field group's `relatedList`) name this
289+
* list? A bare child object name names every list of that child; `object.field`
290+
* names the one FK. The ONE reading of an entry, shared by
291+
* {@link resolveRelatedListOrder} and the Studio editors that author entries.
292+
*/
293+
export function relatedListEntryNames(
294+
entry: string,
295+
list: { childObject: string; referenceField: string },
296+
): boolean {
297+
const dot = entry.indexOf('.');
298+
if (dot === -1) return list.childObject === entry;
299+
return list.childObject === entry.slice(0, dot) && list.referenceField === entry.slice(dot + 1);
300+
}
301+
285302
/** Normalize an object's `fields` (record or array) into `[name, def]` pairs. */
286303
function fieldEntries(fields: MergedObjectLike['fields']): Array<[string, any]> {
287304
if (!fields) return [];
@@ -311,6 +328,14 @@ export interface DeriveRelatedListsOptions {
311328
* the title keeps `childLabel`.
312329
*/
313330
listLabel?: (child: MergedObjectLike & { name: string }) => string;
331+
/**
332+
* Keep the relationships whose FK declares `relatedList: false`, flagged
333+
* `isHidden`. For an AUTHORING surface only (Studio's `relatedLists`
334+
* editor): such a relationship is still one an entry may name (the spec's
335+
* `defineStack` check and `packages/lint` `validate-related-list-refs` count
336+
* it), so the editor lists it. The record page never sets this.
337+
*/
338+
includeHidden?: boolean;
314339
}
315340

316341
/**
@@ -364,7 +389,8 @@ export function deriveRelatedLists(
364389
if (fieldDef.reference !== parentName) continue;
365390
if (AUDIT_FK_FIELDS.has(fieldName)) continue;
366391
// Explicit opt-out lives on the relationship.
367-
if (fieldDef.relatedList === false) continue;
392+
const hidden = fieldDef.relatedList === false;
393+
if (hidden && !options?.includeHidden) continue;
368394

369395
const entry: Working = {
370396
childObject: child.name,
@@ -373,6 +399,7 @@ export function deriveRelatedLists(
373399
isOwned: type === 'master_detail',
374400
isPrimary: isMainContentList(type, fieldDef),
375401
...(inheritedSort ? { sort: inheritedSort } : {}),
402+
...(hidden ? { isHidden: true as const } : {}),
376403
_fkLabel: (typeof fieldDef.label === 'string' && fieldDef.label) || fieldName,
377404
_listLabel: childListLabel,
378405
...(typeof fieldDef.relatedListTitle === 'string' && fieldDef.relatedListTitle

‎ui/packages/app-shell/src/views/studio-design/HighlightFieldsEditor.tsx‎

Lines changed: 19 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
* this order. So the editor is a list, not a chip cloud:
1515
*
1616
* - each row can be dragged to a new place, or moved with the up / down
17-
* buttons (the keyboard path; the drag has no keyboard equivalent);
17+
* buttons (the keyboard path; the drag has no keyboard equivalent) — the
18+
* rows are the shared `OrderedRowList`, which `RelatedListsEditor` uses too;
1819
* - fields are added at the end, from the object's visible fields not already
1920
* picked, up to {@link HIGHLIGHT_FIELDS_MAX};
2021
* - a preview below draws the strip in the picked order, so the order is seen
@@ -25,9 +26,9 @@
2526
*/
2627

2728
import React from 'react';
28-
import { GripVertical, X } from 'lucide-react';
2929
import type { FieldEntry } from '../metadata-admin/previews/object-fields-io.js';
30-
import { InspectorReorderButtons, moveArray } from '../metadata-admin/inspectors/_shared.js';
30+
import { moveArray } from '../metadata-admin/inspectors/_shared.js';
31+
import { OrderedRowList } from './OrderedRowList.js';
3132
import { t, tFormat, type SupportedLocale } from '../metadata-admin/i18n.js';
3233

3334
/**
@@ -61,77 +62,26 @@ export function HighlightFieldsEditor({
6162
/** The host's "add field" picker, offered while the list is under the cap. */
6263
renderAdd?: () => React.ReactNode;
6364
}) {
64-
const [dragIndex, setDragIndex] = React.useState<number | null>(null);
65-
const [overIndex, setOverIndex] = React.useState<number | null>(null);
6665
const write = (next: string[]) => onChange(next.length > 0 ? next : undefined);
67-
const move = (from: number, to: number) => {
68-
if (from === to || from < 0 || to < 0 || from >= value.length || to >= value.length) return;
69-
write(moveArray(value, from, to));
70-
};
71-
const endDrag = () => {
72-
setDragIndex(null);
73-
setOverIndex(null);
74-
};
7566
const atCap = value.length >= HIGHLIGHT_FIELDS_MAX;
7667

7768
return (
7869
<div className="space-y-1.5" data-testid="highlight-fields-editor">
79-
{value.length > 0 && (
80-
<ol className="space-y-1" aria-label={t('engine.studio.settings.highlightFields', locale)}>
81-
{value.map((name, i) => (
82-
<li
83-
key={name}
84-
data-testid={`highlight-row-${name}`}
85-
draggable={!disabled}
86-
onDragStart={(e) => {
87-
e.dataTransfer?.setData('text/plain', name);
88-
setDragIndex(i);
89-
}}
90-
onDragOver={(e) => {
91-
if (dragIndex === null) return;
92-
e.preventDefault();
93-
setOverIndex(i);
94-
}}
95-
onDrop={(e) => {
96-
e.preventDefault();
97-
if (dragIndex !== null) move(dragIndex, i);
98-
endDrag();
99-
}}
100-
onDragEnd={endDrag}
101-
className={
102-
'flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-1 text-[12px]'
103-
+ (overIndex === i && dragIndex !== null && dragIndex !== i ? ' border-primary' : '')
104-
+ (dragIndex === i ? ' opacity-50' : '')
105-
}
106-
>
107-
{!disabled && (
108-
<GripVertical
109-
className="h-3.5 w-3.5 shrink-0 cursor-grab text-muted-foreground"
110-
aria-hidden="true"
111-
/>
112-
)}
113-
<span className="w-4 shrink-0 text-right text-[10px] tabular-nums text-muted-foreground">{i + 1}</span>
114-
<span className="min-w-0 flex-1 truncate">
115-
{labelOf(fields, name)}
116-
<span className="ml-1 font-mono text-[10px] text-muted-foreground">{name}</span>
117-
</span>
118-
{!disabled && (
119-
<>
120-
<InspectorReorderButtons index={i} total={value.length} onMove={(to) => move(i, to)} />
121-
<button
122-
type="button"
123-
aria-label={tFormat('engine.studio.settings.removeField', locale, { field: name })}
124-
onClick={() => write(value.filter((x) => x !== name))}
125-
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
126-
>
127-
<X className="h-3 w-3" />
128-
</button>
129-
</>
130-
)}
131-
</li>
132-
))}
133-
</ol>
134-
)}
70+
<OrderedRowList
71+
items={value}
72+
ariaLabel={t('engine.studio.settings.highlightFields', locale)}
73+
rowTestId={(name) => `highlight-row-${name}`}
74+
renderRow={(name) => (
75+
<>
76+
{labelOf(fields, name)}
77+
<span className="ml-1 font-mono text-[10px] text-muted-foreground">{name}</span>
78+
</>
79+
)}
80+
onMove={(from, to) => write(moveArray(value, from, to))}
81+
onRemove={(name) => write(value.filter((x) => x !== name))}
82+
removeLabel={(name) => tFormat('engine.studio.settings.removeField', locale, { field: name })}
83+
disabled={disabled}
84+
/>
13585
<div className="flex flex-wrap items-center gap-1.5">
13686
{!disabled && !atCap && renderAdd?.()}
13787
{atCap && (

‎ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx‎

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@ function Section({
442442
onDelete,
443443
onMove,
444444
readOnly = false,
445+
relatedTable,
445446
}: {
446447
containerId: string;
447448
title: string;
@@ -461,9 +462,15 @@ function Section({
461462
onDelete: () => void;
462463
onMove: (dir: -1 | 1) => void;
463464
readOnly?: boolean;
465+
/**
466+
* objectstack#27069 — the group's `relatedList`: it stands for that related
467+
* list's table, holds no fields (the object parse refuses a field whose
468+
* group is such a group), and so is no drop target.
469+
*/
470+
relatedTable?: string;
464471
}): React.ReactElement {
465472
const locale = useMetadataLocale();
466-
const { setNodeRef, isOver } = useDroppable({ id: containerId });
473+
const { setNodeRef, isOver } = useDroppable({ id: containerId, disabled: !!relatedTable });
467474
// `@container` scopes the field grid's container queries to THIS section's
468475
// width, so a wide screen spreads fields to the same column count the real
469476
// form uses — while a narrow panel collapses to one column.
@@ -543,6 +550,14 @@ function Section({
543550
)}
544551
</div>
545552
</div>
553+
{relatedTable ? (
554+
<div
555+
data-testid={`form-designer-related-table-${unCid(containerId)}`}
556+
className="m-3 flex items-center justify-center rounded-md border border-dashed bg-muted/30 py-3 text-[11px] text-muted-foreground"
557+
>
558+
{tFormat('engine.studio.designer.relatedTable', locale, { name: relatedTable })}
559+
</div>
560+
) : (
546561
<SortableContext items={fieldIds} strategy={verticalListSortingStrategy}>
547562
{/* Field grid mirrors the real form's density: container-query columns
548563
(1 → up to `columns`) via {@link containerGridColsFor}, so the layout
@@ -576,6 +591,7 @@ function Section({
576591
})}
577592
</div>
578593
</SortableContext>
594+
)}
579595
</div>
580596
);
581597
}
@@ -627,8 +643,18 @@ export function ObjectFormDesigner({
627643
[view.entries, systemFieldNames],
628644
);
629645

630-
// Container order: declared groups (in order) then the ungrouped bucket.
631-
const containerOrder = React.useMemo(() => [...groups.map((g) => cid(g.key)), cid(UNGROUPED)], [groups]);
646+
// objectstack#27069 — a group carrying `relatedList` stands for a related
647+
// table: it is drawn in its place, but it is no field container (it holds no
648+
// fields), so drags, keyboard steps and the commit never reach it.
649+
const tableOf = React.useMemo(() => {
650+
const m = new Map<string, string>();
651+
for (const g of groups) if (typeof g.relatedList === 'string' && g.relatedList) m.set(cid(g.key), g.relatedList);
652+
return m;
653+
}, [groups]);
654+
// Section order: declared groups (in order) then the ungrouped bucket.
655+
const sectionOrder = React.useMemo(() => [...groups.map((g) => cid(g.key)), cid(UNGROUPED)], [groups]);
656+
// Container order: the same, without the related-table groups.
657+
const containerOrder = React.useMemo(() => sectionOrder.filter((c) => !tableOf.has(c)), [sectionOrder, tableOf]);
632658
const labelOf = React.useMemo(() => {
633659
const m = new Map<string, string>();
634660
for (const g of groups) {
@@ -857,7 +883,7 @@ export function ObjectFormDesigner({
857883
onDragCancel={() => setActiveId(null)}
858884
>
859885
<div className="mx-auto flex max-w-6xl flex-col gap-4">
860-
{containerOrder.map((c) => {
886+
{sectionOrder.map((c) => {
861887
const isUngrouped = c === cid(UNGROUPED);
862888
// Hide the ungrouped bucket only when it is empty AND groups exist.
863889
if (isUngrouped && (items[c]?.length ?? 0) === 0 && groups.length > 0) return null;
@@ -882,6 +908,7 @@ export function ObjectFormDesigner({
882908
onDelete={() => deleteSection(unCid(c))}
883909
onMove={(dir) => moveSection(unCid(c), dir)}
884910
readOnly={readOnly}
911+
relatedTable={tableOf.get(c)}
885912
/>
886913
);
887914
})}

0 commit comments

Comments
 (0)