@qoretechnologies/reqraft 0.10.61 → 0.10.62-pr.137.g86641aa

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 (101) hide show
  1. package/dist/components/dpqlEditor/DpqlEditor.d.ts.map +1 -1
  2. package/dist/components/dpqlEditor/DpqlEditor.js +8 -3
  3. package/dist/components/dpqlEditor/DpqlEditor.js.map +1 -1
  4. package/dist/components/dpqlEditor/types.d.ts +5 -0
  5. package/dist/components/dpqlEditor/types.d.ts.map +1 -1
  6. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  7. package/dist/components/form/engine/CompactRow.js +17 -8
  8. package/dist/components/form/engine/CompactRow.js.map +1 -1
  9. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  10. package/dist/components/form/engine/FormEngine.js +65 -15
  11. package/dist/components/form/engine/FormEngine.js.map +1 -1
  12. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  13. package/dist/components/form/engine/OptionFieldMessages.js +12 -1
  14. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  15. package/dist/components/form/engine/compactRowContext.d.ts +2 -0
  16. package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
  17. package/dist/components/form/engine/compactRowContext.js.map +1 -1
  18. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  19. package/dist/components/form/engine/readFirst.js +5 -1
  20. package/dist/components/form/engine/readFirst.js.map +1 -1
  21. package/dist/components/form/expressions/ExpressionField.d.ts +21 -1
  22. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  23. package/dist/components/form/expressions/ExpressionField.js +72 -14
  24. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  25. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  26. package/dist/components/form/expressions/builder/index.js +16 -3
  27. package/dist/components/form/expressions/builder/index.js.map +1 -1
  28. package/dist/components/form/expressions/expressionOperands.d.ts +16 -0
  29. package/dist/components/form/expressions/expressionOperands.d.ts.map +1 -0
  30. package/dist/components/form/expressions/expressionOperands.js +97 -0
  31. package/dist/components/form/expressions/expressionOperands.js.map +1 -0
  32. package/dist/components/form/expressions/mockExpressions.d.ts.map +1 -1
  33. package/dist/components/form/expressions/mockExpressions.js +15 -0
  34. package/dist/components/form/expressions/mockExpressions.js.map +1 -1
  35. package/dist/components/form/expressions/renderExpressionToText.d.ts +2 -0
  36. package/dist/components/form/expressions/renderExpressionToText.d.ts.map +1 -1
  37. package/dist/components/form/expressions/renderExpressionToText.js +24 -5
  38. package/dist/components/form/expressions/renderExpressionToText.js.map +1 -1
  39. package/dist/components/form/expressions/textOfExpression.d.ts +18 -0
  40. package/dist/components/form/expressions/textOfExpression.d.ts.map +1 -0
  41. package/dist/components/form/expressions/textOfExpression.js +37 -0
  42. package/dist/components/form/expressions/textOfExpression.js.map +1 -0
  43. package/dist/components/form/expressions/useRenderExpression.d.ts.map +1 -1
  44. package/dist/components/form/expressions/useRenderExpression.js +7 -7
  45. package/dist/components/form/expressions/useRenderExpression.js.map +1 -1
  46. package/dist/components/form/fields/rich-text/RichText.d.ts +8 -1
  47. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  48. package/dist/components/form/fields/rich-text/RichText.js +10 -10
  49. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  50. package/dist/components/form/fields/template/TemplateField.d.ts +6 -0
  51. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  52. package/dist/components/form/fields/template/TemplateField.js +280 -55
  53. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  54. package/dist/components/form/fields/template/writtenAsText.d.ts +57 -0
  55. package/dist/components/form/fields/template/writtenAsText.d.ts.map +1 -0
  56. package/dist/components/form/fields/template/writtenAsText.js +203 -0
  57. package/dist/components/form/fields/template/writtenAsText.js.map +1 -0
  58. package/dist/components/form/index.d.ts +2 -0
  59. package/dist/components/form/index.d.ts.map +1 -1
  60. package/dist/components/form/index.js +7 -1
  61. package/dist/components/form/index.js.map +1 -1
  62. package/dist/components/smartEditor/useLspSession.d.ts +6 -0
  63. package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
  64. package/dist/components/smartEditor/useLspSession.js +7 -6
  65. package/dist/components/smartEditor/useLspSession.js.map +1 -1
  66. package/dist/helpers/templates.d.ts +18 -0
  67. package/dist/helpers/templates.d.ts.map +1 -1
  68. package/dist/helpers/templates.js +30 -1
  69. package/dist/helpers/templates.js.map +1 -1
  70. package/dist/helpers/validations.d.ts.map +1 -1
  71. package/dist/helpers/validations.js +98 -18
  72. package/dist/helpers/validations.js.map +1 -1
  73. package/package.json +1 -1
  74. package/src/components/dpqlEditor/DpqlEditor.tsx +11 -18
  75. package/src/components/dpqlEditor/types.ts +5 -0
  76. package/src/components/form/engine/CompactRow.tsx +31 -10
  77. package/src/components/form/engine/FormEngine.stories.tsx +656 -18
  78. package/src/components/form/engine/FormEngine.tsx +64 -22
  79. package/src/components/form/engine/FormEngineRemote.stories.tsx +5 -0
  80. package/src/components/form/engine/OptionFieldMessages.tsx +12 -1
  81. package/src/components/form/engine/compactRowContext.ts +2 -0
  82. package/src/components/form/engine/readFirst.ts +7 -1
  83. package/src/components/form/expressions/ExpressionField.stories.tsx +57 -9
  84. package/src/components/form/expressions/ExpressionField.tsx +179 -60
  85. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +8 -1
  86. package/src/components/form/expressions/builder/index.tsx +16 -3
  87. package/src/components/form/expressions/dpqlMockLanguage.ts +7 -2
  88. package/src/components/form/expressions/expressionOperands.ts +74 -0
  89. package/src/components/form/expressions/mockExpressions.ts +16 -4
  90. package/src/components/form/expressions/renderExpressionToText.ts +26 -9
  91. package/src/components/form/expressions/textOfExpression.ts +39 -0
  92. package/src/components/form/expressions/useRenderExpression.ts +9 -1
  93. package/src/components/form/fields/number/Number.stories.tsx +8 -7
  94. package/src/components/form/fields/rich-text/RichText.tsx +18 -10
  95. package/src/components/form/fields/template/TemplateField.stories.tsx +486 -18
  96. package/src/components/form/fields/template/TemplateField.tsx +506 -62
  97. package/src/components/form/fields/template/writtenAsText.ts +217 -0
  98. package/src/components/form/index.tsx +6 -0
  99. package/src/components/smartEditor/useLspSession.ts +15 -10
  100. package/src/helpers/templates.ts +35 -0
  101. package/src/helpers/validations.ts +83 -12
@@ -0,0 +1,217 @@
1
+ // Copyright 2026 Qore Technologies, s.r.o.
2
+ // What a field holds for the text written in its template editor.
3
+ //
4
+ // A field that takes templates and custom values shows ONE editor for both: a text
5
+ // field where a chosen template is a chip, with text typed around it (qorus#646). The
6
+ // field's value is read from that text:
7
+ //
8
+ // - a template standing alone is that template - in an untyped field, of the
9
+ // template's own type (a whole-number field of the record makes the value a whole
10
+ // number);
11
+ // - a literal of a scalar field's type (`12` in a whole-number field, `true` in a
12
+ // yes/no one) is that literal;
13
+ // - null in an untyped field is written `null` (an empty field holds no value at all);
14
+ // - anything else is text: an untyped field's value becomes text, a typed field holds
15
+ // what was written and says it does not fit (an expression in it is detected and
16
+ // offered, or switched to, by the field's DPQL detection).
17
+ //
18
+ // Deleting the text around a template makes it a lone template again, and the value
19
+ // takes the template's type again. Pure: no React, no transport.
20
+ import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea';
21
+ import { isUntypedOptionType } from '../../../../helpers/optionUiTypes';
22
+ import {
23
+ findTemplate,
24
+ isCompleteTemplateToken,
25
+ isValueTemplate,
26
+ TEMPLATE_TOKEN_SOURCE,
27
+ } from '../../../../helpers/templates';
28
+
29
+ /** Scalar types whose value can be written as text: whole numbers, numbers, yes/no. Not a date, which has
30
+ * its own control. */
31
+ const TEXTABLE_SCALARS: Record<string, 'int' | 'number' | 'bool'> = {
32
+ int: 'int',
33
+ integer: 'int',
34
+ softint: 'int',
35
+ number: 'number',
36
+ float: 'number',
37
+ softfloat: 'number',
38
+ softnumber: 'number',
39
+ bool: 'bool',
40
+ boolean: 'bool',
41
+ softbool: 'bool',
42
+ };
43
+
44
+ /** Whether a field of this type is written in the template editor: text, untyped, or a textable scalar. */
45
+ export const isWrittenAsText = (type?: string): boolean =>
46
+ type === 'string' || isUntypedOptionType(type) || (!!type && type in TEXTABLE_SCALARS);
47
+
48
+ /**
49
+ * Whether a form option of this type is written as text on its Value tab - a text field taking templates as
50
+ * chips and detecting expressions - rather than on its own control (qorus#646, David): text, untyped values,
51
+ * whole numbers and numbers. A yes / no, a date and fixed choices keep their own control.
52
+ */
53
+ export const isWrittenAsTextOnTheValueTab = (type?: string): boolean =>
54
+ type === 'string' ||
55
+ isUntypedOptionType(type) ||
56
+ (!!type && (TEXTABLE_SCALARS[type] === 'int' || TEXTABLE_SCALARS[type] === 'number'));
57
+
58
+ /** The tabs a field's value is entered on (see `valueTabsOf`). */
59
+ export type TValueTab = 'value' | 'expression' | 'visual' | 'template';
60
+
61
+ /**
62
+ * The text a value shows as on the Value tab: text as it is, a number or a yes/no as written, null as
63
+ * `null` (in an untyped field), nothing for no value.
64
+ */
65
+ export const valueTextOf = (value: unknown, type?: string): string => {
66
+ if (value === undefined) return '';
67
+ if (value === null) return isUntypedOptionType(type) ? NULL_TEXT : '';
68
+ if (typeof value === 'string') return value;
69
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
70
+ return '';
71
+ };
72
+
73
+ /**
74
+ * A value written as an expression's text: a literal as DPQL writes it (`12`, `true`, `"open"`), a template
75
+ * as itself, text with templates as the text and the templates joined (`concat("SUP-", $record:{pos})`).
76
+ * The Expression tab opens on this for a value that is not an expression yet.
77
+ */
78
+ export const expressionTextOfValue = (value: unknown): string => {
79
+ if (value === undefined || value === null) return value === null ? 'null' : '';
80
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
81
+ if (typeof value !== 'string') return '';
82
+ const text = String(value);
83
+ if (isCompleteTemplateToken(text)) return String(text);
84
+ const plain: string = text;
85
+ const parts = plain.split(new RegExp(`(${TEMPLATE_TOKEN_SOURCE})`)).filter((part) => part !== '');
86
+ if (parts.length === 1) return JSON.stringify(plain);
87
+ return `concat(${parts.map((part) => (isCompleteTemplateToken(part) ? part : JSON.stringify(part))).join(', ')})`;
88
+ };
89
+
90
+ /**
91
+ * A lone template written in the expression editor's Text view, as the server parses it: the `template`
92
+ * operation with the reference as its one argument. It is stored as the bare template, which every consumer
93
+ * evaluates; the expression form of it is not one the server reads back as the template's value.
94
+ */
95
+ export const loneTemplateOf = (expression: unknown): string | undefined => {
96
+ const node = (expression as { value?: { exp?: string; args?: unknown[] } } | undefined)?.value;
97
+ if (node?.exp !== 'template' || node.args?.length !== 1) return undefined;
98
+ const arg = node.args[0] as { value?: { raw?: unknown } | string } | undefined;
99
+ const raw = typeof arg?.value === 'string' ? arg.value : arg?.value?.raw;
100
+ return typeof raw === 'string' && isCompleteTemplateToken(raw) ? raw : undefined;
101
+ };
102
+
103
+ /**
104
+ * A lone value written in the expression editor's Text view (`12`, `true`, `"open"`), as the server parses
105
+ * it: the `value` operation with a plain value as its one argument. It is stored as that value - the field's
106
+ * own shape - rather than as an expression the Visual view has nothing to show for. A record field is not
107
+ * such a value: it is read from the row.
108
+ */
109
+ export const loneValueOf = (expression: unknown): { value: unknown } | undefined => {
110
+ const node = (expression as { value?: { exp?: string; args?: unknown[] } } | undefined)?.value;
111
+ if (node?.exp !== 'value' || node.args?.length !== 1) return undefined;
112
+ const arg = node.args[0] as { value?: unknown; is_expression?: boolean } | undefined;
113
+ const value = arg?.value;
114
+ if (!arg || arg.is_expression || (value !== null && typeof value === 'object')) return undefined;
115
+ if (typeof value === 'string' && isValueTemplate(value)) return undefined;
116
+ return { value };
117
+ };
118
+
119
+ /** The text a value is seeded into the Text view as: a template, a number or a yes/no as written. */
120
+ export const expressionSeedOf = (value: unknown): string => {
121
+ if (typeof value === 'string') return isCompleteTemplateToken(value) ? value : '';
122
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
123
+ return '';
124
+ };
125
+
126
+ const INT_LITERAL = /^\s*[-+]?\d+\s*$/;
127
+ const NUMBER_LITERAL = /^\s*[-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?\s*$/i;
128
+ const BOOL_LITERAL = /^\s*(true|false)\s*$/i;
129
+
130
+ /** The literal `text` writes in a scalar type, or `undefined` when it writes none. */
131
+ export const scalarLiteral = (text: string, type?: string): number | boolean | undefined => {
132
+ switch (type ? TEXTABLE_SCALARS[type] : undefined) {
133
+ case 'int':
134
+ return INT_LITERAL.test(text) ? Number.parseInt(text, 10) : undefined;
135
+ case 'number':
136
+ return NUMBER_LITERAL.test(text) ? Number.parseFloat(text) : undefined;
137
+ case 'bool': {
138
+ const match = BOOL_LITERAL.exec(text);
139
+ return match ? match[1].toLowerCase() === 'true' : undefined;
140
+ }
141
+ default:
142
+ return undefined;
143
+ }
144
+ };
145
+
146
+ /** How null is written in an untyped field: the word, so it is not taken for an empty field. */
147
+ export const NULL_TEXT = 'null';
148
+
149
+ /** The text an untyped field shows for its value: `null` for null; for anything else, the text it is. */
150
+ export const untypedTextOf = (value: unknown, type?: string): string | undefined =>
151
+ value === null && isUntypedOptionType(type) ? NULL_TEXT : undefined;
152
+
153
+ export interface ITemplateTextValue {
154
+ value: unknown;
155
+ /** the value's type: the field's own, or for an untyped field the type the text gives it */
156
+ type?: string;
157
+ }
158
+
159
+ /** What the field holds for `text`, written in its template editor. See the top of this file. */
160
+ export const templateTextValue = (
161
+ text: string,
162
+ type: string | undefined,
163
+ templates?: IReqoreFormTemplates
164
+ ): ITemplateTextValue => {
165
+ const trimmed = text.trim();
166
+ if (isCompleteTemplateToken(trimmed)) {
167
+ if (isUntypedOptionType(type)) {
168
+ const badge = templates ? findTemplate(templates, trimmed)?.badge : undefined;
169
+ return { value: trimmed, type: typeof badge === 'string' && badge ? badge : type };
170
+ }
171
+ return { value: trimmed, type };
172
+ }
173
+ // null, the literal that says "no value", is a value of an untyped field: written `null`, it is null
174
+ if (isUntypedOptionType(type) && trimmed === NULL_TEXT) {
175
+ return { value: null, type };
176
+ }
177
+ const literal = scalarLiteral(text, type);
178
+ if (literal !== undefined) {
179
+ return { value: literal, type };
180
+ }
181
+ return { value: text, type: isUntypedOptionType(type) ? 'string' : type };
182
+ };
183
+
184
+ /** The kind of value a type holds, for telling whether a template and a field hold the same kind. */
185
+ const valueKindOf = (type?: string): string | undefined => {
186
+ if (!type) return undefined;
187
+ const t = type.replace(/^\*/, '').replace(/^soft/, '');
188
+ if (TEXTABLE_SCALARS[t]) return TEXTABLE_SCALARS[t];
189
+ if (t === 'string' || t === 'date' || t === 'list' || t === 'hash' || t === 'binary') return t;
190
+ return undefined;
191
+ };
192
+
193
+ /** Whether a template of this type is a value of the field's type as it is, with nothing to convert. */
194
+ export const sameValueType = (templateType: string, fieldType?: string): boolean => {
195
+ const a = valueKindOf(templateType);
196
+ const b = valueKindOf(fieldType);
197
+ // a whole number is a number too
198
+ return !a || !b || a === b || (a === 'int' && b === 'number');
199
+ };
200
+
201
+ /** A type in words: "a whole number", "text". */
202
+ export const templateTypeName = (type?: string): string => {
203
+ switch (valueKindOf(type)) {
204
+ case 'int':
205
+ return 'A whole number';
206
+ case 'number':
207
+ return 'A number';
208
+ case 'bool':
209
+ return 'A yes / no value';
210
+ case 'string':
211
+ return 'Text';
212
+ case 'date':
213
+ return 'A date';
214
+ default:
215
+ return type ?? 'A value';
216
+ }
217
+ };
@@ -35,6 +35,12 @@ export * from './expressions/useExpressions';
35
35
  // Named (not wildcard) so the `_resetRenderExpressionTransportForTests`
36
36
  // test hook stays out of the public package surface.
37
37
  export { renderExpressionToText, useRenderExpression } from './expressions/useRenderExpression';
38
+ export {
39
+ expressionOperand,
40
+ expressionWithOperands,
41
+ isExpressionComplete,
42
+ } from './expressions/expressionOperands';
43
+ export { EXPRESSION_HOLE } from './expressions/renderExpressionToText';
38
44
  export type {
39
45
  IRenderedExpression,
40
46
  IUseRenderExpressionResult,
@@ -40,6 +40,12 @@ export interface IUseLspSessionResult {
40
40
  uri: string;
41
41
  /** Becomes `true` once `initialize` + `didOpen` complete. */
42
42
  isReady: boolean;
43
+ /**
44
+ * `true` once connecting has FAILED: there is no language server to talk to (none configured, or it
45
+ * cannot be reached). A host that needs one - the expression editor's Text view - shows what works
46
+ * without it instead of waiting on a session that is not coming.
47
+ */
48
+ isUnavailable: boolean;
43
49
  /**
44
50
  * Becomes `true` once any language-specific context binding the
45
51
  * wrapper requires has resolved. For the generic primitive session
@@ -93,9 +99,7 @@ export interface IUseLspSessionResult {
93
99
  format: () => Promise<string | null>;
94
100
  }
95
101
 
96
- export function useLspSession(
97
- options: IUseLspSessionOptions
98
- ): IUseLspSessionResult {
102
+ export function useLspSession(options: IUseLspSessionOptions): IUseLspSessionResult {
99
103
  const { languageId, uri: uriProp, initialMetadata, url, initialText = '' } = options;
100
104
 
101
105
  const clientRef = useRef<ReqraftLspClient | null>(null);
@@ -111,11 +115,12 @@ export function useLspSession(
111
115
 
112
116
  const [client, setClient] = useState<ReqraftLspClient | null>(null);
113
117
  const [isReady, setIsReady] = useState(false);
118
+ const [isUnavailable, setIsUnavailable] = useState(false);
114
119
  const [diagnostics, setDiagnostics] = useState<ILspDiagnostic[]>([]);
115
- const [semanticTokensLegend, setSemanticTokensLegend] =
116
- useState<ILspSemanticTokensLegend | null>(null);
117
- const [capabilities, setCapabilities] =
118
- useState<ILspServerCapabilities | null>(null);
120
+ const [semanticTokensLegend, setSemanticTokensLegend] = useState<ILspSemanticTokensLegend | null>(
121
+ null
122
+ );
123
+ const [capabilities, setCapabilities] = useState<ILspServerCapabilities | null>(null);
119
124
 
120
125
  useEffect(() => {
121
126
  const c = new ReqraftLspClient({
@@ -147,9 +152,7 @@ export function useLspSession(
147
152
  serverTextRef.current = editorTextRef.current;
148
153
  c.didOpen(
149
154
  editorTextRef.current,
150
- initialMetadata && Object.keys(initialMetadata).length > 0
151
- ? initialMetadata
152
- : undefined
155
+ initialMetadata && Object.keys(initialMetadata).length > 0 ? initialMetadata : undefined
153
156
  );
154
157
  setIsReady(true);
155
158
  })
@@ -162,6 +165,7 @@ export function useLspSession(
162
165
  // eslint-disable-next-line no-console
163
166
  console.error('[LSP session] Connect failed:', err);
164
167
  setIsReady(false);
168
+ setIsUnavailable(true);
165
169
  });
166
170
 
167
171
  return () => {
@@ -217,6 +221,7 @@ export function useLspSession(
217
221
  client,
218
222
  uri: uriRef.current,
219
223
  isReady,
224
+ isUnavailable,
220
225
  // Generic session has no language-specific context to bind;
221
226
  // wrappers (`useDpqlSession`) override this on their returned
222
227
  // session by spreading + replacing.
@@ -94,6 +94,41 @@ const COMPLETE_TEMPLATE_TOKEN = new RegExp(`^${TEMPLATE_TOKEN_SOURCE}$`);
94
94
  export const isCompleteTemplateToken = (value?: unknown): value is string =>
95
95
  typeof value === 'string' && COMPLETE_TEMPLATE_TOKEN.test(value);
96
96
 
97
+ const TEMPLATE_TOKEN_ANYWHERE = new RegExp(TEMPLATE_TOKEN_SOURCE);
98
+
99
+ /**
100
+ * Text with a template in it that is not the template alone: `$record:{pos} Stk.`, `SUP-$record:{pos}`,
101
+ * two templates side by side. In text it is text; a whole number, a number, a yes/no or a date cannot
102
+ * hold it (qorus#646).
103
+ */
104
+ export const hasTextAroundATemplate = (value?: unknown): value is string =>
105
+ typeof value === 'string' && TEMPLATE_TOKEN_ANYWHERE.test(value) && !isCompleteTemplateToken(value);
106
+
107
+ /** The text around the templates in a value that is not one template alone, and how many templates it holds. */
108
+ export interface ITextAroundATemplate {
109
+ /** The pieces of text before, between and after the templates, trimmed; empty pieces left out. */
110
+ text: string[];
111
+ templates: number;
112
+ }
113
+
114
+ /**
115
+ * What makes a value text rather than one template alone: `$record:{pos} Stk.` holds the text "Stk." and
116
+ * one template; `$record:{a}$record:{b}` no text and two. `undefined` where the value is not text with a
117
+ * template in it, or is one template alone (see {@link hasTextAroundATemplate}).
118
+ */
119
+ export const textAroundATemplate = (value?: unknown): ITextAroundATemplate | undefined => {
120
+ if (!hasTextAroundATemplate(value)) {
121
+ return undefined;
122
+ }
123
+ const tokens = new RegExp(TEMPLATE_TOKEN_SOURCE, 'g');
124
+ const templates = value.match(tokens)?.length ?? 0;
125
+ const text = value
126
+ .split(tokens)
127
+ .map((piece) => piece.trim())
128
+ .filter((piece) => piece !== '');
129
+ return { text, templates };
130
+ };
131
+
97
132
  /** A complete token carrying at least one BRACED context segment —
98
133
  * `$data:{W2n….filename}`, `$qore-expr:{1 + 2}`. These are machine-written
99
134
  * references nobody types by hand, so a surface may render them as a picker
@@ -2,7 +2,7 @@ import { isValidSixCharHex } from '@qoretechnologies/reqore/dist/helpers/colors'
2
2
  import { IQorusFormSchema, TQorusForm, TQorusFormFieldSchema } from '@qoretechnologies/ts-toolkit';
3
3
  import { isValidCron } from 'cron-validator';
4
4
  import jsyaml from 'js-yaml';
5
- import { isBoolean, isNull, isString, isUndefined, memoize, omit } from 'lodash';
5
+ import { isBoolean, isNull, isString, isUndefined, memoize, omit, upperFirst } from 'lodash';
6
6
  import every from 'lodash/every';
7
7
  import isArray from 'lodash/isArray';
8
8
  import isDate from 'lodash/isDate';
@@ -15,7 +15,7 @@ import { fixOperatorValue, getAddress, getProtocol, splitByteSize } from './comm
15
15
  import { isOptionInterfaceUiType } from './optionUiTypes';
16
16
  import { getListElementValue, getOptionsFromRequiredGroups } from './options';
17
17
  import { IProviderType, TVariableActionValue, maybeBuildOptionProvider } from './providerValue';
18
- import { getTemplateKey, getTemplateValue, isValueTemplate } from './templates';
18
+ import { getTemplateKey, getTemplateValue, isValueTemplate, textAroundATemplate } from './templates';
19
19
  import { getUnlistedChoiceReason } from './allowedValues';
20
20
 
21
21
  /** The five cron fields, in order, as a schedule hash may name them. */
@@ -244,6 +244,56 @@ const isValueDefined = (type: string, value: any): boolean => {
244
244
  return value !== undefined && value !== '';
245
245
  };
246
246
 
247
+ /** What a value of each type that is not text is called, where text around a template makes it text. */
248
+ const TEXT_AROUND_A_TEMPLATE_NOT_A: Record<string, string> = (() => {
249
+ const wholeNumber = 'a whole number';
250
+ const number = 'a number';
251
+ const yesNo = 'true or false';
252
+ const date = 'a date';
253
+ return {
254
+ int: wholeNumber,
255
+ integer: wholeNumber,
256
+ softint: wholeNumber,
257
+ number,
258
+ float: number,
259
+ softfloat: number,
260
+ softnumber: number,
261
+ bool: yesNo,
262
+ boolean: yesNo,
263
+ softbool: yesNo,
264
+ date,
265
+ softdate: date,
266
+ };
267
+ })();
268
+
269
+ /**
270
+ * What is said of a value of a type that is not text, written with text around a template: what makes it
271
+ * text, and what to do about it (qlip build 20261008-083118: "How does the user fix this? Why has this
272
+ * happened?"). `undefined` where the type is text, or the value is not text around a template.
273
+ */
274
+ const textAroundATemplateReason = (type: string, value: unknown): string | undefined => {
275
+ const notA = TEXT_AROUND_A_TEMPLATE_NOT_A[type];
276
+ const around = notA ? textAroundATemplate(value) : undefined;
277
+ if (!around) {
278
+ return undefined;
279
+ }
280
+ if (around.templates > 1) {
281
+ return around.text.length
282
+ ? `Text and more than one template make this text, not ${notA}. Keep one template alone.`
283
+ : `Templates side by side make this text, not ${notA}. Keep one of them alone.`;
284
+ }
285
+ return `"${around.text.join(' … ')}" makes this text, not ${notA}. Delete it to keep the template alone.`;
286
+ };
287
+
288
+ /** 1st, 2nd, 3rd, 4th, … 11th, 12th, 13th, … 21st: a position as it is written. */
289
+ const ordinal = (n: number): string => {
290
+ const tens = n % 100;
291
+ if (tens >= 11 && tens <= 13) {
292
+ return `${n}th`;
293
+ }
294
+ return `${n}${({ 1: 'st', 2: 'nd', 3: 'rd' } as Record<number, string>)[n % 10] ?? 'th'}`;
295
+ };
296
+
247
297
  export const _validateField = (
248
298
  type: string,
249
299
  value?: any,
@@ -272,6 +322,14 @@ export const _validateField = (
272
322
  if (pos > 0) {
273
323
  type = type.slice(0, pos);
274
324
  }
325
+ /* A value whose type is not text, written with text around a template, is not of that type: a whole
326
+ number written as "$record:{pos} Stk." passed as a template - anything starting with `$` and holding
327
+ a `:` did - and the form could be saved with a value its type cannot hold (qorus#646). A template
328
+ alone, a literal and an expression are judged as before. */
329
+ const textAroundReason = textAroundATemplateReason(type, value);
330
+ if (textAroundReason) {
331
+ return invalidResult(textAroundReason);
332
+ }
275
333
  // Check if the value is a template string
276
334
  if (isValueTemplate(value)) {
277
335
  const hasKey = !!getTemplateKey(value);
@@ -1617,6 +1675,14 @@ export const _validateField = (
1617
1675
  // Now we need to validate each argument, either as a normal value or as a sub-expression
1618
1676
  const args = castedValue.value?.args ?? [];
1619
1677
 
1678
+ // each operand's name, as the editor shows it
1679
+ const argLabels: string[] = args.map((_arg: unknown, index: number) => {
1680
+ const definition = expressionDefinition.varargs
1681
+ ? expressionDefinition.args[0]
1682
+ : expressionDefinition.args[index];
1683
+ return definition?.display_name ?? definition?.name ?? `argument ${index + 1}`;
1684
+ });
1685
+
1620
1686
  for (let index = 0; index < args.length; index++) {
1621
1687
  const argValue: any = args[index];
1622
1688
  const argDefinition = expressionDefinition.varargs
@@ -1627,8 +1693,13 @@ export const _validateField = (
1627
1693
  `display_name` is what a catalogue MAY carry; the served one carries
1628
1694
  `name`, so every message about an argument read "argument 1
1629
1695
  ("undefined") is invalid" and named a field that does not exist. */
1630
- const argLabel =
1631
- argDefinition?.display_name ?? argDefinition?.name ?? `argument ${index + 1}`;
1696
+ const argLabel = argLabels[index];
1697
+ /* Which operand, where its name alone does not say: every operand of a call taking any number of
1698
+ them is "Value" (qlip build 20261008-083118: "which "value" is the message mentioning"). Said by
1699
+ its position too, as the editor shows them, left to right. */
1700
+ const operand = argLabels.some((label, other) => other !== index && label === argLabel)
1701
+ ? `the ${ordinal(index + 1)} "${argLabel}"`
1702
+ : undefined;
1632
1703
 
1633
1704
  /* An explicit null is a VALUE, and DPQL says so: `null` is a literal
1634
1705
  that parses, serializes and round-trips like any other.
@@ -1659,10 +1730,8 @@ export const _validateField = (
1659
1730
  });
1660
1731
 
1661
1732
  if (!result.isValid) {
1662
- return withContext(
1663
- result,
1664
- `Sub-expression for argument ${index + 1} ("${argLabel}") is invalid`
1665
- );
1733
+ // said by the operand's name, as the editor shows it - not by its position in the call
1734
+ return withContext(result, `The expression in ${operand ?? `"${argLabel}"`} is invalid`);
1666
1735
  }
1667
1736
 
1668
1737
  continue;
@@ -1686,10 +1755,12 @@ export const _validateField = (
1686
1755
  });
1687
1756
 
1688
1757
  if (!result.isValid) {
1689
- return withContext(
1690
- result,
1691
- `Value for argument ${index + 1} ("${argLabel}") is invalid`
1692
- );
1758
+ // an operand not filled in yet says what to do; one that is wrong says which and why (David's
1759
+ // review of qorus#646: "Value for argument 1 ("Value") is invalid: Missing value" while building)
1760
+ if (argValue?.value === undefined || argValue?.value === null || argValue?.value === '') {
1761
+ return invalidResult(operand ? `Enter ${operand}` : `Enter a value for "${argLabel}"`);
1762
+ }
1763
+ return withContext(result, operand ? `${upperFirst(operand)} is invalid` : `"${argLabel}" is invalid`);
1693
1764
  }
1694
1765
  }
1695
1766