@nanobpm/urban 0.54.0 → 0.55.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/core/host.d.ts +31 -5
- package/dist/runtime/core/modules/pages.js +72 -5
- package/dist/runtime/engine/nanosdk.d.ts +6 -5
- package/dist/runtime/engine/nanosdk.js +6 -0
- package/dist/runtime/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/runtime/core/host.ts +32 -5
- package/src/runtime/core/modules/actions.test.ts +1 -0
- package/src/runtime/core/modules/agent.test.ts +1 -0
- package/src/runtime/core/modules/api.test.ts +1 -0
- package/src/runtime/core/modules/cancel.test.ts +1 -0
- package/src/runtime/core/modules/connectors.test.ts +1 -0
- package/src/runtime/core/modules/datasource.migrations.test.ts +1 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +1 -0
- package/src/runtime/core/modules/pages.test.ts +41 -0
- package/src/runtime/core/modules/pages.ts +72 -5
- package/src/runtime/core/modules/surfaces.test.ts +2 -0
- package/src/runtime/core/modules/triggers.test.ts +1 -0
- package/src/runtime/core/modules/workers.test.ts +1 -0
- package/src/runtime/core/runtime.extensions.test.ts +3 -0
- package/src/runtime/core/runtime.test.ts +3 -0
- package/src/runtime/engine/nanosdk.test.ts +18 -0
- package/src/runtime/engine/nanosdk.ts +9 -4
- package/src/runtime/index.ts +1 -0
|
@@ -216,6 +216,17 @@ export interface UserTaskSummary {
|
|
|
216
216
|
/** An external form reference (a form linked outside the deployment), if any. */
|
|
217
217
|
readonly externalFormReference?: string;
|
|
218
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* The lifecycle-state-agnostic selectors for a user-task search: which process instance,
|
|
221
|
+
* assignee, and/or candidate group to match. Shared by {@link EngineClient.searchUserTasks}
|
|
222
|
+
* (which adds an optional `state`) and {@link EngineClient.openUserTasks} (which pins
|
|
223
|
+
* `state: "CREATED"` for you), so the two accessors cannot drift on how a task is selected.
|
|
224
|
+
*/
|
|
225
|
+
export interface UserTaskFilter {
|
|
226
|
+
processInstanceKey?: string;
|
|
227
|
+
assignee?: string;
|
|
228
|
+
candidateGroup?: string;
|
|
229
|
+
}
|
|
219
230
|
/**
|
|
220
231
|
* A deployed form's form-js schema plus its identifying metadata, as
|
|
221
232
|
* {@link EngineClient.getForm} returns it. `schema` is the parsed form-js document
|
|
@@ -267,13 +278,28 @@ export interface EngineClient {
|
|
|
267
278
|
* By default this is unfiltered by lifecycle state and may return tasks in any state
|
|
268
279
|
* (e.g. completed/canceled); pass `state: "CREATED"` to constrain the search to open
|
|
269
280
|
* (answerable) tasks. Each result carries the task's resolved form linkage
|
|
270
|
-
* (`formKey`/`externalFormReference`) when present.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
281
|
+
* (`formKey`/`externalFormReference`) when present.
|
|
282
|
+
*
|
|
283
|
+
* **Footgun:** because this defaults to *any* lifecycle state and lags a completion,
|
|
284
|
+
* a just-answered task is still returned for a moment — so treating mere *existence*
|
|
285
|
+
* of a result as "open / actionable" projects a completed task as if a human could
|
|
286
|
+
* still act on it (a torn read-model row). {@link UserTaskSummary} deliberately omits
|
|
287
|
+
* `state`, so a caller cannot re-filter results defensively. If you are deciding
|
|
288
|
+
* whether a human can act — any reconcile / affordance path — use
|
|
289
|
+
* {@link EngineClient.openUserTasks} instead; reserve `searchUserTasks` for the rare
|
|
290
|
+
* legitimate "any lifecycle state" audit/read case. */
|
|
291
|
+
searchUserTasks(filter?: UserTaskFilter & {
|
|
275
292
|
state?: UserTaskState;
|
|
276
293
|
}): Promise<UserTaskSummary[]>;
|
|
294
|
+
/** The user tasks that are actually *open* (answerable) — semantically the accessor to
|
|
295
|
+
* reach for whenever you are deciding whether a human can act (reconcile / affordance
|
|
296
|
+
* paths). A thin, intent-named wrapper that pins the lifecycle-state invariant the SDK
|
|
297
|
+
* documents: exactly `searchUserTasks({ ...filter, state: "CREATED" })`, since `CREATED`
|
|
298
|
+
* is the only open state ({@link UserTaskState}). Unlike a bare `searchUserTasks`, it
|
|
299
|
+
* cannot surface a completed/canceled task as if it were still actionable — the footgun
|
|
300
|
+
* described on `searchUserTasks`. Same selectors as `searchUserTasks` minus `state`
|
|
301
|
+
* (pinned to `CREATED`). */
|
|
302
|
+
openUserTasks(filter?: UserTaskFilter): Promise<UserTaskSummary[]>;
|
|
277
303
|
/**
|
|
278
304
|
* Fetch a deployed form's form-js schema for the `taskInbox` surface. Resolve by
|
|
279
305
|
* `formKey` (the linkage the engine attaches to a user task) or, as a best-effort
|
|
@@ -1015,7 +1015,9 @@ function resolveCopyText(text) {
|
|
|
1015
1015
|
function openModal(m) {
|
|
1016
1016
|
// Only one modal at a time — drop a second (e.g. double-click) open so we
|
|
1017
1017
|
// never stack overlays or register duplicate document-level keydown handlers.
|
|
1018
|
-
|
|
1018
|
+
// Return null (not a close fn) so a guarded caller (confirmModal) can tell the
|
|
1019
|
+
// open was dropped and resolve rather than hang.
|
|
1020
|
+
if (modalOpen) return null;
|
|
1019
1021
|
modalOpen = true;
|
|
1020
1022
|
const overlay = el("div", { class: "pc-modal-overlay" });
|
|
1021
1023
|
// role="dialog" + aria-modal need an accessible name: label by the visible
|
|
@@ -1034,6 +1036,13 @@ function openModal(m) {
|
|
|
1034
1036
|
// Remember what had focus so we can restore it on close — a keyboard/screen
|
|
1035
1037
|
// reader user returns to where they were instead of being dumped at <body>.
|
|
1036
1038
|
const prevFocus = document.activeElement;
|
|
1039
|
+
// A dismissal (✕/Close button, backdrop, Escape, page switch) resolves with
|
|
1040
|
+
// m.dismissValue; an action button overrides it before closing. onResult fires
|
|
1041
|
+
// exactly once, from close(), so every exit path (button OR dismiss) reports a
|
|
1042
|
+
// result — this is what lets confirmModal() gate on a real user choice inside a
|
|
1043
|
+
// sandboxed iframe where native confirm() would silently return false (#276).
|
|
1044
|
+
const onResult = typeof m.onResult === "function" ? m.onResult : null;
|
|
1045
|
+
let resultValue = m.dismissValue;
|
|
1037
1046
|
let closed = false;
|
|
1038
1047
|
function close() {
|
|
1039
1048
|
if (closed) return;
|
|
@@ -1046,6 +1055,9 @@ function openModal(m) {
|
|
|
1046
1055
|
// accrete dead close() closures until the next page navigation runs teardown.
|
|
1047
1056
|
const i = disposers.indexOf(close);
|
|
1048
1057
|
if (i !== -1) disposers.splice(i, 1);
|
|
1058
|
+
// A consumer-provided result callback must never abort teardown: if it throws,
|
|
1059
|
+
// swallow it so focus restoration below (and Escape/backdrop close) still runs.
|
|
1060
|
+
if (onResult) { try { onResult(resultValue); } catch (_e) {} }
|
|
1049
1061
|
if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
|
|
1050
1062
|
}
|
|
1051
1063
|
// Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
|
|
@@ -1078,7 +1090,29 @@ function openModal(m) {
|
|
|
1078
1090
|
}
|
|
1079
1091
|
const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
|
|
1080
1092
|
closeBtn.addEventListener("click", close);
|
|
1081
|
-
actions
|
|
1093
|
+
// Custom action buttons (m.actions) turn this into a decision dialog — each
|
|
1094
|
+
// sets the result then closes; the plain Close button is the default when none
|
|
1095
|
+
// are given. This is the in-DOM replacement for native confirm()/alert(): one
|
|
1096
|
+
// overlay + focus-trap + teardown machinery, no drift (AGENTS.md).
|
|
1097
|
+
let focusBtn = closeBtn;
|
|
1098
|
+
if (Array.isArray(m.actions) && m.actions.length) {
|
|
1099
|
+
let firstActionBtn = null;
|
|
1100
|
+
let defaultBtn = null;
|
|
1101
|
+
for (const a of m.actions) {
|
|
1102
|
+
const cls = "pc-btn pc-btn-sm" + (a.variant === "ghost" ? " pc-btn-ghost" : "");
|
|
1103
|
+
const ab = el("button", { class: cls, type: "button" }, a.label);
|
|
1104
|
+
ab.addEventListener("click", () => { resultValue = a.value; close(); });
|
|
1105
|
+
actions.append(ab);
|
|
1106
|
+
if (!firstActionBtn) firstActionBtn = ab;
|
|
1107
|
+
if (a.default) defaultBtn = ab;
|
|
1108
|
+
}
|
|
1109
|
+
// Default focus lands on the FIRST action (typically Cancel), never the last:
|
|
1110
|
+
// for confirmModal that keeps a stray Enter from accidentally triggering the
|
|
1111
|
+
// destructive Confirm. An action can opt into autofocus with default:true.
|
|
1112
|
+
focusBtn = defaultBtn || firstActionBtn;
|
|
1113
|
+
} else {
|
|
1114
|
+
actions.append(closeBtn);
|
|
1115
|
+
}
|
|
1082
1116
|
dialog.append(actions);
|
|
1083
1117
|
overlay.append(dialog);
|
|
1084
1118
|
// A click on the backdrop (never the dialog itself) dismisses.
|
|
@@ -1088,7 +1122,36 @@ function openModal(m) {
|
|
|
1088
1122
|
// A page switch runs teardown() before the next render — dispose the modal
|
|
1089
1123
|
// there too so navigating away never leaves a stale overlay + keydown listener.
|
|
1090
1124
|
disposers.push(close);
|
|
1091
|
-
|
|
1125
|
+
focusBtn.focus();
|
|
1126
|
+
return close;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// In-DOM confirm — an accessible replacement for native confirm() that works in
|
|
1130
|
+
// a sandboxed iframe (without allow-modals native confirm() silently returns
|
|
1131
|
+
// false, dead-ending every confirm-guarded action; #276). Renders the message
|
|
1132
|
+
// with Cancel/Confirm and resolves the user's choice; a dismissal (Escape /
|
|
1133
|
+
// backdrop / ✕) resolves false. If a modal is already open the guarded open is
|
|
1134
|
+
// dropped — resolve false so the caller doesn't hang.
|
|
1135
|
+
function confirmModal(message) {
|
|
1136
|
+
return new Promise((resolve) => {
|
|
1137
|
+
const opened = openModal({
|
|
1138
|
+
title: "Confirm",
|
|
1139
|
+
description: String(message),
|
|
1140
|
+
dismissValue: false,
|
|
1141
|
+
actions: [
|
|
1142
|
+
{ label: "Cancel", value: false, variant: "ghost" },
|
|
1143
|
+
{ label: "Confirm", value: true },
|
|
1144
|
+
],
|
|
1145
|
+
onResult: (v) => resolve(!!v),
|
|
1146
|
+
});
|
|
1147
|
+
if (!opened) resolve(false);
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// In-DOM error surface — replaces native alert(), which a sandboxed iframe also
|
|
1152
|
+
// suppresses, so action failures reported through it were invisible (#276).
|
|
1153
|
+
function alertModal(message) {
|
|
1154
|
+
openModal({ title: "Error", description: String(message) });
|
|
1092
1155
|
}
|
|
1093
1156
|
|
|
1094
1157
|
// A standalone button node. Clicking it opens props.modal — used e.g. to surface
|
|
@@ -2032,14 +2095,18 @@ function renderDataGrid(node) {
|
|
|
2032
2095
|
const b = el("button", { class: "pc-btn pc-btn-sm" }, ra.label);
|
|
2033
2096
|
b.addEventListener("click", async (ev) => {
|
|
2034
2097
|
ev.stopPropagation();
|
|
2035
|
-
|
|
2098
|
+
// In-DOM confirm/error (openModal) instead of native confirm()/alert():
|
|
2099
|
+
// a sandboxed iframe without allow-modals makes native confirm() return
|
|
2100
|
+
// false and alert() a no-op, so every confirm-guarded action silently
|
|
2101
|
+
// dead-ended and its errors vanished (#276).
|
|
2102
|
+
if (ra.confirm && !(await confirmModal(ra.confirm))) return;
|
|
2036
2103
|
b.disabled = true;
|
|
2037
2104
|
try {
|
|
2038
2105
|
await fireAction(ra.action, row);
|
|
2039
2106
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
2040
2107
|
} catch (e) {
|
|
2041
2108
|
b.disabled = false;
|
|
2042
|
-
|
|
2109
|
+
alertModal(String(e.message || e));
|
|
2043
2110
|
}
|
|
2044
2111
|
});
|
|
2045
2112
|
return b;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EngineClient, FormSchema, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, WorkerSubscription } from "../core/host.ts";
|
|
1
|
+
import type { EngineClient, FormSchema, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, UserTaskState, UserTaskSummary, UserTaskFilter, WorkerSubscription } from "../core/host.ts";
|
|
2
2
|
import type { EngineSdkClient } from "./sdk.ts";
|
|
3
3
|
/** Coerce an engine response's process-instance key to a non-empty string, or
|
|
4
4
|
* throw — a missing key means a malformed/partial response, not a real instance. */
|
|
@@ -136,12 +136,13 @@ export declare class SdkEngineClient implements EngineClient {
|
|
|
136
136
|
correlationKey?: string;
|
|
137
137
|
variables?: Record<string, unknown>;
|
|
138
138
|
}): Promise<void>;
|
|
139
|
-
searchUserTasks(filter?: {
|
|
140
|
-
processInstanceKey?: string;
|
|
141
|
-
assignee?: string;
|
|
142
|
-
candidateGroup?: string;
|
|
139
|
+
searchUserTasks(filter?: UserTaskFilter & {
|
|
143
140
|
state?: UserTaskState;
|
|
144
141
|
}): Promise<UserTaskSummary[]>;
|
|
142
|
+
/** The open (answerable) user tasks — `searchUserTasks` pinned to `state: "CREATED"`.
|
|
143
|
+
* The single safe accessor for reconcile/affordance paths (see `EngineClient.openUserTasks`);
|
|
144
|
+
* derives from `searchUserTasks` so the two cannot drift. */
|
|
145
|
+
openUserTasks(filter?: UserTaskFilter): Promise<UserTaskSummary[]>;
|
|
145
146
|
getForm(input: {
|
|
146
147
|
formKey?: string;
|
|
147
148
|
formId?: string;
|
|
@@ -127,6 +127,12 @@ export class SdkEngineClient {
|
|
|
127
127
|
}];
|
|
128
128
|
});
|
|
129
129
|
}
|
|
130
|
+
/** The open (answerable) user tasks — `searchUserTasks` pinned to `state: "CREATED"`.
|
|
131
|
+
* The single safe accessor for reconcile/affordance paths (see `EngineClient.openUserTasks`);
|
|
132
|
+
* derives from `searchUserTasks` so the two cannot drift. */
|
|
133
|
+
openUserTasks(filter) {
|
|
134
|
+
return this.searchUserTasks({ ...filter, state: "CREATED" });
|
|
135
|
+
}
|
|
130
136
|
async getForm(input) {
|
|
131
137
|
// The engine addresses deployed forms by key (`GET /forms/{formKey}`). A user task's
|
|
132
138
|
// `formKey` is the engine's resolution of its `formId` linkage to the latest deployed
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export { bindModeToHost, expandEnv, expandEnvString, isBindMode, loadManifest, p
|
|
|
8
8
|
export type { ActionDecl, AppManifest, BindMode, ChatSurface, DataSource, DomainField, DomainType, LlmBinding, NetworkConfig, PagesSurface, Surfaces, TaskInboxSurface, Trigger, Worker, } from "./core/manifest.ts";
|
|
9
9
|
export { collectManifestIssues, ManifestValidationError, validateManifest, } from "./core/validate.ts";
|
|
10
10
|
export type { ValidationIssue } from "./core/validate.ts";
|
|
11
|
-
export type { EngineClient, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, WatchHandle, WorkerSubscription, } from "./core/host.ts";
|
|
11
|
+
export type { EngineClient, EngineJob, FormSchema, HostContext, HttpHandler, HttpRequest, HttpResponse, HttpServer, JobHandler, ProcessInstanceSnapshot, ProcessInstanceState, SqliteDb, UserTaskState, UserTaskSummary, UserTaskFilter, WatchHandle, WorkerSubscription, } from "./core/host.ts";
|
|
12
12
|
export { buildFormSchema, parseFormSchema, pickFormLinkage, presentFormIdentifier, resolveFormIdentifier, } from "./core/form-contract.ts";
|
|
13
13
|
export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
|
|
14
14
|
export { createLogger } from "./core/logger.ts";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
package/src/runtime/core/host.ts
CHANGED
|
@@ -249,6 +249,18 @@ export interface UserTaskSummary {
|
|
|
249
249
|
readonly externalFormReference?: string;
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* The lifecycle-state-agnostic selectors for a user-task search: which process instance,
|
|
254
|
+
* assignee, and/or candidate group to match. Shared by {@link EngineClient.searchUserTasks}
|
|
255
|
+
* (which adds an optional `state`) and {@link EngineClient.openUserTasks} (which pins
|
|
256
|
+
* `state: "CREATED"` for you), so the two accessors cannot drift on how a task is selected.
|
|
257
|
+
*/
|
|
258
|
+
export interface UserTaskFilter {
|
|
259
|
+
processInstanceKey?: string;
|
|
260
|
+
assignee?: string;
|
|
261
|
+
candidateGroup?: string;
|
|
262
|
+
}
|
|
263
|
+
|
|
252
264
|
/**
|
|
253
265
|
* A deployed form's form-js schema plus its identifying metadata, as
|
|
254
266
|
* {@link EngineClient.getForm} returns it. `schema` is the parsed form-js document
|
|
@@ -292,13 +304,28 @@ export interface EngineClient {
|
|
|
292
304
|
* By default this is unfiltered by lifecycle state and may return tasks in any state
|
|
293
305
|
* (e.g. completed/canceled); pass `state: "CREATED"` to constrain the search to open
|
|
294
306
|
* (answerable) tasks. Each result carries the task's resolved form linkage
|
|
295
|
-
* (`formKey`/`externalFormReference`) when present.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
307
|
+
* (`formKey`/`externalFormReference`) when present.
|
|
308
|
+
*
|
|
309
|
+
* **Footgun:** because this defaults to *any* lifecycle state and lags a completion,
|
|
310
|
+
* a just-answered task is still returned for a moment — so treating mere *existence*
|
|
311
|
+
* of a result as "open / actionable" projects a completed task as if a human could
|
|
312
|
+
* still act on it (a torn read-model row). {@link UserTaskSummary} deliberately omits
|
|
313
|
+
* `state`, so a caller cannot re-filter results defensively. If you are deciding
|
|
314
|
+
* whether a human can act — any reconcile / affordance path — use
|
|
315
|
+
* {@link EngineClient.openUserTasks} instead; reserve `searchUserTasks` for the rare
|
|
316
|
+
* legitimate "any lifecycle state" audit/read case. */
|
|
317
|
+
searchUserTasks(filter?: UserTaskFilter & {
|
|
300
318
|
state?: UserTaskState;
|
|
301
319
|
}): Promise<UserTaskSummary[]>;
|
|
320
|
+
/** The user tasks that are actually *open* (answerable) — semantically the accessor to
|
|
321
|
+
* reach for whenever you are deciding whether a human can act (reconcile / affordance
|
|
322
|
+
* paths). A thin, intent-named wrapper that pins the lifecycle-state invariant the SDK
|
|
323
|
+
* documents: exactly `searchUserTasks({ ...filter, state: "CREATED" })`, since `CREATED`
|
|
324
|
+
* is the only open state ({@link UserTaskState}). Unlike a bare `searchUserTasks`, it
|
|
325
|
+
* cannot surface a completed/canceled task as if it were still actionable — the footgun
|
|
326
|
+
* described on `searchUserTasks`. Same selectors as `searchUserTasks` minus `state`
|
|
327
|
+
* (pinned to `CREATED`). */
|
|
328
|
+
openUserTasks(filter?: UserTaskFilter): Promise<UserTaskSummary[]>;
|
|
302
329
|
/**
|
|
303
330
|
* Fetch a deployed form's form-js schema for the `taskInbox` surface. Resolve by
|
|
304
331
|
* `formKey` (the linkage the engine attaches to a user task) or, as a best-effort
|
|
@@ -29,6 +29,7 @@ const engine: EngineClient = {
|
|
|
29
29
|
cancelInstance: async () => {},
|
|
30
30
|
publishMessage: async () => {},
|
|
31
31
|
searchUserTasks: async () => [],
|
|
32
|
+
openUserTasks: async () => [],
|
|
32
33
|
getForm: async () => null,
|
|
33
34
|
completeUserTask: async () => {},
|
|
34
35
|
searchProcessInstances: async () => [],
|
|
@@ -13,6 +13,7 @@ const fakeEngine: EngineClient = {
|
|
|
13
13
|
cancelInstance: async () => {},
|
|
14
14
|
publishMessage: async () => {},
|
|
15
15
|
searchUserTasks: async () => [],
|
|
16
|
+
openUserTasks: async () => [],
|
|
16
17
|
getForm: async () => null,
|
|
17
18
|
completeUserTask: async () => {},
|
|
18
19
|
searchProcessInstances: async () => [],
|
|
@@ -28,6 +28,7 @@ const engine: EngineClient = {
|
|
|
28
28
|
cancelInstance: async () => {},
|
|
29
29
|
publishMessage: async () => {},
|
|
30
30
|
searchUserTasks: async () => [],
|
|
31
|
+
openUserTasks: async () => [],
|
|
31
32
|
getForm: async () => null,
|
|
32
33
|
searchProcessInstances: async () => [],
|
|
33
34
|
completeUserTask: async () => {},
|
|
@@ -73,6 +73,7 @@ function cancelEngine(opts: {
|
|
|
73
73
|
createInstance: notUsed("createInstance"),
|
|
74
74
|
publishMessage: notUsed("publishMessage"),
|
|
75
75
|
searchUserTasks: notUsed("searchUserTasks"),
|
|
76
|
+
openUserTasks: notUsed("openUserTasks"),
|
|
76
77
|
getForm: notUsed("getForm"),
|
|
77
78
|
completeUserTask: notUsed("completeUserTask"),
|
|
78
79
|
registerWorker: notUsed("registerWorker"),
|
|
@@ -17,6 +17,7 @@ class MiniEngine implements EngineClient {
|
|
|
17
17
|
async cancelInstance(): Promise<void> {}
|
|
18
18
|
async publishMessage(): Promise<void> {}
|
|
19
19
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
20
|
+
async openUserTasks(): Promise<[]> { return []; }
|
|
20
21
|
async getForm(): Promise<null> { return null; }
|
|
21
22
|
async completeUserTask(): Promise<void> {}
|
|
22
23
|
async searchProcessInstances(): Promise<[]> { return []; }
|
|
@@ -16,6 +16,7 @@ const noEngine: EngineClient = {
|
|
|
16
16
|
cancelInstance: async () => {},
|
|
17
17
|
publishMessage: async () => {},
|
|
18
18
|
searchUserTasks: async () => [],
|
|
19
|
+
openUserTasks: async () => [],
|
|
19
20
|
getForm: async () => null,
|
|
20
21
|
completeUserTask: async () => {},
|
|
21
22
|
searchProcessInstances: async () => [],
|
|
@@ -96,6 +96,7 @@ function fakeEngine(states: Record<string, ProcessInstanceSnapshot["state"]>): {
|
|
|
96
96
|
cancelInstance: notUsed("cancelInstance"),
|
|
97
97
|
publishMessage: notUsed("publishMessage"),
|
|
98
98
|
searchUserTasks: notUsed("searchUserTasks"),
|
|
99
|
+
openUserTasks: notUsed("openUserTasks"),
|
|
99
100
|
getForm: notUsed("getForm"),
|
|
100
101
|
completeUserTask: notUsed("completeUserTask"),
|
|
101
102
|
registerWorker: notUsed("registerWorker"),
|
|
@@ -40,6 +40,7 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
|
|
|
40
40
|
calls.messages.push(input);
|
|
41
41
|
},
|
|
42
42
|
async searchUserTasks() { return []; },
|
|
43
|
+
async openUserTasks() { return []; },
|
|
43
44
|
async getForm() { return null; },
|
|
44
45
|
async completeUserTask() {},
|
|
45
46
|
async searchProcessInstances() { return []; },
|
|
@@ -891,6 +892,46 @@ test("the renderer ships a button node that opens a copy-pasteable modal", async
|
|
|
891
892
|
});
|
|
892
893
|
|
|
893
894
|
|
|
895
|
+
test("row-action confirm/error route through the in-DOM modal, never native confirm()/alert() (#276)", async () => {
|
|
896
|
+
// A sandboxed iframe without `allow-modals` makes window.confirm() return false
|
|
897
|
+
// WITHOUT prompting and turns alert()/prompt() into no-ops — so every
|
|
898
|
+
// confirm-guarded row action silently dead-ended (the !confirm(...) guard was
|
|
899
|
+
// always true → fireAction never ran) and its errors vanished. The fix routes
|
|
900
|
+
// both through urban's in-DOM openModal overlay, which works with no host
|
|
901
|
+
// sandbox change.
|
|
902
|
+
const res = await dispatch("GET", "/app/runtime.js");
|
|
903
|
+
const js = res.body ?? "";
|
|
904
|
+
// The confirm gate awaits an in-DOM dialog and only proceeds on a real choice.
|
|
905
|
+
assert.match(js, /if \(ra\.confirm && !\(await confirmModal\(ra\.confirm\)\)\) return/);
|
|
906
|
+
// Errors surface through the in-DOM alert instead of the suppressed native one.
|
|
907
|
+
assert.match(js, /alertModal\(String\(e\.message \|\| e\)\)/);
|
|
908
|
+
// The primitives exist and are built on the shared openModal overlay (no drift).
|
|
909
|
+
assert.match(js, /function confirmModal\(message\)/);
|
|
910
|
+
assert.match(js, /return new Promise\(\(resolve\) => \{/);
|
|
911
|
+
assert.match(js, /function alertModal\(message\)/);
|
|
912
|
+
// A dropped guarded open (a modal already showing) resolves false, never hangs.
|
|
913
|
+
assert.match(js, /if \(!opened\) resolve\(false\)/);
|
|
914
|
+
// openModal grew action buttons + a single-fire result callback so it can back a
|
|
915
|
+
// decision dialog; a dismissal (Escape/backdrop/✕/page switch) resolves the
|
|
916
|
+
// caller's default (dismissValue).
|
|
917
|
+
assert.match(js, /Array\.isArray\(m\.actions\)/);
|
|
918
|
+
assert.match(js, /resultValue = a\.value; close\(\);/);
|
|
919
|
+
// Match the semantic call site only — teardown wraps onResult in a try/catch so
|
|
920
|
+
// a throwing callback can't abort focus restoration, so don't pin the control flow.
|
|
921
|
+
assert.match(js, /onResult\(resultValue\)/);
|
|
922
|
+
// Defect-class guard: NO native confirm()/alert()/prompt() CALL (invoked with a
|
|
923
|
+
// string/identifier/template argument) survives anywhere in the served bundle —
|
|
924
|
+
// they are all false/no-ops under a sandboxed iframe. This also catches qualified
|
|
925
|
+
// calls (window.confirm(...) / globalThis.alert(...)): the lookbehind only excludes
|
|
926
|
+
// a preceding word char (so confirmModal(...) / alertModal(...) don't match), not a
|
|
927
|
+
// preceding ".", so a member call still trips the guard. The argument-start class
|
|
928
|
+
// covers string/template literals and identifier heads including $ and _
|
|
929
|
+
// (e.g. confirm(_msg), alert($msg), prompt(`...`)). Empty-paren mentions in
|
|
930
|
+
// comments (e.g. "native confirm()") are documentation, not calls, so are not matched.
|
|
931
|
+
assert.doesNotMatch(js, /(?<!\w)(?:confirm|alert|prompt)\(\s*["'`$_a-zA-Z]/);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
|
|
894
935
|
test("the renderer ships a data-bound prose/markdown list node (#274)", async () => {
|
|
895
936
|
const res = await dispatch("GET", "/app/runtime.js");
|
|
896
937
|
const js = res.body ?? "";
|
|
@@ -1068,7 +1068,9 @@ function resolveCopyText(text) {
|
|
|
1068
1068
|
function openModal(m) {
|
|
1069
1069
|
// Only one modal at a time — drop a second (e.g. double-click) open so we
|
|
1070
1070
|
// never stack overlays or register duplicate document-level keydown handlers.
|
|
1071
|
-
|
|
1071
|
+
// Return null (not a close fn) so a guarded caller (confirmModal) can tell the
|
|
1072
|
+
// open was dropped and resolve rather than hang.
|
|
1073
|
+
if (modalOpen) return null;
|
|
1072
1074
|
modalOpen = true;
|
|
1073
1075
|
const overlay = el("div", { class: "pc-modal-overlay" });
|
|
1074
1076
|
// role="dialog" + aria-modal need an accessible name: label by the visible
|
|
@@ -1087,6 +1089,13 @@ function openModal(m) {
|
|
|
1087
1089
|
// Remember what had focus so we can restore it on close — a keyboard/screen
|
|
1088
1090
|
// reader user returns to where they were instead of being dumped at <body>.
|
|
1089
1091
|
const prevFocus = document.activeElement;
|
|
1092
|
+
// A dismissal (✕/Close button, backdrop, Escape, page switch) resolves with
|
|
1093
|
+
// m.dismissValue; an action button overrides it before closing. onResult fires
|
|
1094
|
+
// exactly once, from close(), so every exit path (button OR dismiss) reports a
|
|
1095
|
+
// result — this is what lets confirmModal() gate on a real user choice inside a
|
|
1096
|
+
// sandboxed iframe where native confirm() would silently return false (#276).
|
|
1097
|
+
const onResult = typeof m.onResult === "function" ? m.onResult : null;
|
|
1098
|
+
let resultValue = m.dismissValue;
|
|
1090
1099
|
let closed = false;
|
|
1091
1100
|
function close() {
|
|
1092
1101
|
if (closed) return;
|
|
@@ -1099,6 +1108,9 @@ function openModal(m) {
|
|
|
1099
1108
|
// accrete dead close() closures until the next page navigation runs teardown.
|
|
1100
1109
|
const i = disposers.indexOf(close);
|
|
1101
1110
|
if (i !== -1) disposers.splice(i, 1);
|
|
1111
|
+
// A consumer-provided result callback must never abort teardown: if it throws,
|
|
1112
|
+
// swallow it so focus restoration below (and Escape/backdrop close) still runs.
|
|
1113
|
+
if (onResult) { try { onResult(resultValue); } catch (_e) {} }
|
|
1102
1114
|
if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
|
|
1103
1115
|
}
|
|
1104
1116
|
// Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
|
|
@@ -1131,7 +1143,29 @@ function openModal(m) {
|
|
|
1131
1143
|
}
|
|
1132
1144
|
const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
|
|
1133
1145
|
closeBtn.addEventListener("click", close);
|
|
1134
|
-
actions
|
|
1146
|
+
// Custom action buttons (m.actions) turn this into a decision dialog — each
|
|
1147
|
+
// sets the result then closes; the plain Close button is the default when none
|
|
1148
|
+
// are given. This is the in-DOM replacement for native confirm()/alert(): one
|
|
1149
|
+
// overlay + focus-trap + teardown machinery, no drift (AGENTS.md).
|
|
1150
|
+
let focusBtn = closeBtn;
|
|
1151
|
+
if (Array.isArray(m.actions) && m.actions.length) {
|
|
1152
|
+
let firstActionBtn = null;
|
|
1153
|
+
let defaultBtn = null;
|
|
1154
|
+
for (const a of m.actions) {
|
|
1155
|
+
const cls = "pc-btn pc-btn-sm" + (a.variant === "ghost" ? " pc-btn-ghost" : "");
|
|
1156
|
+
const ab = el("button", { class: cls, type: "button" }, a.label);
|
|
1157
|
+
ab.addEventListener("click", () => { resultValue = a.value; close(); });
|
|
1158
|
+
actions.append(ab);
|
|
1159
|
+
if (!firstActionBtn) firstActionBtn = ab;
|
|
1160
|
+
if (a.default) defaultBtn = ab;
|
|
1161
|
+
}
|
|
1162
|
+
// Default focus lands on the FIRST action (typically Cancel), never the last:
|
|
1163
|
+
// for confirmModal that keeps a stray Enter from accidentally triggering the
|
|
1164
|
+
// destructive Confirm. An action can opt into autofocus with default:true.
|
|
1165
|
+
focusBtn = defaultBtn || firstActionBtn;
|
|
1166
|
+
} else {
|
|
1167
|
+
actions.append(closeBtn);
|
|
1168
|
+
}
|
|
1135
1169
|
dialog.append(actions);
|
|
1136
1170
|
overlay.append(dialog);
|
|
1137
1171
|
// A click on the backdrop (never the dialog itself) dismisses.
|
|
@@ -1141,7 +1175,36 @@ function openModal(m) {
|
|
|
1141
1175
|
// A page switch runs teardown() before the next render — dispose the modal
|
|
1142
1176
|
// there too so navigating away never leaves a stale overlay + keydown listener.
|
|
1143
1177
|
disposers.push(close);
|
|
1144
|
-
|
|
1178
|
+
focusBtn.focus();
|
|
1179
|
+
return close;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// In-DOM confirm — an accessible replacement for native confirm() that works in
|
|
1183
|
+
// a sandboxed iframe (without allow-modals native confirm() silently returns
|
|
1184
|
+
// false, dead-ending every confirm-guarded action; #276). Renders the message
|
|
1185
|
+
// with Cancel/Confirm and resolves the user's choice; a dismissal (Escape /
|
|
1186
|
+
// backdrop / ✕) resolves false. If a modal is already open the guarded open is
|
|
1187
|
+
// dropped — resolve false so the caller doesn't hang.
|
|
1188
|
+
function confirmModal(message) {
|
|
1189
|
+
return new Promise((resolve) => {
|
|
1190
|
+
const opened = openModal({
|
|
1191
|
+
title: "Confirm",
|
|
1192
|
+
description: String(message),
|
|
1193
|
+
dismissValue: false,
|
|
1194
|
+
actions: [
|
|
1195
|
+
{ label: "Cancel", value: false, variant: "ghost" },
|
|
1196
|
+
{ label: "Confirm", value: true },
|
|
1197
|
+
],
|
|
1198
|
+
onResult: (v) => resolve(!!v),
|
|
1199
|
+
});
|
|
1200
|
+
if (!opened) resolve(false);
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// In-DOM error surface — replaces native alert(), which a sandboxed iframe also
|
|
1205
|
+
// suppresses, so action failures reported through it were invisible (#276).
|
|
1206
|
+
function alertModal(message) {
|
|
1207
|
+
openModal({ title: "Error", description: String(message) });
|
|
1145
1208
|
}
|
|
1146
1209
|
|
|
1147
1210
|
// A standalone button node. Clicking it opens props.modal — used e.g. to surface
|
|
@@ -2085,14 +2148,18 @@ function renderDataGrid(node) {
|
|
|
2085
2148
|
const b = el("button", { class: "pc-btn pc-btn-sm" }, ra.label);
|
|
2086
2149
|
b.addEventListener("click", async (ev) => {
|
|
2087
2150
|
ev.stopPropagation();
|
|
2088
|
-
|
|
2151
|
+
// In-DOM confirm/error (openModal) instead of native confirm()/alert():
|
|
2152
|
+
// a sandboxed iframe without allow-modals makes native confirm() return
|
|
2153
|
+
// false and alert() a no-op, so every confirm-guarded action silently
|
|
2154
|
+
// dead-ended and its errors vanished (#276).
|
|
2155
|
+
if (ra.confirm && !(await confirmModal(ra.confirm))) return;
|
|
2089
2156
|
b.disabled = true;
|
|
2090
2157
|
try {
|
|
2091
2158
|
await fireAction(ra.action, row);
|
|
2092
2159
|
document.dispatchEvent(new CustomEvent("pc:refresh"));
|
|
2093
2160
|
} catch (e) {
|
|
2094
2161
|
b.disabled = false;
|
|
2095
|
-
|
|
2162
|
+
alertModal(String(e.message || e));
|
|
2096
2163
|
}
|
|
2097
2164
|
});
|
|
2098
2165
|
return b;
|
|
@@ -29,6 +29,7 @@ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurf
|
|
|
29
29
|
cancelInstance: async () => {},
|
|
30
30
|
publishMessage: async () => {},
|
|
31
31
|
searchUserTasks: async () => [],
|
|
32
|
+
openUserTasks: async () => [],
|
|
32
33
|
getForm: async () => null,
|
|
33
34
|
completeUserTask: async () => {},
|
|
34
35
|
searchProcessInstances: async () => [],
|
|
@@ -44,6 +45,7 @@ const fakeEngine: EngineClient = {
|
|
|
44
45
|
cancelInstance: async () => {},
|
|
45
46
|
publishMessage: async () => {},
|
|
46
47
|
searchUserTasks: async () => [],
|
|
48
|
+
openUserTasks: async () => [],
|
|
47
49
|
getForm: async () => null,
|
|
48
50
|
completeUserTask: async () => {},
|
|
49
51
|
searchProcessInstances: async () => [],
|
|
@@ -30,6 +30,7 @@ class MiniEngine implements EngineClient {
|
|
|
30
30
|
this.calls.push({ kind: "message", target: input.name, correlationKey: input.correlationKey, variables: input.variables });
|
|
31
31
|
}
|
|
32
32
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
33
|
+
async openUserTasks(): Promise<[]> { return []; }
|
|
33
34
|
async getForm(): Promise<null> { return null; }
|
|
34
35
|
async completeUserTask(): Promise<void> {}
|
|
35
36
|
async searchProcessInstances(): Promise<[]> { return []; }
|
|
@@ -27,6 +27,7 @@ class MiniEngine implements EngineClient {
|
|
|
27
27
|
async cancelInstance(): Promise<void> {}
|
|
28
28
|
async publishMessage(): Promise<void> {}
|
|
29
29
|
async searchUserTasks(): Promise<[]> { return []; }
|
|
30
|
+
async openUserTasks(): Promise<[]> { return []; }
|
|
30
31
|
async getForm(): Promise<null> { return null; }
|
|
31
32
|
async completeUserTask(): Promise<void> {}
|
|
32
33
|
async searchProcessInstances(): Promise<[]> { return []; }
|
|
@@ -231,6 +231,24 @@ test("searchUserTasks forwards a caller-provided lifecycle state filter", async
|
|
|
231
231
|
assert.equal(input?.filter?.processInstanceKey, "pi");
|
|
232
232
|
});
|
|
233
233
|
|
|
234
|
+
test("openUserTasks pins state=CREATED onto the underlying search", async () => {
|
|
235
|
+
let input: { filter?: Record<string, unknown> } | undefined;
|
|
236
|
+
const client = fakeSdkClient({
|
|
237
|
+
searchUserTasks: async (i) => {
|
|
238
|
+
input = i;
|
|
239
|
+
return { items: [{ userTaskKey: 9, elementId: "approve" }] };
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
const engine = new SdkEngineClient(client);
|
|
243
|
+
const tasks = await engine.openUserTasks({ processInstanceKey: "pi" });
|
|
244
|
+
// openUserTasks is the safe accessor: it derives from searchUserTasks with state pinned to
|
|
245
|
+
// CREATED, so a completed/canceled task can never surface through it. The caller's other
|
|
246
|
+
// selectors pass through unchanged.
|
|
247
|
+
assert.equal(input?.filter?.state, "CREATED", "openUserTasks pins state=CREATED");
|
|
248
|
+
assert.equal(input?.filter?.processInstanceKey, "pi");
|
|
249
|
+
assert.deepEqual(tasks, [{ userTaskKey: "9", elementId: "approve", variables: undefined }]);
|
|
250
|
+
});
|
|
251
|
+
|
|
234
252
|
test("searchUserTasks surfaces the resolved form linkage", async () => {
|
|
235
253
|
const client = fakeSdkClient({
|
|
236
254
|
searchUserTasks: async () => ({
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
ProcessInstanceState,
|
|
21
21
|
UserTaskState,
|
|
22
22
|
UserTaskSummary,
|
|
23
|
+
UserTaskFilter,
|
|
23
24
|
WorkerSubscription,
|
|
24
25
|
} from "../core/host.ts";
|
|
25
26
|
import { isBpmnError } from "../core/host.ts";
|
|
@@ -230,10 +231,7 @@ export class SdkEngineClient implements EngineClient {
|
|
|
230
231
|
});
|
|
231
232
|
}
|
|
232
233
|
|
|
233
|
-
async searchUserTasks(filter?: {
|
|
234
|
-
processInstanceKey?: string;
|
|
235
|
-
assignee?: string;
|
|
236
|
-
candidateGroup?: string;
|
|
234
|
+
async searchUserTasks(filter?: UserTaskFilter & {
|
|
237
235
|
state?: UserTaskState;
|
|
238
236
|
}): Promise<UserTaskSummary[]> {
|
|
239
237
|
// User tasks are an eventually consistent read; ask for zero-wait consistency so
|
|
@@ -261,6 +259,13 @@ export class SdkEngineClient implements EngineClient {
|
|
|
261
259
|
});
|
|
262
260
|
}
|
|
263
261
|
|
|
262
|
+
/** The open (answerable) user tasks — `searchUserTasks` pinned to `state: "CREATED"`.
|
|
263
|
+
* The single safe accessor for reconcile/affordance paths (see `EngineClient.openUserTasks`);
|
|
264
|
+
* derives from `searchUserTasks` so the two cannot drift. */
|
|
265
|
+
openUserTasks(filter?: UserTaskFilter): Promise<UserTaskSummary[]> {
|
|
266
|
+
return this.searchUserTasks({ ...filter, state: "CREATED" });
|
|
267
|
+
}
|
|
268
|
+
|
|
264
269
|
async getForm(input: { formKey?: string; formId?: string }): Promise<FormSchema | null> {
|
|
265
270
|
// The engine addresses deployed forms by key (`GET /forms/{formKey}`). A user task's
|
|
266
271
|
// `formKey` is the engine's resolution of its `formId` linkage to the latest deployed
|