@aglyn/plugins-forms 1.0.0-beta.143

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.
Files changed (45) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +35 -0
  3. package/package.json +52 -0
  4. package/src/index.d.ts +18 -0
  5. package/src/index.js +19 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/components/form-design-preview.component.d.ts +68 -0
  8. package/src/lib/components/form-design-preview.component.js +238 -0
  9. package/src/lib/components/form-design-preview.component.js.map +1 -0
  10. package/src/lib/components/form-detail-card.d.ts +53 -0
  11. package/src/lib/components/form-detail-card.js +827 -0
  12. package/src/lib/components/form-detail-card.js.map +1 -0
  13. package/src/lib/components/form-metrics-card.component.d.ts +67 -0
  14. package/src/lib/components/form-metrics-card.component.js +298 -0
  15. package/src/lib/components/form-metrics-card.component.js.map +1 -0
  16. package/src/lib/components/form-submissions-card.component.d.ts +40 -0
  17. package/src/lib/components/form-submissions-card.component.js +104 -0
  18. package/src/lib/components/form-submissions-card.component.js.map +1 -0
  19. package/src/lib/components/form-zones.d.ts +52 -0
  20. package/src/lib/components/form-zones.js +20 -0
  21. package/src/lib/components/form-zones.js.map +1 -0
  22. package/src/lib/components/form.d.ts +186 -0
  23. package/src/lib/components/form.js +1014 -0
  24. package/src/lib/components/form.js.map +1 -0
  25. package/src/lib/components/forms-console-page.d.ts +22 -0
  26. package/src/lib/components/forms-console-page.js +55 -0
  27. package/src/lib/components/forms-console-page.js.map +1 -0
  28. package/src/lib/components/host-forms-card.component.d.ts +56 -0
  29. package/src/lib/components/host-forms-card.component.js +533 -0
  30. package/src/lib/components/host-forms-card.component.js.map +1 -0
  31. package/src/lib/components/use-form-promote-api.d.ts +48 -0
  32. package/src/lib/components/use-form-promote-api.js +63 -0
  33. package/src/lib/components/use-form-promote-api.js.map +1 -0
  34. package/src/lib/constants/bundle-common.d.ts +31 -0
  35. package/src/lib/constants/bundle-common.js +31 -0
  36. package/src/lib/constants/bundle-common.js.map +1 -0
  37. package/src/lib/plugin.d.ts +25 -0
  38. package/src/lib/plugin.js +76 -0
  39. package/src/lib/plugin.js.map +1 -0
  40. package/src/lib/site.d.ts +53 -0
  41. package/src/lib/site.js +83 -0
  42. package/src/lib/site.js.map +1 -0
  43. package/src/lib/utils/generate-preset-id.d.ts +25 -0
  44. package/src/lib/utils/generate-preset-id.js +30 -0
  45. package/src/lib/utils/generate-preset-id.js.map +1 -0
@@ -0,0 +1,1014 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
3
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
4
+ /**
5
+ * @license
6
+ * Copyright 2026 Aglyn LLC
7
+ *
8
+ * Licensed under the Apache License, Version 2.0 (the "License");
9
+ * you may not use this file except in compliance with the License.
10
+ * You may obtain a copy of the License at
11
+ *
12
+ * http://www.apache.org/licenses/LICENSE-2.0
13
+ *
14
+ * Unless required by applicable law or agreed to in writing, software
15
+ * distributed under the License is distributed on an "AS IS" BASIS,
16
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ * See the License for the specific language governing permissions and
18
+ * limitations under the License.
19
+ */ import { sendAnalyticsBeacon } from "@aglyn/aglyn/app-utils/analytics-beacon";
20
+ import { trackEventBeforeNavigation } from "@aglyn/aglyn/app-utils/analytics-events";
21
+ import { campaignTouchField } from "@aglyn/aglyn/app-utils/campaign-touch";
22
+ import * as Aglyn from "@aglyn/aglyn";
23
+ import { mdiEmailFastOutline, mdiEmailOutline, mdiFormTextbox } from "@aglyn/shared-data-mdi";
24
+ import { isSameOriginPath } from "@aglyn/shared-util-http/safe-redirect";
25
+ import Alert from "@mui/material/Alert";
26
+ import Button from "@mui/material/Button";
27
+ import Checkbox from "@mui/material/Checkbox";
28
+ import FormControl from "@mui/material/FormControl";
29
+ import FormControlLabel from "@mui/material/FormControlLabel";
30
+ import FormGroup from "@mui/material/FormGroup";
31
+ import FormLabel from "@mui/material/FormLabel";
32
+ import Link from "@mui/material/Link";
33
+ import MenuItem from "@mui/material/MenuItem";
34
+ import Radio from "@mui/material/Radio";
35
+ import RadioGroup from "@mui/material/RadioGroup";
36
+ import Rating from "@mui/material/Rating";
37
+ import Stack from "@mui/material/Stack";
38
+ import TextField from "@mui/material/TextField";
39
+ import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
40
+ import { BUNDLE_ID } from "../constants/bundle-common.js";
41
+ import { generatePresetId } from "../utils/generate-preset-id.js";
42
+ /*
43
+ * The heading and the stack this block's form sits inside are mui elements,
44
+ * so the nodes that carry them say so. A node's `pluginId` names the bundle
45
+ * that REGISTERS its component, not the bundle whose preset placed it.
46
+ *
47
+ * The id comes from the core's constant, as it does for every plugin that
48
+ * composes mui elements: importing the mui plugin for one string would make
49
+ * this plugin unusable without it on the import graph.
50
+ */ const { MUI_BUNDLE_ID } = Aglyn;
51
+ // Component ids are persisted in screen documents; never rename.
52
+ export const FORM_ID = 'form';
53
+ export const FORM_FIELD_ID = 'formField';
54
+ /**
55
+ * Hidden-input prefix a FormField uses to publish its field → dataset
56
+ * schema-field mapping to the enclosing Form (AGL-556). Rides the DOM so
57
+ * arbitrary nesting between Form and field needs no React context; the
58
+ * `__` prefix keeps it out of the submitted fields.
59
+ */ export const FIELD_MAP_INPUT_PREFIX = '__map__';
60
+ /**
61
+ * Redirect targets are restricted to same-origin paths (`/thanks`) and
62
+ * absolute https URLs (AGL-557): stored props reach every visitor's
63
+ * browser, so `javascript:`/`data:` URLs — and protocol-relative
64
+ * `//host` forms, which keep the scheme but swap the host — never pass.
65
+ *
66
+ * The relative branch asks {@link isSameOriginPath} rather than reading the
67
+ * string's first two characters (AGL-1881). This site never got the AGL-2486
68
+ * backslash fix, and the character list would not have been enough anyway:
69
+ * the URL parser deletes tab/LF/CR before parsing, so `/<TAB>/evil.com`
70
+ * carries neither `//` nor `\` and `window.location.assign` — which
71
+ * `formNavigation` below calls — still lands the visitor on `evil.com`. The
72
+ * absolute branch is unchanged: an explicit `https://` target is a documented
73
+ * feature, and for absolute input the parser's verdict was never in doubt.
74
+ */ export const sanitizeRedirectUrl = (url)=>{
75
+ const trimmed = (url != null ? url : '').trim();
76
+ if (!trimmed) return undefined;
77
+ if (/^https:\/\//i.test(trimmed)) return trimmed;
78
+ if (isSameOriginPath(trimmed)) return trimmed;
79
+ return undefined;
80
+ };
81
+ /**
82
+ * Where a redirect outcome sends the visitor: the picked screen's
83
+ * resolved href wins; the manual URL is the fallback, sanitized above.
84
+ * Undefined (deleted screen, bad URL) degrades to the message outcome.
85
+ */ export const resolveRedirectTarget = (screenHref, redirectUrl)=>screenHref != null ? screenHref : sanitizeRedirectUrl(redirectUrl);
86
+ // Reveal ids are interpolated into a CSS selector — restrict to the id
87
+ // alphabet so a stored prop can't smuggle selector syntax (AGL-557).
88
+ const REVEAL_NODE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
89
+ /**
90
+ * Navigation seam for the redirect outcome: jsdom's `window.location`
91
+ * is not patchable, so tests stub this indirection instead.
92
+ */ export const formNavigation = {
93
+ assign: (url)=>window.location.assign(url)
94
+ };
95
+ /** DOM event dispatched on every successful submit (AGL-557). */ export const FORM_SUBMITTED_EVENT = 'aglyn:form-submitted';
96
+ /**
97
+ * Report that a bound form was seen, or typed into.
98
+ *
99
+ * Fire-and-forget, and deliberately the same instrument the overlay counters
100
+ * use: `sendBeacon` hands the request to the browser, which delivers it
101
+ * outside the page's lifetime, so nothing here can delay a render or survive
102
+ * long enough to fail visibly.
103
+ *
104
+ * Requires a bound `formId`. An unbound form has no document to count on, and
105
+ * an id is never invented: the collector's `update` would find nothing, which
106
+ * is the right outcome but a pointless request.
107
+ */ export function sendFormBeacon(hostId, formId, event) {
108
+ if (!hostId || !formId) return;
109
+ // Best-effort by construction: `sendAnalyticsBeacon` swallows everything and
110
+ // refuses outright from a non-production build or a browser marked ours. A
111
+ // view that does not report is a view this form's completion rate is
112
+ // measured without, which the console says.
113
+ sendAnalyticsBeacon({
114
+ hostId,
115
+ formId,
116
+ form: event
117
+ });
118
+ }
119
+ /**
120
+ * Lead-capture form (AGL-76): collects its field children's values and
121
+ * posts them to the tenant's `/api/forms/submit` with the site's host id
122
+ * from SiteContext. Without a site context (besigner canvas, preview) the
123
+ * submit is inert, so editing never creates submissions. Includes a
124
+ * honeypot input for naive bots.
125
+ */ const Form = /*#__PURE__*/ forwardRef((props, ref)=>{
126
+ const { formId, formName, // Read by compose, which signs them into `datasetBindingToken`; taken out
127
+ // here only so they never reach the DOM through `rest`.
128
+ datasetId: _datasetId, datasetName: _datasetName, datasetBindingToken, submitLabel, successMessage, afterSubmit, redirectScreenId, redirectUrl, revealNodeId, children } = props, rest = _object_without_properties_loose(props, [
129
+ "formId",
130
+ "formName",
131
+ "datasetId",
132
+ "datasetName",
133
+ "datasetBindingToken",
134
+ "submitLabel",
135
+ "successMessage",
136
+ "afterSubmit",
137
+ "redirectScreenId",
138
+ "redirectUrl",
139
+ "revealNodeId",
140
+ "children"
141
+ ]);
142
+ const { hostId } = Aglyn.useSite();
143
+ const siteFetch = Aglyn.useSiteFetch();
144
+ // Resolves the redirect screen like ScreenLink does (rename-safe) and
145
+ // flags editing surfaces, where outcomes must never fire (AGL-557).
146
+ const { href: redirectScreenHref, suppressNavigation } = Aglyn.useScreenLink(afterSubmit === 'redirect' ? redirectScreenId : undefined);
147
+ // Reveal target (AGL-557): hidden through a form-owned <style> tag so
148
+ // SSR paints it hidden from the first frame; the style unmounts with
149
+ // the form after a successful submit — which IS the reveal. Editing
150
+ // surfaces keep the target visible so it stays editable.
151
+ const revealSelector = afterSubmit === 'reveal' && revealNodeId && REVEAL_NODE_ID_PATTERN.test(revealNodeId) && !suppressNavigation ? `[data-aglyn="leaf:${revealNodeId}"]` : undefined;
152
+ const [status, setStatus] = useState('idle');
153
+ /*
154
+ * THE VIEW AND THE START — the two facts a completion rate is taken over.
155
+ *
156
+ * Both are reported ONCE per mounted form, from a ref rather than from
157
+ * state: a counter driven by a re-render would be a Firestore write per
158
+ * keystroke, which is the per-render write this must not become. The ref
159
+ * also survives the re-renders `status` causes, so a visitor who edits a
160
+ * field, submits, and is shown an error is one view and one start.
161
+ *
162
+ * Gated on `!suppressNavigation`, the editing-surface flag: the besigner
163
+ * canvas and the console's preview render this component for its author,
164
+ * and counting an author looking at their own draft as a visitor view would
165
+ * put the merchant into their own denominator.
166
+ */ const reported = useRef({
167
+ view: false,
168
+ start: false
169
+ });
170
+ useEffect(()=>{
171
+ if (suppressNavigation || reported.current.view) return;
172
+ reported.current.view = true;
173
+ sendFormBeacon(hostId, formId, 'view');
174
+ }, [
175
+ hostId,
176
+ formId,
177
+ suppressNavigation
178
+ ]);
179
+ /**
180
+ * A START is the first edit anyone makes to this form.
181
+ *
182
+ * On the form's own `input`, which bubbles from every field — no listener
183
+ * registration, no per-field wiring, and nothing to keep in sync when a
184
+ * field type is added. Focus would be the looser reading and the wrong one:
185
+ * a form that is tabbed through and abandoned was never started, and
186
+ * counting it would inflate the denominator abandonment is measured over.
187
+ */ const handleFirstInput = useCallback(()=>{
188
+ if (suppressNavigation || reported.current.start) return;
189
+ reported.current.start = true;
190
+ sendFormBeacon(hostId, formId, 'start');
191
+ }, [
192
+ hostId,
193
+ formId,
194
+ suppressNavigation
195
+ ]);
196
+ /** The read-only lockdown's own words (AGL-1511); never a hardcoded line. */ const [pausedMessage, setPausedMessage] = useState('');
197
+ /** The abuse ceiling's visitor notice (AGL-1666); see `unavailable` below. */ const [unavailable, setUnavailable] = useState(null);
198
+ const [alerts, setAlerts] = useState([]);
199
+ const handleSubmit = useCallback(async (event)=>{
200
+ event.preventDefault();
201
+ if (!hostId || status === 'sending') return;
202
+ const data = new FormData(event.currentTarget);
203
+ const fields = {};
204
+ let website = '';
205
+ for (const [key, value] of data.entries()){
206
+ if (typeof value !== 'string') continue;
207
+ if (key === 'website') {
208
+ website = value;
209
+ continue;
210
+ }
211
+ // `__`-prefixed inputs are internal controls — the rating field's
212
+ // star radios (AGL-544) and each field's dataset-mapping input — and
213
+ // never submit.
214
+ if (key.startsWith('__')) continue;
215
+ // Checkbox groups emit one entry per ticked box; join them under
216
+ // the field name instead of letting the last one win (AGL-544).
217
+ fields[key] = (key in fields ? `${fields[key]}, ${value}` : value).slice(0, 2000);
218
+ }
219
+ setStatus('sending');
220
+ try {
221
+ const response = await siteFetch('/api/forms/submit', {
222
+ method: 'POST',
223
+ headers: {
224
+ 'Content-Type': 'application/json'
225
+ },
226
+ body: JSON.stringify(_extends({
227
+ hostId
228
+ }, formId ? {
229
+ formId
230
+ } : {}, {
231
+ formName: formName || 'Form'
232
+ }, datasetBindingToken ? {
233
+ datasetBinding: datasetBindingToken
234
+ } : {}, {
235
+ path: window.location.pathname,
236
+ fields,
237
+ website
238
+ }, campaignTouchField()))
239
+ });
240
+ if (response.ok) {
241
+ // Site alerts from the actions builder (AGL-148).
242
+ const payload = await response.json().catch(()=>({}));
243
+ if (Array.isArray(payload == null ? void 0 : payload.alerts)) setAlerts(payload.alerts);
244
+ // Announced for reveal-style listeners/analytics (AGL-557).
245
+ window.dispatchEvent(new CustomEvent(FORM_SUBMITTED_EVENT, {
246
+ detail: {
247
+ formName: formName || 'Form'
248
+ }
249
+ }));
250
+ // GA4 lead conversion (AGL-1561). Inside `response.ok`, so it
251
+ // measures a SUBMISSION, not a click and not a validation failure —
252
+ // the distinction that makes a conversion rate mean anything.
253
+ //
254
+ // AWAITED, because the branch below navigates. Ordering the call
255
+ // ahead of the `assign` is not on its own enough, and the obvious
256
+ // reasoning about beacons is the wrong one: once a hit REACHES gtag
257
+ // a navigation cannot destroy it, so `transport_type: 'beacon'`
258
+ // fixes nothing. What is lost is a hit that never reaches gtag,
259
+ // which is what happens when the surface's transport is async and
260
+ // its initialization promise is still pending — the continuation is
261
+ // scheduled behind the navigation task and never runs. The tenant
262
+ // runtime registers no transport, so this resolves immediately and
263
+ // costs the redirect nothing; it is written this way because the
264
+ // property that makes a bare call safe is invisible from here.
265
+ //
266
+ // `formName` is author-written site content, never a field VALUE:
267
+ // no submitted field ever reaches GA from here.
268
+ //
269
+ // This runs on every tenant site, not only aglyn.com, and that is
270
+ // deliberate — gtag is loaded with whatever measurement id the HOST
271
+ // configured, so a customer's form reports into the customer's own
272
+ // property and aglyn.com's reports into ours. It is consent-gated
273
+ // either way: without a grant the script never loaded (AGL-1498)
274
+ // and the delivery path finds no `window.gtag` to call.
275
+ await trackEventBeforeNavigation('generate_lead', {
276
+ form_name: formName || 'Form',
277
+ form_location: window.location.pathname
278
+ });
279
+ if (afterSubmit === 'redirect' && !suppressNavigation) {
280
+ const target = resolveRedirectTarget(redirectScreenHref, redirectUrl);
281
+ // No valid target (deleted screen, rejected URL) degrades to
282
+ // the success message below.
283
+ if (target) formNavigation.assign(target);
284
+ }
285
+ }
286
+ // A refusal is not a failure (AGL-1249). Preview declines writes on
287
+ // purpose, and "something went wrong — please try again" invites the
288
+ // author to retry something that is working exactly as intended.
289
+ //
290
+ // Since AGL-1511 there are TWO deliberate 423s a submit can meet, so
291
+ // the status alone no longer identifies one: Preview's client-side
292
+ // refusal, and a read-only lockdown refusing a real visitor's write
293
+ // on a live site. Preview's carries `preview: true` and is settled
294
+ // first; anything else 423 is the lockdown pause, whose own copy is
295
+ // rendered rather than the Preview sentence — telling a customer's
296
+ // visitor "this works on your published site" would be nonsense on
297
+ // the published site they are standing on.
298
+ if (!response.ok) {
299
+ const body = await response.json().catch(()=>({}));
300
+ if (Aglyn.isPreviewRefusal(response, body)) {
301
+ setStatus('preview');
302
+ return;
303
+ }
304
+ const paused = Aglyn.parseLockdownRefusal(response.status, body);
305
+ if (paused) {
306
+ setPausedMessage(Aglyn.lockdownRefusalText(paused));
307
+ setStatus('paused');
308
+ return;
309
+ }
310
+ // The site's form-submission abuse ceiling (AGL-1655/1666). This
311
+ // is a THIRD deliberate refusal, and the only one whose status is
312
+ // already spoken for: the Free plan's monthly wall answers 429
313
+ // too. So it is matched on the body's `code` and never on the
314
+ // status — `parseFormUnavailableRefusal` takes no status for
315
+ // exactly that reason. A plan-wall 429 keeps falling through to
316
+ // the generic branch below, which is right: it is the owner's
317
+ // billing problem, and nothing about waiting fixes it.
318
+ const notice = Aglyn.parseFormUnavailableRefusal(body);
319
+ if (notice) {
320
+ setUnavailable(notice);
321
+ setStatus('unavailable');
322
+ return;
323
+ }
324
+ }
325
+ setStatus(response.ok ? 'sent' : 'error');
326
+ } catch (unused) {
327
+ setStatus('error');
328
+ }
329
+ }, [
330
+ hostId,
331
+ status,
332
+ formId,
333
+ formName,
334
+ datasetBindingToken,
335
+ afterSubmit,
336
+ redirectScreenHref,
337
+ redirectUrl,
338
+ suppressNavigation,
339
+ siteFetch
340
+ ]);
341
+ if (status === 'sent') {
342
+ // Reveal outcome: the form (with its hide-style below) unmounts and
343
+ // the revealed element is the confirmation. Redirect keeps the
344
+ // message visible as a fallback while the browser navigates.
345
+ if (revealSelector) return null;
346
+ return /*#__PURE__*/ _jsx(Alert, {
347
+ severity: "success",
348
+ children: successMessage || 'Thanks — your message has been sent.'
349
+ });
350
+ }
351
+ return /*#__PURE__*/ _jsxs(Stack, _extends({
352
+ ref: ref,
353
+ component: "form",
354
+ spacing: 2,
355
+ onSubmit: handleSubmit,
356
+ onInput: handleFirstInput
357
+ }, rest, {
358
+ children: [
359
+ revealSelector ? /*#__PURE__*/ _jsx("style", {
360
+ children: `${revealSelector}{display:none !important}`
361
+ }) : null,
362
+ children,
363
+ /*#__PURE__*/ _jsx("input", {
364
+ type: "text",
365
+ name: "website",
366
+ tabIndex: -1,
367
+ autoComplete: "off",
368
+ "aria-hidden": "true",
369
+ style: {
370
+ position: 'absolute',
371
+ left: '-5000px',
372
+ height: 0,
373
+ width: 0
374
+ }
375
+ }),
376
+ status === 'error' ? /*#__PURE__*/ _jsx(Alert, {
377
+ severity: "error",
378
+ children: 'Something went wrong — please try again.'
379
+ }) : null,
380
+ status === 'paused' ? // `info`, like the Preview notice and for the same reason: this is a
381
+ // deliberate, temporary pause on a site that is otherwise working,
382
+ // and nothing the visitor typed was lost.
383
+ /*#__PURE__*/ _jsx(Alert, {
384
+ severity: "info",
385
+ children: pausedMessage
386
+ }) : null,
387
+ status === 'unavailable' && unavailable ? // `warning`, not `info` and not `error` (AGL-1666). The two `info`
388
+ // notices above are both cases where nothing was lost — Preview
389
+ // never intended to send, and a lockdown pause is minutes long with
390
+ // the visitor's text still in the fields. This one lost a real
391
+ // message on a live site, so it must not read as reassurance. It is
392
+ // not `error` either: nothing is broken and "please try again" would
393
+ // walk the visitor into the same refusal.
394
+ /*#__PURE__*/ _jsxs(Alert, {
395
+ severity: "warning",
396
+ children: [
397
+ unavailable.message,
398
+ unavailable.contact ? /*#__PURE__*/ _jsxs(_Fragment, {
399
+ children: [
400
+ ' In the meantime you can reach us at ',
401
+ /*#__PURE__*/ _jsx(Link, {
402
+ href: `mailto:${unavailable.contact}`,
403
+ children: unavailable.contact
404
+ }),
405
+ '.'
406
+ ]
407
+ }) : null
408
+ ]
409
+ }) : null,
410
+ status === 'preview' ? // Deliberately `info`, not `error`, and it does not say "try again":
411
+ // nothing failed, and the same submit works on the published site.
412
+ /*#__PURE__*/ _jsx(Alert, {
413
+ severity: "info",
414
+ children: 'Preview does not send form submissions. This form works on your ' + 'published site.'
415
+ }) : null,
416
+ alerts.map((alert, index)=>/*#__PURE__*/ _jsx(Alert, {
417
+ severity: alert.severity || 'info',
418
+ children: alert.message
419
+ }, index)),
420
+ /*#__PURE__*/ _jsx(Button, {
421
+ type: "submit",
422
+ variant: "contained",
423
+ disabled: status === 'sending',
424
+ sx: {
425
+ alignSelf: 'flex-start'
426
+ },
427
+ children: status === 'sending' ? 'Sending…' : submitLabel || 'Send'
428
+ })
429
+ ]
430
+ }));
431
+ });
432
+ Form.displayName = 'AglynForm';
433
+ /**
434
+ * Splits a newline- or comma-separated choice list into trimmed,
435
+ * non-empty entries (AGL-544).
436
+ */ export const parseFieldOptions = (options)=>(options || '').split(/[\n,]/).map((entry)=>entry.trim()).filter(Boolean);
437
+ /**
438
+ * Single input inside a Form (AGL-76). Survey field types — select,
439
+ * radio, checkbox group, and star rating — are AGL-544.
440
+ */ const FormField = /*#__PURE__*/ forwardRef((props, ref)=>{
441
+ const { fieldName, datasetFieldId, label, placeholder, fieldType, options, required } = props, rest = _object_without_properties_loose(props, [
442
+ "fieldName",
443
+ "datasetFieldId",
444
+ "label",
445
+ "placeholder",
446
+ "fieldType",
447
+ "options",
448
+ "required"
449
+ ]);
450
+ const name = fieldName || 'field';
451
+ const fieldLabel = label || fieldName || 'Field';
452
+ const choices = parseFieldOptions(options);
453
+ // A CLEARED placeholder must render as no placeholder (AGL-1330). The
454
+ // attributes form persists a cleared text field by dropping the key
455
+ // (ddf maps an emptied field to its clearedValue and final-form's parse
456
+ // turns `''` into `undefined`), and `updateNodeProps` REPLACES the props
457
+ // object — so cleared reaches us as absent. Older documents can still
458
+ // carry `''`/`null`, and a blank string would otherwise reach the DOM as
459
+ // an empty `placeholder` attribute, so blank is normalized to absent too.
460
+ const hint = typeof placeholder === 'string' && placeholder.trim() ? placeholder : undefined;
461
+ // MUI hides a placeholder for as long as the label still sits inside the
462
+ // box, so the hint is only visible once the label is shrunk — and an
463
+ // outlined input with a shrunk label must notch its outline or the label
464
+ // crosses the border. Both are applied ONLY when there is a placeholder,
465
+ // so fields without one keep the historical floating-label behavior.
466
+ const hintSlotProps = hint ? {
467
+ inputLabel: {
468
+ shrink: true
469
+ },
470
+ input: {
471
+ notched: true
472
+ }
473
+ } : undefined;
474
+ // Checkbox groups count ticked boxes so "required" can mean "at least
475
+ // one": native `required` is only asserted while none are ticked.
476
+ const [checkedCount, setCheckedCount] = useState(0);
477
+ // Rating is controlled so a hidden input can serialize the number.
478
+ const [rating, setRating] = useState(null);
479
+ // Publishes this field's dataset schema-field mapping to the enclosing
480
+ // Form via the DOM (AGL-556); `__`-prefixed, so never a submitted field.
481
+ const mapInput = datasetFieldId ? /*#__PURE__*/ _jsx("input", {
482
+ type: "hidden",
483
+ name: `${FIELD_MAP_INPUT_PREFIX}${name}`,
484
+ value: datasetFieldId,
485
+ readOnly: true
486
+ }) : null;
487
+ if (fieldType === 'select') {
488
+ return /*#__PURE__*/ _jsxs(_Fragment, {
489
+ children: [
490
+ /*#__PURE__*/ _jsx(TextField, _extends({
491
+ ref: ref,
492
+ select: true,
493
+ name: name,
494
+ label: fieldLabel,
495
+ required: Boolean(required),
496
+ defaultValue: "",
497
+ fullWidth: true,
498
+ size: "small",
499
+ // A Select has no native placeholder: the hint is what the closed
500
+ // control displays while nothing is chosen, which needs
501
+ // `displayEmpty` (MUI renders nothing for `''` without it).
502
+ slotProps: hint ? _extends({}, hintSlotProps, {
503
+ select: {
504
+ displayEmpty: true,
505
+ renderValue: (value)=>value ? String(value) : /*#__PURE__*/ _jsx("span", {
506
+ style: {
507
+ opacity: 0.6
508
+ },
509
+ children: hint
510
+ })
511
+ }
512
+ }) : undefined
513
+ }, rest, {
514
+ children: choices.map((choice, index)=>/*#__PURE__*/ _jsx(MenuItem, {
515
+ value: choice,
516
+ children: choice
517
+ }, index))
518
+ })),
519
+ mapInput
520
+ ]
521
+ });
522
+ }
523
+ if (fieldType === 'radio') {
524
+ return /*#__PURE__*/ _jsxs(FormControl, _extends({
525
+ ref: ref,
526
+ required: Boolean(required)
527
+ }, rest, {
528
+ children: [
529
+ /*#__PURE__*/ _jsx(FormLabel, {
530
+ children: fieldLabel
531
+ }),
532
+ /*#__PURE__*/ _jsx(RadioGroup, {
533
+ name: name,
534
+ children: choices.map((choice, index)=>/*#__PURE__*/ _jsx(FormControlLabel, {
535
+ value: choice,
536
+ label: choice,
537
+ control: /*#__PURE__*/ _jsx(Radio, {
538
+ size: "small",
539
+ required: Boolean(required)
540
+ })
541
+ }, index))
542
+ }),
543
+ mapInput
544
+ ]
545
+ }));
546
+ }
547
+ if (fieldType === 'checkbox') {
548
+ return /*#__PURE__*/ _jsxs(FormControl, _extends({
549
+ ref: ref,
550
+ required: Boolean(required)
551
+ }, rest, {
552
+ children: [
553
+ /*#__PURE__*/ _jsx(FormLabel, {
554
+ children: fieldLabel
555
+ }),
556
+ /*#__PURE__*/ _jsx(FormGroup, {
557
+ children: choices.map((choice, index)=>/*#__PURE__*/ _jsx(FormControlLabel, {
558
+ label: choice,
559
+ control: /*#__PURE__*/ _jsx(Checkbox, {
560
+ name: name,
561
+ value: choice,
562
+ size: "small",
563
+ required: Boolean(required) && checkedCount === 0,
564
+ onChange: (event)=>setCheckedCount((count)=>count + (event.target.checked ? 1 : -1))
565
+ })
566
+ }, index))
567
+ }),
568
+ mapInput
569
+ ]
570
+ }));
571
+ }
572
+ if (fieldType === 'rating') {
573
+ return /*#__PURE__*/ _jsxs(FormControl, _extends({
574
+ ref: ref,
575
+ required: Boolean(required)
576
+ }, rest, {
577
+ children: [
578
+ /*#__PURE__*/ _jsx(FormLabel, {
579
+ children: fieldLabel
580
+ }),
581
+ /*#__PURE__*/ _jsx(Rating, {
582
+ // `__`-prefixed so the star radios themselves are skipped by
583
+ // Form.handleSubmit; the hidden input carries the value.
584
+ name: `__${name}`,
585
+ value: rating,
586
+ onChange: (_event, value)=>setRating(value)
587
+ }),
588
+ /*#__PURE__*/ _jsx("input", {
589
+ type: "hidden",
590
+ name: name,
591
+ value: rating != null ? rating : ''
592
+ }),
593
+ mapInput
594
+ ]
595
+ }));
596
+ }
597
+ return /*#__PURE__*/ _jsxs(_Fragment, {
598
+ children: [
599
+ /*#__PURE__*/ _jsx(TextField, _extends({
600
+ ref: ref,
601
+ name: name,
602
+ label: fieldLabel,
603
+ placeholder: hint,
604
+ type: fieldType === 'email' ? 'email' : 'text',
605
+ required: Boolean(required),
606
+ multiline: fieldType === 'textarea',
607
+ minRows: fieldType === 'textarea' ? 3 : undefined,
608
+ fullWidth: true,
609
+ size: "small",
610
+ slotProps: hintSlotProps
611
+ }, rest)),
612
+ mapInput
613
+ ]
614
+ });
615
+ });
616
+ FormField.displayName = 'AglynFormField';
617
+ export const formSchema = {
618
+ $id: FORM_ID,
619
+ pluginId: BUNDLE_ID,
620
+ displayName: 'Form',
621
+ description: 'Collects its Form Fields and sends the answers to you, or into a dataset.',
622
+ category: Aglyn.ComponentCategory.FORMS,
623
+ icon: {
624
+ path: mdiEmailFastOutline.path,
625
+ sx: {
626
+ color: 'info.main'
627
+ }
628
+ },
629
+ attributes: [
630
+ {
631
+ name: 'formId',
632
+ description: 'The form this collects for, from the Forms page. Stored by id, so ' + 'renaming the form never splits its submission history and two ' + 'pages placing the same form share one list. Leave unset to keep ' + 'filing submissions under the name below.',
633
+ component: Aglyn.FieldComponentType.FORM_SELECT,
634
+ label: 'Form'
635
+ },
636
+ {
637
+ name: 'formName',
638
+ description: 'Caption shown in the submissions inbox, used when no form above ' + 'is picked. A name is not an identity: renaming it splits the ' + 'history of everything already submitted under the old one.',
639
+ component: Aglyn.FieldComponentType.TEXT_FIELD,
640
+ label: 'Form name'
641
+ },
642
+ {
643
+ name: 'datasetId',
644
+ description: 'Optional dataset (from the Data page) submissions are appended ' + 'to. Stored by id, so renaming the dataset never breaks the ' + 'binding (AGL-556). The inbox always gets a copy.',
645
+ component: Aglyn.FieldComponentType.DATASET_SELECT,
646
+ label: 'Write to dataset'
647
+ },
648
+ {
649
+ name: 'datasetName',
650
+ description: 'Legacy name-based dataset binding — matched against the ' + "dataset's display name at submit time. Pick the dataset above " + 'and clear this; it only shows while a name is still set.',
651
+ component: Aglyn.FieldComponentType.TEXT_FIELD,
652
+ label: 'Write to dataset (legacy name)',
653
+ condition: {
654
+ when: 'datasetName',
655
+ isNotEmpty: true
656
+ }
657
+ },
658
+ {
659
+ name: 'submitLabel',
660
+ description: 'Label of the submit button.',
661
+ component: Aglyn.FieldComponentType.TEXT_FIELD,
662
+ label: 'Submit label'
663
+ },
664
+ {
665
+ name: 'successMessage',
666
+ description: 'Shown after a successful submission.',
667
+ component: Aglyn.FieldComponentType.TEXT_FIELD,
668
+ label: 'Success message'
669
+ },
670
+ // After-submit outcomes (AGL-557).
671
+ {
672
+ name: 'afterSubmit',
673
+ description: 'What a successful submit does: show the success message, send ' + 'the visitor to another page, or reveal a hidden element.',
674
+ component: Aglyn.FieldComponentType.SELECT,
675
+ label: 'After submit',
676
+ // `message` is the first member of the declared `FormAfterSubmit`
677
+ // union, so the sentinel already existed in the type and only the
678
+ // option list was spelling it `''` (AGL-1451) — a value the
679
+ // attributes form strips on change (AGL-1191), which meant a form
680
+ // switched to Redirect could not be switched back to its message.
681
+ options: [
682
+ {
683
+ value: 'message',
684
+ label: 'Show the success message'
685
+ },
686
+ {
687
+ value: 'redirect',
688
+ label: 'Redirect the visitor'
689
+ },
690
+ {
691
+ value: 'reveal',
692
+ label: 'Reveal a hidden element'
693
+ }
694
+ ]
695
+ },
696
+ {
697
+ name: 'redirectScreenId',
698
+ description: 'Screen the visitor lands on after submitting. The address is ' + "resolved at render time, so renaming the screen's slug never " + 'breaks the redirect.',
699
+ component: Aglyn.FieldComponentType.SCREEN_SELECT,
700
+ label: 'Redirect to screen',
701
+ condition: {
702
+ when: 'afterSubmit',
703
+ is: 'redirect'
704
+ }
705
+ },
706
+ {
707
+ name: 'redirectUrl',
708
+ description: 'Used only when no screen is selected above: a same-site path ' + '(/thanks) or an https URL. Anything else is ignored.',
709
+ component: Aglyn.FieldComponentType.TEXT_FIELD,
710
+ label: 'Redirect URL',
711
+ condition: {
712
+ when: 'afterSubmit',
713
+ is: 'redirect'
714
+ }
715
+ },
716
+ {
717
+ name: 'revealNodeId',
718
+ description: 'Element shown after a successful submit — it stays hidden on ' + 'the published page until then.',
719
+ component: Aglyn.FieldComponentType.NODE_SELECT,
720
+ label: 'Element to reveal',
721
+ condition: {
722
+ when: 'afterSubmit',
723
+ is: 'reveal'
724
+ }
725
+ }
726
+ ]
727
+ };
728
+ export const formFieldSchema = {
729
+ $id: FORM_FIELD_ID,
730
+ pluginId: BUNDLE_ID,
731
+ displayName: 'Form Field',
732
+ description: 'One input inside a form — text, select, radio, checkbox or rating.',
733
+ category: Aglyn.ComponentCategory.FORMS,
734
+ icon: {
735
+ path: mdiFormTextbox.path,
736
+ sx: {
737
+ color: 'info.main'
738
+ }
739
+ },
740
+ flags: {
741
+ selfClosing: Aglyn.FEATURE_FLAG.ENABLED
742
+ },
743
+ attributes: [
744
+ {
745
+ name: 'fieldName',
746
+ description: 'Key this value is stored under in submissions.',
747
+ component: Aglyn.FieldComponentType.TEXT_FIELD,
748
+ label: 'Field name'
749
+ },
750
+ {
751
+ name: 'datasetFieldId',
752
+ description: "Schema field of the form's dataset this value is stored under. " + 'Stored by id, so renaming the field never breaks the mapping ' + '(AGL-556). When unset, values match dataset fields by name.',
753
+ component: Aglyn.FieldComponentType.DATASET_FIELD_SELECT,
754
+ label: 'Maps to schema field'
755
+ },
756
+ {
757
+ name: 'label',
758
+ description: 'Visible input label.',
759
+ component: Aglyn.FieldComponentType.TEXT_FIELD,
760
+ label: 'Label'
761
+ },
762
+ {
763
+ name: 'placeholder',
764
+ description: 'Grey hint inside the empty input — "you@company.com" (AGL-1330). ' + 'It is not a label: it disappears as soon as the visitor types, so ' + 'always keep Label set. Ignored by radio, checkbox, and rating ' + 'fields. Clearing it removes the hint.',
765
+ component: Aglyn.FieldComponentType.TEXT_FIELD,
766
+ label: 'Placeholder'
767
+ },
768
+ {
769
+ name: 'fieldType',
770
+ description: 'Input type.',
771
+ component: Aglyn.FieldComponentType.SELECT,
772
+ label: 'Type',
773
+ // `text` is the first member of this prop's own union, and the value
774
+ // the render falls back to. As `''` it was the one field type an
775
+ // author could not choose and keep (AGL-1191/AGL-1451): a field
776
+ // switched to Email or Multiline could never be made plain text
777
+ // again without deleting it.
778
+ options: [
779
+ {
780
+ value: 'text',
781
+ label: 'Text'
782
+ },
783
+ {
784
+ value: 'email',
785
+ label: 'Email'
786
+ },
787
+ {
788
+ value: 'textarea',
789
+ label: 'Multiline'
790
+ },
791
+ {
792
+ value: 'select',
793
+ label: 'Dropdown'
794
+ },
795
+ {
796
+ value: 'radio',
797
+ label: 'Radio choice'
798
+ },
799
+ {
800
+ value: 'checkbox',
801
+ label: 'Checkboxes'
802
+ },
803
+ {
804
+ value: 'rating',
805
+ label: 'Star rating'
806
+ }
807
+ ]
808
+ },
809
+ {
810
+ name: 'options',
811
+ description: 'Choices for dropdown, radio, and checkbox fields — one per line ' + '(or comma-separated). Ignored by the other types.',
812
+ component: Aglyn.FieldComponentType.TEXTAREA,
813
+ label: 'Options'
814
+ },
815
+ {
816
+ name: 'required',
817
+ description: 'Whether the field must be filled.',
818
+ component: Aglyn.FieldComponentType.SWITCH,
819
+ label: 'Required?'
820
+ }
821
+ ]
822
+ };
823
+ export const formPresets = [
824
+ {
825
+ $id: generatePresetId(FORM_ID, 'contact'),
826
+ type: Aglyn.NodeType.PRESET,
827
+ displayName: 'Contact Form',
828
+ pluginId: BUNDLE_ID,
829
+ description: 'Name, email, and message with a send button',
830
+ category: Aglyn.ComponentCategory.FORMS,
831
+ icon: {
832
+ path: mdiEmailFastOutline.path,
833
+ sx: {
834
+ color: 'info.main'
835
+ }
836
+ },
837
+ data: {
838
+ $id: null,
839
+ componentId: FORM_ID,
840
+ pluginId: BUNDLE_ID,
841
+ props: {
842
+ formName: 'Contact'
843
+ },
844
+ nodes: [
845
+ {
846
+ $id: null,
847
+ componentId: FORM_FIELD_ID,
848
+ pluginId: BUNDLE_ID,
849
+ props: {
850
+ fieldName: 'name',
851
+ label: 'Name',
852
+ required: true
853
+ }
854
+ },
855
+ {
856
+ $id: null,
857
+ componentId: FORM_FIELD_ID,
858
+ pluginId: BUNDLE_ID,
859
+ props: {
860
+ fieldName: 'email',
861
+ label: 'Email',
862
+ fieldType: 'email',
863
+ required: true
864
+ }
865
+ },
866
+ {
867
+ $id: null,
868
+ componentId: FORM_FIELD_ID,
869
+ pluginId: BUNDLE_ID,
870
+ props: {
871
+ fieldName: 'message',
872
+ label: 'Message',
873
+ fieldType: 'textarea',
874
+ required: true
875
+ }
876
+ }
877
+ ]
878
+ }
879
+ },
880
+ {
881
+ $id: generatePresetId(FORM_FIELD_ID),
882
+ type: Aglyn.NodeType.PRESET,
883
+ displayName: 'Form Field',
884
+ pluginId: BUNDLE_ID,
885
+ description: 'Single input inside a form',
886
+ category: Aglyn.ComponentCategory.FORMS,
887
+ icon: {
888
+ path: mdiFormTextbox.path,
889
+ sx: {
890
+ color: 'info.main'
891
+ }
892
+ },
893
+ data: {
894
+ $id: null,
895
+ componentId: FORM_FIELD_ID,
896
+ pluginId: BUNDLE_ID,
897
+ props: {
898
+ fieldName: 'field',
899
+ label: 'Field'
900
+ }
901
+ }
902
+ },
903
+ /*
904
+ * The marketing consent field as one preset rather than a recipe. Its props
905
+ * are the platform's `MARKETING_CONSENT_FORM_FIELD`, which every place that adds
906
+ * this field reads, so each adds the same unticked one-option checkbox. It
907
+ * records consent once the form names it as its Marketing consent field.
908
+ */ {
909
+ $id: generatePresetId(FORM_FIELD_ID, 'marketing-consent'),
910
+ type: Aglyn.NodeType.PRESET,
911
+ displayName: 'Marketing consent',
912
+ pluginId: BUNDLE_ID,
913
+ description: 'An unticked checkbox asking permission to send marketing emails',
914
+ category: Aglyn.ComponentCategory.FORMS,
915
+ icon: {
916
+ path: mdiFormTextbox.path,
917
+ sx: {
918
+ color: 'info.main'
919
+ }
920
+ },
921
+ data: {
922
+ $id: null,
923
+ componentId: FORM_FIELD_ID,
924
+ pluginId: BUNDLE_ID,
925
+ props: _extends({}, Aglyn.MARKETING_CONSENT_FORM_FIELD)
926
+ }
927
+ }
928
+ ];
929
+ /**
930
+ * The composed section a form usually arrives inside.
931
+ *
932
+ * It lives here rather than with the generic block library because it PLACES
933
+ * form nodes, and a preset's `data` carries the `pluginId` those nodes are
934
+ * saved with — so authoring it anywhere else stamps every contact form on
935
+ * every new site with the wrong bundle. The heading and the stack around it
936
+ * name `mui`, which is correct: they ARE mui elements, and a page holding this
937
+ * section needs both bundles registered before it paints.
938
+ */ export const formBlockPresets = [
939
+ {
940
+ $id: generatePresetId(FORM_ID, 'contact-section'),
941
+ type: Aglyn.NodeType.PRESET,
942
+ displayName: 'Contact Section',
943
+ pluginId: BUNDLE_ID,
944
+ description: 'Heading with a name/email/message form',
945
+ category: Aglyn.ComponentCategory.BLOCKS,
946
+ icon: {
947
+ path: mdiEmailOutline.path,
948
+ sx: {
949
+ color: 'info.main'
950
+ }
951
+ },
952
+ data: {
953
+ $id: null,
954
+ componentId: 'muiStack',
955
+ pluginId: MUI_BUNDLE_ID,
956
+ props: {
957
+ spacing: 2
958
+ },
959
+ sx: {
960
+ paddingLeft: 4,
961
+ paddingRight: 4,
962
+ paddingTop: 6,
963
+ paddingBottom: 6,
964
+ maxWidth: 560
965
+ },
966
+ nodes: [
967
+ {
968
+ $id: null,
969
+ componentId: 'muiTypography',
970
+ pluginId: MUI_BUNDLE_ID,
971
+ props: {
972
+ variant: 'h4',
973
+ children: 'Get in touch'
974
+ }
975
+ },
976
+ {
977
+ $id: null,
978
+ componentId: FORM_ID,
979
+ pluginId: BUNDLE_ID,
980
+ props: {
981
+ formName: 'Contact',
982
+ submitLabel: 'Send message',
983
+ successMessage: 'Thanks — we will get back to you soon.'
984
+ },
985
+ nodes: [
986
+ contactField('name', 'Name'),
987
+ contactField('email', 'Email', {
988
+ fieldType: 'email'
989
+ }),
990
+ contactField('message', 'Message', {
991
+ fieldType: 'textarea'
992
+ })
993
+ ]
994
+ }
995
+ ]
996
+ }
997
+ }
998
+ ];
999
+ function contactField(fieldName, label, extra) {
1000
+ return {
1001
+ $id: null,
1002
+ componentId: FORM_FIELD_ID,
1003
+ pluginId: BUNDLE_ID,
1004
+ props: _extends({
1005
+ fieldName,
1006
+ label,
1007
+ required: true
1008
+ }, extra)
1009
+ };
1010
+ }
1011
+ export { Form, FormField };
1012
+ export default Form;
1013
+
1014
+ //# sourceMappingURL=form.js.map