@qoretechnologies/reqraft 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/design/COMPACT_ENGINE_REDESIGN.md +156 -0
  2. package/design/FORM_ENGINE_COMPACT_UX_PLAN.md +353 -0
  3. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  4. package/dist/components/form/engine/CompactRow.js +153 -94
  5. package/dist/components/form/engine/CompactRow.js.map +1 -1
  6. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  7. package/dist/components/form/engine/CompactToolbar.js +130 -94
  8. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  9. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  10. package/dist/components/form/engine/FormEngine.js +181 -45
  11. package/dist/components/form/engine/FormEngine.js.map +1 -1
  12. package/dist/components/form/engine/compactRowStyles.d.ts +6 -3
  13. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  14. package/dist/components/form/engine/compactRowStyles.js +70 -48
  15. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  16. package/dist/components/form/engine/compactToolbarContext.d.ts +1 -0
  17. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  18. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  19. package/dist/components/form/engine/readFirst.d.ts +19 -0
  20. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  21. package/dist/components/form/engine/readFirst.js +22 -1
  22. package/dist/components/form/engine/readFirst.js.map +1 -1
  23. package/dist/components/form/engine/variants/VariantCalmTable.d.ts +6 -0
  24. package/dist/components/form/engine/variants/VariantCalmTable.d.ts.map +1 -0
  25. package/dist/components/form/engine/variants/VariantCalmTable.js +94 -0
  26. package/dist/components/form/engine/variants/VariantCalmTable.js.map +1 -0
  27. package/dist/components/form/engine/variants/VariantCards.d.ts +6 -0
  28. package/dist/components/form/engine/variants/VariantCards.d.ts.map +1 -0
  29. package/dist/components/form/engine/variants/VariantCards.js +80 -0
  30. package/dist/components/form/engine/variants/VariantCards.js.map +1 -0
  31. package/dist/components/form/engine/variants/VariantFocus.d.ts +7 -0
  32. package/dist/components/form/engine/variants/VariantFocus.d.ts.map +1 -0
  33. package/dist/components/form/engine/variants/VariantFocus.js +138 -0
  34. package/dist/components/form/engine/variants/VariantFocus.js.map +1 -0
  35. package/dist/components/form/engine/variants/VariantMinimal.d.ts +6 -0
  36. package/dist/components/form/engine/variants/VariantMinimal.d.ts.map +1 -0
  37. package/dist/components/form/engine/variants/VariantMinimal.js +73 -0
  38. package/dist/components/form/engine/variants/VariantMinimal.js.map +1 -0
  39. package/dist/components/form/engine/variants/focusDemo.d.ts +13 -0
  40. package/dist/components/form/engine/variants/focusDemo.d.ts.map +1 -0
  41. package/dist/components/form/engine/variants/focusDemo.js +139 -0
  42. package/dist/components/form/engine/variants/focusDemo.js.map +1 -0
  43. package/dist/components/form/engine/variants/variantModel.d.ts +70 -0
  44. package/dist/components/form/engine/variants/variantModel.d.ts.map +1 -0
  45. package/dist/components/form/engine/variants/variantModel.js +133 -0
  46. package/dist/components/form/engine/variants/variantModel.js.map +1 -0
  47. package/dist/components/form/engine/variants/variantParts.d.ts +79 -0
  48. package/dist/components/form/engine/variants/variantParts.d.ts.map +1 -0
  49. package/dist/components/form/engine/variants/variantParts.js +191 -0
  50. package/dist/components/form/engine/variants/variantParts.js.map +1 -0
  51. package/dist/components/form/fields/auto/AutoFormField.d.ts +3 -0
  52. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  53. package/dist/components/form/fields/auto/AutoFormField.js +2 -2
  54. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  55. package/package.json +1 -1
  56. package/src/components/form/engine/CompactRow.tsx +256 -234
  57. package/src/components/form/engine/CompactToolbar.tsx +108 -68
  58. package/src/components/form/engine/FormEngine.stories.tsx +127 -110
  59. package/src/components/form/engine/FormEngine.tsx +248 -67
  60. package/src/components/form/engine/compactRowStyles.ts +207 -134
  61. package/src/components/form/engine/compactToolbarContext.ts +1 -0
  62. package/src/components/form/engine/readFirst.ts +35 -0
  63. package/src/components/form/engine/variants/FormEngineVariants.stories.tsx +119 -0
  64. package/src/components/form/engine/variants/VariantCalmTable.tsx +242 -0
  65. package/src/components/form/engine/variants/VariantCards.tsx +212 -0
  66. package/src/components/form/engine/variants/VariantFocus.tsx +382 -0
  67. package/src/components/form/engine/variants/VariantMinimal.tsx +170 -0
  68. package/src/components/form/engine/variants/focusDemo.ts +145 -0
  69. package/src/components/form/engine/variants/variantModel.ts +216 -0
  70. package/src/components/form/engine/variants/variantParts.tsx +313 -0
  71. package/src/components/form/fields/auto/AutoFormField.tsx +5 -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
- StyledGroupHeaderLine,
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
@@ -560,6 +566,10 @@ export const FormEngine = ({
560
566
  const [showInvalidOptionsOnly, setShowInvalidOptionsOnly] = useState<boolean>(false);
561
567
  // Which options are expanded into their editor (several can be open at once).
562
568
  const [expandedOptions, setExpandedOptions] = useState<string[]>([]);
569
+ // Remembers each row's last settled status box, so an actively-edited field
570
+ // stays put when its status flips (e.g. becomes valid) instead of jumping to
571
+ // another box mid-edit and stealing focus. Keyed by option name.
572
+ const settledBucket = useRef<Record<string, 'attention' | 'set' | 'optional'>>({});
563
573
  // Measured form width (not viewport — the form lives in drawers/panels of
564
574
  // arbitrary width) drives the stacked narrow layout.
565
575
  const [compactWrapRef, { width: compactWrapWidth }] = useMeasure<HTMLDivElement>();
@@ -1269,6 +1279,73 @@ export const FormEngine = ({
1269
1279
  );
1270
1280
  }, [showInvalidOptionsOnly, JSON.stringify(availableOptions)]);
1271
1281
 
1282
+ // Read-first STATUS / BOX for one option — lifted to component scope so the
1283
+ // status boxes (renderCompact) and the header's "needs attention" count share
1284
+ // exactly one definition. One-of group members travel together (bucket by the
1285
+ // group's satisfaction); everything else by its own status.
1286
+ const schemaMsgIntent = useCallback(
1287
+ (name: string): 'danger' | 'warning' | undefined => {
1288
+ const msgs = ((options?.[name] as { messages?: Array<{ intent?: string }> } | undefined)
1289
+ ?.messages || []) as Array<{ intent?: string }>;
1290
+ if (msgs.some((m) => m.intent === 'danger')) return 'danger';
1291
+ if (msgs.some((m) => m.intent === 'warning')) return 'warning';
1292
+ return undefined;
1293
+ },
1294
+ [JSON.stringify(options)]
1295
+ );
1296
+ const getOptionStatus = useCallback(
1297
+ (name: string, hidden = false): TReadFirstStatus => {
1298
+ if (hidden) return 'optional';
1299
+ const schema = options?.[name];
1300
+ const type = (schema?.ui_type || schema?.type) as TQorusType;
1301
+ const value = (availableOptions as TQorusForm)?.[name]?.value;
1302
+ const empty = isOptionValueEmpty(value);
1303
+ const reqGroups = (schema?.required_groups as string[] | undefined) || [];
1304
+ const required = !!(schema?.required || reqGroups.length);
1305
+ const covered =
1306
+ empty &&
1307
+ reqGroups.some((g) => {
1308
+ const by = requiredGroupsInfo.satisfiedBy[g];
1309
+ return !!by && by !== name;
1310
+ });
1311
+ const msgIntent = schemaMsgIntent(name);
1312
+ const invalid = (!empty && !isOptionValid(name, type, value)) || msgIntent === 'danger';
1313
+ return getReadFirstStatus({
1314
+ empty,
1315
+ required,
1316
+ covered,
1317
+ invalid,
1318
+ warned: msgIntent === 'warning',
1319
+ });
1320
+ },
1321
+ [
1322
+ JSON.stringify(options),
1323
+ JSON.stringify(availableOptions),
1324
+ isOptionValid,
1325
+ requiredGroupsInfo,
1326
+ schemaMsgIntent,
1327
+ ]
1328
+ );
1329
+ const getOptionBucket = useCallback(
1330
+ (name: string, hidden = false): 'attention' | 'set' | 'optional' => {
1331
+ if (!hidden) {
1332
+ const reqGroups = (options?.[name]?.required_groups as string[] | undefined) || [];
1333
+ if (reqGroups.length) {
1334
+ return reqGroups.some((g) => !requiredGroupsInfo.satisfiedBy[g]) ? 'attention' : 'set';
1335
+ }
1336
+ }
1337
+ return getReadFirstBucket(getOptionStatus(name, hidden));
1338
+ },
1339
+ [JSON.stringify(options), requiredGroupsInfo, getOptionStatus]
1340
+ );
1341
+ // How many fields are in the "Needs attention" box — drives the header link.
1342
+ const readFirstAttentionCount = useMemo(
1343
+ () =>
1344
+ Object.keys(availableOptions || {}).filter((name) => getOptionBucket(name) === 'attention')
1345
+ .length,
1346
+ [JSON.stringify(availableOptions), getOptionBucket]
1347
+ );
1348
+
1272
1349
  const getIntent = useCallback(
1273
1350
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
1274
1351
  (optName: string, type: TQorusType, optValue: any, _op: any): TReqoreIntent => {
@@ -1430,20 +1507,37 @@ export const FormEngine = ({
1430
1507
  const operatorParts = fixOperatorValue(other.op);
1431
1508
  return (
1432
1509
  <>
1433
- {(suppressSchemaMessages ? [] : (options?.[optionName] as any)?.messages || []).map(
1434
- ({ intent, title, content }: any, index: number) => (
1510
+ {(() => {
1511
+ const schemaMsgs = (
1512
+ suppressSchemaMessages ? [] : (options?.[optionName] as any)?.messages || []
1513
+ ) as { intent?: string; title?: string; content?: string }[];
1514
+ if (!schemaMsgs.length) return null;
1515
+ const items = schemaMsgs.map(({ intent, title, content }, index) => (
1435
1516
  <ReqoreMessage
1436
- intent={intent}
1517
+ intent={intent as never}
1437
1518
  title={title}
1438
1519
  key={title || index}
1439
1520
  opaque={false}
1440
1521
  size='small'
1441
- margin='bottom'
1522
+ // Compact: flat (no border) to match the read-row info panels;
1523
+ // classic forms keep the bordered, bottom-margined message.
1524
+ flat={compact || undefined}
1525
+ margin={compact ? undefined : 'bottom'}
1442
1526
  >
1443
1527
  {content}
1444
1528
  </ReqoreMessage>
1445
- )
1446
- )}
1529
+ ));
1530
+ // Compact: stack them in a 4px-gap panel so a field's messages look
1531
+ // identical whether the row is collapsed (read panel) or expanded.
1532
+ return compact ?
1533
+ <div
1534
+ className='options-readfirst-info-panel'
1535
+ style={{ display: 'flex', flexFlow: 'column', gap: 4, marginBottom: 8 }}
1536
+ >
1537
+ {items}
1538
+ </div>
1539
+ : <>{items}</>;
1540
+ })()}
1447
1541
  {operators && size(operators) ?
1448
1542
  <>
1449
1543
  <ReqoreControlGroup fill wrap className='operators'>
@@ -1498,6 +1592,9 @@ export const FormEngine = ({
1498
1592
  <TemplateField
1499
1593
  fluid
1500
1594
  {...(options?.[optionName] as any)}
1595
+ // Propagate compact so an arg_schema field renders a COMPACT sub-form
1596
+ // (consistent with the parent) rather than the classic FormEngine.
1597
+ compact={compact}
1501
1598
  // SEAM: forwarded through TemplateField's rest-spread to AutoFormField,
1502
1599
  // which renders consumer-injected editors by field type/ui_type.
1503
1600
  componentOverrides={componentOverrides}
@@ -1726,6 +1823,7 @@ export const FormEngine = ({
1726
1823
  () => ({
1727
1824
  readOnly,
1728
1825
  invalidCount: size(validityData.invalidFields),
1826
+ attentionCount: readFirstAttentionCount,
1729
1827
  completion: readFirstCompletion,
1730
1828
  showInvalidOnly: showInvalidOptionsOnly,
1731
1829
  onToggleInvalidOnly: handleToggleInvalidOnly,
@@ -1751,6 +1849,7 @@ export const FormEngine = ({
1751
1849
  [
1752
1850
  readOnly,
1753
1851
  validityData,
1852
+ readFirstAttentionCount,
1754
1853
  readFirstCompletion,
1755
1854
  showInvalidOptionsOnly,
1756
1855
  handleToggleInvalidOnly,
@@ -1863,6 +1962,67 @@ export const FormEngine = ({
1863
1962
  return groupOrder.indexOf(a) - groupOrder.indexOf(b);
1864
1963
  });
1865
1964
 
1965
+ // Read-first STATUS per row → one of three boxes (Needs attention / Set /
1966
+ // Optional), via the component-scope getOptionBucket (shared with the header
1967
+ // count and the row dot, so box ↔ dot ↔ header always agree). Buckets keep
1968
+ // schema-group order (thin sub-labels) and the required-group clustering: an
1969
+ // unmet one-of group's members all land in attention and still rail up.
1970
+ type TRowEntry = { name: string; hidden: boolean };
1971
+ type TBucketKey = 'attention' | 'set' | 'optional';
1972
+ const buckets: Record<TBucketKey, Record<string, TRowEntry[]>> = {
1973
+ attention: {},
1974
+ set: {},
1975
+ optional: {},
1976
+ };
1977
+ const bucketGroups: Record<TBucketKey, string[]> = { attention: [], set: [], optional: [] };
1978
+ // Freeze the box of any field currently being edited (or whose one-of group
1979
+ // has an edited member) to its last settled box — so finishing an edit that
1980
+ // flips its status doesn't remount it in another box and steal focus.
1981
+ const stableBucketOf = (entry: TRowEntry): TBucketKey => {
1982
+ const fresh = getOptionBucket(entry.name, entry.hidden);
1983
+ const groupBeingEdited =
1984
+ !entry.hidden &&
1985
+ ((options?.[entry.name]?.required_groups as string[] | undefined) || []).some((g) =>
1986
+ (requiredGroupsInfo.members[g] || []).some((m) => expandedOptions.includes(m))
1987
+ );
1988
+ if (!entry.hidden && (expandedOptions.includes(entry.name) || groupBeingEdited)) {
1989
+ const memo = settledBucket.current[entry.name];
1990
+ if (memo) return memo;
1991
+ }
1992
+ settledBucket.current[entry.name] = fresh;
1993
+ return fresh;
1994
+ };
1995
+ groupKeys.forEach((groupName) => {
1996
+ grouped[groupName].forEach((entry) => {
1997
+ const b = stableBucketOf(entry);
1998
+ if (!buckets[b][groupName]) {
1999
+ buckets[b][groupName] = [];
2000
+ bucketGroups[b].push(groupName);
2001
+ }
2002
+ buckets[b][groupName].push(entry);
2003
+ });
2004
+ });
2005
+ const bucketCount = (b: TBucketKey) =>
2006
+ bucketGroups[b].reduce((n, g) => n + buckets[b][g].length, 0);
2007
+ // 'general' / 'optional' are the SYNTHETIC fallback group keys getOptionGroup
2008
+ // assigns to fields with no explicit `group` — printing a "General"/"Optional"
2009
+ // sub-label for those is just noise, so suppress it. BUT a consumer may also
2010
+ // use 'general' as a REAL group (defining it in the `groups` prop and tagging
2011
+ // fields with `group: 'general'`); in that case it's a named group like any
2012
+ // other and DOES get its sub-label.
2013
+ const showGroupSubLabel = (groupName: string) =>
2014
+ (groupName !== 'general' && groupName !== 'optional') || !!groups?.[groupName];
2015
+ const STATUS_BOXES: Array<{
2016
+ key: TBucketKey;
2017
+ label: string;
2018
+ intent?: 'warning' | 'success';
2019
+ icon: IReqoreIconName;
2020
+ }> = [
2021
+ { key: 'attention', label: 'Needs attention', intent: 'warning', icon: 'ErrorWarningLine' },
2022
+ { key: 'set', label: 'Set', intent: 'success', icon: 'CheckLine' },
2023
+ { key: 'optional', label: 'Optional', icon: 'CheckboxBlankCircleLine' },
2024
+ ];
2025
+
1866
2026
  // Build the rows for one group: contiguous required-group members are pulled
1867
2027
  // together at the first member's slot and rendered as a connected rail (flat
1868
2028
  // rows — no wrapper — so the value surface applies normally; the rail + nodes
@@ -1891,7 +2051,9 @@ export const FormEngine = ({
1891
2051
  clusterLast={clusterLast}
1892
2052
  />
1893
2053
  );
1894
- if (compactNarrow) return names.map((entry) => renderRow(entry, false));
2054
+ // (Clustering runs in narrow mode too now — the "One of the below is
2055
+ // required" box wraps the members regardless of width; it no longer relies
2056
+ // on a contiguous rail.)
1895
2057
  const emitted = new Set<string>();
1896
2058
  const groupOf = (name: string) =>
1897
2059
  (options?.[name]?.required_groups as string[] | undefined)?.[0];
@@ -1902,9 +2064,27 @@ export const FormEngine = ({
1902
2064
  const memberEntries = names.filter((e) => !e.hidden && groupOf(e.name) === grp);
1903
2065
  if (memberEntries.length < 2) return renderRow(entry, false);
1904
2066
  emitted.add(grp);
1905
- return memberEntries.map((e, idx) =>
2067
+ const railed = memberEntries.map((e, idx) =>
1906
2068
  renderRow(e, true, idx === 0, idx === memberEntries.length - 1)
1907
2069
  );
2070
+ // An UNMET one-of group gets the explicit "One of the below is required"
2071
+ // box (the Focus cluster). A met group needs no banner — the rail + the
2072
+ // "Covers"/"Covered by" chips already say which member satisfies it.
2073
+ if (requiredGroupsInfo.satisfiedBy[grp]) return railed;
2074
+ return (
2075
+ <StyledRequiredClusterBox
2076
+ key={grp}
2077
+ className='options-readfirst-required-cluster'
2078
+ $border={`${cWarning}33`}
2079
+ $tint={`${cWarning}0d`}
2080
+ >
2081
+ <StyledRequiredClusterHeader $color={cWarning}>
2082
+ <ReqoreIcon icon='LinkM' size='11px' style={{ color: cWarning }} />
2083
+ One of the below is required
2084
+ </StyledRequiredClusterHeader>
2085
+ {railed}
2086
+ </StyledRequiredClusterBox>
2087
+ );
1908
2088
  });
1909
2089
  };
1910
2090
 
@@ -1943,78 +2123,53 @@ export const FormEngine = ({
1943
2123
  </ReqoreMessage>
1944
2124
  : null}
1945
2125
 
1946
- {groupKeys.map((groupName) => {
1947
- const names = grouped[groupName];
1948
- const groupConfig = groups?.[groupName];
1949
- const invalidCount = names.filter(
1950
- (entry) =>
1951
- !entry.hidden &&
1952
- !isOptionValid(
1953
- entry.name,
1954
- (options?.[entry.name]?.ui_type as TQorusType) ||
1955
- (options?.[entry.name]?.type as TQorusType),
1956
- (shownOptions as TQorusForm)[entry.name]?.value
1957
- )
1958
- ).length;
1959
-
2126
+ {STATUS_BOXES.map((box) => {
2127
+ const groupsInBox = bucketGroups[box.key];
2128
+ const count = bucketCount(box.key);
2129
+ if (!count) return null;
2130
+ const accent =
2131
+ box.key === 'attention' ? cWarning
2132
+ : box.key === 'set' ? cSuccess
2133
+ : cMuted;
2134
+ // The muted "Optional" box reads as a quieter, recessed
2135
+ // surface — a touch darker than the page rather than the
2136
+ // faint grey tint the accent would give.
2137
+ const boxBg =
2138
+ box.key === 'optional' ?
2139
+ changeDarkness(getMainBackgroundColor(theme), 0.06)
2140
+ : undefined;
1960
2141
  return (
1961
- <ReqorePanel
1962
- key={groupName}
2142
+ <StyledStatusBox
2143
+ $accent={accent}
2144
+ $bg={boxBg}
2145
+ key={box.key}
1963
2146
  flat
1964
2147
  minimal
1965
2148
  collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}
1966
2149
  collapsible
1967
2150
  label={
1968
2151
  <StyledGroupHeader>
1969
- <ReqoreP effect={{ weight: 'bold' }} size='big'>
1970
- {getOptionGroupLabel(groupName, groups)}
2152
+ <ReqoreP effect={{ weight: 'bold' }} size='normal'>
2153
+ {box.label}
1971
2154
  </ReqoreP>
1972
- <StyledGroupHeaderLine $color={cGroupLine} />
1973
- <ReqoreButton
1974
- readOnly
1975
- size='tiny'
2155
+ <ReqoreTag
2156
+ size='small'
1976
2157
  minimal
1977
- flat
1978
2158
  compact
1979
- effect={{uppercase: true, spaced: 1}}
1980
- {...(groupName === 'optional' ? { label: `${names.length} optional` }
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
- })}
2159
+ intent={box.intent}
2160
+ label={String(count)}
1992
2161
  />
1993
2162
  </StyledGroupHeader>
1994
2163
  }
1995
- icon={groupConfig?.icon}
2164
+ icon={box.icon}
1996
2165
  className='options-readfirst-group'
1997
2166
  padded={false}
1998
2167
  contentStyle={{ padding: '4px 4px 6px' }}
1999
2168
  >
2000
- {groupConfig?.subtitle ?
2001
- <ReqoreP
2002
- size='small'
2003
- effect={{ opacity: 0.6 }}
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}
2169
+ {/* ONE group body per box: every field block (and the
2170
+ thin schema sub-labels) is a direct flex child, so the
2171
+ inter-field gap is identical everywhere — including
2172
+ across schema-group boundaries. */}
2018
2173
  <StyledGroupBody
2019
2174
  $divider={cDivider}
2020
2175
  $hover={cHover}
@@ -2024,9 +2179,35 @@ export const FormEngine = ({
2024
2179
  $lineColor={cGroupLine}
2025
2180
  className={compactNarrow ? 'readfirst-narrow' : undefined}
2026
2181
  >
2027
- {renderGroupRows(names)}
2182
+ {groupsInBox.map((groupName) => {
2183
+ const groupConfig = groups?.[groupName];
2184
+ return (
2185
+ <React.Fragment key={groupName}>
2186
+ {showGroupSubLabel(groupName) ?
2187
+ <StyledStatusBoxGroupLabel>
2188
+ {getOptionGroupLabel(groupName, groups)}
2189
+ </StyledStatusBoxGroupLabel>
2190
+ : null}
2191
+ {showGroupSubLabel(groupName) && groupConfig?.subtitle ?
2192
+ <ReqoreP
2193
+ size='small'
2194
+ effect={{ opacity: 0.6 }}
2195
+ style={{
2196
+ marginTop: 2,
2197
+ marginBottom: 8,
2198
+ marginLeft: GROUP_INDENT,
2199
+ paddingRight: 10,
2200
+ }}
2201
+ >
2202
+ {groupConfig.subtitle}
2203
+ </ReqoreP>
2204
+ : null}
2205
+ {renderGroupRows(buckets[box.key][groupName])}
2206
+ </React.Fragment>
2207
+ );
2208
+ })}
2028
2209
  </StyledGroupBody>
2029
- </ReqorePanel>
2210
+ </StyledStatusBox>
2030
2211
  );
2031
2212
  })}
2032
2213
  </StyledCompactPanel>