@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
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
9
9
|
import { ILspDiagnostic } from '../../utils/lspClient.types';
|
|
10
10
|
import { useLspSession, IUseLspSessionResult } from '../smartEditor/useLspSession';
|
|
11
|
-
import { IDpqlParseResult, TDpqlFsmContext } from './types';
|
|
11
|
+
import { IDpqlParseResult, TDpqlFields, TDpqlFsmContext } from './types';
|
|
12
12
|
|
|
13
13
|
export interface IDpqlFieldMeta {
|
|
14
14
|
display_name?: string;
|
|
@@ -26,6 +26,8 @@ export interface IUseDpqlSessionOptions {
|
|
|
26
26
|
options?: Record<string, any>;
|
|
27
27
|
/** FSM action code (DPAT_FIND / DPAT_UPDATE / DPAT_DELETE / …). */
|
|
28
28
|
actionCode?: number;
|
|
29
|
+
/** The record's fields, for a record no provider has (see `IDpqlEditorProps.fields`). */
|
|
30
|
+
fields?: TDpqlFields;
|
|
29
31
|
/** Initial DPQL text sent on `didOpen`. */
|
|
30
32
|
initialText?: string;
|
|
31
33
|
/**
|
|
@@ -72,6 +74,7 @@ export function useDpqlSession(
|
|
|
72
74
|
recordType,
|
|
73
75
|
options: ctxOptions,
|
|
74
76
|
actionCode,
|
|
77
|
+
fields,
|
|
75
78
|
initialText = '',
|
|
76
79
|
alertPayloadContext = false,
|
|
77
80
|
fsmContext,
|
|
@@ -81,8 +84,11 @@ export function useDpqlSession(
|
|
|
81
84
|
// server-side mutually exclusive — only one binds the URI at a time.
|
|
82
85
|
// When `alertPayloadContext` is `true`, suppress the provider effect
|
|
83
86
|
// so they don't race.
|
|
87
|
+
// An explicit field list binds the document on its own: the provider is optional with it.
|
|
88
|
+
const hasFields = !!fields && Object.keys(fields).length > 0;
|
|
89
|
+
const fieldsKey = hasFields ? JSON.stringify(fields) : undefined;
|
|
84
90
|
const needsProviderBinding =
|
|
85
|
-
Boolean(provider && recordType) && !alertPayloadContext;
|
|
91
|
+
(Boolean(provider && recordType) || hasFields) && !alertPayloadContext;
|
|
86
92
|
|
|
87
93
|
const initialMetadata = (() => {
|
|
88
94
|
const m: Record<string, any> = {};
|
|
@@ -98,6 +104,7 @@ export function useDpqlSession(
|
|
|
98
104
|
if (recordType) m.recordType = recordType;
|
|
99
105
|
if (ctxOptions) m.options = ctxOptions;
|
|
100
106
|
if (actionCode !== undefined) m.action_code = actionCode;
|
|
107
|
+
if (hasFields) m.fields = fields;
|
|
101
108
|
}
|
|
102
109
|
return Object.keys(m).length > 0 ? m : undefined;
|
|
103
110
|
})();
|
|
@@ -156,6 +163,7 @@ export function useDpqlSession(
|
|
|
156
163
|
recordType,
|
|
157
164
|
options: ctxOptions,
|
|
158
165
|
action_code: actionCode,
|
|
166
|
+
...(hasFields ? { fields } : {}),
|
|
159
167
|
})
|
|
160
168
|
.then((result) => {
|
|
161
169
|
if (cancelled) return;
|
|
@@ -196,6 +204,7 @@ export function useDpqlSession(
|
|
|
196
204
|
recordType,
|
|
197
205
|
ctxOptions,
|
|
198
206
|
actionCode,
|
|
207
|
+
fieldsKey,
|
|
199
208
|
]);
|
|
200
209
|
|
|
201
210
|
// Alert-payload binding. On first mount it lands via
|
|
@@ -610,10 +610,25 @@ export const OptionDependsOnOptionInRequiredGroup: Story = {
|
|
|
610
610
|
await _testsWaitForText(
|
|
611
611
|
'This field is disabled because some dependencies are not fulfilled: "Required Option 2"'
|
|
612
612
|
);
|
|
613
|
-
|
|
613
|
+
// typed into Required Option 2 once it has the focus, and every character kept: waiting on the field's
|
|
614
|
+
// own state, not on time, so the picture is of the settled form (a capture taken mid-update lost
|
|
615
|
+
// characters - "I he value")
|
|
616
|
+
const editor = document.querySelectorAll<HTMLElement>('div.system-option [contenteditable="true"]')[1];
|
|
617
|
+
await userEvent.click(editor);
|
|
618
|
+
await waitFor(() => expect(editor.contains(document.activeElement)).toBe(true));
|
|
619
|
+
await userEvent.keyboard('I have value');
|
|
620
|
+
await waitFor(() => expect(editor.textContent).toBe('I have value'));
|
|
614
621
|
await _testsWaitForTextToNotExist(
|
|
615
622
|
'This field is disabled because some dependencies are not fulfilled: "Required Option 2"'
|
|
616
623
|
);
|
|
624
|
+
// Required Option 6, unlocked, is empty: it says it is required
|
|
625
|
+
await waitFor(() =>
|
|
626
|
+
expect(
|
|
627
|
+
[...document.querySelectorAll('.system-option')].some(
|
|
628
|
+
(row) => row.textContent?.includes('Required Option 6') && row.textContent?.includes('This field is required')
|
|
629
|
+
)
|
|
630
|
+
).toBe(true)
|
|
631
|
+
);
|
|
617
632
|
// Option 2 settles the first group; the second (4 or 5) is still empty, and 6 is required: 4, 5 and 6
|
|
618
633
|
// need attention, and 4 names only what would settle its empty group
|
|
619
634
|
await _testsWaitForText('3 fields are not valid and require attention. Click here to only show invalid fields.');
|
|
@@ -43,6 +43,7 @@ import { offersTypeChoices } from './typeChoices';
|
|
|
43
43
|
import { optionRowActions, resolveOptionActions, TOptionActions } from './optionActions';
|
|
44
44
|
import { createRendererOnlyUiTypeCheck, isRendererOnlyUiType } from './rendererTypes';
|
|
45
45
|
import { revealRow } from './revealRow';
|
|
46
|
+
import { useEmittedValues } from '../fields/emittedValues';
|
|
46
47
|
import { cloneDeep, findKey, flatten, forEach, isEqual, isPlainObject, last, uniq } from 'lodash';
|
|
47
48
|
import map from 'lodash/map';
|
|
48
49
|
import reduce from 'lodash/reduce';
|
|
@@ -1608,6 +1609,16 @@ const FormEngineImpl = ({
|
|
|
1608
1609
|
* whole reason the sync-down runs `fixOptions` at all.
|
|
1609
1610
|
*/
|
|
1610
1611
|
const mirroredValue = useRef<TQorusForm | TQorusFlatForm | undefined>(undefined);
|
|
1612
|
+
/**
|
|
1613
|
+
* Every emit the parent has yet to feed back, not only the last one. A parent that takes the value back
|
|
1614
|
+
* late (a store, a debounced owner, a slow render) echoes an older emit after this form has emitted a newer
|
|
1615
|
+
* one. Compared with `lastEmittedValue` alone, that echo read as the parent's own change, and the sync-down
|
|
1616
|
+
* reset the form to it: the field under the cursor went back a step and the keys typed since were lost
|
|
1617
|
+
* ("I have value" came out as "I he value" in a story under load).
|
|
1618
|
+
*/
|
|
1619
|
+
const inFlight = useEmittedValues<TQorusForm | TQorusFlatForm | undefined>((a, b) =>
|
|
1620
|
+
isEqual(normalizeEmptyFieldValues(a), normalizeEmptyFieldValues(b))
|
|
1621
|
+
);
|
|
1611
1622
|
|
|
1612
1623
|
if (originalValue.current === undefined && size(value)) {
|
|
1613
1624
|
originalValue.current = localValue.fields;
|
|
@@ -1630,6 +1641,7 @@ const FormEngineImpl = ({
|
|
|
1630
1641
|
|
|
1631
1642
|
const toEmit = size(localValue.fields) ? (localValue.fields as TQorusForm) : undefined;
|
|
1632
1643
|
lastEmittedValue.current = toEmit;
|
|
1644
|
+
inFlight.record(toEmit);
|
|
1633
1645
|
const meta = size(localValue.meta) ? localValue.meta : undefined;
|
|
1634
1646
|
// Batched mode still emits every staged change (consumers may want to
|
|
1635
1647
|
// live-validate), but flags it as a draft — persistence waits for Save.
|
|
@@ -1790,8 +1802,10 @@ const FormEngineImpl = ({
|
|
|
1790
1802
|
/* What this rebuild lets us agree with the parent on — `undefined` when it
|
|
1791
1803
|
contributed something the parent has yet to learn. */
|
|
1792
1804
|
const agreed = agreementFromRebuild(fixedValue, value);
|
|
1805
|
+
// the echo of an emit still in flight - the last or an older one - is not the parent's change
|
|
1806
|
+
const echo = inFlight.isEcho(value);
|
|
1793
1807
|
if (
|
|
1794
|
-
isEqual(normalizedValue, normalizeEmptyFieldValues(lastEmittedValue.current)) &&
|
|
1808
|
+
(echo || isEqual(normalizedValue, normalizeEmptyFieldValues(lastEmittedValue.current))) &&
|
|
1795
1809
|
agreed !== undefined
|
|
1796
1810
|
) {
|
|
1797
1811
|
return;
|
|
@@ -3108,7 +3122,6 @@ const FormEngineImpl = ({
|
|
|
3108
3122
|
}
|
|
3109
3123
|
key={optionName}
|
|
3110
3124
|
arg_schema={options?.[optionName]?.arg_schema}
|
|
3111
|
-
noSoft={!!rest?.options}
|
|
3112
3125
|
value={expressionAwareValue}
|
|
3113
3126
|
isFunction={optionHoldsExpression(other)}
|
|
3114
3127
|
isDefaultFunction={options?.[optionName]?.default_view === 'expression'}
|
|
@@ -16,6 +16,7 @@ import { IReqoreFormTemplates } from '@qoretechnologies/reqore/dist/components/T
|
|
|
16
16
|
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
17
17
|
import { DpqlEditor, IDpqlEditorRef } from '../../dpqlEditor';
|
|
18
18
|
import { dpqlDisplayedText } from '../../dpqlEditor/dpqlHelpers';
|
|
19
|
+
import { TDpqlFields } from '../../dpqlEditor/types';
|
|
19
20
|
import { ExpressionBuilder, IExpressionBuilderProps } from './builder';
|
|
20
21
|
import { DpqlRendering } from './DpqlRendering';
|
|
21
22
|
import { IExpression, IExpressionSchema, IExpressionValue, TExpressionReorder } from './types';
|
|
@@ -72,6 +73,11 @@ export interface IExpressionFieldProps {
|
|
|
72
73
|
/** Optional data-provider context for DPQL `@field` completions in Text mode. */
|
|
73
74
|
provider?: string;
|
|
74
75
|
recordType?: string;
|
|
76
|
+
/**
|
|
77
|
+
* The record's fields for the Text view, for a record no provider has: every field the expression may
|
|
78
|
+
* use is offered there, as in the Visual view (see `IDpqlEditorProps.fields`).
|
|
79
|
+
*/
|
|
80
|
+
fields?: TDpqlFields;
|
|
75
81
|
/** Initial editor mode (default `visual`). */
|
|
76
82
|
defaultMode?: TExpressionMode;
|
|
77
83
|
/**
|
|
@@ -98,6 +104,7 @@ export const ExpressionField = memo(
|
|
|
98
104
|
serverHandled,
|
|
99
105
|
extraActions,
|
|
100
106
|
provider,
|
|
107
|
+
fields,
|
|
101
108
|
recordType,
|
|
102
109
|
defaultMode = 'visual',
|
|
103
110
|
size,
|
|
@@ -416,6 +423,7 @@ export const ExpressionField = memo(
|
|
|
416
423
|
onChange={handleDpqlChange}
|
|
417
424
|
provider={provider}
|
|
418
425
|
recordType={recordType}
|
|
426
|
+
fields={fields}
|
|
419
427
|
readOnly={readOnly}
|
|
420
428
|
height='48px'
|
|
421
429
|
/>
|
|
@@ -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}
|
|
@@ -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
|
-
|
|
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
|
-
'
|
|
121
|
+
'gu'
|
|
121
122
|
);
|
|
122
123
|
|
|
123
124
|
const tokens: Array<{ line: number; char: number; length: number; type: number }> = [];
|