@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.
- package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
- package/dist/components/form/engine/FormEngine.js +30 -53
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/engine/readFirst.d.ts +16 -20
- package/dist/components/form/engine/readFirst.d.ts.map +1 -1
- package/dist/components/form/engine/readFirst.js +16 -49
- package/dist/components/form/engine/readFirst.js.map +1 -1
- package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
- package/dist/components/form/expressions/builder/index.js +29 -14
- package/dist/components/form/expressions/builder/index.js.map +1 -1
- package/dist/components/form/fields/emittedValues.d.ts +5 -0
- package/dist/components/form/fields/emittedValues.d.ts.map +1 -1
- package/dist/components/form/fields/emittedValues.js +8 -0
- package/dist/components/form/fields/emittedValues.js.map +1 -1
- package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
- package/dist/components/form/fields/template/TemplateField.js +3 -1
- package/dist/components/form/fields/template/TemplateField.js.map +1 -1
- package/dist/components/smartEditor/SmartEditor.d.ts.map +1 -1
- package/dist/components/smartEditor/SmartEditor.js +27 -4
- package/dist/components/smartEditor/SmartEditor.js.map +1 -1
- package/dist/components/smartEditor/useLspSession.d.ts.map +1 -1
- package/dist/components/smartEditor/useLspSession.js +8 -0
- package/dist/components/smartEditor/useLspSession.js.map +1 -1
- package/dist/helpers/allowedValues.d.ts +71 -0
- package/dist/helpers/allowedValues.d.ts.map +1 -0
- package/dist/helpers/allowedValues.js +183 -0
- package/dist/helpers/allowedValues.js.map +1 -0
- package/dist/helpers/common.d.ts +7 -0
- package/dist/helpers/common.d.ts.map +1 -1
- package/dist/helpers/common.js +16 -1
- package/dist/helpers/common.js.map +1 -1
- package/dist/helpers/expressions.d.ts +13 -0
- package/dist/helpers/expressions.d.ts.map +1 -1
- package/dist/helpers/expressions.js +23 -1
- package/dist/helpers/expressions.js.map +1 -1
- package/dist/helpers/validations.d.ts.map +1 -1
- package/dist/helpers/validations.js +14 -0
- package/dist/helpers/validations.js.map +1 -1
- package/dist/hooks/useCoarsePointer.d.ts +9 -0
- package/dist/hooks/useCoarsePointer.d.ts.map +1 -0
- package/dist/hooks/useCoarsePointer.js +29 -0
- package/dist/hooks/useCoarsePointer.js.map +1 -0
- package/dist/utils/fetch.d.ts +4 -0
- package/dist/utils/fetch.d.ts.map +1 -1
- package/dist/utils/fetch.js +6 -1
- package/dist/utils/fetch.js.map +1 -1
- package/package.json +1 -1
- package/src/components/form/engine/FormEngine.tsx +26 -48
- package/src/components/form/engine/readFirst.ts +19 -50
- package/src/components/form/expressions/builder/index.tsx +21 -3
- package/src/components/form/fields/emittedValues.ts +8 -0
- package/src/components/form/fields/template/TemplateField.tsx +4 -1
- package/src/components/smartEditor/SmartEditor.tsx +28 -4
- package/src/components/smartEditor/useLspSession.ts +8 -0
- package/src/helpers/allowedValues.ts +216 -0
- package/src/helpers/common.ts +14 -0
- package/src/helpers/expressions.ts +25 -0
- package/src/helpers/validations.ts +16 -0
- package/src/hooks/useCoarsePointer.ts +30 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
364
|
-
|
|
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
|
+
};
|
package/src/helpers/common.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/utils/fetch.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|