@10x-media/form-builder 0.1.0-beta.0 → 0.1.0-beta.1
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 +52 -0
- package/README.md +43 -1402
- package/dist/actions/buildActionBlocks.js +19 -0
- package/dist/actions/buildActionBlocks.js.map +1 -0
- package/dist/actions/builtin/confirmation.js +49 -0
- package/dist/actions/builtin/confirmation.js.map +1 -0
- package/dist/actions/builtin/emailTeam.js +46 -0
- package/dist/actions/builtin/emailTeam.js.map +1 -0
- package/dist/actions/builtin/index.d.ts +7 -0
- package/dist/actions/builtin/index.js +13 -0
- package/dist/actions/builtin/index.js.map +1 -0
- package/dist/actions/builtin/signedWebhook.js +49 -0
- package/dist/actions/builtin/signedWebhook.js.map +1 -0
- package/dist/actions/defineAction.d.ts +36 -0
- package/dist/actions/defineAction.js +6 -0
- package/dist/actions/defineAction.js.map +1 -0
- package/dist/actions/dispatch.js +48 -0
- package/dist/actions/dispatch.js.map +1 -0
- package/dist/actions/registry.d.ts +16 -0
- package/dist/actions/registry.js +16 -0
- package/dist/actions/registry.js.map +1 -0
- package/dist/actions/runActions.d.ts +9 -0
- package/dist/actions/runActions.js +34 -0
- package/dist/actions/runActions.js.map +1 -0
- package/dist/actions/sign.d.ts +7 -0
- package/dist/actions/sign.js +9 -0
- package/dist/actions/sign.js.map +1 -0
- package/dist/actions/task.js +82 -0
- package/dist/actions/task.js.map +1 -0
- package/dist/aggregation/aggregateResponses.d.ts +31 -0
- package/dist/aggregation/aggregateResponses.js +86 -0
- package/dist/aggregation/aggregateResponses.js.map +1 -0
- package/dist/aggregation/aggregateRows.d.ts +10 -0
- package/dist/aggregation/aggregateRows.js +64 -0
- package/dist/aggregation/aggregateRows.js.map +1 -0
- package/dist/aggregation/resolveResultsRequest.d.ts +32 -0
- package/dist/aggregation/resolveResultsRequest.js +56 -0
- package/dist/aggregation/resolveResultsRequest.js.map +1 -0
- package/dist/aggregation/types.d.ts +39 -0
- package/dist/calc/computeCalcFields.d.ts +15 -0
- package/dist/calc/computeCalcFields.js +27 -0
- package/dist/calc/computeCalcFields.js.map +1 -0
- package/dist/calc/evaluate.d.ts +8 -0
- package/dist/calc/evaluate.js +59 -0
- package/dist/calc/evaluate.js.map +1 -0
- package/dist/calc/normalizeCalc.d.ts +8 -0
- package/dist/calc/normalizeCalc.js +100 -0
- package/dist/calc/normalizeCalc.js.map +1 -0
- package/dist/calc/types.d.ts +28 -0
- package/dist/client/ConditionBuilder.js +121 -0
- package/dist/client/ConditionBuilder.js.map +1 -0
- package/dist/client/ConditionRow.js +150 -0
- package/dist/client/ConditionRow.js.map +1 -0
- package/dist/client/FlowBuilder.d.ts +15 -0
- package/dist/client/FlowBuilder.js +408 -0
- package/dist/client/FlowBuilder.js.map +1 -0
- package/dist/client/FormConditionField.d.ts +21 -0
- package/dist/client/FormConditionField.js +33 -0
- package/dist/client/FormConditionField.js.map +1 -0
- package/dist/client/synthesizeClientField.js +46 -0
- package/dist/client/synthesizeClientField.js.map +1 -0
- package/dist/collections/formSubmissions.js +138 -0
- package/dist/collections/formSubmissions.js.map +1 -0
- package/dist/collections/forms.js +187 -0
- package/dist/collections/forms.js.map +1 -0
- package/dist/collections/uploads.d.ts +29 -0
- package/dist/collections/uploads.js +46 -0
- package/dist/collections/uploads.js.map +1 -0
- package/dist/conditions/conditionType.js +14 -0
- package/dist/conditions/conditionType.js.map +1 -0
- package/dist/conditions/evaluate.d.ts +17 -0
- package/dist/conditions/evaluate.js +94 -0
- package/dist/conditions/evaluate.js.map +1 -0
- package/dist/conditions/fieldTypes.d.ts +9 -0
- package/dist/{fieldTypes-CzhhJXjg.js → conditions/fieldTypes.js} +2 -2
- package/dist/conditions/fieldTypes.js.map +1 -0
- package/dist/conditions/normalizeConditions.js +47 -0
- package/dist/conditions/normalizeConditions.js.map +1 -0
- package/dist/conditions/types.d.ts +8 -0
- package/dist/consent/buildConsentSourceConfig.js +49 -0
- package/dist/consent/buildConsentSourceConfig.js.map +1 -0
- package/dist/consent/builtin/index.d.ts +7 -0
- package/dist/consent/builtin/index.js +11 -0
- package/dist/consent/builtin/index.js.map +1 -0
- package/dist/consent/builtin/pageReference.js +75 -0
- package/dist/consent/builtin/pageReference.js.map +1 -0
- package/dist/consent/builtin/static.js +44 -0
- package/dist/consent/builtin/static.js.map +1 -0
- package/dist/consent/captureConsent.d.ts +28 -0
- package/dist/consent/captureConsent.js +26 -0
- package/dist/consent/captureConsent.js.map +1 -0
- package/dist/consent/defineConsentSource.d.ts +35 -0
- package/dist/consent/defineConsentSource.js +6 -0
- package/dist/consent/defineConsentSource.js.map +1 -0
- package/dist/consent/registry.d.ts +16 -0
- package/dist/consent/registry.js +16 -0
- package/dist/consent/registry.js.map +1 -0
- package/dist/consent/resolveConsentLinks.d.ts +19 -0
- package/dist/consent/resolveConsentLinks.js +26 -0
- package/dist/consent/resolveConsentLinks.js.map +1 -0
- package/dist/consent/resolvePublishedVersionRef.d.ts +17 -0
- package/dist/consent/resolvePublishedVersionRef.js +27 -0
- package/dist/consent/resolvePublishedVersionRef.js.map +1 -0
- package/dist/events/noopSink.js +6 -0
- package/dist/events/noopSink.js.map +1 -0
- package/dist/events/resolveEventSink.js +8 -0
- package/dist/events/resolveEventSink.js.map +1 -0
- package/dist/events/types.d.ts +31 -0
- package/dist/exports/client.d.ts +3 -21
- package/dist/exports/client.js +3 -319
- package/dist/exports/i18n.d.ts +3 -138
- package/dist/exports/i18n.js +2 -2
- package/dist/exports/react.d.ts +45 -550
- package/dist/exports/react.js +39 -1563
- package/dist/exports/rsc.d.ts +2 -16
- package/dist/exports/rsc.js +1 -54
- package/dist/exports/types.d.ts +27 -5
- package/dist/fields/buildFieldBlocks.js +46 -0
- package/dist/fields/buildFieldBlocks.js.map +1 -0
- package/dist/fields/builtin/calculation.js +24 -0
- package/dist/fields/builtin/calculation.js.map +1 -0
- package/dist/fields/builtin/checkbox.js +13 -0
- package/dist/fields/builtin/checkbox.js.map +1 -0
- package/dist/fields/builtin/consent.js +32 -0
- package/dist/fields/builtin/consent.js.map +1 -0
- package/dist/fields/builtin/email.js +18 -0
- package/dist/fields/builtin/email.js.map +1 -0
- package/dist/fields/builtin/file.js +40 -0
- package/dist/fields/builtin/file.js.map +1 -0
- package/dist/fields/builtin/index.js +25 -0
- package/dist/fields/builtin/index.js.map +1 -0
- package/dist/fields/builtin/number.js +17 -0
- package/dist/fields/builtin/number.js.map +1 -0
- package/dist/fields/builtin/select.js +44 -0
- package/dist/fields/builtin/select.js.map +1 -0
- package/dist/fields/builtin/text.js +13 -0
- package/dist/fields/builtin/text.js.map +1 -0
- package/dist/fields/builtin/textarea.js +13 -0
- package/dist/fields/builtin/textarea.js.map +1 -0
- package/dist/fields/defineFormField.d.ts +13 -0
- package/dist/fields/defineFormField.js +12 -0
- package/dist/fields/defineFormField.js.map +1 -0
- package/dist/fields/registry.d.ts +10 -0
- package/dist/fields/registry.js +24 -0
- package/dist/fields/registry.js.map +1 -0
- package/dist/fields/sharedConfig.js +89 -0
- package/dist/fields/sharedConfig.js.map +1 -0
- package/dist/fields/types.d.ts +88 -0
- package/dist/flow/engine.d.ts +17 -0
- package/dist/flow/engine.js +24 -0
- package/dist/flow/engine.js.map +1 -0
- package/dist/flow/normalizeFlow.js +35 -0
- package/dist/flow/normalizeFlow.js.map +1 -0
- package/dist/{types-DsJ_6kGJ.d.ts → flow/types.d.ts} +2 -2
- package/dist/index.d.ts +74 -4
- package/dist/index.js +40 -1889
- package/dist/index.js.map +1 -1
- package/dist/plugin/access.js +12 -0
- package/dist/plugin/access.js.map +1 -0
- package/dist/plugin/registerCollections.js +40 -0
- package/dist/plugin/registerCollections.js.map +1 -0
- package/dist/plugin/registerTranslations.js +19 -0
- package/dist/plugin/registerTranslations.js.map +1 -0
- package/dist/prefill/valuesFromSearchParams.d.ts +18 -0
- package/dist/prefill/valuesFromSearchParams.js +45 -0
- package/dist/prefill/valuesFromSearchParams.js.map +1 -0
- package/dist/presentations/defaults.d.ts +33 -0
- package/dist/presentations/defaults.js +35 -0
- package/dist/presentations/defaults.js.map +1 -0
- package/dist/presentations/registry.d.ts +15 -0
- package/dist/presentations/registry.js +15 -0
- package/dist/presentations/registry.js.map +1 -0
- package/dist/presentations/types.d.ts +18 -0
- package/dist/react/Form.d.ts +81 -0
- package/dist/react/Form.js +457 -0
- package/dist/react/Form.js.map +1 -0
- package/dist/react/FormContext.d.ts +16 -0
- package/dist/react/FormContext.js +14 -0
- package/dist/react/FormContext.js.map +1 -0
- package/dist/react/FormLayout.d.ts +20 -0
- package/dist/react/FormLayout.js +14 -0
- package/dist/react/FormLayout.js.map +1 -0
- package/dist/react/FormResults.d.ts +25 -0
- package/dist/react/FormResults.js +85 -0
- package/dist/react/FormResults.js.map +1 -0
- package/dist/react/Honeypot.d.ts +22 -0
- package/dist/react/Honeypot.js +38 -0
- package/dist/react/Honeypot.js.map +1 -0
- package/dist/react/Poll.d.ts +32 -0
- package/dist/react/Poll.js +77 -0
- package/dist/react/Poll.js.map +1 -0
- package/dist/react/cn.d.ts +6 -0
- package/dist/react/cn.js +8 -0
- package/dist/react/cn.js.map +1 -0
- package/dist/react/contract.d.ts +32 -0
- package/dist/react/contract.js +7 -0
- package/dist/react/contract.js.map +1 -0
- package/dist/react/events.js +14 -0
- package/dist/react/events.js.map +1 -0
- package/dist/react/fetchResults.d.ts +25 -0
- package/dist/react/fetchResults.js +31 -0
- package/dist/react/fetchResults.js.map +1 -0
- package/dist/react/presentation/Backdrop.d.ts +18 -0
- package/dist/react/presentation/Backdrop.js +14 -0
- package/dist/react/presentation/Backdrop.js.map +1 -0
- package/dist/react/presentation/DialogSurface.d.ts +35 -0
- package/dist/react/presentation/DialogSurface.js +56 -0
- package/dist/react/presentation/DialogSurface.js.map +1 -0
- package/dist/react/presentation/presentations.d.ts +7 -0
- package/dist/react/presentation/presentations.js +32 -0
- package/dist/react/presentation/presentations.js.map +1 -0
- package/dist/react/presentation/registry.d.ts +11 -0
- package/dist/react/presentation/registry.js +12 -0
- package/dist/react/presentation/registry.js.map +1 -0
- package/dist/react/presentation/types.d.ts +19 -0
- package/dist/react/presentation/useDismiss.d.ts +19 -0
- package/dist/react/presentation/useDismiss.js +33 -0
- package/dist/react/presentation/useDismiss.js.map +1 -0
- package/dist/react/presentation/useFocusTrap.d.ts +8 -0
- package/dist/react/presentation/useFocusTrap.js +35 -0
- package/dist/react/presentation/useFocusTrap.js.map +1 -0
- package/dist/react/presentation/useScrollLock.d.ts +6 -0
- package/dist/react/presentation/useScrollLock.js +18 -0
- package/dist/react/presentation/useScrollLock.js.map +1 -0
- package/dist/react/primitives/Checkbox.d.ts +26 -0
- package/dist/react/primitives/Checkbox.js +20 -0
- package/dist/react/primitives/Checkbox.js.map +1 -0
- package/dist/react/primitives/FieldShell.d.ts +31 -0
- package/dist/react/primitives/FieldShell.js +51 -0
- package/dist/react/primitives/FieldShell.js.map +1 -0
- package/dist/react/primitives/Input.d.ts +30 -0
- package/dist/react/primitives/Input.js +21 -0
- package/dist/react/primitives/Input.js.map +1 -0
- package/dist/react/primitives/Select.d.ts +34 -0
- package/dist/react/primitives/Select.js +26 -0
- package/dist/react/primitives/Select.js.map +1 -0
- package/dist/react/primitives/Textarea.d.ts +28 -0
- package/dist/react/primitives/Textarea.js +20 -0
- package/dist/react/primitives/Textarea.js.map +1 -0
- package/dist/react/recall.js +20 -0
- package/dist/react/recall.js.map +1 -0
- package/dist/react/registry.d.ts +16 -0
- package/dist/react/registry.js +16 -0
- package/dist/react/registry.js.map +1 -0
- package/dist/react/renderers/calculation.js +30 -0
- package/dist/react/renderers/calculation.js.map +1 -0
- package/dist/react/renderers/checkbox.js +35 -0
- package/dist/react/renderers/checkbox.js.map +1 -0
- package/dist/react/renderers/consent.js +54 -0
- package/dist/react/renderers/consent.js.map +1 -0
- package/dist/react/renderers/email.js +37 -0
- package/dist/react/renderers/email.js.map +1 -0
- package/dist/react/renderers/file.js +92 -0
- package/dist/react/renderers/file.js.map +1 -0
- package/dist/react/renderers/index.d.ts +8 -0
- package/dist/react/renderers/index.js +26 -0
- package/dist/react/renderers/index.js.map +1 -0
- package/dist/react/renderers/number.js +40 -0
- package/dist/react/renderers/number.js.map +1 -0
- package/dist/react/renderers/select.js +38 -0
- package/dist/react/renderers/select.js.map +1 -0
- package/dist/react/renderers/text.js +37 -0
- package/dist/react/renderers/text.js.map +1 -0
- package/dist/react/renderers/textarea.js +36 -0
- package/dist/react/renderers/textarea.js.map +1 -0
- package/dist/react/resolveForm.js +16 -0
- package/dist/react/resolveForm.js.map +1 -0
- package/dist/react/state.d.ts +15 -0
- package/dist/react/state.js +70 -0
- package/dist/react/state.js.map +1 -0
- package/dist/react/submitForm.d.ts +31 -0
- package/dist/react/submitForm.js +58 -0
- package/dist/react/submitForm.js.map +1 -0
- package/dist/react/toFormDocument.d.ts +24 -0
- package/dist/react/toFormDocument.js +22 -0
- package/dist/react/toFormDocument.js.map +1 -0
- package/dist/react/uploadFile.d.ts +23 -0
- package/dist/react/uploadFile.js +39 -0
- package/dist/react/uploadFile.js.map +1 -0
- package/dist/react/useField.d.ts +14 -0
- package/dist/react/useField.js +49 -0
- package/dist/react/useField.js.map +1 -0
- package/dist/react/useFormState.d.ts +8 -0
- package/dist/react/useFormState.js +9 -0
- package/dist/react/useFormState.js.map +1 -0
- package/dist/react/useFormStep.d.ts +8 -0
- package/dist/react/useFormStep.js +9 -0
- package/dist/react/useFormStep.js.map +1 -0
- package/dist/react/validateField.js +30 -0
- package/dist/react/validateField.js.map +1 -0
- package/dist/recall/interpolate.d.ts +10 -0
- package/dist/recall/interpolate.js +16 -0
- package/dist/recall/interpolate.js.map +1 -0
- package/dist/recall/resolver.d.ts +22 -0
- package/dist/recall/resolver.js +36 -0
- package/dist/recall/resolver.js.map +1 -0
- package/dist/spam/captcha.d.ts +12 -0
- package/dist/spam/captcha.js +11 -0
- package/dist/spam/captcha.js.map +1 -0
- package/dist/spam/constants.d.ts +12 -0
- package/dist/spam/constants.js +15 -0
- package/dist/spam/constants.js.map +1 -0
- package/dist/spam/identify.d.ts +12 -0
- package/dist/spam/identify.js +20 -0
- package/dist/spam/identify.js.map +1 -0
- package/dist/spam/rateLimiter.d.ts +18 -0
- package/dist/spam/rateLimiter.js +41 -0
- package/dist/spam/rateLimiter.js.map +1 -0
- package/dist/spam/reserved.js +30 -0
- package/dist/spam/reserved.js.map +1 -0
- package/dist/spam/resolveSpam.d.ts +12 -0
- package/dist/spam/resolveSpam.js +42 -0
- package/dist/spam/resolveSpam.js.map +1 -0
- package/dist/spam/spamGuard.js +68 -0
- package/dist/spam/spamGuard.js.map +1 -0
- package/dist/spam/types.d.ts +73 -0
- package/dist/spam/uploadHooks.js +33 -0
- package/dist/spam/uploadHooks.js.map +1 -0
- package/dist/submissions/SubmissionAnswers.d.ts +16 -0
- package/dist/submissions/SubmissionAnswers.js +57 -0
- package/dist/submissions/SubmissionAnswers.js.map +1 -0
- package/dist/submissions/runSubmission.js +205 -0
- package/dist/submissions/runSubmission.js.map +1 -0
- package/dist/submissions/types.d.ts +33 -0
- package/dist/submissions/validateSubmission.js +59 -0
- package/dist/submissions/validateSubmission.js.map +1 -0
- package/dist/translations/en.d.ts +12 -0
- package/dist/{translations-edMqq_2e.js → translations/en.js} +3 -22
- package/dist/translations/en.js.map +1 -0
- package/dist/translations/index.d.ts +14 -0
- package/dist/translations/index.js +26 -0
- package/dist/translations/index.js.map +1 -0
- package/dist/translations/keys.d.ts +132 -0
- package/dist/{keys-N5xGiUsh.js → translations/keys.js} +2 -2
- package/dist/translations/keys.js.map +1 -0
- package/dist/translations/makeTranslate.d.ts +11 -0
- package/dist/translations/makeTranslate.js +11 -0
- package/dist/translations/makeTranslate.js.map +1 -0
- package/dist/translations/server.js +25 -0
- package/dist/translations/server.js.map +1 -0
- package/dist/translations/useTranslation.js +12 -0
- package/dist/translations/useTranslation.js.map +1 -0
- package/dist/uploads/captureFileRef.d.ts +27 -0
- package/dist/uploads/captureFileRef.js +34 -0
- package/dist/uploads/captureFileRef.js.map +1 -0
- package/dist/uploads/resolveFileRef.d.ts +26 -0
- package/dist/uploads/resolveFileRef.js +44 -0
- package/dist/uploads/resolveFileRef.js.map +1 -0
- package/dist/uploads/types.d.ts +20 -0
- package/dist/validation/buildRuleBlocks.js +46 -0
- package/dist/validation/buildRuleBlocks.js.map +1 -0
- package/dist/validation/builtin/email.js +19 -0
- package/dist/validation/builtin/email.js.map +1 -0
- package/dist/validation/builtin/index.js +27 -0
- package/dist/validation/builtin/index.js.map +1 -0
- package/dist/validation/builtin/matchesField.js +20 -0
- package/dist/validation/builtin/matchesField.js.map +1 -0
- package/dist/validation/builtin/max.js +21 -0
- package/dist/validation/builtin/max.js.map +1 -0
- package/dist/validation/builtin/maxLength.js +26 -0
- package/dist/validation/builtin/maxLength.js.map +1 -0
- package/dist/validation/builtin/min.js +21 -0
- package/dist/validation/builtin/min.js.map +1 -0
- package/dist/validation/builtin/minLength.js +26 -0
- package/dist/validation/builtin/minLength.js.map +1 -0
- package/dist/validation/builtin/notAlreadySubmitted.js +32 -0
- package/dist/validation/builtin/notAlreadySubmitted.js.map +1 -0
- package/dist/validation/builtin/oneOf.js +34 -0
- package/dist/validation/builtin/oneOf.js.map +1 -0
- package/dist/validation/builtin/pattern.js +41 -0
- package/dist/validation/builtin/pattern.js.map +1 -0
- package/dist/validation/builtin/url.js +22 -0
- package/dist/validation/builtin/url.js.map +1 -0
- package/dist/validation/defineValidationRule.d.ts +12 -0
- package/dist/validation/defineValidationRule.js +11 -0
- package/dist/validation/defineValidationRule.js.map +1 -0
- package/dist/validation/message.js +14 -0
- package/dist/validation/message.js.map +1 -0
- package/dist/validation/registry.d.ts +10 -0
- package/dist/validation/registry.js +20 -0
- package/dist/validation/registry.js.map +1 -0
- package/dist/validation/runValidation.js +111 -0
- package/dist/validation/runValidation.js.map +1 -0
- package/dist/validation/types.d.ts +76 -0
- package/package.json +8 -5
- package/styles/form-builder.css +201 -11
- package/dist/constants-B-BUfetP.d.ts +0 -288
- package/dist/exports/client.js.map +0 -1
- package/dist/exports/react.js.map +0 -1
- package/dist/exports/rsc.js.map +0 -1
- package/dist/fieldTypes-B8jkNRob.d.ts +0 -188
- package/dist/fieldTypes-CzhhJXjg.js.map +0 -1
- package/dist/index-DfFYGbVF.d.ts +0 -481
- package/dist/keys-N5xGiUsh.js.map +0 -1
- package/dist/registry-CoCyhtvB.js +0 -282
- package/dist/registry-CoCyhtvB.js.map +0 -1
- package/dist/resolver-OeQyVH2N.js +0 -665
- package/dist/resolver-OeQyVH2N.js.map +0 -1
- package/dist/translations-edMqq_2e.js.map +0 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { aggregateRowsForFields } from "./aggregateRows.js";
|
|
2
|
+
import { FORMS_SLUG } from "../collections/forms.js";
|
|
3
|
+
import { FORM_SUBMISSIONS_SLUG } from "../collections/formSubmissions.js";
|
|
4
|
+
//#region src/aggregation/aggregateResponses.ts
|
|
5
|
+
const optionsOf = (field) => {
|
|
6
|
+
if (!Array.isArray(field.options)) return;
|
|
7
|
+
const options = field.options.filter((option) => typeof option?.value === "string").map((option) => ({
|
|
8
|
+
value: String(option.value),
|
|
9
|
+
label: option.label ?? String(option.value)
|
|
10
|
+
}));
|
|
11
|
+
return options.length > 0 ? options : void 0;
|
|
12
|
+
};
|
|
13
|
+
/** True when a field declares non-empty options (a choice field safe to aggregate publicly). */
|
|
14
|
+
const fieldHasOptions = (field) => optionsOf(field) !== void 0;
|
|
15
|
+
const metaFor = (field) => ({
|
|
16
|
+
field: field.name,
|
|
17
|
+
label: field.label ?? field.name,
|
|
18
|
+
fieldType: field.blockType,
|
|
19
|
+
options: optionsOf(field)
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* Aggregate submission responses for a form. Loads the form for current labels + option order, then pages
|
|
23
|
+
* `payload.find` over its submissions (JSON `values` cannot be GROUP BY'd portably across Mongo + Postgres,
|
|
24
|
+
* so we scan + reduce in JS, cross-DB-identically). Bounded by `maxSubmissions`; beyond it the result is
|
|
25
|
+
* `truncated`. Returns one `FieldAggregation` per requested field (or per enumerable field by default).
|
|
26
|
+
*/
|
|
27
|
+
const aggregateFormResponses = async (args) => {
|
|
28
|
+
const { payload, formId, fields, status = "complete", req, maxSubmissions = 1e4, pageSize = 500 } = args;
|
|
29
|
+
const form = await payload.findByID({
|
|
30
|
+
collection: FORMS_SLUG,
|
|
31
|
+
id: formId,
|
|
32
|
+
depth: 0,
|
|
33
|
+
overrideAccess: true,
|
|
34
|
+
req
|
|
35
|
+
}).catch(() => null);
|
|
36
|
+
if (!form || !Array.isArray(form.fields)) return [];
|
|
37
|
+
const instances = form.fields;
|
|
38
|
+
const selected = fields ? instances.filter((field) => fields.includes(field.name)) : instances.filter((field) => optionsOf(field) !== void 0);
|
|
39
|
+
if (selected.length === 0) return [];
|
|
40
|
+
const metas = selected.map(metaFor);
|
|
41
|
+
const where = { and: [{ form: { equals: formId } }, ...status === "all" ? [] : [{ status: { equals: status } }]] };
|
|
42
|
+
const rows = [];
|
|
43
|
+
let page = 1;
|
|
44
|
+
let truncated = false;
|
|
45
|
+
for (;;) {
|
|
46
|
+
const result = await payload.find({
|
|
47
|
+
collection: FORM_SUBMISSIONS_SLUG,
|
|
48
|
+
where,
|
|
49
|
+
limit: pageSize,
|
|
50
|
+
page,
|
|
51
|
+
depth: 0,
|
|
52
|
+
overrideAccess: true,
|
|
53
|
+
req,
|
|
54
|
+
select: {
|
|
55
|
+
values: true,
|
|
56
|
+
descriptors: true
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
for (const doc of result.docs) {
|
|
60
|
+
if (rows.length >= maxSubmissions) {
|
|
61
|
+
truncated = true;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
rows.push({
|
|
65
|
+
values: doc.values,
|
|
66
|
+
descriptors: doc.descriptors
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (truncated || !result.hasNextPage) break;
|
|
70
|
+
page += 1;
|
|
71
|
+
}
|
|
72
|
+
return aggregateRowsForFields(rows, metas, truncated);
|
|
73
|
+
};
|
|
74
|
+
/** Single-field convenience over `aggregateFormResponses`. Returns the field's aggregation, or null if absent. */
|
|
75
|
+
const aggregateFieldResponses = async (args) => {
|
|
76
|
+
const { field, ...rest } = args;
|
|
77
|
+
const [result] = await aggregateFormResponses({
|
|
78
|
+
...rest,
|
|
79
|
+
fields: [field]
|
|
80
|
+
});
|
|
81
|
+
return result ?? null;
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
export { aggregateFieldResponses, aggregateFormResponses, fieldHasOptions };
|
|
85
|
+
|
|
86
|
+
//# sourceMappingURL=aggregateResponses.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"aggregateResponses.js","names":[],"sources":["../../src/aggregation/aggregateResponses.ts"],"sourcesContent":["import type { Payload, PayloadRequest } from 'payload'\nimport { FORM_SUBMISSIONS_SLUG } from '../collections/formSubmissions'\nimport { FORMS_SLUG } from '../collections/forms'\nimport type { FormFieldInstance } from '../submissions/types'\nimport { aggregateRowsForFields } from './aggregateRows'\nimport type { AggregationRow, FieldAggregation, FieldMeta, SubmissionStatusFilter } from './types'\n\nexport type AggregateFormResponsesArgs = {\n\tpayload: Payload\n\tformId: number | string\n\t/** Field machine names to aggregate. Defaults to every field that declares options (enumerable). */\n\tfields?: string[]\n\t/** Submission status to count. Defaults to `complete`. */\n\tstatus?: SubmissionStatusFilter\n\treq?: PayloadRequest\n\t/** Safety cap on submissions scanned; beyond it the result is flagged `truncated`. Default 10000. */\n\tmaxSubmissions?: number\n\t/** Page size for the submission scan. Default 500. */\n\tpageSize?: number\n}\n\nconst optionsOf = (field: FormFieldInstance): { value: string; label: string }[] | undefined => {\n\tif (!Array.isArray(field.options)) {\n\t\treturn undefined\n\t}\n\tconst options = (field.options as Array<{ label?: string; value?: string }>)\n\t\t.filter((option) => typeof option?.value === 'string')\n\t\t.map((option) => ({ value: String(option.value), label: option.label ?? String(option.value) }))\n\treturn options.length > 0 ? options : undefined\n}\n\n/** True when a field declares non-empty options (a choice field safe to aggregate publicly). */\nexport const fieldHasOptions = (field: FormFieldInstance): boolean => optionsOf(field) !== undefined\n\nconst metaFor = (field: FormFieldInstance): FieldMeta => ({\n\tfield: field.name,\n\tlabel: field.label ?? field.name,\n\tfieldType: field.blockType,\n\toptions: optionsOf(field),\n})\n\n/**\n * Aggregate submission responses for a form. Loads the form for current labels + option order, then pages\n * `payload.find` over its submissions (JSON `values` cannot be GROUP BY'd portably across Mongo + Postgres,\n * so we scan + reduce in JS, cross-DB-identically). Bounded by `maxSubmissions`; beyond it the result is\n * `truncated`. Returns one `FieldAggregation` per requested field (or per enumerable field by default).\n */\nexport const aggregateFormResponses = async (\n\targs: AggregateFormResponsesArgs\n): Promise<FieldAggregation[]> => {\n\tconst {\n\t\tpayload,\n\t\tformId,\n\t\tfields,\n\t\tstatus = 'complete',\n\t\treq,\n\t\tmaxSubmissions = 10000,\n\t\tpageSize = 500,\n\t} = args\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 || !Array.isArray(form.fields)) {\n\t\treturn []\n\t}\n\tconst instances = form.fields as FormFieldInstance[]\n\tconst selected = fields\n\t\t? instances.filter((field) => fields.includes(field.name))\n\t\t: instances.filter((field) => optionsOf(field) !== undefined)\n\tif (selected.length === 0) {\n\t\treturn []\n\t}\n\tconst metas = selected.map(metaFor)\n\n\tconst statusWhere = status === 'all' ? [] : [{ status: { equals: status } }]\n\tconst where = { and: [{ form: { equals: formId } }, ...statusWhere] }\n\n\tconst rows: AggregationRow[] = []\n\tlet page = 1\n\tlet truncated = false\n\tfor (;;) {\n\t\tconst result = await payload.find({\n\t\t\tcollection: FORM_SUBMISSIONS_SLUG,\n\t\t\twhere,\n\t\t\tlimit: pageSize,\n\t\t\tpage,\n\t\t\tdepth: 0,\n\t\t\toverrideAccess: true,\n\t\t\treq,\n\t\t\tselect: { values: true, descriptors: true },\n\t\t})\n\t\tfor (const doc of result.docs as AggregationRow[]) {\n\t\t\t// Check before pushing so `truncated` means \"matching rows were left out\", not \"the cap was reached\n\t\t\t// exactly\": a form with precisely `maxSubmissions` responses is complete, not a sample.\n\t\t\tif (rows.length >= maxSubmissions) {\n\t\t\t\ttruncated = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t\trows.push({ values: doc.values, descriptors: doc.descriptors })\n\t\t}\n\t\tif (truncated || !result.hasNextPage) {\n\t\t\tbreak\n\t\t}\n\t\tpage += 1\n\t}\n\n\treturn aggregateRowsForFields(rows, metas, truncated)\n}\n\nexport type AggregateFieldResponsesArgs = Omit<AggregateFormResponsesArgs, 'fields'> & {\n\tfield: string\n}\n\n/** Single-field convenience over `aggregateFormResponses`. Returns the field's aggregation, or null if absent. */\nexport const aggregateFieldResponses = async (\n\targs: AggregateFieldResponsesArgs\n): Promise<FieldAggregation | null> => {\n\tconst { field, ...rest } = args\n\tconst [result] = await aggregateFormResponses({ ...rest, fields: [field] })\n\treturn result ?? null\n}\n"],"mappings":";;;;AAqBA,MAAM,aAAa,UAA6E;CAC/F,IAAI,CAAC,MAAM,QAAQ,MAAM,OAAO,GAC/B;CAED,MAAM,UAAW,MAAM,QACrB,QAAQ,WAAW,OAAO,QAAQ,UAAU,QAAQ,EACpD,KAAK,YAAY;EAAE,OAAO,OAAO,OAAO,KAAK;EAAG,OAAO,OAAO,SAAS,OAAO,OAAO,KAAK;CAAE,EAAE;CAChG,OAAO,QAAQ,SAAS,IAAI,UAAU,KAAA;AACvC;;AAGA,MAAa,mBAAmB,UAAsC,UAAU,KAAK,MAAM,KAAA;AAE3F,MAAM,WAAW,WAAyC;CACzD,OAAO,MAAM;CACb,OAAO,MAAM,SAAS,MAAM;CAC5B,WAAW,MAAM;CACjB,SAAS,UAAU,KAAK;AACzB;;;;;;;AAQA,MAAa,yBAAyB,OACrC,SACiC;CACjC,MAAM,EACL,SACA,QACA,QACA,SAAS,YACT,KACA,iBAAiB,KACjB,WAAW,QACR;CACJ,MAAM,OAAO,MAAM,QACjB,SAAS;EAAE,YAAY;EAAY,IAAI;EAAQ,OAAO;EAAG,gBAAgB;EAAM;CAAI,CAAC,EACpF,YAAY,IAAI;CAClB,IAAI,CAAC,QAAQ,CAAC,MAAM,QAAQ,KAAK,MAAM,GACtC,OAAO,CAAC;CAET,MAAM,YAAY,KAAK;CACvB,MAAM,WAAW,SACd,UAAU,QAAQ,UAAU,OAAO,SAAS,MAAM,IAAI,CAAC,IACvD,UAAU,QAAQ,UAAU,UAAU,KAAK,MAAM,KAAA,CAAS;CAC7D,IAAI,SAAS,WAAW,GACvB,OAAO,CAAC;CAET,MAAM,QAAQ,SAAS,IAAI,OAAO;CAGlC,MAAM,QAAQ,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,QAAQ,OAAO,EAAE,GAAG,GADhC,WAAW,QAAQ,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,QAAQ,OAAO,EAAE,CAAC,CACT,EAAE;CAEpE,MAAM,OAAyB,CAAC;CAChC,IAAI,OAAO;CACX,IAAI,YAAY;CAChB,SAAS;EACR,MAAM,SAAS,MAAM,QAAQ,KAAK;GACjC,YAAY;GACZ;GACA,OAAO;GACP;GACA,OAAO;GACP,gBAAgB;GAChB;GACA,QAAQ;IAAE,QAAQ;IAAM,aAAa;GAAK;EAC3C,CAAC;EACD,KAAK,MAAM,OAAO,OAAO,MAA0B;GAGlD,IAAI,KAAK,UAAU,gBAAgB;IAClC,YAAY;IACZ;GACD;GACA,KAAK,KAAK;IAAE,QAAQ,IAAI;IAAQ,aAAa,IAAI;GAAY,CAAC;EAC/D;EACA,IAAI,aAAa,CAAC,OAAO,aACxB;EAED,QAAQ;CACT;CAEA,OAAO,uBAAuB,MAAM,OAAO,SAAS;AACrD;;AAOA,MAAa,0BAA0B,OACtC,SACsC;CACtC,MAAM,EAAE,OAAO,GAAG,SAAS;CAC3B,MAAM,CAAC,UAAU,MAAM,uBAAuB;EAAE,GAAG;EAAM,QAAQ,CAAC,KAAK;CAAE,CAAC;CAC1E,OAAO,UAAU;AAClB"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { AggregationRow, FieldAggregation, FieldMeta } from "./types.js";
|
|
2
|
+
|
|
3
|
+
//#region src/aggregation/aggregateRows.d.ts
|
|
4
|
+
/** Aggregate one field across rows: respondent-denominated counts + percentages, option-ordered. */
|
|
5
|
+
declare const aggregateRowForField: (rows: AggregationRow[], meta: FieldMeta, truncated: boolean) => FieldAggregation;
|
|
6
|
+
/** Aggregate several fields across the same set of rows. */
|
|
7
|
+
declare const aggregateRowsForFields: (rows: AggregationRow[], metas: FieldMeta[], truncated: boolean) => FieldAggregation[];
|
|
8
|
+
//#endregion
|
|
9
|
+
export { aggregateRowForField, aggregateRowsForFields };
|
|
10
|
+
//# sourceMappingURL=aggregateRows.d.ts.map
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//#region src/aggregation/aggregateRows.ts
|
|
2
|
+
const round1 = (n) => Math.round(n * 10) / 10;
|
|
3
|
+
/** The grouping keys a single answer contributes: [] for empty, one per element for arrays, else one. */
|
|
4
|
+
const valueKeys = (value) => {
|
|
5
|
+
if (value == null || value === "") return [];
|
|
6
|
+
if (Array.isArray(value)) return value.filter((entry) => entry != null && entry !== "").map((entry) => String(entry));
|
|
7
|
+
return [String(value)];
|
|
8
|
+
};
|
|
9
|
+
/** Merge every row's descriptor snapshot for one field into a value -> label map (last write wins). */
|
|
10
|
+
const snapshotLabels = (rows, field) => {
|
|
11
|
+
const labels = /* @__PURE__ */ new Map();
|
|
12
|
+
for (const row of rows) for (const descriptor of row.descriptors ?? []) if (descriptor.field === field && descriptor.optionLabels) for (const [value, label] of Object.entries(descriptor.optionLabels)) labels.set(value, label);
|
|
13
|
+
return labels;
|
|
14
|
+
};
|
|
15
|
+
/** Aggregate one field across rows: respondent-denominated counts + percentages, option-ordered. */
|
|
16
|
+
const aggregateRowForField = (rows, meta, truncated) => {
|
|
17
|
+
const counts = /* @__PURE__ */ new Map();
|
|
18
|
+
const order = [];
|
|
19
|
+
for (const option of meta.options ?? []) {
|
|
20
|
+
counts.set(option.value, 0);
|
|
21
|
+
order.push(option.value);
|
|
22
|
+
}
|
|
23
|
+
const snapshots = snapshotLabels(rows, meta.field);
|
|
24
|
+
let total = 0;
|
|
25
|
+
for (const row of rows) {
|
|
26
|
+
const keys = valueKeys((row.values ?? []).find((entry) => entry.field === meta.field)?.value);
|
|
27
|
+
if (keys.length === 0) continue;
|
|
28
|
+
total += 1;
|
|
29
|
+
for (const key of keys) {
|
|
30
|
+
if (!counts.has(key)) {
|
|
31
|
+
counts.set(key, 0);
|
|
32
|
+
order.push(key);
|
|
33
|
+
}
|
|
34
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const optionValues = new Set((meta.options ?? []).map((option) => option.value));
|
|
38
|
+
const leftovers = order.filter((value) => !optionValues.has(value)).sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0));
|
|
39
|
+
const ordered = [...(meta.options ?? []).map((option) => option.value), ...leftovers];
|
|
40
|
+
const optionLabelByValue = new Map((meta.options ?? []).map((option) => [option.value, option.label]));
|
|
41
|
+
const buckets = ordered.map((value) => {
|
|
42
|
+
const count = counts.get(value) ?? 0;
|
|
43
|
+
return {
|
|
44
|
+
value,
|
|
45
|
+
label: optionLabelByValue.get(value) ?? snapshots.get(value) ?? value,
|
|
46
|
+
count,
|
|
47
|
+
percentage: total > 0 ? round1(count / total * 100) : 0
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
return {
|
|
51
|
+
field: meta.field,
|
|
52
|
+
label: meta.label,
|
|
53
|
+
fieldType: meta.fieldType,
|
|
54
|
+
total,
|
|
55
|
+
buckets,
|
|
56
|
+
truncated
|
|
57
|
+
};
|
|
58
|
+
};
|
|
59
|
+
/** Aggregate several fields across the same set of rows. */
|
|
60
|
+
const aggregateRowsForFields = (rows, metas, truncated) => metas.map((meta) => aggregateRowForField(rows, meta, truncated));
|
|
61
|
+
//#endregion
|
|
62
|
+
export { aggregateRowForField, aggregateRowsForFields };
|
|
63
|
+
|
|
64
|
+
//# sourceMappingURL=aggregateRows.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"aggregateRows.js","names":[],"sources":["../../src/aggregation/aggregateRows.ts"],"sourcesContent":["import type { AggregationBucket, AggregationRow, FieldAggregation, FieldMeta } from './types'\n\nconst round1 = (n: number): number => Math.round(n * 10) / 10\n\n/** The grouping keys a single answer contributes: [] for empty, one per element for arrays, else one. */\nconst valueKeys = (value: unknown): string[] => {\n\tif (value == null || value === '') {\n\t\treturn []\n\t}\n\tif (Array.isArray(value)) {\n\t\treturn value.filter((entry) => entry != null && entry !== '').map((entry) => String(entry))\n\t}\n\treturn [String(value)]\n}\n\n/** Merge every row's descriptor snapshot for one field into a value -> label map (last write wins). */\nconst snapshotLabels = (rows: AggregationRow[], field: string): Map<string, string> => {\n\tconst labels = new Map<string, string>()\n\tfor (const row of rows) {\n\t\tfor (const descriptor of row.descriptors ?? []) {\n\t\t\tif (descriptor.field === field && descriptor.optionLabels) {\n\t\t\t\tfor (const [value, label] of Object.entries(descriptor.optionLabels)) {\n\t\t\t\t\tlabels.set(value, label)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn labels\n}\n\n/** Aggregate one field across rows: respondent-denominated counts + percentages, option-ordered. */\nexport const aggregateRowForField = (\n\trows: AggregationRow[],\n\tmeta: FieldMeta,\n\ttruncated: boolean\n): FieldAggregation => {\n\tconst counts = new Map<string, number>()\n\tconst order: string[] = []\n\tfor (const option of meta.options ?? []) {\n\t\tcounts.set(option.value, 0)\n\t\torder.push(option.value)\n\t}\n\tconst snapshots = snapshotLabels(rows, meta.field)\n\tlet total = 0\n\tfor (const row of rows) {\n\t\tconst answer = (row.values ?? []).find((entry) => entry.field === meta.field)\n\t\tconst keys = valueKeys(answer?.value)\n\t\tif (keys.length === 0) {\n\t\t\tcontinue\n\t\t}\n\t\ttotal += 1\n\t\tfor (const key of keys) {\n\t\t\tif (!counts.has(key)) {\n\t\t\t\tcounts.set(key, 0)\n\t\t\t\torder.push(key)\n\t\t\t}\n\t\t\tcounts.set(key, (counts.get(key) ?? 0) + 1)\n\t\t}\n\t}\n\n\tconst optionValues = new Set((meta.options ?? []).map((option) => option.value))\n\tconst leftovers = order\n\t\t.filter((value) => !optionValues.has(value))\n\t\t.sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0))\n\tconst ordered = [...(meta.options ?? []).map((option) => option.value), ...leftovers]\n\n\tconst optionLabelByValue = new Map(\n\t\t(meta.options ?? []).map((option) => [option.value, option.label])\n\t)\n\tconst buckets: AggregationBucket[] = ordered.map((value) => {\n\t\tconst count = counts.get(value) ?? 0\n\t\treturn {\n\t\t\tvalue,\n\t\t\tlabel: optionLabelByValue.get(value) ?? snapshots.get(value) ?? value,\n\t\t\tcount,\n\t\t\tpercentage: total > 0 ? round1((count / total) * 100) : 0,\n\t\t}\n\t})\n\n\treturn {\n\t\tfield: meta.field,\n\t\tlabel: meta.label,\n\t\tfieldType: meta.fieldType,\n\t\ttotal,\n\t\tbuckets,\n\t\ttruncated,\n\t}\n}\n\n/** Aggregate several fields across the same set of rows. */\nexport const aggregateRowsForFields = (\n\trows: AggregationRow[],\n\tmetas: FieldMeta[],\n\ttruncated: boolean\n): FieldAggregation[] => metas.map((meta) => aggregateRowForField(rows, meta, truncated))\n"],"mappings":";AAEA,MAAM,UAAU,MAAsB,KAAK,MAAM,IAAI,EAAE,IAAI;;AAG3D,MAAM,aAAa,UAA6B;CAC/C,IAAI,SAAS,QAAQ,UAAU,IAC9B,OAAO,CAAC;CAET,IAAI,MAAM,QAAQ,KAAK,GACtB,OAAO,MAAM,QAAQ,UAAU,SAAS,QAAQ,UAAU,EAAE,EAAE,KAAK,UAAU,OAAO,KAAK,CAAC;CAE3F,OAAO,CAAC,OAAO,KAAK,CAAC;AACtB;;AAGA,MAAM,kBAAkB,MAAwB,UAAuC;CACtF,MAAM,yBAAS,IAAI,IAAoB;CACvC,KAAK,MAAM,OAAO,MACjB,KAAK,MAAM,cAAc,IAAI,eAAe,CAAC,GAC5C,IAAI,WAAW,UAAU,SAAS,WAAW,cAC5C,KAAK,MAAM,CAAC,OAAO,UAAU,OAAO,QAAQ,WAAW,YAAY,GAClE,OAAO,IAAI,OAAO,KAAK;CAK3B,OAAO;AACR;;AAGA,MAAa,wBACZ,MACA,MACA,cACsB;CACtB,MAAM,yBAAS,IAAI,IAAoB;CACvC,MAAM,QAAkB,CAAC;CACzB,KAAK,MAAM,UAAU,KAAK,WAAW,CAAC,GAAG;EACxC,OAAO,IAAI,OAAO,OAAO,CAAC;EAC1B,MAAM,KAAK,OAAO,KAAK;CACxB;CACA,MAAM,YAAY,eAAe,MAAM,KAAK,KAAK;CACjD,IAAI,QAAQ;CACZ,KAAK,MAAM,OAAO,MAAM;EAEvB,MAAM,OAAO,WADG,IAAI,UAAU,CAAC,GAAG,MAAM,UAAU,MAAM,UAAU,KAAK,KAC3C,GAAG,KAAK;EACpC,IAAI,KAAK,WAAW,GACnB;EAED,SAAS;EACT,KAAK,MAAM,OAAO,MAAM;GACvB,IAAI,CAAC,OAAO,IAAI,GAAG,GAAG;IACrB,OAAO,IAAI,KAAK,CAAC;IACjB,MAAM,KAAK,GAAG;GACf;GACA,OAAO,IAAI,MAAM,OAAO,IAAI,GAAG,KAAK,KAAK,CAAC;EAC3C;CACD;CAEA,MAAM,eAAe,IAAI,KAAK,KAAK,WAAW,CAAC,GAAG,KAAK,WAAW,OAAO,KAAK,CAAC;CAC/E,MAAM,YAAY,MAChB,QAAQ,UAAU,CAAC,aAAa,IAAI,KAAK,CAAC,EAC1C,MAAM,GAAG,OAAO,OAAO,IAAI,CAAC,KAAK,MAAM,OAAO,IAAI,CAAC,KAAK,EAAE;CAC5D,MAAM,UAAU,CAAC,IAAI,KAAK,WAAW,CAAC,GAAG,KAAK,WAAW,OAAO,KAAK,GAAG,GAAG,SAAS;CAEpF,MAAM,qBAAqB,IAAI,KAC7B,KAAK,WAAW,CAAC,GAAG,KAAK,WAAW,CAAC,OAAO,OAAO,OAAO,KAAK,CAAC,CAClE;CACA,MAAM,UAA+B,QAAQ,KAAK,UAAU;EAC3D,MAAM,QAAQ,OAAO,IAAI,KAAK,KAAK;EACnC,OAAO;GACN;GACA,OAAO,mBAAmB,IAAI,KAAK,KAAK,UAAU,IAAI,KAAK,KAAK;GAChE;GACA,YAAY,QAAQ,IAAI,OAAQ,QAAQ,QAAS,GAAG,IAAI;EACzD;CACD,CAAC;CAED,OAAO;EACN,OAAO,KAAK;EACZ,OAAO,KAAK;EACZ,WAAW,KAAK;EAChB;EACA;EACA;CACD;AACD;;AAGA,MAAa,0BACZ,MACA,OACA,cACwB,MAAM,KAAK,SAAS,qBAAqB,MAAM,MAAM,SAAS,CAAC"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { FieldAggregation } from "./types.js";
|
|
2
|
+
import { Payload, PayloadRequest } from "payload";
|
|
3
|
+
|
|
4
|
+
//#region src/aggregation/resolveResultsRequest.d.ts
|
|
5
|
+
type ResolveResultsRequestArgs = {
|
|
6
|
+
payload: Payload;
|
|
7
|
+
formId: number | string | undefined; /** The requested field (query param). */
|
|
8
|
+
field?: string; /** Whether the caller is authenticated (an admin/user). */
|
|
9
|
+
isAuthed: boolean;
|
|
10
|
+
req?: PayloadRequest;
|
|
11
|
+
};
|
|
12
|
+
type ResolveResultsRequestResult = {
|
|
13
|
+
status: number;
|
|
14
|
+
body: {
|
|
15
|
+
results: FieldAggregation[];
|
|
16
|
+
} | {
|
|
17
|
+
errors: {
|
|
18
|
+
message: string;
|
|
19
|
+
}[];
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all
|
|
24
|
+
* enumerable fields). Anonymous callers are allowed only when the form opted in (`showResults`), and then
|
|
25
|
+
* only for the configured `resultsField`, and only if that field is enumerable (has options) so a
|
|
26
|
+
* misconfigured `resultsField` pointing at a free-text or PII field can never be dumped publicly. Returns
|
|
27
|
+
* only aggregate counts, never raw submissions.
|
|
28
|
+
*/
|
|
29
|
+
declare const resolveFormResultsRequest: (args: ResolveResultsRequestArgs) => Promise<ResolveResultsRequestResult>;
|
|
30
|
+
//#endregion
|
|
31
|
+
export { ResolveResultsRequestArgs, ResolveResultsRequestResult, resolveFormResultsRequest };
|
|
32
|
+
//# sourceMappingURL=resolveResultsRequest.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { aggregateFormResponses, fieldHasOptions } from "./aggregateResponses.js";
|
|
2
|
+
import { FORMS_SLUG } from "../collections/forms.js";
|
|
3
|
+
//#region src/aggregation/resolveResultsRequest.ts
|
|
4
|
+
const forbidden = {
|
|
5
|
+
status: 403,
|
|
6
|
+
body: { errors: [{ message: "Forbidden" }] }
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all
|
|
10
|
+
* enumerable fields). Anonymous callers are allowed only when the form opted in (`showResults`), and then
|
|
11
|
+
* only for the configured `resultsField`, and only if that field is enumerable (has options) so a
|
|
12
|
+
* misconfigured `resultsField` pointing at a free-text or PII field can never be dumped publicly. Returns
|
|
13
|
+
* only aggregate counts, never raw submissions.
|
|
14
|
+
*/
|
|
15
|
+
const resolveFormResultsRequest = async (args) => {
|
|
16
|
+
const { payload, formId, field, isAuthed, req } = args;
|
|
17
|
+
if (formId == null) return {
|
|
18
|
+
status: 400,
|
|
19
|
+
body: { errors: [{ message: "Missing form id" }] }
|
|
20
|
+
};
|
|
21
|
+
const form = await payload.findByID({
|
|
22
|
+
collection: FORMS_SLUG,
|
|
23
|
+
id: formId,
|
|
24
|
+
depth: 0,
|
|
25
|
+
overrideAccess: true,
|
|
26
|
+
req
|
|
27
|
+
}).catch(() => null);
|
|
28
|
+
if (!form) return {
|
|
29
|
+
status: 404,
|
|
30
|
+
body: { errors: [{ message: "Not found" }] }
|
|
31
|
+
};
|
|
32
|
+
let fields;
|
|
33
|
+
if (isAuthed) fields = field ? [field] : void 0;
|
|
34
|
+
else {
|
|
35
|
+
if (form.showResults !== true) return forbidden;
|
|
36
|
+
const publicField = typeof form.resultsField === "string" && form.resultsField.length > 0 ? form.resultsField : void 0;
|
|
37
|
+
if (!publicField) return forbidden;
|
|
38
|
+
if (field && field !== publicField) return forbidden;
|
|
39
|
+
const instance = (Array.isArray(form.fields) ? form.fields : []).find((entry) => entry.name === publicField);
|
|
40
|
+
if (!instance || !fieldHasOptions(instance)) return forbidden;
|
|
41
|
+
fields = [publicField];
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
status: 200,
|
|
45
|
+
body: { results: await aggregateFormResponses({
|
|
46
|
+
payload,
|
|
47
|
+
formId,
|
|
48
|
+
fields,
|
|
49
|
+
req
|
|
50
|
+
}) }
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
//#endregion
|
|
54
|
+
export { resolveFormResultsRequest };
|
|
55
|
+
|
|
56
|
+
//# sourceMappingURL=resolveResultsRequest.js.map
|
|
@@ -0,0 +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 type { FormFieldInstance } from '../submissions/types'\nimport { aggregateFormResponses, fieldHasOptions } from './aggregateResponses'\nimport type { FieldAggregation } from './types'\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}\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/**\n * Authorize and resolve a poll/survey results request. Authed callers may aggregate any field (or all\n * enumerable fields). Anonymous callers are allowed only when the form opted in (`showResults`), and then\n * only for the configured `resultsField`, and only if that field is enumerable (has options) so a\n * misconfigured `resultsField` pointing at a free-text or PII field can never be dumped publicly. Returns\n * only aggregate counts, never raw submissions.\n */\nexport const resolveFormResultsRequest = async (\n\targs: ResolveResultsRequestArgs\n): Promise<ResolveResultsRequestResult> => {\n\tconst { payload, formId, field, isAuthed, req } = 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\tlet fields: string[] | undefined\n\tif (isAuthed) {\n\t\tfields = field ? [field] : undefined\n\t} else {\n\t\tif (form.showResults !== true) {\n\t\t\treturn forbidden\n\t\t}\n\t\tconst publicField =\n\t\t\ttypeof form.resultsField === 'string' && form.resultsField.length > 0\n\t\t\t\t? form.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 || !fieldHasOptions(instance)) {\n\t\t\treturn forbidden\n\t\t}\n\t\tfields = [publicField]\n\t}\n\n\tconst results = await aggregateFormResponses({ payload, formId, fields, req })\n\treturn { status: 200, body: { results } }\n}\n"],"mappings":";;;AAqBA,MAAM,YAAyC;CAC9C,QAAQ;CACR,MAAM,EAAE,QAAQ,CAAC,EAAE,SAAS,YAAY,CAAC,EAAE;AAC5C;;;;;;;;AASA,MAAa,4BAA4B,OACxC,SAC0C;CAC1C,MAAM,EAAE,SAAS,QAAQ,OAAO,UAAU,QAAQ;CAClD,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;CAGpE,IAAI;CACJ,IAAI,UACH,SAAS,QAAQ,CAAC,KAAK,IAAI,KAAA;MACrB;EACN,IAAI,KAAK,gBAAgB,MACxB,OAAO;EAER,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,YAAY,CAAC,gBAAgB,QAAQ,GACzC,OAAO;EAER,SAAS,CAAC,WAAW;CACtB;CAGA,OAAO;EAAE,QAAQ;EAAK,MAAM,EAAE,SAAA,MADR,uBAAuB;GAAE;GAAS;GAAQ;GAAQ;EAAI,CAAC,EACvC;CAAE;AACzC"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { SubmissionDescriptor, SubmissionValue } from "../submissions/types.js";
|
|
2
|
+
|
|
3
|
+
//#region src/aggregation/types.d.ts
|
|
4
|
+
/** Which submission statuses to count. `complete` (default) excludes partials; `all` counts everything. */
|
|
5
|
+
type SubmissionStatusFilter = 'complete' | 'partial' | 'all';
|
|
6
|
+
/** One answer option (or distinct value) with its tally. */
|
|
7
|
+
type AggregationBucket = {
|
|
8
|
+
/** The answer value, stringified (the grouping key). */value: string; /** Display label: current option label, else the submitted snapshot label, else the raw value. */
|
|
9
|
+
label: string;
|
|
10
|
+
count: number; /** Share of respondents, 0-100, rounded to one decimal. Can sum past 100 for multi-value fields. */
|
|
11
|
+
percentage: number;
|
|
12
|
+
};
|
|
13
|
+
/** Aggregated responses for one field across a form's submissions. */
|
|
14
|
+
type FieldAggregation = {
|
|
15
|
+
field: string;
|
|
16
|
+
label?: string;
|
|
17
|
+
fieldType?: string; /** Respondents: submissions with a non-empty answer for this field. The percentage denominator. */
|
|
18
|
+
total: number;
|
|
19
|
+
buckets: AggregationBucket[]; /** True when the `maxSubmissions` cap was hit, so the tallies are a (large) sample, not the full set. */
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
};
|
|
22
|
+
/** The slice of a submission the reducer reads. */
|
|
23
|
+
type AggregationRow = {
|
|
24
|
+
values?: SubmissionValue[];
|
|
25
|
+
descriptors?: SubmissionDescriptor[];
|
|
26
|
+
};
|
|
27
|
+
/** Field metadata the reducer needs to label and order buckets, derived from the live form definition. */
|
|
28
|
+
type FieldMeta = {
|
|
29
|
+
field: string;
|
|
30
|
+
label?: string;
|
|
31
|
+
fieldType?: string; /** Ordered known options with current labels; drives bucket order and label resolution. */
|
|
32
|
+
options?: {
|
|
33
|
+
value: string;
|
|
34
|
+
label: string;
|
|
35
|
+
}[];
|
|
36
|
+
};
|
|
37
|
+
//#endregion
|
|
38
|
+
export { AggregationBucket, AggregationRow, FieldAggregation, FieldMeta, SubmissionStatusFilter };
|
|
39
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FormFieldInstance } from "../submissions/types.js";
|
|
2
|
+
import { CalcExpression } from "./types.js";
|
|
3
|
+
|
|
4
|
+
//#region src/calc/computeCalcFields.d.ts
|
|
5
|
+
/** Returns the calc expression if the field carries one (non-null object), otherwise undefined. */
|
|
6
|
+
declare const calcExpressionOf: (field: FormFieldInstance) => CalcExpression | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Returns answers with every calc field's value derived from its expression,
|
|
9
|
+
* folded in declaration order so a calc may reference an earlier calc's result.
|
|
10
|
+
* Identity when no field carries an expression.
|
|
11
|
+
*/
|
|
12
|
+
declare const computeCalcFields: (fields: FormFieldInstance[], answers: Record<string, unknown>) => Record<string, unknown>;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { calcExpressionOf, computeCalcFields };
|
|
15
|
+
//# sourceMappingURL=computeCalcFields.d.ts.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { evaluateCalc } from "./evaluate.js";
|
|
2
|
+
//#region src/calc/computeCalcFields.ts
|
|
3
|
+
/** Returns the calc expression if the field carries one (non-null object), otherwise undefined. */
|
|
4
|
+
const calcExpressionOf = (field) => {
|
|
5
|
+
const expr = field.expression;
|
|
6
|
+
return expr !== null && typeof expr === "object" && !Array.isArray(expr) ? expr : void 0;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Returns answers with every calc field's value derived from its expression,
|
|
10
|
+
* folded in declaration order so a calc may reference an earlier calc's result.
|
|
11
|
+
* Identity when no field carries an expression.
|
|
12
|
+
*/
|
|
13
|
+
const computeCalcFields = (fields, answers) => {
|
|
14
|
+
let next = answers;
|
|
15
|
+
for (const field of fields) {
|
|
16
|
+
const expr = calcExpressionOf(field);
|
|
17
|
+
if (expr) next = {
|
|
18
|
+
...next,
|
|
19
|
+
[field.name]: evaluateCalc(expr, next)
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return next;
|
|
23
|
+
};
|
|
24
|
+
//#endregion
|
|
25
|
+
export { calcExpressionOf, computeCalcFields };
|
|
26
|
+
|
|
27
|
+
//# sourceMappingURL=computeCalcFields.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"computeCalcFields.js","names":[],"sources":["../../src/calc/computeCalcFields.ts"],"sourcesContent":["import 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>\n): Record<string, unknown> => {\n\tlet next = answers\n\tfor (const field of fields) {\n\t\tconst expr = calcExpressionOf(field)\n\t\tif (expr) {\n\t\t\tnext = { ...next, [field.name]: evaluateCalc(expr, next) }\n\t\t}\n\t}\n\treturn next\n}\n"],"mappings":";;;AAKA,MAAa,oBAAoB,UAAyD;CACzF,MAAM,OAAO,MAAM;CACnB,OAAO,SAAS,QAAQ,OAAO,SAAS,YAAY,CAAC,MAAM,QAAQ,IAAI,IACnE,OACD,KAAA;AACJ;;;;;;AAOA,MAAa,qBACZ,QACA,YAC6B;CAC7B,IAAI,OAAO;CACX,KAAK,MAAM,SAAS,QAAQ;EAC3B,MAAM,OAAO,iBAAiB,KAAK;EACnC,IAAI,MACH,OAAO;GAAE,GAAG;IAAO,MAAM,OAAO,aAAa,MAAM,IAAI;EAAE;CAE3D;CACA,OAAO;AACR"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { CalcExpression } from "./types.js";
|
|
2
|
+
|
|
3
|
+
//#region src/calc/evaluate.d.ts
|
|
4
|
+
/** Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). */
|
|
5
|
+
declare const evaluateCalc: (expr: CalcExpression | null | undefined, answers: Record<string, unknown>) => number;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { evaluateCalc };
|
|
8
|
+
//# sourceMappingURL=evaluate.d.ts.map
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
//#region src/calc/evaluate.ts
|
|
2
|
+
const MAX_DEPTH = 64;
|
|
3
|
+
const toNumber = (value) => {
|
|
4
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
5
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
6
|
+
const n = Number(value);
|
|
7
|
+
return Number.isFinite(n) ? n : 0;
|
|
8
|
+
}
|
|
9
|
+
return 0;
|
|
10
|
+
};
|
|
11
|
+
const finite = (n) => Number.isFinite(n) ? n : 0;
|
|
12
|
+
const FUNCTIONS = {
|
|
13
|
+
min: (a) => a.length ? Math.min(...a) : 0,
|
|
14
|
+
max: (a) => a.length ? Math.max(...a) : 0,
|
|
15
|
+
round: (a) => Math.round(a[0] ?? 0),
|
|
16
|
+
abs: (a) => Math.abs(a[0] ?? 0),
|
|
17
|
+
ceil: (a) => Math.ceil(a[0] ?? 0),
|
|
18
|
+
floor: (a) => Math.floor(a[0] ?? 0)
|
|
19
|
+
};
|
|
20
|
+
const evalNode = (expr, answers, depth) => {
|
|
21
|
+
if (depth > MAX_DEPTH || expr == null || typeof expr !== "object") return 0;
|
|
22
|
+
switch (expr.type) {
|
|
23
|
+
case "lit": return finite(toNumber(expr.value));
|
|
24
|
+
case "ref": return toNumber(answers[expr.field]);
|
|
25
|
+
case "neg": return finite(-evalNode(expr.operand, answers, depth + 1));
|
|
26
|
+
case "op": {
|
|
27
|
+
const l = evalNode(expr.left, answers, depth + 1);
|
|
28
|
+
const r = evalNode(expr.right, answers, depth + 1);
|
|
29
|
+
switch (expr.op) {
|
|
30
|
+
case "+": return finite(l + r);
|
|
31
|
+
case "-": return finite(l - r);
|
|
32
|
+
case "*": return finite(l * r);
|
|
33
|
+
case "/": return r === 0 ? 0 : finite(l / r);
|
|
34
|
+
case "%": return r === 0 ? 0 : finite(l % r);
|
|
35
|
+
default: return 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
case "fn": {
|
|
39
|
+
const fn = FUNCTIONS[expr.fn];
|
|
40
|
+
if (!fn) return 0;
|
|
41
|
+
return finite(fn(Array.isArray(expr.args) ? expr.args.map((a) => evalNode(a, answers, depth + 1)) : []));
|
|
42
|
+
}
|
|
43
|
+
case "weight": {
|
|
44
|
+
const weights = expr.weights ?? {};
|
|
45
|
+
const value = answers[expr.field];
|
|
46
|
+
return finite((Array.isArray(value) ? value : value == null || value === "" ? [] : [value]).reduce((sum, v) => sum + toNumber(weights[String(v)]), 0));
|
|
47
|
+
}
|
|
48
|
+
default: return 0;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
/** Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). */
|
|
52
|
+
const evaluateCalc = (expr, answers) => {
|
|
53
|
+
if (!expr) return 0;
|
|
54
|
+
return evalNode(expr, answers, 0);
|
|
55
|
+
};
|
|
56
|
+
//#endregion
|
|
57
|
+
export { evaluateCalc };
|
|
58
|
+
|
|
59
|
+
//# sourceMappingURL=evaluate.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"evaluate.js","names":[],"sources":["../../src/calc/evaluate.ts"],"sourcesContent":["import type { CalcExpression } from './types'\n\nconst MAX_DEPTH = 64\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\nconst evalNode = (\n\texpr: CalcExpression,\n\tanswers: Record<string, unknown>,\n\tdepth: number\n): number => {\n\tif (depth > MAX_DEPTH || expr == null || typeof expr !== 'object') {\n\t\treturn 0\n\t}\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 'neg':\n\t\t\treturn finite(-evalNode(expr.operand, answers, depth + 1))\n\t\tcase 'op': {\n\t\t\tconst l = evalNode(expr.left, answers, depth + 1)\n\t\t\tconst r = evalNode(expr.right, answers, 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\tconst fn = 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)\n\t\t\t\t? expr.args.map((a) => evalNode(a, answers, depth + 1))\n\t\t\t\t: []\n\t\t\treturn finite(fn(args))\n\t\t}\n\t\tcase 'weight': {\n\t\t\tconst weights = 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/** Evaluate a calc expression against form answers. Total + safe: no `eval`, always finite, div/mod by zero -> 0, missing ref -> 0, depth-guarded. Isomorphic (client + server). */\nexport const evaluateCalc = (\n\texpr: CalcExpression | null | undefined,\n\tanswers: Record<string, unknown>\n): number => {\n\tif (!expr) {\n\t\treturn 0\n\t}\n\treturn evalNode(expr, answers, 0)\n}\n"],"mappings":";AAEA,MAAM,YAAY;AAElB,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;AAEA,MAAM,YACL,MACA,SACA,UACY;CACZ,IAAI,QAAQ,aAAa,QAAQ,QAAQ,OAAO,SAAS,UACxD,OAAO;CAER,QAAQ,KAAK,MAAb;EACC,KAAK,OACJ,OAAO,OAAO,SAAS,KAAK,KAAK,CAAC;EACnC,KAAK,OACJ,OAAO,SAAS,QAAQ,KAAK,MAAM;EACpC,KAAK,OACJ,OAAO,OAAO,CAAC,SAAS,KAAK,SAAS,SAAS,QAAQ,CAAC,CAAC;EAC1D,KAAK,MAAM;GACV,MAAM,IAAI,SAAS,KAAK,MAAM,SAAS,QAAQ,CAAC;GAChD,MAAM,IAAI,SAAS,KAAK,OAAO,SAAS,QAAQ,CAAC;GACjD,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;GACV,MAAM,KAAK,UAAU,KAAK;GAC1B,IAAI,CAAC,IACJ,OAAO;GAKR,OAAO,OAAO,GAHD,MAAM,QAAQ,KAAK,IAAI,IACjC,KAAK,KAAK,KAAK,MAAM,SAAS,GAAG,SAAS,QAAQ,CAAC,CAAC,IACpD,CAAC,CACiB,CAAC;EACvB;EACA,KAAK,UAAU;GACd,MAAM,UAAU,KAAK,WAAW,CAAC;GACjC,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;;AAGA,MAAa,gBACZ,MACA,YACY;CACZ,IAAI,CAAC,MACJ,OAAO;CAER,OAAO,SAAS,MAAM,SAAS,CAAC;AACjC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { CalcExpression } from "./types.js";
|
|
2
|
+
|
|
3
|
+
//#region src/calc/normalizeCalc.d.ts
|
|
4
|
+
/** Structural guard: returns a valid CalcExpression if `value` is structurally sound (depth-guarded at 64), otherwise undefined. Never throws. */
|
|
5
|
+
declare const normalizeCalc: (value: unknown) => CalcExpression | undefined;
|
|
6
|
+
//#endregion
|
|
7
|
+
export { normalizeCalc };
|
|
8
|
+
//# sourceMappingURL=normalizeCalc.d.ts.map
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
//#region src/calc/normalizeCalc.ts
|
|
2
|
+
const MAX_DEPTH = 64;
|
|
3
|
+
const OP_SET = new Set([
|
|
4
|
+
"+",
|
|
5
|
+
"-",
|
|
6
|
+
"*",
|
|
7
|
+
"/",
|
|
8
|
+
"%"
|
|
9
|
+
]);
|
|
10
|
+
const FN_SET = new Set([
|
|
11
|
+
"min",
|
|
12
|
+
"max",
|
|
13
|
+
"round",
|
|
14
|
+
"abs",
|
|
15
|
+
"ceil",
|
|
16
|
+
"floor"
|
|
17
|
+
]);
|
|
18
|
+
const isRecord = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
19
|
+
const normalizeNode = (value, depth) => {
|
|
20
|
+
if (depth > MAX_DEPTH) return void 0;
|
|
21
|
+
if (!isRecord(value)) return void 0;
|
|
22
|
+
switch (value.type) {
|
|
23
|
+
case "lit": {
|
|
24
|
+
const { value: v } = value;
|
|
25
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return void 0;
|
|
26
|
+
return {
|
|
27
|
+
type: "lit",
|
|
28
|
+
value: v
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
case "ref": {
|
|
32
|
+
const { field } = value;
|
|
33
|
+
if (typeof field !== "string") return void 0;
|
|
34
|
+
return {
|
|
35
|
+
type: "ref",
|
|
36
|
+
field
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
case "op": {
|
|
40
|
+
const { op, left, right } = value;
|
|
41
|
+
if (typeof op !== "string" || !OP_SET.has(op)) return void 0;
|
|
42
|
+
const l = normalizeNode(left, depth + 1);
|
|
43
|
+
if (!l) return void 0;
|
|
44
|
+
const r = normalizeNode(right, depth + 1);
|
|
45
|
+
if (!r) return void 0;
|
|
46
|
+
return {
|
|
47
|
+
type: "op",
|
|
48
|
+
op,
|
|
49
|
+
left: l,
|
|
50
|
+
right: r
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
case "neg": {
|
|
54
|
+
const operand = normalizeNode(value.operand, depth + 1);
|
|
55
|
+
if (!operand) return void 0;
|
|
56
|
+
return {
|
|
57
|
+
type: "neg",
|
|
58
|
+
operand
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
case "fn": {
|
|
62
|
+
const { fn, args } = value;
|
|
63
|
+
if (typeof fn !== "string" || !FN_SET.has(fn)) return void 0;
|
|
64
|
+
if (!Array.isArray(args)) return void 0;
|
|
65
|
+
const normalizedArgs = [];
|
|
66
|
+
for (const arg of args) {
|
|
67
|
+
const a = normalizeNode(arg, depth + 1);
|
|
68
|
+
if (!a) return void 0;
|
|
69
|
+
normalizedArgs.push(a);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
type: "fn",
|
|
73
|
+
fn,
|
|
74
|
+
args: normalizedArgs
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
case "weight": {
|
|
78
|
+
const { field, weights } = value;
|
|
79
|
+
if (typeof field !== "string") return void 0;
|
|
80
|
+
if (!isRecord(weights)) return void 0;
|
|
81
|
+
const normalizedWeights = {};
|
|
82
|
+
for (const [k, v] of Object.entries(weights)) {
|
|
83
|
+
if (typeof v !== "number" || !Number.isFinite(v)) return void 0;
|
|
84
|
+
normalizedWeights[k] = v;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
type: "weight",
|
|
88
|
+
field,
|
|
89
|
+
weights: normalizedWeights
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
default: return;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
/** Structural guard: returns a valid CalcExpression if `value` is structurally sound (depth-guarded at 64), otherwise undefined. Never throws. */
|
|
96
|
+
const normalizeCalc = (value) => normalizeNode(value, 0);
|
|
97
|
+
//#endregion
|
|
98
|
+
export { normalizeCalc };
|
|
99
|
+
|
|
100
|
+
//# sourceMappingURL=normalizeCalc.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalizeCalc.js","names":[],"sources":["../../src/calc/normalizeCalc.ts"],"sourcesContent":["import type { CalcExpression } from './types'\n\nconst MAX_DEPTH = 64\n\nconst OP_SET = new Set(['+', '-', '*', '/', '%'])\nconst FN_SET = new Set(['min', 'max', 'round', 'abs', 'ceil', 'floor'])\n\nconst isRecord = (v: unknown): v is Record<string, unknown> =>\n\tv !== null && typeof v === 'object' && !Array.isArray(v)\n\nconst normalizeNode = (value: unknown, depth: number): CalcExpression | undefined => {\n\tif (depth > MAX_DEPTH) return undefined\n\tif (!isRecord(value)) return undefined\n\n\tswitch (value.type) {\n\t\tcase 'lit': {\n\t\t\tconst { value: v } = value\n\t\t\tif (typeof v !== 'number' || !Number.isFinite(v)) return undefined\n\t\t\treturn { type: 'lit', value: v }\n\t\t}\n\t\tcase 'ref': {\n\t\t\tconst { field } = value\n\t\t\tif (typeof field !== 'string') return undefined\n\t\t\treturn { type: 'ref', field }\n\t\t}\n\t\tcase 'op': {\n\t\t\tconst { op, left, right } = value\n\t\t\tif (typeof op !== 'string' || !OP_SET.has(op)) return undefined\n\t\t\tconst l = normalizeNode(left, depth + 1)\n\t\t\tif (!l) return undefined\n\t\t\tconst r = normalizeNode(right, depth + 1)\n\t\t\tif (!r) return undefined\n\t\t\treturn { type: 'op', op: op as '+' | '-' | '*' | '/' | '%', left: l, right: r }\n\t\t}\n\t\tcase 'neg': {\n\t\t\tconst operand = normalizeNode(value.operand, depth + 1)\n\t\t\tif (!operand) return undefined\n\t\t\treturn { type: 'neg', operand }\n\t\t}\n\t\tcase 'fn': {\n\t\t\tconst { fn, args } = value\n\t\t\tif (typeof fn !== 'string' || !FN_SET.has(fn)) return undefined\n\t\t\tif (!Array.isArray(args)) return undefined\n\t\t\tconst normalizedArgs: CalcExpression[] = []\n\t\t\tfor (const arg of args) {\n\t\t\t\tconst a = normalizeNode(arg, depth + 1)\n\t\t\t\tif (!a) return undefined\n\t\t\t\tnormalizedArgs.push(a)\n\t\t\t}\n\t\t\treturn {\n\t\t\t\ttype: 'fn',\n\t\t\t\tfn: fn as 'min' | 'max' | 'round' | 'abs' | 'ceil' | 'floor',\n\t\t\t\targs: normalizedArgs,\n\t\t\t}\n\t\t}\n\t\tcase 'weight': {\n\t\t\tconst { field, weights } = value\n\t\t\tif (typeof field !== 'string') return undefined\n\t\t\tif (!isRecord(weights)) return undefined\n\t\t\tconst normalizedWeights: Record<string, number> = {}\n\t\t\tfor (const [k, v] of Object.entries(weights)) {\n\t\t\t\tif (typeof v !== 'number' || !Number.isFinite(v)) return undefined\n\t\t\t\tnormalizedWeights[k] = v\n\t\t\t}\n\t\t\treturn { type: 'weight', field, weights: normalizedWeights }\n\t\t}\n\t\tdefault:\n\t\t\treturn undefined\n\t}\n}\n\n/** Structural guard: returns a valid CalcExpression if `value` is structurally sound (depth-guarded at 64), otherwise undefined. Never throws. */\nexport const normalizeCalc = (value: unknown): CalcExpression | undefined => normalizeNode(value, 0)\n"],"mappings":";AAEA,MAAM,YAAY;AAElB,MAAM,SAAS,IAAI,IAAI;CAAC;CAAK;CAAK;CAAK;CAAK;AAAG,CAAC;AAChD,MAAM,SAAS,IAAI,IAAI;CAAC;CAAO;CAAO;CAAS;CAAO;CAAQ;AAAO,CAAC;AAEtE,MAAM,YAAY,MACjB,MAAM,QAAQ,OAAO,MAAM,YAAY,CAAC,MAAM,QAAQ,CAAC;AAExD,MAAM,iBAAiB,OAAgB,UAA8C;CACpF,IAAI,QAAQ,WAAW,OAAO,KAAA;CAC9B,IAAI,CAAC,SAAS,KAAK,GAAG,OAAO,KAAA;CAE7B,QAAQ,MAAM,MAAd;EACC,KAAK,OAAO;GACX,MAAM,EAAE,OAAO,MAAM;GACrB,IAAI,OAAO,MAAM,YAAY,CAAC,OAAO,SAAS,CAAC,GAAG,OAAO,KAAA;GACzD,OAAO;IAAE,MAAM;IAAO,OAAO;GAAE;EAChC;EACA,KAAK,OAAO;GACX,MAAM,EAAE,UAAU;GAClB,IAAI,OAAO,UAAU,UAAU,OAAO,KAAA;GACtC,OAAO;IAAE,MAAM;IAAO;GAAM;EAC7B;EACA,KAAK,MAAM;GACV,MAAM,EAAE,IAAI,MAAM,UAAU;GAC5B,IAAI,OAAO,OAAO,YAAY,CAAC,OAAO,IAAI,EAAE,GAAG,OAAO,KAAA;GACtD,MAAM,IAAI,cAAc,MAAM,QAAQ,CAAC;GACvC,IAAI,CAAC,GAAG,OAAO,KAAA;GACf,MAAM,IAAI,cAAc,OAAO,QAAQ,CAAC;GACxC,IAAI,CAAC,GAAG,OAAO,KAAA;GACf,OAAO;IAAE,MAAM;IAAU;IAAmC,MAAM;IAAG,OAAO;GAAE;EAC/E;EACA,KAAK,OAAO;GACX,MAAM,UAAU,cAAc,MAAM,SAAS,QAAQ,CAAC;GACtD,IAAI,CAAC,SAAS,OAAO,KAAA;GACrB,OAAO;IAAE,MAAM;IAAO;GAAQ;EAC/B;EACA,KAAK,MAAM;GACV,MAAM,EAAE,IAAI,SAAS;GACrB,IAAI,OAAO,OAAO,YAAY,CAAC,OAAO,IAAI,EAAE,GAAG,OAAO,KAAA;GACtD,IAAI,CAAC,MAAM,QAAQ,IAAI,GAAG,OAAO,KAAA;GACjC,MAAM,iBAAmC,CAAC;GAC1C,KAAK,MAAM,OAAO,MAAM;IACvB,MAAM,IAAI,cAAc,KAAK,QAAQ,CAAC;IACtC,IAAI,CAAC,GAAG,OAAO,KAAA;IACf,eAAe,KAAK,CAAC;GACtB;GACA,OAAO;IACN,MAAM;IACF;IACJ,MAAM;GACP;EACD;EACA,KAAK,UAAU;GACd,MAAM,EAAE,OAAO,YAAY;GAC3B,IAAI,OAAO,UAAU,UAAU,OAAO,KAAA;GACtC,IAAI,CAAC,SAAS,OAAO,GAAG,OAAO,KAAA;GAC/B,MAAM,oBAA4C,CAAC;GACnD,KAAK,MAAM,CAAC,GAAG,MAAM,OAAO,QAAQ,OAAO,GAAG;IAC7C,IAAI,OAAO,MAAM,YAAY,CAAC,OAAO,SAAS,CAAC,GAAG,OAAO,KAAA;IACzD,kBAAkB,KAAK;GACxB;GACA,OAAO;IAAE,MAAM;IAAU;IAAO,SAAS;GAAkB;EAC5D;EACA,SACC;CACF;AACD;;AAGA,MAAa,iBAAiB,UAA+C,cAAc,OAAO,CAAC"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/calc/types.d.ts
|
|
2
|
+
/** A serializable, safe-by-construction calculation expression. No strings are parsed; the evaluator tree-walks this closed node set (never `eval`). */
|
|
3
|
+
type CalcExpression = {
|
|
4
|
+
type: 'lit';
|
|
5
|
+
value: number;
|
|
6
|
+
} | {
|
|
7
|
+
type: 'ref';
|
|
8
|
+
field: string;
|
|
9
|
+
} | {
|
|
10
|
+
type: 'op';
|
|
11
|
+
op: '+' | '-' | '*' | '/' | '%';
|
|
12
|
+
left: CalcExpression;
|
|
13
|
+
right: CalcExpression;
|
|
14
|
+
} | {
|
|
15
|
+
type: 'neg';
|
|
16
|
+
operand: CalcExpression;
|
|
17
|
+
} | {
|
|
18
|
+
type: 'fn';
|
|
19
|
+
fn: 'min' | 'max' | 'round' | 'abs' | 'ceil' | 'floor';
|
|
20
|
+
args: CalcExpression[];
|
|
21
|
+
} | {
|
|
22
|
+
type: 'weight';
|
|
23
|
+
field: string;
|
|
24
|
+
weights: Record<string, number>;
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
export { CalcExpression };
|
|
28
|
+
//# sourceMappingURL=types.d.ts.map
|