diff --git a/.changeset/27054-related-lists-editor.md b/.changeset/27054-related-lists-editor.md new file mode 100644 index 00000000000..d7f3349f621 --- /dev/null +++ b/.changeset/27054-related-lists-editor.md @@ -0,0 +1,15 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/console': patch +'@object-ui/i18n': patch +--- + +feat(app-shell): Studio orders an object's related lists, and a field group can place a related table + +Clause-②: no +Metadata-protocol: none + +- **Object settings, `relatedLists`.** The Settings tab lists the object's inbound relationships (each `lookup` / `master_detail` field of a child object that points at it) as an ordered list: drag a row or use the move buttons to reorder, and add a relationship from the picker. An entry is the child object's name, or `object.field` when that child points at the object through more than one field. Relationships no entry names follow, greyed and not draggable, in the default order (owned children first, then lookups). The list orders only: a relationship hidden with `relatedList: false` is listed and marked hidden, and the switch stays on the field. A preview shows the record page's resulting order, split into body tables and side cards. Removing the last entry removes the key. +- **Every write is checked first.** An entry that names no inbound relationship is never written, since `defineStack` and the publish door refuse it. A stored one keeps its row, marked, and the next change to the list leaves it out. Nothing is written until the object list has loaded. +- **Field groups.** The group inspector in the form layout designer has a "Place a related table here" picker that writes the group's `relatedList` from the same relationships. It is withheld while the group still holds fields, and it does not offer a relationship another group already places. In the designer, a group that places a table is drawn in its place as that table and accepts no dropped fields. +- **Shared ordered list.** The highlight fields editor and the related lists editor draw the same draggable, keyboard-movable rows. diff --git a/ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts b/ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts index e81292aefc8..0214b8c763d 100644 --- a/ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts +++ b/ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts @@ -100,6 +100,10 @@ const OBJECTS_READERS: Record = { 'packages/app-shell/src/views/metadata-admin/previews/ScreenPreview.tsx': { kind: 'off', why: "the metadata admin's form-screen preview" }, 'packages/app-shell/src/views/studio-design/ObjectListViewInspector.tsx': { kind: 'off', why: 'Studio' }, 'packages/app-shell/src/views/studio-design/studio-canvas-preview.tsx': { kind: 'off', why: 'Studio' }, + 'packages/app-shell/src/views/studio-design/relatedListVocabulary.ts': { + kind: 'off', + why: "Studio: an object's inbound relationships, read while its Settings relatedLists editor or a field group's related-table picker is mounted", + }, 'packages/plugin-designer/src/pages/CreateAppPage.tsx': { kind: 'off', why: "the app designer's object pickers" }, 'packages/plugin-designer/src/pages/EditAppPage.tsx': { kind: 'off', why: "the app designer's object pickers" }, 'apps/console/src/pages/developer/FlowRunsPage.tsx': { kind: 'off', why: 'the flow runs page' }, diff --git a/ui/packages/app-shell/src/utils/deriveRelatedLists.ts b/ui/packages/app-shell/src/utils/deriveRelatedLists.ts index 0afde27b89e..e5d369b340a 100644 --- a/ui/packages/app-shell/src/utils/deriveRelatedLists.ts +++ b/ui/packages/app-shell/src/utils/deriveRelatedLists.ts @@ -140,6 +140,12 @@ export interface DerivedRelatedList { * the related list's query stays byte-identical to what it sent before. */ sort?: Array<{ field: string; order: 'asc' | 'desc' }>; + /** + * True when the FK declares `relatedList: false` — present only when the + * caller asked for hidden relationships ({@link DeriveRelatedListsOptions.includeHidden}). + * The record page never asks, so a hidden list never reaches it. + */ + isHidden?: true; } /** @@ -269,12 +275,8 @@ export function resolveRelatedListOrder !placed.has(list))]; } +/** + * Does one `relatedLists` entry (or a field group's `relatedList`) name this + * list? A bare child object name names every list of that child; `object.field` + * names the one FK. The ONE reading of an entry, shared by + * {@link resolveRelatedListOrder} and the Studio editors that author entries. + */ +export function relatedListEntryNames( + entry: string, + list: { childObject: string; referenceField: string }, +): boolean { + const dot = entry.indexOf('.'); + if (dot === -1) return list.childObject === entry; + return list.childObject === entry.slice(0, dot) && list.referenceField === entry.slice(dot + 1); +} + /** Normalize an object's `fields` (record or array) into `[name, def]` pairs. */ function fieldEntries(fields: MergedObjectLike['fields']): Array<[string, any]> { if (!fields) return []; @@ -311,6 +328,14 @@ export interface DeriveRelatedListsOptions { * the title keeps `childLabel`. */ listLabel?: (child: MergedObjectLike & { name: string }) => string; + /** + * Keep the relationships whose FK declares `relatedList: false`, flagged + * `isHidden`. For an AUTHORING surface only (Studio's `relatedLists` + * editor): such a relationship is still one an entry may name (the spec's + * `defineStack` check and `packages/lint` `validate-related-list-refs` count + * it), so the editor lists it. The record page never sets this. + */ + includeHidden?: boolean; } /** @@ -364,7 +389,8 @@ export function deriveRelatedLists( if (fieldDef.reference !== parentName) continue; if (AUDIT_FK_FIELDS.has(fieldName)) continue; // Explicit opt-out lives on the relationship. - if (fieldDef.relatedList === false) continue; + const hidden = fieldDef.relatedList === false; + if (hidden && !options?.includeHidden) continue; const entry: Working = { childObject: child.name, @@ -373,6 +399,7 @@ export function deriveRelatedLists( isOwned: type === 'master_detail', isPrimary: isMainContentList(type, fieldDef), ...(inheritedSort ? { sort: inheritedSort } : {}), + ...(hidden ? { isHidden: true as const } : {}), _fkLabel: (typeof fieldDef.label === 'string' && fieldDef.label) || fieldName, _listLabel: childListLabel, ...(typeof fieldDef.relatedListTitle === 'string' && fieldDef.relatedListTitle diff --git a/ui/packages/app-shell/src/views/studio-design/HighlightFieldsEditor.tsx b/ui/packages/app-shell/src/views/studio-design/HighlightFieldsEditor.tsx index f864bff9f72..c20c9b384e0 100644 --- a/ui/packages/app-shell/src/views/studio-design/HighlightFieldsEditor.tsx +++ b/ui/packages/app-shell/src/views/studio-design/HighlightFieldsEditor.tsx @@ -14,7 +14,8 @@ * this order. So the editor is a list, not a chip cloud: * * - each row can be dragged to a new place, or moved with the up / down - * buttons (the keyboard path; the drag has no keyboard equivalent); + * buttons (the keyboard path; the drag has no keyboard equivalent) — the + * rows are the shared `OrderedRowList`, which `RelatedListsEditor` uses too; * - fields are added at the end, from the object's visible fields not already * picked, up to {@link HIGHLIGHT_FIELDS_MAX}; * - a preview below draws the strip in the picked order, so the order is seen @@ -25,9 +26,9 @@ */ import React from 'react'; -import { GripVertical, X } from 'lucide-react'; import type { FieldEntry } from '../metadata-admin/previews/object-fields-io.js'; -import { InspectorReorderButtons, moveArray } from '../metadata-admin/inspectors/_shared.js'; +import { moveArray } from '../metadata-admin/inspectors/_shared.js'; +import { OrderedRowList } from './OrderedRowList.js'; import { t, tFormat, type SupportedLocale } from '../metadata-admin/i18n.js'; /** @@ -61,77 +62,26 @@ export function HighlightFieldsEditor({ /** The host's "add field" picker, offered while the list is under the cap. */ renderAdd?: () => React.ReactNode; }) { - const [dragIndex, setDragIndex] = React.useState(null); - const [overIndex, setOverIndex] = React.useState(null); const write = (next: string[]) => onChange(next.length > 0 ? next : undefined); - const move = (from: number, to: number) => { - if (from === to || from < 0 || to < 0 || from >= value.length || to >= value.length) return; - write(moveArray(value, from, to)); - }; - const endDrag = () => { - setDragIndex(null); - setOverIndex(null); - }; const atCap = value.length >= HIGHLIGHT_FIELDS_MAX; return (
- {value.length > 0 && ( -
    - {value.map((name, i) => ( -
  1. { - e.dataTransfer?.setData('text/plain', name); - setDragIndex(i); - }} - onDragOver={(e) => { - if (dragIndex === null) return; - e.preventDefault(); - setOverIndex(i); - }} - onDrop={(e) => { - e.preventDefault(); - if (dragIndex !== null) move(dragIndex, i); - endDrag(); - }} - onDragEnd={endDrag} - className={ - 'flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-1 text-[12px]' - + (overIndex === i && dragIndex !== null && dragIndex !== i ? ' border-primary' : '') - + (dragIndex === i ? ' opacity-50' : '') - } - > - {!disabled && ( -
  2. - ))} -
- )} + `highlight-row-${name}`} + renderRow={(name) => ( + <> + {labelOf(fields, name)} + {name} + + )} + onMove={(from, to) => write(moveArray(value, from, to))} + onRemove={(name) => write(value.filter((x) => x !== name))} + removeLabel={(name) => tFormat('engine.studio.settings.removeField', locale, { field: name })} + disabled={disabled} + />
{!disabled && !atCap && renderAdd?.()} {atCap && ( diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx index 359642f8233..601c9d1c708 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx @@ -442,6 +442,7 @@ function Section({ onDelete, onMove, readOnly = false, + relatedTable, }: { containerId: string; title: string; @@ -461,9 +462,15 @@ function Section({ onDelete: () => void; onMove: (dir: -1 | 1) => void; readOnly?: boolean; + /** + * objectstack#27069 — the group's `relatedList`: it stands for that related + * list's table, holds no fields (the object parse refuses a field whose + * group is such a group), and so is no drop target. + */ + relatedTable?: string; }): React.ReactElement { const locale = useMetadataLocale(); - const { setNodeRef, isOver } = useDroppable({ id: containerId }); + const { setNodeRef, isOver } = useDroppable({ id: containerId, disabled: !!relatedTable }); // `@container` scopes the field grid's container queries to THIS section's // width, so a wide screen spreads fields to the same column count the real // form uses — while a narrow panel collapses to one column. @@ -543,6 +550,14 @@ function Section({ )}
+ {relatedTable ? ( +
+ {tFormat('engine.studio.designer.relatedTable', locale, { name: relatedTable })} +
+ ) : ( {/* Field grid mirrors the real form's density: container-query columns (1 → up to `columns`) via {@link containerGridColsFor}, so the layout @@ -576,6 +591,7 @@ function Section({ })} + )} ); } @@ -627,8 +643,18 @@ export function ObjectFormDesigner({ [view.entries, systemFieldNames], ); - // Container order: declared groups (in order) then the ungrouped bucket. - const containerOrder = React.useMemo(() => [...groups.map((g) => cid(g.key)), cid(UNGROUPED)], [groups]); + // objectstack#27069 — a group carrying `relatedList` stands for a related + // table: it is drawn in its place, but it is no field container (it holds no + // fields), so drags, keyboard steps and the commit never reach it. + const tableOf = React.useMemo(() => { + const m = new Map(); + for (const g of groups) if (typeof g.relatedList === 'string' && g.relatedList) m.set(cid(g.key), g.relatedList); + return m; + }, [groups]); + // Section order: declared groups (in order) then the ungrouped bucket. + const sectionOrder = React.useMemo(() => [...groups.map((g) => cid(g.key)), cid(UNGROUPED)], [groups]); + // Container order: the same, without the related-table groups. + const containerOrder = React.useMemo(() => sectionOrder.filter((c) => !tableOf.has(c)), [sectionOrder, tableOf]); const labelOf = React.useMemo(() => { const m = new Map(); for (const g of groups) { @@ -857,7 +883,7 @@ export function ObjectFormDesigner({ onDragCancel={() => setActiveId(null)} >
- {containerOrder.map((c) => { + {sectionOrder.map((c) => { const isUngrouped = c === cid(UNGROUPED); // Hide the ungrouped bucket only when it is empty AND groups exist. if (isUngrouped && (items[c]?.length ?? 0) === 0 && groups.length > 0) return null; @@ -882,6 +908,7 @@ export function ObjectFormDesigner({ onDelete={() => deleteSection(unCid(c))} onMove={(dir) => moveSection(unCid(c), dir)} readOnly={readOnly} + relatedTable={tableOf.get(c)} /> ); })} 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 new file mode 100644 index 00000000000..64a32c99571 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/ObjectGroupInspector.relatedListPlacement-27054.test.tsx @@ -0,0 +1,138 @@ +/** + * 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. + */ + +/** + * A field group may stand for a related list's body table (objectstack#27069, + * `fieldGroups[].relatedList`). Studio's group inspector places one, over the + * same inbound-relationship vocabulary the `relatedLists` editor uses, and the + * form designer stops treating such a group as a place fields go. + */ + +import '@testing-library/jest-dom/vitest'; +import React from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent, within, cleanup } from '@testing-library/react'; +import { MetadataCtx, type MetadataContextValue } from '@object-ui/react'; +import { t } from '../metadata-admin/i18n'; +import { ObjectGroupInspector } from './ObjectGroupInspector'; +import { ObjectFormDesigner } from './ObjectFormDesigner'; + +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' } } }; +const task: Row = { name: 'task', label: 'Task', fields: { quote_id: { type: 'lookup', reference: 'quote', label: 'Quote' } } }; + +const STORE: MetadataContextValue = { + apps: [], + objects: [quote, lineItem, task], + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + getTypeStatus: () => 'ready', +}; + +function renderInspector(draft: Row, groupKey: string, onPatch = vi.fn()) { + render( + + {}} /> + , + ); + return onPatch; +} + +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 () => { + 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); + expect(options).toEqual([ + t('engine.studio.designer.group.relatedListNone', 'en-US'), + 'Quote Line (quote_line)', + 'Task (task)', + ]); + fireEvent.click(screen.getByRole('option', { name: 'Quote Line (quote_line)' })); + expect(onPatch.mock.calls.at(-1)![0].fieldGroups).toEqual([ + { key: 'basics', label: 'Basics' }, + { key: 'lines', label: 'Lines', relatedList: 'quote_line' }, + ]); + }); + + it('does not offer a relationship another group already places', async () => { + const draft = { + ...quote, + fieldGroups: [{ key: 'lines', label: 'Lines', relatedList: 'quote_line' }, { key: 'more', label: 'More' }], + }; + renderInspector(draft, 'more'); + 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)'); + }); + + it('withholds the picker while the group still holds fields', () => { + const draft = { + ...quote, + fields: { ...(quote.fields as Row), terms: { type: 'textarea', label: 'Terms', group: 'lines' } }, + fieldGroups: [{ key: 'lines', label: 'Lines' }], + }; + renderInspector(draft, 'lines'); + expect(placementPicker()).toBeDisabled(); + expect(screen.getByTestId('group-related-table-hint')).toHaveTextContent( + t('engine.studio.designer.group.relatedListHasFields', 'en-US'), + ); + }); + + it('clearing the picker removes the key', async () => { + const draft = { ...quote, fieldGroups: [{ key: 'lines', label: 'Lines', relatedList: 'quote_line' }] }; + const onPatch = renderInspector(draft, 'lines'); + fireEvent.keyDown(placementPicker(), { key: 'ArrowDown' }); + fireEvent.click(within(await screen.findByRole('listbox')).getByRole('option', { + name: t('engine.studio.designer.group.relatedListNone', 'en-US'), + })); + expect(onPatch.mock.calls.at(-1)![0].fieldGroups).toEqual([{ key: 'lines', label: 'Lines' }]); + }); +}); + +describe('ObjectFormDesigner — a related-table group is no place for fields', () => { + it('draws the table group in its place, with no drop zone', () => { + render( + {}} + onSelectField={() => {}} + />, + ); + expect(screen.getByTestId('form-designer-related-table-lines')).toHaveTextContent('quote_line'); + // Only the ordinary empty group invites a drop. + expect(screen.getAllByText(t('engine.studio.designer.dropHere', 'en-US'))).toHaveLength(1); + expect(screen.queryByTestId('form-designer-related-table-empty')).toBeNull(); + }); +}); 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 0417df3b134..ea2c7fc8ce9 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectGroupInspector.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectGroupInspector.tsx @@ -24,12 +24,21 @@ import { InspectorSelectField, InspectorEmptyState, } from '../metadata-admin/inspectors/_shared.js'; -import { readGroups, updateGroup } from '../metadata-admin/previews/object-fields-io.js'; +import { readFields, readGroups, updateGroup } from '../metadata-admin/previews/object-fields-io.js'; import { t } from '../metadata-admin/i18n.js'; +import { + acceptRelatedListEntries, + relationshipEntry, + relationshipLabel, + relationshipsNamedBy, + useRelatedListCatalog, +} from './relatedListVocabulary.js'; export interface ObjectGroupInspectorProps { - /** Object metadata draft (reads `fieldGroups`). */ + /** Object metadata draft (reads `fieldGroups`, and `fields` for a group's members). */ draft: Record; + /** The object's name — the parent a placed related table's relationship points at. */ + objectName?: string; /** Key of the selected field group. */ groupKey: string; /** Persist a partial object-draft patch (fieldGroups) + mark dirty. */ @@ -45,6 +54,7 @@ type CollapseMode = 'none' | 'expanded' | 'collapsed'; export function ObjectGroupInspector({ draft, + objectName, groupKey, onPatch, onClose, @@ -54,6 +64,12 @@ export function ObjectGroupInspector({ const tr = React.useCallback((key: string) => t(key, locale), [locale]); const groups = React.useMemo(() => readGroups(draft.fieldGroups), [draft.fieldGroups]); const group = groups.find((g) => g.key === groupKey); + const parentName = objectName || (typeof draft.name === 'string' ? draft.name : ''); + const { relationships, ready } = useRelatedListCatalog(parentName, draft); + const memberCount = React.useMemo( + () => readFields(draft.fields).entries.filter((e) => e.def.group === groupKey).length, + [draft.fields, groupKey], + ); if (!group) { return ( @@ -114,6 +130,92 @@ export function ObjectGroupInspector({

{tr('engine.studio.designer.group.collapseHint')}

+ ); } + +/** + * objectstack#27069 — a field group may stand for a related list's BODY TABLE + * instead of a section of fields: `relatedList` names one inbound relationship + * (the vocabulary the object's `relatedLists` editor uses) and the record page + * 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); + * - 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; + * - a relationship another group already places is not offered again. + */ +function RelatedTablePlacement({ + groups, + group, + relationships, + ready, + memberCount, + onPatch, + readOnly, + tr, +}: { + groups: ReturnType; + group: ReturnType[number]; + relationships: ReturnType['relationships']; + ready: boolean; + memberCount: number; + onPatch: (patch: Record) => void; + readOnly: boolean; + tr: (key: string) => string; +}): React.ReactElement { + const current = typeof group.relatedList === 'string' ? group.relatedList : ''; + const placedElsewhere = new Set( + groups + .filter((g) => g.key !== group.key && typeof g.relatedList === 'string') + .flatMap((g) => relationshipsNamedBy(g.relatedList as string, relationships)), + ); + const options = relationships + .filter((r) => !placedElsewhere.has(r) || relationshipsNamedBy(current, relationships).includes(r)) + .map((r) => { + const entry = relationshipEntry(r, relationships); + return { value: entry, label: `${relationshipLabel(r)} (${entry})` }; + }); + 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. + if (blockedByFields || acceptRelatedListEntries([v], relationships).length === 0) return; + onPatch({ fieldGroups: updateGroup(groups, group.key, { relatedList: v }) }); + }; + return ( +
+ +

+ {!ready + ? tr('engine.studio.designer.group.relatedListLoading') + : blockedByFields + ? tr('engine.studio.designer.group.relatedListHasFields') + : tr('engine.studio.designer.group.relatedListHint')} +

+
+ ); +} diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.relatedListsOrder-27054.test.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.relatedListsOrder-27054.test.tsx new file mode 100644 index 00000000000..54dad7eeba2 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.relatedListsOrder-27054.test.tsx @@ -0,0 +1,209 @@ +/** + * 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. + */ + +/** + * The object settings panel authors `relatedLists` (objectstack#27069) as an + * ordered list over the object's INBOUND relationships, and the record page's + * own derivation (`deriveRelatedLists`) is the judge of what the order does. + * + * The real panel, inside a host that feeds every patch back into the draft and + * a metadata store that serves the child objects: + * - a drag (or up / down) writes an order that changes the record page's + * derived order, read through `deriveRelatedLists` itself; + * - a name that matches no inbound relationship never enters a written value; + * - every unlisted relationship trails, greyed and not draggable, in the + * derived order, and is what the add picker offers; + * - removing the last entry removes the key; nothing is written before the + * object list has answered. + */ + +import '@testing-library/jest-dom/vitest'; +import React from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, fireEvent, within, cleanup } from '@testing-library/react'; +import { MetadataCtx, type MetadataContextValue } from '@object-ui/react'; +import { t } from '../metadata-admin/i18n'; +import { deriveRelatedLists } from '../../utils/deriveRelatedLists'; +import { ObjectSettingsPanel } from './ObjectSettingsPanel'; + +afterEach(cleanup); + +type Row = Record; + +const lead: Row = { name: 'lead', label: 'Lead', fields: { name: { type: 'text', label: 'Name' } } }; +const note: Row = { name: 'note', label: 'Note', fields: { lead: { type: 'master_detail', reference: 'lead', label: 'Lead' } } }; +const task: Row = { + name: 'task', + label: 'Task', + fields: { + lead_id: { type: 'lookup', reference: 'lead', label: 'Lead' }, + // An audit FK is never a related list, so never an inbound relationship. + owner_id: { type: 'lookup', reference: 'lead', label: 'Owner' }, + }, +}; +const event: Row = { + name: 'event', + label: 'Event', + fields: { + who_id: { type: 'lookup', reference: 'lead', label: 'Who' }, + related_to: { type: 'lookup', reference: 'lead', label: 'Related To' }, + }, +}; +const campaignMember: Row = { + name: 'campaign_member', + label: 'Campaign Member', + fields: { lead_id: { type: 'lookup', reference: 'lead', label: 'Lead', relatedList: false } }, +}; +const REGISTRY = [lead, note, task, event, campaignMember]; + +function store(status: 'ready' | 'loading' = 'ready'): MetadataContextValue { + return { + apps: [], + objects: REGISTRY, + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], + getTypeStatus: () => status, + }; +} + +function Host({ initial, writes, status }: { initial: Row; writes: Row[]; status?: 'ready' | 'loading' }) { + const [draft, setDraft] = React.useState(initial); + const value = React.useMemo(() => store(status), [status]); + return ( + + setDraft((d) => { + const next = { ...d, ...patch }; + writes.push(next); + return next; + })} + locale="en-US" + /> + + ); +} + +const rowOrder = () => + screen.getAllByTestId(/^related-lists-row-/).map((el) => el.getAttribute('data-testid')!.replace('related-lists-row-', '')); +const tailOrder = () => + within(screen.getByTestId('related-lists-tail')) + .getAllByRole('listitem') + .map((el) => el.getAttribute('data-testid')!.replace('related-lists-tail-row-', '')); +/** The record page's order for `relatedLists`, through the record page's own derivation. */ +const pageOrder = (relatedLists: unknown) => + deriveRelatedLists({ ...lead, relatedLists: relatedLists as string[] | undefined }, REGISTRY) + .map((r) => `${r.childObject}.${r.referenceField}`); +const lastLists = (writes: Row[]) => writes.at(-1)!.relatedLists; + +describe('ObjectSettingsPanel — relatedLists is an ordered list of the inbound relationships', () => { + it('a drag writes an order that changes the record page\'s derived order', () => { + const writes: Row[] = []; + render(); + expect(rowOrder()).toEqual(['task', 'event.related_to']); + const before = pageOrder(['task', 'event.related_to']); + expect(before.slice(0, 2)).toEqual(['task.lead_id', 'event.related_to']); + + const from = screen.getByTestId('related-lists-row-event.related_to'); + const to = screen.getByTestId('related-lists-row-task'); + expect(from).toHaveAttribute('draggable', 'true'); + fireEvent.dragStart(from); + fireEvent.dragOver(to); + fireEvent.drop(to); + + expect(lastLists(writes)).toEqual(['event.related_to', 'task']); + expect(rowOrder()).toEqual(['event.related_to', 'task']); + const after = pageOrder(lastLists(writes)); + expect(after).not.toEqual(before); + // The listed relationships lead, in the written order; the rest keep the derived order. + expect(after).toEqual(['event.related_to', 'task.lead_id', 'note.lead', 'event.who_id']); + + // The keyboard path writes the same kind of order. + fireEvent.click(within(screen.getByTestId('related-lists-row-task')).getByRole('button', { name: 'Move up' })); + expect(lastLists(writes)).toEqual(['task', 'event.related_to']); + expect(pageOrder(lastLists(writes))).toEqual(before); + }); + + it('a name that matches no inbound relationship never enters a written value', () => { + const writes: Row[] = []; + render( + , + ); + // Stored, it keeps a row and is named as what it is. + expect(rowOrder()).toEqual(['nope', 'task', 'event.nope', 'note']); + const unmatched = screen.getByTestId('related-lists-unmatched'); + expect(unmatched).toHaveTextContent('“nope”'); + expect(unmatched).toHaveTextContent('“event.nope”'); + + // A move writes only the entries that name an inbound relationship. + fireEvent.click(within(screen.getByTestId('related-lists-row-note')).getByRole('button', { name: 'Move up' })); + expect(lastLists(writes)).toEqual(['task', 'note']); + expect(screen.queryByTestId('related-lists-unmatched')).toBeNull(); + }); + + it('every unlisted relationship trails, greyed and not draggable, in the derived order', async () => { + const writes: Row[] = []; + render(); + // The derived order, without the listed one; the hidden FK is listed (an + // entry may name it) and marked; the audit FK is not a relationship. + expect(tailOrder()).toEqual(['note.lead', 'task.lead_id', 'event.who_id', 'campaign_member.lead_id']); + for (const row of within(screen.getByTestId('related-lists-tail')).getAllByRole('listitem')) { + expect(row).toHaveAttribute('aria-disabled', 'true'); + expect(row).not.toHaveAttribute('draggable'); + } + expect(screen.getByTestId('related-lists-tail-row-campaign_member.lead_id')).toHaveTextContent( + t('engine.studio.settings.relatedListsHidden', 'en-US'), + ); + // The record page agrees: the listed one leads, the rest trail in the derived order. + expect(pageOrder(['event.related_to'])).toEqual(['event.related_to', 'note.lead', 'task.lead_id', 'event.who_id']); + + // The add picker offers exactly the tail, spelled as entries. + fireEvent.keyDown(screen.getByTestId('related-lists-add-select'), { key: 'ArrowDown' }); + const options = within(await screen.findByRole('listbox')).getAllByRole('option').map((o) => o.textContent); + expect(options).toEqual([ + t('engine.studio.settings.relatedListsAdd', 'en-US'), + 'Note (note)', + 'Task (task)', + 'Event · Who (event.who_id)', + 'Campaign Member (campaign_member)', + ]); + fireEvent.click(screen.getByRole('option', { name: 'Task (task)' })); + expect(lastLists(writes)).toEqual(['event.related_to', 'task']); + expect(tailOrder()).toEqual(['note.lead', 'event.who_id', 'campaign_member.lead_id']); + }); + + it('removing the last entry removes the key', () => { + const writes: Row[] = []; + render(); + fireEvent.click(screen.getByRole('button', { name: 'Remove note' })); + expect(writes.at(-1)).toHaveProperty('relatedLists', undefined); + expect(screen.queryAllByTestId(/^related-lists-row-/)).toHaveLength(0); + expect(screen.getByText(t('engine.studio.settings.relatedListsUndeclared', 'en-US'))).toBeInTheDocument(); + }); + + it('writes nothing before the object list has answered', () => { + const writes: Row[] = []; + render(); + expect(screen.getByTestId('related-lists-loading')).toBeInTheDocument(); + // The stored rows are shown, but offer no move, removal or drag. + expect(rowOrder()).toEqual(['event.related_to', 'task']); + expect(screen.getByTestId('related-lists-row-task')).toHaveAttribute('draggable', 'false'); + expect(within(screen.getByTestId('related-lists-editor')).queryAllByRole('button')).toHaveLength(0); + expect(screen.queryByTestId('related-lists-unmatched')).toBeNull(); + expect(writes).toHaveLength(0); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx b/ui/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx index 7976a592547..91784239226 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectSettingsPanel.tsx @@ -27,7 +27,10 @@ * first (`7a90afdf9`). * 3. Semantic roles (ADR-0085) — the cross-surface presentation roles: * `nameField`, `stageField` (string | false | unset), `highlightFields` - * (an ordered list with a strip preview — `HighlightFieldsEditor`). + * (an ordered list with a strip preview — `HighlightFieldsEditor`), and + * `relatedLists` (objectstack#27069), the business order of the record + * page's related lists, over the object's inbound relationships + * (`RelatedListsEditor`). * These are the ONLY presentation knobs the protocol carries, so the * builder must make them directly editable — otherwise designers fall * back to guessing which heuristic picked their title/stepper/columns. @@ -54,6 +57,7 @@ import { readFields, type FieldEntry } from '../metadata-admin/previews/object-f import { t, type SupportedLocale } from '../metadata-admin/i18n.js'; import { cbpMasterRefusal, requireCbpMasterDetails } from './owd-sharing.js'; import { HighlightFieldsEditor } from './HighlightFieldsEditor.js'; +import { RelatedListsEditor } from './RelatedListsEditor.js'; /** The item a stored value none of a picker's options carries is shown by. */ const OUTSIDE_OPTIONS = 'outside'; @@ -386,6 +390,34 @@ export function ObjectSettingsPanel({ )} />
+ + {/* relatedLists (objectstack#27069) */} +
+ + {t('engine.studio.settings.relatedLists', locale)} + + onPatch({ relatedLists: next })} + disabled={disabled} + locale={locale} + renderAdd={(options, add) => ( + { + if (v) add(v); + }} + className={PICKER_INLINE} + options={[ + { value: '', label: t('engine.studio.settings.relatedListsAdd', locale) }, + ...options, + ]} + /> + )} + /> +
diff --git a/ui/packages/app-shell/src/views/studio-design/OrderedRowList.tsx b/ui/packages/app-shell/src/views/studio-design/OrderedRowList.tsx new file mode 100644 index 00000000000..14ab5c9f109 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/OrderedRowList.tsx @@ -0,0 +1,113 @@ +/** + * 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. + */ + +/** + * The ordered list the object settings panel authors its ORDER-ONLY keys with + * (`highlightFields`, `relatedLists`): numbered rows, each one draggable to a + * new place or moved with its up / down buttons (the keyboard path — the drag + * has no keyboard equivalent), and removable. + * + * It owns the drag state and the row chrome, nothing else: what a row says, + * what a move or a removal writes, and anything drawn around the list (an add + * picker, a preview) stay with the editor that hosts it. + */ + +import React from 'react'; +import { GripVertical, X } from 'lucide-react'; +import { InspectorReorderButtons } from '../metadata-admin/inspectors/_shared.js'; + +export function OrderedRowList({ + items, + renderRow, + onMove, + onRemove, + removeLabel, + ariaLabel, + rowTestId, + disabled, +}: { + /** The row keys, in order (unique). */ + items: readonly string[]; + /** What row `key` says. */ + renderRow: (key: string, index: number) => React.ReactNode; + /** Move the row at `from` to `to` (both in range, never equal). */ + onMove: (from: number, to: number) => void; + onRemove: (key: string) => void; + /** The remove button's accessible name for `key`. */ + removeLabel: (key: string) => string; + /** The list's accessible name. */ + ariaLabel: string; + /** The row's `data-testid` for `key`. */ + rowTestId: (key: string) => string; + disabled?: boolean; +}) { + const [dragIndex, setDragIndex] = React.useState(null); + const [overIndex, setOverIndex] = React.useState(null); + const move = (from: number, to: number) => { + if (from === to || from < 0 || to < 0 || from >= items.length || to >= items.length) return; + onMove(from, to); + }; + const endDrag = () => { + setDragIndex(null); + setOverIndex(null); + }; + if (items.length === 0) return null; + return ( +
    + {items.map((key, i) => ( +
  1. { + e.dataTransfer?.setData('text/plain', key); + setDragIndex(i); + }} + onDragOver={(e) => { + if (dragIndex === null) return; + e.preventDefault(); + setOverIndex(i); + }} + onDrop={(e) => { + e.preventDefault(); + if (dragIndex !== null) move(dragIndex, i); + endDrag(); + }} + onDragEnd={endDrag} + className={ + 'flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-1 text-[12px]' + + (overIndex === i && dragIndex !== null && dragIndex !== i ? ' border-primary' : '') + + (dragIndex === i ? ' opacity-50' : '') + } + > + {!disabled && ( +
  2. + ))} +
+ ); +} diff --git a/ui/packages/app-shell/src/views/studio-design/RelatedListsEditor.tsx b/ui/packages/app-shell/src/views/studio-design/RelatedListsEditor.tsx new file mode 100644 index 00000000000..c79db9079c4 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/RelatedListsEditor.tsx @@ -0,0 +1,205 @@ +/** + * 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. + */ + +/** + * The object's `relatedLists` (objectstack#27069) as an ORDERED list of its + * inbound relationships — what `highlightFields` is to fields, for the lists of + * child records on the record page. + * + * - The rows are the declared entries, in order: drag one to a new place, or + * move it with its up / down buttons (the shared `OrderedRowList`, the + * rows `HighlightFieldsEditor` draws). + * - Every relationship no entry names follows, greyed and not draggable, in + * the derived order the record page gives it (owned `master_detail` + * children, then `lookup` children); the add picker lists exactly those. + * - It orders; it does not show or hide. A relationship hidden by + * `relatedList: false` on its FK is listed (an entry may name it) and + * marked hidden; the switch itself stays on the field. + * - Every write is checked first ({@link acceptRelatedListEntries}): an entry + * that names no inbound relationship never enters the value, since the + * spec's `defineStack` check and the publish door's lint both refuse it. A + * stored one keeps its row, marked, and the next write leaves it out. + * - Removing the last entry removes the key. + * - A preview draws the order the record page will use, through the record + * page's own derivation, split into body tables and side cards (the order + * applies within each). + */ + +import React from 'react'; +import { + deriveRelatedLists, + type DerivedRelatedList, +} from '../../utils/deriveRelatedLists.js'; +import { moveArray } from '../metadata-admin/inspectors/_shared.js'; +import { t, tFormat, type SupportedLocale } from '../metadata-admin/i18n.js'; +import { OrderedRowList } from './OrderedRowList.js'; +import { + acceptRelatedListEntries, + relationshipEntry, + relationshipLabel, + relationshipsNamedBy, + useRelatedListCatalog, +} from './relatedListVocabulary.js'; + +function Badges({ named, locale }: { named: readonly DerivedRelatedList[]; locale?: SupportedLocale }) { + const hidden = named.length > 0 && named.every((r) => r.isHidden); + const primary = !hidden && named.length > 0 && named.every((r) => r.isPrimary); + if (!hidden && !primary) return null; + return ( + + {hidden ? t('engine.studio.settings.relatedListsHidden', locale) : t('engine.studio.settings.relatedListsPrimary', locale)} + + ); +} + +/** A row's label; none for an entry that names no relationship (its spelling is all it has). */ +function entryLabel(entry: string, named: readonly DerivedRelatedList[]): string { + if (named.length === 0) return ''; + // A bare child name stands for every list of that child: name the child. + return entry.includes('.') ? relationshipLabel(named[0]) : named[0].childLabel; +} + +export function RelatedListsEditor({ + name, + draft, + onChange, + disabled, + locale, + renderAdd, +}: { + /** The object's name (the parent every relationship points at). */ + name: string; + /** The object draft (reads `relatedLists`; its own fields may add a self-reference). */ + draft: Record; + /** The next list; `undefined` removes the key. */ + onChange: (next: string[] | undefined) => void; + disabled?: boolean; + locale?: SupportedLocale; + /** + * The host's "add relationship" picker over `options` (the unlisted + * relationships); `add` appends one, through the same pre-write check. + */ + renderAdd?: (options: Array<{ value: string; label: string }>, add: (entry: string) => void) => React.ReactNode; +}) { + const { catalog, relationships, ready } = useRelatedListCatalog(name, draft); + const stored = Array.isArray(draft.relatedLists) + ? (draft.relatedLists as unknown[]).filter((e): e is string => typeof e === 'string') + : []; + // The rows: the stored entries, in order, once each. An entry that names no + // inbound relationship keeps its row (marked) until a write leaves it out. + const rows = [...new Set(stored)]; + const listed = acceptRelatedListEntries(rows, relationships); + const unmatched = ready ? rows.filter((e) => !listed.includes(e)) : []; + const locked = !!disabled || !ready; + + // The pre-write check: nothing that names no inbound relationship is written. + const write = (next: readonly string[]) => { + const accepted = acceptRelatedListEntries(next, relationships); + onChange(accepted.length > 0 ? accepted : undefined); + }; + + const named = (entry: string) => relationshipsNamedBy(entry, relationships); + const tail = relationships.filter((r) => !listed.some((entry) => named(entry).includes(r))); + const addOptions = tail.map((r) => { + const entry = relationshipEntry(r, relationships); + return { value: entry, label: `${relationshipLabel(r)} (${entry})` }; + }); + + // What the record page will draw: its own derivation, over what a write keeps. + const preview = deriveRelatedLists({ ...draft, name, relatedLists: listed }, catalog as never[]); + const body = preview.filter((r) => r.isPrimary); + const side = preview.filter((r) => !r.isPrimary); + + return ( +
+ {!ready && ( +

+ {t('engine.studio.settings.relatedListsLoading', locale)} +

+ )} + `related-lists-row-${entry}`} + renderRow={(entry) => ( + + {entryLabel(entry, named(entry))} + {entry} + + + )} + onMove={(from, to) => write(moveArray(rows, from, to))} + onRemove={(entry) => write(rows.filter((x) => x !== entry))} + removeLabel={(entry) => tFormat('engine.studio.settings.removeField', locale, { field: entry })} + disabled={locked} + /> + {unmatched.length > 0 && ( +
+ {unmatched.map((entry) => ( +

+ {tFormat('engine.studio.settings.relatedListsUnmatched', locale, { entry })} +

+ ))} +
+ )} + {ready && relationships.length === 0 && ( + + {t('engine.studio.settings.relatedListsNone', locale)} + + )} + {tail.length > 0 && ( +
+ + {t(listed.length > 0 ? 'engine.studio.settings.relatedListsTail' : 'engine.studio.settings.relatedListsUndeclared', locale)} + +
    + {tail.map((r) => { + const entry = relationshipEntry(r, relationships); + return ( +
  • + + {relationshipLabel(r)} + {entry} + + +
  • + ); + })} +
+
+ )} + {!locked && addOptions.length > 0 && ( +
{renderAdd?.(addOptions, (entry) => write([...rows, entry]))}
+ )} + {ready && preview.length > 0 && ( +
+ {t('engine.studio.settings.relatedListsPreview', locale)} +
+ {body.length > 0 && ( +
+ {t('engine.studio.settings.relatedListsPreviewBody', locale)}: + {body.map(relationshipLabel).join(' · ')} +
+ )} + {side.length > 0 && ( +
+ {t('engine.studio.settings.relatedListsPreviewSide', locale)}: + {side.map(relationshipLabel).join(' · ')} +
+ )} +
+
+ )} +
+ ); +} diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index f36bcb4e28a..96505119b39 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -7731,6 +7731,7 @@ export function DataPillar({ const fieldRailContent = fieldSel.kind === 'group' ? ( setFieldSel(null)} diff --git a/ui/packages/app-shell/src/views/studio-design/relatedListVocabulary.ts b/ui/packages/app-shell/src/views/studio-design/relatedListVocabulary.ts new file mode 100644 index 00000000000..6e89ea7c1f1 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/relatedListVocabulary.ts @@ -0,0 +1,134 @@ +/** + * 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. + */ + +/** + * The words Studio may write into an object's `relatedLists` and a field + * group's `relatedList` (objectstack#27069): the object's INBOUND + * relationships — every `lookup` / `master_detail` field of a child object + * whose `reference` is this object. + * + * ⛔ No second relationship enumeration lives here. The list is + * `deriveRelatedLists` — the record page's own derivation — asked with + * `includeHidden`, so the editor offers exactly the relationships the record + * page reads, plus the ones a FK hides with `relatedList: false` (an entry may + * still name those: the spec's `defineStack` check and `packages/lint` + * `validate-related-list-refs` both count them, and both skip the audit FKs + * the derivation skips). + * + * Entry grammar: every name this module writes is the child object's name or + * `child.field`, both taken from a real object and field, so it is the + * snake_case machine-name grammar the spec's entry pattern describes by + * construction — no second copy of that pattern is kept here. + */ + +import React from 'react'; +import { useMetadata } from '@object-ui/react'; +import { + deriveRelatedLists, + relatedListEntryNames, + type DerivedRelatedList, +} from '../../utils/deriveRelatedLists.js'; + +type ObjectDoc = Record; + +/** + * The object catalog the record page derives from, with THIS object's draft in + * its place (or appended, for an object not yet served), so a self-reference + * the draft adds is an inbound relationship too. + */ +export function catalogWithDraft(objects: readonly unknown[], name: string, draft: ObjectDoc): ObjectDoc[] { + const own = { ...draft, name }; + let replaced = false; + const out: ObjectDoc[] = []; + for (const o of objects) { + if (!o || typeof o !== 'object') continue; + if ((o as ObjectDoc).name === name) { + out.push(own); + replaced = true; + } else { + out.push(o as ObjectDoc); + } + } + if (!replaced) out.push(own); + return out; +} + +/** Every inbound relationship of `name`, hidden ones included, in the derived order. */ +export function inboundRelationships(name: string, catalog: readonly ObjectDoc[]): DerivedRelatedList[] { + // The derived order is the order with NO declared entries: the stored + // `relatedLists` must not reorder the vocabulary it is judged against. + return deriveRelatedLists({ name }, catalog as never[], { includeHidden: true }); +} + +/** + * How Studio spells one relationship as an entry: the child object name, or + * `child.field` when that child points here through more than one FK — the + * spelling the spec's refusal lists the inbound relationships in. + */ +export function relationshipEntry(list: DerivedRelatedList, all: readonly DerivedRelatedList[]): string { + const siblings = all.filter((r) => r.childObject === list.childObject).length; + return siblings > 1 ? `${list.childObject}.${list.referenceField}` : list.childObject; +} + +/** The relationships one entry names (empty: the entry names none of them). */ +export function relationshipsNamedBy(entry: string, all: readonly DerivedRelatedList[]): DerivedRelatedList[] { + return all.filter((list) => relatedListEntryNames(entry, list)); +} + +/** + * The pre-write check: keep, in order, each entry that names at least one + * inbound relationship, once. An entry naming none would be refused by the + * spec at `defineStack` and by the publish door's lint, so it never enters a + * value Studio writes. + */ +export function acceptRelatedListEntries( + entries: readonly string[], + all: readonly DerivedRelatedList[], +): string[] { + const out: string[] = []; + for (const entry of entries) { + if (out.includes(entry)) continue; + if (relationshipsNamedBy(entry, all).length === 0) continue; + out.push(entry); + } + return out; +} + +/** What a related list is called in an editor row: its title, else the child's label. */ +export function relationshipLabel(list: DerivedRelatedList): string { + return list.title || list.childLabel; +} + +export interface RelatedListCatalog { + /** The catalog with this object's draft in place. */ + catalog: ObjectDoc[]; + /** Every inbound relationship, hidden ones included, in the derived order. */ + relationships: DerivedRelatedList[]; + /** + * The served object list has answered. Until it has, a stored entry naming + * another object's FK cannot be told from a misspelling, so editors write + * nothing (a write would drop entries the catalog has not shown yet). + */ + ready: boolean; +} + +/** + * The inbound-relationship catalog for one object, from the console's metadata + * store (the whole served object registry: any package's object may point in). + * Read only while an editor that needs it is mounted. + */ +export function useRelatedListCatalog(name: string, draft: ObjectDoc): RelatedListCatalog { + const metadata = useMetadata(); + // Read the list first: on the console's store the read is what asks for it. + const objects = metadata.objects; + const ready = (metadata.getTypeStatus?.('object') ?? 'ready') === 'ready'; + return React.useMemo(() => { + const catalog = catalogWithDraft(Array.isArray(objects) ? objects : [], name, draft); + return { catalog, relationships: inboundRelationships(name, catalog), ready }; + }, [objects, name, draft, ready]); +} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index 1dcf095a254..9d199f86d63 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -2135,6 +2135,18 @@ const STUDIO_EN: Record = { 'engine.studio.settings.undeclared': '(not declared — surfaces pick heuristically)', 'engine.studio.settings.highlightMax': 'Up to {max} highlight fields — remove one to add another.', 'engine.studio.settings.highlightPreview': 'Highlight strip preview', + 'engine.studio.settings.relatedLists': 'Related list order (relatedLists) — the order of the lists of child records on this object\'s record page, most important first; it orders only, and showing or hiding a list stays on its relationship field', + 'engine.studio.settings.relatedListsAdd': '+ Add relationship…', + 'engine.studio.settings.relatedListsTail': 'Not listed — shown after these, in the default order', + 'engine.studio.settings.relatedListsUndeclared': 'Not declared — every list shows in the default order: owned children first, then lookups', + 'engine.studio.settings.relatedListsNone': 'No object points at this one through a lookup or master-detail field yet.', + 'engine.studio.settings.relatedListsLoading': 'Loading the objects that point at this one…', + 'engine.studio.settings.relatedListsUnmatched': '“{entry}” names no relationship that points at this object, so the next change to this list leaves it out.', + 'engine.studio.settings.relatedListsHidden': 'hidden', + 'engine.studio.settings.relatedListsPrimary': 'in body', + 'engine.studio.settings.relatedListsPreview': 'Record page order', + 'engine.studio.settings.relatedListsPreviewBody': 'In the body', + 'engine.studio.settings.relatedListsPreviewSide': 'Side cards', 'engine.studio.settings.sharing': 'Record sharing (OWD)', 'engine.studio.settings.sharingHint': 'Baseline record visibility applied before positions and sharing rules (ADR-0056/0090)', 'engine.studio.settings.sharingModel': 'Sharing model (sharingModel) — who can see and edit records another user owns', @@ -3088,6 +3100,7 @@ const STUDIO_EN: Record = { 'engine.studio.designer.groupDelete': 'Delete group', 'engine.studio.designer.groupDeleteTitle': 'Delete group (its fields move to Ungrouped)', 'engine.studio.designer.dropHere': 'Drag fields here', + 'engine.studio.designer.relatedTable': 'Related table: {name} — this group places a related list, so it holds no fields', 'engine.studio.designer.ungrouped': 'Ungrouped', 'engine.studio.designer.newGroup': 'New group', // objectui#12453 — a read-only field card's title: what a click still does. @@ -3290,6 +3303,11 @@ const STUDIO_EN: Record = { 'engine.studio.designer.group.collapseExpanded': 'Collapsible · expanded by default', 'engine.studio.designer.group.collapseCollapsed': 'Collapsible · collapsed by default', '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.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. 'engine.studio.newAutoTrigger.later': 'Choose later on the Start node', // ── Record Sharing Baseline (OWD) overview (objectui#2505) ────────────── diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index d2143b639a9..948bc1c55b8 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -1949,6 +1949,18 @@ const STUDIO_ZH: Record = { 'engine.studio.settings.undeclared': '(未声明 —— 各处按启发式自动挑选)', 'engine.studio.settings.highlightMax': '最多 {max} 个重点字段——移除一个才能再添加。', 'engine.studio.settings.highlightPreview': '要点条预览', + 'engine.studio.settings.relatedLists': '关联列表顺序(relatedLists)—— 本对象记录页上各子记录列表的顺序,最重要的在前;只管排序,显示与否仍在关系字段上设置', + 'engine.studio.settings.relatedListsAdd': '+ 添加关系…', + 'engine.studio.settings.relatedListsTail': '未列出 —— 排在以上之后,按默认顺序', + 'engine.studio.settings.relatedListsUndeclared': '未声明 —— 所有列表按默认顺序:先主从子对象,再查找关系', + 'engine.studio.settings.relatedListsNone': '还没有对象通过查找或主从字段指向本对象。', + 'engine.studio.settings.relatedListsLoading': '正在加载指向本对象的对象…', + 'engine.studio.settings.relatedListsUnmatched': '“{entry}”不对应任何指向本对象的关系,下次修改此列表时会被去掉。', + 'engine.studio.settings.relatedListsHidden': '已隐藏', + 'engine.studio.settings.relatedListsPrimary': '正文', + 'engine.studio.settings.relatedListsPreview': '记录页顺序', + 'engine.studio.settings.relatedListsPreviewBody': '正文', + 'engine.studio.settings.relatedListsPreviewSide': '侧栏卡片', 'engine.studio.settings.sharing': '记录共享模型(OWD)', 'engine.studio.settings.sharingHint': '在岗位与共享规则之前生效的记录可见性基线(ADR-0056/0090)', 'engine.studio.settings.sharingModel': '共享模型(sharingModel)—— 谁能查看和编辑他人拥有的记录', @@ -2744,6 +2756,7 @@ const STUDIO_ZH: Record = { 'engine.studio.designer.groupDelete': '删除分组', 'engine.studio.designer.groupDeleteTitle': '删除分组(字段归为未分组)', 'engine.studio.designer.dropHere': '拖字段到这里', + 'engine.studio.designer.relatedTable': '关联表:{name} —— 此分组放置一张关联列表,不含字段', 'engine.studio.designer.ungrouped': '未分组', 'engine.studio.designer.newGroup': '新分组', 'engine.studio.designer.fieldTitleReadOnly': '点选字段查看属性', @@ -2929,6 +2942,11 @@ const STUDIO_ZH: Record = { 'engine.studio.designer.group.collapseExpanded': '可折叠 · 默认展开', 'engine.studio.designer.group.collapseCollapsed': '可折叠 · 默认折叠', '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.relatedListHasFields': '请先把本分组的字段移到其他分组:放置关联表的分组不含字段。', + 'engine.studio.designer.group.relatedListLoading': '正在加载指向本对象的对象…', // objectui#11788 — 新建自动化对话框里不选触发方式时的选项。 'engine.studio.newAutoTrigger.later': '稍后在开始节点上选择', // ── 记录共享基线(OWD)总览 (objectui#2505) ──────────────────────────