@aglyn/plugins-forms 1.0.0-beta.246 → 1.0.0-beta.247

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-forms",
3
- "version": "1.0.0-beta.246",
3
+ "version": "1.0.0-beta.247",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,19 +25,19 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.246",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.246",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.246",
31
- "@aglyn/shared-ui-jsx": "1.0.0-beta.246",
32
- "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.246",
33
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.246",
34
- "@aglyn/shared-util-email": "1.0.0-beta.246",
35
- "@aglyn/shared-util-http": "1.0.0-beta.246",
36
- "@aglyn/shared-util-timestamp": "1.0.0-beta.246",
37
- "@aglyn/shared-util-tools": "1.0.0-beta.246",
38
- "@aglyn/tenant-data-admin": "1.0.0-beta.246",
39
- "@aglyn/tenant-feature-instance": "1.0.0-beta.246",
40
- "@aglyn/tenant-runtime": "1.0.0-beta.246",
28
+ "@aglyn/aglyn": "1.0.0-beta.247",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.247",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.247",
31
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.247",
32
+ "@aglyn/shared-ui-jsx-forms": "1.0.0-beta.247",
33
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.247",
34
+ "@aglyn/shared-util-email": "1.0.0-beta.247",
35
+ "@aglyn/shared-util-http": "1.0.0-beta.247",
36
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.247",
37
+ "@aglyn/shared-util-tools": "1.0.0-beta.247",
38
+ "@aglyn/tenant-data-admin": "1.0.0-beta.247",
39
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.247",
40
+ "@aglyn/tenant-runtime": "1.0.0-beta.247",
41
41
  "@swc/helpers": "0.5.23"
42
42
  },
43
43
  "peerDependencies": {
@@ -201,6 +201,15 @@ export declare const parseFieldOptions: (options?: string) => string[];
201
201
  declare const FormField: import("react").ForwardRefExoticComponent<FormFieldProps & import("react").RefAttributes<HTMLDivElement>>;
202
202
  export declare const formSchema: Aglyn.ComponentSchema<FormProps>;
203
203
  export declare const formFieldSchema: Aglyn.ComponentSchema<FormFieldProps>;
204
+ /**
205
+ * A field as a preset: the all-in-one Form Field's props rebuilt as MUI's
206
+ * separate form elements by `formFieldToElements` — the same function the
207
+ * backfill migrates saved fields with, so a field added today and a field
208
+ * migrated from a Form Field are the same tree. Ids are left `null` (fresh
209
+ * ones are minted at insertion) and every node names `mui`, the bundle that
210
+ * registers it.
211
+ */
212
+ export declare function fieldPresetData(props: FormFieldProps): Aglyn.NodePresetData;
204
213
  export declare const formPresets: Aglyn.PresetSchema[];
205
214
  /**
206
215
  * The composed section a form usually arrives inside.
@@ -27,6 +27,7 @@ import { parseFormUnavailableRefusal } from "../model/form-unavailable.js";
27
27
  import { mdiEmailFastOutline, mdiEmailOutline, mdiFormTextbox } from "@aglyn/shared-data-mdi";
28
28
  import { isSameOriginPath } from "@aglyn/shared-util-http/safe-redirect";
29
29
  import Alert from "@mui/material/Alert";
30
+ import Box from "@mui/material/Box";
30
31
  import Button from "@mui/material/Button";
31
32
  import Checkbox from "@mui/material/Checkbox";
32
33
  import FormControl from "@mui/material/FormControl";
@@ -40,9 +41,11 @@ import Radio from "@mui/material/Radio";
40
41
  import RadioGroup from "@mui/material/RadioGroup";
41
42
  import Rating from "@mui/material/Rating";
42
43
  import Stack from "@mui/material/Stack";
44
+ import { useForkRef } from "@mui/material/utils";
43
45
  import TextField from "@mui/material/TextField";
44
46
  import { createContext, forwardRef, useCallback, useContext, useEffect, useRef, useState } from "react";
45
47
  import { BUNDLE_ID } from "../constants/bundle-common.js";
48
+ import { formFieldToElements, LEGACY_FORM_FIELD_COMPONENT_ID } from "../model/form-field-elements.js";
46
49
  import { generatePresetId } from "../utils/generate-preset-id.js";
47
50
  /*
48
51
  * The heading and the stack this block's form sits inside are mui elements,
@@ -73,6 +76,39 @@ export const FORM_FIELD_ID = 'formField';
73
76
  * A form whose consent field never mounted posts no key, and its opt-in is
74
77
  * recorded for the one site the visitor is on.
75
78
  */ export const CONSENT_GROUP_INPUT = '__consentGroup';
79
+ /**
80
+ * The class on a form's built-in submit button. It hides while the form holds
81
+ * a Button the author placed with Type: Submit (see the Form's `:has` rule).
82
+ */ const BUILT_IN_SUBMIT_CLASS = 'aglyn-form-submit-built-in';
83
+ /**
84
+ * The consent-group sentence (AGL-3320), as the all-in-one Form Field draws it
85
+ * under the opt-in, and as the Form draws it for a form built from MUI's own
86
+ * form elements, which know nothing of consent. The Form's copy hides while a
87
+ * Form Field's is on the page, so a form never shows it twice.
88
+ */ const FIELD_CONSENT_NOTE_CLASS = 'aglyn-form-consent-note-field';
89
+ const FORM_CONSENT_NOTE_CLASS = 'aglyn-form-consent-note';
90
+ /**
91
+ * Whether the form's opt-in is one of MUI's form elements (an input named as
92
+ * the declared opt-in field) rather than an all-in-one Form Field, which
93
+ * draws its own sentence. Read off the rendered form after mount: the
94
+ * elements know nothing of consent, and until it is read the form shows no
95
+ * sentence and posts no key — the safe way to be early.
96
+ */ function useOptInDrawnByElements(formRef, fieldName) {
97
+ const [drawn, setDrawn] = useState(false);
98
+ useEffect(()=>{
99
+ const form = formRef.current;
100
+ if (!form || !fieldName) {
101
+ setDrawn(false);
102
+ return;
103
+ }
104
+ const hasInput = Array.from(form.elements).some((element)=>element.name === fieldName);
105
+ setDrawn(hasInput && !form.querySelector(`.${FIELD_CONSENT_NOTE_CLASS}`));
106
+ }, [
107
+ formRef,
108
+ fieldName
109
+ ]);
110
+ return drawn;
111
+ }
76
112
  /**
77
113
  * The disclosure a Form fetched, handed to the fields inside it. A context
78
114
  * because the answer flows DOWN, from the one request the Form makes to the
@@ -179,6 +215,8 @@ const REVEAL_NODE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
179
215
  ]);
180
216
  const { hostId } = Aglyn.useSite();
181
217
  const siteFetch = Aglyn.useSiteFetch();
218
+ const formRef = useRef(null);
219
+ const handleRef = useForkRef(ref, formRef);
182
220
  // Resolves the redirect screen like ScreenLink does (rename-safe) and
183
221
  // flags editing surfaces, where outcomes must never fire (AGL-557).
184
222
  const { href: redirectScreenHref, suppressNavigation } = Aglyn.useScreenLink(afterSubmit === 'redirect' ? redirectScreenId : undefined);
@@ -265,6 +303,7 @@ const REVEAL_NODE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
265
303
  formId,
266
304
  siteFetch
267
305
  ]);
306
+ const optInDrawnByElements = useOptInDrawnByElements(formRef, disclosure == null ? void 0 : disclosure.fieldName);
268
307
  const handleSubmit = useCallback(async (event)=>{
269
308
  event.preventDefault();
270
309
  if (!hostId || status === 'sending') return;
@@ -437,12 +476,30 @@ const REVEAL_NODE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
437
476
  });
438
477
  }
439
478
  return /*#__PURE__*/ _jsxs(Stack, _extends({
440
- ref: ref,
479
+ ref: handleRef,
441
480
  component: "form",
442
481
  spacing: 2,
482
+ // Gap, not margins, between children: a form turned into a row with
483
+ // the Styles panel's Flex direction lines its input and button up
484
+ // instead of pushing every item after the first down a step.
485
+ useFlexGap: true,
443
486
  onSubmit: handleSubmit,
444
487
  onInput: handleFirstInput
445
488
  }, rest, {
489
+ sx: [
490
+ {
491
+ // A Button placed in the form with Type: Submit is the form's
492
+ // button — styled like any element — so the built-in one steps
493
+ // aside. CSS rather than a registration, so the server's first
494
+ // paint is already right.
495
+ [`&:has(button[type="submit"]:not(.${BUILT_IN_SUBMIT_CLASS})) .${BUILT_IN_SUBMIT_CLASS}`]: {
496
+ display: 'none'
497
+ }
498
+ },
499
+ ...Array.isArray(rest['sx']) ? rest['sx'] : [
500
+ rest['sx']
501
+ ]
502
+ ],
446
503
  children: [
447
504
  revealSelector ? /*#__PURE__*/ _jsx("style", {
448
505
  children: `${revealSelector}{display:none !important}`
@@ -451,6 +508,20 @@ const REVEAL_NODE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
451
508
  value: disclosure,
452
509
  children: children
453
510
  }),
511
+ disclosure && optInDrawnByElements ? /*#__PURE__*/ _jsxs(Box, {
512
+ className: FORM_CONSENT_NOTE_CLASS,
513
+ children: [
514
+ /*#__PURE__*/ _jsx(FormHelperText, {
515
+ children: disclosure.text
516
+ }),
517
+ /*#__PURE__*/ _jsx("input", {
518
+ type: "hidden",
519
+ name: CONSENT_GROUP_INPUT,
520
+ value: disclosure.key,
521
+ readOnly: true
522
+ })
523
+ ]
524
+ }) : null,
454
525
  /*#__PURE__*/ _jsx("input", {
455
526
  type: "text",
456
527
  name: "website",
@@ -511,6 +582,7 @@ const REVEAL_NODE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
511
582
  /*#__PURE__*/ _jsx(Button, {
512
583
  type: "submit",
513
584
  variant: "contained",
585
+ className: BUILT_IN_SUBMIT_CLASS,
514
586
  disabled: status === 'sending',
515
587
  sx: {
516
588
  alignSelf: 'flex-start'
@@ -586,6 +658,7 @@ Form.displayName = 'AglynForm';
586
658
  const consentNote = disclosure && describesOptIn ? /*#__PURE__*/ _jsxs(_Fragment, {
587
659
  children: [
588
660
  /*#__PURE__*/ _jsx(FormHelperText, {
661
+ className: FIELD_CONSENT_NOTE_CLASS,
589
662
  children: disclosure.text
590
663
  }),
591
664
  /*#__PURE__*/ _jsx("input", {
@@ -937,7 +1010,61 @@ export const formFieldSchema = {
937
1010
  }
938
1011
  ]
939
1012
  };
940
- export const formPresets = [
1013
+ /**
1014
+ * A field as a preset: the all-in-one Form Field's props rebuilt as MUI's
1015
+ * separate form elements by `formFieldToElements` — the same function the
1016
+ * backfill migrates saved fields with, so a field added today and a field
1017
+ * migrated from a Form Field are the same tree. Ids are left `null` (fresh
1018
+ * ones are minted at insertion) and every node names `mui`, the bundle that
1019
+ * registers it.
1020
+ */ export function fieldPresetData(props) {
1021
+ const built = formFieldToElements({
1022
+ $id: 'field',
1023
+ componentId: LEGACY_FORM_FIELD_COMPONENT_ID,
1024
+ props: props
1025
+ });
1026
+ if (!built) throw new Error('formFieldToElements refused a Form Field');
1027
+ const nest = (id)=>{
1028
+ var _node_nodes, _node_props;
1029
+ const node = built.nodes[id];
1030
+ const children = ((_node_nodes = node.nodes) != null ? _node_nodes : []).map(String);
1031
+ return _extends({
1032
+ $id: null,
1033
+ componentId: node.componentId,
1034
+ pluginId: node.pluginId,
1035
+ props: (_node_props = node.props) != null ? _node_props : {}
1036
+ }, children.length ? {
1037
+ nodes: children.map(nest)
1038
+ } : {});
1039
+ };
1040
+ return nest(built.rootId);
1041
+ }
1042
+ /** The name/email/message fields every contact form starts with. */ const contactFields = ()=>[
1043
+ fieldPresetData({
1044
+ fieldName: 'name',
1045
+ label: 'Name',
1046
+ required: true
1047
+ }),
1048
+ fieldPresetData({
1049
+ fieldName: 'email',
1050
+ label: 'Email',
1051
+ fieldType: 'email',
1052
+ required: true
1053
+ }),
1054
+ fieldPresetData({
1055
+ fieldName: 'message',
1056
+ label: 'Message',
1057
+ fieldType: 'textarea',
1058
+ required: true
1059
+ })
1060
+ ];
1061
+ /*
1062
+ * The all-in-one Form Field has no preset: every piece of a field is its own
1063
+ * element in the mui plugin now (Form Control, Form Label, Input, …, with
1064
+ * Text/Email/Message/Dropdown/Radio/Checkbox/Rating field presets there), so
1065
+ * the Add Element picker no longer offers it. Its component and schema stay
1066
+ * registered so a document nobody has migrated keeps drawing and editing.
1067
+ */ export const formPresets = [
941
1068
  {
942
1069
  $id: generatePresetId(FORM_ID, 'contact'),
943
1070
  type: Aglyn.NodeType.PRESET,
@@ -958,70 +1085,16 @@ export const formPresets = [
958
1085
  props: {
959
1086
  formName: 'Contact'
960
1087
  },
961
- nodes: [
962
- {
963
- $id: null,
964
- componentId: FORM_FIELD_ID,
965
- pluginId: BUNDLE_ID,
966
- props: {
967
- fieldName: 'name',
968
- label: 'Name',
969
- required: true
970
- }
971
- },
972
- {
973
- $id: null,
974
- componentId: FORM_FIELD_ID,
975
- pluginId: BUNDLE_ID,
976
- props: {
977
- fieldName: 'email',
978
- label: 'Email',
979
- fieldType: 'email',
980
- required: true
981
- }
982
- },
983
- {
984
- $id: null,
985
- componentId: FORM_FIELD_ID,
986
- pluginId: BUNDLE_ID,
987
- props: {
988
- fieldName: 'message',
989
- label: 'Message',
990
- fieldType: 'textarea',
991
- required: true
992
- }
993
- }
994
- ]
995
- }
996
- },
997
- {
998
- $id: generatePresetId(FORM_FIELD_ID),
999
- type: Aglyn.NodeType.PRESET,
1000
- displayName: 'Form Field',
1001
- pluginId: BUNDLE_ID,
1002
- description: 'Single input inside a form',
1003
- category: Aglyn.ComponentCategory.FORMS,
1004
- icon: {
1005
- path: mdiFormTextbox.path,
1006
- sx: {
1007
- color: 'info.main'
1008
- }
1009
- },
1010
- data: {
1011
- $id: null,
1012
- componentId: FORM_FIELD_ID,
1013
- pluginId: BUNDLE_ID,
1014
- props: {
1015
- fieldName: 'field',
1016
- label: 'Field'
1017
- }
1088
+ nodes: contactFields()
1018
1089
  }
1019
1090
  },
1020
1091
  /*
1021
- * The marketing consent field as one preset rather than a recipe. Its props
1022
- * are the platform's `MARKETING_CONSENT_FORM_FIELD`, which every place that adds
1023
- * this field reads, so each adds the same unticked one-option checkbox. It
1024
- * records consent once the form names it as its Marketing consent field.
1092
+ * The marketing consent field as one preset rather than a recipe. It is the
1093
+ * platform's `MARKETING_CONSENT_FORM_FIELD` rebuilt as elements — a lone,
1094
+ * unticked, not-required Checkbox named `marketingConsent` in a Choice
1095
+ * Label whose words say what the person agrees to and are also what the box
1096
+ * posts — so every place that adds this field adds the same one. It records
1097
+ * consent once the form names it as its Marketing consent field.
1025
1098
  */ {
1026
1099
  $id: generatePresetId(FORM_FIELD_ID, 'marketing-consent'),
1027
1100
  type: Aglyn.NodeType.PRESET,
@@ -1035,12 +1108,7 @@ export const formPresets = [
1035
1108
  color: 'info.main'
1036
1109
  }
1037
1110
  },
1038
- data: {
1039
- $id: null,
1040
- componentId: FORM_FIELD_ID,
1041
- pluginId: BUNDLE_ID,
1042
- props: _extends({}, Aglyn.MARKETING_CONSENT_FORM_FIELD)
1043
- }
1111
+ data: fieldPresetData(_extends({}, Aglyn.MARKETING_CONSENT_FORM_FIELD))
1044
1112
  }
1045
1113
  ];
1046
1114
  /**
@@ -1099,32 +1167,12 @@ export const formPresets = [
1099
1167
  submitLabel: 'Send message',
1100
1168
  successMessage: 'Thanks — we will get back to you soon.'
1101
1169
  },
1102
- nodes: [
1103
- contactField('name', 'Name'),
1104
- contactField('email', 'Email', {
1105
- fieldType: 'email'
1106
- }),
1107
- contactField('message', 'Message', {
1108
- fieldType: 'textarea'
1109
- })
1110
- ]
1170
+ nodes: contactFields()
1111
1171
  }
1112
1172
  ]
1113
1173
  }
1114
1174
  }
1115
1175
  ];
1116
- function contactField(fieldName, label, extra) {
1117
- return {
1118
- $id: null,
1119
- componentId: FORM_FIELD_ID,
1120
- pluginId: BUNDLE_ID,
1121
- props: _extends({
1122
- fieldName,
1123
- label,
1124
- required: true
1125
- }, extra)
1126
- };
1127
- }
1128
1176
  export { Form, FormField };
1129
1177
  export default Form;
1130
1178