@qoretechnologies/reqraft 0.10.36 → 0.10.37-pr.95.g4e65c57

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 (57) hide show
  1. package/dist/components/codeSize/index.d.ts +37 -0
  2. package/dist/components/codeSize/index.d.ts.map +1 -0
  3. package/dist/components/codeSize/index.js +87 -0
  4. package/dist/components/codeSize/index.js.map +1 -0
  5. package/dist/components/form/engine/CompactRow.d.ts +6 -1
  6. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  7. package/dist/components/form/engine/CompactRow.js +259 -19
  8. package/dist/components/form/engine/CompactRow.js.map +1 -1
  9. package/dist/components/form/engine/FormEngine.d.ts +20 -0
  10. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  11. package/dist/components/form/engine/FormEngine.js +137 -24
  12. package/dist/components/form/engine/FormEngine.js.map +1 -1
  13. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts +37 -0
  14. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -0
  15. package/dist/components/form/engine/_structuredData/SchemaDataView.js +307 -0
  16. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -0
  17. package/dist/components/form/engine/compactRowStyles.d.ts +13 -0
  18. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  19. package/dist/components/form/engine/compactRowStyles.js +30 -5
  20. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  21. package/dist/components/form/engine/readFirst.d.ts +66 -1
  22. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  23. package/dist/components/form/engine/readFirst.js +175 -5
  24. package/dist/components/form/engine/readFirst.js.map +1 -1
  25. package/dist/components/form/fields/array/ArrayAuto.d.ts.map +1 -1
  26. package/dist/components/form/fields/array/ArrayAuto.js +51 -21
  27. package/dist/components/form/fields/array/ArrayAuto.js.map +1 -1
  28. package/dist/components/form/fields/array/ArrayAutoField.d.ts.map +1 -1
  29. package/dist/components/form/fields/array/ArrayAutoField.js +42 -25
  30. package/dist/components/form/fields/array/ArrayAutoField.js.map +1 -1
  31. package/dist/components/form/fields/auto/AutoFormField.d.ts +4 -0
  32. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  33. package/dist/components/form/fields/auto/AutoFormField.js +8 -1
  34. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  35. package/dist/helpers/validations.d.ts +14 -0
  36. package/dist/helpers/validations.d.ts.map +1 -1
  37. package/dist/helpers/validations.js +58 -3
  38. package/dist/helpers/validations.js.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -0
  42. package/dist/index.js.map +1 -1
  43. package/package.json +1 -1
  44. package/src/components/codeSize/index.tsx +79 -0
  45. package/src/components/form/engine/CompactRow.tsx +313 -24
  46. package/src/components/form/engine/FormEngine.stories.tsx +487 -14
  47. package/src/components/form/engine/FormEngine.tsx +142 -8
  48. package/src/components/form/engine/_structuredData/SchemaDataView.stories.tsx +129 -0
  49. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +594 -0
  50. package/src/components/form/engine/compactRowStyles.ts +44 -2
  51. package/src/components/form/engine/readFirst.ts +201 -5
  52. package/src/components/form/fields/array/ArrayAuto.tsx +35 -4
  53. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +53 -0
  54. package/src/components/form/fields/array/ArrayAutoField.tsx +22 -4
  55. package/src/components/form/fields/auto/AutoFormField.tsx +11 -0
  56. package/src/helpers/validations.ts +59 -2
  57. package/src/index.tsx +1 -0
@@ -107,6 +107,7 @@ import {
107
107
  import { OptionsHelpDialog } from './OptionsHelpDialog';
108
108
  import {
109
109
  TReadFirstStatus,
110
+ findAllowedValueOption,
110
111
  getFirstAttentionOptionName,
111
112
  getOptionGroup,
112
113
  getOptionGroupLabel,
@@ -429,13 +430,18 @@ export const fixOptions = (
429
430
  };
430
431
  }
431
432
 
433
+ // A value that is not one of the declared choices is dropped. What counts
434
+ // as "one of the choices" is `findAllowedValueOption` — the same predicate
435
+ // the read-first row uses to LABEL a value — because a value the row can
436
+ // name is by definition a value the form must keep. Inlining a narrower
437
+ // test here (envelope and `name`, but not a bare `value`) silently erased
438
+ // every value declared the bare way: the collapsed row still showed its
439
+ // display name while the editor showed "—" and the value never reached
440
+ // the submitted data.
432
441
  if (
433
442
  newOption.value !== undefined &&
434
443
  options?.[optionName]?.allowed_values &&
435
- !options?.[optionName]?.allowed_values?.find(
436
- (allowedValue: any) =>
437
- allowedValue.value?.value === newOption.value || allowedValue.name === newOption.value
438
- ) &&
444
+ !findAllowedValueOption(newOption.value, options?.[optionName]) &&
439
445
  !isValueTemplate(newOption.value) &&
440
446
  !options?.[optionName]?.multiselect &&
441
447
  !options?.[optionName]?.allowed_values_creatable
@@ -715,6 +721,27 @@ export interface IFormEngineProps extends Omit<IReqoreCollectionProps, 'onChange
715
721
  */
716
722
  autoFocusFirstRequired?: boolean;
717
723
 
724
+ /**
725
+ * Opt-in: on mount, OPEN the first row that needs attention — without taking
726
+ * focus.
727
+ *
728
+ * `autoFocusFirstRequired` fuses two decisions that are not the same: which
729
+ * row is open (layout) and where the caret is (focus). Focus is the half that
730
+ * can be stolen, so that flag waits for focus to be free — and a form mounted
731
+ * BY a click never sees free focus, because the button that mounted it still
732
+ * has it. Adding a list item is exactly that case: the row the author must
733
+ * fill stayed shut behind a second click, and the flag that exists to prevent
734
+ * it could not fire.
735
+ *
736
+ * Opening a row takes nothing from the user, so this half needs no guard. Use
737
+ * it where the mount is already the answer to a deliberate action; use
738
+ * `autoFocusFirstRequired` where the form is the destination and the caret
739
+ * should land in it. Setting both keeps the focusing behaviour. Same one-shot
740
+ * contract: it fires on the first render with focusable content and then never
741
+ * again for the life of the instance. No-op in classic mode. Default: off.
742
+ */
743
+ expandFirstRequired?: boolean;
744
+
718
745
  /**
719
746
  * Names of read-first rows to open on mount, in addition to whatever the
720
747
  * user opens afterwards.
@@ -783,6 +810,7 @@ const FormEngineImpl = ({
783
810
  rendererOnlyUiTypes,
784
811
  inheritedFromParent,
785
812
  autoFocusFirstRequired,
813
+ expandFirstRequired,
786
814
  initialExpandedOptions,
787
815
  ...rest
788
816
  }: IFormEngineProps) => {
@@ -831,6 +859,47 @@ const FormEngineImpl = ({
831
859
  const [showInvalidOptionsOnly, setShowInvalidOptionsOnly] = useState<boolean>(false);
832
860
  // Which options are expanded into their editor (several can be open at once).
833
861
  const [expandedOptions, setExpandedOptions] = useState<string[]>([]);
862
+
863
+ // --- Reveal a status box's content when it is opened ----------------------
864
+ //
865
+ // The status boxes stack, so the last one ("Optional", which holds every
866
+ // not-yet-added field) sits at the bottom of the form — frequently at the
867
+ // bottom of the scroll container too. Opening it mounts its rows BELOW the
868
+ // fold: the box's own header is all that stays on screen, the click appears to
869
+ // have done nothing, and the fields it just revealed have to be hunted for by
870
+ // scrolling.
871
+ //
872
+ // The box that was just opened, read by the layout effect below. Held as state
873
+ // rather than acted on inside the collapse handler because the handler runs
874
+ // BEFORE React re-renders — at that moment the rows do not exist yet, so the
875
+ // box has not grown and there is nothing to scroll to. A layout effect runs
876
+ // after the DOM is updated and before paint, which makes the reveal
877
+ // deterministic instead of a guessed delay.
878
+ const [openedStatusBox, setOpenedStatusBox] = useState<string | undefined>(undefined);
879
+ const statusBoxRefs = useRef<Record<string, HTMLDivElement | null>>({});
880
+ useLayoutEffect(() => {
881
+ if (!openedStatusBox) {
882
+ return;
883
+ }
884
+ const element = statusBoxRefs.current[openedStatusBox];
885
+ // Consume it either way: a box that has gone (a search narrowed it out of
886
+ // existence) must not leave a pending reveal armed for a later render.
887
+ setOpenedStatusBox(undefined);
888
+ // `scrollIntoView` is not implemented in jsdom, and a missing reveal must
889
+ // never break a form.
890
+ if (!element?.scrollIntoView) {
891
+ return;
892
+ }
893
+ // 'nearest' scrolls the MINIMUM needed: nothing at all when the box is
894
+ // already fully visible, and a top-alignment when it is taller than the
895
+ // viewport (header plus the first rows — the right answer for a long
896
+ // Optional list). Anything else moves the page when it did not need to.
897
+ element.scrollIntoView({
898
+ block: 'nearest',
899
+ behavior:
900
+ window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ? 'auto' : 'smooth',
901
+ });
902
+ }, [openedStatusBox]);
834
903
  // Remembers each row's last settled status box, so an actively-edited field
835
904
  // stays put when its status flips (e.g. becomes valid) instead of jumping to
836
905
  // another box mid-edit and stealing focus. Keyed by option name.
@@ -1863,7 +1932,11 @@ const FormEngineImpl = ({
1863
1932
  // (or below-the-fold) form is never scrolled into view on mount.
1864
1933
  const autoFocusNameRef = useRef<string | undefined>(undefined);
1865
1934
  useEffect(() => {
1866
- if (!autoFocusFirstRequired || !compact || !options) {
1935
+ // Two decisions, one scan. `autoFocusFirstRequired` opens the row AND puts
1936
+ // the caret in it; `expandFirstRequired` only opens it. Setting both keeps
1937
+ // the focusing behaviour, since focusing implies opening.
1938
+ const wantsFocus = !!autoFocusFirstRequired;
1939
+ if ((!wantsFocus && !expandFirstRequired) || !compact || !options) {
1867
1940
  return;
1868
1941
  }
1869
1942
  if (hasAutoFocusedRef.current) {
@@ -1881,8 +1954,14 @@ const FormEngineImpl = ({
1881
1954
  // clean mount focus rests on the body, so the intended "drop into the first
1882
1955
  // field" still fires. Bailing here leaves `hasAutoFocusedRef` false, so it
1883
1956
  // retries once focus is free.
1957
+ //
1958
+ // Only the FOCUSING variant waits: opening a row moves no caret, so there is
1959
+ // nothing to steal and nothing to wait for. Making the expand-only variant
1960
+ // wait here would make it a no-op in its main use — a sub-form mounted by a
1961
+ // click, where the button that mounted it still holds focus and never gives
1962
+ // it up.
1884
1963
  const active = document.activeElement as HTMLElement | null;
1885
- if (active && active !== document.body) {
1964
+ if (wantsFocus && active && active !== document.body) {
1886
1965
  return;
1887
1966
  }
1888
1967
 
@@ -1910,7 +1989,10 @@ const FormEngineImpl = ({
1910
1989
 
1911
1990
  if (target) {
1912
1991
  // Set the ref before the state update so CompactRow's focus timer sees it.
1913
- autoFocusNameRef.current = target;
1992
+ // Expand-only leaves it unset, which is what keeps the caret where it is.
1993
+ if (wantsFocus) {
1994
+ autoFocusNameRef.current = target;
1995
+ }
1914
1996
  setExpandedOptions((prev) =>
1915
1997
  prev.includes(target) ? prev
1916
1998
  : expandMode === 'multi' ? [...prev, target]
@@ -1919,6 +2001,7 @@ const FormEngineImpl = ({
1919
2001
  }
1920
2002
  }, [
1921
2003
  autoFocusFirstRequired,
2004
+ expandFirstRequired,
1922
2005
  compact,
1923
2006
  options,
1924
2007
  availableOptions,
@@ -2494,9 +2577,40 @@ const FormEngineImpl = ({
2494
2577
  if (requiredOnly && !(schema?.required || schema?.required_groups)) {
2495
2578
  return false;
2496
2579
  }
2497
- return matchesQuery(optionName);
2580
+ // A field that absorbs a sibling also answers for it: searching
2581
+ // "language" has to surface the row that now holds the language control,
2582
+ // or the field becomes unreachable through the filter.
2583
+ if (matchesQuery(optionName)) return true;
2584
+ const absorb = (schema as { absorb_fields?: string[] } | undefined)?.absorb_fields;
2585
+ return !!absorb?.some((absorbedName) => matchesQuery(absorbedName));
2498
2586
  };
2499
2587
 
2588
+ // Fields a sibling absorbs into its own container.
2589
+ //
2590
+ // A field declaring `absorb_fields: ['language']` renders that sibling's
2591
+ // control inside its own row instead of letting it have a row of its own.
2592
+ // The pair this exists for is a code editor and its language: they are one
2593
+ // decision — "what is this code, and in what language" — and reading them
2594
+ // as two unrelated rows one above the other is what the layout used to say.
2595
+ //
2596
+ // Absorbed fields stay in `grouped`, and therefore in the Set / Optional /
2597
+ // Needs-attention counts and in the completion meter. Only the ROW is
2598
+ // skipped. Dropping them from the buckets instead would leave the meter
2599
+ // ("13/18 set", computed from `availableOptions`) disagreeing with the box
2600
+ // badge beside it, which counts row entries.
2601
+ const absorbedBy: Record<string, string> = {};
2602
+ forEach(options, (option, optionName) => {
2603
+ const absorb = (option as { absorb_fields?: string[] } | undefined)?.absorb_fields;
2604
+ if (!absorb?.length) return;
2605
+ absorb.forEach((absorbedName) => {
2606
+ // Only absorb a sibling that exists and is not already spoken for; a
2607
+ // schema naming a missing field must not make the field disappear.
2608
+ if (options?.[absorbedName] && !absorbedBy[absorbedName]) {
2609
+ absorbedBy[absorbedName] = optionName;
2610
+ }
2611
+ });
2612
+ });
2613
+
2500
2614
  // Group the options by their raw `group` key, remembering the order groups
2501
2615
  // first appear in the schema. Each row carries a `hidden` flag.
2502
2616
  const groupOrder: string[] = [];
@@ -2649,6 +2763,11 @@ const FormEngineImpl = ({
2649
2763
  // together at the first member's slot and rendered as a connected rail (flat
2650
2764
  // rows — no wrapper — so the value surface applies normally; the rail + nodes
2651
2765
  // are drawn per member). Narrow stacks fall back to flat rows.
2766
+ // The absorbed siblings a host row must render, in schema order. Empty for
2767
+ // every row that absorbs nothing, which is all of them by default.
2768
+ const absorbedNamesFor = (hostName: string): string[] =>
2769
+ Object.keys(absorbedBy).filter((absorbedName) => absorbedBy[absorbedName] === hostName);
2770
+
2652
2771
  const renderGroupRows = (names: Array<{ name: string; hidden: boolean }>) => {
2653
2772
  const renderRow = (
2654
2773
  entry: { name: string; hidden?: boolean },
@@ -2671,6 +2790,9 @@ const FormEngineImpl = ({
2671
2790
  clustered={clustered}
2672
2791
  clusterFirst={clusterFirst}
2673
2792
  clusterLast={clusterLast}
2793
+ // The siblings this row renders inside itself instead of leaving
2794
+ // them a row of their own — see `absorbedBy`.
2795
+ absorbedFields={absorbedNamesFor(entry.name)}
2674
2796
  />
2675
2797
  );
2676
2798
  // (Clustering runs in narrow mode too now — the "One of the below is
@@ -2679,6 +2801,10 @@ const FormEngineImpl = ({
2679
2801
  const emitted = new Set<string>();
2680
2802
  const groupOf = (name: string) =>
2681
2803
  (options?.[name]?.required_groups as string[] | undefined)?.[0];
2804
+ // An absorbed field keeps its place in the buckets — so the Set /
2805
+ // Optional counts and the completion meter still see it — but its host
2806
+ // renders its control, so it gets no row of its own here.
2807
+ names = names.filter((entry) => !absorbedBy[entry.name]);
2682
2808
  return names.map((entry) => {
2683
2809
  const grp = groupOf(entry.name);
2684
2810
  if (!grp) return renderRow(entry, false);
@@ -2783,6 +2909,14 @@ const FormEngineImpl = ({
2783
2909
  $accent={accent}
2784
2910
  $bg={boxBg}
2785
2911
  key={box.key}
2912
+ ref={(node: HTMLDivElement | null) => {
2913
+ statusBoxRefs.current[box.key] = node;
2914
+ }}
2915
+ // Opening arms the reveal; collapsing disarms it, so a
2916
+ // close never scrolls.
2917
+ onCollapseChange={(collapsed) =>
2918
+ setOpenedStatusBox(collapsed ? undefined : box.key)
2919
+ }
2786
2920
  flat
2787
2921
  minimal
2788
2922
  collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}
@@ -0,0 +1,129 @@
1
+ import { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { expect } from 'storybook/test';
3
+ import { SchemaDataView } from './SchemaDataView';
4
+
5
+ /**
6
+ * The read-only rendering of a value described by an `arg_schema`.
7
+ *
8
+ * Rendered directly here rather than through a whole FormEngine: these stories
9
+ * are about what the VIEW does with a described value, and mounting a form
10
+ * around it only adds chrome that the form's own stories already cover.
11
+ */
12
+ const meta: Meta<typeof SchemaDataView> = {
13
+ title: 'Form/Engine/SchemaDataView',
14
+ component: SchemaDataView,
15
+ };
16
+
17
+ export default meta;
18
+ type Story = StoryObj<typeof meta>;
19
+
20
+ /** Auth-profile schemes: the first field is a CHOICE, so its heading is prose. */
21
+ const SchemeSchema = {
22
+ type: {
23
+ type: 'string',
24
+ display_name: 'Scheme Type',
25
+ allowed_values: [
26
+ { value: 'default', display_name: 'Default RBAC' },
27
+ { value: 'cookie', display_name: 'Cookie' },
28
+ ],
29
+ },
30
+ cookie_name: { type: 'string', display_name: 'Session Cookie Name' },
31
+ redirect_url: { type: 'string', display_name: 'Redirect URL' },
32
+ } as never;
33
+
34
+ const schemeValue = [
35
+ { type: 'hash', value: { type: 'default' } },
36
+ {
37
+ type: 'hash',
38
+ value: {
39
+ type: 'cookie',
40
+ cookie_name: 'qorus-session',
41
+ redirect_url: 'https://example.com/login',
42
+ },
43
+ },
44
+ ];
45
+
46
+ /** Service methods: the first field is a LITERAL, so its heading is mono. */
47
+ const MethodSchema = {
48
+ name: { type: 'string', display_name: 'Method Name' },
49
+ desc: { type: 'string', display_name: 'Description' },
50
+ } as never;
51
+
52
+ const methodValue = [
53
+ { type: 'hash', value: { name: 'init', desc: 'init method' } },
54
+ { type: 'hash', value: { name: 'onOrderStatus', desc: 'returns the status of a single order' } },
55
+ { type: 'hash', value: { name: 'onConnect', desc: 'connection lifecycle hook' } },
56
+ ];
57
+
58
+ const colors = { key: '#f1f0ee', muted: '#a0a0a0', border: '#a0a0a066', accent: '#3b8eea' };
59
+
60
+ export const Schemes: Story = {
61
+ parameters: {
62
+ docs: {
63
+ description: {
64
+ story:
65
+ 'A described list whose identifying field is a choice — each item is headed by the display name of that choice, in prose.',
66
+ },
67
+ },
68
+ },
69
+ args: { value: schemeValue, schema: SchemeSchema, colors },
70
+ play: async ({ canvasElement }) => {
71
+ const titles = [...canvasElement.querySelectorAll('.schema-view-item-title')].map((element) =>
72
+ (element.textContent ?? '').trim()
73
+ );
74
+ await expect(titles).toEqual(['Default RBAC', 'Cookie']);
75
+ },
76
+ };
77
+
78
+ export const ServiceMethods: Story = {
79
+ parameters: {
80
+ docs: {
81
+ description: {
82
+ story:
83
+ "A described list whose identifying field is a literal — each item is headed by the method's name, which keeps the mono face it has as a value.",
84
+ },
85
+ },
86
+ },
87
+ args: { value: methodValue, schema: MethodSchema, colors },
88
+ play: async ({ canvasElement }) => {
89
+ await expect(canvasElement.querySelectorAll('.schema-view-item-title')).toHaveLength(3);
90
+ },
91
+ };
92
+
93
+ export const Undescribed: Story = {
94
+ parameters: {
95
+ docs: {
96
+ description: {
97
+ story:
98
+ 'Keys the schema does not describe are still shown, under their raw name, after the described ones — a preview that dropped data would be worse than a raw tree.',
99
+ },
100
+ },
101
+ },
102
+ args: {
103
+ value: [{ type: 'hash', value: { type: 'cookie', legacy_flag: 'kept' } }],
104
+ schema: SchemeSchema,
105
+ colors,
106
+ },
107
+ play: async ({ canvasElement }) => {
108
+ await expect(canvasElement.textContent).toContain('legacy_flag');
109
+
110
+ // A field name renders on ONE line. The label column is measured from the
111
+ // longest name, and the label carries `overflow-wrap: anywhere` — so an
112
+ // under-measured column does not ellipsise, it breaks the name in half.
113
+ // That is exactly what happened when the labels became uppercase and the
114
+ // measurement was still in `ch`, which knows nothing about the tracking:
115
+ // `LEGACY_FLAG` came out over two lines.
116
+ //
117
+ // Asserted as geometry rather than as a width, because the width is the
118
+ // thing that keeps being wrong — the invariant is that the name fits.
119
+ const label = [...canvasElement.querySelectorAll<HTMLElement>('.schema-view-fields > *')].find(
120
+ (element) => (element.textContent ?? '').trim().toLowerCase() === 'legacy_flag'
121
+ );
122
+
123
+ await expect(label).toBeTruthy();
124
+
125
+ const lineHeight = parseFloat(getComputedStyle(label!).lineHeight) || 16;
126
+
127
+ await expect(label!.offsetHeight).toBeLessThan(lineHeight * 1.6);
128
+ },
129
+ };