Skip to content

Commit 06a96e9

Browse files
fix(app-shell): supply the spec-declared ctx.org scope in the console action runtime (objectui#10918) (#10945)
Fixes #10918 Clause-②: no ## What changed The spec-declared `org` key is now in both console action contexts that lacked it: - `useConsoleActionRuntime` publishes the active organization under `org` in `actionProviderProps.context`. Before this change it published it only under the undeclared key `activeOrganization`, which is kept unchanged. - `RecordDetailView`'s own `ActionProvider` shadows the shell runtime for every record-page action. It now carries `org` next to the unchanged `record`, `objectName` and `user`. No other key is added. - Both call one helper, `actionContextOrg` in `packages/app-shell/src/utils/actionContextOrg.ts`. It returns `id`, `slug` and `name`, or `null` with no active organization. It is not exported from the package entry, and no exported type, entry export or published accept set changes. ## Why `@objectstack/spec` `ui/action.zod.ts` declares `${ctx.org.id}` in the `ActionSchema.target` interpolation doc ("`ctx.recordId`, `ctx.user.id`, `ctx.org.id`, etc."). The `onSuccess` block lists the scope as "`ctx.user.*`, `ctx.org.*`, `ctx.recordId`, `ctx.selection`". `ActionRunner.buildInterpolationContext` builds `org` from `context.org` (falling back to `context.organization`, then an empty object). The console runtime supplied neither key, so `${ctx.org.id}` interpolated to an empty string. Cloud's welcome-page "invite members" CTA needs `/apps/cloud_control/sys_organization/record/${ctx.org.id}`. Scope: the fix covers every console surface that mounts an action provider. That is `ConsoleShell`'s root provider, `ObjectView`, `PageView` (via `ConsoleActionRuntimeProvider`), `DeclaredActionsBar`, and the record page's own provider in `RecordDetailView`. ## Premise re-verified on `origin/main` (`af2221d`) - The provider context in `useConsoleActionRuntime` carried `activeOrganization` and no `org`. - `ActionRunner.buildInterpolationContext` reads `this.context.org ?? this.context.organization ?? {}`. - The new test failed on the unfixed tree with `Expected: "/apps/cloud_control/sys_organization/record/org_acme"` / `Received: "/apps/cloud_control/sys_organization/record/"`. - The record page's own `ActionProvider` context in `RecordDetailView` carried `record`, `objectName` and `user` and no `org`. With the first commit in place, `${ctx.org.id}` still resolved empty there. ## Tests (all at `44de8b6`, after merging `origin/main` `7e8b3c0`) Two pins. Each drives a `type: 'url'` action targeting `/apps/cloud_control/sys_organization/record/${ctx.org.id}` through a real provider and the real `ActionRunner` interpolation: - `packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.ctxOrg-10918.test.tsx` runs through the real `ConsoleActionRuntimeProvider` and asserts the URL handed to the router. - `packages/app-shell/src/views/RecordDetailView.ctxOrg-10918.test.tsx` runs through the real `RecordDetailView`. A probe inside the record page's own provider instance dispatches the action, and the test asserts the URL the runner hands to the view's `onNavigate`. Each pin has two cases. With an active organization, the id resolves. With none, it resolves to an empty segment, never a literal `null`. Reverse verification, each on the committed tree with `ablation-replace.mjs`, then restored with `git checkout HEAD` (blob equals HEAD, `git diff HEAD` empty): - Runtime pin, on `86e1966`: deleting `org: actionContextOrg(activeOrganization),` (blob `791d2a67a4e6` to `61295f1c5414`) turned it red, `Received: "/apps/cloud_control/sys_organization/record/"`. - Record-page pin, on `04f6e70`: deleting `, org: actionContextOrg(activeOrganization)` from `RecordDetailView.tsx` (blob `eacded96409b` to `efe0920aa134`) turned it red, `Tests 1 failed | 1 passed (2)`, received `"/apps/cloud_control/sys_organization/record/"`. Test runs at `44de8b6`: - All 33 `RecordDetailView.*` test files: `Test Files 33 passed (33)`, `Tests 224 passed (224)`. - The 15 runtime-adjacent files (`useConsoleActionRuntime*`, `DeclaredActionsBar*`, `PageView*`, `ConsoleShell*` tests): `Test Files 15 passed (15)`, `Tests 146 passed (146)`. - `pnpm --filter @object-ui/app-shell type-check`, run after building the dependency closure (28/28 tasks): exit 0. `--listFilesOnly` on `tsconfig.test.json` lists both pins. Gates at `44de8b6`, all exit 0: - ESLint on the 5 touched source and test files: 0 errors. Warnings on `useConsoleActionRuntime.tsx` (37) and `RecordDetailView.tsx` (112) are identical per rule to `main` `7e8b3c0`. `actionContextOrg.ts` has none. The two pins have only no-explicit-any warnings in mock stubs (22 and 9). - Changeset scripts: presence, no-major, overwrite, claims, fixed. `check-changeset-claims` notes that the pending `.changeset/7997-detail-view-related-retired.md` names `RecordDetailView.tsx`. Its paragraph is about the retired `DetailView` `related` member, which this change does not touch. - `check:` gates: pending-changeset-literals, action-ref-convention, handler-key-reads, new-line-citations (0 new), control-bytes, vi-mock-specifiers, vi-mock-inherit, vi-mock-override-shape, test-path-roots, unreferenced-sources, metadata-write-doors, phantom-deps, self-import, esm-specifiers. - Coverage scripts: check-type-check-coverage, check-lint-coverage. Declared to CI: the full app-shell suite and repo-wide `pnpm lint`. ## Acceptance notes - **`ActionRunner`'s `?? this.context.organization` fallback has no producer.** A `git grep` over objectui `packages/**` and `apps/**` found no source that sets `organization` on an action context. Now that the runtime supplies `org`, that fallback is a tolerant alias with zero pull. It is not changed here because `ActionRunner.ts` is held under objectui#10900. - **The undeclared `activeOrganization` key still reaches authors.** `ActionProvider` copies every context key into the `ctx` interpolation scope, so `${ctx.activeOrganization.id}` resolves and predicates can read `ctx.activeOrganization`. Measured readers: `git grep -F "ctx.activeOrganization"` gives 0 in objectui (control `ctx.recordId`: 17), and 0 on cloud `origin/main` and objectstack `origin/main`. It is kept because this card adds a key and removes nothing. Retiring it is a separate decision. Carrier: none. --- _Generated by [Claude Code](https://claude.ai/code/session_015AUunPkX7UTkCH9e7AdZo1)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dd0d78f commit 06a96e9

6 files changed

Lines changed: 414 additions & 5 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): console actions supply the spec-declared `${ctx.org.*}` scope (objectui#10918)
6+
7+
`@objectstack/spec` declares `${ctx.org.id}` in an action's `target`, and lists the
8+
interpolation scope as `ctx.user.*`, `ctx.org.*`, `ctx.recordId` and `ctx.selection`.
9+
The console's action contexts never carried `org`, so the `org` the action runner
10+
interpolates from was empty. A target such as
11+
`/apps/cloud_control/sys_organization/record/${ctx.org.id}` navigated to
12+
`/apps/cloud_control/sys_organization/record/` with the id blanked out.
13+
14+
Two providers were missing it:
15+
16+
- `useConsoleActionRuntime`, the shared runtime behind the console's root action
17+
provider, object views, SDUI pages and declared action bars. It published the active
18+
organization only under the undeclared key `activeOrganization`.
19+
- The record page's own action provider in `RecordDetailView`, which carried only
20+
`record`, `objectName` and `user`.
21+
22+
Both now publish the active organization under `org`, with the fields `id`, `slug` and
23+
`name`, through one shared projection. `${ctx.org.id}` in a console action target
24+
resolves to the active organization's id. With no active organization it still resolves
25+
to an empty string. The existing `activeOrganization` key on the shared runtime is
26+
unchanged, and the record page gains no key besides `org`.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#10918 regression pin — the console action runtime supplies the
11+
* spec-declared `${ctx.org.*}` interpolation scope.
12+
*
13+
* `ActionSchema.target` in `@objectstack/spec` (`ui/action.zod.ts`) declares
14+
* `${ctx.org.id}` as a member of the action context, and the `onSuccess`
15+
* contract lists the same scope as "`ctx.user.*`, `ctx.org.*`, `ctx.recordId`,
16+
* `ctx.selection`". The runtime used to publish the active organization only
17+
* under the undeclared key `activeOrganization`, so `ActionRunner`'s
18+
* interpolation scope carried an empty `org` and a declared target such as
19+
* `/apps/cloud_control/sys_organization/record/${ctx.org.id}` navigated to a
20+
* path with the id blanked out.
21+
*
22+
* Driven end-to-end: the REAL `ConsoleActionRuntimeProvider` (and so the real
23+
* `<ActionProvider>` and `ActionRunner` target interpolation) runs a
24+
* `type: 'url'` action from a `useAction()` consumer, and the navigation the
25+
* runtime hands to the router is what is asserted.
26+
*/
27+
28+
import { describe, it, expect, vi, beforeEach } from 'vitest';
29+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
30+
import React from 'react';
31+
32+
const navigateSpy = vi.fn();
33+
vi.mock('react-router-dom', () => ({ useNavigate: () => navigateSpy }));
34+
35+
// `activeOrganization` is what `useAuth()` answers — the auth layer's own
36+
// `AuthOrganization` shape. Each test sets it before rendering.
37+
let activeOrganization: Record<string, unknown> | null = null;
38+
vi.mock('@object-ui/auth', async (importOriginal) => ({
39+
...(await importOriginal<Record<string, unknown>>()),
40+
useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization }),
41+
createAuthenticatedFetch: () => vi.fn(),
42+
}));
43+
44+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
45+
...(await importOriginal<Record<string, unknown>>()),
46+
useObjectLabel: () => ({
47+
fieldLabel: (_o: any, _n: any, l: any) => l,
48+
fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l,
49+
actionParamText: (_o: any, _a: any, _p: any, _attr: any, fallback: any) => fallback,
50+
actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback,
51+
actionDescription: (_o: any, _a: any, fallback: any) => fallback,
52+
}),
53+
useObjectTranslation: () => ({
54+
t: (key: string, options?: any) => String(options?.defaultValue ?? key),
55+
}),
56+
}));
57+
58+
// The client modal transport and the dialogs are not exercised here — inert
59+
// stubs keep the hook module's import graph cheap.
60+
vi.mock('../useActionModal', () => ({
61+
useActionModal: () => ({
62+
modalHandler: vi.fn(async () => ({ success: true })),
63+
modalElement: null,
64+
closeModal: () => {},
65+
resolveModalTarget: vi.fn(async () => null),
66+
}),
67+
}));
68+
vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
69+
vi.mock('../../views/ActionParamDialog', () => ({ ActionParamDialog: () => null }));
70+
vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null }));
71+
vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null }));
72+
73+
import { ConsoleActionRuntimeProvider } from '../useConsoleActionRuntime';
74+
import { useAction } from '@object-ui/react';
75+
76+
// The shape cloud's welcome-page "invite members" CTA declares (objectui#10918).
77+
const ORG_RECORD_TARGET = '/apps/cloud_control/sys_organization/record/${ctx.org.id}';
78+
79+
function Probe() {
80+
const { execute } = useAction();
81+
return (
82+
<button onClick={() => execute({ type: 'url', name: 'open_org', target: ORG_RECORD_TARGET } as any)}>
83+
run
84+
</button>
85+
);
86+
}
87+
88+
function runOrgTargetAction() {
89+
render(
90+
<ConsoleActionRuntimeProvider dataSource={{}} objects={[]}>
91+
<Probe />
92+
</ConsoleActionRuntimeProvider>,
93+
);
94+
fireEvent.click(screen.getByText('run'));
95+
}
96+
97+
beforeEach(() => {
98+
navigateSpy.mockReset();
99+
activeOrganization = null;
100+
});
101+
102+
describe('useConsoleActionRuntime — the spec-declared ctx.org scope (objectui#10918)', () => {
103+
it('interpolates ${ctx.org.id} in an action target to the active organization id', async () => {
104+
activeOrganization = { id: 'org_acme', slug: 'acme', name: 'Acme Inc.' };
105+
106+
runOrgTargetAction();
107+
108+
await waitFor(() => expect(navigateSpy).toHaveBeenCalledTimes(1));
109+
expect(navigateSpy.mock.calls[0][0]).toBe('/apps/cloud_control/sys_organization/record/org_acme');
110+
});
111+
112+
it('interpolates ${ctx.org.id} to an empty segment, never a literal null, when no organization is active', async () => {
113+
runOrgTargetAction();
114+
115+
await waitFor(() => expect(navigateSpy).toHaveBeenCalledTimes(1));
116+
expect(navigateSpy.mock.calls[0][0]).toBe('/apps/cloud_control/sys_organization/record/');
117+
});
118+
});

‎packages/app-shell/src/hooks/useConsoleActionRuntime.tsx‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { resolvePageVarTokens } from '../utils/resolvePageVarTokens.js';
5353
import { interpretFlowResponse, judgeFlowLaunch } from '../utils/flowResponse.js';
5454
import { createConsoleServerActionHandler } from '../utils/consoleServerAction.js';
5555
import { modalTargetRefusalMessage } from '../utils/modalTargetDiagnostics.js';
56+
import { actionContextOrg } from '../utils/actionContextOrg.js';
5657
import type { ConsoleActionDispatch } from '../consoleActionDispatch.js';
5758

5859
const FALLBACK_USER = { id: 'current-user', name: 'Demo User', isPlatformAdmin: false };
@@ -736,9 +737,13 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
736737
// Backend origin — lets `type: 'url'` actions issue full-page
737738
// navigations to API endpoints across origins in dev.
738739
apiBase: (import.meta as any).env?.VITE_SERVER_URL || '',
739-
activeOrganization: activeOrganization
740-
? { id: activeOrganization.id, slug: activeOrganization.slug, name: activeOrganization.name }
741-
: null,
740+
// The spec-declared `${ctx.org.*}` scope (`ActionSchema.target` and
741+
// `onSuccess.navigate` in `@objectstack/spec` `ui/action.zod.ts`).
742+
// `ActionRunner.buildInterpolationContext` reads `org` from here, so
743+
// without this key `${ctx.org.id}` interpolated to an empty string
744+
// (objectui#10918).
745+
org: actionContextOrg(activeOrganization),
746+
activeOrganization: actionContextOrg(activeOrganization),
742747
},
743748
onConfirm: confirmHandler,
744749
onToast: toastHandler,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import type { AuthOrganization } from '@object-ui/auth';
10+
11+
/**
12+
* The ONE projection of the active organization onto an action context's
13+
* spec-declared `org` key (objectui#10918).
14+
*
15+
* `@objectstack/spec` (`ui/action.zod.ts`) declares `${ctx.org.id}` in an
16+
* action `target` and lists the interpolation scope as `ctx.user.*`,
17+
* `ctx.org.*`, `ctx.recordId` and `ctx.selection`.
18+
* `ActionRunner.buildInterpolationContext` builds that `org` from the context
19+
* it is handed, so every console surface that mounts its own `ActionProvider`
20+
* has to put the organization there:
21+
*
22+
* - `hooks/useConsoleActionRuntime.tsx`, the shared runtime (the console's root
23+
* provider, object views, SDUI pages and declared action bars);
24+
* - `views/RecordDetailView.tsx`, the record page's own provider.
25+
*
26+
* Both call this helper, so the two cannot drift into two shapes. It carries
27+
* the identity fields the platform's `OrganizationSchema` names (`id`, `slug`,
28+
* `name`), or `null` when no organization is active. `${ctx.org.id}` then
29+
* interpolates to an empty string, never a literal `null`.
30+
*/
31+
export function actionContextOrg(org: AuthOrganization | null | undefined) {
32+
return org ? { id: org.id, slug: org.slug, name: org.name } : null;
33+
}

0 commit comments

Comments
 (0)