@jini-ai/admin 0.4.5 → 0.4.6

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 (96) hide show
  1. package/dist/agent-plugins/package-file-tree.d.ts +3 -1
  2. package/dist/agent-plugins/package-file-tree.js +7 -7
  3. package/dist/comments/controllers/comment-queue.controller.d.ts +5 -1
  4. package/dist/comments/controllers/comment-queue.controller.js +2 -2
  5. package/dist/comments/react/hooks/use-comment-queue.hooks.js +14 -3
  6. package/dist/consent/react/ConsentSettingsPanel.d.ts +27 -0
  7. package/dist/consent/react/ConsentSettingsPanel.js +24 -0
  8. package/dist/consent/react/index.d.ts +3 -0
  9. package/dist/consent/react/index.js +2 -0
  10. package/dist/consent/react/settings-panel.hooks.d.ts +19 -0
  11. package/dist/consent/react/settings-panel.hooks.js +30 -0
  12. package/dist/core/ports/forms.d.ts +23 -3
  13. package/dist/core/ports/forms.js +3 -3
  14. package/dist/core/ports/seo.d.ts +17 -0
  15. package/dist/forms/adapters/http.d.ts +5 -2
  16. package/dist/forms/adapters/http.js +7 -2
  17. package/dist/forms/controllers/field-attributes-dialog.controller.d.ts +59 -1
  18. package/dist/forms/controllers/field-attributes-dialog.controller.js +27 -2
  19. package/dist/forms/field-settings.d.ts +142 -0
  20. package/dist/forms/field-settings.js +327 -0
  21. package/dist/forms/form-editor-dirty.d.ts +42 -0
  22. package/dist/forms/form-editor-dirty.js +25 -0
  23. package/dist/forms/forms.module.d.ts +72 -0
  24. package/dist/forms/html-rules.d.ts +41 -3
  25. package/dist/forms/html-rules.js +118 -17
  26. package/dist/forms/messages.en.d.ts +72 -0
  27. package/dist/forms/messages.en.js +73 -0
  28. package/dist/forms/react/components/FieldAttributesDialog.js +20 -19
  29. package/dist/forms/react/components/FieldSettingsSection.d.ts +19 -0
  30. package/dist/forms/react/components/FieldSettingsSection.js +21 -0
  31. package/dist/forms/react/components/FormFieldsEditor.js +7 -8
  32. package/dist/forms/react/components/FormSubmissionDetail.js +5 -2
  33. package/dist/forms/react/hooks/forms-view.hooks.d.ts +75 -5
  34. package/dist/forms/react/hooks/forms-view.hooks.js +46 -2
  35. package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.d.ts +10 -0
  36. package/dist/forms/react/hooks/use-field-attributes-dialog.hooks.js +12 -2
  37. package/dist/forms/react/hooks/use-form-editor.hooks.d.ts +11 -0
  38. package/dist/forms/react/hooks/use-form-editor.hooks.js +25 -2
  39. package/dist/forms/react/hooks/use-form-fields-editor.hooks.d.ts +2 -0
  40. package/dist/forms/react/hooks/use-form-fields-editor.hooks.js +5 -1
  41. package/dist/forms/react/hooks/use-form-submission-detail.hooks.d.ts +4 -0
  42. package/dist/forms/react/hooks/use-form-submission-detail.hooks.js +1 -0
  43. package/dist/forms/react/index.d.ts +216 -0
  44. package/dist/forms/react/pages/FormEditor.js +3 -2
  45. package/dist/forms/rules.d.ts +10 -1
  46. package/dist/forms/rules.js +8 -1
  47. package/dist/media/controllers/library.controller.d.ts +4 -0
  48. package/dist/media/controllers/library.controller.js +13 -0
  49. package/dist/media/react/components/MediaPicker.js +2 -2
  50. package/dist/media/react/hooks/EditMediaPanel.hooks.js +4 -2
  51. package/dist/media/react/hooks/GallerySelection.hooks.d.ts +31 -0
  52. package/dist/media/react/hooks/GallerySelection.hooks.js +32 -0
  53. package/dist/media/react/hooks/LibraryTab.hooks.d.ts +4 -0
  54. package/dist/media/react/hooks/LibraryTab.hooks.js +9 -3
  55. package/dist/media/react/hooks/MediaCard.hooks.d.ts +2 -2
  56. package/dist/media/react/hooks/MediaGrid.hooks.d.ts +2 -2
  57. package/dist/media/react/hooks/MediaPage.hooks.d.ts +4 -0
  58. package/dist/media/react/hooks/MediaPage.hooks.js +10 -13
  59. package/dist/media/react/hooks/MediaPicker.hooks.js +5 -1
  60. package/dist/media/react/hooks/MediaQuery.hooks.d.ts +27 -0
  61. package/dist/media/react/hooks/MediaQuery.hooks.js +41 -0
  62. package/dist/media/react/hooks/ProvidersTab.hooks.js +21 -2
  63. package/dist/media/react/index.d.ts +1 -0
  64. package/dist/media/react/index.js +1 -0
  65. package/dist/react/components/InteractiveHtmlEditor/InteractiveHtmlEditor.js +5 -0
  66. package/dist/react/components/Sidebar.d.ts +6 -0
  67. package/dist/react/components/Sidebar.js +1 -0
  68. package/dist/react/entities/use-entity-edit.d.ts +1 -1
  69. package/dist/react/hooks/use-sidebar-rail.d.ts +10 -1
  70. package/dist/react/hooks/use-sidebar-rail.js +16 -3
  71. package/dist/seo/adapters/http.d.ts +11 -1
  72. package/dist/seo/adapters/http.js +11 -1
  73. package/dist/seo/controllers/entry-picker.controller.d.ts +2 -0
  74. package/dist/seo/controllers/entry-picker.controller.js +2 -0
  75. package/dist/seo/models.d.ts +8 -0
  76. package/dist/seo/react/components/MediaRefField.js +2 -2
  77. package/dist/seo/react/hooks/MediaRefField.hooks.d.ts +4 -2
  78. package/dist/seo/react/hooks/MediaRefField.hooks.js +8 -3
  79. package/dist/seo/react/hooks/SeoQuery.hooks.d.ts +42 -0
  80. package/dist/seo/react/hooks/SeoQuery.hooks.js +65 -0
  81. package/dist/seo/react/hooks/use-entry-picker.hooks.js +7 -9
  82. package/dist/seo/react/hooks/use-seo-entry-panel.hooks.js +9 -2
  83. package/dist/seo/react/hooks/use-seo.hooks.js +22 -4
  84. package/dist/seo/react/hooks/use-share-image-warning.hooks.d.ts +19 -0
  85. package/dist/seo/react/hooks/use-share-image-warning.hooks.js +79 -0
  86. package/dist/seo/react/hooks/use-sitemap-modal.hooks.js +5 -3
  87. package/dist/seo/react/pages/SeoPage.js +1 -1
  88. package/dist/seo/rules.d.ts +21 -2
  89. package/dist/seo/rules.js +44 -4
  90. package/dist/widgets/react/hooks/WidgetsQuery.hooks.d.ts +37 -0
  91. package/dist/widgets/react/hooks/WidgetsQuery.hooks.js +70 -0
  92. package/dist/widgets/react/hooks/use-widget-instance-editor.hooks.js +5 -3
  93. package/dist/widgets/react/hooks/use-widget-region-editor.hooks.js +3 -1
  94. package/dist/widgets/react/hooks/use-widget-regions.hooks.js +7 -3
  95. package/dist/widgets/react/hooks/use-widgets-library.hooks.js +7 -3
  96. package/package.json +20 -14
@@ -7,7 +7,7 @@ import { FormSubmissionDate } from "./FormSubmissionDate.js";
7
7
  import { formSubmissionRows } from '../../html-rules.js';
8
8
  export function FormSubmissionDetail({ formId, submissionId, onBack, onDeleted, useFormSubmissionDetailHook = useWiredFormSubmissionDetail, t, answerColumns = [], }, _optional = {}) {
9
9
  const formatDate = useFormSubmissionDate({});
10
- const { submission, error, confirmOpen, deleting, requestDelete, cancelDelete, confirmDelete } = useFormSubmissionDetailHook({
10
+ const { submission, error, confirmOpen, deleting, requestDelete, cancelDelete, confirmDelete, fileUrl } = useFormSubmissionDetailHook({
11
11
  formId,
12
12
  submissionId,
13
13
  onDeleted,
@@ -22,7 +22,10 @@ export function FormSubmissionDetail({ formId, submissionId, onBack, onDeleted,
22
22
  // button sat right on top of the table and Delete sat right underneath it. Same
23
23
  // flex-column-plus-gap idiom `.field-group` already uses elsewhere for vertical rhythm,
24
24
  // rather than one-off margins on each button.
25
- _jsxs("div", { className: "form-submission-detail", children: [_jsxs("button", { type: "button", className: "btn-secondary", onClick: onBack, "aria-label": t("Back to submissions"), ...agentHandle({ handle: "form-submission-back" }, { role: "link", label: "Back to this form's list of submissions" }), children: ["\u2190 ", t("Back")] }), error ? _jsx("div", { className: "notice error", children: error }) : null, _jsx("div", { className: "table-scroll", children: _jsx("table", { className: "list-table", children: _jsxs("tbody", { children: [_jsxs("tr", { children: [_jsx("th", { children: t("Submitted at") }), _jsx("td", { children: _jsx(FormSubmissionDate, { date: formatDate({ iso: submission.submittedAt }) }) })] }), _jsxs("tr", { children: [_jsx("th", { children: t("Source IP") }), _jsx("td", { children: submission.sourceIp })] }), formSubmissionRows({ data: submission.data, answerColumns }, { unknownFieldLabel: t("Unknown field") }).map(({ key, label, value }) => (_jsxs("tr", { children: [_jsx("th", { className: "jini-submission-field-label", children: label }), _jsx("td", { children: value })] }, key)))] }) }) }), _jsx("button", { type: "button", className: "btn-danger", disabled: deleting, onClick: requestDelete, ...agentHandle({ handle: "form-submission-delete" }, {
25
+ _jsxs("div", { className: "form-submission-detail", children: [_jsxs("button", { type: "button", className: "btn-secondary", onClick: onBack, "aria-label": t("Back to submissions"), ...agentHandle({ handle: "form-submission-back" }, { role: "link", label: "Back to this form's list of submissions" }), children: ["\u2190 ", t("Back")] }), error ? _jsx("div", { className: "notice error", children: error }) : null, _jsx("div", { className: "table-scroll", children: _jsx("table", { className: "list-table", children: _jsxs("tbody", { children: [_jsxs("tr", { children: [_jsx("th", { children: t("Submitted at") }), _jsx("td", { children: _jsx(FormSubmissionDate, { date: formatDate({ iso: submission.submittedAt }) }) })] }), _jsxs("tr", { children: [_jsx("th", { children: t("Source IP") }), _jsx("td", { children: submission.sourceIp })] }), formSubmissionRows({ data: submission.data, answerColumns }, { unknownFieldLabel: t("Unknown field") }).map(({ key, label, value, file }) => {
26
+ const href = file ? fileUrl(file) : null;
27
+ return (_jsxs("tr", { children: [_jsx("th", { className: "jini-submission-field-label", children: label }), _jsx("td", { children: href && file ? _jsx("a", { href: href, download: file.filename, ...agentHandle({ handle: `form-submission-file-${key}` }, { role: "link", label: `Download the uploaded file ${file.filename}` }), children: value }) : value })] }, key));
28
+ })] }) }) }), _jsx("button", { type: "button", className: "btn-danger", disabled: deleting, onClick: requestDelete, ...agentHandle({ handle: "form-submission-delete" }, {
26
29
  role: "button",
27
30
  label: "Move this submission to the trash. Opens a confirmation dialog first.",
28
31
  }), children: t("Delete submission") }), _jsx(ConfirmDialog, { open: confirmOpen, agentHandle: "form-submission-delete-confirm", title: t("Move to trash?"), body: _jsx("p", { children: t("It will disappear from this list. You can restore it from the Trash.") }), confirmLabel: t("Move to trash"), destructive: true, pending: deleting, onConfirm: confirmDelete, onCancel: cancelDelete })] }));
@@ -2,6 +2,7 @@ import type { ChangeEvent, MutableRefObject } from 'react';
2
2
  import { type FormDateLine } from '../../rules.js';
3
3
  import type { FormFieldsEditorController } from './use-form-fields-editor.hooks.js';
4
4
  import type { FieldAttributesDialogController } from './use-field-attributes-dialog.hooks.js';
5
+ import type { FieldSettingsForm } from '../../field-settings.js';
5
6
  import type { FormSubmissionsController } from './use-form-submissions.hooks.js';
6
7
  import type { FormEditorFieldsBodyProps } from '../components/FormEditorFieldsBody.js';
7
8
  import type { AdminFormDefinition, AdminFormField } from '../../models.js';
@@ -18,14 +19,14 @@ export declare function useFormsListView({ forms, t, formatFormDates, toggleStat
18
19
  dates: FormDateLine[];
19
20
  menuLabel: string;
20
21
  menuItems: import("../../../react/index.js").RowMenuItem[];
21
- id: string;
22
- name: string;
23
22
  html?: string | undefined;
24
23
  status: import("../../../core/index.js").FormDefinitionStatus;
25
- mode?: "builder" | "html" | undefined;
24
+ id: string;
25
+ slug: string;
26
26
  createdAt: string;
27
27
  updatedAt: string;
28
- slug: string;
28
+ name: string;
29
+ mode?: "builder" | "html" | undefined;
29
30
  workspaceId?: string;
30
31
  fields: AdminFormField[];
31
32
  notify: import("../../models.js").AdminFormNotify;
@@ -47,6 +48,14 @@ export declare function useFormFieldsView({ fields, existingFieldIds, controller
47
48
  base: string;
48
49
  isExisting: boolean;
49
50
  displayName: string;
51
+ typeGroups: {
52
+ group: import("../../field-settings.js").FieldTypeGroup;
53
+ types: Array<{
54
+ type: string;
55
+ label: string;
56
+ }>;
57
+ }[];
58
+ hasLength: boolean;
50
59
  onId: (e: ChangeEvent<HTMLInputElement>) => void;
51
60
  onLabel: (e: ChangeEvent<HTMLInputElement>) => void;
52
61
  onType: (e: ChangeEvent<HTMLSelectElement>) => void;
@@ -65,6 +74,7 @@ export declare function useFieldAttributesView({ field, fieldIndex, controller,
65
74
  }, _optional?: {}): {
66
75
  displayName: string;
67
76
  onClose: () => void;
77
+ hasStyling: boolean;
68
78
  onClassName: (e: ChangeEvent<HTMLInputElement>) => void;
69
79
  rows: {
70
80
  handle: string;
@@ -76,6 +86,66 @@ export declare function useFieldAttributesView({ field, fieldIndex, controller,
76
86
  value: string;
77
87
  }[];
78
88
  };
89
+ /** Bind the settings section's controls; which ones render comes from the field's registry kind. */
90
+ export declare function useFieldSettingsView({ field, controller, t }: {
91
+ field: AdminFormField;
92
+ controller: FieldAttributesDialogController;
93
+ t: (key: string) => string;
94
+ }, _optional?: {}): {
95
+ settings: import("../../field-settings.js").FieldSettings;
96
+ form: FieldSettingsForm;
97
+ boundsType: import("../../field-settings.js").FieldBounds;
98
+ boundsClass: string;
99
+ /** `<input type>` for a typed default, or `null` when the default is a box or a choice. */
100
+ defaultInputType: string | null;
101
+ defaultClass: string | undefined;
102
+ onPlaceholder: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
103
+ onHelpText: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
104
+ onDefaultText: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
105
+ onMinLength: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
106
+ onMaxLength: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
107
+ onPattern: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
108
+ onMin: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
109
+ onMax: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
110
+ onStep: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
111
+ onMaxFileSize: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
112
+ onDefaultChecked: (e: ChangeEvent<HTMLInputElement>) => void;
113
+ onDefaultChoice: (e: ChangeEvent<HTMLSelectElement>) => void;
114
+ addOption: () => void;
115
+ defaultChoices: {
116
+ key: number;
117
+ value: string;
118
+ label: string;
119
+ }[];
120
+ options: {
121
+ handle: string;
122
+ isFirst: boolean;
123
+ isLast: boolean;
124
+ isDefault: boolean;
125
+ labelAria: string;
126
+ valueAria: string;
127
+ defaultAria: string;
128
+ upAria: string;
129
+ downAria: string;
130
+ removeAria: string;
131
+ onLabel: (e: ChangeEvent<HTMLInputElement>) => void;
132
+ onValue: (e: ChangeEvent<HTMLInputElement>) => void;
133
+ onDefault: (e: ChangeEvent<HTMLInputElement>) => void;
134
+ onUp: () => void;
135
+ onDown: () => void;
136
+ onRemove: () => void;
137
+ _rowId: number;
138
+ value: string;
139
+ label: string;
140
+ }[];
141
+ uploads: {
142
+ checked: boolean;
143
+ handle: string;
144
+ onToggle: (e: ChangeEvent<HTMLInputElement>) => void;
145
+ value: string;
146
+ label: string;
147
+ }[];
148
+ };
79
149
  export declare function useSubmissionsView({ controller }: {
80
150
  controller: FormSubmissionsController;
81
151
  }, _optional?: {}): {
@@ -84,8 +154,8 @@ export declare function useSubmissionsView({ controller }: {
84
154
  rows: {
85
155
  handle: string;
86
156
  view: () => void;
87
- id: string;
88
157
  status?: "active" | "trash";
158
+ id: string;
89
159
  formDefinitionId: string;
90
160
  sourceIp: string;
91
161
  submittedAt: string;
@@ -1,5 +1,6 @@
1
1
  import { buildAgentListHandles } from '@jini-ai/agentic';
2
2
  import { fieldDisplayName, formRowMenuItems, FORM_TABS } from '../../rules.js';
3
+ import { fieldSettingsFor, fieldTypeGroups, labelChangePatch, retypeField, UPLOAD_CHOICES } from '../../field-settings.js';
3
4
  import { useFormsOptions, FormsPortsContext } from './FormsPorts.hooks.js';
4
5
  import { useContext } from 'react';
5
6
  /** Publication actions keep their original DOM at the host slot. */
@@ -32,9 +33,11 @@ export function useFormFieldsView({ fields, existingFieldIds, controller }, _opt
32
33
  controller.closeAttrsDialog();
33
34
  },
34
35
  rows: fields.map((field, index) => ({ field, index, base: handles[index], isExisting: existingFieldIds.includes(field.id), displayName: fieldDisplayName({ field, index }),
36
+ // Registry-driven: the type picker's groups and whether this type has a text length.
37
+ typeGroups: fieldTypeGroups({ current: field.type }), hasLength: fieldSettingsFor({ type: field.type }).length,
35
38
  onId: (e) => controller.updateField(index, { id: e.target.value }),
36
- onLabel: (e) => controller.updateField(index, { label: e.target.value }),
37
- onType: (e) => controller.updateField(index, { type: e.target.value }),
39
+ onLabel: (e) => controller.updateField(index, labelChangePatch({ field, label: e.target.value, isExisting: existingFieldIds.includes(field.id) })),
40
+ onType: (e) => controller.replaceField(index, retypeField({ field, type: e.target.value })),
38
41
  onRequired: (e) => controller.updateField(index, { required: e.target.checked }),
39
42
  onMaxLength: (e) => controller.updateField(index, { maxLength: e.target.value ? Number(e.target.value) : null }),
40
43
  ref: (el) => { controller.kebabRefs.current[index] = el; },
@@ -45,6 +48,7 @@ export function useFieldAttributesView({ field, fieldIndex, controller, onCancel
45
48
  const handles = buildAgentListHandles({ prefix: 'form-field-attrs-row', ids: controller.rows.map(row => String(row._rowId)) });
46
49
  return {
47
50
  displayName: fieldDisplayName({ field, index: fieldIndex }), onClose: () => onCancel(),
51
+ hasStyling: Boolean(field.className) || Object.keys(field.attributes ?? {}).length > 0,
48
52
  onClassName: (e) => controller.setClassName(e.target.value),
49
53
  rows: controller.rows.map((row, index) => ({ ...row, handle: handles[index],
50
54
  onName: (e) => controller.updateRow(row._rowId, { name: e.target.value }),
@@ -52,6 +56,46 @@ export function useFieldAttributesView({ field, fieldIndex, controller, onCancel
52
56
  onRemove: () => controller.removeRow(row._rowId), })),
53
57
  };
54
58
  }
59
+ /** Bind the settings section's controls; which ones render comes from the field's registry kind. */
60
+ export function useFieldSettingsView({ field, controller, t }, _optional = {}) {
61
+ const settings = fieldSettingsFor({ type: field.type });
62
+ const form = controller.settings;
63
+ const value = (key) => (e) => controller.setSettings({ [key]: e.target.value });
64
+ const optionHandles = buildAgentListHandles({ prefix: 'form-field-option', ids: form.options.map(row => String(row._rowId)) });
65
+ const numbered = (key, n) => t(key).replace('{n}', String(n));
66
+ const kind = settings.defaultKind;
67
+ const boundsType = settings.bounds ?? 'number';
68
+ return {
69
+ settings, form, boundsType,
70
+ boundsClass: settings.bounds === 'number' ? 'field-settings-num' : 'field-settings-compact',
71
+ /** `<input type>` for a typed default, or `null` when the default is a box or a choice. */
72
+ defaultInputType: kind === 'temporal' ? boundsType : kind === 'number' ? 'number' : kind === 'text' || kind === 'textarea' ? 'text' : null,
73
+ defaultClass: kind === 'number' || kind === 'temporal' ? 'field-settings-compact' : undefined,
74
+ onPlaceholder: value('placeholder'), onHelpText: value('helpText'), onDefaultText: value('defaultText'),
75
+ onMinLength: value('minLength'), onMaxLength: value('maxLength'), onPattern: value('pattern'),
76
+ onMin: value('min'), onMax: value('max'), onStep: value('step'), onMaxFileSize: value('maxFileSizeMb'),
77
+ onDefaultChecked: (e) => controller.setSettings({ defaultChecked: e.target.checked }),
78
+ onDefaultChoice: (e) => controller.toggleDefaultChoice(e.target.value, true, true),
79
+ addOption: () => controller.addOption(),
80
+ defaultChoices: form.options.filter(row => row.value).map(row => ({ key: row._rowId, value: row.value, label: row.label || row.value })),
81
+ options: form.options.map((row, index) => ({ ...row, handle: optionHandles[index],
82
+ isFirst: index === 0, isLast: index === form.options.length - 1,
83
+ // Multi-choice defaults are ticked per option; a single default uses one select instead.
84
+ isDefault: form.defaultChoices.includes(row.value),
85
+ labelAria: numbered('Option {n} label', index + 1), valueAria: numbered('Option {n} value', index + 1),
86
+ defaultAria: numbered('Option {n} selected by default', index + 1),
87
+ upAria: numbered('Move option {n} up', index + 1), downAria: numbered('Move option {n} down', index + 1),
88
+ removeAria: numbered('Remove option {n}', index + 1),
89
+ onLabel: (e) => controller.updateOption(row._rowId, { label: e.target.value }),
90
+ onValue: (e) => controller.updateOption(row._rowId, { value: e.target.value }),
91
+ onDefault: (e) => controller.toggleDefaultChoice(row.value, e.target.checked, false),
92
+ onUp: () => controller.moveOption(row._rowId, -1), onDown: () => controller.moveOption(row._rowId, 1),
93
+ onRemove: () => controller.removeOption(row._rowId), })),
94
+ uploads: UPLOAD_CHOICES.map(choice => ({ ...choice, checked: form.accept.includes(choice.value),
95
+ handle: `form-field-settings-accept-${choice.label.toLowerCase()}`,
96
+ onToggle: (e) => controller.toggleAccept(choice.value, e.target.checked) })),
97
+ };
98
+ }
55
99
  export function useSubmissionsView({ controller }, _optional = {}) {
56
100
  const handles = buildAgentListHandles({ prefix: 'form-submission-view', ids: (controller.submissions ?? []).map(submission => submission.id) });
57
101
  return {
@@ -1,5 +1,6 @@
1
1
  import type { AdminFormField } from "../../models.js";
2
2
  import { type AttrRow } from "../../rules.js";
3
+ import { type FieldSettingsForm, type OptionRow } from "../../field-settings.js";
3
4
  /**
4
5
  * @file `FieldAttributesDialog`'s own state and submit action (per-field CSS classes + HTML
5
6
  * attributes), so the dialog in `FormEditor.tsx` is only markup.
@@ -20,6 +21,15 @@ export interface FieldAttributesDialogController {
20
21
  updateRow: (rowId: number, patch: Partial<AttrRow>) => void;
21
22
  removeRow: (rowId: number) => void;
22
23
  addRow: () => void;
24
+ /** The field type's builder settings draft (`field-settings.ts`). */
25
+ settings: FieldSettingsForm;
26
+ setSettings: (patch: Partial<FieldSettingsForm>) => void;
27
+ toggleDefaultChoice: (value: string, on: boolean, single: boolean) => void;
28
+ toggleAccept: (value: string, on: boolean) => void;
29
+ updateOption: (rowId: number, patch: Partial<Pick<OptionRow, "value" | "label">>) => void;
30
+ addOption: () => void;
31
+ removeOption: (rowId: number) => void;
32
+ moveOption: (rowId: number, offset: -1 | 1) => void;
23
33
  error: string | null;
24
34
  submit: (e: React.FormEvent) => void;
25
35
  }
@@ -1,9 +1,10 @@
1
1
  import { useController } from "../../../core/react/use-controller.js";
2
2
  import { createFieldAttributesDialogController } from "../../controllers/field-attributes-dialog.controller.js";
3
3
  import {} from "../../rules.js";
4
+ import { settingsFormFromField } from "../../field-settings.js";
4
5
  export function useFieldAttributesDialog(props, _optional = {}) {
5
6
  const { controller: stateController, snapshot: stateSnapshot } = useController({ create: () => createFieldAttributesDialogController({ field: props.field }), dependencies: [] });
6
- const state = stateSnapshot ?? { className: props.field.className ?? "", rows: [], error: null };
7
+ const state = stateSnapshot ?? { className: props.field.className ?? "", rows: [], settings: settingsFormFromField({ field: props.field }), error: null };
7
8
  const className = state.className;
8
9
  const setClassName = (value) => stateController?.update({ key: "className", value });
9
10
  const rows = state.rows;
@@ -11,12 +12,21 @@ export function useFieldAttributesDialog(props, _optional = {}) {
11
12
  function updateRow(rowId, patch) { stateController?.updateRow({ rowId, patch }); }
12
13
  function removeRow(rowId) { stateController?.removeRow({ rowId }); }
13
14
  function addRow() { stateController?.addRow({}); }
15
+ const settingsActions = {
16
+ setSettings: (patch) => stateController?.setSettings({ patch }),
17
+ toggleDefaultChoice: (value, on, single) => stateController?.toggleDefaultChoice({ value, on, single }),
18
+ toggleAccept: (value, on) => stateController?.toggleAccept({ value, on }),
19
+ updateOption: (rowId, patch) => stateController?.updateOption({ rowId, patch }),
20
+ addOption: () => stateController?.addOption({}),
21
+ removeOption: (rowId) => stateController?.removeOption({ rowId }),
22
+ moveOption: (rowId, offset) => stateController?.moveOption({ rowId, offset }),
23
+ };
14
24
  function submit(e) {
15
25
  e.preventDefault();
16
26
  const result = stateController?.submit({});
17
27
  if (result?.ok)
18
28
  props.onSave(result.patch);
19
29
  }
20
- return { className, setClassName, rows, updateRow, removeRow, addRow, error, submit };
30
+ return { className, setClassName, rows, updateRow, removeRow, addRow, settings: state.settings, ...settingsActions, error, submit };
21
31
  }
22
32
  //# sourceMappingURL=use-field-attributes-dialog.hooks.js.map
@@ -96,6 +96,17 @@ export interface FormEditorController {
96
96
  onTabsKeyDown: (e: React.KeyboardEvent) => void;
97
97
  handleSave: () => void;
98
98
  handleStatusToggle: () => void;
99
+ /** Back with unsaved edits opens an app confirm modal instead of discarding them silently
100
+ * (Chrome check, 2026-10-11). `onBack` routes through the injected navigation port;
101
+ * only a dirty draft requires confirmation. */
102
+ leave: {
103
+ confirmOpen: boolean;
104
+ onBack: (event: {
105
+ preventDefault: () => void;
106
+ }) => void;
107
+ confirm: () => void;
108
+ cancel: () => void;
109
+ };
99
110
  /** Bound translator — see this file's own header for why it arrives via the hook rather than
100
111
  * `FormEditor.tsx` calling `useAdminLocale()`/`FORMS_DICT` directly. */
101
112
  t: (key: string) => string;
@@ -3,7 +3,9 @@ import { useFormsPorts, useFormsOptions } from "./FormsPorts.hooks.js";
3
3
  import { useController } from "../../../core/react/use-controller.js";
4
4
  import { createFormEditorController, initialFormEditorState } from "../../controllers/form-editor.controller.js";
5
5
  import { formHtmlEmbed, formAnswerColumns } from "../../html-rules.js";
6
- import { useEffect, useRef } from "react";
6
+ import { useEffect, useRef, useState } from "react";
7
+ import { useDirtyGuard } from "@jini-ai/ui/panel-kit";
8
+ import { formEditorBaseline, formEditorDraft } from "../../form-editor-dirty.js";
7
9
  import { useFetchMutation, useFetchQuery } from "@jini-ai/ui/fetch-query";
8
10
  import { FORM_TABS, KEYS, existingFieldIdsOf, nextTabIndex, visibleFormEditorError } from "../../rules.js";
9
11
  export function useFormEditor(props, { clipboard } = {}) {
@@ -145,6 +147,26 @@ export function useFormEditor(props, { clipboard } = {}) {
145
147
  // already surfaced through statusMutation.error -> error below
146
148
  }
147
149
  }
150
+ // Same guard as the post and widget editors (reload/close prompt included); the in-app Back
151
+ // gets the admin's confirm modal rather than `confirmLeave`'s `window.confirm`.
152
+ const { isDirty } = useDirtyGuard({ current: formEditorDraft({ name, slug, fields, mode, html }), original: formEditorBaseline({ form, isNew }) });
153
+ const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
154
+ const leave = {
155
+ confirmOpen: leaveConfirmOpen,
156
+ onBack(event) {
157
+ event.preventDefault();
158
+ if (!isDirty) {
159
+ navigate("/forms");
160
+ return;
161
+ }
162
+ setLeaveConfirmOpen(true);
163
+ },
164
+ confirm() {
165
+ setLeaveConfirmOpen(false);
166
+ navigate("/forms");
167
+ },
168
+ cancel: () => setLeaveConfirmOpen(false),
169
+ };
148
170
  const saving = updateMutation.status === "pending" || createMutation.status === "pending" || statusMutation.status === "pending";
149
171
  const error = visibleFormEditorError({
150
172
  updateError: updateMutation.error,
@@ -175,7 +197,7 @@ export function useFormEditor(props, { clipboard } = {}) {
175
197
  isNew,
176
198
  form,
177
199
  mode, onModeChange, html, setHtml, copyEmbed, copyFeedback,
178
- answerColumns: formAnswerColumns({ fields: form?.fields ?? [] }),
200
+ answerColumns: formAnswerColumns({ fields: form?.fields ?? [] }, { yes: t("Yes"), no: t("No") }),
179
201
  name,
180
202
  setName,
181
203
  slug,
@@ -192,6 +214,7 @@ export function useFormEditor(props, { clipboard } = {}) {
192
214
  onTabsKeyDown,
193
215
  handleSave,
194
216
  handleStatusToggle,
217
+ leave,
195
218
  t,
196
219
  };
197
220
  }
@@ -24,6 +24,8 @@ export interface FormFieldsEditorController {
24
24
  * callback in the view. */
25
25
  kebabRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>;
26
26
  updateField: (index: number, patch: Partial<AdminFormField>) => void;
27
+ /** Replaces the whole field, so keys absent from `field` are dropped (a type change). */
28
+ replaceField: (index: number, field: AdminFormField) => void;
27
29
  addField: () => void;
28
30
  removeField: (index: number) => void;
29
31
  }
@@ -1,7 +1,7 @@
1
1
  import { useController } from "../../../core/react/use-controller.js";
2
2
  import { createFormFieldsEditorController, initialFormFieldsEditorState } from "../../controllers/form-fields-editor.controller.js";
3
3
  import { useRef } from "react";
4
- import { addFormField, removeFormField, updateFormField } from "../../rules.js";
4
+ import { addFormField, removeFormField, replaceFormField, updateFormField } from "../../rules.js";
5
5
  export function useFormFieldsEditor(props, _optional = {}) {
6
6
  const { controller: stateController, snapshot: stateSnapshot } = useController({ create: () => createFormFieldsEditorController({}), dependencies: [] });
7
7
  const state = stateSnapshot ?? initialFormFieldsEditorState({});
@@ -17,6 +17,9 @@ export function useFormFieldsEditor(props, _optional = {}) {
17
17
  function updateField(index, patch) {
18
18
  props.onChange(updateFormField({ fields: props.fields, index, patch }));
19
19
  }
20
+ function replaceField(index, field) {
21
+ props.onChange(replaceFormField({ fields: props.fields, index, field }));
22
+ }
20
23
  function addField() {
21
24
  props.onChange(addFormField({ fields: props.fields }));
22
25
  }
@@ -29,6 +32,7 @@ export function useFormFieldsEditor(props, _optional = {}) {
29
32
  closeAttrsDialog,
30
33
  kebabRefs,
31
34
  updateField,
35
+ replaceField,
32
36
  addField,
33
37
  removeField,
34
38
  };
@@ -48,6 +48,10 @@ export interface FormSubmissionDetailController {
48
48
  * either way, so a failed delete doesn't leave the operator stuck behind it — the failure is
49
49
  * still visible via `error` below. */
50
50
  confirmDelete: () => Promise<void>;
51
+ /** Download URL for a file answer, or `null` when the host wired no media path. */
52
+ fileUrl: (file: {
53
+ mediaId: string;
54
+ }) => string | null;
51
55
  }
52
56
  export declare function useFormSubmissionDetail(props: {
53
57
  port: AdminFormsPort;
@@ -71,6 +71,7 @@ export function useFormSubmissionDetail(props, _optional = {}) {
71
71
  requestDelete,
72
72
  cancelDelete,
73
73
  confirmDelete,
74
+ fileUrl: ({ mediaId }) => port.submissionFileUrl?.({ mediaId }) ?? null,
74
75
  };
75
76
  }
76
77
  export function useWiredFormSubmissionDetail(props, _optional = {}) {