@qoretechnologies/reqraft 0.10.60-pr.134.g36530dc → 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 (72) 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/engine/OptionFieldMessages.d.ts.map +1 -1
  21. package/dist/components/form/engine/OptionFieldMessages.js +2 -1
  22. package/dist/components/form/engine/OptionFieldMessages.js.map +1 -1
  23. package/dist/components/form/expressions/ExpressionField.d.ts +7 -1
  24. package/dist/components/form/expressions/ExpressionField.d.ts.map +1 -1
  25. package/dist/components/form/expressions/ExpressionField.js +2 -2
  26. package/dist/components/form/expressions/ExpressionField.js.map +1 -1
  27. package/dist/components/form/expressions/builder/argumentWrapper.d.ts.map +1 -1
  28. package/dist/components/form/expressions/builder/argumentWrapper.js +23 -1
  29. package/dist/components/form/expressions/builder/argumentWrapper.js.map +1 -1
  30. package/dist/components/form/expressions/builder/index.d.ts.map +1 -1
  31. package/dist/components/form/expressions/builder/index.js +18 -4
  32. package/dist/components/form/expressions/builder/index.js.map +1 -1
  33. package/dist/components/form/fields/auto/AutoFormField.d.ts +30 -1
  34. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  35. package/dist/components/form/fields/auto/AutoFormField.js +52 -27
  36. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  37. package/dist/components/form/fields/number/Number.d.ts +7 -0
  38. package/dist/components/form/fields/number/Number.d.ts.map +1 -1
  39. package/dist/components/form/fields/number/Number.js +36 -17
  40. package/dist/components/form/fields/number/Number.js.map +1 -1
  41. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  42. package/dist/components/form/fields/template/TemplateField.js +3 -1
  43. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  44. package/dist/helpers/validations.d.ts +10 -0
  45. package/dist/helpers/validations.d.ts.map +1 -1
  46. package/dist/helpers/validations.js +16 -7
  47. package/dist/helpers/validations.js.map +1 -1
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +2 -1
  51. package/dist/index.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/components/dpqlEditor/DpqlEditor.tsx +2 -0
  54. package/src/components/dpqlEditor/dpqlHelpers.ts +9 -2
  55. package/src/components/dpqlEditor/index.ts +1 -0
  56. package/src/components/dpqlEditor/types.ts +10 -0
  57. package/src/components/dpqlEditor/useDpqlSession.ts +11 -2
  58. package/src/components/form/engine/FormEngine.stories.tsx +22 -2
  59. package/src/components/form/engine/FormEngine.tsx +15 -2
  60. package/src/components/form/engine/OptionFieldMessages.tsx +3 -1
  61. package/src/components/form/expressions/ExpressionField.tsx +8 -0
  62. package/src/components/form/expressions/builder/ExpressionBuilder.stories.tsx +172 -0
  63. package/src/components/form/expressions/builder/argumentWrapper.tsx +26 -3
  64. package/src/components/form/expressions/builder/index.tsx +17 -4
  65. package/src/components/form/expressions/dpqlMockLanguage.ts +3 -2
  66. package/src/components/form/fields/auto/AutoFormField.stories.tsx +154 -12
  67. package/src/components/form/fields/auto/AutoFormField.tsx +57 -26
  68. package/src/components/form/fields/number/Number.stories.tsx +36 -9
  69. package/src/components/form/fields/number/Number.tsx +39 -32
  70. package/src/components/form/fields/template/TemplateField.tsx +2 -0
  71. package/src/helpers/validations.ts +25 -13
  72. package/src/index.tsx +1 -0
@@ -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
- style={{ flexShrink: 1, ...dragProps.style }}
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
- <ReqoreControlGroup verticalAlign='flex-start' wrap={!fluid} fluid>
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
- </ReqoreControlGroup>
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
- fixed={true}
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={templates.value}
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={templates.value}
1524
+ localTemplates={resolvedTemplates}
1512
1525
  value={value}
1513
1526
  isChild={isChild}
1514
1527
  type={type}
@@ -109,7 +109,8 @@ export function mockTokenizeDpql(text: string): number[] {
109
109
  `("(?:\\\\.|[^"\\\\])*")`, // 1: double-quoted string
110
110
  `('(?:\\\\.|[^'\\\\])*')`, // 2: single-quoted string
111
111
  `(/(?:\\\\.|[^/\\\\])*/[gimsux]*)`, // 3: regex literal /…/flags
112
- `(@"(?:\\\\.|[^"\\\\])*"|@[A-Za-z_][\\w.]*)`, // 4: @field
112
+ // a field name is bare in any script, as the server reads it: @menge_bestätigt, @名前
113
+ `(@"(?:\\\\.|[^"\\\\])*"|@[\\p{L}_][\\p{L}\\p{M}\\p{N}_.]*)`, // 4: @field
113
114
  `(\\$[A-Za-z_-][\\w-]*:(?:\\{[^}]*\\}|[\\w.{}]+))`, // 5: $context:value
114
115
  `(\\b\\d+(?:\\.\\d+)?(?:[eE][+-]?\\d+)?\\b)`, // 6: number
115
116
  `(==|!=|<=|>=|&&|\\|\\||=~|!~|[+\\-*/%<>!=])`, // 7: operator
@@ -117,7 +118,7 @@ export function mockTokenizeDpql(text: string): number[] {
117
118
  `(\\.\\.|[,(){}\\[\\].])`,
118
119
  `(\\b[A-Za-z_][\\w]*\\b)`, // 9: identifier (keyword check)
119
120
  ].join('|'),
120
- 'g'
121
+ 'gu'
121
122
  );
122
123
 
123
124
  const tokens: Array<{ line: number; char: number; length: number; type: number }> = [];
@@ -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