diff --git a/.changeset/29851-contributed-nav-labels.md b/.changeset/29851-contributed-nav-labels.md new file mode 100644 index 00000000000..ce9cec4f258 --- /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-②: yes (widening) +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/InterfacesNavRail.tsx b/ui/packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx index b70b12d5da2..342a99cbb6e 100644 --- a/ui/packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx +++ b/ui/packages/app-shell/src/views/studio-design/InterfacesNavRail.tsx @@ -44,7 +44,13 @@ import { type RailCustomizations, } from './StudioRailCustomizations.js'; import { CustomizationMark } from './CustomizationMark.js'; -import type { NavContributionPlacement, TopLevelContributedNav } from './contributionRows.js'; +import { + NO_SERVED_CONTRIBUTED_NAV, + servedEntryOf, + type NavContributionPlacement, + type ServedContributedNav, + type TopLevelContributedNav, +} from './contributionRows.js'; const KIND_ICON: Record = { group: Folder, @@ -206,6 +212,13 @@ export 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. + */ +export 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 @@ -221,6 +234,9 @@ export 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 ( <> @@ -228,6 +244,7 @@ export function ContributedNavRows({ } title={contributedItemTitle(entry, locale, undeclaredGroup)} @@ -239,6 +256,21 @@ export 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: NavTargetLabelResolver, +): string | undefined { + if (!node) return undefined; + const text = navEntryLabelText(node as NavNode, locale, targetLabel).trim(); + return text || undefined; +} + export function NavTree({ nodes, active, 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 3a79cfd7570..7192c516a89 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); @@ -651,3 +716,130 @@ 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(); + }); + + /** The package-list reads this harness's surface sends for an app-less package, with or without the empty state's reads. */ + const SURFACE_PACKAGE_LIST_READS = 2; + 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, + // and no package-list request of its own: the surface's reads are reused, as many as + // the CONTROL below (a package declaring nothing) sends. + expect(srv.reads.filter((r) => r === '/api/v1/packages')).toHaveLength(SURFACE_PACKAGE_LIST_READS); + 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([]); + expect(srv.reads.filter((r) => r === '/api/v1/packages')).toHaveLength(SURFACE_PACKAGE_LIST_READS); + }); +}); 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 3d6616ee34b..5794a309296 100644 --- a/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -98,6 +98,7 @@ import { InterfacesNavContributionsContext, InterfacesNavOverrideMark, InterfacesRailMarksContext, + InterfacesServedContributedNavContext, NavTree, OFF_NAV_TYPES, OffNavSections, @@ -415,10 +416,12 @@ import { prefixObjectName, type EnsuredCustomizationPackage, type PkgEntry, + type ListedPackageManifest, } from './packages-io.js'; import { usePackageCustomizations } from './CustomizationMark.js'; import { ContributedRailRow, + contributedElsewhereText, RailCustomizationFilter, RailCustomizationMark, StudioPairItemChip, @@ -426,6 +429,7 @@ import { customizationPairBaseOf, railCustomizationsOf, useDeepLinkFallback, + useContributedElsewhere, useOpenContributor, useRailCustomizationsOnly, withPairItems, @@ -435,7 +439,10 @@ import { contributedFieldsOf, placeNavContributions, placesAnyNav, + servedContributedNavOf, withContributedFlowGroups, + NO_SERVED_CONTRIBUTED_NAV, + type ServedContributedNav, type ContributedOnObject, } from './contributionRows.js'; import { @@ -646,6 +653,14 @@ function StudioDesignSurfaceBody({ aiSlot, area }: StudioDesignSurfaceProps): Re basedOn: string | null; listedPair: string | null; } | null>(null); + // objectstack#29851 — the listed manifests this same read answered, held + // with the id it answered for: the Interfaces pillar's no-app empty state + // reads the open package's declared navigation targets off it (addresses + // only), so it sends no package-list request of its own. + const [listedManifests, setListedManifests] = React.useState<{ + for: string; + manifests: ReadonlyMap; + } | null>(null); React.useEffect(() => { // objectui#11553 — the package-less scope has no package to look up; its // writability is decided below, not fetched. @@ -659,7 +674,12 @@ function StudioDesignSurfaceBody({ aiSlot, area }: StudioDesignSurfaceProps): Re const listedIds = fetchListedPackageIds(); listedIds.catch(() => {}); const listedManifests = fetchListedPackageManifests(); - listedManifests.catch(() => {}); + listedManifests.then( + (manifests) => { + if (!cancelled) setListedManifests({ for: packageId, manifests }); + }, + () => {}, + ); const settle = (entry: PkgEntry | null) => { setPkgWritable(entry?.writable ?? null); setPkgEntry(entry); @@ -1830,6 +1850,7 @@ function StudioDesignSurfaceBody({ aiSlot, area }: StudioDesignSurfaceProps): Re readOnly={readOnly} readOnlySettled={pkgWritable !== undefined} customization={customization} + listedManifests={listedManifests?.for === packageId ? listedManifests.manifests : null} onDirtyChange={setPillarDirty} onSurfaceLabelChange={setSurfaceLabel} onHeldEditChange={setHeldEdit} @@ -1977,8 +1998,14 @@ export function InterfacesPillar({ onSurfaceLabelChange, onHeldEditChange, customization = null, + listedManifests = null, }: { packageId: string; + /** objectstack#29851 — the package list's manifests, as the Studio surface + * already read them; `null` until that read answers (or when it failed). + * The no-app empty state reads the open package's declared navigation + * targets off it, as addresses only. */ + listedManifests?: ReadonlyMap | null; /** objectui#12718 — the package's display name, as the Studio top bar * reads it; `undefined` until the package list answers. The pillar names a * package with no app by it, so the id shows only when neither exists. */ @@ -2063,6 +2090,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, @@ -2391,6 +2419,16 @@ 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, + listedManifests, + (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>( @@ -2619,6 +2657,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 @@ -4326,10 +4367,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 && (