@qoretechnologies/reqraft 0.10.53 → 0.10.54-pr.115.g9bc96ed

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 (283) 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_EXPRESSIONS.md +110 -0
  6. package/design/FORM_ENGINE_FIELD_MIGRATION.md +56 -1
  7. package/design/RENDER_EXPRESSION_TRANSPORT.md +76 -10
  8. package/design/SMART_EDITOR_UX.md +21 -0
  9. package/dist/components/dpqlEditor/DpqlEditor.d.ts.map +1 -1
  10. package/dist/components/dpqlEditor/DpqlEditor.js +3 -3
  11. package/dist/components/dpqlEditor/DpqlEditor.js.map +1 -1
  12. package/dist/components/dpqlEditor/dpqlHelpers.d.ts +6 -0
  13. package/dist/components/dpqlEditor/dpqlHelpers.d.ts.map +1 -1
  14. package/dist/components/dpqlEditor/dpqlHelpers.js +21 -2
  15. package/dist/components/dpqlEditor/dpqlHelpers.js.map +1 -1
  16. package/dist/components/dpqlEditor/dpqlProbe.d.ts +37 -0
  17. package/dist/components/dpqlEditor/dpqlProbe.d.ts.map +1 -0
  18. package/dist/components/dpqlEditor/dpqlProbe.js +203 -0
  19. package/dist/components/dpqlEditor/dpqlProbe.js.map +1 -0
  20. package/dist/components/dpqlEditor/dpqlTags.d.ts +8 -0
  21. package/dist/components/dpqlEditor/dpqlTags.d.ts.map +1 -1
  22. package/dist/components/dpqlEditor/dpqlTags.js +5 -1
  23. package/dist/components/dpqlEditor/dpqlTags.js.map +1 -1
  24. package/dist/components/dpqlEditor/index.d.ts +4 -0
  25. package/dist/components/dpqlEditor/index.d.ts.map +1 -1
  26. package/dist/components/dpqlEditor/index.js +5 -1
  27. package/dist/components/dpqlEditor/index.js.map +1 -1
  28. package/dist/components/dpqlEditor/types.d.ts +42 -2
  29. package/dist/components/dpqlEditor/types.d.ts.map +1 -1
  30. package/dist/components/dpqlEditor/types.js +0 -4
  31. package/dist/components/dpqlEditor/types.js.map +1 -1
  32. package/dist/components/dpqlEditor/useDpqlProbe.d.ts +4 -0
  33. package/dist/components/dpqlEditor/useDpqlProbe.d.ts.map +1 -0
  34. package/dist/components/dpqlEditor/useDpqlProbe.js +29 -0
  35. package/dist/components/dpqlEditor/useDpqlProbe.js.map +1 -0
  36. package/dist/components/dpqlEditor/useDpqlSession.d.ts +5 -2
  37. package/dist/components/dpqlEditor/useDpqlSession.d.ts.map +1 -1
  38. package/dist/components/dpqlEditor/useDpqlSession.js +8 -2
  39. package/dist/components/dpqlEditor/useDpqlSession.js.map +1 -1
  40. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  41. package/dist/components/form/engine/CompactRow.js +192 -45
  42. package/dist/components/form/engine/CompactRow.js.map +1 -1
  43. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  44. package/dist/components/form/engine/CompactToolbar.js +5 -2
  45. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  46. package/dist/components/form/engine/FormEngine.d.ts +160 -12
  47. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  48. package/dist/components/form/engine/FormEngine.js +376 -75
  49. package/dist/components/form/engine/FormEngine.js.map +1 -1
  50. package/dist/components/form/engine/FormFieldsSkeleton.d.ts +48 -0
  51. package/dist/components/form/engine/FormFieldsSkeleton.d.ts.map +1 -0
  52. package/dist/components/form/engine/FormFieldsSkeleton.js +38 -0
  53. package/dist/components/form/engine/FormFieldsSkeleton.js.map +1 -0
  54. package/dist/components/form/engine/OptionFieldMessages.d.ts +17 -2
  55. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  56. package/dist/components/form/engine/OptionFieldMessages.js +61 -11
  57. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  58. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  59. package/dist/components/form/engine/_structuredData/SchemaDataView.js +7 -1
  60. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  61. package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
  62. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  63. package/dist/components/form/engine/readFirst.d.ts +42 -1
  64. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  65. package/dist/components/form/engine/readFirst.js +74 -7
  66. package/dist/components/form/engine/readFirst.js.map +1 -1
  67. package/dist/components/form/engine/rendererTypes.d.ts +13 -0
  68. package/dist/components/form/engine/rendererTypes.d.ts.map +1 -1
  69. package/dist/components/form/engine/rendererTypes.js.map +1 -1
  70. package/dist/components/form/engine/rowMenuContext.d.ts +78 -0
  71. package/dist/components/form/engine/rowMenuContext.d.ts.map +1 -0
  72. package/dist/components/form/engine/rowMenuContext.js +103 -0
  73. package/dist/components/form/engine/rowMenuContext.js.map +1 -0
  74. package/dist/components/form/engine/typeChoices.d.ts +37 -0
  75. package/dist/components/form/engine/typeChoices.d.ts.map +1 -0
  76. package/dist/components/form/engine/typeChoices.js +25 -0
  77. package/dist/components/form/engine/typeChoices.js.map +1 -0
  78. package/dist/components/form/expressions/DpqlRendering.d.ts +26 -0
  79. package/dist/components/form/expressions/DpqlRendering.d.ts.map +1 -0
  80. package/dist/components/form/expressions/DpqlRendering.js +53 -0
  81. package/dist/components/form/expressions/DpqlRendering.js.map +1 -0
  82. package/dist/components/form/expressions/ExpressionField.d.ts +7 -1
  83. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  84. package/dist/components/form/expressions/ExpressionField.js +202 -45
  85. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  86. package/dist/components/form/expressions/argumentPresence.d.ts +97 -0
  87. package/dist/components/form/expressions/argumentPresence.d.ts.map +1 -0
  88. package/dist/components/form/expressions/argumentPresence.js +203 -0
  89. package/dist/components/form/expressions/argumentPresence.js.map +1 -0
  90. package/dist/components/form/expressions/builder/argumentLabel.d.ts.map +1 -1
  91. package/dist/components/form/expressions/builder/argumentLabel.js +9 -4
  92. package/dist/components/form/expressions/builder/argumentLabel.js.map +1 -1
  93. package/dist/components/form/expressions/builder/index.d.ts +15 -2
  94. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  95. package/dist/components/form/expressions/builder/index.js +20 -58
  96. package/dist/components/form/expressions/builder/index.js.map +1 -1
  97. package/dist/components/form/expressions/builder/renderTemplate.d.ts +4 -1
  98. package/dist/components/form/expressions/builder/renderTemplate.d.ts.map +1 -1
  99. package/dist/components/form/expressions/builder/renderTemplate.js +10 -67
  100. package/dist/components/form/expressions/builder/renderTemplate.js.map +1 -1
  101. package/dist/components/form/expressions/dpqlMockLanguage.d.ts +76 -0
  102. package/dist/components/form/expressions/dpqlMockLanguage.d.ts.map +1 -0
  103. package/dist/components/form/expressions/dpqlMockLanguage.js +525 -0
  104. package/dist/components/form/expressions/dpqlMockLanguage.js.map +1 -0
  105. package/dist/components/form/expressions/dpqlMockLsp.d.ts +31 -2
  106. package/dist/components/form/expressions/dpqlMockLsp.d.ts.map +1 -1
  107. package/dist/components/form/expressions/dpqlMockLsp.js +86 -80
  108. package/dist/components/form/expressions/dpqlMockLsp.js.map +1 -1
  109. package/dist/components/form/expressions/mockExpressions.d.ts.map +1 -1
  110. package/dist/components/form/expressions/mockExpressions.js +8 -4
  111. package/dist/components/form/expressions/mockExpressions.js.map +1 -1
  112. package/dist/components/form/expressions/renderExpressionToText.d.ts +1 -1
  113. package/dist/components/form/expressions/renderExpressionToText.d.ts.map +1 -1
  114. package/dist/components/form/expressions/renderExpressionToText.js +95 -19
  115. package/dist/components/form/expressions/renderExpressionToText.js.map +1 -1
  116. package/dist/components/form/expressions/types.d.ts +7 -0
  117. package/dist/components/form/expressions/types.d.ts.map +1 -1
  118. package/dist/components/form/expressions/useRenderExpression.d.ts +7 -16
  119. package/dist/components/form/expressions/useRenderExpression.d.ts.map +1 -1
  120. package/dist/components/form/expressions/useRenderExpression.js +50 -126
  121. package/dist/components/form/expressions/useRenderExpression.js.map +1 -1
  122. package/dist/components/form/fields/Field.d.ts.map +1 -1
  123. package/dist/components/form/fields/Field.js +3 -1
  124. package/dist/components/form/fields/Field.js.map +1 -1
  125. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +11 -1
  126. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  127. package/dist/components/form/fields/allowed-values/AllowedValues.js +14 -2
  128. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  129. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  130. package/dist/components/form/fields/auto/AutoFormField.js +32 -8
  131. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  132. package/dist/components/form/fields/date/Date.d.ts +1 -1
  133. package/dist/components/form/fields/date/Date.d.ts.map +1 -1
  134. package/dist/components/form/fields/date/Date.js +16 -2
  135. package/dist/components/form/fields/date/Date.js.map +1 -1
  136. package/dist/components/form/fields/rich-text/RichText.d.ts +21 -1
  137. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  138. package/dist/components/form/fields/rich-text/RichText.js +140 -8
  139. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  140. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts +2 -1
  141. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  142. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +3 -2
  143. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  144. package/dist/components/form/fields/template/TemplateField.d.ts +31 -1
  145. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  146. package/dist/components/form/fields/template/TemplateField.js +528 -50
  147. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  148. package/dist/components/form/index.d.ts +1 -0
  149. package/dist/components/form/index.d.ts.map +1 -1
  150. package/dist/components/form/index.js +1 -0
  151. package/dist/components/form/index.js.map +1 -1
  152. package/dist/components/smartEditor/SmartEditor.d.ts.map +1 -1
  153. package/dist/components/smartEditor/SmartEditor.js +29 -8
  154. package/dist/components/smartEditor/SmartEditor.js.map +1 -1
  155. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts +8 -0
  156. package/dist/components/smartEditor/__fixtures__/mockLspServer.d.ts.map +1 -1
  157. package/dist/components/smartEditor/__fixtures__/mockLspServer.js +53 -10
  158. package/dist/components/smartEditor/__fixtures__/mockLspServer.js.map +1 -1
  159. package/dist/components/smartEditor/useLspSession.d.ts +5 -1
  160. package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
  161. package/dist/components/smartEditor/useLspSession.js +16 -2
  162. package/dist/components/smartEditor/useLspSession.js.map +1 -1
  163. package/dist/helpers/dpqlDetection.d.ts +58 -0
  164. package/dist/helpers/dpqlDetection.d.ts.map +1 -0
  165. package/dist/helpers/dpqlDetection.js +164 -0
  166. package/dist/helpers/dpqlDetection.js.map +1 -0
  167. package/dist/helpers/optionUiTypes.d.ts +3 -3
  168. package/dist/helpers/options.d.ts +37 -0
  169. package/dist/helpers/options.d.ts.map +1 -1
  170. package/dist/helpers/options.js +78 -1
  171. package/dist/helpers/options.js.map +1 -1
  172. package/dist/helpers/templateItems.d.ts +47 -0
  173. package/dist/helpers/templateItems.d.ts.map +1 -0
  174. package/dist/helpers/templateItems.js +88 -0
  175. package/dist/helpers/templateItems.js.map +1 -0
  176. package/dist/helpers/templateText.d.ts +37 -0
  177. package/dist/helpers/templateText.d.ts.map +1 -0
  178. package/dist/helpers/templateText.js +64 -0
  179. package/dist/helpers/templateText.js.map +1 -0
  180. package/dist/helpers/templates.d.ts +19 -11
  181. package/dist/helpers/templates.d.ts.map +1 -1
  182. package/dist/helpers/templates.js +46 -29
  183. package/dist/helpers/templates.js.map +1 -1
  184. package/dist/helpers/validations.d.ts.map +1 -1
  185. package/dist/helpers/validations.js +56 -9
  186. package/dist/helpers/validations.js.map +1 -1
  187. package/dist/hooks/useArgSchema.d.ts +2 -1
  188. package/dist/hooks/useArgSchema.d.ts.map +1 -1
  189. package/dist/hooks/useFetch/useFetch.d.ts +3 -1
  190. package/dist/hooks/useFetch/useFetch.d.ts.map +1 -1
  191. package/dist/hooks/useFetch/useFetch.js +47 -9
  192. package/dist/hooks/useFetch/useFetch.js.map +1 -1
  193. package/dist/hooks/useTemplates.d.ts +20 -0
  194. package/dist/hooks/useTemplates.d.ts.map +1 -1
  195. package/dist/hooks/useTemplates.js +34 -2
  196. package/dist/hooks/useTemplates.js.map +1 -1
  197. package/dist/index.d.ts +2 -1
  198. package/dist/index.d.ts.map +1 -1
  199. package/dist/index.js +5 -1
  200. package/dist/index.js.map +1 -1
  201. package/dist/stories/storyNetwork.d.ts +36 -0
  202. package/dist/stories/storyNetwork.d.ts.map +1 -0
  203. package/dist/stories/storyNetwork.js +82 -0
  204. package/dist/stories/storyNetwork.js.map +1 -0
  205. package/dist/utils/fetch.d.ts +151 -1
  206. package/dist/utils/fetch.d.ts.map +1 -1
  207. package/dist/utils/fetch.js +256 -44
  208. package/dist/utils/fetch.js.map +1 -1
  209. package/dist/utils/lspClient.d.ts +6 -0
  210. package/dist/utils/lspClient.d.ts.map +1 -1
  211. package/dist/utils/lspClient.js +62 -11
  212. package/dist/utils/lspClient.js.map +1 -1
  213. package/dist/utils/websocket.d.ts +6 -0
  214. package/dist/utils/websocket.d.ts.map +1 -1
  215. package/dist/utils/websocket.js +15 -7
  216. package/dist/utils/websocket.js.map +1 -1
  217. package/package.json +2 -2
  218. package/src/components/dpqlEditor/DpqlEditor.stories.tsx +3 -119
  219. package/src/components/dpqlEditor/DpqlEditor.tsx +5 -2
  220. package/src/components/dpqlEditor/dpqlHelpers.ts +20 -1
  221. package/src/components/dpqlEditor/dpqlProbe.ts +165 -0
  222. package/src/components/dpqlEditor/dpqlTags.ts +13 -0
  223. package/src/components/dpqlEditor/index.ts +4 -0
  224. package/src/components/dpqlEditor/types.ts +38 -2
  225. package/src/components/dpqlEditor/useDpqlProbe.ts +33 -0
  226. package/src/components/dpqlEditor/useDpqlSession.ts +20 -4
  227. package/src/components/form/engine/CompactRow.tsx +240 -51
  228. package/src/components/form/engine/CompactToolbar.tsx +10 -3
  229. package/src/components/form/engine/FormEngine.stories.tsx +423 -120
  230. package/src/components/form/engine/FormEngine.tsx +504 -87
  231. package/src/components/form/engine/FormFieldsSkeleton.tsx +76 -0
  232. package/src/components/form/engine/OptionFieldMessages.tsx +89 -11
  233. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +11 -0
  234. package/src/components/form/engine/compactToolbarContext.ts +1 -1
  235. package/src/components/form/engine/readFirst.ts +86 -7
  236. package/src/components/form/engine/rendererTypes.ts +12 -0
  237. package/src/components/form/engine/rowMenuContext.ts +159 -0
  238. package/src/components/form/engine/typeChoices.ts +55 -0
  239. package/src/components/form/expressions/DpqlRendering.tsx +85 -0
  240. package/src/components/form/expressions/ExpressionField.stories.tsx +826 -48
  241. package/src/components/form/expressions/ExpressionField.tsx +265 -54
  242. package/src/components/form/expressions/argumentPresence.ts +200 -0
  243. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +20 -4
  244. package/src/components/form/expressions/builder/argumentLabel.tsx +10 -4
  245. package/src/components/form/expressions/builder/index.tsx +39 -77
  246. package/src/components/form/expressions/builder/renderTemplate.tsx +23 -56
  247. package/src/components/form/expressions/dpqlMockLanguage.ts +565 -0
  248. package/src/components/form/expressions/dpqlMockLsp.ts +109 -83
  249. package/src/components/form/expressions/mockExpressions.ts +8 -4
  250. package/src/components/form/expressions/renderExpressionToText.ts +112 -22
  251. package/src/components/form/expressions/types.ts +7 -0
  252. package/src/components/form/expressions/useRenderExpression.ts +43 -127
  253. package/src/components/form/fields/Field.tsx +3 -2
  254. package/src/components/form/fields/allowed-values/AllowedValues.tsx +13 -2
  255. package/src/components/form/fields/auto/AutoFormField.stories.tsx +27 -20
  256. package/src/components/form/fields/auto/AutoFormField.tsx +33 -9
  257. package/src/components/form/fields/date/Date.tsx +17 -1
  258. package/src/components/form/fields/rich-text/RichText.tsx +199 -22
  259. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +4 -2
  260. package/src/components/form/fields/template/TemplateField.stories.tsx +31 -9
  261. package/src/components/form/fields/template/TemplateField.tsx +714 -122
  262. package/src/components/form/index.tsx +1 -0
  263. package/src/components/log/Log.stories.tsx +2 -1
  264. package/src/components/smartEditor/SmartEditor.tsx +41 -9
  265. package/src/components/smartEditor/__fixtures__/mockLspServer.ts +52 -3
  266. package/src/components/smartEditor/useLspSession.ts +21 -3
  267. package/src/helpers/dpqlDetection.ts +209 -0
  268. package/src/helpers/options.ts +70 -0
  269. package/src/helpers/templateItems.ts +81 -0
  270. package/src/helpers/templateText.ts +92 -0
  271. package/src/helpers/templates.ts +47 -37
  272. package/src/helpers/validations.ts +59 -9
  273. package/src/hooks/useFetch/useFetch.stories.tsx +6 -5
  274. package/src/hooks/useFetch/useFetch.tsx +53 -7
  275. package/src/hooks/useStorage/useStorage.stories.tsx +2 -1
  276. package/src/hooks/useTemplates.ts +41 -0
  277. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +2 -1
  278. package/src/index.tsx +14 -1
  279. package/src/stores/currentUser/currentUser.stories.tsx +2 -1
  280. package/src/stories/storyNetwork.ts +92 -0
  281. package/src/utils/fetch.ts +311 -21
  282. package/src/utils/lspClient.ts +68 -11
  283. package/src/utils/websocket.ts +20 -9
@@ -28,8 +28,8 @@ import {
28
28
  IReqoreTextareaProps,
29
29
  } from '@qoretechnologies/reqore/dist/components/Textarea';
30
30
  import { IQorusFormFieldSchemaBase, TQorusType } from '@qoretechnologies/ts-toolkit';
31
- import { size } from 'lodash';
32
- import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
31
+ import { omit, size } from 'lodash';
32
+ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
33
33
  import { useUpdateEffect } from 'react-use';
34
34
  import {
35
35
  filterTemplatesByType as templatesFilterFunc,
@@ -40,7 +40,14 @@ import {
40
40
  describeTemplateReference,
41
41
  isValueTemplate,
42
42
  } from '../../../../helpers/templates';
43
+ import { classifyTypedText, mightBeDpqlExpression } from '../../../../helpers/dpqlDetection';
44
+ import {
45
+ renderTemplateItemDescriptions,
46
+ templateItemsToShow,
47
+ } from '../../../../helpers/templateItems';
48
+ import { useMarkdownRenderer } from '../../../Description/markdownRendererContext';
43
49
  import { getTypeFromValue } from '../../../../helpers/validations';
50
+ import { useDpqlProbe } from '../../../dpqlEditor/useDpqlProbe';
44
51
  import { useQorusTypes } from '../../../../hooks/useQorusTypes';
45
52
  import { useWhyDidYouUpdate } from '../../../../hooks/useWhyDidYouUpdate';
46
53
  import { ExpressionBuilder, IExpressionBuilderProps } from '../../expressions/builder';
@@ -57,11 +64,27 @@ import LongStringFormField from '../long-string/LongString';
57
64
  import NumberFormField from '../number/Number';
58
65
  import { ReadOnlyTemplateTag } from './ReadOnlyTemplateTag';
59
66
  import { RichTextFormField } from '../rich-text/RichText';
67
+ import { richtextToString } from '../../../../helpers/common';
68
+ import { isSingleLineStringType } from '../../../../helpers/singleLineString';
69
+ import {
70
+ IRowMenuRegistration,
71
+ RowMenuContext,
72
+ useRowMenu,
73
+ useRowMenuPublisher,
74
+ } from '../../engine/rowMenuContext';
60
75
 
61
76
  // Re-export template utilities for consumers
62
77
  export { getTemplateKey, getTemplateValue, isValueTemplate };
63
78
  export type { IQorusFormType as IQorusType };
64
79
 
80
+ /**
81
+ * How long the author must pause before typed text is sent to the DPQL
82
+ * probe. Long enough that a probe is not sent for every prefix of what is
83
+ * being typed, short enough that the offer appears while they are still
84
+ * looking at the field.
85
+ */
86
+ const DPQL_DETECT_DEBOUNCE_MS = 400;
87
+
65
88
  export const TemplatesListProps: IReqoreDropdownProps = {
66
89
  useTargetWidth: true,
67
90
  handler: 'focus',
@@ -160,6 +183,14 @@ export interface ITemplateFieldProps extends Partial<
160
183
  * out of `rest` and never spread onto a leaf input.
161
184
  */
162
185
  extraActions?: IExpressionBuilderProps['extraActions'];
186
+ /**
187
+ * SEAM (reqraft): the host's per-`ui_type` editors, forwarded to the field
188
+ * component and to the expression editor's operand rows. Declared here — not
189
+ * left to the index signature — for the same reason as `extraActions` above:
190
+ * so it is destructured out of `rest` and never spread onto a leaf input or a
191
+ * Reqore layout component, both of which pass unknown props to the DOM.
192
+ */
193
+ componentOverrides?: Record<string, React.FC<any>>;
163
194
  [key: string]: any;
164
195
  default_value?: unknown;
165
196
  }
@@ -297,10 +328,9 @@ export const CustomMenuItems = memo(
297
328
  return (
298
329
  <ReqoreMenuSection label='Set Custom Value' isCollapsed transparent icon='Text' {...rest}>
299
330
  {items.map((menuItem, index) =>
300
- 'isDivider' in menuItem ? (
331
+ 'isDivider' in menuItem ?
301
332
  <ReqoreMenuDivider key={index} {...menuItem} />
302
- ) : (
303
- <ReqoreButton
333
+ : <ReqoreButton
304
334
  {...(rest as any)}
305
335
  {...menuItem}
306
336
  key={index}
@@ -312,7 +342,6 @@ export const CustomMenuItems = memo(
312
342
  });
313
343
  }}
314
344
  />
315
- )
316
345
  )}
317
346
  </ReqoreMenuSection>
318
347
  );
@@ -331,6 +360,15 @@ export const TemplateDropdownSelector = memo(
331
360
  size,
332
361
  ...rest
333
362
  }: ITemplateDropdownSelectorProps) => {
363
+ /* The LAST hop before Reqore, which is the only place a drawn description
364
+ may be made: `filteredTemplates` upstream is a `JSON.stringify` memo key,
365
+ and an element in it throws on its own circular owner. */
366
+ const renderMarkdown = useMarkdownRenderer();
367
+ const shownItems = useMemo(
368
+ () => renderTemplateItemDescriptions(templateItemsToShow(items), renderMarkdown),
369
+ [items, renderMarkdown]
370
+ );
371
+
334
372
  // One resolver for every surface that names a reference — the picker chip
335
373
  // here, the read-only tag, and the compact row's expression summary. The
336
374
  // row and this editor must agree: a reference that reads as its path when
@@ -364,7 +402,7 @@ export const TemplateDropdownSelector = memo(
364
402
  minimal
365
403
  compact
366
404
  onItemSelect={onItemSelect}
367
- items={items}
405
+ items={shownItems}
368
406
  label={label}
369
407
  leftIconProps={leftIconProps}
370
408
  caretPosition='right'
@@ -372,7 +410,7 @@ export const TemplateDropdownSelector = memo(
372
410
  size={size}
373
411
  {...TemplatesListProps}
374
412
  />
375
- {allowCustomValues || value ? (
413
+ {allowCustomValues || value ?
376
414
  <ReqoreButton
377
415
  customTheme={TemplatesListProps.listCustomTheme}
378
416
  fixed
@@ -384,15 +422,48 @@ export const TemplateDropdownSelector = memo(
384
422
  size={size}
385
423
  onClick={onRemoveClick}
386
424
  />
387
- ) : null}
425
+ : null}
388
426
  </ReqoreControlGroup>
389
427
  </ReqoreControlGroup>
390
428
  );
391
429
  }
392
430
  );
393
431
 
394
- export const TemplateField = memo(
432
+ /**
433
+ * `ui_type`s whose OWN editor renders templates as chips inline.
434
+ *
435
+ * For these the template SELECTOR must not take over: it replaces a control the
436
+ * author can type in with one they can only pick from. A Qorus test assertion's
437
+ * `Value` is the case this was written for — the IDE's own `auto.tsx` already
438
+ * renders it as template rich text (type freely, references become chips), and
439
+ * the same field reached through this form engine offered a dropdown and no way
440
+ * to type at all.
441
+ *
442
+ * `richtext` is reqraft's own; a consumer adds its types through the
443
+ * `templateAwareUiTypes` prop, exactly as it adds `rendererOnlyUiTypes`.
444
+ */
445
+ export const BuiltInTemplateAwareUiTypes = ['richtext'];
446
+
447
+ export const isTemplateAwareUiType = (uiType?: string, extra?: string[]): boolean =>
448
+ !!uiType && [...BuiltInTemplateAwareUiTypes, ...(extra ?? [])].includes(uiType);
449
+
450
+ /**
451
+ * One entry of the "set a value of this type" menu an untyped field offers.
452
+ *
453
+ * `onClick` is typed by what the caller actually invokes it with — the chosen
454
+ * value and a reset callback — rather than as `Function`, which accepts a class
455
+ * declaration as readily as a handler and gives no help at the call site.
456
+ */
457
+ interface IUntypedFieldMenuItem {
458
+ label?: unknown;
459
+ description?: unknown;
460
+ isDivider?: boolean;
461
+ onClick?: (value: unknown, reset: () => void) => void;
462
+ }
463
+
464
+ const TemplateFieldImpl = memo(
395
465
  ({
466
+ rowMenu,
396
467
  value,
397
468
  name,
398
469
  onChange,
@@ -403,6 +474,7 @@ export const TemplateField = memo(
403
474
  allowFunctions,
404
475
  allowTextExpressions,
405
476
  extraActions,
477
+ componentOverrides,
406
478
  allowCustomValues = true,
407
479
  filterTemplatesByType = true,
408
480
  filterTemplatesFunc,
@@ -418,7 +490,7 @@ export const TemplateField = memo(
418
490
  reorder,
419
491
  label,
420
492
  ...rest
421
- }: ITemplateFieldProps) => {
493
+ }: ITemplateFieldProps & { rowMenu?: IRowMenuRegistration }) => {
422
494
  const qorusTypes = useQorusTypes();
423
495
  const functions = useExpressions({
424
496
  allow: !!allowFunctions,
@@ -427,9 +499,22 @@ export const TemplateField = memo(
427
499
  });
428
500
  const type = rest.ui_type || rest.type || rest.defaultType;
429
501
 
502
+ /* The type an EXPRESSION must return, and is stored with, is a DATA type.
503
+ `type` above deliberately prefers `ui_type`, because that is what decides
504
+ which editor to render — but a ui_type says how a value is EDITED, not
505
+ what it is. The IDE's reference fields carry `ui_type: 'test-reference'`
506
+ (a rich-text editor that turns `$.` paths into chips), and feeding that
507
+ to the expression builder asked it for an expression RETURNING
508
+ `test-reference`: no expression returns one, so every choice was refused
509
+ with *"the expected return type is test-reference"*, and the ui_type was
510
+ then stored on the saved expression as its declared type.
511
+
512
+ The declared data type is the constraint; the ui_type is only a fallback
513
+ for a field that declares no type at all. */
514
+ const expressionDataType = rest.type || rest.defaultType || rest.ui_type;
515
+
430
516
  const filteredTemplates = useMemo<IReqoreFormTemplates>(():
431
- | IReqoreFormTemplates
432
- | undefined => {
517
+ IReqoreFormTemplates | undefined => {
433
518
  if (!allowTemplates) {
434
519
  return undefined;
435
520
  }
@@ -444,7 +529,20 @@ export const TemplateField = memo(
444
529
  result = filterTemplatesFunc(result);
445
530
  }
446
531
 
447
- return result;
532
+ /* A lone category is opened, once, HERE — where this control resolves the
533
+ templates every one of its pickers then reads: the "Select Template"
534
+ dropdown, the in-editor `$` list, the numeric field's focus dropdown and
535
+ the expression builder's argument picker. Applying it per picker meant
536
+ each new one had to remember, and the ones that forgot made the author
537
+ click through a header naming the only category on offer to reach the
538
+ only values on offer.
539
+
540
+ LAST, after both filters. `filterTemplatesFunc` is given the grouped
541
+ shape on purpose — the Qog expression builder filters top-level items by
542
+ `metadata.dataRole`, which is a property of the CATEGORY — so flattening
543
+ before it would hand it values and quietly change what it keeps. This
544
+ only changes what is finally shown. */
545
+ return { ...result, items: templateItemsToShow(result?.items) };
448
546
  }, [
449
547
  JSON.stringify(templates),
450
548
  type,
@@ -469,24 +567,74 @@ export const TemplateField = memo(
469
567
  // Only while the field is EMPTY. A field already holding a literal must
470
568
  // open showing that literal — flipping to the template view would hide a
471
569
  // value the author put there and make it look lost.
472
- // ...and only when there is actually something to pick. A field with no
473
- // templates on offer would otherwise open on an EMPTY picker, which is a
474
- // worse place to start than the type picker it replaced.
570
+ //
571
+ // A field that may hold a custom value opens there whether or not templates
572
+ // are on offer, and whether or not they have ARRIVED: template mode gives it
573
+ // a typable editor, which offers the list once there is one. Waiting for the
574
+ // list decided the landing from data that is async — on a cold load the
575
+ // type-filtered list did not exist at mount, so the field fell to the type
576
+ // picker and stayed there. A pick-only field (no custom values) is in
577
+ // template mode regardless, by the `!allowCustomValues` term.
475
578
  const typeIsAnyLike = type === 'any' || type === 'auto';
476
- const isEmptyValue = value === undefined || value === null || value === '';
579
+ /* An EMPTY field, for the purpose of offering the template selector.
580
+
581
+ `null` is deliberately not empty. It is the DPQL null literal — a value
582
+ an author writes to say "this is nothing", and the only way to assert
583
+ that a call returned no value. Counted as emptiness here, an argument
584
+ holding it was treated as a field the author had just cleared: with a
585
+ type of `any` and templates on offer, the selector-restore effect below
586
+ flipped the field into template mode with a null template value, and
587
+ that reported the field as cleared — so writing `null` in the Text tab
588
+ and switching to Visual silently erased it, leaving `{type: "any"}` in
589
+ the draft and the message "Value for argument 1 ("any") is invalid:
590
+ Value is empty".
591
+
592
+ `undefined` is the cleared state; `null` is a value. Same rule as
593
+ `expressions/argumentPresence`, which states it canonically. */
594
+ const isEmptyValue = value === undefined || value === '';
477
595
  const hasTemplatesOnOffer = !!size(filteredTemplates?.items);
478
- const [isTemplate, setIsTemplate] = useState<boolean>(
479
- (isDefaultTemplate ||
480
- isValueTemplate(value) ||
481
- !allowCustomValues ||
482
- (typeIsAnyLike && isEmptyValue && hasTemplatesOnOffer)) &&
596
+ /* The field's own editor already renders templates inline, so the selector
597
+ is not merely unnecessary here — it is a downgrade, swapping a typable
598
+ control for a pick-only one. Derived rather than folded into the state
599
+ below so every `setIsTemplate` path keeps working untouched; they simply
600
+ stop having anything to say for these fields. */
601
+ const editorHandlesTemplates =
602
+ !!allowTemplates && isTemplateAwareUiType(type as string, (rest as any).templateAwareUiTypes);
603
+
604
+ const hasOnlyAllowedValues = useMemo(
605
+ () => !!size(rest.allowed_values) && !rest.allowed_values_creatable,
606
+ [rest.allowed_values, rest.allowed_values_creatable]
607
+ );
608
+
609
+ /** Where this field lands when it is empty: an untyped one in template mode — see above. */
610
+ const emptyLandsOnTemplates =
611
+ typeIsAnyLike && (hasTemplatesOnOffer || (!!allowCustomValues && !hasOnlyAllowedValues));
612
+ const opensOnTemplates = isEmptyValue && emptyLandsOnTemplates;
613
+
614
+ const [isTemplateState, setIsTemplate] = useState<boolean>(
615
+ (isDefaultTemplate || isValueTemplate(value) || !allowCustomValues || opensOnTemplates) &&
483
616
  allowTemplates
484
617
  );
618
+
619
+ const isTemplate = editorHandlesTemplates ? false : isTemplateState;
485
620
  const [internalIsFunction, setInternalIsFunction] = useState<boolean>(
486
621
  !!isDefaultFunction && !!allowFunctions
487
622
  );
488
623
  const [templateValue, setTemplateValue] = useState<string | null>(value);
489
624
 
625
+ // Typed text that turned out to be an expression the author may accept.
626
+ const [dpqlOffer, setDpqlOffer] = useState<{ text: string; expression: any } | null>(null);
627
+ // The literal this field held before typed text switched it into
628
+ // expression mode — kept so the switch is undoable, and so the editor
629
+ // opens on the Text view the author was already writing in.
630
+ const [expressionFromText, setExpressionFromText] = useState<string | null>(null);
631
+ // Texts the author has said no to. Without this, dismissing an offer (or
632
+ // undoing a switch) would re-detect the same text on the next render and
633
+ // ask again forever.
634
+ const declinedTexts = useRef<Set<string>>(new Set());
635
+ const detectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
636
+ const probeDpql = useDpqlProbe();
637
+
490
638
  const effectiveIsFunction = isFunction || internalIsFunction;
491
639
 
492
640
  useWhyDidYouUpdate(`Template field ${name}`, {
@@ -502,6 +650,63 @@ export const TemplateField = memo(
502
650
  }
503
651
  }, [JSON.stringify(value)]);
504
652
 
653
+ /* Clearing an untyped field returns it to the template selector, which is
654
+ where mounting it empty already lands.
655
+
656
+ The rule above that opens an `any`-like field on the template selector is
657
+ a `useState` INITIALISER, so it only ever ran on mount. Clearing a value
658
+ in place does not remount anything, so the field fell through to the TYPE
659
+ picker and demanded `string`/`int`/`hash` before it would let the author
660
+ name a value they had already captured -- the very question that rule
661
+ exists to stop asking. Reloading the page fixed it, which is the tell:
662
+ same field, same empty value, different landing, because only one of the
663
+ two paths ran the rule.
664
+
665
+ Watched as a TRANSITION from a value to no value, not as "the value is
666
+ empty": an author who picks `Set Custom Value` on an empty field is
667
+ asking for the type picker, and re-asserting the template view on every
668
+ render while the field sat empty would take it away again immediately. */
669
+ const hadValue = useRef(!isEmptyValue);
670
+ /* A clear is REMEMBERED rather than acted on in the same render.
671
+
672
+ While the field holds a value its resolved type is that VALUE's type — a
673
+ reference reads as `test-reference`, not `auto` — and clearing reverts it
674
+ to the schema's `auto` one render LATER than the value empties. Acting
675
+ only on the transition therefore ran while `typeIsAnyLike` was still
676
+ false and did nothing, and by the time the type settled the transition
677
+ had passed. Measured in the live IDE at the moment of the clear:
678
+ `{ wasCleared: true, typeIsAnyLike: false, type: "test-reference",
679
+ hasTemplatesOnOffer: true }`, then `typeIsAnyLike: true` on the very next
680
+ render with `wasCleared` already false. */
681
+ const restoreSelectorWhenSettled = useRef(false);
682
+ /** Set by the template control's own `×`, which is an author saying "not a
683
+ * template" — that clear must not be answered with the selector again. */
684
+ const suppressSelectorRestore = useRef(false);
685
+
686
+ useEffect(() => {
687
+ if (hadValue.current && isEmptyValue) {
688
+ restoreSelectorWhenSettled.current = !suppressSelectorRestore.current;
689
+ suppressSelectorRestore.current = false;
690
+ }
691
+ if (!isEmptyValue) {
692
+ // A value arrived: whatever the author did, they are not sitting on an
693
+ // empty field waiting to be offered the list.
694
+ restoreSelectorWhenSettled.current = false;
695
+ }
696
+ hadValue.current = !isEmptyValue;
697
+
698
+ if (
699
+ restoreSelectorWhenSettled.current &&
700
+ opensOnTemplates &&
701
+ allowTemplates &&
702
+ !effectiveIsFunction
703
+ ) {
704
+ restoreSelectorWhenSettled.current = false;
705
+ setTemplateValue(null);
706
+ setIsTemplate(true);
707
+ }
708
+ }, [isEmptyValue, opensOnTemplates, allowTemplates, effectiveIsFunction]);
709
+
505
710
  useEffect(() => {
506
711
  if (allowCustomValues && isTemplate && value && !isValueTemplate(value)) {
507
712
  setIsTemplate(false);
@@ -522,6 +727,17 @@ export const TemplateField = memo(
522
727
  return;
523
728
  }
524
729
 
730
+ // A template reference with an OPERATOR on it (`$local:count + 1`) is
731
+ // an expression, not a template. The loose check above passes it —
732
+ // it starts with `$` and has a colon — so without this every such
733
+ // string was swallowed into template mode, where the arithmetic is
734
+ // dead text and no affordance can reach it. A token standing alone
735
+ // (`$local:count`, `$data:{1.field}`) has no operator and still
736
+ // flips, which is the case template mode is for.
737
+ if (!isCompleteTemplateToken(value) && mightBeDpqlExpression(value)) {
738
+ return;
739
+ }
740
+
525
741
  setIsTemplate(true);
526
742
  setTemplateValue(value);
527
743
  }
@@ -534,11 +750,6 @@ export const TemplateField = memo(
534
750
  }
535
751
  }, [JSON.stringify(templateValue)]);
536
752
 
537
- const hasOnlyAllowedValues = useMemo(
538
- () => !!size(rest.allowed_values) && !rest.allowed_values_creatable,
539
- [rest.allowed_values, rest.allowed_values_creatable]
540
- );
541
-
542
753
  const showTemplateToggle = allowCustomValues && allowTemplates && !rest.arg_schema;
543
754
 
544
755
  // Only a BRACED context ref (`$data:{…}` — machine-written, nobody types
@@ -550,8 +761,23 @@ export const TemplateField = memo(
550
761
  // the rich-text string mode.
551
762
  const templateValueIsBracedToken = isBracedTemplateToken(templateValue);
552
763
 
764
+ /* An UNTYPED field can be typed into as well as picked from.
765
+
766
+ Template mode has a typable editor whenever the field's type can hold
767
+ arbitrary text, and `any`/`auto` can — it is the type that has not been
768
+ narrowed yet, not a type that excludes text. Restricting this to
769
+ `string` meant an empty untyped field opened on template mode with no
770
+ editor to offer, so it fell through to the pick-only "Select Template"
771
+ dropdown: a control that can only choose from a list, on exactly the
772
+ fields where an author most often needs to write something the list
773
+ cannot hold — a deeper walk, a literal, or an expression.
774
+
775
+ Reported against a Qorus assertion's Expected Value, and it was never
776
+ specific to that field: every empty untyped field with templates on
777
+ offer got the same downgrade. The editor still offers the same templates
778
+ on focus, so nothing is lost by being able to type as well. */
553
779
  const templateSupportsCustomValues =
554
- allowCustomValues && type === 'string' && !hasOnlyAllowedValues;
780
+ allowCustomValues && (type === 'string' || typeIsAnyLike) && !hasOnlyAllowedValues;
555
781
  const showTemplatesDropdown =
556
782
  allowTemplates && (!allowCustomValues || (isTemplate && !templateSupportsCustomValues));
557
783
  const hasOnlyExpressions = !allowCustomValues && !allowTemplates && allowFunctions;
@@ -572,18 +798,28 @@ export const TemplateField = memo(
572
798
  const componentTypeProp = componentFromType ? { type } : {};
573
799
  const fieldAriaLabel = rest['aria-label'] ?? label ?? rest.display_name ?? name;
574
800
 
575
-
576
801
  const handleTemplateFieldChange = useCallback(
577
802
  (_name: string, val: string) => {
578
803
  if (!val) {
579
- setIsTemplate(false);
804
+ /* Emptying the text leaves the field where an empty field lands. For
805
+ an untyped field that is THIS editor: switching to custom mode here
806
+ mounted a different editor in its place, so deleting the last
807
+ character lost the cursor. */
808
+ if (!(emptyLandsOnTemplates && allowTemplates)) {
809
+ setIsTemplate(false);
810
+ }
580
811
  setTemplateValue(null);
581
812
  onChange(name, undefined);
582
813
  } else {
583
814
  setTemplateValue(val);
584
815
  }
585
816
  },
586
- [name, onChange]
817
+ [name, onChange, emptyLandsOnTemplates, allowTemplates]
818
+ );
819
+
820
+ const handleTemplateTextChange = useCallback(
821
+ (val: unknown) => handleTemplateFieldChange(name, typeof val === 'string' ? val : ''),
822
+ [handleTemplateFieldChange, name]
587
823
  );
588
824
 
589
825
  const handleSelectTemplateFromList = useCallback(
@@ -591,24 +827,38 @@ export const TemplateField = memo(
591
827
  (item) => {
592
828
  // If the template type is richtext, we need to wrap the template value in the richtext template format
593
829
  const value =
594
- item.badge === 'richtext'
595
- ? ([
596
- {
597
- type: 'paragraph',
598
- children: [
599
- {
600
- children: [{ text: '' }],
601
- label: item.label,
602
- type: 'tag',
603
- value: item.value,
604
- metadata: item.metadata,
605
- },
606
- ],
607
- },
608
- ] as IReqoreRichTextEditorProps['value'])
609
- : item.value;
610
-
611
- onChange(name, value, hasOnlyAllowedValues ? (type as TQorusType) : (item.badge as TQorusType));
830
+ item.badge === 'richtext' ?
831
+ ([
832
+ {
833
+ type: 'paragraph',
834
+ /* The empty text nodes on either side are load-bearing. A
835
+ chip is an inline VOID: it holds no text of its own, so the
836
+ only places a cursor can go are the text nodes AROUND it.
837
+ Slate treats that as an invariant and repairs it during
838
+ normalisation, which runs on edits but NOT on a document
839
+ handed in whole as a controlled value — which is what this
840
+ is. Without them the picked template renders as a chip that
841
+ cannot be typed after, so it can never be extended by hand. */
842
+ children: [
843
+ { text: '' },
844
+ {
845
+ children: [{ text: '' }],
846
+ label: item.label,
847
+ type: 'tag',
848
+ value: item.value,
849
+ metadata: item.metadata,
850
+ },
851
+ { text: '' },
852
+ ],
853
+ },
854
+ ] as IReqoreRichTextEditorProps['value'])
855
+ : item.value;
856
+
857
+ onChange(
858
+ name,
859
+ value,
860
+ hasOnlyAllowedValues ? (type as TQorusType) : (item.badge as TQorusType)
861
+ );
612
862
  },
613
863
  [name, onChange]
614
864
  );
@@ -618,7 +868,15 @@ export const TemplateField = memo(
618
868
  // IDE-only, so the menu item is dropped (`allowSaving` is inert).
619
869
 
620
870
  const handleRemoveTemplateClick = useCallback(() => {
871
+ /* This is the `×` ON the template control, and it means "I do not want a
872
+ template here" — on a field whose menu offers no `Set Custom Value` it
873
+ is the ONLY way to reach a literal. So it still drops to the custom
874
+ value editor, and it tells the empty-field rule below to keep its hands
875
+ off this particular clear: that rule watches the value going away, and
876
+ this handler clears the value too, so without the flag it would send
877
+ the author straight back to the selector they just dismissed. */
621
878
  if (allowCustomValues) {
879
+ suppressSelectorRestore.current = true;
622
880
  setIsTemplate(false);
623
881
  }
624
882
 
@@ -668,12 +926,245 @@ export const TemplateField = memo(
668
926
  (expressionValue: IExpression | undefined, remove: boolean) => {
669
927
  if (remove) {
670
928
  setInternalIsFunction(false);
929
+ // The expression is gone, so there is no longer a switch to undo.
930
+ setExpressionFromText(null);
671
931
  }
672
- onChange(name, expressionValue?.value, type as TQorusType, !remove);
932
+ onChange(name, expressionValue?.value, expressionDataType as TQorusType, !remove);
933
+ },
934
+ [name, onChange, expressionDataType, value]
935
+ );
936
+
937
+ // ─── Text typed into a plain field that is really a DPQL expression ───
938
+ //
939
+ // A field that accepts expressions still gets DPQL TYPED into it, in the
940
+ // ordinary editor, by an author who never opened the expression view.
941
+ // What happens then depends on the field's own type, and the asymmetry is
942
+ // deliberate: text that could stand as a literal here is only ever
943
+ // OFFERED, because silently rewriting the string `a + b` into a
944
+ // concatenation would destroy a value the author meant. Text that could
945
+ // NOT be a literal here was already an error the moment it was typed, so
946
+ // switching costs nothing and explains the error.
947
+ //
948
+ // The server decides whether the text is an expression at all — see
949
+ // `helpers/dpqlDetection` for why a successful parse alone means nothing.
950
+ const validationField = useMemo(
951
+ () => ({
952
+ validation_regex: rest.validation_regex,
953
+ has_to_be_valid_identifier: rest.has_to_be_valid_identifier,
954
+ has_to_have_value: rest.has_to_have_value,
955
+ rules: rest.rules,
956
+ arg_schema: rest.arg_schema,
957
+ }),
958
+ [
959
+ rest.validation_regex,
960
+ rest.has_to_be_valid_identifier,
961
+ rest.has_to_have_value,
962
+ JSON.stringify(rest.rules),
963
+ JSON.stringify(rest.arg_schema),
964
+ ]
965
+ );
966
+
967
+ const enterExpressionFromText = useCallback(
968
+ (text: string, expression: any) => {
969
+ setDpqlOffer(null);
970
+ setExpressionFromText(text);
971
+ setInternalIsFunction(true);
972
+ setIsTemplate(false);
973
+ setTemplateValue(null);
974
+ // `dpql/parse` answers with the field-ready envelope
975
+ // (`{is_expression, value}`); this field stores the inner AST and
976
+ // signals the flag through `onChange`'s fourth argument, exactly as
977
+ // `handleExpressionChange` does.
978
+ onChange?.(name, expression?.value ?? expression, expressionDataType as TQorusType, true);
673
979
  },
674
- [name, onChange, type, value]
980
+ [name, onChange, expressionDataType]
675
981
  );
676
982
 
983
+ const undoExpressionFromText = useCallback(() => {
984
+ if (expressionFromText === null) {
985
+ return;
986
+ }
987
+
988
+ declinedTexts.current.add(expressionFromText);
989
+ setInternalIsFunction(false);
990
+ setExpressionFromText(null);
991
+ onChange?.(name, expressionFromText, type as TQorusType, false);
992
+ }, [expressionFromText, name, onChange, type]);
993
+
994
+ const declineDpqlOffer = useCallback(() => {
995
+ if (dpqlOffer) {
996
+ declinedTexts.current.add(dpqlOffer.text);
997
+ }
998
+
999
+ setDpqlOffer(null);
1000
+ }, [dpqlOffer]);
1001
+
1002
+ const acceptDpqlOffer = useCallback(() => {
1003
+ if (dpqlOffer) {
1004
+ enterExpressionFromText(dpqlOffer.text, dpqlOffer.expression);
1005
+ }
1006
+ }, [dpqlOffer, enterExpressionFromText]);
1007
+
1008
+ // Held in a ref so the debounce below does not depend on it. `onChange`
1009
+ // comes from the host form and can be a fresh function on any render;
1010
+ // with the callback in the effect's deps, a re-render caused by a
1011
+ // DIFFERENT field would restart this field's timer, and a form the
1012
+ // author is typing in re-renders constantly.
1013
+ const enterExpressionRef = useRef(enterExpressionFromText);
1014
+ useEffect(() => {
1015
+ enterExpressionRef.current = enterExpressionFromText;
1016
+ }, [enterExpressionFromText]);
1017
+
1018
+ // Detection only runs on a field that could hold an expression and is
1019
+ // currently showing a plain editor — never on a read-only field, a
1020
+ // fixed-value list, or one already in template or expression mode.
1021
+ const canDetectDpql =
1022
+ !!allowFunctions &&
1023
+ !!allowTextExpressions &&
1024
+ !effectiveIsFunction &&
1025
+ !isTemplate &&
1026
+ !hasOnlyAllowedValues &&
1027
+ !rest.readonly &&
1028
+ !rest.readOnly &&
1029
+ !rest.disabled;
1030
+
1031
+ useEffect(() => {
1032
+ if (detectTimer.current) {
1033
+ clearTimeout(detectTimer.current);
1034
+ detectTimer.current = null;
1035
+ }
1036
+
1037
+ /* A richtext field is still text. It hands its value back as a Slate
1038
+ DOCUMENT rather than a flattened string — deliberately, so a chosen
1039
+ reference cannot fuse with text typed beside it — and reading only
1040
+ `typeof value === 'string'` here silently switched detection off for
1041
+ every one of them the moment the field changed shape. A test
1042
+ assertion's Value is exactly that field, and `1 + 2` typed into it
1043
+ stopped being offered as an expression.
1044
+
1045
+ `richtextToString` flattens a tag to its raw value, so a document
1046
+ holding only a chosen reference reads as `$.result` — no operator, so
1047
+ `mightBeDpqlExpression` rejects it below and the server is never
1048
+ asked. */
1049
+ const text =
1050
+ typeof value === 'string' ? value
1051
+ : Array.isArray(value) ? richtextToString(value as never)
1052
+ : undefined;
1053
+
1054
+ if (
1055
+ !canDetectDpql ||
1056
+ !text ||
1057
+ declinedTexts.current.has(text) ||
1058
+ !mightBeDpqlExpression(text)
1059
+ ) {
1060
+ setDpqlOffer(null);
1061
+ return undefined;
1062
+ }
1063
+
1064
+ let cancelled = false;
1065
+
1066
+ detectTimer.current = setTimeout(() => {
1067
+ detectTimer.current = null;
1068
+
1069
+ void probeDpql(text)
1070
+ .then((parsed) => {
1071
+ if (cancelled) {
1072
+ return;
1073
+ }
1074
+
1075
+ const outcome = classifyTypedText({ text, type, field: validationField, parsed });
1076
+
1077
+ if (outcome === 'switch') {
1078
+ enterExpressionRef.current(text, parsed.expression);
1079
+ } else if (outcome === 'offer') {
1080
+ setDpqlOffer({ text, expression: parsed.expression });
1081
+ } else {
1082
+ setDpqlOffer(null);
1083
+ }
1084
+ })
1085
+ .catch(() => {
1086
+ // Detection is an offer of help, never a reason for a field to
1087
+ // break: an unreachable instance or a classifier that threw leaves
1088
+ // the author's text exactly where they put it.
1089
+ if (!cancelled) {
1090
+ setDpqlOffer(null);
1091
+ }
1092
+ });
1093
+ }, DPQL_DETECT_DEBOUNCE_MS);
1094
+
1095
+ return () => {
1096
+ cancelled = true;
1097
+
1098
+ if (detectTimer.current) {
1099
+ clearTimeout(detectTimer.current);
1100
+ detectTimer.current = null;
1101
+ }
1102
+ };
1103
+ }, [value, canDetectDpql, type, validationField, probeDpql]);
1104
+
1105
+ const canOfferExpression =
1106
+ allowFunctions && !hasOnlyAllowedValues && !rest.readonly && !internalIsFunction;
1107
+ const canOfferTemplate = showTemplateToggle && !isTemplate;
1108
+
1109
+ const publishedItems = useMemo(
1110
+ () => [
1111
+ ...(canOfferExpression && !functions.loading
1112
+ ? [
1113
+ {
1114
+ label: 'Use Expression',
1115
+ icon: 'Functions' as const,
1116
+ tooltip: 'Run a function on this value',
1117
+ onClick: handleSelectFunctionChange,
1118
+ },
1119
+ ]
1120
+ : []),
1121
+ ...(canOfferTemplate
1122
+ ? [
1123
+ {
1124
+ label: 'Use Template',
1125
+ icon: 'MoneyDollarCircleLine' as const,
1126
+ tooltip: 'Use a template',
1127
+ onClick: handleTemplateToggleClick,
1128
+ },
1129
+ ]
1130
+ : []),
1131
+ /* The "set a value of this type" choices an untyped field offers.
1132
+ Dividers are dropped: they grouped items in a menu this field drew
1133
+ itself, and in the row's shared menu they would divide other
1134
+ people's. */
1135
+ ...((menuItems ?? []) as IUntypedFieldMenuItem[])
1136
+ .filter((item) => !('isDivider' in item))
1137
+ .map((item) => ({
1138
+ label: item.label as string,
1139
+ description: item.description as string | undefined,
1140
+ onClick: () =>
1141
+ item.onClick?.(undefined, () => {
1142
+ setIsTemplate(false);
1143
+ setTemplateValue(null);
1144
+ }),
1145
+ })),
1146
+ ],
1147
+ [
1148
+ canOfferExpression,
1149
+ functions.loading,
1150
+ canOfferTemplate,
1151
+ menuItems,
1152
+ handleSelectFunctionChange,
1153
+ handleTemplateToggleClick,
1154
+ ]
1155
+ );
1156
+
1157
+ /* Keyed on WHICH items are offered, never on the items: they carry
1158
+ handlers, so they are a new array every render and a row comparing them
1159
+ by value would loop. */
1160
+ const publishedKey = [
1161
+ canOfferExpression && !functions.loading ? 'expression' : '',
1162
+ canOfferTemplate ? 'template' : '',
1163
+ `custom:${((menuItems ?? []) as { label?: unknown }[]).map((item) => String(item.label ?? '')).join('|')}`,
1164
+ ].join(',');
1165
+
1166
+ useRowMenuPublisher(rowMenu, publishedKey, publishedItems as never);
1167
+
677
1168
  const renderControls = useCallback(() => {
678
1169
  const showFunctionsDropdown =
679
1170
  allowFunctions && !hasOnlyAllowedValues && !rest.readonly && !internalIsFunction;
@@ -681,33 +1172,44 @@ export const TemplateField = memo(
681
1172
  // The "Set value" label promises a way to set one — reorder rows alone don't.
682
1173
  const hasValueRows = showFunctionsDropdown || showTemplatesButton || size(menuItems) > 0;
683
1174
 
1175
+ /* ONE menu per control. Where this field sits in a form ROW, the row
1176
+ already renders a ⋮ of its own, and drawing a second one beside it
1177
+ put two menus of slightly different widths on the same control —
1178
+ with the type choices an `any` field offers hidden in the narrower
1179
+ one. The items are published to the row instead (see
1180
+ `rowMenuContext`); outside a row there is nothing to publish into,
1181
+ so the field goes on drawing its own. */
1182
+ if (rowMenu) {
1183
+ return null;
1184
+ }
1185
+
684
1186
  if (hasOnlyExpressions) {
685
- return showFunctionsDropdown ? (
686
- functions.loading ? (
687
- <ReqoreSkeleton size={rest.size} />
688
- ) : (
689
- <ReqoreButton
690
- compact
691
- minimal
692
- label='Create New Expression'
693
- className='function-selector'
694
- icon='Functions'
695
- tooltip='This field only accepts expressions'
696
- onClick={() => {
697
- setIsTemplate(false);
698
- setTemplateValue(null);
699
- onChange?.(
700
- name,
701
- {
702
- args: [],
703
- },
704
- undefined,
705
- true
706
- );
707
- }}
708
- />
709
- )
710
- ) : null;
1187
+ return (
1188
+ showFunctionsDropdown ?
1189
+ functions.loading ?
1190
+ <ReqoreSkeleton size={rest.size} />
1191
+ : <ReqoreButton
1192
+ compact
1193
+ minimal
1194
+ label='Create New Expression'
1195
+ className='function-selector'
1196
+ icon='Functions'
1197
+ tooltip='This field only accepts expressions'
1198
+ onClick={() => {
1199
+ setIsTemplate(false);
1200
+ setTemplateValue(null);
1201
+ onChange?.(
1202
+ name,
1203
+ {
1204
+ args: [],
1205
+ },
1206
+ undefined,
1207
+ true
1208
+ );
1209
+ }}
1210
+ />
1211
+ : null
1212
+ );
711
1213
  }
712
1214
 
713
1215
  if (hasValueRows || size(menuActions?.items) > 0) {
@@ -728,10 +1230,18 @@ export const TemplateField = memo(
728
1230
  transparent: true,
729
1231
  size: rest.size,
730
1232
  fixed: true,
731
- // Centre the trailing menu in its flex line so it lines up with
732
- // sibling action buttons (reqore alignSelf; replaces a reqraft
733
- // `align-self !important` override of this button).
734
- alignSelf: 'center',
1233
+ /* Centre the trailing menu in its flex line so it lines up
1234
+ with sibling action buttons (reqore alignSelf; replaces a
1235
+ reqraft `align-self !important` override of this button).
1236
+
1237
+ Centring is only right beside a ONE-LINE editor. The
1238
+ expression shell is a toolbar with an editor, a type message
1239
+ and a preview stacked under it, so centring put this menu
1240
+ somewhere down the side of that block — level with nothing,
1241
+ over the editor, and a long way from the Undo it belongs
1242
+ beside. There it goes to the top, which is where the shell's
1243
+ own toolbar is. */
1244
+ alignSelf: effectiveIsFunction ? 'flex-start' : 'center',
735
1245
  label: hasInputAffordance || !hasValueRows ? undefined : 'Set value',
736
1246
  style:
737
1247
  hasInputAffordance ?
@@ -757,11 +1267,10 @@ export const TemplateField = memo(
757
1267
  handler='click'
758
1268
  content={
759
1269
  <ReqoreMenu size={rest.size} maxHeight='400px' style={{ overflow: 'auto' }}>
760
- {showFunctionsDropdown ? (
761
- functions.loading ? (
1270
+ {showFunctionsDropdown ?
1271
+ functions.loading ?
762
1272
  <ReqoreSkeleton size={rest.size} />
763
- ) : (
764
- <ReqoreButton
1273
+ : <ReqoreButton
765
1274
  compact
766
1275
  transparent
767
1276
  label='Use Expression'
@@ -770,10 +1279,10 @@ export const TemplateField = memo(
770
1279
  tooltip='Run a function on this value'
771
1280
  onClick={handleSelectFunctionChange}
772
1281
  />
773
- )
774
- ) : null}
775
1282
 
776
- {showTemplatesButton ? (
1283
+ : null}
1284
+
1285
+ {showTemplatesButton ?
777
1286
  <ReqoreButton
778
1287
  transparent
779
1288
  icon='MoneyDollarCircleLine'
@@ -786,19 +1295,19 @@ export const TemplateField = memo(
786
1295
  {' '}
787
1296
  Use Template{' '}
788
1297
  </ReqoreButton>
789
- ) : null}
1298
+ : null}
790
1299
 
791
- {size(menuActions?.items) > 0 ? (
1300
+ {size(menuActions?.items) > 0 ?
792
1301
  <MenuActionsSection actions={menuActions} size={rest.size} />
793
- ) : null}
1302
+ : null}
794
1303
 
795
- {size(menuItems) > 0 ? (
1304
+ {size(menuItems) > 0 ?
796
1305
  <CustomMenuItems
797
1306
  items={menuItems}
798
1307
  setIsTemplate={setIsTemplate}
799
1308
  setTemplateValue={setTemplateValue}
800
1309
  />
801
- ) : null}
1310
+ : null}
802
1311
  </ReqoreMenu>
803
1312
  }
804
1313
  />
@@ -822,7 +1331,10 @@ export const TemplateField = memo(
822
1331
  menuItems,
823
1332
  menuActions,
824
1333
  internalIsFunction,
1334
+ effectiveIsFunction,
825
1335
  hasInputAffordance,
1336
+ // the row decides whether this field draws a menu at all
1337
+ rowMenu,
826
1338
  ]);
827
1339
 
828
1340
  // When the type is a list, and it has an element type - that element type is different
@@ -831,12 +1343,12 @@ export const TemplateField = memo(
831
1343
  // This is a special case only for lists with element types
832
1344
  const componentTemplates = useMemo(
833
1345
  () =>
834
- type === 'list' && (rest.ui_element_type || rest.element_type)
835
- ? templates
836
- : {
837
- ...filteredTemplates,
838
- ...TemplatesListProps,
839
- },
1346
+ type === 'list' && (rest.ui_element_type || rest.element_type) ?
1347
+ templates
1348
+ : {
1349
+ ...filteredTemplates,
1350
+ ...TemplatesListProps,
1351
+ },
840
1352
  [JSON.stringify(filteredTemplates), rest.ui_element_type, rest.element_type, type]
841
1353
  );
842
1354
 
@@ -854,9 +1366,14 @@ export const TemplateField = memo(
854
1366
  is_expression: true,
855
1367
  value,
856
1368
  }}
1369
+ // The host's per-ui_type editors reach this field through the
1370
+ // rest-spread; the expression shell needs them explicitly or the
1371
+ // builder's operands render "Unknown type!" for any consumer
1372
+ // ui_type (an assertion's `test-reference` Value, for one).
1373
+ componentOverrides={componentOverrides}
857
1374
  localTemplates={templates}
858
1375
  type={type as string}
859
- returnType={(returnType || type) as any}
1376
+ returnType={(returnType || expressionDataType) as any}
860
1377
  onChange={handleExpressionChange}
861
1378
  readOnly={rest.readOnly || rest.disabled}
862
1379
  expressions={rest.expressions}
@@ -864,9 +1381,29 @@ export const TemplateField = memo(
864
1381
  serverHandled={rest.server_expression_handling}
865
1382
  extraActions={extraActions}
866
1383
  size={rest.size}
1384
+ // An author who typed the expression as text is already
1385
+ // writing in that language — dropping them into the visual
1386
+ // builder would make them find their own sentence again.
1387
+ defaultMode={expressionFromText !== null ? 'text' : 'visual'}
867
1388
  reorder={reorder}
868
1389
  />
869
1390
  </ReqoreErrorBoundary>
1391
+ {expressionFromText !== null ?
1392
+ <ReqoreButton
1393
+ fixed
1394
+ compact
1395
+ minimal
1396
+ icon='ArrowGoBackLine'
1397
+ // No `intent` with `minimal`: a Reqore intent is a FILL, and
1398
+ // `muted` as TEXT is 1.3:1 on the app surface.
1399
+ size={rest.size}
1400
+ className='dpql-detected-undo'
1401
+ tooltip={`Keep "${expressionFromText}" as plain text instead`}
1402
+ onClick={undoExpressionFromText}
1403
+ >
1404
+ Undo
1405
+ </ReqoreButton>
1406
+ : null}
870
1407
  {renderControls()}
871
1408
  </ReqoreControlGroup>
872
1409
  );
@@ -880,10 +1417,11 @@ export const TemplateField = memo(
880
1417
  is_expression: true,
881
1418
  value,
882
1419
  }}
1420
+ componentOverrides={componentOverrides}
883
1421
  localTemplates={templates}
884
1422
  level={level}
885
1423
  type={type as string}
886
- returnType={(returnType || type) as any}
1424
+ returnType={(returnType || expressionDataType) as any}
887
1425
  onChange={handleExpressionChange}
888
1426
  readOnly={rest.readOnly || rest.disabled}
889
1427
  expressions={rest.expressions}
@@ -907,7 +1445,15 @@ export const TemplateField = memo(
907
1445
  return <ReadOnlyTemplateTag value={templateValue} templates={templates} size={rest.size} />;
908
1446
  }
909
1447
 
910
- return <Comp value={value} onChange={onChange} name={name} {...rest} />;
1448
+ return (
1449
+ <Comp
1450
+ value={value}
1451
+ onChange={onChange}
1452
+ name={name}
1453
+ {...rest}
1454
+ componentOverrides={componentOverrides}
1455
+ />
1456
+ );
911
1457
  }
912
1458
 
913
1459
  return (
@@ -919,7 +1465,7 @@ export const TemplateField = memo(
919
1465
  stack={false}
920
1466
  verticalAlign='flex-start'
921
1467
  >
922
- {!isTemplate && allowCustomValues ? (
1468
+ {!isTemplate && allowCustomValues ?
923
1469
  <Component
924
1470
  value={value}
925
1471
  allowTemplates={allowTemplates}
@@ -927,32 +1473,61 @@ export const TemplateField = memo(
927
1473
  name={name}
928
1474
  level={level}
929
1475
  {...rest}
1476
+ componentOverrides={componentOverrides}
930
1477
  {...componentTypeProp}
931
1478
  aria-label={fieldAriaLabel}
932
1479
  className={`${className} template-selector`}
933
1480
  templates={componentTemplates}
934
1481
  />
935
- ) : null}
936
-
937
- {isTemplate && templateSupportsCustomValues && !templateValueIsBracedToken ? (
938
- <LongStringField
1482
+ : null}
1483
+
1484
+ {/* Template mode's editor for a value that can also be typed: each
1485
+ reference in it is a chip named as the catalogue names it, while the
1486
+ field still stores the plain string. A textarea spelled a chosen
1487
+ template `$local:name` wherever it was edited — in the Visual
1488
+ builder's operands, in every string field that takes templates. */}
1489
+ {isTemplate && templateSupportsCustomValues && !templateValueIsBracedToken ?
1490
+ <RichTextFormField
939
1491
  className='template-selector'
940
- type='string'
941
- name='templateVal'
942
- level={level}
943
- value={templateValue}
944
- templates={{
945
- ...filteredTemplates,
946
- ...TemplatesListProps,
947
- }}
948
- onChange={handleTemplateFieldChange}
949
- {...rest}
1492
+ valueFormat='text'
1493
+ singleLine={isSingleLineStringType('string')}
1494
+ value={typeof templateValue === 'string' ? templateValue : ''}
1495
+ templates={filteredTemplates}
1496
+ allowTemplates
1497
+ onChange={handleTemplateTextChange}
1498
+ // `tags` here are the field's own chips; the editor's `tags` are its template list.
1499
+ {...omit(rest, 'tags')}
950
1500
  aria-label={fieldAriaLabel}
951
1501
  />
952
- ) : null}
1502
+ : null}
953
1503
 
954
- {showTemplatesDropdown ||
955
- (isTemplate && templateSupportsCustomValues && templateValueIsBracedToken) ? (
1504
+ {dpqlOffer ?
1505
+ <ReqoreControlGroup fixed stack size={rest.size}>
1506
+ <ReqoreButton
1507
+ compact
1508
+ icon='Functions'
1509
+ intent='info'
1510
+ className='dpql-detected-offer'
1511
+ tooltip={`"${dpqlOffer.text}" reads as an expression. It is also valid text here, so nothing has been changed — use it as an expression?`}
1512
+ onClick={acceptDpqlOffer}
1513
+ >
1514
+ Use as expression
1515
+ </ReqoreButton>
1516
+ <ReqoreButton
1517
+ compact
1518
+ icon='CloseLine'
1519
+ intent='info'
1520
+ className='dpql-detected-dismiss'
1521
+ tooltip='Keep it as plain text'
1522
+ onClick={declineDpqlOffer}
1523
+ />
1524
+ </ReqoreControlGroup>
1525
+ : null}
1526
+
1527
+ {(
1528
+ showTemplatesDropdown ||
1529
+ (isTemplate && templateSupportsCustomValues && templateValueIsBracedToken)
1530
+ ) ?
956
1531
  <TemplateDropdownSelector
957
1532
  allowCustomValues={allowCustomValues}
958
1533
  templates={templates}
@@ -964,10 +1539,27 @@ export const TemplateField = memo(
964
1539
  label={label}
965
1540
  hasOnlyAllowedValues={hasOnlyAllowedValues}
966
1541
  />
967
- ) : null}
1542
+ : null}
968
1543
 
969
1544
  {renderControls()}
970
1545
  </ReqoreControlGroup>
971
1546
  );
972
1547
  }
973
1548
  );
1549
+
1550
+ /**
1551
+ * The row channel belongs to the row's OWN editor: this field. It is read here
1552
+ * and hidden from everything this field renders, so a field nested inside it —
1553
+ * an expression operand, a list item — keeps drawing its own menu instead of
1554
+ * publishing actions into a row menu that cannot say which value they act on.
1555
+ * `undefined` outside a row, where this field draws its own menu too.
1556
+ */
1557
+ export const TemplateField = memo((props: ITemplateFieldProps) => {
1558
+ const rowMenu = useRowMenu();
1559
+
1560
+ return (
1561
+ <RowMenuContext.Provider value={undefined}>
1562
+ <TemplateFieldImpl {...props} rowMenu={rowMenu} />
1563
+ </RowMenuContext.Provider>
1564
+ );
1565
+ });