@qoretechnologies/reqraft 0.10.8 → 0.10.9
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/dist/components/form/engine/CompactRow.d.ts.map +1 -1
- package/dist/components/form/engine/CompactRow.js +6 -2
- package/dist/components/form/engine/CompactRow.js.map +1 -1
- package/dist/components/form/engine/FormEngine.d.ts +16 -1
- package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
- package/dist/components/form/engine/FormEngine.js +83 -1
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/engine/compactRowContext.d.ts +1 -0
- package/dist/components/form/engine/compactRowContext.d.ts.map +1 -1
- package/dist/components/form/engine/compactRowContext.js.map +1 -1
- package/dist/components/form/engine/readFirst.d.ts +28 -0
- package/dist/components/form/engine/readFirst.d.ts.map +1 -1
- package/dist/components/form/engine/readFirst.js +24 -1
- package/dist/components/form/engine/readFirst.js.map +1 -1
- package/dist/components/form/fields/binary/Binary.d.ts +15 -0
- package/dist/components/form/fields/binary/Binary.d.ts.map +1 -0
- package/dist/components/form/fields/binary/Binary.js +74 -0
- package/dist/components/form/fields/binary/Binary.js.map +1 -0
- package/package.json +2 -2
- package/src/components/form/engine/CompactRow.tsx +6 -2
- package/src/components/form/engine/FormEngine.stories.tsx +169 -0
- package/src/components/form/engine/FormEngine.tsx +108 -0
- package/src/components/form/engine/compactRowContext.ts +6 -0
- package/src/components/form/engine/readFirst.ts +42 -0
- package/src/components/form/fields/binary/Binary.stories.tsx +90 -0
- package/src/components/form/fields/binary/Binary.tsx +81 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __assign = (this && this.__assign) || function () {
|
|
3
|
+
__assign = Object.assign || function(t) {
|
|
4
|
+
for (var s, i = 1, n = arguments.length; i < n; i++) {
|
|
5
|
+
s = arguments[i];
|
|
6
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
|
|
7
|
+
t[p] = s[p];
|
|
8
|
+
}
|
|
9
|
+
return t;
|
|
10
|
+
};
|
|
11
|
+
return __assign.apply(this, arguments);
|
|
12
|
+
};
|
|
13
|
+
var __rest = (this && this.__rest) || function (s, e) {
|
|
14
|
+
var t = {};
|
|
15
|
+
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
|
|
16
|
+
t[p] = s[p];
|
|
17
|
+
if (s != null && typeof Object.getOwnPropertySymbols === "function")
|
|
18
|
+
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
|
|
19
|
+
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
|
|
20
|
+
t[p[i]] = s[p[i]];
|
|
21
|
+
}
|
|
22
|
+
return t;
|
|
23
|
+
};
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.ReqraftBinaryFormField = void 0;
|
|
26
|
+
var jsx_runtime_1 = require("react/jsx-runtime");
|
|
27
|
+
var reqore_1 = require("@qoretechnologies/reqore");
|
|
28
|
+
var react_1 = require("react");
|
|
29
|
+
var react_use_1 = require("react-use");
|
|
30
|
+
var File_1 = require("../file/File");
|
|
31
|
+
/**
|
|
32
|
+
* Strip a `data:<mime-type>;base64,` prefix so the emitted value carries raw base64, matching the
|
|
33
|
+
* canonical binary wire form the Qorus server expects. `ReqraftFileFormField` reads uploads with
|
|
34
|
+
* `readAsDataURL`, so its `content` always carries that prefix.
|
|
35
|
+
*/
|
|
36
|
+
var stripDataUrlPrefix = function (dataUrl) {
|
|
37
|
+
var commaIndex = dataUrl.indexOf(',');
|
|
38
|
+
return commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1);
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* A field for binary values encoded as base64. The value can be typed or pasted directly into the
|
|
42
|
+
* textarea, or populated by uploading a file — the upload reuses {@link ReqraftFileFormField} (the
|
|
43
|
+
* shared Reqraft file picker / drop zone) rather than a bespoke dropzone; its data-URL content is
|
|
44
|
+
* decoded to raw base64 for the field value.
|
|
45
|
+
*/
|
|
46
|
+
exports.ReqraftBinaryFormField = (0, react_1.memo)(function (_a) {
|
|
47
|
+
var value = _a.value, onChange = _a.onChange, rest = __rest(_a, ["value", "onChange"]);
|
|
48
|
+
var _b = (0, react_1.useState)(value !== null && value !== void 0 ? value : ''), localValue = _b[0], setLocalValue = _b[1];
|
|
49
|
+
(0, react_1.useEffect)(function () {
|
|
50
|
+
if (value !== localValue) {
|
|
51
|
+
setLocalValue(value !== null && value !== void 0 ? value : '');
|
|
52
|
+
}
|
|
53
|
+
}, [value]);
|
|
54
|
+
(0, react_use_1.useDebounce)(function () {
|
|
55
|
+
if (localValue !== value) {
|
|
56
|
+
onChange === null || onChange === void 0 ? void 0 : onChange(localValue);
|
|
57
|
+
}
|
|
58
|
+
}, 100, [localValue, onChange]);
|
|
59
|
+
var handleChange = (0, react_1.useCallback)(function (event) {
|
|
60
|
+
setLocalValue(event.target.value);
|
|
61
|
+
}, []);
|
|
62
|
+
var handleFileChange = (0, react_1.useCallback)(function (file) {
|
|
63
|
+
var _a;
|
|
64
|
+
var base64 = stripDataUrlPrefix((_a = file === null || file === void 0 ? void 0 : file.content) !== null && _a !== void 0 ? _a : '');
|
|
65
|
+
setLocalValue(base64);
|
|
66
|
+
onChange === null || onChange === void 0 ? void 0 : onChange(base64);
|
|
67
|
+
}, [onChange]);
|
|
68
|
+
return ((0, jsx_runtime_1.jsxs)(reqore_1.ReqoreControlGroup, { vertical: true, fluid: true, children: [(0, jsx_runtime_1.jsx)(reqore_1.ReqoreTextarea, __assign({ scaleWithContent: true, fluid: true, value: localValue, onChange: handleChange, onClearClick: function () {
|
|
69
|
+
setLocalValue('');
|
|
70
|
+
onChange === null || onChange === void 0 ? void 0 : onChange('');
|
|
71
|
+
}, placeholder: 'Base64-encoded binary value' }, rest)), (0, jsx_runtime_1.jsx)(File_1.ReqraftFileFormField, { disabled: rest.disabled, onChange: handleFileChange })] }));
|
|
72
|
+
});
|
|
73
|
+
exports.default = exports.ReqraftBinaryFormField;
|
|
74
|
+
//# sourceMappingURL=Binary.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Binary.js","sourceRoot":"","sources":["../../../../../src/components/form/fields/binary/Binary.tsx"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAAA,mDAA8E;AAE9E,+BAA4E;AAC5E,uCAAwC;AACxC,qCAAyE;AAOzE;;;;GAIG;AACH,IAAM,kBAAkB,GAAG,UAAC,OAAe;IACzC,IAAM,UAAU,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;IACxC,OAAO,UAAU,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,KAAK,CAAC,UAAU,GAAG,CAAC,CAAC,CAAC;AACrE,CAAC,CAAC;AAEF;;;;;GAKG;AACU,QAAA,sBAAsB,GAAG,IAAA,YAAI,EACxC,UAAC,EAA0D;IAAxD,IAAA,KAAK,WAAA,EAAE,QAAQ,cAAA,EAAK,IAAI,cAA1B,qBAA4B,CAAF;IACnB,IAAA,KAA8B,IAAA,gBAAQ,EAAS,KAAK,aAAL,KAAK,cAAL,KAAK,GAAI,EAAE,CAAC,EAA1D,UAAU,QAAA,EAAE,aAAa,QAAiC,CAAC;IAElE,IAAA,iBAAS,EAAC;QACR,IAAI,KAAK,KAAK,UAAU,EAAE,CAAC;YACzB,aAAa,CAAC,KAAK,aAAL,KAAK,cAAL,KAAK,GAAI,EAAE,CAAC,CAAC;QAC7B,CAAC;IACH,CAAC,EAAE,CAAC,KAAK,CAAC,CAAC,CAAC;IAEZ,IAAA,uBAAW,EACT;QACE,IAAI,UAAU,KAAK,KAAK,EAAE,CAAC;YACzB,QAAQ,aAAR,QAAQ,uBAAR,QAAQ,CAAG,UAAU,CAAC,CAAC;QACzB,CAAC;IACH,CAAC,EACD,GAAG,EACH,CAAC,UAAU,EAAE,QAAQ,CAAC,CACvB,CAAC;IAEF,IAAM,YAAY,GAAG,IAAA,mBAAW,EAAC,UAAC,KAAuC;QACvE,aAAa,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACpC,CAAC,EAAE,EAAE,CAAC,CAAC;IAEP,IAAM,gBAAgB,GAAG,IAAA,mBAAW,EAClC,UAAC,IAA0B;;QACzB,IAAM,MAAM,GAAG,kBAAkB,CAAC,MAAA,IAAI,aAAJ,IAAI,uBAAJ,IAAI,CAAE,OAAO,mCAAI,EAAE,CAAC,CAAC;QACvD,aAAa,CAAC,MAAM,CAAC,CAAC;QACtB,QAAQ,aAAR,QAAQ,uBAAR,QAAQ,CAAG,MAAM,CAAC,CAAC;IACrB,CAAC,EACD,CAAC,QAAQ,CAAC,CACX,CAAC;IAEF,OAAO,CACL,wBAAC,2BAAkB,IAAC,QAAQ,QAAC,KAAK,mBAChC,uBAAC,uBAAc,aACb,gBAAgB,QAChB,KAAK,QACL,KAAK,EAAE,UAAU,EACjB,QAAQ,EAAE,YAAY,EACtB,YAAY,EAAE;oBACZ,aAAa,CAAC,EAAE,CAAC,CAAC;oBAClB,QAAQ,aAAR,QAAQ,uBAAR,QAAQ,CAAG,EAAE,CAAC,CAAC;gBACjB,CAAC,EACD,WAAW,EAAC,6BAA6B,IACrC,IAAI,EACR,EACF,uBAAC,2BAAoB,IAAC,QAAQ,EAAE,IAAI,CAAC,QAAQ,EAAE,QAAQ,EAAE,gBAAgB,GAAI,IAC1D,CACtB,CAAC;AACJ,CAAC,CACF,CAAC;AAEF,kBAAe,8BAAsB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qoretechnologies/reqraft",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.9",
|
|
4
4
|
"description": "ReQraft is a collection of React components and hooks that are used across Qore Technologies' products made using the ReQore component library from Qore.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"@chromatic-com/storybook": "^5.2.1",
|
|
57
57
|
"@netsells/storybook-mockdate": "^0.3.3",
|
|
58
58
|
"@qoretechnologies/qlip": "^0.1.3-beta.20260622084321",
|
|
59
|
-
"@qoretechnologies/reqore": "^0.70.
|
|
59
|
+
"@qoretechnologies/reqore": "^0.70.6",
|
|
60
60
|
"@storybook/addon-docs": "^10.4.6",
|
|
61
61
|
"@storybook/addon-links": "^10.4.6",
|
|
62
62
|
"@storybook/addon-vitest": "^10.4.6",
|
|
@@ -143,6 +143,7 @@ export const CompactRow = memo(
|
|
|
143
143
|
const isExpanded = useContextSelector(CompactRowContext, (v) =>
|
|
144
144
|
v.expandedOptions.includes(optionName)
|
|
145
145
|
);
|
|
146
|
+
const autoFocusNameRef = useContextSelector(CompactRowContext, (v) => v.autoFocusNameRef);
|
|
146
147
|
const isHighlighted = useContextSelector(CompactRowContext, (v) =>
|
|
147
148
|
v.highlightedOptions.includes(optionName)
|
|
148
149
|
);
|
|
@@ -437,10 +438,13 @@ export const CompactRow = memo(
|
|
|
437
438
|
const el = editorRef.current?.querySelector<HTMLElement>(
|
|
438
439
|
'input:not([type="hidden"]):not([disabled]), textarea, [contenteditable="true"]'
|
|
439
440
|
);
|
|
440
|
-
|
|
441
|
+
// A user-driven expand scrolls the just-opened editor into view; the
|
|
442
|
+
// programmatic `autoFocusFirstRequired` expand must NOT — otherwise an
|
|
443
|
+
// off-screen / below-the-fold form scrolls itself into view on mount.
|
|
444
|
+
el?.focus(autoFocusNameRef?.current === optionName ? { preventScroll: true } : undefined);
|
|
441
445
|
}, 60);
|
|
442
446
|
return () => window.clearTimeout(id);
|
|
443
|
-
}, [isExpanded]);
|
|
447
|
+
}, [isExpanded, optionName, autoFocusNameRef]);
|
|
444
448
|
|
|
445
449
|
const revertButton =
|
|
446
450
|
changed ?
|
|
@@ -4402,3 +4402,172 @@ export const CompactFieldSortWithinGroups: Story = {
|
|
|
4402
4402
|
await expect(document.querySelectorAll('.readfirst-cluster-first')).toHaveLength(2);
|
|
4403
4403
|
},
|
|
4404
4404
|
};
|
|
4405
|
+
|
|
4406
|
+
// `autoFocusFirstRequired` drops the user straight into the first field they
|
|
4407
|
+
// must fill. Here `name` is required-but-filled and `description` is
|
|
4408
|
+
// required-but-empty, so the engine expands the `description` row on mount and
|
|
4409
|
+
// its editor takes focus — no click, no DOM scraping.
|
|
4410
|
+
export const CompactAutoFocusFirstRequired: Story = {
|
|
4411
|
+
args: {
|
|
4412
|
+
compact: true,
|
|
4413
|
+
minColumnWidth: '300px',
|
|
4414
|
+
options: CompactSchema,
|
|
4415
|
+
value: CompactValue,
|
|
4416
|
+
groups: CompactGroups,
|
|
4417
|
+
autoFocusFirstRequired: true,
|
|
4418
|
+
},
|
|
4419
|
+
play: async () => {
|
|
4420
|
+
await _testsWaitForText('order-fulfilment');
|
|
4421
|
+
|
|
4422
|
+
// The first empty required field (`description`) auto-expands into its
|
|
4423
|
+
// inline editor without any interaction.
|
|
4424
|
+
await waitFor(
|
|
4425
|
+
() =>
|
|
4426
|
+
expect(
|
|
4427
|
+
document.querySelector(
|
|
4428
|
+
'[data-field="description"] input, [data-field="description"] textarea'
|
|
4429
|
+
)
|
|
4430
|
+
).toBeTruthy(),
|
|
4431
|
+
{ timeout: 10000 }
|
|
4432
|
+
);
|
|
4433
|
+
|
|
4434
|
+
// …and that editor receives focus (CompactRow focuses the expanded row's
|
|
4435
|
+
// control). `name` is required but already filled, so it is skipped.
|
|
4436
|
+
await waitFor(
|
|
4437
|
+
() => {
|
|
4438
|
+
const active = document.activeElement as HTMLElement | null;
|
|
4439
|
+
const field = document.querySelector('[data-field="description"]');
|
|
4440
|
+
expect(!!active && !!field && field.contains(active)).toBe(true);
|
|
4441
|
+
},
|
|
4442
|
+
{ timeout: 10000 }
|
|
4443
|
+
);
|
|
4444
|
+
|
|
4445
|
+
// The already-satisfied required field is NOT expanded/focused.
|
|
4446
|
+
const nameField = document.querySelector('[data-field="name"]');
|
|
4447
|
+
expect(nameField?.contains(document.activeElement)).toBeFalsy();
|
|
4448
|
+
},
|
|
4449
|
+
};
|
|
4450
|
+
|
|
4451
|
+
// A required field can be FILLED yet INVALID — here `endpoint` has a value that
|
|
4452
|
+
// fails its own `validation_regex` (it must be an http(s) URL). It still "needs
|
|
4453
|
+
// attention", so autofocus must land on IT and not skip ahead to the empty
|
|
4454
|
+
// `description` (which the old empty-only selector would have chosen). This is
|
|
4455
|
+
// the regression guard for the "filled-but-invalid" drift.
|
|
4456
|
+
const CompactInvalidFilledSchema: Record<string, TCompactField> = {
|
|
4457
|
+
endpoint: {
|
|
4458
|
+
type: 'string',
|
|
4459
|
+
ui_type: 'string',
|
|
4460
|
+
display_name: 'Endpoint URL',
|
|
4461
|
+
required: true,
|
|
4462
|
+
group: 'info',
|
|
4463
|
+
// Real format constraint: the value must be an http(s) URL. `validation_regex`
|
|
4464
|
+
// is read by the engine's own validation (helpers/validations.ts); it's not on
|
|
4465
|
+
// the base schema type, so cast.
|
|
4466
|
+
validation_regex: '^https?://',
|
|
4467
|
+
} as TCompactField,
|
|
4468
|
+
description: {
|
|
4469
|
+
type: 'string',
|
|
4470
|
+
ui_type: 'string',
|
|
4471
|
+
display_name: 'Description',
|
|
4472
|
+
required: true,
|
|
4473
|
+
group: 'general',
|
|
4474
|
+
},
|
|
4475
|
+
};
|
|
4476
|
+
|
|
4477
|
+
const CompactInvalidFilledValue: IOptions = {
|
|
4478
|
+
endpoint: { type: 'string', value: 'bad-endpoint' }, // filled, but not an http(s) URL → fails validation_regex
|
|
4479
|
+
// description left empty
|
|
4480
|
+
};
|
|
4481
|
+
|
|
4482
|
+
export const CompactAutoFocusTargetsInvalidFilledField: Story = {
|
|
4483
|
+
args: {
|
|
4484
|
+
compact: true,
|
|
4485
|
+
minColumnWidth: '300px',
|
|
4486
|
+
options: CompactInvalidFilledSchema,
|
|
4487
|
+
value: CompactInvalidFilledValue,
|
|
4488
|
+
groups: CompactGroups,
|
|
4489
|
+
autoFocusFirstRequired: true,
|
|
4490
|
+
},
|
|
4491
|
+
play: async () => {
|
|
4492
|
+
await _testsWaitForText('Endpoint URL');
|
|
4493
|
+
|
|
4494
|
+
// Sanity: the value genuinely fails the field's own validation (it isn't an
|
|
4495
|
+
// http(s) URL), using the very same validator the engine runs — so this is a
|
|
4496
|
+
// real filled-but-INVALID field, not a synthetically-flagged one.
|
|
4497
|
+
expect(validateField('string', 'bad-endpoint', { validation_regex: '^https?://' })).toBe(false);
|
|
4498
|
+
|
|
4499
|
+
// `endpoint` has a value but is invalid, so it needs attention and must be
|
|
4500
|
+
// the target — even though `description` is the empty required field the old
|
|
4501
|
+
// empty-only logic would have picked.
|
|
4502
|
+
await waitFor(
|
|
4503
|
+
() =>
|
|
4504
|
+
expect(
|
|
4505
|
+
document.querySelector('[data-field="endpoint"] input, [data-field="endpoint"] textarea')
|
|
4506
|
+
).toBeTruthy(),
|
|
4507
|
+
{ timeout: 10000 }
|
|
4508
|
+
);
|
|
4509
|
+
|
|
4510
|
+
await waitFor(
|
|
4511
|
+
() => {
|
|
4512
|
+
const active = document.activeElement as HTMLElement | null;
|
|
4513
|
+
const field = document.querySelector('[data-field="endpoint"]');
|
|
4514
|
+
expect(!!active && !!field && field.contains(active)).toBe(true);
|
|
4515
|
+
},
|
|
4516
|
+
{ timeout: 10000 }
|
|
4517
|
+
);
|
|
4518
|
+
|
|
4519
|
+
// The empty `description` is NOT the one expanded/focused.
|
|
4520
|
+
const descField = document.querySelector('[data-field="description"]');
|
|
4521
|
+
expect(descField?.contains(document.activeElement)).toBeFalsy();
|
|
4522
|
+
},
|
|
4523
|
+
};
|
|
4524
|
+
|
|
4525
|
+
// DEMO / manual-test story: the first "needs attention" field is a BOOLEAN,
|
|
4526
|
+
// whose compact editor is a `<div tabindex=0>` (ReqoreCheckbox) — NOT an
|
|
4527
|
+
// input/textarea/contenteditable that CompactRow's focus selector matches. Both
|
|
4528
|
+
// `enabled` and `name` are required and unset, so both need attention; `enabled`
|
|
4529
|
+
// is first. Open this in Storybook to see the UX: autofocus TARGETS the boolean
|
|
4530
|
+
// (its row expands) and does NOT skip ahead to the focusable `name` field — but
|
|
4531
|
+
// no element inside actually receives keyboard focus, so the caret is left
|
|
4532
|
+
// nowhere. (This is the non-text-editor focus gap; kept as a playground rather
|
|
4533
|
+
// than a hard assertion until we decide how CompactRow should focus such rows.)
|
|
4534
|
+
const CompactNonFocusableFirstSchema: Record<string, TCompactField> = {
|
|
4535
|
+
enabled: {
|
|
4536
|
+
type: 'bool',
|
|
4537
|
+
ui_type: 'bool',
|
|
4538
|
+
display_name: 'Enabled',
|
|
4539
|
+
short_desc: 'A boolean — the first field that needs attention',
|
|
4540
|
+
required: true,
|
|
4541
|
+
preselected: true,
|
|
4542
|
+
group: 'info',
|
|
4543
|
+
},
|
|
4544
|
+
name: {
|
|
4545
|
+
type: 'string',
|
|
4546
|
+
ui_type: 'string',
|
|
4547
|
+
display_name: 'Name',
|
|
4548
|
+
short_desc: 'A focusable text field — comes second',
|
|
4549
|
+
required: true,
|
|
4550
|
+
preselected: true,
|
|
4551
|
+
group: 'general',
|
|
4552
|
+
},
|
|
4553
|
+
};
|
|
4554
|
+
|
|
4555
|
+
const CompactNonFocusableFirstValue: IOptions = {}; // both unset → both need attention
|
|
4556
|
+
|
|
4557
|
+
export const CompactAutoFocusNonFocusableFirstField: Story = {
|
|
4558
|
+
args: {
|
|
4559
|
+
compact: true,
|
|
4560
|
+
minColumnWidth: '300px',
|
|
4561
|
+
options: CompactNonFocusableFirstSchema,
|
|
4562
|
+
value: CompactNonFocusableFirstValue,
|
|
4563
|
+
groups: CompactGroups,
|
|
4564
|
+
autoFocusFirstRequired: true,
|
|
4565
|
+
},
|
|
4566
|
+
play: async () => {
|
|
4567
|
+
// Smoke: both required-but-unset fields render (both land in "needs
|
|
4568
|
+
// attention"), with the boolean first. Focus behaviour is intentionally left
|
|
4569
|
+
// for manual observation — see the note above.
|
|
4570
|
+
await _testsWaitForText('Enabled');
|
|
4571
|
+
await _testsWaitForText('Name');
|
|
4572
|
+
},
|
|
4573
|
+
};
|
|
@@ -94,6 +94,7 @@ import {
|
|
|
94
94
|
import { OptionFieldMessages } from './OptionFieldMessages';
|
|
95
95
|
import { OptionsHelpDialog } from './OptionsHelpDialog';
|
|
96
96
|
import {
|
|
97
|
+
getFirstAttentionOptionName,
|
|
97
98
|
getOptionGroup,
|
|
98
99
|
getOptionGroupLabel,
|
|
99
100
|
getReadFirstBucket,
|
|
@@ -564,6 +565,22 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
|
|
|
564
565
|
* plumbing for the inherit_props scope-forwarding contract.
|
|
565
566
|
*/
|
|
566
567
|
inheritedFromParent?: Record<string, unknown>;
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Opt-in: on mount, drop the user straight into the first field they must
|
|
571
|
+
* fill — the first empty, focusable field (in schema/sort order) that is
|
|
572
|
+
* `required` or a member of a still-unsatisfied one-of `required_groups`
|
|
573
|
+
* group. Disabled, readonly, read-only-form, and dependency-locked fields are
|
|
574
|
+
* skipped. In compact (read-first) mode the target row is expanded through
|
|
575
|
+
* the engine's own `expandedOptions` state, so its editor gains focus with no
|
|
576
|
+
* DOM scraping or synthetic clicks. Strictly one-shot: it fires the first
|
|
577
|
+
* time the form has focusable content (so an async-loaded schema is covered)
|
|
578
|
+
* and then never again — a later value edit, server-driven field update, or
|
|
579
|
+
* in-place schema reload will not re-focus; only a remount re-arms it. It also
|
|
580
|
+
* never steals focus from a control the user has already moved into. No-op in
|
|
581
|
+
* classic (non-compact) mode. Default: off.
|
|
582
|
+
*/
|
|
583
|
+
autoFocusFirstRequired?: boolean;
|
|
567
584
|
}
|
|
568
585
|
|
|
569
586
|
// Option types rendered full-width (IDE Options parity, commit 8e6b7781).
|
|
@@ -602,6 +619,7 @@ export const FormEngine = ({
|
|
|
602
619
|
optionActions,
|
|
603
620
|
componentOverrides,
|
|
604
621
|
inheritedFromParent,
|
|
622
|
+
autoFocusFirstRequired,
|
|
605
623
|
...rest
|
|
606
624
|
}: IFormEngineProps) => {
|
|
607
625
|
const [options, setOptions] = useState<IQorusFormSchema | undefined>(rest?.options || undefined);
|
|
@@ -1517,6 +1535,94 @@ export const FormEngine = ({
|
|
|
1517
1535
|
[expandMode]
|
|
1518
1536
|
);
|
|
1519
1537
|
|
|
1538
|
+
// --- First-attention-field autofocus (opt-in) -----------------------------
|
|
1539
|
+
// With `autoFocusFirstRequired`, drop the user straight into the first field
|
|
1540
|
+
// they must fix. We reuse the engine's own ordering (`availableOptions`), the
|
|
1541
|
+
// very same `getOptionBucket` the status boxes use to decide "needs attention"
|
|
1542
|
+
// (so we cover empty-required, unsatisfied one-of, AND filled-but-invalid rows
|
|
1543
|
+
// without ever drifting from what the user sees), and dependency gating
|
|
1544
|
+
// (`dependencyLockedNames`), then expand the target row through
|
|
1545
|
+
// `expandedOptions` — the row's editor-focus effect does the rest. No DOM
|
|
1546
|
+
// scraping, no synthetic clicks, no polling.
|
|
1547
|
+
//
|
|
1548
|
+
// Strictly one-shot: it fires the first time the form has focusable content
|
|
1549
|
+
// (so an async-loaded schema is still covered — the empty first pass doesn't
|
|
1550
|
+
// count), then never again for the life of this instance. A server-driven
|
|
1551
|
+
// field update or an in-place schema reload will NOT re-grab focus; only a
|
|
1552
|
+
// genuine remount (a fresh instance) auto-focuses again, which is the intended
|
|
1553
|
+
// on-mount behaviour.
|
|
1554
|
+
const hasAutoFocusedRef = useRef(false);
|
|
1555
|
+
// The field expanded programmatically for autofocus. A ref (not state) so
|
|
1556
|
+
// CompactRow's 60ms focus timer reads the current value regardless of render
|
|
1557
|
+
// batching; CompactRow focuses this one with `preventScroll` so an off-screen
|
|
1558
|
+
// (or below-the-fold) form is never scrolled into view on mount.
|
|
1559
|
+
const autoFocusNameRef = useRef<string | undefined>(undefined);
|
|
1560
|
+
useEffect(() => {
|
|
1561
|
+
if (!autoFocusFirstRequired || !compact || !options) {
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
if (hasAutoFocusedRef.current) {
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const orderedNames = Object.keys(availableOptions);
|
|
1569
|
+
if (!orderedNames.length) {
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Never grab focus from a control the user is already in — including a field
|
|
1574
|
+
// *inside* this form. If a slow/async schema lets the user start typing
|
|
1575
|
+
// before this first-required scan runs, we must not steal their caret. On a
|
|
1576
|
+
// clean mount focus rests on the body, so the intended "drop into the first
|
|
1577
|
+
// field" still fires. Bailing here leaves `hasAutoFocusedRef` false, so it
|
|
1578
|
+
// retries once focus is free.
|
|
1579
|
+
const active = document.activeElement as HTMLElement | null;
|
|
1580
|
+
if (active && active !== document.body) {
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const target = getFirstAttentionOptionName(orderedNames, (fieldName) => {
|
|
1585
|
+
const schema = options[fieldName];
|
|
1586
|
+
if (!schema) {
|
|
1587
|
+
return undefined;
|
|
1588
|
+
}
|
|
1589
|
+
return {
|
|
1590
|
+
focusable:
|
|
1591
|
+
!readOnly &&
|
|
1592
|
+
!schema.disabled &&
|
|
1593
|
+
!(schema as { readonly?: boolean }).readonly &&
|
|
1594
|
+
!dependencyLockedNames.includes(fieldName),
|
|
1595
|
+
// Single source of truth: the same bucket the read-first status boxes
|
|
1596
|
+
// show, so a filled-but-invalid required row is a target too.
|
|
1597
|
+
needsAttention: getOptionBucket(fieldName) === 'attention',
|
|
1598
|
+
};
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
// Mark done even when nothing needs attention, so later value edits or an
|
|
1602
|
+
// in-place schema reload never re-scan or re-focus. Only a remount arms it
|
|
1603
|
+
// again.
|
|
1604
|
+
hasAutoFocusedRef.current = true;
|
|
1605
|
+
|
|
1606
|
+
if (target) {
|
|
1607
|
+
// Set the ref before the state update so CompactRow's focus timer sees it.
|
|
1608
|
+
autoFocusNameRef.current = target;
|
|
1609
|
+
setExpandedOptions((prev) =>
|
|
1610
|
+
prev.includes(target) ? prev
|
|
1611
|
+
: expandMode === 'multi' ? [...prev, target]
|
|
1612
|
+
: [target]
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
}, [
|
|
1616
|
+
autoFocusFirstRequired,
|
|
1617
|
+
compact,
|
|
1618
|
+
options,
|
|
1619
|
+
availableOptions,
|
|
1620
|
+
readOnly,
|
|
1621
|
+
dependencyLockedNames,
|
|
1622
|
+
getOptionBucket,
|
|
1623
|
+
expandMode,
|
|
1624
|
+
]);
|
|
1625
|
+
|
|
1520
1626
|
// Read-first completion summary (how many shown options have a value set),
|
|
1521
1627
|
// surfaced as a progress meter at the top of the compact form.
|
|
1522
1628
|
const readFirstCompletion = useMemo(
|
|
@@ -1871,6 +1977,7 @@ export const FormEngine = ({
|
|
|
1871
1977
|
showFieldTypes,
|
|
1872
1978
|
showAllDescriptions,
|
|
1873
1979
|
expandedOptions,
|
|
1980
|
+
autoFocusNameRef,
|
|
1874
1981
|
highlightedOptions,
|
|
1875
1982
|
flashedOptions,
|
|
1876
1983
|
infoPanelOverrides,
|
|
@@ -1915,6 +2022,7 @@ export const FormEngine = ({
|
|
|
1915
2022
|
showFieldTypes,
|
|
1916
2023
|
showAllDescriptions,
|
|
1917
2024
|
expandedOptions,
|
|
2025
|
+
autoFocusNameRef,
|
|
1918
2026
|
highlightedOptions,
|
|
1919
2027
|
flashedOptions,
|
|
1920
2028
|
infoPanelOverrides,
|
|
@@ -34,6 +34,12 @@ export interface ICompactRowContext {
|
|
|
34
34
|
// display name in the read-first chip.
|
|
35
35
|
templates?: IReqoreFormTemplates;
|
|
36
36
|
expandedOptions: string[];
|
|
37
|
+
// Name of the field the engine expanded programmatically for
|
|
38
|
+
// `autoFocusFirstRequired`. CompactRow focuses this one with `preventScroll`
|
|
39
|
+
// so an off-screen / below-the-fold form is never scrolled into view on
|
|
40
|
+
// mount. A ref (not state) so it's current when the 60ms focus timer fires,
|
|
41
|
+
// regardless of render batching.
|
|
42
|
+
autoFocusNameRef?: MutableRefObject<string | undefined>;
|
|
37
43
|
highlightedOptions: string[];
|
|
38
44
|
flashedOptions: string[];
|
|
39
45
|
infoPanelOverrides: Record<string, boolean>;
|
|
@@ -502,3 +502,45 @@ export const getReadFirstCompletion = (
|
|
|
502
502
|
|
|
503
503
|
return { total, set, pct };
|
|
504
504
|
};
|
|
505
|
+
|
|
506
|
+
/** What the "first field to fix" selector needs to know about one field.
|
|
507
|
+
* Kept deliberately minimal — both verdicts are supplied by the caller — so the
|
|
508
|
+
* selector is decoupled from the engine's value/schema shapes and unit-testable
|
|
509
|
+
* in isolation. */
|
|
510
|
+
export interface IFirstAttentionFieldMeta {
|
|
511
|
+
/** Whether the field can actually receive focus — the caller folds in
|
|
512
|
+
* disabled / readonly / read-only-form / unmet-dependency gating here. */
|
|
513
|
+
focusable: boolean;
|
|
514
|
+
/** Whether the form considers this field to "need attention". The caller
|
|
515
|
+
* supplies this from the SAME `getOptionBucket` the read-first status boxes
|
|
516
|
+
* use, so it covers every attention case — empty-required, an unsatisfied
|
|
517
|
+
* one-of group, AND a filled-but-invalid value — and the autofocus target can
|
|
518
|
+
* never drift from the visible needs-attention set. */
|
|
519
|
+
needsAttention: boolean;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Pick the first field the user must fix: the first focusable field — in the
|
|
524
|
+
* supplied (already ordered) name list — that the form buckets as "needs
|
|
525
|
+
* attention".
|
|
526
|
+
*
|
|
527
|
+
* Pure and side-effect free. It deliberately does NOT re-derive emptiness /
|
|
528
|
+
* required / validity itself; the caller passes `needsAttention` straight from
|
|
529
|
+
* the engine's own `getOptionBucket`, so there is a single source of truth and
|
|
530
|
+
* the "focus the first field to fix" affordance can never disagree with the
|
|
531
|
+
* per-row status the user sees (including filled-but-invalid rows). Returns
|
|
532
|
+
* `undefined` when nothing needs attention.
|
|
533
|
+
*/
|
|
534
|
+
export const getFirstAttentionOptionName = (
|
|
535
|
+
orderedNames: string[],
|
|
536
|
+
getFieldMeta: (name: string) => IFirstAttentionFieldMeta | undefined
|
|
537
|
+
): string | undefined => {
|
|
538
|
+
for (const name of orderedNames) {
|
|
539
|
+
const meta = getFieldMeta(name);
|
|
540
|
+
if (meta?.focusable && meta.needsAttention) {
|
|
541
|
+
return name;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return undefined;
|
|
546
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { ReqoreControlGroup } from '@qoretechnologies/reqore';
|
|
2
|
+
import { StoryObj } from '@storybook/react-vite';
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { expect, fn, userEvent, waitFor, within } from 'storybook/test';
|
|
5
|
+
|
|
6
|
+
import { StoryMeta } from '../../../../types';
|
|
7
|
+
import { ReqraftBinaryFormField } from './Binary';
|
|
8
|
+
|
|
9
|
+
const meta = {
|
|
10
|
+
component: ReqraftBinaryFormField,
|
|
11
|
+
title: 'Components/Form/Binary',
|
|
12
|
+
args: {
|
|
13
|
+
onChange: fn(),
|
|
14
|
+
'aria-label': 'Binary',
|
|
15
|
+
},
|
|
16
|
+
render(args) {
|
|
17
|
+
const [value, setValue] = useState(args.value);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<ReqoreControlGroup>
|
|
21
|
+
<ReqraftBinaryFormField
|
|
22
|
+
{...args}
|
|
23
|
+
value={value}
|
|
24
|
+
onChange={(value) => {
|
|
25
|
+
args.onChange?.(value);
|
|
26
|
+
setValue(value);
|
|
27
|
+
}}
|
|
28
|
+
/>
|
|
29
|
+
</ReqoreControlGroup>
|
|
30
|
+
);
|
|
31
|
+
},
|
|
32
|
+
} as StoryMeta<typeof ReqraftBinaryFormField>;
|
|
33
|
+
|
|
34
|
+
export default meta;
|
|
35
|
+
type Story = StoryObj<typeof meta>;
|
|
36
|
+
|
|
37
|
+
export const WithValue: Story = {
|
|
38
|
+
args: {
|
|
39
|
+
value: 'cGFzc3dvcmQ=',
|
|
40
|
+
},
|
|
41
|
+
async play({ canvasElement }) {
|
|
42
|
+
const canvas = within(canvasElement);
|
|
43
|
+
const textarea = canvas.getByLabelText('Binary');
|
|
44
|
+
|
|
45
|
+
await expect(textarea).toBeInTheDocument();
|
|
46
|
+
await expect(textarea).toHaveValue('cGFzc3dvcmQ=');
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const TypingUpdatesValue: Story = {
|
|
51
|
+
async play({ canvasElement, args }) {
|
|
52
|
+
const canvas = within(canvasElement);
|
|
53
|
+
const textarea = canvas.getByLabelText('Binary');
|
|
54
|
+
|
|
55
|
+
await userEvent.type(textarea, 'YWJj');
|
|
56
|
+
await expect(textarea).toHaveValue('YWJj');
|
|
57
|
+
await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith('YWJj'), {
|
|
58
|
+
timeout: 500,
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const UploadEncodesFileAsBase64: Story = {
|
|
64
|
+
async play({ canvasElement, args }) {
|
|
65
|
+
const file = new File(['hello'], 'test.bin', { type: 'application/octet-stream' });
|
|
66
|
+
// the hidden file input rendered by the reused ReqraftFileFormField drop zone
|
|
67
|
+
const input = canvasElement.querySelector('input[type="file"]') as HTMLInputElement;
|
|
68
|
+
|
|
69
|
+
await expect(input).toBeInTheDocument();
|
|
70
|
+
await userEvent.upload(input, file);
|
|
71
|
+
|
|
72
|
+
// base64 of "hello" is "aGVsbG8="; the data: URL prefix must be stripped
|
|
73
|
+
await waitFor(() => expect(args.onChange).toHaveBeenLastCalledWith('aGVsbG8='), {
|
|
74
|
+
timeout: 1000,
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const Disabled: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
value: 'cGFzc3dvcmQ=',
|
|
82
|
+
disabled: true,
|
|
83
|
+
},
|
|
84
|
+
async play({ canvasElement }) {
|
|
85
|
+
const canvas = within(canvasElement);
|
|
86
|
+
const textarea = canvas.getByLabelText('Binary');
|
|
87
|
+
|
|
88
|
+
await expect(textarea).toBeDisabled();
|
|
89
|
+
},
|
|
90
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { ReqoreControlGroup, ReqoreTextarea } from '@qoretechnologies/reqore';
|
|
2
|
+
import { IReqoreTextareaProps } from '@qoretechnologies/reqore/dist/components/Textarea';
|
|
3
|
+
import { ChangeEvent, memo, useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { useDebounce } from 'react-use';
|
|
5
|
+
import { IFileFormFieldValue, ReqraftFileFormField } from '../file/File';
|
|
6
|
+
|
|
7
|
+
export interface IReqraftBinaryFormFieldProps extends Omit<IReqoreTextareaProps, 'onChange' | 'value'> {
|
|
8
|
+
value?: string;
|
|
9
|
+
onChange?: (value: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Strip a `data:<mime-type>;base64,` prefix so the emitted value carries raw base64, matching the
|
|
14
|
+
* canonical binary wire form the Qorus server expects. `ReqraftFileFormField` reads uploads with
|
|
15
|
+
* `readAsDataURL`, so its `content` always carries that prefix.
|
|
16
|
+
*/
|
|
17
|
+
const stripDataUrlPrefix = (dataUrl: string): string => {
|
|
18
|
+
const commaIndex = dataUrl.indexOf(',');
|
|
19
|
+
return commaIndex === -1 ? dataUrl : dataUrl.slice(commaIndex + 1);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A field for binary values encoded as base64. The value can be typed or pasted directly into the
|
|
24
|
+
* textarea, or populated by uploading a file — the upload reuses {@link ReqraftFileFormField} (the
|
|
25
|
+
* shared Reqraft file picker / drop zone) rather than a bespoke dropzone; its data-URL content is
|
|
26
|
+
* decoded to raw base64 for the field value.
|
|
27
|
+
*/
|
|
28
|
+
export const ReqraftBinaryFormField = memo(
|
|
29
|
+
({ value, onChange, ...rest }: IReqraftBinaryFormFieldProps) => {
|
|
30
|
+
const [localValue, setLocalValue] = useState<string>(value ?? '');
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
if (value !== localValue) {
|
|
34
|
+
setLocalValue(value ?? '');
|
|
35
|
+
}
|
|
36
|
+
}, [value]);
|
|
37
|
+
|
|
38
|
+
useDebounce(
|
|
39
|
+
() => {
|
|
40
|
+
if (localValue !== value) {
|
|
41
|
+
onChange?.(localValue);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
100,
|
|
45
|
+
[localValue, onChange]
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const handleChange = useCallback((event: ChangeEvent<HTMLTextAreaElement>): void => {
|
|
49
|
+
setLocalValue(event.target.value);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
const handleFileChange = useCallback(
|
|
53
|
+
(file?: IFileFormFieldValue) => {
|
|
54
|
+
const base64 = stripDataUrlPrefix(file?.content ?? '');
|
|
55
|
+
setLocalValue(base64);
|
|
56
|
+
onChange?.(base64);
|
|
57
|
+
},
|
|
58
|
+
[onChange]
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<ReqoreControlGroup vertical fluid>
|
|
63
|
+
<ReqoreTextarea
|
|
64
|
+
scaleWithContent
|
|
65
|
+
fluid
|
|
66
|
+
value={localValue}
|
|
67
|
+
onChange={handleChange}
|
|
68
|
+
onClearClick={() => {
|
|
69
|
+
setLocalValue('');
|
|
70
|
+
onChange?.('');
|
|
71
|
+
}}
|
|
72
|
+
placeholder='Base64-encoded binary value'
|
|
73
|
+
{...rest}
|
|
74
|
+
/>
|
|
75
|
+
<ReqraftFileFormField disabled={rest.disabled} onChange={handleFileChange} />
|
|
76
|
+
</ReqoreControlGroup>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
export default ReqraftBinaryFormField;
|