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
110 changes: 104 additions & 6 deletions docs/qa/platform-checklist/areas/records-forms.json

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions docs/qa/platform-checklist/coverage.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"api-backend.packaged-action-disabled-dispatch",
"api-backend.action-activation-door-contract",
"automation.setup-packaged-automation-board",
"platform-core.activation-ledger-registration-home"
"platform-core.activation-ledger-registration-home",
"records-forms.script-action-hook-refusal-toast"
]
},
"agent": {
Expand Down Expand Up @@ -154,7 +155,8 @@
"items": [
"records-forms.object-hook-lifecycle",
"cli.hook-body-extraction-gates",
"access-security.record-view-read-audit"
"access-security.record-view-read-audit",
"records-forms.script-action-hook-refusal-toast"
]
},
"job": {
Expand Down
41 changes: 41 additions & 0 deletions examples/app-showcase/src/data/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,50 @@ export const StampInquiryDefaultsHook = {
description: 'Stamps status=new and source=web on every new inquiry (public web-to-lead defaults).',
};

/**
* The sentence {@link GuardTaskReopenHook} refuses with. Exported so the
* fixture's test quotes the same string the hook throws.
*/
export const TASK_REOPEN_REFUSAL =
'A finished task cannot be reopened. Create a follow-up task instead.';

/**
* beforeUpdate (gated) — the showcase's one hook that REFUSES a write, with a
* sentence addressed to the user.
*
* `throw new Error('<sentence>')` from a sandboxed body is the business-refusal
* shape: the write is aborted (`onError: 'abort'`) and the sentence travels to
* the caller as a 4xx — on `/data`, and through a script action's `ctx.api`
* write on `/actions` too (`packages/runtime/src/sandbox/
* nested-hook-refusal-is-a-rejection.test.ts`). `showcase_reopen_task`
* (`src/ui/actions/index.ts`) is the script action that reaches it, and the
* platform checklist item `records-forms.script-action-hook-refusal-toast`
* drives that action in the console.
*
* The condition is the two-root transition form (same lesson as
* {@link AuditTaskCompletionHook}): only the write that flips `done` from true
* to not-true is refused. An edit of a finished task that leaves `done` alone
* still lands.
*/
export const GuardTaskReopenHook = {
name: 'showcase_guard_task_reopen',
label: 'Guard Task Reopen',
object: 'showcase_task',
events: ['beforeUpdate'] as LifecycleEvent[],
condition: 'previous.done == true && record.done != true',
body: {
language: 'js' as const,
source: `throw new Error(${JSON.stringify(TASK_REOPEN_REFUSAL)});`,
},
priority: 60,
onError: 'abort' as const,
description: 'Refuses reopening a finished task (done true to false) with a user-facing sentence.',
};

export const allHooks = [
NormalizeTaskTitleHook,
StampInquiryDefaultsHook,
GuardTaskReopenHook,
AuditTaskCompletionHook,
WarnOverBudgetHook,
];
3 changes: 3 additions & 0 deletions examples/app-showcase/src/system/translations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,9 @@ export const ShowcaseTranslationBundle = {
label: '重算所选',
successMessage: '已为整个选中集重算工时。',
},
// The hook-refusal specimen: every click is refused by
// showcase_guard_task_reopen, so it carries no successMessage.
showcase_reopen_task: { label: '重新打开' },
},
// Section headings of the six form-view projections in
// `ui/views/task.view.ts` (edit / tabbed / wizard / split / quick).
Expand Down
38 changes: 38 additions & 0 deletions examples/app-showcase/src/ui/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,43 @@ export const MarkDoneAction = defineAction({
refreshAfter: true,
});

/**
* script — the hook-REFUSAL specimen. Reopening a finished task is refused by
* the `showcase_guard_task_reopen` beforeUpdate hook
* (`src/data/hooks/index.ts`), so a click on this action always fails, by
* design: the body's `ctx.api` write is aborted and the hook's sentence is what
* the user must be told. The action route answers it as a 4xx carrying that
* sentence, and the console owes exactly one error toast that contains it.
* Platform checklist item `records-forms.script-action-hook-refusal-toast`
* drives this.
*
* Shown only on finished tasks — the only records the refusal applies to.
*/
export const ReopenTaskAction = defineAction({
name: 'showcase_reopen_task',
label: 'Reopen',
icon: 'rotate-ccw',
objectName: task,
type: 'script',
body: {
language: 'js',
source:
"var id = ctx.recordId || (ctx.record && ctx.record.id) || input.recordId;" +
"if (!id) throw new Error('No record to reopen');" +
"await ctx.api.object('showcase_task').update({ id: id, done: false });" +
"return { ok: true, id: id };",
capabilities: ['api.write'],
},
execution: 'perRecord',
// #8990 — `has()` guards the sparse `list_item` face, as on Mark Done.
visible: 'has(record.done) && record.done == true',
// The task list's row menu only. `record_header` would be inert here: the
// Task Detail page (`showcase_task_detail`, `kind: 'full'`) owns the whole
// record layout and renders no header action bar.
locations: ['list_item'],
refreshAfter: true,
});

/** url — navigate out, from the row overflow menu. */
export const OpenDocsAction = defineAction({
name: 'showcase_open_docs',
Expand Down Expand Up @@ -457,6 +494,7 @@ export const PortfolioSnapshotAction = defineAction({

export const allActions = [
MarkDoneAction,
ReopenTaskAction,
OpenDocsAction,
BulkReassignAction,
QuickViewAction,
Expand Down
183 changes: 183 additions & 0 deletions examples/app-showcase/test/task-reopen-hook-refusal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The showcase's hook-REFUSAL fixture: `showcase_guard_task_reopen` refuses the
* write that reopens a finished task, with a sentence addressed to the user,
* and `showcase_reopen_task` is the script action whose write reaches it.
*
* Platform checklist item `records-forms.script-action-hook-refusal-toast`
* drives this pair in the console (4xx on the action route, exactly one error
* toast carrying the sentence, record unchanged). This file pins the two halves
* the item stands on, so a run that FAILS is about the console and the action
* route, never about a fixture that quietly stopped refusing:
*
* 1. on the real engine with the app's real hooks, the reopening write is
* refused with the sentence and the stored row is unchanged — while an
* edit of the same finished task that leaves `done` alone still lands;
* 2. the action's body writes exactly the reopening shape (`done: false` on
* its own record), so a click reaches the hook's condition.
*
* The action route's own 4xx for a nested sandboxed refusal is pinned in
* `packages/runtime/src/sandbox/nested-hook-refusal-is-a-rejection.test.ts`;
* it is not restated here.
*
* Harness: the production one `hook-body-persisted-writes.test.ts` uses — real
* `ObjectQL`, real `SqlDriver` (better-sqlite3), real `QuickJSScriptRunner`
* behind `hookBodyRunnerFactory`, the app's real objects and hooks.
*/

import { describe, it, expect, afterEach } from 'vitest';
import { ObjectQL } from '@objectstack/objectql';
import { SqlDriver } from '@objectstack/driver-sql';
import {
QuickJSScriptRunner,
actionBodyRunnerFactory,
hookBodyRunnerFactory,
} from '@objectstack/runtime';

import { Account, Project, Task } from '../src/data/objects/index.js';
import { allHooks, TASK_REOPEN_REFUSAL } from '../src/data/hooks/index.js';
import { ReopenTaskAction } from '../src/ui/actions/index.js';

const APP_ID = 'com.objectstack.showcase';
const PACKAGE_ID = `app:${APP_ID}`;

const openEngines: ObjectQL[] = [];
afterEach(async () => {
while (openEngines.length) {
try { await openEngines.pop()?.destroy(); } catch { /* noop */ }
}
});

async function bootShowcase(hooks: unknown[] = allHooks): Promise<ObjectQL> {
const driver = new SqlDriver({
client: 'better-sqlite3',
connection: { filename: ':memory:' },
useNullAsDefault: true,
});
await driver.connect();

const engine = new ObjectQL();
openEngines.push(engine);
engine.registerDriver(driver as never, true);
await engine.init();
for (const def of [Account, Project, Task]) {
engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase');
}
await engine.syncSchemas();
engine.bindHooks(hooks as never[], {
packageId: PACKAGE_ID,
bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: APP_ID }),
});
return engine;
}

const ctx = { context: { userId: 'u_showcase', isSystem: true } };

const readBack = async (engine: ObjectQL, id: string) =>
(await engine.find('showcase_task', { where: { id } }, ctx as never))[0] as any;

/** A finished task on a real project chain: done = true, progress = 100. */
async function finishedTask(engine: ObjectQL): Promise<string> {
const account: any = await engine.insert('showcase_account', { name: 'Initech', status: 'active' }, ctx as never);
const project: any = await engine.insert(
'showcase_project',
{ name: 'Platform', account: String(account.id), status: 'planned' },
ctx as never,
);
const task: any = await engine.insert(
'showcase_task',
{ title: 'Audit current IA', project: String(project.id), status: 'backlog' },
ctx as never,
);
const id = String(task.id);
await engine.update('showcase_task', { id, done: true, progress: 100 }, ctx as never);
expect((await readBack(engine, id)).done).toBeTruthy();
return id;
}

/** Every string an error carries that a client could be shown. */
function textOf(err: any): string {
return [err?.message, err?.innerMessage, err?.cause?.message].filter(Boolean).join(' | ');
}

describe('showcase_guard_task_reopen — the hook refuses reopening a finished task', () => {
it('the reopening write is refused with the sentence, and the stored row is unchanged', async () => {
const engine = await bootShowcase();
const id = await finishedTask(engine);

const err = await engine
.update('showcase_task', { id, done: false }, ctx as never)
.then(() => null, (e: unknown) => e);

expect(err, 'expected the reopening write to be refused, but it resolved').not.toBeNull();
expect(textOf(err)).toContain(TASK_REOPEN_REFUSAL);
// The refusal is a business sentence, not a script fault: no native
// error-class name leads it (`TypeError: …` is the fault shape #7543 keeps
// off the wire).
expect(String((err as any).innerMessage ?? '')).not.toMatch(/^(TypeError|ReferenceError|SyntaxError):/);

const stored = await readBack(engine, id);
expect(stored.done).toBeTruthy();
expect(stored.progress).toBe(100);
}, 30000);

it('an edit of the finished task that leaves `done` alone still lands', async () => {
// The two-root condition: `previous.done == true && record.done != true`.
// A guard collapsed to `previous.done == true` would refuse this too and
// make every finished task read-only.
const engine = await bootShowcase();
const id = await finishedTask(engine);

await engine.update('showcase_task', { id, priority: 'high' }, ctx as never);

const stored = await readBack(engine, id);
expect(stored.priority).toBe('high');
expect(stored.done).toBeTruthy();
}, 30000);

it('REVERSE: with the hooks unbound the same write lands — the refusal is the hook', async () => {
const engine = await bootShowcase([]);
const id = await finishedTask(engine);

await engine.update('showcase_task', { id, done: false }, ctx as never);

expect((await readBack(engine, id)).done).toBeFalsy();
}, 30000);
});

describe('showcase_reopen_task — the script action writes the shape the hook refuses', () => {
it("the body updates its own record with done: false and nothing else", async () => {
let written: { object: string; data: Record<string, unknown> } | undefined;
const ql = {
object: (object: string) => ({
update: async (data: Record<string, unknown>) => {
written = { object, data };
return { id: data.id };
},
}),
};

const handler = actionBodyRunnerFactory(new QuickJSScriptRunner(), { ql, appId: 'showcase' })(
ReopenTaskAction as never,
);
expect(typeof handler).toBe('function');

await handler!({
recordId: 'task_1',
record: { id: 'task_1', done: true, progress: 100 },
params: {},
user: { id: 'u1' },
});

expect(written).toEqual({ object: 'showcase_task', data: { id: 'task_1', done: false } });
});

it('is offered only on finished tasks', () => {
expect(ReopenTaskAction.type).toBe('script');
expect(ReopenTaskAction.execution).toBe('perRecord');
const visible = ReopenTaskAction.visible as unknown;
const source = typeof visible === 'string' ? visible : (visible as { source?: string }).source;
expect(source).toBe('has(record.done) && record.done == true');
});
});
Loading