Skip to content

Commit a33c76e

Browse files
claudeos-zhuang
authored andcommitted
feat(showcase): add the object-less (global) action specimen
#3913 and its follow-up (#4005) fixed object-less action dispatch and the empty-object-segment route with no live specimen to exercise either. The showcase is the "one specimen of everything" app, yet every action in it declared an `objectName` — so the `global` engine key, and both object-less URL shapes, had nothing in-app that actually ran through them. #4005 could only verify routing end to end; a real dispatch was never observed. `showcase_portfolio_snapshot` closes that. It declares no `objectName`, so `ObjectQLPlugin.actionObjectKey` / AppPlugin's `action.object || 'global'` key it at `global`, and it is reachable at both shapes. The body is genuinely object-less — it counts across three objects, so there is no single record or object it could sensibly hang off — and `locations` is `global_nav` for the same reason. Verified live against the running showcase; both URL shapes return byte-identical single-wrapped results: POST /api/v1/actions/global/showcase_portfolio_snapshot POST /api/v1/actions//showcase_portfolio_snapshot -> 200 {"success":true,"data":{"ok":true,"scope":"global", "accounts":15,"projects":5,"invoices":13}} That is the first live confirmation of all three fixes in this arc acting together: the canonical `global` key (#3913), the empty-segment route (#4005), and single-wrap success (#3962). Two tests pin the specimen in `test/actions.test.ts`, next to the #2169 executability cases. The first is the one that matters: adding an `objectName` to this action would still build, still pass coverage.test.ts, and still work — while silently removing the app's only coverage of object-less dispatch. It asserts the action declares neither `objectName` nor `object`, and that it remains the only such action. Confirmed the guard fails when an `objectName` is added. Showcase build green (Actions 10 -> 11), 60 tests pass, typecheck and eslint clean. Example package is private, so the changeset releases nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011AvZj6cLX7APd7roh2eK4F
1 parent b332246 commit a33c76e

4 files changed

Lines changed: 130 additions & 2 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
---
3+
4+
Showcase example only, releases nothing: add `showcase_portfolio_snapshot`, the
5+
first object-less (`global`) action in the app. #3913 and its follow-up fixed
6+
object-less dispatch and the empty-object-segment route with no live specimen to
7+
exercise them — the "one specimen of everything" app had no action without an
8+
`objectName`. This is that specimen.

examples/app-showcase/src/coverage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
9595
status: 'demonstrated',
9696
files: ['src/ui/actions/index.ts'],
9797
notes:
98-
'Every ActionType (script/url/flow/modal/api/form). `ActionParamGalleryAction` additionally exercises the ADR-0059 param-dialog widgets: one inline param per non-trivial type (richtext/color/date/select/number/autonumber) plus image/file uploads with multiple/accept/maxSize and the upload guard.',
98+
"Every ActionType (script/url/flow/modal/api/form). `ActionParamGalleryAction` additionally exercises the ADR-0059 param-dialog widgets: one inline param per non-trivial type (richtext/color/date/select/number/autonumber) plus image/file uploads with multiple/accept/maxSize and the upload guard. `PortfolioSnapshotAction` is the OBJECT-LESS specimen (framework#3913): it declares no `objectName`, so it keys at `global` — the app's only live exerciser of object-less dispatch and of both object-less URL shapes (`/actions/global/:action` and `/actions//:action`).",
9999
},
100100
report: { status: 'demonstrated', files: ['src/ui/reports/index.ts'] },
101101
dataset: { status: 'demonstrated', files: ['src/ui/datasets/index.ts'] },

examples/app-showcase/src/ui/actions/index.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,55 @@ export const ArchiveTaskAction = defineAction({
275275
refreshAfter: false,
276276
});
277277

278+
/**
279+
* script, **OBJECT-LESS** — the `global` action specimen (framework#3913).
280+
*
281+
* Every other action here declares an `objectName`. This one deliberately does
282+
* NOT, which is the whole point: `objectName` is optional, and an action
283+
* without one is an *object-less* action. `AppPlugin` registers it under the
284+
* canonical `'global'` engine key (`action.object || 'global'`), and it is
285+
* reachable over REST at BOTH object-less URL shapes:
286+
*
287+
* POST /api/v1/actions/global/showcase_portfolio_snapshot
288+
* POST /api/v1/actions//showcase_portfolio_snapshot ← empty object segment
289+
*
290+
* Why the app needed this: framework#3913 was filed because object-less actions
291+
* were unreachable (registered under `'global'`, looked up under `'*'`), and its
292+
* follow-up found the empty-segment URL had no route registration at all. Both
293+
* were fixed blind — the showcase, the "one specimen of everything" app, had no
294+
* object-less action, so neither the dispatch path nor either URL shape had a
295+
* live specimen to exercise. This is that specimen.
296+
*
297+
* The body is genuinely object-less: it counts across SEVERAL objects, so there
298+
* is no single record or object the action could sensibly hang off. `location`
299+
* is `global_nav` for the same reason — an object-less action has no row and no
300+
* record header to render on.
301+
*/
302+
export const PortfolioSnapshotAction = defineAction({
303+
name: 'showcase_portfolio_snapshot',
304+
label: 'Portfolio Snapshot',
305+
icon: 'gauge',
306+
// NO objectName — this is what makes it an object-less ('global') action.
307+
type: 'script',
308+
body: {
309+
language: 'js',
310+
source:
311+
"var accounts = await ctx.api.object('showcase_account').count({});" +
312+
"var projects = await ctx.api.object('showcase_project').count({});" +
313+
"var invoices = await ctx.api.object('showcase_invoice').count({});" +
314+
"return { ok: true, scope: 'global', accounts: accounts, projects: projects, invoices: invoices };",
315+
capabilities: ['api.read'],
316+
},
317+
successMessage: 'Portfolio snapshot taken.',
318+
locations: ['global_nav'],
319+
refreshAfter: false,
320+
ai: {
321+
exposed: true,
322+
description:
323+
'Count the accounts, projects and invoices in this workspace. Use when the user asks how big the portfolio is, or for a quick health snapshot across objects.',
324+
},
325+
});
326+
278327
export const allActions = [
279328
MarkDoneAction,
280329
OpenDocsAction,
@@ -286,4 +335,5 @@ export const allActions = [
286335
SubmitForSignoffAction,
287336
ActionParamGalleryAction,
288337
ArchiveTaskAction,
338+
PortfolioSnapshotAction,
289339
];

examples/app-showcase/test/actions.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { describe, it, expect } from 'vitest';
44
import { actionBodyRunnerFactory, QuickJSScriptRunner } from '@objectstack/runtime';
55

6-
import { allActions, MarkDoneAction } from '../src/ui/actions/index.js';
6+
import { allActions, MarkDoneAction, PortfolioSnapshotAction } from '../src/ui/actions/index.js';
77

88
/**
99
* Execution-path coverage for declared actions.
@@ -84,3 +84,73 @@ describe('showcase actions — executability', () => {
8484
expect(handler).toBeUndefined();
8585
});
8686
});
87+
88+
/**
89+
* The object-less ("global") action specimen — framework#3913.
90+
*
91+
* #3913 was filed because object-less actions were unreachable: registered
92+
* under the canonical `'global'` key, looked up under `'*'`. Its follow-up then
93+
* found that `POST /api/v1/actions//:action` — the empty-object-segment URL the
94+
* issue was actually filed against — had no route registration at all. Both
95+
* were fixed with **no live specimen**: the showcase is the "one specimen of
96+
* everything" app and every action in it declared an `objectName`, so nothing
97+
* exercised the object-less dispatch path end to end.
98+
*
99+
* These tests pin the two properties that make it a specimen. The first is the
100+
* one that silently rots: adding an `objectName` to this action would still
101+
* build, still pass `coverage.test.ts`, and still work — while quietly removing
102+
* the app's only coverage of object-less dispatch.
103+
*/
104+
describe('showcase actions — the object-less (`global`) specimen', () => {
105+
const runner = new QuickJSScriptRunner();
106+
107+
it('declares no object, so it keys at `global` (framework#3913)', () => {
108+
// This mirrors ObjectQLPlugin.actionObjectKey / AppPlugin's
109+
// `action.object || 'global'`: neither field set → the 'global' bucket.
110+
const a = PortfolioSnapshotAction as { objectName?: string; object?: string };
111+
expect(a.objectName).toBeUndefined();
112+
expect(a.object).toBeUndefined();
113+
114+
// ...and it is the ONLY one, so this test is what keeps the specimen alive.
115+
const objectLess = allActions.filter((x) => {
116+
const y = x as { objectName?: string; object?: string };
117+
return !y.objectName && !y.object;
118+
});
119+
expect(objectLess.map((x) => x.name)).toEqual(['showcase_portfolio_snapshot']);
120+
});
121+
122+
it('counts across several objects via the sandboxed body', async () => {
123+
// An object-less action has no record and no single object to hang off —
124+
// this body reads three, which is why it cannot be given an `objectName`.
125+
const counts: Record<string, number> = {
126+
showcase_account: 15,
127+
showcase_project: 5,
128+
showcase_invoice: 13,
129+
};
130+
const seen: string[] = [];
131+
const ql = {
132+
object: (object: string) => ({
133+
count: async () => {
134+
seen.push(object);
135+
return counts[object] ?? 0;
136+
},
137+
}),
138+
};
139+
140+
const factory = actionBodyRunnerFactory(runner, { ql, appId: 'showcase' });
141+
const handler = factory(PortfolioSnapshotAction as never);
142+
expect(typeof handler).toBe('function');
143+
144+
// No recordId, no record — the object-less invocation shape.
145+
const result = await handler!({ params: {}, user: { id: 'u1' } });
146+
147+
expect(seen).toEqual(['showcase_account', 'showcase_project', 'showcase_invoice']);
148+
expect(result).toEqual({
149+
ok: true,
150+
scope: 'global',
151+
accounts: 15,
152+
projects: 5,
153+
invoices: 13,
154+
});
155+
});
156+
});

0 commit comments

Comments
 (0)