@qoretechnologies/reqraft 0.10.57 → 0.10.58-pr.129.g2af0cf5
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/design/FORM_ENGINE_COMPACT.md +23 -5
- package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
- package/dist/components/form/engine/CompactToolbar.js +2 -2
- package/dist/components/form/engine/CompactToolbar.js.map +1 -1
- package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
- package/dist/components/form/engine/FormEngine.js +71 -62
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
- package/dist/components/form/engine/OptionFieldMessages.js +6 -3
- package/dist/components/form/engine/OptionFieldMessages.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/engine/variants/VariantCalmTable.d.ts.map +1 -1
- package/dist/components/form/engine/variants/VariantCalmTable.js +1 -1
- package/dist/components/form/engine/variants/VariantCalmTable.js.map +1 -1
- package/dist/components/form/engine/variants/variantModel.d.ts.map +1 -1
- package/dist/components/form/engine/variants/variantModel.js +8 -3
- package/dist/components/form/engine/variants/variantModel.js.map +1 -1
- package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
- package/dist/components/form/expressions/builder/index.js +22 -13
- package/dist/components/form/expressions/builder/index.js.map +1 -1
- package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
- package/dist/components/form/fields/array/ArrayAuto.js +8 -0
- package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
- package/dist/components/form/fields/array/ArrayAutoField.d.ts.map +1 -1
- package/dist/components/form/fields/array/ArrayAutoField.js +8 -0
- package/dist/components/form/fields/array/ArrayAutoField.js.map +1 -1
- package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
- package/dist/components/form/fields/auto/AutoFormField.js +4 -1
- package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
- package/dist/components/form/fields/binary/Binary.d.ts.map +1 -1
- package/dist/components/form/fields/binary/Binary.js +10 -0
- package/dist/components/form/fields/binary/Binary.js.map +1 -1
- package/dist/components/form/fields/emittedValues.d.ts +38 -0
- package/dist/components/form/fields/emittedValues.d.ts.map +1 -0
- package/dist/components/form/fields/emittedValues.js +67 -0
- package/dist/components/form/fields/emittedValues.js.map +1 -0
- package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
- package/dist/components/form/fields/long-string/LongString.js +24 -8
- package/dist/components/form/fields/long-string/LongString.js.map +1 -1
- package/dist/components/form/fields/number/Number.d.ts.map +1 -1
- package/dist/components/form/fields/number/Number.js +9 -0
- package/dist/components/form/fields/number/Number.js.map +1 -1
- package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
- package/dist/components/form/fields/rich-text/RichText.js +10 -0
- package/dist/components/form/fields/rich-text/RichText.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/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/components/supportTicket/InterfaceReferenceTags.d.ts +21 -5
- package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
- package/dist/components/supportTicket/InterfaceReferenceTags.js +24 -8
- package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
- package/dist/components/supportTicket/ReferencePicker.d.ts +5 -4
- package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
- package/dist/components/supportTicket/ReferencePicker.js +27 -2
- package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
- package/dist/components/supportTicket/meta.d.ts +16 -0
- package/dist/components/supportTicket/meta.d.ts.map +1 -1
- package/dist/components/supportTicket/meta.js +6 -1
- package/dist/components/supportTicket/meta.js.map +1 -1
- package/dist/components/supportTicket/useLoadedImages.d.ts +18 -0
- package/dist/components/supportTicket/useLoadedImages.d.ts.map +1 -0
- package/dist/components/supportTicket/useLoadedImages.js +72 -0
- package/dist/components/supportTicket/useLoadedImages.js.map +1 -0
- package/dist/components/ticketReferences/TicketReferences.d.ts +9 -5
- package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -1
- package/dist/components/ticketReferences/TicketReferences.js +22 -9
- package/dist/components/ticketReferences/TicketReferences.js.map +1 -1
- package/dist/components/ticketThread/TicketThread.d.ts +10 -6
- package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
- package/dist/components/ticketThread/TicketThread.js +2 -2
- package/dist/components/ticketThread/TicketThread.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/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/CompactToolbar.tsx +8 -3
- package/src/components/form/engine/FormEngine.stories.tsx +206 -13
- package/src/components/form/engine/FormEngine.tsx +70 -57
- package/src/components/form/engine/OptionFieldMessages.tsx +7 -5
- package/src/components/form/engine/readFirst.ts +19 -50
- package/src/components/form/engine/variants/VariantCalmTable.tsx +3 -1
- package/src/components/form/engine/variants/variantModel.ts +10 -3
- package/src/components/form/expressions/builder/index.tsx +15 -2
- package/src/components/form/fields/array/ArrayAuto.tsx +10 -1
- package/src/components/form/fields/array/ArrayAutoField.tsx +10 -1
- package/src/components/form/fields/auto/AutoFormField.tsx +4 -1
- package/src/components/form/fields/binary/Binary.tsx +10 -0
- package/src/components/form/fields/emittedValues.ts +62 -0
- package/src/components/form/fields/long-string/LongString.tsx +28 -8
- package/src/components/form/fields/number/Number.tsx +9 -0
- package/src/components/form/fields/rich-text/RichText.tsx +10 -0
- package/src/components/form/fields/template/TemplateField.tsx +4 -1
- package/src/components/smartEditor/useLspSession.ts +8 -0
- package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +86 -0
- package/src/components/supportTicket/InterfaceReferenceTags.tsx +44 -11
- package/src/components/supportTicket/ReferencePicker.stories.tsx +65 -0
- package/src/components/supportTicket/ReferencePicker.tsx +26 -5
- package/src/components/supportTicket/TicketReplyBox.stories.tsx +5 -2
- package/src/components/supportTicket/__fixtures__/kindMarks.ts +28 -0
- package/src/components/supportTicket/meta.ts +21 -0
- package/src/components/supportTicket/useLoadedImages.ts +73 -0
- package/src/components/ticketReferences/TicketReferences.tsx +27 -7
- package/src/components/ticketThread/TicketThread.tsx +11 -3
- 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/utils/fetch.ts +10 -1
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
isOptionValueEmpty,
|
|
21
21
|
} from '../readFirst';
|
|
22
22
|
import { isValueTemplate } from '../../../../helpers/templates';
|
|
23
|
+
import { hasAllDependenciesFullfilled } from '../../../../helpers/validations';
|
|
23
24
|
|
|
24
25
|
export type TVariantStatus =
|
|
25
26
|
| 'set' // has a valid value
|
|
@@ -153,8 +154,13 @@ export function buildVariantGroups(
|
|
|
153
154
|
const empty = isOptionValueEmpty(field?.value);
|
|
154
155
|
const ownGroups: string[] = schema?.required_groups || [];
|
|
155
156
|
// Member of a one-of group that nobody has satisfied yet.
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
// A field locked by an unmet `depends_on` does not apply yet, so it is
|
|
158
|
+
// neither required nor part of an unmet group — the engine's rule.
|
|
159
|
+
const locked =
|
|
160
|
+
!!schema?.depends_on && !hasAllDependenciesFullfilled(schema.depends_on, values, options);
|
|
161
|
+
const inUnmetGroup =
|
|
162
|
+
!locked && ownGroups.some((k) => reqGroups[k] && !reqGroups[k].satisfied);
|
|
163
|
+
const required = !locked && !!schema?.required;
|
|
158
164
|
|
|
159
165
|
let status: TVariantStatus;
|
|
160
166
|
let reason: string | undefined;
|
|
@@ -183,7 +189,8 @@ export function buildVariantGroups(
|
|
|
183
189
|
label: (schema?.display_name as string) || name,
|
|
184
190
|
shortDesc: schema?.short_desc,
|
|
185
191
|
longDesc: schema?.desc,
|
|
186
|
-
|
|
192
|
+
// The asterisk still marks it: it is required once it applies.
|
|
193
|
+
required: !!schema?.required || ownGroups.length > 0,
|
|
187
194
|
readOnly: !!schema?.readonly,
|
|
188
195
|
status,
|
|
189
196
|
reason,
|
|
@@ -24,7 +24,11 @@ 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';
|
|
@@ -594,6 +598,15 @@ export const Expression = ({
|
|
|
594
598
|
const defaultItems = useMemo(
|
|
595
599
|
() =>
|
|
596
600
|
expressions.value
|
|
601
|
+
// a condition (a field or an operand that must be true or false) offers the operations that
|
|
602
|
+
// give true or false; a group's members are its own business, and the operation already chosen
|
|
603
|
+
// stays, so a stored mismatch can be seen and fixed
|
|
604
|
+
.filter(
|
|
605
|
+
(exp) =>
|
|
606
|
+
isChild ||
|
|
607
|
+
exp.name === value?.value?.exp ||
|
|
608
|
+
expressionFitsReturnType(returnType as string | string[] | undefined, exp)
|
|
609
|
+
)
|
|
597
610
|
.map((exp) => ({
|
|
598
611
|
name: exp.name,
|
|
599
612
|
value: exp.name,
|
|
@@ -612,7 +625,7 @@ export const Expression = ({
|
|
|
612
625
|
? !exp.from_server
|
|
613
626
|
: true
|
|
614
627
|
),
|
|
615
|
-
[JSON.stringify(expressions.value), serverExpression, level]
|
|
628
|
+
[JSON.stringify(expressions.value), serverExpression, level, isChild, returnType, value?.value?.exp]
|
|
616
629
|
);
|
|
617
630
|
|
|
618
631
|
// SEAM (reqraft): reqraft's `SelectFormField` calls `onChange(value)` with
|
|
@@ -17,9 +17,10 @@ import {
|
|
|
17
17
|
ReqoreVerticalSpacer,
|
|
18
18
|
useReqoreProperty,
|
|
19
19
|
} from '@qoretechnologies/reqore';
|
|
20
|
-
import { map, size } from 'lodash';
|
|
20
|
+
import { isEqual, map, size } from 'lodash';
|
|
21
21
|
import { useEffect, useState } from 'react';
|
|
22
22
|
import { useDebounce } from 'react-use';
|
|
23
|
+
import { useEmittedValues } from '../emittedValues';
|
|
23
24
|
import { validateFieldWithResult } from '../../../../helpers/validations';
|
|
24
25
|
import { useWhyDidYouUpdate } from '../../../../hooks/useWhyDidYouUpdate';
|
|
25
26
|
// Direct imports — the cycle (ArrayAuto → TemplateField/auto → ArrayAuto) is
|
|
@@ -81,12 +82,20 @@ export const ArrayAuto = ({
|
|
|
81
82
|
...rest,
|
|
82
83
|
});
|
|
83
84
|
|
|
85
|
+
// The parent's echo of an emit still in flight is not a change: copying it
|
|
86
|
+
// over the local list lost what was edited since. See `EmittedValues`.
|
|
87
|
+
const emitted = useEmittedValues<unknown>(isEqual);
|
|
88
|
+
|
|
84
89
|
useEffect(() => {
|
|
90
|
+
if (emitted.isEcho(value)) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
85
93
|
setLocalValue(value);
|
|
86
94
|
}, [JSON.stringify(value)]);
|
|
87
95
|
|
|
88
96
|
useDebounce(
|
|
89
97
|
() => {
|
|
98
|
+
emitted.record(localValue);
|
|
90
99
|
onChange?.(name, localValue);
|
|
91
100
|
},
|
|
92
101
|
300,
|
|
@@ -8,10 +8,11 @@ import {
|
|
|
8
8
|
useReqoreProperty,
|
|
9
9
|
} from '@qoretechnologies/reqore';
|
|
10
10
|
import { IQorusFormSchema } from '@qoretechnologies/ts-toolkit';
|
|
11
|
-
import { map, size } from 'lodash';
|
|
11
|
+
import { isEqual, map, size } from 'lodash';
|
|
12
12
|
import { recordIdentity } from '../../engine/readFirst';
|
|
13
13
|
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
|
|
14
14
|
import { useDebounce } from 'react-use';
|
|
15
|
+
import { useEmittedValues } from '../emittedValues';
|
|
15
16
|
import { validateFieldWithResult } from '../../../../helpers/validations';
|
|
16
17
|
|
|
17
18
|
export interface IArrayAutoFieldProps {
|
|
@@ -77,12 +78,20 @@ export const ArrayAutoField = memo(
|
|
|
77
78
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
|
78
79
|
const inputKeyRef = useRef(0);
|
|
79
80
|
|
|
81
|
+
// The parent's echo of an emit still in flight is not a change: copying it
|
|
82
|
+
// over the local list lost what was edited since. See `EmittedValues`.
|
|
83
|
+
const emitted = useEmittedValues<unknown>(isEqual);
|
|
84
|
+
|
|
80
85
|
useEffect(() => {
|
|
86
|
+
if (emitted.isEcho(value)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
81
89
|
setLocalValue(value);
|
|
82
90
|
}, [JSON.stringify(value)]);
|
|
83
91
|
|
|
84
92
|
useDebounce(
|
|
85
93
|
() => {
|
|
94
|
+
emitted.record(localValue);
|
|
86
95
|
onChange(name, localValue);
|
|
87
96
|
},
|
|
88
97
|
300,
|
|
@@ -819,9 +819,12 @@ function AutoField<T = any>({
|
|
|
819
819
|
);
|
|
820
820
|
}
|
|
821
821
|
|
|
822
|
+
// the multi-select reads each item's `{type, value}` envelope itself, as Field.tsx gives it;
|
|
823
|
+
// handing it the unwrapped values left every choice `undefined`, so a pick stored an empty
|
|
824
|
+
// element and drew an "undefined" chip
|
|
822
825
|
return (
|
|
823
826
|
<MultiSelectFormField
|
|
824
|
-
items={
|
|
827
|
+
items={rest.element_allowed_values}
|
|
825
828
|
value={formatFromServerValue(value)}
|
|
826
829
|
onChange={(selected) => {
|
|
827
830
|
handleChange(name, formatToServerValue(selected));
|
|
@@ -2,6 +2,7 @@ import { ReqoreControlGroup, ReqoreTextarea } from '@qoretechnologies/reqore';
|
|
|
2
2
|
import { IReqoreTextareaProps } from '@qoretechnologies/reqore/dist/components/Textarea';
|
|
3
3
|
import { ChangeEvent, memo, useCallback, useEffect, useState } from 'react';
|
|
4
4
|
import { useDebounce } from 'react-use';
|
|
5
|
+
import { useEmittedValues } from '../emittedValues';
|
|
5
6
|
import { IFileFormFieldValue, ReqraftFileFormField } from '../file/File';
|
|
6
7
|
|
|
7
8
|
export interface IReqraftBinaryFormFieldProps extends Omit<IReqoreTextareaProps, 'onChange' | 'value'> {
|
|
@@ -28,8 +29,14 @@ const stripDataUrlPrefix = (dataUrl: string): string => {
|
|
|
28
29
|
export const ReqraftBinaryFormField = memo(
|
|
29
30
|
({ value, onChange, ...rest }: IReqraftBinaryFormFieldProps) => {
|
|
30
31
|
const [localValue, setLocalValue] = useState<string>(value ?? '');
|
|
32
|
+
// The parent's echo of an emit still in flight is not a change: copying it
|
|
33
|
+
// over the local value lost what was typed since. See `EmittedValues`.
|
|
34
|
+
const emitted = useEmittedValues<string | undefined>();
|
|
31
35
|
|
|
32
36
|
useEffect(() => {
|
|
37
|
+
if (emitted.isEcho(value)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
33
40
|
if (value !== localValue) {
|
|
34
41
|
setLocalValue(value ?? '');
|
|
35
42
|
}
|
|
@@ -38,6 +45,7 @@ export const ReqraftBinaryFormField = memo(
|
|
|
38
45
|
useDebounce(
|
|
39
46
|
() => {
|
|
40
47
|
if (localValue !== value) {
|
|
48
|
+
emitted.record(localValue);
|
|
41
49
|
onChange?.(localValue);
|
|
42
50
|
}
|
|
43
51
|
},
|
|
@@ -53,6 +61,7 @@ export const ReqraftBinaryFormField = memo(
|
|
|
53
61
|
(file?: IFileFormFieldValue) => {
|
|
54
62
|
const base64 = stripDataUrlPrefix(file?.content ?? '');
|
|
55
63
|
setLocalValue(base64);
|
|
64
|
+
emitted.record(base64);
|
|
56
65
|
onChange?.(base64);
|
|
57
66
|
},
|
|
58
67
|
[onChange]
|
|
@@ -67,6 +76,7 @@ export const ReqraftBinaryFormField = memo(
|
|
|
67
76
|
onChange={handleChange}
|
|
68
77
|
onClearClick={() => {
|
|
69
78
|
setLocalValue('');
|
|
79
|
+
emitted.record('');
|
|
70
80
|
onChange?.('');
|
|
71
81
|
}}
|
|
72
82
|
placeholder='Base64-encoded binary value'
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a debounced field has told its parent and not yet heard back.
|
|
5
|
+
*
|
|
6
|
+
* A field that keeps a local copy of what is being typed reports it after a
|
|
7
|
+
* short pause, and the parent hands that value back down as the `value` prop —
|
|
8
|
+
* usually after its OWN debounce, so the echo of an emit arrives while the
|
|
9
|
+
* person is already typing the next character. Treating every incoming value
|
|
10
|
+
* as an external change copied that stale echo over the newer text: typing
|
|
11
|
+
* "ship-order" with 150 ms between keys left "shp-order".
|
|
12
|
+
*
|
|
13
|
+
* So an incoming value is first checked against what is in flight. One that
|
|
14
|
+
* matches an emit is the parent catching up, not a change, and it acknowledges
|
|
15
|
+
* that emit and every one before it (a parent that debounces skips some).
|
|
16
|
+
* Anything else is a genuine change from outside and replaces the local text;
|
|
17
|
+
* it also drops whatever was in flight, since the parent has moved past it.
|
|
18
|
+
*
|
|
19
|
+
* A genuine external change that happens to equal an emit still in flight is
|
|
20
|
+
* indistinguishable from its echo, and is treated as one — harmless, because it
|
|
21
|
+
* sets the very value the field already reported.
|
|
22
|
+
*/
|
|
23
|
+
export class EmittedValues<T> {
|
|
24
|
+
private pending: T[] = [];
|
|
25
|
+
|
|
26
|
+
/** Bounded: a parent that never feeds its value back never acknowledges. */
|
|
27
|
+
private static readonly LIMIT = 50;
|
|
28
|
+
|
|
29
|
+
constructor(private readonly equals: (a: T, b: T) => boolean = Object.is) {}
|
|
30
|
+
|
|
31
|
+
/** Record a value just reported to the parent. */
|
|
32
|
+
record(value: T): void {
|
|
33
|
+
this.pending.push(value);
|
|
34
|
+
if (this.pending.length > EmittedValues.LIMIT) {
|
|
35
|
+
this.pending.shift();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether `incoming` is the echo of an emit still in flight. An echo
|
|
41
|
+
* acknowledges that emit and every earlier one; anything else clears the
|
|
42
|
+
* in-flight list, because the parent has a value of its own.
|
|
43
|
+
*/
|
|
44
|
+
isEcho(incoming: T): boolean {
|
|
45
|
+
const index = this.pending.findIndex((value) => this.equals(value, incoming));
|
|
46
|
+
if (index === -1) {
|
|
47
|
+
this.pending = [];
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
this.pending = this.pending.slice(index + 1);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One `EmittedValues` for the life of the component. */
|
|
56
|
+
export const useEmittedValues = <T>(equals?: (a: T, b: T) => boolean): EmittedValues<T> => {
|
|
57
|
+
const ref = useRef<EmittedValues<T>>();
|
|
58
|
+
if (!ref.current) {
|
|
59
|
+
ref.current = new EmittedValues<T>(equals);
|
|
60
|
+
}
|
|
61
|
+
return ref.current;
|
|
62
|
+
};
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
hasLineBreak,
|
|
8
8
|
isSingleLineStringType,
|
|
9
9
|
} from '../../../../helpers/singleLineString';
|
|
10
|
+
import { useEmittedValues } from '../emittedValues';
|
|
10
11
|
|
|
11
12
|
export interface ILongStringFormFieldProps extends Omit<IReqoreTextareaProps, 'onChange'> {
|
|
12
13
|
value?: string;
|
|
@@ -38,24 +39,41 @@ export const LongStringFormField = ({
|
|
|
38
39
|
and reporting that difference marked every form holding an empty text
|
|
39
40
|
field as changed before anyone typed. */
|
|
40
41
|
const lastEmittedRef = useRef<string>(value ?? '');
|
|
42
|
+
/* The emits the parent has not handed back yet. Its echo of an earlier one
|
|
43
|
+
arrives while the next keys are being typed, and copying it over the text
|
|
44
|
+
lost them ("ship-order" typed 150 ms apart became "shp-order"). An echo is
|
|
45
|
+
the parent catching up, not a change; see `EmittedValues`. */
|
|
46
|
+
const emitted = useEmittedValues<string>();
|
|
47
|
+
|
|
48
|
+
const emit = useCallback(
|
|
49
|
+
(next: string) => {
|
|
50
|
+
lastEmittedRef.current = next;
|
|
51
|
+
emitted.record(next);
|
|
52
|
+
onChange?.(next);
|
|
53
|
+
},
|
|
54
|
+
[onChange, emitted]
|
|
55
|
+
);
|
|
41
56
|
|
|
42
57
|
useEffect(() => {
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
const incoming = value ?? '';
|
|
59
|
+
if (emitted.isEcho(incoming)) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (incoming !== localValue) {
|
|
63
|
+
setLocalValue(incoming);
|
|
45
64
|
}
|
|
46
65
|
// A value from outside is not something to report back to whoever sent it.
|
|
47
|
-
lastEmittedRef.current =
|
|
66
|
+
lastEmittedRef.current = incoming;
|
|
48
67
|
}, [value]);
|
|
49
68
|
|
|
50
69
|
useDebounce(
|
|
51
70
|
() => {
|
|
52
71
|
if (localValue !== lastEmittedRef.current) {
|
|
53
|
-
|
|
54
|
-
onChange?.(localValue);
|
|
72
|
+
emit(localValue);
|
|
55
73
|
}
|
|
56
74
|
},
|
|
57
75
|
100,
|
|
58
|
-
[localValue,
|
|
76
|
+
[localValue, emit]
|
|
59
77
|
);
|
|
60
78
|
|
|
61
79
|
const singleLine = isSingleLineStringType(type);
|
|
@@ -87,9 +105,11 @@ export const LongStringFormField = ({
|
|
|
87
105
|
|
|
88
106
|
const handleClearClick = useCallback(() => {
|
|
89
107
|
setLocalValue('');
|
|
90
|
-
|
|
108
|
+
// Reported at once, and recorded like any emit, so the debounce does not
|
|
109
|
+
// report it a second time and its echo is not mistaken for a change.
|
|
110
|
+
emit('');
|
|
91
111
|
onClearClick?.();
|
|
92
|
-
}, [
|
|
112
|
+
}, [emit, onClearClick]);
|
|
93
113
|
|
|
94
114
|
return (
|
|
95
115
|
<ReqoreTextarea
|
|
@@ -3,6 +3,7 @@ import { IReqoreInputProps } from '@qoretechnologies/reqore/dist/components/Inpu
|
|
|
3
3
|
import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea';
|
|
4
4
|
import { ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
|
5
5
|
import { useDebounce } from 'react-use';
|
|
6
|
+
import { useEmittedValues } from '../emittedValues';
|
|
6
7
|
// Const-only usage at render time — the cycle (Number → TemplateField → Number)
|
|
7
8
|
// is safe the same way Field → AutoFormField → Field is.
|
|
8
9
|
import { TemplatesListProps } from '../template/TemplateField';
|
|
@@ -37,8 +38,14 @@ export const NumberFormField = ({
|
|
|
37
38
|
...rest
|
|
38
39
|
}: INumberFormFieldProps) => {
|
|
39
40
|
const [localValue, setLocalValue] = useState<number | string>(value ?? '');
|
|
41
|
+
// The parent's echo of an emit still in flight is not a change: copying it
|
|
42
|
+
// over the local value lost what was typed since. See `EmittedValues`.
|
|
43
|
+
const emitted = useEmittedValues<number | string | undefined>();
|
|
40
44
|
|
|
41
45
|
useEffect(() => {
|
|
46
|
+
if (emitted.isEcho(value)) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
42
49
|
if (value !== localValue) {
|
|
43
50
|
setLocalValue(value ?? '');
|
|
44
51
|
}
|
|
@@ -47,6 +54,7 @@ export const NumberFormField = ({
|
|
|
47
54
|
useDebounce(
|
|
48
55
|
() => {
|
|
49
56
|
if (localValue !== value) {
|
|
57
|
+
emitted.record(localValue);
|
|
50
58
|
onChange?.(localValue);
|
|
51
59
|
}
|
|
52
60
|
},
|
|
@@ -75,6 +83,7 @@ export const NumberFormField = ({
|
|
|
75
83
|
|
|
76
84
|
const handleResetClick = useCallback((): void => {
|
|
77
85
|
setLocalValue(0);
|
|
86
|
+
emitted.record(0);
|
|
78
87
|
onChange?.(0);
|
|
79
88
|
}, [onChange]);
|
|
80
89
|
|
|
@@ -8,6 +8,7 @@ import { IReqoreTooltip } from '@qoretechnologies/reqore/dist/types/global';
|
|
|
8
8
|
import { isEqual, size } from 'lodash';
|
|
9
9
|
import { KeyboardEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
10
10
|
import { useDebounce } from 'react-use';
|
|
11
|
+
import { useEmittedValues } from '../emittedValues';
|
|
11
12
|
import { flattenToSingleLine, hasLineBreak } from '../../../../helpers/singleLineString';
|
|
12
13
|
import {
|
|
13
14
|
renderTemplateItemDescriptions,
|
|
@@ -129,8 +130,15 @@ export const RichTextFormField = memo(({
|
|
|
129
130
|
document from it would hand the editor a new tree on every keystroke —
|
|
130
131
|
which it answers by replacing its content and moving the cursor to the end. */
|
|
131
132
|
const lastTextRef = useRef(isText ? String(value ?? '') : '');
|
|
133
|
+
/* The parent's echo of an emit still in flight is not a change. It arrives
|
|
134
|
+
while the next keys are typed, and rebuilding the document from it threw
|
|
135
|
+
them away. See `EmittedValues`. */
|
|
136
|
+
const emitted = useEmittedValues<unknown>(isEqual);
|
|
132
137
|
|
|
133
138
|
useEffect(() => {
|
|
139
|
+
if (emitted.isEcho(isText ? String(value ?? '') : value)) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
134
142
|
if (isText) {
|
|
135
143
|
const text = String(value ?? '');
|
|
136
144
|
if (text !== lastTextRef.current) {
|
|
@@ -148,11 +156,13 @@ export const RichTextFormField = memo(({
|
|
|
148
156
|
() => {
|
|
149
157
|
if (isText) {
|
|
150
158
|
if (lastTextRef.current !== String(value ?? '')) {
|
|
159
|
+
emitted.record(lastTextRef.current);
|
|
151
160
|
onChange?.(lastTextRef.current);
|
|
152
161
|
}
|
|
153
162
|
return;
|
|
154
163
|
}
|
|
155
164
|
if (!isEqual(localValue, value)) {
|
|
165
|
+
emitted.record(localValue);
|
|
156
166
|
onChange?.(localValue);
|
|
157
167
|
}
|
|
158
168
|
},
|
|
@@ -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`)
|
|
@@ -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;
|
|
@@ -3,6 +3,14 @@ import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
|
|
|
3
3
|
import { StoryMeta } from '../../types';
|
|
4
4
|
import { InterfaceReferenceTags } from './InterfaceReferenceTags';
|
|
5
5
|
import { IInterfaceReference } from './meta';
|
|
6
|
+
import {
|
|
7
|
+
STORY_APP_LOGO,
|
|
8
|
+
STORY_BROKEN_IMAGE,
|
|
9
|
+
STORY_QOG_IMAGE,
|
|
10
|
+
storyBrokenKindMark,
|
|
11
|
+
storyKindMark,
|
|
12
|
+
} from './__fixtures__/kindMarks';
|
|
13
|
+
import { whenImageSettled } from './useLoadedImages';
|
|
6
14
|
|
|
7
15
|
// Several references, including two of the same kind (workflow), as `{kind, name}`
|
|
8
16
|
// snapshots — no `resolveInterfaceIcon` is passed, so the built-in per-kind default
|
|
@@ -169,3 +177,81 @@ export const Empty: Story = {
|
|
|
169
177
|
await expect(within(canvasElement).queryByText('order-sync:1.2')).not.toBeInTheDocument();
|
|
170
178
|
},
|
|
171
179
|
};
|
|
180
|
+
|
|
181
|
+
const QOG_REFERENCES: IInterfaceReference[] = [
|
|
182
|
+
{ interface_kind: 'fsm', interface_name: 'order-flow', reference_id: 'q1' },
|
|
183
|
+
{
|
|
184
|
+
interface_kind: 'fsm',
|
|
185
|
+
interface_name: 'telegram-intake',
|
|
186
|
+
reference_id: 'q2',
|
|
187
|
+
logo: STORY_APP_LOGO,
|
|
188
|
+
},
|
|
189
|
+
{ interface_kind: 'service', interface_name: 'notification-svc', reference_id: 'q3' },
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
const chipImages = (canvasElement: HTMLElement) =>
|
|
193
|
+
Array.from(canvasElement.querySelectorAll('.reqore-tag img')).map((img) =>
|
|
194
|
+
img.getAttribute('src')
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
export const KindImage: Story = {
|
|
198
|
+
parameters: {
|
|
199
|
+
docs: {
|
|
200
|
+
description: {
|
|
201
|
+
story:
|
|
202
|
+
"Renders references with a host resolver that gives a Qog an image (the green mark) and a font icon fallback. The plain Qog shows the image; the app-triggered Qog keeps its stored trigger-app logo, because by default a reference's own logo comes first; the service has no image and shows its font icon.",
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
args: { references: QOG_REFERENCES, resolveInterfaceIcon: storyKindMark },
|
|
207
|
+
async play({ canvasElement }) {
|
|
208
|
+
await waitFor(() =>
|
|
209
|
+
expect(chipImages(canvasElement).sort()).toEqual([STORY_APP_LOGO, STORY_QOG_IMAGE].sort())
|
|
210
|
+
);
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export const KindImagePreferred: Story = {
|
|
215
|
+
parameters: {
|
|
216
|
+
docs: {
|
|
217
|
+
description: {
|
|
218
|
+
story:
|
|
219
|
+
'Renders the same references with preferKindImage: the kind image comes before a reference\'s own logo, so both Qogs show the green Qog mark and the service keeps its font icon.',
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
args: {
|
|
224
|
+
references: QOG_REFERENCES,
|
|
225
|
+
resolveInterfaceIcon: storyKindMark,
|
|
226
|
+
preferKindImage: true,
|
|
227
|
+
},
|
|
228
|
+
async play({ canvasElement }) {
|
|
229
|
+
await waitFor(() =>
|
|
230
|
+
expect(chipImages(canvasElement)).toEqual([STORY_QOG_IMAGE, STORY_QOG_IMAGE])
|
|
231
|
+
);
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
export const KindImageFailsToLoad: Story = {
|
|
236
|
+
parameters: {
|
|
237
|
+
docs: {
|
|
238
|
+
description: {
|
|
239
|
+
story:
|
|
240
|
+
'Renders a Qog reference whose kind image cannot load: the chip keeps the font icon instead of drawing a broken picture.',
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
args: {
|
|
245
|
+
references: [QOG_REFERENCES[0]],
|
|
246
|
+
resolveInterfaceIcon: storyBrokenKindMark,
|
|
247
|
+
preferKindImage: true,
|
|
248
|
+
},
|
|
249
|
+
async play({ canvasElement }) {
|
|
250
|
+
const canvas = within(canvasElement);
|
|
251
|
+
await expect(canvas.getByText('order-flow')).toBeInTheDocument();
|
|
252
|
+
// Once the load has failed, no picture is drawn.
|
|
253
|
+
await whenImageSettled(STORY_BROKEN_IMAGE);
|
|
254
|
+
await expect(chipImages(canvasElement)).toEqual([]);
|
|
255
|
+
await expect(canvasElement.querySelector('.reqore-tag .reqore-icon')).toBeTruthy();
|
|
256
|
+
},
|
|
257
|
+
};
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { ReqoreControlGroup, ReqoreP, ReqoreTag } from '@qoretechnologies/reqore';
|
|
2
2
|
import { TSizes } from '@qoretechnologies/reqore/dist/constants/sizes';
|
|
3
|
-
import { IReqoreIconName } from '@qoretechnologies/reqore/dist/types/icons';
|
|
4
3
|
import { ComponentProps } from 'react';
|
|
5
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
defaultInterfaceIcon,
|
|
6
|
+
IInterfaceReference,
|
|
7
|
+
toInterfaceKindMark,
|
|
8
|
+
TResolveInterfaceIcon,
|
|
9
|
+
} from './meta';
|
|
10
|
+
import { useLoadedImages } from './useLoadedImages';
|
|
6
11
|
|
|
7
12
|
export interface IInterfaceReferenceTagsProps {
|
|
8
13
|
references?: IInterfaceReference[];
|
|
9
14
|
/** Optional leading label, e.g. "References" in a ticket header. Omitted (the
|
|
10
15
|
* default) inside the thread, where the chips sit under a message. */
|
|
11
16
|
label?: string;
|
|
12
|
-
/** Resolve a per-kind icon
|
|
17
|
+
/** Resolve a per-kind icon: a font icon, or a mark `{ icon, image }` whose image
|
|
18
|
+
* (e.g. the Qog logo) is drawn once it loads, with the font icon before that and
|
|
19
|
+
* instead of it if it fails. Consumers with their own icon vocabulary (the IDE)
|
|
13
20
|
* pass one; when omitted the built-in per-kind default is used. */
|
|
14
|
-
resolveInterfaceIcon?:
|
|
21
|
+
resolveInterfaceIcon?: TResolveInterfaceIcon;
|
|
15
22
|
/** Open a referenced interface; chips are static (informational) when omitted —
|
|
16
23
|
* e.g. the staff view, which can't reach the customer's instance. */
|
|
17
24
|
onInterfaceClick?: (reference: IInterfaceReference) => void;
|
|
@@ -32,6 +39,21 @@ export interface IInterfaceReferenceTagsProps {
|
|
|
32
39
|
* qogs list does. When this (or the reference's own `logo`) returns a value, the
|
|
33
40
|
* chip shows the image in place of the kind icon. */
|
|
34
41
|
resolveInterfaceImage?: (reference: IInterfaceReference) => string | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Which image a chip draws when there is more than one:
|
|
44
|
+
*
|
|
45
|
+
* - `false` (the default): the reference's own image first — its stored `logo`,
|
|
46
|
+
* then `resolveInterfaceImage` — and the kind's image (from
|
|
47
|
+
* `resolveInterfaceIcon`) only when it has neither. This is how chips have
|
|
48
|
+
* always looked: an app-triggered Qog shows its trigger app's logo.
|
|
49
|
+
* - `true`: the kind's image first, then the reference's own. For a host that
|
|
50
|
+
* draws every interface of a kind with one mark (the IDE's green Qog), so a
|
|
51
|
+
* Qog reads as a Qog whatever app triggers it.
|
|
52
|
+
*
|
|
53
|
+
* Either way the kind's font icon shows until the chosen image loads, and
|
|
54
|
+
* instead of it if it cannot.
|
|
55
|
+
*/
|
|
56
|
+
preferKindImage?: boolean;
|
|
35
57
|
}
|
|
36
58
|
|
|
37
59
|
/**
|
|
@@ -51,11 +73,22 @@ export const InterfaceReferenceTags = ({
|
|
|
51
73
|
intent,
|
|
52
74
|
customTheme,
|
|
53
75
|
resolveInterfaceImage,
|
|
76
|
+
preferKindImage = false,
|
|
54
77
|
}: IInterfaceReferenceTagsProps) => {
|
|
78
|
+
const resolveIcon = resolveInterfaceIcon ?? defaultInterfaceIcon;
|
|
79
|
+
const marks = (references ?? []).map((reference) => {
|
|
80
|
+
const mark = toInterfaceKindMark(resolveIcon(reference.interface_kind));
|
|
81
|
+
const own = reference.logo ?? resolveInterfaceImage?.(reference);
|
|
82
|
+
return {
|
|
83
|
+
reference,
|
|
84
|
+
icon: mark.icon,
|
|
85
|
+
image: preferKindImage ? (mark.image ?? own) : (own ?? mark.image),
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
const isLoaded = useLoadedImages(marks.map(({ image }) => image));
|
|
55
89
|
if (!references?.length) {
|
|
56
90
|
return null;
|
|
57
91
|
}
|
|
58
|
-
const resolveIcon = resolveInterfaceIcon ?? defaultInterfaceIcon;
|
|
59
92
|
return (
|
|
60
93
|
<ReqoreControlGroup verticalAlign='center' wrap gapSize='small'>
|
|
61
94
|
{label ? (
|
|
@@ -63,18 +96,18 @@ export const InterfaceReferenceTags = ({
|
|
|
63
96
|
{label}
|
|
64
97
|
</ReqoreP>
|
|
65
98
|
) : null}
|
|
66
|
-
{
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
const
|
|
99
|
+
{marks.map(({ reference, icon, image }) => {
|
|
100
|
+
// The chosen image (see `preferKindImage`) once it has loaded; the kind's
|
|
101
|
+
// font icon until then, and if it fails.
|
|
102
|
+
const loaded = isLoaded(image);
|
|
70
103
|
return (
|
|
71
104
|
<ReqoreTag
|
|
72
105
|
key={
|
|
73
106
|
reference.reference_id ??
|
|
74
107
|
`${reference.interface_kind}:${reference.interface_name}`
|
|
75
108
|
}
|
|
76
|
-
icon={
|
|
77
|
-
leftIconProps={
|
|
109
|
+
icon={icon}
|
|
110
|
+
leftIconProps={loaded ? { image } : undefined}
|
|
78
111
|
labelKey={reference.interface_kind}
|
|
79
112
|
label={reference.interface_name}
|
|
80
113
|
size={size}
|