@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,827 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ import { _ as _extends } from "@swc/helpers/_/_extends";
18
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
19
+ import * as Aglyn from "@aglyn/aglyn";
20
+ import { buildRoute, formFieldsCanYieldAnEmail, formFieldsCaptureConsent, PageHeaderActions, PageHeaderRecord, listConsoleWidgets, pluginDocsHelp, Route } from "@aglyn/aglyn";
21
+ import { useConsoleWidgetSlot } from "@aglyn/aglyn/app-utils/console-widget-slot-context";
22
+ // The CRM's route builder by its leaf path, not the plugin barrel: the barrel
23
+ // is the entry point the tenant's loader imports to activate the plugin's
24
+ // site half, and a console page named there would ship to every published
25
+ // page. The same import the Inbox makes for the same reason.
26
+ import { pluginRecordFilteredHref } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
27
+ import { ICON_VARIANT_BESIGNER } from "@aglyn/shared-data-enums";
28
+ import { AppLink, CardDisplay, GridItems, MdiIcon, useLoading } from "@aglyn/shared-ui-jsx";
29
+ import { ScrollTable } from "@aglyn/shared-ui-jsx/components/scroll-table.component";
30
+ import { useSnackbar } from "@aglyn/shared-ui-snackstack";
31
+ import { Timestamp } from "@aglyn/shared-util-timestamp";
32
+ import { Alert, AlertTitle, Button, Chip, FormControlLabel, MenuItem, Stack, Switch, TableBody, TableCell, TableHead, TableRow, TextField, Typography } from "@mui/material";
33
+ import { useConsoleHostRoute, useFirestore, useFirestoreCollection, useFirestoreDoc, useHostCampaigns, useHostVersionApi } from "@aglyn/tenant-feature-instance";
34
+ import CampaignPicker from "@aglyn/shared-ui-email-campaigns/components/campaign-picker.component";
35
+ import { collection, doc, limit, query, updateDoc } from "firebase/firestore";
36
+ import { useRouter } from "next/navigation";
37
+ import { useCallback, useMemo, useState } from "react";
38
+ import { FORM_CONTACT_FIELDS_ZONE } from "./form-zones.js";
39
+ import FormDesignPreview from "./form-design-preview.component.js";
40
+ import FormMetricsCard from "./form-metrics-card.component.js";
41
+ import FormSubmissionsCard from "./form-submissions-card.component.js";
42
+ import useFormPromoteApi from "./use-form-promote-api.js";
43
+ /**
44
+ * One form — the component detail surface, for a document that is also a
45
+ * contract.
46
+ *
47
+ * The split this enforces is the reason it exists rather than sending a row
48
+ * straight into the besigner. A form has two halves:
49
+ *
50
+ * - the DESIGN, which is what an author draws, and which lives in the
51
+ * besigner reached from here;
52
+ * - the DECLARATION — where a submission is routed and which field carries
53
+ * marketing consent — which is edited here.
54
+ *
55
+ * They are edited apart because the declaration is what the design is then
56
+ * checked AGAINST. `checkFormContract` compares the two at publish, so a
57
+ * surface that let an author change both in one motion would let them satisfy
58
+ * the check by moving whichever side happened to be easier — which is how a
59
+ * form ends up with lead routing pointed at a field nobody fills in.
60
+ *
61
+ * ## Promotion lives here, not only in the besigner
62
+ *
63
+ * A component's version history offers Publish on any version: promotion is
64
+ * how you go back, not only how you go forward, and an author restoring last
65
+ * week's design should not have to open a canvas to do it.
66
+ *
67
+ * It rides `/api/hosts/forms/promote` rather than an `updateDoc` here, because
68
+ * a form's promotion has to run `checkFormContract` on the tree it is about to
69
+ * write and REFUSE. A check in this component would be advice a determined
70
+ * client could skip; the route reads the stored version itself, so nothing
71
+ * about the design crosses the wire inbound and there is no version of this
72
+ * surface that can publish a design the check has not seen.
73
+ */ export function FormDetailCard(props) {
74
+ var _ref, _ref1;
75
+ var _form_routing;
76
+ const { hostId, formId, basePath, canPublish, hostRoleLoaded } = props;
77
+ const firestore = useFirestore();
78
+ const ContactFieldsZone = useConsoleWidgetSlot();
79
+ // Read at render, after the shell has loaded its plugins.
80
+ const hasContactFieldsWidget = listConsoleWidgets(FORM_CONTACT_FIELDS_ZONE.id).length > 0;
81
+ /**
82
+ * The write a widget in the contact-fields zone asks for. The declaration
83
+ * is one array on the form document, and the document is this plugin's, so
84
+ * the page writes what the widget decided.
85
+ */ const saveFields = useCallback(async (fields)=>{
86
+ await updateDoc(doc(firestore, 'hosts', hostId, 'forms', formId), {
87
+ fields,
88
+ updatedAt: Timestamp.now()
89
+ });
90
+ }, [
91
+ firestore,
92
+ hostId,
93
+ formId
94
+ ]);
95
+ const { orgSlug, subdomain: host } = useConsoleHostRoute(hostId);
96
+ const createHostVersion = useHostVersionApi();
97
+ const promoteForm = useFormPromoteApi();
98
+ const router = useRouter();
99
+ const { enqueueSnackbar } = useSnackbar();
100
+ const { queueLoading } = useLoading();
101
+ const { data: form, status } = useFirestoreDoc(()=>doc(firestore, 'hosts', hostId, 'forms', formId), [
102
+ firestore,
103
+ hostId,
104
+ formId
105
+ ], {
106
+ idField: '$id'
107
+ });
108
+ // Three states, not two (AGL-706): loading and absent both arrive as
109
+ // `undefined`, and an editable form over a mistyped id reads as data loss.
110
+ const notFound = status !== 'loading' && !form;
111
+ /**
112
+ * The published design, in BOTH stored forms (AGL-1151).
113
+ *
114
+ * The read above is converter-less, so `nodes` arrives exactly as stored —
115
+ * msgpack for a design promoted since forms were compressed, a plain map
116
+ * for everything older. Decoded once here because two surfaces below need
117
+ * it and both fail silently on the raw value: `checkFormContract` reports
118
+ * bogus violations against a tree it cannot walk, and the AGL-753 empty
119
+ * guard passes on a `Bytes` because `Object.keys` counts the wrapper.
120
+ */ const formNodes = useMemo(()=>Aglyn.decodeStoredNodes(form == null ? void 0 : form.nodes), [
121
+ form == null ? void 0 : form.nodes
122
+ ]);
123
+ // No orderBy: the oldest version docs predate `createdAt`, and Firestore
124
+ // drops documents missing the ordered field.
125
+ const { data: versionDocs } = useFirestoreCollection(()=>query(collection(firestore, 'hosts', hostId, 'forms', formId, 'versions'), limit(100)), [
126
+ firestore,
127
+ hostId,
128
+ formId
129
+ ], {
130
+ idField: '$id'
131
+ });
132
+ const versions = [
133
+ ...versionDocs != null ? versionDocs : []
134
+ ].sort((a, b)=>{
135
+ var _ref, _ref1;
136
+ var _b_createdAt, _a_createdAt;
137
+ return ((_ref = (_b_createdAt = b.createdAt) == null ? void 0 : _b_createdAt.seconds) != null ? _ref : 0) - ((_ref1 = (_a_createdAt = a.createdAt) == null ? void 0 : _a_createdAt.seconds) != null ? _ref1 : 0);
138
+ });
139
+ const publishedVersionId = form == null ? void 0 : form.versionId;
140
+ const [name, setName] = useState(null);
141
+ const [lead, setLead] = useState(null);
142
+ const [consentField, setConsentField] = useState(null);
143
+ /**
144
+ * The campaigns picked on screen, or null while the stored value stands.
145
+ *
146
+ * Null and `[]` are different: null means "not touched", `[]` means "take
147
+ * this form out of every campaign". Collapsing them would make clearing the
148
+ * last campaign a no-op, which is the half of set-and-clear that is easy to
149
+ * ship broken.
150
+ */ const [campaignIds, setCampaignIds] = useState(null);
151
+ const [opening, setOpening] = useState(false);
152
+ const [promoting, setPromoting] = useState(null);
153
+ /**
154
+ * What a REFUSED promotion said, kept on the surface rather than in a toast.
155
+ *
156
+ * A contract violation is a list of things to go and fix in the besigner,
157
+ * and a snackbar is gone by the time the author has read the second one.
158
+ * The besigner's own refusal persists for the same reason.
159
+ */ const [promoteRefusal, setPromoteRefusal] = useState(null);
160
+ /** The fields the PUBLISHED design draws, which is what a submission can carry. */ const declaredFields = useMemo(()=>Array.isArray(form == null ? void 0 : form.fields) ? form.fields : [], [
161
+ form
162
+ ]);
163
+ const effectiveLead = lead != null ? lead : (form == null ? void 0 : (_form_routing = form.routing) == null ? void 0 : _form_routing.lead) === true;
164
+ const effectiveConsent = consentField != null ? consentField : String((_ref = form == null ? void 0 : form.consentFieldName) != null ? _ref : '');
165
+ /*
166
+ * Whether lead routing COULD work on this design — the two preconditions
167
+ * `checkFormContract` applies at publish, asked here so the switch says so
168
+ * before it is flipped rather than after (AGL-2612). Consent is read as
169
+ * it is being edited: picking a field below is what satisfies it.
170
+ */ const hasEmailField = formFieldsCanYieldAnEmail(declaredFields);
171
+ const capturesConsent = formFieldsCaptureConsent(declaredFields, effectiveConsent);
172
+ /*
173
+ * Where this form's people are: the contacts list narrowed to this form,
174
+ * asked of the plugin that keeps people (`null`, and no link, without one). Built from the resolved org slug and
175
+ * subdomain the besigner link already waits on, so the link appears when
176
+ * they land and never points at a half-built address.
177
+ */ const contactsHref = orgSlug && host ? pluginRecordFilteredHref('contact', {
178
+ orgSlug,
179
+ host
180
+ }, 'form', formId) : null;
181
+ /*
182
+ * WHICH CAMPAIGNS THIS FORM IS PART OF.
183
+ *
184
+ * The site's campaigns are read while this page is open because the field
185
+ * is DISPLAYED here as well as edited here: the document stores ids, and a
186
+ * page that drew them until a button was pressed would be showing a
187
+ * merchant raw storage. Bounded at the ceiling the campaigns table itself
188
+ * draws, so it can offer nothing that list does not.
189
+ */ const siteCampaigns = useHostCampaigns(hostId, {
190
+ enabled: Boolean(form)
191
+ });
192
+ const storedCampaigns = useMemo(()=>Aglyn.readCampaignIds(form), [
193
+ form
194
+ ]);
195
+ const effectiveCampaigns = campaignIds != null ? campaignIds : storedCampaigns;
196
+ /**
197
+ * What the published design would break, judged against the declaration as
198
+ * it is being edited here.
199
+ *
200
+ * Shown here as well as at publish because the two halves are edited in
201
+ * different places: turning lead routing on for a form whose design has no
202
+ * email field is a break the author causes HERE, on a design they are not
203
+ * looking at, and the besigner would not tell them until they next opened
204
+ * it.
205
+ */ const violations = useMemo(()=>{
206
+ var _form_routing;
207
+ return form ? Aglyn.checkFormContract({
208
+ form: _extends({
209
+ routing: _extends({}, (_form_routing = form.routing) != null ? _form_routing : {}, {
210
+ lead: effectiveLead
211
+ })
212
+ }, effectiveConsent ? {
213
+ consentFieldName: effectiveConsent
214
+ } : {}),
215
+ formId,
216
+ nodes: formNodes,
217
+ formNodeId: findFormNodeId(formNodes)
218
+ }) : [];
219
+ }, [
220
+ form,
221
+ formId,
222
+ effectiveLead,
223
+ effectiveConsent
224
+ ]);
225
+ const handleSave = useCallback(async ()=>{
226
+ const dequeue = queueLoading();
227
+ try {
228
+ var _ref;
229
+ await updateDoc(doc(firestore, 'hosts', hostId, 'forms', formId), _extends({}, name != null ? {
230
+ displayName: name.trim()
231
+ } : {}, lead != null ? {
232
+ routing: _extends({}, (_ref = form == null ? void 0 : form.routing) != null ? _ref : {}, {
233
+ lead
234
+ })
235
+ } : {}, consentField != null ? {
236
+ consentFieldName: consentField.trim()
237
+ } : {}, campaignIds != null ? {
238
+ [Aglyn.CAMPAIGN_MEMBERSHIP_FIELD]: Aglyn.campaignMembershipValue(campaignIds)
239
+ } : {}, {
240
+ updatedAt: Timestamp.now()
241
+ }));
242
+ setName(null);
243
+ setLead(null);
244
+ setConsentField(null);
245
+ setCampaignIds(null);
246
+ enqueueSnackbar('Form saved', {
247
+ variant: 'success',
248
+ persist: false
249
+ });
250
+ } catch (error) {
251
+ console.error(error);
252
+ enqueueSnackbar('An error has occurred', {
253
+ variant: 'error',
254
+ allowDuplicate: true
255
+ });
256
+ } finally{
257
+ dequeue();
258
+ }
259
+ }, [
260
+ firestore,
261
+ hostId,
262
+ formId,
263
+ form,
264
+ name,
265
+ lead,
266
+ consentField,
267
+ campaignIds,
268
+ queueLoading,
269
+ enqueueSnackbar
270
+ ]);
271
+ /**
272
+ * Makes one version the version the live sites serve.
273
+ *
274
+ * The route decides, not this handler: it re-reads the stored version, runs
275
+ * `checkFormContract` on the tree it is about to write, and answers 422 with
276
+ * the violations when a publish would stop the submissions arriving. So the
277
+ * only thing here is what to do with each answer — and a refusal is put on
278
+ * the page rather than in a toast, because it is a list of things to fix.
279
+ */ const handlePromote = useCallback((targetVersionId)=>async ()=>{
280
+ if (promoting) return;
281
+ setPromoting(targetVersionId);
282
+ setPromoteRefusal(null);
283
+ try {
284
+ const result = await promoteForm({
285
+ hostId,
286
+ formId,
287
+ versionId: targetVersionId
288
+ });
289
+ if (!result.ok) {
290
+ var _result_message, _result_violations;
291
+ const message = (_result_message = result.message) != null ? _result_message : 'Publish failed';
292
+ setPromoteRefusal({
293
+ message,
294
+ violations: (_result_violations = result.violations) != null ? _result_violations : []
295
+ });
296
+ // `persist` because this is a refusal an author has to act on: an
297
+ // auto-dismissed warning is how someone walks away believing the
298
+ // form shipped.
299
+ enqueueSnackbar(message, {
300
+ variant: 'warning',
301
+ allowDuplicate: true,
302
+ persist: true
303
+ });
304
+ return;
305
+ }
306
+ enqueueSnackbar('Published. The live sites now serve this version, and the form’s ' + 'declared fields match it.', {
307
+ variant: 'success',
308
+ persist: false
309
+ });
310
+ } catch (error) {
311
+ console.error(error);
312
+ enqueueSnackbar('Could not publish that version', {
313
+ variant: 'error',
314
+ allowDuplicate: true,
315
+ persist: true
316
+ });
317
+ } finally{
318
+ setPromoting(null);
319
+ }
320
+ }, [
321
+ promoting,
322
+ promoteForm,
323
+ hostId,
324
+ formId,
325
+ enqueueSnackbar
326
+ ]);
327
+ /**
328
+ * Opens a version in the besigner, minting the first one when the form has
329
+ * none.
330
+ *
331
+ * Forms adopted from a page carry a declared field list and a `nodes` map
332
+ * seeded from the page they were adopted from — so the first version opens
333
+ * on the form the site was ALREADY submitting rather than a blank canvas.
334
+ */ const handleOpen = useCallback((targetVersionId)=>async ()=>{
335
+ if (opening) return;
336
+ setOpening(true);
337
+ try {
338
+ var _ref;
339
+ var _versions_;
340
+ let versionId = (_ref = targetVersionId != null ? targetVersionId : publishedVersionId) != null ? _ref : (_versions_ = versions[0]) == null ? void 0 : _versions_.$id;
341
+ if (!versionId) {
342
+ versionId = Aglyn.createResourceUid();
343
+ // Rides /api/hosts/versions: rules deny the client create, and the
344
+ // route allows a resource's FIRST version on every plan — which
345
+ // this always is, since the form reached here with no `versionId`.
346
+ await createHostVersion({
347
+ hostId,
348
+ kind: 'form',
349
+ parentId: formId,
350
+ id: versionId,
351
+ data: _extends({
352
+ formId,
353
+ hostId,
354
+ displayName: 'Initial version',
355
+ // Emptiness, not just absence: a form that holds no `nodes` and
356
+ // `??` would pass an empty map straight through to a version
357
+ // that opens uneditable (AGL-753). Measured on the DECODED
358
+ // tree — `Object.keys` over a stored `Bytes` is non-empty, so
359
+ // the raw value passes this guard and mints exactly the
360
+ // uneditable version it exists to stop (AGL-1151).
361
+ nodes: Object.keys(formNodes != null ? formNodes : {}).length ? formNodes : {
362
+ [Aglyn.CANVAS_ROOT_ELEMENT_ID]: {
363
+ $id: Aglyn.CANVAS_ROOT_ELEMENT_ID,
364
+ componentId: 'div',
365
+ nodes: []
366
+ }
367
+ }
368
+ }, (form == null ? void 0 : form.rootId) ? {
369
+ rootId: form.rootId
370
+ } : {})
371
+ });
372
+ await updateDoc(doc(firestore, 'hosts', hostId, 'forms', formId), {
373
+ versionId,
374
+ updatedAt: Timestamp.now()
375
+ });
376
+ }
377
+ // The editor's URL needs the resolved org slug and subdomain, which
378
+ // `useConsoleHostRoute` reads. Until they land the button below is
379
+ // disabled, so this branch cannot be reached without them.
380
+ if (orgSlug && host) {
381
+ router.push(buildRoute(Route.FORM_BESIGNER, {
382
+ orgSlug,
383
+ host,
384
+ formId,
385
+ versionId
386
+ }));
387
+ }
388
+ } catch (error) {
389
+ console.error(error);
390
+ enqueueSnackbar('Could not open the besigner', {
391
+ variant: 'error',
392
+ allowDuplicate: true
393
+ });
394
+ setOpening(false);
395
+ }
396
+ }, [
397
+ opening,
398
+ createHostVersion,
399
+ publishedVersionId,
400
+ versions,
401
+ firestore,
402
+ hostId,
403
+ formId,
404
+ form,
405
+ router,
406
+ orgSlug,
407
+ host,
408
+ enqueueSnackbar
409
+ ]);
410
+ const dirty = name != null || lead != null || consentField != null || campaignIds != null && !Aglyn.campaignMembershipUnchanged(storedCampaigns, campaignIds);
411
+ // Named before it is used on every row, so a role denial reads as a reason
412
+ // rather than as a control that does nothing.
413
+ const publishBlock = hostRoleLoaded ? 'Your role on this site can edit content but not publish it' : 'Checking what your role can do…';
414
+ if (notFound) {
415
+ return /*#__PURE__*/ _jsx(CardDisplay, {
416
+ header: "Form not found",
417
+ help: pluginDocsHelp('forms', {
418
+ anchor: '#build-a-form',
419
+ excerpt: 'A form is created from the Forms list and keeps the id every ' + 'submission is filed under.'
420
+ }),
421
+ contentGutterX: true,
422
+ contentGutterY: true,
423
+ children: /*#__PURE__*/ _jsxs(Stack, {
424
+ spacing: 2,
425
+ sx: {
426
+ alignItems: 'flex-start'
427
+ },
428
+ children: [
429
+ /*#__PURE__*/ _jsx(Alert, {
430
+ severity: "info",
431
+ children: `No form on this site has the id ${formId}. It may have been deleted, or the link may be from another site.`
432
+ }),
433
+ basePath ? /*#__PURE__*/ _jsx(AppLink, {
434
+ href: basePath,
435
+ children: 'Back to forms'
436
+ }) : null
437
+ ]
438
+ })
439
+ });
440
+ }
441
+ /* The cards, named so the page chrome above them is a plain list of
442
+ what this surface publishes upward. */ const cards = /*#__PURE__*/ _jsx(GridItems, {
443
+ spacing: 3,
444
+ items: [
445
+ {
446
+ size: {
447
+ xs: 12,
448
+ lg: 5
449
+ },
450
+ children: /*#__PURE__*/ _jsx(CardDisplay, {
451
+ header: "Details",
452
+ help: pluginDocsHelp('forms', {
453
+ anchor: '#build-a-form',
454
+ excerpt: 'The id is what every submission is filed under, so ' + 'renaming a form never splits its history.'
455
+ }),
456
+ contentGutterX: true,
457
+ contentGutterY: true,
458
+ children: /*#__PURE__*/ _jsxs(Stack, {
459
+ spacing: 2,
460
+ children: [
461
+ /*#__PURE__*/ _jsx(TextField, {
462
+ label: "Display name",
463
+ size: "small",
464
+ value: (_ref1 = name != null ? name : form == null ? void 0 : form.displayName) != null ? _ref1 : '',
465
+ onChange: (event)=>setName(event.target.value),
466
+ fullWidth: true
467
+ }),
468
+ /*#__PURE__*/ _jsx(Typography, {
469
+ variant: "caption",
470
+ color: "text.secondary",
471
+ children: `ID ${formId} — every submission is filed under this, so it never changes`
472
+ }),
473
+ /*#__PURE__*/ _jsx(CampaignPicker, {
474
+ options: siteCampaigns.options,
475
+ value: effectiveCampaigns,
476
+ onChange: setCampaignIds,
477
+ helperText: "The campaigns this form is part of. It does not change who a campaign mails.",
478
+ empty: siteCampaigns.ready && !siteCampaigns.options.length
479
+ }),
480
+ /*#__PURE__*/ _jsx(Stack, {
481
+ direction: "row",
482
+ spacing: 1,
483
+ children: /*#__PURE__*/ _jsx(Button, {
484
+ variant: "contained",
485
+ color: "primary",
486
+ size: "small",
487
+ disabled: !dirty,
488
+ onClick: handleSave,
489
+ children: 'Save'
490
+ })
491
+ })
492
+ ]
493
+ })
494
+ })
495
+ },
496
+ {
497
+ size: {
498
+ xs: 12,
499
+ lg: 7
500
+ },
501
+ children: /*#__PURE__*/ _jsx(CardDisplay, {
502
+ header: "CRM routing",
503
+ help: pluginDocsHelp('forms', {
504
+ anchor: '#where-submissions-go',
505
+ excerpt: 'Every submission reaches the Inbox and updates the ' + 'contact. Lead routing and the consent field decide ' + 'what else happens to it.'
506
+ }),
507
+ contentGutterX: true,
508
+ contentGutterY: true,
509
+ children: /*#__PURE__*/ _jsxs(Stack, {
510
+ spacing: 2,
511
+ children: [
512
+ /*#__PURE__*/ _jsx(Typography, {
513
+ variant: "body2",
514
+ children: 'Every submission with an email address updates the person ' + 'in Contacts at stage Lead — a new contact when the ' + 'address is new, an interaction on their timeline when it ' + 'is not. A customer stays a customer.'
515
+ }),
516
+ /*#__PURE__*/ _jsxs(Stack, {
517
+ spacing: 0.5,
518
+ children: [
519
+ /*#__PURE__*/ _jsx(FormControlLabel, {
520
+ control: /*#__PURE__*/ _jsx(Switch, {
521
+ checked: effectiveLead,
522
+ onChange: (event)=>setLead(event.target.checked)
523
+ }),
524
+ label: "Also create a lead from the address someone gives this form"
525
+ }),
526
+ /*#__PURE__*/ _jsx(Typography, {
527
+ variant: "caption",
528
+ color: "text.secondary",
529
+ children: !hasEmailField ? 'Needs an email field in the published design — a ' + 'submission without an address cannot key a lead, and ' + 'publishing with this on would be refused until one exists.' : !capturesConsent ? 'Needs a marketing consent field — a lead nobody opted ' + 'in with is one the team cannot email, and publishing ' + 'with this on would be refused until the field below ' + 'names one.' : 'Files a lead in CRM → Leads for the sales team to work, ' + 'carrying the opt-in the consent field records.'
530
+ })
531
+ ]
532
+ }),
533
+ /*#__PURE__*/ _jsxs(TextField, {
534
+ select: true,
535
+ label: "Marketing consent field",
536
+ size: "small",
537
+ value: effectiveConsent,
538
+ onChange: (event)=>setConsentField(event.target.value),
539
+ fullWidth: true,
540
+ helperText: "The one field that IS the opt-in. Submitting a form is never itself consent.",
541
+ children: [
542
+ /*#__PURE__*/ _jsx(MenuItem, {
543
+ value: "",
544
+ children: 'None — this form collects no opt-in'
545
+ }),
546
+ declaredFields.map((field)=>/*#__PURE__*/ _jsx(MenuItem, {
547
+ value: field.fieldName,
548
+ children: field.label ? `${field.label} (${field.fieldName})` : field.fieldName
549
+ }, field.fieldName))
550
+ ]
551
+ }),
552
+ violations.length ? /*#__PURE__*/ _jsxs(Alert, {
553
+ severity: "warning",
554
+ children: [
555
+ /*#__PURE__*/ _jsx(AlertTitle, {
556
+ children: 'This form would stop collecting'
557
+ }),
558
+ /*#__PURE__*/ _jsx(Stack, {
559
+ component: "ul",
560
+ sx: {
561
+ pl: 2,
562
+ m: 0
563
+ },
564
+ children: violations.map((violation)=>{
565
+ var _ref, _violation_nodeId;
566
+ return /*#__PURE__*/ _jsx(Typography, {
567
+ component: "li",
568
+ variant: "body2",
569
+ children: violation.message
570
+ }, `${violation.code}:${(_ref = (_violation_nodeId = violation.nodeId) != null ? _violation_nodeId : violation.fieldName) != null ? _ref : ''}`);
571
+ })
572
+ })
573
+ ]
574
+ }) : null,
575
+ contactsHref ? /*#__PURE__*/ _jsx(Typography, {
576
+ variant: "body2",
577
+ children: /*#__PURE__*/ _jsx(AppLink, {
578
+ href: contactsHref,
579
+ children: 'See the contacts this form captured in the CRM'
580
+ })
581
+ }) : null
582
+ ]
583
+ })
584
+ })
585
+ },
586
+ // Where each field saves on the person: another plugin's card, in a
587
+ // zone this page hosts. No item at all where nothing fills it, so the
588
+ // grid keeps no empty cell.
589
+ ...ContactFieldsZone && hasContactFieldsWidget ? [
590
+ {
591
+ size: {
592
+ xs: 12
593
+ },
594
+ children: /*#__PURE__*/ _jsx(ContactFieldsZone, {
595
+ slot: FORM_CONTACT_FIELDS_ZONE.id,
596
+ hostId: hostId,
597
+ formId: formId,
598
+ fields: declaredFields,
599
+ loading: status === 'loading',
600
+ saveFields: saveFields
601
+ })
602
+ }
603
+ ] : [],
604
+ {
605
+ size: {
606
+ xs: 12,
607
+ lg: 5
608
+ },
609
+ children: /*#__PURE__*/ _jsx(FormMetricsCard, {
610
+ stats: form == null ? void 0 : form.stats,
611
+ fields: declaredFields,
612
+ leadRouting: effectiveLead,
613
+ loading: status === 'loading'
614
+ })
615
+ },
616
+ {
617
+ size: {
618
+ xs: 12,
619
+ lg: 7
620
+ },
621
+ children: /*#__PURE__*/ _jsx(CardDisplay, {
622
+ header: "Versions",
623
+ help: pluginDocsHelp('forms', {
624
+ anchor: '#build-a-form',
625
+ excerpt: 'Publishing a form version updates every page that ' + 'places it. Saving does not — a save is not a publish.'
626
+ }),
627
+ contentGutterX: true,
628
+ contentGutterY: true,
629
+ children: /*#__PURE__*/ _jsxs(Stack, {
630
+ spacing: 2,
631
+ children: [
632
+ promoteRefusal ? /*#__PURE__*/ _jsxs(Alert, {
633
+ severity: "warning",
634
+ onClose: ()=>setPromoteRefusal(null),
635
+ children: [
636
+ /*#__PURE__*/ _jsx(AlertTitle, {
637
+ children: promoteRefusal.message
638
+ }),
639
+ promoteRefusal.violations.length ? /*#__PURE__*/ _jsx(Stack, {
640
+ component: "ul",
641
+ sx: {
642
+ pl: 2,
643
+ m: 0
644
+ },
645
+ children: promoteRefusal.violations.map((violation)=>{
646
+ var _ref, _violation_nodeId;
647
+ return /*#__PURE__*/ _jsx(Typography, {
648
+ component: "li",
649
+ variant: "body2",
650
+ children: violation.message
651
+ }, `${violation.code}:${(_ref = (_violation_nodeId = violation.nodeId) != null ? _violation_nodeId : violation.fieldName) != null ? _ref : ''}`);
652
+ })
653
+ }) : null
654
+ ]
655
+ }) : null,
656
+ versions.length === 0 ? /*#__PURE__*/ _jsx(Typography, {
657
+ variant: "body2",
658
+ color: "text.secondary",
659
+ children: 'No versions yet — opening the besigner creates the first one.'
660
+ }) : /*#__PURE__*/ _jsxs(ScrollTable, {
661
+ size: "small",
662
+ children: [
663
+ /*#__PURE__*/ _jsx(TableHead, {
664
+ children: /*#__PURE__*/ _jsxs(TableRow, {
665
+ children: [
666
+ /*#__PURE__*/ _jsx(TableCell, {
667
+ children: 'Version'
668
+ }),
669
+ /*#__PURE__*/ _jsx(TableCell, {
670
+ children: 'Created'
671
+ }),
672
+ /*#__PURE__*/ _jsx(TableCell, {
673
+ children: 'Updated'
674
+ }),
675
+ /*#__PURE__*/ _jsx(TableCell, {
676
+ align: "right",
677
+ children: 'Actions'
678
+ })
679
+ ]
680
+ })
681
+ }),
682
+ /*#__PURE__*/ _jsx(TableBody, {
683
+ children: versions.map((version)=>{
684
+ var _version_displayName, _ref, _ref1;
685
+ var _version_createdAt_toDate, _version_createdAt, _version_updatedAt_toDate, _version_updatedAt;
686
+ return /*#__PURE__*/ _jsxs(TableRow, {
687
+ hover: true,
688
+ children: [
689
+ /*#__PURE__*/ _jsx(TableCell, {
690
+ children: /*#__PURE__*/ _jsxs(Stack, {
691
+ direction: "row",
692
+ spacing: 1,
693
+ sx: {
694
+ alignItems: 'center'
695
+ },
696
+ children: [
697
+ /*#__PURE__*/ _jsx(Typography, {
698
+ variant: "body2",
699
+ children: (_version_displayName = version.displayName) != null ? _version_displayName : version.$id
700
+ }),
701
+ version.$id === publishedVersionId ? /*#__PURE__*/ _jsx(Chip, {
702
+ label: "Current",
703
+ color: "success",
704
+ size: "small"
705
+ }) : null
706
+ ]
707
+ })
708
+ }),
709
+ /*#__PURE__*/ _jsx(TableCell, {
710
+ children: (_ref = (_version_createdAt = version.createdAt) == null ? void 0 : (_version_createdAt_toDate = _version_createdAt.toDate) == null ? void 0 : _version_createdAt_toDate.call(_version_createdAt).toLocaleString()) != null ? _ref : '--'
711
+ }),
712
+ /*#__PURE__*/ _jsx(TableCell, {
713
+ children: (_ref1 = (_version_updatedAt = version.updatedAt) == null ? void 0 : (_version_updatedAt_toDate = _version_updatedAt.toDate) == null ? void 0 : _version_updatedAt_toDate.call(_version_updatedAt).toLocaleString()) != null ? _ref1 : '--'
714
+ }),
715
+ /*#__PURE__*/ _jsx(TableCell, {
716
+ align: "right",
717
+ children: /*#__PURE__*/ _jsxs(Stack, {
718
+ direction: "row",
719
+ spacing: 1,
720
+ sx: {
721
+ justifyContent: 'flex-end'
722
+ },
723
+ children: [
724
+ /*#__PURE__*/ _jsx(Button, {
725
+ size: "small",
726
+ disabled: opening || !orgSlug || !host,
727
+ onClick: handleOpen(version.$id),
728
+ children: 'Open'
729
+ }),
730
+ /*#__PURE__*/ _jsx(Button, {
731
+ size: "small",
732
+ variant: "outlined",
733
+ title: canPublish ? undefined : publishBlock,
734
+ disabled: !canPublish || promoting !== null || version.$id === publishedVersionId,
735
+ onClick: handlePromote(version.$id),
736
+ children: promoting === version.$id ? 'Publishing…' : version.$id === publishedVersionId ? 'Published' : 'Publish'
737
+ })
738
+ ]
739
+ })
740
+ })
741
+ ]
742
+ }, version.$id);
743
+ })
744
+ })
745
+ ]
746
+ })
747
+ ]
748
+ })
749
+ })
750
+ },
751
+ {
752
+ /*
753
+ THE MESSAGES THEMSELVES, under the numbers that describe them.
754
+ Full width because it is a table of people and what they wrote,
755
+ and it sits below the metrics because those are read at a glance
756
+ and this is read a row at a time.
757
+ */ size: {
758
+ xs: 12
759
+ },
760
+ children: /*#__PURE__*/ _jsx(FormSubmissionsCard, {
761
+ hostId: hostId,
762
+ formId: formId
763
+ })
764
+ },
765
+ {
766
+ // Full width: the frame is a document, and a document in a
767
+ // five-column card is a column of one-word lines.
768
+ size: {
769
+ xs: 12
770
+ },
771
+ children: /*#__PURE__*/ _jsx(CardDisplay, {
772
+ header: "What a submission will carry",
773
+ help: pluginDocsHelp('forms', {
774
+ anchor: '#field-types',
775
+ excerpt: 'Each field arrives under its own name. Two fields ' + 'sharing one name arrive as a single answer.'
776
+ }),
777
+ contentGutterX: true,
778
+ contentGutterY: true,
779
+ children: /*#__PURE__*/ _jsx(FormDesignPreview, {
780
+ formId: formId,
781
+ nodes: form == null ? void 0 : form.nodes,
782
+ consentFieldName: effectiveConsent ? effectiveConsent : undefined,
783
+ loading: status === 'loading'
784
+ })
785
+ })
786
+ }
787
+ ]
788
+ });
789
+ return /*#__PURE__*/ _jsxs(_Fragment, {
790
+ children: [
791
+ /*#__PURE__*/ _jsx(PageHeaderRecord, {
792
+ title: form ? form.displayName || formId : undefined
793
+ }),
794
+ /*#__PURE__*/ _jsx(PageHeaderActions, {
795
+ children: /*#__PURE__*/ _jsx(Button, {
796
+ size: "small",
797
+ variant: "contained",
798
+ disabled: opening || !orgSlug || !host,
799
+ title: orgSlug && host ? undefined : 'Resolving this site’s address…',
800
+ onClick: handleOpen(),
801
+ startIcon: /*#__PURE__*/ _jsx(MdiIcon, {
802
+ color: "inherit",
803
+ path: ICON_VARIANT_BESIGNER.path
804
+ }),
805
+ children: opening ? 'Opening…' : 'Edit in besigner'
806
+ })
807
+ }),
808
+ cards
809
+ ]
810
+ });
811
+ }
812
+ FormDetailCard.displayName = 'FormDetailCard';
813
+ /**
814
+ * The `form` node inside a published design.
815
+ *
816
+ * A form document's tree holds exactly one, because the document IS that
817
+ * form — but the tree is a flat map with a synthetic root, so the node has to
818
+ * be found rather than assumed to be the root itself.
819
+ */ function findFormNodeId(nodes) {
820
+ return Object.keys(nodes != null ? nodes : {}).find((id)=>{
821
+ var _nodes_id;
822
+ return (nodes == null ? void 0 : (_nodes_id = nodes[id]) == null ? void 0 : _nodes_id.componentId) === 'form';
823
+ });
824
+ }
825
+ export default FormDetailCard;
826
+
827
+ //# sourceMappingURL=form-detail-card.js.map