Skip to content

Commit 99c0696

Browse files
fix(app-shell): an areas-only app lands on its first area entry; a url/action-only app says it opens from its menu (#29849)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb6YPmefvezx1CHWqAYCx6ZW)_ Fixes #29771 Clause-②: yes Metadata-protocol: none ## 维护者速读 - **改了什么**:控制台打开一个应用的根地址时,落地页不再只看顶层导航:先看顶层导航,再按顺序看每个分区(areas)的导航,在全部这些导航里先找对象、页面、仪表盘、报表或组件条目,找不到再找第一个文档条目。若成员能看到的条目全是外部链接或动作,根页面显示一个空状态,说明“这个应用从菜单使用”,不再是一片空白。设计器预览里的“首页”提示同步这条规则。 - **为什么改**:条目只放在分区里的应用、以及菜单只有链接或动作的应用,打开后主区域一片空白,没有跳转也没有任何说明,成员分不清是应用坏了还是本来就空(#29678 的现场实测,#29769 只修了纯文档应用)。 - **风险与回滚**:只在原来什么都不落地的情况下生效;顶层导航有可落地条目的应用照旧落到原处。新空状态不带“去设置”按钮,也不说“无权限”,因为成员确实有权限。新增两条界面文案(十种语言)。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/console/AppContent.tsx` 的 `resolveLandingRoute` / `firstRouteAcross`(跨全部导航树的落地顺序)和 `AppRootLanding` 末尾(原来的 `return null`);`ui/packages/app-shell/src/console/AppNoAccessEmptyState.tsx` 的 `menu-only` 分支。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/console/__tests__/AppContent.docOnlyLanding-29678.test.tsx`,应见 13 个用例全绿:只有分区的应用落到分区里的第一个对象,只有链接/动作的应用显示 `data-reason="menu-only"` 的空状态,原有对照组照旧。 - **需要你做的**:无,席位落地。 ## What changes Claim: issuecomment 6595973888, amended by 6596042756 (declaration lines `Clause-②: yes`, `Metadata-protocol: none`; surface plus `AppNoAccessEmptyState.tsx` and the ten locale packs). An app whose entries live only under `areas`, or whose every entry is a `url` or `action` link, opened `/apps/APP` to an empty `main`. `resolveLandingRoute` walked only `shownTrees[0]` (the top-level `navigation`), although `AppRootLanding` already handed it every shown tree, and `AppRootLanding` then rendered `null`. - **Landing walk across every shown tree** (`AppContent.tsx`, new `firstRouteAcross`). The declared landing first, then the first working entry (object, page, dashboard, report, component) across `navigation` and each `areas[].navigation` in order, then the first `doc` entry across the same order. The preference holds **across** the trees, not per tree: a working entry in any area outranks a `doc` entry in the top-level navigation. That is #29678's rule (a working entry outranks reading material) applied to the app rather than to one tree. The sidebar elects the area that holds the entry the route addresses (`electSidebarArea`), so a landing in a later area opens with that area active. - **No in-app landing at all** (`AppContent.tsx`). When nothing lands and the app still shows the member entries, every one of them is a `url` or `action` link. The root now draws `AppNoAccessEmptyState` with a new `reason="menu-only"` instead of `null`. - **`AppNoAccessEmptyState.tsx`**: the `menu-only` reason has its own title and description (`empty.appOpensFromMenu`, `empty.appOpensFromMenuDescription`), a menu icon instead of the lock, and no Setup link even when `setupPath` is given, because the member has access. The other two reasons are unchanged. - **`@object-ui/i18n`**: the two keys in all ten locale packs. - **`AppPreview.tsx`**: `findFirstLandingAcross` normalizes each area's navigation and walks the trees in the same order, so the designer names the entry the console opens on. - **`appServesNoNavigation.ts`**: the docblock said the resolver walks the top-level navigation only and that a doc entry does not land; both went stale (#29678, and this PR). Docblock only. - **Moved pin**: `AppContent.studioRootLanding-12113.test.tsx` pinned, for objectui#12113, that a url-only app "stays on its root, with no empty state and no page" (it asserted `app-no-access-empty-state` absent). This card makes exactly that blank root the defect, so the case now asserts the `menu-only` empty state; the "stays on its root, no page" half is kept. Seat-approved in the card thread. ## Tests - `AppContent.docOnlyLanding-29678.test.tsx`, new top-level `describe` on the same harness (real `AppContent`, `ExpressionProvider`, `MePermissionsProvider`): an areas-only app lands on the first working entry of its areas; a working entry in an area outranks a top-level doc; a doc in an area lands when no tree holds a working entry; a url/action-only app (group and area included) draws `data-reason="menu-only"` with no Setup link; CONTROL: no shown navigation keeps `data-reason="no-navigation"`; CONTROL: a pending read in an area holds the skeleton, then lands. The file's #29678 cases (doc-only lands, object app redirects, no-navigation, pending read) stay as controls. - `AppNoAccessEmptyState.sharedState-25915.test.tsx`: `menu-only` added to the `data-reason` table; its copy is not the no-access sentence; no Setup link with a setup path. - `AppPreview.areas-11027.test.tsx`: the preview names the area entry for an areas-only app, prefers it over a top-level doc, and CONTROL keeps a top-level working entry first. - `AppContent.studioRootLanding-12113.test.tsx`: the moved pin above. ## Acceptance notes - **Across trees, not per tree** (seat ruling, card thread): a working entry anywhere outranks a doc anywhere. An app with a manual at the top of `navigation` and objects in an area opens on the first object. - **The menu-only screen reuses `AppNoAccessEmptyState`** (seat ruling) and keeps its `data-testid="app-no-access-empty-state"`; `data-reason="menu-only"` is what tells it apart. It says nothing about access and offers no Setup link. - **`Clause-②: yes`**: the two new keys widen `@object-ui/i18n`'s built `dist/locales/en.d.ts` (every leaf is a readonly literal and the entry re-exports `en` and `type TranslationKeys`). Changeset level stays `patch` with `Metadata-protocol: none`. - Read from source (`UnifiedSidebar.tsx`), not measured, not changed: when an app has a visible area, `UnifiedSidebar` draws the active area's tree in place of the top-level `navigation` (`activeArea?.navigation || readableAppNavigation`). So in an app that has both, a landing on a top-level entry can open a page with no sidebar entry. This PR does not change that; the order here only decides which entry lands. - Observed, not changed: on showcase the url-only app's root rewrote to its package address (`/apps/com.example.r29771-landing`), which is the existing package-address behaviour for the package's first app. ## Verification Final head: `d1b4d8e8` (merge of `origin/main` at `b0ef25f5`). <details><summary>Tests on the final head</summary> On `d1b4d8e8`: every `console/__tests__/AppContent.*.test.tsx` file, `AppNoAccessEmptyState.sharedState-25915.test.tsx`, every `previews/AppPreview*.test.tsx` file and `packages/i18n/`: 150/150 files, 1932 passed, 13 skipped. `turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n`: 66/66 tasks. Before the main merge (head `fecc3403`): the four touched files 4/4, 33/33; the consumer sweep (every ui test file naming `AppContent`, `AppNoAccessEmptyState`, `AppPreview`, `appServesNoNavigation` or the empty-state keys) 155/155 files, 1305/1305 tests in four locked batches; `packages/i18n/` 95/95 files, 1443 passed, 13 skipped; `turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n` 66/66 tasks; `eslint` on the touched ui files 0 errors. </details> <details><summary>Ablations (each restored, blob equal to HEAD and git diff HEAD empty, by scripts/ablation-replace.mjs)</summary> 1. `resolveLandingRoute` walks `shownTrees.slice(0, 1)` only: `AppContent.docOnlyLanding-29678.test.tsx` 4 failed, 9 passed (the areas-only, area-over-doc, doc-in-area and pending-in-area cases). 2. The fallthrough back to `return null`: 2 failed across `docOnlyLanding-29678` and `studioRootLanding-12113` (both menu-only cases). 3. `AppPreview` walks `[navItems]` only: `AppPreview.areas-11027.test.tsx` 2 failed, 6 passed. 4. The `menu-only` branch made unreachable: `AppNoAccessEmptyState.sharedState-25915.test.tsx` 2 failed, 5 passed. </details> <details><summary>Local gate union</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 families on `fecc3403`; all 35 ran with exit 0 (`pnpm --dir ui exec vitest run scripts/`: 170 files passed, 2 skipped). `--ran` reconciliation: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. Also `node scripts/check-i18n-en-drift.mjs` (from `ui/`) exit 0: 2 keys added, 0 en values changed. The body gates (`check-adr-0087-registration`, `check-changeset-no-major`, `check-closing-keyword-parity`) ran against this body. </details> <details><summary>Live, showcase --fresh with the Console Vite on source</summary> Four apps published into a scratch package `com.example.r29771-landing`: url-only (one `url` and one `action` entry), areas-only (`navigation: []`, first area holds an object entry for `showcase_account`, second a `url`), doc-only (a doc entry for `showcase_manual`), and an object app. Opened at `/apps/APP` as admin and as `auditor.demo@example.com`. | app | before (AppContent.tsx at main) | after | |:---|:---|:---| | url-only | empty `main` (0 chars), admin and auditor | `data-reason="menu-only"`, "R29771 Links opens from its menu", admin and auditor | | areas-only | empty `main` (0 chars), admin and auditor | redirects to `/apps/r29771_areas/showcase_account`, the list renders | | doc-only (control) | | `/apps/r29771_doconly/docs/manual/showcase_index` | | object app (control) | | `/apps/r29771_object/showcase_account` | The before leg swapped only `AppContent.tsx` to main under Vite (Vite was confirmed serving main's module), then restored it (blob equal to HEAD). The after shots were taken before the swap. Shots: desktop for both personas plus a 390 px phone shot of the menu-only screen. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSW6s8ABou6nBvnfbz)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2a0f0b7 commit 99c0696

19 files changed

Lines changed: 326 additions & 26 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
Clause-②: yes
7+
Metadata-protocol: none
8+
9+
An app whose entries live only under `areas` now opens on its first entry there, and an app whose menu holds only links or actions now says it opens from its menu, instead of an empty page (#29771).
10+
11+
- The app root walks the top-level navigation, then each area's navigation in order. It opens the first object, page, dashboard, report or component entry the member is shown in any of them, and only then the first doc entry. A working entry in an area therefore outranks a doc in the top-level navigation.
12+
- When every entry the member is shown is a `url` or `action` link, the root draws an empty state that says the app opens from its menu (`AppNoAccessEmptyState`, reason `menu-only`). It offers no Setup link: the member has access.
13+
- `@object-ui/i18n` adds `empty.appOpensFromMenu` and `empty.appOpensFromMenuDescription` to all ten locale packs.
14+
- The app designer's preview names the same landing as the console, areas included.

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1825,6 +1825,9 @@ function buildItemRoute(item: any, ctx?: NavTemplateContext): string {
18251825
* walks groups and lands on an `object`, `page`, `dashboard`, `report` or
18261826
* `component` entry (objectui#12113 added `component`). It skips the item
18271827
* types that address nothing inside the app (`url`, `separator`, `action`).
1828+
* The walk covers every tree the member is shown: the top-level `navigation`
1829+
* first, then each `areas[].navigation` in order, so an app whose entries live
1830+
* only under `areas` lands too (objectstack-ai/objectstack#29771).
18281831
* A `doc` entry is passed over there: a working entry outranks reading
18291832
* material, so an app whose first entry is its manual still opens on its first
18301833
* working entry. Only when no entry lands does the root open the FIRST `doc`
@@ -1850,8 +1853,28 @@ function resolveLandingRoute(
18501853
const declared = resolveDeclaredLandingRoute(app?.landing, shownTrees, evaluateVisibility, (item) =>
18511854
buildItemRoute(item, ctx),
18521855
);
1853-
const tree = shownTrees[0] ?? [];
1854-
return declared || findFirstRoute(tree, ctx) || findFirstRoute(tree, ctx, DOC_LANDING_TYPES);
1856+
return declared || firstRouteAcross(shownTrees, ctx) || firstRouteAcross(shownTrees, ctx, DOC_LANDING_TYPES);
1857+
}
1858+
1859+
/**
1860+
* objectstack-ai/objectstack#29771 — the first route of `types` across every
1861+
* tree the member is shown, in order: the top-level `navigation`, then each
1862+
* `areas[].navigation` as authored. The preference holds across the trees, not
1863+
* per tree: a working entry in any area outranks a `doc` entry in the
1864+
* top-level navigation, as it does inside one tree (#29678). The sidebar elects
1865+
* the area that holds the landed entry (`electSidebarArea`), so a landing in a
1866+
* later area opens with that area active.
1867+
*/
1868+
function firstRouteAcross(
1869+
shownTrees: readonly NavigationItem[][],
1870+
ctx?: NavTemplateContext,
1871+
types: ReadonlySet<string> = WORKING_LANDING_TYPES,
1872+
): string {
1873+
for (const tree of shownTrees) {
1874+
const route = findFirstRoute(tree ?? [], ctx, types);
1875+
if (route) return route;
1876+
}
1877+
return '';
18551878
}
18561879

18571880
/**
@@ -1981,13 +2004,14 @@ function AppRootLanding({
19812004
if (appServesNoNavigation(shownApp)) {
19822005
return <AppNoAccessEmptyState app={app} setupPath={setupPath} />;
19832006
}
1984-
// A navigation with no landing: every entry the sidebar draws is a `url` or
1985-
// `action` link, or the items live only under `areas`, which the resolver
1986-
// does not walk (a `doc` entry lands, objectstack-ai/objectstack#29678). The
1987-
// root renders no page of its own and the sidebar offers the entries. It used
1988-
// to render the Studio app's overview, `StudioHomePage`, which objectui#12113
1989-
// retired.
1990-
return null;
2007+
// objectstack-ai/objectstack#29771 — a navigation with no in-app landing:
2008+
// every entry the member is shown, in `navigation` and in every area, is a
2009+
// `url` or `action` link (a working or `doc` entry anywhere would have
2010+
// landed). The root has no page of its own to open, so it says the app opens
2011+
// from its menu, instead of the empty `main` it used to render. The member
2012+
// does have access here, so the screen offers no Setup link. Before
2013+
// objectui#12113 this fell through to the Studio app's overview.
2014+
return <AppNoAccessEmptyState app={app} setupPath={setupPath} reason="menu-only" />;
19912015
}
19922016

19932017
/**

‎ui/packages/app-shell/src/console/AppNoAccessEmptyState.tsx‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,17 @@
2323
* still draws its dashboards and pages, but every tile on them would refuse,
2424
* so the description says the caller can read nothing here yet and asks for a
2525
* permission set, instead of claiming the app has no pages.
26+
*
27+
* objectstack-ai/objectstack#29771 — and a third: an app that shows the caller
28+
* entries, none of which opens a page inside the app (every one is a `url` or
29+
* `action` link, in `navigation` and in every area). The caller has access;
30+
* the app simply has no page of its own. So this reason has its own title and
31+
* description, which point at the menu, a menu icon instead of the lock, and
32+
* no Setup link: there is nothing to grant.
2633
*/
2734

2835
import { Link } from 'react-router-dom';
29-
import { Lock } from 'lucide-react';
36+
import { Lock, PanelLeft } from 'lucide-react';
3037
// The shared empty state (objectstack#25915): it draws the muted icon square
3138
// this screen used to hand-copy, and it is `role="status"`, so "nothing here
3239
// for you" is announced instead of being an unlabelled block. `AppContent.tsx`
@@ -47,9 +54,13 @@ export interface AppNoAccessEmptyStateProps {
4754
/**
4855
* Why the app has nothing for this caller: it shows them no navigation
4956
* (`'no-navigation'`, the default), or it names only objects they may not
50-
* read (`'no-readable-object'`, objectstack#25338).
57+
* read (`'no-readable-object'`, objectstack#25338), or every entry it
58+
* shows them is a `url` or `action` link, so the app opens from its menu
59+
* and has no page of its own (`'menu-only'`,
60+
* objectstack-ai/objectstack#29771). `setupPath` is not read for
61+
* `'menu-only'`: the caller has access, so there is nothing to grant.
5162
*/
52-
reason?: 'no-navigation' | 'no-readable-object';
63+
reason?: 'no-navigation' | 'no-readable-object' | 'menu-only';
5364
}
5465

5566
export function AppNoAccessEmptyState({ app, setupPath, reason = 'no-navigation' }: AppNoAccessEmptyStateProps) {
@@ -58,6 +69,24 @@ export function AppNoAccessEmptyState({ app, setupPath, reason = 'no-navigation'
5869
// The same label the header's app switcher shows for this app.
5970
const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) });
6071

72+
if (reason === 'menu-only') {
73+
return (
74+
<div className="flex h-full w-full items-center justify-center p-8" data-testid="app-no-access-empty-state">
75+
<DataEmptyState
76+
data-reason={reason}
77+
icon={<PanelLeft className="size-5 text-muted-foreground" aria-hidden="true" />}
78+
title={t('empty.appOpensFromMenu', {
79+
app: label,
80+
defaultValue: '{{app}} opens from its menu',
81+
})}
82+
description={t('empty.appOpensFromMenuDescription', {
83+
defaultValue: 'This app has no page of its own. Choose an entry in its menu to open a link or run an action.',
84+
})}
85+
/>
86+
</div>
87+
);
88+
}
89+
6190
const description =
6291
reason === 'no-readable-object'
6392
? setupPath

‎ui/packages/app-shell/src/console/__tests__/AppContent.docOnlyLanding-29678.test.tsx‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,15 @@
2323
* - CONTROL: a pending read ahead of the doc still holds the skeleton, and
2424
* the doc is landed on only once that read fails.
2525
*
26+
* objectstack-ai/objectstack#29771 — the rest of that blank page, pinned in
27+
* its own `describe` below on the same harness:
28+
* - an app whose entries live only under `areas` lands on the first working
29+
* entry across the area trees, and a working entry in an area outranks a
30+
* `doc` entry in the top-level navigation;
31+
* - an app whose every shown entry is a `url` or `action` link draws the
32+
* menu-only empty state (`data-reason="menu-only"`), with no Setup link;
33+
* - CONTROL: a pending read in an area still holds the skeleton.
34+
*
2635
* NOTE ON SCOPE: routing only. `ConsoleLayout`, the lazily imported pages and
2736
* the host's docs portal are stubbed.
2837
*/
@@ -326,3 +335,117 @@ describe('objectstack-ai/objectstack#29678 — a doc-only app opens its first do
326335
expect(pathname()).toBe('/apps/crm/docs/crm_manual');
327336
});
328337
});
338+
339+
const urlEntry = (id: string) => ({ id, type: 'url', label: id, url: 'https://example.com' });
340+
const actionEntry = (id: string) => ({ id, type: 'action', label: id, actionDef: { actionName: 'export_data' } });
341+
342+
describe('objectstack-ai/objectstack#29771 — an areas-only app lands; a url/action-only app says it opens from its menu', () => {
343+
beforeEach(() => {
344+
metadataApps = [];
345+
});
346+
347+
it('lands on the first working entry of its areas when the top-level navigation is empty', async () => {
348+
metadataApps = [
349+
{
350+
name: 'crm',
351+
label: 'CRM',
352+
navigation: [],
353+
areas: [
354+
{ id: 'area_links', label: 'Links', navigation: [urlEntry('nav_site')] },
355+
{ id: 'area_sales', label: 'Sales', navigation: [docEntry('nav_manual', { book: 'crm_manual' }), plainEntry('nav_deals', 'crm_deal')] },
356+
],
357+
},
358+
];
359+
const { apiFetch } = heldApiFetch();
360+
renderConsoleAt('/apps/crm', apiFetch);
361+
362+
expect(await screen.findByTestId('object-view')).toBeInTheDocument();
363+
expect(pathname()).toBe('/apps/crm/crm_deal');
364+
expect(screen.queryByTestId('app-no-access-empty-state')).not.toBeInTheDocument();
365+
});
366+
367+
it('prefers a working entry in an area over a doc entry in the top-level navigation', async () => {
368+
metadataApps = [
369+
{
370+
name: 'crm',
371+
label: 'CRM',
372+
navigation: [docEntry('nav_manual', { book: 'crm_manual' })],
373+
areas: [{ id: 'area_sales', label: 'Sales', navigation: [plainEntry('nav_deals', 'crm_deal')] }],
374+
},
375+
];
376+
const { apiFetch } = heldApiFetch();
377+
renderConsoleAt('/apps/crm', apiFetch);
378+
379+
expect(await screen.findByTestId('object-view')).toBeInTheDocument();
380+
expect(pathname()).toBe('/apps/crm/crm_deal');
381+
expect(screen.queryByTestId('docs-probe')).not.toBeInTheDocument();
382+
});
383+
384+
it('lands on a doc in an area when no tree holds a working entry', async () => {
385+
metadataApps = [
386+
{
387+
name: 'crm_help',
388+
label: 'CRM Help',
389+
navigation: [urlEntry('nav_site')],
390+
areas: [{ id: 'area_help', label: 'Help', navigation: [docEntry('nav_manual', { book: 'crm_manual' })] }],
391+
},
392+
];
393+
const { apiFetch } = heldApiFetch();
394+
renderConsoleAt('/apps/crm_help', apiFetch);
395+
396+
expect(await screen.findByTestId('docs-probe')).toHaveTextContent('crm_manual');
397+
expect(pathname()).toBe('/apps/crm_help/docs/crm_manual');
398+
});
399+
400+
it('draws the menu-only empty state when every shown entry is a `url` or `action` link', async () => {
401+
metadataApps = [
402+
{
403+
name: 'links',
404+
label: 'Links',
405+
navigation: [urlEntry('nav_site'), { id: 'group_tools', type: 'group', label: 'Tools', children: [actionEntry('nav_run')] }],
406+
areas: [{ id: 'area_more', label: 'More', navigation: [urlEntry('nav_wiki')] }],
407+
},
408+
];
409+
const { apiFetch } = heldApiFetch();
410+
renderConsoleAt('/apps/links', apiFetch);
411+
412+
const status = await screen.findByRole('status');
413+
expect(status).toHaveAttribute('data-reason', 'menu-only');
414+
expect(screen.getByTestId('app-no-access-empty-state')).toContainElement(status);
415+
expect(status).toHaveTextContent('Links opens from its menu');
416+
expect(status).not.toHaveTextContent('available to you yet');
417+
expect(screen.queryByTestId('app-no-access-open-setup')).not.toBeInTheDocument();
418+
expect(pathname()).toBe('/apps/links');
419+
});
420+
421+
it('CONTROL: an app that shows the member no navigation keeps the no-access reason', async () => {
422+
metadataApps = [{ name: 'links', label: 'Links', navigation: [], areas: [{ id: 'area_empty', label: 'Empty', navigation: [] }] }];
423+
const { apiFetch } = heldApiFetch();
424+
renderConsoleAt('/apps/links', apiFetch);
425+
426+
expect(await screen.findByRole('status')).toHaveAttribute('data-reason', 'no-navigation');
427+
expect(screen.getByRole('status')).toHaveTextContent('Nothing in Links is available to you yet');
428+
});
429+
430+
it('CONTROL: a pending read in an area holds the skeleton, then lands on the bound entry once shown', async () => {
431+
const envUrl = freshUrl();
432+
metadataApps = [
433+
{
434+
name: 'crm',
435+
label: 'CRM',
436+
navigation: [urlEntry('nav_site')],
437+
areas: [{ id: 'area_ops', label: 'Ops', navigation: [boundEntry('nav_env', 'crm_env', envUrl), docEntry('nav_manual', { book: 'crm_manual' })] }],
438+
},
439+
];
440+
const { apiFetch, answer } = heldApiFetch();
441+
renderConsoleAt('/apps/crm', apiFetch);
442+
443+
await waitFor(() => expect(apiFetch).toHaveBeenCalledWith(envUrl, expect.objectContaining({ method: 'GET' })));
444+
expect(screen.getByTestId('app-landing-pending')).toBeInTheDocument();
445+
expect(pathname()).toBe('/apps/crm');
446+
447+
await answer(envUrl, 200, SHOWN);
448+
expect(await screen.findByTestId('object-view')).toBeInTheDocument();
449+
expect(pathname()).toBe('/apps/crm/crm_env');
450+
});
451+
});

‎ui/packages/app-shell/src/console/__tests__/AppContent.studioRootLanding-12113.test.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
* - control: an app whose first entry is an `object` lands where it did;
2424
* - control: an app that serves no navigation still gets the empty state;
2525
* - an app whose entries address no page in the app (only `url` links)
26-
* stays on its root with neither the empty state nor any page.
26+
* stays on its root with no page, and says it opens from its menu
27+
* (objectstack-ai/objectstack#29771; it used to draw nothing at all).
2728
*
2829
* NOTE ON SCOPE: routing only. `ConsoleLayout` and the lazily imported pages
2930
* are stubbed, as in `AppContent.emptyNavigationLanding-12079.test.tsx`.
@@ -243,7 +244,7 @@ describe('AppContent — the Studio app root lands on its first navigation entry
243244
expect(screen.getByTestId('pathname').textContent).toBe('/apps/clm');
244245
});
245246

246-
it('an app whose entries address no page in the app stays on its root, with no empty state and no page', async () => {
247+
it('an app whose entries address no page in the app stays on its root, with the menu-only empty state and no page', async () => {
247248
metadataApps = [
248249
{
249250
name: 'links',
@@ -255,7 +256,10 @@ describe('AppContent — the Studio app root lands on its first navigation entry
255256

256257
expect(await screen.findByTestId('console-layout')).toHaveAttribute('data-active-app', 'links');
257258
expect(screen.getByTestId('pathname').textContent).toBe('/apps/links');
258-
expect(screen.queryByTestId('app-no-access-empty-state')).not.toBeInTheDocument();
259+
// objectstack-ai/objectstack#29771 — the root says the app opens from its
260+
// menu, never the "no access" sentence.
261+
expect(screen.getByTestId('app-no-access-empty-state')).toContainElement(screen.getByRole('status'));
262+
expect(screen.getByRole('status')).toHaveAttribute('data-reason', 'menu-only');
259263
expect(screen.queryByTestId('component-nav-view')).not.toBeInTheDocument();
260264
expect(screen.queryByTestId('metadata-directory-page')).not.toBeInTheDocument();
261265
});

‎ui/packages/app-shell/src/console/__tests__/AppNoAccessEmptyState.sharedState-25915.test.tsx‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ describe('AppNoAccessEmptyState draws the shared empty state (objectstack#25915)
6060
it.each([
6161
['no-navigation', undefined],
6262
['no-readable-object', 'no-readable-object' as const],
63+
['menu-only', 'menu-only' as const],
6364
])('carries data-reason=%s on the status node', (expected, reason) => {
6465
renderState(reason ? { reason } : {});
6566

@@ -74,3 +75,21 @@ describe('AppNoAccessEmptyState draws the shared empty state (objectstack#25915)
7475
expect(link).toHaveAttribute('href', '/setup/permissions');
7576
});
7677
});
78+
79+
describe("AppNoAccessEmptyState's menu-only reason (objectstack-ai/objectstack#29771)", () => {
80+
it('says the app opens from its menu, never the no-access sentence', () => {
81+
renderState({ reason: 'menu-only' });
82+
83+
const status = screen.getByRole('status');
84+
expect(status).toHaveTextContent('{{app}} opens from its menu');
85+
expect(status).toHaveTextContent('Choose an entry in its menu to open a link or run an action.');
86+
expect(status).not.toHaveTextContent('available to you yet');
87+
});
88+
89+
it('offers no Setup link even when a setup path is given: the member has access', () => {
90+
renderState({ reason: 'menu-only', setupPath: '/setup/permissions' });
91+
92+
expect(screen.getByRole('status')).toHaveAttribute('data-reason', 'menu-only');
93+
expect(screen.queryByTestId('app-no-access-open-setup')).not.toBeInTheDocument();
94+
});
95+
});

‎ui/packages/app-shell/src/console/appServesNoNavigation.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,15 @@ interface AppNavigationLike {
2828
* sidebar wires an action dispatcher, so an `action` item counts as content.
2929
*
3030
* ⛔ This is NOT "no landing route". The landing resolver walks the top-level
31-
* `navigation` only and lands on an `object`, `page`, `dashboard`, `report` or
32-
* `component` entry, so an app can serve entries and still have no landing:
33-
* one whose entries are all `url`, `action` or `doc` links, or one that serves
34-
* them only under `areas`. Such an app is not one the caller has no access in,
35-
* and this answers no for it. The Studio app is not that case: its navigation
36-
* is all `component` entries, and its root lands on the first one
37-
* (objectui#12113).
31+
* `navigation`, then every `areas[].navigation` in order
32+
* (objectstack-ai/objectstack#29771), and lands on the first `object`, `page`,
33+
* `dashboard`, `report` or `component` entry, else on the first `doc` entry
34+
* (objectstack-ai/objectstack#29678). So an app can serve entries and still
35+
* have no landing: one whose entries are all `url` or `action` links. Such an
36+
* app is not one the caller has no access in, and this answers no for it; the
37+
* root says the app opens from its menu instead (`AppNoAccessEmptyState`'s
38+
* `'menu-only'` reason). The Studio app is not that case: its navigation is all
39+
* `component` entries, and its root lands on the first one (objectui#12113).
3840
*/
3941
export function appServesNoNavigation(app: AppNavigationLike | null | undefined): boolean {
4042
const options = { hasActionHandler: true };

0 commit comments

Comments
 (0)