@qoretechnologies/reqraft 0.10.58-pr.129.gd317f2e → 0.10.58

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 (60) hide show
  1. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  2. package/dist/components/form/engine/FormEngine.js +30 -53
  3. package/dist/components/form/engine/FormEngine.js.map +1 -1
  4. package/dist/components/form/engine/readFirst.d.ts +16 -20
  5. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  6. package/dist/components/form/engine/readFirst.js +16 -49
  7. package/dist/components/form/engine/readFirst.js.map +1 -1
  8. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  9. package/dist/components/form/expressions/builder/index.js +29 -14
  10. package/dist/components/form/expressions/builder/index.js.map +1 -1
  11. package/dist/components/form/fields/emittedValues.d.ts +5 -0
  12. package/dist/components/form/fields/emittedValues.d.ts.map +1 -1
  13. package/dist/components/form/fields/emittedValues.js +8 -0
  14. package/dist/components/form/fields/emittedValues.js.map +1 -1
  15. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  16. package/dist/components/form/fields/template/TemplateField.js +3 -1
  17. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  18. package/dist/components/smartEditor/SmartEditor.d.ts.map +1 -1
  19. package/dist/components/smartEditor/SmartEditor.js +27 -4
  20. package/dist/components/smartEditor/SmartEditor.js.map +1 -1
  21. package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
  22. package/dist/components/smartEditor/useLspSession.js +8 -0
  23. package/dist/components/smartEditor/useLspSession.js.map +1 -1
  24. package/dist/helpers/allowedValues.d.ts +71 -0
  25. package/dist/helpers/allowedValues.d.ts.map +1 -0
  26. package/dist/helpers/allowedValues.js +183 -0
  27. package/dist/helpers/allowedValues.js.map +1 -0
  28. package/dist/helpers/common.d.ts +7 -0
  29. package/dist/helpers/common.d.ts.map +1 -1
  30. package/dist/helpers/common.js +16 -1
  31. package/dist/helpers/common.js.map +1 -1
  32. package/dist/helpers/expressions.d.ts +13 -0
  33. package/dist/helpers/expressions.d.ts.map +1 -1
  34. package/dist/helpers/expressions.js +23 -1
  35. package/dist/helpers/expressions.js.map +1 -1
  36. package/dist/helpers/validations.d.ts.map +1 -1
  37. package/dist/helpers/validations.js +14 -0
  38. package/dist/helpers/validations.js.map +1 -1
  39. package/dist/hooks/useCoarsePointer.d.ts +9 -0
  40. package/dist/hooks/useCoarsePointer.d.ts.map +1 -0
  41. package/dist/hooks/useCoarsePointer.js +29 -0
  42. package/dist/hooks/useCoarsePointer.js.map +1 -0
  43. package/dist/utils/fetch.d.ts +4 -0
  44. package/dist/utils/fetch.d.ts.map +1 -1
  45. package/dist/utils/fetch.js +6 -1
  46. package/dist/utils/fetch.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/components/form/engine/FormEngine.tsx +26 -48
  49. package/src/components/form/engine/readFirst.ts +19 -50
  50. package/src/components/form/expressions/builder/index.tsx +21 -3
  51. package/src/components/form/fields/emittedValues.ts +8 -0
  52. package/src/components/form/fields/template/TemplateField.tsx +4 -1
  53. package/src/components/smartEditor/SmartEditor.tsx +28 -4
  54. package/src/components/smartEditor/useLspSession.ts +8 -0
  55. package/src/helpers/allowedValues.ts +216 -0
  56. package/src/helpers/common.ts +14 -0
  57. package/src/helpers/expressions.ts +25 -0
  58. package/src/helpers/validations.ts +16 -0
  59. package/src/hooks/useCoarsePointer.ts +30 -0
  60. package/src/utils/fetch.ts +10 -1
@@ -24,13 +24,18 @@ import { darken, rgba } from 'polished';
24
24
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
25
25
  import styled from 'styled-components';
26
26
  import { moveItem } from '../../../../helpers/common';
27
- import { areQorusTypesCompatible, getArgumentType } from '../../../../helpers/expressions';
27
+ import {
28
+ areQorusTypesCompatible,
29
+ expressionFitsReturnType,
30
+ getArgumentType,
31
+ } from '../../../../helpers/expressions';
28
32
  import { addMissingExpressionArgs } from '../argumentPresence';
29
33
  import { findTemplate } from '../../../../helpers/templates';
30
34
  import { validateField, validateFieldWithResult } from '../../../../helpers/validations';
31
35
  import { IQorusTypeObject, useQorusTypes } from '../../../../hooks/useQorusTypes';
32
36
  import { useReqraftStorage } from '../../../../hooks/useStorage/useStorage';
33
37
  import { usePhoneViewport } from '../../../../hooks/usePhoneViewport';
38
+ import { useCoarsePointer } from '../../../../hooks/useCoarsePointer';
34
39
  import { useTemplates } from '../../../../hooks/useTemplates';
35
40
  import { AutoFormField as auto } from '../../fields/auto/AutoFormField';
36
41
  import { SelectFormField as Select } from '../../fields/select/Select';
@@ -148,6 +153,7 @@ export const Expression = ({
148
153
  componentOverrides,
149
154
  ...props
150
155
  }: IExpressionProps) => {
156
+ const coarsePointer = useCoarsePointer();
151
157
  const types = useQorusTypes();
152
158
  const [showSummary, setShowSummary] = useState(false);
153
159
  const [confirmDialogData, setConfirmDialogData] = useState<
@@ -594,6 +600,15 @@ export const Expression = ({
594
600
  const defaultItems = useMemo(
595
601
  () =>
596
602
  expressions.value
603
+ // a condition (a field or an operand that must be true or false) offers the operations that
604
+ // give true or false; a group's members are its own business, and the operation already chosen
605
+ // stays, so a stored mismatch can be seen and fixed
606
+ .filter(
607
+ (exp) =>
608
+ isChild ||
609
+ exp.name === value?.value?.exp ||
610
+ expressionFitsReturnType(returnType as string | string[] | undefined, exp)
611
+ )
597
612
  .map((exp) => ({
598
613
  name: exp.name,
599
614
  value: exp.name,
@@ -612,7 +627,7 @@ export const Expression = ({
612
627
  ? !exp.from_server
613
628
  : true
614
629
  ),
615
- [JSON.stringify(expressions.value), serverExpression, level]
630
+ [JSON.stringify(expressions.value), serverExpression, level, isChild, returnType, value?.value?.exp]
616
631
  );
617
632
 
618
633
  // SEAM (reqraft): reqraft's `SelectFormField` calls `onChange(value)` with
@@ -879,7 +894,10 @@ export const Expression = ({
879
894
  contentStyle={{
880
895
  overflowX: 'hidden',
881
896
  }}
882
- floatingActions
897
+ // with a mouse, the actions float above the panel while it is hovered; a finger cannot hover, and a
898
+ // tap left them floating over what is above - the Visual / Text switch, where a tap meant for
899
+ // Text hit "Remove this expression" - so on a touch device they stay in the panel's own bar
900
+ floatingActions={!coarsePointer}
883
901
  actions={[
884
902
  // SEAM (reqraft): the IDE renders `AiAssistanceAction` first here;
885
903
  // consumers inject it (or anything else) via `extraActions`.
@@ -36,6 +36,14 @@ export class EmittedValues<T> {
36
36
  }
37
37
  }
38
38
 
39
+ /**
40
+ * Whether `incoming` is one of the emits still in flight, without acknowledging anything: for a render,
41
+ * which may run more than once for one value, while `isEcho` is called once the value is committed.
42
+ */
43
+ includes(incoming: T): boolean {
44
+ return this.pending.some((value) => this.equals(value, incoming));
45
+ }
46
+
39
47
  /**
40
48
  * Whether `incoming` is the echo of an emit still in flight. An echo
41
49
  * acknowledges that emit and every earlier one; anything else clears the
@@ -803,7 +803,10 @@ const TemplateFieldImpl = memo(
803
803
  }
804
804
  }, [JSON.stringify(templateValue)]);
805
805
 
806
- const showTemplateToggle = allowCustomValues && allowTemplates && !rest.arg_schema;
806
+ // an editor that takes templates into its own text has no template mode to switch to: "Use Template"
807
+ // there emptied the field and showed nothing in its place
808
+ const showTemplateToggle =
809
+ allowCustomValues && allowTemplates && !rest.arg_schema && !editorHandlesTemplates;
807
810
 
808
811
  // Only a BRACED context ref (`$data:{…}` — machine-written, nobody types
809
812
  // one) renders as the picker chip (named via `resolveTemplateLabel`)
@@ -31,6 +31,7 @@ import React, {
31
31
  import { Editor, NodeEntry, Range, Transforms } from 'slate';
32
32
  import { ReactEditor, RenderLeafProps } from 'slate-react';
33
33
  import { ILspSignatureHelp } from '../../utils/lspClient.types';
34
+ import { useEmittedValues } from '../form/fields/emittedValues';
34
35
  import { defaultSlateConverter, expandSnippet, lspPositionToOffset } from './helpers';
35
36
  import { MarkdownDoc } from './MarkdownDoc';
36
37
  import { COMPLETION_KIND_INTENTS, SMART_EDITOR_OVERLAY_EFFECT } from './styling';
@@ -359,9 +360,25 @@ export const SmartEditor = forwardRef<TReqoreRichTextEditorRef, ISmartEditorProp
359
360
  enabled: enableHover,
360
361
  });
361
362
 
363
+ /* What the editor has reported and the host has not handed back yet. A host can hand an emit back
364
+ after the next key is already in the editor - one that debounces, or a render that lands between
365
+ two keys - and that stale value is not a change: rebuilding the document from it replaced the text
366
+ and put the caret at its end, so the next key followed the stale text (`name` typed came out
367
+ `nae`). See `EmittedValues`. */
368
+ const emitted = useEmittedValues<string>();
369
+ /* The value the host last handed down. The editor re-renders between keys for its own reasons (it
370
+ has just reported a new tree), and the host has not caught up then: the value it still holds is
371
+ not a change either. */
372
+ // nothing until the first value is committed, so the server is sent the first value too
373
+ const committedValueRef = useRef<string | undefined>(undefined);
374
+
362
375
  const slateValue = useMemo(() => {
363
- // Reuse the cache when `value` is just the editor's own typing echo.
364
- if (slateValueRef.current !== null && value === lastPlainTextRef.current) {
376
+ // Reuse the cache unless the host handed down something new: the same value again, the editor's
377
+ // own typing echo - the latest, or one still in flight - is not
378
+ if (
379
+ slateValueRef.current !== null &&
380
+ (value === committedValueRef.current || value === lastPlainTextRef.current || emitted.includes(value))
381
+ ) {
365
382
  return slateValueRef.current;
366
383
  }
367
384
  const next = converter.toSlateNodes(value);
@@ -376,8 +393,14 @@ export const SmartEditor = forwardRef<TReqoreRichTextEditorRef, ISmartEditorProp
376
393
  this one; typing already sent its text, and is not resent. */
377
394
  const { didChange: syncDocument } = session;
378
395
  useEffect(() => {
396
+ // the echo of an emit is acknowledged, and is not sent: the server already has the newer text
397
+ // typing sent; anything else is a change from outside
398
+ if (committedValueRef.current === value) return;
399
+ committedValueRef.current = value;
400
+ if (emitted.isEcho(value)) return;
401
+ lastPlainTextRef.current = value;
379
402
  syncDocument(value);
380
- }, [syncDocument, value]);
403
+ }, [syncDocument, value, emitted]);
381
404
 
382
405
  const diagnosticDecorate = useLspDiagnosticDecorations(
383
406
  session.diagnostics,
@@ -486,6 +509,7 @@ export const SmartEditor = forwardRef<TReqoreRichTextEditorRef, ISmartEditorProp
486
509
 
487
510
  if (plainText !== lastPlainTextRef.current) {
488
511
  lastPlainTextRef.current = plainText;
512
+ emitted.record(plainText);
489
513
  // CRITICAL: the next render's `value` equals `lastPlainTextRef` (the
490
514
  // typing echo), so `slateValue`'s cache returns the live nodes set
491
515
  // above — leave them stale and downstream hooks (signature help,
@@ -499,7 +523,7 @@ export const SmartEditor = forwardRef<TReqoreRichTextEditorRef, ISmartEditorProp
499
523
  autocomplete.onSlateChange(editorRef.current as any, newNodes);
500
524
  }
501
525
  },
502
- [onChange, session, readOnly, autocomplete, converter]
526
+ [onChange, session, readOnly, autocomplete, converter, emitted]
503
527
  );
504
528
 
505
529
  // Replace mode: open the dropdown at the chip's DOM rect (not the
@@ -132,6 +132,8 @@ export function useLspSession(
132
132
 
133
133
  c.onReady(setIsReady);
134
134
 
135
+ // the session ends when the editor goes: a connection it closes itself has not failed
136
+ let disposed = false;
135
137
  c.connect()
136
138
  .then(() => {
137
139
  // Initialize completed — mirror the captured server
@@ -152,12 +154,18 @@ export function useLspSession(
152
154
  setIsReady(true);
153
155
  })
154
156
  .catch((err) => {
157
+ if (disposed) {
158
+ // the editor went before the connection opened (Text mode left again, a form closed): it was
159
+ // closed on purpose, which is not a failure to report
160
+ return;
161
+ }
155
162
  // eslint-disable-next-line no-console
156
163
  console.error('[LSP session] Connect failed:', err);
157
164
  setIsReady(false);
158
165
  });
159
166
 
160
167
  return () => {
168
+ disposed = true;
161
169
  c.disconnect();
162
170
  clientRef.current = null;
163
171
  serverTextRef.current = null;
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Is a stored value one of a field's fixed choices?
3
+ *
4
+ * Two questions share this one predicate: the read-first row asks it to LABEL a
5
+ * value with the choice's display name, and the validator asks it to decide
6
+ * whether a value is one of the choices at all. Answering them differently is
7
+ * how values were lost: a value the row could name was erased by a narrower
8
+ * test, and the emptied form was then saved.
9
+ */
10
+ import { TQorusFormFieldSchema } from '@qoretechnologies/ts-toolkit';
11
+ import isEqual from 'lodash/isEqual';
12
+ import { richtextHasTag, richtextToString } from './common';
13
+ import { getListElementValue } from './options';
14
+ import { isValueTemplate } from './templates';
15
+
16
+ const isStructured = (value: unknown): boolean => value !== null && typeof value === 'object';
17
+
18
+ /**
19
+ * Prefer the matching allowed_values entry's display_name (fallback `name`)
20
+ * over the raw stored value.
21
+ *
22
+ * A LIST carries its options under `element_allowed_values` — they constrain
23
+ * each element, not the list itself — so a multi-select that was missing here
24
+ * printed what it stores rather than what you picked: `orders, batch` for
25
+ * fields whose picker reads "Orders, Batch". The gap shows worst exactly where
26
+ * allowed values earn their keep, since the stored form is often not readable
27
+ * at all (a permission code, `PO_REQUIRE_TYPES`, an app-specific id).
28
+ *
29
+ * Exported (as `findAllowedValueOption`) because the form engine's value
30
+ * validation has to answer the SAME question — "is this stored value one of the
31
+ * declared choices?" — and answering it differently loses data. An
32
+ * `allowed_values` entry is written three ways: an envelope (`{value: {type,
33
+ * value}}`), a bare value (`{value: 'default'}`) or a named entry
34
+ * (`{name: 'default'}`). This has always accepted all three; the engine's
35
+ * clearing guard accepted only the first and the third, so a schema using the
36
+ * bare form had its value ERASED on load while this function went on rendering
37
+ * the display name for it — the row read "Default RBAC" collapsed and "—" when
38
+ * opened, and the value was gone from the submitted data.
39
+ */
40
+ export const findAllowedValueOption = (
41
+ value: unknown,
42
+ schema?: TQorusFormFieldSchema
43
+ ): any | undefined => {
44
+ const s = schema as
45
+ { allowed_values?: any[]; element_allowed_values?: any[]; items?: any[] } | undefined;
46
+ const options =
47
+ (s?.allowed_values?.length && s.allowed_values) ||
48
+ (s?.element_allowed_values?.length && s.element_allowed_values) ||
49
+ s?.items;
50
+ if (!options?.length) {
51
+ return undefined;
52
+ }
53
+ // A stored element can be the bare value or the typed `{type, value}`
54
+ // envelope the form engine round-trips; match either against either.
55
+ const stored =
56
+ value && typeof value === 'object' && 'value' in (value as Record<string, unknown>) ?
57
+ (value as Record<string, unknown>).value
58
+ : value;
59
+
60
+ // A rich-text document is its text: a field with fixed choices can hold the document an editor wrote
61
+ // (for example while its choices could not be loaded), and the document for "Case" is the choice "Case".
62
+ const text = Array.isArray(stored) ? richtextToString(stored as never) : undefined;
63
+
64
+ return options.find(
65
+ (option) =>
66
+ option?.value?.value === value ||
67
+ option?.value === value ||
68
+ option?.name === value ||
69
+ option?.value?.value === stored ||
70
+ option?.value === stored ||
71
+ option?.name === stored ||
72
+ (text !== undefined &&
73
+ (option?.value?.value === text || option?.value === text || option?.name === text)) ||
74
+ // A hash- or list-valued choice is a fresh object every time the schema
75
+ // arrives, so identity can never match it; its contents can.
76
+ (isStructured(stored) &&
77
+ (isEqual(option?.value?.value, stored) || isEqual(option?.value, stored)))
78
+ );
79
+ };
80
+
81
+ /**
82
+ * A Slate rich-text document: a non-empty array of element nodes, each with
83
+ * `children`. Told apart from a LIST value, which is an array of elements.
84
+ */
85
+ const isRichTextDocument = (value: unknown): boolean =>
86
+ Array.isArray(value) &&
87
+ value.length > 0 &&
88
+ value.every(
89
+ (node) => !!node && typeof node === 'object' && Array.isArray((node as any).children)
90
+ );
91
+
92
+ /** A value resolved only when the interface runs — never one of the choices. */
93
+ const isResolvedAtRunTime = (value: unknown): boolean =>
94
+ (typeof value === 'string' && isValueTemplate(value)) || richtextHasTag(value);
95
+
96
+ const isEmptyChoiceValue = (value: unknown): boolean =>
97
+ value === undefined ||
98
+ value === null ||
99
+ value === '' ||
100
+ (Array.isArray(value) && value.length === 0);
101
+
102
+ /** The fields of a schema entry the choice check reads. */
103
+ export interface IChoiceFieldSchema {
104
+ allowed_values?: any[];
105
+ allowed_values_creatable?: boolean;
106
+ element_allowed_values?: any[];
107
+ element_allowed_values_creatable?: boolean;
108
+ multiselect?: boolean;
109
+ /** Set when the value is an expression, which is resolved at run time. */
110
+ isFunction?: boolean;
111
+ is_expression?: boolean;
112
+ }
113
+
114
+ /**
115
+ * The parts of a stored value that are not among the field's fixed choices.
116
+ *
117
+ * Empty when the value is acceptable, or when there is nothing to judge it
118
+ * against:
119
+ * - an empty value (whether one is needed is the required check's question);
120
+ * - an expression, a template string or a document holding a template tag,
121
+ * all of which are resolved when the interface runs;
122
+ * - `allowed_values: []` — the server has no choices to offer right now, which
123
+ * is "unknown", not "nothing is allowed";
124
+ * - creatable choices, where any value may be added.
125
+ *
126
+ * A single-valued field is judged as a whole. A multi-select field, and a list
127
+ * whose elements are constrained by `element_allowed_values`, are judged per
128
+ * element, so the answer names exactly the elements that are not choices.
129
+ */
130
+ export const getUnlistedChoices = (value: unknown, field?: IChoiceFieldSchema): unknown[] => {
131
+ if (!field || isEmptyChoiceValue(value) || field.isFunction || field.is_expression) {
132
+ return [];
133
+ }
134
+
135
+ const judgeElements = (elements: unknown[], choices: any[]): unknown[] =>
136
+ elements.filter((element) => {
137
+ const stored = getListElementValue(element);
138
+ if (isEmptyChoiceValue(stored) || isResolvedAtRunTime(stored)) {
139
+ return false;
140
+ }
141
+ if ((element as { is_expression?: boolean })?.is_expression) {
142
+ return false;
143
+ }
144
+ return !findAllowedValueOption(element, { allowed_values: choices } as never);
145
+ });
146
+
147
+ if (isResolvedAtRunTime(value)) {
148
+ return [];
149
+ }
150
+
151
+ if (field.allowed_values?.length && !field.allowed_values_creatable) {
152
+ if (findAllowedValueOption(value, { allowed_values: field.allowed_values } as never)) {
153
+ return [];
154
+ }
155
+ // A multi-select holds a list of choices; any list that is not itself a
156
+ // choice is judged element by element.
157
+ if (Array.isArray(value) && !isRichTextDocument(value)) {
158
+ return judgeElements(value, field.allowed_values);
159
+ }
160
+ return [value];
161
+ }
162
+
163
+ if (
164
+ field.element_allowed_values?.length &&
165
+ !field.element_allowed_values_creatable &&
166
+ !field.allowed_values_creatable &&
167
+ Array.isArray(value) &&
168
+ !isRichTextDocument(value)
169
+ ) {
170
+ return judgeElements(value, field.element_allowed_values);
171
+ }
172
+
173
+ return [];
174
+ };
175
+
176
+ /** How a value not among the choices is named in the message. */
177
+ const describeChoiceValue = (value: unknown): string => {
178
+ const stored = getListElementValue(value);
179
+ if (typeof stored === 'string') {
180
+ return stored;
181
+ }
182
+ if (isRichTextDocument(stored)) {
183
+ return richtextToString(stored as never);
184
+ }
185
+ if (stored === null || typeof stored !== 'object') {
186
+ return String(stored);
187
+ }
188
+ try {
189
+ return JSON.stringify(stored);
190
+ } catch {
191
+ return String(stored);
192
+ }
193
+ };
194
+
195
+ /**
196
+ * Why a stored value is not acceptable for a field with fixed choices, or
197
+ * `undefined` when it is (see {@link getUnlistedChoices} for what is judged).
198
+ *
199
+ * The value is kept — it is never erased for not being a choice — and this
200
+ * reason is what makes the field invalid, so the form shows it under "Needs
201
+ * attention" and blocks submitting until the author picks a choice.
202
+ */
203
+ export const getUnlistedChoiceReason = (
204
+ value: unknown,
205
+ field?: IChoiceFieldSchema
206
+ ): string | undefined => {
207
+ const unlisted = getUnlistedChoices(value, field);
208
+ if (!unlisted.length) {
209
+ return undefined;
210
+ }
211
+ const names = unlisted.map((item) => `"${describeChoiceValue(item)}"`);
212
+ if (names.length === 1) {
213
+ return `${names[0]} is not one of the choices`;
214
+ }
215
+ return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]} are not among the choices`;
216
+ };
@@ -93,6 +93,20 @@ export const getDefaultValue = (schema?: TQorusFormFieldSchema): unknown | undef
93
93
  return undefined;
94
94
  };
95
95
 
96
+ /**
97
+ * Whether a rich-text document contains a template tag (a `$local:...`-style reference drawn as a chip).
98
+ *
99
+ * Such a value is resolved when the interface runs, so it can never be one of a field's fixed choices and must
100
+ * not be judged against them.
101
+ */
102
+ export const richtextHasTag = (richtext: unknown): boolean => {
103
+ const walk = (node: any): boolean =>
104
+ !!node &&
105
+ typeof node === 'object' &&
106
+ (node.type === 'tag' || (Array.isArray(node.children) && node.children.some(walk)));
107
+ return Array.isArray(richtext) && richtext.some(walk);
108
+ };
109
+
96
110
  export const richtextToString = (richtext: IReqoreRichTextEditorProps['value']): string => {
97
111
  // A `richtext` ui_type option can still hold a plain scalar value (set
98
112
  // programmatically or loaded from the server before the editor ever touched
@@ -100,3 +100,28 @@ export const areQorusTypesCompatible = (
100
100
  // If none of the types are compatible return false
101
101
  return false;
102
102
  };
103
+
104
+ /**
105
+ * Whether an operation is offered where its result must be of `returnType`: a condition (`bool`) is
106
+ * built from the operations that give true or false, not from every operation the server has.
107
+ *
108
+ * Only a condition narrows the list. Any other type keeps every operation: a value of one type is often
109
+ * made from an operation that gives another (text from rich text, a number from an int), and the
110
+ * mismatch message after the choice says when it does not fit. An operation whose result is known
111
+ * only once it is built (`any`, or the type of its first argument) is offered everywhere.
112
+ */
113
+ export const expressionFitsReturnType = (
114
+ returnType: string | string[] | undefined,
115
+ expression: { ui_return_type?: string; return_type_first_arg?: boolean }
116
+ ): boolean => {
117
+ const types = isArray(returnType) ? returnType : returnType ? [returnType] : [];
118
+ const condition = types.length > 0 && types.every((type) => /^\*?(bool|boolean)$/.test(type));
119
+ if (!condition) {
120
+ return true;
121
+ }
122
+ const result = expression.ui_return_type;
123
+ if (expression.return_type_first_arg || !result || result === 'any' || result === 'auto') {
124
+ return true;
125
+ }
126
+ return /^\*?(bool|boolean)$/.test(result);
127
+ };
@@ -16,6 +16,7 @@ import { isOptionInterfaceUiType } from './optionUiTypes';
16
16
  import { getListElementValue, getOptionsFromRequiredGroups } from './options';
17
17
  import { IProviderType, TVariableActionValue, maybeBuildOptionProvider } from './providerValue';
18
18
  import { getTemplateKey, getTemplateValue, isValueTemplate } from './templates';
19
+ import { getUnlistedChoiceReason } from './allowedValues';
19
20
 
20
21
  /** The five cron fields, in order, as a schedule hash may name them. */
21
22
  const CRON_FIELDS: [string, string][] = [
@@ -309,6 +310,21 @@ export const _validateField = (
309
310
  }
310
311
  }
311
312
 
313
+ // A value that is not one of the field's fixed choices is INVALID — never
314
+ // erased. The form used to drop such a value when the choices arrived, and
315
+ // three times the dropped value was a real one (a document naming a choice,
316
+ // a bare-declared choice, a value whose choices had not loaded yet) that the
317
+ // emptied form then saved. Keeping it and saying why it cannot be submitted
318
+ // leaves the author to decide. Templates and expression values returned
319
+ // above; `allowed_values: []` and creatable choices are not judged. An
320
+ // `expression` is never judged either: the expression branch above recurses
321
+ // here with the field's schema (choices included) and the AST as the value.
322
+ const unlistedChoiceReason =
323
+ type === 'expression' ? undefined : getUnlistedChoiceReason(value, field);
324
+ if (unlistedChoiceReason) {
325
+ return invalidResult(unlistedChoiceReason);
326
+ }
327
+
312
328
  // An interface type names another Qorus object by its name, so a non-empty
313
329
  // string is the whole contract. `connection` is the exception: it carries
314
330
  // enablement and authentication state of its own, checked below.
@@ -0,0 +1,30 @@
1
+ // Copyright 2026 Qore Technologies, s.r.o.
2
+ import { useEffect, useState } from 'react';
3
+
4
+ /** The pointer a finger is: it cannot hover. */
5
+ export const COARSE_POINTER_QUERY = '(pointer: coarse)';
6
+
7
+ /**
8
+ * Whether the primary pointer is coarse (a finger), from the media query itself.
9
+ *
10
+ * Starts `false` and settles after mount, so a server render and the first paint agree.
11
+ */
12
+ export const useCoarsePointer = (): boolean => {
13
+ const [coarse, setCoarse] = useState<boolean>(false);
14
+
15
+ useEffect(() => {
16
+ if (typeof window === 'undefined' || !window.matchMedia) {
17
+ return undefined;
18
+ }
19
+
20
+ const query = window.matchMedia(COARSE_POINTER_QUERY);
21
+ const sync = () => setCoarse(query.matches);
22
+
23
+ sync();
24
+ query.addEventListener?.('change', sync);
25
+
26
+ return () => query.removeEventListener?.('change', sync);
27
+ }, []);
28
+
29
+ return coarse;
30
+ };
@@ -12,6 +12,10 @@ export interface IReqraftFetchResponse<T> {
12
12
  data: T;
13
13
  ok: boolean;
14
14
  code?: number;
15
+ /**
16
+ * Set on every non-ok response: the HTTP reason phrase, or `HTTP <status>`
17
+ * when there is none (always the case over HTTP/2).
18
+ */
15
19
  error?: any;
16
20
  /**
17
21
  * The parsed error body on a non-ok response, when the server sent JSON.
@@ -335,7 +339,12 @@ export async function query<T>({
335
339
  data: requestData.data,
336
340
  ok: false,
337
341
  code: requestData.status,
338
- error: requestData.statusText,
342
+ // Never empty: HTTP/2 has no reason phrase, so `statusText` is always ''
343
+ // there, and an empty `error` reads as "no error" to every caller that
344
+ // tests it for truthiness - `useFetch` consumers then saw a failed request
345
+ // as one still loading, forever (a 403 left a dashboard panel on its
346
+ // skeleton).
347
+ error: requestData.statusText || `HTTP ${requestData.status}`,
339
348
  // Only when the body really was JSON: `data` is `{}` for a non-JSON body,
340
349
  // and an empty object masquerading as a parsed error tells a caller
341
350
  // nothing while looking like it did.