@qoretechnologies/reqraft 0.10.4 → 0.10.6

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 (25) hide show
  1. package/.claude/CLAUDE.md +5 -0
  2. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  3. package/dist/components/form/engine/CompactRow.js +7 -9
  4. package/dist/components/form/engine/CompactRow.js.map +1 -1
  5. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  6. package/dist/components/form/engine/CompactToolbar.js +8 -27
  7. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  8. package/dist/components/form/engine/FormEngine.d.ts +10 -2
  9. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  10. package/dist/components/form/engine/FormEngine.js +127 -44
  11. package/dist/components/form/engine/FormEngine.js.map +1 -1
  12. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  13. package/dist/components/form/engine/compactRowStyles.js +8 -3
  14. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  15. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  16. package/dist/components/form/fields/auto/AutoFormField.js +4 -1
  17. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  18. package/package.json +4 -3
  19. package/src/components/form/engine/CompactRow.tsx +18 -25
  20. package/src/components/form/engine/CompactToolbar.tsx +4 -17
  21. package/src/components/form/engine/FormEngine.stories.tsx +219 -10
  22. package/src/components/form/engine/FormEngine.tsx +126 -16
  23. package/src/components/form/engine/compactRowStyles.ts +33 -29
  24. package/src/components/form/fields/auto/AutoFormField.stories.tsx +9 -2
  25. package/src/components/form/fields/auto/AutoFormField.tsx +3 -0
@@ -131,6 +131,37 @@ export interface IFormValidityData {
131
131
  invalidFields: IFormFieldValidityData[];
132
132
  }
133
133
 
134
+ /**
135
+ * Resolve a field's `inherit_props` map against the current available
136
+ * options, producing a `{ propName: siblingValue }` hash suitable for
137
+ * spreading onto the field's renderer.
138
+ *
139
+ * For each entry `<prop-name-on-renderer> -> <sibling-field-name>`, the
140
+ * sibling's current value (`availableOptions[siblingName]?.value`) is
141
+ * forwarded under the receiving prop name. Missing siblings emit
142
+ * `undefined`, which the renderer's prop type can treat as "no hint".
143
+ *
144
+ * Designed to be JSON-pure: no closures, no transformations. Renderers
145
+ * decide how to use the forwarded value (e.g. the consumer-injected
146
+ * `code-editor` renderer maps a `language: "qore"` prop to its
147
+ * highlighter mode).
148
+ *
149
+ * Mirrored in qorus-ide `src/components/Field/systemOptions.tsx`; keep
150
+ * the two in sync (see qorus-ide's `.claude/CLAUDE.md`).
151
+ */
152
+ const resolveInheritProps = (
153
+ inheritProps: Record<string, string> | undefined,
154
+ availableOptions: TQorusForm | undefined
155
+ ): Record<string, unknown> => {
156
+ if (!inheritProps || !availableOptions) return {};
157
+ const out: Record<string, unknown> = {};
158
+ for (const propName in inheritProps) {
159
+ const siblingName = inheritProps[propName];
160
+ out[propName] = (availableOptions[siblingName] as IQorusFormField | undefined)?.value;
161
+ }
162
+ return out;
163
+ };
164
+
134
165
  const NegativeColorEffect: any = {
135
166
  gradient: {
136
167
  colors: { 0: 'danger', 100: 'danger:darken:2' },
@@ -475,6 +506,14 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
475
506
  * form should line up with the section description above it. Default `false`.
476
507
  */
477
508
  compactFlush?: boolean;
509
+ /**
510
+ * Compact mode only: this form is an EMBEDDED sub-form (e.g. an arg_schema
511
+ * field's nested form) rather than the top-level scroller. It doesn't own a
512
+ * scroll context, so the toolbar isn't sticky and its header drops the dark
513
+ * blurred backdrop (and the stacking context that goes with it) — it sits
514
+ * transparently inside the parent's edit card. Default `false`.
515
+ */
516
+ compactNested?: boolean;
478
517
  /** Compact mode only: per-group display metadata (label / icon / subtitle /
479
518
  * order) — the server only sends the bare group key. */
480
519
  groups?: Record<string, IFormEngineGroup>;
@@ -531,6 +570,7 @@ export const FormEngine = ({
531
570
  showTypeToggle = true,
532
571
  compact,
533
572
  compactFlush = false,
573
+ compactNested = false,
534
574
  commitMode = 'immediate',
535
575
  expandMode = 'single',
536
576
  onCommit,
@@ -624,9 +664,15 @@ export const FormEngine = ({
624
664
  const flashTimeout = useRef<ReturnType<typeof setTimeout>>();
625
665
  const flashOptions = useCallback((optionNames: string[], scrollToFirst = false) => {
626
666
  if (scrollToFirst && optionNames[0]) {
627
- document
628
- .querySelector(`.readfirst-row[data-field="${optionNames[0]}"]`)
629
- ?.scrollIntoView({ block: 'center', behavior: 'smooth' });
667
+ // Defer to the next frame: when this fires for a field that just changed
668
+ // panels, its row has only just re-mounted in the new box — scrolling in the
669
+ // same tick targets the stale (pre-move) layout, so the page doesn't budge.
670
+ // A rAF lets the new position settle first.
671
+ requestAnimationFrame(() => {
672
+ document
673
+ .querySelector(`.readfirst-row[data-field="${optionNames[0]}"]`)
674
+ ?.scrollIntoView({ block: 'center', behavior: 'smooth' });
675
+ });
630
676
  }
631
677
  setFlashedOptions(optionNames);
632
678
  clearTimeout(flashTimeout.current);
@@ -637,6 +683,28 @@ export const FormEngine = ({
637
683
  [flashOptions]
638
684
  );
639
685
  useEffect(() => () => clearTimeout(flashTimeout.current), []);
686
+
687
+ // Follow a field across panels: when its status bucket changes — e.g. you fill
688
+ // an optional field and it jumps to Set / Needs attention — scroll to its new
689
+ // row and flash it so it's easy to keep track of. `settledBucket` holds each
690
+ // field's current panel (frozen while the field is being edited, it re-buckets
691
+ // on collapse), so diffing it after every render catches the move the instant it
692
+ // lands in the new panel. Runs every render; the diff is cheap and only fires a
693
+ // scroll on an ACTUAL move of a non-expanded field.
694
+ const prevSettledBucket = useRef<Record<string, 'attention' | 'set' | 'optional'>>({});
695
+ useEffect(() => {
696
+ if (!compact) return;
697
+ const cur = settledBucket.current;
698
+ const prev = prevSettledBucket.current;
699
+ const moved = Object.keys(cur).find(
700
+ (name) => prev[name] && prev[name] !== cur[name] && !expandedOptions.includes(name)
701
+ );
702
+ prevSettledBucket.current = { ...cur };
703
+ if (moved) {
704
+ flashOptions([moved], true);
705
+ }
706
+ });
707
+
640
708
  const compactNarrow = !!compactWrapWidth && compactWrapWidth < 480;
641
709
  // Info panels auto-open on Tier-1 content; the per-row user override sticks.
642
710
  const [infoPanelOverrides, setInfoPanelOverrides] = useState<Record<string, boolean>>({});
@@ -1038,6 +1106,10 @@ export const FormEngine = ({
1038
1106
  meta: undefined,
1039
1107
  };
1040
1108
  });
1109
+ // Collapse it too: a removed field drops back to the (collapsed) Optional box
1110
+ // as a quiet addable row — if it was being edited, that editor must close
1111
+ // rather than linger as an open editor for a field that's no longer added.
1112
+ setExpandedOptions((prev) => prev.filter((name) => name !== optionName));
1041
1113
  }, []);
1042
1114
 
1043
1115
  const handleAddOptionalFieldChange = useCallback(
@@ -1592,6 +1664,17 @@ export const FormEngine = ({
1592
1664
  <TemplateField
1593
1665
  fluid
1594
1666
  {...(options?.[optionName] as any)}
1667
+ // qorus#347-followup: resolve the field's `inherit_props` against
1668
+ // the CURRENT sibling values, threading each entry as a top-level
1669
+ // prop. Each `<prop-name>: <sibling-field-name>` mapping copies
1670
+ // the sibling's value (read from `availableOptions[name].value`)
1671
+ // onto the rendered field's renderer — e.g. a code-editor with
1672
+ // `inherit_props: { language: 'lang' }` picks up the live `lang`
1673
+ // value as a `language` prop without a schema refetch. Spread
1674
+ // AFTER `{...options?.[optionName]}` so the runtime value wins
1675
+ // over any schema-defined default of the same key. Mirrored in
1676
+ // qorus-ide's `systemOptions.tsx`; see the CLAUDE.md rule there.
1677
+ {...resolveInheritProps(options?.[optionName]?.inherit_props, availableOptions)}
1595
1678
  // Propagate compact so an arg_schema field renders a COMPACT sub-form
1596
1679
  // (consistent with the parent) rather than the classic FormEngine.
1597
1680
  compact={compact}
@@ -1908,15 +1991,18 @@ export const FormEngine = ({
1908
1991
  pushRow(optionName, false);
1909
1992
  }
1910
1993
  });
1911
- // When searching, also surface matching hidden optional fields (not yet
1912
- // added) so the search spans the whole schema, not just the visible rows.
1913
- if (query) {
1914
- forEach(filteredOptions, (_schema, optionName) => {
1915
- if (matchesQuery(optionName)) {
1916
- pushRow(optionName, true);
1917
- }
1918
- });
1919
- }
1994
+ // Surface EVERY not-yet-added optional field as an addable (hidden) row, so
1995
+ // the whole schema is browsable inline they all land in the Optional box
1996
+ // (hidden ⇒ 'optional' bucket) instead of being buried in the Fields menu.
1997
+ // Narrowed by the same filters as the listed rows (search query + required-
1998
+ // only). availableOptions (listed) and filteredOptions (these) are disjoint —
1999
+ // the former is built from fixedValue keys, the latter excludes them — so a
2000
+ // field is never both a listed and a hidden row.
2001
+ forEach(filteredOptions, (_schema, optionName) => {
2002
+ if (matchesFilters(optionName)) {
2003
+ pushRow(optionName, true);
2004
+ }
2005
+ });
1920
2006
 
1921
2007
  // User sort (Fields menu → "Sort by"), applied WITHIN each group so the
1922
2008
  // group sections and the required-group rails are preserved. Schema order is
@@ -2102,19 +2188,35 @@ export const FormEngine = ({
2102
2188
  <StyledCompactWrap
2103
2189
  ref={setCompactWrap}
2104
2190
  className='options-readfirst-scroll'
2105
- $flush={compactFlush}
2191
+ // A nested sub-form sits flush inside the parent's card — no outer
2192
+ // gutter (the card already provides the breathing room).
2193
+ $flush={compactFlush || compactNested}
2106
2194
  >
2107
2195
  <StyledCompactPanel
2108
- $headerBg={headerBg}
2196
+ // The top-level form scrolls, so its toolbar STICKS and carries a
2197
+ // dark blurred backdrop so content ghosts cleanly beneath it. A
2198
+ // nested (arg_schema) sub-form owns no scroll context — drop the
2199
+ // sticky, the backdrop, and the stacking context so its header is
2200
+ // transparent inside the parent's card.
2201
+ $headerBg={compactNested ? 'transparent' : headerBg}
2202
+ $nested={compactNested}
2109
2203
  flat
2110
- stickyHeader
2204
+ // No panel background: the form sits transparently on whatever
2205
+ // hosts it (page, drawer, or — for an arg_schema field — the
2206
+ // parent's edit card) instead of stacking its own dark surface.
2207
+ // The status boxes keep their own tints; the sticky toolbar keeps
2208
+ // its blurred header via the $headerBg override.
2209
+ transparent
2210
+ stickyHeader={!compactNested}
2111
2211
  padded={false}
2112
2212
  actions={compactHeaderActions}
2113
2213
  contentStyle={{
2114
2214
  display: 'flex',
2115
2215
  flexFlow: 'column',
2116
2216
  gap: '10px',
2117
- padding: '0 0 12px',
2217
+ // Nested sub-form: no surrounding panel padding (it's flush in
2218
+ // the parent card); top-level keeps a small bottom gutter.
2219
+ padding: compactNested ? '0' : '0 0 12px',
2118
2220
  }}
2119
2221
  >
2120
2222
  {size(groupKeys) === 0 ?
@@ -2147,6 +2249,14 @@ export const FormEngine = ({
2147
2249
  minimal
2148
2250
  collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}
2149
2251
  collapsible
2252
+ // The Optional box now holds every not-yet-added field, so
2253
+ // it starts COLLAPSED to keep the form focused on what's in
2254
+ // use. But a SEARCH must surface matching addable fields —
2255
+ // and ReqorePanel unmounts collapsed content — so force it
2256
+ // open whenever a query is active. (isCollapsed is the
2257
+ // panel's controllable state; manual toggling still works
2258
+ // when no query is set.)
2259
+ isCollapsed={box.key === 'optional' && !query}
2150
2260
  label={
2151
2261
  <StyledGroupHeader>
2152
2262
  <ReqoreP effect={{ weight: 'bold' }} size='normal'>
@@ -44,12 +44,17 @@ export const PANEL_LEFT_CSS = `calc(${LABEL_COL} + ${COMPACT_ROW_PAD_X + COMPACT
44
44
  // content blurs softly through.
45
45
  export const StyledCompactPanel = styled(ReqorePanel)<{
46
46
  $headerBg: string;
47
+ $nested?: boolean;
47
48
  }>`
48
49
  > .reqore-panel-title {
49
50
  background: ${({ $headerBg }) => $headerBg};
50
- backdrop-filter: blur(6px);
51
- -webkit-backdrop-filter: blur(6px);
52
- transform: translateZ(0);
51
+ /* The blur + translateZ exist only to make the STICKY top-level toolbar ghost
52
+ content beneath it; a nested sub-form's header isn't sticky, so skip them
53
+ (and the stacking context translateZ creates). */
54
+ ${({ $nested }) =>
55
+ $nested ?
56
+ ''
57
+ : 'backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); transform: translateZ(0);'}
53
58
  padding-top: ${GAP_FROM_SIZE[HEADER_GAP]}px;
54
59
  padding-bottom: ${GAP_FROM_SIZE[HEADER_GAP]}px;
55
60
  }
@@ -289,18 +294,11 @@ export const StyledRowValue = styled.div<{ $color: string; $empty?: boolean }>`
289
294
  export const StyledRowActions = styled.div`
290
295
  display: flex;
291
296
  align-items: center;
292
- /* The row top-aligns its cells, so the actions sit at the row's content top.
293
- Hover action buttons (revert/delete) are ~24px and would otherwise pull the
294
- centred dot down with them — pin the dot to the LABEL's first line instead so
295
- it stays at a single, consistent height on every row no matter what hangs
296
- below the value. */
297
- align-self: start;
297
+ /* No align-self override: the actions (incl. the status dot) follow the row's
298
+ own vertical alignment CENTRED on the common single-line row, TOP-aligned
299
+ (first line) on the tall rows that opt into align-items:start (descriptions /
300
+ message panels / hash previews). */
298
301
  gap: 6px;
299
- .options-readfirst-statusdot-slot {
300
- align-self: flex-start;
301
- align-items: center;
302
- height: 12px;
303
- }
304
302
  `;
305
303
 
306
304
  // A single status mark pinned at the row's trailing edge: one dot, colour =
@@ -383,14 +381,18 @@ export const StyledGroupBody = styled.div<{
383
381
  grid wider than its container and produce a horizontal scrollbar. The 0
384
382
  minimum lets it shrink and the value cell's ellipsis take over instead. */
385
383
  grid-template-columns: ${LABEL_COL} minmax(0, 1fr) auto;
386
- /* TOP-align cells: the label, value and status dot all start on the first
387
- line, so the dot sits at a consistent place no matter how tall the value
388
- (chips, wrapped text, message panels) makes the row. Rows size to content
389
- (no min-height) so the inter-field gap stays uniform. */
390
- align-items: start;
384
+ /* CENTRE the cells vertically: on the common single-line read row the label,
385
+ value and status dot all sit on one centred line. Tall rows (a shown
386
+ description, message panels or a hash preview) opt back into top-alignment
387
+ (.readfirst-row-info-open / .readfirst-row-tall below) so the label + dot
388
+ stay on the value's FIRST line instead of floating to the middle. */
389
+ align-items: center;
391
390
  gap: 14px;
392
- min-height: 26px;
393
- padding: 4px 10px;
391
+ /* Generous, SYMMETRIC vertical padding so a single-line row isn't cramped
392
+ (content sits with even breathing room top + bottom); a min-height floor
393
+ keeps the rare shorter row a comfortable tap target. */
394
+ min-height: 40px;
395
+ padding: 9px 10px;
394
396
  border-radius: 6px;
395
397
  cursor: pointer;
396
398
  transition: background 0.12s ease;
@@ -469,10 +471,11 @@ export const StyledGroupBody = styled.div<{
469
471
  the ✓/↺ cluster get small offsets to sit optically centred on it. */
470
472
  align-items: start;
471
473
  background: ${({ $hover }) => $hover};
472
- /* Zero vertical padding: the pinned min-height (captured from the read
473
- row at activation) owns the height; the editor centres within it. */
474
+ /* No top padding (the per-cell nudges below anchor the editor to the first
475
+ line), but a real BOTTOM padding so the editor never sits flush against
476
+ the row's bottom edge — a tall input used to look clipped/unfinished. */
474
477
  padding-top: 0;
475
- padding-bottom: 0;
478
+ padding-bottom: 9px;
476
479
  /* Tighter column gap: the editor's trailing template ⋮ and our ✓ should
477
480
  read as one control cluster, not two separated groups. */
478
481
  column-gap: 6px;
@@ -566,11 +569,12 @@ export const StyledGroupBody = styled.div<{
566
569
  /* (The required-group connection rail was removed — the "One of the below is
567
570
  required" box now carries the grouping.) */
568
571
 
569
- /* A field's short_desc renders under its NAME (revealed by the toggle),
570
- growing the label block to multiple lines. Top-anchor those open rows so the
571
- value lines up with the name rather than the middle of the taller label;
572
- closed (single-line) rows keep the centred read-row rhythm. */
573
- .readfirst-row-info-open {
572
+ /* Tall rows top-anchor (overriding the row's centred default) so the label and
573
+ dot stay on the value's FIRST line rather than floating to the vertical
574
+ middle: .readfirst-row-info-open = a shown short_desc grows the LABEL;
575
+ .readfirst-row-tall = message panels / a hash preview grow the VALUE cell. */
576
+ .readfirst-row-info-open,
577
+ .readfirst-row-tall {
574
578
  align-items: start;
575
579
  }
576
580
  /* Narrow stacks label-over-value: the value aligns flush UNDER the label (no
@@ -190,9 +190,16 @@ export const ViaFormEngine: Story = {
190
190
  },
191
191
  async play({ canvasElement }) {
192
192
  const canvas = within(canvasElement);
193
- await expect(await canvas.findByText('My Auto Field')).toBeInTheDocument();
193
+ // Generous timeout: findByText defaults to 1s, which flakes under CI load
194
+ // while the engine boots the auto field's type picker (the rest of the suite
195
+ // waits ~10s).
196
+ await expect(
197
+ await canvas.findByText('My Auto Field', undefined, { timeout: 10000 })
198
+ ).toBeInTheDocument();
194
199
  // The auto field renders its type picker inside the engine-driven form.
195
- await expect(await canvas.findByText('Please select data type')).toBeInTheDocument();
200
+ await expect(
201
+ await canvas.findByText('Please select data type', undefined, { timeout: 10000 })
202
+ ).toBeInTheDocument();
196
203
  },
197
204
  };
198
205
 
@@ -545,6 +545,9 @@ function AutoField<T = any>({
545
545
  wrapperPadding='top'
546
546
  flat
547
547
  compact={compact}
548
+ // Embedded sub-form: no scroll context of its own, so its toolbar
549
+ // isn't sticky and its header stays transparent (no dark backdrop).
550
+ compactNested
548
551
  name={name}
549
552
  uniqueName={uniqueName}
550
553
  options={finalArgSchema}