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/showcase-global-action-specimen.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
---
---

Showcase example only, releases nothing: add `showcase_portfolio_snapshot`, the
first object-less (`global`) action in the app. #3913 and its follow-up fixed
object-less dispatch and the empty-object-segment route with no live specimen to
exercise them — the "one specimen of everything" app had no action without an
`objectName`. This is that specimen.
2 changes: 1 addition & 1 deletion examples/app-showcase/src/coverage.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -95,7 +95,7 @@ export const KIND_COVERAGE: Record<MetadataType, KindCoverage> = {
status: 'demonstrated',
files: ['src/ui/actions/index.ts'],
notes:
'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.',
"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`).",
},
report: { status: 'demonstrated', files: ['src/ui/reports/index.ts'] },
dataset: { status: 'demonstrated', files: ['src/ui/datasets/index.ts'] },
Expand Down
22 changes: 22 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -138,6 +138,17 @@ export const ShowcaseTranslationBundle = {
label: 'Field Zoo', pluralLabel: 'Field Zoo',
},
},
// The OBJECT-LESS action slot (framework#3913). `globalActions` is the only
// place the resolver looks for an action that declares no `objectName` —
// keys for such an action under `objects.*._actions` are never read. Before
// `showcase_portfolio_snapshot` the showcase had no object-less action, so
// this slot had no specimen anywhere in the repo.
globalActions: {
showcase_portfolio_snapshot: {
label: 'Portfolio Snapshot',
successMessage: 'Portfolio snapshot taken.',
},
},
},
'zh-CN': {
objects: {
Expand DownExpand Up@@ -286,5 +297,16 @@ export const ShowcaseTranslationBundle = {
},
},
},
// See the `en` block: object-less actions resolve ONLY through
// `globalActions`. Translated at birth for the same reason as
// `showcase_action_param_gallery.params.p_assignee` above — this example is
// ratcheted at its current untranslated count, so a new declared label that
// skips zh-CN widens the debt and fails `check-i18n-coverage`.
globalActions: {
showcase_portfolio_snapshot: {
label: '业务概览',
successMessage: '已生成业务概览。',
},
},
},
};
50 changes: 50 additions & 0 deletions examples/app-showcase/src/ui/actions/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -275,6 +275,55 @@ export const ArchiveTaskAction = defineAction({
refreshAfter: false,
});

/**
* script, **OBJECT-LESS** — the `global` action specimen (framework#3913).
*
* Every other action here declares an `objectName`. This one deliberately does
* NOT, which is the whole point: `objectName` is optional, and an action
* without one is an *object-less* action. `AppPlugin` registers it under the
* canonical `'global'` engine key (`action.object || 'global'`), and it is
* reachable over REST at BOTH object-less URL shapes:
*
* POST /api/v1/actions/global/showcase_portfolio_snapshot
* POST /api/v1/actions//showcase_portfolio_snapshot ← empty object segment
*
* Why the app needed this: framework#3913 was filed because object-less actions
* were unreachable (registered under `'global'`, looked up under `'*'`), and its
* follow-up found the empty-segment URL had no route registration at all. Both
* were fixed blind — the showcase, the "one specimen of everything" app, had no
* object-less action, so neither the dispatch path nor either URL shape had a
* live specimen to exercise. This is that specimen.
*
* The body is genuinely object-less: it counts across SEVERAL objects, so there
* is no single record or object the action could sensibly hang off. `location`
* is `global_nav` for the same reason — an object-less action has no row and no
* record header to render on.
*/
export const PortfolioSnapshotAction = defineAction({
name: 'showcase_portfolio_snapshot',
label: 'Portfolio Snapshot',
icon: 'gauge',
// NO objectName — this is what makes it an object-less ('global') action.
type: 'script',
body: {
language: 'js',
source:
"var accounts = await ctx.api.object('showcase_account').count({});" +
"var projects = await ctx.api.object('showcase_project').count({});" +
"var invoices = await ctx.api.object('showcase_invoice').count({});" +
"return { ok: true, scope: 'global', accounts: accounts, projects: projects, invoices: invoices };",
capabilities: ['api.read'],
},
successMessage: 'Portfolio snapshot taken.',
locations: ['global_nav'],
refreshAfter: false,
ai: {
exposed: true,
description:
'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.',
},
});

export const allActions = [
MarkDoneAction,
OpenDocsAction,
Expand All@@ -286,4 +335,5 @@ export const allActions = [
SubmitForSignoffAction,
ActionParamGalleryAction,
ArchiveTaskAction,
PortfolioSnapshotAction,
];
72 changes: 71 additions & 1 deletion examples/app-showcase/test/actions.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,7 +3,7 @@
import { describe, it, expect } from 'vitest';
import { actionBodyRunnerFactory, QuickJSScriptRunner } from '@objectstack/runtime';

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

/**
* Execution-path coverage for declared actions.
Expand DownExpand Up@@ -84,3 +84,73 @@ describe('showcase actions — executability', () => {
expect(handler).toBeUndefined();
});
});

/**
* The object-less ("global") action specimen — framework#3913.
*
* #3913 was filed because object-less actions were unreachable: registered
* under the canonical `'global'` key, looked up under `'*'`. Its follow-up then
* found that `POST /api/v1/actions//:action` — the empty-object-segment URL the
* issue was actually filed against — had no route registration at all. Both
* were fixed with **no live specimen**: the showcase is the "one specimen of
* everything" app and every action in it declared an `objectName`, so nothing
* exercised the object-less dispatch path end to end.
*
* These tests pin the two properties that make it a specimen. The first is the
* one that silently rots: adding an `objectName` to this action would still
* build, still pass `coverage.test.ts`, and still work — while quietly removing
* the app's only coverage of object-less dispatch.
*/
describe('showcase actions — the object-less (`global`) specimen', () => {
const runner = new QuickJSScriptRunner();

it('declares no object, so it keys at `global` (framework#3913)', () => {
// This mirrors ObjectQLPlugin.actionObjectKey / AppPlugin's
// `action.object || 'global'`: neither field set → the 'global' bucket.
const a = PortfolioSnapshotAction as { objectName?: string; object?: string };
expect(a.objectName).toBeUndefined();
expect(a.object).toBeUndefined();

// ...and it is the ONLY one, so this test is what keeps the specimen alive.
const objectLess = allActions.filter((x) => {
const y = x as { objectName?: string; object?: string };
return !y.objectName && !y.object;
});
expect(objectLess.map((x) => x.name)).toEqual(['showcase_portfolio_snapshot']);
});

it('counts across several objects via the sandboxed body', async () => {
// An object-less action has no record and no single object to hang off —
// this body reads three, which is why it cannot be given an `objectName`.
const counts: Record<string, number> = {
showcase_account: 15,
showcase_project: 5,
showcase_invoice: 13,
};
const seen: string[] = [];
const ql = {
object: (object: string) => ({
count: async () => {
seen.push(object);
return counts[object] ?? 0;
},
}),
};

const factory = actionBodyRunnerFactory(runner, { ql, appId: 'showcase' });
const handler = factory(PortfolioSnapshotAction as never);
expect(typeof handler).toBe('function');

// No recordId, no record — the object-less invocation shape.
const result = await handler!({ params: {}, user: { id: 'u1' } });

expect(seen).toEqual(['showcase_account', 'showcase_project', 'showcase_invoice']);
expect(result).toEqual({
ok: true,
scope: 'global',
accounts: 15,
projects: 5,
invoices: 13,
});
});
});
Loading