Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/27054-related-lists-editor.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ const OBJECTS_READERS: Record<string, { kind: Kind; why: string }> = {
'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' },
Expand Down
39 changes: 33 additions & 6 deletions ui/packages/app-shell/src/utils/deriveRelatedLists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -269,19 +275,30 @@ export function resolveRelatedListOrder<T extends { childObject: string; referen
const head: T[] = [];
for (const entry of declared) {
if (typeof entry !== 'string') continue;
const dot = entry.indexOf('.');
const child = dot === -1 ? entry : entry.slice(0, dot);
const field = dot === -1 ? undefined : entry.slice(dot + 1);
for (const list of lists) {
if (placed.has(list) || list.childObject !== child) continue;
if (field !== undefined && list.referenceField !== field) continue;
if (placed.has(list) || !relatedListEntryNames(entry, list)) continue;
placed.add(list);
head.push(list);
}
}
return [...head, ...lists.filter((list) => !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 [];
Expand Down Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';

/**
Expand Down Expand Up @@ -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<number | null>(null);
const [overIndex, setOverIndex] = React.useState<number | null>(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 (
<div className="space-y-1.5" data-testid="highlight-fields-editor">
{value.length > 0 && (
<ol className="space-y-1" aria-label={t('engine.studio.settings.highlightFields', locale)}>
{value.map((name, i) => (
<li
key={name}
data-testid={`highlight-row-${name}`}
draggable={!disabled}
onDragStart={(e) => {
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 && (
<GripVertical
className="h-3.5 w-3.5 shrink-0 cursor-grab text-muted-foreground"
aria-hidden="true"
/>
)}
<span className="w-4 shrink-0 text-right text-[10px] tabular-nums text-muted-foreground">{i + 1}</span>
<span className="min-w-0 flex-1 truncate">
{labelOf(fields, name)}
<span className="ml-1 font-mono text-[10px] text-muted-foreground">{name}</span>
</span>
{!disabled && (
<>
<InspectorReorderButtons index={i} total={value.length} onMove={(to) => move(i, to)} />
<button
type="button"
aria-label={tFormat('engine.studio.settings.removeField', locale, { field: name })}
onClick={() => write(value.filter((x) => x !== name))}
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
</>
)}
</li>
))}
</ol>
)}
<OrderedRowList
items={value}
ariaLabel={t('engine.studio.settings.highlightFields', locale)}
rowTestId={(name) => `highlight-row-${name}`}
renderRow={(name) => (
<>
{labelOf(fields, name)}
<span className="ml-1 font-mono text-[10px] text-muted-foreground">{name}</span>
</>
)}
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}
/>
<div className="flex flex-wrap items-center gap-1.5">
{!disabled && !atCap && renderAdd?.()}
{atCap && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ function Section({
onDelete,
onMove,
readOnly = false,
relatedTable,
}: {
containerId: string;
title: string;
Expand All @@ -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.
Expand Down Expand Up @@ -543,6 +550,14 @@ function Section({
)}
</div>
</div>
{relatedTable ? (
<div
data-testid={`form-designer-related-table-${unCid(containerId)}`}
className="m-3 flex items-center justify-center rounded-md border border-dashed bg-muted/30 py-3 text-[11px] text-muted-foreground"
>
{tFormat('engine.studio.designer.relatedTable', locale, { name: relatedTable })}
</div>
) : (
<SortableContext items={fieldIds} strategy={verticalListSortingStrategy}>
{/* Field grid mirrors the real form's density: container-query columns
(1 → up to `columns`) via {@link containerGridColsFor}, so the layout
Expand Down Expand Up @@ -576,6 +591,7 @@ function Section({
})}
</div>
</SortableContext>
)}
</div>
);
}
Expand Down Expand Up @@ -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<string, string>();
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<string, string>();
for (const g of groups) {
Expand Down Expand Up @@ -857,7 +883,7 @@ export function ObjectFormDesigner({
onDragCancel={() => setActiveId(null)}
>
<div className="mx-auto flex max-w-6xl flex-col gap-4">
{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;
Expand All @@ -882,6 +908,7 @@ export function ObjectFormDesigner({
onDelete={() => deleteSection(unCid(c))}
onMove={(dir) => moveSection(unCid(c), dir)}
readOnly={readOnly}
relatedTable={tableOf.get(c)}
/>
);
})}
Expand Down
Loading
Loading