@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.
Files changed (86) hide show
  1. package/design/FORM_ENGINE_COMPACT.md +23 -5
  2. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  3. package/dist/components/form/engine/CompactToolbar.js +2 -2
  4. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  5. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  6. package/dist/components/form/engine/FormEngine.js +35 -8
  7. package/dist/components/form/engine/FormEngine.js.map +1 -1
  8. package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
  9. package/dist/components/form/engine/OptionFieldMessages.js +6 -3
  10. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  11. package/dist/components/form/engine/variants/VariantCalmTable.d.ts.map +1 -1
  12. package/dist/components/form/engine/variants/VariantCalmTable.js +1 -1
  13. package/dist/components/form/engine/variants/VariantCalmTable.js.map +1 -1
  14. package/dist/components/form/engine/variants/variantModel.d.ts.map +1 -1
  15. package/dist/components/form/engine/variants/variantModel.js +8 -3
  16. package/dist/components/form/engine/variants/variantModel.js.map +1 -1
  17. package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
  18. package/dist/components/form/fields/array/ArrayAuto.js +8 -0
  19. package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
  20. package/dist/components/form/fields/array/ArrayAutoField.d.ts.map +1 -1
  21. package/dist/components/form/fields/array/ArrayAutoField.js +8 -0
  22. package/dist/components/form/fields/array/ArrayAutoField.js.map +1 -1
  23. package/dist/components/form/fields/binary/Binary.d.ts.map +1 -1
  24. package/dist/components/form/fields/binary/Binary.js +10 -0
  25. package/dist/components/form/fields/binary/Binary.js.map +1 -1
  26. package/dist/components/form/fields/emittedValues.d.ts +38 -0
  27. package/dist/components/form/fields/emittedValues.d.ts.map +1 -0
  28. package/dist/components/form/fields/emittedValues.js +67 -0
  29. package/dist/components/form/fields/emittedValues.js.map +1 -0
  30. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  31. package/dist/components/form/fields/long-string/LongString.js +24 -8
  32. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  33. package/dist/components/form/fields/number/Number.d.ts.map +1 -1
  34. package/dist/components/form/fields/number/Number.js +9 -0
  35. package/dist/components/form/fields/number/Number.js.map +1 -1
  36. package/dist/components/form/fields/rich-text/RichText.d.ts.map +1 -1
  37. package/dist/components/form/fields/rich-text/RichText.js +10 -0
  38. package/dist/components/form/fields/rich-text/RichText.js.map +1 -1
  39. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts +21 -5
  40. package/dist/components/supportTicket/InterfaceReferenceTags.d.ts.map +1 -1
  41. package/dist/components/supportTicket/InterfaceReferenceTags.js +24 -8
  42. package/dist/components/supportTicket/InterfaceReferenceTags.js.map +1 -1
  43. package/dist/components/supportTicket/ReferencePicker.d.ts +5 -4
  44. package/dist/components/supportTicket/ReferencePicker.d.ts.map +1 -1
  45. package/dist/components/supportTicket/ReferencePicker.js +27 -2
  46. package/dist/components/supportTicket/ReferencePicker.js.map +1 -1
  47. package/dist/components/supportTicket/meta.d.ts +16 -0
  48. package/dist/components/supportTicket/meta.d.ts.map +1 -1
  49. package/dist/components/supportTicket/meta.js +6 -1
  50. package/dist/components/supportTicket/meta.js.map +1 -1
  51. package/dist/components/supportTicket/useLoadedImages.d.ts +18 -0
  52. package/dist/components/supportTicket/useLoadedImages.d.ts.map +1 -0
  53. package/dist/components/supportTicket/useLoadedImages.js +72 -0
  54. package/dist/components/supportTicket/useLoadedImages.js.map +1 -0
  55. package/dist/components/ticketReferences/TicketReferences.d.ts +9 -5
  56. package/dist/components/ticketReferences/TicketReferences.d.ts.map +1 -1
  57. package/dist/components/ticketReferences/TicketReferences.js +22 -9
  58. package/dist/components/ticketReferences/TicketReferences.js.map +1 -1
  59. package/dist/components/ticketThread/TicketThread.d.ts +10 -6
  60. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
  61. package/dist/components/ticketThread/TicketThread.js +2 -2
  62. package/dist/components/ticketThread/TicketThread.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/components/form/engine/CompactToolbar.tsx +8 -3
  65. package/src/components/form/engine/FormEngine.stories.tsx +206 -13
  66. package/src/components/form/engine/FormEngine.tsx +38 -8
  67. package/src/components/form/engine/OptionFieldMessages.tsx +7 -5
  68. package/src/components/form/engine/variants/VariantCalmTable.tsx +3 -1
  69. package/src/components/form/engine/variants/variantModel.ts +10 -3
  70. package/src/components/form/fields/array/ArrayAuto.tsx +10 -1
  71. package/src/components/form/fields/array/ArrayAutoField.tsx +10 -1
  72. package/src/components/form/fields/binary/Binary.tsx +10 -0
  73. package/src/components/form/fields/emittedValues.ts +62 -0
  74. package/src/components/form/fields/long-string/LongString.tsx +28 -8
  75. package/src/components/form/fields/number/Number.tsx +9 -0
  76. package/src/components/form/fields/rich-text/RichText.tsx +10 -0
  77. package/src/components/supportTicket/InterfaceReferenceTags.stories.tsx +86 -0
  78. package/src/components/supportTicket/InterfaceReferenceTags.tsx +44 -11
  79. package/src/components/supportTicket/ReferencePicker.stories.tsx +51 -0
  80. package/src/components/supportTicket/ReferencePicker.tsx +26 -5
  81. package/src/components/supportTicket/TicketReplyBox.stories.tsx +5 -2
  82. package/src/components/supportTicket/__fixtures__/kindMarks.ts +28 -0
  83. package/src/components/supportTicket/meta.ts +21 -0
  84. package/src/components/supportTicket/useLoadedImages.ts +73 -0
  85. package/src/components/ticketReferences/TicketReferences.tsx +27 -7
  86. 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
- !options?.[optionName]?.required &&
2329
- !options?.[optionName]?.required_groups &&
2330
- (optionValue === undefined || optionValue === '')
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
- [JSON.stringify(options), JSON.stringify(availableOptions), JSON.stringify(localValue.fields)]
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
- if (!isRequired && !hasRequiredGroups && isEmpty) {
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
- const required = !!(schema?.required || reqGroups.length);
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
- if (!hidden) {
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
- if (optionSchema?.required && !untouched) {
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
- {s.attention ? 'Draft' : 'Ready'}
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
- const inUnmetGroup = ownGroups.some((k) => reqGroups[k] && !reqGroups[k].satisfied);
157
- const required = !!schema?.required;
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
- required: required || ownGroups.length > 0,
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
- if ((value ?? '') !== localValue) {
44
- setLocalValue(value ?? '');
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 = value ?? '';
66
+ lastEmittedRef.current = incoming;
48
67
  }, [value]);
49
68
 
50
69
  useDebounce(
51
70
  () => {
52
71
  if (localValue !== lastEmittedRef.current) {
53
- lastEmittedRef.current = localValue;
54
- onChange?.(localValue);
72
+ emit(localValue);
55
73
  }
56
74
  },
57
75
  100,
58
- [localValue, onChange]
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
- onChange?.('');
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
- }, [onChange, onClearClick]);
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
+ };