diff --git a/.changeset/29786-studio-contributed-items-greyed.md b/.changeset/29786-studio-contributed-items-greyed.md new file mode 100644 index 00000000000..54d19fe99d2 --- /dev/null +++ b/.changeset/29786-studio-contributed-items-greyed.md @@ -0,0 +1,18 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(studio): draw the nav entries, fields and flows other packages contribute to a base greyed, with the contributing package + +Clause-②: no +Metadata-protocol: none + +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. + +- **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. +- **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. +- **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. +- **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. +- **Unchanged.** A writable package, a base whose read lists no contributions, and the contributing package's own surfaces draw as before. +- **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. diff --git a/ui/packages/app-shell/src/views/studio-design/CustomizationMark.tsx b/ui/packages/app-shell/src/views/studio-design/CustomizationMark.tsx index 81a44c74c20..9dd7fe3ac73 100644 --- a/ui/packages/app-shell/src/views/studio-design/CustomizationMark.tsx +++ b/ui/packages/app-shell/src/views/studio-design/CustomizationMark.tsx @@ -118,6 +118,12 @@ export type PackageCustomizationsState = index: CustomizationMarkIndex; /** The base's listed name, else its id. */ baseName: string; + /** + * objectstack#29786 — each contributing package's listed name, by the id + * a `contributions` entry names; a package the list cannot name is absent + * and its id stands in. + */ + contributorNames: ReadonlyMap; }; /** @@ -125,8 +131,9 @@ export type PackageCustomizationsState = * derives no pair: a customization package itself has none to read, and the * route refuses its id. * - * The base's name comes from the package list the area already reads (one - * request, `packages-io.ts`); when that list cannot name it, the id stands in. + * The base's name, and each contributing package's (objectstack#29786), come + * from the package list the area already reads (one request, `packages-io.ts`); + * when that list cannot name one, its id stands in. * * `refresh` (objectstack#28572): read again whenever it moves (Studio's * 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 React.useEffect(() => { if (!readable || baseId === null) return; let cancelled = false; - const name = fetchListedPackageManifests().then( - (manifests) => manifests.get(baseId)?.name ?? baseId, - () => baseId, - ); - Promise.all([fetchPackageCustomizations(baseId), name]).then( - ([read, baseName]) => { + const listed = fetchListedPackageManifests().catch(() => null); + Promise.all([fetchPackageCustomizations(baseId), listed]).then( + ([read, manifests]) => { if (cancelled) return; const index = indexCustomizations(baseId, read); - setAnswer({ id: baseId, state: index ? { status: 'read', read, index, baseName } : { status: 'off' } }); + const baseName = manifests?.get(baseId)?.name ?? baseId; + // objectstack#29786 — the contributing packages are named as the base + // is: by the id, from the same package list. + const contributorNames = new Map(); + for (const c of read.contributions) { + const name = manifests?.get(c.packageId)?.name; + if (name) contributorNames.set(c.packageId, name); + } + setAnswer({ + id: baseId, + state: index ? { status: 'read', read, index, baseName, contributorNames } : { status: 'off' }, + }); }, (err: unknown) => { if (cancelled) return; 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 aec0624c6bc..0be15cdd20b 100644 --- a/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx +++ b/ui/packages/app-shell/src/views/studio-design/ObjectFormDesigner.tsx @@ -59,6 +59,8 @@ import { formDndAccessibility, type FormDndLookups, type FormDndSlot } from './f import { AddFieldPopover } from './AddFieldPopover.js'; import type { FieldTypeId } from '../metadata-admin/previews/field-types.js'; import { fieldTypeName } from '../metadata-admin/previews/fieldTypeName.js'; +import { ContributionMark, contributedItemTitle } from './StudioRailCustomizations.js'; +import type { ContributedOnObject } from './contributionRows.js'; const UNGROUPED = '__ungrouped__'; @@ -311,8 +313,18 @@ export interface ObjectFormDesignerProps { * toolbar so the Form tab keeps one toolbar row (objectui#12451). */ modeControl?: React.ReactNode; + /** + * objectstack#29786 — the object's fields another package contributes, by + * name (`contributedFieldsOf`): their cards are greyed, name the package, and + * are never dragged. A click still selects (the inspector shows them + * read-only). + */ + contributedFields?: ReadonlyMap; } +const NO_CONTRIBUTED_FIELDS: ReadonlyMap = new Map(); +const ContributedFieldsContext = React.createContext>(NO_CONTRIBUTED_FIELDS); + /** * The Form tab's one toolbar row (objectui#12451): the host's mode control and * a hint at the left, the actions at the right. The designer draws it in Layout; @@ -415,13 +427,17 @@ function SortableField({ readOnly?: boolean; }): React.ReactElement { const locale = useMetadataLocale(); + // objectstack#29786 — a field another package contributes reads as a + // read-only card on any surface, and names its package. + const contributed = React.useContext(ContributedFieldsContext).get(entry.name) ?? null; + const locked = readOnly || contributed !== null; const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: fid(entry.name), // objectui#11872 — the role a screen reader announces for the card, in the // author's locale; left unset, dnd-kit writes its English `sortable`. A // read-only card is not draggable, so it gets none: ARIA does not expose a // blank `aria-roledescription`, and the card reads as the button it is. - attributes: { roleDescription: readOnly ? '' : t('engine.studio.designer.fieldRole', locale) }, + attributes: { roleDescription: locked ? '' : t('engine.studio.designer.fieldRole', locale) }, }); const type = String(entry.def.type ?? 'text'); const required = !!entry.def.required; @@ -441,27 +457,39 @@ function SortableField({ style={{ transform: CSS.Transform.toString(transform), transition }} onClick={onSelect} {...attributes} - {...listeners} + // objectstack#29786 — a contributed card takes no drag listener: it is + // never a drag source. Not `useSortable`'s `disabled`, which would mark + // it `aria-disabled` while a click still selects it. + {...(contributed ? {} : listeners)} // objectui#11924 — dnd-kit points every card's `aria-describedby` at the // canvas's drag instructions, and `useSortable` cannot take it away // (`disabled` keeps it and adds `aria-disabled`, though a click still // selects). A read-only card cannot be dragged, so it names none. - aria-describedby={readOnly ? undefined : attributes['aria-describedby']} + aria-describedby={locked ? undefined : attributes['aria-describedby']} aria-label={ - readOnly + locked ? tFormat('engine.studio.designer.fieldAriaReadOnly', locale, { label }) : tFormat('engine.studio.designer.fieldAria', locale, { label }) } // objectui#12453 — what a click still does on a read-only package, the // half of the retired toolbar hint that was not "read-only". - title={readOnly ? t('engine.studio.designer.fieldTitleReadOnly', locale) : undefined} + title={ + contributed + ? contributedItemTitle(contributed, locale) + : readOnly + ? t('engine.studio.designer.fieldTitleReadOnly', locale) + : undefined + } + data-contributed={contributed ? 'field' : undefined} + data-package={contributed?.packageId} // objectui#12451 — a field card: a transparent border that turns muted on // hover; selected, a primary border with a soft primary ring, and the // type tag turns primary. className={cn( 'flex touch-none select-none flex-col gap-2 rounded-md border px-3 py-2.5', - readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing', + locked ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing', spanFull && 'col-span-full', + contributed && 'text-muted-foreground opacity-70', selected ? 'border-primary bg-background ring-[3px] ring-primary/10' : 'border-transparent hover:border-border', isDragging && 'opacity-40', )} @@ -471,6 +499,9 @@ function SortableField({ {label} {required && *} + {contributed && ( + + )} readFields(draft.fields), [draft.fields]); @@ -893,6 +925,7 @@ export function ObjectFormDesigner({ const activeEntry = activeId && !activeId.startsWith('g:') ? entryByName.get(unFid(activeId)) : null; return ( +
+
); } diff --git a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx index 1223eb6a51d..3135b0e7e55 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.oneSurface-28572.test.tsx @@ -25,8 +25,8 @@ * override, with no Customize and no read-only link; * - the Data rail lists the pair's added object, marked, opening it names * the pair; - * - nothing a third package contributes is drawn: no read names it - * (objectstack#28722), and the rail asks none; + * - the rails ask no list of a third package: what it contributes comes from + * the base's one customization read (objectstack#29786, below); * - CONTROL: a base with no pair reads no pair list, and draws no mark and * no filter. */ @@ -53,6 +53,8 @@ const srv = vi.hoisted(() => ({ reads: [] as string[], ensure: [] as number[], saves: [] as Array<{ path: string; search: string; body: Record }>, + /** objectstack#29786 — the read serves {@link CONTRIBUTIONS}, and the app and object hold what they target. */ + contributed: false, })); vi.mock('@object-ui/auth', async (importOriginal) => { @@ -133,6 +135,36 @@ const READ = { contributions: [], counts: { overrides: 1, extensions: 1, switchedOff: 0, contributions: 0 }, }; +/** + * objectstack#29786 — what the third package contributes to the base: a nav + * entry into a group the app declares, one into a group it does not declare, + * one for another app, a field and a flow on `crm_account`, and a flow on + * `crm_deal`, which holds no flow of the base's. + */ +const CONTRIBUTIONS = [ + { kind: 'field', object: 'crm_account', name: 'acme_score', packageId: ADDON }, + { kind: 'flow', object: 'crm_account', name: 'acme_sync', packageId: ADDON }, + { kind: 'flow', object: 'crm_deal', name: 'acme_deal_watch', packageId: ADDON }, + { kind: 'nav', app: 'crm_app', group: 'service_group', name: 'nav_cases', packageId: ADDON }, + { kind: 'nav', app: 'crm_app', group: 'ghost_group', name: 'nav_knowledge', packageId: ADDON }, + { kind: 'nav', app: 'other_app', name: 'nav_elsewhere', packageId: ADDON }, +]; +/** The app with a declared group of its own that holds nothing. */ +const APP_WITH_GROUP = { + ...APP, + navigation: [...NAV, { id: 'service_group', type: 'group', label: 'Service', children: [] }], +}; +/** `crm_account` as served: the third package's extension field merged in, as the engine merges it. */ +const ACCOUNT_SERVED = { + name: 'crm_account', + label: 'Account', + fields: { + name: { type: 'text', label: 'Account Name' }, + acme_score: { type: 'number', label: 'Acme score' }, + }, +}; +const readWith = (read: typeof READ) => + srv.contributed ? { ...read, contributions: CONTRIBUTIONS, counts: { ...read.counts, contributions: CONTRIBUTIONS.length } } : read; const EMPTY_READ = { packageId: BASE, overrides: [], extensions: [], switchedOff: [], contributions: [], counts: { overrides: 0, extensions: 0, switchedOff: 0, contributions: 0 } }; /** The base's own lists, by type. The override is served under the base's name (ADR-0146 D8 item 2). */ @@ -183,6 +215,7 @@ beforeEach(() => { srv.reads.length = 0; srv.ensure.length = 0; srv.saves.length = 0; + srv.contributed = false; vi.stubGlobal( 'fetch', vi.fn(async (input: unknown, init?: RequestInit) => { @@ -192,7 +225,7 @@ beforeEach(() => { if (method === 'GET') srv.reads.push(`${path}${url.search}`); if (path === '/api/v1/packages') return json({ success: true, data: packageRows() }); if (path === `/api/v1/packages/${BASE}/customizations`) { - return json({ success: true, data: srv.pairListed ? READ : EMPTY_READ }); + return json({ success: true, data: readWith(srv.pairListed ? READ : EMPTY_READ) }); } if (path === `/api/v1/packages/${BASE}/ensure-customization` && method === 'POST') { const created = !srv.pairListed; @@ -209,7 +242,12 @@ beforeEach(() => { if (path === '/api/v1/meta/app') { return json(url.searchParams.get('package') === PAIR ? [] : [{ name: 'crm_app', label: 'CRM' }]); } - if (path === '/api/v1/meta/app/crm_app/layers') return json({ code: null, overlay: null, overlayScope: null, effective: APP }); + if (path === '/api/v1/meta/app/crm_app/layers') { + return json({ code: null, overlay: null, overlayScope: null, effective: srv.contributed ? APP_WITH_GROUP : APP }); + } + if (srv.contributed && path === '/api/v1/meta/object/crm_account/layers') { + return json({ code: ACCOUNT_SERVED, overlay: null, overlayScope: null, effective: ACCOUNT_SERVED }); + } const list = /^\/api\/v1\/meta\/([^/]+)$/.exec(path); if (list && method === 'GET') { const type = decodeURIComponent(list[1]!); @@ -428,8 +466,8 @@ describe('the package switcher and the pair’s URLs (objectstack#28572)', () => }); }); -describe('nothing a third package contributes is drawn (objectstack#28722)', () => { - it('the rails ask no list of another package, and no row names one', async () => { +describe('a third package’s lists are never asked (objectstack#28722, objectstack#29786)', () => { + it('the rails ask no list of another package, and with no contribution served no row names one', async () => { renderAt(`/studio/${BASE}/interfaces`); await interfacesReady(); expect(srv.reads.filter((r) => r.includes(encodeURIComponent(ADDON)) || r.includes(`package=${ADDON}`))).toEqual([]); @@ -478,3 +516,137 @@ describe('an item the pair adds reads its layers at the pair (objectstack#28584) } }); }); + +/** + * objectstack#29786 — what a third package contributes to the base, drawn on + * each pillar from the base's one customization read: greyed, named by the + * contributing package ("from Acme add-on"), and opening that package. + */ +describe('items another package contributes are drawn greyed, with the package (objectstack#29786)', () => { + const FROM_ACME = tFormat('engine.studio.contributed.from', 'en', { package: 'Acme add-on' }); + const contributed = (root: HTMLElement, kind: string, name: string) => + root.querySelector(`[data-contributed="${kind}"][data-testid="contributed-${kind}-${name}"]`); + + it('Interfaces: a nav entry draws in the group it names, which is no longer empty; an undeclared group’s entry draws at the top level and says so', async () => { + srv.contributed = true; + renderAt(`/studio/${BASE}/interfaces`); + await interfacesReady(); + await waitFor(() => expect(contributed(ifRail(), 'nav', 'nav_cases')).not.toBeNull(), { timeout: 8000 }); + + const cases = contributed(ifRail(), 'nav', 'nav_cases')!; + expect(cases).toHaveTextContent('nav_cases'); + expect(within(cases).getByTestId('contribution-mark')).toHaveTextContent(FROM_ACME); + expect(within(cases).getByTestId('contribution-mark')).toHaveAttribute('data-package', ADDON); + expect(cases.className).toMatch(/opacity-70/); + // Under the app's own "Service" group, which held nothing of its own. + // The group's header is a button; the group is the element that holds it and its rows. + const group = within(ifRail()).getByText('Service').closest('button')!.parentElement!; + expect(group).toContainElement(cases); + expect(within(ifRail()).queryByText(t('engine.studio.if.emptyGroup', 'en'))).toBeNull(); + + const knowledge = contributed(ifRail(), 'nav', 'nav_knowledge')!; + expect(knowledge).not.toBeNull(); + expect(group).not.toContainElement(knowledge); + expect(knowledge.getAttribute('title')).toContain( + tFormat('engine.studio.contributed.undeclaredGroup', 'en', { group: 'ghost_group' }), + ); + // Another app's entry is not this app's. + expect(contributed(ifRail(), 'nav', 'nav_elsewhere')).toBeNull(); + // Contributed entries are no nav entries of the base: no row of the tree's own (`type · name`) names them. + expect(within(ifRail()).queryByTitle(/nav_cases$/)).toBeNull(); + }); + + it('Interfaces: a contributed entry opens the contributing package', async () => { + srv.contributed = true; + renderAt(`/studio/${BASE}/interfaces`); + await interfacesReady(); + await waitFor(() => expect(contributed(ifRail(), 'nav', 'nav_cases')).not.toBeNull(), { timeout: 8000 }); + fireEvent.click(contributed(ifRail(), 'nav', 'nav_cases')!); + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent(`/studio/${encodeURIComponent(ADDON)}/interfaces`)); + }); + + it('CONTROL: with nothing contributed, the declared empty group still says it is empty', async () => { + srv.contributed = true; + const answer = CONTRIBUTIONS.splice(0, CONTRIBUTIONS.length); + try { + renderAt(`/studio/${BASE}/interfaces`); + await interfacesReady(); + await waitFor(() => expect(within(ifRail()).getByText(t('engine.studio.if.emptyGroup', 'en'))).toBeInTheDocument(), { + timeout: 8000, + }); + expect(ifRail().querySelector('[data-contributed]')).toBeNull(); + } finally { + CONTRIBUTIONS.push(...answer); + } + }); + + it('Data: the contributed field is marked in the Form layout, once, and its properties are read-only while the base’s are not', async () => { + srv.contributed = true; + renderAt(`/studio/${BASE}/data`); + await waitFor(() => expect(within(dataRail()).getByText('Account')).toBeInTheDocument(), { timeout: 8000 }); + fireEvent.click(within(dataRail()).getByText('Account').closest('button')!); + fireEvent.click(await screen.findByText(t('engine.studio.data.tab.form', 'en'), undefined, { timeout: 8000 })); + + const card = await waitFor( + () => { + const el = document.querySelector('[data-contributed="field"]'); + expect(el).not.toBeNull(); + return el!; + }, + { timeout: 8000 }, + ); + expect(card).toHaveTextContent('Acme score'); + // Never a drag source, yet still a card a click selects: not announced as disabled. + expect(card).not.toHaveAttribute('aria-disabled', 'true'); + expect(within(card).getByTestId('contribution-mark')).toHaveTextContent(FROM_ACME); + expect(card.getAttribute('title')).toBe( + tFormat('engine.studio.contributed.fieldTitle', 'en', { name: 'acme_score', package: 'Acme add-on' }), + ); + // Marked where it is drawn: no second copy of it. + expect(screen.getAllByText('Acme score')).toHaveLength(1); + expect(document.querySelectorAll('[data-contributed="field"]')).toHaveLength(1); + + fireEvent.click(card); + const rail = await screen.findByTestId('data-field-inspector', undefined, { timeout: 8000 }); + await waitFor(() => expect(rail.querySelector('[data-disabled-reason]')).not.toBeNull()); + expect(rail).toHaveTextContent( + tFormat('engine.studio.contributed.fieldTitle', 'en', { name: 'acme_score', package: 'Acme add-on' }), + ); + + // CONTROL: the base's own field opens in the pair's display-property mode, not read-only. + fireEvent.click(screen.getByLabelText(tFormat('engine.studio.designer.fieldAriaReadOnly', 'en', { label: 'Account Name' }))); + await waitFor(() => expect(screen.getByTestId('data-field-inspector').querySelector('[data-disabled-reason]')).toBeNull()); + }); + + it('Automations: a contributed flow draws in its object’s group, an object with no flow of the base’s gets one, and a row opens the contributor', async () => { + srv.contributed = true; + renderAt(`/studio/${BASE}/automations`); + const autoRail = await screen.findByTestId('studio-rail-automations', undefined, { timeout: 8000 }); + await waitFor(() => expect(contributed(autoRail, 'flow', 'acme_sync')).not.toBeNull(), { timeout: 8000 }); + + const accountGroup = within(autoRail).getByTestId('auto-rail-group-crm_account'); + const sync = contributed(autoRail, 'flow', 'acme_sync')!; + expect(accountGroup).toContainElement(sync); + expect(within(sync).getByTestId('contribution-mark')).toHaveTextContent(FROM_ACME); + expect(within(autoRail).getByTestId('auto-rail-group-crm_deal')).toContainElement( + contributed(autoRail, 'flow', 'acme_deal_watch'), + ); + // The base ships no flow: the rail does not say "no flows". + expect(within(autoRail).queryByText(t('engine.studio.auto.none', 'en'))).toBeNull(); + + fireEvent.click(sync); + await waitFor(() => + expect(screen.getByTestId('location')).toHaveTextContent(`/studio/${encodeURIComponent(ADDON)}/automations?surface=`), + ); + expect(screen.getByTestId('location').textContent).toContain('acme_sync'); + }); + + it('one reader: no request beyond the base’s customization read names the contributing package', async () => { + srv.contributed = true; + renderAt(`/studio/${BASE}/automations`); + const autoRail = await screen.findByTestId('studio-rail-automations', undefined, { timeout: 8000 }); + await waitFor(() => expect(contributed(autoRail, 'flow', 'acme_sync')).not.toBeNull(), { timeout: 8000 }); + expect(srv.reads.filter((r) => r.includes(encodeURIComponent(ADDON)) || r.includes(`package=${ADDON}`))).toEqual([]); + expect(srv.reads.filter((r) => r.startsWith(`/api/v1/packages/${BASE}/customizations`)).length).toBeGreaterThan(0); + }); +}); 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 6d874e81e4d..43a2911ccbb 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -402,17 +402,30 @@ import { } from './packages-io.js'; import { usePackageCustomizations, type PackageCustomizationsState } from './CustomizationMark.js'; import { + ContributedRailRow, RailCustomizationFilter, RailCustomizationMark, StudioPairItemChip, + contributedItemTitle, customizationPairBaseOf, foldCustomizationPairs, railCustomizationsOf, useDeepLinkFallback, + useOpenContributor, useRailCustomizationsOnly, withPairItems, type RailCustomizations, } from './StudioRailCustomizations.js'; +import { + baseContributionsOf, + contributedFieldsOf, + placeNavContributions, + placesAnyNav, + withContributedFlowGroups, + type ContributedOnObject, + type NavContributionPlacement, + type TopLevelContributedNav, +} from './contributionRows.js'; import { StudioCustomizeButton, StudioOverrideChip, @@ -3003,6 +3016,11 @@ function NavTreeGroup({ const locale = useMetadataLocale(); const targetLabel = useNavTargetLabel(); const children = node.children ?? []; + // objectstack#29786 — a declared group (`type: 'group'`) also holds the + // entries other packages contribute to it; a group with only those is no + // longer empty. + const placement = React.useContext(InterfacesNavContributionsContext); + const contributed = node.type === 'group' && typeof node.id === 'string' ? (placement.byGroup.get(node.id) ?? []) : []; const holdsActive = navTreeHoldsActive(children, active, locale, targetLabel); const [open, setOpen] = React.useState(() => { if (holdsActive) return true; @@ -3019,12 +3037,19 @@ function NavTreeGroup({ } return ( setOpen((v) => !v)}> - {children.length === 0 ? ( + {children.length === 0 && contributed.length === 0 ? (

{t('engine.studio.if.emptyGroup', locale)}

) : ( - + <> + {children.length > 0 && ( + + )} + {/* objectstack#29786 — what other packages add to this group, after + the group's own entries, as the served navigation appends them. */} + ({ entry, undeclaredGroup: null }))} indent /> + )}
); @@ -3057,6 +3082,46 @@ function NavTreeGroup({ */ const InterfacesRailMarksContext = React.createContext(null); +/** + * objectstack#29786 — where the open app's contributed nav entries go + * (`placeNavContributions`), read by the tree's groups and its top level. + * Empty on a package with none. + */ +const InterfacesNavContributionsContext = React.createContext({ byGroup: new Map(), top: [] }); + +/** + * objectstack#29786 — contributed nav entries as rail rows: greyed, with the + * contributing package, and opening that package's Interfaces pillar. Not + * nav entries of this app: never in its edit buffer, never dragged, renamed + * or deleted here. + */ +function ContributedNavRows({ + rows, + indent = false, +}: { + rows: readonly TopLevelContributedNav[]; + indent?: boolean; +}): React.ReactElement | null { + const locale = useMetadataLocale(); + const openContributor = useOpenContributor(); + if (rows.length === 0) return null; + return ( + <> + {rows.map(({ entry, undeclaredGroup }) => ( + } + title={contributedItemTitle(entry, locale, undeclaredGroup)} + onOpen={() => openContributor(`/studio/${encodeURIComponent(entry.packageId)}/interfaces`)} + locale={locale} + /> + ))} + + ); +} + function NavTree({ nodes, active, @@ -4421,6 +4486,15 @@ export function InterfacesPillar({ const [servedNav, setServedNav] = React.useState(null); const railTree = React.useMemo(() => displayNavTree(navTree, servedNav), [navTree, servedNav]); const railAppLabel = displayAppLabel(appLabel, servedNav); + // objectstack#29786 — what other packages add to this app's navigation, + // from the base's one customization read (`customization.marks`), placed + // in the tree the rail draws. + const customizationMarks = customization?.marks; + const contributions = React.useMemo(() => baseContributionsOf(customizationMarks), [customizationMarks]); + const navPlacement = React.useMemo( + () => placeNavContributions(contributions, appName, railTree), + [contributions, appName, railTree], + ); // nav editing — drag-drop reorder / rename / add / remove via AppNavCanvas const [editNav, setEditNav] = React.useState(false); const [navSel, setNavSel] = React.useState<{ kind: string; id: string } | null>(null); @@ -7072,6 +7146,7 @@ export function InterfacesPillar({ open={railOpen} > + {editNav && (
{navHasDraft && ( @@ -7112,7 +7187,7 @@ export function InterfacesPillar({ onSelectionChange={(s) => setNavSel(s ? { kind: s.kind, id: s.id } : null)} onRenamePendingChange={setNavRenamePending} /> - ) : navTree.length === 0 ? ( + ) : navTree.length === 0 && !(appStatus === 'ready' && placesAnyNav(navPlacement)) ? (

{error ? t('engine.studio.loadFailed', locale) @@ -7121,7 +7196,13 @@ export function InterfacesPillar({ : t('engine.studio.if.noNavItems', locale)}

) : ( - + <> + + {/* objectstack#29786 — contributed entries the app's top level + holds: those naming no group, and those naming a group the + app does not declare (the served navigation appends both). */} + + )} {/* objectstack#26889 — under the tree (or the no-app notice), the items no entry opens. Not while nav editing: the rail is the @@ -7134,6 +7215,7 @@ export function InterfacesPillar({ onlyCustomized={listOnlyCustomized} /> )} + @@ -8699,9 +8781,24 @@ export function DataPillar({ // the inspector's display-property mode. The rail's read-only reason wrapper // is then not drawn: the inspector describes each control it locks there // (objectstack#28986). - const fieldRailReadOnly = readOnly && !(extWritable && fieldSel?.kind === 'field'); + // objectstack#29786 — the open base object's fields another package + // contributes (the base's one customization read): greyed in the Form + // layout, and read-only in this rail whatever the pair could write, since + // neither the base nor its pair owns them. + const customizationMarks = customization?.marks; + const contributedFields = React.useMemo( + () => contributedFieldsOf(baseContributionsOf(customizationMarks), current?.name), + [customizationMarks, current?.name], + ); + const selectedContributedField = + fieldSel?.kind === 'field' ? (contributedFields.get(String(fieldSel.id)) ?? null) : null; + const fieldRailReadOnly = (readOnly && !(extWritable && fieldSel?.kind === 'field')) || selectedContributedField !== null; const selectedBaseField = - extWritable && fieldSel?.kind === 'field' && baseOwnedFields !== null && baseOwnedFields.has(String(fieldSel.id)); + extWritable && + fieldSel?.kind === 'field' && + selectedContributedField === null && + baseOwnedFields !== null && + baseOwnedFields.has(String(fieldSel.id)); const addFieldWalledId = React.useId(); // objectui#12725 — the field rail is up exactly when the render below draws @@ -9294,6 +9391,7 @@ export function DataPillar({ onAddField={canAddFields ? addField : undefined} readOnly={readOnly} modeControl={formToolbarLeading} + contributedFields={contributedFields} /> ) : ( <> @@ -9509,7 +9607,9 @@ export function DataPillar({ // focus, and Tab skipped the whole rail). One trigger for the // rail rather than one per control: the reason is the same for // all of them, and the controls are the shared inspector's. - const reason = t('engine.studio.pkg.readonlyHint', locale); + const reason = selectedContributedField + ? contributedItemTitle(selectedContributedField, locale) + : t('engine.studio.pkg.readonlyHint', locale); return ( @@ -11020,6 +11120,24 @@ export function AutomationsPillar({ }), [flows, liveFlowDefinitions, objectLabels, objectOrder, locale], ); + // objectstack#29786 — the flows other packages ship on the base's objects, + // from the base's one customization read, each in its object's group. + const customizationMarks = customization?.marks; + const contributedFlows = React.useMemo(() => baseContributionsOf(customizationMarks), [customizationMarks]); + const railGroupsShown = React.useMemo( + () => + withContributedFlowGroups(railGroups, contributedFlows, (object) => ({ + key: object, + objectName: object, + label: objectLabels.get(object) || object, + items: [] as Surface[], + })), + [railGroups, contributedFlows, objectLabels], + ); + const openContributor = useOpenContributor(); + const contributedFlowShown = (f: ContributedOnObject) => + !onlyCustomized && + (!railNeedle || f.name.toLowerCase().includes(railNeedle) || f.packageName.toLowerCase().includes(railNeedle)); // The legend under the rail: the four trigger glyphs and the dots. Off and // Refused are named only while some flow in the rail reads so. @@ -11180,16 +11298,17 @@ export function AutomationsPillar({ line (the row's title holds it whole, with its trigger kind and status words — the redesign trades objectui#11794's wrap for the 32 px row), and its status dot. */} - {railGroups.map((group) => { + {railGroupsShown.map(({ group, contributed: contributedAll }) => { const items = railNeedle || onlyCustomized ? group.items.filter(flowShown) : group.items; - if (items.length === 0) return null; + const contributed = contributedAll.filter(contributedFlowShown); + if (items.length === 0 && contributed.length === 0) return null; const groupKey = group.key || NO_OBJECT_GROUP_KEY; return ( setFoldedGroups((folded) => { @@ -11231,10 +11350,25 @@ export function AutomationsPillar({ ); })} + {/* objectstack#29786 — the flows another package ships on this + object: greyed, with the package, opening it there. */} + {contributed.map((f) => ( + } + title={contributedItemTitle(f, locale)} + onOpen={() => + openContributor(studioPillarItemPath(f.packageId, 'automations', { type: 'flow', name: f.name })) + } + locale={locale} + /> + ))} ); })} - {flows.length === 0 && !creating && (error || !listed || openCreateFlow) && ( + {flows.length === 0 && (contributedFlows?.flows.length ?? 0) === 0 && !creating && (error || !listed || openCreateFlow) && (

{error ? t('engine.studio.loadFailed', locale) diff --git a/ui/packages/app-shell/src/views/studio-design/StudioRailCustomizations.tsx b/ui/packages/app-shell/src/views/studio-design/StudioRailCustomizations.tsx index 0afb5a79b4b..013c18f8fd1 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioRailCustomizations.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioRailCustomizations.tsx @@ -25,18 +25,24 @@ * base asks the ensure door for the pair first (`StudioBaseCustomization`). * * Items another package contributes to the base (fields extending its - * objects, flows on its objects, ADR-0029 D7 navigation entries) are not - * drawn: no served read names the contributing package (objectstack#28722), - * and the rail derives none. + * objects, flows on its objects, ADR-0029 D7 navigation entries) are drawn + * greyed and read-only, each with the contributing package + * ({@link ContributionMark}, {@link ContributedRailRow}), from the same read's + * `contributions` (objectstack#29786; the placement rules are + * `contributionRows.ts`'s). A click opens the contributing package. */ import * as React from 'react'; +import { useNavigate } from 'react-router-dom'; import { cn } from '@object-ui/components'; import { Boxes } from 'lucide-react'; import { customizationBasePackageId } from '@objectstack/spec/kernel'; import { tFormat } from '../metadata-admin/i18n.js'; +import { confirmUnsavedLeave } from '../../chrome/unsavedEdits.js'; import { CustomizationMark, type PackageCustomizationsState } from './CustomizationMark.js'; import { customizationMarkOf, type CustomizationMarkKind } from './customizationMarks.js'; +import { StudioRailRow } from './StudioRail.js'; +import type { ContributedNav, ContributedOnObject } from './contributionRows.js'; /** A rail row as the one-surface helpers read it. */ export interface PairRailRow { @@ -264,3 +270,104 @@ export function foldCustomizationPairs( export function customizationPairBaseOf(packageId: string | null): string | null { return packageId === null ? null : customizationBasePackageId(packageId); } + +/** + * objectstack#29786 — the chip that names the package an item was contributed + * by ("from HotCRM Service"). Muted, as the contributed row it sits on. + */ +export function ContributionMark({ + packageName, + packageId, + locale, + className, +}: { + packageName: string; + packageId: string; + locale: string; + className?: string; +}): React.ReactElement { + return ( + + {tFormat('engine.studio.contributed.from', locale, { package: packageName })} + + ); +} + +/** + * The tooltip of a contributed item: what it is, who added it, and that it is + * changed there. A `nav` entry whose group the app does not declare says it + * is drawn at the top level, as the served navigation draws it. + */ +export function contributedItemTitle( + item: ContributedNav | ContributedOnObject, + locale: string, + undeclaredGroup: string | null = null, +): string { + const vars = { name: item.name, package: item.packageName }; + const said = tFormat(`engine.studio.contributed.${item.kind}Title`, locale, vars); + return undeclaredGroup === null + ? said + : `${said} ${tFormat('engine.studio.contributed.undeclaredGroup', locale, { group: undeclaredGroup })}`; +} + +/** + * Open the contributing package's Studio at `path`: the one way a contributed + * row is acted on, since the base's surface cannot change it. Leaves through + * the shell's unsaved-edits seam, so a pillar holding unsaved work asks first. + */ +export function useOpenContributor(): (path: string) => void { + const navigate = useNavigate(); + return React.useCallback((path: string) => confirmUnsavedLeave(() => navigate(path)), [navigate]); +} + +/** + * One contributed item as a rail row: greyed, labelled with its package, and + * never a drag source or an editor's target. `onOpen` goes to the contributing + * package. + */ +export function ContributedRailRow({ + item, + icon, + indent = false, + title, + onOpen, + locale, + testId, +}: { + item: ContributedNav | ContributedOnObject; + icon?: React.ReactNode; + indent?: boolean; + title: string; + onOpen: () => void; + locale: string; + testId?: string; +}): React.ReactElement { + return ( + + + {item.name} + + + + ); +} diff --git a/ui/packages/app-shell/src/views/studio-design/contributionRows.test.ts b/ui/packages/app-shell/src/views/studio-design/contributionRows.test.ts new file mode 100644 index 00000000000..4f5f25efe6d --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/contributionRows.test.ts @@ -0,0 +1,158 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#29786 — the placement rules for the items other packages + * contribute to a read-only base, from the base's one customization read. + * + * Pinned: + * - only a read answer yields contributions; off / loading / failed yield none; + * - each entry keeps its kind, and its package is named from the list, else by id; + * - a `nav` entry goes under the group it names when the app declares a + * `type: 'group'` item of that id at any depth, else at the top level, and + * the undeclared group is named; an entry naming no group is top level; + * an entry for another app is left out; a non-group item sharing the id + * is no container (the server's fold asks the same); + * - fields are indexed per object; a flow joins its object's group, or gets + * a group of its own before "No object". + */ + +import { describe, expect, it } from 'vitest'; +import type { PackageCustomizationsState } from './CustomizationMark'; +import { + baseContributionsOf, + contributedFieldsOf, + findDeclaredNavGroup, + placeNavContributions, + placesAnyNav, + withContributedFlowGroups, +} from './contributionRows'; + +const BASE = 'com.example.hotcrm'; +const SERVICE = 'com.example.hotcrm.service'; +const ACME = 'com.acme.addon'; + +const READ = { + packageId: BASE, + overrides: [], + extensions: [], + switchedOff: [], + contributions: [ + { kind: 'field' as const, object: 'crm_account', name: 'acme_score', packageId: ACME }, + { kind: 'flow' as const, object: 'crm_account', name: 'acme_sync', packageId: ACME }, + { kind: 'flow' as const, object: 'crm_case', name: 'case_escalate', packageId: SERVICE }, + { kind: 'nav' as const, app: 'crm_app', name: 'nav_top', packageId: SERVICE }, + { kind: 'nav' as const, app: 'crm_app', group: 'service_group', name: 'nav_cases', packageId: SERVICE }, + { kind: 'nav' as const, app: 'crm_app', group: 'ghost_group', name: 'nav_knowledge', packageId: SERVICE }, + { kind: 'nav' as const, app: 'other_app', name: 'nav_elsewhere', packageId: SERVICE }, + ], + counts: { overrides: 0, extensions: 0, switchedOff: 0, contributions: 7 }, +}; + +const readState = (): PackageCustomizationsState => ({ + status: 'read', + read: READ, + index: { baseId: BASE, pairId: `${BASE}.custom`, overrides: new Set() }, + baseName: 'HotCRM', + contributorNames: new Map([[SERVICE, 'HotCRM Service']]), +}); + +describe('baseContributionsOf', () => { + it('splits a read answer by kind, naming each package from the list, else by its id', () => { + const c = baseContributionsOf(readState())!; + expect(c.fields).toEqual([ + { kind: 'field', object: 'crm_account', name: 'acme_score', packageId: ACME, packageName: ACME }, + ]); + expect(c.flows.map((f) => [f.object, f.name, f.packageName])).toEqual([ + ['crm_account', 'acme_sync', ACME], + ['crm_case', 'case_escalate', 'HotCRM Service'], + ]); + expect(c.nav.map((n) => [n.app, n.group ?? null, n.name])).toEqual([ + ['crm_app', null, 'nav_top'], + ['crm_app', 'service_group', 'nav_cases'], + ['crm_app', 'ghost_group', 'nav_knowledge'], + ['other_app', null, 'nav_elsewhere'], + ]); + expect('group' in c.nav[0]!).toBe(false); + }); + + it('yields nothing before or without an answer', () => { + expect(baseContributionsOf(null)).toBeNull(); + expect(baseContributionsOf({ status: 'off' })).toBeNull(); + expect(baseContributionsOf({ status: 'loading' })).toBeNull(); + expect(baseContributionsOf({ status: 'failed', message: 'nope' })).toBeNull(); + }); +}); + +describe('placeNavContributions', () => { + const NAV = [ + { id: 'nav_home', type: 'dashboard', dashboardName: 'home' }, + { id: 'outer', type: 'group', children: [{ id: 'service_group', type: 'group', children: [] }] }, + ]; + + it('puts an entry under the declared group it names, at any depth', () => { + const p = placeNavContributions(baseContributionsOf(readState()), 'crm_app', NAV); + expect(p.byGroup.get('service_group')?.map((e) => e.name)).toEqual(['nav_cases']); + expect([...p.byGroup.keys()]).toEqual(['service_group']); + }); + + it('puts an entry naming no group, or an undeclared one, at the top level and names the undeclared group', () => { + const p = placeNavContributions(baseContributionsOf(readState()), 'crm_app', NAV); + expect(p.top.map((r) => [r.entry.name, r.undeclaredGroup])).toEqual([ + ['nav_top', null], + ['nav_knowledge', 'ghost_group'], + ]); + }); + + it('leaves out the entries for another app, and places nothing without an app or a read', () => { + const p = placeNavContributions(baseContributionsOf(readState()), 'other_app', NAV); + expect(p.top.map((r) => r.entry.name)).toEqual(['nav_elsewhere']); + expect(placesAnyNav(placeNavContributions(baseContributionsOf(readState()), null, NAV))).toBe(false); + expect(placesAnyNav(placeNavContributions(null, 'crm_app', NAV))).toBe(false); + expect(placesAnyNav(placeNavContributions(baseContributionsOf(readState()), 'no_such_app', NAV))).toBe(false); + }); + + it('a non-group item sharing the id is no container: the entry goes to the top level', () => { + const nav = [{ id: 'service_group', type: 'object', objectName: 'crm_case' }]; + expect(findDeclaredNavGroup(nav, 'service_group')).toBeUndefined(); + const p = placeNavContributions(baseContributionsOf(readState()), 'crm_app', nav); + expect(p.byGroup.size).toBe(0); + expect(p.top.find((r) => r.entry.name === 'nav_cases')?.undeclaredGroup).toBe('service_group'); + }); +}); + +describe('contributedFieldsOf', () => { + it('indexes the fields of one object by name', () => { + const c = baseContributionsOf(readState()); + expect([...contributedFieldsOf(c, 'crm_account').keys()]).toEqual(['acme_score']); + expect(contributedFieldsOf(c, 'crm_case').size).toBe(0); + expect(contributedFieldsOf(c, null).size).toBe(0); + expect(contributedFieldsOf(null, 'crm_account').size).toBe(0); + }); +}); + +describe('withContributedFlowGroups', () => { + type G = { key: string; objectName: string | null; label: string }; + const make = (object: string): G => ({ key: object, objectName: object, label: `(${object})` }); + + it('joins a flow to its object’s group, and gives an object with no group one before "No object"', () => { + const groups: G[] = [ + { key: 'crm_account', objectName: 'crm_account', label: 'Account' }, + { key: '', objectName: null, label: 'No object' }, + ]; + const out = withContributedFlowGroups(groups, baseContributionsOf(readState()), make); + expect(out.map(({ group, contributed }) => [group.key, contributed.map((f) => f.name)])).toEqual([ + ['crm_account', ['acme_sync']], + ['crm_case', ['case_escalate']], + ['', []], + ]); + }); + + it('appends the new groups when the rail has no "No object" group, and changes nothing without a read', () => { + const groups: G[] = [{ key: 'crm_account', objectName: 'crm_account', label: 'Account' }]; + expect(withContributedFlowGroups(groups, baseContributionsOf(readState()), make).map((g) => g.group.key)).toEqual([ + 'crm_account', + 'crm_case', + ]); + expect(withContributedFlowGroups(groups, null, make)).toEqual([{ group: groups[0], contributed: [] }]); + }); +}); diff --git a/ui/packages/app-shell/src/views/studio-design/contributionRows.ts b/ui/packages/app-shell/src/views/studio-design/contributionRows.ts new file mode 100644 index 00000000000..7873501c857 --- /dev/null +++ b/ui/packages/app-shell/src/views/studio-design/contributionRows.ts @@ -0,0 +1,220 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack#29786 — the items other installed packages contribute to a + * read-only base, as the Studio pillars draw them: greyed, read-only and + * labelled with the contributing package (ADR-0146 D3, "On top, one + * surface"; ADR-0029 D7 for the navigation half). Pure, so the placement + * rules are pinned without a render. + * + * ## The facts it reads, and only those + * + * The base's ONE customization read (`GET /api/v1/packages/:id/customizations`, + * `contributions`), the same answer the rails' marks come from + * (`usePackageCustomizations`): every `field`, `flow` and `nav` entry, each + * naming its contributing package. The package's display name is the listed + * package's, looked up by that id as the base's own name is. + * + * ⛔ Nothing is joined client-side on names (ruling 6566915346, letter E): a + * `nav` entry names the item's id and no label, and its row says the id. The + * contributor's manifest is never read for a label, a target or an order — + * that would be a second copy of the server's navigation fold. + * + * ## Where a `nav` entry goes + * + * Under the group the entry names when the open app's navigation declares a + * `type: 'group'` item of that id, at any depth — the one question the + * server's fold asks (`findNavGroup`, `@objectstack/objectql`). Otherwise at + * the top level, as the fold appends it; an entry that names a group the app + * does not declare says so (`undeclaredGroup`). + */ + +import type { PackageCustomizationsState } from './CustomizationMark.js'; + +/** What every contributed item carries. */ +interface ContributedBase { + /** The item's name: a field name, a flow name, a nav item's id. */ + name: string; + /** The contributing package's id. */ + packageId: string; + /** The contributing package's listed name, else its id. */ + packageName: string; +} + +/** A field or flow another package contributes to one of the base's objects. */ +export interface ContributedOnObject extends ContributedBase { + kind: 'field' | 'flow'; + /** The base-owned object it is on. */ + object: string; +} + +/** A navigation item another package contributes to one of the base's apps. */ +export interface ContributedNav extends ContributedBase { + kind: 'nav'; + app: string; + /** The group the contribution targets, as declared; absent for the top level. */ + group?: string; +} + +/** A base's contributed items, by kind. */ +export interface BaseContributions { + nav: readonly ContributedNav[]; + fields: readonly ContributedOnObject[]; + flows: readonly ContributedOnObject[]; +} + +/** + * The contributed items of a base's read, or `null` when there is no answer + * to draw from: no read, one in flight, or a failed one (the rail's filter + * already says why). Entries keep the read's order. + */ +export function baseContributionsOf(state: PackageCustomizationsState | null | undefined): BaseContributions | null { + if (!state || state.status !== 'read') return null; + const nameOf = (id: string) => state.contributorNames.get(id) ?? id; + const nav: ContributedNav[] = []; + const fields: ContributedOnObject[] = []; + const flows: ContributedOnObject[] = []; + for (const entry of state.read.contributions) { + if (entry.kind === 'nav') { + nav.push({ + kind: 'nav', + app: entry.app, + ...(entry.group !== undefined ? { group: entry.group } : {}), + name: entry.name, + packageId: entry.packageId, + packageName: nameOf(entry.packageId), + }); + } else { + const item: ContributedOnObject = { + kind: entry.kind, + object: entry.object, + name: entry.name, + packageId: entry.packageId, + packageName: nameOf(entry.packageId), + }; + (entry.kind === 'field' ? fields : flows).push(item); + } + } + return { nav, fields, flows }; +} + +/** The fields contributed to one object, by field name. */ +export function contributedFieldsOf( + contributions: BaseContributions | null, + object: string | null | undefined, +): ReadonlyMap { + const out = new Map(); + if (!contributions || !object) return out; + for (const f of contributions.fields) if (f.object === object && !out.has(f.name)) out.set(f.name, f); + return out; +} + +/** + * The `type: 'group'` nav item of id `groupId` in `items`, at any depth, or + * `undefined`. The server's fold asks exactly this (both conditions: an entry + * of another type sharing the id is no container), so a group this finds is + * the one the served navigation fills. + */ +export function findDeclaredNavGroup(items: unknown, groupId: string): Record | undefined { + if (!Array.isArray(items)) return undefined; + for (const item of items) { + if (!item || typeof item !== 'object') continue; + const node = item as Record; + if (node.id === groupId && node.type === 'group') return node; + const found = findDeclaredNavGroup(node.children, groupId); + if (found) return found; + } + return undefined; +} + +/** A contributed nav row drawn at the app's top level. */ +export interface TopLevelContributedNav { + entry: ContributedNav; + /** The group the entry names that the app does not declare, or `null`. */ + undeclaredGroup: string | null; +} + +/** Where one app's contributed nav rows go in its tree. */ +export interface NavContributionPlacement { + /** By declared group id: the rows drawn after that group's own children. */ + byGroup: ReadonlyMap; + /** The rows drawn after the app's own top-level entries. */ + top: readonly TopLevelContributedNav[]; +} + +const NO_PLACEMENT: NavContributionPlacement = { byGroup: new Map(), top: [] }; + +/** + * Place the contributions to `app` in its `navigation` (the tree the rail + * draws). Entries for other apps are left out. + */ +export function placeNavContributions( + contributions: BaseContributions | null, + app: string | null, + navigation: unknown, +): NavContributionPlacement { + if (!contributions || !app) return NO_PLACEMENT; + const byGroup = new Map(); + const top: TopLevelContributedNav[] = []; + for (const entry of contributions.nav) { + if (entry.app !== app) continue; + if (entry.group === undefined) { + top.push({ entry, undeclaredGroup: null }); + } else if (findDeclaredNavGroup(navigation, entry.group)) { + const list = byGroup.get(entry.group) ?? []; + list.push(entry); + byGroup.set(entry.group, list); + } else { + top.push({ entry, undeclaredGroup: entry.group }); + } + } + return byGroup.size === 0 && top.length === 0 ? NO_PLACEMENT : { byGroup, top }; +} + +/** Whether a placement draws any row. */ +export function placesAnyNav(placement: NavContributionPlacement): boolean { + return placement.top.length > 0 || placement.byGroup.size > 0; +} + +/** A flow rail group with the flows other packages trigger on its object. */ +export interface GroupWithContributedFlows { + group: G; + contributed: readonly ContributedOnObject[]; +} + +/** + * The Automations rail's groups, each with the contributed flows on its + * object. A contributed flow whose object has no group of the base's own gets + * one, labelled as the rail labels an object (`labelOf`), after the base's + * object groups and before the "No object" group, in the read's order. + */ +export function withContributedFlowGroups( + groups: readonly G[], + contributions: BaseContributions | null, + makeGroup: (object: string) => G, +): Array> { + const byObject = new Map(); + for (const flow of contributions?.flows ?? []) { + const list = byObject.get(flow.object) ?? []; + list.push(flow); + byObject.set(flow.object, list); + } + const out: Array> = []; + const held = new Set(); + for (const group of groups) { + if (group.objectName !== null) held.add(group.objectName); + } + const added = [...byObject.keys()] + .filter((object) => !held.has(object)) + .map((object) => ({ group: makeGroup(object), contributed: byObject.get(object) ?? [] })); + let addedPlaced = false; + for (const group of groups) { + if (group.objectName === null && !addedPlaced) { + out.push(...added); + addedPlaced = true; + } + out.push({ group, contributed: group.objectName === null ? [] : (byObject.get(group.objectName) ?? []) }); + } + if (!addedPlaced) out.push(...added); + return out; +} diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index b2951029110..75c67bb001a 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -3234,6 +3234,12 @@ const STUDIO_EN: Record = { // objectstack#28572 — one surface for a read-only base and its customization package. 'engine.studio.customize.addedIn': 'In {pair}', 'engine.studio.customize.addedInTitle': 'This item is added by {pair}, this deployment’s customization package, and is saved there.', + // objectstack#29786 — items another package contributes to a read-only base. + 'engine.studio.contributed.from': 'from {package}', + 'engine.studio.contributed.navTitle': 'Navigation item {name}, added to this app by {package}. Read-only here: open {package} to change it.', + 'engine.studio.contributed.fieldTitle': 'Field {name}, added to this object by {package}. Read-only here: open {package} to change it.', + 'engine.studio.contributed.flowTitle': 'Flow {name}, triggered on this object and shipped by {package}. Read-only here: open {package} to change it.', + 'engine.studio.contributed.undeclaredGroup': 'It names the group “{group}”, which this app does not declare, so it shows at the top level.', // objectstack#29705 — what a person can do with a type's items, in the words // the pillars use for an item's state: an overlay type of a read-only package // takes a customization; a create-only type of a writable package takes new diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 61ca19c0f53..659e2c95a02 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -2863,6 +2863,12 @@ const STUDIO_ZH: Record = { // objectstack#28572 — 只读基础包与其定制包合为一个界面。 'engine.studio.customize.addedIn': '位于 {pair}', 'engine.studio.customize.addedInTitle': '此项由本部署的定制包 {pair} 新增,并保存在其中。', + // objectstack#29786 — items another package contributes to a read-only base. + 'engine.studio.contributed.from': '来自 {package}', + 'engine.studio.contributed.navTitle': '导航项 {name},由 {package} 加入此应用。在这里只读:要修改,请打开 {package}。', + 'engine.studio.contributed.fieldTitle': '字段 {name},由 {package} 加入此对象。在这里只读:要修改,请打开 {package}。', + 'engine.studio.contributed.flowTitle': '流程 {name},在此对象上触发,由 {package} 提供。在这里只读:要修改,请打开 {package}。', + 'engine.studio.contributed.undeclaredGroup': '它指向的分组「{group}」在此应用中没有声明,因此显示在顶层。', 'engine.studio.customize.available': '可定制', 'engine.studio.customize.availableTitle': '此软件包为只读。对其中这些项的修改会保存到本部署的定制包中,软件包本身保持安装时的样子。', 'engine.studio.customize.addable': '可新增',