@qoretechnologies/reqraft 0.10.55-pr.124.g77dd26c → 0.10.55

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 (375) hide show
  1. package/.claude/CLAUDE.md +25 -0
  2. package/.tasks/INDEX.md +1 -0
  3. package/.tasks/STORY_NETWORK_ISOLATION.md +98 -0
  4. package/README.md +1 -1
  5. package/design/FORM_ENGINE_COMPACT.md +34 -0
  6. package/design/FORM_ENGINE_EXPRESSIONS.md +110 -0
  7. package/design/FORM_ENGINE_FIELD_MIGRATION.md +155 -1
  8. package/design/IDE_INTEGRATION.md +13 -0
  9. package/design/RENDER_EXPRESSION_TRANSPORT.md +76 -10
  10. package/design/SELECT_DIALOG_FLOOR.md +86 -0
  11. package/design/SMART_EDITOR_UX.md +21 -0
  12. package/design/UNAVAILABLE_ALLOWED_VALUES.md +226 -0
  13. package/dist/components/composer/Composer.d.ts +8 -3
  14. package/dist/components/composer/Composer.d.ts.map +1 -1
  15. package/dist/components/composer/Composer.js.map +1 -1
  16. package/dist/components/dpqlEditor/DpqlEditor.d.ts.map +1 -1
  17. package/dist/components/dpqlEditor/DpqlEditor.js +3 -3
  18. package/dist/components/dpqlEditor/DpqlEditor.js.map +1 -1
  19. package/dist/components/dpqlEditor/dpqlHelpers.d.ts +6 -0
  20. package/dist/components/dpqlEditor/dpqlHelpers.d.ts.map +1 -1
  21. package/dist/components/dpqlEditor/dpqlHelpers.js +21 -2
  22. package/dist/components/dpqlEditor/dpqlHelpers.js.map +1 -1
  23. package/dist/components/dpqlEditor/dpqlProbe.d.ts +37 -0
  24. package/dist/components/dpqlEditor/dpqlProbe.d.ts.map +1 -0
  25. package/dist/components/dpqlEditor/dpqlProbe.js +203 -0
  26. package/dist/components/dpqlEditor/dpqlProbe.js.map +1 -0
  27. package/dist/components/dpqlEditor/dpqlTags.d.ts +8 -0
  28. package/dist/components/dpqlEditor/dpqlTags.d.ts.map +1 -1
  29. package/dist/components/dpqlEditor/dpqlTags.js +5 -1
  30. package/dist/components/dpqlEditor/dpqlTags.js.map +1 -1
  31. package/dist/components/dpqlEditor/index.d.ts +4 -0
  32. package/dist/components/dpqlEditor/index.d.ts.map +1 -1
  33. package/dist/components/dpqlEditor/index.js +5 -1
  34. package/dist/components/dpqlEditor/index.js.map +1 -1
  35. package/dist/components/dpqlEditor/types.d.ts +42 -2
  36. package/dist/components/dpqlEditor/types.d.ts.map +1 -1
  37. package/dist/components/dpqlEditor/types.js +0 -4
  38. package/dist/components/dpqlEditor/types.js.map +1 -1
  39. package/dist/components/dpqlEditor/useDpqlProbe.d.ts +4 -0
  40. package/dist/components/dpqlEditor/useDpqlProbe.d.ts.map +1 -0
  41. package/dist/components/dpqlEditor/useDpqlProbe.js +29 -0
  42. package/dist/components/dpqlEditor/useDpqlProbe.js.map +1 -0
  43. package/dist/components/dpqlEditor/useDpqlSession.d.ts +5 -2
  44. package/dist/components/dpqlEditor/useDpqlSession.d.ts.map +1 -1
  45. package/dist/components/dpqlEditor/useDpqlSession.js +8 -2
  46. package/dist/components/dpqlEditor/useDpqlSession.js.map +1 -1
  47. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  48. package/dist/components/form/engine/CompactRow.js +414 -128
  49. package/dist/components/form/engine/CompactRow.js.map +1 -1
  50. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  51. package/dist/components/form/engine/CompactToolbar.js +5 -2
  52. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  53. package/dist/components/form/engine/FormEngine.d.ts +273 -22
  54. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  55. package/dist/components/form/engine/FormEngine.js +669 -192
  56. package/dist/components/form/engine/FormEngine.js.map +1 -1
  57. package/dist/components/form/engine/FormFieldsSkeleton.d.ts +48 -0
  58. package/dist/components/form/engine/FormFieldsSkeleton.d.ts.map +1 -0
  59. package/dist/components/form/engine/FormFieldsSkeleton.js +41 -0
  60. package/dist/components/form/engine/FormFieldsSkeleton.js.map +1 -0
  61. package/dist/components/form/engine/OptionFieldMessages.d.ts +108 -2
  62. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  63. package/dist/components/form/engine/OptionFieldMessages.js +203 -13
  64. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  65. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  66. package/dist/components/form/engine/_structuredData/SchemaDataView.js +68 -5
  67. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  68. package/dist/components/form/engine/compactRowContext.d.ts +15 -1
  69. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  70. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  71. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  72. package/dist/components/form/engine/compactRowStyles.js +55 -29
  73. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  74. package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
  75. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  76. package/dist/components/form/engine/optionActions.d.ts +37 -1
  77. package/dist/components/form/engine/optionActions.d.ts.map +1 -1
  78. package/dist/components/form/engine/optionActions.js +71 -1
  79. package/dist/components/form/engine/optionActions.js.map +1 -1
  80. package/dist/components/form/engine/optionsContext.d.ts +22 -0
  81. package/dist/components/form/engine/optionsContext.d.ts.map +1 -0
  82. package/dist/components/form/engine/optionsContext.js +20 -0
  83. package/dist/components/form/engine/optionsContext.js.map +1 -0
  84. package/dist/components/form/engine/readFirst.d.ts +42 -1
  85. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  86. package/dist/components/form/engine/readFirst.js +76 -8
  87. package/dist/components/form/engine/readFirst.js.map +1 -1
  88. package/dist/components/form/engine/rendererTypes.d.ts +42 -3
  89. package/dist/components/form/engine/rendererTypes.d.ts.map +1 -1
  90. package/dist/components/form/engine/rendererTypes.js +3 -1
  91. package/dist/components/form/engine/rendererTypes.js.map +1 -1
  92. package/dist/components/form/engine/rowFocus.d.ts +30 -0
  93. package/dist/components/form/engine/rowFocus.d.ts.map +1 -0
  94. package/dist/components/form/engine/rowFocus.js +93 -0
  95. package/dist/components/form/engine/rowFocus.js.map +1 -0
  96. package/dist/components/form/engine/rowMenuContext.d.ts +78 -0
  97. package/dist/components/form/engine/rowMenuContext.d.ts.map +1 -0
  98. package/dist/components/form/engine/rowMenuContext.js +103 -0
  99. package/dist/components/form/engine/rowMenuContext.js.map +1 -0
  100. package/dist/components/form/engine/rowOpenPicker.d.ts +27 -0
  101. package/dist/components/form/engine/rowOpenPicker.d.ts.map +1 -0
  102. package/dist/components/form/engine/rowOpenPicker.js +30 -0
  103. package/dist/components/form/engine/rowOpenPicker.js.map +1 -0
  104. package/dist/components/form/engine/typeChoices.d.ts +42 -0
  105. package/dist/components/form/engine/typeChoices.d.ts.map +1 -0
  106. package/dist/components/form/engine/typeChoices.js +33 -0
  107. package/dist/components/form/engine/typeChoices.js.map +1 -0
  108. package/dist/components/form/expressions/DpqlRendering.d.ts +26 -0
  109. package/dist/components/form/expressions/DpqlRendering.d.ts.map +1 -0
  110. package/dist/components/form/expressions/DpqlRendering.js +53 -0
  111. package/dist/components/form/expressions/DpqlRendering.js.map +1 -0
  112. package/dist/components/form/expressions/ExpressionField.d.ts +7 -1
  113. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  114. package/dist/components/form/expressions/ExpressionField.js +203 -45
  115. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  116. package/dist/components/form/expressions/argumentPresence.d.ts +84 -0
  117. package/dist/components/form/expressions/argumentPresence.d.ts.map +1 -0
  118. package/dist/components/form/expressions/argumentPresence.js +217 -0
  119. package/dist/components/form/expressions/argumentPresence.js.map +1 -0
  120. package/dist/components/form/expressions/builder/argumentLabel.d.ts.map +1 -1
  121. package/dist/components/form/expressions/builder/argumentLabel.js +9 -4
  122. package/dist/components/form/expressions/builder/argumentLabel.js.map +1 -1
  123. package/dist/components/form/expressions/builder/index.d.ts +15 -2
  124. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  125. package/dist/components/form/expressions/builder/index.js +21 -65
  126. package/dist/components/form/expressions/builder/index.js.map +1 -1
  127. package/dist/components/form/expressions/builder/renderTemplate.d.ts +4 -1
  128. package/dist/components/form/expressions/builder/renderTemplate.d.ts.map +1 -1
  129. package/dist/components/form/expressions/builder/renderTemplate.js +10 -67
  130. package/dist/components/form/expressions/builder/renderTemplate.js.map +1 -1
  131. package/dist/components/form/expressions/mockExpressions.d.ts.map +1 -1
  132. package/dist/components/form/expressions/mockExpressions.js +8 -4
  133. package/dist/components/form/expressions/mockExpressions.js.map +1 -1
  134. package/dist/components/form/expressions/renderExpressionToText.d.ts +1 -1
  135. package/dist/components/form/expressions/renderExpressionToText.d.ts.map +1 -1
  136. package/dist/components/form/expressions/renderExpressionToText.js +95 -19
  137. package/dist/components/form/expressions/renderExpressionToText.js.map +1 -1
  138. package/dist/components/form/expressions/types.d.ts +17 -2
  139. package/dist/components/form/expressions/types.d.ts.map +1 -1
  140. package/dist/components/form/expressions/useExpressions.d.ts.map +1 -1
  141. package/dist/components/form/expressions/useExpressions.js +5 -2
  142. package/dist/components/form/expressions/useExpressions.js.map +1 -1
  143. package/dist/components/form/expressions/useRenderExpression.d.ts +7 -16
  144. package/dist/components/form/expressions/useRenderExpression.d.ts.map +1 -1
  145. package/dist/components/form/expressions/useRenderExpression.js +50 -126
  146. package/dist/components/form/expressions/useRenderExpression.js.map +1 -1
  147. package/dist/components/form/fields/Field.d.ts +21 -0
  148. package/dist/components/form/fields/Field.d.ts.map +1 -1
  149. package/dist/components/form/fields/Field.js +54 -8
  150. package/dist/components/form/fields/Field.js.map +1 -1
  151. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +11 -1
  152. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  153. package/dist/components/form/fields/allowed-values/AllowedValues.js +52 -8
  154. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  155. package/dist/components/form/fields/auto/AutoFormField.d.ts +7 -1
  156. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  157. package/dist/components/form/fields/auto/AutoFormField.js +91 -28
  158. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  159. package/dist/components/form/fields/date/Date.d.ts +1 -1
  160. package/dist/components/form/fields/date/Date.d.ts.map +1 -1
  161. package/dist/components/form/fields/date/Date.js +16 -2
  162. package/dist/components/form/fields/date/Date.js.map +1 -1
  163. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  164. package/dist/components/form/fields/long-string/LongString.js +14 -2
  165. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  166. package/dist/components/form/fields/multi-select/MultiSelectFormField.d.ts.map +1 -1
  167. package/dist/components/form/fields/multi-select/MultiSelectFormField.js +50 -4
  168. package/dist/components/form/fields/multi-select/MultiSelectFormField.js.map +1 -1
  169. package/dist/components/form/fields/number/Number.d.ts +12 -1
  170. package/dist/components/form/fields/number/Number.d.ts.map +1 -1
  171. package/dist/components/form/fields/number/Number.js +6 -3
  172. package/dist/components/form/fields/number/Number.js.map +1 -1
  173. package/dist/components/form/fields/rich-text/RichText.d.ts +47 -2
  174. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  175. package/dist/components/form/fields/rich-text/RichText.js +153 -12
  176. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  177. package/dist/components/form/fields/select/Select.d.ts.map +1 -1
  178. package/dist/components/form/fields/select/Select.js +120 -20
  179. package/dist/components/form/fields/select/Select.js.map +1 -1
  180. package/dist/components/form/fields/select/SelectCollection.d.ts +166 -9
  181. package/dist/components/form/fields/select/SelectCollection.d.ts.map +1 -1
  182. package/dist/components/form/fields/select/SelectCollection.js +200 -7
  183. package/dist/components/form/fields/select/SelectCollection.js.map +1 -1
  184. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts +2 -1
  185. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  186. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +3 -2
  187. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  188. package/dist/components/form/fields/template/TemplateField.d.ts +34 -2
  189. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  190. package/dist/components/form/fields/template/TemplateField.js +606 -58
  191. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  192. package/dist/components/form/fields/template/TemplateText.d.ts +36 -0
  193. package/dist/components/form/fields/template/TemplateText.d.ts.map +1 -0
  194. package/dist/components/form/fields/template/TemplateText.js +80 -0
  195. package/dist/components/form/fields/template/TemplateText.js.map +1 -0
  196. package/dist/components/form/index.d.ts +2 -0
  197. package/dist/components/form/index.d.ts.map +1 -1
  198. package/dist/components/form/index.js +4 -0
  199. package/dist/components/form/index.js.map +1 -1
  200. package/dist/components/smartEditor/SmartEditor.d.ts.map +1 -1
  201. package/dist/components/smartEditor/SmartEditor.js +29 -8
  202. package/dist/components/smartEditor/SmartEditor.js.map +1 -1
  203. package/dist/components/smartEditor/useLspSession.d.ts +5 -1
  204. package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
  205. package/dist/components/smartEditor/useLspSession.js +16 -2
  206. package/dist/components/smartEditor/useLspSession.js.map +1 -1
  207. package/dist/components/supportTicket/TicketReplyBox.d.ts +8 -4
  208. package/dist/components/supportTicket/TicketReplyBox.d.ts.map +1 -1
  209. package/dist/components/supportTicket/TicketReplyBox.js.map +1 -1
  210. package/dist/helpers/common.d.ts.map +1 -1
  211. package/dist/helpers/common.js +7 -3
  212. package/dist/helpers/common.js.map +1 -1
  213. package/dist/helpers/dpqlDetection.d.ts +58 -0
  214. package/dist/helpers/dpqlDetection.d.ts.map +1 -0
  215. package/dist/helpers/dpqlDetection.js +164 -0
  216. package/dist/helpers/dpqlDetection.js.map +1 -0
  217. package/dist/helpers/optionUiTypes.d.ts +31 -3
  218. package/dist/helpers/optionUiTypes.d.ts.map +1 -1
  219. package/dist/helpers/optionUiTypes.js +36 -1
  220. package/dist/helpers/optionUiTypes.js.map +1 -1
  221. package/dist/helpers/options.d.ts +57 -0
  222. package/dist/helpers/options.d.ts.map +1 -1
  223. package/dist/helpers/options.js +97 -1
  224. package/dist/helpers/options.js.map +1 -1
  225. package/dist/helpers/templateItems.d.ts +47 -0
  226. package/dist/helpers/templateItems.d.ts.map +1 -0
  227. package/dist/helpers/templateItems.js +88 -0
  228. package/dist/helpers/templateItems.js.map +1 -0
  229. package/dist/helpers/templateText.d.ts +80 -0
  230. package/dist/helpers/templateText.d.ts.map +1 -0
  231. package/dist/helpers/templateText.js +291 -0
  232. package/dist/helpers/templateText.js.map +1 -0
  233. package/dist/helpers/templates.d.ts +30 -12
  234. package/dist/helpers/templates.d.ts.map +1 -1
  235. package/dist/helpers/templates.js +91 -33
  236. package/dist/helpers/templates.js.map +1 -1
  237. package/dist/helpers/validations.d.ts +35 -8
  238. package/dist/helpers/validations.d.ts.map +1 -1
  239. package/dist/helpers/validations.js +162 -25
  240. package/dist/helpers/validations.js.map +1 -1
  241. package/dist/hooks/useArgSchema.d.ts +2 -1
  242. package/dist/hooks/useArgSchema.d.ts.map +1 -1
  243. package/dist/hooks/useFetch/useFetch.d.ts +3 -1
  244. package/dist/hooks/useFetch/useFetch.d.ts.map +1 -1
  245. package/dist/hooks/useFetch/useFetch.js +47 -9
  246. package/dist/hooks/useFetch/useFetch.js.map +1 -1
  247. package/dist/hooks/useTemplates.d.ts +20 -0
  248. package/dist/hooks/useTemplates.d.ts.map +1 -1
  249. package/dist/hooks/useTemplates.js +34 -2
  250. package/dist/hooks/useTemplates.js.map +1 -1
  251. package/dist/index.d.ts +2 -1
  252. package/dist/index.d.ts.map +1 -1
  253. package/dist/index.js +5 -1
  254. package/dist/index.js.map +1 -1
  255. package/dist/utils/fetch.d.ts +151 -1
  256. package/dist/utils/fetch.d.ts.map +1 -1
  257. package/dist/utils/fetch.js +256 -44
  258. package/dist/utils/fetch.js.map +1 -1
  259. package/dist/utils/lspClient.d.ts +6 -0
  260. package/dist/utils/lspClient.d.ts.map +1 -1
  261. package/dist/utils/lspClient.js +62 -11
  262. package/dist/utils/lspClient.js.map +1 -1
  263. package/dist/utils/websocket.d.ts +6 -0
  264. package/dist/utils/websocket.d.ts.map +1 -1
  265. package/dist/utils/websocket.js +15 -7
  266. package/dist/utils/websocket.js.map +1 -1
  267. package/package.json +10 -6
  268. package/src/components/composer/Composer.tsx +7 -2
  269. package/src/components/dpqlEditor/DpqlEditor.stories.tsx +3 -119
  270. package/src/components/dpqlEditor/DpqlEditor.tsx +5 -2
  271. package/src/components/dpqlEditor/dpqlHelpers.ts +20 -1
  272. package/src/components/dpqlEditor/dpqlProbe.ts +165 -0
  273. package/src/components/dpqlEditor/dpqlTags.ts +13 -0
  274. package/src/components/dpqlEditor/index.ts +4 -0
  275. package/src/components/dpqlEditor/types.ts +38 -2
  276. package/src/components/dpqlEditor/useDpqlProbe.ts +33 -0
  277. package/src/components/dpqlEditor/useDpqlSession.ts +20 -4
  278. package/src/components/form/engine/CompactRow.tsx +487 -160
  279. package/src/components/form/engine/CompactToolbar.tsx +10 -3
  280. package/src/components/form/engine/FormEngine.stories.tsx +1269 -211
  281. package/src/components/form/engine/FormEngine.tsx +922 -270
  282. package/src/components/form/engine/FormEngineStickyHeaders.stories.tsx +331 -0
  283. package/src/components/form/engine/FormFieldsSkeleton.tsx +77 -0
  284. package/src/components/form/engine/OptionFieldMessages.tsx +300 -14
  285. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +94 -3
  286. package/src/components/form/engine/compactRowContext.ts +15 -6
  287. package/src/components/form/engine/compactRowStyles.ts +56 -2
  288. package/src/components/form/engine/compactToolbarContext.ts +1 -1
  289. package/src/components/form/engine/optionActions.ts +89 -1
  290. package/src/components/form/engine/optionsContext.ts +24 -0
  291. package/src/components/form/engine/readFirst.ts +88 -8
  292. package/src/components/form/engine/rendererTypes.ts +48 -4
  293. package/src/components/form/engine/rowFocus.ts +101 -0
  294. package/src/components/form/engine/rowMenuContext.ts +159 -0
  295. package/src/components/form/engine/rowOpenPicker.ts +27 -0
  296. package/src/components/form/engine/typeChoices.ts +63 -0
  297. package/src/components/form/expressions/DpqlRendering.tsx +85 -0
  298. package/src/components/form/expressions/ExpressionField.stories.tsx +826 -48
  299. package/src/components/form/expressions/ExpressionField.tsx +269 -55
  300. package/src/components/form/expressions/argumentPresence.ts +213 -0
  301. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +38 -21
  302. package/src/components/form/expressions/builder/argumentLabel.tsx +10 -4
  303. package/src/components/form/expressions/builder/index.tsx +39 -81
  304. package/src/components/form/expressions/builder/renderTemplate.tsx +23 -56
  305. package/src/components/form/expressions/dpqlMockLanguage.ts +565 -0
  306. package/src/components/form/expressions/dpqlMockLsp.ts +109 -83
  307. package/src/components/form/expressions/mockExpressions.ts +8 -4
  308. package/src/components/form/expressions/renderExpressionToText.ts +112 -22
  309. package/src/components/form/expressions/types.ts +17 -2
  310. package/src/components/form/expressions/useExpressions.ts +5 -1
  311. package/src/components/form/expressions/useRenderExpression.ts +43 -127
  312. package/src/components/form/fields/Field.tsx +102 -20
  313. package/src/components/form/fields/allowed-values/AllowedValues.tsx +106 -44
  314. package/src/components/form/fields/auto/AutoFormField.stories.tsx +85 -18
  315. package/src/components/form/fields/auto/AutoFormField.tsx +97 -21
  316. package/src/components/form/fields/date/Date.tsx +17 -1
  317. package/src/components/form/fields/long-string/LongString.tsx +15 -3
  318. package/src/components/form/fields/multi-select/MultiSelectFormField.tsx +65 -6
  319. package/src/components/form/fields/number/Number.tsx +25 -0
  320. package/src/components/form/fields/rich-text/RichText.tsx +248 -30
  321. package/src/components/form/fields/select/Select.stories.tsx +392 -0
  322. package/src/components/form/fields/select/Select.tsx +164 -27
  323. package/src/components/form/fields/select/SelectCollection.tsx +350 -6
  324. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +4 -2
  325. package/src/components/form/fields/template/TemplateField.stories.tsx +93 -14
  326. package/src/components/form/fields/template/TemplateField.tsx +825 -131
  327. package/src/components/form/fields/template/TemplateText.tsx +115 -0
  328. package/src/components/form/index.tsx +4 -0
  329. package/src/components/log/Log.stories.tsx +2 -1
  330. package/src/components/searchFilterBar/SearchFilterBar.stories.tsx +3 -3
  331. package/src/components/smartEditor/SmartEditor.tsx +41 -9
  332. package/src/components/smartEditor/__fixtures__/mockLspServer.ts +52 -3
  333. package/src/components/smartEditor/useLspSession.ts +21 -3
  334. package/src/components/supportTicket/TicketReplyBox.tsx +8 -4
  335. package/src/components/ticketThread/TicketThread.stories.tsx +1 -0
  336. package/src/helpers/common.ts +7 -3
  337. package/src/helpers/dpqlDetection.ts +209 -0
  338. package/src/helpers/optionUiTypes.ts +38 -0
  339. package/src/helpers/options.ts +96 -0
  340. package/src/helpers/templateItems.ts +81 -0
  341. package/src/helpers/templateText.ts +350 -0
  342. package/src/helpers/templates.ts +94 -41
  343. package/src/helpers/validations.ts +203 -51
  344. package/src/hooks/useFetch/useFetch.stories.tsx +6 -5
  345. package/src/hooks/useFetch/useFetch.tsx +53 -7
  346. package/src/hooks/useStorage/useStorage.stories.tsx +2 -1
  347. package/src/hooks/useTemplates.ts +41 -0
  348. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +2 -1
  349. package/src/index.tsx +14 -1
  350. package/src/stores/currentUser/currentUser.stories.tsx +2 -1
  351. package/src/stories/storyNetwork.ts +92 -0
  352. package/src/utils/fetch.ts +311 -21
  353. package/src/utils/lspClient.ts +68 -11
  354. package/src/utils/websocket.ts +20 -9
  355. package/dist/components/form/engine/__fixtures__/basicFormOptions.d.ts +0 -73
  356. package/dist/components/form/engine/__fixtures__/basicFormOptions.d.ts.map +0 -1
  357. package/dist/components/form/engine/__fixtures__/basicFormOptions.js +0 -317
  358. package/dist/components/form/engine/__fixtures__/basicFormOptions.js.map +0 -1
  359. package/dist/components/form/engine/__fixtures__/fieldChromeOptions.d.ts +0 -41
  360. package/dist/components/form/engine/__fixtures__/fieldChromeOptions.d.ts.map +0 -1
  361. package/dist/components/form/engine/__fixtures__/fieldChromeOptions.js +0 -45
  362. package/dist/components/form/engine/__fixtures__/fieldChromeOptions.js.map +0 -1
  363. package/dist/components/form/expressions/dpqlMockLsp.d.ts +0 -3
  364. package/dist/components/form/expressions/dpqlMockLsp.d.ts.map +0 -1
  365. package/dist/components/form/expressions/dpqlMockLsp.js +0 -91
  366. package/dist/components/form/expressions/dpqlMockLsp.js.map +0 -1
  367. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts +0 -2
  368. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts.map +0 -1
  369. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js +0 -9
  370. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js.map +0 -1
  371. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts +0 -55
  372. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts.map +0 -1
  373. package/dist/components/smartEditor/__fixtures__/mockLspServer.js +0 -126
  374. package/dist/components/smartEditor/__fixtures__/mockLspServer.js.map +0 -1
  375. package/dist/stories/Data/fileFieldArgSchema.json +0 -86
@@ -1,11 +1,17 @@
1
- import { ReqoreInput } from '@qoretechnologies/reqore';
1
+ import {
2
+ ReqoreButton,
3
+ ReqoreInput,
4
+ ReqoreP,
5
+ ReqoreTag,
6
+ ReqoreTagGroup,
7
+ ReqoreVerticalSpacer,
8
+ } from '@qoretechnologies/reqore';
2
9
  import { TSizes } from '@qoretechnologies/reqore/dist/constants/sizes';
3
10
  import { IQorusFormSchema } from '@qoretechnologies/ts-toolkit';
4
11
  import { Meta, StoryObj } from '@storybook/react-vite';
5
- import { ChangeEvent, useEffect, useMemo, useState } from 'react';
12
+ import { ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
6
13
  import { expect, fireEvent, fn, userEvent, waitFor, within } from 'storybook/test';
7
14
  import { validateField } from '../../../helpers/validations';
8
- import { defaultQorusTypes } from '../../../hooks/useQorusTypes';
9
15
  import {
10
16
  _testsChangeRichText,
11
17
  _testsChangeStringField,
@@ -19,7 +25,6 @@ import {
19
25
  _testsWaitForTextToNotExist,
20
26
  sleep,
21
27
  } from '../../../stories/Tests/utils';
22
- import { startDpqlMockLsp } from '../expressions/dpqlMockLsp';
23
28
  import { mockExpressions } from '../expressions/mockExpressions';
24
29
  import { mockPopulatedDefinition } from '../fields/schema-definition/mockDefinition';
25
30
  import { defaultMarkdownRenderer } from '../fields/markdown/MarkdownView';
@@ -92,29 +97,9 @@ const meta: Meta<typeof FormEngine> = {
92
97
  chromatic: {
93
98
  viewports: [2560],
94
99
  },
95
- // `useQorusTypes` resolves the type catalogue as `size(data) ? data : defaultQorusTypes`,
96
- // so a reachable, authenticated instance *replaces* the built-in list rather than
97
- // supplementing it. These stories never opt into live data (no `live: true`), but the
98
- // request fires anyway — which made `Option With Any Type` pass locally (401 → built-in
99
- // list, so "Boolean" exists) and fail in CI, where the token is valid and the server's
100
- // list decides the labels. Pin the catalogue so the type names the plays click are ours.
101
- mockData: [
102
- {
103
- // `query()` builds `${instance}api/latest/${url}`, and the hook's url is
104
- // `/system/qorus-type-info` — hence the doubled slash. Both spellings are listed
105
- // so the mock keeps matching if that leading slash is ever dropped.
106
- url: 'https://hq.qoretechnologies.com:8092/api/latest//system/qorus-type-info',
107
- method: 'GET',
108
- status: 200,
109
- response: defaultQorusTypes,
110
- },
111
- {
112
- url: 'https://hq.qoretechnologies.com:8092/api/latest/system/qorus-type-info',
113
- method: 'GET',
114
- status: 200,
115
- response: defaultQorusTypes,
116
- },
117
- ],
100
+ // The type catalogue the plays click through (`Option With Any Type` picks
101
+ // "Boolean") is pinned for every story by the global mocks in
102
+ // `src/stories/storyNetwork.ts`.
118
103
  },
119
104
  render: ({ value, onChange, ...rest }: IFormEngineProps) => {
120
105
  const [val, setValue] = useState(value);
@@ -191,11 +176,17 @@ export const Basic: Story = {
191
176
  value: basicFormValue,
192
177
  },
193
178
  play: async ({ canvasElement, args }) => {
194
- const canvas = within(canvasElement);
195
-
196
- await waitFor(() => expect(canvas.getAllByDisplayValue('$local:test')[0]).toBeInTheDocument(), {
197
- timeout: 10000,
198
- });
179
+ // The template option's `$local:test` reads as the name the form's
180
+ // templates give it, as a chip in its editor.
181
+ await waitFor(
182
+ () =>
183
+ expect(
184
+ Array.from(canvasElement.querySelectorAll('[contenteditable="true"] .reqore-tag')).some((chip) =>
185
+ chip.textContent?.includes('Test (local)')
186
+ )
187
+ ).toBe(true),
188
+ { timeout: 10000 }
189
+ );
199
190
  await waitFor(
200
191
  () =>
201
192
  expect(
@@ -299,19 +290,62 @@ export const OptionalOpened: Story = {
299
290
  },
300
291
  };
301
292
 
293
+ /** Opens one option row's ⋯ menu. */
294
+ const openOptionRowMenu = async (nth = 0) => {
295
+ await _testsClickButton({ selector: '.options-item-more', nth });
296
+ };
297
+
298
+ /**
299
+ * A row's own actions stay inside the row.
300
+ *
301
+ * They used to float: a bar portalled above the hovered row, which on the
302
+ * first row landed on the form's own header buttons and made them
303
+ * unclickable — and a bar that only appears on hover is out of reach on a
304
+ * phone. The row's secondary actions now live in its ⋯ menu.
305
+ */
306
+ export const RowActionsStayInTheRow: Story = {
307
+ ...Basic,
308
+ parameters: {
309
+ docs: {
310
+ description: {
311
+ story:
312
+ "Renders the Basic FormEngine, hovers its first option and opens that option's ⋯ menu. No action bar floats over the form's header when the row is hovered; the row's secondary actions — Focused editing among them — are listed in its menu, where they can be reached without a hover.",
313
+ },
314
+ },
315
+ },
316
+ play: async (args) => {
317
+ await Basic.play!(args);
318
+ const firstRow = document.querySelectorAll<HTMLElement>('.system-option')[0];
319
+ // The row draws its own actions, in its own header, before anyone hovers
320
+ // it. A floating bar is only ever drawn elsewhere, and only on a hover.
321
+ const more = await waitFor(
322
+ () => {
323
+ const button = firstRow.querySelector<HTMLElement>('.options-item-more');
324
+ expect(button).toBeTruthy();
325
+ return button!;
326
+ },
327
+ { timeout: 10000 }
328
+ );
329
+ await userEvent.hover(firstRow);
330
+ await expect(document.querySelector('.reqore-panel-floating-actions')).toBeNull();
331
+ await userEvent.click(more);
332
+ await _testsWaitForText('Focused editing');
333
+ },
334
+ };
335
+
302
336
  export const FocusedEditing: Story = {
303
337
  ...Basic,
304
338
  parameters: {
305
339
  docs: {
306
340
  description: {
307
341
  story:
308
- 'Renders the Basic FormEngine, hovers an option and clicks its fullscreen action — the Focused Editing modal opens over that single field.',
342
+ "Renders the Basic FormEngine, opens an option's ⋯ menu and picks Focused editing — the Focused Editing modal opens over that single field.",
309
343
  },
310
344
  },
311
345
  },
312
346
  play: async (args) => {
313
347
  await Basic.play!(args);
314
- await userEvent.hover(document.querySelectorAll('.system-option')[0]);
348
+ await openOptionRowMenu(0);
315
349
  await _testsClickButton({ selector: '.options-item-fullscreen', nth: 0 });
316
350
  await _testsWaitForText('Focused Editing');
317
351
  },
@@ -341,7 +375,7 @@ export const ValueCanBeRemoved: Story = {
341
375
  docs: {
342
376
  description: {
343
377
  story:
344
- 'Renders FormEngine holding a text option and a file option, both with values. Hovering each row and clicking its remove action clears the value and marks the row as revertable.',
378
+ "Renders FormEngine holding a text option and a file option, both with values. Picking Remove value from each row's ⋯ menu clears the value, and the row's menu then offers Revert changes.",
345
379
  },
346
380
  },
347
381
  },
@@ -370,36 +404,61 @@ export const ValueCanBeRemoved: Story = {
370
404
  },
371
405
  play: async () => {
372
406
  await _testsWaitForText('Click here to upload a different file');
373
- await userEvent.hover(document.querySelectorAll('.system-option')[0]);
374
- await waitFor(() => expect(document.querySelector('.options-item-remove')).toBeTruthy(), {
375
- timeout: 10000,
376
- });
407
+ // The first row's value goes, and its menu now offers the way back.
408
+ await openOptionRowMenu(0);
377
409
  await _testsClickButton({ selector: '.options-item-remove', nth: 0 });
410
+ await openOptionRowMenu(0);
378
411
  await waitFor(() => expect(document.querySelectorAll('.options-item-revert').length).toBe(1), {
379
412
  timeout: 10000,
380
413
  });
381
- await userEvent.hover(document.querySelectorAll('.system-option')[1]);
382
- await waitFor(() => expect(document.querySelector('.options-item-remove')).toBeTruthy(), {
414
+ await userEvent.keyboard('{Escape}');
415
+ await waitFor(() => expect(document.querySelectorAll('.options-item-revert').length).toBe(0), {
383
416
  timeout: 10000,
384
417
  });
418
+ // Then the second row's.
419
+ await openOptionRowMenu(1);
385
420
  await _testsClickButton({ selector: '.options-item-remove', nth: 0 });
386
421
  await _testsWaitForTextToNotExist('Click here to upload a different file');
387
422
  },
388
423
  };
389
424
 
425
+ /**
426
+ * A phone reaches a row's actions through its ⋯ menu: there is no hover to
427
+ * reveal anything, and no bar floating over the row above.
428
+ */
429
+ export const RowActionsOnAPhone: Story = {
430
+ ...ValueCanBeRemoved,
431
+ parameters: {
432
+ qlip: { viewport: { width: 390, height: 844 } },
433
+ docs: {
434
+ description: {
435
+ story:
436
+ "Renders a text option and a file option, both with values, at phone width with the first option's ⋯ menu open — Focused editing and Remove value are listed there, reachable without a hover.",
437
+ },
438
+ },
439
+ },
440
+ play: async () => {
441
+ await _testsWaitForText('Click here to upload a different file');
442
+ await openOptionRowMenu(0);
443
+ await _testsWaitForText('Focused editing');
444
+ await _testsWaitForText('Remove value');
445
+ },
446
+ };
447
+
390
448
  export const ChangeCanBeReverted: Story = {
391
449
  ...ValueCanBeRemoved,
392
450
  parameters: {
393
451
  docs: {
394
452
  description: {
395
453
  story:
396
- 'Renders the ValueCanBeRemoved fixture after both values are removed, then clicks the per-row revert action — the file value comes back.',
454
+ "Renders the ValueCanBeRemoved fixture after both values are removed, then picks Revert changes from the file row's ⋯ menu — the file value comes back.",
397
455
  },
398
456
  },
399
457
  },
400
458
  play: async (args) => {
401
459
  await ValueCanBeRemoved.play!(args);
402
- await _testsClickButton({ selector: '.options-item-revert', nth: 1 });
460
+ await openOptionRowMenu(1);
461
+ await _testsClickButton({ selector: '.options-item-revert', nth: 0 });
403
462
  await _testsWaitForText('Click here to upload a different file');
404
463
  },
405
464
  };
@@ -510,11 +569,11 @@ export const OptionDependsOnOptionOrAnotherOption: Story = {
510
569
  });
511
570
 
512
571
  await _testsWaitForText(
513
- 'This field is disabled because some dependencies are not fulfilled: "Required Option 2", "Required Option 5"'
572
+ 'This field is disabled because some dependencies are not fulfilled: ("Required Option 2" or "Required Option 5")'
514
573
  );
515
574
  await _testsChangeRichText('I have value', 5);
516
575
  await _testsWaitForTextToNotExist(
517
- 'This field is disabled because some dependencies are not fulfilled: "Required Option 2", "Required Option 5"'
576
+ 'This field is disabled because some dependencies are not fulfilled: ("Required Option 2" or "Required Option 5")'
518
577
  );
519
578
  },
520
579
  };
@@ -582,7 +641,7 @@ export const OptionWithAnyType: Story = {
582
641
  docs: {
583
642
  description: {
584
643
  story:
585
- 'Renders four options typed as any with templates enabled — empty ones show a Select Template dropdown, the pre-typed number field renders as a Number input and the operator can switch types via the More menu.',
644
+ 'Renders four options typed as any with templates enabled — empty ones show a Select Template dropdown, the pre-typed number field renders as a Number input and the operator can switch types via the More menu. That menu holds one group, "Set Custom Value", so it opens already expanded and the data types are one click away rather than two.',
586
645
  },
587
646
  },
588
647
  },
@@ -644,9 +703,10 @@ export const OptionWithAnyType: Story = {
644
703
  await _testsClickButton({ label: 'Richtext Template' });
645
704
 
646
705
  // Open the ... menu on the 4th field (optionWithAnyTypeToChangeToTemplateToCustomData)
647
- // and switch it to a specific custom type (Boolean).
706
+ // and switch it to a specific custom type (Boolean). The type rows are
707
+ // reachable straight from the menu: "Set Custom Value" is its only group,
708
+ // so that section opens itself rather than asking for a click first.
648
709
  await _testsOpenTemplateMenu(4);
649
- await _testsClickButton({ label: 'Set Custom Value' });
650
710
  await _testsClickButton({ label: 'Boolean' });
651
711
  },
652
712
  };
@@ -997,6 +1057,154 @@ export const CompactRowCancelEdit: Story = {
997
1057
  },
998
1058
  };
999
1059
 
1060
+ export const CompactRowReadOnlyIsAWayIn: Story = {
1061
+ parameters: {
1062
+ /* Nothing to SEE here: what this story asserts is a role, a focus stop, a
1063
+ cursor and an accessible name — none of which a pixel capture records,
1064
+ so the frame is an ordinary read-only row either way. Rejected on qlip
1065
+ build #212 as "visually empty / not worth snapshotting"; the story stays
1066
+ because the behaviour it pins is the point. */
1067
+ qlip: { skip: true },
1068
+ docs: {
1069
+ description: {
1070
+ story:
1071
+ 'A read-only row opens nothing by itself — it shows everything it has. Where the consumer says the field is editable somewhere else (`onReadOnlyActivate`), the row becomes the way in to that place and announces it: a button role, keyboard handling, a pointer cursor, and an accessible name that says what the click DOES ("Edit Cookie Name") rather than just naming the field. A reader clicking a value they want to change has said exactly what they want; answering with silence teaches them the surface is broken.',
1072
+ },
1073
+ },
1074
+ },
1075
+ args: {
1076
+ compact: true,
1077
+ readOnly: true,
1078
+ minColumnWidth: '360px',
1079
+ value: { cookie_name: { type: 'string', value: 'my-cookie' } },
1080
+ options: {
1081
+ cookie_name: {
1082
+ type: 'string',
1083
+ ui_type: 'string',
1084
+ display_name: 'Cookie Name',
1085
+ short_desc: 'Cookie name for cookie authentication',
1086
+ },
1087
+ },
1088
+ onReadOnlyActivate: fn(),
1089
+ },
1090
+ play: async ({ canvasElement, args }) => {
1091
+ await waitFor(() =>
1092
+ expect(canvasElement.querySelector('[data-field="cookie_name"]')).toBeTruthy()
1093
+ );
1094
+
1095
+ const row = canvasElement.querySelector<HTMLElement>('[data-field="cookie_name"]')!;
1096
+ // It reads as a control, not as text that happens to respond.
1097
+ expect(row.getAttribute('role')).toBe('button');
1098
+ expect(row.getAttribute('aria-label')).toBe('Edit Cookie Name');
1099
+ expect(row.getAttribute('tabindex')).toBe('0');
1100
+
1101
+ fireEvent.click(row);
1102
+ await waitFor(() => expect((args as any).onReadOnlyActivate).toHaveBeenCalledWith('cookie_name'));
1103
+
1104
+ // And it still does not open an editor in place — the point is that the
1105
+ // editing happens where the consumer sent it.
1106
+ expect(canvasElement.querySelector('.readfirst-row-editing')).toBeNull();
1107
+ },
1108
+ };
1109
+
1110
+ export const CompactRowReadOnlyStaysInertWithoutAWayIn: Story = {
1111
+ parameters: {
1112
+ /* Nothing to SEE here: what this story asserts is a role, a focus stop, a
1113
+ cursor and an accessible name — none of which a pixel capture records,
1114
+ so the frame is an ordinary read-only row either way. Rejected on qlip
1115
+ build #212 as "visually empty / not worth snapshotting"; the story stays
1116
+ because the behaviour it pins is the point. */
1117
+ qlip: { skip: true },
1118
+ docs: {
1119
+ description: {
1120
+ story:
1121
+ 'The same read-only row with nowhere to send the reader. It stays inert: no button role, no focus stop, no cursor — because a control that does nothing is worse than plain text, and a read-only form with no editor behind it is a perfectly ordinary thing.',
1122
+ },
1123
+ },
1124
+ },
1125
+ args: {
1126
+ ...(CompactRowReadOnlyIsAWayIn.args as any),
1127
+ onReadOnlyActivate: undefined,
1128
+ },
1129
+ play: async ({ canvasElement }) => {
1130
+ await waitFor(() =>
1131
+ expect(canvasElement.querySelector('[data-field="cookie_name"]')).toBeTruthy()
1132
+ );
1133
+
1134
+ const row = canvasElement.querySelector<HTMLElement>('[data-field="cookie_name"]')!;
1135
+ expect(row.getAttribute('role')).toBeNull();
1136
+ expect(row.getAttribute('tabindex')).toBeNull();
1137
+
1138
+ fireEvent.click(row);
1139
+ // Nothing opens, and nothing is claimed to have happened.
1140
+ expect(canvasElement.querySelector('.readfirst-row-editing')).toBeNull();
1141
+ },
1142
+ };
1143
+
1144
+ export const CompactRowReleasesItsOpeningFloor: Story = {
1145
+ parameters: {
1146
+ docs: {
1147
+ description: {
1148
+ story:
1149
+ 'Opening a row pins the height it had while being READ, so the click cannot yank the content below it up under the pointer. That floor is for the transition only: it is released the moment the author changes the value. Without the release, a row that was tall because it carried a warning kept that height after the author fixed the very thing the warning was about — reported as "the chip is removed, but the vertical size of the option remains the same", leaving a large empty box where the explanation had been.',
1150
+ },
1151
+ },
1152
+ },
1153
+ args: {
1154
+ compact: true,
1155
+ minColumnWidth: '360px',
1156
+ value: { cookie_name: { type: 'string', value: 'my-cookie' } },
1157
+ options: {
1158
+ cookie_name: {
1159
+ type: 'string',
1160
+ ui_type: 'string',
1161
+ display_name: 'Cookie Name',
1162
+ short_desc: 'Cookie name for cookie authentication',
1163
+ // A message is what makes a read row tall enough for the stuck floor
1164
+ // to be visible rather than merely present.
1165
+ messages: [
1166
+ {
1167
+ intent: 'warning',
1168
+ title: 'This name is not one the browser will keep',
1169
+ content:
1170
+ 'A cookie name may not contain a space, a comma or a semicolon. Rename it, and this notice goes away — and so should the space it took up.',
1171
+ },
1172
+ ],
1173
+ },
1174
+ },
1175
+ },
1176
+ play: async ({ canvasElement }) => {
1177
+ await waitFor(() =>
1178
+ expect(canvasElement.querySelector('[data-field="cookie_name"]')).toBeTruthy()
1179
+ );
1180
+ fireEvent.click(canvasElement.querySelector<HTMLElement>('[data-field="cookie_name"]')!);
1181
+
1182
+ await waitFor(() => expect(canvasElement.querySelector('.readfirst-row-editing')).toBeTruthy());
1183
+
1184
+ /* Opening pins the read height. Asserted on the inline style rather than
1185
+ on the measured height: jsdom lays nothing out, so a height comparison
1186
+ here would pass on any code at all. */
1187
+ const editing = canvasElement.querySelector<HTMLElement>('.readfirst-row-editing');
1188
+ expect(editing!.style.minHeight).not.toBe('');
1189
+
1190
+ const input = canvasElement.querySelector<HTMLInputElement>(
1191
+ '[data-field="cookie_name"] input, [data-field="cookie_name"] textarea'
1192
+ );
1193
+ expect(input).toBeTruthy();
1194
+ fireEvent.change(input!, { target: { value: 'changed-cookie' } });
1195
+
1196
+ // ...and the author changing the value releases it, so the row can settle
1197
+ // to whatever it now holds.
1198
+ await waitFor(
1199
+ () => {
1200
+ const still = canvasElement.querySelector<HTMLElement>('.readfirst-row-editing');
1201
+ expect(still?.style.minHeight ?? '').toBe('');
1202
+ },
1203
+ { timeout: 5000 }
1204
+ );
1205
+ },
1206
+ };
1207
+
1000
1208
  // Language and Source code are one decision — "what is this code, and in what
1001
1209
  // language" — and the form used to ask it as two unrelated rows one above the
1002
1210
  // other. `absorb_fields` lets the editor take the language into its own
@@ -1544,6 +1752,119 @@ export const NestedOptionInheritsRenderPropFromAncestorCompact: Story = {
1544
1752
  },
1545
1753
  };
1546
1754
 
1755
+ /**
1756
+ * Counts how often the inheritance bag it is handed becomes a DIFFERENT object
1757
+ * while the value in it never changes — the cost of a prop built inline.
1758
+ */
1759
+ const InheritedScopeProbe = ({ inheritedFromParent }: any) => {
1760
+ const renders = useRef(0);
1761
+ const identities = useRef(0);
1762
+ const lastBag = useRef<unknown>(undefined);
1763
+ renders.current += 1;
1764
+ if (lastBag.current !== inheritedFromParent) {
1765
+ identities.current += 1;
1766
+ lastBag.current = inheritedFromParent;
1767
+ }
1768
+ return (
1769
+ <ReqoreTagGroup>
1770
+ <ReqoreTag labelKey='renders' label={`${renders.current}`} />
1771
+ <ReqoreTag
1772
+ labelKey='bag identities'
1773
+ label={`${identities.current}`}
1774
+ intent={identities.current > 1 ? 'danger' : 'success'}
1775
+ />
1776
+ <ReqoreTag labelKey='language' label={`${inheritedFromParent?.language ?? 'none'}`} />
1777
+ </ReqoreTagGroup>
1778
+ );
1779
+ };
1780
+
1781
+ /**
1782
+ * Re-renders the form on demand, the way a host does on every keystroke
1783
+ * somewhere else on the page.
1784
+ */
1785
+ const InheritedScopeHarness = (args: any) => {
1786
+ const [tick, setTick] = useState(0);
1787
+ return (
1788
+ <>
1789
+ <ReqoreButton id='rerender-the-form' onClick={() => setTick((current) => current + 1)}>
1790
+ Re-render the form ({tick})
1791
+ </ReqoreButton>
1792
+ <ReqoreVerticalSpacer height={10} />
1793
+ <FormEngine {...args} />
1794
+ </>
1795
+ );
1796
+ };
1797
+
1798
+ // The bag a field forwards to any sub-form it hosts is a PROP, and it used to be
1799
+ // built inline — `{ ...inheritedFromParent, ...resolveInheritProps(...) }` — so
1800
+ // every field got a NEW object on every render of the form, including the
1801
+ // majority that declare no `inherit_props` at all and were handed
1802
+ // `{ ...(undefined ?? {}) }`. A memoised field re-renders on an unequal prop, so
1803
+ // this churn re-rendered every field of every form the engine draws, forever,
1804
+ // over a value that never changed (it surfaced as `Template field kind Updated
1805
+ // because: {inheritedFromParent}` repeating in the console). The probe below
1806
+ // counts the bag's identities: it must stay at 1 however often the form renders.
1807
+ export const CompactInheritedScopeKeepsItsIdentity: Story = {
1808
+ parameters: {
1809
+ docs: {
1810
+ description: {
1811
+ story:
1812
+ 'Re-renders a form whose fields inherit a scope from their parent — the inheritance bag each field is handed stays the same object, so a field that receives it is not re-rendered over a value that never changed.',
1813
+ },
1814
+ },
1815
+ },
1816
+ args: {
1817
+ compact: true,
1818
+ minColumnWidth: '300px',
1819
+ name: 'inherited-scope',
1820
+ inheritedFromParent: { language: 'qore' },
1821
+ value: {
1822
+ kind: { type: 'string', value: 'service' },
1823
+ body: { type: 'string', value: 'sub my_method() {}' },
1824
+ },
1825
+ options: {
1826
+ kind: {
1827
+ type: 'string',
1828
+ ui_type: 'scope-probe',
1829
+ display_name: 'Kind',
1830
+ short_desc: 'Declares no inherit_props — it forwards the bag it was handed',
1831
+ },
1832
+ body: {
1833
+ type: 'string',
1834
+ ui_type: 'scope-probe',
1835
+ display_name: 'Body',
1836
+ short_desc: 'Declares inherit_props — its bag is a merge, made once',
1837
+ inherit_props: { subjectKind: 'kind' },
1838
+ },
1839
+ } as unknown as IOptionsSchema,
1840
+ componentOverrides: { 'scope-probe': InheritedScopeProbe },
1841
+ initialExpandedOptions: ['kind', 'body'],
1842
+ },
1843
+ render: (args) => <InheritedScopeHarness {...args} />,
1844
+ play: async ({ canvasElement }) => {
1845
+ const canvas = within(canvasElement);
1846
+ await waitFor(() => expect(canvas.getAllByText('renders').length).toBe(2), { timeout: 10000 });
1847
+
1848
+ const identityCounts = () =>
1849
+ Array.from(canvasElement.querySelectorAll('.reqore-tag'))
1850
+ .filter((tag) => (tag.textContent ?? '').startsWith('bag identities'))
1851
+ .map((tag) => (tag.textContent ?? '').replace('bag identities', ''));
1852
+
1853
+ const before = identityCounts();
1854
+ expect(before).toEqual(['1', '1']);
1855
+
1856
+ // Render the form again, three times over, with nothing about the fields
1857
+ // changed.
1858
+ await _testsClickButton({ selector: '#rerender-the-form' });
1859
+ await _testsClickButton({ selector: '#rerender-the-form' });
1860
+ await _testsClickButton({ selector: '#rerender-the-form' });
1861
+ await _testsWaitForText('Re-render the form (3)');
1862
+
1863
+ // Every field was handed the SAME bag each time.
1864
+ expect(identityCounts()).toEqual(['1', '1']);
1865
+ },
1866
+ };
1867
+
1547
1868
  // The companion above deliberately stops at the collapsed summary, which is
1548
1869
  // exactly where this bug hid: nothing expanded a list-of-hash row inside a
1549
1870
  // compact form. `compact` is destructured out of AutoFormField's `...rest`, so
@@ -1750,12 +2071,15 @@ export const DependantsResetWhenParentChanges: Story = {
1750
2071
 
1751
2072
  await sleep(500);
1752
2073
 
2074
+ // The engine clears a dependant to `undefined`. This read `''` while an
2075
+ // emptied text field reported '' on its own after the reset, which is not
2076
+ // the engine's value and marked untouched forms as changed.
1753
2077
  await expect(args.onChange).toHaveBeenLastCalledWith(
1754
2078
  'test',
1755
2079
  expect.objectContaining({
1756
2080
  optionWithDependents: { type: 'long-string', value: 'My value changed' },
1757
2081
  anotherDependent: { type: 'long-string', value: 'My value will not change' },
1758
- dependent1: { type: 'long-string', value: '' },
2082
+ dependent1: { type: 'long-string', value: undefined },
1759
2083
  dependent2: { type: 'hash', value: undefined },
1760
2084
  }),
1761
2085
  undefined
@@ -4364,7 +4688,7 @@ const FieldTypeCatalogGroups: Record<string, IFormEngineGroup> = {
4364
4688
  * summary of the `{exp,args}` AST (`renderExpressionToText`); drilling in opens
4365
4689
  * the full `ExpressionField` in the card — the Visual builder (catalogue offline
4366
4690
  * via `mockExpressions`) plus the Text/DPQL editor, whose seed + Explain are
4367
- * served by the in-test `dpqlMockLsp`. No instance/LSP required.
4691
+ * served by the story language server every story gets. No instance required.
4368
4692
  */
4369
4693
  export const CompactExpressions: Story = {
4370
4694
  // chromatic off: ends with the live ExpressionField editor (Text mode) open.
@@ -4404,65 +4728,61 @@ export const CompactExpressions: Story = {
4404
4728
  } as unknown as IOptions,
4405
4729
  },
4406
4730
  play: async () => {
4407
- const stop = startDpqlMockLsp();
4408
- try {
4409
- // Read-first: the offline DPQL summary of the AST — the literals read as
4410
- // the text they are, and the template reference reads as a chip rather
4411
- // than as its raw `$local:name` token inside the string.
4412
- await waitFor(() => {
4413
- const chip = Array.from(document.querySelectorAll('.reqore-tag')).find((tag) =>
4414
- tag.textContent?.includes('$local:name')
4415
- );
4416
- expect(chip, 'the reference in the summary renders as a chip').toBeTruthy();
4417
- });
4418
- await _testsWaitForText('" == "John"');
4419
-
4420
- // Drill in → the card hosts the ExpressionField (Visual builder).
4421
- await fireEvent.click(
4422
- document.querySelector('.readfirst-row[data-field="condition"]') as HTMLElement
4423
- );
4424
- await waitFor(
4425
- () =>
4426
- expect(
4427
- document.querySelector('.options-readfirst-card[data-field="condition"] .expression')
4428
- ).toBeInTheDocument(),
4429
- { timeout: 10000 }
4731
+ // Read-first: the summary of the AST reads as DPQL, through the same
4732
+ // rendering the Explain panel and Preview use — the template reference a
4733
+ // chip rather than its raw `$local:name` token, the literal as the text it is.
4734
+ await waitFor(() => {
4735
+ const summary = document.querySelector(
4736
+ '.readfirst-row[data-field="condition"] .dpql-rendering'
4430
4737
  );
4431
- // The builder resolves the operator from the offline catalogue.
4432
- await _testsWaitForText('Logical Equals');
4738
+ expect(summary, 'the collapsed row renders its summary as DPQL').toBeTruthy();
4739
+ expect(summary?.textContent).toContain('local: name');
4740
+ expect(summary?.textContent).toContain('== "John"');
4741
+ });
4433
4742
 
4434
- // Text/DPQL tab — seeded from the AST via the mock LSP (dpql/serialize).
4435
- const textBtn = Array.from(
4436
- document.querySelectorAll(
4437
- '.options-readfirst-card[data-field="condition"] .expression-field button'
4438
- )
4439
- ).find((b) => b.textContent?.trim() === 'Text') as HTMLElement;
4440
- await fireEvent.click(textBtn);
4441
- await waitFor(
4442
- () =>
4443
- expect(
4444
- document.querySelector(
4445
- '.options-readfirst-card[data-field="condition"] [data-testid="expression-preview"]'
4446
- )
4447
- ).toBeInTheDocument(),
4448
- { timeout: 10000 }
4449
- );
4743
+ // Drill in → the card hosts the ExpressionField (Visual builder).
4744
+ await fireEvent.click(
4745
+ document.querySelector('.readfirst-row[data-field="condition"]') as HTMLElement
4746
+ );
4747
+ await waitFor(
4748
+ () =>
4749
+ expect(
4750
+ document.querySelector('.options-readfirst-card[data-field="condition"] .expression')
4751
+ ).toBeInTheDocument(),
4752
+ { timeout: 10000 }
4753
+ );
4754
+ // The builder resolves the operator from the offline catalogue.
4755
+ await _testsWaitForText('Logical Equals');
4450
4756
 
4451
- // The "Parsed" line is the single live rendering of the AST (over the
4452
- // mock dpql/renderExpression) — it reflects the seeded expression. The
4453
- // separate Text-mode "Explain" button was dropped; Parsed is canonical.
4454
- await waitFor(
4455
- () =>
4456
- expect(
4457
- document.querySelector(
4458
- '.options-readfirst-card[data-field="condition"] [data-testid="expression-preview"]'
4459
- )?.textContent
4460
- ).toContain('John'),
4461
- { timeout: 10000 }
4462
- );
4463
- } finally {
4464
- stop();
4465
- }
4757
+ // Text/DPQL tab — seeded from the AST via the mock LSP (dpql/serialize).
4758
+ const textBtn = Array.from(
4759
+ document.querySelectorAll(
4760
+ '.options-readfirst-card[data-field="condition"] .expression-field button'
4761
+ )
4762
+ ).find((b) => b.textContent?.trim() === 'Text') as HTMLElement;
4763
+ await fireEvent.click(textBtn);
4764
+ await waitFor(
4765
+ () =>
4766
+ expect(
4767
+ document.querySelector(
4768
+ '.options-readfirst-card[data-field="condition"] [data-testid="expression-preview"]'
4769
+ )
4770
+ ).toBeInTheDocument(),
4771
+ { timeout: 10000 }
4772
+ );
4773
+
4774
+ // The "Parsed" line is the single live rendering of the AST (over the
4775
+ // mock dpql/renderExpression) — it reflects the seeded expression. The
4776
+ // separate Text-mode "Explain" button was dropped; Parsed is canonical.
4777
+ await waitFor(
4778
+ () =>
4779
+ expect(
4780
+ document.querySelector(
4781
+ '.options-readfirst-card[data-field="condition"] [data-testid="expression-preview"]'
4782
+ )?.textContent
4783
+ ).toContain('John'),
4784
+ { timeout: 10000 }
4785
+ );
4466
4786
  },
4467
4787
  };
4468
4788
 
@@ -5242,15 +5562,18 @@ export const CompactFieldTypes: Story = {
5242
5562
  // than a raw hash key-count.
5243
5563
  await _testsWaitForText('512MiB');
5244
5564
  await _testsWaitForText(/example_customer_addresses/);
5245
- // Expression field → read-first shows the offline DPQL summary of the AST,
5246
- // with the template reference inside it chipped rather than printed raw.
5565
+ // Expression field → read-first shows the offline DPQL summary of the AST
5566
+ // as DPQL, with the template reference inside it chipped rather than
5567
+ // printed raw.
5247
5568
  await waitFor(() => {
5248
- const chip = Array.from(document.querySelectorAll('.reqore-tag')).find((tag) =>
5249
- tag.textContent?.includes('$local:name')
5569
+ const summary = document.querySelector('.readfirst-row[data-field="expr"] .dpql-rendering');
5570
+ const chip = Array.from(summary?.querySelectorAll('.reqore-tag') ?? []).find((tag) =>
5571
+ tag.textContent?.includes('name')
5250
5572
  );
5251
5573
  expect(chip, 'the reference in the summary renders as a chip').toBeTruthy();
5574
+ expect(summary?.textContent).toContain('== "John"');
5575
+ expect(summary?.textContent).not.toContain('$local:name');
5252
5576
  });
5253
- await _testsWaitForText('" == "John"');
5254
5577
  await expect(document.querySelectorAll('.options-readfirst-card')).toHaveLength(0);
5255
5578
 
5256
5579
  // A hash renders its read-first preview (the structured tree by default)
@@ -6572,15 +6895,13 @@ export const CompactAutoFocusTargetsInvalidFilledField: Story = {
6572
6895
  },
6573
6896
  };
6574
6897
 
6575
- // DEMO / manual-test story: the first "needs attention" field is a BOOLEAN,
6576
- // whose compact editor is a `<div tabindex=0>` (ReqoreCheckbox) — NOT an
6577
- // input/textarea/contenteditable that CompactRow's focus selector matches. Both
6578
- // `enabled` and `name` are required and unset, so both need attention; `enabled`
6579
- // is first. Open this in Storybook to see the UX: autofocus TARGETS the boolean
6580
- // (its row expands) and does NOT skip ahead to the focusable `name` field — but
6581
- // no element inside actually receives keyboard focus, so the caret is left
6582
- // nowhere. (This is the non-text-editor focus gap; kept as a playground rather
6583
- // than a hard assertion until we decide how CompactRow should focus such rows.)
6898
+ // The first "needs attention" field is a BOOLEAN, whose compact editor is a
6899
+ // focusable icon (ReqoreCheckbox) — not an input/textarea/contenteditable. Both
6900
+ // `enabled` and `name` are required and unset, so both need attention;
6901
+ // `enabled` is first. The row's scan used to look for text controls alone, so
6902
+ // autofocus targeted the boolean, opened its row, and left the caret nowhere —
6903
+ // this story was the playground that documented the gap. The row now focuses
6904
+ // whatever control it offers, so the caret is ON the boolean.
6584
6905
  const CompactNonFocusableFirstSchema: Record<string, TCompactField> = {
6585
6906
  enabled: {
6586
6907
  type: 'bool',
@@ -6609,7 +6930,7 @@ export const CompactAutoFocusNonFocusableFirstField: Story = {
6609
6930
  docs: {
6610
6931
  description: {
6611
6932
  story:
6612
- 'Renders a compact form with autoFocus enabled where the first needs-attention field is a boolean (not focusable) — the autofocus skips it and opens the next focusable field instead.',
6933
+ 'Renders a compact form with autoFocus enabled where the first needs-attention field is a boolean, whose editor is not a text box — the caret lands on the boolean itself rather than being left nowhere.',
6613
6934
  },
6614
6935
  },
6615
6936
  },
@@ -6622,29 +6943,167 @@ export const CompactAutoFocusNonFocusableFirstField: Story = {
6622
6943
  autoFocusFirstRequired: true,
6623
6944
  },
6624
6945
  play: async () => {
6625
- // Smoke: both required-but-unset fields render (both land in "needs
6626
- // attention"), with the boolean first. Focus behaviour is intentionally left
6627
- // for manual observation — see the note above.
6628
6946
  await _testsWaitForText('Enabled');
6629
6947
  await _testsWaitForText('Name');
6948
+
6949
+ // The caret is inside the boolean's row — the field the engine targeted.
6950
+ await waitFor(
6951
+ () => {
6952
+ const active = document.activeElement as HTMLElement | null;
6953
+ expect(document.querySelector('[data-field="enabled"]')?.contains(active!)).toBe(true);
6954
+ },
6955
+ { timeout: 10000 }
6956
+ );
6957
+ // On the control itself, which is not a text box: that is the whole case.
6958
+ const active = document.activeElement as HTMLElement;
6959
+ expect(['INPUT', 'TEXTAREA']).not.toContain(active.tagName);
6960
+ // It did not settle for the focusable text field further down instead.
6961
+ expect(document.querySelector('[data-field="name"]')?.contains(active)).toBe(false);
6630
6962
  },
6631
6963
  };
6632
6964
 
6633
- // A richtext value whose prose is interleaved with template chips — the shape an
6634
- // alert rule's message body has. The read-first summary renders the chips inline
6635
- // with the surrounding words, so the two have to sit on the same baseline.
6636
- const RichtextTemplateSchema: IOptionsSchema = {
6637
- body: {
6638
- type: 'richtext',
6639
- ui_type: 'richtext',
6640
- display_name: 'Message Body',
6965
+ // The first question most forms ask is "which kind?", and a pick-one is a set
6966
+ // of buttons: with ≤ 3 candidates the engine renders reqore checkboxes, which
6967
+ // are focusable icons rather than inputs. The engine opened this row and the
6968
+ // caret stayed on whatever opened the form.
6969
+ const CompactPickOneFirstSchema: Record<string, TCompactField> = {
6970
+ kind: {
6971
+ type: 'string',
6972
+ ui_type: 'string',
6973
+ display_name: 'Kind',
6974
+ short_desc: 'Which kind of interface is this? — the first required question',
6975
+ required: true,
6976
+ preselected: true,
6977
+ group: 'general',
6978
+ allowed_values: [
6979
+ { display_name: 'Service', value: { type: 'string', value: 'service' } },
6980
+ { display_name: 'Job', value: { type: 'string', value: 'job' } },
6981
+ { display_name: 'Workflow', value: { type: 'string', value: 'workflow' } },
6982
+ ],
6983
+ } as TCompactField,
6984
+ name: {
6985
+ type: 'string',
6986
+ ui_type: 'string',
6987
+ display_name: 'Name',
6988
+ short_desc: 'A text field — comes second',
6641
6989
  required: true,
6990
+ preselected: true,
6991
+ group: 'general',
6642
6992
  },
6643
6993
  };
6644
6994
 
6645
- const RichtextTemplateValue: IOptions = {
6646
- body: {
6647
- type: 'richtext',
6995
+ export const CompactAutoFocusPickOneFirstQuestion: Story = {
6996
+ parameters: {
6997
+ docs: {
6998
+ description: {
6999
+ story:
7000
+ 'Renders a compact form with autoFocus enabled whose first required question is a pick-one — the caret lands on the first choice, so the answer can be given from the keyboard.',
7001
+ },
7002
+ },
7003
+ },
7004
+ args: {
7005
+ compact: true,
7006
+ minColumnWidth: '300px',
7007
+ options: CompactPickOneFirstSchema as IOptionsSchema,
7008
+ value: {},
7009
+ groups: CompactGroups,
7010
+ autoFocusFirstRequired: true,
7011
+ },
7012
+ play: async () => {
7013
+ await _testsWaitForText('Service');
7014
+
7015
+ await waitFor(
7016
+ () => {
7017
+ const active = document.activeElement as HTMLElement | null;
7018
+ expect(document.querySelector('[data-field="kind"]')?.contains(active!)).toBe(true);
7019
+ },
7020
+ { timeout: 10000 }
7021
+ );
7022
+ const active = document.activeElement as HTMLElement;
7023
+ // The control the row actually offers: a choice, not a text box.
7024
+ expect(['INPUT', 'TEXTAREA']).not.toContain(active.tagName);
7025
+ expect(active.getAttribute('tabindex')).toBe('0');
7026
+ },
7027
+ };
7028
+
7029
+ /**
7030
+ * A host editor that cannot draw its control on the first frame — it resolves
7031
+ * its catalogue first, the way the IDE's connection and interface selectors do.
7032
+ * The row the engine opens is empty for the first commits, and the control is
7033
+ * the LAST thing to arrive.
7034
+ */
7035
+ const LateMountingEditor = () => {
7036
+ const [ready, setReady] = useState(false);
7037
+ useEffect(() => {
7038
+ const timer = setTimeout(() => setReady(true), 400);
7039
+ return () => clearTimeout(timer);
7040
+ }, []);
7041
+ return ready ?
7042
+ <ReqoreButton className='late-editor-control'>Choose a connection…</ReqoreButton>
7043
+ : <ReqoreP effect={{ opacity: 0.5 }}>Loading connections…</ReqoreP>;
7044
+ };
7045
+
7046
+ const CompactLateEditorSchema = {
7047
+ connection: {
7048
+ type: 'string',
7049
+ // A host `ui_type`: the editor is injected, exactly as the IDE injects its
7050
+ // connection and interface selectors.
7051
+ ui_type: 'late-editor',
7052
+ display_name: 'Connection',
7053
+ short_desc: 'A host editor that resolves its catalogue before it can draw a control',
7054
+ required: true,
7055
+ },
7056
+ } as unknown as IOptionsSchema;
7057
+
7058
+ export const CompactAutoFocusLateMountingEditor: Story = {
7059
+ parameters: {
7060
+ docs: {
7061
+ description: {
7062
+ story:
7063
+ "Renders a compact form whose first required question is drawn by a host editor that mounts its control after the form has rendered — the caret follows the control when it arrives, instead of being spent on a row that was still empty.",
7064
+ },
7065
+ },
7066
+ },
7067
+ args: {
7068
+ compact: true,
7069
+ minColumnWidth: '300px',
7070
+ options: CompactLateEditorSchema,
7071
+ value: {},
7072
+ autoFocusFirstRequired: true,
7073
+ componentOverrides: { 'late-editor': LateMountingEditor },
7074
+ },
7075
+ render: (args) => <FormEngine {...(args as IFormEngineProps)} />,
7076
+ play: async () => {
7077
+ // Nothing to focus yet: the row is open and its editor is still loading.
7078
+ await _testsWaitForText('Loading connections…');
7079
+ expect(document.activeElement).toBe(document.body);
7080
+
7081
+ await _testsWaitForText('Choose a connection…');
7082
+ await waitFor(
7083
+ () => {
7084
+ const active = document.activeElement as HTMLElement | null;
7085
+ expect(active?.closest('.late-editor-control')).toBeTruthy();
7086
+ },
7087
+ { timeout: 10000 }
7088
+ );
7089
+ },
7090
+ };
7091
+
7092
+ // A richtext value whose prose is interleaved with template chips — the shape an
7093
+ // alert rule's message body has. The read-first summary renders the chips inline
7094
+ // with the surrounding words, so the two have to sit on the same baseline.
7095
+ const RichtextTemplateSchema: IOptionsSchema = {
7096
+ body: {
7097
+ type: 'richtext',
7098
+ ui_type: 'richtext',
7099
+ display_name: 'Message Body',
7100
+ required: true,
7101
+ },
7102
+ };
7103
+
7104
+ const RichtextTemplateValue: IOptions = {
7105
+ body: {
7106
+ type: 'richtext',
6648
7107
  value: [
6649
7108
  {
6650
7109
  type: 'paragraph',
@@ -7179,10 +7638,10 @@ export const CompactListOfHashReadsInSchemaWordsMobile: Story = {
7179
7638
  };
7180
7639
 
7181
7640
  /**
7182
- * The read-first summary of an EXPRESSION: it renders to one line of text, and
7183
- * a template reference inside it used to print as its own raw token — the same
7184
- * reference the editor shows as a named chip. This is the Save Reply row of the
7185
- * Discord assistant template, whose value is
7641
+ * The read-first summary of an EXPRESSION: it reads as DPQL, and a template
7642
+ * reference inside it used to print as its own raw token — the same reference
7643
+ * the editor shows as a named chip. This is the Save Reply row of the Discord
7644
+ * assistant template, whose value is
7186
7645
  * `trim($data:{dc_ai_reply.choices[0].message.content})`.
7187
7646
  */
7188
7647
  export const CompactExpressionTemplateChips: Story = {
@@ -7190,7 +7649,7 @@ export const CompactExpressionTemplateChips: Story = {
7190
7649
  docs: {
7191
7650
  description: {
7192
7651
  story:
7193
- 'Renders a compact read-first row whose value is an expression wrapping a template reference — the reference renders as a named chip inside the summary text (trim("Choices[0].message.content")) rather than as its raw $data token.',
7652
+ 'Renders a compact read-first row whose value is an expression wrapping a template reference — the summary reads as DPQL and the reference inside it is a chip named from the form\'s template catalogue, trim(Choices[0].message.content), rather than its raw $data token or its path.',
7194
7653
  },
7195
7654
  },
7196
7655
  chromatic: { disable: true },
@@ -7241,20 +7700,20 @@ export const CompactExpressionTemplateChips: Story = {
7241
7700
  onChange={fn()}
7242
7701
  />
7243
7702
  ),
7244
- play: async () => {
7245
- // The summary keeps its literal text…
7246
- await _testsWaitForText('trim("', undefined, 1);
7247
- // …and the reference inside it is a named chip, not the raw token.
7703
+ play: async ({ canvasElement }) => {
7248
7704
  await waitFor(() => {
7249
- const chip = Array.from(document.querySelectorAll('.reqore-tag')).find((tag) =>
7705
+ const summary = canvasElement.querySelector('.readfirst-row .dpql-rendering');
7706
+ // The summary keeps its literal text…
7707
+ expect(summary?.textContent, 'the row summarises the expression as DPQL').toContain('trim(');
7708
+ // …and the reference inside it is a chip named from the catalogue.
7709
+ const chip = Array.from(summary?.querySelectorAll('.reqore-tag') ?? []).find((tag) =>
7250
7710
  tag.textContent?.includes('Choices[0].message.content')
7251
7711
  );
7252
7712
  expect(chip, 'the reference renders as a named chip in the summary').toBeTruthy();
7713
+ // Named, so neither the raw token nor the path it resolves.
7714
+ expect(summary?.textContent).not.toContain('$data:{');
7715
+ expect(summary?.textContent).not.toContain('dc_ai_reply');
7253
7716
  });
7254
- const raw = Array.from(document.querySelectorAll('.readfirst-row')).filter((row) =>
7255
- row.textContent?.includes('$data:{')
7256
- );
7257
- expect(raw, 'the row prints no raw token').toHaveLength(0);
7258
7717
  },
7259
7718
  };
7260
7719
 
@@ -7269,7 +7728,7 @@ export const CompactExpressionWithoutCatalogue: Story = {
7269
7728
  docs: {
7270
7729
  description: {
7271
7730
  story:
7272
- 'Renders a compact read-first row whose value is an expression wrapping a template reference, with no templates supplied — the reference still renders as a chip carrying its own path (dc_ai_reply.choices[0].message.content) rather than the raw $data token.',
7731
+ 'Renders a compact read-first row whose value is an expression wrapping a template reference, with no templates supplied — the summary reads as DPQL and the reference still renders as a chip carrying its own path (data: dc_ai_reply.choices[0].message.content) rather than the raw $data token.',
7273
7732
  },
7274
7733
  },
7275
7734
  chromatic: { disable: true },
@@ -7304,18 +7763,16 @@ export const CompactExpressionWithoutCatalogue: Story = {
7304
7763
  onChange={fn()}
7305
7764
  />
7306
7765
  ),
7307
- play: async () => {
7308
- await _testsWaitForText('trim("', undefined, 1);
7766
+ play: async ({ canvasElement }) => {
7309
7767
  await waitFor(() => {
7310
- const chip = Array.from(document.querySelectorAll('.reqore-tag')).find((tag) =>
7768
+ const summary = canvasElement.querySelector('.readfirst-row .dpql-rendering');
7769
+ expect(summary?.textContent, 'the row summarises the expression as DPQL').toContain('trim(');
7770
+ const chip = Array.from(summary?.querySelectorAll('.reqore-tag') ?? []).find((tag) =>
7311
7771
  tag.textContent?.includes('dc_ai_reply.choices[0].message.content')
7312
7772
  );
7313
7773
  expect(chip, 'the reference chips even with no catalogue').toBeTruthy();
7774
+ expect(summary?.textContent, 'the row prints no raw token').not.toContain('$data:{');
7314
7775
  });
7315
- const raw = Array.from(document.querySelectorAll('.readfirst-row')).filter((row) =>
7316
- row.textContent?.includes('$data:{')
7317
- );
7318
- expect(raw, 'the row prints no raw token').toHaveLength(0);
7319
7776
  },
7320
7777
  };
7321
7778
 
@@ -7469,12 +7926,12 @@ export const CompactToolbarTrimmed: Story = {
7469
7926
  },
7470
7927
  };
7471
7928
 
7472
- export const CompactCollapseListLeavesLoneOptionalOpen: Story = {
7929
+ export const CompactLoneOptionalStaysOpenByDefault: Story = {
7473
7930
  parameters: {
7474
7931
  docs: {
7475
7932
  description: {
7476
7933
  story:
7477
- "Renders an all-optional compact form with an explicit `compactCollapsedGroups={['optional']}` — the Optional box stays open anyway, because it is the whole form and collapsing it would render a titled card containing one collapsed strip and nothing else. The invariant outranks the consumer's collapse list.",
7934
+ 'Renders an all-optional compact form with NO `compactCollapsedGroups` — the Optional box is the whole form, so it stays open. Collapsing the only box would render a titled card containing one collapsed strip and nothing else, which reads as broken rather than as tidy. This is the default half of the contract; its pair below is what an explicit collapse list changes.',
7478
7935
  },
7479
7936
  },
7480
7937
  },
@@ -7486,16 +7943,42 @@ export const CompactCollapseListLeavesLoneOptionalOpen: Story = {
7486
7943
  beta: { type: 'string', ui_type: 'string', display_name: 'Beta' },
7487
7944
  } as unknown as IOptionsSchema,
7488
7945
  value: {} as IOptions,
7489
- compactCollapsedGroups: ['optional'],
7490
7946
  },
7491
7947
  play: async () => {
7492
- // The lone Optional box is open — its rows are on screen with no click.
7948
+ // No collapse list was passed, so the invariant holds and the rows are on
7949
+ // screen with no click.
7493
7950
  await expectBoxOpen('Optional');
7494
7951
  await _testsWaitForText('Alpha');
7495
7952
  await _testsWaitForText('Beta');
7496
7953
  },
7497
7954
  };
7498
7955
 
7956
+ export const CompactCollapseListCollapsesLoneOptional: Story = {
7957
+ parameters: {
7958
+ docs: {
7959
+ description: {
7960
+ story:
7961
+ "Renders the same all-optional form with an explicit `compactCollapsedGroups={['optional']}` — and this time the Optional box IS collapsed. Passing the prop waives the \"never collapse the sole box\" invariant: it is a default, not a law. A host that supplies its own heading and explanation around the form — a run-options panel headed \"Change a setting for this run\", say — has already told the reader what the card is, and has asked for the settings to start folded away. The default (the story above) is unchanged for every caller that says nothing.",
7962
+ },
7963
+ },
7964
+ },
7965
+ args: {
7966
+ compact: true,
7967
+ minColumnWidth: '300px',
7968
+ options: {
7969
+ alpha: { type: 'string', ui_type: 'string', display_name: 'Alpha' },
7970
+ beta: { type: 'string', ui_type: 'string', display_name: 'Beta' },
7971
+ } as unknown as IOptionsSchema,
7972
+ value: {} as IOptions,
7973
+ compactCollapsedGroups: ['optional'],
7974
+ },
7975
+ play: async () => {
7976
+ // The caller asked for it folded, so it is folded even though it is the
7977
+ // only box: `ReqorePanel` unmounts collapsed content, so the rows are gone.
7978
+ await expectBoxCollapsed('Optional');
7979
+ },
7980
+ };
7981
+
7499
7982
  export const CompactNoSearchCollapseIsFinal: Story = {
7500
7983
  parameters: {
7501
7984
  docs: {
@@ -7833,76 +8316,651 @@ export const CompactNoMaxFieldsShown: Story = {
7833
8316
  };
7834
8317
 
7835
8318
  /**
7836
- * A filter that matches nothing must not look like a broken form.
8319
+ * A test's cases read as chips on the collapsed row.
7837
8320
  *
7838
- * Found by building a DB schema by hand in qorus-ide: the filter box is
7839
- * the first input on that page, so reaching for "the name field" and
7840
- * hitting the filter is easy — and what came back was one grey line,
7841
- * "No fields match the current filters.", under a toolbar still reading
7842
- * "2 need attention →". The form claimed two fields wanted the reader's
7843
- * attention and then showed them none.
8321
+ * `test-cases` is a renderer-only `ui_type`, so it reached none of the typed
8322
+ * branches in `renderReadFirstValue` and fell through to the generic summary,
8323
+ * which flattens the array of case hashes to `"case_1, resolved_expected_values"`.
8324
+ * The table the row opens already draws each name as a chip, so the collapsed
8325
+ * row was the only surface still printing an identifier as prose.
7844
8326
  *
7845
- * At phone width that line plus the toolbar IS the whole form, and the
7846
- * input's own clear affordance is a ~20px target. So the empty state now
7847
- * names the query, says what is behind it, and carries the way back.
8327
+ * The fall-throughs are the point of the other two fields: chips are all-or-
8328
+ * nothing, because a partly-chipped row reads as though the unnamed cases were
8329
+ * missing entirely.
7848
8330
  */
7849
- export const CompactFilterMatchesNothing: Story = {
8331
+ export const CompactRowTestCaseChips: Story = {
7850
8332
  parameters: {
7851
8333
  docs: {
7852
8334
  description: {
7853
8335
  story:
7854
- 'Renders a compact form whose filter matches nothing. The empty state names the query, says how many of the hidden fields still need attention — the sentence that stops it contradicting the toolbar above — and offers "Show every field", which clears the filter and brings the form back.',
8336
+ 'Renders a collapsed `test-cases` row: every case name is a chip, matching the cases table the row opens. A value whose entries are not all named, and an empty value, keep the plain summary instead — chips are all-or-nothing.',
7855
8337
  },
7856
8338
  },
7857
- chromatic: { disable: true },
7858
8339
  },
7859
8340
  args: {
7860
8341
  compact: true,
7861
- minColumnWidth: '300px',
7862
- options: CompactSchema,
7863
- // Nothing filled, so the required fields genuinely need attention and
7864
- // the count in the empty state is a real one.
7865
- value: {},
7866
- groups: CompactGroups,
8342
+ minColumnWidth: '360px',
8343
+ value: {
8344
+ cases: {
8345
+ type: 'list',
8346
+ value: [
8347
+ { name: 'case_1', desc: 'the happy path' },
8348
+ { name: 'resolved_expected_values', desc: 'reference resolution' },
8349
+ ],
8350
+ },
8351
+ mixed: {
8352
+ type: 'list',
8353
+ value: [{ name: 'case_1' }, { desc: 'a case that has no name yet' }],
8354
+ },
8355
+ none: { type: 'list', value: [] },
8356
+ },
8357
+ // Cast for the same reason the markdown/cron/dpql stories need one: a
8358
+ // renderer-only `ui_type` names an EDITOR, and ts-toolkit's `TQorusType`
8359
+ // enumerates STORAGE types, so it has no member for one.
8360
+ options: {
8361
+ cases: { type: 'list', ui_type: 'test-cases', display_name: 'Cases' },
8362
+ mixed: { type: 'list', ui_type: 'test-cases', display_name: 'Mixed' },
8363
+ none: { type: 'list', ui_type: 'test-cases', display_name: 'None' },
8364
+ } as never,
7867
8365
  },
7868
- play: async () => {
7869
- await _testsWaitForText('Name');
8366
+ play: async ({ canvasElement }) => {
8367
+ // (a) Every case name is a chip, in order.
8368
+ await waitFor(
8369
+ () => {
8370
+ const chips = canvasElement.querySelectorAll(
8371
+ '[data-field="cases"] .options-readfirst-case-tag'
8372
+ );
8373
+ expect(chips.length).toBe(2);
8374
+ expect(Array.from(chips).map((chip) => chip.textContent?.trim())).toEqual([
8375
+ 'case_1',
8376
+ 'resolved_expected_values',
8377
+ ]);
8378
+ },
8379
+ { timeout: 5000 }
8380
+ );
7870
8381
 
7871
- await _testsChangeStringField({
7872
- selector: 'input[placeholder="Filter fields..."]',
7873
- value: 'zzzznomatch',
8382
+ // (b) …and the comma-joined prose they replace is gone. Asserting the text
8383
+ // rather than only the chip count: the first cut rendered the chips
8384
+ // BESIDE the summary, which is the same identifiers twice.
8385
+ const casesText =
8386
+ canvasElement
8387
+ .querySelector('[data-field="cases"] .options-readfirst-valuetext')
8388
+ ?.textContent?.trim() ?? '';
8389
+ expect(casesText).not.toContain('case_1,');
8390
+
8391
+ // (c) NEGATIVE: one entry has no name, so no entry is chipped — a
8392
+ // half-chipped row would read as though that case were missing.
8393
+ const mixedRow = canvasElement.querySelector('[data-field="mixed"]');
8394
+ expect(mixedRow?.querySelectorAll('.options-readfirst-case-tag').length).toBe(0);
8395
+
8396
+ // (d) NEGATIVE: an empty value is not a SET value, so it never reaches
8397
+ // this branch at all — its row sits in the collapsed optional group and
8398
+ // is not in the DOM. Counting canvas-wide says the same thing more
8399
+ // strongly than probing for a row that does not exist: the only chips
8400
+ // on the whole form are the two named cases, so neither the unnamed nor
8401
+ // the empty field contributed one.
8402
+ expect(canvasElement.querySelectorAll('.options-readfirst-case-tag').length).toBe(2);
8403
+ },
8404
+ };
8405
+
8406
+ /**
8407
+ * Clear-value is offered only when there is something to clear.
8408
+ *
8409
+ * `[]` is neither `undefined`, `null` nor `''`, so a list field whose last item
8410
+ * had just been removed went on showing the red clear button, and confirming it
8411
+ * cleared nothing. It is visible wherever a list field stays open — a test's
8412
+ * cases table sits above exactly such a button once its last case is deleted.
8413
+ *
8414
+ * Both fields here are lists with the same schema; only the value differs, so
8415
+ * the button's presence is decided by the value alone.
8416
+ */
8417
+ export const ClearValueOnlyWhenThereIsAValue: Story = {
8418
+ parameters: {
8419
+ docs: {
8420
+ description: {
8421
+ story:
8422
+ 'A list field with items offers Clear value; the same field with an empty list does not, because there is nothing to clear.',
8423
+ },
8424
+ },
8425
+ },
8426
+ args: {
8427
+ compact: true,
8428
+ minColumnWidth: '360px',
8429
+ /* Both rows OPEN, because that is where this button lives. A collapsed list
8430
+ row shows no clear at all — the inline one is only for editors with no
8431
+ clear of their own (bools, fixed-choice pickers) — so the button under
8432
+ test is the expanded card's, which is what a consumer that opens a field
8433
+ by default puts permanently on screen. */
8434
+ initialExpandedOptions: ['filled', 'emptied'],
8435
+ value: {
8436
+ filled: { type: 'list', value: ['alpha', 'beta'] },
8437
+ emptied: { type: 'list', value: [] },
8438
+ },
8439
+ options: {
8440
+ /* Both REQUIRED, like a test's `cases`. An unset optional field folds
8441
+ into the collapsed Optional box and is not in the DOM at all, so an
8442
+ empty list only stays on screen — where this button can be misclicked —
8443
+ when the field is one the form insists on. */
8444
+ filled: {
8445
+ type: 'list',
8446
+ ui_type: 'list',
8447
+ element_type: 'string',
8448
+ display_name: 'Filled',
8449
+ required: true,
8450
+ },
8451
+ emptied: {
8452
+ type: 'list',
8453
+ ui_type: 'list',
8454
+ element_type: 'string',
8455
+ display_name: 'Emptied',
8456
+ required: true,
8457
+ },
8458
+ } as never,
8459
+ },
8460
+ play: async ({ canvasElement }) => {
8461
+ await waitFor(
8462
+ () => {
8463
+ expect(canvasElement.querySelector('[data-field="filled"]')).toBeTruthy();
8464
+ },
8465
+ { timeout: 5000 }
8466
+ );
8467
+
8468
+ // (a) items present -> the clear button is offered
8469
+ const filled = canvasElement.querySelector('[data-field="filled"]');
8470
+ expect(filled?.querySelectorAll('.options-readfirst-clear').length).toBe(1);
8471
+
8472
+ // (b) empty list -> nothing to clear, so no button and no dialog to reach
8473
+ const emptied = canvasElement.querySelector('[data-field="emptied"]');
8474
+ expect(emptied).toBeTruthy();
8475
+ expect(emptied?.querySelectorAll('.options-readfirst-clear').length).toBe(0);
8476
+ },
8477
+ };
8478
+
8479
+ /**
8480
+ * A creatable picker is ONE control, not two.
8481
+ *
8482
+ * `rendersCreatableValueSelect` exists so that exactly one of the two renderers
8483
+ * draws the value: the chip picker holds it and offers the candidates, and the
8484
+ * raw editor stands down. It asked for `type === 'string'` — but a picker field
8485
+ * declares a storage type (`*string`) AND a `ui_type` (`select-string`), and by
8486
+ * the time the schema arrives the two are flattened to `type: 'select-string'`.
8487
+ * So neither renderer stood down: the picker drew the value, and the
8488
+ * saved-and-suggested list drew the same candidates again beneath it.
8489
+ *
8490
+ * It showed on a test's service-method step, where Service and Method offered
8491
+ * 183 and 7 values twice over in two controls that set one value each.
8492
+ */
8493
+ export const CreatablePickerIsOneControl: Story = {
8494
+ parameters: {
8495
+ docs: {
8496
+ description: {
8497
+ story:
8498
+ 'A creatable select-string with candidates renders a single picker that both holds the value and offers the list — not a picker plus a second "Saved & Suggested Values" control listing the same items.',
8499
+ },
8500
+ },
8501
+ },
8502
+ args: {
8503
+ compact: true,
8504
+ minColumnWidth: '360px',
8505
+ initialExpandedOptions: ['method'],
8506
+ value: { method: { type: 'select-string', value: 'init' } },
8507
+ options: {
8508
+ method: {
8509
+ type: 'select-string',
8510
+ ui_type: 'select-string',
8511
+ display_name: 'Method',
8512
+ required: true,
8513
+ allowed_values_creatable: true,
8514
+ allowed_values: [
8515
+ { display_name: 'init', value: { type: 'string', value: 'init' } },
8516
+ { display_name: 'start', value: { type: 'string', value: 'start' } },
8517
+ { display_name: 'stop', value: { type: 'string', value: 'stop' } },
8518
+ ],
8519
+ },
8520
+ } as never,
8521
+ },
8522
+ play: async ({ canvasElement }) => {
8523
+ const row = await waitFor(
8524
+ () => {
8525
+ const el = canvasElement.querySelector('[data-field="method"]');
8526
+ expect(el).toBeTruthy();
8527
+ return el as HTMLElement;
8528
+ },
8529
+ { timeout: 5000 }
8530
+ );
8531
+
8532
+ // The value is still shown and still editable — one control, not none.
8533
+ expect(row.textContent).toContain('init');
8534
+
8535
+ // …and the second way to set the same field is gone. Asserting the label
8536
+ // rather than a count: it is what the duplicate control announces itself as,
8537
+ // and what a reader saw twice.
8538
+ expect(row.textContent).not.toContain('Saved & Suggested Values');
8539
+ },
8540
+ };
8541
+
8542
+ /**
8543
+ * A chosen template hovers ONCE, and says what the value is.
8544
+ *
8545
+ * Two defects met on the same row. The chip carries its own popover and the row
8546
+ * carried a native `title` as well, so hovering fired both — in two places, and
8547
+ * only the popover appears in a screenshot, which is what makes the pair hard to
8548
+ * report. `showsTemplateChips` had already closed this for prose with templates
8549
+ * embedded in it; a value that IS one template was left open.
8550
+ *
8551
+ * And both of them showed the reference — `$._case.title` — which is the
8552
+ * grammar's business, not the author's. The catalogue already carries what they
8553
+ * want: the entry's name and the prose describing it. The reference stays in the
8554
+ * field's own help and in the picker, where it is genuinely needed.
8555
+ */
8556
+ export const TemplateValueHoversOnceAndDescribesItself: Story = {
8557
+ parameters: {
8558
+ docs: {
8559
+ description: {
8560
+ story:
8561
+ 'A row whose value is a chosen template shows one hover — the chip’s own — carrying the value’s name and description rather than the raw reference, and the row adds no second browser tooltip.',
8562
+ },
8563
+ },
8564
+ },
8565
+ args: {
8566
+ compact: true,
8567
+ minColumnWidth: '360px',
8568
+ value: { expected: { type: 'string', value: '$._case.title' } },
8569
+ options: {
8570
+ expected: {
8571
+ type: 'string',
8572
+ display_name: 'Expected Value',
8573
+ supports_templates: true,
8574
+ templates: {
8575
+ items: [
8576
+ {
8577
+ label: 'Values this case captures',
8578
+ items: [
8579
+ {
8580
+ value: '$._case.title',
8581
+ label: 'title (this case)',
8582
+ description: 'A short line naming what this case verifies. (string)',
8583
+ },
8584
+ ],
8585
+ },
8586
+ ],
8587
+ },
8588
+ },
8589
+ } as never,
8590
+ },
8591
+ play: async ({ canvasElement }) => {
8592
+ const row = await waitFor(
8593
+ () => {
8594
+ const el = canvasElement.querySelector('[data-field="expected"]');
8595
+ expect(el).toBeTruthy();
8596
+ return el as HTMLElement;
8597
+ },
8598
+ { timeout: 5000 }
8599
+ );
8600
+
8601
+ // (a) The row draws the friendly name, not the reference.
8602
+ expect(row.textContent).toContain('title (this case)');
8603
+
8604
+ // (b) ONE hover on the value. The row must not add a native `title`
8605
+ // alongside the chip's own popover — the label keeps its own, which is
8606
+ // a different element and a different thing to say.
8607
+ const nativeTitles = Array.from(row.querySelectorAll('[title]')).map((el) =>
8608
+ el.getAttribute('title')
8609
+ );
8610
+ expect(nativeTitles).not.toContain('$._case.title');
8611
+ },
8612
+ };
8613
+
8614
+ /** The grammar a Qorus test writes its references in — `$.step.field`, which
8615
+ * carries no `$key:` and so matches none of reqraft's own token grammar. */
8616
+ const SKIP_WHEN_GRAMMAR = /(\$\.[A-Za-z_][\w]*(?:(?:\.[A-Za-z_][\w]*)|(?:\[\d+\]))*)/g;
8617
+
8618
+ /** What that case's own picker offers, and the names it offers them under. */
8619
+ const SKIP_WHEN_TEMPLATES = {
8620
+ items: [
8621
+ {
8622
+ label: 'Values this case captures',
8623
+ items: [
8624
+ {
8625
+ value: '$._case.mode',
8626
+ label: 'mode (this case)',
8627
+ description: 'How this case runs — live, or simulated. (string)',
8628
+ },
8629
+ ],
8630
+ },
8631
+ ],
8632
+ };
8633
+
8634
+ /**
8635
+ * A value that MENTIONS a reference reads as a sentence, not as a token.
8636
+ *
8637
+ * The story above closed the case where the value IS one template. This is the
8638
+ * one next to it, and it is the shape a condition actually arrives in: a
8639
+ * comparison the author wrote with a reference inside it. It matched neither the
8640
+ * whole-value template branch nor the rich-text one, so it fell all the way
8641
+ * through to the plain string and the row printed `$._case.mode != 'simulate'` —
8642
+ * the engine's spelling of a value the author had picked by name, on a surface
8643
+ * whose only job is to show them what they wrote.
8644
+ *
8645
+ * Reported against a test case's **Skip when** field, where the case list above
8646
+ * the drawer already named the reference and the drawer beneath it did not.
8647
+ *
8648
+ * Both halves matter. The reference becomes the chip the picker offered; the
8649
+ * comparison is the author's own text and survives exactly as typed.
8650
+ */
8651
+ export const AValueThatMentionsATemplateReadsAsOne: Story = {
8652
+ parameters: {
8653
+ docs: {
8654
+ description: {
8655
+ story:
8656
+ 'A row whose value holds a reference inside the author’s own text — a skip-when predicate — draws the reference as the chip the picker names it by and leaves the comparison beside it exactly as written, instead of printing the raw token.',
8657
+ },
8658
+ },
8659
+ },
8660
+ args: {
8661
+ compact: true,
8662
+ minColumnWidth: '360px',
8663
+ value: {
8664
+ skip_when: { type: 'string', value: "$._case.mode != 'simulate'" },
8665
+ // The same value one level down, so the row's own line and the preview it
8666
+ // opens are compared against each other in one story.
8667
+ guard: {
8668
+ type: 'hash',
8669
+ value: { when: { type: 'string', value: "$._case.mode != 'simulate'" } },
8670
+ },
8671
+ },
8672
+ options: {
8673
+ skip_when: {
8674
+ type: 'string',
8675
+ display_name: 'Skip when',
8676
+ supports_templates: true,
8677
+ // The grammar this host writes its references in — `$.step.field`, which
8678
+ // carries no `$key:` and so matches none of reqraft's own token grammar.
8679
+ // A field that speaks one says so, exactly as it hands down its list.
8680
+ templateToken: SKIP_WHEN_GRAMMAR,
8681
+ templates: SKIP_WHEN_TEMPLATES,
8682
+ },
8683
+ guard: {
8684
+ type: 'hash',
8685
+ display_name: 'Guard',
8686
+ arg_schema: {
8687
+ when: {
8688
+ type: 'string',
8689
+ display_name: 'When',
8690
+ supports_templates: true,
8691
+ templateToken: SKIP_WHEN_GRAMMAR,
8692
+ templates: SKIP_WHEN_TEMPLATES,
8693
+ },
8694
+ },
8695
+ },
8696
+ } as never,
8697
+ },
8698
+ play: async ({ canvasElement }) => {
8699
+ const row = await waitFor(
8700
+ () => {
8701
+ const el = canvasElement.querySelector('[data-field="skip_when"]');
8702
+ expect(el).toBeTruthy();
8703
+ return el as HTMLElement;
8704
+ },
8705
+ { timeout: 5000 }
8706
+ );
8707
+
8708
+ // (a) The reference wears the name it was chosen by, on a chip.
8709
+ const chip = await waitFor(() => {
8710
+ const el = row.querySelector('.reqraft-template-chip');
8711
+ expect(el).toBeTruthy();
8712
+ return el as HTMLElement;
7874
8713
  });
8714
+ expect(chip.textContent).toContain('mode (this case)');
7875
8715
 
7876
- // The form is hidden...
7877
- await _testsWaitForTextToNotExist('Description');
7878
- // ...and the empty state says which filter did it, quoting the query
7879
- // rather than the generic "the current filters".
7880
- await _testsWaitForText(/No field matches "zzzznomatch"/);
7881
- // The half that resolves the contradiction with the toolbar: the
7882
- // fields are still there, the filter is simply in front of them.
7883
- await _testsWaitForText(/still need attention/);
8716
+ // (b) The author's comparison is still there, word for word.
8717
+ expect(row.textContent).toContain("!= 'simulate'");
7884
8718
 
7885
- // One control puts every field back.
7886
- const clear = document.querySelector('.options-readfirst-clear-filters') as HTMLElement;
7887
- expect(clear, 'the empty state must offer a way back').toBeTruthy();
7888
- clear.click();
8719
+ // (c) And the engine's own spelling is nowhere on the row — not in its text,
8720
+ // and not in a native tooltip hovering the very thing the chip replaced.
8721
+ expect(row.textContent).not.toContain('$._case.mode');
8722
+ const nativeTitles = Array.from(row.querySelectorAll('[title]')).map((el) =>
8723
+ el.getAttribute('title')
8724
+ );
8725
+ expect(nativeTitles.some((title) => title?.includes('$._case.mode'))).toBe(false);
7889
8726
 
7890
- await _testsWaitForText('Description');
7891
- await waitFor(() => {
7892
- const search = document.querySelector(
7893
- 'input[placeholder="Filter fields..."]'
7894
- ) as HTMLInputElement;
7895
- expect(search.value, 'clearing must empty the filter box too').toBe('');
8727
+ // (d) The schema preview under the hash row is the same value one level
8728
+ // down, and it used to reach the mono cell as a raw string — a row
8729
+ // disagreeing with its own inset about what its value is.
8730
+ const preview = await waitFor(() => {
8731
+ const el = canvasElement.querySelector('[data-field="guard"] .schema-data-view');
8732
+ expect(el).toBeTruthy();
8733
+ return el as HTMLElement;
7896
8734
  });
8735
+ expect(preview.querySelector('.reqraft-template-chip')?.textContent).toContain(
8736
+ 'mode (this case)'
8737
+ );
8738
+ expect(preview.textContent).toContain("!= 'simulate'");
8739
+ expect(preview.textContent).not.toContain('$._case.mode');
8740
+ },
8741
+ };
7897
8742
 
7898
- // Leave the form BACK in the state this story is about. The capture is
7899
- // taken after the play, so ending on the restored form would publish a
7900
- // picture of an ordinary form — and a reviewer looking at it would learn
7901
- // nothing about the empty state the story exists to show.
7902
- await _testsChangeStringField({
7903
- selector: 'input[placeholder="Filter fields..."]',
7904
- value: 'zzzznomatch',
8743
+ /**
8744
+ * The editor a row opens recognises the SAME references the row does.
8745
+ *
8746
+ * The story above asserts the two READ surfaces — the collapsed row and the
8747
+ * schema preview under a hash row. Neither of them opens anything, and the
8748
+ * editor was the surface that disagreed: `templateTextSegments` took the
8749
+ * field's declared grammar, while `templateTextToNodes` had no grammar
8750
+ * parameter at all and knew only reqraft's built-in `$key:{path}`. So a host's
8751
+ * own spelling was a named chip on the row and raw text the moment the author
8752
+ * clicked it — the same defect as before, pointing the other way.
8753
+ *
8754
+ * Both now resolve references through one `lineReferenceSpans`, and the field's
8755
+ * `templateToken` reaches the editor as a prop. This opens the row and asserts
8756
+ * the chip is still a chip, with the author's comparison beside it.
8757
+ */
8758
+ export const AHostGrammarSurvivesTheEditorTheRowOpens: Story = {
8759
+ parameters: {
8760
+ docs: {
8761
+ description: {
8762
+ story:
8763
+ 'Opens the Skip when row whose field declares its own token grammar — the reference stays the chip the picker names it by inside the editor, with the author’s comparison beside it, instead of reverting to its raw spelling.',
8764
+ },
8765
+ },
8766
+ },
8767
+ args: {
8768
+ ...AValueThatMentionsATemplateReadsAsOne.args,
8769
+ /* A SECOND reference the catalogue does not list. It is the one that
8770
+ separates the two mechanisms: `$._case.mode` is an offered value and
8771
+ would be found by matching the catalogue's literals alone, while
8772
+ `$._case.attempt` exists only as far as the declared grammar says it
8773
+ does. A surface that has not been given the grammar draws one chip
8774
+ here and the raw spelling of the other. */
8775
+ value: {
8776
+ skip_when: {
8777
+ type: 'string',
8778
+ value: "$._case.mode != 'simulate' && $._case.attempt == 1",
8779
+ },
8780
+ },
8781
+ },
8782
+ play: async ({ canvasElement }) => {
8783
+ const row = await waitFor(
8784
+ () => {
8785
+ const el = canvasElement.querySelector('[data-field="skip_when"]');
8786
+ expect(el).toBeTruthy();
8787
+ return el as HTMLElement;
8788
+ },
8789
+ { timeout: 5000 }
8790
+ );
8791
+
8792
+ // The collapsed row draws BOTH — the named one and the one only the
8793
+ // grammar knows the extent of.
8794
+ await waitFor(() =>
8795
+ expect(
8796
+ Array.from(row.querySelectorAll('.reqraft-template-chip')).map((chip) =>
8797
+ chip.textContent?.trim()
8798
+ )
8799
+ ).toEqual(['mode (this case)', '$._case.attempt'])
8800
+ );
8801
+
8802
+ // Open the row.
8803
+ fireEvent.click(row);
8804
+ await waitFor(() => expect(canvasElement.querySelector('.readfirst-row-editing')).toBeTruthy(), {
8805
+ timeout: 5000,
8806
+ });
8807
+
8808
+ // The editor draws the same reference as the same name.
8809
+ const editor = await waitFor(
8810
+ () => {
8811
+ const el = canvasElement.querySelector<HTMLElement>(
8812
+ '[data-field="skip_when"] [contenteditable="true"]'
8813
+ );
8814
+ expect(el).toBeTruthy();
8815
+ return el!;
8816
+ },
8817
+ { timeout: 10000 }
8818
+ );
8819
+ await waitFor(
8820
+ () =>
8821
+ expect(
8822
+ Array.from(editor.querySelectorAll('.reqore-tag')).map((chip) => chip.textContent?.trim())
8823
+ ).toEqual(['mode (this case)', '$._case.attempt']),
8824
+ { timeout: 10000 }
8825
+ );
8826
+
8827
+ // And the comparison the author wrote is beside them, unchanged — while
8828
+ // the engine's own spelling of the NAMED reference is nowhere in the
8829
+ // editor. (`$._case.attempt` has no name to wear: an unnamed reference is
8830
+ // its own honest label, and it is a chip either way.)
8831
+ expect(editor.textContent).toContain("!= 'simulate'");
8832
+ expect(editor.textContent).toContain('== 1');
8833
+ expect(editor.textContent).not.toContain('$._case.mode');
8834
+ },
8835
+ };
8836
+
8837
+ /**
8838
+ * A choice whose author has to click twice to change it.
8839
+ *
8840
+ * Read-first is the collapsed row's job: the row prints the value, and clicking
8841
+ * it mounts the editor. A PICKER's editor is a closed trigger printing that
8842
+ * same value — so the row opened, nothing on screen changed, and the author had
8843
+ * to click again to see the list. Reported on a test case's Kind row:
8844
+ * "clicking on a closed option like 'Kind' opens the option, but the same
8845
+ * chosen value is displayed … the user has to click twice to edit the option".
8846
+ *
8847
+ * A row the AUTHOR opens now opens its picker with it. A row the FORM opens
8848
+ * still does not — see `CompactInitialExpandedOptions` and
8849
+ * `CompactAutoFocusFirstRequired`, where the host is showing a row rather than
8850
+ * asking the question, and a list thrown over it would cover what it came to
8851
+ * show.
8852
+ */
8853
+ const CompactPickerRowSchema: Record<string, TCompactField> = {
8854
+ kind: {
8855
+ type: 'string',
8856
+ ui_type: 'string',
8857
+ display_name: 'Kind',
8858
+ short_desc: 'Which kind of assertion is this?',
8859
+ required: true,
8860
+ preselected: true,
8861
+ group: 'general',
8862
+ allowed_values: [
8863
+ {
8864
+ display_name: 'Process coverage',
8865
+ short_desc: 'Which Qorus process ran the code',
8866
+ value: { type: 'string', value: 'process_coverage' },
8867
+ },
8868
+ {
8869
+ display_name: 'Log match',
8870
+ short_desc: 'A line the run had to log',
8871
+ value: { type: 'string', value: 'log_match' },
8872
+ },
8873
+ {
8874
+ display_name: 'Value equals',
8875
+ short_desc: 'A captured value, compared',
8876
+ value: { type: 'string', value: 'value_equals' },
8877
+ },
8878
+ {
8879
+ display_name: 'Row count',
8880
+ short_desc: 'How many rows a query returned',
8881
+ value: { type: 'string', value: 'row_count' },
8882
+ },
8883
+ {
8884
+ display_name: 'Raises error',
8885
+ short_desc: 'The error the step had to raise',
8886
+ value: { type: 'string', value: 'raises_error' },
8887
+ },
8888
+ ],
8889
+ } as TCompactField,
8890
+ name: {
8891
+ type: 'string',
8892
+ ui_type: 'string',
8893
+ display_name: 'Name',
8894
+ short_desc: 'A text field — its editor is the text box itself',
8895
+ required: true,
8896
+ preselected: true,
8897
+ group: 'general',
8898
+ },
8899
+ };
8900
+
8901
+ export const CompactRowOpensItsPicker: Story = {
8902
+ parameters: {
8903
+ docs: {
8904
+ description: {
8905
+ story:
8906
+ 'Clicking a closed row whose value is a choice lands the author in the list of choices, in one click, instead of opening the row onto a closed picker showing the value it already had.',
8907
+ },
8908
+ },
8909
+ },
8910
+ args: {
8911
+ compact: true,
8912
+ minColumnWidth: '360px',
8913
+ options: CompactPickerRowSchema as IOptionsSchema,
8914
+ value: { kind: { type: 'string', value: 'log_match' } },
8915
+ groups: CompactGroups,
8916
+ },
8917
+ play: async () => {
8918
+ await _testsWaitForText('Kind');
8919
+ // Read-first: the row prints the chosen kind, and no other kind is on screen.
8920
+ await _testsWaitForTextToNotExist('Row count');
8921
+
8922
+ await _testsClickButton({ selector: '[data-field="kind"].readfirst-row' });
8923
+
8924
+ // ONE click, and the choices the author came for are in front of them.
8925
+ await _testsWaitForText('Row count');
8926
+ await _testsWaitForText('Raises error');
8927
+
8928
+ /* ...in ONE picker. The collection modal is rendered beside the trigger
8929
+ rather than as one of its branches, so it is not mutually exclusive with
8930
+ the anchored list, and `forceDropdown` defaults to true — so asking for
8931
+ the choices used to open a dialog ON TOP of the list, both offering the
8932
+ same five items. Asserting the items alone did not catch it: they were
8933
+ present twice. Reported on qlip build #212. */
8934
+ await _testsWaitForTextToNotExist('Select from items');
8935
+ },
8936
+ };
8937
+
8938
+ export const CompactTextRowOpensItsTextBox: Story = {
8939
+ parameters: {
8940
+ docs: {
8941
+ description: {
8942
+ story:
8943
+ 'The other half of the same rule: a row whose editor is a text box opens straight into it with the caret, and opens nothing over the form.',
8944
+ },
8945
+ },
8946
+ },
8947
+ args: {
8948
+ compact: true,
8949
+ minColumnWidth: '360px',
8950
+ options: CompactPickerRowSchema as IOptionsSchema,
8951
+ value: { name: { type: 'string', value: 'the assertion' } },
8952
+ groups: CompactGroups,
8953
+ },
8954
+ play: async () => {
8955
+ await _testsWaitForText('Name');
8956
+ await _testsClickButton({ selector: '[data-field="name"].readfirst-row' });
8957
+
8958
+ await waitFor(() => {
8959
+ const active = document.activeElement as HTMLElement;
8960
+ expect(['INPUT', 'TEXTAREA']).toContain(active.tagName);
8961
+ expect(document.querySelector('[data-field="name"]')?.contains(active)).toBe(true);
7905
8962
  });
7906
- await _testsWaitForText(/No field matches "zzzznomatch"/);
8963
+ // Nothing was opened over the form — the text box IS the editor.
8964
+ expect(document.querySelector('.reqraft-select-dialog')).toBeNull();
7907
8965
  },
7908
8966
  };