diff --git a/.changeset/27478-field-group-related-list-body-tables.md b/.changeset/27478-field-group-related-list-body-tables.md new file mode 100644 index 00000000000..df3213c238b --- /dev/null +++ b/.changeset/27478-field-group-related-list-body-tables.md @@ -0,0 +1,24 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +'@object-ui/app-shell': minor +'@object-ui/plugin-detail': minor +'@object-ui/i18n': minor +--- + +feat(spec,lint,app-shell)!: a field group's `relatedList` names body tables only — a group naming a side card or a hidden list is refused, and Studio offers body tables only + +Clause-②: no (narrowing) +Metadata-protocol: narrowed + + + +**BREAKING** for one shape: an object's `fieldGroups[i].relatedList` that names an inbound relationship which is not a body table of the record page. Ruling B on #27478: whether a related list is a body table is decided once, by the relationship's own keys on the child's FK (ruling B on #27055), and a field group only positions one. + +- **Before.** A group's `relatedList` naming any inbound relationship parsed, passed `defineStack` and published, but the record page draws only body tables at a group: a group naming a side card (`task` through a plain lookup, say) drew nothing at the group and the card stayed in the related column, silently. Studio's group inspector offered every inbound relationship. +- **Now.** One predicate, `isBodyTableRelationship` (exported from `@objectstack/spec/data`), answers "is this inbound relationship a body table": its FK declares `relatedList: 'primary'`, or it is a `master_detail` FK that sets `inlineEdit` and declares no `relatedList`. A group whose `relatedList` names a relationship it does not count is refused where the child's fields are known: `defineStack` (`os validate`, `os build`) with a `STACK_CROSS_REFERENCE_INVALID` issue, and the object publish door (`PUT /api/v1/meta/object`, Studio, MCP) with `422 INVALID_METADATA`, rule `related-list-group-not-body` (`@objectstack/lint`, with an `os explain` entry). The sentence, written by `fieldGroupBodyTableRefusal` on both surfaces, names each FK that is not a body table (a side card, or hidden by `relatedList: false`) and the remedy. A bare child name names every FK of that child, so it is refused when any of them is not a body table, and the remedy points at `object.field` for the one that is. Children the name check leaves unjudged (another package's object) are left unjudged here too. `relatedLists` entries are not judged this way: they order body tables and side cards alike. +- **The remedy.** Mark the relationship `relatedList: 'primary'` on the child's FK (or, on an inline-edited `master_detail`, remove an explicit `relatedList: true`), name only the body table as `object.field`, or remove the group's `relatedList`. There is no automatic conversion: which edit the author meant is not recorded. The upgrade guide entry is `field-group-related-list-side-card-refused`. +- **One record page cell moves.** A `master_detail` FK that sets `inlineEdit` and declares `relatedList: true` is a side card, as `relatedList`'s description says ("write true to keep it a side card") and as Studio's relationship inspector writes it. The record page (`@object-ui/app-shell` `RecordDetailView`) used to draw it as a body table, because it kept its own copy of the rule that ignored `relatedList`. It now reads the spec predicate through `deriveRelatedLists`' `isPrimary`, so that list moves to the related column. `isMainContentList` (an internal helper of `@object-ui/app-shell`) is gone; its callers read `isBodyTableRelationship`. +- **Studio.** The field-group inspector's related-table picker (`@object-ui/app-shell`) offers body tables only, refuses to write any other pick, and shows a stored value that is not a body table with a line saying why the page draws nothing for it (`@object-ui/i18n`, en and zh). +- **Stored rows and built artifacts.** The object parse does not judge this, so a stored `sys_metadata` object row carrying such a group is not rewritten and still loads and registers unchanged; its record page draws what it drew before, except the one cell above. The next save of that object through the metadata write door is refused (`422`, at `fieldGroups[i].relatedList`) until it is fixed, and so is a write of the child that turns a body table a stored group places into a side card. A built artifact is judged when it is built. Census at the change: no example, platform object, dogfood fixture or HotCRM object (hotcrm `47a0666`) declares a field group `relatedList` at all; deployed metadata was not measured. +- **`@object-ui/plugin-detail`.** Documentation only: `bodyTablePlacement` and the `isBody` option name the spec predicate. diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index befb82d0d88..fc4c1bfe04b 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -315,7 +315,7 @@ const result = ApiMethod.parse(data); | **label** | `string` | ✅ | Group display label | | **icon** | `string` | optional | Icon name (Lucide/Material) for the group header | | **description** | `string` | optional | Optional description shown under the group header | -| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. relatedList: false on the relationship still hides the table. | +| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. It names a body table only (a FK declaring relatedList: 'primary', or a master_detail FK that sets inlineEdit and declares no relatedList): a group naming a side card or a list hidden by relatedList: false draws nothing, so it is refused where the child's fields are known (os validate / os build and the metadata write door), naming the FK and the remedy (set relatedList: 'primary' on it, name only the body table as object.field when a bare child name also names a side card, or remove the group's relatedList). | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'` | | **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). | | **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. | @@ -701,7 +701,7 @@ External datasource binding (ADR-0015) | **label** | `string` | ✅ | Group display label | | **icon** | `string` | optional | Icon name (Lucide/Material) for the group header | | **description** | `string` | optional | Optional description shown under the group header | -| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. relatedList: false on the relationship still hides the table. | +| **relatedList** | `string` | optional | Places a related list's body table at this position in the record's details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. It names a body table only (a FK declaring relatedList: 'primary', or a master_detail FK that sets inlineEdit and declares no relatedList): a group naming a side card or a list hidden by relatedList: false draws nothing, so it is refused where the child's fields are known (os validate / os build and the metadata write door), naming the FK and the remedy (set relatedList: 'primary' on it, name only the body table as object.field when a bare child name also names a side card, or remove the group's relatedList). | | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Section visibility predicate (CEL) — the whole group (header included) is shown only when TRUE, else hidden (fail-closed). e.g. P`record.type == 'invoice'` | | **collapse** | `Enum<'none' \| 'expanded' \| 'collapsed'>` | optional (default: `"none"`) | [ADR-0085] Section collapse behaviour: 'none' (always open, no toggle), 'expanded' (collapsible, starts open), 'collapsed' (collapsible, starts closed). | | **defaultExpanded** | `boolean` | optional | [DEPRECATED → collapse] true → 'expanded', false → 'collapsed'. | diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 7ae8960877b..acf14b55eac 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -586,10 +586,12 @@ export type { // [#27069] Related-list names: each `relatedLists` entry and field-group // `relatedList` must match an inbound lookup / master_detail relationship. // `error`, the verdict `defineStack` gives, and on the runtime publish door, -// which runs no `defineStack`. +// which runs no `defineStack`. [#27478] A field group's `relatedList` must +// name body tables only (`related-list-group-not-body`). export { validateRelatedListRefs, RELATED_LIST_REF_UNKNOWN, + RELATED_LIST_GROUP_NOT_BODY, } from './validate-related-list-refs.js'; export type { RelatedListRefFinding, diff --git a/packages/lint/src/reference-integrity-suite.ts b/packages/lint/src/reference-integrity-suite.ts index 03ca2180380..49a6355a41d 100644 --- a/packages/lint/src/reference-integrity-suite.ts +++ b/packages/lint/src/reference-integrity-suite.ts @@ -405,6 +405,10 @@ export const REFERENCE_INTEGRITY_RULES: readonly ReferenceIntegrityRule[] = [ // artifact's scope. MEASURED over the shipped object corpus: the two keys // are new and no shipped object declares either, so 0 findings; the door // controls in `runtime-gate.object-writes.test.ts` are the lit probe. + // [#27478] The same member refuses a field group naming a relationship that + // is not a body table (`related-list-group-not-body`), on the same children; + // a write of the child that turns a placed body table into a side card is + // refused too, the finding being new on the stored parent. Same census: 0. { name: 'validateRelatedListRefs', runtimeTypes: ['flow', 'object'], diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index f202847c348..0617bbef4e8 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3865,6 +3865,27 @@ const RELATED_LIST_REF_UNKNOWN_EXPLANATION: RuleExplanation = { ], }; +const RELATED_LIST_GROUP_NOT_BODY_EXPLANATION: RuleExplanation = { + rule: 'related-list-group-not-body', + covers: 'which related list a field group may place', + paragraphs: [ + 'A field group\'s `relatedList` places one related list\'s table among the field sections of the ' + + 'record\'s details column. It only POSITIONS a list that is already a body table; it does not make ' + + 'one. Whether a list is a body table is decided once, by the relationship\'s own FK field on the ' + + 'child: `relatedList: \'primary\'`, or a `master_detail` FK that sets `inlineEdit` and declares no ' + + '`relatedList`. Every other list is a side card in the related column (`relatedList: true` or ' + + 'absent), or hidden (`relatedList: false`).', + 'A group naming a side card or a hidden list draws nothing at the group, silently, so it is an ' + + 'error, the verdict `defineStack` gives on `os validate` and `os build` and the runtime publish ' + + 'gate gives too. A bare child name names every FK of that child, so it is refused when any of ' + + 'them is not a body table. The fix is on the relationship (mark it `relatedList: \'primary\'`), ' + + 'in the name (`object.field` for the one FK that is a body table), or removing the group\'s ' + + '`relatedList`.', + 'Judged on the children this rule can see, the same ones `related-list-ref-unknown` judges: a ' + + 'child outside the stack (another package\'s object) is not judged here.', + ], +}; + // ── Object-name references (`validate-object-references.ts`) ──────────────── /** The sites judged — both object-reference ids. */ @@ -5326,6 +5347,7 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [NAV_TARGET_UNRESOLVED_EXPLANATION.rule]: NAV_TARGET_UNRESOLVED_EXPLANATION, [OBJECT_FIELD_REF_UNKNOWN_EXPLANATION.rule]: OBJECT_FIELD_REF_UNKNOWN_EXPLANATION, [RELATED_LIST_REF_UNKNOWN_EXPLANATION.rule]: RELATED_LIST_REF_UNKNOWN_EXPLANATION, + [RELATED_LIST_GROUP_NOT_BODY_EXPLANATION.rule]: RELATED_LIST_GROUP_NOT_BODY_EXPLANATION, [OBJECT_REFERENCE_UNKNOWN_EXPLANATION.rule]: OBJECT_REFERENCE_UNKNOWN_EXPLANATION, [OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION.rule]: OBJECT_REFERENCE_UNREGISTERED_PLATFORM_EXPLANATION, [CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION.rule]: CAPABILITY_REFERENCE_UNKNOWN_EXPLANATION, diff --git a/packages/lint/src/runtime-gate.object-writes.test.ts b/packages/lint/src/runtime-gate.object-writes.test.ts index 637a872368f..ed8341d20cd 100644 --- a/packages/lint/src/runtime-gate.object-writes.test.ts +++ b/packages/lint/src/runtime-gate.object-writes.test.ts @@ -597,6 +597,7 @@ describe('the object door judges related-list names against the live universe (# fields: { title: { type: 'text' }, ...fields }, }); const withFk = childWith({ request_id: { type: 'lookup', reference: 'leave_request' } }); + const withBodyFk = childWith({ request_id: { type: 'lookup', reference: 'leave_request', relatedList: 'primary' } }); const withoutFk = childWith({}); const gateWith = (item: unknown, child: unknown) => runRuntimeAuthoringRules({ type: 'object', item, context: { objects: [...STORED, child] } }); @@ -625,7 +626,36 @@ describe('the object door judges related-list names against the live universe (# expect(refused.errors.map((e) => [e.rule, e.path])).toEqual([ ['related-list-ref-unknown', 'objects.leave_request.fieldGroups[0].relatedList'], ]); - expect(gateWith(cleanObject({ fieldGroups: groups }), withFk).errors).toEqual([]); + expect(gateWith(cleanObject({ fieldGroups: groups }), withBodyFk).errors).toEqual([]); + }); + + // [#27478] A group only positions a BODY TABLE: the same group naming the + // stored child through a plain lookup (a side card) is refused; its control + // differs by the FK's `relatedList: 'primary'` alone. + it('REFUSES a field group naming a side card, and publishes it once the FK is primary', () => { + const groups = [{ key: 'lines', label: 'Lines', relatedList: 'leave_task' }]; + const refused = gateWith(cleanObject({ fieldGroups: groups }), withFk); + expect(refused.errors.map((e) => [e.rule, e.path])).toEqual([ + ['related-list-group-not-body', 'objects.leave_request.fieldGroups[0].relatedList'], + ]); + expect(refused.errors[0].message).toContain("'leave_task.request_id' is a side card"); + expect(gateWith(cleanObject({ fieldGroups: groups }), withBodyFk).errors).toEqual([]); + }); + + // [#27478] The other half of the same verdict: a write of the CHILD that + // turns a body table a stored group places into a side card is refused too + // (the finding is new on the stored parent). Rewriting the child while it + // stays a body table publishes. + it('REFUSES a child write that makes a placed body table a side card; keeping it primary publishes', () => { + const parent = cleanObject({ fieldGroups: [{ key: 'lines', label: 'Lines', relatedList: 'leave_task' }] }); + const gateChild = (child: unknown) => + runRuntimeAuthoringRules({ type: 'object', item: child, context: { objects: [parent, withBodyFk] } }); + const refused = gateChild(childWith({ request_id: { type: 'lookup', reference: 'leave_request', relatedList: true } })); + expect(refused.errors.map((e) => [e.rule, e.path])).toEqual([ + ['related-list-group-not-body', 'objects.leave_request.fieldGroups[0].relatedList'], + ]); + expect(gateChild(childWith({ request_id: { type: 'lookup', reference: 'leave_request', relatedList: 'primary' }, note: { type: 'text' } })).errors) + .toEqual([]); }); it('REFUSES a child that exists nowhere: with no closure in force the context is the whole collection', () => { diff --git a/packages/lint/src/validate-related-list-refs.test.ts b/packages/lint/src/validate-related-list-refs.test.ts index 69229fd67f6..6196d568751 100644 --- a/packages/lint/src/validate-related-list-refs.test.ts +++ b/packages/lint/src/validate-related-list-refs.test.ts @@ -9,7 +9,11 @@ import { describe, expect, it } from 'vitest'; import { defineStack } from '@objectstack/spec'; -import { RELATED_LIST_REF_UNKNOWN, validateRelatedListRefs } from './validate-related-list-refs.js'; +import { + RELATED_LIST_GROUP_NOT_BODY, + RELATED_LIST_REF_UNKNOWN, + validateRelatedListRefs, +} from './validate-related-list-refs.js'; const lead = (extra: Record = {}) => ({ name: 'rlo_lead', @@ -80,9 +84,41 @@ describe('validateRelatedListRefs', () => { expect(validateRelatedListRefs({})).toEqual([]); }); + it('a field group naming a side card is related-list-group-not-body; a body table is its control (#27478)', () => { + const line = { + name: 'rlo_line', + label: 'Line', + fields: { lead_id: { type: 'master_detail', label: 'Lead', reference: 'rlo_lead', inlineEdit: 'grid' } }, + }; + const groups = (relatedList: string) => [{ key: 'basics', label: 'Basics' }, { key: 'lines', label: 'Lines', relatedList }]; + const stack = (relatedList: string, child: Record = task) => + ({ objects: [lead({ fieldGroups: groups(relatedList) }), child, line] }); + expect(rules(stack('rlo_task'))).toEqual([[RELATED_LIST_GROUP_NOT_BODY, 'objects[0].fieldGroups[1].relatedList']]); + const finding = validateRelatedListRefs(stack('rlo_task'))[0]!; + expect(finding.severity).toBe('error'); + expect(finding.where).toBe('object "rlo_lead" › fieldGroups'); + expect(finding.message).toContain("'rlo_task.lead_id' is a side card"); + // Controls: the same group naming a body table, and the side card once marked primary. + expect(rules(stack('rlo_line'))).toEqual([]); + const primaryTask = { ...task, fields: { lead_id: { ...task.fields.lead_id, relatedList: 'primary' } } }; + expect(rules(stack('rlo_task', primaryTask))).toEqual([]); + // A hidden list is not a body table either. + const hiddenTask = { ...task, fields: { lead_id: { ...task.fields.lead_id, relatedList: false } } }; + expect(validateRelatedListRefs(stack('rlo_task', hiddenTask))[0]?.message).toContain('hidden (relatedList: false)'); + // relatedLists entries order side cards as well; they are not judged this way. + expect(rules({ objects: [lead({ relatedLists: ['rlo_task', 'rlo_line'] }), task, line] })).toEqual([]); + }); + it('writes the sentence defineStack writes for the same entry, word for word', () => { const manifest = { id: 'com.example.rlo', namespace: 'rlo', name: 'rlo', version: '1.0.0', type: 'module' as const }; - const stack = stackOf(lead({ relatedLists: ['rlo_task', 'rlo_task.nope'], fieldGroups: [{ key: 'lines', label: 'Lines', relatedList: 'rlo_note' }] })); + const stack = stackOf(lead({ + relatedLists: ['rlo_task', 'rlo_task.nope'], + fieldGroups: [ + { key: 'lines', label: 'Lines', relatedList: 'rlo_note' }, + { key: 'tasks', label: 'Tasks', relatedList: 'rlo_task' }, + { key: 'events', label: 'Events', relatedList: 'rlo_event' }, + ], + })); let issues: readonly string[] = []; try { defineStack({ manifest, ...stack } as never); @@ -90,7 +126,7 @@ describe('validateRelatedListRefs', () => { issues = (e as { issues?: readonly string[] }).issues ?? []; } const messages = validateRelatedListRefs(stack).map((f) => f.message); - expect(messages).toHaveLength(2); + expect(messages).toHaveLength(4); expect(issues).toEqual(messages); }); }); diff --git a/packages/lint/src/validate-related-list-refs.ts b/packages/lint/src/validate-related-list-refs.ts index 3bd02e4c1fa..5499d1c398c 100644 --- a/packages/lint/src/validate-related-list-refs.ts +++ b/packages/lint/src/validate-related-list-refs.ts @@ -42,13 +42,30 @@ * The audit / ownership FKs (`created_by`, `updated_by`, `owner_id`) never * become a related list (objectui `deriveRelatedLists`), so they are not * inbound relationships an entry may name — the set `defineStack` excludes. + * + * ## A field group names body tables only (#27478) + * + * A field group's `relatedList` positions a BODY TABLE; it never promotes a + * side card (ruling B on #27055: body-ness is the FK's own keys, + * `isBodyTableRelationship` in `@objectstack/spec/data`). So a group whose + * entry names a relationship that is not one — a side card, or a list hidden by + * `relatedList: false` — is `related-list-group-not-body` at `error`: the + * record page would draw nothing at the group. Judged on the same children as + * the name check above (a child the stack holds, whose FK keys are known), and + * the sentence is `fieldGroupBodyTableRefusal`'s, the one `defineStack` writes. + * `relatedLists` entries are not judged this way: they order body tables and + * side cards alike. */ +import { fieldGroupBodyTableRefusal, type NamedInboundRelationship } from '@objectstack/spec/data'; import { recordsOf } from './object-graph.js'; /** A related-list entry that matches no inbound relationship of its object. */ export const RELATED_LIST_REF_UNKNOWN = 'related-list-ref-unknown'; +/** A field group's `relatedList` naming a relationship that is not a body table. */ +export const RELATED_LIST_GROUP_NOT_BODY = 'related-list-group-not-body'; + export interface RelatedListRefFinding { /** Always `error`: the same verdict `defineStack` gives. */ severity: 'error'; @@ -82,6 +99,10 @@ const HINT = 'Name a child object that has a lookup / master_detail field pointing at this object, ' + 'or object.field to pick one FK of a child with several; remove the entry otherwise.'; +const NOT_BODY_HINT = + "Mark the relationship relatedList: 'primary' on the child's FK (or leave relatedList off an inline-edited " + + "master_detail), name only a body table as object.field, or remove the group's relatedList."; + /** The inbound relationships, spelled the way an entry may name them. */ function spell(relationships: ReadonlyArray<{ child: string; field: string }>): string { if (relationships.length === 0) return 'it has none'; @@ -114,13 +135,13 @@ export function validateRelatedListRefs(stack: AnyRec, options?: RelatedListRefO } // Parent name → its inbound relationships, in child then field order. - const inbound = new Map>(); + const inbound = new Map(); for (const [child, fields] of fieldsByObject) { for (const [field, def] of Object.entries(fields)) { if (!isRec(def) || (def.type !== 'lookup' && def.type !== 'master_detail')) continue; if (typeof def.reference !== 'string' || AUDIT_FKS.has(field)) continue; const list = inbound.get(def.reference) ?? []; - list.push({ child, field }); + list.push({ child, field, def }); inbound.set(def.reference, list); } } @@ -138,12 +159,17 @@ export function validateRelatedListRefs(stack: AnyRec, options?: RelatedListRefO const parentName = obj.name; const relationships = inbound.get(parentName) ?? []; - const judge = (entry: unknown, subject: string, key: string, path: string): void => { - if (typeof entry !== 'string' || entry.length === 0) return; + // The inbound relationships an entry names: every FK of a bare child, or the one `object.field`. + const namedBy = (entry: string): NamedInboundRelationship[] => { const dot = entry.indexOf('.'); const child = dot === -1 ? entry : entry.slice(0, dot); const field = dot === -1 ? undefined : entry.slice(dot + 1); - if (relationships.some((r) => r.child === child && (field === undefined || r.field === field))) return; + return relationships.filter((r) => r.child === child && (field === undefined || r.field === field)); + }; + const judge = (entry: unknown, subject: string, key: string, path: string): void => { + if (typeof entry !== 'string' || entry.length === 0) return; + const child = entry.includes('.') ? entry.slice(0, entry.indexOf('.')) : entry; + if (namedBy(entry).length > 0) return; if (!fieldsByObject.has(child) && !options?.wholeCollection) return; findings.push({ severity: 'error', @@ -166,7 +192,20 @@ export function validateRelatedListRefs(stack: AnyRec, options?: RelatedListRefO if (Array.isArray(obj.fieldGroups)) { obj.fieldGroups.forEach((group, i) => { if (!isRec(group) || group.relatedList === undefined) return; - judge(group.relatedList, `fieldGroups[${i}].relatedList`, 'fieldGroups', `${objPath}.fieldGroups[${i}].relatedList`); + const path = `${objPath}.fieldGroups[${i}].relatedList`; + judge(group.relatedList, `fieldGroups[${i}].relatedList`, 'fieldGroups', path); + if (typeof group.relatedList !== 'string' || group.relatedList.length === 0) return; + const message = fieldGroupBodyTableRefusal(parentName, i, group.relatedList, namedBy(group.relatedList)); + if (message === undefined) return; + findings.push({ + severity: 'error', + rule: RELATED_LIST_GROUP_NOT_BODY, + where: `object "${parentName}" › fieldGroups`, + path, + // The sentence `defineStack` writes for the same group, word for word. + message, + hint: NOT_BODY_HINT, + }); }); } } diff --git a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts index 37f4a437086..d460ad92bc8 100644 --- a/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts +++ b/packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts @@ -4985,7 +4985,9 @@ describe('the object save door judges related-list names against the live regist }); it('SAVES the same write once the registry child carries the FK — and a body-table group beside it', async () => { - const { protocol, rows } = hostWith([line({ quote: { type: 'master_detail', label: 'Quote', reference: 'rl_quote' } })]); + const { protocol, rows } = hostWith([ + line({ quote: { type: 'master_detail', label: 'Quote', reference: 'rl_quote', inlineEdit: 'grid' } }), + ]); const result = await save(protocol, parent({ relatedLists: ['rl_quote_line'], @@ -4995,4 +4997,22 @@ describe('the object save door judges related-list names against the live regist expect(result.success).toBe(true); expect(quoteRows(rows).map((r) => r.state)).toEqual(['active']); }); + + // [#27478] A field group only positions a BODY TABLE. The control above + // differs from this refusal by the FK's `inlineEdit` alone: without it a + // plain master_detail child is a side card, and the group would draw nothing. + it('REFUSES a field group naming a side card, with the 422 envelope and the prescription', async () => { + const { protocol, rows } = hostWith([line({ quote: { type: 'master_detail', label: 'Quote', reference: 'rl_quote' } })]); + + const err = await save(protocol, parent({ + fieldGroups: [{ key: 'lines', label: 'Lines', relatedList: 'rl_quote_line.quote' }], + })).catch((e: any) => e); + + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + const issue = err.issues.find((i: any) => i.rule === 'related-list-group-not-body'); + expect(issue, `issues: ${JSON.stringify(err.issues)}`).toBeDefined(); + expect(issue.path).toBe('objects.rl_quote.fieldGroups[0].relatedList'); + expect(issue.message).toContain("'rl_quote_line.quote' is a side card"); + expect(quoteRows(rows)).toEqual([]); + }); }); diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 96b7e6023bd..15ada60db94 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -471,6 +471,7 @@ "NUMERIC_STRING_GRAMMAR_CASES (const)", "NUMERIC_STRING_PATTERN (const)", "NUMERIC_VALUE_TYPES (const)", + "NamedInboundRelationship (interface)", "NoSQLDataTypeMapping (type)", "NoSQLDataTypeMappingSchema (const)", "NoSQLDatabaseType (type)", @@ -644,6 +645,7 @@ "ReferenceResolutionErrorSchema (const)", "ReferenceResolutionParsed (type)", "ReferenceResolutionSchema (const)", + "RelatedListRelationshipFields (interface)", "RenderAutonumberInput (interface)", "RenderedAutonumber (interface)", "ReplicationConfig (type)", @@ -873,6 +875,7 @@ "expandEmptyOperator (function)", "externalBindingDatasourceRefusal (function)", "fieldForm (const)", + "fieldGroupBodyTableRefusal (function)", "filterSubtreeProvenanceOf (function)", "foldAsciiCase (function)", "foldQueryAliasSlots (function)", @@ -901,6 +904,7 @@ "isApiOperationAllowed (function)", "isApiPrimitive (function)", "isAppResolvedDefaultToken (function)", + "isBodyTableRelationship (function)", "isCompatible (function)", "isComposedReadableIds (function)", "isContextToken (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 71e26d894d9..60aa9617e44 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -461,6 +461,7 @@ "NUMERIC_STRING_GRAMMAR_CASES": "src/data/filter-number-comparand-declared-type.ts#NUMERIC_STRING_GRAMMAR_CASES (const)", "NUMERIC_STRING_PATTERN": "src/data/filter-number-comparand-declared-type.ts#NUMERIC_STRING_PATTERN (const)", "NUMERIC_VALUE_TYPES": "src/data/field-value.zod.ts#NUMERIC_VALUE_TYPES (const)", + "NamedInboundRelationship": "src/data/related-list-body-table.ts#NamedInboundRelationship (interface)", "NoSQLDataTypeMapping": "src/data/driver-nosql.zod.ts#NoSQLDataTypeMapping (type)", "NoSQLDataTypeMappingSchema": "src/data/driver-nosql.zod.ts#NoSQLDataTypeMappingSchema (const)", "NoSQLDatabaseType": "src/data/driver-nosql.zod.ts#NoSQLDatabaseType (type)", @@ -632,6 +633,7 @@ "ReferenceResolutionErrorSchema": "src/data/seed-loader.zod.ts#ReferenceResolutionErrorSchema (const)", "ReferenceResolutionParsed": "src/data/seed-loader.zod.ts#ReferenceResolutionParsed (type)", "ReferenceResolutionSchema": "src/data/seed-loader.zod.ts#ReferenceResolutionSchema (const)", + "RelatedListRelationshipFields": "src/data/related-list-body-table.ts#RelatedListRelationshipFields (interface)", "RenderAutonumberInput": "src/data/autonumber-format.ts#RenderAutonumberInput (interface)", "RenderedAutonumber": "src/data/autonumber-format.ts#RenderedAutonumber (interface)", "ReplicationConfig": "src/data/driver-nosql.zod.ts#ReplicationConfig (type)", @@ -859,6 +861,7 @@ "expandEmptyOperator": "src/data/filter-empty-operator.ts#expandEmptyOperator (function)", "externalBindingDatasourceRefusal": "src/data/external-binding-datasource.ts#externalBindingDatasourceRefusal (function)", "fieldForm": "src/data/field.form.ts#fieldForm (const)", + "fieldGroupBodyTableRefusal": "src/data/related-list-body-table.ts#fieldGroupBodyTableRefusal (function)", "filterSubtreeProvenanceOf": "src/data/filter-subtree-provenance.ts#filterSubtreeProvenanceOf (function)", "foldAsciiCase": "src/data/filter.zod.ts#foldAsciiCase (function)", "foldQueryAliasSlots": "src/data/data-engine.zod.ts#foldQueryAliasSlots (function)", @@ -887,6 +890,7 @@ "isApiOperationAllowed": "src/data/api-derivation.ts#isApiOperationAllowed (function)", "isApiPrimitive": "src/data/api-derivation.ts#isApiPrimitive (function)", "isAppResolvedDefaultToken": "src/data/default-value-tokens.ts#isAppResolvedDefaultToken (function)", + "isBodyTableRelationship": "src/data/related-list-body-table.ts#isBodyTableRelationship (function)", "isCompatible": "src/data/type-compat.ts#isCompatible (function)", "isComposedReadableIds": "src/data/readable-ids.ts#isComposedReadableIds (function)", "isContextToken": "src/data/context-tokens.zod.ts#isContextToken (function)", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 7d0d1e090c9..7b13fc4f763 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -498,10 +498,10 @@ }, "relatedList": { "status": "live", - "verifiedAt": "2026-10-28", + "verifiedAt": "2026-10-29", "evidenceScope": "in-repo", - "evidence": "ui/packages/app-shell/src/utils/deriveRelatedLists.ts:367 skips the FK entirely when relatedList is false; ui/packages/app-shell/src/utils/deriveRelatedLists.ts#isMainContentList marks the derived list isPrimary when it is 'primary', or when it is absent on a master_detail FK that sets inlineEdit; ui/packages/app-shell/src/views/RecordDetailView.tsx:1959 calls deriveRelatedLists and renders what it returns, consuming the derived structure rather than the key", - "note": "RE-CITED 2026-08-10 (#7133/#7142): the old split citation's RecordDetailView half went to prose — every `relatedList` occurrence in that file is now a comment (:1013, :1847, :1857, :1864). The working half is deriveRelatedLists, which is what branches on the value. RecordDetailView is kept, explicitly labelled as the consumer of the DERIVED list, so the next re-audit does not read its zero key-hits as rot a second time. Verdict unchanged: LIVE. 2026-10-28 (#27069): 'primary' re-read as PLACEMENT (the record's main content: a body table, its own tab on the tabbed page) rather than order — the parent object's `relatedLists` orders the lists now — and an absent relatedList on an inline-edited master_detail FK is 'primary' by default (`isMainContentList`). Re-cited to the moved lines; verdict unchanged: LIVE." + "evidence": "ui/packages/app-shell/src/utils/deriveRelatedLists.ts:367 skips the FK entirely when relatedList is false; ui/packages/app-shell/src/utils/deriveRelatedLists.ts#deriveRelatedLists marks the derived list isPrimary (a body table) by packages/spec/src/data/related-list-body-table.ts#isBodyTableRelationship: when it is 'primary', or when it is absent on a master_detail FK that sets inlineEdit; ui/packages/app-shell/src/views/RecordDetailView.tsx:1959 calls deriveRelatedLists and renders what it returns, consuming the derived structure rather than the key", + "note": "RE-CITED 2026-08-10 (#7133/#7142): the old split citation's RecordDetailView half went to prose — every `relatedList` occurrence in that file is now a comment (:1013, :1847, :1857, :1864). The working half is deriveRelatedLists, which is what branches on the value. RecordDetailView is kept, explicitly labelled as the consumer of the DERIVED list, so the next re-audit does not read its zero key-hits as rot a second time. Verdict unchanged: LIVE. 2026-10-28 (#27069): 'primary' re-read as PLACEMENT (the record's main content: a body table, its own tab on the tabbed page) rather than order — the parent object's `relatedLists` orders the lists now — and an absent relatedList on an inline-edited master_detail FK is 'primary' by default (`isMainContentList`). Re-cited to the moved lines; verdict unchanged: LIVE. 2026-10-29 (#27478): the body-table rule moved into the spec as `isBodyTableRelationship`, the one predicate the record page (deriveRelatedLists isPrimary, RecordDetailView isBody), Studio and the refusal of a field group naming a side card read; RecordDetailView's own copy, which drew an inline-edited master_detail declaring relatedList: true as a body table, is gone. Verdict unchanged: LIVE." }, "relatedListTitle": { "status": "live", diff --git a/packages/spec/liveness/object.json b/packages/spec/liveness/object.json index 07915aea59e..022fc572d5c 100644 --- a/packages/spec/liveness/object.json +++ b/packages/spec/liveness/object.json @@ -52,7 +52,7 @@ "verifiedAt": "2026-10-28", "evidenceScope": "in-repo", "evidence": "ui/packages/app-shell/src/utils/deriveRelatedLists.ts#resolveRelatedListOrder (the one resolution: the parent's declared entries first, in order — a child object takes all of its lists, `object.field` one FK — then every unlisted list in the derived owned → lookup order); ui/packages/app-shell/src/utils/deriveRelatedLists.ts#deriveRelatedLists (hands `objectDef.relatedLists` to it); packages/spec/src/stack.zod.ts#collectRelatedListNameErrors (the AUTHORING judgement: an entry that matches no inbound lookup / master_detail field is refused at defineStack, naming the entry and the relationships that exist); packages/lint/src/validate-related-list-refs.ts#validateRelatedListRefs (the same judgement at the runtime object publish door, `related-list-ref-unknown` at error, against the children the live registry and stored rows hold)", - "note": "#27069, maintainer ruling A (an ordered list on the parent object). The record page reads it through deriveRelatedLists, whose output RecordDetailView renders in order (the tabbed page's own tabs and its Related tab keep this order within each). `relatedList: false` on the FK still hides a listed relationship, and `relatedList: 'primary'` places a list rather than ordering it. The cross-object strict-name check runs where the children's fields are known: `defineStack` (so `os validate` / `os build`), and the object publish door, whose snapshot carries the registry's objects and the stored rows. `fieldGroups[].relatedList` has its own row under the drilled `fieldGroups`, graded `planned` until the record page v3 body layout (#27055) draws it." + "note": "#27069, maintainer ruling A (an ordered list on the parent object). The record page reads it through deriveRelatedLists, whose output RecordDetailView renders in order (the tabbed page's own tabs and its Related tab keep this order within each). `relatedList: false` on the FK still hides a listed relationship, and `relatedList: 'primary'` places a list rather than ordering it. The cross-object strict-name check runs where the children's fields are known: `defineStack` (so `os validate` / `os build`), and the object publish door, whose snapshot carries the registry's objects and the stored rows. `fieldGroups[].relatedList` has its own row under the drilled `fieldGroups` (live since the record page v3 body layout, #27055, draws it; it names body tables only, #27478)." }, "stageField": { "status": "live", @@ -97,9 +97,11 @@ "note": "The blurb under the group heading, on forms and detail pages." }, "relatedList": { - "status": "planned", - "verifiedAt": "2026-10-28", - "note": "#27069. A group carrying it stands for one related list's table in the record's details column (an inbound relationship, a child object name or `object.field`, as `relatedLists` spells one) instead of a section of fields; a field naming such a group as its `group` is refused at parse, and a name matching no inbound lookup / master_detail field is refused by `defineStack` (`collectRelatedListNameErrors`) and at the object publish door (`related-list-ref-unknown`, packages/lint/src/validate-related-list-refs.ts#validateRelatedListRefs). PLANNED: no renderer draws the placement yet — the shared derivation drops a group no field references, so such a group draws nothing, as any empty group does. The reader is the record page v3 body layout, #27055, which re-grades this row when it lands." + "status": "live", + "verifiedAt": "2026-10-29", + "evidenceScope": "in-repo", + "evidence": "ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts#bodyTablePlacement (reads each group's relatedList and draws the body table it names at the group's position in the details column); packages/spec/src/stack.zod.ts#collectRelatedListNameErrors (the AUTHORING judgement at defineStack: a name matching no inbound relationship, and a name naming a relationship that is not a body table); packages/lint/src/validate-related-list-refs.ts#validateRelatedListRefs (the same judgement at the object publish door: related-list-ref-unknown and related-list-group-not-body, at error)", + "note": "#27069. A group carrying it stands for one related list's table in the record's details column (an inbound relationship, a child object name or `object.field`, as `relatedLists` spells one) instead of a section of fields; a field naming such a group as its `group` is refused at parse. RE-GRADED planned → live 2026-10-29 (#27478): the record page v3 body layout (#27055, PR #27473) draws the placement, and ruling B on #27478 narrowed the value to name BODY TABLES only — the group positions a list the FK already makes a body table (`relatedList: 'primary'`, or an inline-edited master_detail with no relatedList; `packages/spec/src/data/related-list-body-table.ts#isBodyTableRelationship`, the one predicate the record page and Studio read) and never promotes a side card. A name matching no inbound lookup / master_detail field, or naming a side card or a hidden list, is refused by `defineStack` and at the object publish door; Studio's group inspector offers body tables only. D3 entry `field-group-related-list-side-card-refused`." }, "visibleWhen": { "status": "live", diff --git a/packages/spec/liveness/state-counts/object.md b/packages/spec/liveness/state-counts/object.md index 7cc2f61f190..45d91fbf326 100644 --- a/packages/spec/liveness/state-counts/object.md +++ b/packages/spec/liveness/state-counts/object.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `object` | 76 | 0 | 0 | 1 | 1 | 78 | +| `object` | 77 | 0 | 0 | 1 | 0 | 78 | diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index f8912e8243b..fc388d94a58 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -385,6 +385,13 @@ export * from './injected-system-column-provenance'; // that writes `controlled_by_parent` asks the spec instead of restating it. export * from './controlled-by-parent-master'; +// related-list body-table predicate (#27478, ruling B on #27055) — the single +// source for "is this inbound relationship a body table of its parent's record +// page", and the refusal a field group's `relatedList` earns for naming one that +// is not. Read by `defineStack`, `@objectstack/lint` and the objectui record page +// and Studio, so the four cannot drift. +export * from './related-list-body-table'; + // Feed & Activity Protocol — retains only the UI activity-timeline config enums // (FeedItemType / FeedFilterMode); the feed backend contracts were retired (ADR-0052 §5). export * from './feed.zod'; diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 0e639262d3a..3e50383e5b5 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -1250,12 +1250,20 @@ export const ObjectFieldGroupSchema = lazySchema(() => strictObject({ * everywhere; a child that exists nowhere is refused at `defineStack` (save a * platform-prefixed name, left to the runtime) and by a metadata write that * names no package, and a write into a package stores it, placing nothing. - * Body tables no group places keep the record page's default position. `relatedList: false` on the FK - * still hides the table. + * It names a BODY TABLE only (`isBodyTableRelationship`, #27478): an inbound + * FK declaring `relatedList: 'primary'`, or a `master_detail` FK that sets + * `inlineEdit` and declares no `relatedList`. Body-ness is the FK's to + * decide; the group only positions it. A group naming a side card, or a list + * its FK hides with `relatedList: false`, draws nothing at the group, so it + * is refused wherever the child's fields are known: at `defineStack` and at + * the runtime metadata write door (`related-list-group-not-body`), naming + * each FK and the remedy (mark it `relatedList: 'primary'`, name only the + * body table as `object.field`, or remove the group's `relatedList`). + * Body tables no group places keep the record page's default position. */ relatedList: z.string().regex(RELATED_LIST_ENTRY_PATTERN, { message: 'A field group\'s relatedList is a child object name (e.g. "task") or object.field (e.g. "event.related_to"), in snake_case', - }).optional().describe('Places a related list\'s body table at this position in the record\'s details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. relatedList: false on the relationship still hides the table.').meta({ title: 'Related List' }), + }).optional().describe('Places a related list\'s body table at this position in the record\'s details column instead of a section of fields: names one inbound relationship, as a relatedLists entry does (child object, or object.field when that child has more than one relationship to this object). A group with relatedList holds no fields: a field that names it as its group is refused. The name is judged as a relatedLists entry is: refused when its child is in the stack with no inbound lookup / master_detail field, or exists nowhere (by os validate / os build, which leave a sys_ / cloud_ / ai_ name to the runtime, and by a metadata write that names no package); a write into a package stores it, and the record page places nothing for it. It names a body table only (a FK declaring relatedList: \'primary\', or a master_detail FK that sets inlineEdit and declares no relatedList): a group naming a side card or a list hidden by relatedList: false draws nothing, so it is refused where the child\'s fields are known (os validate / os build and the metadata write door), naming the FK and the remedy (set relatedList: \'primary\' on it, name only the body table as object.field when a bare child name also names a side card, or remove the group\'s relatedList).').meta({ title: 'Related List' }), /** * [ADR-0085 §5] Section visibility predicate — CEL, the ADR-0089 canonical diff --git a/packages/spec/src/data/related-list-body-table.test.ts b/packages/spec/src/data/related-list-body-table.test.ts new file mode 100644 index 00000000000..f5c4c08568c --- /dev/null +++ b/packages/spec/src/data/related-list-body-table.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27478, ruling B on #27055] The one "is this inbound relationship a body + * table" predicate, as a verdict table over the FK keys that decide it, and the + * refusal sentence `defineStack` and `@objectstack/lint` both write. + * + * The table is the record page's verdict as it stood before this predicate was + * extracted (objectui `isMainContentList`), with one cell moved on purpose: a + * `master_detail` FK that sets `inlineEdit` AND declares `relatedList: true` is + * a side card, as `relatedList`'s own description says ("write true to keep it + * a side card"); the page used to draw it as a body table. + */ +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { ObjectFieldGroupSchema } from './object.zod'; +import { + fieldGroupBodyTableRefusal, + isBodyTableRelationship, + type NamedInboundRelationship, +} from './related-list-body-table'; + +describe('isBodyTableRelationship — the verdict table', () => { + it.each([ + // type, relatedList, inlineEdit → body? + ['lookup', undefined, undefined, false], + ['lookup', true, undefined, false], + ['lookup', false, undefined, false], + ['lookup', 'primary', undefined, true], + ['lookup', undefined, 'grid', false], + ['master_detail', undefined, undefined, false], + ['master_detail', undefined, false, false], + ['master_detail', undefined, true, true], + ['master_detail', undefined, 'grid', true], + ['master_detail', undefined, 'form', true], + ['master_detail', 'primary', undefined, true], + ['master_detail', true, 'grid', false], + ['master_detail', false, 'grid', false], + ['text', 'primary', undefined, false], + ] as const)('%s · relatedList %s · inlineEdit %s → %s', (type, relatedList, inlineEdit, body) => { + const field: Record = { type }; + if (relatedList !== undefined) field.relatedList = relatedList; + if (inlineEdit !== undefined) field.inlineEdit = inlineEdit; + expect(isBodyTableRelationship(field)).toBe(body); + }); + + it('answers false for a missing field', () => { + expect(isBodyTableRelationship(undefined)).toBe(false); + expect(isBodyTableRelationship(null)).toBe(false); + }); +}); + +describe('fieldGroupBodyTableRefusal — the sentence both surfaces write', () => { + const side: NamedInboundRelationship = { child: 'task', field: 'lead_id', def: { type: 'lookup' } }; + const hidden: NamedInboundRelationship = { child: 'note', field: 'lead_id', def: { type: 'lookup', relatedList: false } }; + const body: NamedInboundRelationship = { child: 'line', field: 'lead_id', def: { type: 'master_detail', inlineEdit: 'grid' } }; + + it('is undefined when every named relationship is a body table, or none is named', () => { + expect(fieldGroupBodyTableRefusal('lead', 0, 'line', [body])).toBeUndefined(); + expect(fieldGroupBodyTableRefusal('lead', 0, 'nope', [])).toBeUndefined(); + }); + + it('names the position, the entry, each FK that is not a body table, and the remedy', () => { + expect(fieldGroupBodyTableRefusal('lead', 2, 'task', [side])).toBe( + "Object 'lead' fieldGroups[2].relatedList 'task' names a related list that is not a body table: " + + "'task.lead_id' is a side card. A field group's relatedList only positions a body table (a FK declaring " + + "relatedList: 'primary', or a master_detail FK that sets inlineEdit and declares no relatedList), so the " + + "record page draws nothing at the group for any other list. Set relatedList: 'primary' on that FK, or " + + "remove the group's relatedList.", + ); + expect(fieldGroupBodyTableRefusal('lead', 0, 'note', [hidden])).toContain("'note.lead_id' is hidden (relatedList: false)"); + }); + + it('points a mixed bare name at object.field for its body tables', () => { + const msg = fieldGroupBodyTableRefusal('lead', 0, 'event', [ + { ...side, child: 'event', field: 'who_id' }, + { ...hidden, child: 'event', field: 'what_id' }, + { ...body, child: 'event', field: 'related_to' }, + ]); + expect(msg).toContain("'event.who_id' is a side card, 'event.what_id' is hidden (relatedList: false)."); + expect(msg).toContain("Set relatedList: 'primary' on those FKs, name only the body table as object.field ('event.related_to'), or remove"); + }); +}); + +describe('fieldGroups[i].relatedList describes the body-table rule it enforces', () => { + const root = z.toJSONSchema(ObjectFieldGroupSchema as z.ZodType, { unrepresentable: 'any', io: 'input' }) as { + properties?: Record; + }; + const text = root.properties?.relatedList?.description ?? ''; + + it('says it names a body table only, and that any other list is refused with the remedy', () => { + expect(text).toContain("It names a body table only (a FK declaring relatedList: 'primary', or a master_detail FK that sets inlineEdit and declares no relatedList)"); + expect(text).toContain('a group naming a side card or a list hidden by relatedList: false draws nothing, so it is refused'); + expect(text).toContain("set relatedList: 'primary' on it, name only the body table as object.field when a bare child name also names a side card, or remove the group's relatedList"); + }); + + it('no longer says relatedList: false still hides a placed table', () => { + expect(text).not.toContain('still hides the table'); + }); +}); diff --git a/packages/spec/src/data/related-list-body-table.ts b/packages/spec/src/data/related-list-body-table.ts new file mode 100644 index 00000000000..20ad97a6b14 --- /dev/null +++ b/packages/spec/src/data/related-list-body-table.ts @@ -0,0 +1,102 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27478 · ruling B on #27055] The ONE predicate behind "is this inbound + * relationship a BODY TABLE of its parent's record page", and the one refusal + * a field group's `relatedList` earns when it names a relationship that is not. + * + * Body-ness is decided once, by the relationship's own keys on the child's FK + * field (`FieldSchema.relatedList`, `FieldSchema.inlineEdit`): + * + * - `relatedList: 'primary'` — the author marks the list part of the + * record's main content; + * - a `master_detail` FK that sets `inlineEdit` (`true`, `'grid'` or + * `'form'`) and declares NO `relatedList` — line items edited inside the + * parent's form are drawn under it by default; + * - anything else is a side card (`true` / absent), or hidden (`false`). + * `relatedList: true` on an inline-edited `master_detail` keeps it a side + * card, as the key's own description says. + * + * A field group's `relatedList` (`ObjectFieldGroupSchema.relatedList`) only + * POSITIONS a body table among the field sections; it never promotes a side + * card. Giving body-ness a second source on the group would let the two + * disagree (the FK saying side card, the group saying body), so a group that + * names a relationship this predicate does not count is refused, with + * {@link fieldGroupBodyTableRefusal}'s sentence, where the child's fields are + * known: `defineStack` (`os validate`, `os build`) and the runtime object + * publish door (`@objectstack/lint` `validate-related-list-refs`). + * + * Readers, one judgement (no second copy anywhere): + * - the record page: objectui `deriveRelatedLists` (`isPrimary`) and + * `RecordDetailView` (`isBody`), which decide which lists the synthesized + * page draws as body tables (`bodyTablePlacement`) and which as side cards; + * - Studio: the field-group inspector offers only body tables + * (`RelatedTablePlacement`), and the relationship field inspector reads an + * absent `relatedList` through it; + * - the refusal at `defineStack` and at the publish door. + * + * Read on either the PARSED field or a stored body: neither key carries a + * default, so an omitted key is `undefined` on both and the verdict agrees. + */ + +/** The FK keys body-ness is decided by — a child's `lookup` / `master_detail` field. */ +export interface RelatedListRelationshipFields { + readonly type?: unknown; + readonly relatedList?: unknown; + readonly inlineEdit?: unknown; +} + +/** + * Is the related list this FK field produces a BODY TABLE on its parent's + * record page? See the module header for the rule. A field that is not a + * `lookup` / `master_detail` produces no related list at all, so it is `false`. + */ +export function isBodyTableRelationship(field: RelatedListRelationshipFields | null | undefined): boolean { + if (!field || (field.type !== 'lookup' && field.type !== 'master_detail')) return false; + if (field.relatedList === 'primary') return true; + if (field.relatedList !== undefined) return false; + const inlineEdit = field.inlineEdit; + return field.type === 'master_detail' && (inlineEdit === true || inlineEdit === 'grid' || inlineEdit === 'form'); +} + +/** One inbound relationship a field group's `relatedList` names: the child, its FK, and the FK's keys. */ +export interface NamedInboundRelationship { + readonly child: string; + readonly field: string; + readonly def: RelatedListRelationshipFields; +} + +/** + * The refusal for `fieldGroups[index].relatedList` (`entry`) on `objectName`, + * given the inbound relationships the entry names (`named`, non-empty: an entry + * naming none is the unknown-name refusal's case). `undefined` when every one + * of them is a body table. `defineStack` and `@objectstack/lint` both write + * this sentence, so the two surfaces cannot word it apart. + * + * A bare child name names every FK of that child, so it is refused when ANY of + * them is not a body table; when one of them is, the prescription points at + * `object.field` for it. + */ +export function fieldGroupBodyTableRefusal( + objectName: string, + index: number, + entry: string, + named: readonly NamedInboundRelationship[], +): string | undefined { + const notBody = named.filter((r) => !isBodyTableRelationship(r.def)); + if (named.length === 0 || notBody.length === 0) return undefined; + const what = notBody + .map((r) => `'${r.child}.${r.field}' is ${r.def.relatedList === false ? 'hidden (relatedList: false)' : 'a side card'}`) + .join(', '); + const bodies = named.filter((r) => isBodyTableRelationship(r.def)); + const fk = notBody.length === 1 ? 'that FK' : 'those FKs'; + const pick = bodies.length > 0 + ? `, name only the body table as object.field (${bodies.map((r) => `'${r.child}.${r.field}'`).join(', ')})` + : ''; + return ( + `Object '${objectName}' fieldGroups[${index}].relatedList '${entry}' names a related list that is not a body table: ` + + `${what}. A field group's relatedList only positions a body table (a FK declaring relatedList: 'primary', ` + + `or a master_detail FK that sets inlineEdit and declares no relatedList), so the record page draws nothing ` + + `at the group for any other list. Set relatedList: 'primary' on ${fk}${pick}, or remove the group's relatedList.` + ); +} diff --git a/packages/spec/src/migrations/entries/semantic/18.field-group-related-list-side-card-refused.ts b/packages/spec/src/migrations/entries/semantic/18.field-group-related-list-side-card-refused.ts new file mode 100644 index 00000000000..dcc8fb701d3 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.field-group-related-list-side-card-refused.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #27478 (ruling B on #27055) — a field group's `relatedList` only POSITIONS a +// body table among the field sections; whether a related list is a body table +// is decided once, by the relationship's own FK keys +// (`isBodyTableRelationship`, `data/related-list-body-table.ts`). A group that +// names a side card or a hidden list is refused at `defineStack` and at the +// runtime object publish door (`related-list-group-not-body`). Semantic only, +// with no D2 conversion: which of the two remedies the author meant (promote +// the relationship, or drop the placement) is not recorded anywhere. +export const entry: SemanticMigration = { + id: 'field-group-related-list-side-card-refused', + // No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it + // inside a code span AND a table cell. + surface: + 'objects[].fieldGroups[].relatedList (data.ObjectFieldGroupSchema.relatedList) naming an inbound ' + + 'relationship that is not a body table — a lookup or master_detail FK on the child declaring ' + + 'relatedList true or false, a plain master_detail with no inlineEdit, a lookup with no relatedList, ' + + 'or a bare child name one of whose FKs is any of those', + replacement: + 'one of two edits, the author\'s choice. To keep the table at the group: make the relationship a ' + + 'body table on the child\'s FK — `relatedList: \'primary\'`, or for an inline-edited master_detail ' + + 'remove an explicit `relatedList: true` — or, for a bare child name whose other FK is a side card, ' + + 'name the body table\'s FK as `object.field`. To keep the list a side card: remove the group\'s ' + + '`relatedList` (and the group itself, if it holds nothing else)', + reason: + 'A field group\'s `relatedList` was described as placing a related list\'s body table at the ' + + 'group, but the record page draws only lists that ARE body tables by the FK\'s own keys ' + + '(`relatedList: \'primary\'`, or a master_detail FK that sets inlineEdit and declares no ' + + 'relatedList), so a group naming a side card or a hidden list parsed, published and drew nothing ' + + 'at the group — silently; Studio\'s group inspector offered every inbound relationship, so the ' + + 'public door produced it. Ruling B keeps body-ness in one place (the FK) instead of giving the ' + + 'group a second, disagreeing source, so the group is refused rather than honoured. The ' + + 'refusal is a cross-object judgement, not a parse: `defineStack` (os validate, os build) and the ' + + 'object publish door refuse it, naming each FK that is not a body table and the remedy. There is ' + + 'no D2 conversion: promoting the relationship changes every record page of the parent, and ' + + 'dropping the placement discards what the author wrote, so the edit is the author\'s. A STORED ' + + 'sys_metadata object row carrying such a group is not rewritten and still loads and registers ' + + 'unchanged (the object parse does not judge it); its record page draws the side card in the ' + + 'related column and nothing at the group, as before — except one shape the page used to read ' + + 'apart from the FK\'s own description: an inline-edited master_detail FK declaring relatedList ' + + 'true was drawn as a body table, and is a side card now, so a group placing it draws nothing at ' + + 'the group and the list moves to the related column — and the next ' + + 'save of that object through the metadata write door is refused 422 INVALID_METADATA at ' + + 'fieldGroups[i].relatedList until it is fixed. A write of the CHILD that turns a body table a ' + + 'stored group places into a side card is refused the same way. In-repo census before the change: ' + + 'no example, platform object, dogfood fixture or HotCRM object declared a field group relatedList ' + + 'at all; deployed metadata was NOT measured. ADR-0085 / ADR-0087', + acceptanceCriteria: + 'os validate (defineStack) passes with no STACK_CROSS_REFERENCE_INVALID issue reading "names a ' + + 'related list that is not a body table", and a save of each changed object through the metadata ' + + 'write door answers 200 instead of 422 related-list-group-not-body. On the record page, each group ' + + 'kept with a promoted relationship draws that body table at its position in the details column; ' + + 'each removed placement leaves the list a side card in the related column, as it was drawn ' + + 'before. A group naming a body table, and every relatedLists entry, parse and publish ' + + 'byte-identically to before.', +}; diff --git a/packages/spec/src/stack-related-lists-order.test.ts b/packages/spec/src/stack-related-lists-order.test.ts index cadfaa69836..50184da4be5 100644 --- a/packages/spec/src/stack-related-lists-order.test.ts +++ b/packages/spec/src/stack-related-lists-order.test.ts @@ -46,9 +46,15 @@ const event = { }, }; const note = { name: 'rlo_note', label: 'Note', fields: { title: { type: 'text' as const, label: 'Title' } } }; +// [#27478] A body table: an inline-edited master_detail child with no relatedList. +const line = { + name: 'rlo_line', + label: 'Line', + fields: { lead_id: { type: 'master_detail' as const, label: 'Lead', reference: 'rlo_lead', inlineEdit: 'grid' as const } }, +}; const stackOf = (parent: Record, extra: Record = {}) => - ({ manifest, objects: [parent, task, event, note], ...extra }) as any; + ({ manifest, objects: [parent, task, event, note, line], ...extra }) as any; const refusalOf = (fn: () => unknown): Refusal | undefined => { try { @@ -154,9 +160,87 @@ describe('defineStack — relatedLists names an inbound relationship', () => { it('judges a field group relatedList the same way', () => { const groups = (relatedList: string) => [{ key: 'lines', label: 'Lines', relatedList }]; - expect(() => defineStack(stackOf(lead({ fieldGroups: groups('rlo_task') })))).not.toThrow(); + expect(() => defineStack(stackOf(lead({ fieldGroups: groups('rlo_line') })))).not.toThrow(); const refusal = refusalOf(() => defineStack(stackOf(lead({ fieldGroups: groups('rlo_nope') })))); expect(refusal?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); expect(refusal?.issues?.[0]).toContain("Object 'rlo_lead' fieldGroups[0].relatedList 'rlo_nope'"); }); }); + +/** + * [#27478, ruling B on #27055] A field group's `relatedList` only POSITIONS a + * body table; body-ness is the FK's own keys (`isBodyTableRelationship`). A + * group naming a side card or a hidden list would draw nothing at the group, + * so `defineStack` refuses it with a prescription. Each refusal's control + * differs from it by the FK's keys or by the entry alone. + */ +describe('defineStack — a field group relatedList names body tables only', () => { + const groups = (relatedList: string) => [{ key: 'basics', label: 'Basics' }, { key: 'lines', label: 'Lines', relatedList }]; + const withChild = (child: Record, relatedList: string) => + ({ manifest, objects: [lead({ fieldGroups: groups(relatedList) }), child] }) as any; + const taskFk = (keys: Record) => ({ + ...task, + fields: { lead_id: { ...task.fields.lead_id, ...keys } }, + }); + const lineFk = (keys: Record) => ({ + ...line, + fields: { lead_id: { ...line.fields.lead_id, ...keys } }, + }); + + it('accepts a group naming a body table: relatedList primary, or an inline-edited master_detail (controls)', () => { + expect(() => defineStack(withChild(taskFk({ relatedList: 'primary' }), 'rlo_task'))).not.toThrow(); + expect(() => defineStack(withChild(line, 'rlo_line'))).not.toThrow(); + expect(() => defineStack(withChild(lineFk({ inlineEdit: true }), 'rlo_line.lead_id'))).not.toThrow(); + }); + + it('refuses a group naming a side card, with the FK and the prescription', () => { + const refusal = refusalOf(() => defineStack(withChild(task, 'rlo_task'))); + expect(refusal?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(typeof refusal?.status).toBe('number'); + expect(refusal?.issues).toHaveLength(1); + expect(refusal?.issues?.[0]).toContain( + "Object 'rlo_lead' fieldGroups[1].relatedList 'rlo_task' names a related list that is not a body table: 'rlo_task.lead_id' is a side card.", + ); + expect(refusal?.issues?.[0]).toContain("Set relatedList: 'primary' on that FK, or remove the group's relatedList."); + }); + + it("refuses an inline-edited master_detail that keeps itself a side card with relatedList: true", () => { + const refusal = refusalOf(() => defineStack(withChild(lineFk({ relatedList: true }), 'rlo_line'))); + expect(refusal?.code).toBe('STACK_CROSS_REFERENCE_INVALID'); + expect(refusal?.issues).toEqual([expect.stringContaining("'rlo_line.lead_id' is a side card")]); + }); + + it('refuses a plain master_detail (no inlineEdit) and a hidden list, naming which', () => { + expect(refusalOf(() => defineStack(withChild(lineFk({ inlineEdit: undefined }), 'rlo_line')))?.issues) + .toEqual([expect.stringContaining("'rlo_line.lead_id' is a side card")]); + expect(refusalOf(() => defineStack(withChild(taskFk({ relatedList: false }), 'rlo_task')))?.issues) + .toEqual([expect.stringContaining("'rlo_task.lead_id' is hidden (relatedList: false)")]); + }); + + it('refuses a bare child name when any FK it names is not a body table, pointing at object.field', () => { + const mixed = { + ...event, + fields: { ...event.fields, related_to: { ...event.fields.related_to, relatedList: 'primary' } }, + }; + const refusal = refusalOf(() => defineStack(withChild(mixed, 'rlo_event'))); + expect(refusal?.issues).toHaveLength(1); + expect(refusal?.issues?.[0]).toContain("'rlo_event.who_id' is a side card"); + expect(refusal?.issues?.[0]).toContain("name only the body table as object.field ('rlo_event.related_to')"); + // Control: the one FK that is a body table, named as object.field. + expect(() => defineStack(withChild(mixed, 'rlo_event.related_to'))).not.toThrow(); + }); + + it('does not judge relatedLists entries this way: they order side cards too (control)', () => { + expect(() => defineStack(stackOf(lead({ relatedLists: ['rlo_task', 'rlo_line'] })))).not.toThrow(); + }); + + it('leaves a group naming a child it cannot see unjudged, as the name check does', () => { + expect(() => defineStack(stackOf(lead({ fieldGroups: groups('rlo_sibling') })), { artifactObjects: ['rlo_sibling'] })).not.toThrow(); + }); + + it('a name matching nothing is the unknown-name refusal alone, not also this one', () => { + const refusal = refusalOf(() => defineStack(stackOf(lead({ fieldGroups: groups('rlo_note') })))); + expect(refusal?.issues).toHaveLength(1); + expect(refusal?.issues?.[0]).toContain('matches no inbound relationship'); + }); +}); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 36d5974b451..f2dd6fb42d9 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -38,6 +38,7 @@ import { lintUnknownAuthoringKeys, lintUnknownStackKeys } from './kernel/metadat // Data Protocol import { ObjectSchema, ObjectExtensionSchema } from './data/object.zod'; import { controlledByParentMasterRefusal } from './data/controlled-by-parent-master'; +import { fieldGroupBodyTableRefusal, type NamedInboundRelationship } from './data/related-list-body-table'; import { externalBindingDatasourceRefusal } from './data/external-binding-datasource'; import { InlineGridColumnSchema } from './data/field.zod'; import { pageComponentSlotPositions } from './ui/component.zod'; @@ -3846,6 +3847,14 @@ const RELATED_LIST_AUDIT_FKS: ReadonlySet = new Set(['created_by', 'upda * * The message names the entry and every inbound relationship that exists, * spelled the way an entry may name it. + * + * [#27478] A field group's `relatedList` that DOES name inbound relationships + * must name body tables only (`isBodyTableRelationship`, ruling B on #27055): + * the group positions a body table and never promotes a side card, so one that + * names a side card or a hidden list would draw nothing at the group. Judged + * here for the same reason — the FK's keys live on the child — and on the same + * children: a child left unjudged above is left unjudged here. The sentence is + * `fieldGroupBodyTableRefusal`'s, which `@objectstack/lint` writes too. */ function collectRelatedListNameErrors( config: ObjectStackDefinition, @@ -3884,14 +3893,14 @@ function collectRelatedListNameErrors( } // Parent name → its inbound relationships, in child then field order. - const inbound = new Map>(); + const inbound = new Map(); for (const [child, fields] of fieldsByObject) { for (const [field, def] of Object.entries(fields)) { const fieldDef = asRecord(def); if (!fieldDef || (fieldDef.type !== 'lookup' && fieldDef.type !== 'master_detail')) continue; if (typeof fieldDef.reference !== 'string' || RELATED_LIST_AUDIT_FKS.has(field)) continue; const list = inbound.get(fieldDef.reference) ?? []; - list.push({ child, field }); + list.push({ child, field, def: fieldDef }); inbound.set(fieldDef.reference, list); } } @@ -3913,12 +3922,17 @@ function collectRelatedListNameErrors( if (seen.has(parentName)) continue; seen.add(parentName); const relationships = inbound.get(parentName) ?? []; - const judge = (entry: unknown, where: string): void => { - if (typeof entry !== 'string') return; + // The inbound relationships an entry names: every FK of a bare child, or the one `object.field`. + const namedBy = (entry: string): NamedInboundRelationship[] => { const dot = entry.indexOf('.'); const child = dot === -1 ? entry : entry.slice(0, dot); const field = dot === -1 ? undefined : entry.slice(dot + 1); - if (relationships.some((r) => r.child === child && (field === undefined || r.field === field))) return; + return relationships.filter((r) => r.child === child && (field === undefined || r.field === field)); + }; + const judge = (entry: unknown, where: string): void => { + if (typeof entry !== 'string') return; + const child = entry.includes('.') ? entry.slice(0, entry.indexOf('.')) : entry; + if (namedBy(entry).length > 0) return; if (!fieldsByObject.has(child) && (artifactScope.has(child) || isPlatformObjectName(child))) return; errors.push( `Object '${parentName}' ${where} '${entry}' matches no inbound relationship of '${parentName}' ` + @@ -3932,7 +3946,11 @@ function collectRelatedListNameErrors( if (Array.isArray(parent.fieldGroups)) { parent.fieldGroups.forEach((group: unknown, i: number) => { const record = asRecord(group); - if (record?.relatedList !== undefined) judge(record.relatedList, `fieldGroups[${i}].relatedList`); + if (record?.relatedList === undefined) return; + judge(record.relatedList, `fieldGroups[${i}].relatedList`); + if (typeof record.relatedList !== 'string') return; + const refusal = fieldGroupBodyTableRefusal(parentName, i, record.relatedList, namedBy(record.relatedList)); + if (refusal !== undefined) errors.push(refusal); }); } } diff --git a/ui/packages/app-shell/src/utils/deriveRelatedLists.ts b/ui/packages/app-shell/src/utils/deriveRelatedLists.ts index a7b64507a33..db471d031a6 100644 --- a/ui/packages/app-shell/src/utils/deriveRelatedLists.ts +++ b/ui/packages/app-shell/src/utils/deriveRelatedLists.ts @@ -70,6 +70,7 @@ * server-side; this closes the UI/DX gap. */ +import { isBodyTableRelationship } from '@objectstack/spec/data'; import { convertSortToQueryParams } from '@object-ui/core'; import { relatedListEntryNames } from '@object-ui/plugin-detail'; @@ -113,10 +114,13 @@ export interface DerivedRelatedList { /** True when the child→parent link is a `master_detail` (owned) relationship. */ isOwned: boolean; /** - * True when the list is part of the record's MAIN CONTENT: the FK declares - * `relatedList: 'primary'`, or it is a `master_detail` FK that sets - * `inlineEdit` and declares no `relatedList` at all (objectstack#27069 — line - * items edited inside the parent's form are drawn under it, not beside it). + * True when the list is part of the record's MAIN CONTENT — a body table: the + * FK declares `relatedList: 'primary'`, or it is a `master_detail` FK that + * sets `inlineEdit` and declares no `relatedList` at all (objectstack#27069 — + * line items edited inside the parent's form are drawn under it, not beside + * it). The verdict is `@objectstack/spec`'s `isBodyTableRelationship` + * (objectstack#27478), the one predicate the spec's refusal of a field group + * naming a side card reads too. * A placement, not an order: the body draws primary lists as full-width * tables, the tabbed page gives each its OWN tab, and non-primary lists are * the side cards (a single "Related" tab on the tabbed page). @@ -237,22 +241,6 @@ function isDeclaredFilter(value: unknown): value is Record { ); } -/** - * Is this FK's list part of the record's main content (`isPrimary`)? - * - * `relatedList: 'primary'` says so explicitly. Left unset, a `master_detail` - * FK that sets `inlineEdit` (any arm: `true`, `'grid'`, `'form'`) is primary - * by default — the spec's own reading of the key (objectstack#27069). An - * explicit `true` is the author keeping the list a side card, so the default - * applies only to an ABSENT `relatedList`. - */ -export function isMainContentList(type: string, fieldDef: Record): boolean { - if (fieldDef.relatedList === 'primary') return true; - if (fieldDef.relatedList !== undefined) return false; - const inlineEdit = fieldDef.inlineEdit; - return type === 'master_detail' && (inlineEdit === true || inlineEdit === 'grid' || inlineEdit === 'form'); -} - /** * The ONE resolution of related-list order (objectstack#27069): the parent's * declared `relatedLists` first, in the declared order; every list no entry @@ -392,7 +380,7 @@ export function deriveRelatedLists( childLabel, referenceField: fieldName, isOwned: type === 'master_detail', - isPrimary: isMainContentList(type, fieldDef), + isPrimary: isBodyTableRelationship(fieldDef), ...(inheritedSort ? { sort: inheritedSort } : {}), ...(hidden ? { isHidden: true as const } : {}), _fkLabel: (typeof fieldDef.label === 'string' && fieldDef.label) || fieldName, diff --git a/ui/packages/app-shell/src/views/RecordDetailView.bodyTablePredicate-27478.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.bodyTablePredicate-27478.test.tsx new file mode 100644 index 00000000000..7f1abd59170 --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.bodyTablePredicate-27478.test.tsx @@ -0,0 +1,186 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectstack#27478 (ruling B on objectstack#27055) — which related lists the + * record page draws as BODY TABLES is `@objectstack/spec`'s + * `isBodyTableRelationship`, the predicate the spec's refusal of a field group + * naming a side card reads too: `relatedList: 'primary'`, or a `master_detail` + * FK that sets `inlineEdit` and declares no `relatedList`. + * + * Behaviour change pinned here: an inline-edited `master_detail` FK that + * declares `relatedList: true` is a SIDE CARD, as the key's description says + * ("write true to keep it a side card"). The page used to draw it as a body + * table, because its own copy of the rule ignored `relatedList`. + * + * Measured from a real `RecordDetailView` mount at three columns (the + * `relatedListsOrder-27241` harness). + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { MetadataCtx } from '@object-ui/react'; +import { RelatedCountStore } from '@object-ui/components'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +// Orthogonal chrome — stubbed so the only asynchrony here is the page's own. +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { RecordDetailView } from './RecordDetailView'; + +const PARENT = 'showcase_quote'; +const RECORD_ID = 'q-1'; +const BASE_FIELDS = { id: { type: 'text', label: 'Id' }, name: { type: 'text', label: 'Name' } }; + +const objectsWith = (taskFk: Record) => [ + { + name: PARENT, + label: 'Quote', + pluralLabel: 'Quotes', + managedBy: 'platform', + fields: { ...BASE_FIELDS, name: { type: 'text', label: 'Name', group: 'basic' } }, + fieldGroups: [ + { key: 'basic', label: 'Basic' }, + { key: 'lines', label: 'Lines', relatedList: 'showcase_line' }, + ], + }, + { + name: 'showcase_line', + label: 'Line', + pluralLabel: 'Lines', + managedBy: 'platform', + fields: { ...BASE_FIELDS, quote: { type: 'master_detail', reference: PARENT, label: 'Quote', inlineEdit: 'grid' } }, + }, + { + name: 'showcase_task', + label: 'Task', + pluralLabel: 'Tasks', + managedBy: 'platform', + fields: { ...BASE_FIELDS, quote: { type: 'master_detail', reference: PARENT, label: 'Quote', inlineEdit: 'grid', ...taskFk } }, + }, + { + name: 'showcase_note', + label: 'Note', + pluralLabel: 'Notes', + managedBy: 'platform', + fields: { ...BASE_FIELDS, quote: { type: 'lookup', reference: PARENT, label: 'Quote' } }, + }, +]; + +function renderPage(objects: ReturnType) { + const metadata = { + objects, + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + } as never; + const dataSource = { + find: vi.fn(async () => ({ data: [], total: 0 })), + create: vi.fn(async (_o: string, row: unknown) => row), + findOne: vi.fn(async (_o: string, recordId: string) => ({ id: recordId, name: `Quote ${recordId}` })), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + }; + return render( + + + {}} + objectNameOverride={PARENT} + recordIdOverride={RECORD_ID} + /> + + , + ); +} + +const cardOrder = (container: HTMLElement) => + Array.from(container.querySelectorAll('[data-record-column="related"] [data-related-card-id]')).map((el) => + el.getAttribute('data-related-card-id'), + ); + +beforeEach(() => { + cleanup(); + window.localStorage.clear(); + RelatedCountStore._reset(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); + const measure = HTMLElement.prototype.getBoundingClientRect; + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (!this.hasAttribute('data-record-page-root')) return measure.call(this); + return { x: 0, y: 0, top: 0, left: 0, right: 1440, bottom: 800, width: 1440, height: 800, toJSON: () => ({}) } as DOMRect; + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const detailsText = (container: HTMLElement) => + container.querySelector('[data-record-column="details"]')?.textContent ?? ''; + +describe('RecordDetailView — body tables are the spec predicate\'s verdict (objectstack#27478)', () => { + it('an inline-edited master_detail with no relatedList is a body table; one with relatedList: true is a side card', async () => { + const { container } = renderPage(objectsWith({ relatedList: true })); + await waitFor(() => expect(cardOrder(container)).toHaveLength(2), { timeout: 5000 }); + expect(cardOrder(container)).toEqual(['showcase_task.quote', 'showcase_note.quote']); + expect(detailsText(container)).toContain('Lines'); + expect(detailsText(container)).not.toContain('Tasks'); + }); + + it('control: the same FK with no relatedList is a body table, not a card', async () => { + const { container } = renderPage(objectsWith({})); + await waitFor(() => expect(cardOrder(container)).toHaveLength(1), { timeout: 5000 }); + expect(cardOrder(container)).toEqual(['showcase_note.quote']); + await waitFor(() => expect(detailsText(container)).toContain('Tasks'), { timeout: 5000 }); + expect(detailsText(container)).toContain('Lines'); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 7ea439d2969..e6173fbc6e4 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -3448,14 +3448,15 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri ]; const related = orderedRelations.map(({ childObject, childLabel, referenceField, title: titleOverride, columns: columnsOverride, isPrimary, sort: inheritedSort, filter: declaredFilter }) => { const childObjectDef = objects.find((o: any) => o.name === childObject); - // objectstack#27055, Target 8 — the record's own BODY: a `master_detail` - // FK declaring `inlineEdit` (the child is edited as part of the parent), - // or a list the author marks `relatedList: 'primary'`. The two keys the - // spec already has; no key of its own. - const fkDef = (childObjectDef as any)?.fields?.[referenceField]; - const isBody = - isPrimary || - (fkDef?.type === 'master_detail' && (fkDef.inlineEdit === true || fkDef.inlineEdit === 'grid' || fkDef.inlineEdit === 'form')); + // objectstack#27055, Target 8 — the record's own BODY: a list the author + // marks `relatedList: 'primary'`, or a `master_detail` FK declaring + // `inlineEdit` (the child is edited as part of the parent) and no + // `relatedList` — `relatedList: true` keeps it a side card. That is + // `isPrimary`: `deriveRelatedLists` sets it from `@objectstack/spec`'s + // `isBodyTableRelationship` (objectstack#27478), the one predicate the + // spec's refusal of a field group naming a side card reads too, so no + // second copy of the rule lives here. + const isBody = isPrimary; // A `relatedListTitle` on the relationship wins; else fall back to the // localized child-object PLURAL label — the section lists the child's // records, so it is named as that object's list page is (objectui#11733). diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx index f5ff3e8a0dc..acb9cdaa257 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/ObjectFieldInspector.tsx @@ -50,6 +50,7 @@ import * as React from 'react'; import { FieldSchema, + isBodyTableRelationship, MULTI_OPTION_TYPES, NUMERIC_VALUE_TYPES, SelectOptionSchema, @@ -126,7 +127,6 @@ import { t, tFormat, type SupportedLocale } from '../i18n.js'; import { FilterConditionField } from './FilterConditionField.js'; import type { FilterCondition } from './datasetFilterCondition.js'; import { usePickerLoad, type LoadState } from '../loadState.js'; -import { isMainContentList } from '../../../utils/deriveRelatedLists.js'; import { fieldNameTakenRefusal } from '../../studio-design/AddFieldPopover.js'; @@ -2265,7 +2265,8 @@ type RelatedListMode = 'shown' | 'hidden' | 'primary'; /** * The stored `relatedList` as one of the control's three states. `true` reads * as `shown`. ABSENT reads as the relationship's default, which the record - * page's own predicate decides (`isMainContentList`, objectstack#27069): a + * page's own predicate decides (`@objectstack/spec`'s `isBodyTableRelationship`, + * objectstack#27069 / objectstack#27478): a * `master_detail` FK that sets `inlineEdit` is primary by default, every other * FK is shown. Without `field` an absent value reads as `shown`. */ @@ -2276,7 +2277,7 @@ export function readRelatedListMode( if (value === false) return 'hidden'; if (value === 'primary') return 'primary'; if (value === undefined && field && typeof field.type === 'string' - && isMainContentList(field.type, { inlineEdit: field.inlineEdit })) return 'primary'; + && isBodyTableRelationship({ type: field.type, inlineEdit: field.inlineEdit })) return 'primary'; return 'shown'; } diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectGroupInspector.relatedListPlacement-27054.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectGroupInspector.relatedListPlacement-27054.test.tsx index 64a32c99571..df59f7ed7c6 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectGroupInspector.relatedListPlacement-27054.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectGroupInspector.relatedListPlacement-27054.test.tsx @@ -27,12 +27,15 @@ afterEach(cleanup); type Row = Record; const quote: Row = { name: 'quote', label: 'Quote', fields: { name: { type: 'text', label: 'Name' } } }; -const lineItem: Row = { name: 'quote_line', label: 'Quote Line', fields: { quote: { type: 'master_detail', reference: 'quote', label: 'Quote' } } }; +// Body tables (objectstack#27478): an inline-edited master_detail child, and a +// lookup marked relatedList: 'primary'. The plain lookup `task` is a side card. +const lineItem: Row = { name: 'quote_line', label: 'Quote Line', fields: { quote: { type: 'master_detail', reference: 'quote', label: 'Quote', inlineEdit: 'grid' } } }; +const schedule: Row = { name: 'schedule', label: 'Schedule', fields: { quote_id: { type: 'lookup', reference: 'quote', label: 'Quote', relatedList: 'primary' } } }; const task: Row = { name: 'task', label: 'Task', fields: { quote_id: { type: 'lookup', reference: 'quote', label: 'Quote' } } }; const STORE: MetadataContextValue = { apps: [], - objects: [quote, lineItem, task], + objects: [quote, lineItem, schedule, task], dashboards: [], reports: [], pages: [], @@ -59,15 +62,17 @@ const placementPicker = () => within(screen.getByTestId('group-related-table')).getByRole('combobox'); describe('ObjectGroupInspector — a group places a related table', () => { - it('offers the inbound relationships and writes the picked one as relatedList', async () => { + it('offers the inbound relationships that are body tables, and writes the picked one as relatedList', async () => { const draft = { ...quote, fieldGroups: [{ key: 'basics', label: 'Basics' }, { key: 'lines', label: 'Lines' }] }; const onPatch = renderInspector(draft, 'lines'); fireEvent.keyDown(placementPicker(), { key: 'ArrowDown' }); const options = within(await screen.findByRole('listbox')).getAllByRole('option').map((o) => o.textContent); + // objectstack#27478: the side card `task` is not offered — a group only + // positions a body table. expect(options).toEqual([ t('engine.studio.designer.group.relatedListNone', 'en-US'), 'Quote Line (quote_line)', - 'Task (task)', + 'Schedule (schedule)', ]); fireEvent.click(screen.getByRole('option', { name: 'Quote Line (quote_line)' })); expect(onPatch.mock.calls.at(-1)![0].fieldGroups).toEqual([ @@ -85,7 +90,20 @@ describe('ObjectGroupInspector — a group places a related table', () => { fireEvent.keyDown(placementPicker(), { key: 'ArrowDown' }); const options = within(await screen.findByRole('listbox')).getAllByRole('option').map((o) => o.textContent); expect(options).not.toContain('Quote Line (quote_line)'); - expect(options).toContain('Task (task)'); + expect(options).toContain('Schedule (schedule)'); + }); + + it('shows a stored side-card value with a line saying why it draws nothing (objectstack#27478)', () => { + const draft = { ...quote, fieldGroups: [{ key: 'lines', label: 'Lines', relatedList: 'task' }] }; + renderInspector(draft, 'lines'); + expect(screen.getByTestId('group-related-table-not-body')).toHaveTextContent('“task” is not a body table'); + expect(placementPicker()).toHaveTextContent('task'); + }); + + it('a stored body-table value draws no such line (control)', () => { + const draft = { ...quote, fieldGroups: [{ key: 'lines', label: 'Lines', relatedList: 'quote_line' }] }; + renderInspector(draft, 'lines'); + expect(screen.queryByTestId('group-related-table-not-body')).toBeNull(); }); it('withholds the picker while the group still holds fields', () => { diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectGroupInspector.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectGroupInspector.tsx index ea2c7fc8ce9..b459954447a 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectGroupInspector.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectGroupInspector.tsx @@ -25,7 +25,7 @@ import { InspectorEmptyState, } from '../metadata-admin/inspectors/_shared.js'; import { readFields, readGroups, updateGroup } from '../metadata-admin/previews/object-fields-io.js'; -import { t } from '../metadata-admin/i18n.js'; +import { t, tFormat } from '../metadata-admin/i18n.js'; import { acceptRelatedListEntries, relationshipEntry, @@ -139,6 +139,7 @@ export function ObjectGroupInspector({ onPatch={onPatch} readOnly={readOnly} tr={tr} + locale={locale} /> ); @@ -151,9 +152,17 @@ export function ObjectGroupInspector({ * draws that table at the group's position in the details column. * * What the picker can write is held to what the spec accepts: - * - only inbound relationships are offered, and a pick is checked against - * them before it is written (a name matching none is refused at - * `defineStack` and by the publish door's lint); + * - only inbound relationships that are BODY TABLES are offered + * (objectstack#27478): a group positions a body table and never promotes a + * side card, so a relationship whose FK keeps it a side card, or hides it, + * is not offered. Body-ness is the record page's own verdict (`isPrimary`, + * `@objectstack/spec`'s `isBodyTableRelationship`), the predicate the spec's + * refusal reads too. A pick is checked against them before it is written (a + * name matching none, or naming a list that is not a body table, is refused + * at `defineStack` and by the publish door's lint); + * - a stored value naming a list that is not a body table stays visible, with + * a line saying why the page draws nothing for it, so the author can clear + * it or mark the relationship primary; * - a group that still holds fields cannot be made a table (the object parse * refuses a field whose group is a table group), so the picker is withheld * until its fields move elsewhere; @@ -168,6 +177,7 @@ function RelatedTablePlacement({ onPatch, readOnly, tr, + locale, }: { groups: ReturnType; group: ReturnType[number]; @@ -177,6 +187,7 @@ function RelatedTablePlacement({ onPatch: (patch: Record) => void; readOnly: boolean; tr: (key: string) => string; + locale?: string; }): React.ReactElement { const current = typeof group.relatedList === 'string' ? group.relatedList : ''; const placedElsewhere = new Set( @@ -184,20 +195,34 @@ function RelatedTablePlacement({ .filter((g) => g.key !== group.key && typeof g.relatedList === 'string') .flatMap((g) => relationshipsNamedBy(g.relatedList as string, relationships)), ); + // Every relationship an entry names must be a body table (a bare child name + // names all of that child's FKs); the spec refuses the entry otherwise. + const namesBodyTablesOnly = (entry: string) => { + const named = relationshipsNamedBy(entry, relationships); + return named.length > 0 && named.every((r) => r.isPrimary); + }; const options = relationships + .filter((r) => r.isPrimary) .filter((r) => !placedElsewhere.has(r) || relationshipsNamedBy(current, relationships).includes(r)) .map((r) => { const entry = relationshipEntry(r, relationships); return { value: entry, label: `${relationshipLabel(r)} (${entry})` }; }); + // A stored value the picker would not offer — it names a side card or a + // hidden list — is still shown, so the select does not read as "None". + const currentNotBody = ready && current !== '' && relationshipsNamedBy(current, relationships).length > 0 + && !namesBodyTablesOnly(current); + if (currentNotBody && !options.some((o) => o.value === current)) options.push({ value: current, label: current }); const blockedByFields = memberCount > 0; const commit = (v: string) => { if (!v) { onPatch({ fieldGroups: updateGroup(groups, group.key, { relatedList: undefined }) }); return; } - // The pre-write check, shared with the relatedLists editor. + // The pre-write check, shared with the relatedLists editor, plus the body + // table rule a group's entry answers to. if (blockedByFields || acceptRelatedListEntries([v], relationships).length === 0) return; + if (!namesBodyTablesOnly(v)) return; onPatch({ fieldGroups: updateGroup(groups, group.key, { relatedList: v }) }); }; return ( @@ -216,6 +241,11 @@ function RelatedTablePlacement({ ? tr('engine.studio.designer.group.relatedListHasFields') : tr('engine.studio.designer.group.relatedListHint')}

+ {currentNotBody && ( +

+ {tFormat('engine.studio.designer.group.relatedListNotBody', locale, { entry: current })} +

+ )} ); } diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index dea9ae057c2..bcbbd0cc955 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3324,7 +3324,8 @@ const STUDIO_EN: Record = { 'engine.studio.designer.group.collapseHint': 'Controls how this group appears in the actual form (see Preview).', 'engine.studio.designer.group.relatedListLabel': 'Place a related table here (relatedList)', 'engine.studio.designer.group.relatedListNone': 'None — a section of fields', - 'engine.studio.designer.group.relatedListHint': 'The group then stands for that related list\'s table, drawn at this position in the record\'s details column. A group that places a table holds no fields.', + 'engine.studio.designer.group.relatedListHint': 'The group then stands for that related list\'s table, drawn at this position in the record\'s details column. Only body tables are offered: a relationship marked Primary (in body), or an inline-edited master-detail child. A group that places a table holds no fields.', + 'engine.studio.designer.group.relatedListNotBody': '“{entry}” is not a body table (a side card or a hidden list), so the record page draws nothing here and publishing refuses it. Mark that relationship Primary (in body), or pick None.', 'engine.studio.designer.group.relatedListHasFields': 'Move this group\'s fields to another group first: a group that places a related table holds no fields.', 'engine.studio.designer.group.relatedListLoading': 'Loading the objects that point at this one…', // objectui#11788 — the New automation dialog's trigger choice, left unset. diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 3f772523ddb..36cf33d1791 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2961,7 +2961,8 @@ const STUDIO_ZH: Record = { 'engine.studio.designer.group.collapseHint': '控制该分组在真实表单(预览)中的折叠方式。', 'engine.studio.designer.group.relatedListLabel': '在此放置关联表(relatedList)', 'engine.studio.designer.group.relatedListNone': '无 —— 一组字段', - 'engine.studio.designer.group.relatedListHint': '该分组将代表这张关联列表的表格,显示在记录详情栏的这个位置。放置表格的分组不含字段。', + 'engine.studio.designer.group.relatedListHint': '该分组将代表这张关联列表的表格,显示在记录详情栏的这个位置。只列出正文表格:标为「主要(进正文)」的关系,或内嵌编辑的主从子对象。放置表格的分组不含字段。', + 'engine.studio.designer.group.relatedListNotBody': '“{entry}”不是正文表格(是侧栏卡片或已隐藏的列表),记录页不会在此绘制任何内容,发布时会被拒绝。请把该关系标为「主要(进正文)」,或选择「无」。', 'engine.studio.designer.group.relatedListHasFields': '请先把本分组的字段移到其他分组:放置关联表的分组不含字段。', 'engine.studio.designer.group.relatedListLoading': '正在加载指向本对象的对象…', // objectui#11788 — 新建自动化对话框里不选触发方式时的选项。 diff --git a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts index 55bd0342417..162ba50ecef 100644 --- a/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts +++ b/ui/packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.bodyTables-27055.test.ts @@ -154,6 +154,14 @@ describe('a field group places a body table at its position (objectstack#27069, ]); }); + it('a group only positions a body table: naming a side card promotes nothing (objectstack#27478)', () => { + // The spec refuses this group (`isBodyTableRelationship`, ruling B on + // objectstack#27055); the renderer never second-guesses body-ness either. + const tabs = buildDefaultTabs(placed('task'), { related: [LINES, TASKS] }); + expect(shape(items(tabs)[0].children)).toEqual(['details(basic)', 'table(quote_line_item)', 'details(terms,notes)']); + expect(items(tabs)[1].children.map((n: any) => n.properties.objectName)).toEqual(['task']); + }); + it('the host-passed sections place the same way (the `{ group }` sections RecordDetailView passes)', () => { const def = placed('quote_line_item'); // The `{ group }` reference form, as the host writes it (the options type diff --git a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts index 7736561f626..e8dd06da325 100644 --- a/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts +++ b/ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts @@ -230,12 +230,14 @@ export interface BuildPageOptions { */ isPrimary?: boolean; /** - * The list is the record's own body (objectstack#27055, Target 8): a - * `master_detail` FK declaring `inlineEdit`, or `relatedList: 'primary'`. - * Drawn as a full-width table section in the Details tab, right after the - * first field section, instead of in the Related tab / related column. - * Absent: `isPrimary` decides. The host computes it from the FK (no spec - * key of its own). + * The list is the record's own body (objectstack#27055, Target 8): + * `relatedList: 'primary'`, or a `master_detail` FK declaring `inlineEdit` + * and no `relatedList`. Drawn as a full-width table section in the Details + * tab, right after the first field section, instead of in the Related tab / + * related column. Absent: `isPrimary` decides. The host computes it from + * the FK with `@objectstack/spec`'s `isBodyTableRelationship` + * (objectstack#27478) — the one predicate, which the spec's refusal of a + * field group naming a side card reads too (no spec key of its own). */ isBody?: boolean; }>; @@ -920,7 +922,10 @@ export const BODY_TABLE_PAGE_SIZE = 10; * among the object's `fieldGroups`: after the sections of the groups * declared before it, before those declared after it. Such a group holds * no fields (the spec refuses one that does), so it is a position, never a - * section of its own; + * section of its own. A group only POSITIONS a body table: only the + * lists in `bodies` are placed, and a side card is never among them (objectstack#27478: + * the spec refuses a group naming a side card or a hidden list, by the same + * `isBodyTableRelationship` the host computes `isBody` with); * - every other body table goes right after the FIRST field section * (conventionally 基本信息), stacked in the order given; with one section * or none, after the details.