@qoretechnologies/reqraft 0.10.57 → 0.10.58-pr.129.g43f4175
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 +35 -8
- 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/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/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/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/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/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 +38 -8
- package/src/components/form/engine/OptionFieldMessages.tsx +7 -5
- package/src/components/form/engine/variants/VariantCalmTable.tsx +3 -1
- package/src/components/form/engine/variants/variantModel.ts +10 -3
- 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/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/supportTicket/InterfaceReferenceTags.stories.tsx +86 -0
- package/src/components/supportTicket/InterfaceReferenceTags.tsx +44 -11
- package/src/components/supportTicket/ReferencePicker.stories.tsx +51 -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
|
@@ -2324,10 +2324,13 @@ const FormEngineImpl = ({
|
|
|
2324
2324
|
|
|
2325
2325
|
const isOptionValid = useCallback(
|
|
2326
2326
|
(optionName: string, type: TQorusType, optionValue: any) => {
|
|
2327
|
+
const isEmpty = optionValue === undefined || optionValue === '';
|
|
2328
|
+
// An empty field locked by an unmet `depends_on` does not apply, so it is
|
|
2329
|
+
// not missing — the same rule the read-first status follows.
|
|
2327
2330
|
if (
|
|
2328
|
-
|
|
2329
|
-
!options?.[optionName]?.required_groups
|
|
2330
|
-
|
|
2331
|
+
isEmpty &&
|
|
2332
|
+
((!options?.[optionName]?.required && !options?.[optionName]?.required_groups) ||
|
|
2333
|
+
dependencyLockedNames.includes(optionName))
|
|
2331
2334
|
) {
|
|
2332
2335
|
return true;
|
|
2333
2336
|
}
|
|
@@ -2342,7 +2345,12 @@ const FormEngineImpl = ({
|
|
|
2342
2345
|
isFunction: (availableOptions?.[optionName] as { is_expression?: boolean })?.is_expression,
|
|
2343
2346
|
} as any);
|
|
2344
2347
|
},
|
|
2345
|
-
[
|
|
2348
|
+
[
|
|
2349
|
+
JSON.stringify(options),
|
|
2350
|
+
JSON.stringify(availableOptions),
|
|
2351
|
+
JSON.stringify(localValue.fields),
|
|
2352
|
+
dependencyLockedNames,
|
|
2353
|
+
]
|
|
2346
2354
|
);
|
|
2347
2355
|
|
|
2348
2356
|
const getValidityData = useCallback((): IFormValidityData => {
|
|
@@ -2366,7 +2374,17 @@ const FormEngineImpl = ({
|
|
|
2366
2374
|
|
|
2367
2375
|
let validation: IValidationResult;
|
|
2368
2376
|
|
|
2369
|
-
|
|
2377
|
+
/* A required field locked by an unmet `depends_on` does not apply
|
|
2378
|
+
while it is locked: it is not in "Needs attention", and an empty one
|
|
2379
|
+
does not make the form incomplete either. Otherwise a check whose
|
|
2380
|
+
kind is Equals could never be saved, because Minimum — required for
|
|
2381
|
+
Between alone — is empty; and a form whose only gaps are fields
|
|
2382
|
+
nobody can fill in would say Incomplete with nothing to act on. A
|
|
2383
|
+
locked field that still HOLDS a value is validated as before. */
|
|
2384
|
+
if (
|
|
2385
|
+
isEmpty &&
|
|
2386
|
+
((!isRequired && !hasRequiredGroups) || dependencyLockedNames.includes(optionName))
|
|
2387
|
+
) {
|
|
2370
2388
|
validation = { isValid: true, reasons: [] };
|
|
2371
2389
|
} else {
|
|
2372
2390
|
validation = validateFieldWithResult(getType(type), optionValue, {
|
|
@@ -2404,6 +2422,7 @@ const FormEngineImpl = ({
|
|
|
2404
2422
|
JSON.stringify(options),
|
|
2405
2423
|
JSON.stringify(operators),
|
|
2406
2424
|
JSON.stringify(localValue.fields),
|
|
2425
|
+
dependencyLockedNames,
|
|
2407
2426
|
]);
|
|
2408
2427
|
|
|
2409
2428
|
const validityData = useMemo(() => getValidityData(), [getValidityData]);
|
|
@@ -2487,7 +2506,14 @@ const FormEngineImpl = ({
|
|
|
2487
2506
|
// sub-form it summarises said 0/3 set.
|
|
2488
2507
|
const empty = isOptionValueEmpty(value) || isUnsetSchemaHash(value, schema);
|
|
2489
2508
|
const reqGroups = (schema?.required_groups as string[] | undefined) || [];
|
|
2490
|
-
|
|
2509
|
+
// Required only while it applies. A field locked by an unmet `depends_on`
|
|
2510
|
+
// renders disabled with what unlocks it; nothing the author can do to it
|
|
2511
|
+
// is a next step — the next step is the field it depends on, which carries
|
|
2512
|
+
// its own status. Counting it here put a guided check's locked Path /
|
|
2513
|
+
// Minimum / Maximum under "Needs attention" before its kind was even
|
|
2514
|
+
// picked. The moment the dependency holds, it is required again.
|
|
2515
|
+
const required =
|
|
2516
|
+
!dependencyLockedNames.includes(name) && !!(schema?.required || reqGroups.length);
|
|
2491
2517
|
const covered =
|
|
2492
2518
|
empty &&
|
|
2493
2519
|
reqGroups.some((g) => {
|
|
@@ -2512,11 +2538,15 @@ const FormEngineImpl = ({
|
|
|
2512
2538
|
isOptionValid,
|
|
2513
2539
|
requiredGroupsInfo,
|
|
2514
2540
|
schemaMsgIntent,
|
|
2541
|
+
dependencyLockedNames,
|
|
2515
2542
|
]
|
|
2516
2543
|
);
|
|
2517
2544
|
const getOptionBucket = useCallback(
|
|
2518
2545
|
(name: string, hidden = false): 'attention' | 'set' | 'optional' => {
|
|
2519
|
-
|
|
2546
|
+
// A locked one-of member cannot be the one that satisfies its group, so it
|
|
2547
|
+
// does not travel with the group into "Needs attention"; its own status
|
|
2548
|
+
// (not required while locked) decides its box.
|
|
2549
|
+
if (!hidden && !dependencyLockedNames.includes(name)) {
|
|
2520
2550
|
const reqGroups = (options?.[name]?.required_groups as string[] | undefined) || [];
|
|
2521
2551
|
if (reqGroups.length) {
|
|
2522
2552
|
return reqGroups.some((g) => !requiredGroupsInfo.satisfiedBy[g]) ? 'attention' : 'set';
|
|
@@ -2524,7 +2554,7 @@ const FormEngineImpl = ({
|
|
|
2524
2554
|
}
|
|
2525
2555
|
return getReadFirstBucket(getOptionStatus(name, hidden));
|
|
2526
2556
|
},
|
|
2527
|
-
[JSON.stringify(options), requiredGroupsInfo, getOptionStatus]
|
|
2557
|
+
[JSON.stringify(options), requiredGroupsInfo, getOptionStatus, dependencyLockedNames]
|
|
2528
2558
|
);
|
|
2529
2559
|
// How many fields are in the "Needs attention" box — drives the header link.
|
|
2530
2560
|
const readFirstAttentionCount = useMemo(
|
|
@@ -358,6 +358,9 @@ export const getOptionFieldMessages = ({
|
|
|
358
358
|
}: IOptionFieldMessagesProps): IReqoreTagProps[] => {
|
|
359
359
|
const optionSchema = schema[name];
|
|
360
360
|
const result: IReqoreTagProps[] = [];
|
|
361
|
+
const locked =
|
|
362
|
+
!!optionSchema?.depends_on &&
|
|
363
|
+
!hasAllDependenciesFullfilled(optionSchema.depends_on, allOptions, schema);
|
|
361
364
|
|
|
362
365
|
if (option.value || option.value === false || option.value === 0 || option.value === null) {
|
|
363
366
|
/* A concrete type the VALUE carries beats an untyped schema.
|
|
@@ -392,7 +395,9 @@ export const getOptionFieldMessages = ({
|
|
|
392
395
|
});
|
|
393
396
|
}
|
|
394
397
|
} else {
|
|
395
|
-
|
|
398
|
+
// A locked field is not asked for: the dependency message below says what
|
|
399
|
+
// unlocks it, and until then there is nothing to fill in.
|
|
400
|
+
if (optionSchema?.required && !untouched && !locked) {
|
|
396
401
|
result.push({ label: 'This field is required', intent: 'danger' });
|
|
397
402
|
}
|
|
398
403
|
|
|
@@ -413,10 +418,7 @@ export const getOptionFieldMessages = ({
|
|
|
413
418
|
}
|
|
414
419
|
}
|
|
415
420
|
|
|
416
|
-
if (
|
|
417
|
-
optionSchema?.depends_on &&
|
|
418
|
-
!hasAllDependenciesFullfilled(optionSchema.depends_on, allOptions, schema)
|
|
419
|
-
) {
|
|
421
|
+
if (locked) {
|
|
420
422
|
const dependsOn = describeDependencies(optionSchema.depends_on, schema);
|
|
421
423
|
|
|
422
424
|
result.push({
|
|
@@ -112,7 +112,9 @@ const Meter = ({ form }: { form: TVariantForm }) => {
|
|
|
112
112
|
color: (s.attention ? c.warning : c.success) as never,
|
|
113
113
|
}}
|
|
114
114
|
>
|
|
115
|
-
{
|
|
115
|
+
{/* What the form lacks, not whether it has unsaved changes:
|
|
116
|
+
"Draft" is the batched mode's per-row chip alone. */}
|
|
117
|
+
{s.attention ? 'Incomplete' : 'Ready'}
|
|
116
118
|
</ReqoreP>
|
|
117
119
|
<span>
|
|
118
120
|
{s.set} of {s.total} set
|
|
@@ -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,
|
|
@@ -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,
|
|
@@ -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
|
},
|
|
@@ -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
|
+
};
|