From d36804b5dd03a74e7434c07df95abc08a08c9c02 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 08:13:02 +0000 Subject: [PATCH 1/4] feat(app-shell): a contributed nav entry shows its served label, and a contributor-only package says where its entries go The contributed nav row reads the label the live app shows, by (app, id) in the served app row the Interfaces pillar already reads; the id stays as a second line. A package with no app of its own that declares navigation contributions counts its entries from the target owner's served customization read and says so, with an Open button; any fallback keeps the old text. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QsR2jSRtrD4WV4w6qQDYJ2 --- .changeset/29851-contributed-nav-labels.md | 13 ++ ...dioDesignSurface.oneSurface-28572.test.tsx | 194 +++++++++++++++++- .../studio-design/StudioDesignSurface.tsx | 92 ++++++++- .../StudioRailCustomizations.tsx | 104 +++++++++- .../studio-design/contributionRows.test.ts | 92 +++++++++ .../views/studio-design/contributionRows.ts | 113 ++++++++++ ui/packages/i18n/src/namespaces/studio/en.ts | 7 + ui/packages/i18n/src/namespaces/studio/zh.ts | 6 + 8 files changed, 608 insertions(+), 13 deletions(-) create mode 100644 .changeset/29851-contributed-nav-labels.md diff --git a/.changeset/29851-contributed-nav-labels.md b/.changeset/29851-contributed-nav-labels.md new file mode 100644 index 00000000000..6a9747cf93c --- /dev/null +++ b/.changeset/29851-contributed-nav-labels.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(studio): a contributed nav entry shows the label the live app shows, and a package that only contributes navigation says where it goes + +Clause-②: no +Metadata-protocol: none + +- **The label.** A navigation entry another package contributes to a base's app now shows the label the live app shows, with its id as a muted second line under it. Before, the row showed only the id. The label is looked up by `(app, id)` in the app row the Interfaces pillar already reads (`GET /meta/app?package=`), which serves the folded navigation: the served entries the app's published layer does not hold. No request is added and nothing is read from the contributor's manifest. An id the base also holds, or one two contributions share, keeps the bare id, so no label is guessed. A label-less entry reads what it inherits, through the rail's own resolver. A never-published app has no served row, so its contributed rows keep the id. +- **The contributor's empty state.** A package with no app of its own whose listed manifest declares `navigationContributions` now says what it adds: "This package has no app of its own. It adds 2 navigation entries to HotCRM's app.", with a button that opens that package. The declared app is an address only. Its owner comes from the served app read (`GET /meta/app/:app`), and the count comes from the owner's served customization read (`GET /packages/:owner/customizations`), counting the `nav` contributions stamped with this package for that app. That is at most two reads per declared target app, sent only on that empty state. When the package is disabled, the target app is missing, either read fails, or the count is 0, the text stays "This package has no app yet." +- **New strings.** `@object-ui/i18n`'s studio namespace gains five keys in `en` and `zh` (`engine.studio.if.noOwnApp`, `.noOwnAppTitle`, `.contributesOne`, `.contributesOther`, `.openOwner`). 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 3135b0e7e55..d488077fd60 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 @@ -55,6 +55,18 @@ const srv = vi.hoisted(() => ({ 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, + /** objectstack#29851 — the base's `/meta/app` row serves the folded navigation ({@link SERVED_APP}). */ + servedFold: false, + /** objectstack#29851 — the add-on is a contributor with no app of its own (see {@link addonRow}). */ + addonContributes: false, + /** objectstack#29851 — the add-on's listed row says it is disabled; the server then lists none of its entries. */ + addonDisabled: false, + /** objectstack#29851 — how `GET /meta/app/crm_app` answers. */ + targetApp: 'served' as 'served' | 'missing' | 'fails', + /** objectstack#29851 — the base's customization read fails. */ + ownerReadFails: false, + /** objectstack#29851 — the add-on declares its entries for this app. */ + declaredApp: 'crm_app', })); vi.mock('@object-ui/auth', async (importOriginal) => { @@ -122,10 +134,25 @@ function packageRows() { ...(srv.pairListed ? [{ manifest: { id: PAIR, name: 'HotCRM customizations', namespace: 'hotcrm_custom', version: '0.1.0' }, writable: true }] : []), - { manifest: { id: ADDON, name: 'Acme add-on', namespace: 'acme', version: '1.0.0' }, writable: false }, + addonRow(), ]; } +/** + * objectstack#29851 — the add-on's listed row. As a contributor it declares + * navigation for the base's app: the declaration is an address only, so the + * stub's served reads decide what Studio says. + */ +function addonRow() { + const manifest: Record = { id: ADDON, name: 'Acme add-on', namespace: 'acme', version: '1.0.0' }; + if (srv.addonContributes) { + manifest.navigationContributions = [ + { app: srv.declaredApp, group: 'service_group', items: [{ id: 'nav_cases', type: 'url', url: '/cases' }] }, + ]; + } + return { manifest, writable: false, ...(srv.addonDisabled ? { enabled: false } : {}) }; +} + /** The base's read: its `pipeline` dashboard overridden, and a field its pair adds to `crm_account`. */ const READ = { packageId: BASE, @@ -163,8 +190,28 @@ const ACCOUNT_SERVED = { 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 readWith = (read: typeof READ) => { + if (!srv.contributed) return read; + // A disabled package's contributions are not listed (the server's fold drops them). + const served = srv.addonDisabled ? CONTRIBUTIONS.filter((c) => c.packageId !== ADDON) : CONTRIBUTIONS; + return { ...read, contributions: served, counts: { ...read.counts, contributions: served.length } }; +}; +/** + * objectstack#29851 — the base's app as `/meta/app?package=` serves it: the + * fold has added the add-on's entries to it. `nav_cases` once, under the + * group it names; `nav_knowledge` twice (two contributions share the id), so + * no label can be told for it. + */ +const SERVED_APP = { + name: 'crm_app', + label: 'CRM', + navigation: [ + ...NAV, + { id: 'service_group', type: 'group', label: 'Service', children: [{ id: 'nav_cases', type: 'url', label: 'Cases', url: '/cases' }] }, + { id: 'nav_knowledge', type: 'url', label: 'Knowledge', url: '/kb' }, + { id: 'nav_knowledge', type: 'url', label: 'Knowledge base', url: '/kb2' }, + ], +}; 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). */ @@ -216,6 +263,12 @@ beforeEach(() => { srv.ensure.length = 0; srv.saves.length = 0; srv.contributed = false; + srv.servedFold = false; + srv.addonContributes = false; + srv.addonDisabled = false; + srv.targetApp = 'served'; + srv.ownerReadFails = false; + srv.declaredApp = 'crm_app'; vi.stubGlobal( 'fetch', vi.fn(async (input: unknown, init?: RequestInit) => { @@ -225,8 +278,17 @@ 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`) { + if (srv.ownerReadFails) return json({ success: false, error: { code: 'INTERNAL_ERROR', message: 'down' } }, 500); return json({ success: true, data: readWith(srv.pairListed ? READ : EMPTY_READ) }); } + if (path === `/api/v1/packages/${ADDON}/customizations`) { + return json({ success: true, data: { ...EMPTY_READ, packageId: ADDON } }); + } + if (path === '/api/v1/meta/app/crm_app') { + if (srv.targetApp === 'fails') return json({ success: false, error: { message: 'down' } }, 500); + if (srv.targetApp === 'missing') return json({}, 404); + return json({ type: 'app', name: 'crm_app', item: { ...SERVED_APP, _packageId: BASE } }); + } if (path === `/api/v1/packages/${BASE}/ensure-customization` && method === 'POST') { const created = !srv.pairListed; srv.pairListed = true; @@ -240,7 +302,9 @@ beforeEach(() => { return json({ drafts: draftRows(url.searchParams.get('packageId'), url.searchParams.get('type')) }); } if (path === '/api/v1/meta/app') { - return json(url.searchParams.get('package') === PAIR ? [] : [{ name: 'crm_app', label: 'CRM' }]); + const pkg = url.searchParams.get('package'); + if (pkg === PAIR || (pkg === ADDON && srv.addonContributes)) return json([]); + return json([srv.servedFold ? SERVED_APP : { name: 'crm_app', label: 'CRM' }]); } if (path === '/api/v1/meta/app/crm_app/layers') { return json({ code: null, overlay: null, overlayScope: null, effective: srv.contributed ? APP_WITH_GROUP : APP }); @@ -254,6 +318,7 @@ beforeEach(() => { const pkg = url.searchParams.get('package'); if (pkg === PAIR) return json(srv.pairListed ? pairList(type) : []); if (pkg === BASE) return json(BASE_LISTS[type] ?? []); + if (pkg === ADDON && srv.addonContributes) return json([]); return json([...(BASE_LISTS[type] ?? []), ...pairList(type)]); } const one = /^\/api\/v1\/meta\/([^/]+)\/([^/]+)$/.exec(path); @@ -650,3 +715,124 @@ describe('items another package contributes are drawn greyed, with the package ( expect(srv.reads.filter((r) => r.startsWith(`/api/v1/packages/${BASE}/customizations`)).length).toBeGreaterThan(0); }); }); + +/** + * objectstack#29851 — a contributed nav row reads the label the live app + * shows, looked up by `(app, id)` in the served app row the pillar already + * reads; a package that only contributes says what it adds, as the owner's + * served read lists it. + */ +describe('contributed nav entries show their served label, and a contributor says what it adds (objectstack#29851)', () => { + const contributed = (root: HTMLElement, name: string) => + root.querySelector(`[data-contributed="nav"][data-testid="contributed-nav-${name}"]`); + + it('a unique id shows its served label with the id as a second line; a shared id keeps the bare id', async () => { + srv.contributed = true; + srv.servedFold = true; + renderAt(`/studio/${BASE}/interfaces`); + await interfacesReady(); + await waitFor(() => expect(contributed(ifRail(), 'nav_cases')).not.toBeNull(), { timeout: 8000 }); + const cases = contributed(ifRail(), 'nav_cases')!; + await waitFor(() => expect(within(cases).getByTestId('contributed-label')).toHaveTextContent('Cases')); + expect(within(cases).getByTestId('contributed-id')).toHaveTextContent('nav_cases'); + // Two served entries share `nav_knowledge`: no label is guessed. + const knowledge = contributed(ifRail(), 'nav_knowledge')!; + expect(within(knowledge).getByTestId('contributed-label')).toHaveTextContent('nav_knowledge'); + expect(within(knowledge).queryByTestId('contributed-id')).toBeNull(); + // No request of its own: the app item is never read for a package that has an app. + expect(srv.reads.filter((r) => r.startsWith('/api/v1/meta/app/crm_app') && !r.includes('/layers'))).toEqual([]); + }); + + it('CONTROL: with no served fold, the row keeps the id alone', async () => { + srv.contributed = true; + renderAt(`/studio/${BASE}/interfaces`); + await interfacesReady(); + await waitFor(() => expect(contributed(ifRail(), 'nav_cases')).not.toBeNull(), { timeout: 8000 }); + const cases = contributed(ifRail(), 'nav_cases')!; + expect(within(cases).getByTestId('contributed-label')).toHaveTextContent('nav_cases'); + expect(within(cases).queryByTestId('contributed-id')).toBeNull(); + }); + + const NO_APP = () => t('engine.studio.if.noApp', 'en'); + const ADDS_TWO = () => tFormat('engine.studio.if.contributesOther', 'en', { count: 2, package: 'HotCRM' }); + + async function contributorRailSays(): Promise { + renderAt(`/studio/${ADDON}/interfaces`); + const rail = await screen.findByTestId('if-no-app-rail', undefined, { timeout: 8000 }); + return rail; + } + + it('a contributor-only package says what it adds to the owner’s app, from two reads, and opens the owner', async () => { + srv.contributed = true; + srv.addonContributes = true; + const rail = await contributorRailSays(); + await waitFor(() => expect(rail).toHaveTextContent(ADDS_TWO()), { timeout: 8000 }); + expect(rail).toHaveTextContent(t('engine.studio.if.noOwnApp', 'en')); + expect(rail).not.toHaveTextContent(NO_APP()); + const pane = screen.getByTestId('if-contributes-elsewhere'); + expect(pane).toHaveTextContent(t('engine.studio.if.noOwnAppTitle', 'en')); + expect(pane).toHaveTextContent(ADDS_TWO()); + // The request budget: one owner lookup and one customization read for the one target. + expect(srv.reads.filter((r) => r === '/api/v1/meta/app/crm_app')).toHaveLength(1); + expect(srv.reads.filter((r) => r.startsWith(`/api/v1/packages/${BASE}/customizations`))).toHaveLength(1); + fireEvent.click(within(pane).getByTestId('if-open-owner')); + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent(`/studio/${encodeURIComponent(BASE)}/interfaces`)); + }); + + async function keepsTodaysText(): Promise { + const rail = await contributorRailSays(); + await waitFor(() => expect(rail).toHaveTextContent(NO_APP()), { timeout: 8000 }); + // Let the reads settle: the text still says what it said. + await new Promise((r) => setTimeout(r, 300)); + expect(rail).toHaveTextContent(NO_APP()); + expect(screen.queryByTestId('if-contributes-elsewhere')).toBeNull(); + expect(screen.getByText(t('engine.studio.if.noAppTitle', 'en'))).toBeInTheDocument(); + } + + it('FALLBACK: a disabled package (the owner lists none of its entries) keeps today’s text', async () => { + srv.contributed = true; + srv.addonContributes = true; + srv.addonDisabled = true; + await keepsTodaysText(); + }); + + it('FALLBACK: a missing target app keeps today’s text, and the owner is never read', async () => { + srv.contributed = true; + srv.addonContributes = true; + srv.targetApp = 'missing'; + await keepsTodaysText(); + expect(srv.reads.filter((r) => r.startsWith(`/api/v1/packages/${BASE}/customizations`))).toEqual([]); + }); + + it('FALLBACK: a failed app read or a failed owner read keeps today’s text', async () => { + srv.contributed = true; + srv.addonContributes = true; + srv.targetApp = 'fails'; + await keepsTodaysText(); + cleanup(); + srv.targetApp = 'served'; + srv.ownerReadFails = true; + await keepsTodaysText(); + }); + + it('FALLBACK: 0 entries for the declared app keeps today’s text', async () => { + srv.contributed = true; + srv.addonContributes = true; + // The owner serves the add-on's field, flow and `other_app` entry, but no entry on `crm_app`. + const answer = CONTRIBUTIONS.splice(0, CONTRIBUTIONS.length); + CONTRIBUTIONS.push(...answer.filter((c) => !(c.kind === 'nav' && 'app' in c && c.app === 'crm_app'))); + try { + await keepsTodaysText(); + expect(srv.reads.filter((r) => r.startsWith(`/api/v1/packages/${BASE}/customizations`))).toHaveLength(1); + } finally { + CONTRIBUTIONS.splice(0, CONTRIBUTIONS.length, ...answer); + } + }); + + it('CONTROL: a package with no app that declares no contribution reads nothing more', async () => { + srv.addonContributes = true; + srv.declaredApp = ''; + await keepsTodaysText(); + expect(srv.reads.filter((r) => r.startsWith('/api/v1/meta/app/'))).toEqual([]); + }); +}); 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 3eb30f0a6d7..acd47df8c37 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -403,6 +403,7 @@ import { import { usePackageCustomizations, type PackageCustomizationsState } from './CustomizationMark.js'; import { ContributedRailRow, + contributedElsewhereText, RailCustomizationFilter, RailCustomizationMark, StudioPairItemChip, @@ -411,6 +412,7 @@ import { foldCustomizationPairs, railCustomizationsOf, useDeepLinkFallback, + useContributedElsewhere, useOpenContributor, useRailCustomizationsOnly, withPairItems, @@ -421,7 +423,11 @@ import { contributedFieldsOf, placeNavContributions, placesAnyNav, + servedContributedNavOf, + servedEntryOf, withContributedFlowGroups, + NO_SERVED_CONTRIBUTED_NAV, + type ServedContributedNav, type ContributedOnObject, type NavContributionPlacement, type TopLevelContributedNav, @@ -3090,6 +3096,13 @@ const InterfacesRailMarksContext = React.createContext({ byGroup: new Map(), top: [] }); +/** + * objectstack#29851 — the open app's served entries its published layer does + * not hold, by id (`servedContributedNavOf`): what a contributed row reads + * its label from. Empty for a never-published app. + */ +const InterfacesServedContributedNavContext = React.createContext(NO_SERVED_CONTRIBUTED_NAV); + /** * objectstack#29786 — contributed nav entries as rail rows: greyed, with the * contributing package, and opening that package's Interfaces pillar. Not @@ -3105,6 +3118,9 @@ function ContributedNavRows({ }): React.ReactElement | null { const locale = useMetadataLocale(); const openContributor = useOpenContributor(); + const served = React.useContext(InterfacesServedContributedNavContext); + // objectstack#29851 — the label-less entry inherits as the live sidebar's does. + const targetLabel = useNavTargetLabel(); if (rows.length === 0) return null; return ( <> @@ -3112,6 +3128,7 @@ function ContributedNavRows({ } title={contributedItemTitle(entry, locale, undeclaredGroup)} @@ -3123,6 +3140,21 @@ function ContributedNavRows({ ); } +/** + * objectstack#29851 — the text the live app shows for a contributed entry: the + * served entry read with the rail's own resolver, or `undefined` when no served + * entry is known for it (the row then keeps its id). + */ +function contributedNavLabel( + node: Record | undefined, + locale: string, + targetLabel: ReturnType, +): string | undefined { + if (!node) return undefined; + const text = navEntryLabelText(node as NavNode, locale, targetLabel).trim(); + return text || undefined; +} + function NavTree({ nodes, active, @@ -4485,6 +4517,7 @@ export function InterfacesPillar({ // `railAppLabel`; every editor keeps reading `navTree` / `appLabel`, the // authored buffer, so no input ever echoes a served text. const [servedNav, setServedNav] = React.useState(null); + const [servedContributed, setServedContributed] = React.useState(NO_SERVED_CONTRIBUTED_NAV); 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, @@ -4791,6 +4824,15 @@ export function InterfacesPillar({ // objectui#11785 — a load failure as it was (`plainRefusal`), a refused leaf // save as an author sentence with the raw text behind Details. const [error, setError] = React.useState(null); + // objectstack#29851 — on the no-app empty state only: what this package adds + // to other packages' apps, from the target's served owner and that owner's + // customization read (the declared app is an address, never shown). + const contributedElsewhere = useContributedElsewhere( + packageId, + appStatus === 'missing' && !error, + (name) => client.get>('app', name), + ); + const openContributor = useOpenContributor(); // Objects in THIS package (published ∪ draft) — the nav item inspector's // object picker, so nav can be wired to sibling objects before publishing. const [pkgObjects, setPkgObjects] = React.useState>([]); @@ -5000,6 +5042,9 @@ export function InterfacesPillar({ // translated from. A never-saved app has neither, and no served text. const served = saved ? servedNavTextOf(eff, published[0]) : null; setServedNav(served); + // objectstack#29851 — from the same two answers: the entries the fold + // added, by id, for the contributed rows' labels. No request of its own. + setServedContributed(saved ? servedContributedNavOf(eff, published[0]) : NO_SERVED_CONTRIBUTED_NAV); const appDraftBody = extractDraftBody(appDraftResp); // A served draft is the whole document — taken as-is, never spread // over the published layer (objectui#10765; the rule is stated once @@ -6601,10 +6646,32 @@ export function InterfacesPillar({
-
-

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

-

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

-
+ {contributedElsewhere ? ( + // objectstack#29851 — what the served reads show this package + // adding to other packages' apps, each with a way there. +
+

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

+ {contributedElsewhere.map((found) => ( +
+

{contributedElsewhereText(found, locale)}

+ +
+ ))} +
+ ) : ( +
+

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

+

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

+
+ )} {onCreateApp && (