diff --git a/.changeset/21984-approval-requests-all-first.md b/.changeset/21984-approval-requests-all-first.md new file mode 100644 index 00000000000..59a12113fc3 --- /dev/null +++ b/.changeset/21984-approval-requests-all-first.md @@ -0,0 +1,13 @@ +--- +"@objectstack/plugin-approvals": patch +--- + +Setup → Approvals → Requests opens on every approval request, not on the requests pending on the administrator. Before this, the entry named no view, and `sys_approval_request` declared the caller-scoped "My Pending" view (`pending_approvers contains {current_user_id}`) first, so the console opened it. + +Clause-②: no + +- `sys_approval_request` now declares its unscoped "All" view (`all_requests`) first. "My Pending", "I Submitted" and "Completed" follow it in their previous order, still as tabs. A route that names no view, such as a record page's object breadcrumb or the object switcher, now opens "All". No view is added, removed or changed. +- The Setup entry `nav_approval_requests` now names `all_requests` with `viewName`, so it does not depend on the declared order. The Account app's Approvals entry opens the Approvals Inbox component and reads neither. +- The declared order decides which view opens, not which rows a caller may read. +- The generated translation bundles follow the new view order. No translated text changed. +- ⛔ No schema, parse, export or accept-set change. diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts index 464612069be..70ec3ecd12a 100644 --- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts +++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts @@ -137,6 +137,13 @@ export class ApprovalsServicePlugin implements Plugin { // progress, drawer); the three object entries below stay as the // admin/diagnostic view of the engine's own tables — reachable only here, // behind `group_approvals`' `manage_platform_settings` gate. + // + // `nav_approval_requests` names `all_requests` explicitly: an entry that + // names no view opens the object's first declared list view, so an + // administrator's page would otherwise depend on `listViews` order (it + // opened the caller-scoped `my_pending` until that view moved off first + // place). The two other object entries' objects declare no caller-scoped + // view. navigationContributions: [ { app: 'setup', @@ -144,7 +151,7 @@ export class ApprovalsServicePlugin implements Plugin { priority: 100, items: [ { id: 'nav_approvals_inbox', type: 'component', label: 'Approvals Inbox', componentRef: 'approvals:inbox', icon: 'list-checks' }, - { id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' }, + { id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', viewName: 'all_requests', icon: 'inbox', requiresObject: 'sys_approval_request' }, { id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' }, { id: 'nav_approval_delegations', type: 'object', label: 'Delegations (OOO)', objectName: 'sys_approval_delegation', icon: 'user-clock', requiresObject: 'sys_approval_delegation' }, ], diff --git a/packages/plugins/plugin-approvals/src/nav-contribution.test.ts b/packages/plugins/plugin-approvals/src/nav-contribution.test.ts index 99e6e38fd22..4253f9fc5bd 100644 --- a/packages/plugins/plugin-approvals/src/nav-contribution.test.ts +++ b/packages/plugins/plugin-approvals/src/nav-contribution.test.ts @@ -1,7 +1,25 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect } from 'vitest'; +import { NavigationContributionSchema } from '@objectstack/spec/ui'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; +import { SysApprovalRequest } from './sys-approval-request.object.js'; + +/** The plugin's one manifest, as `init` registers it. */ +async function registeredManifest(): Promise { + const registered: any[] = []; + const ctx: any = { + getService: (name: string) => + name === 'manifest' ? { register: (m: any) => registered.push(m) } : undefined, + logger: { info: () => {}, warn: () => {} }, + }; + await new ApprovalsServicePlugin({ disableService: true }).init(ctx); + expect(registered).toHaveLength(1); + return registered[0]; +} + +/** A view is caller-scoped when it filters on the caller: the token appears anywhere in it. */ +const isCallerScoped = (view: unknown): boolean => JSON.stringify(view ?? {}).includes('{current_user_id}'); /** * ADR-0029 K2.b / D7 — the approvals plugin owns sys_approval_request / @@ -80,4 +98,31 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', () expect(item.requiresObject).toBe(item.objectName); } }); + + // Setup → Approvals → Requests is an administrator's page. When a route names + // no view, the console opens the object's FIRST declared list view, so two + // things must hold: the first declared view is not filtered to the caller, + // and the Setup entry does not depend on that order at all. The merged-app + // pin in `packages/qa/dogfood` asserts the same two rules over every object + // entry of the runtime-merged Setup and Account apps; this case is the owning + // package's own copy for this one entry. + it('the Requests entry opens the tenant-wide list, and no caller-scoped view is declared first', async () => { + const manifest = await registeredManifest(); + const entry = manifest.navigationContributions[0].items.find((i: any) => i.id === 'nav_approval_requests'); + expect(entry, 'nav_approval_requests is contributed').toBeDefined(); + expect(entry.viewName, 'nav_approval_requests names its view').toBe('all_requests'); + + const listViews = (SysApprovalRequest as any).listViews as Record; + expect(Object.keys(listViews)[0], 'sys_approval_request declares all_requests first').toBe('all_requests'); + expect(isCallerScoped(listViews.all_requests), 'all_requests is not filtered to the caller').toBe(false); + // Non-vacuity: the object really declares caller-scoped views, so the order + // and the named view are what decide the landing page. + expect(isCallerScoped(listViews.my_pending)).toBe(true); + expect(isCallerScoped(listViews.submitted_by_me)).toBe(true); + + // `viewName` is the key the spec already declares on an object nav item; + // it survives the contribution schema rather than being stripped. + const parsed = NavigationContributionSchema.parse(manifest.navigationContributions[0]); + expect(parsed.items.find((i: any) => i.id === 'nav_approval_requests')).toMatchObject({ viewName: 'all_requests' }); + }); }); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts index 6cb56301a33..17a36501e7f 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request.object.ts @@ -58,7 +58,26 @@ export const SysApprovalRequest = ObjectSchema.create({ // Curated built-in list views — render as segmented tabs in the console. // Filters use {current_user_id} substitution wired by the console. + // + // `all_requests` is declared FIRST, and that position is the contract: when + // a route names no view, the console opens an object's first declared list + // view (objectui `ObjectView`: URL view id, `?view=`, `isDefault`, then + // `views[0]`). A caller-scoped view (`my_pending`, `submitted_by_me`) in + // first place turned Setup → Approvals → Requests, and every bare-object door + // (the record page's object breadcrumb, the object switcher), into a list of + // the caller's own rows. A caller-scoped list view is never an object's + // first; an entry that wants one names it. The order decides which view + // opens, never which rows a caller may read. listViews: { + all_requests: { + type: 'grid', + name: 'all_requests', + label: 'All', + data: { provider: 'object', object: 'sys_approval_request' }, + columns: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'submitter_id', 'updated_at'], + sort: [{ field: 'updated_at', order: 'desc' }], + pagination: { pageSize: 50 }, + }, my_pending: { type: 'grid', name: 'my_pending', @@ -96,15 +115,6 @@ export const SysApprovalRequest = ObjectSchema.create({ sort: [{ field: 'completed_at', order: 'desc' }], pagination: { pageSize: 25 }, }, - all_requests: { - type: 'grid', - name: 'all_requests', - label: 'All', - data: { provider: 'object', object: 'sys_approval_request' }, - columns: ['process_name', 'object_name', 'record_id', 'status', 'current_step', 'submitter_id', 'updated_at'], - sort: [{ field: 'updated_at', order: 'desc' }], - pagination: { pageSize: 50 }, - }, }, fields: { diff --git a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts index afc9d56a6e2..0781daf014b 100644 --- a/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/en.objects.generated.ts @@ -104,6 +104,9 @@ export const enObjects: NonNullable = { } }, _views: { + all_requests: { + label: "All" + }, my_pending: { label: "My Pending", emptyState: { @@ -116,9 +119,6 @@ export const enObjects: NonNullable = { }, completed: { label: "Completed" - }, - all_requests: { - label: "All" } }, _actions: { diff --git a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts index 591b678970e..f8ba11fa049 100644 --- a/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/es-ES.objects.generated.ts @@ -104,6 +104,9 @@ export const esESObjects: NonNullable = { } }, _views: { + all_requests: { + label: "Todas" + }, my_pending: { label: "Aprobaciones pendientes", emptyState: { @@ -116,9 +119,6 @@ export const esESObjects: NonNullable = { }, completed: { label: "Completadas" - }, - all_requests: { - label: "Todas" } }, _actions: { diff --git a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts index 4e5d7a88157..80c6a7cd636 100644 --- a/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/ja-JP.objects.generated.ts @@ -104,6 +104,9 @@ export const jaJPObjects: NonNullable = { } }, _views: { + all_requests: { + label: "すべて" + }, my_pending: { label: "承認待ち", emptyState: { @@ -116,9 +119,6 @@ export const jaJPObjects: NonNullable = { }, completed: { label: "完了済み" - }, - all_requests: { - label: "すべて" } }, _actions: { diff --git a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts index 9cede315869..942c1e1b589 100644 --- a/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts +++ b/packages/plugins/plugin-approvals/src/translations/zh-CN.objects.generated.ts @@ -104,6 +104,9 @@ export const zhCNObjects: NonNullable = { } }, _views: { + all_requests: { + label: "全部" + }, my_pending: { label: "待我审批", emptyState: { @@ -116,9 +119,6 @@ export const zhCNObjects: NonNullable = { }, completed: { label: "已完成" - }, - all_requests: { - label: "全部" } }, _actions: { diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 31f8331af34..aa767ed7377 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -41,12 +41,14 @@ "@objectstack/verify": "workspace:*" }, "devDependencies": { + "@objectstack/account": "workspace:*", "@objectstack/cli": "workspace:*", "@objectstack/cloud-connection": "workspace:*", "@objectstack/core": "workspace:*", "@objectstack/driver-sql": "workspace:*", "@objectstack/driver-sqlite-wasm": "workspace:*", "@objectstack/driver-turso": "workspace:*", + "@objectstack/setup": "workspace:*", "@objectstack/trigger-api": "workspace:*", "@types/node": "^26.6.3", "typescript": "^6.0.3", diff --git a/packages/qa/dogfood/test/platform-app-object-entry-views.test.ts b/packages/qa/dogfood/test/platform-app-object-entry-views.test.ts new file mode 100644 index 00000000000..1479ae42a12 --- /dev/null +++ b/packages/qa/dogfood/test/platform-app-object-entry-views.test.ts @@ -0,0 +1,393 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A caller-scoped list view is never the FIRST view of an object that a Setup + * or Account entry opens, and every entry that wants one names it — judged over + * the RUNTIME-MERGED apps, plugin contributions included. + * + * ## The mechanism + * + * When a route names no view, the console opens the object's first declared + * list view (objectui `ObjectView`: the URL view id, then `?view=`, then a view + * marked `isDefault`, then `views[0]`). A `{current_user_id}`-filtered view + * declared first is therefore what an administrator lands on from a Setup entry + * that names no view: their own rows, on the page meant for administering + * everyone's. + * + * ## Why a boot, and why here + * + * `packages/platform-objects/src/apps/caller-scoped-first-list-view.test.ts` + * pins these two rules over that package's own Setup contributions and Account + * app. It cannot see a plugin's entries: the plugins depend on + * `platform-objects`, not the other way round, and their Setup entries arrive + * only at runtime, through `manifest.navigationContributions`. Setup → + * Approvals → Requests (`nav_approval_requests`, contributed by + * `@objectstack/plugin-approvals`) opened the caller-scoped `my_pending` view + * inside exactly that blind spot. + * + * So this file boots the composition the way + * `packages/cli/scripts/check-app-nav-i18n.mjs` does — the same contributor + * roster, the same `manifest`-service seam, each manifest handed to + * `ObjectQL.registerApp` — and reads the apps back through the registry's + * `getApp`, which applies the same `applyNavContributions` merge the + * `/api/v1/meta/app` path serves. Every `type: 'object'` entry of the merged + * Setup and Account apps is in the population, so an entry a plugin adds later + * is judged without anyone listing it here. + * + * ## The two rules + * + * (a) every object such an entry names declares a first list view that is + * not caller-scoped; + * (b) every such entry whose object declares a caller-scoped view names a + * view (`viewName`) the object declares under that name, and a Setup + * entry names one that is not caller-scoped. + * + * "Caller-scoped" is read off the view itself: `{current_user_id}` anywhere in + * it (the `${current_user_id}` spelling contains it too). That token is + * presentation scope, not access: which rows a caller may read is row-level + * security's decision, so the declared order decides which view opens and + * nothing else. + * + * ## Where an object's definition is read + * + * - From the composition's own registry (`registry.getObject`), which holds + * every object the booted contributors register. + * - Otherwise from `@objectstack/platform-objects/identity`, the barrel that + * `@objectstack/plugin-auth` registers its identity objects from + * (`authIdentityObjects` in its `manifest.ts`). plugin-auth is not booted: + * `AuthPlugin` refuses to start without a secret, which is also why + * check-app-nav-i18n leaves it out of its roster. + * + * A named object neither source declares fails the run: an entry neither rule + * can judge is not a pass. + * + * ## Reach, stated so a green run is not read wider than it is + * + * - The roster below mirrors check-app-nav-i18n's `CONTRIBUTORS`. A + * contributor added there and not here is one this file does not see, so + * the two move together. + * - plugin-auth's `nav_sso_providers` is contributed only when an external + * IdP is wired, so no composition this file boots merges it. + * - An object that declares ONLY caller-scoped list views cannot meet (a) + * without a new view, which is not this file's to add. That set is pinned + * exactly, and (b) holds every entry naming one of them to a named view. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import * as IdentityObjects from '@objectstack/platform-objects/identity'; +import { createSetupAppPlugin } from '@objectstack/setup'; +import { createAccountAppPlugin } from '@objectstack/account'; +import { SecurityPlugin } from '@objectstack/plugin-security'; +import { SharingServicePlugin } from '@objectstack/plugin-sharing'; +import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals'; +import { AuditPlugin } from '@objectstack/plugin-audit'; +import { WebhookOutboxPlugin } from '@objectstack/plugin-webhooks'; +import { MessagingServicePlugin } from '@objectstack/service-messaging'; +import { DatasourceAdminServicePlugin } from '@objectstack/service-datasource'; +import { CONNECT_AGENT_UI_BUNDLE } from '@objectstack/mcp'; +import { + CLOUD_CONNECTION_UI_BUNDLE, + MARKETPLACE_BROWSE_UI_BUNDLE, + MARKETPLACE_INSTALLED_UI_BUNDLE, +} from '@objectstack/cloud-connection'; + +type AppName = 'setup' | 'account'; +const APPS: readonly AppName[] = ['setup', 'account']; + +type Manifest = { + apps?: Array<{ name?: string; navigation?: unknown[] }>; + navigationContributions?: Array<{ app?: string; items?: Array<{ id?: string }> }>; +}; + +type BootablePlugin = { + init?(ctx: unknown): unknown; + start?(ctx: unknown): unknown; +}; + +type Contributor = { + source: string; + apps: readonly AppName[]; + load(): { plugin?: BootablePlugin; manifests?: Manifest[] }; +}; + +// The composition. EXPLICIT, as in check-app-nav-i18n: a contributor that +// drops out of this list must do so in a diff someone reads. +const CONTRIBUTORS: readonly Contributor[] = [ + { source: '@objectstack/setup', apps: ['setup'], load: () => ({ plugin: createSetupAppPlugin() }) }, + { source: '@objectstack/account', apps: ['account'], load: () => ({ plugin: createAccountAppPlugin() }) }, + { source: '@objectstack/plugin-security', apps: ['setup'], load: () => ({ plugin: new SecurityPlugin({}) }) }, + { source: '@objectstack/plugin-sharing', apps: ['setup'], load: () => ({ plugin: new SharingServicePlugin({}) }) }, + { + source: '@objectstack/plugin-approvals', + apps: ['setup'], + load: () => ({ plugin: new ApprovalsServicePlugin({ disableService: true }) }), + }, + { source: '@objectstack/plugin-audit', apps: ['setup'], load: () => ({ plugin: new AuditPlugin() }) }, + { + source: '@objectstack/plugin-webhooks', + apps: ['setup'], + load: () => ({ plugin: new WebhookOutboxPlugin({ autoEnqueue: false }) }), + }, + { source: '@objectstack/service-messaging', apps: ['setup'], load: () => ({ plugin: new MessagingServicePlugin({}) }) }, + { + source: '@objectstack/service-datasource', + apps: ['setup'], + load: () => ({ plugin: new DatasourceAdminServicePlugin({}) }), + }, + { + source: '@objectstack/mcp', + apps: ['setup', 'account'], + load: () => ({ manifests: [CONNECT_AGENT_UI_BUNDLE as Manifest] }), + }, + { + source: '@objectstack/cloud-connection', + apps: ['setup'], + load: () => ({ + manifests: [CLOUD_CONNECTION_UI_BUNDLE, MARKETPLACE_BROWSE_UI_BUNDLE, MARKETPLACE_INSTALLED_UI_BUNDLE] as Manifest[], + }), + }, +]; + +type NavItem = { id?: string; type?: string; objectName?: string; viewName?: string; children?: NavItem[] }; +type ListView = Record; +type ObjectDef = { name?: string; fields?: unknown; listViews?: Record }; + +type Entry = { id: string; app: AppName; objectName: string; viewName?: string; source: string }; + +const CALLER_TOKEN = '{current_user_id}'; +const isCallerScoped = (view: unknown): boolean => JSON.stringify(view ?? {}).includes(CALLER_TOKEN); + +/** Every nav id in a tree, depth-first, groups included. */ +function navIds(items: unknown[] | undefined): string[] { + const out: string[] = []; + const walk = (list: unknown[] | undefined) => { + for (const raw of list ?? []) { + const item = (raw ?? {}) as NavItem; + if (typeof item.id === 'string' && item.id) out.push(item.id); + if (Array.isArray(item.children)) walk(item.children); + } + }; + walk(items); + return out; +} + +// ── Boot ──────────────────────────────────────────────────────────────────── + +const engine = new ObjectQL(); + +/** Manifests registered by the contributor currently being booted. */ +let sink: Manifest[] = []; + +// Exactly one real service, `manifest`, whose `register` is the seam every nav +// contribution flows through, and inert but COMPLETE members for the rest of +// the context, so a plugin never dies on a missing member and reads as absent. +const ctx = { + getService: (name: string) => (name === 'manifest' ? { register: (m: Manifest) => sink.push(m) } : undefined), + registerService: () => {}, + registerServiceFactory: () => {}, + replaceService: () => {}, + getServiceScoped: async () => undefined, + getServices: () => new Map(), + hook: () => {}, + trigger: async () => {}, + logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, + getKernel: () => undefined, +}; + +/** contributor → app → the nav ids it landed there. */ +const landed = new Map>(); +/** app → nav id → the contributor that declared it. */ +const declaredBy = new Map>(APPS.map((a) => [a, new Map()])); + +for (const contributor of CONTRIBUTORS) { + sink = []; + const loaded = contributor.load(); + if (loaded.plugin) { + await loaded.plugin.init?.(ctx); + await loaded.plugin.start?.(ctx); + } + sink.push(...(loaded.manifests ?? [])); + + const ids: Record = { setup: [], account: [] }; + for (const manifest of sink) { + for (const contribution of manifest.navigationContributions ?? []) { + const app = contribution.app as AppName; + if (!APPS.includes(app)) continue; + for (const item of contribution.items ?? []) { + if (!item?.id) continue; + ids[app].push(item.id); + declaredBy.get(app)!.set(item.id, contributor.source); + } + } + for (const shell of manifest.apps ?? []) { + const app = shell.name as AppName; + if (!APPS.includes(app)) continue; + for (const id of navIds(shell.navigation)) { + ids[app].push(id); + if (!declaredBy.get(app)!.has(id)) declaredBy.get(app)!.set(id, contributor.source); + } + } + engine.registerApp(manifest); + } + landed.set(contributor.source, ids); +} + +/** Every `type: 'object'` entry of one merged app, depth-first. */ +function objectEntries(app: AppName): Entry[] { + const merged = engine.registry.getApp(app) as { navigation?: unknown[] } | undefined; + const out: Entry[] = []; + const walk = (list: unknown[] | undefined) => { + for (const raw of list ?? []) { + const item = (raw ?? {}) as NavItem; + if (item.type === 'object') { + // Thrown, never skipped: an entry this walk cannot read is an entry + // neither rule judges. + if (typeof item.id !== 'string' || typeof item.objectName !== 'string') { + throw new Error(`a merged ${app} object entry without an id or objectName: ${JSON.stringify(item)}`); + } + out.push({ + id: item.id, + app, + objectName: item.objectName, + viewName: item.viewName, + source: declaredBy.get(app)!.get(item.id) ?? '(undeclared)', + }); + } + if (Array.isArray(item.children)) walk(item.children); + } + }; + walk(merged?.navigation); + return out; +} + +const MERGED_APPS = new Map(APPS.map((app) => [app, engine.registry.getApp(app) !== undefined])); +const ENTRIES: Entry[] = APPS.flatMap((app) => (MERGED_APPS.get(app) ? objectEntries(app) : [])); + +/** The identity objects plugin-auth registers at runtime, by name. */ +const IDENTITY: Map = (() => { + const byName = new Map(); + for (const value of Object.values(IdentityObjects)) { + const def = value as unknown as ObjectDef; + if (!def || typeof def !== 'object' || typeof def.name !== 'string' || !def.fields) continue; + const seen = byName.get(def.name); + if (seen && seen !== def) throw new Error(`two different identity object definitions export the name ${def.name}`); + byName.set(def.name, def); + } + return byName; +})(); + +/** Where each named object's definition came from. */ +type Resolved = { def: ObjectDef; from: 'composition' | 'identity' }; +const NAMED_OBJECTS = [...new Set(ENTRIES.map((e) => e.objectName))].sort(); +const CATALOGUE = new Map(); +for (const name of NAMED_OBJECTS) { + const registered = engine.registry.getObject(name) as ObjectDef | undefined; + if (registered) CATALOGUE.set(name, { def: registered, from: 'composition' }); + else if (IDENTITY.has(name)) CATALOGUE.set(name, { def: IDENTITY.get(name)!, from: 'identity' }); +} +const UNRESOLVED = NAMED_OBJECTS.filter((name) => !CATALOGUE.has(name)); + +const listViewsOf = (name: string): Record => CATALOGUE.get(name)?.def.listViews ?? {}; + +/** Resolved objects whose every declared list view is caller-scoped. */ +const ONLY_CALLER_SCOPED = NAMED_OBJECTS.filter((name) => { + const views = Object.values(listViewsOf(name)); + return CATALOGUE.has(name) && views.length > 0 && views.every(isCallerScoped); +}); + +/** The objects (a) judges: resolved, declaring no list view or at least one unscoped one. */ +const JUDGED_BY_A = NAMED_OBJECTS.filter((name) => CATALOGUE.has(name) && !ONLY_CALLER_SCOPED.includes(name)); + +/** The entries (b) judges: the object declares a caller-scoped view, or cannot be read. */ +const JUDGED_BY_B = ENTRIES.filter( + (e) => !CATALOGUE.has(e.objectName) || Object.values(listViewsOf(e.objectName)).some(isCallerScoped), +); + +// ── The composition is complete ───────────────────────────────────────────── + +describe('the merged Setup and Account apps are booted whole', () => { + it('both apps are registered and merged', () => { + for (const app of APPS) expect(MERGED_APPS.get(app), `the ${app} app is not registered at all`).toBe(true); + }); + + // The anti-false-green half: a contributor whose registration silently + // no-ops shrinks the population, which makes this file GREENER, not redder. + it.each(CONTRIBUTORS.flatMap((c) => c.apps.map((app) => [c.source, app] as const)))( + '%s lands at least one navigation id in the %s app', + (source, app) => { + expect(landed.get(source)?.[app] ?? [], `${source} landed no ${app} navigation id`).not.toHaveLength(0); + }, + ); + + it('the population carries entries a plugin contributes, not only the shells', () => { + const pluginEntries = ENTRIES.filter((e) => e.source !== '@objectstack/setup' && e.source !== '@objectstack/account'); + expect(pluginEntries.map((e) => `${e.app}/${e.id}`)).toEqual( + expect.arrayContaining(['setup/nav_approval_requests', 'setup/nav_record_shares']), + ); + expect(ENTRIES.filter((e) => e.app === 'account').length).toBeGreaterThan(0); + }); + + // Non-vacuity, not the population: every object this family reordered must + // still be judged by both rules, or a green run says nothing about it. + it('judges every object whose caller-scoped first view was moved off first place', () => { + for (const name of [ + 'sys_approval_request', + 'sys_record_share', + 'sys_user', + 'sys_api_key', + 'sys_session', + 'sys_oauth_application', + 'sys_account', + 'sys_user_preference', + ]) { + expect(JUDGED_BY_A, `(a) no longer judges ${name}`).toContain(name); + expect(JUDGED_BY_B.map((e) => e.objectName), `(b) no longer judges an entry naming ${name}`).toContain(name); + } + }); + + it('every object an entry names is declared by the composition or by the identity barrel', () => { + // Remedy for a red here: add the plugin that registers the object to the + // roster above (and to check-app-nav-i18n's), never a fallback. + expect(UNRESOLVED, 'named objects neither source declares').toEqual([]); + }); + + it('the objects that declare only caller-scoped list views are exactly these', () => { + // Each can meet (a) only with a new unscoped view, which is triage's call, + // so the set is pinned exactly and (b) holds every entry naming one of them. + expect(ONLY_CALLER_SCOPED).toEqual(['sys_inbox_message', 'sys_member']); + }); +}); + +// ── (a) a caller-scoped list view is never an object's first ──────────────── + +describe('(a) no object a Setup or Account entry opens declares a caller-scoped list view first', () => { + it.each(JUDGED_BY_A)('%s', (name) => { + const [first, view] = Object.entries(listViewsOf(name))[0] ?? []; + const namedBy = ENTRIES.filter((e) => e.objectName === name).map((e) => `${e.app}/${e.id}`); + expect( + isCallerScoped(view), + `${name} declares the caller-scoped list view "${first}" first; it is opened by ${namedBy.join(', ')}`, + ).toBe(false); + }); +}); + +// ── (b) every entry that wants a caller-scoped object names its view ──────── + +describe('(b) every entry whose object declares a caller-scoped view names its view', () => { + it.each(JUDGED_BY_B.map((e) => [`${e.app}/${e.id}`, e] as const))('%s', (_label, entry) => { + const where = `${entry.app}/${entry.id} (object ${entry.objectName}, contributed by ${entry.source})`; + expect(entry.viewName, `${where} names no viewName`).toBeTypeOf('string'); + const views = listViewsOf(entry.objectName); + if (!CATALOGUE.has(entry.objectName)) return; + expect(Object.keys(views), `${where} names "${entry.viewName}", which the object does not declare`).toContain( + entry.viewName, + ); + if (entry.app === 'setup') { + expect( + isCallerScoped(views[entry.viewName!]), + `${where} is an administrator's entry and names the caller-scoped view "${entry.viewName}"`, + ).toBe(false); + } + }); +}); diff --git a/packages/qa/dogfood/vitest.config.ts b/packages/qa/dogfood/vitest.config.ts index 9d2fb25c69c..e2662b3a95c 100644 --- a/packages/qa/dogfood/vitest.config.ts +++ b/packages/qa/dogfood/vitest.config.ts @@ -304,6 +304,27 @@ export default defineConfig({ find: /^@objectstack\/plugin-pinyin-search$/, replacement: path.resolve(__dirname, '../../plugins/plugin-pinyin-search/src/index.ts'), }, + // `platform-app-object-entry-views.test.ts` boots the Setup and + // Account app shells and every plugin that contributes navigation + // into them, then judges each object entry against the object's + // declared list views. The shells and the sharing plugin are part + // of that subject — their entries and view order ARE the verdict — + // so they are aliased to THIS checkout's source, not to the last + // `pnpm build`. The other contributors it boots are either aliased + // above or resolve through `dist/` as this package's + // `check:test-source-alias` row already records. + { + find: /^@objectstack\/setup$/, + replacement: path.resolve(__dirname, '../../apps/setup/src/index.ts'), + }, + { + find: /^@objectstack\/account$/, + replacement: path.resolve(__dirname, '../../apps/account/src/index.ts'), + }, + { + find: /^@objectstack\/plugin-sharing$/, + replacement: path.resolve(__dirname, '../../plugins/plugin-sharing/src/index.ts'), + }, ], }, test: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4188f8455e2..ee856b891ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2110,6 +2110,9 @@ importers: specifier: workspace:* version: link:../../verify devDependencies: + '@objectstack/account': + specifier: workspace:* + version: link:../../apps/account '@objectstack/cli': specifier: workspace:* version: link:../../cli @@ -2128,6 +2131,9 @@ importers: '@objectstack/driver-turso': specifier: workspace:* version: link:../../drivers/driver-turso + '@objectstack/setup': + specifier: workspace:* + version: link:../../apps/setup '@objectstack/trigger-api': specifier: workspace:* version: link:../../triggers/trigger-api