@jini-ai/admin 0.4.5 → 0.4.7

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 (97) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/agent-plugins/package-file-tree.d.ts +3 -1
  3. package/dist/agent-plugins/package-file-tree.js +7 -7
  4. package/dist/comments/controllers/comment-queue.controller.d.ts +5 -1
  5. package/dist/comments/controllers/comment-queue.controller.js +2 -2
  6. package/dist/comments/react/hooks/use-comment-queue.hooks.js +14 -3
  7. package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -0
  8. package/dist/consent/react/ConsentSettingsPanel.js +24 -0
  9. package/dist/consent/react/index.d.ts +3 -0
  10. package/dist/consent/react/index.js +2 -0
  11. package/dist/consent/react/settings-panel.hooks.d.ts +19 -0
  12. package/dist/consent/react/settings-panel.hooks.js +30 -0
  13. package/dist/core/ports/forms.d.ts +23 -3
  14. package/dist/core/ports/forms.js +3 -3
  15. package/dist/core/ports/seo.d.ts +17 -0
  16. package/dist/forms/adapters/http.d.ts +5 -2
  17. package/dist/forms/adapters/http.js +7 -2
  18. package/dist/forms/controllers/field-attributes-dialog.controller.d.ts +59 -1
  19. package/dist/forms/controllers/field-attributes-dialog.controller.js +27 -2
  20. package/dist/forms/field-settings.d.ts +142 -0
  21. package/dist/forms/field-settings.js +327 -0
  22. package/dist/forms/form-editor-dirty.d.ts +42 -0
  23. package/dist/forms/form-editor-dirty.js +25 -0
  24. package/dist/forms/forms.module.d.ts +72 -0
  25. package/dist/forms/html-rules.d.ts +41 -3
  26. package/dist/forms/html-rules.js +118 -17
  27. package/dist/forms/messages.en.d.ts +72 -0
  28. package/dist/forms/messages.en.js +73 -0
  29. package/dist/forms/react/components/FieldAttributesDialog.js +20 -19
  30. package/dist/forms/react/components/FieldSettingsSection.d.ts +19 -0
  31. package/dist/forms/react/components/FieldSettingsSection.js +21 -0
  32. package/dist/forms/react/components/FormFieldsEditor.js +7 -8
  33. package/dist/forms/react/components/FormSubmissionDetail.js +5 -2
  34. package/dist/forms/react/hooks/forms-view.hooks.d.ts +75 -5
  35. package/dist/forms/react/hooks/forms-view.hooks.js +46 -2
  36. package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.d.ts +10 -0
  37. package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.js +12 -2
  38. package/dist/forms/react/hooks/use-form-editor.hooks.d.ts +11 -0
  39. package/dist/forms/react/hooks/use-form-editor.hooks.js +25 -2
  40. package/dist/forms/react/hooks/use-form-fields-editor.hooks.d.ts +2 -0
  41. package/dist/forms/react/hooks/use-form-fields-editor.hooks.js +5 -1
  42. package/dist/forms/react/hooks/use-form-submission-detail.hooks.d.ts +4 -0
  43. package/dist/forms/react/hooks/use-form-submission-detail.hooks.js +1 -0
  44. package/dist/forms/react/index.d.ts +216 -0
  45. package/dist/forms/react/pages/FormEditor.js +3 -2
  46. package/dist/forms/rules.d.ts +10 -1
  47. package/dist/forms/rules.js +8 -1
  48. package/dist/media/controllers/library.controller.d.ts +4 -0
  49. package/dist/media/controllers/library.controller.js +13 -0
  50. package/dist/media/react/components/MediaPicker.js +2 -2
  51. package/dist/media/react/hooks/EditMediaPanel.hooks.js +4 -2
  52. package/dist/media/react/hooks/GallerySelection.hooks.d.ts +31 -0
  53. package/dist/media/react/hooks/GallerySelection.hooks.js +32 -0
  54. package/dist/media/react/hooks/LibraryTab.hooks.d.ts +4 -0
  55. package/dist/media/react/hooks/LibraryTab.hooks.js +9 -3
  56. package/dist/media/react/hooks/MediaCard.hooks.d.ts +2 -2
  57. package/dist/media/react/hooks/MediaGrid.hooks.d.ts +2 -2
  58. package/dist/media/react/hooks/MediaPage.hooks.d.ts +4 -0
  59. package/dist/media/react/hooks/MediaPage.hooks.js +10 -13
  60. package/dist/media/react/hooks/MediaPicker.hooks.js +5 -1
  61. package/dist/media/react/hooks/MediaQuery.hooks.d.ts +27 -0
  62. package/dist/media/react/hooks/MediaQuery.hooks.js +41 -0
  63. package/dist/media/react/hooks/ProvidersTab.hooks.js +21 -2
  64. package/dist/media/react/index.d.ts +1 -0
  65. package/dist/media/react/index.js +1 -0
  66. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +5 -0
  67. package/dist/react/components/Sidebar.d.ts +6 -0
  68. package/dist/react/components/Sidebar.js +1 -0
  69. package/dist/react/entities/use-entity-edit.d.ts +1 -1
  70. package/dist/react/hooks/use-sidebar-rail.d.ts +10 -1
  71. package/dist/react/hooks/use-sidebar-rail.js +16 -3
  72. package/dist/seo/adapters/http.d.ts +11 -1
  73. package/dist/seo/adapters/http.js +11 -1
  74. package/dist/seo/controllers/entry-picker.controller.d.ts +2 -0
  75. package/dist/seo/controllers/entry-picker.controller.js +2 -0
  76. package/dist/seo/models.d.ts +8 -0
  77. package/dist/seo/react/components/MediaRefField.js +2 -2
  78. package/dist/seo/react/hooks/MediaRefField.hooks.d.ts +4 -2
  79. package/dist/seo/react/hooks/MediaRefField.hooks.js +8 -3
  80. package/dist/seo/react/hooks/SeoQuery.hooks.d.ts +42 -0
  81. package/dist/seo/react/hooks/SeoQuery.hooks.js +65 -0
  82. package/dist/seo/react/hooks/use-entry-picker.hooks.js +7 -9
  83. package/dist/seo/react/hooks/use-seo-entry-panel.hooks.js +9 -2
  84. package/dist/seo/react/hooks/use-seo.hooks.js +22 -4
  85. package/dist/seo/react/hooks/use-share-image-warning.hooks.d.ts +19 -0
  86. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +79 -0
  87. package/dist/seo/react/hooks/use-sitemap-modal.hooks.js +5 -3
  88. package/dist/seo/react/pages/SeoPage.js +1 -1
  89. package/dist/seo/rules.d.ts +21 -2
  90. package/dist/seo/rules.js +44 -4
  91. package/dist/widgets/react/hooks/WidgetsQuery.hooks.d.ts +37 -0
  92. package/dist/widgets/react/hooks/WidgetsQuery.hooks.js +70 -0
  93. package/dist/widgets/react/hooks/use-widget-instance-editor.hooks.js +5 -3
  94. package/dist/widgets/react/hooks/use-widget-region-editor.hooks.js +3 -1
  95. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +7 -3
  96. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +7 -3
  97. package/package.json +20 -14
@@ -0,0 +1,142 @@
1
+ import { type FieldType } from "@jini-ai/cms/forms";
2
+ import type { AdminFormField } from "./models.js";
3
+ /** Plain-language type names (translation keys). Exhaustive over the registry by type. */
4
+ export declare const FIELD_TYPE_LABELS: Record<FieldType, string>;
5
+ export type FieldBounds = "number" | "date" | "time" | "datetime-local";
6
+ export type FieldDefaultKind = "text" | "textarea" | "number" | "temporal" | "boolean" | "choice" | "choices";
7
+ /** Which builder settings apply to one field type. */
8
+ export interface FieldSettings {
9
+ placeholder: boolean;
10
+ helpText: boolean;
11
+ length: boolean;
12
+ pattern: boolean;
13
+ /** Native input type for min/max, or `null` when the type has no bounds. */
14
+ bounds: FieldBounds | null;
15
+ step: boolean;
16
+ options: boolean;
17
+ multiple: boolean;
18
+ defaultKind: FieldDefaultKind | null;
19
+ upload: boolean;
20
+ }
21
+ /**
22
+ * Settings for one type, from its registry kind. Unknown (legacy or host-added) types get help
23
+ * text only, so the editor never offers a setting the server would refuse.
24
+ * @complexity O(1).
25
+ */
26
+ export declare function fieldSettingsFor({ type }: {
27
+ type: string;
28
+ }, _optional?: Record<string, never>): FieldSettings;
29
+ export type FieldTypeGroup = "Text" | "Numbers and dates" | "Choices" | "Other";
30
+ /**
31
+ * The type picker's `<optgroup>`s, in registry order within each group. A stored type the
32
+ * registry does not know is kept as its own trailing option so loading never changes it.
33
+ * @complexity O(t) in registered types.
34
+ */
35
+ export declare function fieldTypeGroups({ current }: {
36
+ current?: string;
37
+ }, _optional?: Record<string, never>): Array<{
38
+ group: FieldTypeGroup;
39
+ types: Array<{
40
+ type: string;
41
+ label: string;
42
+ }>;
43
+ }>;
44
+ /** Lowercase letters, digits and `_`, starting with a letter: the CMS `FIELD_ID_PATTERN`. */
45
+ export declare function fieldIdFromLabel({ label }: {
46
+ label: string;
47
+ }, _optional?: Record<string, never>): string;
48
+ /**
49
+ * A label edit on an unsaved field also renames its id while the id still follows the label (empty
50
+ * or equal to the old label's slug). A hand-typed id, or any saved id, is never touched.
51
+ * @complexity O(n) in label length.
52
+ */
53
+ export declare function labelChangePatch({ field, label, isExisting }: {
54
+ field: AdminFormField;
55
+ label: string;
56
+ isExisting: boolean;
57
+ }, _optional?: Record<string, never>): Partial<AdminFormField>;
58
+ /**
59
+ * Change a field's type, dropping every setting the new type does not support (the CMS refuses
60
+ * them) and seeding two options for a new choice field, which needs at least one.
61
+ * Identity, label, help text and styling always survive.
62
+ * @complexity O(1) apart from copying the option list.
63
+ */
64
+ export declare function retypeField({ field, type }: {
65
+ field: AdminFormField;
66
+ type: AdminFormField["type"];
67
+ }, _optional?: Record<string, never>): AdminFormField;
68
+ export interface OptionRow {
69
+ _rowId: number;
70
+ value: string;
71
+ label: string;
72
+ }
73
+ /** String draft of every builder setting; numbers stay text until Save. */
74
+ export interface FieldSettingsForm {
75
+ placeholder: string;
76
+ helpText: string;
77
+ defaultText: string;
78
+ defaultChecked: boolean;
79
+ defaultChoices: string[];
80
+ minLength: string;
81
+ maxLength: string;
82
+ pattern: string;
83
+ min: string;
84
+ max: string;
85
+ step: string;
86
+ options: OptionRow[];
87
+ accept: string[];
88
+ maxFileSizeMb: string;
89
+ }
90
+ /** Seed the draft from a stored field. @complexity O(o) in options. */
91
+ export declare function settingsFormFromField({ field }: {
92
+ field: AdminFormField;
93
+ }, _optional?: Record<string, never>): FieldSettingsForm;
94
+ /**
95
+ * Edit one option row. A value that still mirrors its label follows label edits, so typing labels
96
+ * is enough for most forms; an edited value stays put.
97
+ * @complexity O(o).
98
+ */
99
+ export declare function updateOptionRow({ rows, rowId, patch }: {
100
+ rows: OptionRow[];
101
+ rowId: number;
102
+ patch: Partial<Pick<OptionRow, "value" | "label">>;
103
+ }, _optional?: Record<string, never>): OptionRow[];
104
+ /** @complexity O(o). */
105
+ export declare function addOptionRow({ rows }: {
106
+ rows: OptionRow[];
107
+ }, _optional?: Record<string, never>): OptionRow[];
108
+ /** @complexity O(o). */
109
+ export declare function removeOptionRow({ rows, rowId }: {
110
+ rows: OptionRow[];
111
+ rowId: number;
112
+ }, _optional?: Record<string, never>): OptionRow[];
113
+ /** Swap a row with its neighbour; moving past either end is a no-op. @complexity O(o). */
114
+ export declare function moveOptionRow({ rows, rowId, offset }: {
115
+ rows: OptionRow[];
116
+ rowId: number;
117
+ offset: -1 | 1;
118
+ }, _optional?: Record<string, never>): OptionRow[];
119
+ /** Upload groups offered as checkboxes; values are accept entries the CMS recognizes. */
120
+ export declare const UPLOAD_CHOICES: {
121
+ value: string;
122
+ label: string;
123
+ }[];
124
+ export declare const MAX_UPLOAD_MB: number;
125
+ export type FieldSettingsPatchResult = {
126
+ ok: true;
127
+ patch: Partial<AdminFormField>;
128
+ } | {
129
+ ok: false;
130
+ error: string;
131
+ };
132
+ /**
133
+ * Parse and check the dialog draft into a field patch. Settings the type does not support come out
134
+ * `undefined`, so Save also clears anything stale. Max length instead uses `null` when blank and
135
+ * is omitted for unsupported types; `retypeField` already drops it on a type change. First failure wins.
136
+ * @complexity O(o) in options.
137
+ */
138
+ export declare function buildFieldSettingsPatch({ form, field }: {
139
+ form: FieldSettingsForm;
140
+ field: Pick<AdminFormField, "type" | "checkboxValue">;
141
+ }, _optional?: Record<string, never>): FieldSettingsPatchResult;
142
+ //# sourceMappingURL=field-settings.d.ts.map
@@ -0,0 +1,327 @@
1
+ import { FIELD_TYPE_REGISTRY, isBrowserSafeFieldPattern, MAX_FORM_FILE_BYTES, SAFE_FORM_FILE_TYPES } from "@jini-ai/cms/forms";
2
+ const kindOf = (type) => Object.hasOwn(FIELD_TYPE_REGISTRY, type) ? FIELD_TYPE_REGISTRY[type] : undefined;
3
+ /** Plain-language type names (translation keys). Exhaustive over the registry by type. */
4
+ export const FIELD_TYPE_LABELS = {
5
+ text: "Short text", textarea: "Paragraph", email: "Email", phone: "Phone", url: "Website address",
6
+ number: "Number", range: "Slider", date: "Date", time: "Time", datetime: "Date and time",
7
+ select: "Dropdown", "multi-select": "Multi-select list", radio: "Radio buttons", checkbox: "Checkbox",
8
+ "checkbox-group": "Checkboxes", toggle: "Toggle switch", file: "File upload", hidden: "Hidden value",
9
+ };
10
+ const NO_SETTINGS = { placeholder: false, helpText: true, length: false, pattern: false, bounds: null, step: false, options: false, multiple: false, defaultKind: null, upload: false };
11
+ const BOUNDS = new Set(["number", "range", "date", "time", "datetime-local"]);
12
+ /** The default value a control can carry, read from its registry kind. */
13
+ function defaultKindOf(kind) {
14
+ if (kind.choices)
15
+ return kind.multiple ? "choices" : "choice";
16
+ if (kind.htmlType === "file")
17
+ return null;
18
+ if (kind.htmlType === "checkbox")
19
+ return "boolean";
20
+ if (kind.htmlType === "number" || kind.htmlType === "range")
21
+ return "number";
22
+ if (BOUNDS.has(kind.htmlType))
23
+ return "temporal";
24
+ return kind.control === "textarea" ? "textarea" : "text";
25
+ }
26
+ /**
27
+ * Settings for one type, from its registry kind. Unknown (legacy or host-added) types get help
28
+ * text only, so the editor never offers a setting the server would refuse.
29
+ * @complexity O(1).
30
+ */
31
+ export function fieldSettingsFor({ type }, _optional = {}) {
32
+ const kind = kindOf(type);
33
+ if (!kind)
34
+ return NO_SETTINGS;
35
+ const hidden = kind.htmlType === "hidden";
36
+ const numeric = kind.htmlType === "number" || kind.htmlType === "range";
37
+ const bounds = BOUNDS.has(kind.htmlType) ? (numeric ? "number" : kind.htmlType) : null;
38
+ return {
39
+ // A single-choice select shows its placeholder as the empty first option.
40
+ placeholder: !hidden && ((kind.textual === true && kind.control !== "select") || kind.htmlType === "number" || (kind.control === "select" && !kind.multiple)),
41
+ helpText: !hidden,
42
+ length: kind.textual === true,
43
+ pattern: kind.textual === true && !hidden,
44
+ bounds,
45
+ step: numeric,
46
+ options: kind.choices === true,
47
+ multiple: kind.multiple === true,
48
+ defaultKind: defaultKindOf(kind),
49
+ upload: kind.htmlType === "file",
50
+ };
51
+ }
52
+ const GROUP_ORDER = ["Text", "Choices", "Numbers and dates", "Other"];
53
+ function groupOf(type) {
54
+ const settings = fieldSettingsFor({ type });
55
+ if (settings.options || settings.defaultKind === "boolean")
56
+ return "Choices";
57
+ if (settings.bounds)
58
+ return "Numbers and dates";
59
+ if (settings.length && settings.helpText)
60
+ return "Text";
61
+ return "Other";
62
+ }
63
+ /**
64
+ * The type picker's `<optgroup>`s, in registry order within each group. A stored type the
65
+ * registry does not know is kept as its own trailing option so loading never changes it.
66
+ * @complexity O(t) in registered types.
67
+ */
68
+ export function fieldTypeGroups({ current }, _optional = {}) {
69
+ const types = Object.keys(FIELD_TYPE_REGISTRY);
70
+ const groups = GROUP_ORDER.map((group) => ({ group, types: types.filter((type) => groupOf(type) === group).map((type) => ({ type, label: FIELD_TYPE_LABELS[type] })) }));
71
+ if (current && !kindOf(current))
72
+ groups[groups.length - 1].types.push({ type: current, label: current });
73
+ return groups.filter((entry) => entry.types.length > 0);
74
+ }
75
+ /** Lowercase letters, digits and `_`, starting with a letter: the CMS `FIELD_ID_PATTERN`. */
76
+ export function fieldIdFromLabel({ label }, _optional = {}) {
77
+ const slug = label.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 60);
78
+ return slug === "" ? "" : /^[a-z]/.test(slug) ? slug : `field_${slug}`;
79
+ }
80
+ /**
81
+ * A label edit on an unsaved field also renames its id while the id still follows the label (empty
82
+ * or equal to the old label's slug). A hand-typed id, or any saved id, is never touched.
83
+ * @complexity O(n) in label length.
84
+ */
85
+ export function labelChangePatch({ field, label, isExisting }, _optional = {}) {
86
+ const followsLabel = field.id === "" || field.id === fieldIdFromLabel({ label: field.label });
87
+ return !isExisting && followsLabel ? { label, id: fieldIdFromLabel({ label }) } : { label };
88
+ }
89
+ const SEED_OPTIONS = [{ value: "Option 1", label: "Option 1" }, { value: "Option 2", label: "Option 2" }];
90
+ /**
91
+ * Change a field's type, dropping every setting the new type does not support (the CMS refuses
92
+ * them) and seeding two options for a new choice field, which needs at least one.
93
+ * Identity, label, help text and styling always survive.
94
+ * @complexity O(1) apart from copying the option list.
95
+ */
96
+ export function retypeField({ field, type }, _optional = {}) {
97
+ const before = fieldSettingsFor({ type: field.type });
98
+ const after = fieldSettingsFor({ type });
99
+ const next = { id: field.id, label: field.label, type, required: after.helpText ? field.required : false };
100
+ if (after.helpText && field.helpText !== undefined)
101
+ next.helpText = field.helpText;
102
+ if (field.className !== undefined)
103
+ next.className = field.className;
104
+ if (field.attributes !== undefined)
105
+ next.attributes = { ...field.attributes };
106
+ if (after.placeholder && field.placeholder !== undefined)
107
+ next.placeholder = field.placeholder;
108
+ if (after.length) {
109
+ if (field.maxLength != null)
110
+ next.maxLength = field.maxLength;
111
+ if (field.minLength !== undefined)
112
+ next.minLength = field.minLength;
113
+ }
114
+ if (after.pattern && field.pattern !== undefined)
115
+ next.pattern = field.pattern;
116
+ if (after.bounds && after.bounds === before.bounds) {
117
+ if (field.min !== undefined)
118
+ next.min = field.min;
119
+ if (field.max !== undefined)
120
+ next.max = field.max;
121
+ }
122
+ if (after.step && field.step !== undefined)
123
+ next.step = field.step;
124
+ if (after.options)
125
+ next.options = field.options?.length ? field.options.map((option) => ({ ...option })) : SEED_OPTIONS.map((option) => ({ ...option }));
126
+ if (after.upload) {
127
+ if (field.accept !== undefined)
128
+ next.accept = [...field.accept];
129
+ if (field.maxFileSize !== undefined)
130
+ next.maxFileSize = field.maxFileSize;
131
+ }
132
+ // A default only survives when its shape is unchanged (text to text, one choice to one choice).
133
+ if (field.default !== undefined && after.defaultKind !== null && after.defaultKind === before.defaultKind && after.bounds === before.bounds)
134
+ next.default = field.default;
135
+ return next;
136
+ }
137
+ /** Local React keys for option rows; same module-counter pattern as `rules.ts`'s attribute rows. */
138
+ let nextOptionRowId = 0;
139
+ const text = (value) => (value === undefined || value === null ? "" : String(value));
140
+ /** Seed the draft from a stored field. @complexity O(o) in options. */
141
+ export function settingsFormFromField({ field }, _optional = {}) {
142
+ const defaults = field.default;
143
+ return {
144
+ placeholder: text(field.placeholder), helpText: text(field.helpText),
145
+ defaultText: typeof defaults === "boolean" || Array.isArray(defaults) ? "" : text(defaults),
146
+ defaultChecked: defaults === true || (field.checkboxValue !== undefined && defaults === field.checkboxValue),
147
+ defaultChoices: Array.isArray(defaults) ? [...defaults] : typeof defaults === "string" && fieldSettingsFor({ type: field.type }).options ? [defaults] : [],
148
+ minLength: text(field.minLength), maxLength: text(field.maxLength), pattern: text(field.pattern),
149
+ min: text(field.min), max: text(field.max), step: text(field.step),
150
+ options: (field.options ?? []).map((option) => ({ _rowId: nextOptionRowId++, value: option.value, label: option.label })),
151
+ accept: [...(field.accept ?? [])],
152
+ maxFileSizeMb: field.maxFileSize === undefined ? "" : String(Math.round((field.maxFileSize / 1024 / 1024) * 100) / 100),
153
+ };
154
+ }
155
+ /**
156
+ * Edit one option row. A value that still mirrors its label follows label edits, so typing labels
157
+ * is enough for most forms; an edited value stays put.
158
+ * @complexity O(o).
159
+ */
160
+ export function updateOptionRow({ rows, rowId, patch }, _optional = {}) {
161
+ return rows.map((row) => {
162
+ if (row._rowId !== rowId)
163
+ return row;
164
+ const followsLabel = patch.label !== undefined && patch.value === undefined && row.value === row.label;
165
+ return { ...row, ...patch, ...(followsLabel ? { value: patch.label } : {}) };
166
+ });
167
+ }
168
+ /** @complexity O(o). */
169
+ export function addOptionRow({ rows }, _optional = {}) {
170
+ return [...rows, { _rowId: nextOptionRowId++, value: "", label: "" }];
171
+ }
172
+ /** @complexity O(o). */
173
+ export function removeOptionRow({ rows, rowId }, _optional = {}) {
174
+ return rows.filter((row) => row._rowId !== rowId);
175
+ }
176
+ /** Swap a row with its neighbour; moving past either end is a no-op. @complexity O(o). */
177
+ export function moveOptionRow({ rows, rowId, offset }, _optional = {}) {
178
+ const from = rows.findIndex((row) => row._rowId === rowId);
179
+ const to = from + offset;
180
+ if (from < 0 || to < 0 || to >= rows.length)
181
+ return rows;
182
+ const next = [...rows];
183
+ [next[from], next[to]] = [next[to], next[from]];
184
+ return next;
185
+ }
186
+ /** Upload groups offered as checkboxes; values are accept entries the CMS recognizes. */
187
+ // The CMS widens `image/*`/`video/*` to its safe formats only; PDF is its one safe document type.
188
+ export const UPLOAD_CHOICES = [
189
+ { value: "image/*", label: "Images" },
190
+ { value: "video/*", label: "Videos" },
191
+ ...(Object.hasOwn(SAFE_FORM_FILE_TYPES, "application/pdf") ? [{ value: "application/pdf", label: "PDF" }] : []),
192
+ ];
193
+ export const MAX_UPLOAD_MB = MAX_FORM_FILE_BYTES / 1024 / 1024;
194
+ const isBlank = (value) => value.trim() === "";
195
+ function optionalNumber(value, error, { integer = false } = {}) {
196
+ if (isBlank(value))
197
+ return { ok: true, value: undefined };
198
+ const number = Number(value);
199
+ return Number.isFinite(number) && (!integer || (Number.isInteger(number) && number >= 0)) ? { ok: true, value: number } : { ok: false, error };
200
+ }
201
+ /** Text length, pattern and placeholder settings. */
202
+ function textPatch(form, settings) {
203
+ const patch = { placeholder: settings.placeholder && !isBlank(form.placeholder) ? form.placeholder : undefined, pattern: undefined, minLength: undefined };
204
+ if (settings.length) {
205
+ const min = optionalNumber(form.minLength, "Length must be a whole number.", { integer: true });
206
+ const max = optionalNumber(form.maxLength, "Length must be a whole number.", { integer: true });
207
+ if (!min.ok)
208
+ return min;
209
+ if (!max.ok)
210
+ return max;
211
+ if (min.value !== undefined && max.value !== undefined && min.value > max.value)
212
+ return { ok: false, error: "Minimum must not be above maximum." };
213
+ patch.minLength = min.value;
214
+ // The wire contract clears a text length with null; unsupported types omit the key entirely.
215
+ patch.maxLength = max.value ?? null;
216
+ }
217
+ if (settings.pattern && !isBlank(form.pattern)) {
218
+ // Browser-safe too: a `v`-flag-invalid pattern is silently ignored by browsers (2026-10-11).
219
+ if (!isBrowserSafeFieldPattern({ pattern: form.pattern }))
220
+ return { ok: false, error: "That pattern isn't supported." };
221
+ patch.pattern = form.pattern;
222
+ }
223
+ return { ok: true, value: patch };
224
+ }
225
+ /** Numeric or date/time bounds, plus a numeric step. Native date/time strings compare as text. */
226
+ function boundsPatch(form, settings) {
227
+ const patch = { min: undefined, max: undefined, step: undefined };
228
+ if (settings.bounds === "number") {
229
+ const min = optionalNumber(form.min, "Enter numbers for min, max and step.");
230
+ const max = optionalNumber(form.max, "Enter numbers for min, max and step.");
231
+ const step = optionalNumber(form.step, "Enter numbers for min, max and step.");
232
+ if (!min.ok)
233
+ return min;
234
+ if (!max.ok)
235
+ return max;
236
+ if (!step.ok)
237
+ return step;
238
+ if (step.value !== undefined && step.value <= 0)
239
+ return { ok: false, error: "Step must be above 0." };
240
+ if (min.value !== undefined && max.value !== undefined && min.value > max.value)
241
+ return { ok: false, error: "Minimum must not be above maximum." };
242
+ return { ok: true, value: { min: min.value, max: max.value, step: step.value } };
243
+ }
244
+ if (settings.bounds) {
245
+ const min = isBlank(form.min) ? undefined : form.min;
246
+ const max = isBlank(form.max) ? undefined : form.max;
247
+ if (min !== undefined && max !== undefined && min > max)
248
+ return { ok: false, error: "Minimum must not be above maximum." };
249
+ return { ok: true, value: { min, max } };
250
+ }
251
+ return { ok: true, value: patch };
252
+ }
253
+ /** Choice options: blank rows are skipped; the rest need a label and a unique value. */
254
+ function optionsPatch(form, settings) {
255
+ if (!settings.options)
256
+ return { ok: true, value: undefined };
257
+ const options = form.options.filter((row) => !(isBlank(row.value) && isBlank(row.label))).map((row) => ({ value: row.value.trim(), label: row.label.trim() }));
258
+ if (options.length === 0)
259
+ return { ok: false, error: "Add at least one option." };
260
+ if (options.some((option) => !option.value || !option.label) || new Set(options.map((option) => option.value)).size !== options.length) {
261
+ return { ok: false, error: "Each option needs a label and a unique value." };
262
+ }
263
+ return { ok: true, value: options };
264
+ }
265
+ /** The default in the stored shape for its kind; choices drop values no longer offered. */
266
+ function defaultValue(form, settings, { options, checkboxValue }) {
267
+ const offered = new Set((options ?? []).map((option) => option.value));
268
+ switch (settings.defaultKind) {
269
+ // A box with an authored value submits that value, so its "checked" default is that value too.
270
+ case "boolean": return { ok: true, value: form.defaultChecked ? checkboxValue ?? true : undefined };
271
+ case "choice": return { ok: true, value: form.defaultChoices.find((value) => offered.has(value)) };
272
+ case "choices": {
273
+ const chosen = form.defaultChoices.filter((value) => offered.has(value));
274
+ return { ok: true, value: chosen.length ? chosen : undefined };
275
+ }
276
+ case "number": return optionalNumber(form.defaultText, "Default must be a number.");
277
+ case "text":
278
+ case "textarea":
279
+ case "temporal": return { ok: true, value: isBlank(form.defaultText) ? undefined : form.defaultText };
280
+ default: return { ok: true, value: undefined };
281
+ }
282
+ }
283
+ /** Upload limits: no boxes ticked means every safe type; size is whole megabytes up to the cap. */
284
+ function uploadPatch(form, settings) {
285
+ if (!settings.upload)
286
+ return { ok: true, value: {} };
287
+ const size = optionalNumber(form.maxFileSizeMb, "Max size must be up to 10 MB.");
288
+ if (!size.ok)
289
+ return size;
290
+ if (size.value !== undefined && (size.value <= 0 || size.value > MAX_UPLOAD_MB))
291
+ return { ok: false, error: "Max size must be up to 10 MB." };
292
+ return { ok: true, value: {
293
+ accept: form.accept.length ? [...form.accept] : undefined,
294
+ maxFileSize: size.value === undefined ? undefined : Math.min(MAX_FORM_FILE_BYTES, Math.max(1, Math.round(size.value * 1024 * 1024))),
295
+ } };
296
+ }
297
+ /**
298
+ * Parse and check the dialog draft into a field patch. Settings the type does not support come out
299
+ * `undefined`, so Save also clears anything stale. Max length instead uses `null` when blank and
300
+ * is omitted for unsupported types; `retypeField` already drops it on a type change. First failure wins.
301
+ * @complexity O(o) in options.
302
+ */
303
+ export function buildFieldSettingsPatch({ form, field }, _optional = {}) {
304
+ const settings = fieldSettingsFor({ type: field.type });
305
+ const textual = textPatch(form, settings);
306
+ if (!textual.ok)
307
+ return textual;
308
+ const bounds = boundsPatch(form, settings);
309
+ if (!bounds.ok)
310
+ return bounds;
311
+ const options = optionsPatch(form, settings);
312
+ if (!options.ok)
313
+ return options;
314
+ const fallback = defaultValue(form, settings, { options: options.value, checkboxValue: field.checkboxValue });
315
+ if (!fallback.ok)
316
+ return fallback;
317
+ const upload = uploadPatch(form, settings);
318
+ if (!upload.ok)
319
+ return upload;
320
+ return { ok: true, patch: {
321
+ ...textual.value, ...bounds.value, ...upload.value,
322
+ helpText: settings.helpText && !isBlank(form.helpText) ? form.helpText : undefined,
323
+ options: options.value,
324
+ default: fallback.value,
325
+ } };
326
+ }
327
+ //# sourceMappingURL=field-settings.js.map
@@ -0,0 +1,42 @@
1
+ import type { AdminFormDefinition, AdminFormField } from "./models.js";
2
+ /**
3
+ * @file What "unsaved" means on the form editor, so Back can warn before it discards edits.
4
+ *
5
+ * Back used to discard an edited, unsaved form silently (Chrome check, 2026-10-11). The editor
6
+ * feeds these two drafts to `useDirtyGuard` (the post and widget editors' guard): it warns on
7
+ * reload/close, and its `isDirty` gates the in-app confirm modal on Back.
8
+ */
9
+ /** The parts of the editor a Save writes. `notify` has no UI here, so it is never a pending edit. */
10
+ export interface FormEditorDraft {
11
+ name: string;
12
+ slug: string;
13
+ fields: AdminFormField[];
14
+ mode: "builder" | "html";
15
+ /** Only counted in HTML mode: a Builder save never sends it, so a starter left behind by a
16
+ * Builder → HTML → Builder round trip is not an edit. */
17
+ html: string;
18
+ }
19
+ /**
20
+ * @param input The live editor values.
21
+ * @returns The comparable draft.
22
+ * @complexity O(1).
23
+ */
24
+ export declare function formEditorDraft(input: {
25
+ name: string;
26
+ slug: string;
27
+ fields: AdminFormField[];
28
+ mode: "builder" | "html";
29
+ html: string;
30
+ }): FormEditorDraft;
31
+ /**
32
+ * The draft the editor started from: the loaded form, or the blank new-form state.
33
+ * @param input.form The loaded form; `null` until it loads.
34
+ * @param input.isNew Whether this is the "new form" screen.
35
+ * @returns `null` while an existing form is still loading — nothing to lose yet, so never dirty.
36
+ * @complexity O(1).
37
+ */
38
+ export declare function formEditorBaseline(input: {
39
+ form: AdminFormDefinition | null;
40
+ isNew: boolean;
41
+ }): FormEditorDraft | null;
42
+ //# sourceMappingURL=form-editor-dirty.d.ts.map
@@ -0,0 +1,25 @@
1
+ import { blankField } from "./rules.js";
2
+ /**
3
+ * @param input The live editor values.
4
+ * @returns The comparable draft.
5
+ * @complexity O(1).
6
+ */
7
+ export function formEditorDraft(input) {
8
+ return { name: input.name, slug: input.slug, fields: input.fields, mode: input.mode, html: input.mode === "html" ? input.html : "" };
9
+ }
10
+ /**
11
+ * The draft the editor started from: the loaded form, or the blank new-form state.
12
+ * @param input.form The loaded form; `null` until it loads.
13
+ * @param input.isNew Whether this is the "new form" screen.
14
+ * @returns `null` while an existing form is still loading — nothing to lose yet, so never dirty.
15
+ * @complexity O(1).
16
+ */
17
+ export function formEditorBaseline(input) {
18
+ if (input.isNew)
19
+ return formEditorDraft({ name: "", slug: "", fields: [blankField()], mode: "builder", html: "" });
20
+ if (!input.form)
21
+ return null;
22
+ const mode = input.form.mode ?? "builder";
23
+ return formEditorDraft({ name: input.form.name, slug: input.form.slug, fields: input.form.fields, mode, html: input.form.html ?? "" });
24
+ }
25
+ //# sourceMappingURL=form-editor-dirty.js.map
@@ -68,6 +68,8 @@ export declare const formsModule: {
68
68
  readonly "Load more": "Load more";
69
69
  readonly "Loading\u2026": "Loading…";
70
70
  readonly "No submissions yet.": "No submissions yet.";
71
+ readonly Yes: "Yes";
72
+ readonly No: "No";
71
73
  readonly "Create form": "Create form";
72
74
  readonly Form: "Form";
73
75
  readonly "Configure a new form's fields.": "Configure a new form's fields.";
@@ -104,6 +106,76 @@ export declare const formsModule: {
104
106
  readonly "failed to load submission": "failed to load submission";
105
107
  readonly "delete failed": "delete failed";
106
108
  readonly "Unknown field": "Unknown field";
109
+ readonly "Short text": "Short text";
110
+ readonly Paragraph: "Paragraph";
111
+ readonly Email: "Email";
112
+ readonly Phone: "Phone";
113
+ readonly "Website address": "Website address";
114
+ readonly Number: "Number";
115
+ readonly Slider: "Slider";
116
+ readonly Date: "Date";
117
+ readonly Time: "Time";
118
+ readonly "Date and time": "Date and time";
119
+ readonly Dropdown: "Dropdown";
120
+ readonly "Multi-select list": "Multi-select list";
121
+ readonly "Radio buttons": "Radio buttons";
122
+ readonly Checkbox: "Checkbox";
123
+ readonly Checkboxes: "Checkboxes";
124
+ readonly "Toggle switch": "Toggle switch";
125
+ readonly "File upload": "File upload";
126
+ readonly "Hidden value": "Hidden value";
127
+ readonly Text: "Text";
128
+ readonly Choices: "Choices";
129
+ readonly "Numbers and dates": "Numbers and dates";
130
+ readonly Other: "Other";
131
+ readonly "Field settings \u2014": "Field settings —";
132
+ readonly "Settings for field \"{name}\"": "Settings for field \"{name}\"";
133
+ readonly "CSS classes and HTML attributes": "CSS classes and HTML attributes";
134
+ readonly Required: "Required";
135
+ readonly Settings: "Settings";
136
+ readonly Placeholder: "Placeholder";
137
+ readonly "Help text": "Help text";
138
+ readonly "Shown under the field and read out by screen readers.": "Shown under the field and read out by screen readers.";
139
+ readonly Options: "Options";
140
+ readonly "The label is what visitors see. The value is what the submission stores; it follows the label until you edit it.": "The label is what visitors see. The value is what the submission stores; it follows the label until you edit it.";
141
+ readonly "Option {n} label": "Option {n} label";
142
+ readonly "Option {n} value": "Option {n} value";
143
+ readonly "Option {n} selected by default": "Option {n} selected by default";
144
+ readonly "Move option {n} up": "Move option {n} up";
145
+ readonly "Move option {n} down": "Move option {n} down";
146
+ readonly "Remove option {n}": "Remove option {n}";
147
+ readonly "Add option": "Add option";
148
+ readonly Default: "Default";
149
+ readonly "Checked by default": "Checked by default";
150
+ readonly None: "None";
151
+ readonly Length: "Length";
152
+ readonly "Min length": "Min length";
153
+ readonly Min: "Min";
154
+ readonly Max: "Max";
155
+ readonly Pattern: "Pattern";
156
+ readonly "Optional. The whole answer must match it, e.g. [0-9]+ for digits only. Groups, | and repeating more than once are not supported.": "Optional. The whole answer must match it, e.g. [0-9]+ for digits only. Groups, | and repeating more than once are not supported.";
157
+ readonly Range: "Range";
158
+ readonly Step: "Step";
159
+ readonly Files: "Files";
160
+ readonly "Leave all unticked to accept every safe type: images, video and PDF.": "Leave all unticked to accept every safe type: images, video and PDF.";
161
+ readonly Images: "Images";
162
+ readonly Videos: "Videos";
163
+ readonly PDF: "PDF";
164
+ readonly "Max size (MB)": "Max size (MB)";
165
+ readonly "Length must be a whole number.": "Length must be a whole number.";
166
+ readonly "Minimum must not be above maximum.": "Minimum must not be above maximum.";
167
+ readonly "That pattern isn't supported.": "That pattern isn't supported.";
168
+ readonly "Enter numbers for min, max and step.": "Enter numbers for min, max and step.";
169
+ readonly "Step must be above 0.": "Step must be above 0.";
170
+ readonly "Add at least one option.": "Add at least one option.";
171
+ readonly "Each option needs a label and a unique value.": "Each option needs a label and a unique value.";
172
+ readonly "Default must be a number.": "Default must be a number.";
173
+ readonly "Max size must be up to 10 MB.": "Max size must be up to 10 MB.";
174
+ readonly "Discard unsaved changes?": "Discard unsaved changes?";
175
+ readonly "Your changes to this form have not been saved.": "Your changes to this form have not been saved.";
176
+ readonly "Discard changes": "Discard changes";
177
+ readonly "Keep editing": "Keep editing";
178
+ readonly "Classes are unrestricted, e.g. Tailwind md:col-span-2. Attribute names must be aria-*, data-* or a safe layout attribute; event handlers like onclick are rejected.": "Classes are unrestricted, e.g. Tailwind md:col-span-2. Attribute names must be aria-*, data-* or a safe layout attribute; event handlers like onclick are rejected.";
107
179
  };
108
180
  readonly pages: {
109
181
  readonly list: {