@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
@@ -84,6 +84,8 @@ export declare function forms(_required: Record<string, never>, options?: FormsR
84
84
  readonly "Load more": "Load more";
85
85
  readonly "Loading\u2026": "Loading…";
86
86
  readonly "No submissions yet.": "No submissions yet.";
87
+ readonly Yes: "Yes";
88
+ readonly No: "No";
87
89
  readonly "Create form": "Create form";
88
90
  readonly Form: "Form";
89
91
  readonly "Configure a new form's fields.": "Configure a new form's fields.";
@@ -120,6 +122,76 @@ export declare function forms(_required: Record<string, never>, options?: FormsR
120
122
  readonly "failed to load submission": "failed to load submission";
121
123
  readonly "delete failed": "delete failed";
122
124
  readonly "Unknown field": "Unknown field";
125
+ readonly "Short text": "Short text";
126
+ readonly Paragraph: "Paragraph";
127
+ readonly Email: "Email";
128
+ readonly Phone: "Phone";
129
+ readonly "Website address": "Website address";
130
+ readonly Number: "Number";
131
+ readonly Slider: "Slider";
132
+ readonly Date: "Date";
133
+ readonly Time: "Time";
134
+ readonly "Date and time": "Date and time";
135
+ readonly Dropdown: "Dropdown";
136
+ readonly "Multi-select list": "Multi-select list";
137
+ readonly "Radio buttons": "Radio buttons";
138
+ readonly Checkbox: "Checkbox";
139
+ readonly Checkboxes: "Checkboxes";
140
+ readonly "Toggle switch": "Toggle switch";
141
+ readonly "File upload": "File upload";
142
+ readonly "Hidden value": "Hidden value";
143
+ readonly Text: "Text";
144
+ readonly Choices: "Choices";
145
+ readonly "Numbers and dates": "Numbers and dates";
146
+ readonly Other: "Other";
147
+ readonly "Field settings \u2014": "Field settings —";
148
+ readonly "Settings for field \"{name}\"": "Settings for field \"{name}\"";
149
+ readonly "CSS classes and HTML attributes": "CSS classes and HTML attributes";
150
+ readonly Required: "Required";
151
+ readonly Settings: "Settings";
152
+ readonly Placeholder: "Placeholder";
153
+ readonly "Help text": "Help text";
154
+ readonly "Shown under the field and read out by screen readers.": "Shown under the field and read out by screen readers.";
155
+ readonly Options: "Options";
156
+ 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.";
157
+ readonly "Option {n} label": "Option {n} label";
158
+ readonly "Option {n} value": "Option {n} value";
159
+ readonly "Option {n} selected by default": "Option {n} selected by default";
160
+ readonly "Move option {n} up": "Move option {n} up";
161
+ readonly "Move option {n} down": "Move option {n} down";
162
+ readonly "Remove option {n}": "Remove option {n}";
163
+ readonly "Add option": "Add option";
164
+ readonly Default: "Default";
165
+ readonly "Checked by default": "Checked by default";
166
+ readonly None: "None";
167
+ readonly Length: "Length";
168
+ readonly "Min length": "Min length";
169
+ readonly Min: "Min";
170
+ readonly Max: "Max";
171
+ readonly Pattern: "Pattern";
172
+ 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.";
173
+ readonly Range: "Range";
174
+ readonly Step: "Step";
175
+ readonly Files: "Files";
176
+ 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.";
177
+ readonly Images: "Images";
178
+ readonly Videos: "Videos";
179
+ readonly PDF: "PDF";
180
+ readonly "Max size (MB)": "Max size (MB)";
181
+ readonly "Length must be a whole number.": "Length must be a whole number.";
182
+ readonly "Minimum must not be above maximum.": "Minimum must not be above maximum.";
183
+ readonly "That pattern isn't supported.": "That pattern isn't supported.";
184
+ readonly "Enter numbers for min, max and step.": "Enter numbers for min, max and step.";
185
+ readonly "Step must be above 0.": "Step must be above 0.";
186
+ readonly "Add at least one option.": "Add at least one option.";
187
+ readonly "Each option needs a label and a unique value.": "Each option needs a label and a unique value.";
188
+ readonly "Default must be a number.": "Default must be a number.";
189
+ readonly "Max size must be up to 10 MB.": "Max size must be up to 10 MB.";
190
+ readonly "Discard unsaved changes?": "Discard unsaved changes?";
191
+ readonly "Your changes to this form have not been saved.": "Your changes to this form have not been saved.";
192
+ readonly "Discard changes": "Discard changes";
193
+ readonly "Keep editing": "Keep editing";
194
+ 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.";
123
195
  };
124
196
  pages: {
125
197
  readonly list: {
@@ -230,6 +302,8 @@ export declare function forms(_required: Record<string, never>, options?: FormsR
230
302
  readonly "Load more": "Load more";
231
303
  readonly "Loading\u2026": "Loading…";
232
304
  readonly "No submissions yet.": "No submissions yet.";
305
+ readonly Yes: "Yes";
306
+ readonly No: "No";
233
307
  readonly "Create form": "Create form";
234
308
  readonly Form: "Form";
235
309
  readonly "Configure a new form's fields.": "Configure a new form's fields.";
@@ -266,6 +340,76 @@ export declare function forms(_required: Record<string, never>, options?: FormsR
266
340
  readonly "failed to load submission": "failed to load submission";
267
341
  readonly "delete failed": "delete failed";
268
342
  readonly "Unknown field": "Unknown field";
343
+ readonly "Short text": "Short text";
344
+ readonly Paragraph: "Paragraph";
345
+ readonly Email: "Email";
346
+ readonly Phone: "Phone";
347
+ readonly "Website address": "Website address";
348
+ readonly Number: "Number";
349
+ readonly Slider: "Slider";
350
+ readonly Date: "Date";
351
+ readonly Time: "Time";
352
+ readonly "Date and time": "Date and time";
353
+ readonly Dropdown: "Dropdown";
354
+ readonly "Multi-select list": "Multi-select list";
355
+ readonly "Radio buttons": "Radio buttons";
356
+ readonly Checkbox: "Checkbox";
357
+ readonly Checkboxes: "Checkboxes";
358
+ readonly "Toggle switch": "Toggle switch";
359
+ readonly "File upload": "File upload";
360
+ readonly "Hidden value": "Hidden value";
361
+ readonly Text: "Text";
362
+ readonly Choices: "Choices";
363
+ readonly "Numbers and dates": "Numbers and dates";
364
+ readonly Other: "Other";
365
+ readonly "Field settings \u2014": "Field settings —";
366
+ readonly "Settings for field \"{name}\"": "Settings for field \"{name}\"";
367
+ readonly "CSS classes and HTML attributes": "CSS classes and HTML attributes";
368
+ readonly Required: "Required";
369
+ readonly Settings: "Settings";
370
+ readonly Placeholder: "Placeholder";
371
+ readonly "Help text": "Help text";
372
+ readonly "Shown under the field and read out by screen readers.": "Shown under the field and read out by screen readers.";
373
+ readonly Options: "Options";
374
+ 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.";
375
+ readonly "Option {n} label": "Option {n} label";
376
+ readonly "Option {n} value": "Option {n} value";
377
+ readonly "Option {n} selected by default": "Option {n} selected by default";
378
+ readonly "Move option {n} up": "Move option {n} up";
379
+ readonly "Move option {n} down": "Move option {n} down";
380
+ readonly "Remove option {n}": "Remove option {n}";
381
+ readonly "Add option": "Add option";
382
+ readonly Default: "Default";
383
+ readonly "Checked by default": "Checked by default";
384
+ readonly None: "None";
385
+ readonly Length: "Length";
386
+ readonly "Min length": "Min length";
387
+ readonly Min: "Min";
388
+ readonly Max: "Max";
389
+ readonly Pattern: "Pattern";
390
+ 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.";
391
+ readonly Range: "Range";
392
+ readonly Step: "Step";
393
+ readonly Files: "Files";
394
+ 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.";
395
+ readonly Images: "Images";
396
+ readonly Videos: "Videos";
397
+ readonly PDF: "PDF";
398
+ readonly "Max size (MB)": "Max size (MB)";
399
+ readonly "Length must be a whole number.": "Length must be a whole number.";
400
+ readonly "Minimum must not be above maximum.": "Minimum must not be above maximum.";
401
+ readonly "That pattern isn't supported.": "That pattern isn't supported.";
402
+ readonly "Enter numbers for min, max and step.": "Enter numbers for min, max and step.";
403
+ readonly "Step must be above 0.": "Step must be above 0.";
404
+ readonly "Add at least one option.": "Add at least one option.";
405
+ readonly "Each option needs a label and a unique value.": "Each option needs a label and a unique value.";
406
+ readonly "Default must be a number.": "Default must be a number.";
407
+ readonly "Max size must be up to 10 MB.": "Max size must be up to 10 MB.";
408
+ readonly "Discard unsaved changes?": "Discard unsaved changes?";
409
+ readonly "Your changes to this form have not been saved.": "Your changes to this form have not been saved.";
410
+ readonly "Discard changes": "Discard changes";
411
+ readonly "Keep editing": "Keep editing";
412
+ 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.";
269
413
  };
270
414
  readonly pages: {
271
415
  readonly list: {
@@ -380,6 +524,8 @@ export declare function forms(_required: Record<string, never>, options?: FormsR
380
524
  readonly "Load more": "Load more";
381
525
  readonly "Loading\u2026": "Loading…";
382
526
  readonly "No submissions yet.": "No submissions yet.";
527
+ readonly Yes: "Yes";
528
+ readonly No: "No";
383
529
  readonly "Create form": "Create form";
384
530
  readonly Form: "Form";
385
531
  readonly "Configure a new form's fields.": "Configure a new form's fields.";
@@ -416,6 +562,76 @@ export declare function forms(_required: Record<string, never>, options?: FormsR
416
562
  readonly "failed to load submission": "failed to load submission";
417
563
  readonly "delete failed": "delete failed";
418
564
  readonly "Unknown field": "Unknown field";
565
+ readonly "Short text": "Short text";
566
+ readonly Paragraph: "Paragraph";
567
+ readonly Email: "Email";
568
+ readonly Phone: "Phone";
569
+ readonly "Website address": "Website address";
570
+ readonly Number: "Number";
571
+ readonly Slider: "Slider";
572
+ readonly Date: "Date";
573
+ readonly Time: "Time";
574
+ readonly "Date and time": "Date and time";
575
+ readonly Dropdown: "Dropdown";
576
+ readonly "Multi-select list": "Multi-select list";
577
+ readonly "Radio buttons": "Radio buttons";
578
+ readonly Checkbox: "Checkbox";
579
+ readonly Checkboxes: "Checkboxes";
580
+ readonly "Toggle switch": "Toggle switch";
581
+ readonly "File upload": "File upload";
582
+ readonly "Hidden value": "Hidden value";
583
+ readonly Text: "Text";
584
+ readonly Choices: "Choices";
585
+ readonly "Numbers and dates": "Numbers and dates";
586
+ readonly Other: "Other";
587
+ readonly "Field settings \u2014": "Field settings —";
588
+ readonly "Settings for field \"{name}\"": "Settings for field \"{name}\"";
589
+ readonly "CSS classes and HTML attributes": "CSS classes and HTML attributes";
590
+ readonly Required: "Required";
591
+ readonly Settings: "Settings";
592
+ readonly Placeholder: "Placeholder";
593
+ readonly "Help text": "Help text";
594
+ readonly "Shown under the field and read out by screen readers.": "Shown under the field and read out by screen readers.";
595
+ readonly Options: "Options";
596
+ 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.";
597
+ readonly "Option {n} label": "Option {n} label";
598
+ readonly "Option {n} value": "Option {n} value";
599
+ readonly "Option {n} selected by default": "Option {n} selected by default";
600
+ readonly "Move option {n} up": "Move option {n} up";
601
+ readonly "Move option {n} down": "Move option {n} down";
602
+ readonly "Remove option {n}": "Remove option {n}";
603
+ readonly "Add option": "Add option";
604
+ readonly Default: "Default";
605
+ readonly "Checked by default": "Checked by default";
606
+ readonly None: "None";
607
+ readonly Length: "Length";
608
+ readonly "Min length": "Min length";
609
+ readonly Min: "Min";
610
+ readonly Max: "Max";
611
+ readonly Pattern: "Pattern";
612
+ 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.";
613
+ readonly Range: "Range";
614
+ readonly Step: "Step";
615
+ readonly Files: "Files";
616
+ 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.";
617
+ readonly Images: "Images";
618
+ readonly Videos: "Videos";
619
+ readonly PDF: "PDF";
620
+ readonly "Max size (MB)": "Max size (MB)";
621
+ readonly "Length must be a whole number.": "Length must be a whole number.";
622
+ readonly "Minimum must not be above maximum.": "Minimum must not be above maximum.";
623
+ readonly "That pattern isn't supported.": "That pattern isn't supported.";
624
+ readonly "Enter numbers for min, max and step.": "Enter numbers for min, max and step.";
625
+ readonly "Step must be above 0.": "Step must be above 0.";
626
+ readonly "Add at least one option.": "Add at least one option.";
627
+ readonly "Each option needs a label and a unique value.": "Each option needs a label and a unique value.";
628
+ readonly "Default must be a number.": "Default must be a number.";
629
+ readonly "Max size must be up to 10 MB.": "Max size must be up to 10 MB.";
630
+ readonly "Discard unsaved changes?": "Discard unsaved changes?";
631
+ readonly "Your changes to this form have not been saved.": "Your changes to this form have not been saved.";
632
+ readonly "Discard changes": "Discard changes";
633
+ readonly "Keep editing": "Keep editing";
634
+ 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.";
419
635
  };
420
636
  readonly pages: {
421
637
  readonly list: {
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { agentHandle } from '@jini-ai/agentic';
3
+ import { ConfirmDialog } from '../../../react/components/ConfirmDialog/ConfirmDialog.js';
3
4
  import { useWiredFormEditor } from "../hooks/use-form-editor.hooks.js";
4
5
  import { FormEditorFieldsBody } from "../components/FormEditorFieldsBody.js";
5
6
  import { FormEditorHeaderText } from "../components/FormEditorHeaderText.js";
@@ -12,7 +13,7 @@ import { FormEditorMainPanel } from "../components/FormEditorMainPanel.js";
12
13
  * `const`, because a `const` assigned inside `FormEditor` still executes in that function's own
13
14
  * scope — every branch inside it would still count toward `FormEditor`'s own complexity score. */
14
15
  export function FormEditor({ formId, tab, useFormEditorHook = useWiredFormEditor }, _optional = {}) {
15
- const { answerColumns, mode, onModeChange, html, setHtml, copyEmbed, copyFeedback, isNew, form, name, setName, slug, setSlug, fields, setFields, tab: activeTab, onTabChange, error, saving, existingFieldIds, showTabs, tabRefs, onTabsKeyDown, handleSave, handleStatusToggle, t, } = useFormEditorHook({ formId, tab });
16
+ const { answerColumns, mode, onModeChange, html, setHtml, copyEmbed, copyFeedback, isNew, form, name, setName, slug, setSlug, fields, setFields, tab: activeTab, onTabChange, error, saving, existingFieldIds, showTabs, tabRefs, onTabsKeyDown, handleSave, handleStatusToggle, leave, t, } = useFormEditorHook({ formId, tab });
16
17
  if (!isNew && !form && !error)
17
18
  return _jsx("div", { className: "notice", children: t("Loading form…") });
18
19
  // Previously this was the ONLY guard, and it only covers the pre-error case — once the load
@@ -34,7 +35,7 @@ export function FormEditor({ formId, tab, useFormEditorHook = useWiredFormEditor
34
35
  return (_jsxs("div", { className: "page", children: [_jsxs("div", { className: "page-header page-header-split", ...agentHandle({ handle: "form-editor-header" }, {
35
36
  role: "region",
36
37
  label: "Form editor header — title and the Back to forms link",
37
- }), children: [_jsx("div", { className: "page-header-lead", children: _jsxs("a", { className: "btn-secondary", href: "/admin/forms", "aria-label": t("Back to forms"), ...agentHandle({ handle: "form-editor-back" }, { role: "link", label: "Back to the list of all forms" }), children: ["\u2190 ", t("Back")] }) }), _jsx(FormEditorHeaderText, { isNew: isNew, name: name, t: t })] }), error ? _jsx("div", { className: "notice error", children: error }) : null, _jsx(FormEditorTabStrip, { showTabs: showTabs, tab: activeTab, onTabChange: onTabChange, tabRefs: tabRefs, onTabsKeyDown: onTabsKeyDown, t: t }), _jsx(FormEditorMainPanel, { isNew: isNew, tab: activeTab, formId: formId, fieldsBody: fieldsBody, t: t, answerColumns: answerColumns })] }));
38
+ }), children: [_jsx("div", { className: "page-header-lead", children: _jsxs("a", { className: "btn-secondary", href: "/admin/forms", "aria-label": t("Back to forms"), onClick: leave.onBack, ...agentHandle({ handle: "form-editor-back" }, { role: "link", label: "Back to the list of all forms" }), children: ["\u2190 ", t("Back")] }) }), _jsx(FormEditorHeaderText, { isNew: isNew, name: name, t: t })] }), error ? _jsx("div", { className: "notice error", children: error }) : null, _jsx(FormEditorTabStrip, { showTabs: showTabs, tab: activeTab, onTabChange: onTabChange, tabRefs: tabRefs, onTabsKeyDown: onTabsKeyDown, t: t }), _jsx(FormEditorMainPanel, { isNew: isNew, tab: activeTab, formId: formId, fieldsBody: fieldsBody, t: t, answerColumns: answerColumns }), _jsx(ConfirmDialog, { open: leave.confirmOpen, agentHandle: "form-editor-leave-confirm", title: t("Discard unsaved changes?"), body: _jsx("p", { children: t("Your changes to this form have not been saved.") }), confirmLabel: t("Discard changes"), cancelLabel: t("Keep editing"), tone: "danger", onConfirm: leave.confirm, onCancel: leave.cancel })] }));
38
39
  }
39
40
  export default FormEditor;
40
41
  //# sourceMappingURL=FormEditor.js.map
@@ -1,6 +1,7 @@
1
1
  import type { RowMenuItem } from "../react/components/RowMenu/RowMenu.js";
2
2
  import type { AdminFormDefinition, AdminFormField } from "./models.js";
3
3
  import type { QueryKey } from "@jini-ai/ui/fetch-query";
4
+ import { type FieldType } from "@jini-ai/cms/forms";
4
5
  /**
5
6
  * @file Pure logic for the `forms` feature (`FormEditor.tsx` — see `FormsList.tsx`'s own
6
7
  * `rules.ts`/hook for the list screen's extraction) — everything that computes a value rather than
@@ -183,7 +184,8 @@ export declare function formRowMenuItems({ form, handlers, t }: {
183
184
  handlers: FormRowMenuHandlers;
184
185
  t: (key: string) => string;
185
186
  }, _optional?: Record<string, never>): RowMenuItem[];
186
- export declare const FIELD_TYPES: readonly ["text", "email", "textarea", "checkbox"];
187
+ /** Every registered field type, in registry order; the picker groups them via `field-settings.ts`. */
188
+ export declare const FIELD_TYPES: FieldType[];
187
189
  /** The two tab-panel views on an existing form's editor (`formId !== "new"`). */
188
190
  export declare const FORM_TABS: readonly [{
189
191
  readonly id: "fields";
@@ -216,6 +218,13 @@ export declare function updateFormField({ fields, index, patch }: {
216
218
  index: number;
217
219
  patch: Partial<AdminFormField>;
218
220
  }, _optional?: Record<string, never>): AdminFormField[];
221
+ /** Swap in a whole field (a type change drops settings, which a merge would keep).
222
+ * @complexity O(n) in `fields.length`. */
223
+ export declare function replaceFormField({ fields, index, field }: {
224
+ fields: AdminFormField[];
225
+ index: number;
226
+ field: AdminFormField;
227
+ }, _optional?: Record<string, never>): AdminFormField[];
219
228
  /** @complexity O(n) in `fields.length`. */
220
229
  export declare function addFormField({ fields }: {
221
230
  fields: AdminFormField[];
@@ -1,5 +1,6 @@
1
1
  import { AdminApiError as ApiError, describeApiError as describeCoreApiError } from "../core/transport/errors.js";
2
2
  const describeApiError = (e, fallback) => describeCoreApiError({ e, fallback });
3
+ import { FIELD_TYPE_REGISTRY } from "@jini-ai/cms/forms";
3
4
  /**
4
5
  * @file Pure logic for the `forms` feature (`FormEditor.tsx` — see `FormsList.tsx`'s own
5
6
  * `rules.ts`/hook for the list screen's extraction) — everything that computes a value rather than
@@ -199,7 +200,8 @@ export function formRowMenuItems({ form, handlers, t }, _optional = {}) {
199
200
  { key: "delete", label: t("Delete"), tone: "danger", onSelect: () => handlers.onDelete(form) },
200
201
  ];
201
202
  }
202
- export const FIELD_TYPES = ["text", "email", "textarea", "checkbox"];
203
+ /** Every registered field type, in registry order; the picker groups them via `field-settings.ts`. */
204
+ export const FIELD_TYPES = Object.keys(FIELD_TYPE_REGISTRY);
203
205
  /** The two tab-panel views on an existing form's editor (`formId !== "new"`). */
204
206
  export const FORM_TABS = [
205
207
  { id: "fields", label: "Fields" },
@@ -235,6 +237,11 @@ export function fieldDisplayName({ field, index }, _optional = {}) {
235
237
  export function updateFormField({ fields, index, patch }, _optional = {}) {
236
238
  return fields.map((f, i) => (i === index ? { ...f, ...patch } : f));
237
239
  }
240
+ /** Swap in a whole field (a type change drops settings, which a merge would keep).
241
+ * @complexity O(n) in `fields.length`. */
242
+ export function replaceFormField({ fields, index, field }, _optional = {}) {
243
+ return fields.map((f, i) => (i === index ? field : f));
244
+ }
238
245
  /** @complexity O(n) in `fields.length`. */
239
246
  export function addFormField({ fields }, _optional = {}) {
240
247
  return [...fields, blankField()];
@@ -22,6 +22,10 @@ export declare function createLibraryController({ api }: {
22
22
  listener: () => void;
23
23
  }) => () => void;
24
24
  load: (_required?: Record<string, never>, _optional?: Record<string, never>) => Promise<void>;
25
+ settleLibrary: ({ all, error }: {
26
+ all?: readonly MediaAsset[];
27
+ error?: Error | null;
28
+ }, _optional?: Record<string, never>) => void;
25
29
  setQuery({ query }: {
26
30
  query: MediaQuery;
27
31
  }, _optional?: Record<string, never>): Promise<void>;
@@ -69,6 +69,18 @@ export function createLibraryController({ api }, { query = {} } = {}) {
69
69
  store.set({ patch: { loading: false } });
70
70
  }
71
71
  }
72
+ // A reactive cache observer may refresh this library without calling load again. Keep
73
+ // filtering/counts in the headless domain; accepting cache data must spend no extra read.
74
+ function settleLibrary({ all, error }, _optional = {}) {
75
+ if (store.signal.aborted)
76
+ return;
77
+ if (all) {
78
+ library = all;
79
+ store.set({ patch: { items: queryMedia({ media: all, query: store.getSnapshot().query }), counts: countMediaTabs({ media: all }), hasUntyped: all.some(item => item.contentType === null), error: null } });
80
+ }
81
+ if (error)
82
+ store.set({ patch: { error: error.message } });
83
+ }
72
84
  let writes = Promise.resolve();
73
85
  let exclusiveWrite = false;
74
86
  function write(run, id) {
@@ -114,6 +126,7 @@ export function createLibraryController({ api }, { query = {} } = {}) {
114
126
  getSnapshot: store.getSnapshot,
115
127
  subscribe: store.subscribe,
116
128
  load,
129
+ settleLibrary,
117
130
  async setQuery({ query }, _optional = {}) {
118
131
  // Already-fetched full library owns display order/filtering. Tab/search/order changes
119
132
  // never spend another request or flash a loading zero badge.
@@ -1,10 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { MediaDialog } from './MediaDialog.js';
3
- import { Button, TextField } from '@jini-ai/ui-kit/react';
3
+ import { Button, IconButton, TextField } from '@jini-ai/ui-kit/react';
4
4
  import { useMediaPicker } from '../hooks/MediaPicker.hooks.js';
5
5
  import { MediaGrid } from './MediaGrid.js';
6
6
  export function MediaPicker(props, _optional = {}) {
7
7
  const vm = useMediaPicker(props);
8
- return (_jsxs(MediaDialog, { className: "jini-media-picker-dialog", attrs: { 'data-jini-part': 'media.picker' }, title: "Choose media", open: true, onClose: vm.onClose, children: [_jsx(TextField, { attrs: { 'data-jini-part': 'media.picker.search' }, label: "Search media", value: vm.search, onValueChange: vm.onSearch }), vm.snapshot?.error && _jsxs("div", { className: "jini-notice jini-notice-error", children: [_jsx("p", { className: "jini-notice-text", role: "alert", children: vm.snapshot.error }), _jsx(Button, { onPress: vm.onRetry, children: "Retry" })] }), vm.loading ? (_jsx("p", { className: "jini-notice", role: "status", children: "Loading media\u2026" })) : (_jsx(MediaGrid, { actions: vm.actions, api: vm.api })), _jsx(Button, { attrs: { 'data-jini-part': 'media.picker.cancel', 'data-jini-autofocus': true }, onPress: vm.onClose, children: "Cancel" })] }));
8
+ return (_jsxs(MediaDialog, { className: "jini-media-picker-dialog", attrs: { 'data-jini-part': 'media.picker' }, title: "Choose media", open: true, onClose: vm.onClose, children: [_jsx(TextField, { attrs: { 'data-jini-part': 'media.picker.search' }, label: "Search media", value: vm.search, onValueChange: vm.onSearch }), vm.snapshot?.error && _jsxs("div", { className: "jini-notice jini-notice-error", children: [_jsx("p", { className: "jini-notice-text", role: "alert", children: vm.snapshot.error }), _jsx(Button, { onPress: vm.onRetry, children: "Retry" })] }), vm.loading ? (_jsx("p", { className: "jini-notice", role: "status", children: "Loading media\u2026" })) : (_jsx(MediaGrid, { actions: vm.actions, api: vm.api })), _jsxs("div", { className: "jini-media-picker-footer", "data-jini-part": "media.picker.footer", children: [_jsx(IconButton, { className: "jini-media-picker-back", label: "Back", attrs: { 'data-jini-part': 'media.picker.back' }, onPress: vm.onClose, children: "\u2190" }), _jsx(Button, { attrs: { 'data-jini-part': 'media.picker.cancel', 'data-jini-autofocus': true }, onPress: vm.onClose, children: "Cancel" })] })] }));
9
9
  }
10
10
  //# sourceMappingURL=MediaPicker.js.map
@@ -1,12 +1,14 @@
1
+ import { useMediaLibraryQuery } from './MediaQuery.hooks.js';
1
2
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
3
  import { useController } from '../../../core/react/use-controller.js';
3
4
  import { createEditMediaController } from '../../controllers/edit-media.controller.js';
4
5
  import { canReplaceMedia, mediaEmbedSnippet, parseOptionalPixelSize, safeMediaUrl } from '../../rules.js';
5
6
  import { parseMediaHtmlAttributes, describeMediaHtmlAttributeError } from '../../html-attributes.js';
6
7
  export function useEditMediaPanel(props, _optional = {}) {
8
+ const cached = useMediaLibraryQuery({ api: props.api }, { enabled: false });
7
9
  const { controller, snapshot } = useController({
8
- create: () => createEditMediaController({ api: props.api, item: props.item }),
9
- dependencies: [props.api, props.item.id],
10
+ create: () => createEditMediaController({ api: cached.api, item: props.item }),
11
+ dependencies: [cached.api, props.item.id],
10
12
  });
11
13
  const [copied, setCopied] = useState({});
12
14
  const timers = useRef(new Map());
@@ -0,0 +1,31 @@
1
+ export interface GallerySelectionImage {
2
+ readonly mediaId: string;
3
+ readonly alt: string;
4
+ readonly caption: string;
5
+ }
6
+ /** Controlled gallery selection: metadata follows media ids through reordering and deduplication.
7
+ * The host owns persistence and media loading; these callbacks only derive the next ordered value.
8
+ * @complexity O(n+m) time/space per action; the default bound is 50 images.
9
+ */
10
+ export declare function useGallerySelection({ images, onChange }: {
11
+ images: readonly GallerySelectionImage[];
12
+ onChange: (images: GallerySelectionImage[]) => void;
13
+ }, { maxImages }?: {
14
+ maxImages?: number;
15
+ }): {
16
+ add({ images: additions }: {
17
+ images: readonly GallerySelectionImage[];
18
+ }, _optional?: Record<string, never>): void;
19
+ update({ mediaId, patch }: {
20
+ mediaId: string;
21
+ patch: Partial<Pick<GallerySelectionImage, "alt" | "caption">>;
22
+ }, _optional?: Record<string, never>): void;
23
+ remove({ mediaId }: {
24
+ mediaId: string;
25
+ }, _optional?: Record<string, never>): void;
26
+ move({ mediaId, beforeId }: {
27
+ mediaId: string;
28
+ beforeId: string;
29
+ }, _optional?: Record<string, never>): void;
30
+ };
31
+ //# sourceMappingURL=GallerySelection.hooks.d.ts.map
@@ -0,0 +1,32 @@
1
+ /** Controlled gallery selection: metadata follows media ids through reordering and deduplication.
2
+ * The host owns persistence and media loading; these callbacks only derive the next ordered value.
3
+ * @complexity O(n+m) time/space per action; the default bound is 50 images.
4
+ */
5
+ export function useGallerySelection({ images, onChange }, { maxImages = 50 } = {}) {
6
+ return {
7
+ add({ images: additions }, _optional = {}) {
8
+ const next = new Map(images.map(image => [image.mediaId, image]));
9
+ for (const image of additions)
10
+ if (!next.has(image.mediaId))
11
+ next.set(image.mediaId, image);
12
+ if (next.size > maxImages)
13
+ throw new Error(`Choose at most ${maxImages} gallery images`);
14
+ onChange([...next.values()]);
15
+ },
16
+ update({ mediaId, patch }, _optional = {}) {
17
+ onChange(images.map(image => image.mediaId === mediaId ? { ...image, ...patch } : image));
18
+ },
19
+ remove({ mediaId }, _optional = {}) { onChange(images.filter(image => image.mediaId !== mediaId)); },
20
+ move({ mediaId, beforeId }, _optional = {}) {
21
+ if (mediaId === beforeId)
22
+ return;
23
+ const source = images.find(image => image.mediaId === mediaId);
24
+ if (!source || !images.some(image => image.mediaId === beforeId))
25
+ return;
26
+ const next = images.filter(image => image.mediaId !== mediaId);
27
+ next.splice(next.findIndex(image => image.mediaId === beforeId), 0, source);
28
+ onChange(next);
29
+ },
30
+ };
31
+ }
32
+ //# sourceMappingURL=GallerySelection.hooks.js.map
@@ -18,6 +18,10 @@ export declare function useLibraryTab({ params, permissions }: TabViewProps, _op
18
18
  listener: () => void;
19
19
  }) => () => void;
20
20
  load: (_required?: Record<string, never>, _optional?: Record<string, never>) => Promise<void>;
21
+ settleLibrary: ({ all, error }: {
22
+ all?: readonly MediaAsset[];
23
+ error?: Error | null;
24
+ }, _optional?: Record<string, never>) => void;
21
25
  setQuery({ query }: {
22
26
  query: import("../../models.js").MediaQuery;
23
27
  }, _optional?: Record<string, never>): Promise<void>;
@@ -1,3 +1,5 @@
1
+ import { useInvalidate } from '@jini-ai/ui/fetch-query';
2
+ import { MEDIA_QUERY_KEYS, useMediaLibraryQuery } from './MediaQuery.hooks.js';
1
3
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
4
  import { useController } from '../../../core/react/use-controller.js';
3
5
  import { createLibraryController } from '../../controllers/library.controller.js';
@@ -9,9 +11,11 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
9
11
  const { mediaApi, mediaEvents } = useMediaPorts();
10
12
  const filter = params.filter === 'images' || params.filter === 'videos' ? params.filter : 'all';
11
13
  const shared = useSharedMediaLibrary();
14
+ const cached = useMediaLibraryQuery({ api: mediaApi }, { enabled: !shared });
15
+ const invalidate = useInvalidate();
12
16
  const local = useController({
13
- create: () => createLibraryController({ api: mediaApi }, { query: { filter } }),
14
- dependencies: [mediaApi, filter],
17
+ create: () => createLibraryController({ api: cached.api }, { query: { filter } }),
18
+ dependencies: [cached.api, filter],
15
19
  }, {
16
20
  start: ({ controller }) => {
17
21
  if (!shared)
@@ -19,7 +23,9 @@ export function useLibraryTab({ params, permissions = [] }, _optional = {}) {
19
23
  },
20
24
  });
21
25
  const { controller, snapshot } = shared ?? local;
22
- useEffect(() => !shared ? mediaEvents?.subscribe({ onRefresh: () => { void controller?.load(); } }) : undefined, [mediaEvents, controller, shared]);
26
+ useEffect(() => !shared ? mediaEvents?.subscribe({ onRefresh: () => invalidate({ key: MEDIA_QUERY_KEYS.root }) }) : undefined, [mediaEvents, controller, shared, invalidate]);
27
+ useEffect(() => { if (!shared)
28
+ local.controller?.settleLibrary({ ...(cached.query.data ? { all: cached.query.data } : {}), error: cached.query.error }); }, [shared, local.controller, cached.query.data, cached.query.error]);
23
29
  const gridRef = useRef(null);
24
30
  const [success, setSuccess] = useState(null);
25
31
  const [focusAfterAction, setFocusAfterAction] = useState(null);
@@ -59,7 +59,7 @@ export declare function useMediaCard(props: MediaCardProps, _optional?: Record<s
59
59
  disabled: boolean;
60
60
  })[];
61
61
  src: string | undefined;
62
- stage: "image" | "video" | "unsupported";
62
+ stage: "video" | "image" | "unsupported";
63
63
  image: boolean | "" | undefined;
64
64
  video: boolean | "" | undefined;
65
65
  onImageError: () => void;
@@ -67,7 +67,7 @@ export declare function useMediaCard(props: MediaCardProps, _optional?: Record<s
67
67
  alt: string;
68
68
  statusClassName: string;
69
69
  statusLabel: "active" | "trashed";
70
- statusTone: "success" | "warning";
70
+ statusTone: "warning" | "success";
71
71
  trashed: boolean;
72
72
  item: MediaAsset;
73
73
  handleBase?: string;
@@ -10,10 +10,10 @@ export interface MediaGridProps {
10
10
  export declare function useMediaGrid(props: MediaGridProps, _optional?: Record<string, never>): {
11
11
  actions: {
12
12
  handleBase: string;
13
+ item: import("../../models.js").MediaAsset;
14
+ locale?: string;
13
15
  busy?: boolean;
14
16
  onPurge?: () => void;
15
- locale?: string;
16
- item: import("../../models.js").MediaAsset;
17
17
  onEdit?: () => void;
18
18
  onPreview?: () => void;
19
19
  onTrash?: () => void;
@@ -21,6 +21,10 @@ export declare function useMediaPage(props: MediaPageProps, _optional?: Record<s
21
21
  listener: () => void;
22
22
  }) => () => void;
23
23
  load: (_required?: Record<string, never>, _optional?: Record<string, never>) => Promise<void>;
24
+ settleLibrary: ({ all, error }: {
25
+ all?: readonly import("../../models.js").MediaAsset[];
26
+ error?: Error | null;
27
+ }, _optional?: Record<string, never>) => void;
24
28
  setQuery({ query }: {
25
29
  query: import("../../models.js").MediaQuery;
26
30
  }, _optional?: Record<string, never>): Promise<void>;
@@ -1,3 +1,5 @@
1
+ import { useInvalidate } from '@jini-ai/ui/fetch-query';
2
+ import { MEDIA_QUERY_KEYS, useMediaLibraryQuery, useMediaProvidersQuery } from './MediaQuery.hooks.js';
1
3
  import { createElement, createContext, useContext, useEffect, useState } from 'react';
2
4
  import { useController } from '../../../core/react/use-controller.js';
3
5
  import { createLibraryController } from '../../controllers/library.controller.js';
@@ -11,20 +13,15 @@ export function useSharedMediaLibrary(_required = {}, _optional = {}) {
11
13
  export function useMediaPage(props, _optional = {}) {
12
14
  const [localTab, setLocalTab] = useState('all');
13
15
  const { mediaApi, mediaEvents, mediaProviders } = useMediaPorts();
14
- const library = useController({ create: () => createLibraryController({ api: mediaApi }, { query: { filter: requestedFilter({ tabId: props.requestedTab ?? localTab }) } }), dependencies: [mediaApi] }, { start: ({ controller }) => { if (props.description.visible)
16
+ const cached = useMediaLibraryQuery({ api: mediaApi }, { enabled: props.description.visible });
17
+ const providers = useMediaProvidersQuery({ port: mediaProviders }, { enabled: props.description.visible && props.description.grants.includes('media.providers') });
18
+ const invalidate = useInvalidate();
19
+ const library = useController({ create: () => createLibraryController({ api: cached.api }, { query: { filter: requestedFilter({ tabId: props.requestedTab ?? localTab }) } }), dependencies: [cached.api] }, { start: ({ controller }) => { if (props.description.visible)
15
20
  void controller.load(); } });
16
- useEffect(() => props.description.visible ? mediaEvents?.subscribe({ onRefresh: () => { void library.controller?.load(); } }) : undefined, [props.description.visible, mediaEvents, library.controller]);
17
- const [providerCount, setProviderCount] = useState(null);
18
- useEffect(() => {
19
- if (!props.description.visible || !mediaProviders || !props.description.grants.includes('media.providers'))
20
- return;
21
- const abort = new AbortController();
22
- void mediaProviders.list({}, { signal: abort.signal }).then(items => {
23
- if (!abort.signal.aborted && items)
24
- setProviderCount(items.length);
25
- }).catch(() => { });
26
- return () => abort.abort();
27
- }, [mediaProviders, props.description.grants, props.description.visible]);
21
+ useEffect(() => props.description.visible ? mediaEvents?.subscribe({ onRefresh: () => { invalidate({ key: MEDIA_QUERY_KEYS.root }); } }) : undefined, [props.description.visible, mediaEvents, library.controller, invalidate]);
22
+ useEffect(() => { library.controller?.settleLibrary({ ...(cached.query.data ? { all: cached.query.data } : {}), error: cached.query.error }); }, [library.controller, cached.query.data, cached.query.error]);
23
+ /* An unavailable read is not evidence of a zero catalogue. */
24
+ const providerCount = providers.query.data?.length ?? null;
28
25
  const visible = props.description.visible
29
26
  ? props.description.tabs.filter((tab) => tab.visible)
30
27
  : [];