@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.
- package/design/FORM_ENGINE_FIELD_MIGRATION.md +10 -4
- package/dist/components/dpqlEditor/DpqlEditor.d.ts.map +1 -1
- package/dist/components/dpqlEditor/DpqlEditor.js +2 -1
- package/dist/components/dpqlEditor/DpqlEditor.js.map +1 -1
- package/dist/components/dpqlEditor/dpqlHelpers.d.ts.map +1 -1
- package/dist/components/dpqlEditor/dpqlHelpers.js +8 -1
- package/dist/components/dpqlEditor/dpqlHelpers.js.map +1 -1
- package/dist/components/dpqlEditor/index.d.ts +1 -1
- package/dist/components/dpqlEditor/index.d.ts.map +1 -1
- package/dist/components/dpqlEditor/index.js.map +1 -1
- package/dist/components/dpqlEditor/types.d.ts +12 -0
- package/dist/components/dpqlEditor/types.d.ts.map +1 -1
- package/dist/components/dpqlEditor/useDpqlSession.d.ts +3 -1
- package/dist/components/dpqlEditor/useDpqlSession.d.ts.map +1 -1
- package/dist/components/dpqlEditor/useDpqlSession.js +9 -9
- package/dist/components/dpqlEditor/useDpqlSession.js.map +1 -1
- package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
- package/dist/components/form/engine/FormEngine.js +16 -2
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/expressions/ExpressionField.d.ts +7 -1
- package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
- package/dist/components/form/expressions/ExpressionField.js +2 -2
- package/dist/components/form/expressions/ExpressionField.js.map +1 -1
- package/dist/components/form/expressions/builder/argumentWrapper.d.ts.map +1 -1
- package/dist/components/form/expressions/builder/argumentWrapper.js +23 -1
- package/dist/components/form/expressions/builder/argumentWrapper.js.map +1 -1
- package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
- package/dist/components/form/expressions/builder/index.js +18 -4
- package/dist/components/form/expressions/builder/index.js.map +1 -1
- package/dist/components/form/fields/auto/AutoFormField.d.ts +30 -1
- package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
- package/dist/components/form/fields/auto/AutoFormField.js +52 -27
- package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
- package/dist/components/form/fields/number/Number.d.ts +7 -0
- package/dist/components/form/fields/number/Number.d.ts.map +1 -1
- package/dist/components/form/fields/number/Number.js +36 -17
- package/dist/components/form/fields/number/Number.js.map +1 -1
- package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
- package/dist/components/form/fields/template/TemplateField.js +3 -1
- package/dist/components/form/fields/template/TemplateField.js.map +1 -1
- package/dist/providers/StorageProvider.d.ts.map +1 -1
- package/dist/providers/StorageProvider.js +8 -1
- package/dist/providers/StorageProvider.js.map +1 -1
- package/package.json +1 -1
- package/src/components/dpqlEditor/DpqlEditor.tsx +2 -0
- package/src/components/dpqlEditor/dpqlHelpers.ts +9 -2
- package/src/components/dpqlEditor/index.ts +1 -0
- package/src/components/dpqlEditor/types.ts +10 -0
- package/src/components/dpqlEditor/useDpqlSession.ts +11 -2
- package/src/components/form/engine/FormEngine.stories.tsx +16 -1
- package/src/components/form/engine/FormEngine.tsx +15 -2
- package/src/components/form/expressions/ExpressionField.tsx +8 -0
- package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +172 -0
- package/src/components/form/expressions/builder/argumentWrapper.tsx +26 -3
- package/src/components/form/expressions/builder/index.tsx +17 -4
- package/src/components/form/expressions/dpqlMockLanguage.ts +3 -2
- package/src/components/form/fields/auto/AutoFormField.stories.tsx +154 -12
- package/src/components/form/fields/auto/AutoFormField.tsx +57 -26
- package/src/components/form/fields/number/Number.stories.tsx +36 -9
- package/src/components/form/fields/number/Number.tsx +39 -32
- package/src/components/form/fields/template/TemplateField.tsx +2 -0
- 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
|
|
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
|
-
/**
|
|
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
|
|
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('
|
|
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
|
|
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
|
|
509
|
-
await
|
|
510
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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 [
|
|
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 &&
|
|
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
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
{
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
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
|
|
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('
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
67
|
-
|
|
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
|
-
|
|
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) =>
|
|
112
|
+
const handleItemSelect = useCallback((item) => setText(String(item.value)), []);
|
|
102
113
|
|
|
103
|
-
// The schema's bounds
|
|
104
|
-
//
|
|
105
|
-
|
|
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={
|
|
130
|
+
value={text}
|
|
125
131
|
onItemSelect={handleItemSelect}
|
|
126
132
|
onChange={handleInputChange}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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={
|
|
148
|
+
value={text}
|
|
142
149
|
onChange={handleInputChange}
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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({
|