@qoretechnologies/reqraft 0.10.59 → 0.10.60-pr.131.g07a26ef
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/form/engine/FormEngine.d.ts.map +1 -1
- package/dist/components/form/engine/FormEngine.js +1 -1
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/engine/OptionFieldMessages.d.ts.map +1 -1
- package/dist/components/form/engine/OptionFieldMessages.js +2 -1
- package/dist/components/form/engine/OptionFieldMessages.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/helpers/validations.d.ts +10 -0
- package/dist/helpers/validations.d.ts.map +1 -1
- package/dist/helpers/validations.js +16 -7
- package/dist/helpers/validations.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.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/form/engine/FormEngine.stories.tsx +6 -1
- package/src/components/form/engine/FormEngine.tsx +0 -1
- package/src/components/form/engine/OptionFieldMessages.tsx +3 -1
- 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/fields/auto/AutoFormField.stories.tsx +112 -11
- package/src/components/form/fields/auto/AutoFormField.tsx +57 -26
- package/src/helpers/validations.ts +25 -13
- package/src/index.tsx +1 -0
- package/src/providers/StorageProvider.tsx +8 -1
|
@@ -1033,6 +1033,178 @@ export const AddValueSlotOnPhone: Story = {
|
|
|
1033
1033
|
},
|
|
1034
1034
|
};
|
|
1035
1035
|
|
|
1036
|
+
/**
|
|
1037
|
+
* The expression panels, nested or not, that reach past the box of the one they are nested in: none
|
|
1038
|
+
* should. A nested expression is indented; at its full width the indent pushed its right edge - and the
|
|
1039
|
+
* actions that float above it on hover - past its parent, and past a narrow screen's edge.
|
|
1040
|
+
*/
|
|
1041
|
+
const panelsPastTheirParent = () =>
|
|
1042
|
+
[...document.querySelectorAll<HTMLElement>('.expression, .reqore-panel')]
|
|
1043
|
+
.filter((panel) => panel.getBoundingClientRect().width > 0)
|
|
1044
|
+
.map((panel) => {
|
|
1045
|
+
const parent = panel.parentElement!.getBoundingClientRect();
|
|
1046
|
+
const box = panel.getBoundingClientRect();
|
|
1047
|
+
return { panel: panel.className.split(' ').pop(), over: Math.round(box.right - parent.right) };
|
|
1048
|
+
})
|
|
1049
|
+
.filter(({ over }) => over > 0);
|
|
1050
|
+
|
|
1051
|
+
/** The controls of an expression's title (its operation, its actions) that reach past its panel: none should. */
|
|
1052
|
+
const titlesPastTheirPanel = () =>
|
|
1053
|
+
[...document.querySelectorAll<HTMLElement>('.expression')].flatMap((panel) => {
|
|
1054
|
+
const box = panel.getBoundingClientRect();
|
|
1055
|
+
return [...panel.querySelectorAll<HTMLElement>('.expression-operator-selector, .reqore-panel-title button')]
|
|
1056
|
+
.filter((control) => control.closest('.expression') === panel && control.getBoundingClientRect().width > 0)
|
|
1057
|
+
.map((control) => Math.round(control.getBoundingClientRect().right - box.right))
|
|
1058
|
+
.filter((over) => over > 0);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
/** The column a 320px screen leaves the condition, and narrower ones a form's indents leave it. */
|
|
1062
|
+
const NARROW_COLUMNS = [320, 280, 240, 200, 186];
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* The order's fields, for the narrow-column stories: the operands refer to them, and a field picker
|
|
1066
|
+
* whose value is not among its templates has nothing to offer - it is drawn disabled, and the operand
|
|
1067
|
+
* reads as read-only.
|
|
1068
|
+
*/
|
|
1069
|
+
const orderTemplates = {
|
|
1070
|
+
label: 'Order',
|
|
1071
|
+
items: [
|
|
1072
|
+
{
|
|
1073
|
+
label: 'Order',
|
|
1074
|
+
badge: 'Order',
|
|
1075
|
+
items: [
|
|
1076
|
+
{ label: 'Quantity', badge: 'int', value: '$local:quantity' },
|
|
1077
|
+
{ label: 'Price', badge: 'int', value: '$local:price' },
|
|
1078
|
+
],
|
|
1079
|
+
},
|
|
1080
|
+
],
|
|
1081
|
+
} as any;
|
|
1082
|
+
|
|
1083
|
+
/** A comparison with the longest operation name nested in an "AND" group, in a column it can narrow. */
|
|
1084
|
+
export const NestedFitsNarrowColumn: Story = {
|
|
1085
|
+
args: {
|
|
1086
|
+
localTemplates: orderTemplates,
|
|
1087
|
+
value: {
|
|
1088
|
+
is_expression: true,
|
|
1089
|
+
value: {
|
|
1090
|
+
exp: '&&',
|
|
1091
|
+
args: [
|
|
1092
|
+
{
|
|
1093
|
+
is_expression: true,
|
|
1094
|
+
value: {
|
|
1095
|
+
exp: '>=',
|
|
1096
|
+
args: [
|
|
1097
|
+
{ type: 'int', value: '$local:quantity' },
|
|
1098
|
+
{ type: 'int', value: 100 },
|
|
1099
|
+
],
|
|
1100
|
+
},
|
|
1101
|
+
},
|
|
1102
|
+
{
|
|
1103
|
+
is_expression: true,
|
|
1104
|
+
value: {
|
|
1105
|
+
exp: '>=',
|
|
1106
|
+
args: [
|
|
1107
|
+
{ type: 'int', value: '$local:price' },
|
|
1108
|
+
{ type: 'int', value: 5 },
|
|
1109
|
+
],
|
|
1110
|
+
},
|
|
1111
|
+
},
|
|
1112
|
+
],
|
|
1113
|
+
},
|
|
1114
|
+
},
|
|
1115
|
+
},
|
|
1116
|
+
decorators: [
|
|
1117
|
+
(Story) => (
|
|
1118
|
+
<div className='narrow-column' style={{ width: NARROW_COLUMNS[0], maxWidth: '100%' }}>
|
|
1119
|
+
<Story />
|
|
1120
|
+
</div>
|
|
1121
|
+
),
|
|
1122
|
+
],
|
|
1123
|
+
parameters: {
|
|
1124
|
+
qlip: { viewport: { width: 320, height: 800 } },
|
|
1125
|
+
docs: {
|
|
1126
|
+
description: {
|
|
1127
|
+
story:
|
|
1128
|
+
'Renders two "Logical Greater Than Or Equal" comparisons nested in an "AND" group, in a column narrowed from 320px to 186px - what a 320px screen leaves a condition in a form - and shown at 186px: each nested expression is indented and stays inside the group, its operation name shortened to fit, so the actions that float above it on hover stay on the screen.',
|
|
1129
|
+
},
|
|
1130
|
+
},
|
|
1131
|
+
},
|
|
1132
|
+
play: async ({ canvasElement }) => {
|
|
1133
|
+
await waitFor(() => expect(expressionCount()).toBe(2), { timeout: 10000 });
|
|
1134
|
+
// editable: every control of the operands can be used (a field picker with nothing to offer is disabled)
|
|
1135
|
+
expect(
|
|
1136
|
+
[...canvasElement.querySelectorAll<HTMLButtonElement>('.expression-arg button')].filter((b) => b.disabled).map((b) => b.textContent)
|
|
1137
|
+
).toEqual([]);
|
|
1138
|
+
const column = canvasElement.querySelector('.narrow-column') as HTMLElement;
|
|
1139
|
+
// as narrow as a 320px screen leaves the condition, and narrower
|
|
1140
|
+
for (const width of NARROW_COLUMNS) {
|
|
1141
|
+
column.style.width = `${width}px`;
|
|
1142
|
+
await waitFor(
|
|
1143
|
+
() =>
|
|
1144
|
+
expect({ width, past: panelsPastTheirParent(), titles: titlesPastTheirPanel() }).toEqual({
|
|
1145
|
+
width,
|
|
1146
|
+
past: [],
|
|
1147
|
+
titles: [],
|
|
1148
|
+
}),
|
|
1149
|
+
{ timeout: 10000 }
|
|
1150
|
+
);
|
|
1151
|
+
// the operation stays readable: named in full in a 320px column, and shortened, not crushed, below
|
|
1152
|
+
const selector = canvasElement.querySelector('.expression .expression-operator-selector') as HTMLElement;
|
|
1153
|
+
const name = selector.querySelector('.reqore-button-text-content') as HTMLElement;
|
|
1154
|
+
const shown = { width, selector: Math.round(selector.getBoundingClientRect().width) >= 60, full: name.scrollWidth <= name.clientWidth + 1 };
|
|
1155
|
+
expect(shown).toEqual({ width, selector: true, full: width === NARROW_COLUMNS[0] ? true : shown.full });
|
|
1156
|
+
}
|
|
1157
|
+
},
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* The controls of the operands (a value or a field, its clear button, its `⋮` menu) that reach past their
|
|
1162
|
+
* operand's row, or a row past its expression: none should, or a control is cut off or hangs outside.
|
|
1163
|
+
*/
|
|
1164
|
+
const operandControlsPastTheirRow = () =>
|
|
1165
|
+
[...document.querySelectorAll<HTMLElement>('.expression-arg')].flatMap((row) => {
|
|
1166
|
+
const box = row.getBoundingClientRect();
|
|
1167
|
+
const panel = (row.closest('.expression') as HTMLElement).getBoundingClientRect();
|
|
1168
|
+
const right = Math.min(box.right, panel.right);
|
|
1169
|
+
return [...row.querySelectorAll<HTMLElement>('button, input, .reqore-tag')]
|
|
1170
|
+
.filter((control) => control.closest('.expression-arg') === row && control.getBoundingClientRect().width > 0)
|
|
1171
|
+
.map((control) => ({
|
|
1172
|
+
control: control.className.split(' ').filter((c) => !/^sc-/.test(c)).pop() ?? control.tagName,
|
|
1173
|
+
over: Math.round(control.getBoundingClientRect().right - right),
|
|
1174
|
+
}))
|
|
1175
|
+
.filter(({ over }) => over > 0);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
/** The same condition's operands - a field and a number each - in a column narrowed to 186px. */
|
|
1179
|
+
export const OperandsFitNarrowColumn: Story = {
|
|
1180
|
+
args: NestedFitsNarrowColumn.args,
|
|
1181
|
+
decorators: NestedFitsNarrowColumn.decorators,
|
|
1182
|
+
parameters: {
|
|
1183
|
+
qlip: { viewport: { width: 320, height: 800 } },
|
|
1184
|
+
docs: {
|
|
1185
|
+
description: {
|
|
1186
|
+
story:
|
|
1187
|
+
'Renders the nested "AND" condition in a column narrowed from 320px to 186px - what a 320px screen leaves a condition in a form - and shown at 186px: each operand\'s value or field, its clear button and its ⋮ menu stay inside the operand\'s row, the field shortened to fit.',
|
|
1188
|
+
},
|
|
1189
|
+
},
|
|
1190
|
+
},
|
|
1191
|
+
play: async ({ canvasElement }) => {
|
|
1192
|
+
await waitFor(() => expect(expressionCount()).toBe(2), { timeout: 10000 });
|
|
1193
|
+
// editable: the operands' controls are all usable, so their narrow layout is the editing one
|
|
1194
|
+
expect(
|
|
1195
|
+
[...canvasElement.querySelectorAll<HTMLButtonElement>('.expression-arg button')].filter((b) => b.disabled).map((b) => b.textContent)
|
|
1196
|
+
).toEqual([]);
|
|
1197
|
+
const column = canvasElement.querySelector('.narrow-column') as HTMLElement;
|
|
1198
|
+
for (const width of NARROW_COLUMNS) {
|
|
1199
|
+
column.style.width = `${width}px`;
|
|
1200
|
+
await waitFor(
|
|
1201
|
+
() => expect({ width, past: operandControlsPastTheirRow() }).toEqual({ width, past: [] }),
|
|
1202
|
+
{ timeout: 10000 }
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
},
|
|
1206
|
+
};
|
|
1207
|
+
|
|
1036
1208
|
/**
|
|
1037
1209
|
* On a phone the remove action leaves the operand row for its ⋮ menu. The play
|
|
1038
1210
|
* runs at the runner's desktop viewport, where the inline buttons are the
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useReqoreTheme,
|
|
7
7
|
} from '@qoretechnologies/reqore';
|
|
8
8
|
import { memo } from 'react';
|
|
9
|
+
import styled from 'styled-components';
|
|
9
10
|
import { ordinal } from '../../../../helpers/common';
|
|
10
11
|
import {
|
|
11
12
|
IExpression,
|
|
@@ -15,6 +16,27 @@ import {
|
|
|
15
16
|
} from '../types';
|
|
16
17
|
import { ExpressionBuilderArgumentLabel } from './argumentLabel';
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* An operand's field row: the field (a value or a field reference, its clear button, its ⋮ menu) shrinks
|
|
21
|
+
* with a narrow row. A control group that is not fluid does not shrink, and a field is several of them
|
|
22
|
+
* nested, so in a narrow column - not only on a phone, where operands are fluid - the field kept its
|
|
23
|
+
* natural width and its ⋮ was cut off at the expression's edge or hung past it. The value gives way, down
|
|
24
|
+
* to its own least width, and the controls beside it stay in the row.
|
|
25
|
+
*
|
|
26
|
+
* The row's own control group, styled - not a wrapper inside it: a control group hands its size to its
|
|
27
|
+
* children, and a wrapper between them drew the field at the normal size instead of the row's small one.
|
|
28
|
+
*/
|
|
29
|
+
const StyledOperandFieldRow = styled(ReqoreControlGroup)`
|
|
30
|
+
min-width: 0;
|
|
31
|
+
max-width: 100%;
|
|
32
|
+
|
|
33
|
+
.reqore-control-group {
|
|
34
|
+
flex-shrink: 1;
|
|
35
|
+
min-width: 0;
|
|
36
|
+
max-width: 100%;
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
|
|
18
40
|
export interface IExpressionBuilderArgumentWrapperProps {
|
|
19
41
|
children: React.ReactNode;
|
|
20
42
|
schema?: IExpressionSchemaArg;
|
|
@@ -184,7 +206,8 @@ export const ExpressionBuilderArgumentWrapper = memo(
|
|
|
184
206
|
size='small'
|
|
185
207
|
className='expression-arg'
|
|
186
208
|
{...dragProps}
|
|
187
|
-
|
|
209
|
+
// at most the width of the expression it is in: a narrow column shrinks its field instead
|
|
210
|
+
style={{ flexShrink: 1, minWidth: 0, maxWidth: '100%', ...dragProps.style }}
|
|
188
211
|
>
|
|
189
212
|
<ExpressionBuilderArgumentLabel
|
|
190
213
|
arg={arg}
|
|
@@ -195,7 +218,7 @@ export const ExpressionBuilderArgumentWrapper = memo(
|
|
|
195
218
|
{/* A fluid operand is a phone row: grip, field and `⋮` share it, and
|
|
196
219
|
the field takes what is left; wrapping would put the grip on a
|
|
197
220
|
line of its own above a full-width field. */}
|
|
198
|
-
<
|
|
221
|
+
<StyledOperandFieldRow verticalAlign='flex-start' wrap={!fluid} fluid className='expression-arg-field'>
|
|
199
222
|
{reorder && renderReorderControls()}
|
|
200
223
|
{children}
|
|
201
224
|
{hasMultipleArgs && (
|
|
@@ -213,7 +236,7 @@ export const ExpressionBuilderArgumentWrapper = memo(
|
|
|
213
236
|
disabled={readOnly}
|
|
214
237
|
/>
|
|
215
238
|
)}
|
|
216
|
-
</
|
|
239
|
+
</StyledOperandFieldRow>
|
|
217
240
|
</ReqoreControlGroup>
|
|
218
241
|
);
|
|
219
242
|
}
|
|
@@ -878,7 +878,9 @@ export const Expression = ({
|
|
|
878
878
|
icon='Functions'
|
|
879
879
|
placeholder='Select operation'
|
|
880
880
|
fluid={false}
|
|
881
|
-
|
|
881
|
+
// shrinks with a narrow panel, its name shortened, rather than holding the panel open
|
|
882
|
+
fixed={false}
|
|
883
|
+
style={{ minWidth: 0, maxWidth: '100%' }}
|
|
882
884
|
flat
|
|
883
885
|
// The operation catalogue is long and every entry carries a description,
|
|
884
886
|
// so it belongs in the searchable collection modal rather than an inline
|
|
@@ -895,6 +897,10 @@ export const Expression = ({
|
|
|
895
897
|
}
|
|
896
898
|
style={{
|
|
897
899
|
marginLeft: isChild ? 10 : undefined,
|
|
900
|
+
// indented, it is as wide as what is left of the group's width: at its full width the indent
|
|
901
|
+
// pushed it - and the actions floating above it on hover - past the group and a narrow screen
|
|
902
|
+
maxWidth: isChild ? 'calc(100% - 10px)' : undefined,
|
|
903
|
+
minWidth: 0,
|
|
898
904
|
borderStyle: 'dashed',
|
|
899
905
|
flexShrink: 1,
|
|
900
906
|
}}
|
|
@@ -1208,7 +1214,6 @@ export const Expression = ({
|
|
|
1208
1214
|
componentOverrides={componentOverrides}
|
|
1209
1215
|
// SEAM (reqraft): same as the first operand above.
|
|
1210
1216
|
extraActions={extraActions}
|
|
1211
|
-
noSoft
|
|
1212
1217
|
level={level + 1}
|
|
1213
1218
|
allowFunctions={!arg?.allowed_values && !arg?.element_allowed_values}
|
|
1214
1219
|
isFunction={rest[index]?.is_expression}
|
|
@@ -1339,6 +1344,11 @@ export const ExpressionBuilder = ({
|
|
|
1339
1344
|
reorder,
|
|
1340
1345
|
}: IExpressionBuilderProps) => {
|
|
1341
1346
|
const templates = useTemplates(!isChild, localTemplates);
|
|
1347
|
+
/* A builder nested in a group does not resolve templates again: its parent hands it the resolved ones.
|
|
1348
|
+
`useTemplates` gives a nested builder none, and they were what it handed on, so every comparison in
|
|
1349
|
+
an AND/OR group had no templates - its field pickers had nothing to offer and were drawn disabled,
|
|
1350
|
+
and the operands read as read-only. */
|
|
1351
|
+
const resolvedTemplates = isChild ? localTemplates : templates.value;
|
|
1342
1352
|
const theme = useReqoreTheme();
|
|
1343
1353
|
const [showSummary, setShowSummary] = useState(false);
|
|
1344
1354
|
const _expressions = useExpressions({
|
|
@@ -1429,6 +1439,9 @@ export const ExpressionBuilder = ({
|
|
|
1429
1439
|
label={isChild ? (value.value.exp === '||' ? 'OR group' : 'AND group') : 'IF group'}
|
|
1430
1440
|
style={{
|
|
1431
1441
|
marginLeft: isChild ? 10 : 0,
|
|
1442
|
+
// a nested group too: its indent is taken out of its width, not added past it
|
|
1443
|
+
maxWidth: isChild ? 'calc(100% - 10px)' : undefined,
|
|
1444
|
+
minWidth: 0,
|
|
1432
1445
|
}}
|
|
1433
1446
|
actions={[
|
|
1434
1447
|
{
|
|
@@ -1472,7 +1485,7 @@ export const ExpressionBuilder = ({
|
|
|
1472
1485
|
<React.Fragment key={index}>
|
|
1473
1486
|
<ExpressionBuilder
|
|
1474
1487
|
value={arg}
|
|
1475
|
-
localTemplates={
|
|
1488
|
+
localTemplates={resolvedTemplates}
|
|
1476
1489
|
isChild
|
|
1477
1490
|
path={`${path ? `${path}.` : ''}value.args.${index}`}
|
|
1478
1491
|
level={level + 1}
|
|
@@ -1508,7 +1521,7 @@ export const ExpressionBuilder = ({
|
|
|
1508
1521
|
return (
|
|
1509
1522
|
<ReqoreErrorBoundary>
|
|
1510
1523
|
<Expression
|
|
1511
|
-
localTemplates={
|
|
1524
|
+
localTemplates={resolvedTemplates}
|
|
1512
1525
|
value={value}
|
|
1513
1526
|
isChild={isChild}
|
|
1514
1527
|
type={type}
|
|
@@ -52,6 +52,62 @@ 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
|
+
const expectOffered = async (popover: HTMLElement, labels: string[]) => {
|
|
100
|
+
await waitFor(
|
|
101
|
+
() => {
|
|
102
|
+
for (const label of labels) {
|
|
103
|
+
expect(within(popover).getAllByText(label, { exact: true }).length).toBeGreaterThan(0);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
{ timeout: 10000 }
|
|
107
|
+
);
|
|
108
|
+
await expect(within(popover).queryByText(/^soft/)).not.toBeInTheDocument();
|
|
109
|
+
};
|
|
110
|
+
|
|
55
111
|
// --- reqraft behavioral stories (kept from the field-migration batch) -------
|
|
56
112
|
|
|
57
113
|
/** Untyped, no value — the field opens on an editor to type into, never on a type question. */
|
|
@@ -193,21 +249,21 @@ export const AllowedTypesSubset: Story = {
|
|
|
193
249
|
docs: {
|
|
194
250
|
description: {
|
|
195
251
|
story:
|
|
196
|
-
'Renders AutoFormField with allowedTypes restricted to Integer and Text. The type picker offers those 2 types
|
|
252
|
+
'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
253
|
},
|
|
198
254
|
},
|
|
199
255
|
},
|
|
200
256
|
async play({ canvasElement }) {
|
|
201
|
-
// The picker badge reflects the restricted list — 2 instead of 14.
|
|
202
|
-
await waitForText('2');
|
|
203
257
|
await expect(within(canvasElement).queryByText('Please select data type')).not.toBeInTheDocument();
|
|
258
|
+
const popover = await openTypePicker(canvasElement);
|
|
259
|
+
await expectOffered(popover, ['Integer', 'Text']);
|
|
260
|
+
await expect(within(popover).queryByText('Decimal')).not.toBeInTheDocument();
|
|
204
261
|
},
|
|
205
262
|
};
|
|
206
263
|
|
|
207
|
-
/**
|
|
264
|
+
/** The picker offers no `soft*` type: a typed-in literal has nothing to convert. */
|
|
208
265
|
export const NoSoftTypes: Story = {
|
|
209
266
|
args: {
|
|
210
|
-
noSoft: true,
|
|
211
267
|
/* The picker belongs to a value with a type: an untyped field that is
|
|
212
268
|
empty is typed into, and one holding text has resolved to `string`. */
|
|
213
269
|
defaultType: 'auto',
|
|
@@ -217,13 +273,14 @@ export const NoSoftTypes: Story = {
|
|
|
217
273
|
docs: {
|
|
218
274
|
description: {
|
|
219
275
|
story:
|
|
220
|
-
'Renders an untyped AutoFormField holding a text value
|
|
276
|
+
'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
277
|
},
|
|
222
278
|
},
|
|
223
279
|
},
|
|
224
280
|
async play({ canvasElement }) {
|
|
225
|
-
await waitForText('9');
|
|
226
281
|
await expect(within(canvasElement).queryByText('Please select data type')).not.toBeInTheDocument();
|
|
282
|
+
const popover = await openTypePicker(canvasElement, 'Text');
|
|
283
|
+
await expectOffered(popover, IMMEDIATE_TYPE_NAMES);
|
|
227
284
|
},
|
|
228
285
|
};
|
|
229
286
|
|
|
@@ -501,13 +558,57 @@ export const AutoDoesNotChangeTypeBackToAuto: Story = {
|
|
|
501
558
|
docs: {
|
|
502
559
|
description: {
|
|
503
560
|
story:
|
|
504
|
-
'Renders AutoFormField in auto mode with a string value — the inferred type
|
|
561
|
+
'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
562
|
},
|
|
506
563
|
},
|
|
507
564
|
},
|
|
508
|
-
play
|
|
509
|
-
await
|
|
510
|
-
await
|
|
565
|
+
async play({ canvasElement }) {
|
|
566
|
+
const popover = await openTypePicker(canvasElement, 'Text');
|
|
567
|
+
await expectOffered(popover, IMMEDIATE_TYPE_NAMES);
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
/** A soft type in a caller's allowed types is offered once, as its base type. */
|
|
572
|
+
export const AllowedSoftTypesOfferedAsTheirBase: Story = {
|
|
573
|
+
args: {
|
|
574
|
+
allowedTypes: [{ name: 'softint' }, { name: 'int' }, { name: 'softstring' }],
|
|
575
|
+
},
|
|
576
|
+
parameters: {
|
|
577
|
+
docs: {
|
|
578
|
+
description: {
|
|
579
|
+
story:
|
|
580
|
+
'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.',
|
|
581
|
+
},
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
async play({ canvasElement }) {
|
|
585
|
+
const popover = await openTypePicker(canvasElement);
|
|
586
|
+
await expectOffered(popover, ['Integer', 'Text']);
|
|
587
|
+
await expect(within(popover).getAllByText('Integer', { exact: true })).toHaveLength(1);
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
/** A value saved with a soft type is edited with its base type's editor. */
|
|
592
|
+
export const SavedSoftTypeEditsAsItsBase: Story = {
|
|
593
|
+
args: {
|
|
594
|
+
defaultType: 'auto',
|
|
595
|
+
defaultInternalType: 'softint',
|
|
596
|
+
value: 5,
|
|
597
|
+
},
|
|
598
|
+
parameters: {
|
|
599
|
+
docs: {
|
|
600
|
+
description: {
|
|
601
|
+
story:
|
|
602
|
+
'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!".',
|
|
603
|
+
},
|
|
604
|
+
},
|
|
605
|
+
},
|
|
606
|
+
async play({ canvasElement }) {
|
|
607
|
+
const canvas = within(canvasElement);
|
|
608
|
+
await expect(await canvas.findByDisplayValue('5')).toBeInTheDocument();
|
|
609
|
+
await expect(canvas.queryByText('Unknown type!')).not.toBeInTheDocument();
|
|
610
|
+
const popover = await openTypePicker(canvasElement, 'Integer');
|
|
611
|
+
await expectOffered(popover, IMMEDIATE_TYPE_NAMES);
|
|
511
612
|
},
|
|
512
613
|
};
|
|
513
614
|
|
|
@@ -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);
|