Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/29102-setup-create-route-verdict.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@object-ui/app-shell': patch
---

Clause-②: no
Metadata-protocol: none

A member the Setup app refuses who opens an object's create form under the Setup segment, `/apps/setup/OBJECT/new`, now gets the same "You don't have access to this app" screen as the object list beside it, instead of the create form inside the default app's header and sidebar. The package-id spelling `/apps/com.objectstack.setup/OBJECT/new` draws the same screen. Admins see the form as before, and the record pages the approvals inbox links to (`/apps/setup/OBJECT/record/ID`) are unchanged.
40 changes: 26 additions & 14 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -202,24 +202,31 @@ function preloadAppRouteChunk(subPath: string, hostPaths: readonly string[] = []
}

/**
* objectstack#28988 — the route paths that draw an object's LIST: the default
* list, a saved list view and the bare data surface. Record-scoped paths
* (`:objectName/record/...`, the `:objectName/:maybeRecordId` shorthand) and
* the create form are deliberately absent; see `appSurfaceAwaitsVerdict`.
* The object-scoped route paths that name no record: the default list, a saved
* list view, the bare data surface (objectstack#28988) and the create form
* (objectstack#29102). Record-scoped paths (`:objectName/record/...`, the
* `:objectName/:maybeRecordId` shorthand) are deliberately absent; see
* `appSurfaceAwaitsVerdict`.
*/
const OBJECT_LIST_ROUTE_PATHS: ReadonlySet<string> = new Set([':objectName', ':objectName/view/:viewId', ':objectName/data']);
const OBJECT_COLLECTION_ROUTE_PATHS: ReadonlySet<string> = new Set([
':objectName',
':objectName/view/:viewId',
':objectName/data',
':objectName/new',
]);

/**
* Whether the route at `subPath` (the path below `/apps/:appName`) draws an
* object's list. Matched the way `<Routes>` ranks paths, beside every path
* `AppContent` and the host declare, so a static route or a host route that
* shadows `:objectName` (`search`, `reports`, `system/...`) is not one.
* Whether the route at `subPath` (the path below `/apps/:appName`) is one of
* `OBJECT_COLLECTION_ROUTE_PATHS`. Matched the way `<Routes>` ranks paths,
* beside every path `AppContent` and the host declare, so a static route or a
* host route that shadows `:objectName` (`search`, `reports`, `system/...`) is
* not one.
*/
function isObjectListAppRoute(subPath: string, hostPaths: readonly string[] = []): boolean {
function isObjectCollectionAppRoute(subPath: string, hostPaths: readonly string[] = []): boolean {
const routes = [...APP_ROUTE_METADATA_READS.map(({ path }) => ({ path })), ...hostPaths.map((path) => ({ path }))];
const matches = matchRoutes(routes, `/${subPath.replace(/^\/+/, '')}`);
const path = matches?.[matches.length - 1].route.path;
return path !== undefined && OBJECT_LIST_ROUTE_PATHS.has(path);
return path !== undefined && OBJECT_COLLECTION_ROUTE_PATHS.has(path);
}

interface AppContentProps {
Expand Down Expand Up @@ -579,16 +586,21 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// while the canonical `/apps/com.objectstack.setup/OBJECT`, an ordinary
// unmatched app, already drew the refusal. The record routes the approvals
// inbox links to stay outside this gate, as above; the list paths are
// matched by route rank (`isObjectListAppRoute`), so they never reach one.
// matched by route rank (`isObjectCollectionAppRoute`), so they never reach
// one.
//
// objectstack#29102 — and the create form beside them (`/apps/setup/OBJECT/
// new`), which a member reached the same way. It names no record, so no
// inbox link lands on it.
const isSetupRootRoute = location.pathname === '/apps/setup' || location.pathname === '/apps/setup/';
const [appSubRoot, appSubPage] = appSubPath.split('/');
const isSettingsOrMarketplaceRoute =
appSubRoot === 'system' && (appSubPage === 'settings' || appSubPage === 'marketplace');
const isSetupObjectListRoute = isSetupRoute && isObjectListAppRoute(appSubPath, hostRoutePaths);
const isSetupObjectCollectionRoute = isSetupRoute && isObjectCollectionAppRoute(appSubPath, hostRoutePaths);
const appSurfaceAwaitsVerdict =
!!appName &&
!matchedApp &&
(isMetadataRoute || isSetupRootRoute || isSettingsOrMarketplaceRoute || isSetupObjectListRoute);
(isMetadataRoute || isSetupRootRoute || isSettingsOrMarketplaceRoute || isSetupObjectCollectionRoute);

// Post-publish readiness: when a requested app isn't in the loaded metadata,
// re-check ONCE (the registry can lag a beat behind a publish) before
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@
*
* The alias-spelled list routes (`OBJECT`, `OBJECT/view/ID`, `OBJECT/data`)
* wait on the verdict for the app the URL names and draw `app-access-denied`
* on `denied`, nothing of the list mounted. An admitted caller and a server
* on `denied`, nothing of the list mounted. So does the create form beside
* them, `OBJECT/new` (objectstack#29102), in both spellings; an admitted
* caller still gets the form. An admitted caller and a server
* without the app render as before. The record routes the approvals inbox
* links to stay reachable (objectui#12706 leaves them open), as does the inbox
* itself; `AppContent.setupItemRoutesVerdict-12706.test.tsx` pins both too.
Expand All @@ -34,7 +36,7 @@
* real probe reading the real envelope.
*
* NOTE ON SCOPE: this file measures WHICH SURFACE renders. `ConsoleLayout` and
* the list, data and record pages are stubbed; each stub counts its mounts, so
* the list, data, record and create-form pages are stubbed; each stub counts its mounts, so
* "nothing of the list mounted" is a count.
*/

Expand Down Expand Up @@ -70,6 +72,12 @@ vi.mock('../../views/ObjectDataPage', () => ({
return counted('object-data-page', objectName);
},
}));
vi.mock('../../views/RecordFormPage', () => ({
RecordFormPage: ({ mode }: { mode?: string }) => {
const { objectName } = useParams<{ objectName?: string }>();
return counted('record-form-page', `${objectName}/${mode ?? ''}`);
},
}));
vi.mock('../../views/RecordDetailView', () => ({
RecordDetailView: () => {
const { objectName, recordId } = useParams<{ objectName?: string; recordId?: string }>();
Expand Down Expand Up @@ -246,7 +254,7 @@ function renderConsoleAt(initialUrl: string) {
const pathname = () => screen.getByTestId('pathname').textContent;

/** Every page stub this file counts; a refused caller mounts none of them. */
const PAGE_STUBS = ['object-view', 'object-data-page', 'record-detail-view', 'approvals-inbox-page'];
const PAGE_STUBS = ['object-view', 'object-data-page', 'record-form-page', 'record-detail-view', 'approvals-inbox-page'];
const pagesMounted = () => PAGE_STUBS.filter((id) => (mounts[id] ?? 0) > 0);

describe('AppContent — alias-spelled Setup object lists wait on the app\'s access verdict (objectstack#28988)', () => {
Expand Down Expand Up @@ -298,6 +306,34 @@ describe('AppContent — alias-spelled Setup object lists wait on the app\'s acc
expect(metaItemRequests[0]).toContain('/api/v1/meta/app/com.objectstack.setup');
});

// objectstack#29102 — the create form names no record, so no inbox link
// lands on it; it waits on the verdict like the lists beside it.
it.each([
['the alias', '/apps/setup/sys_user/new', 'setup'],
['an app object under the alias', '/apps/setup/showcase_task/new', 'setup'],
['the package id', '/apps/com.objectstack.setup/sys_user/new', 'com.objectstack.setup'],
])('a refused caller gets app-access-denied on the create form spelled with %s, never the form', async (_label, url, app) => {
renderConsoleAt(url);

expect(await screen.findByTestId('app-access-denied')).toBeInTheDocument();
expect(screen.queryByTestId('console-layout')).not.toBeInTheDocument();
expect(pagesMounted()).toEqual([]);
expect(metaItemRequests).toHaveLength(1);
expect(metaItemRequests[0]).toContain(`/api/v1/meta/app/${app}`);
expect(pathname()).toBe(url);
});

it('CONTROL — an admitted caller gets the create form under Setup, and nothing is probed', async () => {
metadataApps = [SHOWCASE_APP, SETUP_APP];

renderConsoleAt('/apps/setup/sys_user/new');

expect(await screen.findByTestId('record-form-page')).toHaveTextContent('sys_user/create');
expect(screen.getByTestId('console-layout')).toHaveAttribute('data-active-app', 'setup');
expect(screen.queryByTestId('app-access-denied')).not.toBeInTheDocument();
expect(metaItemRequests).toEqual([]);
});

it('CONTROL — an admitted caller gets the list under Setup, and nothing is probed', async () => {
metadataApps = [SHOWCASE_APP, SETUP_APP];

Expand Down
Loading