@aglyn/plugins-forms 1.0.0-beta.220 → 1.0.0-beta.222

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 (75) hide show
  1. package/README.md +1 -0
  2. package/package.json +14 -12
  3. package/src/lib/components/form-detail-card.js +166 -29
  4. package/src/lib/components/form-detail-card.js.map +1 -1
  5. package/src/lib/components/form-submissions-card.component.d.ts +12 -0
  6. package/src/lib/components/form-submissions-card.component.js +35 -2
  7. package/src/lib/components/form-submissions-card.component.js.map +1 -1
  8. package/src/lib/components/form.js +2 -1
  9. package/src/lib/components/form.js.map +1 -1
  10. package/src/lib/components/host-forms-card.component.d.ts +8 -0
  11. package/src/lib/components/host-forms-card.component.js +26 -1
  12. package/src/lib/components/host-forms-card.component.js.map +1 -1
  13. package/src/lib/components/site-package-form-preview.component.d.ts +13 -0
  14. package/src/lib/components/site-package-form-preview.component.js +57 -0
  15. package/src/lib/components/site-package-form-preview.component.js.map +1 -0
  16. package/src/lib/constants/site-package.d.ts +18 -0
  17. package/src/lib/constants/site-package.js +18 -0
  18. package/src/lib/constants/site-package.js.map +1 -0
  19. package/src/lib/declarations.console-server.d.ts +39 -0
  20. package/src/lib/declarations.console-server.js +68 -0
  21. package/src/lib/declarations.console-server.js.map +1 -0
  22. package/src/lib/declarations.server.js +22 -0
  23. package/src/lib/declarations.server.js.map +1 -1
  24. package/src/lib/model/form-door.d.ts +37 -0
  25. package/src/lib/model/form-door.js +50 -0
  26. package/src/lib/model/form-door.js.map +1 -0
  27. package/src/lib/model/form-submission-counts.d.ts +26 -0
  28. package/src/lib/model/form-submission-counts.js +35 -0
  29. package/src/lib/model/form-submission-counts.js.map +1 -0
  30. package/src/lib/model/form-submission-list.d.ts +40 -0
  31. package/src/lib/model/form-submission-list.js +66 -0
  32. package/src/lib/model/form-submission-list.js.map +1 -0
  33. package/src/lib/model/form-submission-record.d.ts +50 -0
  34. package/src/lib/model/form-submission-record.js +68 -0
  35. package/src/lib/model/form-submission-record.js.map +1 -0
  36. package/src/lib/model/form-unavailable.d.ts +83 -0
  37. package/src/lib/model/form-unavailable.js +88 -0
  38. package/src/lib/model/form-unavailable.js.map +1 -0
  39. package/src/lib/model/forms-record-lists.d.ts +31 -0
  40. package/src/lib/model/forms-record-lists.js +50 -0
  41. package/src/lib/model/forms-record-lists.js.map +1 -0
  42. package/src/lib/plan-entitlements.js +6 -6
  43. package/src/lib/plan-entitlements.js.map +1 -1
  44. package/src/lib/plugin.js +34 -0
  45. package/src/lib/plugin.js.map +1 -1
  46. package/src/lib/server/api-v1/form-submissions.d.ts +34 -0
  47. package/src/lib/server/api-v1/form-submissions.js +273 -0
  48. package/src/lib/server/api-v1/form-submissions.js.map +1 -0
  49. package/src/lib/server/api-v1/openapi.d.ts +26 -0
  50. package/src/lib/server/api-v1/openapi.js +133 -0
  51. package/src/lib/server/api-v1/openapi.js.map +1 -0
  52. package/src/lib/server/form-intake-gate.d.ts +34 -0
  53. package/src/lib/server/form-intake-gate.js +44 -0
  54. package/src/lib/server/form-intake-gate.js.map +1 -0
  55. package/src/lib/server/form-submission-index.d.ts +26 -0
  56. package/src/lib/server/form-submission-index.js +51 -0
  57. package/src/lib/server/form-submission-index.js.map +1 -0
  58. package/src/lib/server/form-submit.d.ts +43 -0
  59. package/src/lib/server/form-submit.js +769 -0
  60. package/src/lib/server/form-submit.js.map +1 -0
  61. package/src/lib/server/increment-form-stats.d.ts +70 -0
  62. package/src/lib/server/increment-form-stats.js +112 -0
  63. package/src/lib/server/increment-form-stats.js.map +1 -0
  64. package/src/lib/server.d.ts +10 -0
  65. package/src/lib/server.js +15 -0
  66. package/src/lib/server.js.map +1 -1
  67. package/src/lib/transfer/form-submissions-transfer-key.d.ts +37 -0
  68. package/src/lib/transfer/form-submissions-transfer-key.js +40 -0
  69. package/src/lib/transfer/form-submissions-transfer-key.js.map +1 -0
  70. package/src/lib/transfer/form-submissions-transfer.d.ts +98 -0
  71. package/src/lib/transfer/form-submissions-transfer.js +563 -0
  72. package/src/lib/transfer/form-submissions-transfer.js.map +1 -0
  73. package/src/lib/transfer/form-submissions-transfer.server.d.ts +26 -0
  74. package/src/lib/transfer/form-submissions-transfer.server.js +31 -0
  75. package/src/lib/transfer/form-submissions-transfer.server.js.map +1 -0
package/README.md CHANGED
@@ -25,6 +25,7 @@ Presets for both, including a composed contact section, appear in the Besigner's
25
25
 
26
26
  - A `Forms` nav item at `/forms`: the form catalog and one form's own page (declaration, metrics, versions, promotion, design preview). The nav item owns its subtree, because a form's URL names a document id. The page is code-split and loads when opened.
27
27
  - Two zones the plugin hosts on a form's page for other plugins to fill, registered with `registerPluginZone`: `formSubmissions`, where a reader of that form's submissions is drawn, and a zone for mapping a form's fields onto a person's fields.
28
+ - The `formSubmission` kind on the record seams: a console list source (`plugin-record-lists`) — the newest of a site's, the base the Inbox walks, one submission's document, and a site's submissions by name — a server record index (`plugin-record-index`), and a count source (`plugin-record-counts`) for how many a site received. The Inbox and a campaign's conversion report read submissions through these instead of the collection.
28
29
 
29
30
  **Ids are persisted.** `componentId` (`form`, `formField`) is stored in every screen document and does not change. `pluginId` is stored beside it and is `forms` (`BUNDLE_ID`); the renderer reads it to decide which bundles must register before first paint, so a page with no form on it does not wait for this package.
30
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-forms",
3
- "version": "1.0.0-beta.220",
3
+ "version": "1.0.0-beta.222",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,17 +25,19 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.220",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.220",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.220",
31
- "@aglyn/shared-ui-jsx": "1.0.0-beta.220",
32
- "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.220",
33
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.220",
34
- "@aglyn/shared-util-http": "1.0.0-beta.220",
35
- "@aglyn/shared-util-timestamp": "1.0.0-beta.220",
36
- "@aglyn/shared-util-tools": "1.0.0-beta.220",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.220",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.220",
28
+ "@aglyn/aglyn": "1.0.0-beta.222",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.222",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.222",
31
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.222",
32
+ "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.222",
33
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.222",
34
+ "@aglyn/shared-util-email": "1.0.0-beta.222",
35
+ "@aglyn/shared-util-http": "1.0.0-beta.222",
36
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.222",
37
+ "@aglyn/shared-util-tools": "1.0.0-beta.222",
38
+ "@aglyn/tenant-data-admin": "1.0.0-beta.222",
39
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.222",
40
+ "@aglyn/tenant-runtime": "1.0.0-beta.222",
39
41
  "@swc/helpers": "0.5.23"
40
42
  },
41
43
  "peerDependencies": {
@@ -25,7 +25,7 @@ import { useConsoleWidgetSlot } from "@aglyn/aglyn/app-utils/console-widget-slot
25
25
  // page. The same import the Inbox makes for the same reason.
26
26
  import { pluginRecordFilteredHref } from "@aglyn/aglyn/plugin-manager/plugin-record-routes";
27
27
  import { ICON_VARIANT_BESIGNER } from "@aglyn/shared-data-enums";
28
- import { AppLink, CardDisplay, GridItems, MdiIcon, useLoading } from "@aglyn/shared-ui-jsx";
28
+ import { AppLink, CardDisplay, GridItems, MdiIcon, useConfirmationContext, useLoading } from "@aglyn/shared-ui-jsx";
29
29
  import { ScrollTable } from "@aglyn/shared-ui-jsx/components/scroll-table.component";
30
30
  import { useSnackbar } from "@aglyn/shared-ui-snackstack";
31
31
  import { Timestamp } from "@aglyn/shared-util-timestamp";
@@ -34,7 +34,7 @@ import { useConsoleHostRoute, useFirestore, useFirestoreCollection, useFirestore
34
34
  import ContainerPicker from "@aglyn/tenant-feature-instance/components/container-picker";
35
35
  import { collection, doc, limit, query, updateDoc } from "firebase/firestore";
36
36
  import { useRouter } from "next/navigation";
37
- import { useCallback, useMemo, useState } from "react";
37
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
38
38
  import { FORM_CONTACT_FIELDS_ZONE } from "./form-zones.js";
39
39
  import { FORMS_DOCUMENT_SEGMENT } from "../constants/bundle-common.js";
40
40
  import FormDesignPreview from "./form-design-preview.component.js";
@@ -73,8 +73,8 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
73
73
  * about the design crosses the wire inbound and there is no version of this
74
74
  * surface that can publish a design the check has not seen.
75
75
  */ export function FormDetailCard(props) {
76
- var _ref, _ref1;
77
- var _form_routing;
76
+ var _ref, _ref1, _ref2, _ref3;
77
+ var _form_routing, _form_routing1;
78
78
  const { hostId, formId, basePath, canPublish, hostRoleLoaded } = props;
79
79
  const firestore = useFirestore();
80
80
  const ContactFieldsZone = useConsoleWidgetSlot();
@@ -101,6 +101,7 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
101
101
  const router = useRouter();
102
102
  const { enqueueSnackbar } = useSnackbar();
103
103
  const { queueLoading } = useLoading();
104
+ const { confirm } = useConfirmationContext();
104
105
  const { data: form, status } = useFirestoreDoc(()=>doc(firestore, 'hosts', hostId, 'forms', formId), [
105
106
  firestore,
106
107
  hostId,
@@ -225,31 +226,79 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
225
226
  effectiveLead,
226
227
  effectiveConsent
227
228
  ]);
228
- const handleSave = useCallback(async ()=>{
229
+ /**
230
+ * WHICH CARD IS WRITING, or null.
231
+ *
232
+ * Each card saves what it shows and nothing else (AGL-3508). One handler
233
+ * used to write the name, the campaigns, the lead switch and the consent
234
+ * field together from a button in Details, so a routing change made in its
235
+ * own card sat unsaved until somebody pressed Save somewhere else — and was
236
+ * dropped without a word when they left instead.
237
+ */ const [saving, setSaving] = useState(null);
238
+ /**
239
+ * Details: the name and the campaigns. Never `routing` or the consent
240
+ * field, so saving a rename cannot also commit a half-made routing change.
241
+ */ const handleSaveDetails = useCallback(async ()=>{
229
242
  const dequeue = queueLoading();
243
+ setSaving('details');
230
244
  try {
231
- var _ref;
232
- var _form_routing;
233
245
  await updateDoc(doc(firestore, 'hosts', hostId, 'forms', formId), _extends({}, name != null ? _extends({
234
246
  displayName: name.trim()
235
247
  }, Aglyn.formListFields({
236
248
  id: formId,
237
249
  displayName: name.trim(),
238
250
  slug: form == null ? void 0 : form.slug
239
- })) : {}, lead != null ? {
251
+ })) : {}, campaignIds != null ? Aglyn.formCampaignFields(campaignIds) : {}, {
252
+ updatedAt: Timestamp.now()
253
+ }));
254
+ setName(null);
255
+ setCampaignIds(null);
256
+ enqueueSnackbar('Details saved', {
257
+ variant: 'success',
258
+ persist: false
259
+ });
260
+ } catch (error) {
261
+ console.error(error);
262
+ enqueueSnackbar('An error has occurred', {
263
+ variant: 'error',
264
+ allowDuplicate: true
265
+ });
266
+ } finally{
267
+ setSaving(null);
268
+ dequeue();
269
+ }
270
+ }, [
271
+ firestore,
272
+ hostId,
273
+ formId,
274
+ form,
275
+ name,
276
+ campaignIds,
277
+ queueLoading,
278
+ enqueueSnackbar
279
+ ]);
280
+ /**
281
+ * CRM routing: the lead switch and the consent field, together, because
282
+ * the contract judges them together — a lead surface with no opt-in field
283
+ * is one of the breaks the warning in this card names.
284
+ */ const handleSaveRouting = useCallback(async ()=>{
285
+ const dequeue = queueLoading();
286
+ setSaving('routing');
287
+ try {
288
+ var _ref;
289
+ var _form_routing;
290
+ await updateDoc(doc(firestore, 'hosts', hostId, 'forms', formId), _extends({}, lead != null ? {
240
291
  routing: _extends({}, (_ref = form == null ? void 0 : form.routing) != null ? _ref : {}, {
241
292
  lead
242
293
  })
243
294
  } : {}, consentField != null ? {
244
295
  consentFieldName: consentField.trim()
245
- } : {}, campaignIds != null ? Aglyn.formCampaignFields(campaignIds) : {}, {
296
+ } : {}, {
246
297
  updatedAt: Timestamp.now()
247
298
  }));
248
- setName(null);
249
299
  setLead(null);
250
300
  setConsentField(null);
251
- setCampaignIds(null);
252
- enqueueSnackbar('Form saved', {
301
+ enqueueSnackbar('CRM routing saved', {
253
302
  variant: 'success',
254
303
  persist: false
255
304
  });
@@ -272,6 +321,7 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
272
321
  allowDuplicate: true
273
322
  });
274
323
  } finally{
324
+ setSaving(null);
275
325
  dequeue();
276
326
  }
277
327
  }, [
@@ -279,14 +329,57 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
279
329
  hostId,
280
330
  formId,
281
331
  form,
282
- name,
283
332
  lead,
284
333
  consentField,
285
- campaignIds,
286
334
  queueLoading,
287
335
  enqueueSnackbar,
288
336
  recountFormStats
289
337
  ]);
338
+ /*
339
+ * Whether each card holds a change, measured against what is stored rather
340
+ * than against "was touched": flipping the switch and flipping it back is
341
+ * no change, and a Save that writes nothing should not be offered.
342
+ */ const detailsDirty = name != null && name.trim() !== String((_ref1 = form == null ? void 0 : form.displayName) != null ? _ref1 : '') || campaignIds != null && !Aglyn.containerMembershipUnchanged(storedCampaigns, campaignIds);
343
+ const routingDirty = lead != null && lead !== ((form == null ? void 0 : (_form_routing1 = form.routing) == null ? void 0 : _form_routing1.lead) === true) || consentField != null && consentField.trim() !== String((_ref2 = form == null ? void 0 : form.consentFieldName) != null ? _ref2 : '');
344
+ const dirty = detailsDirty || routingDirty;
345
+ /**
346
+ * Read from inside handlers that must not re-subscribe on every keystroke:
347
+ * `beforeunload` and the besigner buttons need the CURRENT answer.
348
+ */ const dirtyRef = useRef(false);
349
+ dirtyRef.current = dirty;
350
+ /**
351
+ * The exits a router cannot mediate: reload, tab close, or a link out of
352
+ * the console. Registered only while a card holds a change, so a clean page
353
+ * never nags — the same guard a content entry's editor keeps. The App
354
+ * Router has no navigation-blocking hook, so an in-console link is guarded
355
+ * only where this surface makes the move itself (the besigner buttons,
356
+ * below); everywhere else the enabled Save and Discard in each card's
357
+ * header are what say a change is waiting.
358
+ */ useEffect(()=>{
359
+ if (!dirty) return;
360
+ const warn = (event)=>{
361
+ event.preventDefault();
362
+ event.returnValue = '';
363
+ };
364
+ window.addEventListener('beforeunload', warn);
365
+ return ()=>window.removeEventListener('beforeunload', warn);
366
+ }, [
367
+ dirty
368
+ ]);
369
+ /**
370
+ * Asked before this surface navigates away on its own — opening the
371
+ * besigner — while a card holds a change. `confirm` resolves with no value
372
+ * and REJECTS on cancel.
373
+ */ const confirmLeave = useCallback(async ()=>{
374
+ if (!dirtyRef.current) return true;
375
+ return confirm({
376
+ title: 'Discard unsaved changes?',
377
+ description: 'This form has changes that have not been saved yet. Opening the ' + 'besigner now discards them.',
378
+ confirmationText: 'Discard and open'
379
+ }).then(()=>true).catch(()=>false);
380
+ }, [
381
+ confirm
382
+ ]);
290
383
  /**
291
384
  * Makes one version the version the live sites serve.
292
385
  *
@@ -352,6 +445,7 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
352
445
  * on the form the site was ALREADY submitting rather than a blank canvas.
353
446
  */ const handleOpen = useCallback((targetVersionId)=>async ()=>{
354
447
  if (opening) return;
448
+ if (!await confirmLeave()) return;
355
449
  setOpening(true);
356
450
  try {
357
451
  var _ref;
@@ -415,6 +509,7 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
415
509
  }
416
510
  }, [
417
511
  opening,
512
+ confirmLeave,
418
513
  createHostVersion,
419
514
  publishedVersionId,
420
515
  versions,
@@ -427,7 +522,6 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
427
522
  host,
428
523
  enqueueSnackbar
429
524
  ]);
430
- const dirty = name != null || lead != null || consentField != null || campaignIds != null && !Aglyn.containerMembershipUnchanged(storedCampaigns, campaignIds);
431
525
  // Named before it is used on every row, so a role denial reads as a reason
432
526
  // rather than as a control that does nothing.
433
527
  const publishBlock = hostRoleLoaded ? 'Your role on this site can edit content but not publish it' : 'Checking what your role can do…';
@@ -473,6 +567,18 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
473
567
  anchor: '#build-a-form',
474
568
  excerpt: 'The id is what every submission is filed under, so ' + 'renaming a form never splits its history.'
475
569
  }),
570
+ HeaderProps: {
571
+ action: /*#__PURE__*/ _jsx(FormDetailCardActions, {
572
+ section: "details",
573
+ dirty: detailsDirty,
574
+ saving: saving,
575
+ onDiscard: ()=>{
576
+ setName(null);
577
+ setCampaignIds(null);
578
+ },
579
+ onSave: handleSaveDetails
580
+ })
581
+ },
476
582
  contentGutterX: true,
477
583
  contentGutterY: true,
478
584
  children: /*#__PURE__*/ _jsxs(Stack, {
@@ -481,7 +587,7 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
481
587
  /*#__PURE__*/ _jsx(TextField, {
482
588
  label: "Display name",
483
589
  size: "small",
484
- value: (_ref1 = name != null ? name : form == null ? void 0 : form.displayName) != null ? _ref1 : '',
590
+ value: (_ref3 = name != null ? name : form == null ? void 0 : form.displayName) != null ? _ref3 : '',
485
591
  onChange: (event)=>setName(event.target.value),
486
592
  fullWidth: true
487
593
  }),
@@ -497,18 +603,6 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
497
603
  onChange: setCampaignIds,
498
604
  helperText: "The campaigns this form is part of. It does not change who a campaign mails.",
499
605
  empty: siteCampaigns.ready && !siteCampaigns.options.length
500
- }),
501
- /*#__PURE__*/ _jsx(Stack, {
502
- direction: "row",
503
- spacing: 1,
504
- children: /*#__PURE__*/ _jsx(Button, {
505
- variant: "contained",
506
- color: "primary",
507
- size: "small",
508
- disabled: !dirty,
509
- onClick: handleSave,
510
- children: 'Save'
511
- })
512
606
  })
513
607
  ]
514
608
  })
@@ -525,6 +619,18 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
525
619
  anchor: '#where-submissions-go',
526
620
  excerpt: 'Every submission reaches the Inbox and updates the ' + 'contact. Lead routing and the consent field decide ' + 'what else happens to it.'
527
621
  }),
622
+ HeaderProps: {
623
+ action: /*#__PURE__*/ _jsx(FormDetailCardActions, {
624
+ section: "routing",
625
+ dirty: routingDirty,
626
+ saving: saving,
627
+ onDiscard: ()=>{
628
+ setLead(null);
629
+ setConsentField(null);
630
+ },
631
+ onSave: handleSaveRouting
632
+ })
633
+ },
528
634
  contentGutterX: true,
529
635
  contentGutterY: true,
530
636
  children: /*#__PURE__*/ _jsxs(Stack, {
@@ -780,7 +886,8 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
780
886
  },
781
887
  children: /*#__PURE__*/ _jsx(FormSubmissionsCard, {
782
888
  hostId: hostId,
783
- formId: formId
889
+ formId: formId,
890
+ formName: form == null ? void 0 : form.displayName
784
891
  })
785
892
  },
786
893
  {
@@ -831,6 +938,36 @@ import useFormStatsRecountApi from "./use-form-stats-recount-api.js";
831
938
  });
832
939
  }
833
940
  FormDetailCard.displayName = 'FormDetailCard';
941
+ /**
942
+ * One card's Discard and Save, in that card's header (AGL-3508).
943
+ *
944
+ * Each is disabled until its card holds a change, and both while either card
945
+ * is writing: the two saves touch one document, and a second write landing
946
+ * mid-flight would be judged against a form the first is still changing.
947
+ */ function FormDetailCardActions(props) {
948
+ const { section, dirty, saving, onDiscard, onSave } = props;
949
+ return /*#__PURE__*/ _jsxs(Stack, {
950
+ direction: "row",
951
+ spacing: 1,
952
+ children: [
953
+ /*#__PURE__*/ _jsx(Button, {
954
+ size: "small",
955
+ variant: "text",
956
+ disabled: !dirty || saving !== null,
957
+ onClick: onDiscard,
958
+ children: 'Discard changes'
959
+ }),
960
+ /*#__PURE__*/ _jsx(Button, {
961
+ size: "small",
962
+ variant: "contained",
963
+ color: "primary",
964
+ disabled: !dirty || saving !== null,
965
+ onClick: ()=>void onSave(),
966
+ children: saving === section ? 'Saving…' : 'Save'
967
+ })
968
+ ]
969
+ });
970
+ }
834
971
  /**
835
972
  * The `form` node inside a published design.
836
973
  *