@cosmicdrift/kumiko-samples 0.364.0 → 0.365.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/LICENSE +1 -1
- package/package.json +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/audit/changes.json +12 -0
- package/packages/bundled-features/src/audit/feature.ts +1 -1
- package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -3
- package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
- package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
- package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
- package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
- package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
- package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
- package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
- package/packages/bundled-features/src/billing-foundation/changes.json +12 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +8 -1
- package/packages/bundled-features/src/billing-foundation/i18n.ts +6 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +10 -2
- package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
- package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
- package/packages/bundled-features/src/cap-counter/changes.json +13 -0
- package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
- package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
- package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
- package/packages/bundled-features/src/channel-email/changes.json +18 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +30 -20
- package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
- package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
- package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +7 -1
- package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
- package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
- package/packages/bundled-features/src/delivery/changes.json +12 -0
- package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
- package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
- package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
- package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
- package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
- package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
- package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
- package/packages/bundled-features/src/jobs/changes.json +12 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
- package/packages/bundled-features/src/metrics/changes.json +6 -0
- package/packages/bundled-features/src/metrics/filters.ts +28 -12
- package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
- package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
- package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
- package/packages/bundled-features/src/shared/index.ts +8 -0
- package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
- package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
- package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
- package/packages/bundled-features/src/template-resolver/changes.json +81 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
- package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
- package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
- package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
- package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
- package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
- package/packages/bundled-features/src/template-resolver/index.ts +6 -1
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
- package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
- package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
- package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
- package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
- package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
- package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
- package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
- package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
- package/packages/bundled-features/src/tenant/changes.json +6 -0
- package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
- package/packages/bundled-features/src/tenant/seeding.ts +33 -27
- package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
- package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
- package/packages/bundled-features/src/tier-engine/changes.json +12 -0
- package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
- package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
- package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
- package/packages/bundled-features/src/tier-engine/index.ts +2 -0
- package/packages/bundled-features/src/user/constants.ts +1 -0
- package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
- package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
- package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
- package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
- package/packages/bundled-features/src/waitlist/changes.json +18 -0
- package/packages/bundled-features/src/waitlist/feature.ts +1 -1
- package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
- package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
- package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
- package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
- package/packages/bundled-features/src/waitlist/lib.ts +20 -0
- package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
- package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
- package/samples/apps/use-all-bundled/README.md +3 -13
- package/samples/apps/use-all-bundled/feature-manifest.json +3 -3
- package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
- package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
- package/samples/recipes/auth-mfa/README.md +2 -1
- package/samples/recipes/auth-mfa/src/feature.ts +1 -0
- package/samples/recipes/custom-handlers/README.md +4 -3
- package/samples/recipes/custom-handlers/src/feature.ts +21 -12
- package/samples/recipes/dashboard-stats/README.md +38 -0
- package/samples/recipes/dashboard-stats/package.json +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
- package/samples/recipes/legal-pages/README.md +2 -2
- package/samples/recipes/managed-pages/README.md +2 -2
- package/samples/recipes/pdf-audit-export/README.md +2 -1
- package/samples/recipes/pdf-audit-export/package.json +1 -1
- package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
- package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
- package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
- package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
- package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
- package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
- package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
|
@@ -27,6 +27,7 @@ import { createTemplateResolverApi, TemplateNotFoundError, type TemplateResource
|
|
|
27
27
|
import { collectDocumentResourceKeys } from "./compile-document-body.js";
|
|
28
28
|
import {
|
|
29
29
|
DEFAULT_MAIL_LAYOUT_SLUG,
|
|
30
|
+
DEFAULT_PDF_LAYOUT_SLUG,
|
|
30
31
|
MAIL_PREVIEW_MAX_BYTES,
|
|
31
32
|
SYSTEM_TENANT_ID,
|
|
32
33
|
type TemplateScope,
|
|
@@ -54,7 +55,6 @@ import {
|
|
|
54
55
|
const SAMPLE_LINK = "https://example.com";
|
|
55
56
|
const SAMPLE_DATE = "2026-01-15";
|
|
56
57
|
const SAMPLE_LIST_ROWS = 3;
|
|
57
|
-
const DEFAULT_PDF_LAYOUT_SLUG = "default";
|
|
58
58
|
|
|
59
59
|
function sampleScalar(name: string, def: TemplateScalarVariableDef): unknown {
|
|
60
60
|
if (def.example !== undefined) return def.example;
|
|
@@ -6,8 +6,7 @@ import type {
|
|
|
6
6
|
WriteResult,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
8
|
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
9
|
-
import {
|
|
10
|
-
import { fileContextForTenant } from "../file-foundation/index.js";
|
|
9
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
11
10
|
import { requireForTenant } from "../shared/index.js";
|
|
12
11
|
import { executor } from "./handlers/shared.js";
|
|
13
12
|
import type { LinkedResourceEntry } from "./linked-resources.js";
|
|
@@ -42,48 +41,8 @@ export async function readTemplateResource(
|
|
|
42
41
|
return { fileName: row.fileName, contentType: row.mimeType, bytes: await handle.read() };
|
|
43
42
|
}
|
|
44
43
|
|
|
45
|
-
// Gives the target tenant its own copy of every file behind the entries, so a template that was
|
|
46
|
-
// copied from the system default keeps working once the tenant exports it or measures its
|
|
47
|
-
// attachments. Rendered-PDF links carry no file and pass through. An entry whose source file is
|
|
48
|
-
// gone is left out: it could not be resolved at render time either.
|
|
49
|
-
export async function duplicateLinkedResourceFiles(
|
|
50
|
-
ctx: HandlerContext,
|
|
51
|
-
args: {
|
|
52
|
-
readonly user: SessionUser;
|
|
53
|
-
readonly tenantId: TenantId;
|
|
54
|
-
readonly resources: Readonly<Record<string, LinkedResourceEntry>>;
|
|
55
|
-
},
|
|
56
|
-
): Promise<WriteResult<Record<string, LinkedResourceEntry>>> {
|
|
57
|
-
const { user, tenantId } = args;
|
|
58
|
-
const { db } = requireForTenant(ctx, tenantId);
|
|
59
|
-
const copies: Record<string, LinkedResourceEntry> = {};
|
|
60
|
-
for (const [key, entry] of Object.entries(args.resources)) {
|
|
61
|
-
if (typeof entry !== "string" && entry.kind === "rendered-pdf") {
|
|
62
|
-
copies[key] = entry;
|
|
63
|
-
continue;
|
|
64
|
-
}
|
|
65
|
-
const sourceFileRefId = typeof entry === "string" ? entry : entry.fileRefId;
|
|
66
|
-
const file = await readTemplateResource(ctx, sourceFileRefId);
|
|
67
|
-
// skip: the source file is gone, there is nothing to copy
|
|
68
|
-
if (file === null) continue;
|
|
69
|
-
const created = await createFileRefFromBytes(
|
|
70
|
-
{
|
|
71
|
-
db,
|
|
72
|
-
files: fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
|
|
73
|
-
registry: ctx.registry,
|
|
74
|
-
user: { ...user, tenantId },
|
|
75
|
-
},
|
|
76
|
-
{ fileName: file.fileName, mimeType: file.contentType, data: file.bytes },
|
|
77
|
-
);
|
|
78
|
-
if (!created.isSuccess) return created;
|
|
79
|
-
copies[key] =
|
|
80
|
-
typeof entry === "string" ? created.data.id : { ...entry, fileRefId: created.data.id };
|
|
81
|
-
}
|
|
82
|
-
return { isSuccess: true, data: copies };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
44
|
// Replaces the whole key map of one row with files that are already stored (bundle import,
|
|
86
|
-
// copy-locale). Single keys go through
|
|
45
|
+
// copy-locale). Single keys go through storePreparedTemplateResource and resource-remove.
|
|
87
46
|
export async function attachTemplateResources(
|
|
88
47
|
ctx: Pick<HandlerContext, "forTenant">,
|
|
89
48
|
args: {
|
|
@@ -128,12 +128,10 @@ function isKnownName(
|
|
|
128
128
|
): Resolution {
|
|
129
129
|
const { variables } = scope;
|
|
130
130
|
const root = rootName(name);
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
131
|
+
// Declared variables and item fields are scalars: a dotted path under one never renders a value.
|
|
132
|
+
if (Object.hasOwn(itemFields, name)) return { known: true, used: "" };
|
|
134
133
|
if (scope.systemPlaceholders.has(root)) return { known: true, used: "" };
|
|
135
134
|
if (Object.hasOwn(variables, name)) return { known: true, used: name };
|
|
136
|
-
if (Object.hasOwn(variables, root)) return { known: true, used: root };
|
|
137
135
|
return { known: false, used: "" };
|
|
138
136
|
}
|
|
139
137
|
|
|
@@ -151,6 +149,11 @@ function enclosingEachName(element: ScanElement): string | null {
|
|
|
151
149
|
return element.closest("tr[data-each]")?.getAttribute("data-each") ?? null;
|
|
152
150
|
}
|
|
153
151
|
|
|
152
|
+
const NAME_ATTRIBUTE_BY_TAG: Readonly<Record<string, string>> = {
|
|
153
|
+
span: "data-variable",
|
|
154
|
+
div: "data-steps",
|
|
155
|
+
};
|
|
156
|
+
|
|
154
157
|
function scanBodyPlaceholders(body: string, scope: PlaceholderScope): void {
|
|
155
158
|
withParsedDocument(body, (root) => {
|
|
156
159
|
for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
|
|
@@ -160,12 +163,13 @@ function scanBodyPlaceholders(body: string, scope: PlaceholderScope): void {
|
|
|
160
163
|
else scope.unknown.add(listName);
|
|
161
164
|
}
|
|
162
165
|
for (const element of Array.from(
|
|
163
|
-
root.querySelectorAll("span[data-variable], a[href], img[data-resource]"),
|
|
166
|
+
root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"),
|
|
164
167
|
)) {
|
|
165
168
|
const itemFields = listItemFields(scope.variables, enclosingEachName(element));
|
|
166
169
|
const tag = element.tagName.toLowerCase();
|
|
167
|
-
|
|
168
|
-
|
|
170
|
+
const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
|
|
171
|
+
if (nameAttribute !== undefined) {
|
|
172
|
+
recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
|
|
169
173
|
} else if (tag === "a") {
|
|
170
174
|
const href = element.getAttribute("href") ?? "";
|
|
171
175
|
if (isDocumentPlaceholderHref(href)) recordName(href.slice(2, -2), itemFields, scope);
|
|
@@ -179,7 +183,7 @@ function scanBodyPlaceholders(body: string, scope: PlaceholderScope): void {
|
|
|
179
183
|
|
|
180
184
|
const HANDLEBARS_EXPRESSION = /\{\{([^{}]*)\}\}/g;
|
|
181
185
|
|
|
182
|
-
// Meta text supports only plain `{{name}}`
|
|
186
|
+
// Meta text supports only plain `{{name}}` references; every other
|
|
183
187
|
// Handlebars construct is reported as unknown so it can never reach the compiler.
|
|
184
188
|
function scanMetaPlaceholders(text: string, scope: PlaceholderScope): void {
|
|
185
189
|
const leftover = text.replace(HANDLEBARS_EXPRESSION, (_match, inner: string) => {
|
|
@@ -193,7 +197,9 @@ function scanMetaPlaceholders(text: string, scope: PlaceholderScope): void {
|
|
|
193
197
|
|
|
194
198
|
function metaTexts(meta: TemplateMeta | null): readonly string[] {
|
|
195
199
|
if (meta === null || !("subject" in meta)) return [];
|
|
196
|
-
return [meta.subject,
|
|
200
|
+
return [meta.subject, meta.previewText, meta.headerChip, meta.tone, meta.footerNote].filter(
|
|
201
|
+
(text): text is string => text !== undefined,
|
|
202
|
+
);
|
|
197
203
|
}
|
|
198
204
|
|
|
199
205
|
export function validateTemplatePlaceholders(input: {
|
|
@@ -7,9 +7,11 @@ import {
|
|
|
7
7
|
DocumentButton,
|
|
8
8
|
DocumentPageBreak,
|
|
9
9
|
DocumentResource,
|
|
10
|
+
DocumentSteps,
|
|
10
11
|
DocumentVariable,
|
|
11
12
|
documentExtensions,
|
|
12
13
|
} from "../document-schema.js";
|
|
14
|
+
import { readDocumentStepsAttribute } from "../document-steps.js";
|
|
13
15
|
import type { DocumentResourcePreview } from "./document-toolbar-blocks.js";
|
|
14
16
|
|
|
15
17
|
const DOCUMENT_RESOURCE_SIZE_TOKEN = "{size}";
|
|
@@ -20,6 +22,7 @@ export type DocumentEditorLabels = {
|
|
|
20
22
|
readonly image: string;
|
|
21
23
|
readonly pageBreak: string;
|
|
22
24
|
readonly button?: string;
|
|
25
|
+
readonly steps?: string;
|
|
23
26
|
/** Canvas block label of an image, e.g. "Image · inline". */
|
|
24
27
|
readonly imageBlock?: string;
|
|
25
28
|
readonly variableLabels?: Readonly<Record<string, string>>;
|
|
@@ -175,6 +178,35 @@ function createPageBreakView(pageBreakLabel: string): NodeViewRenderer {
|
|
|
175
178
|
};
|
|
176
179
|
}
|
|
177
180
|
|
|
181
|
+
// A static sketch of the row: which stage is reached is only known at render time.
|
|
182
|
+
function createStepsView(labels: DocumentEditorLabels): NodeViewRenderer {
|
|
183
|
+
return ({ node }) => {
|
|
184
|
+
const dom = createElementWithClass(
|
|
185
|
+
"div",
|
|
186
|
+
"relative my-3 rounded border border-dashed border-input px-3 pb-2 pt-3",
|
|
187
|
+
);
|
|
188
|
+
dom.setAttribute(DOCUMENT_BLOCK_ATTRIBUTE, "");
|
|
189
|
+
dom.contentEditable = "false";
|
|
190
|
+
const variable = String(node.attrs["variable"]);
|
|
191
|
+
const variableLabels = labels.variableLabels ?? {};
|
|
192
|
+
const variableLabel = Object.hasOwn(variableLabels, variable)
|
|
193
|
+
? variableLabels[variable]
|
|
194
|
+
: undefined;
|
|
195
|
+
dom.append(createBlockLabel(`${labels.steps ?? "Steps"} · ${variableLabel ?? variable}`));
|
|
196
|
+
const row = createElementWithClass("div", "flex gap-1.5");
|
|
197
|
+
for (const step of readDocumentStepsAttribute(node.attrs["steps"])) {
|
|
198
|
+
const cell = createElementWithClass("div", "min-w-0 flex-1");
|
|
199
|
+
cell.append(
|
|
200
|
+
createElementWithClass("div", "h-1 rounded-full bg-primary/40"),
|
|
201
|
+
createElementWithClass("div", "truncate pt-1.5 text-xs text-muted-foreground", step.label),
|
|
202
|
+
);
|
|
203
|
+
row.append(cell);
|
|
204
|
+
}
|
|
205
|
+
dom.append(row);
|
|
206
|
+
return { dom };
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
178
210
|
// Replaced by name, not appended: the schema must keep exactly one node per
|
|
179
211
|
// name, and only the editor view differs from the shared document schema.
|
|
180
212
|
export function createDocumentEditorExtensions(labels: DocumentEditorLabels): Extensions {
|
|
@@ -195,6 +227,8 @@ export function createDocumentEditorExtensions(labels: DocumentEditorLabels): Ex
|
|
|
195
227
|
return labels.blockLabels
|
|
196
228
|
? DocumentButton.extend({ addNodeView: () => createButtonView(labels.button ?? "") })
|
|
197
229
|
: extension;
|
|
230
|
+
case DocumentSteps.name:
|
|
231
|
+
return DocumentSteps.extend({ addNodeView: () => createStepsView(labels) });
|
|
198
232
|
case DocumentPageBreak.name:
|
|
199
233
|
return DocumentPageBreak.extend({
|
|
200
234
|
addNodeView: () => createPageBreakView(labels.pageBreak),
|
|
@@ -21,13 +21,14 @@ export function DocumentContentEditor(props: DocumentEditorProps): ReactNode {
|
|
|
21
21
|
const editorProps = props.blocks === undefined ? { ...props, blocks: contextBlocks } : props;
|
|
22
22
|
return (
|
|
23
23
|
<Suspense
|
|
24
|
+
// Read-only without chips: a textarea would insert {{name}} text and plaintext into a document body.
|
|
24
25
|
fallback={
|
|
25
26
|
<TextareaContentEditor
|
|
26
27
|
id={props.id}
|
|
27
28
|
value={props.value}
|
|
28
29
|
onChange={props.onChange}
|
|
29
|
-
variables={
|
|
30
|
-
readOnly
|
|
30
|
+
variables={[]}
|
|
31
|
+
readOnly
|
|
31
32
|
/>
|
|
32
33
|
}
|
|
33
34
|
>
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
Italic as ItalicIcon,
|
|
30
30
|
Link as LinkIcon,
|
|
31
31
|
List,
|
|
32
|
+
ListChecks,
|
|
32
33
|
ListOrdered,
|
|
33
34
|
Minus,
|
|
34
35
|
MousePointerClick,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
isDocumentReferenceName,
|
|
48
49
|
isDocumentResourceKey,
|
|
49
50
|
} from "../document-schema.js";
|
|
51
|
+
import { parseDocumentStepsInput } from "../document-steps.js";
|
|
50
52
|
import {
|
|
51
53
|
createDocumentEditorExtensions,
|
|
52
54
|
DOCUMENT_BLOCK_ATTRIBUTE,
|
|
@@ -70,7 +72,7 @@ function previewsKey(previews: Readonly<Record<string, DocumentResourcePreview>>
|
|
|
70
72
|
.join("\n");
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
type PanelKind = "link" | "placeholder" | "image" | "button" | "repeat" | "align";
|
|
75
|
+
type PanelKind = "link" | "placeholder" | "image" | "button" | "repeat" | "align" | "steps";
|
|
74
76
|
|
|
75
77
|
// Blocks that stay in the canvas toolbar; the rest moves into the overflow menu.
|
|
76
78
|
const CANVAS_INLINE_BLOCKS: readonly DocumentToolbarBlock[] = ["image", "table"];
|
|
@@ -80,6 +82,7 @@ const BLOCK_PANEL: Partial<Record<DocumentToolbarBlock, PanelKind>> = {
|
|
|
80
82
|
button: "button",
|
|
81
83
|
repeat: "repeat",
|
|
82
84
|
align: "align",
|
|
85
|
+
steps: "steps",
|
|
83
86
|
};
|
|
84
87
|
|
|
85
88
|
const ALIGNMENT_ICONS: Record<DocumentTextAlignment, ReactNode> = {
|
|
@@ -315,6 +318,9 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
|
|
|
315
318
|
const [buttonText, setButtonText] = useState(() =>
|
|
316
319
|
t("template-resolver.documentEditor.buttonDefaultText"),
|
|
317
320
|
);
|
|
321
|
+
const [stepsText, setStepsText] = useState(() =>
|
|
322
|
+
t("template-resolver.documentEditor.stepsDefault"),
|
|
323
|
+
);
|
|
318
324
|
const [chosenName, setChosenName] = useState("");
|
|
319
325
|
const [error, setError] = useState<string | null>(null);
|
|
320
326
|
|
|
@@ -364,7 +370,17 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
|
|
|
364
370
|
insertAndClose({ type: "resource", attrs: { resource, alt: alt || null } });
|
|
365
371
|
};
|
|
366
372
|
|
|
367
|
-
const
|
|
373
|
+
const submitSteps = (): void => {
|
|
374
|
+
const steps = parseDocumentStepsInput(stepsText);
|
|
375
|
+
if (steps === null) {
|
|
376
|
+
setError(t("template-resolver.documentEditor.invalidSteps"));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
insertAndClose({ type: "steps", attrs: { variable: selectedName, steps } });
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const needsVariables =
|
|
383
|
+
kind === "button" || kind === "repeat" || kind === "placeholder" || kind === "steps";
|
|
368
384
|
const noVariables = needsVariables && selectable.length === 0;
|
|
369
385
|
const idPrefix = `${editorId}-panel`;
|
|
370
386
|
|
|
@@ -467,6 +483,29 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
|
|
|
467
483
|
onChange={setChosenName}
|
|
468
484
|
/>
|
|
469
485
|
)}
|
|
486
|
+
{kind === "steps" && !noVariables && (
|
|
487
|
+
<>
|
|
488
|
+
<NameSelect
|
|
489
|
+
id={`${idPrefix}-steps-variable`}
|
|
490
|
+
label={t("template-resolver.documentEditor.stepsVariable")}
|
|
491
|
+
names={selectable}
|
|
492
|
+
value={selectedName}
|
|
493
|
+
onChange={setChosenName}
|
|
494
|
+
/>
|
|
495
|
+
<Field
|
|
496
|
+
id={`${idPrefix}-steps-list`}
|
|
497
|
+
label={t("template-resolver.documentEditor.stepsList")}
|
|
498
|
+
>
|
|
499
|
+
<Input
|
|
500
|
+
kind="text"
|
|
501
|
+
id={`${idPrefix}-steps-list`}
|
|
502
|
+
name="steps"
|
|
503
|
+
value={stepsText}
|
|
504
|
+
onChange={setStepsText}
|
|
505
|
+
/>
|
|
506
|
+
</Field>
|
|
507
|
+
</>
|
|
508
|
+
)}
|
|
470
509
|
{error !== null && (
|
|
471
510
|
<p role="alert" className="text-sm text-destructive">
|
|
472
511
|
{error}
|
|
@@ -495,6 +534,7 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
|
|
|
495
534
|
})}
|
|
496
535
|
{...(kind === "repeat" &&
|
|
497
536
|
!noVariables && { onInsert: () => insertAndClose(repeatTableContent(selectedName)) })}
|
|
537
|
+
{...(kind === "steps" && !noVariables && { onInsert: submitSteps })}
|
|
498
538
|
/>
|
|
499
539
|
</div>
|
|
500
540
|
</div>
|
|
@@ -531,6 +571,7 @@ export default function DocumentTiptapEditor({
|
|
|
531
571
|
const imageBlockLabel = t("template-resolver.documentEditor.imageBlock");
|
|
532
572
|
const pageBreakLabel = t("template-resolver.documentEditor.pageBreak");
|
|
533
573
|
const buttonLabel = t("template-resolver.documentEditor.button");
|
|
574
|
+
const stepsLabel = t("template-resolver.documentEditor.steps");
|
|
534
575
|
const stableLabels = useStableByKey(variableLabels, JSON.stringify(variableLabels));
|
|
535
576
|
const stableSystem = useStableByKey(systemVariables, systemVariables.join("\n"));
|
|
536
577
|
const stablePreviews = useStableByKey(resourcePreviews, previewsKey(resourcePreviews));
|
|
@@ -541,6 +582,7 @@ export default function DocumentTiptapEditor({
|
|
|
541
582
|
image: imageLabel,
|
|
542
583
|
pageBreak: pageBreakLabel,
|
|
543
584
|
button: buttonLabel,
|
|
585
|
+
steps: stepsLabel,
|
|
544
586
|
imageBlock: imageBlockLabel,
|
|
545
587
|
variableLabels: stableLabels,
|
|
546
588
|
systemVariables: stableSystem,
|
|
@@ -553,6 +595,7 @@ export default function DocumentTiptapEditor({
|
|
|
553
595
|
imageBlockLabel,
|
|
554
596
|
pageBreakLabel,
|
|
555
597
|
buttonLabel,
|
|
598
|
+
stepsLabel,
|
|
556
599
|
stableLabels,
|
|
557
600
|
stableSystem,
|
|
558
601
|
stablePreviews,
|
|
@@ -573,6 +616,7 @@ export default function DocumentTiptapEditor({
|
|
|
573
616
|
immediatelyRender: false,
|
|
574
617
|
editorProps: { attributes: { id } },
|
|
575
618
|
onCreate: ({ editor: created }) => onEditorReady?.(createEditorHandle(created)),
|
|
619
|
+
onDestroy: () => onEditorReady?.(null),
|
|
576
620
|
onUpdate: ({ editor: e }) => onChange(e.getHTML()),
|
|
577
621
|
},
|
|
578
622
|
[extensions],
|
|
@@ -614,27 +658,27 @@ export default function DocumentTiptapEditor({
|
|
|
614
658
|
|
|
615
659
|
// The entry loads asynchronously, so `value` arrives after useEditor read
|
|
616
660
|
// its initial content; the guard keeps in-flight typing from being clobbered.
|
|
661
|
+
// When `extensions` change, useEditor's own effect destroys this render's
|
|
662
|
+
// editor earlier in the same commit (a late validate result in the layout
|
|
663
|
+
// editor does that); reading it then throws and unmounts the whole app. The
|
|
664
|
+
// replacement editor re-renders and syncs `value` itself.
|
|
617
665
|
// kumiko-lint-ignore no-raw-hooks DOM integration: syncs the controlled value into the imperative tiptap editor
|
|
618
666
|
useEffect(() => {
|
|
619
|
-
if (editor && value !== editor.getHTML()) {
|
|
667
|
+
if (editor && !editor.isDestroyed && value !== editor.getHTML()) {
|
|
620
668
|
editor.commands.setContent(value, { emitUpdate: false });
|
|
621
669
|
}
|
|
622
670
|
}, [value, editor]);
|
|
623
671
|
|
|
624
672
|
// kumiko-lint-ignore no-raw-hooks DOM integration: tiptap exposes editability only imperatively via setEditable
|
|
625
673
|
useEffect(() => {
|
|
626
|
-
|
|
674
|
+
// setEditable emits "update" by default, which would report the mount as an edit.
|
|
675
|
+
if (editor && !editor.isDestroyed) editor.setEditable(!readOnly, false);
|
|
627
676
|
}, [readOnly, editor]);
|
|
628
677
|
|
|
678
|
+
// Read-only without chips: a textarea would insert {{name}} text and plaintext into a document body.
|
|
629
679
|
if (!editor)
|
|
630
680
|
return (
|
|
631
|
-
<TextareaContentEditor
|
|
632
|
-
id={id}
|
|
633
|
-
value={value}
|
|
634
|
-
onChange={onChange}
|
|
635
|
-
variables={variables}
|
|
636
|
-
readOnly={readOnly}
|
|
637
|
-
/>
|
|
681
|
+
<TextareaContentEditor id={id} value={value} onChange={onChange} variables={[]} readOnly />
|
|
638
682
|
);
|
|
639
683
|
|
|
640
684
|
const togglePanel = (kind: PanelKind): void =>
|
|
@@ -670,6 +714,7 @@ export default function DocumentTiptapEditor({
|
|
|
670
714
|
repeat: { label: t("template-resolver.documentEditor.repeat"), icon: <Repeat size={16} /> },
|
|
671
715
|
pageBreak: { label: pageBreakLabel, icon: <Scissors size={16} /> },
|
|
672
716
|
align: { label: t("template-resolver.documentEditor.align"), icon: <AlignLeft size={16} /> },
|
|
717
|
+
steps: { label: stepsLabel, icon: <ListChecks size={16} /> },
|
|
673
718
|
};
|
|
674
719
|
|
|
675
720
|
const repeatTable = activeState?.repeatTable ?? null;
|
|
@@ -9,7 +9,8 @@ export type DocumentToolbarBlock =
|
|
|
9
9
|
| "divider"
|
|
10
10
|
| "repeat"
|
|
11
11
|
| "pageBreak"
|
|
12
|
-
| "align"
|
|
12
|
+
| "align"
|
|
13
|
+
| "steps";
|
|
13
14
|
|
|
14
15
|
/** Imperative access for an outside panel (placeholder list, resource list). */
|
|
15
16
|
export type DocumentEditorHandle = {
|
|
@@ -45,8 +46,8 @@ export type DocumentEditorProps = ContentEditorProps & {
|
|
|
45
46
|
/** Canvas only: renders the toolbar (and its insert panel) into this element instead of above the editor;
|
|
46
47
|
* null renders none. Lets several region editors share one toolbar that follows the focused one. */
|
|
47
48
|
readonly toolbarHost?: HTMLElement | null;
|
|
48
|
-
/** Called with the handle once the editor exists and with null when it is destroyed. */
|
|
49
|
-
readonly onEditorReady?: (handle: DocumentEditorHandle) => void;
|
|
49
|
+
/** Called with the handle once the editor exists and with null when it is destroyed (unmount or rebuild), so a held handle never outlives its editor. */
|
|
50
|
+
readonly onEditorReady?: (handle: DocumentEditorHandle | null) => void;
|
|
50
51
|
};
|
|
51
52
|
|
|
52
53
|
export const MAIL_DOCUMENT_BLOCKS = [
|
|
@@ -55,6 +56,7 @@ export const MAIL_DOCUMENT_BLOCKS = [
|
|
|
55
56
|
"button",
|
|
56
57
|
"divider",
|
|
57
58
|
"repeat",
|
|
59
|
+
"steps",
|
|
58
60
|
] as const satisfies readonly DocumentToolbarBlock[];
|
|
59
61
|
|
|
60
62
|
export const PDF_DOCUMENT_BLOCKS = [
|
|
@@ -63,6 +65,7 @@ export const PDF_DOCUMENT_BLOCKS = [
|
|
|
63
65
|
"table",
|
|
64
66
|
"divider",
|
|
65
67
|
"repeat",
|
|
68
|
+
"steps",
|
|
66
69
|
"pageBreak",
|
|
67
70
|
] as const satisfies readonly DocumentToolbarBlock[];
|
|
68
71
|
|
|
@@ -43,6 +43,13 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
43
43
|
"template-resolver.documentEditor.removeLink": "Remove link",
|
|
44
44
|
"template-resolver.documentEditor.repeat": "Repeat",
|
|
45
45
|
"template-resolver.documentEditor.repeatList": "List to repeat",
|
|
46
|
+
"template-resolver.documentEditor.steps": "Steps",
|
|
47
|
+
"template-resolver.documentEditor.stepsVariable": "Current step from",
|
|
48
|
+
"template-resolver.documentEditor.stepsList": "Steps, separated by commas (value=label)",
|
|
49
|
+
"template-resolver.documentEditor.stepsDefault":
|
|
50
|
+
"reported=Reported, investigating=Investigating, resolved=Resolved",
|
|
51
|
+
"template-resolver.documentEditor.invalidSteps":
|
|
52
|
+
"Enter 1 to 8 different steps; values use letters, digits, - and _.",
|
|
46
53
|
"template-resolver.documentEditor.resourceKey": "Resource key",
|
|
47
54
|
"template-resolver.documentEditor.table": "Table",
|
|
48
55
|
"template-resolver.editor.titleLabel": "Title",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { cn } from "@cosmicdrift/kumiko-renderer-web";
|
|
8
8
|
import { Extension, Node } from "@tiptap/core";
|
|
9
|
-
import { UndoRedo } from "@tiptap/extensions";
|
|
9
|
+
import { Placeholder, UndoRedo } from "@tiptap/extensions";
|
|
10
10
|
import { EditorContent, useEditor } from "@tiptap/react";
|
|
11
11
|
import StarterKit from "@tiptap/starter-kit";
|
|
12
12
|
import { type ReactNode, useMemo, useState } from "react";
|
|
@@ -28,7 +28,9 @@ export type PlaceholderLineEditorProps = {
|
|
|
28
28
|
readonly readOnly?: boolean;
|
|
29
29
|
/** Drops the input frame so the line sits flat inside a label/value row. */
|
|
30
30
|
readonly flat?: boolean;
|
|
31
|
-
|
|
31
|
+
/** Hint shown while the line is empty. */
|
|
32
|
+
readonly placeholder?: string;
|
|
33
|
+
readonly onEditorReady?: (handle: DocumentEditorHandle | null) => void;
|
|
32
34
|
readonly "aria-label": string;
|
|
33
35
|
};
|
|
34
36
|
|
|
@@ -53,6 +55,7 @@ function LineEditorBody({
|
|
|
53
55
|
systemVariables,
|
|
54
56
|
readOnly,
|
|
55
57
|
flat,
|
|
58
|
+
placeholder,
|
|
56
59
|
onEditorReady,
|
|
57
60
|
ariaLabel,
|
|
58
61
|
}: {
|
|
@@ -63,7 +66,8 @@ function LineEditorBody({
|
|
|
63
66
|
readonly systemVariables: readonly string[];
|
|
64
67
|
readonly readOnly: boolean;
|
|
65
68
|
readonly flat: boolean;
|
|
66
|
-
readonly
|
|
69
|
+
readonly placeholder: string | undefined;
|
|
70
|
+
readonly onEditorReady: ((handle: DocumentEditorHandle | null) => void) | undefined;
|
|
67
71
|
readonly ariaLabel: string;
|
|
68
72
|
}): ReactNode {
|
|
69
73
|
const extensions = useMemo(
|
|
@@ -96,8 +100,9 @@ function LineEditorBody({
|
|
|
96
100
|
}),
|
|
97
101
|
UndoRedo,
|
|
98
102
|
BlockLineBreaks,
|
|
103
|
+
...(placeholder === undefined ? [] : [Placeholder.configure({ placeholder })]),
|
|
99
104
|
],
|
|
100
|
-
[variableLabels, systemVariables, flat],
|
|
105
|
+
[variableLabels, systemVariables, flat, placeholder],
|
|
101
106
|
);
|
|
102
107
|
|
|
103
108
|
const editor = useEditor({
|
|
@@ -115,6 +120,7 @@ function LineEditorBody({
|
|
|
115
120
|
transformPastedHTML: (html) => singleLine(html.replace(/<[^>]*>/g, " ")),
|
|
116
121
|
},
|
|
117
122
|
onCreate: ({ editor: created }) => onEditorReady?.(createEditorHandle(created)),
|
|
123
|
+
onDestroy: () => onEditorReady?.(null),
|
|
118
124
|
onUpdate: ({ editor: e }) => onChange(contentToLine(e.getJSON().content?.[0]?.content)),
|
|
119
125
|
});
|
|
120
126
|
|
|
@@ -126,6 +132,7 @@ function LineEditorBody({
|
|
|
126
132
|
flat ? "min-h-10" : "min-h-9 rounded-md border border-input px-3",
|
|
127
133
|
"[&_.ProseMirror]:w-full [&_.ProseMirror]:whitespace-nowrap [&_.ProseMirror]:outline-none",
|
|
128
134
|
"[&_p]:m-0",
|
|
135
|
+
"[&_.is-editor-empty]:before:pointer-events-none [&_.is-editor-empty]:before:float-left [&_.is-editor-empty]:before:h-0 [&_.is-editor-empty]:before:text-muted-foreground [&_.is-editor-empty]:before:content-[attr(data-placeholder)]",
|
|
129
136
|
readOnly && "opacity-60",
|
|
130
137
|
)}
|
|
131
138
|
/>
|
|
@@ -140,6 +147,7 @@ export default function PlaceholderLineEditor({
|
|
|
140
147
|
systemVariables = NO_NAMES,
|
|
141
148
|
readOnly = false,
|
|
142
149
|
flat = false,
|
|
150
|
+
placeholder,
|
|
143
151
|
onEditorReady,
|
|
144
152
|
"aria-label": ariaLabel,
|
|
145
153
|
}: PlaceholderLineEditorProps): ReactNode {
|
|
@@ -158,7 +166,7 @@ export default function PlaceholderLineEditor({
|
|
|
158
166
|
|
|
159
167
|
return (
|
|
160
168
|
<LineEditorBody
|
|
161
|
-
key={`${epoch}-${readOnly}-${JSON.stringify(stableLabels)}`}
|
|
169
|
+
key={`${epoch}-${readOnly}-${JSON.stringify(stableLabels)}-${stableSystem.join("\n")}`}
|
|
162
170
|
id={id}
|
|
163
171
|
initialValue={value}
|
|
164
172
|
onChange={(next) => {
|
|
@@ -169,6 +177,7 @@ export default function PlaceholderLineEditor({
|
|
|
169
177
|
systemVariables={stableSystem}
|
|
170
178
|
readOnly={readOnly}
|
|
171
179
|
flat={flat}
|
|
180
|
+
placeholder={placeholder}
|
|
172
181
|
onEditorReady={onEditorReady}
|
|
173
182
|
ariaLabel={ariaLabel}
|
|
174
183
|
/>
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.365.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Post-save seed hooks use the after-commit notify factory",
|
|
6
|
+
"detail": "afterCommit seed hooks now get a notify function bound to the signup dispatcher's job runner when the dispatcher provides one, falling back to the regular factory. The afterCommit hook context is built only when the hook actually runs."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.357.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
mayOverrideTenant,
|
|
10
10
|
mergeAssignedRoles,
|
|
11
11
|
parseTenantId,
|
|
12
|
-
type SessionUser,
|
|
13
12
|
SYSTEM_ROLE,
|
|
14
13
|
type TenantId,
|
|
15
14
|
withResponseData,
|
|
@@ -50,10 +49,6 @@ function resolveTargetTenantId(
|
|
|
50
49
|
return parseTenantId(payloadTenantId) ?? undefined;
|
|
51
50
|
}
|
|
52
51
|
|
|
53
|
-
function foreignStreamTenant(user: SessionUser, targetTenantId: TenantId): TenantId | undefined {
|
|
54
|
-
return targetTenantId === user.tenantId ? undefined : targetTenantId;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
52
|
// Editing your OWN roles keeps the current session: roles are re-derived per
|
|
58
53
|
// request, so the new roles apply at once without a re-login. Other sessions
|
|
59
54
|
// of that user and any foreign edit still revoke all.
|
|
@@ -195,8 +190,8 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
195
190
|
}
|
|
196
191
|
|
|
197
192
|
// The membership stream lives on the membership's tenant (where seedTenantMembership/
|
|
198
|
-
// invite-accept wrote it);
|
|
199
|
-
//
|
|
193
|
+
// invite-accept wrote it); streamTenantId addresses it with the operator as actor, also
|
|
194
|
+
// without membership there.
|
|
200
195
|
const result = await executor.update(
|
|
201
196
|
{
|
|
202
197
|
id: row["id"] as string, // @cast-boundary db-row
|
|
@@ -205,7 +200,7 @@ export const updateMemberRolesWrite = defineWriteHandler({
|
|
|
205
200
|
},
|
|
206
201
|
event.user,
|
|
207
202
|
db,
|
|
208
|
-
{ streamTenantId:
|
|
203
|
+
{ streamTenantId: targetTenantId },
|
|
209
204
|
);
|
|
210
205
|
|
|
211
206
|
return withResponseData(result, {
|