@qoretechnologies/reqraft 0.10.25 → 0.10.26

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 (310) hide show
  1. package/.claude/CLAUDE.md +52 -23
  2. package/design/COMPACT_ENGINE_REDESIGN.md +156 -0
  3. package/design/FORM_ENGINE_COMPACT_UX_PLAN.md +353 -0
  4. package/dist/components/FocusedEditing/index.d.ts +9 -1
  5. package/dist/components/FocusedEditing/index.d.ts.map +1 -1
  6. package/dist/components/FocusedEditing/index.js +2 -2
  7. package/dist/components/FocusedEditing/index.js.map +1 -1
  8. package/dist/components/composer/Composer.d.ts +108 -0
  9. package/dist/components/composer/Composer.d.ts.map +1 -0
  10. package/dist/components/composer/Composer.js +238 -0
  11. package/dist/components/composer/Composer.js.map +1 -0
  12. package/dist/components/composer/helpers.d.ts +21 -0
  13. package/dist/components/composer/helpers.d.ts.map +1 -0
  14. package/dist/components/composer/helpers.js +75 -0
  15. package/dist/components/composer/helpers.js.map +1 -0
  16. package/dist/components/composer/index.d.ts +3 -0
  17. package/dist/components/composer/index.d.ts.map +1 -0
  18. package/dist/components/composer/index.js +19 -0
  19. package/dist/components/composer/index.js.map +1 -0
  20. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  21. package/dist/components/form/engine/CompactRow.js +307 -110
  22. package/dist/components/form/engine/CompactRow.js.map +1 -1
  23. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  24. package/dist/components/form/engine/CompactToolbar.js +123 -106
  25. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  26. package/dist/components/form/engine/FormEngine.d.ts +107 -11
  27. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  28. package/dist/components/form/engine/FormEngine.js +618 -179
  29. package/dist/components/form/engine/FormEngine.js.map +1 -1
  30. package/dist/components/form/engine/compactRowContext.d.ts +9 -0
  31. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  32. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  33. package/dist/components/form/engine/compactRowStyles.d.ts +7 -3
  34. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  35. package/dist/components/form/engine/compactRowStyles.js +88 -51
  36. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  37. package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
  38. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  39. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  40. package/dist/components/form/engine/optionActions.d.ts +28 -0
  41. package/dist/components/form/engine/optionActions.d.ts.map +1 -0
  42. package/dist/components/form/engine/optionActions.js +18 -0
  43. package/dist/components/form/engine/optionActions.js.map +1 -0
  44. package/dist/components/form/engine/readFirst.d.ts +63 -2
  45. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  46. package/dist/components/form/engine/readFirst.js +121 -6
  47. package/dist/components/form/engine/readFirst.js.map +1 -1
  48. package/dist/components/form/engine/rendererTypes.d.ts +35 -0
  49. package/dist/components/form/engine/rendererTypes.d.ts.map +1 -0
  50. package/dist/components/form/engine/rendererTypes.js +63 -0
  51. package/dist/components/form/engine/rendererTypes.js.map +1 -0
  52. package/dist/components/form/engine/variants/VariantCalmTable.d.ts +6 -0
  53. package/dist/components/form/engine/variants/VariantCalmTable.d.ts.map +1 -0
  54. package/dist/components/form/engine/variants/VariantCalmTable.js +94 -0
  55. package/dist/components/form/engine/variants/VariantCalmTable.js.map +1 -0
  56. package/dist/components/form/engine/variants/VariantCards.d.ts +6 -0
  57. package/dist/components/form/engine/variants/VariantCards.d.ts.map +1 -0
  58. package/dist/components/form/engine/variants/VariantCards.js +80 -0
  59. package/dist/components/form/engine/variants/VariantCards.js.map +1 -0
  60. package/dist/components/form/engine/variants/VariantFocus.d.ts +7 -0
  61. package/dist/components/form/engine/variants/VariantFocus.d.ts.map +1 -0
  62. package/dist/components/form/engine/variants/VariantFocus.js +138 -0
  63. package/dist/components/form/engine/variants/VariantFocus.js.map +1 -0
  64. package/dist/components/form/engine/variants/VariantMinimal.d.ts +6 -0
  65. package/dist/components/form/engine/variants/VariantMinimal.d.ts.map +1 -0
  66. package/dist/components/form/engine/variants/VariantMinimal.js +73 -0
  67. package/dist/components/form/engine/variants/VariantMinimal.js.map +1 -0
  68. package/dist/components/form/engine/variants/focusDemo.d.ts +13 -0
  69. package/dist/components/form/engine/variants/focusDemo.d.ts.map +1 -0
  70. package/dist/components/form/engine/variants/focusDemo.js +139 -0
  71. package/dist/components/form/engine/variants/focusDemo.js.map +1 -0
  72. package/dist/components/form/engine/variants/variantModel.d.ts +70 -0
  73. package/dist/components/form/engine/variants/variantModel.d.ts.map +1 -0
  74. package/dist/components/form/engine/variants/variantModel.js +133 -0
  75. package/dist/components/form/engine/variants/variantModel.js.map +1 -0
  76. package/dist/components/form/engine/variants/variantParts.d.ts +79 -0
  77. package/dist/components/form/engine/variants/variantParts.d.ts.map +1 -0
  78. package/dist/components/form/engine/variants/variantParts.js +191 -0
  79. package/dist/components/form/engine/variants/variantParts.js.map +1 -0
  80. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  81. package/dist/components/form/expressions/builder/index.js +13 -2
  82. package/dist/components/form/expressions/builder/index.js.map +1 -1
  83. package/dist/components/form/expressions/useExpressions.d.ts.map +1 -1
  84. package/dist/components/form/expressions/useExpressions.js +4 -1
  85. package/dist/components/form/expressions/useExpressions.js.map +1 -1
  86. package/dist/components/form/fields/Field.d.ts.map +1 -1
  87. package/dist/components/form/fields/Field.js +5 -3
  88. package/dist/components/form/fields/Field.js.map +1 -1
  89. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +2 -1
  90. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  91. package/dist/components/form/fields/allowed-values/AllowedValues.js +8 -26
  92. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  93. package/dist/components/form/fields/auto/AutoFormField.d.ts +3 -0
  94. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  95. package/dist/components/form/fields/auto/AutoFormField.js +42 -8
  96. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  97. package/dist/components/form/fields/binary/Binary.d.ts +15 -0
  98. package/dist/components/form/fields/binary/Binary.d.ts.map +1 -0
  99. package/dist/components/form/fields/binary/Binary.js +74 -0
  100. package/dist/components/form/fields/binary/Binary.js.map +1 -0
  101. package/dist/components/form/fields/boolean/Boolean.js +1 -1
  102. package/dist/components/form/fields/boolean/Boolean.js.map +1 -1
  103. package/dist/components/form/fields/multi-select/MultiSelectFormField.d.ts.map +1 -1
  104. package/dist/components/form/fields/multi-select/MultiSelectFormField.js +5 -4
  105. package/dist/components/form/fields/multi-select/MultiSelectFormField.js.map +1 -1
  106. package/dist/components/form/fields/select/Select.d.ts.map +1 -1
  107. package/dist/components/form/fields/select/Select.js +29 -12
  108. package/dist/components/form/fields/select/Select.js.map +1 -1
  109. package/dist/components/form/fields/select/SelectCollection.d.ts +8 -2
  110. package/dist/components/form/fields/select/SelectCollection.d.ts.map +1 -1
  111. package/dist/components/form/fields/select/SelectCollection.js +52 -16
  112. package/dist/components/form/fields/select/SelectCollection.js.map +1 -1
  113. package/dist/components/form/index.d.ts +2 -0
  114. package/dist/components/form/index.d.ts.map +1 -1
  115. package/dist/components/form/index.js +2 -0
  116. package/dist/components/form/index.js.map +1 -1
  117. package/dist/components/imageLightbox/ImageLightbox.d.ts +21 -0
  118. package/dist/components/imageLightbox/ImageLightbox.d.ts.map +1 -0
  119. package/dist/components/imageLightbox/ImageLightbox.js +169 -0
  120. package/dist/components/imageLightbox/ImageLightbox.js.map +1 -0
  121. package/dist/components/imageLightbox/index.d.ts +2 -0
  122. package/dist/components/imageLightbox/index.d.ts.map +1 -0
  123. package/dist/components/imageLightbox/index.js +18 -0
  124. package/dist/components/imageLightbox/index.js.map +1 -0
  125. package/dist/components/searchFilterBar/SearchFilterBar.d.ts +33 -0
  126. package/dist/components/searchFilterBar/SearchFilterBar.d.ts.map +1 -0
  127. package/dist/components/searchFilterBar/SearchFilterBar.js +76 -0
  128. package/dist/components/searchFilterBar/SearchFilterBar.js.map +1 -0
  129. package/dist/components/searchFilterBar/index.d.ts +2 -0
  130. package/dist/components/searchFilterBar/index.d.ts.map +1 -0
  131. package/dist/components/searchFilterBar/index.js +18 -0
  132. package/dist/components/searchFilterBar/index.js.map +1 -0
  133. package/dist/components/supportTicket/AttachmentChips.d.ts +10 -0
  134. package/dist/components/supportTicket/AttachmentChips.d.ts.map +1 -0
  135. package/dist/components/supportTicket/AttachmentChips.js +122 -0
  136. package/dist/components/supportTicket/AttachmentChips.js.map +1 -0
  137. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +43 -0
  138. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -0
  139. package/dist/components/supportTicket/InterfaceReferenceTags.js +31 -0
  140. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -0
  141. package/dist/components/supportTicket/ReferencePicker.d.ts +87 -0
  142. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -0
  143. package/dist/components/supportTicket/ReferencePicker.js +295 -0
  144. package/dist/components/supportTicket/ReferencePicker.js.map +1 -0
  145. package/dist/components/supportTicket/TicketHeader.d.ts +86 -0
  146. package/dist/components/supportTicket/TicketHeader.d.ts.map +1 -0
  147. package/dist/components/supportTicket/TicketHeader.js +149 -0
  148. package/dist/components/supportTicket/TicketHeader.js.map +1 -0
  149. package/dist/components/supportTicket/TicketMetaTags.d.ts +32 -0
  150. package/dist/components/supportTicket/TicketMetaTags.d.ts.map +1 -0
  151. package/dist/components/supportTicket/TicketMetaTags.js +21 -0
  152. package/dist/components/supportTicket/TicketMetaTags.js.map +1 -0
  153. package/dist/components/supportTicket/TicketReplyBox.d.ts +63 -0
  154. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -0
  155. package/dist/components/supportTicket/TicketReplyBox.js +115 -0
  156. package/dist/components/supportTicket/TicketReplyBox.js.map +1 -0
  157. package/dist/components/supportTicket/attachmentAccess.d.ts +37 -0
  158. package/dist/components/supportTicket/attachmentAccess.d.ts.map +1 -0
  159. package/dist/components/supportTicket/attachmentAccess.js +132 -0
  160. package/dist/components/supportTicket/attachmentAccess.js.map +1 -0
  161. package/dist/components/supportTicket/fileToAttachment.d.ts +9 -0
  162. package/dist/components/supportTicket/fileToAttachment.d.ts.map +1 -0
  163. package/dist/components/supportTicket/fileToAttachment.js +28 -0
  164. package/dist/components/supportTicket/fileToAttachment.js.map +1 -0
  165. package/dist/components/supportTicket/index.d.ts +11 -0
  166. package/dist/components/supportTicket/index.d.ts.map +1 -0
  167. package/dist/components/supportTicket/index.js +27 -0
  168. package/dist/components/supportTicket/index.js.map +1 -0
  169. package/dist/components/supportTicket/meta.d.ts +71 -0
  170. package/dist/components/supportTicket/meta.d.ts.map +1 -0
  171. package/dist/components/supportTicket/meta.js +115 -0
  172. package/dist/components/supportTicket/meta.js.map +1 -0
  173. package/dist/components/supportTicket/ticketModel.d.ts +102 -0
  174. package/dist/components/supportTicket/ticketModel.d.ts.map +1 -0
  175. package/dist/components/supportTicket/ticketModel.js +3 -0
  176. package/dist/components/supportTicket/ticketModel.js.map +1 -0
  177. package/dist/components/ticketReferences/TicketReferences.d.ts +52 -0
  178. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -0
  179. package/dist/components/ticketReferences/TicketReferences.js +492 -0
  180. package/dist/components/ticketReferences/TicketReferences.js.map +1 -0
  181. package/dist/components/ticketReferences/index.d.ts +3 -0
  182. package/dist/components/ticketReferences/index.d.ts.map +1 -0
  183. package/dist/components/ticketReferences/index.js +6 -0
  184. package/dist/components/ticketReferences/index.js.map +1 -0
  185. package/dist/components/ticketThread/TicketThread.d.ts +57 -0
  186. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -0
  187. package/dist/components/ticketThread/TicketThread.js +299 -0
  188. package/dist/components/ticketThread/TicketThread.js.map +1 -0
  189. package/dist/components/ticketThread/index.d.ts +3 -0
  190. package/dist/components/ticketThread/index.d.ts.map +1 -0
  191. package/dist/components/ticketThread/index.js +7 -0
  192. package/dist/components/ticketThread/index.js.map +1 -0
  193. package/dist/helpers/common.d.ts +5 -0
  194. package/dist/helpers/common.d.ts.map +1 -1
  195. package/dist/helpers/common.js +20 -1
  196. package/dist/helpers/common.js.map +1 -1
  197. package/dist/helpers/scrollbar.d.ts +2 -0
  198. package/dist/helpers/scrollbar.d.ts.map +1 -0
  199. package/dist/helpers/scrollbar.js +25 -0
  200. package/dist/helpers/scrollbar.js.map +1 -0
  201. package/dist/index.d.ts +8 -1
  202. package/dist/index.d.ts.map +1 -1
  203. package/dist/index.js +10 -1
  204. package/dist/index.js.map +1 -1
  205. package/dist/providers/StorageProvider.d.ts +13 -0
  206. package/dist/providers/StorageProvider.d.ts.map +1 -1
  207. package/dist/providers/StorageProvider.js +29 -5
  208. package/dist/providers/StorageProvider.js.map +1 -1
  209. package/package.json +6 -5
  210. package/src/components/FocusedEditing/index.tsx +17 -2
  211. package/src/components/composer/Composer.stories.tsx +238 -0
  212. package/src/components/composer/Composer.tsx +545 -0
  213. package/src/components/composer/helpers.ts +74 -0
  214. package/src/components/composer/index.ts +2 -0
  215. package/src/components/dpqlEditor/DpqlEditor.stories.tsx +74 -2
  216. package/src/components/form/engine/CompactRow.tsx +561 -283
  217. package/src/components/form/engine/CompactToolbar.tsx +216 -182
  218. package/src/components/form/engine/FormEngine.stories.tsx +1736 -162
  219. package/src/components/form/engine/FormEngine.tsx +886 -170
  220. package/src/components/form/engine/FormEngineRemote.stories.tsx +377 -0
  221. package/src/components/form/engine/_structuredData/StructuredDataView.stories.tsx +8 -0
  222. package/src/components/form/engine/compactRowContext.ts +14 -0
  223. package/src/components/form/engine/compactRowStyles.ts +273 -146
  224. package/src/components/form/engine/compactToolbarContext.ts +1 -0
  225. package/src/components/form/engine/optionActions.ts +40 -0
  226. package/src/components/form/engine/readFirst.ts +169 -10
  227. package/src/components/form/engine/rendererTypes.ts +55 -0
  228. package/src/components/form/engine/variants/FormEngineVariants.stories.tsx +159 -0
  229. package/src/components/form/engine/variants/VariantCalmTable.tsx +242 -0
  230. package/src/components/form/engine/variants/VariantCards.tsx +212 -0
  231. package/src/components/form/engine/variants/VariantFocus.tsx +382 -0
  232. package/src/components/form/engine/variants/VariantMinimal.tsx +170 -0
  233. package/src/components/form/engine/variants/focusDemo.ts +145 -0
  234. package/src/components/form/engine/variants/variantModel.ts +216 -0
  235. package/src/components/form/engine/variants/variantParts.tsx +313 -0
  236. package/src/components/form/expressions/ExpressionField.stories.tsx +84 -2
  237. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +213 -19
  238. package/src/components/form/expressions/builder/index.tsx +12 -1
  239. package/src/components/form/expressions/useExpressions.ts +4 -1
  240. package/src/components/form/fields/Field.stories.tsx +40 -0
  241. package/src/components/form/fields/Field.tsx +10 -1
  242. package/src/components/form/fields/allowed-values/AllowedValues.tsx +13 -36
  243. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +64 -0
  244. package/src/components/form/fields/auto/AutoFormField.stories.tsx +146 -2
  245. package/src/components/form/fields/auto/AutoFormField.tsx +42 -1
  246. package/src/components/form/fields/binary/Binary.stories.tsx +122 -0
  247. package/src/components/form/fields/binary/Binary.tsx +81 -0
  248. package/src/components/form/fields/boolean/Boolean.stories.tsx +58 -0
  249. package/src/components/form/fields/boolean/Boolean.tsx +1 -1
  250. package/src/components/form/fields/byte-size/ByteSize.stories.tsx +24 -0
  251. package/src/components/form/fields/color/Color.stories.tsx +30 -0
  252. package/src/components/form/fields/cron/Cron.stories.tsx +8 -0
  253. package/src/components/form/fields/date/Date.stories.tsx +22 -0
  254. package/src/components/form/fields/file/File.stories.tsx +48 -0
  255. package/src/components/form/fields/long-string/LongString.stories.tsx +24 -0
  256. package/src/components/form/fields/markdown/Markdown.stories.tsx +8 -0
  257. package/src/components/form/fields/multi-select/MultiSelectFormField.tsx +14 -10
  258. package/src/components/form/fields/number/Number.stories.tsx +31 -0
  259. package/src/components/form/fields/object/Object.stories.tsx +88 -0
  260. package/src/components/form/fields/radio-group/RadioGroup.stories.tsx +24 -0
  261. package/src/components/form/fields/rich-text/RichText.stories.tsx +23 -0
  262. package/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx +72 -0
  263. package/src/components/form/fields/select/Select.stories.tsx +108 -0
  264. package/src/components/form/fields/select/Select.tsx +38 -12
  265. package/src/components/form/fields/select/SelectCollection.tsx +78 -17
  266. package/src/components/form/fields/string/String.stories.tsx +16 -0
  267. package/src/components/form/fields/template/TemplateField.stories.tsx +160 -0
  268. package/src/components/form/fields/url/Url.stories.tsx +24 -0
  269. package/src/components/form/index.tsx +2 -0
  270. package/src/components/imageLightbox/ImageLightbox.stories.tsx +238 -0
  271. package/src/components/imageLightbox/ImageLightbox.tsx +236 -0
  272. package/src/components/imageLightbox/index.ts +1 -0
  273. package/src/components/log/Log.stories.tsx +96 -0
  274. package/src/components/menu/Menu.stories.tsx +54 -0
  275. package/src/components/qonsoleSmartInput/QonsoleSmartInput.stories.tsx +83 -37
  276. package/src/components/searchFilterBar/SearchFilterBar.tsx +131 -0
  277. package/src/components/searchFilterBar/index.ts +1 -0
  278. package/src/components/smartEditor/SmartEditor.stories.tsx +32 -0
  279. package/src/components/supportTicket/AttachmentChips.stories.tsx +171 -0
  280. package/src/components/supportTicket/AttachmentChips.tsx +147 -0
  281. package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +171 -0
  282. package/src/components/supportTicket/InterfaceReferenceTags.tsx +96 -0
  283. package/src/components/supportTicket/ReferencePicker.stories.tsx +519 -0
  284. package/src/components/supportTicket/ReferencePicker.tsx +563 -0
  285. package/src/components/supportTicket/TicketConversation.stories.tsx +121 -0
  286. package/src/components/supportTicket/TicketHeader.stories.tsx +183 -0
  287. package/src/components/supportTicket/TicketHeader.tsx +247 -0
  288. package/src/components/supportTicket/TicketMetaTags.stories.tsx +103 -0
  289. package/src/components/supportTicket/TicketMetaTags.tsx +85 -0
  290. package/src/components/supportTicket/TicketReplyBox.stories.tsx +351 -0
  291. package/src/components/supportTicket/TicketReplyBox.tsx +146 -0
  292. package/src/components/supportTicket/attachmentAccess.ts +97 -0
  293. package/src/components/supportTicket/fileToAttachment.ts +24 -0
  294. package/src/components/supportTicket/index.ts +10 -0
  295. package/src/components/supportTicket/meta.ts +185 -0
  296. package/src/components/supportTicket/ticketModel.ts +131 -0
  297. package/src/components/ticketReferences/TicketReferences.stories.tsx +315 -0
  298. package/src/components/ticketReferences/TicketReferences.tsx +886 -0
  299. package/src/components/ticketReferences/index.ts +2 -0
  300. package/src/components/ticketThread/TicketThread.stories.tsx +828 -0
  301. package/src/components/ticketThread/TicketThread.tsx +704 -0
  302. package/src/components/ticketThread/index.ts +6 -0
  303. package/src/helpers/common.ts +19 -0
  304. package/src/helpers/scrollbar.ts +32 -0
  305. package/src/hooks/useFetch/useFetch.stories.tsx +30 -0
  306. package/src/hooks/useStorage/useStorage.stories.tsx +82 -0
  307. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +127 -1
  308. package/src/index.tsx +8 -1
  309. package/src/providers/StorageProvider.tsx +30 -4
  310. package/src/stores/currentUser/currentUser.stories.tsx +49 -0
@@ -3,9 +3,9 @@ import {
3
3
  ReqoreCollection,
4
4
  ReqoreControlGroup,
5
5
  ReqoreErrorBoundary,
6
+ ReqoreIcon,
6
7
  ReqoreMessage,
7
8
  ReqoreP,
8
- ReqorePanel,
9
9
  ReqoreSkeleton,
10
10
  ReqoreTag,
11
11
  ReqoreTagGroup,
@@ -15,19 +15,16 @@ import {
15
15
  } from '@qoretechnologies/reqore';
16
16
  import { IReqoreCollectionProps } from '@qoretechnologies/reqore/dist/components/Collection';
17
17
  import { IReqoreCollectionItemProps } from '@qoretechnologies/reqore/dist/components/Collection/item';
18
- import {
19
- IReqorePanelAction,
20
- IReqorePanelProps,
21
- } from '@qoretechnologies/reqore/dist/components/Panel';
18
+ import { IReqorePanelProps } from '@qoretechnologies/reqore/dist/components/Panel';
22
19
  import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea';
23
20
  import { TReqoreIntent } from '@qoretechnologies/reqore/dist/constants/theme';
24
- import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
25
21
  import {
26
22
  changeDarkness,
27
23
  getMainBackgroundColor,
28
24
  getReadableColor,
29
25
  percentToHexAlpha,
30
26
  } from '@qoretechnologies/reqore/dist/helpers/colors';
27
+ import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
31
28
  import {
32
29
  IQorusFormField,
33
30
  IQorusFormFieldMessage,
@@ -41,6 +38,8 @@ import {
41
38
  TQorusFormOperatorValue,
42
39
  TQorusType,
43
40
  } from '@qoretechnologies/ts-toolkit';
41
+ import { resolveOptionActions, TOptionActions } from './optionActions';
42
+ import { createRendererOnlyUiTypeCheck, isRendererOnlyUiType } from './rendererTypes';
44
43
  import { cloneDeep, findKey, flatten, forEach, isEqual, isPlainObject, last } from 'lodash';
45
44
  import isArray from 'lodash/isArray';
46
45
  import map from 'lodash/map';
@@ -48,11 +47,10 @@ import reduce from 'lodash/reduce';
48
47
  import size from 'lodash/size';
49
48
  import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
50
49
  import { useMeasure, useMount, useUpdateEffect } from 'react-use';
51
- import { createContext } from 'use-context-selector';
52
50
  import styled from 'styled-components';
51
+ import { createContext } from 'use-context-selector';
53
52
  import { getDefaultValue, insertAtIndex, richtextToString } from '../../../helpers/common';
54
53
  import { getRequiredOptionMessage } from '../../../helpers/options';
55
- import { query } from '../../../utils/fetch';
56
54
  import {
57
55
  IValidationResult,
58
56
  hasAllDependenciesFullfilled,
@@ -61,6 +59,7 @@ import {
61
59
  } from '../../../helpers/validations';
62
60
  import { useQorusTypes } from '../../../hooks/useQorusTypes';
63
61
  import { useTemplates } from '../../../hooks/useTemplates';
62
+ import { query } from '../../../utils/fetch';
64
63
  import { Description } from '../../Description';
65
64
  import { FocusedEditing } from '../../FocusedEditing';
66
65
  import { SelectFormField } from '../fields/select/Select';
@@ -72,12 +71,6 @@ import {
72
71
  } from '../fields/template/TemplateField';
73
72
  import { CompactRow } from './CompactRow';
74
73
  import { CompactRowContext, ICompactRowContext } from './compactRowContext';
75
- import { CompactToolbar } from './CompactToolbar';
76
- import {
77
- CompactToolbarContext,
78
- ICompactToolbarContext,
79
- TCompactSort,
80
- } from './compactToolbarContext';
81
74
  import {
82
75
  GROUP_INDENT,
83
76
  LABEL_COL_MAX,
@@ -86,15 +79,30 @@ import {
86
79
  StyledCompactPanel,
87
80
  StyledGroupBody,
88
81
  StyledGroupHeader,
89
- StyledGroupHeaderLine,
82
+ StyledRequiredClusterBox,
83
+ StyledRequiredClusterHeader,
84
+ StyledStatusBox,
85
+ StyledStatusBoxGroupLabel,
90
86
  } from './compactRowStyles';
87
+ import { CompactToolbar } from './CompactToolbar';
88
+ import {
89
+ CompactToolbarContext,
90
+ ICompactToolbarContext,
91
+ TCompactSort,
92
+ } from './compactToolbarContext';
91
93
  import { OptionFieldMessages } from './OptionFieldMessages';
92
94
  import { OptionsHelpDialog } from './OptionsHelpDialog';
93
95
  import {
96
+ TReadFirstStatus,
97
+ getFirstAttentionOptionName,
94
98
  getOptionGroup,
95
99
  getOptionGroupLabel,
100
+ getReadFirstBucket,
96
101
  getReadFirstCompletion,
102
+ getReadFirstStatus,
97
103
  isOptionValueEmpty,
104
+ isFixedCompactAllowedValueOption,
105
+ shouldAutoCollapseCompactOption,
98
106
  } from './readFirst';
99
107
 
100
108
  // Re-export types for consumers
@@ -125,6 +133,47 @@ export interface IFormValidityData {
125
133
  invalidFields: IFormFieldValidityData[];
126
134
  }
127
135
 
136
+ /**
137
+ * Resolve a field's `inherit_props` map against the current available
138
+ * options (plus any values inherited from an outer FormEngine scope),
139
+ * producing a `{ propName: siblingValue }` hash suitable for spreading
140
+ * onto the field's renderer.
141
+ *
142
+ * For each entry `<prop-name-on-renderer> -> <sibling-field-name>`, the
143
+ * sibling's current value is forwarded under the receiving prop name.
144
+ * Lookup order:
145
+ * 1. `availableOptions[siblingName]?.value` — the field's local scope.
146
+ * 2. `inheritedFromParent[siblingName]` — the bag threaded in by an
147
+ * outer FormEngine (see the `inheritedFromParent` prop). Used when
148
+ * a composite field's `arg_schema` sub-form needs to reach a value
149
+ * on an ancestor scope — e.g. a service-method row's `body` picking
150
+ * up `language` from the parent service form.
151
+ * Missing siblings emit `undefined`, which the renderer's prop type can
152
+ * treat as "no hint".
153
+ *
154
+ * Designed to be JSON-pure: no closures, no transformations. Renderers
155
+ * decide how to use the forwarded value (e.g. the consumer-injected
156
+ * `code-editor` renderer maps a `language: "qore"` prop to its
157
+ * highlighter mode).
158
+ *
159
+ * Mirrored in qorus-ide `src/components/Field/systemOptions.tsx`; keep
160
+ * the two in sync (see qorus-ide's `.claude/CLAUDE.md`).
161
+ */
162
+ const resolveInheritProps = (
163
+ inheritProps: Record<string, string> | undefined,
164
+ availableOptions: TQorusForm | undefined,
165
+ inheritedFromParent?: Record<string, unknown>
166
+ ): Record<string, unknown> => {
167
+ if (!inheritProps) return {};
168
+ const out: Record<string, unknown> = {};
169
+ for (const propName in inheritProps) {
170
+ const siblingName = inheritProps[propName];
171
+ const localValue = (availableOptions?.[siblingName] as IQorusFormField | undefined)?.value;
172
+ out[propName] = localValue !== undefined ? localValue : inheritedFromParent?.[siblingName];
173
+ }
174
+ return out;
175
+ };
176
+
128
177
  const NegativeColorEffect: any = {
129
178
  gradient: {
130
179
  colors: { 0: 'danger', 100: 'danger:darken:2' },
@@ -153,13 +202,6 @@ const StyledCompactWrap = styled.div<{ $flush?: boolean }>`
153
202
  engage instead of overflowing the container horizontally. */
154
203
  min-width: 0;
155
204
  max-width: 100%;
156
- /* A bit of horizontal breathing room for the whole form (header + content
157
- alike). Horizontal only — top padding would break the sticky toolbar's
158
- flush pin (see the scroll-context note below). Dropped to flush via the
159
- compactFlush prop, for embeds that own their own gutters (e.g. the
160
- SchemaDefinition tab body). */
161
- padding: ${({ $flush }) => ($flush ? '0' : '0 12px')};
162
-
163
205
  /* Own our scroll context instead of borrowing the host's. The sticky toolbar
164
206
  pins to whatever scrolls; if that scroller carries top padding (e.g. a
165
207
  ReqorePanel/ReqoreContent body), sticky \`top: 0\` resolves against its
@@ -204,12 +246,16 @@ const StyledCommitDock = styled.div<{ $bg: string; $border: string }>`
204
246
  `;
205
247
 
206
248
  export const getType = (
207
- type: TQorusType | TQorusType[],
249
+ type?: TQorusType | TQorusType[],
208
250
  operators?: IOperatorsSchema,
209
251
  operator?: TOperatorValue
210
252
  ): TQorusType => {
211
253
  const finalType = getTypeFromOperator(operators, fixOperatorValue(operator)) || type;
212
- return (isArray(finalType) ? finalType[0] : finalType) as TQorusType;
254
+ const resolvedType = isArray(finalType) ? finalType[0] : finalType;
255
+
256
+ // A value can be received before its server-driven option schema. Keep the
257
+ // renderer type-safe during that transition without guessing a concrete type.
258
+ return (resolvedType || 'any') as TQorusType;
213
259
  };
214
260
 
215
261
  const getTypeFromOperator = (
@@ -226,6 +272,40 @@ export const fixOperatorValue = (operator: TOperatorValue): (string | null | und
226
272
  return isArray(operator) ? operator : [operator];
227
273
  };
228
274
 
275
+ /**
276
+ * The renderer-only predicate. Defaults to reqraft's built-ins; a FormEngine
277
+ * instance passes its own (built-ins + the `rendererOnlyUiTypes` prop) so a
278
+ * consumer's injected editors are recognised too. See `./rendererTypes`.
279
+ */
280
+ type TRendererOnlyCheck = (type?: TQorusType | TQorusType[]) => boolean;
281
+
282
+ const getOptionSchemaStorageType = (
283
+ option?: TQorusFormFieldSchema,
284
+ isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
285
+ ): TQorusType =>
286
+ ((isFixedCompactAllowedValueOption(option)
287
+ ? option?.type || option?.ui_type
288
+ : isRendererOnly(option?.ui_type)
289
+ ? option?.type || option?.ui_type
290
+ : option?.ui_type || option?.type) || 'any') as TQorusType;
291
+
292
+ const getOptionFieldStorageType = (
293
+ optionName: string,
294
+ fieldType: TQorusType | TQorusType[] | undefined,
295
+ schema?: IQorusFormSchema,
296
+ operators?: IOperatorsSchema,
297
+ operatorData?: TOperatorValue,
298
+ isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
299
+ ): TQorusType => {
300
+ const schemaOption = schema?.[optionName];
301
+ const storedType =
302
+ fieldType && !(fieldType === schemaOption?.ui_type && isRendererOnly(fieldType))
303
+ ? fieldType
304
+ : getOptionSchemaStorageType(schemaOption, isRendererOnly);
305
+
306
+ return getType(storedType as TQorusType, operators, operatorData);
307
+ };
308
+
229
309
  export const hasRequiredOptions = (options: IQorusFormSchema = {}) => {
230
310
  return !!findKey(options, (option) => option.required);
231
311
  };
@@ -237,11 +317,19 @@ export const OptionsContext = createContext<{
237
317
 
238
318
  export const fixOptions = (
239
319
  value: TQorusForm | TQorusFlatForm = {},
240
- options: IQorusFormSchema,
241
- operators?: IOperatorsSchema
320
+ options?: IQorusFormSchema,
321
+ operators?: IOperatorsSchema,
322
+ isRendererOnly: TRendererOnlyCheck = isRendererOnlyUiType
242
323
  ): TQorusForm => {
243
324
  const fixedValue = cloneDeep(value);
244
325
 
326
+ // Server-driven schemas can arrive after the persisted value. Preserve that
327
+ // value verbatim until its schema is available instead of manufacturing
328
+ // partially typed form fields that can crash the following render.
329
+ if (!size(options)) {
330
+ return fixedValue as TQorusForm;
331
+ }
332
+
245
333
  forEach(options, (option, name) => {
246
334
  if (
247
335
  option.value ||
@@ -251,7 +339,7 @@ export const fixOptions = (
251
339
  ) {
252
340
  let obj: IQorusFormField;
253
341
  const type = getType(
254
- (option.ui_type || option.type) as TQorusType,
342
+ getOptionSchemaStorageType(option, isRendererOnly),
255
343
  operators,
256
344
  (fixedValue[name] as IQorusFormField)?.op
257
345
  );
@@ -287,20 +375,35 @@ export const fixOptions = (
287
375
  let newOption = option as IQorusFormField;
288
376
 
289
377
  if (!isPlainObject(newOption) || !(newOption as IQorusFormField)?.type) {
378
+ const isUntypedEnvelope = isPlainObject(newOption) && 'value' in newOption;
379
+ const untypedFieldValue =
380
+ isUntypedEnvelope ? (newOption as IQorusFormField).value : newOption;
381
+ // Spread the original envelope first so every key it carried survives —
382
+ // rebuilding from just {type, value} silently dropped `op`, `is_expression`
383
+ // and anything else a field needs to render, which emptied the card.
290
384
  const fixedOption: IQorusFormField = {
385
+ ...(isPlainObject(newOption) ? (newOption as IQorusFormField) : {}),
291
386
  type: getType(
292
- (options?.[optionName]?.ui_type || options?.[optionName]?.type) as TQorusType,
387
+ getOptionSchemaStorageType(options?.[optionName], isRendererOnly),
293
388
  operators,
294
389
  (newOption as IQorusFormField)?.op
295
390
  ),
296
- value: newOption,
391
+ value: untypedFieldValue,
297
392
  };
298
393
 
299
- if ((newOption as IQorusFormField)?.is_expression) {
300
- fixedOption.is_expression = true;
301
- }
302
-
303
394
  newOption = fixedOption;
395
+ } else if (
396
+ newOption.type === options?.[optionName]?.ui_type &&
397
+ isRendererOnly(newOption.type)
398
+ ) {
399
+ newOption = {
400
+ ...newOption,
401
+ type: getType(
402
+ getOptionSchemaStorageType(options?.[optionName], isRendererOnly),
403
+ operators,
404
+ newOption.op
405
+ ),
406
+ };
304
407
  }
305
408
 
306
409
  if (
@@ -370,7 +473,7 @@ export const flattenOptions = (options: TQorusForm): TQorusFlatForm => {
370
473
  };
371
474
 
372
475
  export const getTypeAndCanBeNull = (
373
- type: TQorusType | TQorusType[],
476
+ type?: TQorusType | TQorusType[],
374
477
  allowed_values?: any[],
375
478
  operatorData?: TOperatorValue,
376
479
  operators?: IOperatorsSchema
@@ -456,6 +559,11 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
456
559
  /** Opt-in: fetch global templates from `system/getContextData` for this context. */
457
560
  interfaceContext?: string;
458
561
  templateFieldProps?: Partial<ITemplateFieldProps>;
562
+ /**
563
+ * Force single-value selectors to use inline dropdowns even when their
564
+ * allowed values carry descriptions.
565
+ */
566
+ forceDropdown?: boolean;
459
567
  showTypeToggle?: boolean;
460
568
  /**
461
569
  * Compact (read-first) mode: each option renders as a row with its formatted
@@ -469,6 +577,26 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
469
577
  * form should line up with the section description above it. Default `false`.
470
578
  */
471
579
  compactFlush?: boolean;
580
+ /**
581
+ * Compact mode only: this form is an EMBEDDED sub-form (e.g. an arg_schema
582
+ * field's nested form) rather than the top-level scroller. It doesn't own a
583
+ * scroll context, so the toolbar isn't sticky and its header drops the dark
584
+ * blurred backdrop (and the stacking context that goes with it) — it sits
585
+ * transparently inside the parent's edit card. Default `false`.
586
+ */
587
+ compactNested?: boolean;
588
+ /**
589
+ * Compact mode only: props forwarded to the read-first form's outer
590
+ * `ReqorePanel` — the panel that carries the sticky toolbar header and holds
591
+ * the status boxes. Spread AFTER the engine's own defaults, so anything set
592
+ * here wins (`flat`, `raised`, `minimal`, `stickyHeader`, `label`, `icon`,
593
+ * `intent`, `padded`, `size`, …). Two props merge instead of replacing, so a
594
+ * consumer can add to the panel without dismantling the form:
595
+ * - `actions` — appended after the engine's toolbar action.
596
+ * - `contentStyle` — merged over the engine's flex-column layout.
597
+ * No-op in classic (non-compact) mode.
598
+ */
599
+ compactPanelProps?: IReqorePanelProps;
472
600
  /** Compact mode only: per-group display metadata (label / icon / subtitle /
473
601
  * order) — the server only sends the bare group key. */
474
602
  groups?: Record<string, IFormEngineGroup>;
@@ -485,19 +613,84 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
485
613
  * option's name/schema/value (the context the IDE's `AiAssistanceAction`
486
614
  * captures); the consumer injects the button, reqraft stays AI-free.
487
615
  */
488
- optionActions?:
489
- | IReqorePanelAction[]
490
- | ((context: {
491
- name: string;
492
- schema: IQorusFormSchema[string];
493
- value?: TOption;
494
- }) => IReqorePanelAction[]);
616
+ optionActions?: TOptionActions;
617
+ /**
618
+ * Whether per-option injected actions collapse into the row's overflow menu
619
+ * instead of rendering as inline buttons.
620
+ *
621
+ * `'auto'` (default) collapses when the device cannot hover or the viewport is
622
+ * narrow — on a phone a hover-gated action would otherwise be unreachable, and
623
+ * a row has no width for a button strip. `'always'` / `'never'` force it, which
624
+ * consumers can use for a known-mobile surface and stories use to capture the
625
+ * collapsed state deterministically.
626
+ *
627
+ * Actions beyond `MAX_INLINE_OPTION_ACTIONS` always overflow into the menu, so
628
+ * a consumer injecting many actions can never blow out the row.
629
+ */
630
+ optionActionsCollapse?: 'auto' | 'always' | 'never';
495
631
  /**
496
632
  * SEAM (reqraft): consumer-injected field editors for types reqraft doesn't
497
633
  * ship (IDE domain fields). Keyed by field `type`/`ui_type`; forwarded through
498
634
  * `TemplateField` to the `AutoFormField` override seam.
499
635
  */
500
636
  componentOverrides?: Record<string, React.FC<any>>;
637
+ /**
638
+ * The `ui_type` names among `componentOverrides` that select a bespoke editor
639
+ * but store their value as the schema's plainer `type` (a `cron` editor stores
640
+ * a string). Declaring them here keeps the field's stored `type` correct —
641
+ * without it the renderer name is written into the value and validation and
642
+ * round-tripping both break. Merged with reqraft's own built-in list, so a
643
+ * consumer's new editor no longer needs a reqraft release to be handled.
644
+ */
645
+ rendererOnlyUiTypes?: string[];
646
+
647
+ /**
648
+ * Bag of values forwarded from an outer FormEngine scope, used as a
649
+ * fallback when a field's `inherit_props` names a sibling that isn't in
650
+ * this form's own `availableOptions`. Populated automatically when
651
+ * FormEngine renders a nested `arg_schema` sub-form (through
652
+ * AutoFormField's hash / list mount sites) so that each level accumulates
653
+ * its ancestors' inherited props. Consumers rarely set this by hand — it's
654
+ * plumbing for the inherit_props scope-forwarding contract.
655
+ */
656
+ inheritedFromParent?: Record<string, unknown>;
657
+
658
+ /**
659
+ * Opt-in: on mount, drop the user straight into the first field they must
660
+ * fill — the first empty, focusable field (in schema/sort order) that is
661
+ * `required` or a member of a still-unsatisfied one-of `required_groups`
662
+ * group. Disabled, readonly, read-only-form, and dependency-locked fields are
663
+ * skipped. In compact (read-first) mode the target row is expanded through
664
+ * the engine's own `expandedOptions` state, so its editor gains focus with no
665
+ * DOM scraping or synthetic clicks. Strictly one-shot: it fires the first
666
+ * time the form has focusable content (so an async-loaded schema is covered)
667
+ * and then never again — a later value edit, server-driven field update, or
668
+ * in-place schema reload will not re-focus; only a remount re-arms it. It also
669
+ * never steals focus from a control the user has already moved into. No-op in
670
+ * classic (non-compact) mode. Default: off.
671
+ */
672
+ autoFocusFirstRequired?: boolean;
673
+
674
+ /**
675
+ * Names of read-first rows to open on mount, in addition to whatever the
676
+ * user opens afterwards.
677
+ *
678
+ * Which rows are expanded is otherwise private to the engine, which is
679
+ * fine while expansion is only ever a click. It stops being fine when the
680
+ * expanded row is part of an address: a consumer whose field renders its
681
+ * own routable surface (a table whose rows open panes with their own URLs)
682
+ * can restore the pane from the URL, but not the row that has to be open
683
+ * for the pane to exist at all — so a pasted or reloaded link lands on a
684
+ * collapsed form.
685
+ *
686
+ * Applied once, on the first render where the schema has rows, so an
687
+ * async-loaded schema is covered. It never re-expands a row the user has
688
+ * since collapsed, and it does not participate in `expandMode: 'single'`
689
+ * accordion collapsing — the caller is naming a starting point, not
690
+ * driving the state. Only a remount re-arms it. No-op in classic
691
+ * (non-compact) mode.
692
+ */
693
+ initialExpandedOptions?: string[];
501
694
  }
502
695
 
503
696
  // Option types rendered full-width (IDE Options parity, commit 8e6b7781).
@@ -522,9 +715,12 @@ export const FormEngine = ({
522
715
  allowTemplates = true,
523
716
  interfaceContext,
524
717
  templateFieldProps,
718
+ forceDropdown,
525
719
  showTypeToggle = true,
526
720
  compact,
527
721
  compactFlush = false,
722
+ compactNested = false,
723
+ compactPanelProps,
528
724
  commitMode = 'immediate',
529
725
  expandMode = 'single',
530
726
  onCommit,
@@ -533,9 +729,35 @@ export const FormEngine = ({
533
729
  optionsLoader,
534
730
  onValidityChange,
535
731
  optionActions,
732
+ optionActionsCollapse = 'auto',
536
733
  componentOverrides,
734
+ rendererOnlyUiTypes,
735
+ inheritedFromParent,
736
+ autoFocusFirstRequired,
737
+ initialExpandedOptions,
537
738
  ...rest
538
739
  }: IFormEngineProps) => {
740
+ // Built-ins + whatever the consumer declared for its own injected editors.
741
+ const isRendererOnly = useMemo(
742
+ () => createRendererOnlyUiTypeCheck(rendererOnlyUiTypes),
743
+ [JSON.stringify(rendererOnlyUiTypes)]
744
+ );
745
+ // Pointer CAPABILITY and viewport WIDTH are different questions and both
746
+ // matter here: a hover-gated action is unreachable without a hover, and a
747
+ // phone-width row has no space for a button strip either way. Both now come
748
+ // from Reqore's context (one subscription for the whole app) rather than a
749
+ // reqraft-local matchMedia hook.
750
+ //
751
+ // `isHoverCapable` defaults to `true` in Reqore, but fall back explicitly so
752
+ // an older Reqore — where the property does not exist — degrades to "this
753
+ // pointer hovers" (the pre-existing behaviour) instead of collapsing every
754
+ // action into a menu for everyone.
755
+ const isHoverCapable = useReqoreProperty('isHoverCapable') ?? true;
756
+ const isMobile = useReqoreProperty('isMobile');
757
+ const collapseOptionActions =
758
+ optionActionsCollapse === 'always' ? true
759
+ : optionActionsCollapse === 'never' ? false
760
+ : !isHoverCapable || !!isMobile;
539
761
  const [options, setOptions] = useState<IQorusFormSchema | undefined>(rest?.options || undefined);
540
762
  // optionsLoader lifecycle: loading feeds the skeleton gate, error the banner.
541
763
  const [optionsLoading, setOptionsLoading] = useState<boolean>(!!optionsLoader && !rest?.options);
@@ -560,6 +782,10 @@ export const FormEngine = ({
560
782
  const [showInvalidOptionsOnly, setShowInvalidOptionsOnly] = useState<boolean>(false);
561
783
  // Which options are expanded into their editor (several can be open at once).
562
784
  const [expandedOptions, setExpandedOptions] = useState<string[]>([]);
785
+ // Remembers each row's last settled status box, so an actively-edited field
786
+ // stays put when its status flips (e.g. becomes valid) instead of jumping to
787
+ // another box mid-edit and stealing focus. Keyed by option name.
788
+ const settledBucket = useRef<Record<string, 'attention' | 'set' | 'optional'>>({});
563
789
  // Measured form width (not viewport — the form lives in drawers/panels of
564
790
  // arbitrary width) drives the stacked narrow layout.
565
791
  const [compactWrapRef, { width: compactWrapWidth }] = useMeasure<HTMLDivElement>();
@@ -614,9 +840,18 @@ export const FormEngine = ({
614
840
  const flashTimeout = useRef<ReturnType<typeof setTimeout>>();
615
841
  const flashOptions = useCallback((optionNames: string[], scrollToFirst = false) => {
616
842
  if (scrollToFirst && optionNames[0]) {
617
- document
618
- .querySelector(`.readfirst-row[data-field="${optionNames[0]}"]`)
619
- ?.scrollIntoView({ block: 'center', behavior: 'smooth' });
843
+ // Defer to the next frame: when this fires for a field that just changed
844
+ // panels, its row has only just re-mounted in the new box — scrolling in the
845
+ // same tick targets the stale (pre-move) layout, so the page doesn't budge.
846
+ // A rAF lets the new position settle first.
847
+ requestAnimationFrame(() => {
848
+ const target = document.querySelector<HTMLElement>(
849
+ `.readfirst-row[data-field="${optionNames[0]}"]`
850
+ );
851
+ if (typeof target?.scrollIntoView === 'function') {
852
+ target.scrollIntoView({ block: 'center', behavior: 'smooth' });
853
+ }
854
+ });
620
855
  }
621
856
  setFlashedOptions(optionNames);
622
857
  clearTimeout(flashTimeout.current);
@@ -627,6 +862,28 @@ export const FormEngine = ({
627
862
  [flashOptions]
628
863
  );
629
864
  useEffect(() => () => clearTimeout(flashTimeout.current), []);
865
+
866
+ // Follow a field across panels: when its status bucket changes — e.g. you fill
867
+ // an optional field and it jumps to Set / Needs attention — scroll to its new
868
+ // row and flash it so it's easy to keep track of. `settledBucket` holds each
869
+ // field's current panel (frozen while the field is being edited, it re-buckets
870
+ // on collapse), so diffing it after every render catches the move the instant it
871
+ // lands in the new panel. Runs every render; the diff is cheap and only fires a
872
+ // scroll on an ACTUAL move of a non-expanded field.
873
+ const prevSettledBucket = useRef<Record<string, 'attention' | 'set' | 'optional'>>({});
874
+ useEffect(() => {
875
+ if (!compact) return;
876
+ const cur = settledBucket.current;
877
+ const prev = prevSettledBucket.current;
878
+ const moved = Object.keys(cur).find(
879
+ (name) => prev[name] && prev[name] !== cur[name] && !expandedOptions.includes(name)
880
+ );
881
+ prevSettledBucket.current = { ...cur };
882
+ if (moved) {
883
+ flashOptions([moved], true);
884
+ }
885
+ });
886
+
630
887
  const compactNarrow = !!compactWrapWidth && compactWrapWidth < 480;
631
888
  // Info panels auto-open on Tier-1 content; the per-row user override sticks.
632
889
  const [infoPanelOverrides, setInfoPanelOverrides] = useState<Record<string, boolean>>({});
@@ -639,7 +896,7 @@ export const FormEngine = ({
639
896
  fields: TQorusForm | TQorusFlatForm;
640
897
  meta?: IOptionsOnChangeMeta;
641
898
  }>(() => ({
642
- fields: fixOptions(value, options || {}),
899
+ fields: fixOptions(value, options || {}, undefined, isRendererOnly),
643
900
  meta: undefined,
644
901
  }));
645
902
  const originalValue = useRef<any>();
@@ -732,7 +989,7 @@ export const FormEngine = ({
732
989
  setOptions({});
733
990
  return;
734
991
  }
735
- setLocalValue({ fields: fixOptions(value, data.data), meta: undefined });
992
+ setLocalValue({ fields: fixOptions(value, data.data, undefined, isRendererOnly), meta: undefined });
736
993
  if (!operatorsUrl) {
737
994
  setLoading(false);
738
995
  }
@@ -777,7 +1034,7 @@ export const FormEngine = ({
777
1034
  }
778
1035
  setOptions(data.data);
779
1036
  onOptionsLoaded?.(data.data);
780
- setLocalValue({ fields: fixOptions({}, data.data), meta: undefined });
1037
+ setLocalValue({ fields: fixOptions({}, data.data, undefined, isRendererOnly), meta: undefined });
781
1038
  })();
782
1039
  }
783
1040
  }, [url, customUrl]);
@@ -801,7 +1058,7 @@ export const FormEngine = ({
801
1058
  }, [operatorsUrl]);
802
1059
 
803
1060
  useUpdateEffect(() => {
804
- const fixedValue = fixOptions(value, options || {});
1061
+ const fixedValue = fixOptions(value, options || {}, undefined, isRendererOnly);
805
1062
 
806
1063
  // When the value we're receiving is the one we just emitted AND fixOptions has nothing to
807
1064
  // meaningfully add or change, skip the update. This breaks the controlled-component loop for
@@ -825,19 +1082,21 @@ export const FormEngine = ({
825
1082
  }
826
1083
 
827
1084
  setLocalValue?.({ fields: fixedValue, meta: undefined });
828
- }, [JSON.stringify(options), JSON.stringify(value)]);
1085
+ }, [JSON.stringify(options), JSON.stringify(value), isRendererOnly]);
829
1086
 
830
1087
  const handleValueChange = useCallback(
831
1088
  (optionName: string, val?: any, _type?: string, isFunction?: boolean) => {
832
1089
  setLocalValue(({ fields = {} }) => {
833
- const schemaType = (options?.[optionName]?.ui_type ||
834
- options?.[optionName]?.type) as TQorusType;
1090
+ const schemaType = getOptionSchemaStorageType(options?.[optionName], isRendererOnly);
835
1091
  const isAnyLike = schemaType === 'any' || schemaType === 'auto';
836
1092
  // For any/auto schema types, preserve the user's chosen type stored in the field
837
1093
  const resolvedSchemaType =
838
- isAnyLike && (fields[optionName] as IQorusFormField)?.type ?
839
- ((fields[optionName] as IQorusFormField).type as TQorusType)
840
- : schemaType || ((fields[optionName] as IQorusFormField)?.type as TQorusType);
1094
+ isAnyLike && (fields[optionName] as IQorusFormField)?.type
1095
+ ? ((fields[optionName] as IQorusFormField).type as TQorusType)
1096
+ : (fields[optionName] as IQorusFormField)?.type === options?.[optionName]?.ui_type &&
1097
+ isRendererOnly((fields[optionName] as IQorusFormField)?.type)
1098
+ ? schemaType
1099
+ : schemaType || ((fields[optionName] as IQorusFormField)?.type as TQorusType);
841
1100
  const type =
842
1101
  _type ||
843
1102
  getTypeAndCanBeNull(resolvedSchemaType, options?.[optionName]?.allowed_values).type;
@@ -911,6 +1170,14 @@ export const FormEngine = ({
911
1170
 
912
1171
  onSingleOptionsChange?.(optionName, updatedValue[optionName]);
913
1172
 
1173
+ if (
1174
+ compact &&
1175
+ !readOnly &&
1176
+ shouldAutoCollapseCompactOption(options?.[optionName], val)
1177
+ ) {
1178
+ setExpandedOptions((prev) => prev.filter((name) => name !== optionName));
1179
+ }
1180
+
914
1181
  return {
915
1182
  fields: updatedValue,
916
1183
  meta,
@@ -920,6 +1187,9 @@ export const FormEngine = ({
920
1187
  [
921
1188
  onSingleOptionsChange,
922
1189
  onDependableOptionChange,
1190
+ isRendererOnly,
1191
+ compact,
1192
+ readOnly,
923
1193
  JSON.stringify(options),
924
1194
  JSON.stringify(operators),
925
1195
  ]
@@ -1028,6 +1298,10 @@ export const FormEngine = ({
1028
1298
  meta: undefined,
1029
1299
  };
1030
1300
  });
1301
+ // Collapse it too: a removed field drops back to the (collapsed) Optional box
1302
+ // as a quiet addable row — if it was being edited, that editor must close
1303
+ // rather than linger as an open editor for a field that's no longer added.
1304
+ setExpandedOptions((prev) => prev.filter((name) => name !== optionName));
1031
1305
  }, []);
1032
1306
 
1033
1307
  const handleAddOptionalFieldChange = useCallback(
@@ -1036,8 +1310,7 @@ export const FormEngine = ({
1036
1310
  optionName as string,
1037
1311
  getDefaultValue(options?.[optionName as string]),
1038
1312
  getTypeAndCanBeNull(
1039
- (options?.[optionName as string]?.ui_type ||
1040
- options?.[optionName as string]?.type) as TQorusType,
1313
+ getOptionSchemaStorageType(options?.[optionName as string], isRendererOnly),
1041
1314
  options?.[optionName as string]?.allowed_values
1042
1315
  ).type
1043
1316
  );
@@ -1065,36 +1338,37 @@ export const FormEngine = ({
1065
1338
  return newValue;
1066
1339
  }
1067
1340
 
1068
- const schemaType = getType(
1341
+ const rendererType = getType(
1069
1342
  (options[optionName].ui_type || options[optionName].type) as TQorusType,
1070
1343
  operators,
1071
1344
  (option as IQorusFormField)?.op
1072
1345
  );
1346
+ const storageType = getOptionFieldStorageType(
1347
+ optionName,
1348
+ (option as IQorusFormField)?.type,
1349
+ options,
1350
+ operators,
1351
+ (option as IQorusFormField)?.op,
1352
+ isRendererOnly
1353
+ );
1073
1354
 
1074
1355
  if (!isPlainObject(option)) {
1075
1356
  return {
1076
1357
  ...newValue,
1077
1358
  [optionName]: {
1078
- type: schemaType,
1359
+ type: storageType || rendererType,
1079
1360
  value: option,
1080
1361
  },
1081
1362
  };
1082
1363
  }
1083
1364
 
1084
- // any/auto: preserve the user-picked type; otherwise normalize to the
1085
- // schema type (ui_type wins) so rendering and validation agree.
1086
- const isAnyLike = schemaType === 'any' || schemaType === 'auto';
1087
- const effectiveType =
1088
- isAnyLike && (option as IQorusFormField)?.type ?
1089
- (option as IQorusFormField).type
1090
- : schemaType;
1091
-
1092
1365
  return {
1093
1366
  ...newValue,
1094
- [optionName]: { ...(option as IQorusFormField), type: effectiveType },
1367
+ [optionName]: { ...(option as IQorusFormField), type: storageType || rendererType },
1095
1368
  };
1096
1369
  }, {});
1097
1370
  }, [
1371
+ isRendererOnly,
1098
1372
  JSON.stringify(fixedValue),
1099
1373
  JSON.stringify(options),
1100
1374
  unavailableOptionsCount.current,
@@ -1191,10 +1465,14 @@ export const FormEngine = ({
1191
1465
  const fields: IFormFieldValidityData[] = reduce(
1192
1466
  availableOptions,
1193
1467
  (result, option, optionName) => {
1194
- const type =
1195
- (option as IQorusFormField).type ||
1196
- (options?.[optionName]?.ui_type as TQorusType) ||
1197
- (options?.[optionName]?.type as TQorusType);
1468
+ const type = getOptionFieldStorageType(
1469
+ optionName,
1470
+ (option as IQorusFormField).type,
1471
+ options,
1472
+ operators,
1473
+ (option as IQorusFormField).op,
1474
+ isRendererOnly
1475
+ );
1198
1476
  const optionValue = (option as IQorusFormField).value;
1199
1477
 
1200
1478
  const isRequired = options?.[optionName]?.required;
@@ -1236,8 +1514,10 @@ export const FormEngine = ({
1236
1514
  invalidFields,
1237
1515
  };
1238
1516
  }, [
1517
+ isRendererOnly,
1239
1518
  JSON.stringify(availableOptions),
1240
1519
  JSON.stringify(options),
1520
+ JSON.stringify(operators),
1241
1521
  JSON.stringify(localValue.fields),
1242
1522
  ]);
1243
1523
 
@@ -1256,8 +1536,14 @@ export const FormEngine = ({
1256
1536
  showInvalidOptionsOnly &&
1257
1537
  isOptionValid(
1258
1538
  optionName,
1259
- (options?.[optionName]?.ui_type as TQorusType) ||
1260
- (options?.[optionName]?.type as TQorusType),
1539
+ getOptionFieldStorageType(
1540
+ optionName,
1541
+ (option as IQorusFormField).type,
1542
+ options,
1543
+ operators,
1544
+ (option as IQorusFormField).op,
1545
+ isRendererOnly
1546
+ ),
1261
1547
  (option as IQorusFormField).value
1262
1548
  )
1263
1549
  ) {
@@ -1267,7 +1553,89 @@ export const FormEngine = ({
1267
1553
  },
1268
1554
  {}
1269
1555
  );
1270
- }, [showInvalidOptionsOnly, JSON.stringify(availableOptions)]);
1556
+ }, [
1557
+ showInvalidOptionsOnly,
1558
+ isRendererOnly,
1559
+ JSON.stringify(availableOptions),
1560
+ JSON.stringify(options),
1561
+ JSON.stringify(operators),
1562
+ ]);
1563
+
1564
+ // Read-first STATUS / BOX for one option — lifted to component scope so the
1565
+ // status boxes (renderCompact) and the header's "needs attention" count share
1566
+ // exactly one definition. One-of group members travel together (bucket by the
1567
+ // group's satisfaction); everything else by its own status.
1568
+ const schemaMsgIntent = useCallback(
1569
+ (name: string): 'danger' | 'warning' | undefined => {
1570
+ const msgs = ((options?.[name] as { messages?: Array<{ intent?: string }> } | undefined)
1571
+ ?.messages || []) as Array<{ intent?: string }>;
1572
+ if (msgs.some((m) => m.intent === 'danger')) return 'danger';
1573
+ if (msgs.some((m) => m.intent === 'warning')) return 'warning';
1574
+ return undefined;
1575
+ },
1576
+ [JSON.stringify(options)]
1577
+ );
1578
+ const getOptionStatus = useCallback(
1579
+ (name: string, hidden = false): TReadFirstStatus => {
1580
+ if (hidden) return 'optional';
1581
+ const schema = options?.[name];
1582
+ const type = getOptionFieldStorageType(
1583
+ name,
1584
+ (availableOptions as TQorusForm)?.[name]?.type,
1585
+ options,
1586
+ operators,
1587
+ (availableOptions as TQorusForm)?.[name]?.op,
1588
+ isRendererOnly
1589
+ );
1590
+ const value = (availableOptions as TQorusForm)?.[name]?.value;
1591
+ const empty = isOptionValueEmpty(value);
1592
+ const reqGroups = (schema?.required_groups as string[] | undefined) || [];
1593
+ const required = !!(schema?.required || reqGroups.length);
1594
+ const covered =
1595
+ empty &&
1596
+ reqGroups.some((g) => {
1597
+ const by = requiredGroupsInfo.satisfiedBy[g];
1598
+ return !!by && by !== name;
1599
+ });
1600
+ const msgIntent = schemaMsgIntent(name);
1601
+ const invalid = (!empty && !isOptionValid(name, type, value)) || msgIntent === 'danger';
1602
+ return getReadFirstStatus({
1603
+ empty,
1604
+ required,
1605
+ covered,
1606
+ invalid,
1607
+ warned: msgIntent === 'warning',
1608
+ });
1609
+ },
1610
+ [
1611
+ isRendererOnly,
1612
+ JSON.stringify(options),
1613
+ JSON.stringify(availableOptions),
1614
+ JSON.stringify(operators),
1615
+ isOptionValid,
1616
+ requiredGroupsInfo,
1617
+ schemaMsgIntent,
1618
+ ]
1619
+ );
1620
+ const getOptionBucket = useCallback(
1621
+ (name: string, hidden = false): 'attention' | 'set' | 'optional' => {
1622
+ if (!hidden) {
1623
+ const reqGroups = (options?.[name]?.required_groups as string[] | undefined) || [];
1624
+ if (reqGroups.length) {
1625
+ return reqGroups.some((g) => !requiredGroupsInfo.satisfiedBy[g]) ? 'attention' : 'set';
1626
+ }
1627
+ }
1628
+ return getReadFirstBucket(getOptionStatus(name, hidden));
1629
+ },
1630
+ [JSON.stringify(options), requiredGroupsInfo, getOptionStatus]
1631
+ );
1632
+ // How many fields are in the "Needs attention" box — drives the header link.
1633
+ const readFirstAttentionCount = useMemo(
1634
+ () =>
1635
+ Object.keys(availableOptions || {}).filter((name) => getOptionBucket(name) === 'attention')
1636
+ .length,
1637
+ [JSON.stringify(availableOptions), getOptionBucket]
1638
+ );
1271
1639
 
1272
1640
  const getIntent = useCallback(
1273
1641
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -1346,6 +1714,120 @@ export const FormEngine = ({
1346
1714
  [expandMode]
1347
1715
  );
1348
1716
 
1717
+ // --- Caller-named starting rows (opt-in) ----------------------------------
1718
+ // `initialExpandedOptions` lets a consumer name the rows that must already
1719
+ // be open when the form first paints — the case where the expanded row is
1720
+ // part of an address rather than a click (see the prop's docs).
1721
+ //
1722
+ // One-shot for the same reason autofocus is: it fires on the first render
1723
+ // that actually has rows (so an async-loaded schema is covered) and then
1724
+ // never again, so a row the user collapses afterwards stays collapsed and a
1725
+ // later schema reload does not reopen it.
1726
+ const hasAppliedInitialExpansionRef = useRef(false);
1727
+ useEffect(() => {
1728
+ if (!initialExpandedOptions?.length || !compact || hasAppliedInitialExpansionRef.current) {
1729
+ return;
1730
+ }
1731
+ const names = Object.keys(availableOptions);
1732
+ if (!names.length) {
1733
+ return;
1734
+ }
1735
+ hasAppliedInitialExpansionRef.current = true;
1736
+ const toExpand = initialExpandedOptions.filter((name) => names.includes(name));
1737
+ if (!toExpand.length) {
1738
+ return;
1739
+ }
1740
+ setExpandedOptions((prev) => [...prev, ...toExpand.filter((name) => !prev.includes(name))]);
1741
+ }, [initialExpandedOptions, compact, availableOptions]);
1742
+
1743
+ // --- First-attention-field autofocus (opt-in) -----------------------------
1744
+ // With `autoFocusFirstRequired`, drop the user straight into the first field
1745
+ // they must fix. We reuse the engine's own ordering (`availableOptions`), the
1746
+ // very same `getOptionBucket` the status boxes use to decide "needs attention"
1747
+ // (so we cover empty-required, unsatisfied one-of, AND filled-but-invalid rows
1748
+ // without ever drifting from what the user sees), and dependency gating
1749
+ // (`dependencyLockedNames`), then expand the target row through
1750
+ // `expandedOptions` — the row's editor-focus effect does the rest. No DOM
1751
+ // scraping, no synthetic clicks, no polling.
1752
+ //
1753
+ // Strictly one-shot: it fires the first time the form has focusable content
1754
+ // (so an async-loaded schema is still covered — the empty first pass doesn't
1755
+ // count), then never again for the life of this instance. A server-driven
1756
+ // field update or an in-place schema reload will NOT re-grab focus; only a
1757
+ // genuine remount (a fresh instance) auto-focuses again, which is the intended
1758
+ // on-mount behaviour.
1759
+ const hasAutoFocusedRef = useRef(false);
1760
+ // The field expanded programmatically for autofocus. A ref (not state) so
1761
+ // CompactRow's 60ms focus timer reads the current value regardless of render
1762
+ // batching; CompactRow focuses this one with `preventScroll` so an off-screen
1763
+ // (or below-the-fold) form is never scrolled into view on mount.
1764
+ const autoFocusNameRef = useRef<string | undefined>(undefined);
1765
+ useEffect(() => {
1766
+ if (!autoFocusFirstRequired || !compact || !options) {
1767
+ return;
1768
+ }
1769
+ if (hasAutoFocusedRef.current) {
1770
+ return;
1771
+ }
1772
+
1773
+ const orderedNames = Object.keys(availableOptions);
1774
+ if (!orderedNames.length) {
1775
+ return;
1776
+ }
1777
+
1778
+ // Never grab focus from a control the user is already in — including a field
1779
+ // *inside* this form. If a slow/async schema lets the user start typing
1780
+ // before this first-required scan runs, we must not steal their caret. On a
1781
+ // clean mount focus rests on the body, so the intended "drop into the first
1782
+ // field" still fires. Bailing here leaves `hasAutoFocusedRef` false, so it
1783
+ // retries once focus is free.
1784
+ const active = document.activeElement as HTMLElement | null;
1785
+ if (active && active !== document.body) {
1786
+ return;
1787
+ }
1788
+
1789
+ const target = getFirstAttentionOptionName(orderedNames, (fieldName) => {
1790
+ const schema = options[fieldName];
1791
+ if (!schema) {
1792
+ return undefined;
1793
+ }
1794
+ return {
1795
+ focusable:
1796
+ !readOnly &&
1797
+ !schema.disabled &&
1798
+ !(schema as { readonly?: boolean }).readonly &&
1799
+ !dependencyLockedNames.includes(fieldName),
1800
+ // Single source of truth: the same bucket the read-first status boxes
1801
+ // show, so a filled-but-invalid required row is a target too.
1802
+ needsAttention: getOptionBucket(fieldName) === 'attention',
1803
+ };
1804
+ });
1805
+
1806
+ // Mark done even when nothing needs attention, so later value edits or an
1807
+ // in-place schema reload never re-scan or re-focus. Only a remount arms it
1808
+ // again.
1809
+ hasAutoFocusedRef.current = true;
1810
+
1811
+ if (target) {
1812
+ // Set the ref before the state update so CompactRow's focus timer sees it.
1813
+ autoFocusNameRef.current = target;
1814
+ setExpandedOptions((prev) =>
1815
+ prev.includes(target) ? prev
1816
+ : expandMode === 'multi' ? [...prev, target]
1817
+ : [target]
1818
+ );
1819
+ }
1820
+ }, [
1821
+ autoFocusFirstRequired,
1822
+ compact,
1823
+ options,
1824
+ availableOptions,
1825
+ readOnly,
1826
+ dependencyLockedNames,
1827
+ getOptionBucket,
1828
+ expandMode,
1829
+ ]);
1830
+
1349
1831
  // Read-first completion summary (how many shown options have a value set),
1350
1832
  // surfaced as a progress meter at the top of the compact form.
1351
1833
  const readFirstCompletion = useMemo(
@@ -1395,10 +1877,10 @@ export const FormEngine = ({
1395
1877
  next[optionName] = value as IQorusFormField;
1396
1878
  }
1397
1879
  });
1398
- return { fields: fixOptions(next, options || {}, operators), meta: undefined };
1880
+ return { fields: fixOptions(next, options || {}, operators, isRendererOnly), meta: undefined };
1399
1881
  });
1400
1882
  setRequiredOnly(false);
1401
- }, [JSON.stringify(options), JSON.stringify(operators)]);
1883
+ }, [JSON.stringify(options), JSON.stringify(operators), isRendererOnly]);
1402
1884
 
1403
1885
  const getCustomMenuTemplateItems = useCallback<(optionName: string) => TCustomTemplateItems>(
1404
1886
  (optionName) => {
@@ -1428,22 +1910,72 @@ export const FormEngine = ({
1428
1910
  suppressSchemaMessages?: boolean
1429
1911
  ) => {
1430
1912
  const operatorParts = fixOperatorValue(other.op);
1913
+ // The RENDERER type for this row — which editor mounts. Distinct from the
1914
+ // storage type on the value envelope: a `richtext` field stores a string
1915
+ // but must render the richtext editor, so `ui_type` wins here.
1916
+ //
1917
+ // The exception is an any-like `ui_type`. Those schemas say
1918
+ // `ui_type: 'any'` precisely so the user can pick a concrete type, and
1919
+ // that pick lives on the field's stored `type` — letting 'any' win would
1920
+ // pin the row to the untyped editor forever.
1921
+ //
1922
+ // The trailing fallbacks keep the row rendering when a server-driven
1923
+ // schema has not arrived yet (`type` undefined) instead of crashing.
1924
+ const optionSchema = options?.[optionName];
1925
+ const schemaUiType = optionSchema?.ui_type as TQorusType;
1926
+ const uiTypeIsAnyLike = schemaUiType === 'any' || schemaUiType === 'auto';
1927
+ const resolvedType =
1928
+ (isFixedCompactAllowedValueOption(optionSchema)
1929
+ ? getOptionSchemaStorageType(optionSchema, isRendererOnly)
1930
+ : schemaUiType && !uiTypeIsAnyLike
1931
+ ? schemaUiType
1932
+ : undefined) ||
1933
+ type ||
1934
+ schemaUiType ||
1935
+ (optionSchema?.type as TQorusType) ||
1936
+ 'any';
1937
+ const effectiveForceDropdown =
1938
+ forceDropdown ??
1939
+ (templateFieldProps as { forceDropdown?: boolean } | undefined)?.forceDropdown;
1940
+
1431
1941
  return (
1432
1942
  <>
1433
- {(suppressSchemaMessages ? [] : (options?.[optionName] as any)?.messages || []).map(
1434
- ({ intent, title, content }: any, index: number) => (
1943
+ {(() => {
1944
+ const schemaMsgs = (
1945
+ suppressSchemaMessages ?
1946
+ []
1947
+ : (options?.[optionName] as any)?.messages || []) as {
1948
+ intent?: string;
1949
+ title?: string;
1950
+ content?: string;
1951
+ }[];
1952
+ if (!schemaMsgs.length) return null;
1953
+ const items = schemaMsgs.map(({ intent, title, content }, index) => (
1435
1954
  <ReqoreMessage
1436
- intent={intent}
1955
+ intent={intent as never}
1437
1956
  title={title}
1438
1957
  key={title || index}
1439
1958
  opaque={false}
1440
1959
  size='small'
1441
- margin='bottom'
1960
+ // Compact: flat (no border) to match the read-row info panels;
1961
+ // classic forms keep the bordered, bottom-margined message.
1962
+ flat={compact || undefined}
1963
+ margin={compact ? undefined : 'bottom'}
1442
1964
  >
1443
1965
  {content}
1444
1966
  </ReqoreMessage>
1445
- )
1446
- )}
1967
+ ));
1968
+ // Compact: stack them in a 4px-gap panel so a field's messages look
1969
+ // identical whether the row is collapsed (read panel) or expanded.
1970
+ return compact ?
1971
+ <div
1972
+ className='options-readfirst-info-panel'
1973
+ style={{ display: 'flex', flexFlow: 'column', gap: 4, marginBottom: 8 }}
1974
+ >
1975
+ {items}
1976
+ </div>
1977
+ : <>{items}</>;
1978
+ })()}
1447
1979
  {operators && size(operators) ?
1448
1980
  <>
1449
1981
  <ReqoreControlGroup fill wrap className='operators'>
@@ -1498,6 +2030,46 @@ export const FormEngine = ({
1498
2030
  <TemplateField
1499
2031
  fluid
1500
2032
  {...(options?.[optionName] as any)}
2033
+ // qorus#347-followup: resolve the field's `inherit_props` against
2034
+ // the CURRENT sibling values (with a fallback to
2035
+ // `inheritedFromParent` — the bag threaded in from an outer
2036
+ // FormEngine when this scope is an `arg_schema` sub-form),
2037
+ // threading each entry as a top-level prop. Each
2038
+ // `<prop-name>: <sibling-field-name>` mapping copies the
2039
+ // sibling's value onto the rendered field's renderer — e.g. a
2040
+ // `code-editor` with `inherit_props: { language: 'language' }`
2041
+ // picks up the live `language` value as a `language` prop without
2042
+ // a schema refetch. Spread AFTER `{...options?.[optionName]}` so
2043
+ // the runtime value wins over any schema-defined default of the
2044
+ // same key. Mirrored in qorus-ide's `systemOptions.tsx`; see the
2045
+ // CLAUDE.md rule there.
2046
+ {...resolveInheritProps(
2047
+ options?.[optionName]?.inherit_props,
2048
+ availableOptions,
2049
+ inheritedFromParent
2050
+ )}
2051
+ // qorus#347-followup (scope forwarding): merge accumulated
2052
+ // inheritance (`inheritedFromParent`) with THIS field's freshly
2053
+ // resolved inherit_props, and pass down as a single bag so any
2054
+ // nested `arg_schema` sub-form (mounted by AutoFormField for
2055
+ // `hash` / `free-hash` / list-of-hash fields) sees every value
2056
+ // the ancestor chain forwarded. This is the plumbing that lets a
2057
+ // service-method row's `body` sub-field pick up the parent
2058
+ // service form's `language` — the parent field declares
2059
+ // `inherit_props: { language: 'language' }`, the list renderer
2060
+ // forwards it into each row, and the row's body resolves against
2061
+ // the accumulated bag.
2062
+ inheritedFromParent={{
2063
+ ...inheritedFromParent,
2064
+ ...resolveInheritProps(
2065
+ options?.[optionName]?.inherit_props,
2066
+ availableOptions,
2067
+ inheritedFromParent
2068
+ ),
2069
+ }}
2070
+ // Propagate compact so an arg_schema field renders a COMPACT sub-form
2071
+ // (consistent with the parent) rather than the classic FormEngine.
2072
+ compact={compact}
1501
2073
  // SEAM: forwarded through TemplateField's rest-spread to AutoFormField,
1502
2074
  // which renders consumer-injected editors by field type/ui_type.
1503
2075
  componentOverrides={componentOverrides}
@@ -1506,16 +2078,27 @@ export const FormEngine = ({
1506
2078
  // reqraft: form-level expression fields get the Visual/Text shell
1507
2079
  // (DPQL text mode); opt out per-form via `templateFieldProps`.
1508
2080
  allowTextExpressions
2081
+ // A fixed allowed-value field still needs its selector even when
2082
+ // arbitrary custom values are forbidden. TemplateField uses this
2083
+ // flag to decide whether to mount AutoFormField at all; treating
2084
+ // `supports_custom_values: false` as "no input" left dependent
2085
+ // delivery fields (Discord server/channel, for example) as empty
2086
+ // control groups. The allowed-values renderer itself enforces the
2087
+ // fixed catalogue and never exposes free-form entry.
1509
2088
  allowCustomValues={
1510
- options?.[optionName]?.supports_custom_values !== false && type !== 'any'
2089
+ isFixedCompactAllowedValueOption(optionSchema) ||
2090
+ (options?.[optionName]?.supports_custom_values !== false && resolvedType !== 'any')
1511
2091
  }
1512
2092
  templates={templates.value}
1513
2093
  {...getTypeAndCanBeNull(
1514
- type as TQorusType,
2094
+ // The RENDERER type picks the editor — the storage type lives on
2095
+ // the value envelope. Passing storage here rendered a `richtext`
2096
+ // field as a plain string input.
2097
+ resolvedType,
1515
2098
  options?.[optionName]?.allowed_values,
1516
2099
  other.op
1517
2100
  )}
1518
- ui_type={type}
2101
+ ui_type={resolvedType}
1519
2102
  name={optionName}
1520
2103
  uniqueName={`${uniqueName ? `${uniqueName}.` : `${name ? `${name}.` : ''}`}${optionName}`}
1521
2104
  onChange={
@@ -1534,6 +2117,7 @@ export const FormEngine = ({
1534
2117
  default_value={getDefaultValue(options?.[optionName])}
1535
2118
  isDefaultTemplate={options?.[optionName]?.default_view === 'template'}
1536
2119
  allowed_values={options?.[optionName]?.allowed_values}
2120
+ allowed_values_creatable={options?.[optionName]?.allowed_values_creatable}
1537
2121
  disabled={
1538
2122
  options?.[optionName]?.disabled ||
1539
2123
  readOnly ||
@@ -1551,12 +2135,13 @@ export const FormEngine = ({
1551
2135
  : undefined
1552
2136
  }
1553
2137
  {...templateFieldProps}
2138
+ forceDropdown={effectiveForceDropdown}
1554
2139
  />
1555
2140
  <OptionFieldMessages
1556
2141
  schema={options || {}}
1557
2142
  allOptions={availableOptions}
1558
2143
  name={optionName}
1559
- option={{ type, ...other }}
2144
+ option={{ type: resolvedType, ...other }}
1560
2145
  getType={getTypeForOption}
1561
2146
  />
1562
2147
  {operators && size(operators) && size(other.op) ?
@@ -1571,7 +2156,7 @@ export const FormEngine = ({
1571
2156
  intent='info'
1572
2157
  label={
1573
2158
  other.value ?
1574
- type === 'richtext' ?
2159
+ resolvedType === 'richtext' ?
1575
2160
  richtextToString(other.value)
1576
2161
  : JSON.stringify(other.value)
1577
2162
  : ''
@@ -1594,6 +2179,7 @@ export const FormEngine = ({
1594
2179
  uniqueName,
1595
2180
  componentOverrides,
1596
2181
  templateFieldProps,
2182
+ forceDropdown,
1597
2183
  availableOptions,
1598
2184
  fixedValue,
1599
2185
  // Depend on the specific `rest` values used, not the whole `rest` object
@@ -1643,6 +2229,7 @@ export const FormEngine = ({
1643
2229
  showFieldTypes,
1644
2230
  showAllDescriptions,
1645
2231
  expandedOptions,
2232
+ autoFocusNameRef,
1646
2233
  highlightedOptions,
1647
2234
  flashedOptions,
1648
2235
  infoPanelOverrides,
@@ -1663,6 +2250,8 @@ export const FormEngine = ({
1663
2250
  getTypeForOption,
1664
2251
  isOptionValid,
1665
2252
  confirmAction,
2253
+ optionActions,
2254
+ collapseOptionActions,
1666
2255
  renderOption,
1667
2256
  theme,
1668
2257
  cText,
@@ -1687,6 +2276,7 @@ export const FormEngine = ({
1687
2276
  showFieldTypes,
1688
2277
  showAllDescriptions,
1689
2278
  expandedOptions,
2279
+ autoFocusNameRef,
1690
2280
  highlightedOptions,
1691
2281
  flashedOptions,
1692
2282
  infoPanelOverrides,
@@ -1707,6 +2297,8 @@ export const FormEngine = ({
1707
2297
  getTypeForOption,
1708
2298
  isOptionValid,
1709
2299
  confirmAction,
2300
+ optionActions,
2301
+ collapseOptionActions,
1710
2302
  renderOption,
1711
2303
  theme,
1712
2304
  cText,
@@ -1726,6 +2318,7 @@ export const FormEngine = ({
1726
2318
  () => ({
1727
2319
  readOnly,
1728
2320
  invalidCount: size(validityData.invalidFields),
2321
+ attentionCount: readFirstAttentionCount,
1729
2322
  completion: readFirstCompletion,
1730
2323
  showInvalidOnly: showInvalidOptionsOnly,
1731
2324
  onToggleInvalidOnly: handleToggleInvalidOnly,
@@ -1751,6 +2344,7 @@ export const FormEngine = ({
1751
2344
  [
1752
2345
  readOnly,
1753
2346
  validityData,
2347
+ readFirstAttentionCount,
1754
2348
  readFirstCompletion,
1755
2349
  showInvalidOptionsOnly,
1756
2350
  handleToggleInvalidOnly,
@@ -1776,7 +2370,13 @@ export const FormEngine = ({
1776
2370
  ]
1777
2371
  );
1778
2372
 
1779
- const compactHeaderActions = useMemo(() => [{ as: CompactToolbar, responsive: false }], []);
2373
+ // The engine's own toolbar action, plus whatever the consumer added through
2374
+ // `compactPanelProps.actions` — appended, so an outside action can never
2375
+ // knock the form's toolbar out of the header.
2376
+ const compactHeaderActions = useMemo(
2377
+ () => [{ as: CompactToolbar, responsive: false }, ...(compactPanelProps?.actions || [])],
2378
+ [compactPanelProps?.actions]
2379
+ );
1780
2380
  const renderCompact = () => {
1781
2381
  const headerBg = `${changeDarkness(getMainBackgroundColor(theme), 0.02)}${percentToHexAlpha(88)}`;
1782
2382
  // Toolbar filters narrow the listed rows; the meter reflects the full set.
@@ -1809,15 +2409,18 @@ export const FormEngine = ({
1809
2409
  pushRow(optionName, false);
1810
2410
  }
1811
2411
  });
1812
- // When searching, also surface matching hidden optional fields (not yet
1813
- // added) so the search spans the whole schema, not just the visible rows.
1814
- if (query) {
1815
- forEach(filteredOptions, (_schema, optionName) => {
1816
- if (matchesQuery(optionName)) {
1817
- pushRow(optionName, true);
1818
- }
1819
- });
1820
- }
2412
+ // Surface EVERY not-yet-added optional field as an addable (hidden) row, so
2413
+ // the whole schema is browsable inline they all land in the Optional box
2414
+ // (hidden ⇒ 'optional' bucket) instead of being buried in the Fields menu.
2415
+ // Narrowed by the same filters as the listed rows (search query + required-
2416
+ // only). availableOptions (listed) and filteredOptions (these) are disjoint —
2417
+ // the former is built from fixedValue keys, the latter excludes them — so a
2418
+ // field is never both a listed and a hidden row.
2419
+ forEach(filteredOptions, (_schema, optionName) => {
2420
+ if (matchesFilters(optionName)) {
2421
+ pushRow(optionName, true);
2422
+ }
2423
+ });
1821
2424
 
1822
2425
  // User sort (Fields menu → "Sort by"), applied WITHIN each group so the
1823
2426
  // group sections and the required-group rails are preserved. Schema order is
@@ -1832,7 +2435,14 @@ export const FormEngine = ({
1832
2435
  const isFieldInvalid = (name: string) =>
1833
2436
  !isOptionValid(
1834
2437
  name,
1835
- (options?.[name]?.ui_type || options?.[name]?.type) as TQorusType,
2438
+ getOptionFieldStorageType(
2439
+ name,
2440
+ (shownOptions as TQorusForm)[name]?.type,
2441
+ options,
2442
+ operators,
2443
+ (shownOptions as TQorusForm)[name]?.op,
2444
+ isRendererOnly
2445
+ ),
1836
2446
  (shownOptions as TQorusForm)[name]?.value
1837
2447
  );
1838
2448
  const comparator = (a: { name: string }, b: { name: string }): number => {
@@ -1863,6 +2473,67 @@ export const FormEngine = ({
1863
2473
  return groupOrder.indexOf(a) - groupOrder.indexOf(b);
1864
2474
  });
1865
2475
 
2476
+ // Read-first STATUS per row → one of three boxes (Needs attention / Set /
2477
+ // Optional), via the component-scope getOptionBucket (shared with the header
2478
+ // count and the row dot, so box ↔ dot ↔ header always agree). Buckets keep
2479
+ // schema-group order (thin sub-labels) and the required-group clustering: an
2480
+ // unmet one-of group's members all land in attention and still rail up.
2481
+ type TRowEntry = { name: string; hidden: boolean };
2482
+ type TBucketKey = 'attention' | 'set' | 'optional';
2483
+ const buckets: Record<TBucketKey, Record<string, TRowEntry[]>> = {
2484
+ attention: {},
2485
+ set: {},
2486
+ optional: {},
2487
+ };
2488
+ const bucketGroups: Record<TBucketKey, string[]> = { attention: [], set: [], optional: [] };
2489
+ // Freeze the box of any field currently being edited (or whose one-of group
2490
+ // has an edited member) to its last settled box — so finishing an edit that
2491
+ // flips its status doesn't remount it in another box and steal focus.
2492
+ const stableBucketOf = (entry: TRowEntry): TBucketKey => {
2493
+ const fresh = getOptionBucket(entry.name, entry.hidden);
2494
+ const groupBeingEdited =
2495
+ !entry.hidden &&
2496
+ ((options?.[entry.name]?.required_groups as string[] | undefined) || []).some((g) =>
2497
+ (requiredGroupsInfo.members[g] || []).some((m) => expandedOptions.includes(m))
2498
+ );
2499
+ if (!entry.hidden && (expandedOptions.includes(entry.name) || groupBeingEdited)) {
2500
+ const memo = settledBucket.current[entry.name];
2501
+ if (memo) return memo;
2502
+ }
2503
+ settledBucket.current[entry.name] = fresh;
2504
+ return fresh;
2505
+ };
2506
+ groupKeys.forEach((groupName) => {
2507
+ grouped[groupName].forEach((entry) => {
2508
+ const b = stableBucketOf(entry);
2509
+ if (!buckets[b][groupName]) {
2510
+ buckets[b][groupName] = [];
2511
+ bucketGroups[b].push(groupName);
2512
+ }
2513
+ buckets[b][groupName].push(entry);
2514
+ });
2515
+ });
2516
+ const bucketCount = (b: TBucketKey) =>
2517
+ bucketGroups[b].reduce((n, g) => n + buckets[b][g].length, 0);
2518
+ // 'general' / 'optional' are the SYNTHETIC fallback group keys getOptionGroup
2519
+ // assigns to fields with no explicit `group` — printing a "General"/"Optional"
2520
+ // sub-label for those is just noise, so suppress it. BUT a consumer may also
2521
+ // use 'general' as a REAL group (defining it in the `groups` prop and tagging
2522
+ // fields with `group: 'general'`); in that case it's a named group like any
2523
+ // other and DOES get its sub-label.
2524
+ const showGroupSubLabel = (groupName: string) =>
2525
+ (groupName !== 'general' && groupName !== 'optional') || !!groups?.[groupName];
2526
+ const STATUS_BOXES: Array<{
2527
+ key: TBucketKey;
2528
+ label: string;
2529
+ intent?: 'warning' | 'success';
2530
+ icon: IReqoreIconName;
2531
+ }> = [
2532
+ { key: 'attention', label: 'Needs attention', intent: 'warning', icon: 'ErrorWarningLine' },
2533
+ { key: 'set', label: 'Set', intent: 'success', icon: 'CheckLine' },
2534
+ { key: 'optional', label: 'Optional', icon: 'CheckboxBlankCircleLine' },
2535
+ ];
2536
+
1866
2537
  // Build the rows for one group: contiguous required-group members are pulled
1867
2538
  // together at the first member's slot and rendered as a connected rail (flat
1868
2539
  // rows — no wrapper — so the value surface applies normally; the rail + nodes
@@ -1880,7 +2551,7 @@ export const FormEngine = ({
1880
2551
  optionField={
1881
2552
  entry.hidden ?
1882
2553
  ({
1883
- type: (options?.[entry.name]?.ui_type || options?.[entry.name]?.type) as TQorusType,
2554
+ type: getOptionSchemaStorageType(options?.[entry.name], isRendererOnly),
1884
2555
  value: undefined,
1885
2556
  } as IQorusFormField)
1886
2557
  : ((shownOptions as TQorusForm)[entry.name] as IQorusFormField)
@@ -1891,7 +2562,9 @@ export const FormEngine = ({
1891
2562
  clusterLast={clusterLast}
1892
2563
  />
1893
2564
  );
1894
- if (compactNarrow) return names.map((entry) => renderRow(entry, false));
2565
+ // (Clustering runs in narrow mode too now — the "One of the below is
2566
+ // required" box wraps the members regardless of width; it no longer relies
2567
+ // on a contiguous rail.)
1895
2568
  const emitted = new Set<string>();
1896
2569
  const groupOf = (name: string) =>
1897
2570
  (options?.[name]?.required_groups as string[] | undefined)?.[0];
@@ -1902,9 +2575,27 @@ export const FormEngine = ({
1902
2575
  const memberEntries = names.filter((e) => !e.hidden && groupOf(e.name) === grp);
1903
2576
  if (memberEntries.length < 2) return renderRow(entry, false);
1904
2577
  emitted.add(grp);
1905
- return memberEntries.map((e, idx) =>
2578
+ const railed = memberEntries.map((e, idx) =>
1906
2579
  renderRow(e, true, idx === 0, idx === memberEntries.length - 1)
1907
2580
  );
2581
+ // An UNMET one-of group gets the explicit "One of the below is required"
2582
+ // box (the Focus cluster). A met group needs no banner — the rail + the
2583
+ // "Covers"/"Covered by" chips already say which member satisfies it.
2584
+ if (requiredGroupsInfo.satisfiedBy[grp]) return railed;
2585
+ return (
2586
+ <StyledRequiredClusterBox
2587
+ key={grp}
2588
+ className='options-readfirst-required-cluster'
2589
+ $border={`${cWarning}33`}
2590
+ $tint={`${cWarning}0d`}
2591
+ >
2592
+ <StyledRequiredClusterHeader $color={cWarning}>
2593
+ <ReqoreIcon icon='LinkM' size='11px' style={{ color: cWarning }} />
2594
+ One of the below is required
2595
+ </StyledRequiredClusterHeader>
2596
+ {railed}
2597
+ </StyledRequiredClusterBox>
2598
+ );
1908
2599
  });
1909
2600
  };
1910
2601
 
@@ -1922,19 +2613,37 @@ export const FormEngine = ({
1922
2613
  <StyledCompactWrap
1923
2614
  ref={setCompactWrap}
1924
2615
  className='options-readfirst-scroll'
1925
- $flush={compactFlush}
2616
+ // A nested sub-form sits flush inside the parent's card — no outer
2617
+ // gutter (the card already provides the breathing room).
2618
+ $flush={compactFlush || compactNested}
1926
2619
  >
1927
2620
  <StyledCompactPanel
1928
- $headerBg={headerBg}
2621
+ // The top-level form scrolls, so its toolbar STICKS and carries a
2622
+ // dark blurred backdrop so content ghosts cleanly beneath it. A
2623
+ // nested (arg_schema) sub-form owns no scroll context — drop the
2624
+ // sticky, the backdrop, and the stacking context so its header is
2625
+ // transparent inside the parent's card.
2626
+ $headerBg={compactNested ? 'transparent' : headerBg}
2627
+ $nested={compactNested}
1929
2628
  flat
1930
- stickyHeader
1931
- padded={false}
2629
+ raised
2630
+ minimal
2631
+ // No panel background: the form sits transparently on whatever
2632
+ // hosts it (page, drawer, or — for an arg_schema field — the
2633
+ // parent's edit card) instead of stacking its own dark surface.
2634
+ // The status boxes keep their own tints; the sticky toolbar keeps
2635
+ // its blurred header via the $headerBg override.
2636
+ stickyHeader={!compactNested}
2637
+ // Consumer overrides land last so they win over every default
2638
+ // above; `actions` and `contentStyle` below merge rather than
2639
+ // replace (see `compactPanelProps`).
2640
+ {...compactPanelProps}
1932
2641
  actions={compactHeaderActions}
1933
2642
  contentStyle={{
1934
2643
  display: 'flex',
1935
2644
  flexFlow: 'column',
1936
2645
  gap: '10px',
1937
- padding: '0 0 12px',
2646
+ ...compactPanelProps?.contentStyle,
1938
2647
  }}
1939
2648
  >
1940
2649
  {size(groupKeys) === 0 ?
@@ -1943,78 +2652,61 @@ export const FormEngine = ({
1943
2652
  </ReqoreMessage>
1944
2653
  : null}
1945
2654
 
1946
- {groupKeys.map((groupName) => {
1947
- const names = grouped[groupName];
1948
- const groupConfig = groups?.[groupName];
1949
- const invalidCount = names.filter(
1950
- (entry) =>
1951
- !entry.hidden &&
1952
- !isOptionValid(
1953
- entry.name,
1954
- (options?.[entry.name]?.ui_type as TQorusType) ||
1955
- (options?.[entry.name]?.type as TQorusType),
1956
- (shownOptions as TQorusForm)[entry.name]?.value
1957
- )
1958
- ).length;
1959
-
2655
+ {STATUS_BOXES.map((box) => {
2656
+ const groupsInBox = bucketGroups[box.key];
2657
+ const count = bucketCount(box.key);
2658
+ if (!count) return null;
2659
+ const accent =
2660
+ box.key === 'attention' ? cWarning
2661
+ : box.key === 'set' ? cSuccess
2662
+ : cMuted;
2663
+ // The muted "Optional" box reads as a quieter, recessed
2664
+ // surface — a touch darker than the page rather than the
2665
+ // faint grey tint the accent would give.
2666
+ const boxBg =
2667
+ box.key === 'optional' ?
2668
+ changeDarkness(getMainBackgroundColor(theme), 0.06)
2669
+ : undefined;
1960
2670
  return (
1961
- <ReqorePanel
1962
- key={groupName}
2671
+ <StyledStatusBox
2672
+ $accent={accent}
2673
+ $bg={boxBg}
2674
+ key={box.key}
1963
2675
  flat
1964
2676
  minimal
1965
2677
  collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}
1966
2678
  collapsible
2679
+ // The Optional box now holds every not-yet-added field, so
2680
+ // it starts COLLAPSED to keep the form focused on what's in
2681
+ // use. But a SEARCH must surface matching addable fields —
2682
+ // and ReqorePanel unmounts collapsed content — so force it
2683
+ // open whenever a query is active. (isCollapsed is the
2684
+ // panel's controllable state; manual toggling still works
2685
+ // when no query is set.)
2686
+ isCollapsed={box.key === 'optional' && !query}
1967
2687
  label={
1968
2688
  <StyledGroupHeader>
1969
- <ReqoreP effect={{ weight: 'bold' }} size='big'>
1970
- {getOptionGroupLabel(groupName, groups)}
2689
+ <ReqoreP effect={{ weight: 'bold' }} size='normal'>
2690
+ {box.label}
1971
2691
  </ReqoreP>
1972
- <StyledGroupHeaderLine $color={cGroupLine} />
1973
- <ReqoreButton
1974
- readOnly
1975
- size='tiny'
2692
+ <ReqoreTag
2693
+ size='small'
1976
2694
  minimal
1977
- flat
1978
2695
  compact
1979
- effect={{uppercase: true, spaced: 1}}
1980
- {...(groupName === 'optional' ? { label: `${names.length} optional` }
1981
- : invalidCount ?
1982
- {
1983
- label: `${invalidCount} to resolve`,
1984
- intent: 'warning' as const,
1985
- icon: 'ErrorWarningLine' as const,
1986
- }
1987
- : {
1988
- label: 'All set',
1989
- intent: 'success' as const,
1990
- icon: 'CheckLine' as const,
1991
- })}
2696
+ intent={box.intent}
2697
+ label={String(count)}
1992
2698
  />
1993
2699
  </StyledGroupHeader>
1994
2700
  }
1995
- icon={groupConfig?.icon}
2701
+ icon={box.icon}
1996
2702
  className='options-readfirst-group'
1997
2703
  padded={false}
1998
2704
  contentStyle={{ padding: '4px 4px 6px' }}
1999
2705
  >
2000
- {groupConfig?.subtitle ?
2001
- <ReqoreP
2002
- size='small'
2003
- effect={{ opacity: 0.6 }}
2004
- // Indent to the same content line as the rows (StyledGroupBody's
2005
- // `margin-left` clamp) so the subtitle sits tucked under the group
2006
- // name instead of at the panel edge — and clears the group's
2007
- // vertical rule (left:16px) rather than crossing it.
2008
- style={{
2009
- marginTop: 2,
2010
- marginBottom: 8,
2011
- marginLeft: GROUP_INDENT,
2012
- paddingRight: 10,
2013
- }}
2014
- >
2015
- {groupConfig.subtitle}
2016
- </ReqoreP>
2017
- : null}
2706
+ {/* ONE group body per box: every field block (and the
2707
+ thin schema sub-labels) is a direct flex child, so the
2708
+ inter-field gap is identical everywhere — including
2709
+ across schema-group boundaries. */}
2018
2710
  <StyledGroupBody
2019
2711
  $divider={cDivider}
2020
2712
  $hover={cHover}
@@ -2024,9 +2716,35 @@ export const FormEngine = ({
2024
2716
  $lineColor={cGroupLine}
2025
2717
  className={compactNarrow ? 'readfirst-narrow' : undefined}
2026
2718
  >
2027
- {renderGroupRows(names)}
2719
+ {groupsInBox.map((groupName) => {
2720
+ const groupConfig = groups?.[groupName];
2721
+ return (
2722
+ <React.Fragment key={groupName}>
2723
+ {showGroupSubLabel(groupName) ?
2724
+ <StyledStatusBoxGroupLabel>
2725
+ {getOptionGroupLabel(groupName, groups)}
2726
+ </StyledStatusBoxGroupLabel>
2727
+ : null}
2728
+ {showGroupSubLabel(groupName) && groupConfig?.subtitle ?
2729
+ <ReqoreP
2730
+ size='small'
2731
+ effect={{ opacity: 0.6 }}
2732
+ style={{
2733
+ marginTop: 2,
2734
+ marginBottom: 8,
2735
+ marginLeft: GROUP_INDENT,
2736
+ paddingRight: 10,
2737
+ }}
2738
+ >
2739
+ {groupConfig.subtitle}
2740
+ </ReqoreP>
2741
+ : null}
2742
+ {renderGroupRows(buckets[box.key][groupName])}
2743
+ </React.Fragment>
2744
+ );
2745
+ })}
2028
2746
  </StyledGroupBody>
2029
- </ReqorePanel>
2747
+ </StyledStatusBox>
2030
2748
  );
2031
2749
  })}
2032
2750
  </StyledCompactPanel>
@@ -2294,13 +3012,11 @@ export const FormEngine = ({
2294
3012
  // option's schema as context). The consumer (the IDE) injects
2295
3013
  // it; same factory pattern as the ExpressionBuilder's
2296
3014
  // `extraActions`.
2297
- ...(typeof optionActions === 'function' ?
2298
- optionActions({
2299
- name: optionName,
2300
- schema: options[optionName],
2301
- value: availableOptions?.[optionName] as TOption,
2302
- })
2303
- : (optionActions ?? [])),
3015
+ ...resolveOptionActions(optionActions, {
3016
+ name: optionName,
3017
+ schema: options[optionName],
3018
+ value: availableOptions?.[optionName] as TOption,
3019
+ }),
2304
3020
  {
2305
3021
  size: 'tiny',
2306
3022
  icon: 'FullscreenLine',