@10x-media/form-builder 0.1.0-beta.11 → 0.1.0-beta.13
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/CHANGELOG.md +24 -0
- package/dist/aggregation/resolveResultsRequest.d.ts +6 -0
- package/dist/aggregation/resolveResultsRequest.js +32 -3
- package/dist/aggregation/resolveResultsRequest.js.map +1 -1
- package/dist/calc/computeCalcFields.d.ts +5 -3
- package/dist/calc/computeCalcFields.js +4 -3
- package/dist/calc/computeCalcFields.js.map +1 -1
- package/dist/calc/evaluate.d.ts +26 -3
- package/dist/calc/evaluate.js +33 -11
- package/dist/calc/evaluate.js.map +1 -1
- package/dist/calc/formatCalc.d.ts +11 -0
- package/dist/calc/formatCalc.js +35 -0
- package/dist/calc/formatCalc.js.map +1 -0
- package/dist/calc/formatCalcValue.d.ts +17 -0
- package/dist/calc/formatCalcValue.js +17 -0
- package/dist/calc/formatCalcValue.js.map +1 -0
- package/dist/calc/normalizeCalc.d.ts +17 -3
- package/dist/calc/normalizeCalc.js +39 -17
- package/dist/calc/normalizeCalc.js.map +1 -1
- package/dist/calc/registry.d.ts +43 -0
- package/dist/calc/registry.js +27 -0
- package/dist/calc/registry.js.map +1 -0
- package/dist/calc/resolveCalcContext.d.ts +36 -0
- package/dist/calc/resolveCalcContext.js +87 -0
- package/dist/calc/resolveCalcContext.js.map +1 -0
- package/dist/calc/types.d.ts +18 -3
- package/dist/calc/types.js +2 -1
- package/dist/calc/types.js.map +1 -1
- package/dist/client/CalcExpressionBuilder.d.ts +20 -0
- package/dist/client/CalcExpressionBuilder.js +890 -0
- package/dist/client/CalcExpressionBuilder.js.map +1 -0
- package/dist/client/admin.css +282 -0
- package/dist/collections/formSubmissions.js +7 -9
- package/dist/collections/formSubmissions.js.map +1 -1
- package/dist/collections/forms.js +59 -34
- package/dist/collections/forms.js.map +1 -1
- package/dist/collections/formsEndpoints.js +4 -2
- package/dist/collections/formsEndpoints.js.map +1 -1
- package/dist/collections/settingsFields.d.ts +31 -0
- package/dist/collections/settingsFields.js +45 -0
- package/dist/collections/settingsFields.js.map +1 -0
- package/dist/exports/client.d.ts +2 -1
- package/dist/exports/client.js +2 -1
- package/dist/exports/react.d.ts +7 -3
- package/dist/exports/react.js +6 -2
- package/dist/exports/rsc.js +1 -1
- package/dist/exports/types.d.ts +1 -1
- package/dist/fields/buildFieldBlocks.js +2 -1
- package/dist/fields/buildFieldBlocks.js.map +1 -1
- package/dist/fields/builtin/calculation.d.ts +17 -0
- package/dist/fields/builtin/calculation.js +170 -115
- package/dist/fields/builtin/calculation.js.map +1 -1
- package/dist/fields/builtin/checkbox.js +15 -0
- package/dist/fields/builtin/checkbox.js.map +1 -1
- package/dist/fields/builtin/email.js +7 -0
- package/dist/fields/builtin/email.js.map +1 -1
- package/dist/fields/builtin/index.d.ts +6 -2
- package/dist/fields/builtin/index.js +9 -4
- package/dist/fields/builtin/index.js.map +1 -1
- package/dist/fields/builtin/select.js +20 -1
- package/dist/fields/builtin/select.js.map +1 -1
- package/dist/fields/builtin/text.js +7 -0
- package/dist/fields/builtin/text.js.map +1 -1
- package/dist/fields/instanceOptions.js +1 -1
- package/dist/fields/instanceOptions.js.map +1 -1
- package/dist/fields/sharedConfig.js +15 -11
- package/dist/fields/sharedConfig.js.map +1 -1
- package/dist/fields/types.d.ts +8 -2
- package/dist/form/toFormDocument.d.ts +7 -0
- package/dist/form/toFormDocument.js +2 -1
- package/dist/form/toFormDocument.js.map +1 -1
- package/dist/form/types.d.ts +7 -0
- package/dist/index.d.ts +12 -4
- package/dist/index.js +35 -8
- package/dist/index.js.map +1 -1
- package/dist/options.d.ts +35 -0
- package/dist/plugin/registerCollections.js +29 -19
- package/dist/plugin/registerCollections.js.map +1 -1
- package/dist/poll/closeJob.d.ts +4 -3
- package/dist/poll/closeJob.js +6 -4
- package/dist/poll/closeJob.js.map +1 -1
- package/dist/poll/pollTypeRegistry.js +1 -1
- package/dist/poll/resolvePollCloseRequest.js +2 -1
- package/dist/poll/resolvePollCloseRequest.js.map +1 -1
- package/dist/poll/resolvePollOutcome.d.ts +6 -0
- package/dist/poll/resolvePollOutcome.js +17 -3
- package/dist/poll/resolvePollOutcome.js.map +1 -1
- package/dist/poll/votes/aggregateFromVotes.d.ts +27 -0
- package/dist/poll/votes/aggregateFromVotes.js +53 -0
- package/dist/poll/votes/aggregateFromVotes.js.map +1 -0
- package/dist/poll/votes/bumpPollVote.js +88 -0
- package/dist/poll/votes/bumpPollVote.js.map +1 -0
- package/dist/poll/votes/recountPollVotes.d.ts +20 -0
- package/dist/poll/votes/recountPollVotes.js +63 -0
- package/dist/poll/votes/recountPollVotes.js.map +1 -0
- package/dist/poll/votes/voteTallyHook.js +58 -0
- package/dist/poll/votes/voteTallyHook.js.map +1 -0
- package/dist/poll/votes/votesCollection.d.ts +19 -0
- package/dist/poll/votes/votesCollection.js +89 -0
- package/dist/poll/votes/votesCollection.js.map +1 -0
- package/dist/react/Form.d.ts +7 -0
- package/dist/react/Form.js +20 -3
- package/dist/react/Form.js.map +1 -1
- package/dist/react/FormContext.d.ts +8 -1
- package/dist/react/FormContext.js.map +1 -1
- package/dist/react/primitives/Checkbox.d.ts +4 -2
- package/dist/react/primitives/Checkbox.js +3 -2
- package/dist/react/primitives/Checkbox.js.map +1 -1
- package/dist/react/primitives/ChoiceGroup.d.ts +40 -0
- package/dist/react/primitives/ChoiceGroup.js +33 -0
- package/dist/react/primitives/ChoiceGroup.js.map +1 -0
- package/dist/react/primitives/FieldShell.d.ts +11 -1
- package/dist/react/primitives/FieldShell.js +43 -33
- package/dist/react/primitives/FieldShell.js.map +1 -1
- package/dist/react/primitives/Input.d.ts +3 -1
- package/dist/react/primitives/Input.js +2 -1
- package/dist/react/primitives/Input.js.map +1 -1
- package/dist/react/renderers/calculation.js +6 -1
- package/dist/react/renderers/calculation.js.map +1 -1
- package/dist/react/renderers/checkbox.js +2 -1
- package/dist/react/renderers/checkbox.js.map +1 -1
- package/dist/react/renderers/email.js +3 -1
- package/dist/react/renderers/email.js.map +1 -1
- package/dist/react/renderers/select.js +20 -2
- package/dist/react/renderers/select.js.map +1 -1
- package/dist/react/renderers/text.js +3 -1
- package/dist/react/renderers/text.js.map +1 -1
- package/dist/react/useCalcValues.d.ts +10 -0
- package/dist/react/useCalcValues.js +13 -0
- package/dist/react/useCalcValues.js.map +1 -0
- package/dist/recall/resolver.js +1 -1
- package/dist/recall/resolver.js.map +1 -1
- package/dist/submissions/formIdOf.js +13 -0
- package/dist/submissions/formIdOf.js.map +1 -0
- package/dist/submissions/runSubmission.js +3 -3
- package/dist/submissions/runSubmission.js.map +1 -1
- package/dist/submissions/validateSubmission.js +26 -2
- package/dist/submissions/validateSubmission.js.map +1 -1
- package/dist/translations/de.js +58 -3
- package/dist/translations/de.js.map +1 -1
- package/dist/translations/en.js +58 -3
- package/dist/translations/en.js.map +1 -1
- package/dist/translations/keys.d.ts +56 -1
- package/dist/translations/keys.js +56 -1
- package/dist/translations/keys.js.map +1 -1
- package/dist/validation/builtin/index.js +2 -0
- package/dist/validation/builtin/index.js.map +1 -1
- package/dist/validation/builtin/integer.js +15 -0
- package/dist/validation/builtin/integer.js.map +1 -0
- package/package.json +10 -4
- package/styles/form-builder.css +84 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @10x-media/form-builder
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.13
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Make the poll tally writer's Postgres import opaque to bundlers.
|
|
8
|
+
|
|
9
|
+
The optional `@payloadcms/db-postgres` peer was loaded via a literal dynamic import, which bundlers resolve at build time: a Mongo host on Turbopack failed `next build` with "Module not found" even though the Postgres branch never runs there (and `serverExternalPackages` does not help, since it governs bundling, not resolution). The specifier is now built at runtime, so no bundler resolves it and the package is only touched when the adapter is actually Postgres. Mongo hosts that worked around this with a resolve alias or stub can remove it.
|
|
10
|
+
|
|
11
|
+
Two upgrade notes from the same integrator round: hosts that had extended the select field block with their own `display` field must remove it on the beta.12 bump (the plugin ships one with identical values, and Payload refuses to boot with two same-name fields); and custom renderers replace the built-ins wholesale, so per-instance settings added in minor releases (`display` variants, `autocomplete`, calculation formatting) must be read by your renderer or an author's choice is silently ignored, now documented in the rendering guide.
|
|
12
|
+
|
|
13
|
+
## 0.1.0-beta.12
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- Authoring UX round: a visual calculation builder, display variants, an optional-by-default field config surface, and a poll vote tally store.
|
|
18
|
+
|
|
19
|
+
- **Visual calculation builder.** The calculation field's expression is authored in a native left-to-right chain editor: start with a value (Field, Number, Weighted field, Function, or a registered source), add operation steps, optionally finish with a function, with a live readable preview and honest remove affordances throughout. The stored expression tree is unchanged and any API-written expression loads as the equivalent chain; read-only and error states are wired, and field pickers only offer numeric fields (calculations restricted to earlier ones, matching evaluation order).
|
|
20
|
+
- **Calculation extensibility.** A new `calc` option opens the expression engine to host data: `calc.sources` registers named value resolvers (`resolve` returns one number per render/submission, e.g. a tax rate or member discount; `resolveWeights` returns per-option numbers for a chosen field, e.g. each selectable product's live price) that resolve server-side, ride the form document for the client preview, and re-resolve authoritatively at submit; `calc.functions` registers custom calculation functions (client previews additionally accept the same map via `<Form calcFunctions>`). The calculation field also gains display formatting (`decimals`, localized `prefix`/`suffix`) applied in the renderer, admin answers, and email output. New utilities: `defineCalcSource`, `defineCalcFunction`, `resolveCalcContext`, `formatCalc`, `formatCalcValue`, and a `useCalcValues()` hook for composed frontends reading live computed values.
|
|
21
|
+
- **Display variants.** Select fields gain an optional `display` setting (`dropdown` default, `radio`, `buttons`) rendered as an accessible radio group with the invalid-state focus routing intact; checkbox fields gain `checkbox` or `switch`. The `ChoiceGroup` primitive is exported for custom renderers.
|
|
22
|
+
- **Optional field settings.** Field width and select option labels are no longer required: width defaults to full (the renderer already falls back), and a missing or blank option label falls back to the value everywhere (renderer, admin answers, aggregation, recall). Width option labels are localized.
|
|
23
|
+
- **Sidebar form flags with a compose seam.** `multistep`, `pollEnabled`, and `persistSubmissions` are stacked sidebar checkboxes, composed through the new `settings.fields` option so a host can relocate, wrap, extend, or drop them. Forms now registers before form-submissions in the admin nav.
|
|
24
|
+
- **Advanced tab seam.** Field types can append settings to the Advanced tab via `advancedConfig`; text and email use it for an `autocomplete` autofill hint. The `hidden` flag documents that hidden fields are still validated. New `integer` validation rule for number fields; flow validation messages are localized.
|
|
25
|
+
- **Poll vote store (default on).** Votes are counted into a hidden append-only tally collection (`form-poll-votes`) when a submission completes: results reads and `mostVoted` resolution become one indexed find (no scan, no 10k truncation), tallies survive `persistSubmissions: false` and admin deletions (votes are permanent), rows are pure aggregate so a persist-off poll retains zero per-visitor data, and writes are atomic, transaction-joined, and sharded against concurrent write conflicts. `poll.votes: false` restores scan-based results and rejects persist-off polls at save. Exported: `recountPollVotes`, `aggregateFromVotes`, `POLL_VOTES_SLUG`, `RESPONDENTS_VALUE`, `VOTE_SHARDS`. Postgres hosts create the new `form_poll_votes` table with their next migration; `@10x-media/form-builder` declares `@payloadcms/db-postgres` as an optional peer for the tally write path.
|
|
26
|
+
|
|
3
27
|
## 0.1.0-beta.11
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
|
@@ -29,6 +29,12 @@ type ResolveResultsRequestArgs = {
|
|
|
29
29
|
* answers as leftover result buckets.
|
|
30
30
|
*/
|
|
31
31
|
eligibleTypes?: readonly string[];
|
|
32
|
+
/**
|
|
33
|
+
* Whether the hidden tally store backs poll reads. When true, the poll results field is served
|
|
34
|
+
* from `aggregateFromVotes` (anonymous reads, and an authed read naming exactly that field)
|
|
35
|
+
* instead of the submission scan; every authorization gate stays identical either way.
|
|
36
|
+
*/
|
|
37
|
+
pollVotesEnabled?: boolean;
|
|
32
38
|
};
|
|
33
39
|
type ResolveResultsRequestResult = {
|
|
34
40
|
status: number;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { isPollClosed, pollConfigOf } from "../form/pollState.js";
|
|
2
2
|
import { aggregateFormResponses, fieldHasOptions } from "./aggregateResponses.js";
|
|
3
|
+
import { resolveEffectivePollOptions } from "../poll/effectivePollOptions.js";
|
|
4
|
+
import { aggregateFromVotes } from "../poll/votes/aggregateFromVotes.js";
|
|
3
5
|
import { resolvePollOutcome } from "../poll/resolvePollOutcome.js";
|
|
4
6
|
import { shouldAutoResolvePoll } from "../poll/closeJob.js";
|
|
5
|
-
import { resolveEffectivePollOptions } from "../poll/effectivePollOptions.js";
|
|
6
7
|
import { FORMS_SLUG } from "../collections/forms.js";
|
|
7
8
|
//#region src/aggregation/resolveResultsRequest.ts
|
|
8
9
|
const forbidden = {
|
|
@@ -13,6 +14,11 @@ const unavailable = {
|
|
|
13
14
|
status: 503,
|
|
14
15
|
body: { errors: [{ message: "Poll options unavailable" }] }
|
|
15
16
|
};
|
|
17
|
+
/** Scan-shape parity for a tally-served field: label from the live instance, else the field name. */
|
|
18
|
+
const tallyMetaOf = (instance, field) => ({
|
|
19
|
+
label: typeof instance?.label === "string" && instance.label.length > 0 ? instance.label : field,
|
|
20
|
+
fieldType: instance?.blockType
|
|
21
|
+
});
|
|
16
22
|
/**
|
|
17
23
|
* Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all
|
|
18
24
|
* enumerable fields) and bypass the `access` seam; for a poll-enabled form they also get the results
|
|
@@ -29,7 +35,7 @@ const unavailable = {
|
|
|
29
35
|
* closed (503) on the anonymous path. Returns only aggregate counts, never raw submissions.
|
|
30
36
|
*/
|
|
31
37
|
const resolveFormResultsRequest = async (args) => {
|
|
32
|
-
const { payload, formId, field, isAuthed, req, access, eligibleTypes } = args;
|
|
38
|
+
const { payload, formId, field, isAuthed, req, access, eligibleTypes, pollVotesEnabled } = args;
|
|
33
39
|
if (formId == null) return {
|
|
34
40
|
status: 400,
|
|
35
41
|
body: { errors: [{ message: "Missing form id" }] }
|
|
@@ -48,7 +54,8 @@ const resolveFormResultsRequest = async (args) => {
|
|
|
48
54
|
if (shouldAutoResolvePoll(form)) await resolvePollOutcome({
|
|
49
55
|
payload,
|
|
50
56
|
formId,
|
|
51
|
-
req
|
|
57
|
+
req,
|
|
58
|
+
pollVotesEnabled
|
|
52
59
|
}).catch(() => void 0);
|
|
53
60
|
let fields;
|
|
54
61
|
let resolvedOptions;
|
|
@@ -63,6 +70,17 @@ const resolveFormResultsRequest = async (args) => {
|
|
|
63
70
|
form
|
|
64
71
|
}).catch(() => []);
|
|
65
72
|
if (options.length > 0) resolvedOptions = { [resultsField]: options };
|
|
73
|
+
if (pollVotesEnabled === true && field === resultsField) return {
|
|
74
|
+
status: 200,
|
|
75
|
+
body: { results: [await aggregateFromVotes({
|
|
76
|
+
payload,
|
|
77
|
+
formId,
|
|
78
|
+
field: resultsField,
|
|
79
|
+
meta: tallyMetaOf((Array.isArray(form.fields) ? form.fields : []).find((entry) => entry.name === resultsField), resultsField),
|
|
80
|
+
options: resolvedOptions?.[resultsField] ?? [],
|
|
81
|
+
req
|
|
82
|
+
})] }
|
|
83
|
+
};
|
|
66
84
|
}
|
|
67
85
|
} else {
|
|
68
86
|
const poll = pollConfigOf(form.poll);
|
|
@@ -93,6 +111,17 @@ const resolveFormResultsRequest = async (args) => {
|
|
|
93
111
|
return unavailable;
|
|
94
112
|
}
|
|
95
113
|
if (options.length === 0) return forbidden;
|
|
114
|
+
if (pollVotesEnabled === true) return {
|
|
115
|
+
status: 200,
|
|
116
|
+
body: { results: [await aggregateFromVotes({
|
|
117
|
+
payload,
|
|
118
|
+
formId,
|
|
119
|
+
field: publicField,
|
|
120
|
+
meta: tallyMetaOf(instance, publicField),
|
|
121
|
+
options,
|
|
122
|
+
req
|
|
123
|
+
})] }
|
|
124
|
+
};
|
|
96
125
|
resolvedOptions = { [publicField]: options };
|
|
97
126
|
fields = [publicField];
|
|
98
127
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolveResultsRequest.js","names":[],"sources":["../../src/aggregation/resolveResultsRequest.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport { FORMS_SLUG } from '../collections/forms'\nimport { isPollClosed, pollConfigOf } from '../form/pollState'\nimport { type PollFormLike, shouldAutoResolvePoll } from '../poll/closeJob'\nimport type { PollOption } from '../poll/definePollOptionSource'\nimport { resolveEffectivePollOptions } from '../poll/effectivePollOptions'\nimport { resolvePollOutcome } from '../poll/resolvePollOutcome'\nimport type { FormFieldInstance } from '../submissions/types'\nimport { aggregateFormResponses, fieldHasOptions } from './aggregateResponses'\nimport type { FieldAggregation } from './types'\n\nexport type FormResultsAccessArgs = {\n\treq: PayloadRequest\n\t/** The loaded forms document (depth 0). Untyped beyond `id`: hosts read their own fields (e.g. `form.tenant`). */\n\tform: { id: number | string } & Record<string, unknown>\n}\n\n/**\n * Host seam gating anonymous results reads, evaluated after the form is loaded and before anything\n * is aggregated. Multi-tenant recipe: compare `form.tenant` against the tenant derived from `req`\n * (host header, cookie, or auth context) and return `false` for a cross-tenant read. Authenticated\n * callers bypass this seam (they are admin-trusted, like the rest of the results endpoint).\n */\nexport type FormResultsAccess = (args: FormResultsAccessArgs) => boolean | Promise<boolean>\n\nexport type ResolveResultsRequestArgs = {\n\tpayload: Payload\n\tformId: number | string | undefined\n\t/** The requested field (query param). */\n\tfield?: string\n\t/** Whether the caller is authenticated (an admin/user). */\n\tisAuthed: boolean\n\treq?: PayloadRequest\n\t/** Optional host seam for anonymous reads; absent means plugin-default gating only. */\n\taccess?: FormResultsAccess\n\t/**\n\t * Poll-eligible field types (registry-derived). When set, an anonymous read of an\n\t * option-source poll also requires the results field's instance to be one of these types,\n\t * so legacy or db-written docs pointing at a free-text field can never expose stored\n\t * answers as leftover result buckets.\n\t */\n\teligibleTypes?: readonly string[]\n}\n\nexport type ResolveResultsRequestResult = {\n\tstatus: number\n\tbody: { results: FieldAggregation[] } | { errors: { message: string }[] }\n}\n\nconst forbidden: ResolveResultsRequestResult = {\n\tstatus: 403,\n\tbody: { errors: [{ message: 'Forbidden' }] },\n}\n\n// 503 over 403 for a failed option-source resolve: the form and its poll config are already\n// publicly readable, so \"temporarily unavailable\" leaks nothing new, matches the submission\n// path's precedent, and does not mislabel a transient server fault as an authorization denial.\nconst unavailable: ResolveResultsRequestResult = {\n\tstatus: 503,\n\tbody: { errors: [{ message: 'Poll options unavailable' }] },\n}\n\n/**\n * Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all\n * enumerable fields) and bypass the `access` seam; for a poll-enabled form they also get the results\n * field's effective options injected so sourced/resolver-backed polls render labels (best-effort, a\n * resolve failure degrades to raw values rather than blocking the trusted read). Anonymous callers are\n * allowed only when the form's poll is enabled, the poll's `resultsVisibility` permits it (`afterVote`:\n * any time; `afterClose`: only once `closesAt` has passed), and the optional host `access` seam\n * approves; and then only for the configured `poll.resultsField`, and only if that field is enumerable\n * so a misconfigured `resultsField` pointing at a free-text or PII field can never be dumped publicly. A\n * static poll is enumerable through its authored options; a poll whose options come from an\n * `optionSource` or the field type's own `resolveOptions` resolves them here (registry via\n * `config.custom`, per-request cache), gated by `eligibleTypes` and enumerable only when resolution\n * yields any, with the resolved options driving bucket order and labels. Resolution failure fails\n * closed (503) on the anonymous path. Returns only aggregate counts, never raw submissions.\n */\nexport const resolveFormResultsRequest = async (\n\targs: ResolveResultsRequestArgs\n): Promise<ResolveResultsRequestResult> => {\n\tconst { payload, formId, field, isAuthed, req, access, eligibleTypes } = args\n\tif (formId == null) {\n\t\treturn { status: 400, body: { errors: [{ message: 'Missing form id' }] } }\n\t}\n\tconst form = await payload\n\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t.catch(() => null)\n\tif (!form) {\n\t\treturn { status: 404, body: { errors: [{ message: 'Not found' }] } }\n\t}\n\n\t// No-runner safety net: when a closed poll's `mostVoted`/`source` outcome was never auto-resolved\n\t// (no wired job runner fired the close task), a results read heals it. Idempotent and gate-neutral,\n\t// it runs on the already-loaded form via overrideAccess and writes only through the outcome hook, so\n\t// it cannot relax the anonymous authorization below. A failure degrades silently to the normal path.\n\tif (shouldAutoResolvePoll(form as PollFormLike)) {\n\t\tawait resolvePollOutcome({ payload, formId, req }).catch(() => undefined)\n\t}\n\n\tlet fields: string[] | undefined\n\tlet resolvedOptions: Record<string, { value: string; label: string }[]> | undefined\n\tif (isAuthed) {\n\t\tfields = field ? [field] : undefined\n\t\t// Label a poll's results field for admins: resolve its effective options (a source, the\n\t\t// field type's own resolver, or authored options) and inject them so a sourced/resolved poll\n\t\t// shows labels instead of raw stored values, and so an all-fields read still surfaces a\n\t\t// resolver-backed field that carries no authored options. Best-effort: a resolver/source\n\t\t// failure degrades to raw values rather than blocking the trusted read (the anonymous path\n\t\t// below fails closed instead). Only run when the read would actually surface the results field.\n\t\tconst poll = pollConfigOf(form.poll)\n\t\tconst resultsField =\n\t\t\ttypeof poll?.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t\t? poll.resultsField\n\t\t\t\t: undefined\n\t\tif (form.pollEnabled === true && resultsField && (!field || field === resultsField)) {\n\t\t\tconst options: PollOption[] = await resolveEffectivePollOptions({\n\t\t\t\tpayload,\n\t\t\t\treq,\n\t\t\t\tform,\n\t\t\t}).catch(() => [])\n\t\t\tif (options.length > 0) {\n\t\t\t\tresolvedOptions = { [resultsField]: options }\n\t\t\t}\n\t\t}\n\t} else {\n\t\tconst poll = pollConfigOf(form.poll)\n\t\tif (form.pollEnabled !== true || !poll) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (poll.resultsVisibility === 'afterClose' && !isPollClosed(poll)) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (access) {\n\t\t\t// No req means the seam cannot be evaluated; fail closed rather than skip a configured gate.\n\t\t\t// The concrete generated Form doc has no index signature; the seam's Record<string, unknown>\n\t\t\t// is the ergonomic host-facing contract, so widen the doc to it here.\n\t\t\tconst allowed = req\n\t\t\t\t? await access({ req, form: form as unknown as FormResultsAccessArgs['form'] })\n\t\t\t\t: false\n\t\t\tif (!allowed) {\n\t\t\t\treturn forbidden\n\t\t\t}\n\t\t}\n\t\tconst publicField =\n\t\t\ttypeof poll.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t\t? poll.resultsField\n\t\t\t\t: undefined\n\t\tif (!publicField) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (field && field !== publicField) {\n\t\t\treturn forbidden\n\t\t}\n\t\tconst instances = Array.isArray(form.fields) ? (form.fields as FormFieldInstance[]) : []\n\t\tconst instance = instances.find((entry) => entry.name === publicField)\n\t\tif (!instance) {\n\t\t\treturn forbidden\n\t\t}\n\t\tconst hasSource = typeof poll.optionSource === 'string' && poll.optionSource.length > 0\n\t\tconst authored = fieldHasOptions(instance)\n\t\t// Options that don't come from the author's own literal `options` list (a source or a\n\t\t// field-typed resolver) require the results field to be a poll-eligible type, so a legacy or\n\t\t// db-written doc pointing `resultsField` at a free-text field can never dump raw answers as\n\t\t// leftover buckets. A static authored poll is a choice field already, so it keeps serving.\n\t\tif ((hasSource || !authored) && eligibleTypes && !eligibleTypes.includes(instance.blockType)) {\n\t\t\treturn forbidden\n\t\t}\n\t\tlet options: PollOption[]\n\t\ttry {\n\t\t\toptions = await resolveEffectivePollOptions({ payload, req, form })\n\t\t} catch {\n\t\t\treturn unavailable\n\t\t}\n\t\tif (options.length === 0) {\n\t\t\treturn forbidden\n\t\t}\n\t\tresolvedOptions = { [publicField]: options }\n\t\tfields = [publicField]\n\t}\n\n\tconst results = await aggregateFormResponses({ payload, formId, fields, req, resolvedOptions })\n\treturn { status: 200, body: { results } }\n}\n"],"mappings":";;;;;;;AAiDA,MAAM,YAAyC;CAC9C,QAAQ;CACR,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;AAC5C;AAKA,MAAM,cAA2C;CAChD,QAAQ;CACR,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,2BAA2B,CAAC,EAAE;AAC3D;;;;;;;;;;;;;;;;AAiBA,MAAa,4BAA4B,OACxC,SAC0C;CAC1C,MAAM,EAAE,SAAS,QAAQ,OAAO,UAAU,KAAK,QAAQ,kBAAkB;CACzE,IAAI,UAAU,MACb,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,kBAAkB,CAAC,EAAE;CAAE;CAE1E,MAAM,OAAO,MAAM,QACjB,SAAS;EAAE,YAAY;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;EAAM;CAAI,CAAC,EACpF,YAAY,IAAI;CAClB,IAAI,CAAC,MACJ,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;CAAE;CAOpE,IAAI,sBAAsB,IAAoB,GAC7C,MAAM,mBAAmB;EAAE;EAAS;EAAQ;CAAI,CAAC,EAAE,YAAY,KAAA,CAAS;CAGzE,IAAI;CACJ,IAAI;CACJ,IAAI,UAAU;EACb,SAAS,QAAQ,CAAC,KAAK,IAAI,KAAA;EAO3B,MAAM,OAAO,aAAa,KAAK,IAAI;EACnC,MAAM,eACL,OAAO,MAAM,iBAAiB,YAAY,KAAK,aAAa,SAAS,IAClE,KAAK,eACL,KAAA;EACJ,IAAI,KAAK,gBAAgB,QAAQ,iBAAiB,CAAC,SAAS,UAAU,eAAe;GACpF,MAAM,UAAwB,MAAM,4BAA4B;IAC/D;IACA;IACA;GACD,CAAC,EAAE,YAAY,CAAC,CAAC;GACjB,IAAI,QAAQ,SAAS,GACpB,kBAAkB,GAAG,eAAe,QAAQ;EAE9C;CACD,OAAO;EACN,MAAM,OAAO,aAAa,KAAK,IAAI;EACnC,IAAI,KAAK,gBAAgB,QAAQ,CAAC,MACjC,OAAO;EAER,IAAI,KAAK,sBAAsB,gBAAgB,CAAC,aAAa,IAAI,GAChE,OAAO;EAER,IAAI;OAOC,EAHY,MACb,MAAM,OAAO;IAAE;IAAW;GAAiD,CAAC,IAC5E,QAEF,OAAO;EAAA;EAGT,MAAM,cACL,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS,IACjE,KAAK,eACL,KAAA;EACJ,IAAI,CAAC,aACJ,OAAO;EAER,IAAI,SAAS,UAAU,aACtB,OAAO;EAGR,MAAM,YADY,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAiC,CAAC,GAC5D,MAAM,UAAU,MAAM,SAAS,WAAW;EACrE,IAAI,CAAC,UACJ,OAAO;EAER,MAAM,YAAY,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS;EACtF,MAAM,WAAW,gBAAgB,QAAQ;EAKzC,KAAK,aAAa,CAAC,aAAa,iBAAiB,CAAC,cAAc,SAAS,SAAS,SAAS,GAC1F,OAAO;EAER,IAAI;EACJ,IAAI;GACH,UAAU,MAAM,4BAA4B;IAAE;IAAS;IAAK;GAAK,CAAC;EACnE,QAAQ;GACP,OAAO;EACR;EACA,IAAI,QAAQ,WAAW,GACtB,OAAO;EAER,kBAAkB,GAAG,cAAc,QAAQ;EAC3C,SAAS,CAAC,WAAW;CACtB;CAGA,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,SAAA,MADR,uBAAuB;GAAE;GAAS;GAAQ;GAAQ;GAAK;EAAgB,CAAC,EACxD;CAAE;AACzC"}
|
|
1
|
+
{"version":3,"file":"resolveResultsRequest.js","names":[],"sources":["../../src/aggregation/resolveResultsRequest.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport { FORMS_SLUG } from '../collections/forms'\nimport { isPollClosed, pollConfigOf } from '../form/pollState'\nimport { type PollFormLike, shouldAutoResolvePoll } from '../poll/closeJob'\nimport type { PollOption } from '../poll/definePollOptionSource'\nimport { resolveEffectivePollOptions } from '../poll/effectivePollOptions'\nimport { resolvePollOutcome } from '../poll/resolvePollOutcome'\nimport { aggregateFromVotes } from '../poll/votes/aggregateFromVotes'\nimport type { FormFieldInstance } from '../submissions/types'\nimport { aggregateFormResponses, fieldHasOptions } from './aggregateResponses'\nimport type { FieldAggregation } from './types'\n\nexport type FormResultsAccessArgs = {\n\treq: PayloadRequest\n\t/** The loaded forms document (depth 0). Untyped beyond `id`: hosts read their own fields (e.g. `form.tenant`). */\n\tform: { id: number | string } & Record<string, unknown>\n}\n\n/**\n * Host seam gating anonymous results reads, evaluated after the form is loaded and before anything\n * is aggregated. Multi-tenant recipe: compare `form.tenant` against the tenant derived from `req`\n * (host header, cookie, or auth context) and return `false` for a cross-tenant read. Authenticated\n * callers bypass this seam (they are admin-trusted, like the rest of the results endpoint).\n */\nexport type FormResultsAccess = (args: FormResultsAccessArgs) => boolean | Promise<boolean>\n\nexport type ResolveResultsRequestArgs = {\n\tpayload: Payload\n\tformId: number | string | undefined\n\t/** The requested field (query param). */\n\tfield?: string\n\t/** Whether the caller is authenticated (an admin/user). */\n\tisAuthed: boolean\n\treq?: PayloadRequest\n\t/** Optional host seam for anonymous reads; absent means plugin-default gating only. */\n\taccess?: FormResultsAccess\n\t/**\n\t * Poll-eligible field types (registry-derived). When set, an anonymous read of an\n\t * option-source poll also requires the results field's instance to be one of these types,\n\t * so legacy or db-written docs pointing at a free-text field can never expose stored\n\t * answers as leftover result buckets.\n\t */\n\teligibleTypes?: readonly string[]\n\t/**\n\t * Whether the hidden tally store backs poll reads. When true, the poll results field is served\n\t * from `aggregateFromVotes` (anonymous reads, and an authed read naming exactly that field)\n\t * instead of the submission scan; every authorization gate stays identical either way.\n\t */\n\tpollVotesEnabled?: boolean\n}\n\nexport type ResolveResultsRequestResult = {\n\tstatus: number\n\tbody: { results: FieldAggregation[] } | { errors: { message: string }[] }\n}\n\nconst forbidden: ResolveResultsRequestResult = {\n\tstatus: 403,\n\tbody: { errors: [{ message: 'Forbidden' }] },\n}\n\n// 503 over 403 for a failed option-source resolve: the form and its poll config are already\n// publicly readable, so \"temporarily unavailable\" leaks nothing new, matches the submission\n// path's precedent, and does not mislabel a transient server fault as an authorization denial.\nconst unavailable: ResolveResultsRequestResult = {\n\tstatus: 503,\n\tbody: { errors: [{ message: 'Poll options unavailable' }] },\n}\n\n/** Scan-shape parity for a tally-served field: label from the live instance, else the field name. */\nconst tallyMetaOf = (\n\tinstance: FormFieldInstance | undefined,\n\tfield: string\n): { label: string; fieldType?: string } => ({\n\tlabel: typeof instance?.label === 'string' && instance.label.length > 0 ? instance.label : field,\n\tfieldType: instance?.blockType,\n})\n\n/**\n * Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all\n * enumerable fields) and bypass the `access` seam; for a poll-enabled form they also get the results\n * field's effective options injected so sourced/resolver-backed polls render labels (best-effort, a\n * resolve failure degrades to raw values rather than blocking the trusted read). Anonymous callers are\n * allowed only when the form's poll is enabled, the poll's `resultsVisibility` permits it (`afterVote`:\n * any time; `afterClose`: only once `closesAt` has passed), and the optional host `access` seam\n * approves; and then only for the configured `poll.resultsField`, and only if that field is enumerable\n * so a misconfigured `resultsField` pointing at a free-text or PII field can never be dumped publicly. A\n * static poll is enumerable through its authored options; a poll whose options come from an\n * `optionSource` or the field type's own `resolveOptions` resolves them here (registry via\n * `config.custom`, per-request cache), gated by `eligibleTypes` and enumerable only when resolution\n * yields any, with the resolved options driving bucket order and labels. Resolution failure fails\n * closed (503) on the anonymous path. Returns only aggregate counts, never raw submissions.\n */\nexport const resolveFormResultsRequest = async (\n\targs: ResolveResultsRequestArgs\n): Promise<ResolveResultsRequestResult> => {\n\tconst { payload, formId, field, isAuthed, req, access, eligibleTypes, pollVotesEnabled } = args\n\tif (formId == null) {\n\t\treturn { status: 400, body: { errors: [{ message: 'Missing form id' }] } }\n\t}\n\tconst form = await payload\n\t\t.findByID({ collection: FORMS_SLUG, id: formId, depth: 0, overrideAccess: true, req })\n\t\t.catch(() => null)\n\tif (!form) {\n\t\treturn { status: 404, body: { errors: [{ message: 'Not found' }] } }\n\t}\n\n\t// No-runner safety net: when a closed poll's `mostVoted`/`source` outcome was never auto-resolved\n\t// (no wired job runner fired the close task), a results read heals it. Idempotent and gate-neutral,\n\t// it runs on the already-loaded form via overrideAccess and writes only through the outcome hook, so\n\t// it cannot relax the anonymous authorization below. A failure degrades silently to the normal path.\n\tif (shouldAutoResolvePoll(form as PollFormLike)) {\n\t\tawait resolvePollOutcome({ payload, formId, req, pollVotesEnabled }).catch(() => undefined)\n\t}\n\n\tlet fields: string[] | undefined\n\tlet resolvedOptions: Record<string, { value: string; label: string }[]> | undefined\n\tif (isAuthed) {\n\t\tfields = field ? [field] : undefined\n\t\t// Label a poll's results field for admins: resolve its effective options (a source, the\n\t\t// field type's own resolver, or authored options) and inject them so a sourced/resolved poll\n\t\t// shows labels instead of raw stored values, and so an all-fields read still surfaces a\n\t\t// resolver-backed field that carries no authored options. Best-effort: a resolver/source\n\t\t// failure degrades to raw values rather than blocking the trusted read (the anonymous path\n\t\t// below fails closed instead). Only run when the read would actually surface the results field.\n\t\tconst poll = pollConfigOf(form.poll)\n\t\tconst resultsField =\n\t\t\ttypeof poll?.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t\t? poll.resultsField\n\t\t\t\t: undefined\n\t\tif (form.pollEnabled === true && resultsField && (!field || field === resultsField)) {\n\t\t\tconst options: PollOption[] = await resolveEffectivePollOptions({\n\t\t\t\tpayload,\n\t\t\t\treq,\n\t\t\t\tform,\n\t\t\t}).catch(() => [])\n\t\t\tif (options.length > 0) {\n\t\t\t\tresolvedOptions = { [resultsField]: options }\n\t\t\t}\n\t\t\t// Only the explicit single-field poll read switches to the tally store; an all-fields\n\t\t\t// (or multi-field) read keeps the scan, which is the only source for non-poll fields.\n\t\t\tif (pollVotesEnabled === true && field === resultsField) {\n\t\t\t\tconst instances = Array.isArray(form.fields) ? (form.fields as FormFieldInstance[]) : []\n\t\t\t\tconst instance = instances.find((entry) => entry.name === resultsField)\n\t\t\t\tconst aggregation = await aggregateFromVotes({\n\t\t\t\t\tpayload,\n\t\t\t\t\tformId,\n\t\t\t\t\tfield: resultsField,\n\t\t\t\t\tmeta: tallyMetaOf(instance, resultsField),\n\t\t\t\t\toptions: resolvedOptions?.[resultsField] ?? [],\n\t\t\t\t\treq,\n\t\t\t\t})\n\t\t\t\treturn { status: 200, body: { results: [aggregation] } }\n\t\t\t}\n\t\t}\n\t} else {\n\t\tconst poll = pollConfigOf(form.poll)\n\t\tif (form.pollEnabled !== true || !poll) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (poll.resultsVisibility === 'afterClose' && !isPollClosed(poll)) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (access) {\n\t\t\t// No req means the seam cannot be evaluated; fail closed rather than skip a configured gate.\n\t\t\t// The concrete generated Form doc has no index signature; the seam's Record<string, unknown>\n\t\t\t// is the ergonomic host-facing contract, so widen the doc to it here.\n\t\t\tconst allowed = req\n\t\t\t\t? await access({ req, form: form as unknown as FormResultsAccessArgs['form'] })\n\t\t\t\t: false\n\t\t\tif (!allowed) {\n\t\t\t\treturn forbidden\n\t\t\t}\n\t\t}\n\t\tconst publicField =\n\t\t\ttypeof poll.resultsField === 'string' && poll.resultsField.length > 0\n\t\t\t\t? poll.resultsField\n\t\t\t\t: undefined\n\t\tif (!publicField) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (field && field !== publicField) {\n\t\t\treturn forbidden\n\t\t}\n\t\tconst instances = Array.isArray(form.fields) ? (form.fields as FormFieldInstance[]) : []\n\t\tconst instance = instances.find((entry) => entry.name === publicField)\n\t\tif (!instance) {\n\t\t\treturn forbidden\n\t\t}\n\t\tconst hasSource = typeof poll.optionSource === 'string' && poll.optionSource.length > 0\n\t\tconst authored = fieldHasOptions(instance)\n\t\t// Options that don't come from the author's own literal `options` list (a source or a\n\t\t// field-typed resolver) require the results field to be a poll-eligible type, so a legacy or\n\t\t// db-written doc pointing `resultsField` at a free-text field can never dump raw answers as\n\t\t// leftover buckets. A static authored poll is a choice field already, so it keeps serving.\n\t\tif ((hasSource || !authored) && eligibleTypes && !eligibleTypes.includes(instance.blockType)) {\n\t\t\treturn forbidden\n\t\t}\n\t\tlet options: PollOption[]\n\t\ttry {\n\t\t\toptions = await resolveEffectivePollOptions({ payload, req, form })\n\t\t} catch {\n\t\t\treturn unavailable\n\t\t}\n\t\tif (options.length === 0) {\n\t\t\treturn forbidden\n\t\t}\n\t\tif (pollVotesEnabled === true) {\n\t\t\tconst aggregation = await aggregateFromVotes({\n\t\t\t\tpayload,\n\t\t\t\tformId,\n\t\t\t\tfield: publicField,\n\t\t\t\tmeta: tallyMetaOf(instance, publicField),\n\t\t\t\toptions,\n\t\t\t\treq,\n\t\t\t})\n\t\t\treturn { status: 200, body: { results: [aggregation] } }\n\t\t}\n\t\tresolvedOptions = { [publicField]: options }\n\t\tfields = [publicField]\n\t}\n\n\tconst results = await aggregateFormResponses({ payload, formId, fields, req, resolvedOptions })\n\treturn { status: 200, body: { results } }\n}\n"],"mappings":";;;;;;;;AAwDA,MAAM,YAAyC;CAC9C,QAAQ;CACR,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;AAC5C;AAKA,MAAM,cAA2C;CAChD,QAAQ;CACR,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,2BAA2B,CAAC,EAAE;AAC3D;;AAGA,MAAM,eACL,UACA,WAC4C;CAC5C,OAAO,OAAO,UAAU,UAAU,YAAY,SAAS,MAAM,SAAS,IAAI,SAAS,QAAQ;CAC3F,WAAW,UAAU;AACtB;;;;;;;;;;;;;;;;AAiBA,MAAa,4BAA4B,OACxC,SAC0C;CAC1C,MAAM,EAAE,SAAS,QAAQ,OAAO,UAAU,KAAK,QAAQ,eAAe,qBAAqB;CAC3F,IAAI,UAAU,MACb,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,kBAAkB,CAAC,EAAE;CAAE;CAE1E,MAAM,OAAO,MAAM,QACjB,SAAS;EAAE,YAAY;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;EAAM;CAAI,CAAC,EACpF,YAAY,IAAI;CAClB,IAAI,CAAC,MACJ,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;CAAE;CAOpE,IAAI,sBAAsB,IAAoB,GAC7C,MAAM,mBAAmB;EAAE;EAAS;EAAQ;EAAK;CAAiB,CAAC,EAAE,YAAY,KAAA,CAAS;CAG3F,IAAI;CACJ,IAAI;CACJ,IAAI,UAAU;EACb,SAAS,QAAQ,CAAC,KAAK,IAAI,KAAA;EAO3B,MAAM,OAAO,aAAa,KAAK,IAAI;EACnC,MAAM,eACL,OAAO,MAAM,iBAAiB,YAAY,KAAK,aAAa,SAAS,IAClE,KAAK,eACL,KAAA;EACJ,IAAI,KAAK,gBAAgB,QAAQ,iBAAiB,CAAC,SAAS,UAAU,eAAe;GACpF,MAAM,UAAwB,MAAM,4BAA4B;IAC/D;IACA;IACA;GACD,CAAC,EAAE,YAAY,CAAC,CAAC;GACjB,IAAI,QAAQ,SAAS,GACpB,kBAAkB,GAAG,eAAe,QAAQ;GAI7C,IAAI,qBAAqB,QAAQ,UAAU,cAW1C,OAAO;IAAE,QAAQ;IAAK,MAAM,EAAE,SAAS,CAAC,MARd,mBAAmB;KAC5C;KACA;KACA,OAAO;KACP,MAAM,aANW,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAiC,CAAC,GAC5D,MAAM,UAAU,MAAM,SAAS,YAKhC,GAAG,YAAY;KACxC,SAAS,kBAAkB,iBAAiB,CAAC;KAC7C;IACD,CAAC,CACkD,EAAE;GAAE;EAEzD;CACD,OAAO;EACN,MAAM,OAAO,aAAa,KAAK,IAAI;EACnC,IAAI,KAAK,gBAAgB,QAAQ,CAAC,MACjC,OAAO;EAER,IAAI,KAAK,sBAAsB,gBAAgB,CAAC,aAAa,IAAI,GAChE,OAAO;EAER,IAAI;OAOC,EAHY,MACb,MAAM,OAAO;IAAE;IAAW;GAAiD,CAAC,IAC5E,QAEF,OAAO;EAAA;EAGT,MAAM,cACL,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS,IACjE,KAAK,eACL,KAAA;EACJ,IAAI,CAAC,aACJ,OAAO;EAER,IAAI,SAAS,UAAU,aACtB,OAAO;EAGR,MAAM,YADY,MAAM,QAAQ,KAAK,MAAM,IAAK,KAAK,SAAiC,CAAC,GAC5D,MAAM,UAAU,MAAM,SAAS,WAAW;EACrE,IAAI,CAAC,UACJ,OAAO;EAER,MAAM,YAAY,OAAO,KAAK,iBAAiB,YAAY,KAAK,aAAa,SAAS;EACtF,MAAM,WAAW,gBAAgB,QAAQ;EAKzC,KAAK,aAAa,CAAC,aAAa,iBAAiB,CAAC,cAAc,SAAS,SAAS,SAAS,GAC1F,OAAO;EAER,IAAI;EACJ,IAAI;GACH,UAAU,MAAM,4BAA4B;IAAE;IAAS;IAAK;GAAK,CAAC;EACnE,QAAQ;GACP,OAAO;EACR;EACA,IAAI,QAAQ,WAAW,GACtB,OAAO;EAER,IAAI,qBAAqB,MASxB,OAAO;GAAE,QAAQ;GAAK,MAAM,EAAE,SAAS,CAAC,MARd,mBAAmB;IAC5C;IACA;IACA,OAAO;IACP,MAAM,YAAY,UAAU,WAAW;IACvC;IACA;GACD,CAAC,CACkD,EAAE;EAAE;EAExD,kBAAkB,GAAG,cAAc,QAAQ;EAC3C,SAAS,CAAC,WAAW;CACtB;CAGA,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,SAAA,MADR,uBAAuB;GAAE;GAAS;GAAQ;GAAQ;GAAK;EAAgB,CAAC,EACxD;CAAE;AACzC"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { FormFieldInstance } from "../submissions/types.js";
|
|
2
1
|
import { CalcExpression } from "./types.js";
|
|
2
|
+
import { FormFieldInstance } from "../submissions/types.js";
|
|
3
|
+
import { CalcResolved } from "./evaluate.js";
|
|
3
4
|
|
|
4
5
|
//#region src/calc/computeCalcFields.d.ts
|
|
5
6
|
/** Returns the calc expression if the field carries one (non-null object), otherwise undefined. */
|
|
@@ -7,9 +8,10 @@ declare const calcExpressionOf: (field: FormFieldInstance) => CalcExpression | u
|
|
|
7
8
|
/**
|
|
8
9
|
* Returns answers with every calc field's value derived from its expression,
|
|
9
10
|
* folded in declaration order so a calc may reference an earlier calc's result.
|
|
10
|
-
* Identity when no field carries an expression.
|
|
11
|
+
* Identity when no field carries an expression. `resolved` threads server-resolved
|
|
12
|
+
* source values, weight maps, and custom functions into each evaluation.
|
|
11
13
|
*/
|
|
12
|
-
declare const computeCalcFields: (fields: FormFieldInstance[], answers: Record<string, unknown
|
|
14
|
+
declare const computeCalcFields: (fields: FormFieldInstance[], answers: Record<string, unknown>, resolved?: CalcResolved) => Record<string, unknown>;
|
|
13
15
|
//#endregion
|
|
14
16
|
export { calcExpressionOf, computeCalcFields };
|
|
15
17
|
//# sourceMappingURL=computeCalcFields.d.ts.map
|
|
@@ -9,15 +9,16 @@ const calcExpressionOf = (field) => {
|
|
|
9
9
|
/**
|
|
10
10
|
* Returns answers with every calc field's value derived from its expression,
|
|
11
11
|
* folded in declaration order so a calc may reference an earlier calc's result.
|
|
12
|
-
* Identity when no field carries an expression.
|
|
12
|
+
* Identity when no field carries an expression. `resolved` threads server-resolved
|
|
13
|
+
* source values, weight maps, and custom functions into each evaluation.
|
|
13
14
|
*/
|
|
14
|
-
const computeCalcFields = (fields, answers) => {
|
|
15
|
+
const computeCalcFields = (fields, answers, resolved) => {
|
|
15
16
|
let next = answers;
|
|
16
17
|
for (const field of fields) {
|
|
17
18
|
const expr = calcExpressionOf(field);
|
|
18
19
|
if (expr && isNamedField(field)) next = {
|
|
19
20
|
...next,
|
|
20
|
-
[field.name]: evaluateCalc(expr, next)
|
|
21
|
+
[field.name]: evaluateCalc(expr, next, resolved)
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
return next;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"computeCalcFields.js","names":[],"sources":["../../src/calc/computeCalcFields.ts"],"sourcesContent":["import { isNamedField } from '../fields/fieldKey'\nimport type { FormFieldInstance } from '../submissions/types'\nimport { evaluateCalc } from './evaluate'\nimport type { CalcExpression } from './types'\n\n/** Returns the calc expression if the field carries one (non-null object), otherwise undefined. */\nexport const calcExpressionOf = (field: FormFieldInstance): CalcExpression | undefined => {\n\tconst expr = field.expression\n\treturn expr !== null && typeof expr === 'object' && !Array.isArray(expr)\n\t\t? (expr as CalcExpression)\n\t\t: undefined\n}\n\n/**\n * Returns answers with every calc field's value derived from its expression,\n * folded in declaration order so a calc may reference an earlier calc's result.\n * Identity when no field carries an expression.\n */\nexport const computeCalcFields = (\n\tfields: FormFieldInstance[],\n\tanswers: Record<string, unknown
|
|
1
|
+
{"version":3,"file":"computeCalcFields.js","names":[],"sources":["../../src/calc/computeCalcFields.ts"],"sourcesContent":["import { isNamedField } from '../fields/fieldKey'\nimport type { FormFieldInstance } from '../submissions/types'\nimport { type CalcResolved, evaluateCalc } from './evaluate'\nimport type { CalcExpression } from './types'\n\n/** Returns the calc expression if the field carries one (non-null object), otherwise undefined. */\nexport const calcExpressionOf = (field: FormFieldInstance): CalcExpression | undefined => {\n\tconst expr = field.expression\n\treturn expr !== null && typeof expr === 'object' && !Array.isArray(expr)\n\t\t? (expr as CalcExpression)\n\t\t: undefined\n}\n\n/**\n * Returns answers with every calc field's value derived from its expression,\n * folded in declaration order so a calc may reference an earlier calc's result.\n * Identity when no field carries an expression. `resolved` threads server-resolved\n * source values, weight maps, and custom functions into each evaluation.\n */\nexport const computeCalcFields = (\n\tfields: FormFieldInstance[],\n\tanswers: Record<string, unknown>,\n\tresolved?: CalcResolved\n): Record<string, unknown> => {\n\tlet next = answers\n\tfor (const field of fields) {\n\t\tconst expr = calcExpressionOf(field)\n\t\tif (expr && isNamedField(field)) {\n\t\t\tnext = { ...next, [field.name]: evaluateCalc(expr, next, resolved) }\n\t\t}\n\t}\n\treturn next\n}\n"],"mappings":";;;;AAMA,MAAa,oBAAoB,UAAyD;CACzF,MAAM,OAAO,MAAM;CACnB,OAAO,SAAS,QAAQ,OAAO,SAAS,YAAY,CAAC,MAAM,QAAQ,IAAI,IACnE,OACD,KAAA;AACJ;;;;;;;AAQA,MAAa,qBACZ,QACA,SACA,aAC6B;CAC7B,IAAI,OAAO;CACX,KAAK,MAAM,SAAS,QAAQ;EAC3B,MAAM,OAAO,iBAAiB,KAAK;EACnC,IAAI,QAAQ,aAAa,KAAK,GAC7B,OAAO;GAAE,GAAG;IAAO,MAAM,OAAO,aAAa,MAAM,MAAM,QAAQ;EAAE;CAErE;CACA,OAAO;AACR"}
|
package/dist/calc/evaluate.d.ts
CHANGED
|
@@ -1,8 +1,31 @@
|
|
|
1
1
|
import { CalcExpression } from "./types.js";
|
|
2
2
|
|
|
3
3
|
//#region src/calc/evaluate.d.ts
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Server-resolved calc extension values, threaded into evaluation as data (never re-resolved
|
|
6
|
+
* here, so the evaluator stays sync and isomorphic). `sources`/`weights` are serializable and
|
|
7
|
+
* ride the form document for the client's live preview; `functions` never serialize and are
|
|
8
|
+
* supplied per environment (registry `apply` fns on the server, the Form prop on the client).
|
|
9
|
+
*/
|
|
10
|
+
type CalcResolved = {
|
|
11
|
+
sources?: Record<string, number>; /** Keyed `source + ' ' + field` (see `calcWeightKey` / `resolveCalcContext`). */
|
|
12
|
+
weights?: Record<string, Record<string, number>>;
|
|
13
|
+
functions?: Record<string, (args: number[]) => number>;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The `CalcResolved.weights` key for a sourced weight node: source key + space + field name.
|
|
17
|
+
* Unambiguous because registered source keys can never contain a space
|
|
18
|
+
* (`assertValidCalcSourceKeys` enforces `[\w.-]` at boot), so the first space always ends the
|
|
19
|
+
* source segment.
|
|
20
|
+
*/
|
|
21
|
+
declare const calcWeightKey: (source: string, field: string) => string;
|
|
22
|
+
/**
|
|
23
|
+
* Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod
|
|
24
|
+
* by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). `resolved` supplies
|
|
25
|
+
* server-resolved source values, sourced weight maps, and custom functions; any extension node
|
|
26
|
+
* whose value is absent evaluates to 0.
|
|
27
|
+
*/
|
|
28
|
+
declare const evaluateCalc: (expr: CalcExpression | null | undefined, answers: Record<string, unknown>, resolved?: CalcResolved) => number;
|
|
6
29
|
//#endregion
|
|
7
|
-
export { evaluateCalc };
|
|
30
|
+
export { CalcResolved, calcWeightKey, evaluateCalc };
|
|
8
31
|
//# sourceMappingURL=evaluate.d.ts.map
|
package/dist/calc/evaluate.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
//#region src/calc/evaluate.ts
|
|
2
|
+
/**
|
|
3
|
+
* The `CalcResolved.weights` key for a sourced weight node: source key + space + field name.
|
|
4
|
+
* Unambiguous because registered source keys can never contain a space
|
|
5
|
+
* (`assertValidCalcSourceKeys` enforces `[\w.-]` at boot), so the first space always ends the
|
|
6
|
+
* source segment.
|
|
7
|
+
*/
|
|
8
|
+
const calcWeightKey = (source, field) => `${source} ${field}`;
|
|
2
9
|
const toNumber = (value) => {
|
|
3
10
|
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
4
11
|
if (typeof value === "string" && value.trim() !== "") {
|
|
@@ -16,15 +23,17 @@ const FUNCTIONS = {
|
|
|
16
23
|
ceil: (a) => Math.ceil(a[0] ?? 0),
|
|
17
24
|
floor: (a) => Math.floor(a[0] ?? 0)
|
|
18
25
|
};
|
|
19
|
-
const evalNode = (expr,
|
|
26
|
+
const evalNode = (expr, ctx, depth) => {
|
|
20
27
|
if (depth > 64 || expr == null || typeof expr !== "object") return 0;
|
|
28
|
+
const { answers, resolved } = ctx;
|
|
21
29
|
switch (expr.type) {
|
|
22
30
|
case "lit": return finite(toNumber(expr.value));
|
|
23
31
|
case "ref": return toNumber(answers[expr.field]);
|
|
24
|
-
case "
|
|
32
|
+
case "source": return toNumber(resolved?.sources?.[expr.source]);
|
|
33
|
+
case "neg": return finite(-evalNode(expr.operand, ctx, depth + 1));
|
|
25
34
|
case "op": {
|
|
26
|
-
const l = evalNode(expr.left,
|
|
27
|
-
const r = evalNode(expr.right,
|
|
35
|
+
const l = evalNode(expr.left, ctx, depth + 1);
|
|
36
|
+
const r = evalNode(expr.right, ctx, depth + 1);
|
|
28
37
|
switch (expr.op) {
|
|
29
38
|
case "+": return finite(l + r);
|
|
30
39
|
case "-": return finite(l - r);
|
|
@@ -35,24 +44,37 @@ const evalNode = (expr, answers, depth) => {
|
|
|
35
44
|
}
|
|
36
45
|
}
|
|
37
46
|
case "fn": {
|
|
38
|
-
const fn = FUNCTIONS[expr.fn];
|
|
47
|
+
const fn = FUNCTIONS[expr.fn] ?? resolved?.functions?.[expr.fn];
|
|
39
48
|
if (!fn) return 0;
|
|
40
|
-
|
|
49
|
+
const args = Array.isArray(expr.args) ? expr.args.map((a) => evalNode(a, ctx, depth + 1)) : [];
|
|
50
|
+
try {
|
|
51
|
+
return finite(fn(args));
|
|
52
|
+
} catch {
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
41
55
|
}
|
|
42
56
|
case "weight": {
|
|
43
|
-
const weights = expr.weights ?? {};
|
|
57
|
+
const weights = typeof expr.source === "string" ? resolved?.weights?.[calcWeightKey(expr.source, expr.field)] ?? {} : expr.weights ?? {};
|
|
44
58
|
const value = answers[expr.field];
|
|
45
59
|
return finite((Array.isArray(value) ? value : value == null || value === "" ? [] : [value]).reduce((sum, v) => sum + toNumber(weights[String(v)]), 0));
|
|
46
60
|
}
|
|
47
61
|
default: return 0;
|
|
48
62
|
}
|
|
49
63
|
};
|
|
50
|
-
/**
|
|
51
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod
|
|
66
|
+
* by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). `resolved` supplies
|
|
67
|
+
* server-resolved source values, sourced weight maps, and custom functions; any extension node
|
|
68
|
+
* whose value is absent evaluates to 0.
|
|
69
|
+
*/
|
|
70
|
+
const evaluateCalc = (expr, answers, resolved) => {
|
|
52
71
|
if (!expr) return 0;
|
|
53
|
-
return evalNode(expr,
|
|
72
|
+
return evalNode(expr, {
|
|
73
|
+
answers,
|
|
74
|
+
resolved
|
|
75
|
+
}, 0);
|
|
54
76
|
};
|
|
55
77
|
//#endregion
|
|
56
|
-
export { evaluateCalc };
|
|
78
|
+
export { calcWeightKey, evaluateCalc };
|
|
57
79
|
|
|
58
80
|
//# sourceMappingURL=evaluate.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"evaluate.js","names":[],"sources":["../../src/calc/evaluate.ts"],"sourcesContent":["import { type CalcExpression, MAX_DEPTH } from './types'\n\nconst toNumber = (value: unknown): number => {\n\tif (typeof value === 'number') {\n\t\treturn Number.isFinite(value) ? value : 0\n\t}\n\tif (typeof value === 'string' && value.trim() !== '') {\n\t\tconst n = Number(value)\n\t\treturn Number.isFinite(n) ? n : 0\n\t}\n\treturn 0\n}\n\nconst finite = (n: number): number => (Number.isFinite(n) ? n : 0)\n\nconst FUNCTIONS: Record<string, (args: number[]) => number> = {\n\tmin: (a) => (a.length ? Math.min(...a) : 0),\n\tmax: (a) => (a.length ? Math.max(...a) : 0),\n\tround: (a) => Math.round(a[0] ?? 0),\n\tabs: (a) => Math.abs(a[0] ?? 0),\n\tceil: (a) => Math.ceil(a[0] ?? 0),\n\tfloor: (a) => Math.floor(a[0] ?? 0),\n}\n\
|
|
1
|
+
{"version":3,"file":"evaluate.js","names":[],"sources":["../../src/calc/evaluate.ts"],"sourcesContent":["import { type CalcExpression, MAX_DEPTH } from './types'\n\n/**\n * Server-resolved calc extension values, threaded into evaluation as data (never re-resolved\n * here, so the evaluator stays sync and isomorphic). `sources`/`weights` are serializable and\n * ride the form document for the client's live preview; `functions` never serialize and are\n * supplied per environment (registry `apply` fns on the server, the Form prop on the client).\n */\nexport type CalcResolved = {\n\tsources?: Record<string, number>\n\t/** Keyed `source + ' ' + field` (see `calcWeightKey` / `resolveCalcContext`). */\n\tweights?: Record<string, Record<string, number>>\n\tfunctions?: Record<string, (args: number[]) => number>\n}\n\n/**\n * The `CalcResolved.weights` key for a sourced weight node: source key + space + field name.\n * Unambiguous because registered source keys can never contain a space\n * (`assertValidCalcSourceKeys` enforces `[\\w.-]` at boot), so the first space always ends the\n * source segment.\n */\nexport const calcWeightKey = (source: string, field: string): string => `${source} ${field}`\n\nconst toNumber = (value: unknown): number => {\n\tif (typeof value === 'number') {\n\t\treturn Number.isFinite(value) ? value : 0\n\t}\n\tif (typeof value === 'string' && value.trim() !== '') {\n\t\tconst n = Number(value)\n\t\treturn Number.isFinite(n) ? n : 0\n\t}\n\treturn 0\n}\n\nconst finite = (n: number): number => (Number.isFinite(n) ? n : 0)\n\nconst FUNCTIONS: Record<string, (args: number[]) => number> = {\n\tmin: (a) => (a.length ? Math.min(...a) : 0),\n\tmax: (a) => (a.length ? Math.max(...a) : 0),\n\tround: (a) => Math.round(a[0] ?? 0),\n\tabs: (a) => Math.abs(a[0] ?? 0),\n\tceil: (a) => Math.ceil(a[0] ?? 0),\n\tfloor: (a) => Math.floor(a[0] ?? 0),\n}\n\ntype EvalContext = { answers: Record<string, unknown>; resolved?: CalcResolved }\n\nconst evalNode = (expr: CalcExpression, ctx: EvalContext, depth: number): number => {\n\tif (depth > MAX_DEPTH || expr == null || typeof expr !== 'object') {\n\t\treturn 0\n\t}\n\tconst { answers, resolved } = ctx\n\tswitch (expr.type) {\n\t\tcase 'lit':\n\t\t\treturn finite(toNumber(expr.value))\n\t\tcase 'ref':\n\t\t\treturn toNumber(answers[expr.field])\n\t\tcase 'source':\n\t\t\treturn toNumber(resolved?.sources?.[expr.source])\n\t\tcase 'neg':\n\t\t\treturn finite(-evalNode(expr.operand, ctx, depth + 1))\n\t\tcase 'op': {\n\t\t\tconst l = evalNode(expr.left, ctx, depth + 1)\n\t\t\tconst r = evalNode(expr.right, ctx, depth + 1)\n\t\t\tswitch (expr.op) {\n\t\t\t\tcase '+':\n\t\t\t\t\treturn finite(l + r)\n\t\t\t\tcase '-':\n\t\t\t\t\treturn finite(l - r)\n\t\t\t\tcase '*':\n\t\t\t\t\treturn finite(l * r)\n\t\t\t\tcase '/':\n\t\t\t\t\treturn r === 0 ? 0 : finite(l / r)\n\t\t\t\tcase '%':\n\t\t\t\t\treturn r === 0 ? 0 : finite(l % r)\n\t\t\t\tdefault:\n\t\t\t\t\treturn 0\n\t\t\t}\n\t\t}\n\t\tcase 'fn': {\n\t\t\t// Built-ins always win, so a resolved custom fn can never shadow the canonical grammar.\n\t\t\tconst fn = FUNCTIONS[expr.fn] ?? resolved?.functions?.[expr.fn]\n\t\t\tif (!fn) {\n\t\t\t\treturn 0\n\t\t\t}\n\t\t\tconst args = Array.isArray(expr.args) ? expr.args.map((a) => evalNode(a, ctx, depth + 1)) : []\n\t\t\t// Custom fns are host code: a throw must degrade to 0, not break the whole evaluation.\n\t\t\ttry {\n\t\t\t\treturn finite(fn(args))\n\t\t\t} catch {\n\t\t\t\treturn 0\n\t\t\t}\n\t\t}\n\t\tcase 'weight': {\n\t\t\t// A sourced weight always reads the resolved map (missing map -> 0 per chosen); inline weights are ignored.\n\t\t\tconst weights =\n\t\t\t\ttypeof expr.source === 'string'\n\t\t\t\t\t? (resolved?.weights?.[calcWeightKey(expr.source, expr.field)] ?? {})\n\t\t\t\t\t: (expr.weights ?? {})\n\t\t\tconst value = answers[expr.field]\n\t\t\tconst chosen = Array.isArray(value) ? value : value == null || value === '' ? [] : [value]\n\t\t\treturn finite(\n\t\t\t\tchosen.reduce((sum: number, v: unknown) => sum + toNumber(weights[String(v)]), 0)\n\t\t\t)\n\t\t}\n\t\tdefault:\n\t\t\treturn 0\n\t}\n}\n\n/**\n * Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod\n * by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). `resolved` supplies\n * server-resolved source values, sourced weight maps, and custom functions; any extension node\n * whose value is absent evaluates to 0.\n */\nexport const evaluateCalc = (\n\texpr: CalcExpression | null | undefined,\n\tanswers: Record<string, unknown>,\n\tresolved?: CalcResolved\n): number => {\n\tif (!expr) {\n\t\treturn 0\n\t}\n\treturn evalNode(expr, { answers, resolved }, 0)\n}\n"],"mappings":";;;;;;;AAqBA,MAAa,iBAAiB,QAAgB,UAA0B,GAAG,OAAO,GAAG;AAErF,MAAM,YAAY,UAA2B;CAC5C,IAAI,OAAO,UAAU,UACpB,OAAO,OAAO,SAAS,KAAK,IAAI,QAAQ;CAEzC,IAAI,OAAO,UAAU,YAAY,MAAM,KAAK,MAAM,IAAI;EACrD,MAAM,IAAI,OAAO,KAAK;EACtB,OAAO,OAAO,SAAS,CAAC,IAAI,IAAI;CACjC;CACA,OAAO;AACR;AAEA,MAAM,UAAU,MAAuB,OAAO,SAAS,CAAC,IAAI,IAAI;AAEhE,MAAM,YAAwD;CAC7D,MAAM,MAAO,EAAE,SAAS,KAAK,IAAI,GAAG,CAAC,IAAI;CACzC,MAAM,MAAO,EAAE,SAAS,KAAK,IAAI,GAAG,CAAC,IAAI;CACzC,QAAQ,MAAM,KAAK,MAAM,EAAE,MAAM,CAAC;CAClC,MAAM,MAAM,KAAK,IAAI,EAAE,MAAM,CAAC;CAC9B,OAAO,MAAM,KAAK,KAAK,EAAE,MAAM,CAAC;CAChC,QAAQ,MAAM,KAAK,MAAM,EAAE,MAAM,CAAC;AACnC;AAIA,MAAM,YAAY,MAAsB,KAAkB,UAA0B;CACnF,IAAI,QAAA,MAAqB,QAAQ,QAAQ,OAAO,SAAS,UACxD,OAAO;CAER,MAAM,EAAE,SAAS,aAAa;CAC9B,QAAQ,KAAK,MAAb;EACC,KAAK,OACJ,OAAO,OAAO,SAAS,KAAK,KAAK,CAAC;EACnC,KAAK,OACJ,OAAO,SAAS,QAAQ,KAAK,MAAM;EACpC,KAAK,UACJ,OAAO,SAAS,UAAU,UAAU,KAAK,OAAO;EACjD,KAAK,OACJ,OAAO,OAAO,CAAC,SAAS,KAAK,SAAS,KAAK,QAAQ,CAAC,CAAC;EACtD,KAAK,MAAM;GACV,MAAM,IAAI,SAAS,KAAK,MAAM,KAAK,QAAQ,CAAC;GAC5C,MAAM,IAAI,SAAS,KAAK,OAAO,KAAK,QAAQ,CAAC;GAC7C,QAAQ,KAAK,IAAb;IACC,KAAK,KACJ,OAAO,OAAO,IAAI,CAAC;IACpB,KAAK,KACJ,OAAO,OAAO,IAAI,CAAC;IACpB,KAAK,KACJ,OAAO,OAAO,IAAI,CAAC;IACpB,KAAK,KACJ,OAAO,MAAM,IAAI,IAAI,OAAO,IAAI,CAAC;IAClC,KAAK,KACJ,OAAO,MAAM,IAAI,IAAI,OAAO,IAAI,CAAC;IAClC,SACC,OAAO;GACT;EACD;EACA,KAAK,MAAM;GAEV,MAAM,KAAK,UAAU,KAAK,OAAO,UAAU,YAAY,KAAK;GAC5D,IAAI,CAAC,IACJ,OAAO;GAER,MAAM,OAAO,MAAM,QAAQ,KAAK,IAAI,IAAI,KAAK,KAAK,KAAK,MAAM,SAAS,GAAG,KAAK,QAAQ,CAAC,CAAC,IAAI,CAAC;GAE7F,IAAI;IACH,OAAO,OAAO,GAAG,IAAI,CAAC;GACvB,QAAQ;IACP,OAAO;GACR;EACD;EACA,KAAK,UAAU;GAEd,MAAM,UACL,OAAO,KAAK,WAAW,WACnB,UAAU,UAAU,cAAc,KAAK,QAAQ,KAAK,KAAK,MAAM,CAAC,IAChE,KAAK,WAAW,CAAC;GACtB,MAAM,QAAQ,QAAQ,KAAK;GAE3B,OAAO,QADQ,MAAM,QAAQ,KAAK,IAAI,QAAQ,SAAS,QAAQ,UAAU,KAAK,CAAC,IAAI,CAAC,KAAK,GAEjF,QAAQ,KAAa,MAAe,MAAM,SAAS,QAAQ,OAAO,CAAC,EAAE,GAAG,CAAC,CACjF;EACD;EACA,SACC,OAAO;CACT;AACD;;;;;;;AAQA,MAAa,gBACZ,MACA,SACA,aACY;CACZ,IAAI,CAAC,MACJ,OAAO;CAER,OAAO,SAAS,MAAM;EAAE;EAAS;CAAS,GAAG,CAAC;AAC/C"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { CalcExpression } from "./types.js";
|
|
2
|
+
|
|
3
|
+
//#region src/calc/formatCalc.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Renders a CalcExpression as a human-readable one-line string for the visual expression builder's
|
|
6
|
+
* live preview. `sourceLabel` resolves a calc source key to its display label (raw key when omitted).
|
|
7
|
+
*/
|
|
8
|
+
declare const formatCalc: (expr: CalcExpression, labelOf: (field: string) => string, sourceLabel?: (key: string) => string) => string;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { formatCalc };
|
|
11
|
+
//# sourceMappingURL=formatCalc.d.ts.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//#region src/calc/formatCalc.ts
|
|
2
|
+
const OP_GLYPHS = {
|
|
3
|
+
"+": "+",
|
|
4
|
+
"-": "−",
|
|
5
|
+
"*": "×",
|
|
6
|
+
"/": "÷",
|
|
7
|
+
"%": "%"
|
|
8
|
+
};
|
|
9
|
+
const wrap = (child, resolvers, wrapTypes = ["op"]) => {
|
|
10
|
+
const formatted = format(child, resolvers);
|
|
11
|
+
return wrapTypes.includes(child.type) ? `(${formatted})` : formatted;
|
|
12
|
+
};
|
|
13
|
+
const format = (expr, resolvers) => {
|
|
14
|
+
switch (expr.type) {
|
|
15
|
+
case "lit": return String(expr.value);
|
|
16
|
+
case "ref": return resolvers.labelOf(expr.field);
|
|
17
|
+
case "neg": return `−${wrap(expr.operand, resolvers, ["op", "neg"])}`;
|
|
18
|
+
case "op": return `${wrap(expr.left, resolvers)} ${OP_GLYPHS[expr.op]} ${wrap(expr.right, resolvers)}`;
|
|
19
|
+
case "fn": return `${expr.fn}(${expr.args.map((arg) => format(arg, resolvers)).join(", ")})`;
|
|
20
|
+
case "source": return resolvers.sourceLabel(expr.source);
|
|
21
|
+
case "weight": return `weights(${resolvers.labelOf(expr.field)})`;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Renders a CalcExpression as a human-readable one-line string for the visual expression builder's
|
|
26
|
+
* live preview. `sourceLabel` resolves a calc source key to its display label (raw key when omitted).
|
|
27
|
+
*/
|
|
28
|
+
const formatCalc = (expr, labelOf, sourceLabel = (key) => key) => format(expr, {
|
|
29
|
+
labelOf,
|
|
30
|
+
sourceLabel
|
|
31
|
+
});
|
|
32
|
+
//#endregion
|
|
33
|
+
export { formatCalc };
|
|
34
|
+
|
|
35
|
+
//# sourceMappingURL=formatCalc.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"formatCalc.js","names":[],"sources":["../../src/calc/formatCalc.ts"],"sourcesContent":["import type { CalcExpression, CalcOp } from './types'\n\nconst OP_GLYPHS: Record<CalcOp, string> = {\n\t'+': '+',\n\t'-': '−',\n\t'*': '×',\n\t'/': '÷',\n\t'%': '%',\n}\n\ntype LabelResolvers = {\n\tlabelOf: (field: string) => string\n\tsourceLabel: (key: string) => string\n}\n\nconst wrap = (\n\tchild: CalcExpression,\n\tresolvers: LabelResolvers,\n\twrapTypes: readonly CalcExpression['type'][] = ['op']\n): string => {\n\tconst formatted = format(child, resolvers)\n\treturn wrapTypes.includes(child.type) ? `(${formatted})` : formatted\n}\n\nconst format = (expr: CalcExpression, resolvers: LabelResolvers): string => {\n\tswitch (expr.type) {\n\t\tcase 'lit':\n\t\t\treturn String(expr.value)\n\t\tcase 'ref':\n\t\t\treturn resolvers.labelOf(expr.field)\n\t\tcase 'neg':\n\t\t\t// Parenthesize a nested `neg` operand too, otherwise \"-(-5)\" glues into the ambiguous \"--5\".\n\t\t\treturn `−${wrap(expr.operand, resolvers, ['op', 'neg'])}`\n\t\tcase 'op':\n\t\t\treturn `${wrap(expr.left, resolvers)} ${OP_GLYPHS[expr.op]} ${wrap(expr.right, resolvers)}`\n\t\tcase 'fn':\n\t\t\treturn `${expr.fn}(${expr.args.map((arg) => format(arg, resolvers)).join(', ')})`\n\t\tcase 'source':\n\t\t\treturn resolvers.sourceLabel(expr.source)\n\t\tcase 'weight':\n\t\t\treturn `weights(${resolvers.labelOf(expr.field)})`\n\t}\n}\n\n/**\n * Renders a CalcExpression as a human-readable one-line string for the visual expression builder's\n * live preview. `sourceLabel` resolves a calc source key to its display label (raw key when omitted).\n */\nexport const formatCalc = (\n\texpr: CalcExpression,\n\tlabelOf: (field: string) => string,\n\tsourceLabel: (key: string) => string = (key) => key\n): string => format(expr, { labelOf, sourceLabel })\n"],"mappings":";AAEA,MAAM,YAAoC;CACzC,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;AACN;AAOA,MAAM,QACL,OACA,WACA,YAA+C,CAAC,IAAI,MACxC;CACZ,MAAM,YAAY,OAAO,OAAO,SAAS;CACzC,OAAO,UAAU,SAAS,MAAM,IAAI,IAAI,IAAI,UAAU,KAAK;AAC5D;AAEA,MAAM,UAAU,MAAsB,cAAsC;CAC3E,QAAQ,KAAK,MAAb;EACC,KAAK,OACJ,OAAO,OAAO,KAAK,KAAK;EACzB,KAAK,OACJ,OAAO,UAAU,QAAQ,KAAK,KAAK;EACpC,KAAK,OAEJ,OAAO,IAAI,KAAK,KAAK,SAAS,WAAW,CAAC,MAAM,KAAK,CAAC;EACvD,KAAK,MACJ,OAAO,GAAG,KAAK,KAAK,MAAM,SAAS,EAAE,GAAG,UAAU,KAAK,IAAI,GAAG,KAAK,KAAK,OAAO,SAAS;EACzF,KAAK,MACJ,OAAO,GAAG,KAAK,GAAG,GAAG,KAAK,KAAK,KAAK,QAAQ,OAAO,KAAK,SAAS,CAAC,EAAE,KAAK,IAAI,EAAE;EAChF,KAAK,UACJ,OAAO,UAAU,YAAY,KAAK,MAAM;EACzC,KAAK,UACJ,OAAO,WAAW,UAAU,QAAQ,KAAK,KAAK,EAAE;CAClD;AACD;;;;;AAMA,MAAa,cACZ,MACA,SACA,eAAwC,QAAQ,QACpC,OAAO,MAAM;CAAE;CAAS;AAAY,CAAC"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/calc/formatCalcValue.d.ts
|
|
2
|
+
/** The calculation field's display settings (`decimals`, `prefix`, `suffix` config fields). */
|
|
3
|
+
type CalcDisplayConfig = {
|
|
4
|
+
decimals?: unknown;
|
|
5
|
+
prefix?: unknown;
|
|
6
|
+
suffix?: unknown;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Formats a computed calculation value for display: fixed `decimals` when configured, wrapped in
|
|
10
|
+
* `prefix`/`suffix` exactly as authored (no injected spaces; authors control spacing in their
|
|
11
|
+
* strings). Shared by the visitor renderer and the definition's `format` so the frontend, admin
|
|
12
|
+
* answers view, emails, and recall all agree.
|
|
13
|
+
*/
|
|
14
|
+
declare const formatCalcValue: (value: number, config?: CalcDisplayConfig) => string;
|
|
15
|
+
//#endregion
|
|
16
|
+
export { CalcDisplayConfig, formatCalcValue };
|
|
17
|
+
//# sourceMappingURL=formatCalcValue.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
//#region src/calc/formatCalcValue.ts
|
|
2
|
+
const asDecimals = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 6 ? value : void 0;
|
|
3
|
+
/**
|
|
4
|
+
* Formats a computed calculation value for display: fixed `decimals` when configured, wrapped in
|
|
5
|
+
* `prefix`/`suffix` exactly as authored (no injected spaces; authors control spacing in their
|
|
6
|
+
* strings). Shared by the visitor renderer and the definition's `format` so the frontend, admin
|
|
7
|
+
* answers view, emails, and recall all agree.
|
|
8
|
+
*/
|
|
9
|
+
const formatCalcValue = (value, config) => {
|
|
10
|
+
const decimals = asDecimals(config?.decimals);
|
|
11
|
+
const number = decimals !== void 0 ? value.toFixed(decimals) : String(value);
|
|
12
|
+
return `${typeof config?.prefix === "string" ? config.prefix : ""}${number}${typeof config?.suffix === "string" ? config.suffix : ""}`;
|
|
13
|
+
};
|
|
14
|
+
//#endregion
|
|
15
|
+
export { formatCalcValue };
|
|
16
|
+
|
|
17
|
+
//# sourceMappingURL=formatCalcValue.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"formatCalcValue.js","names":[],"sources":["../../src/calc/formatCalcValue.ts"],"sourcesContent":["/** The calculation field's display settings (`decimals`, `prefix`, `suffix` config fields). */\nexport type CalcDisplayConfig = {\n\tdecimals?: unknown\n\tprefix?: unknown\n\tsuffix?: unknown\n}\n\nconst asDecimals = (value: unknown): number | undefined =>\n\ttypeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 6\n\t\t? value\n\t\t: undefined\n\n/**\n * Formats a computed calculation value for display: fixed `decimals` when configured, wrapped in\n * `prefix`/`suffix` exactly as authored (no injected spaces; authors control spacing in their\n * strings). Shared by the visitor renderer and the definition's `format` so the frontend, admin\n * answers view, emails, and recall all agree.\n */\nexport const formatCalcValue = (value: number, config?: CalcDisplayConfig): string => {\n\tconst decimals = asDecimals(config?.decimals)\n\tconst number = decimals !== undefined ? value.toFixed(decimals) : String(value)\n\tconst prefix = typeof config?.prefix === 'string' ? config.prefix : ''\n\tconst suffix = typeof config?.suffix === 'string' ? config.suffix : ''\n\treturn `${prefix}${number}${suffix}`\n}\n"],"mappings":";AAOA,MAAM,cAAc,UACnB,OAAO,UAAU,YAAY,OAAO,UAAU,KAAK,KAAK,SAAS,KAAK,SAAS,IAC5E,QACA,KAAA;;;;;;;AAQJ,MAAa,mBAAmB,OAAe,WAAuC;CACrF,MAAM,WAAW,WAAW,QAAQ,QAAQ;CAC5C,MAAM,SAAS,aAAa,KAAA,IAAY,MAAM,QAAQ,QAAQ,IAAI,OAAO,KAAK;CAG9E,OAAO,GAFQ,OAAO,QAAQ,WAAW,WAAW,OAAO,SAAS,KAEjD,SADJ,OAAO,QAAQ,WAAW,WAAW,OAAO,SAAS;AAErE"}
|
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { CalcExpression } from "./types.js";
|
|
2
2
|
|
|
3
3
|
//#region src/calc/normalizeCalc.d.ts
|
|
4
|
-
/**
|
|
5
|
-
|
|
4
|
+
/**
|
|
5
|
+
* The extension names a normalized expression may reference: registered calc source keys and custom
|
|
6
|
+
* function names (plugin option `calc`). Absent (the default for every un-threaded caller), only the
|
|
7
|
+
* built-in grammar is valid: `source` nodes, sourced weights, and custom fns all reject.
|
|
8
|
+
*/
|
|
9
|
+
type CalcAllowed = {
|
|
10
|
+
sources?: ReadonlySet<string>;
|
|
11
|
+
functions?: ReadonlySet<string>;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Structural guard: returns a valid CalcExpression if `value` is structurally sound (depth-guarded
|
|
15
|
+
* at 64), otherwise undefined. Never throws. `allowed` admits registered extension names (source
|
|
16
|
+
* keys, custom function names); without it only the built-in grammar passes, so an un-threaded
|
|
17
|
+
* caller can never validate an expression the evaluator has no values for.
|
|
18
|
+
*/
|
|
19
|
+
declare const normalizeCalc: (value: unknown, allowed?: CalcAllowed) => CalcExpression | undefined;
|
|
6
20
|
//#endregion
|
|
7
|
-
export { normalizeCalc };
|
|
21
|
+
export { CalcAllowed, normalizeCalc };
|
|
8
22
|
//# sourceMappingURL=normalizeCalc.d.ts.map
|
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { CALC_OPS, isCalcFn } from "./types.js";
|
|
2
2
|
//#region src/calc/normalizeCalc.ts
|
|
3
3
|
const isOp = (v) => CALC_OPS.includes(v);
|
|
4
|
-
const isFn = (v) => CALC_FNS.includes(v);
|
|
5
4
|
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
6
|
-
const normalizeNode = (value, depth) => {
|
|
5
|
+
const normalizeNode = (value, depth, allowed) => {
|
|
7
6
|
if (depth > 64) return void 0;
|
|
8
7
|
if (!isRecord(value)) return void 0;
|
|
9
8
|
switch (value.type) {
|
|
@@ -26,9 +25,9 @@ const normalizeNode = (value, depth) => {
|
|
|
26
25
|
case "op": {
|
|
27
26
|
const { op, left, right } = value;
|
|
28
27
|
if (!isOp(op)) return void 0;
|
|
29
|
-
const l = normalizeNode(left, depth + 1);
|
|
28
|
+
const l = normalizeNode(left, depth + 1, allowed);
|
|
30
29
|
if (!l) return void 0;
|
|
31
|
-
const r = normalizeNode(right, depth + 1);
|
|
30
|
+
const r = normalizeNode(right, depth + 1, allowed);
|
|
32
31
|
if (!r) return void 0;
|
|
33
32
|
return {
|
|
34
33
|
type: "op",
|
|
@@ -38,7 +37,7 @@ const normalizeNode = (value, depth) => {
|
|
|
38
37
|
};
|
|
39
38
|
}
|
|
40
39
|
case "neg": {
|
|
41
|
-
const operand = normalizeNode(value.operand, depth + 1);
|
|
40
|
+
const operand = normalizeNode(value.operand, depth + 1, allowed);
|
|
42
41
|
if (!operand) return void 0;
|
|
43
42
|
return {
|
|
44
43
|
type: "neg",
|
|
@@ -47,11 +46,12 @@ const normalizeNode = (value, depth) => {
|
|
|
47
46
|
}
|
|
48
47
|
case "fn": {
|
|
49
48
|
const { fn, args } = value;
|
|
50
|
-
if (
|
|
49
|
+
if (typeof fn !== "string") return void 0;
|
|
50
|
+
if (!isCalcFn(fn) && !allowed?.functions?.has(fn)) return void 0;
|
|
51
51
|
if (!Array.isArray(args)) return void 0;
|
|
52
52
|
const normalizedArgs = [];
|
|
53
53
|
for (const arg of args) {
|
|
54
|
-
const a = normalizeNode(arg, depth + 1);
|
|
54
|
+
const a = normalizeNode(arg, depth + 1, allowed);
|
|
55
55
|
if (!a) return void 0;
|
|
56
56
|
normalizedArgs.push(a);
|
|
57
57
|
}
|
|
@@ -61,26 +61,48 @@ const normalizeNode = (value, depth) => {
|
|
|
61
61
|
args: normalizedArgs
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
+
case "source": {
|
|
65
|
+
const { source } = value;
|
|
66
|
+
if (typeof source !== "string") return void 0;
|
|
67
|
+
if (!allowed?.sources?.has(source)) return void 0;
|
|
68
|
+
return {
|
|
69
|
+
type: "source",
|
|
70
|
+
source
|
|
71
|
+
};
|
|
72
|
+
}
|
|
64
73
|
case "weight": {
|
|
65
|
-
const { field, weights } = value;
|
|
74
|
+
const { field, weights, source } = value;
|
|
66
75
|
if (typeof field !== "string") return void 0;
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
if (source !== void 0) {
|
|
77
|
+
if (typeof source !== "string" || !allowed?.sources?.has(source)) return void 0;
|
|
78
|
+
}
|
|
79
|
+
let normalizedWeights;
|
|
80
|
+
if (weights !== void 0) {
|
|
81
|
+
if (!isRecord(weights)) return void 0;
|
|
82
|
+
normalizedWeights = {};
|
|
83
|
+
for (const [k, v] of Object.entries(weights)) {
|
|
84
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return void 0;
|
|
85
|
+
normalizedWeights[k] = v;
|
|
86
|
+
}
|
|
72
87
|
}
|
|
88
|
+
if (normalizedWeights === void 0 && source === void 0) return void 0;
|
|
73
89
|
return {
|
|
74
90
|
type: "weight",
|
|
75
91
|
field,
|
|
76
|
-
weights: normalizedWeights
|
|
92
|
+
...normalizedWeights !== void 0 ? { weights: normalizedWeights } : {},
|
|
93
|
+
...typeof source === "string" ? { source } : {}
|
|
77
94
|
};
|
|
78
95
|
}
|
|
79
96
|
default: return;
|
|
80
97
|
}
|
|
81
98
|
};
|
|
82
|
-
/**
|
|
83
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Structural guard: returns a valid CalcExpression if `value` is structurally sound (depth-guarded
|
|
101
|
+
* at 64), otherwise undefined. Never throws. `allowed` admits registered extension names (source
|
|
102
|
+
* keys, custom function names); without it only the built-in grammar passes, so an un-threaded
|
|
103
|
+
* caller can never validate an expression the evaluator has no values for.
|
|
104
|
+
*/
|
|
105
|
+
const normalizeCalc = (value, allowed) => normalizeNode(value, 0, allowed);
|
|
84
106
|
//#endregion
|
|
85
107
|
export { normalizeCalc };
|
|
86
108
|
|