Skip to content

Commit 2a0f0b7

Browse files
feat(studio): draw the nav entries, fields and flows other packages contribute to a base greyed, with the package (#29847)
Fixes #29786 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:在只读的基础包上,Studio 现在会画出其他已安装包贡献给它的导航项、字段和流程:灰色、只读,并标注"来自 某包"。导航项放在它声明的分组下,分组未声明时放在顶层;字段在表单布局中原位标记(不再画第二份);流程放进其对象的分组。 - **为什么改**:此前 Studio 一项都不画,而运行中的应用全部显示;一个只被其他包填充的分组会显示"此包中没有项目",管理员在定制或卸载被扩展的基础包时看不到谁依赖它。 - **风险与回滚**:只改 Studio 绘制,不改 spec、服务端或任何请求(仍是同一个 customizations 读取)。可写包、没有贡献的基础包、贡献方自身的页面画法不变。回滚即还原本 PR。 - **先看**:`ui/packages/app-shell/src/views/studio-design/contributionRows.ts`(放置规则,纯函数),再看 `StudioDesignSurface.tsx` 三个 pillar 的接入。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/contributionRows.test.ts packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx`(27 项全绿);对照截图见下方 Verification。 - **需要你做的**:看一眼 Acceptance notes 里的三条后续候选(导航项只显示 id、Automations 计数、贡献方一侧文案),决定是否另开卡。 ## What changes On a read-only base, Studio draws the items other installed packages contribute to it, from the `contributions` of the base's one customization read (`GET /api/v1/packages/:id/customizations`). That is the read the rails' marks already make, so no request is added and nothing is joined on names in the browser. Each item is greyed, read-only, labelled "from PACKAGE", and a click opens the contributing package (through the shell's unsaved-edits seam). - **One reader.** `usePackageCustomizations` now also names each contributing package from the package list, by id, as it already names the base. `contributionRows.ts` (new, pure) splits the read by kind and holds the placement rules. - **Interfaces.** A `nav` entry for the open app draws under the group it names when the app declares a `type: 'group'` item of that id at any depth (the question the server's navigation fold asks), and at the top level otherwise. An entry naming an undeclared group says so in its tooltip. A declared group holding only contributed rows no longer says "No items in this package". The row reads the item's id (the served entry carries no label). The row is never in the nav edit buffer, so it cannot be dragged, renamed or deleted. - **Data.** A contributed field is already served on the base's object (the engine merges every extension), so it was drawn before, unmarked. The Form layout now marks that same card ("from PACKAGE", greyed, title) instead of drawing a second copy. It takes no drag listener and opens read-only in the inspector, even where the pair could write a base field's display properties. - **Automations.** A contributed `flow` draws in its object's rail group. An object with no flow of the base's gets a group of its own, before "No object". - **Strings.** Five keys in the studio namespace, `en` and `zh` (`engine.studio.contributed.*`). ## Tests - `contributionRows.test.ts` (new, 9): split by kind and package naming, declared group at depth, top level and undeclared group, other app left out, a non-group item sharing the id is no container, fields per object, flow groups joined or added before "No object". - `StudioDesignSurface.oneSurface-28572.test.tsx` (+6): Interfaces group, top-level and undeclared placement; a nav row opens the contributor; CONTROL: an empty declared group still says "No items" when nothing is contributed; Data card marked once, not `aria-disabled`, inspector read-only while the base's own field is not; Automations group placement, a new group, no empty message, opens the contributor; no request to the contributing package. - Ablations, each red then restored (blob equals HEAD): group rows, top-level rows, `findDeclaredNavGroup`, the `contributedFields` prop, the rail read-only, flow rows, and `useSortable` `disabled` on a contributed card. ## Acceptance notes - **Nav rows show the id, not a label** (seat ruling). The customization read's `nav` entry carries no label. Measured: the app list read the pillar already makes (`GET /meta/app?package=`) carries the folded navigation with labels. Using it would need a ruling on reading the fold, so no label is joined here. - **Automations heading count** stays the base's own (0 on the fixture), and the main pane still says "No automations in this package yet." A contributed flow is not the base's. - **Contributor side**: the contributing package's Interfaces pillar still reads "This package has no app yet." Its package-list row carries the raw `manifest.navigationContributions` (a declaration, not the fold). Left as is and reported (seat ruling). - **Grid column headers** on the Records tab are not marked: that grid is `plugin-grid`, outside this surface. - **Strings** are `en` and `zh` only. The studio namespace lives in `ui/packages/i18n/src/namespaces/studio/`, not in the ten locale packs the claim names; other languages fall back to `en` as the whole namespace does. - **Screenshots** came from `examples/app-multi-package` plus a temporary third package (a nav entry under an undeclared group, a field and a flow on `crm_account`). That package was never committed; the pins carry their own fixtures. ## Verification - Pins: 27/27 on the final head. Type-check (`turbo type-check` app-shell + i18n), eslint on touched files: 0 errors. - Derived gates (`dispatch-gates --commands`): all exit 0; see the os-dev-report on the card. - Live, `objectstack dev` on `app-multi-package` plus the temporary package, Console via Vite. The base read lists 4 contributions (field `acme_score`, flow `crm_acme_account_sync`, nav `nav_acme_portal` under undeclared `ghost_group`, nav `nav_orders` under `sales_group` from the orders package). After: `nav_orders` under SALES and `nav_acme_portal` at the top level with the undeclared-group tooltip; the Acme score card marked once with the inspector read-only; `crm_acme_account_sync` in the ACCOUNT group. Before (601832b): none of the nav or flow rows, and the field card unmarked. - Before/after screenshots (Interfaces, Data form, Automations, plus rail crops, the field inspector and a phone-width Automations, and three side-by-side comparisons) are handed to the seat with this PR for attaching to the card. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jQkUExPHsEsPoX99Uf --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jQkUExPHsEsPoX99Uf)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a932270 commit 2a0f0b7

10 files changed

Lines changed: 905 additions & 35 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(studio): draw the nav entries, fields and flows other packages contribute to a base greyed, with the contributing package
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
Studio now draws on a read-only base the items other installed packages contribute to it. Before, it drew none of them, while the live app showed them all. The Interfaces tree of a base said "No items in this package" for a group that only other packages fill.
12+
13+
- **One reader.** Everything comes from the `contributions` of the base's customization read (`GET /api/v1/packages/:id/customizations`), the read the rails' marks already make. No second request is sent. Nothing is joined on names in the browser. The contributing package is named as the base is, by its id, from the package list.
14+
- **Interfaces.** Each `nav` entry for the open app draws in its tree, greyed and labelled "from PACKAGE". It draws under the group it names when the app declares that group (`type: 'group'`, at any depth: the question the server's navigation fold asks), and at the top level otherwise. An entry naming a group the app does not declare says so in its tooltip. A declared group that holds only contributed entries no longer says "No items in this package". The served entry names the item's id and no label, so the row reads the id. A contributed entry is no nav entry of the base: it is never in the nav edit buffer and cannot be dragged, renamed or deleted. A click opens the contributing package's Interfaces pillar.
15+
- **Data.** A contributed field is already served on the base's object (the engine merges every extension). The Form layout now marks its card, greyed with "from PACKAGE", and never as a drag source. Its properties open read-only, even where the pair could write a base field's display properties.
16+
- **Automations.** Each contributed `flow` draws in its object's rail group, greyed with "from PACKAGE". An object that holds no flow of the base's gets a group of its own. A click opens the flow in the contributing package's Automations pillar.
17+
- **Unchanged.** A writable package, a base whose read lists no contributions, and the contributing package's own surfaces draw as before.
18+
- **New strings.** `@object-ui/i18n`'s studio namespace gains five keys in `en` and `zh` (`engine.studio.contributed.from`, `.navTitle`, `.fieldTitle`, `.flowTitle`, `.undeclaredGroup`). The other languages fall back to `en`, as the whole namespace does.

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

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -118,15 +118,22 @@ export type PackageCustomizationsState =
118118
index: CustomizationMarkIndex;
119119
/** The base's listed name, else its id. */
120120
baseName: string;
121+
/**
122+
* objectstack#29786 — each contributing package's listed name, by the id
123+
* a `contributions` entry names; a package the list cannot name is absent
124+
* and its id stands in.
125+
*/
126+
contributorNames: ReadonlyMap<string, string>;
121127
};
122128

123129
/**
124130
* Read `baseId`'s customizations, or nothing (`off`) when `baseId` is `null` or
125131
* derives no pair: a customization package itself has none to read, and the
126132
* route refuses its id.
127133
*
128-
* The base's name comes from the package list the area already reads (one
129-
* request, `packages-io.ts`); when that list cannot name it, the id stands in.
134+
* The base's name, and each contributing package's (objectstack#29786), come
135+
* from the package list the area already reads (one request, `packages-io.ts`);
136+
* when that list cannot name one, its id stands in.
130137
*
131138
* `refresh` (objectstack#28572): read again whenever it moves (Studio's
132139
* publish, which is what changes the served overrides). A re-read keeps the
@@ -138,15 +145,23 @@ export function usePackageCustomizations(baseId: string | null, refresh: unknown
138145
React.useEffect(() => {
139146
if (!readable || baseId === null) return;
140147
let cancelled = false;
141-
const name = fetchListedPackageManifests().then(
142-
(manifests) => manifests.get(baseId)?.name ?? baseId,
143-
() => baseId,
144-
);
145-
Promise.all([fetchPackageCustomizations(baseId), name]).then(
146-
([read, baseName]) => {
148+
const listed = fetchListedPackageManifests().catch(() => null);
149+
Promise.all([fetchPackageCustomizations(baseId), listed]).then(
150+
([read, manifests]) => {
147151
if (cancelled) return;
148152
const index = indexCustomizations(baseId, read);
149-
setAnswer({ id: baseId, state: index ? { status: 'read', read, index, baseName } : { status: 'off' } });
153+
const baseName = manifests?.get(baseId)?.name ?? baseId;
154+
// objectstack#29786 — the contributing packages are named as the base
155+
// is: by the id, from the same package list.
156+
const contributorNames = new Map<string, string>();
157+
for (const c of read.contributions) {
158+
const name = manifests?.get(c.packageId)?.name;
159+
if (name) contributorNames.set(c.packageId, name);
160+
}
161+
setAnswer({
162+
id: baseId,
163+
state: index ? { status: 'read', read, index, baseName, contributorNames } : { status: 'off' },
164+
});
150165
},
151166
(err: unknown) => {
152167
if (cancelled) return;

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

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ import { formDndAccessibility, type FormDndLookups, type FormDndSlot } from './f
5959
import { AddFieldPopover } from './AddFieldPopover.js';
6060
import type { FieldTypeId } from '../metadata-admin/previews/field-types.js';
6161
import { fieldTypeName } from '../metadata-admin/previews/fieldTypeName.js';
62+
import { ContributionMark, contributedItemTitle } from './StudioRailCustomizations.js';
63+
import type { ContributedOnObject } from './contributionRows.js';
6264

6365
const UNGROUPED = '__ungrouped__';
6466

@@ -311,8 +313,18 @@ export interface ObjectFormDesignerProps {
311313
* toolbar so the Form tab keeps one toolbar row (objectui#12451).
312314
*/
313315
modeControl?: React.ReactNode;
316+
/**
317+
* objectstack#29786 — the object's fields another package contributes, by
318+
* name (`contributedFieldsOf`): their cards are greyed, name the package, and
319+
* are never dragged. A click still selects (the inspector shows them
320+
* read-only).
321+
*/
322+
contributedFields?: ReadonlyMap<string, ContributedOnObject>;
314323
}
315324

325+
const NO_CONTRIBUTED_FIELDS: ReadonlyMap<string, ContributedOnObject> = new Map();
326+
const ContributedFieldsContext = React.createContext<ReadonlyMap<string, ContributedOnObject>>(NO_CONTRIBUTED_FIELDS);
327+
316328
/**
317329
* The Form tab's one toolbar row (objectui#12451): the host's mode control and
318330
* a hint at the left, the actions at the right. The designer draws it in Layout;
@@ -415,13 +427,17 @@ function SortableField({
415427
readOnly?: boolean;
416428
}): React.ReactElement {
417429
const locale = useMetadataLocale();
430+
// objectstack#29786 — a field another package contributes reads as a
431+
// read-only card on any surface, and names its package.
432+
const contributed = React.useContext(ContributedFieldsContext).get(entry.name) ?? null;
433+
const locked = readOnly || contributed !== null;
418434
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
419435
id: fid(entry.name),
420436
// objectui#11872 — the role a screen reader announces for the card, in the
421437
// author's locale; left unset, dnd-kit writes its English `sortable`. A
422438
// read-only card is not draggable, so it gets none: ARIA does not expose a
423439
// blank `aria-roledescription`, and the card reads as the button it is.
424-
attributes: { roleDescription: readOnly ? '' : t('engine.studio.designer.fieldRole', locale) },
440+
attributes: { roleDescription: locked ? '' : t('engine.studio.designer.fieldRole', locale) },
425441
});
426442
const type = String(entry.def.type ?? 'text');
427443
const required = !!entry.def.required;
@@ -441,27 +457,39 @@ function SortableField({
441457
style={{ transform: CSS.Transform.toString(transform), transition }}
442458
onClick={onSelect}
443459
{...attributes}
444-
{...listeners}
460+
// objectstack#29786 — a contributed card takes no drag listener: it is
461+
// never a drag source. Not `useSortable`'s `disabled`, which would mark
462+
// it `aria-disabled` while a click still selects it.
463+
{...(contributed ? {} : listeners)}
445464
// objectui#11924 — dnd-kit points every card's `aria-describedby` at the
446465
// canvas's drag instructions, and `useSortable` cannot take it away
447466
// (`disabled` keeps it and adds `aria-disabled`, though a click still
448467
// selects). A read-only card cannot be dragged, so it names none.
449-
aria-describedby={readOnly ? undefined : attributes['aria-describedby']}
468+
aria-describedby={locked ? undefined : attributes['aria-describedby']}
450469
aria-label={
451-
readOnly
470+
locked
452471
? tFormat('engine.studio.designer.fieldAriaReadOnly', locale, { label })
453472
: tFormat('engine.studio.designer.fieldAria', locale, { label })
454473
}
455474
// objectui#12453 — what a click still does on a read-only package, the
456475
// half of the retired toolbar hint that was not "read-only".
457-
title={readOnly ? t('engine.studio.designer.fieldTitleReadOnly', locale) : undefined}
476+
title={
477+
contributed
478+
? contributedItemTitle(contributed, locale)
479+
: readOnly
480+
? t('engine.studio.designer.fieldTitleReadOnly', locale)
481+
: undefined
482+
}
483+
data-contributed={contributed ? 'field' : undefined}
484+
data-package={contributed?.packageId}
458485
// objectui#12451 — a field card: a transparent border that turns muted on
459486
// hover; selected, a primary border with a soft primary ring, and the
460487
// type tag turns primary.
461488
className={cn(
462489
'flex touch-none select-none flex-col gap-2 rounded-md border px-3 py-2.5',
463-
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing',
490+
locked ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing',
464491
spanFull && 'col-span-full',
492+
contributed && 'text-muted-foreground opacity-70',
465493
selected ? 'border-primary bg-background ring-[3px] ring-primary/10' : 'border-transparent hover:border-border',
466494
isDragging && 'opacity-40',
467495
)}
@@ -471,6 +499,9 @@ function SortableField({
471499
<span className="truncate">{label}</span>
472500
{required && <span className="text-destructive">*</span>}
473501
</span>
502+
{contributed && (
503+
<ContributionMark packageName={contributed.packageName} packageId={contributed.packageId} locale={locale} />
504+
)}
474505
<span
475506
className={cn(
476507
'shrink-0 text-[10px] font-semibold uppercase tracking-wider',
@@ -672,6 +703,7 @@ export function ObjectFormDesigner({
672703
onSelectGroup,
673704
readOnly = false,
674705
modeControl,
706+
contributedFields = NO_CONTRIBUTED_FIELDS,
675707
}: ObjectFormDesignerProps): React.ReactElement {
676708
const locale = useMetadataLocale();
677709
const view = React.useMemo(() => readFields(draft.fields), [draft.fields]);
@@ -893,6 +925,7 @@ export function ObjectFormDesigner({
893925
const activeEntry = activeId && !activeId.startsWith('g:') ? entryByName.get(unFid(activeId)) : null;
894926

895927
return (
928+
<ContributedFieldsContext.Provider value={contributedFields}>
896929
<div className="flex min-h-0 flex-1 flex-col">
897930
<FormToolbar
898931
leading={modeControl}
@@ -991,5 +1024,6 @@ export function ObjectFormDesigner({
9911024
</DndContext>
9921025
</div>
9931026
</div>
1027+
</ContributedFieldsContext.Provider>
9941028
);
9951029
}

0 commit comments

Comments
 (0)