@qoretechnologies/reqraft 0.10.2 → 0.10.5
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/.claude/CLAUDE.md +5 -0
- package/design/COMPACT_ENGINE_REDESIGN.md +156 -0
- package/design/FORM_ENGINE_COMPACT_UX_PLAN.md +353 -0
- package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
- package/dist/components/form/engine/CompactRow.js +158 -101
- package/dist/components/form/engine/CompactRow.js.map +1 -1
- package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
- package/dist/components/form/engine/CompactToolbar.js +122 -105
- package/dist/components/form/engine/CompactToolbar.js.map +1 -1
- package/dist/components/form/engine/FormEngine.d.ts +9 -1
- package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
- package/dist/components/form/engine/FormEngine.js +272 -82
- package/dist/components/form/engine/FormEngine.js.map +1 -1
- package/dist/components/form/engine/compactRowStyles.d.ts +6 -3
- package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
- package/dist/components/form/engine/compactRowStyles.js +76 -49
- package/dist/components/form/engine/compactRowStyles.js.map +1 -1
- package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
- package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
- package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
- package/dist/components/form/engine/readFirst.d.ts +19 -0
- package/dist/components/form/engine/readFirst.d.ts.map +1 -1
- package/dist/components/form/engine/readFirst.js +22 -1
- package/dist/components/form/engine/readFirst.js.map +1 -1
- package/dist/components/form/engine/variants/VariantCalmTable.d.ts +6 -0
- package/dist/components/form/engine/variants/VariantCalmTable.d.ts.map +1 -0
- package/dist/components/form/engine/variants/VariantCalmTable.js +94 -0
- package/dist/components/form/engine/variants/VariantCalmTable.js.map +1 -0
- package/dist/components/form/engine/variants/VariantCards.d.ts +6 -0
- package/dist/components/form/engine/variants/VariantCards.d.ts.map +1 -0
- package/dist/components/form/engine/variants/VariantCards.js +80 -0
- package/dist/components/form/engine/variants/VariantCards.js.map +1 -0
- package/dist/components/form/engine/variants/VariantFocus.d.ts +7 -0
- package/dist/components/form/engine/variants/VariantFocus.d.ts.map +1 -0
- package/dist/components/form/engine/variants/VariantFocus.js +138 -0
- package/dist/components/form/engine/variants/VariantFocus.js.map +1 -0
- package/dist/components/form/engine/variants/VariantMinimal.d.ts +6 -0
- package/dist/components/form/engine/variants/VariantMinimal.d.ts.map +1 -0
- package/dist/components/form/engine/variants/VariantMinimal.js +73 -0
- package/dist/components/form/engine/variants/VariantMinimal.js.map +1 -0
- package/dist/components/form/engine/variants/focusDemo.d.ts +13 -0
- package/dist/components/form/engine/variants/focusDemo.d.ts.map +1 -0
- package/dist/components/form/engine/variants/focusDemo.js +139 -0
- package/dist/components/form/engine/variants/focusDemo.js.map +1 -0
- package/dist/components/form/engine/variants/variantModel.d.ts +70 -0
- package/dist/components/form/engine/variants/variantModel.d.ts.map +1 -0
- package/dist/components/form/engine/variants/variantModel.js +133 -0
- package/dist/components/form/engine/variants/variantModel.js.map +1 -0
- package/dist/components/form/engine/variants/variantParts.d.ts +79 -0
- package/dist/components/form/engine/variants/variantParts.d.ts.map +1 -0
- package/dist/components/form/engine/variants/variantParts.js +191 -0
- package/dist/components/form/engine/variants/variantParts.js.map +1 -0
- package/dist/components/form/fields/auto/AutoFormField.d.ts +3 -0
- package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
- package/dist/components/form/fields/auto/AutoFormField.js +5 -2
- package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
- package/package.json +1 -1
- package/src/components/form/engine/CompactRow.tsx +273 -258
- package/src/components/form/engine/CompactToolbar.tsx +112 -85
- package/src/components/form/engine/FormEngine.stories.tsx +239 -115
- package/src/components/form/engine/FormEngine.tsx +332 -83
- package/src/components/form/engine/compactRowStyles.ts +221 -144
- package/src/components/form/engine/compactToolbarContext.ts +1 -0
- package/src/components/form/engine/readFirst.ts +35 -0
- package/src/components/form/engine/variants/FormEngineVariants.stories.tsx +119 -0
- package/src/components/form/engine/variants/VariantCalmTable.tsx +242 -0
- package/src/components/form/engine/variants/VariantCards.tsx +212 -0
- package/src/components/form/engine/variants/VariantFocus.tsx +382 -0
- package/src/components/form/engine/variants/VariantMinimal.tsx +170 -0
- package/src/components/form/engine/variants/focusDemo.ts +145 -0
- package/src/components/form/engine/variants/variantModel.ts +216 -0
- package/src/components/form/engine/variants/variantParts.tsx +313 -0
- package/src/components/form/fields/auto/AutoFormField.stories.tsx +9 -2
- package/src/components/form/fields/auto/AutoFormField.tsx +8 -0
|
@@ -3,9 +3,9 @@ import {
|
|
|
3
3
|
ReqoreCollection,
|
|
4
4
|
ReqoreControlGroup,
|
|
5
5
|
ReqoreErrorBoundary,
|
|
6
|
+
ReqoreIcon,
|
|
6
7
|
ReqoreMessage,
|
|
7
8
|
ReqoreP,
|
|
8
|
-
ReqorePanel,
|
|
9
9
|
ReqoreSkeleton,
|
|
10
10
|
ReqoreTag,
|
|
11
11
|
ReqoreTagGroup,
|
|
@@ -86,15 +86,21 @@ import {
|
|
|
86
86
|
StyledCompactPanel,
|
|
87
87
|
StyledGroupBody,
|
|
88
88
|
StyledGroupHeader,
|
|
89
|
-
|
|
89
|
+
StyledRequiredClusterBox,
|
|
90
|
+
StyledRequiredClusterHeader,
|
|
91
|
+
StyledStatusBox,
|
|
92
|
+
StyledStatusBoxGroupLabel,
|
|
90
93
|
} from './compactRowStyles';
|
|
91
94
|
import { OptionFieldMessages } from './OptionFieldMessages';
|
|
92
95
|
import { OptionsHelpDialog } from './OptionsHelpDialog';
|
|
93
96
|
import {
|
|
94
97
|
getOptionGroup,
|
|
95
98
|
getOptionGroupLabel,
|
|
99
|
+
getReadFirstBucket,
|
|
96
100
|
getReadFirstCompletion,
|
|
101
|
+
getReadFirstStatus,
|
|
97
102
|
isOptionValueEmpty,
|
|
103
|
+
TReadFirstStatus,
|
|
98
104
|
} from './readFirst';
|
|
99
105
|
|
|
100
106
|
// Re-export types for consumers
|
|
@@ -469,6 +475,14 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
|
|
|
469
475
|
* form should line up with the section description above it. Default `false`.
|
|
470
476
|
*/
|
|
471
477
|
compactFlush?: boolean;
|
|
478
|
+
/**
|
|
479
|
+
* Compact mode only: this form is an EMBEDDED sub-form (e.g. an arg_schema
|
|
480
|
+
* field's nested form) rather than the top-level scroller. It doesn't own a
|
|
481
|
+
* scroll context, so the toolbar isn't sticky and its header drops the dark
|
|
482
|
+
* blurred backdrop (and the stacking context that goes with it) — it sits
|
|
483
|
+
* transparently inside the parent's edit card. Default `false`.
|
|
484
|
+
*/
|
|
485
|
+
compactNested?: boolean;
|
|
472
486
|
/** Compact mode only: per-group display metadata (label / icon / subtitle /
|
|
473
487
|
* order) — the server only sends the bare group key. */
|
|
474
488
|
groups?: Record<string, IFormEngineGroup>;
|
|
@@ -525,6 +539,7 @@ export const FormEngine = ({
|
|
|
525
539
|
showTypeToggle = true,
|
|
526
540
|
compact,
|
|
527
541
|
compactFlush = false,
|
|
542
|
+
compactNested = false,
|
|
528
543
|
commitMode = 'immediate',
|
|
529
544
|
expandMode = 'single',
|
|
530
545
|
onCommit,
|
|
@@ -560,6 +575,10 @@ export const FormEngine = ({
|
|
|
560
575
|
const [showInvalidOptionsOnly, setShowInvalidOptionsOnly] = useState<boolean>(false);
|
|
561
576
|
// Which options are expanded into their editor (several can be open at once).
|
|
562
577
|
const [expandedOptions, setExpandedOptions] = useState<string[]>([]);
|
|
578
|
+
// Remembers each row's last settled status box, so an actively-edited field
|
|
579
|
+
// stays put when its status flips (e.g. becomes valid) instead of jumping to
|
|
580
|
+
// another box mid-edit and stealing focus. Keyed by option name.
|
|
581
|
+
const settledBucket = useRef<Record<string, 'attention' | 'set' | 'optional'>>({});
|
|
563
582
|
// Measured form width (not viewport — the form lives in drawers/panels of
|
|
564
583
|
// arbitrary width) drives the stacked narrow layout.
|
|
565
584
|
const [compactWrapRef, { width: compactWrapWidth }] = useMeasure<HTMLDivElement>();
|
|
@@ -614,9 +633,15 @@ export const FormEngine = ({
|
|
|
614
633
|
const flashTimeout = useRef<ReturnType<typeof setTimeout>>();
|
|
615
634
|
const flashOptions = useCallback((optionNames: string[], scrollToFirst = false) => {
|
|
616
635
|
if (scrollToFirst && optionNames[0]) {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
636
|
+
// Defer to the next frame: when this fires for a field that just changed
|
|
637
|
+
// panels, its row has only just re-mounted in the new box — scrolling in the
|
|
638
|
+
// same tick targets the stale (pre-move) layout, so the page doesn't budge.
|
|
639
|
+
// A rAF lets the new position settle first.
|
|
640
|
+
requestAnimationFrame(() => {
|
|
641
|
+
document
|
|
642
|
+
.querySelector(`.readfirst-row[data-field="${optionNames[0]}"]`)
|
|
643
|
+
?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
644
|
+
});
|
|
620
645
|
}
|
|
621
646
|
setFlashedOptions(optionNames);
|
|
622
647
|
clearTimeout(flashTimeout.current);
|
|
@@ -627,6 +652,28 @@ export const FormEngine = ({
|
|
|
627
652
|
[flashOptions]
|
|
628
653
|
);
|
|
629
654
|
useEffect(() => () => clearTimeout(flashTimeout.current), []);
|
|
655
|
+
|
|
656
|
+
// Follow a field across panels: when its status bucket changes — e.g. you fill
|
|
657
|
+
// an optional field and it jumps to Set / Needs attention — scroll to its new
|
|
658
|
+
// row and flash it so it's easy to keep track of. `settledBucket` holds each
|
|
659
|
+
// field's current panel (frozen while the field is being edited, it re-buckets
|
|
660
|
+
// on collapse), so diffing it after every render catches the move the instant it
|
|
661
|
+
// lands in the new panel. Runs every render; the diff is cheap and only fires a
|
|
662
|
+
// scroll on an ACTUAL move of a non-expanded field.
|
|
663
|
+
const prevSettledBucket = useRef<Record<string, 'attention' | 'set' | 'optional'>>({});
|
|
664
|
+
useEffect(() => {
|
|
665
|
+
if (!compact) return;
|
|
666
|
+
const cur = settledBucket.current;
|
|
667
|
+
const prev = prevSettledBucket.current;
|
|
668
|
+
const moved = Object.keys(cur).find(
|
|
669
|
+
(name) => prev[name] && prev[name] !== cur[name] && !expandedOptions.includes(name)
|
|
670
|
+
);
|
|
671
|
+
prevSettledBucket.current = { ...cur };
|
|
672
|
+
if (moved) {
|
|
673
|
+
flashOptions([moved], true);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
|
|
630
677
|
const compactNarrow = !!compactWrapWidth && compactWrapWidth < 480;
|
|
631
678
|
// Info panels auto-open on Tier-1 content; the per-row user override sticks.
|
|
632
679
|
const [infoPanelOverrides, setInfoPanelOverrides] = useState<Record<string, boolean>>({});
|
|
@@ -1028,6 +1075,10 @@ export const FormEngine = ({
|
|
|
1028
1075
|
meta: undefined,
|
|
1029
1076
|
};
|
|
1030
1077
|
});
|
|
1078
|
+
// Collapse it too: a removed field drops back to the (collapsed) Optional box
|
|
1079
|
+
// as a quiet addable row — if it was being edited, that editor must close
|
|
1080
|
+
// rather than linger as an open editor for a field that's no longer added.
|
|
1081
|
+
setExpandedOptions((prev) => prev.filter((name) => name !== optionName));
|
|
1031
1082
|
}, []);
|
|
1032
1083
|
|
|
1033
1084
|
const handleAddOptionalFieldChange = useCallback(
|
|
@@ -1269,6 +1320,73 @@ export const FormEngine = ({
|
|
|
1269
1320
|
);
|
|
1270
1321
|
}, [showInvalidOptionsOnly, JSON.stringify(availableOptions)]);
|
|
1271
1322
|
|
|
1323
|
+
// Read-first STATUS / BOX for one option — lifted to component scope so the
|
|
1324
|
+
// status boxes (renderCompact) and the header's "needs attention" count share
|
|
1325
|
+
// exactly one definition. One-of group members travel together (bucket by the
|
|
1326
|
+
// group's satisfaction); everything else by its own status.
|
|
1327
|
+
const schemaMsgIntent = useCallback(
|
|
1328
|
+
(name: string): 'danger' | 'warning' | undefined => {
|
|
1329
|
+
const msgs = ((options?.[name] as { messages?: Array<{ intent?: string }> } | undefined)
|
|
1330
|
+
?.messages || []) as Array<{ intent?: string }>;
|
|
1331
|
+
if (msgs.some((m) => m.intent === 'danger')) return 'danger';
|
|
1332
|
+
if (msgs.some((m) => m.intent === 'warning')) return 'warning';
|
|
1333
|
+
return undefined;
|
|
1334
|
+
},
|
|
1335
|
+
[JSON.stringify(options)]
|
|
1336
|
+
);
|
|
1337
|
+
const getOptionStatus = useCallback(
|
|
1338
|
+
(name: string, hidden = false): TReadFirstStatus => {
|
|
1339
|
+
if (hidden) return 'optional';
|
|
1340
|
+
const schema = options?.[name];
|
|
1341
|
+
const type = (schema?.ui_type || schema?.type) as TQorusType;
|
|
1342
|
+
const value = (availableOptions as TQorusForm)?.[name]?.value;
|
|
1343
|
+
const empty = isOptionValueEmpty(value);
|
|
1344
|
+
const reqGroups = (schema?.required_groups as string[] | undefined) || [];
|
|
1345
|
+
const required = !!(schema?.required || reqGroups.length);
|
|
1346
|
+
const covered =
|
|
1347
|
+
empty &&
|
|
1348
|
+
reqGroups.some((g) => {
|
|
1349
|
+
const by = requiredGroupsInfo.satisfiedBy[g];
|
|
1350
|
+
return !!by && by !== name;
|
|
1351
|
+
});
|
|
1352
|
+
const msgIntent = schemaMsgIntent(name);
|
|
1353
|
+
const invalid = (!empty && !isOptionValid(name, type, value)) || msgIntent === 'danger';
|
|
1354
|
+
return getReadFirstStatus({
|
|
1355
|
+
empty,
|
|
1356
|
+
required,
|
|
1357
|
+
covered,
|
|
1358
|
+
invalid,
|
|
1359
|
+
warned: msgIntent === 'warning',
|
|
1360
|
+
});
|
|
1361
|
+
},
|
|
1362
|
+
[
|
|
1363
|
+
JSON.stringify(options),
|
|
1364
|
+
JSON.stringify(availableOptions),
|
|
1365
|
+
isOptionValid,
|
|
1366
|
+
requiredGroupsInfo,
|
|
1367
|
+
schemaMsgIntent,
|
|
1368
|
+
]
|
|
1369
|
+
);
|
|
1370
|
+
const getOptionBucket = useCallback(
|
|
1371
|
+
(name: string, hidden = false): 'attention' | 'set' | 'optional' => {
|
|
1372
|
+
if (!hidden) {
|
|
1373
|
+
const reqGroups = (options?.[name]?.required_groups as string[] | undefined) || [];
|
|
1374
|
+
if (reqGroups.length) {
|
|
1375
|
+
return reqGroups.some((g) => !requiredGroupsInfo.satisfiedBy[g]) ? 'attention' : 'set';
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
return getReadFirstBucket(getOptionStatus(name, hidden));
|
|
1379
|
+
},
|
|
1380
|
+
[JSON.stringify(options), requiredGroupsInfo, getOptionStatus]
|
|
1381
|
+
);
|
|
1382
|
+
// How many fields are in the "Needs attention" box — drives the header link.
|
|
1383
|
+
const readFirstAttentionCount = useMemo(
|
|
1384
|
+
() =>
|
|
1385
|
+
Object.keys(availableOptions || {}).filter((name) => getOptionBucket(name) === 'attention')
|
|
1386
|
+
.length,
|
|
1387
|
+
[JSON.stringify(availableOptions), getOptionBucket]
|
|
1388
|
+
);
|
|
1389
|
+
|
|
1272
1390
|
const getIntent = useCallback(
|
|
1273
1391
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
1274
1392
|
(optName: string, type: TQorusType, optValue: any, _op: any): TReqoreIntent => {
|
|
@@ -1430,20 +1548,37 @@ export const FormEngine = ({
|
|
|
1430
1548
|
const operatorParts = fixOperatorValue(other.op);
|
|
1431
1549
|
return (
|
|
1432
1550
|
<>
|
|
1433
|
-
{(
|
|
1434
|
-
|
|
1551
|
+
{(() => {
|
|
1552
|
+
const schemaMsgs = (
|
|
1553
|
+
suppressSchemaMessages ? [] : (options?.[optionName] as any)?.messages || []
|
|
1554
|
+
) as { intent?: string; title?: string; content?: string }[];
|
|
1555
|
+
if (!schemaMsgs.length) return null;
|
|
1556
|
+
const items = schemaMsgs.map(({ intent, title, content }, index) => (
|
|
1435
1557
|
<ReqoreMessage
|
|
1436
|
-
intent={intent}
|
|
1558
|
+
intent={intent as never}
|
|
1437
1559
|
title={title}
|
|
1438
1560
|
key={title || index}
|
|
1439
1561
|
opaque={false}
|
|
1440
1562
|
size='small'
|
|
1441
|
-
|
|
1563
|
+
// Compact: flat (no border) to match the read-row info panels;
|
|
1564
|
+
// classic forms keep the bordered, bottom-margined message.
|
|
1565
|
+
flat={compact || undefined}
|
|
1566
|
+
margin={compact ? undefined : 'bottom'}
|
|
1442
1567
|
>
|
|
1443
1568
|
{content}
|
|
1444
1569
|
</ReqoreMessage>
|
|
1445
|
-
)
|
|
1446
|
-
|
|
1570
|
+
));
|
|
1571
|
+
// Compact: stack them in a 4px-gap panel so a field's messages look
|
|
1572
|
+
// identical whether the row is collapsed (read panel) or expanded.
|
|
1573
|
+
return compact ?
|
|
1574
|
+
<div
|
|
1575
|
+
className='options-readfirst-info-panel'
|
|
1576
|
+
style={{ display: 'flex', flexFlow: 'column', gap: 4, marginBottom: 8 }}
|
|
1577
|
+
>
|
|
1578
|
+
{items}
|
|
1579
|
+
</div>
|
|
1580
|
+
: <>{items}</>;
|
|
1581
|
+
})()}
|
|
1447
1582
|
{operators && size(operators) ?
|
|
1448
1583
|
<>
|
|
1449
1584
|
<ReqoreControlGroup fill wrap className='operators'>
|
|
@@ -1498,6 +1633,9 @@ export const FormEngine = ({
|
|
|
1498
1633
|
<TemplateField
|
|
1499
1634
|
fluid
|
|
1500
1635
|
{...(options?.[optionName] as any)}
|
|
1636
|
+
// Propagate compact so an arg_schema field renders a COMPACT sub-form
|
|
1637
|
+
// (consistent with the parent) rather than the classic FormEngine.
|
|
1638
|
+
compact={compact}
|
|
1501
1639
|
// SEAM: forwarded through TemplateField's rest-spread to AutoFormField,
|
|
1502
1640
|
// which renders consumer-injected editors by field type/ui_type.
|
|
1503
1641
|
componentOverrides={componentOverrides}
|
|
@@ -1726,6 +1864,7 @@ export const FormEngine = ({
|
|
|
1726
1864
|
() => ({
|
|
1727
1865
|
readOnly,
|
|
1728
1866
|
invalidCount: size(validityData.invalidFields),
|
|
1867
|
+
attentionCount: readFirstAttentionCount,
|
|
1729
1868
|
completion: readFirstCompletion,
|
|
1730
1869
|
showInvalidOnly: showInvalidOptionsOnly,
|
|
1731
1870
|
onToggleInvalidOnly: handleToggleInvalidOnly,
|
|
@@ -1751,6 +1890,7 @@ export const FormEngine = ({
|
|
|
1751
1890
|
[
|
|
1752
1891
|
readOnly,
|
|
1753
1892
|
validityData,
|
|
1893
|
+
readFirstAttentionCount,
|
|
1754
1894
|
readFirstCompletion,
|
|
1755
1895
|
showInvalidOptionsOnly,
|
|
1756
1896
|
handleToggleInvalidOnly,
|
|
@@ -1809,15 +1949,18 @@ export const FormEngine = ({
|
|
|
1809
1949
|
pushRow(optionName, false);
|
|
1810
1950
|
}
|
|
1811
1951
|
});
|
|
1812
|
-
//
|
|
1813
|
-
//
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1952
|
+
// Surface EVERY not-yet-added optional field as an addable (hidden) row, so
|
|
1953
|
+
// the whole schema is browsable inline — they all land in the Optional box
|
|
1954
|
+
// (hidden ⇒ 'optional' bucket) instead of being buried in the Fields menu.
|
|
1955
|
+
// Narrowed by the same filters as the listed rows (search query + required-
|
|
1956
|
+
// only). availableOptions (listed) and filteredOptions (these) are disjoint —
|
|
1957
|
+
// the former is built from fixedValue keys, the latter excludes them — so a
|
|
1958
|
+
// field is never both a listed and a hidden row.
|
|
1959
|
+
forEach(filteredOptions, (_schema, optionName) => {
|
|
1960
|
+
if (matchesFilters(optionName)) {
|
|
1961
|
+
pushRow(optionName, true);
|
|
1962
|
+
}
|
|
1963
|
+
});
|
|
1821
1964
|
|
|
1822
1965
|
// User sort (Fields menu → "Sort by"), applied WITHIN each group so the
|
|
1823
1966
|
// group sections and the required-group rails are preserved. Schema order is
|
|
@@ -1863,6 +2006,67 @@ export const FormEngine = ({
|
|
|
1863
2006
|
return groupOrder.indexOf(a) - groupOrder.indexOf(b);
|
|
1864
2007
|
});
|
|
1865
2008
|
|
|
2009
|
+
// Read-first STATUS per row → one of three boxes (Needs attention / Set /
|
|
2010
|
+
// Optional), via the component-scope getOptionBucket (shared with the header
|
|
2011
|
+
// count and the row dot, so box ↔ dot ↔ header always agree). Buckets keep
|
|
2012
|
+
// schema-group order (thin sub-labels) and the required-group clustering: an
|
|
2013
|
+
// unmet one-of group's members all land in attention and still rail up.
|
|
2014
|
+
type TRowEntry = { name: string; hidden: boolean };
|
|
2015
|
+
type TBucketKey = 'attention' | 'set' | 'optional';
|
|
2016
|
+
const buckets: Record<TBucketKey, Record<string, TRowEntry[]>> = {
|
|
2017
|
+
attention: {},
|
|
2018
|
+
set: {},
|
|
2019
|
+
optional: {},
|
|
2020
|
+
};
|
|
2021
|
+
const bucketGroups: Record<TBucketKey, string[]> = { attention: [], set: [], optional: [] };
|
|
2022
|
+
// Freeze the box of any field currently being edited (or whose one-of group
|
|
2023
|
+
// has an edited member) to its last settled box — so finishing an edit that
|
|
2024
|
+
// flips its status doesn't remount it in another box and steal focus.
|
|
2025
|
+
const stableBucketOf = (entry: TRowEntry): TBucketKey => {
|
|
2026
|
+
const fresh = getOptionBucket(entry.name, entry.hidden);
|
|
2027
|
+
const groupBeingEdited =
|
|
2028
|
+
!entry.hidden &&
|
|
2029
|
+
((options?.[entry.name]?.required_groups as string[] | undefined) || []).some((g) =>
|
|
2030
|
+
(requiredGroupsInfo.members[g] || []).some((m) => expandedOptions.includes(m))
|
|
2031
|
+
);
|
|
2032
|
+
if (!entry.hidden && (expandedOptions.includes(entry.name) || groupBeingEdited)) {
|
|
2033
|
+
const memo = settledBucket.current[entry.name];
|
|
2034
|
+
if (memo) return memo;
|
|
2035
|
+
}
|
|
2036
|
+
settledBucket.current[entry.name] = fresh;
|
|
2037
|
+
return fresh;
|
|
2038
|
+
};
|
|
2039
|
+
groupKeys.forEach((groupName) => {
|
|
2040
|
+
grouped[groupName].forEach((entry) => {
|
|
2041
|
+
const b = stableBucketOf(entry);
|
|
2042
|
+
if (!buckets[b][groupName]) {
|
|
2043
|
+
buckets[b][groupName] = [];
|
|
2044
|
+
bucketGroups[b].push(groupName);
|
|
2045
|
+
}
|
|
2046
|
+
buckets[b][groupName].push(entry);
|
|
2047
|
+
});
|
|
2048
|
+
});
|
|
2049
|
+
const bucketCount = (b: TBucketKey) =>
|
|
2050
|
+
bucketGroups[b].reduce((n, g) => n + buckets[b][g].length, 0);
|
|
2051
|
+
// 'general' / 'optional' are the SYNTHETIC fallback group keys getOptionGroup
|
|
2052
|
+
// assigns to fields with no explicit `group` — printing a "General"/"Optional"
|
|
2053
|
+
// sub-label for those is just noise, so suppress it. BUT a consumer may also
|
|
2054
|
+
// use 'general' as a REAL group (defining it in the `groups` prop and tagging
|
|
2055
|
+
// fields with `group: 'general'`); in that case it's a named group like any
|
|
2056
|
+
// other and DOES get its sub-label.
|
|
2057
|
+
const showGroupSubLabel = (groupName: string) =>
|
|
2058
|
+
(groupName !== 'general' && groupName !== 'optional') || !!groups?.[groupName];
|
|
2059
|
+
const STATUS_BOXES: Array<{
|
|
2060
|
+
key: TBucketKey;
|
|
2061
|
+
label: string;
|
|
2062
|
+
intent?: 'warning' | 'success';
|
|
2063
|
+
icon: IReqoreIconName;
|
|
2064
|
+
}> = [
|
|
2065
|
+
{ key: 'attention', label: 'Needs attention', intent: 'warning', icon: 'ErrorWarningLine' },
|
|
2066
|
+
{ key: 'set', label: 'Set', intent: 'success', icon: 'CheckLine' },
|
|
2067
|
+
{ key: 'optional', label: 'Optional', icon: 'CheckboxBlankCircleLine' },
|
|
2068
|
+
];
|
|
2069
|
+
|
|
1866
2070
|
// Build the rows for one group: contiguous required-group members are pulled
|
|
1867
2071
|
// together at the first member's slot and rendered as a connected rail (flat
|
|
1868
2072
|
// rows — no wrapper — so the value surface applies normally; the rail + nodes
|
|
@@ -1891,7 +2095,9 @@ export const FormEngine = ({
|
|
|
1891
2095
|
clusterLast={clusterLast}
|
|
1892
2096
|
/>
|
|
1893
2097
|
);
|
|
1894
|
-
|
|
2098
|
+
// (Clustering runs in narrow mode too now — the "One of the below is
|
|
2099
|
+
// required" box wraps the members regardless of width; it no longer relies
|
|
2100
|
+
// on a contiguous rail.)
|
|
1895
2101
|
const emitted = new Set<string>();
|
|
1896
2102
|
const groupOf = (name: string) =>
|
|
1897
2103
|
(options?.[name]?.required_groups as string[] | undefined)?.[0];
|
|
@@ -1902,9 +2108,27 @@ export const FormEngine = ({
|
|
|
1902
2108
|
const memberEntries = names.filter((e) => !e.hidden && groupOf(e.name) === grp);
|
|
1903
2109
|
if (memberEntries.length < 2) return renderRow(entry, false);
|
|
1904
2110
|
emitted.add(grp);
|
|
1905
|
-
|
|
2111
|
+
const railed = memberEntries.map((e, idx) =>
|
|
1906
2112
|
renderRow(e, true, idx === 0, idx === memberEntries.length - 1)
|
|
1907
2113
|
);
|
|
2114
|
+
// An UNMET one-of group gets the explicit "One of the below is required"
|
|
2115
|
+
// box (the Focus cluster). A met group needs no banner — the rail + the
|
|
2116
|
+
// "Covers"/"Covered by" chips already say which member satisfies it.
|
|
2117
|
+
if (requiredGroupsInfo.satisfiedBy[grp]) return railed;
|
|
2118
|
+
return (
|
|
2119
|
+
<StyledRequiredClusterBox
|
|
2120
|
+
key={grp}
|
|
2121
|
+
className='options-readfirst-required-cluster'
|
|
2122
|
+
$border={`${cWarning}33`}
|
|
2123
|
+
$tint={`${cWarning}0d`}
|
|
2124
|
+
>
|
|
2125
|
+
<StyledRequiredClusterHeader $color={cWarning}>
|
|
2126
|
+
<ReqoreIcon icon='LinkM' size='11px' style={{ color: cWarning }} />
|
|
2127
|
+
One of the below is required
|
|
2128
|
+
</StyledRequiredClusterHeader>
|
|
2129
|
+
{railed}
|
|
2130
|
+
</StyledRequiredClusterBox>
|
|
2131
|
+
);
|
|
1908
2132
|
});
|
|
1909
2133
|
};
|
|
1910
2134
|
|
|
@@ -1922,19 +2146,35 @@ export const FormEngine = ({
|
|
|
1922
2146
|
<StyledCompactWrap
|
|
1923
2147
|
ref={setCompactWrap}
|
|
1924
2148
|
className='options-readfirst-scroll'
|
|
1925
|
-
|
|
2149
|
+
// A nested sub-form sits flush inside the parent's card — no outer
|
|
2150
|
+
// gutter (the card already provides the breathing room).
|
|
2151
|
+
$flush={compactFlush || compactNested}
|
|
1926
2152
|
>
|
|
1927
2153
|
<StyledCompactPanel
|
|
1928
|
-
|
|
2154
|
+
// The top-level form scrolls, so its toolbar STICKS and carries a
|
|
2155
|
+
// dark blurred backdrop so content ghosts cleanly beneath it. A
|
|
2156
|
+
// nested (arg_schema) sub-form owns no scroll context — drop the
|
|
2157
|
+
// sticky, the backdrop, and the stacking context so its header is
|
|
2158
|
+
// transparent inside the parent's card.
|
|
2159
|
+
$headerBg={compactNested ? 'transparent' : headerBg}
|
|
2160
|
+
$nested={compactNested}
|
|
1929
2161
|
flat
|
|
1930
|
-
|
|
2162
|
+
// No panel background: the form sits transparently on whatever
|
|
2163
|
+
// hosts it (page, drawer, or — for an arg_schema field — the
|
|
2164
|
+
// parent's edit card) instead of stacking its own dark surface.
|
|
2165
|
+
// The status boxes keep their own tints; the sticky toolbar keeps
|
|
2166
|
+
// its blurred header via the $headerBg override.
|
|
2167
|
+
transparent
|
|
2168
|
+
stickyHeader={!compactNested}
|
|
1931
2169
|
padded={false}
|
|
1932
2170
|
actions={compactHeaderActions}
|
|
1933
2171
|
contentStyle={{
|
|
1934
2172
|
display: 'flex',
|
|
1935
2173
|
flexFlow: 'column',
|
|
1936
2174
|
gap: '10px',
|
|
1937
|
-
|
|
2175
|
+
// Nested sub-form: no surrounding panel padding (it's flush in
|
|
2176
|
+
// the parent card); top-level keeps a small bottom gutter.
|
|
2177
|
+
padding: compactNested ? '0' : '0 0 12px',
|
|
1938
2178
|
}}
|
|
1939
2179
|
>
|
|
1940
2180
|
{size(groupKeys) === 0 ?
|
|
@@ -1943,78 +2183,61 @@ export const FormEngine = ({
|
|
|
1943
2183
|
</ReqoreMessage>
|
|
1944
2184
|
: null}
|
|
1945
2185
|
|
|
1946
|
-
{
|
|
1947
|
-
const
|
|
1948
|
-
const
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
2186
|
+
{STATUS_BOXES.map((box) => {
|
|
2187
|
+
const groupsInBox = bucketGroups[box.key];
|
|
2188
|
+
const count = bucketCount(box.key);
|
|
2189
|
+
if (!count) return null;
|
|
2190
|
+
const accent =
|
|
2191
|
+
box.key === 'attention' ? cWarning
|
|
2192
|
+
: box.key === 'set' ? cSuccess
|
|
2193
|
+
: cMuted;
|
|
2194
|
+
// The muted "Optional" box reads as a quieter, recessed
|
|
2195
|
+
// surface — a touch darker than the page rather than the
|
|
2196
|
+
// faint grey tint the accent would give.
|
|
2197
|
+
const boxBg =
|
|
2198
|
+
box.key === 'optional' ?
|
|
2199
|
+
changeDarkness(getMainBackgroundColor(theme), 0.06)
|
|
2200
|
+
: undefined;
|
|
1960
2201
|
return (
|
|
1961
|
-
<
|
|
1962
|
-
|
|
2202
|
+
<StyledStatusBox
|
|
2203
|
+
$accent={accent}
|
|
2204
|
+
$bg={boxBg}
|
|
2205
|
+
key={box.key}
|
|
1963
2206
|
flat
|
|
1964
2207
|
minimal
|
|
1965
2208
|
collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}
|
|
1966
2209
|
collapsible
|
|
2210
|
+
// The Optional box now holds every not-yet-added field, so
|
|
2211
|
+
// it starts COLLAPSED to keep the form focused on what's in
|
|
2212
|
+
// use. But a SEARCH must surface matching addable fields —
|
|
2213
|
+
// and ReqorePanel unmounts collapsed content — so force it
|
|
2214
|
+
// open whenever a query is active. (isCollapsed is the
|
|
2215
|
+
// panel's controllable state; manual toggling still works
|
|
2216
|
+
// when no query is set.)
|
|
2217
|
+
isCollapsed={box.key === 'optional' && !query}
|
|
1967
2218
|
label={
|
|
1968
2219
|
<StyledGroupHeader>
|
|
1969
|
-
<ReqoreP effect={{ weight: 'bold' }} size='
|
|
1970
|
-
{
|
|
2220
|
+
<ReqoreP effect={{ weight: 'bold' }} size='normal'>
|
|
2221
|
+
{box.label}
|
|
1971
2222
|
</ReqoreP>
|
|
1972
|
-
<
|
|
1973
|
-
|
|
1974
|
-
readOnly
|
|
1975
|
-
size='tiny'
|
|
2223
|
+
<ReqoreTag
|
|
2224
|
+
size='small'
|
|
1976
2225
|
minimal
|
|
1977
|
-
flat
|
|
1978
2226
|
compact
|
|
1979
|
-
|
|
1980
|
-
{
|
|
1981
|
-
: invalidCount ?
|
|
1982
|
-
{
|
|
1983
|
-
label: `${invalidCount} to resolve`,
|
|
1984
|
-
intent: 'warning' as const,
|
|
1985
|
-
icon: 'ErrorWarningLine' as const,
|
|
1986
|
-
}
|
|
1987
|
-
: {
|
|
1988
|
-
label: 'All set',
|
|
1989
|
-
intent: 'success' as const,
|
|
1990
|
-
icon: 'CheckLine' as const,
|
|
1991
|
-
})}
|
|
2227
|
+
intent={box.intent}
|
|
2228
|
+
label={String(count)}
|
|
1992
2229
|
/>
|
|
1993
2230
|
</StyledGroupHeader>
|
|
1994
2231
|
}
|
|
1995
|
-
icon={
|
|
2232
|
+
icon={box.icon}
|
|
1996
2233
|
className='options-readfirst-group'
|
|
1997
2234
|
padded={false}
|
|
1998
2235
|
contentStyle={{ padding: '4px 4px 6px' }}
|
|
1999
2236
|
>
|
|
2000
|
-
{
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
// Indent to the same content line as the rows (StyledGroupBody's
|
|
2005
|
-
// `margin-left` clamp) so the subtitle sits tucked under the group
|
|
2006
|
-
// name instead of at the panel edge — and clears the group's
|
|
2007
|
-
// vertical rule (left:16px) rather than crossing it.
|
|
2008
|
-
style={{
|
|
2009
|
-
marginTop: 2,
|
|
2010
|
-
marginBottom: 8,
|
|
2011
|
-
marginLeft: GROUP_INDENT,
|
|
2012
|
-
paddingRight: 10,
|
|
2013
|
-
}}
|
|
2014
|
-
>
|
|
2015
|
-
{groupConfig.subtitle}
|
|
2016
|
-
</ReqoreP>
|
|
2017
|
-
: null}
|
|
2237
|
+
{/* ONE group body per box: every field block (and the
|
|
2238
|
+
thin schema sub-labels) is a direct flex child, so the
|
|
2239
|
+
inter-field gap is identical everywhere — including
|
|
2240
|
+
across schema-group boundaries. */}
|
|
2018
2241
|
<StyledGroupBody
|
|
2019
2242
|
$divider={cDivider}
|
|
2020
2243
|
$hover={cHover}
|
|
@@ -2024,9 +2247,35 @@ export const FormEngine = ({
|
|
|
2024
2247
|
$lineColor={cGroupLine}
|
|
2025
2248
|
className={compactNarrow ? 'readfirst-narrow' : undefined}
|
|
2026
2249
|
>
|
|
2027
|
-
{
|
|
2250
|
+
{groupsInBox.map((groupName) => {
|
|
2251
|
+
const groupConfig = groups?.[groupName];
|
|
2252
|
+
return (
|
|
2253
|
+
<React.Fragment key={groupName}>
|
|
2254
|
+
{showGroupSubLabel(groupName) ?
|
|
2255
|
+
<StyledStatusBoxGroupLabel>
|
|
2256
|
+
{getOptionGroupLabel(groupName, groups)}
|
|
2257
|
+
</StyledStatusBoxGroupLabel>
|
|
2258
|
+
: null}
|
|
2259
|
+
{showGroupSubLabel(groupName) && groupConfig?.subtitle ?
|
|
2260
|
+
<ReqoreP
|
|
2261
|
+
size='small'
|
|
2262
|
+
effect={{ opacity: 0.6 }}
|
|
2263
|
+
style={{
|
|
2264
|
+
marginTop: 2,
|
|
2265
|
+
marginBottom: 8,
|
|
2266
|
+
marginLeft: GROUP_INDENT,
|
|
2267
|
+
paddingRight: 10,
|
|
2268
|
+
}}
|
|
2269
|
+
>
|
|
2270
|
+
{groupConfig.subtitle}
|
|
2271
|
+
</ReqoreP>
|
|
2272
|
+
: null}
|
|
2273
|
+
{renderGroupRows(buckets[box.key][groupName])}
|
|
2274
|
+
</React.Fragment>
|
|
2275
|
+
);
|
|
2276
|
+
})}
|
|
2028
2277
|
</StyledGroupBody>
|
|
2029
|
-
</
|
|
2278
|
+
</StyledStatusBox>
|
|
2030
2279
|
);
|
|
2031
2280
|
})}
|
|
2032
2281
|
</StyledCompactPanel>
|