@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.
- package/LICENSE +201 -0
- package/README.md +35 -0
- package/package.json +52 -0
- package/src/index.d.ts +18 -0
- package/src/index.js +19 -0
- package/src/index.js.map +1 -0
- package/src/lib/components/form-design-preview.component.d.ts +68 -0
- package/src/lib/components/form-design-preview.component.js +238 -0
- package/src/lib/components/form-design-preview.component.js.map +1 -0
- package/src/lib/components/form-detail-card.d.ts +53 -0
- package/src/lib/components/form-detail-card.js +827 -0
- package/src/lib/components/form-detail-card.js.map +1 -0
- package/src/lib/components/form-metrics-card.component.d.ts +67 -0
- package/src/lib/components/form-metrics-card.component.js +298 -0
- package/src/lib/components/form-metrics-card.component.js.map +1 -0
- package/src/lib/components/form-submissions-card.component.d.ts +40 -0
- package/src/lib/components/form-submissions-card.component.js +104 -0
- package/src/lib/components/form-submissions-card.component.js.map +1 -0
- package/src/lib/components/form-zones.d.ts +52 -0
- package/src/lib/components/form-zones.js +20 -0
- package/src/lib/components/form-zones.js.map +1 -0
- package/src/lib/components/form.d.ts +186 -0
- package/src/lib/components/form.js +1014 -0
- package/src/lib/components/form.js.map +1 -0
- package/src/lib/components/forms-console-page.d.ts +22 -0
- package/src/lib/components/forms-console-page.js +55 -0
- package/src/lib/components/forms-console-page.js.map +1 -0
- package/src/lib/components/host-forms-card.component.d.ts +56 -0
- package/src/lib/components/host-forms-card.component.js +533 -0
- package/src/lib/components/host-forms-card.component.js.map +1 -0
- package/src/lib/components/use-form-promote-api.d.ts +48 -0
- package/src/lib/components/use-form-promote-api.js +63 -0
- package/src/lib/components/use-form-promote-api.js.map +1 -0
- package/src/lib/constants/bundle-common.d.ts +31 -0
- package/src/lib/constants/bundle-common.js +31 -0
- package/src/lib/constants/bundle-common.js.map +1 -0
- package/src/lib/plugin.d.ts +25 -0
- package/src/lib/plugin.js +76 -0
- package/src/lib/plugin.js.map +1 -0
- package/src/lib/site.d.ts +53 -0
- package/src/lib/site.js +83 -0
- package/src/lib/site.js.map +1 -0
- package/src/lib/utils/generate-preset-id.d.ts +25 -0
- package/src/lib/utils/generate-preset-id.js +30 -0
- 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
|