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
13 changes: 13 additions & 0 deletions .changeset/21984-approval-requests-all-first.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 8 additions & 1 deletion packages/plugins/plugin-approvals/src/approvals-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,14 +137,21 @@ 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',
group: 'group_approvals',
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' },
],
Expand Down
45 changes: 45 additions & 0 deletions packages/plugins/plugin-approvals/src/nav-contribution.test.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
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 /
Expand Down Expand Up @@ -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<string, unknown>;
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' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
}
},
_views: {
all_requests: {
label: "All"
},
my_pending: {
label: "My Pending",
emptyState: {
Expand All @@ -116,9 +119,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
completed: {
label: "Completed"
},
all_requests: {
label: "All"
}
},
_actions: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
}
},
_views: {
all_requests: {
label: "Todas"
},
my_pending: {
label: "Aprobaciones pendientes",
emptyState: {
Expand All @@ -116,9 +119,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
completed: {
label: "Completadas"
},
all_requests: {
label: "Todas"
}
},
_actions: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
}
},
_views: {
all_requests: {
label: "すべて"
},
my_pending: {
label: "承認待ち",
emptyState: {
Expand All @@ -116,9 +119,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
completed: {
label: "完了済み"
},
all_requests: {
label: "すべて"
}
},
_actions: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
}
},
_views: {
all_requests: {
label: "全部"
},
my_pending: {
label: "待我审批",
emptyState: {
Expand All @@ -116,9 +119,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
completed: {
label: "已完成"
},
all_requests: {
label: "全部"
}
},
_actions: {
Expand Down
2 changes: 2 additions & 0 deletions packages/qa/dogfood/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading