@qoretechnologies/reqraft 0.10.60-pr.133.gc8250dd → 0.10.60

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 (62) hide show
  1. package/design/FORM_ENGINE_FIELD_MIGRATION.md +10 -4
  2. package/dist/components/dpqlEditor/DpqlEditor.d.ts.map +1 -1
  3. package/dist/components/dpqlEditor/DpqlEditor.js +2 -1
  4. package/dist/components/dpqlEditor/DpqlEditor.js.map +1 -1
  5. package/dist/components/dpqlEditor/dpqlHelpers.d.ts.map +1 -1
  6. package/dist/components/dpqlEditor/dpqlHelpers.js +8 -1
  7. package/dist/components/dpqlEditor/dpqlHelpers.js.map +1 -1
  8. package/dist/components/dpqlEditor/index.d.ts +1 -1
  9. package/dist/components/dpqlEditor/index.d.ts.map +1 -1
  10. package/dist/components/dpqlEditor/index.js.map +1 -1
  11. package/dist/components/dpqlEditor/types.d.ts +12 -0
  12. package/dist/components/dpqlEditor/types.d.ts.map +1 -1
  13. package/dist/components/dpqlEditor/useDpqlSession.d.ts +3 -1
  14. package/dist/components/dpqlEditor/useDpqlSession.d.ts.map +1 -1
  15. package/dist/components/dpqlEditor/useDpqlSession.js +9 -9
  16. package/dist/components/dpqlEditor/useDpqlSession.js.map +1 -1
  17. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  18. package/dist/components/form/engine/FormEngine.js +16 -2
  19. package/dist/components/form/engine/FormEngine.js.map +1 -1
  20. package/dist/components/form/expressions/ExpressionField.d.ts +7 -1
  21. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  22. package/dist/components/form/expressions/ExpressionField.js +2 -2
  23. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  24. package/dist/components/form/expressions/builder/argumentWrapper.d.ts.map +1 -1
  25. package/dist/components/form/expressions/builder/argumentWrapper.js +23 -1
  26. package/dist/components/form/expressions/builder/argumentWrapper.js.map +1 -1
  27. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  28. package/dist/components/form/expressions/builder/index.js +18 -4
  29. package/dist/components/form/expressions/builder/index.js.map +1 -1
  30. package/dist/components/form/fields/auto/AutoFormField.d.ts +30 -1
  31. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  32. package/dist/components/form/fields/auto/AutoFormField.js +52 -27
  33. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  34. package/dist/components/form/fields/number/Number.d.ts +7 -0
  35. package/dist/components/form/fields/number/Number.d.ts.map +1 -1
  36. package/dist/components/form/fields/number/Number.js +36 -17
  37. package/dist/components/form/fields/number/Number.js.map +1 -1
  38. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  39. package/dist/components/form/fields/template/TemplateField.js +3 -1
  40. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  41. package/dist/providers/StorageProvider.d.ts.map +1 -1
  42. package/dist/providers/StorageProvider.js +8 -1
  43. package/dist/providers/StorageProvider.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/components/dpqlEditor/DpqlEditor.tsx +2 -0
  46. package/src/components/dpqlEditor/dpqlHelpers.ts +9 -2
  47. package/src/components/dpqlEditor/index.ts +1 -0
  48. package/src/components/dpqlEditor/types.ts +10 -0
  49. package/src/components/dpqlEditor/useDpqlSession.ts +11 -2
  50. package/src/components/form/engine/FormEngine.stories.tsx +16 -1
  51. package/src/components/form/engine/FormEngine.tsx +15 -2
  52. package/src/components/form/expressions/ExpressionField.tsx +8 -0
  53. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +172 -0
  54. package/src/components/form/expressions/builder/argumentWrapper.tsx +26 -3
  55. package/src/components/form/expressions/builder/index.tsx +17 -4
  56. package/src/components/form/expressions/dpqlMockLanguage.ts +3 -2
  57. package/src/components/form/fields/auto/AutoFormField.stories.tsx +154 -12
  58. package/src/components/form/fields/auto/AutoFormField.tsx +57 -26
  59. package/src/components/form/fields/number/Number.stories.tsx +36 -9
  60. package/src/components/form/fields/number/Number.tsx +39 -32
  61. package/src/components/form/fields/template/TemplateField.tsx +2 -0
  62. package/src/providers/StorageProvider.tsx +8 -1
@@ -52,6 +52,94 @@ const waitForText = async (text: string | RegExp) => {
52
52
  await waitFor(() => expect(body.queryAllByText(text)[0]).toBeTruthy(), { timeout: 10000 });
53
53
  };
54
54
 
55
+ /** The readable names of the types an untyped field's picker offers. */
56
+ const IMMEDIATE_TYPE_NAMES = [
57
+ 'True/False',
58
+ 'Date',
59
+ 'Text',
60
+ 'Binary',
61
+ 'Decimal',
62
+ 'List',
63
+ 'Key/Value {}',
64
+ 'Integer',
65
+ 'RGB Color',
66
+ ];
67
+
68
+ /**
69
+ * Opens the type picker and returns the open list. Asserts the closed trigger
70
+ * reads `label` (the chosen type's readable name) when one is given, and that
71
+ * it carries no item count: a count on a fixed list of types is noise.
72
+ */
73
+ const openTypePicker = async (canvasElement: HTMLElement, label?: string) => {
74
+ const trigger = await waitFor(
75
+ () => {
76
+ const button = canvasElement.querySelector(
77
+ '.auto-field-group [aria-haspopup]'
78
+ ) as HTMLElement | null;
79
+ expect(button).toBeTruthy();
80
+ return button!;
81
+ },
82
+ { timeout: 10000 }
83
+ );
84
+ if (label) {
85
+ await waitFor(() => expect(trigger.textContent).toContain(label), { timeout: 10000 });
86
+ }
87
+ await expect(trigger.querySelector('.reqore-button-badge')).toBeNull();
88
+ await userEvent.click(trigger);
89
+ return waitFor(
90
+ () => {
91
+ const el = document.querySelector('.reqore-popover-content') as HTMLElement | null;
92
+ expect(el).toBeTruthy();
93
+ return el!;
94
+ },
95
+ { timeout: 10000 }
96
+ );
97
+ };
98
+
99
+ /**
100
+ * Closes the open type picker the way an author does — a click outside the
101
+ * list (reqore's popover closes on an outside click; Escape could discard an
102
+ * edit) — and waits until the list is gone.
103
+ */
104
+ const closeTypePicker = async (popover: HTMLElement) => {
105
+ await userEvent.click(document.body);
106
+ await waitFor(
107
+ () => {
108
+ expect(popover.isConnected).toBe(false);
109
+ expect(document.querySelector('.reqore-popover-content')).toBeNull();
110
+ },
111
+ { timeout: 10000 }
112
+ );
113
+ };
114
+
115
+ /**
116
+ * Nothing (an open list) covers the control: it is on top at its centre and
117
+ * across its width — a full-width editor's centre is clear of a narrow list
118
+ * anchored at its left, so the centre alone would not show it.
119
+ */
120
+ const expectUncovered = async (control: HTMLElement) => {
121
+ const box = control.getBoundingClientRect();
122
+ for (const fraction of [0.1, 0.3, 0.5, 0.7, 0.9]) {
123
+ const hit = document.elementFromPoint(
124
+ box.left + box.width * fraction,
125
+ box.top + box.height / 2
126
+ );
127
+ await expect(hit !== null && (hit === control || control.contains(hit))).toBe(true);
128
+ }
129
+ };
130
+
131
+ const expectOffered = async (popover: HTMLElement, labels: string[]) => {
132
+ await waitFor(
133
+ () => {
134
+ for (const label of labels) {
135
+ expect(within(popover).getAllByText(label, { exact: true }).length).toBeGreaterThan(0);
136
+ }
137
+ },
138
+ { timeout: 10000 }
139
+ );
140
+ await expect(within(popover).queryByText(/^soft/)).not.toBeInTheDocument();
141
+ };
142
+
55
143
  // --- reqraft behavioral stories (kept from the field-migration batch) -------
56
144
 
57
145
  /** Untyped, no value — the field opens on an editor to type into, never on a type question. */
@@ -193,21 +281,21 @@ export const AllowedTypesSubset: Story = {
193
281
  docs: {
194
282
  description: {
195
283
  story:
196
- 'Renders AutoFormField with allowedTypes restricted to Integer and Text. The type picker offers those 2 types (its badge reads 2), and the field can be typed into meanwhile without being asked for a data type.',
284
+ 'Renders AutoFormField with allowedTypes restricted to Integer and Text. The type picker offers exactly those 2 types, with no item count, and the field can be typed into meanwhile without being asked for a data type.',
197
285
  },
198
286
  },
199
287
  },
200
288
  async play({ canvasElement }) {
201
- // The picker badge reflects the restricted list — 2 instead of 14.
202
- await waitForText('2');
203
289
  await expect(within(canvasElement).queryByText('Please select data type')).not.toBeInTheDocument();
290
+ const popover = await openTypePicker(canvasElement);
291
+ await expectOffered(popover, ['Integer', 'Text']);
292
+ await expect(within(popover).queryByText('Decimal')).not.toBeInTheDocument();
204
293
  },
205
294
  };
206
295
 
207
- /** `noSoft` hides the `soft*` variants from the picker (9 instead of 14). */
296
+ /** The picker offers no `soft*` type: a typed-in literal has nothing to convert. */
208
297
  export const NoSoftTypes: Story = {
209
298
  args: {
210
- noSoft: true,
211
299
  /* The picker belongs to a value with a type: an untyped field that is
212
300
  empty is typed into, and one holding text has resolved to `string`. */
213
301
  defaultType: 'auto',
@@ -217,13 +305,14 @@ export const NoSoftTypes: Story = {
217
305
  docs: {
218
306
  description: {
219
307
  story:
220
- 'Renders an untyped AutoFormField holding a text value with noSoft enabled. The picker for the type the value resolved to leaves out the soft* variants, so its badge reads 9 instead of 14.',
308
+ 'Renders an untyped AutoFormField holding a text value. The type picker, chosen as Text, offers the 9 base types by readable name (True/False, Date, Text, Binary, Decimal, List, Key/Value {}, Integer, RGB Color) and no soft type, and shows no item count.',
221
309
  },
222
310
  },
223
311
  },
224
312
  async play({ canvasElement }) {
225
- await waitForText('9');
226
313
  await expect(within(canvasElement).queryByText('Please select data type')).not.toBeInTheDocument();
314
+ const popover = await openTypePicker(canvasElement, 'Text');
315
+ await expectOffered(popover, IMMEDIATE_TYPE_NAMES);
227
316
  },
228
317
  };
229
318
 
@@ -486,7 +575,7 @@ export const Timeout: Story = {
486
575
 
487
576
  const input = await canvas.findByDisplayValue('45');
488
577
  await expect(input).toBeInTheDocument();
489
- await expect(input).toHaveAttribute('type', 'number');
578
+ await expect(input).toHaveAttribute('inputmode', 'numeric');
490
579
  await expect(canvas.getByText('seconds')).toBeInTheDocument();
491
580
  await expect(canvas.queryByText('Unknown type!')).not.toBeInTheDocument();
492
581
  },
@@ -501,13 +590,66 @@ export const AutoDoesNotChangeTypeBackToAuto: Story = {
501
590
  docs: {
502
591
  description: {
503
592
  story:
504
- 'Renders AutoFormField in auto mode with a string value — the inferred type ("string") is shown and the picker still exposes all 14 types so the operator can switch away from auto without the field snapping back.',
593
+ 'Renders AutoFormField in auto mode with a string value — the inferred type is shown as Text and the picker still offers every base type so the operator can switch away from auto without the field snapping back.',
505
594
  },
506
595
  },
507
596
  },
508
- play: async () => {
509
- await waitForText('string');
510
- await waitForText('14');
597
+ async play({ canvasElement }) {
598
+ const popover = await openTypePicker(canvasElement, 'Text');
599
+ await expectOffered(popover, IMMEDIATE_TYPE_NAMES);
600
+ // End on what the story is about: the text value, still in its editor.
601
+ await closeTypePicker(popover);
602
+ const editor = await within(canvasElement).findByDisplayValue('test');
603
+ await expectUncovered(editor);
604
+ },
605
+ };
606
+
607
+ /** A soft type in a caller's allowed types is offered once, as its base type. */
608
+ export const AllowedSoftTypesOfferedAsTheirBase: Story = {
609
+ args: {
610
+ allowedTypes: [{ name: 'softint' }, { name: 'int' }, { name: 'softstring' }],
611
+ },
612
+ parameters: {
613
+ docs: {
614
+ description: {
615
+ story:
616
+ 'Renders AutoFormField whose allowedTypes are softint, int and softstring. The type picker offers Integer once and Text, with no soft type and no item count.',
617
+ },
618
+ },
619
+ },
620
+ async play({ canvasElement }) {
621
+ const popover = await openTypePicker(canvasElement);
622
+ await expectOffered(popover, ['Integer', 'Text']);
623
+ await expect(within(popover).getAllByText('Integer', { exact: true })).toHaveLength(1);
624
+ },
625
+ };
626
+
627
+ /** A value saved with a soft type is edited with its base type's editor. */
628
+ export const SavedSoftTypeEditsAsItsBase: Story = {
629
+ args: {
630
+ defaultType: 'auto',
631
+ defaultInternalType: 'softint',
632
+ value: 5,
633
+ },
634
+ parameters: {
635
+ docs: {
636
+ description: {
637
+ story:
638
+ 'Renders an untyped AutoFormField holding 5, saved with the soft type softint. It is edited with the integer editor and the type picker reads Integer, instead of drawing "Unknown type!".',
639
+ },
640
+ },
641
+ },
642
+ async play({ canvasElement }) {
643
+ const canvas = within(canvasElement);
644
+ await expect(await canvas.findByDisplayValue('5')).toBeInTheDocument();
645
+ await expect(canvas.queryByText('Unknown type!')).not.toBeInTheDocument();
646
+ const popover = await openTypePicker(canvasElement, 'Integer');
647
+ await expectOffered(popover, IMMEDIATE_TYPE_NAMES);
648
+ // End on what the story is about: the integer editor, holding 5.
649
+ await closeTypePicker(popover);
650
+ const input = await canvas.findByDisplayValue('5');
651
+ await expect(input.tagName).toBe('INPUT');
652
+ await expectUncovered(input);
511
653
  },
512
654
  };
513
655
 
@@ -12,7 +12,7 @@ import {
12
12
  import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/Textarea';
13
13
  import { IWithReqoreSize } from '@qoretechnologies/reqore/dist/types/global';
14
14
  import { TQorusFormFieldSchema, TQorusType } from '@qoretechnologies/ts-toolkit';
15
- import { isEqual, size } from 'lodash';
15
+ import { isEqual, size, uniqBy } from 'lodash';
16
16
  import React, { memo, useCallback, useEffect, useState } from 'react';
17
17
  import { useUpdateEffect } from 'react-use';
18
18
  import useMount from 'react-use/lib/useMount';
@@ -97,6 +97,10 @@ export interface IAutoFieldProps
97
97
  level?: number;
98
98
  defaultType?: IQorusType;
99
99
  defaultInternalType?: IQorusType;
100
+ /**
101
+ * @deprecated Inert: the type picker never offers a soft type (see
102
+ * `getImmediateValueTypes`). Kept so existing callers still compile.
103
+ */
100
104
  noSoft?: boolean;
101
105
 
102
106
  /** SEAM: inert in reqraft — saved-values storage is IDE-only. */
@@ -134,7 +138,17 @@ export interface IAutoFieldProps
134
138
  [key: string]: any;
135
139
  }
136
140
 
137
- export const DefaultNoSoftTypes = [
141
+ /**
142
+ * The types the picker offers for a value typed into this field.
143
+ *
144
+ * No Qore soft type (`softint`, `softstring`, `softlist`, …) is among them. A
145
+ * soft type is a DECLARATION: it says that whatever a variable or parameter is
146
+ * given is converted to the base type. A value typed in here is already a
147
+ * literal of the type its editor makes, so there is nothing to convert and no
148
+ * editor of its own to draw — the picker used to offer them anyway, and each
149
+ * one drew "Unknown type!".
150
+ */
151
+ export const getImmediateValueTypes = (): { name: IQorusType; display_name: string }[] => [
138
152
  { name: 'bool', display_name: 'True/False' },
139
153
  { name: 'date', display_name: 'Date' },
140
154
  { name: 'string', display_name: 'Text' },
@@ -146,6 +160,21 @@ export const DefaultNoSoftTypes = [
146
160
  { name: 'rgbcolor', display_name: 'RGB Color' },
147
161
  ];
148
162
 
163
+ /** @deprecated Use `getImmediateValueTypes()`; every picker now offers this list. */
164
+ export const DefaultNoSoftTypes = getImmediateValueTypes();
165
+
166
+ /**
167
+ * The type a value is EDITED as: a soft type is edited as its base type.
168
+ *
169
+ * A soft type still reaches this field from data saved before the picker
170
+ * stopped offering them, and from a declared type the server sends (an option
171
+ * or a config item declared `softint`). It is shown and edited as the base
172
+ * type it converts to, so that value keeps its editor; the declared type
173
+ * itself is left as it is and is what the field reports back.
174
+ */
175
+ export const editorTypeOf = <T extends string | undefined | null>(type: T): T =>
176
+ (typeof type === 'string' && /^soft[a-z]/.test(type) ? type.slice(4) : type) as T;
177
+
149
178
  function AutoField<T = any>({
150
179
  name,
151
180
  onChange,
@@ -155,7 +184,8 @@ function AutoField<T = any>({
155
184
  defaultInternalType,
156
185
  requestFieldData,
157
186
  type,
158
- noSoft,
187
+ // Inert (deprecated); destructured so it does not land in `...rest`.
188
+ noSoft, // eslint-disable-line @typescript-eslint/no-unused-vars
159
189
  path,
160
190
  arg_schema,
161
191
  compact,
@@ -185,9 +215,11 @@ function AutoField<T = any>({
185
215
  ...rest
186
216
  }: IAutoFieldProps & T) {
187
217
  const [currentType, setType] = useState<IQorusType>(defaultInternalType || null);
188
- const [currentInternalType, setInternalType] = useState<IQorusType>(
218
+ const [storedInternalType, setInternalType] = useState<IQorusType>(
189
219
  defaultInternalType || 'any'
190
220
  );
221
+ // What the value is edited as; see `editorTypeOf`.
222
+ const currentInternalType = editorTypeOf(storedInternalType);
191
223
  const [isSetToNull, setIsSetToNull] = useState<boolean>(false);
192
224
  const [finalArgSchema, setFinalArgSchema] = useState<IOptionsSchema>(
193
225
  typeof arg_schema === 'string' ? undefined : arg_schema
@@ -203,7 +235,6 @@ function AutoField<T = any>({
203
235
  defaultInternalType,
204
236
  requestFieldData,
205
237
  type,
206
- noSoft,
207
238
  path,
208
239
  arg_schema,
209
240
  column,
@@ -288,7 +319,7 @@ function AutoField<T = any>({
288
319
  });
289
320
 
290
321
  useUpdateEffect(() => {
291
- if (defaultType && currentInternalType !== defaultType) {
322
+ if (defaultType && storedInternalType !== defaultType) {
292
323
  setType(defaultType);
293
324
  setInternalType(defaultType);
294
325
  }
@@ -1055,26 +1086,23 @@ function AutoField<T = any>({
1055
1086
  currentType === 'auto' ||
1056
1087
  currentType === 'any'));
1057
1088
 
1058
- const types =
1059
- allowedTypes ||
1060
- (!noSoft
1061
- ? [
1062
- { value: 'bool' },
1063
- { value: 'softbool' },
1064
- { value: 'date' },
1065
- { value: 'string' },
1066
- { value: 'softstring' },
1067
- { value: 'binary' },
1068
- { value: 'float' },
1069
- { value: 'softfloat' },
1070
- { value: 'list' },
1071
- { value: 'softlist' },
1072
- { value: 'hash' },
1073
- { value: 'int' },
1074
- { value: 'softint' },
1075
- { value: 'rgbcolor' },
1076
- ]
1077
- : DefaultNoSoftTypes);
1089
+ // A caller's own list is offered as the editors it names, so a soft type in
1090
+ // it is offered once, as its base type.
1091
+ const types = allowedTypes
1092
+ ? uniqBy(
1093
+ allowedTypes.map((allowed) => {
1094
+ const name = editorTypeOf(allowed.name);
1095
+ return {
1096
+ ...allowed,
1097
+ name,
1098
+ display_name:
1099
+ allowed.display_name ||
1100
+ getImmediateValueTypes().find((type) => type.name === name)?.display_name,
1101
+ };
1102
+ }),
1103
+ 'name'
1104
+ )
1105
+ : getImmediateValueTypes();
1078
1106
 
1079
1107
  if (error) {
1080
1108
  return <ReqoreMessage intent='danger'>{error}</ReqoreMessage>;
@@ -1102,6 +1130,9 @@ function AutoField<T = any>({
1102
1130
  minimal={rest.minimal}
1103
1131
  size={rest.size}
1104
1132
  items={types as ISelectFormFieldItem[]}
1133
+ /* A count on a fixed list of types says nothing the open list
1134
+ does not; the IDE's picker shows none either. */
1135
+ hideItemCount
1105
1136
  value={currentInternalType}
1106
1137
  onChange={(value) => {
1107
1138
  handleTypeChange(name, value as IQorusType);
@@ -51,12 +51,14 @@ export const Integer: Story = {
51
51
  const input = canvas.getByLabelText('Number');
52
52
 
53
53
  await expect(input).toBeInTheDocument();
54
- await expect(input).toHaveValue(42);
55
- await expect(input).toHaveAttribute('type', 'number');
54
+ await expect(input).toHaveValue('42');
55
+ // typed freely, on a numeric keyboard
56
+ await expect(input).toHaveAttribute('type', 'text');
57
+ await expect(input).toHaveAttribute('inputmode', 'numeric');
56
58
 
57
59
  await userEvent.clear(input);
58
60
  await userEvent.type(input, '10');
59
- await expect(input).toHaveValue(10);
61
+ await expect(input).toHaveValue('10');
60
62
  await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith(10), { timeout: 500 });
61
63
  },
62
64
  };
@@ -70,7 +72,7 @@ export const Float: Story = {
70
72
  docs: {
71
73
  description: {
72
74
  story:
73
- 'Renders the Number field in float mode with 3.14 pre-populated — the input uses a 0.1 step and accepts decimals.',
75
+ 'Renders the Number field in float mode with 3.14 pre-populated — the input asks for a decimal keyboard and accepts decimals.',
74
76
  },
75
77
  },
76
78
  },
@@ -78,12 +80,12 @@ export const Float: Story = {
78
80
  const canvas = within(canvasElement);
79
81
  const input = canvas.getByLabelText('Number');
80
82
 
81
- await expect(input).toHaveValue(3.14);
82
- await expect(input).toHaveAttribute('step', '0.1');
83
+ await expect(input).toHaveValue('3.14');
84
+ await expect(input).toHaveAttribute('inputmode', 'decimal');
83
85
 
84
86
  await userEvent.clear(input);
85
87
  await userEvent.type(input, '10.9');
86
- await expect(input).toHaveValue(10.9);
88
+ await expect(input).toHaveValue('10.9');
87
89
  await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith(10.9), { timeout: 500 });
88
90
  },
89
91
  };
@@ -104,7 +106,7 @@ export const Empty: Story = {
104
106
  const input = canvas.getByLabelText('Number');
105
107
 
106
108
  await expect(input).toBeInTheDocument();
107
- await expect(input).toHaveValue(null);
109
+ await expect(input).toHaveValue('');
108
110
  },
109
111
  };
110
112
 
@@ -127,6 +129,31 @@ export const Disabled: Story = {
127
129
  const input = canvas.getByLabelText('Number');
128
130
 
129
131
  await expect(input).toBeDisabled();
130
- await expect(input).toHaveValue(99);
132
+ await expect(input).toHaveValue('99');
133
+ },
134
+ };
135
+
136
+ export const TypedFreely: Story = {
137
+ args: {
138
+ value: 7,
139
+ type: 'int',
140
+ },
141
+ parameters: {
142
+ docs: {
143
+ description: {
144
+ story:
145
+ 'Renders the Number field in integer mode after typing what is not a number: 12abc stays as typed (the form flags it, it is not cut to 12), and a template ($local:quantity) or an expression (@qty * 2) is passed on as typed for the field around it to take up.',
146
+ },
147
+ },
148
+ },
149
+ async play({ args, canvasElement }) {
150
+ const canvas = within(canvasElement);
151
+ const input = canvas.getByLabelText('Number');
152
+ for (const text of ['12abc', '@qty * 2', '$local:quantity']) {
153
+ await userEvent.clear(input);
154
+ await userEvent.type(input, text);
155
+ await expect(input).toHaveValue(text);
156
+ await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith(text), { timeout: 500 });
157
+ }
131
158
  },
132
159
  };
@@ -27,6 +27,22 @@ export interface INumberFormFieldProps extends Omit<IReqoreInputProps, 'value' |
27
27
  max_value?: number;
28
28
  }
29
29
 
30
+ /** A whole number as typed: an optional sign and digits. */
31
+ const WHOLE_NUMBER = /^[-+]?\d+$/;
32
+ /** A decimal number as typed: an optional sign, digits with a point, and an exponent. */
33
+ const DECIMAL_NUMBER = /^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/;
34
+
35
+ /**
36
+ * What a typed text is: the number it writes, or - when it writes none - the text as typed. A field of a
37
+ * number type is typed freely (David's review of qorus#646): a template (`$local:x`) or an expression
38
+ * (`@qty * 2`) is taken up by the field around it, and anything else is kept as typed for the form to flag
39
+ * as not a number. `parseInt` cut `12abc` to 12 without a word.
40
+ */
41
+ export const typedNumber = (text: string, type: 'int' | 'float'): number | string => {
42
+ const trimmed = text.trim();
43
+ return (type === 'int' ? WHOLE_NUMBER : DECIMAL_NUMBER).test(trimmed) ? Number(trimmed) : text;
44
+ };
45
+
30
46
  export const NumberFormField = ({
31
47
  onChange,
32
48
  autoFocus,
@@ -37,7 +53,10 @@ export const NumberFormField = ({
37
53
  max_value,
38
54
  ...rest
39
55
  }: INumberFormFieldProps) => {
40
- const [localValue, setLocalValue] = useState<number | string>(value ?? '');
56
+ // What is shown is what was typed; what the form gets is the number it writes, or the text when it writes
57
+ // none. Showing the number instead dropped a decimal point mid-typing: `10.` is 10, and `10.9` became `109`.
58
+ const [text, setText] = useState<string>(value === undefined || value === null ? '' : String(value));
59
+ const localValue = text === '' ? '' : typedNumber(text, type);
41
60
  // The parent's echo of an emit still in flight is not a change: copying it
42
61
  // over the local value lost what was typed since. See `EmittedValues`.
43
62
  const emitted = useEmittedValues<number | string | undefined>();
@@ -47,7 +66,7 @@ export const NumberFormField = ({
47
66
  return;
48
67
  }
49
68
  if (value !== localValue) {
50
- setLocalValue(value ?? '');
69
+ setText(value === undefined || value === null ? '' : String(value));
51
70
  }
52
71
  }, [value]);
53
72
 
@@ -62,17 +81,9 @@ export const NumberFormField = ({
62
81
  [localValue, onChange]
63
82
  );
64
83
 
65
- const handleChange = useCallback(
66
- (rawValue: number | string): void => {
67
- if (rawValue === '' || rawValue === '-' || rawValue === '-.') {
68
- setLocalValue(rawValue);
69
- return;
70
- }
71
- const parsed = type === 'int' ? parseInt(rawValue as string, 10) : parseFloat(rawValue as string);
72
- setLocalValue(isNaN(parsed) ? rawValue : parsed);
73
- },
74
- [type]
75
- );
84
+ const handleChange = useCallback((rawValue: number | string): void => {
85
+ setText(String(rawValue));
86
+ }, []);
76
87
 
77
88
  const handleInputChange = useCallback(
78
89
  (event: ChangeEvent<HTMLInputElement>): void => {
@@ -82,7 +93,7 @@ export const NumberFormField = ({
82
93
  );
83
94
 
84
95
  const handleResetClick = useCallback((): void => {
85
- setLocalValue(0);
96
+ setText('0');
86
97
  emitted.record(0);
87
98
  onChange?.(0);
88
99
  }, [onChange]);
@@ -98,17 +109,12 @@ export const NumberFormField = ({
98
109
  [autoFocus]
99
110
  );
100
111
 
101
- const handleItemSelect = useCallback((item) => setLocalValue(item.value), []);
112
+ const handleItemSelect = useCallback((item) => setText(String(item.value)), []);
102
113
 
103
- // The schema's bounds under the names the input uses. Omitted entirely when the schema
104
- // declares none, so an undeclared bound stays undeclared rather than becoming `min={0}`.
105
- const bounds = useMemo(
106
- () => ({
107
- ...(typeof min_value === 'number' ? { min: min_value } : {}),
108
- ...(typeof max_value === 'number' ? { max: max_value } : {}),
109
- }),
110
- [min_value, max_value]
111
- );
114
+ // The schema's bounds are the form's to check (a text input has no min or max); they are named in the
115
+ // props only to keep them off the DOM node.
116
+ void min_value;
117
+ void max_value;
112
118
 
113
119
  // IDE `NumberField` parity: with templates the input is wrapped in a
114
120
  // focus-opened dropdown of the template values.
@@ -121,12 +127,13 @@ export const NumberFormField = ({
121
127
  icon='MoneyDollarCircleLine'
122
128
  items={templates?.items}
123
129
  filterable
124
- value={localValue}
130
+ value={text}
125
131
  onItemSelect={handleItemSelect}
126
132
  onChange={handleInputChange}
127
- type='number'
128
- step={type === 'int' ? 1 : 0.1}
129
- {...bounds}
133
+ // typed freely: a number input drops what is not part of a number, so a template or an expression
134
+ // could not be typed; the keyboard is still a numeric one
135
+ type='text'
136
+ inputMode={type === 'int' ? 'numeric' : 'decimal'}
130
137
  onClearClick={handleResetClick}
131
138
  focusRules={focusRules}
132
139
  {...TemplatesListProps}
@@ -138,14 +145,14 @@ export const NumberFormField = ({
138
145
  <ReqoreInput
139
146
  fluid
140
147
  icon='MoneyDollarCircleLine'
141
- value={localValue}
148
+ value={text}
142
149
  onChange={handleInputChange}
143
- type='number'
144
- step={type === 'int' ? 1 : 0.1}
150
+ // typed freely, on a numeric keyboard (see above)
151
+ type='text'
152
+ inputMode={type === 'int' ? 'numeric' : 'decimal'}
145
153
  onClearClick={handleResetClick}
146
154
  focusRules={focusRules}
147
155
  {...rest}
148
- {...bounds}
149
156
  />
150
157
  );
151
158
  };
@@ -1529,6 +1529,8 @@ const TemplateFieldImpl = memo(
1529
1529
  readOnly={rest.readOnly || rest.disabled}
1530
1530
  expressions={rest.expressions}
1531
1531
  expressionsUrl={rest.expressions_url}
1532
+ // the record's fields for the Text view, from the field's schema (`dpql_fields`)
1533
+ fields={rest.dpql_fields}
1532
1534
  serverHandled={rest.server_expression_handling}
1533
1535
  extraActions={extraActions}
1534
1536
  size={rest.size}
@@ -41,7 +41,14 @@ export const ReqraftUserProvider = ({ children, waitForStorage }: IReqraftStorag
41
41
  } = currentUserStore();
42
42
 
43
43
  useEffectOnce(() => {
44
- loadCurrentUser();
44
+ /* A user that cannot be loaded - no token (401), another origin the instance
45
+ refuses (CORS), no such endpoint (404) - is a state the store keeps
46
+ (`error`, `errorData`), and the page renders without one. The promise was
47
+ dropped, so the failure was an unhandled rejection: Storybook marked a story
48
+ wrapped in this provider as errored whenever it landed first. */
49
+ loadCurrentUser().catch((error: unknown) => {
50
+ console.warn('Reqraft: the current user could not be loaded', error);
51
+ });
45
52
  });
46
53
 
47
54
  const { load } = useFetch({