@qoretechnologies/reqraft 0.10.46-pr.95.ge595a13 → 0.10.46

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 (147) hide show
  1. package/.claude/CLAUDE.md +12 -0
  2. package/.tasks/INDEX.md +1 -0
  3. package/.tasks/TEMPLATE_TOKEN_GRAMMAR.md +72 -0
  4. package/dist/components/composer/Composer.d.ts.map +1 -1
  5. package/dist/components/composer/Composer.js +5 -1
  6. package/dist/components/composer/Composer.js.map +1 -1
  7. package/dist/components/form/engine/CompactRow.d.ts +14 -0
  8. package/dist/components/form/engine/CompactRow.d.ts.map +1 -1
  9. package/dist/components/form/engine/CompactRow.js +199 -41
  10. package/dist/components/form/engine/CompactRow.js.map +1 -1
  11. package/dist/components/form/engine/CompactToolbar.d.ts.map +1 -1
  12. package/dist/components/form/engine/CompactToolbar.js +72 -72
  13. package/dist/components/form/engine/CompactToolbar.js.map +1 -1
  14. package/dist/components/form/engine/FormEngine.d.ts +65 -1
  15. package/dist/components/form/engine/FormEngine.d.ts.map +1 -1
  16. package/dist/components/form/engine/FormEngine.js +276 -108
  17. package/dist/components/form/engine/FormEngine.js.map +1 -1
  18. package/dist/components/form/engine/_structuredData/SchemaDataView.d.ts.map +1 -1
  19. package/dist/components/form/engine/_structuredData/SchemaDataView.js +55 -13
  20. package/dist/components/form/engine/_structuredData/SchemaDataView.js.map +1 -1
  21. package/dist/components/form/engine/_structuredData/structuredData.d.ts +15 -0
  22. package/dist/components/form/engine/_structuredData/structuredData.d.ts.map +1 -1
  23. package/dist/components/form/engine/_structuredData/structuredData.js +22 -1
  24. package/dist/components/form/engine/_structuredData/structuredData.js.map +1 -1
  25. package/dist/components/form/engine/compactRowStyles.d.ts.map +1 -1
  26. package/dist/components/form/engine/compactRowStyles.js +15 -3
  27. package/dist/components/form/engine/compactRowStyles.js.map +1 -1
  28. package/dist/components/form/engine/compactToolbarContext.d.ts +8 -0
  29. package/dist/components/form/engine/compactToolbarContext.d.ts.map +1 -1
  30. package/dist/components/form/engine/compactToolbarContext.js.map +1 -1
  31. package/dist/components/form/engine/readFirst.d.ts +24 -0
  32. package/dist/components/form/engine/readFirst.d.ts.map +1 -1
  33. package/dist/components/form/engine/readFirst.js +84 -6
  34. package/dist/components/form/engine/readFirst.js.map +1 -1
  35. package/dist/components/form/fields/Field.d.ts.map +1 -1
  36. package/dist/components/form/fields/Field.js +1 -1
  37. package/dist/components/form/fields/Field.js.map +1 -1
  38. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts +12 -2
  39. package/dist/components/form/fields/allowed-values/AllowedValues.d.ts.map +1 -1
  40. package/dist/components/form/fields/allowed-values/AllowedValues.js +16 -6
  41. package/dist/components/form/fields/allowed-values/AllowedValues.js.map +1 -1
  42. package/dist/components/form/fields/auto/AutoFormField.d.ts.map +1 -1
  43. package/dist/components/form/fields/auto/AutoFormField.js +64 -14
  44. package/dist/components/form/fields/auto/AutoFormField.js.map +1 -1
  45. package/dist/components/form/fields/long-string/LongString.d.ts +9 -1
  46. package/dist/components/form/fields/long-string/LongString.d.ts.map +1 -1
  47. package/dist/components/form/fields/long-string/LongString.js +25 -4
  48. package/dist/components/form/fields/long-string/LongString.js.map +1 -1
  49. package/dist/components/form/fields/template/ExampleValueModal.d.ts +29 -0
  50. package/dist/components/form/fields/template/ExampleValueModal.d.ts.map +1 -0
  51. package/dist/components/form/fields/template/ExampleValueModal.js +122 -0
  52. package/dist/components/form/fields/template/ExampleValueModal.js.map +1 -0
  53. package/dist/components/form/fields/template/ReadOnlyTemplateTag.d.ts.map +1 -1
  54. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js +3 -3
  55. package/dist/components/form/fields/template/ReadOnlyTemplateTag.js.map +1 -1
  56. package/dist/components/form/fields/template/TemplateField.d.ts.map +1 -1
  57. package/dist/components/form/fields/template/TemplateField.js +81 -27
  58. package/dist/components/form/fields/template/TemplateField.js.map +1 -1
  59. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts +2 -0
  60. package/dist/components/form/fields/template/__fixtures__/longExampleValue.d.ts.map +1 -0
  61. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js +9 -0
  62. package/dist/components/form/fields/template/__fixtures__/longExampleValue.js.map +1 -0
  63. package/dist/components/form/index.d.ts +1 -0
  64. package/dist/components/form/index.d.ts.map +1 -1
  65. package/dist/components/form/index.js +1 -0
  66. package/dist/components/form/index.js.map +1 -1
  67. package/dist/components/searchFilterBar/SearchFilterBar.d.ts +12 -1
  68. package/dist/components/searchFilterBar/SearchFilterBar.d.ts.map +1 -1
  69. package/dist/components/searchFilterBar/SearchFilterBar.js +2 -2
  70. package/dist/components/searchFilterBar/SearchFilterBar.js.map +1 -1
  71. package/dist/components/ticketThread/TicketThread.d.ts.map +1 -1
  72. package/dist/components/ticketThread/TicketThread.js +1 -1
  73. package/dist/components/ticketThread/TicketThread.js.map +1 -1
  74. package/dist/helpers/common.d.ts +8 -1
  75. package/dist/helpers/common.d.ts.map +1 -1
  76. package/dist/helpers/common.js +9 -1
  77. package/dist/helpers/common.js.map +1 -1
  78. package/dist/helpers/expressions.d.ts.map +1 -1
  79. package/dist/helpers/expressions.js +15 -2
  80. package/dist/helpers/expressions.js.map +1 -1
  81. package/dist/helpers/optionUiTypes.d.ts +30 -0
  82. package/dist/helpers/optionUiTypes.d.ts.map +1 -0
  83. package/dist/helpers/optionUiTypes.js +156 -0
  84. package/dist/helpers/optionUiTypes.js.map +1 -0
  85. package/dist/helpers/options.d.ts +19 -0
  86. package/dist/helpers/options.d.ts.map +1 -1
  87. package/dist/helpers/options.js +25 -1
  88. package/dist/helpers/options.js.map +1 -1
  89. package/dist/helpers/providerValue.d.ts +68 -0
  90. package/dist/helpers/providerValue.d.ts.map +1 -0
  91. package/dist/helpers/providerValue.js +50 -0
  92. package/dist/helpers/providerValue.js.map +1 -0
  93. package/dist/helpers/singleLineString.d.ts +37 -0
  94. package/dist/helpers/singleLineString.d.ts.map +1 -0
  95. package/dist/helpers/singleLineString.js +66 -0
  96. package/dist/helpers/singleLineString.js.map +1 -0
  97. package/dist/helpers/templates.d.ts +100 -0
  98. package/dist/helpers/templates.d.ts.map +1 -1
  99. package/dist/helpers/templates.js +237 -4
  100. package/dist/helpers/templates.js.map +1 -1
  101. package/dist/helpers/validations.d.ts +103 -2
  102. package/dist/helpers/validations.d.ts.map +1 -1
  103. package/dist/helpers/validations.js +858 -91
  104. package/dist/helpers/validations.js.map +1 -1
  105. package/dist/index.d.ts +1 -1
  106. package/dist/index.d.ts.map +1 -1
  107. package/dist/index.js +2 -1
  108. package/dist/index.js.map +1 -1
  109. package/package.json +1 -1
  110. package/src/components/composer/Composer.tsx +7 -3
  111. package/src/components/form/engine/CompactRow.tsx +279 -33
  112. package/src/components/form/engine/CompactToolbar.tsx +105 -98
  113. package/src/components/form/engine/FormEngine.stories.tsx +995 -12
  114. package/src/components/form/engine/FormEngine.tsx +377 -94
  115. package/src/components/form/engine/_structuredData/SchemaDataView.stories.tsx +104 -0
  116. package/src/components/form/engine/_structuredData/SchemaDataView.tsx +158 -101
  117. package/src/components/form/engine/_structuredData/structuredData.ts +20 -0
  118. package/src/components/form/engine/compactRowStyles.ts +55 -1
  119. package/src/components/form/engine/compactToolbarContext.ts +8 -0
  120. package/src/components/form/engine/readFirst.ts +125 -35
  121. package/src/components/form/fields/Field.tsx +3 -1
  122. package/src/components/form/fields/allowed-values/AllowedValues.tsx +24 -2
  123. package/src/components/form/fields/array/ArrayAutoField.stories.tsx +81 -0
  124. package/src/components/form/fields/auto/AutoFormField.stories.tsx +47 -1
  125. package/src/components/form/fields/auto/AutoFormField.tsx +73 -11
  126. package/src/components/form/fields/long-string/LongString.tsx +46 -5
  127. package/src/components/form/fields/schema-definition/SchemaDefinitionField.stories.tsx +5 -0
  128. package/src/components/form/fields/template/ExampleValueModal.stories.tsx +114 -0
  129. package/src/components/form/fields/template/ExampleValueModal.tsx +99 -0
  130. package/src/components/form/fields/template/ReadOnlyTemplateTag.tsx +8 -4
  131. package/src/components/form/fields/template/TemplateField.stories.tsx +389 -1
  132. package/src/components/form/fields/template/TemplateField.tsx +100 -35
  133. package/src/components/form/fields/template/__fixtures__/longExampleValue.ts +7 -0
  134. package/src/components/form/index.tsx +1 -0
  135. package/src/components/searchFilterBar/SearchFilterBar.stories.tsx +62 -0
  136. package/src/components/searchFilterBar/SearchFilterBar.tsx +13 -0
  137. package/src/components/ticketThread/TicketThread.tsx +7 -0
  138. package/src/helpers/common.ts +11 -1
  139. package/src/helpers/expressions.ts +16 -2
  140. package/src/helpers/optionUiTypes.ts +161 -0
  141. package/src/helpers/options.ts +23 -0
  142. package/src/helpers/providerValue.ts +125 -0
  143. package/src/helpers/singleLineString.ts +61 -0
  144. package/src/helpers/templates.ts +272 -3
  145. package/src/helpers/validations.ts +1177 -109
  146. package/src/hooks/useWebSocket/useWebsocket.stories.tsx +23 -2
  147. package/src/index.tsx +1 -0
@@ -55,6 +55,78 @@ const methodValue = [
55
55
  { type: 'hash', value: { name: 'onConnect', desc: 'connection lifecycle hook' } },
56
56
  ];
57
57
 
58
+ /**
59
+ * A schema whose hash values hold hash values of their own — three levels, not
60
+ * one.
61
+ *
62
+ * Every other fixture here is a flat record, and a flat record cannot show what
63
+ * this view does with depth: whether a nested level is legible, whether the
64
+ * rules stack sensibly, whether a heading two levels down still reads as a
65
+ * heading. Judging the treatment on a single `key: value` pair is what the
66
+ * review said was impossible, and it was right.
67
+ */
68
+ const NestedSchema = {
69
+ name: { type: 'string', display_name: 'Endpoint' },
70
+ auth: {
71
+ type: 'hash',
72
+ display_name: 'Authentication',
73
+ arg_schema: {
74
+ scheme: {
75
+ type: 'string',
76
+ display_name: 'Scheme',
77
+ allowed_values: [
78
+ { value: 'oauth2', display_name: 'OAuth2' },
79
+ { value: 'basic', display_name: 'Basic' },
80
+ ],
81
+ },
82
+ token: {
83
+ type: 'hash',
84
+ display_name: 'Token',
85
+ arg_schema: {
86
+ url: { type: 'string', display_name: 'Token URL' },
87
+ ttl: { type: 'int', display_name: 'Lifetime (s)' },
88
+ rotate: { type: 'bool', display_name: 'Rotate Automatically' },
89
+ },
90
+ },
91
+ },
92
+ },
93
+ retry: {
94
+ type: 'hash',
95
+ display_name: 'Retry Policy',
96
+ arg_schema: {
97
+ attempts: { type: 'int', display_name: 'Attempts' },
98
+ backoff: { type: 'string', display_name: 'Backoff' },
99
+ },
100
+ },
101
+ } as never;
102
+
103
+ const nestedValue = [
104
+ {
105
+ type: 'hash',
106
+ value: {
107
+ name: 'POST /orders',
108
+ auth: {
109
+ scheme: 'oauth2',
110
+ token: {
111
+ url: 'https://auth.example.com/oauth2/token',
112
+ ttl: 3600,
113
+ rotate: true,
114
+ },
115
+ },
116
+ retry: { attempts: 3, backoff: 'exponential' },
117
+ },
118
+ },
119
+ {
120
+ // A second record that fills only part of the tree: the nested level is
121
+ // present but shallow, which is where "no data under the label" shows up.
122
+ type: 'hash',
123
+ value: {
124
+ name: 'GET /orders/{id}',
125
+ auth: { scheme: 'basic' },
126
+ },
127
+ },
128
+ ];
129
+
58
130
  const colors = { key: '#f1f0ee', muted: '#a0a0a0', border: '#a0a0a066', accent: '#3b8eea' };
59
131
 
60
132
  export const Schemes: Story = {
@@ -127,3 +199,35 @@ export const Undescribed: Story = {
127
199
  await expect(label!.offsetHeight).toBeLessThan(lineHeight * 1.6);
128
200
  },
129
201
  };
202
+
203
+ /**
204
+ * The deeper case: hash values holding hash values.
205
+ *
206
+ * Asked for in review because the flat fixtures could not answer whether the
207
+ * treatment survives nesting — three levels here (endpoint → authentication →
208
+ * token), plus a second record that stops after one, so a nested level with
209
+ * nothing under it renders beside one that is full.
210
+ */
211
+ export const NestedSchemas: Story = {
212
+ parameters: {
213
+ docs: {
214
+ description: {
215
+ story:
216
+ 'A described list whose records nest two levels deeper: an endpoint carrying an Authentication hash, which itself carries a Token hash of URL, lifetime and a bool. The second record fills only the first nested level, so a shallow branch sits next to a full one.',
217
+ },
218
+ },
219
+ },
220
+ args: { value: nestedValue, schema: NestedSchema, colors },
221
+ play: async ({ canvasElement }) => {
222
+ const titles = [...canvasElement.querySelectorAll('.schema-view-item-title')].map((element) =>
223
+ (element.textContent ?? '').trim()
224
+ );
225
+
226
+ await expect(titles).toEqual(['POST /orders', 'GET /orders/{id}']);
227
+ // The deepest level actually rendered: a label from the third tier, and the
228
+ // bool beneath it resolved to a word rather than printed as `true`.
229
+ await expect(canvasElement.textContent).toContain('Token URL');
230
+ await expect(canvasElement.textContent).toContain('Lifetime (s)');
231
+ await expect(canvasElement.textContent).not.toContain('[object Object]');
232
+ },
233
+ };
@@ -1,4 +1,10 @@
1
- import { ReqoreCollapsibleContent, ReqoreIcon, ReqoreP, ReqoreTag } from '@qoretechnologies/reqore';
1
+ import {
2
+ ReqoreCollapsibleContent,
3
+ ReqoreIcon,
4
+ ReqoreLink,
5
+ ReqoreP,
6
+ ReqoreTag,
7
+ } from '@qoretechnologies/reqore';
2
8
  import {
3
9
  IQorusFormField,
4
10
  IQorusFormSchema,
@@ -8,7 +14,12 @@ import React from 'react';
8
14
  import styled from 'styled-components';
9
15
  import { useContextSelector } from 'use-context-selector';
10
16
  import { CompactRowContext } from '../compactRowContext';
11
- import { MONO_FONT_STACK, StyledCodePreview, StyledRowLabel, StyledRowValue } from '../compactRowStyles';
17
+ import {
18
+ MONO_FONT_STACK,
19
+ StyledCodePreview,
20
+ StyledRowLabel,
21
+ StyledRowValue,
22
+ } from '../compactRowStyles';
12
23
  import {
13
24
  fieldLabel,
14
25
  findAllowedValueOption,
@@ -19,7 +30,7 @@ import {
19
30
  recordIdentity,
20
31
  titleKeyFor,
21
32
  } from '../readFirst';
22
- import { isUiEncodedValue } from './structuredData';
33
+ import { isEmptyUiEnvelope, isUiEncodedValue } from './structuredData';
23
34
 
24
35
  /**
25
36
  * A read-only rendering of a value THROUGH the schema that describes it.
@@ -139,10 +150,38 @@ const StyledItemTitle = styled.div<{ $color: string; $mono: boolean; $accent: st
139
150
  ${({ $mono }) => ($mono ? `font-family: ${MONO_FONT_STACK}; font-size: 15px;` : '')}
140
151
  `;
141
152
 
142
- /** The rule tying an item's fields to its number. */
143
- const StyledItemFields = styled.div<{ $border: string }>`
144
- border-left: 1px solid ${({ $border }) => $border};
145
- padding-left: 10px;
153
+ /**
154
+ * Does this record have anything to show BELOW its heading?
155
+ *
156
+ * The heading consumes the identifying field, so a record holding only that has
157
+ * an empty body — and a rule drawn down an empty body is a line to nowhere.
158
+ */
159
+ const hasContentUnderHeading = (record: unknown, schema?: IQorusFormSchema): boolean => {
160
+ if (!isRecord(record)) {
161
+ return true;
162
+ }
163
+ const titleKey = titleKeyFor(record, schema);
164
+
165
+ return Object.entries(record).some(
166
+ ([key, value]) => key !== titleKey && value !== undefined && value !== null && value !== ''
167
+ );
168
+ };
169
+
170
+ /** The rule tying an item's fields to its number.
171
+ *
172
+ * Same 3px as the heading's accent bar directly above it: at 1px the two read as
173
+ * two different devices stacked on each other rather than one rule running down
174
+ * the item, and the padding drops to 8px so the text stays on the same column.
175
+ *
176
+ * The rule is drawn only when something is actually under the heading. The
177
+ * heading is pulled left of the rule by its own negative margin, so an item
178
+ * whose record holds nothing but its identity showed a 1-2px sliver of line and
179
+ * nothing else — which reads as a rendering artefact, because that is what it
180
+ * was. It keeps a transparent border in that case so the column does not shift.
181
+ */
182
+ const StyledItemFields = styled.div<{ $border: string; $rule: boolean }>`
183
+ border-left: 3px solid ${({ $border, $rule }) => ($rule ? $border : 'transparent')};
184
+ padding-left: 8px;
146
185
  min-width: 0;
147
186
  `;
148
187
 
@@ -349,24 +388,22 @@ const FieldValue = ({
349
388
  style={{ flexShrink: 0, marginRight: 6, verticalAlign: 'middle' }}
350
389
  />
351
390
  : null}
352
- {formatted}
353
391
  {isNavigableUrl(field.value) ?
354
- // The read-only detail views make an address openable, and a
355
- // preview that shows the same value should not be the one place it
356
- // is inert. A real anchor, so middle-click and "copy link address"
357
- // work; `stopPropagation` because the row around it toggles.
358
- <a
392
+ // The address IS the link it used to render as inert text with a
393
+ // separate icon button beside it to open it, which is two things
394
+ // where there is one. ReqoreLink renders a real anchor, so
395
+ // middle-click and "copy link address" still work;
396
+ // `stopPropagation` because the row around it toggles.
397
+ <ReqoreLink
359
398
  className='schema-view-open-url'
360
399
  href={field.value}
361
400
  target='_blank'
362
401
  rel='noreferrer noopener'
363
- aria-label='Open in a new tab'
364
- style={{ marginLeft: 4, verticalAlign: 'middle' }}
365
- onClick={(event) => event.stopPropagation()}
402
+ onClick={(event: React.MouseEvent) => event.stopPropagation()}
366
403
  >
367
- <ReqoreIcon icon='ExternalLinkLine' size='11px' />
368
- </a>
369
- : null}
404
+ {formatted}
405
+ </ReqoreLink>
406
+ : formatted}
370
407
  </span>
371
408
  }
372
409
  </StyledRowValue>
@@ -391,92 +428,109 @@ const SchemaRecord = ({
391
428
  // The SAME host renderer the row above uses for a `code-editor` field. Read
392
429
  // from context rather than threaded through props: a nested level is arbitrarily
393
430
  // deep, and the renderer is a property of the form, not of any one level.
394
- const codePreviewRenderer = useContextSelector(
395
- CompactRowContext,
396
- (v) => v.codePreviewRenderer
397
- );
431
+ const codePreviewRenderer = useContextSelector(CompactRowContext, (v) => v.codePreviewRenderer);
398
432
  const cHover = useContextSelector(CompactRowContext, (v) => v.cHover);
399
433
 
400
434
  return (
401
- <StyledFields className='schema-view-fields'>
402
- {orderedKeys(record, schema)
403
- .filter((key) => key !== skipKey)
404
- .map((key) => {
405
- const fieldSchema = schema[key] as TQorusFormFieldSchema | undefined;
406
- const nested = (fieldSchema as { arg_schema?: IQorusFormSchema } | undefined)?.arg_schema;
407
- const value = unwrap(record[key]);
408
- const label = fieldLabel(key, fieldSchema);
409
- const labelWithType =
410
- showTypes && fieldSchema?.type ? `${label} · ${fieldSchema.type}` : label;
411
-
412
- // A field that describes its own level renders AS that level, indented
413
- // under its name the same decision, one step down.
414
- if (nested && (isRecord(value) || Array.isArray(value))) {
415
- return (
416
- <React.Fragment key={key}>
417
- <StyledNestedLabel>
418
- <StyledFieldLabel $color={colors.key}>{labelWithType}</StyledFieldLabel>
419
- </StyledNestedLabel>
420
- <StyledNested $border={colors.border}>
421
- <SchemaLevel value={value} schema={nested} showTypes={showTypes} colors={colors} />
422
- </StyledNested>
423
- </React.Fragment>
424
- );
425
- }
426
-
427
- // A code value is source, and source is read as source: the host's renderer
428
- // gives it the syntax highlighting (and read-only LSP hover) it has in the
429
- // Source Code field, and "Show more" keeps a long body from taking over the
430
- // preview. Flattening it to one mono line — which is what a plain value cell
431
- // does — throws all of that away for a field whose whole content is code.
432
- if (isCodeField(fieldSchema) && typeof value === 'string') {
433
- return (
434
- <React.Fragment key={key}>
435
- <StyledNestedLabel>
436
- <StyledFieldLabel $color={colors.key}>{labelWithType}</StyledFieldLabel>
437
- </StyledNestedLabel>
438
- <StyledCodeCell onClick={(event) => event.stopPropagation()}>
439
- <ReqoreCollapsibleContent
440
- maxCollapsedHeight={96}
441
- buttonProps={{ className: 'options-readfirst-viewmore' }}
442
- >
443
- {codePreviewRenderer ?
444
- codePreviewRenderer({
445
- value,
446
- name: key,
447
- schema: fieldSchema,
448
- options: schema,
449
- values: record as never,
450
- })
451
- : <StyledCodePreview
452
- className='options-readfirst-code'
453
- $bg={cHover}
454
- $border={`${colors.border}88`}
455
- $fg={colors.key}
435
+ <StyledFields className='schema-view-fields'>
436
+ {orderedKeys(record, schema)
437
+ .filter((key) => key !== skipKey)
438
+ .map((key) => {
439
+ const fieldSchema = schema[key] as TQorusFormFieldSchema | undefined;
440
+ const nested = (fieldSchema as { arg_schema?: IQorusFormSchema } | undefined)?.arg_schema;
441
+ // A schema-declared field the form materialised but never filled in
442
+ // arrives as `{type: 'int'}` with no `value` key. `unwrap` cannot see
443
+ // that — `isUiEncodedValue` requires a `value` — so it fell through as
444
+ // CONTENT and was wrapped as one below, and `formatOptionValue` counted
445
+ // the envelope's own `type` key: an untouched Runtime Defaults read
446
+ // "Timeout (Seconds) 1 field" three times over while the sub-form it
447
+ // previews correctly said 0/3 set. `fieldSchema` is what licenses the
448
+ // reading: the schema declares this field, so a `{type}`-only object
449
+ // under it is an empty envelope, never somebody's data.
450
+ const raw = record[key];
451
+ // `!nested` is the discriminator, and it is not a detail: a field that
452
+ // declares its OWN level describes what is inside it, so `{type: 'default'}`
453
+ // there is content — the scheme's Scheme Type, reading "Default RBAC" —
454
+ // not an envelope. Only a leaf can be empty in this sense.
455
+ const value =
456
+ fieldSchema && !nested && isEmptyUiEnvelope(raw) ? undefined : unwrap(raw);
457
+ const label = fieldLabel(key, fieldSchema);
458
+ const labelWithType =
459
+ showTypes && fieldSchema?.type ? `${label} · ${fieldSchema.type}` : label;
460
+
461
+ // A field that describes its own level renders AS that level, indented
462
+ // under its name the same decision, one step down.
463
+ if (nested && (isRecord(value) || Array.isArray(value))) {
464
+ return (
465
+ <React.Fragment key={key}>
466
+ <StyledNestedLabel>
467
+ <StyledFieldLabel $color={colors.key}>{labelWithType}</StyledFieldLabel>
468
+ </StyledNestedLabel>
469
+ <StyledNested $border={colors.border}>
470
+ <SchemaLevel
471
+ value={value}
472
+ schema={nested}
473
+ showTypes={showTypes}
474
+ colors={colors}
475
+ />
476
+ </StyledNested>
477
+ </React.Fragment>
478
+ );
479
+ }
480
+
481
+ // A code value is source, and source is read as source: the host's renderer
482
+ // gives it the syntax highlighting (and read-only LSP hover) it has in the
483
+ // Source Code field, and "Show more" keeps a long body from taking over the
484
+ // preview. Flattening it to one mono line — which is what a plain value cell
485
+ // does — throws all of that away for a field whose whole content is code.
486
+ if (isCodeField(fieldSchema) && typeof value === 'string') {
487
+ return (
488
+ <React.Fragment key={key}>
489
+ <StyledNestedLabel>
490
+ <StyledFieldLabel $color={colors.key}>{labelWithType}</StyledFieldLabel>
491
+ </StyledNestedLabel>
492
+ <StyledCodeCell onClick={(event) => event.stopPropagation()}>
493
+ <ReqoreCollapsibleContent
494
+ maxCollapsedHeight={96}
495
+ buttonProps={{ className: 'options-readfirst-viewmore' }}
456
496
  >
457
- {value}
458
- </StyledCodePreview>
459
- }
460
- </ReqoreCollapsibleContent>
461
- </StyledCodeCell>
462
- </React.Fragment>
463
- );
464
- }
497
+ {codePreviewRenderer ?
498
+ codePreviewRenderer({
499
+ value,
500
+ name: key,
501
+ schema: fieldSchema,
502
+ options: schema,
503
+ values: record as never,
504
+ })
505
+ : <StyledCodePreview
506
+ className='options-readfirst-code'
507
+ $bg={cHover}
508
+ $border={`${colors.border}88`}
509
+ $fg={colors.key}
510
+ >
511
+ {value}
512
+ </StyledCodePreview>
513
+ }
514
+ </ReqoreCollapsibleContent>
515
+ </StyledCodeCell>
516
+ </React.Fragment>
517
+ );
518
+ }
465
519
 
466
- return (
467
- <React.Fragment key={key}>
468
- <StyledFieldLabel $color={colors.key} title={label}>
469
- {labelWithType}
470
- </StyledFieldLabel>
471
- <FieldValue
472
- field={{ type: fieldSchema?.type, value } as IQorusFormField}
473
- fieldSchema={fieldSchema}
474
- color={colors.key}
475
- />
476
- </React.Fragment>
477
- );
478
- })}
479
- </StyledFields>
520
+ return (
521
+ <React.Fragment key={key}>
522
+ <StyledFieldLabel $color={colors.key} title={label}>
523
+ {labelWithType}
524
+ </StyledFieldLabel>
525
+ <FieldValue
526
+ field={{ type: fieldSchema?.type, value } as IQorusFormField}
527
+ fieldSchema={fieldSchema}
528
+ color={colors.key}
529
+ />
530
+ </React.Fragment>
531
+ );
532
+ })}
533
+ </StyledFields>
480
534
  );
481
535
  };
482
536
 
@@ -502,7 +556,10 @@ const SchemaLevel = ({
502
556
  return (
503
557
  <StyledItem key={index} className='schema-view-item'>
504
558
  <StyledMarker $color={colors.muted}>{index + 1}.</StyledMarker>
505
- <StyledItemFields $border={colors.border}>
559
+ <StyledItemFields
560
+ $border={colors.border}
561
+ $rule={hasContentUnderHeading(record, schema)}
562
+ >
506
563
  {isRecord(record) ?
507
564
  <>
508
565
  {(() => {
@@ -52,6 +52,26 @@ export const isRecord = (value: unknown): value is Record<string, unknown> =>
52
52
  export const isStructuredContainerValue = (value: unknown): boolean =>
53
53
  Array.isArray(value) || isRecord(value);
54
54
 
55
+ /**
56
+ * A field the form materialised but left UNSET: `{type: 'int'}`, no `value` key.
57
+ *
58
+ * `fixOptions` omits the key entirely when the value is undefined, so this is
59
+ * what an untouched sub-field of a schema-declared hash looks like on the wire.
60
+ * It is an EMPTY ENVELOPE, never content — and every renderer has to agree on
61
+ * that, because the ones that do not count the envelope's own `type` key and
62
+ * report an unset field as "1 field" while the sub-form correctly says 0 set.
63
+ *
64
+ * Deliberately separate from {@link isUiEncodedValue}, which requires a `value`
65
+ * and therefore cannot see this shape. Callers gate on knowing the field is
66
+ * schema-declared: without a schema saying so, a `{type: …}` object could
67
+ * legitimately be somebody's data.
68
+ */
69
+ export const isEmptyUiEnvelope = (value: unknown): boolean =>
70
+ isRecord(value) &&
71
+ typeof value.type === 'string' &&
72
+ !('value' in value) &&
73
+ Object.keys(value).every((key) => UI_ENVELOPE_KEYS.has(key));
74
+
55
75
  export const isUiEncodedValue = (value: unknown): value is IUiEncodedValue => {
56
76
  if (!isRecord(value) || typeof value.type !== 'string' || !('value' in value)) {
57
77
  return false;
@@ -61,6 +61,9 @@ export const PANEL_LEFT_CSS = `calc(${LABEL_COL} + ${COMPACT_ROW_PAD_X + COMPACT
61
61
  export const StyledCompactPanel = styled(ReqorePanel)<{
62
62
  $headerBg: string;
63
63
  $nested?: boolean;
64
+ /** True when the toolbar renders no search row (hidden via `compactToolbar`,
65
+ * self-hidden on a single-option form, or no toolbar at all). */
66
+ $tightHeader?: boolean;
64
67
  }>`
65
68
  > .reqore-panel-title {
66
69
  /* The blur + translateZ exist only to make the STICKY top-level toolbar ghost
@@ -71,7 +74,17 @@ export const StyledCompactPanel = styled(ReqorePanel)<{
71
74
  'backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); transform: translateZ(0);'
72
75
  )}
73
76
  padding-top: ${GAP_FROM_SIZE[HEADER_GAP]}px;
74
- padding-bottom: ${GAP_FROM_SIZE[HEADER_GAP]}px;
77
+ /* A header with a SEARCH ROW keeps the \`big\` gap below it — a tall
78
+ interactive control earns separation from the content it filters. A
79
+ header without one (search hidden via \`compactToolbar\`, self-hidden on
80
+ a single-option form, or no toolbar at all) is a thin strip, and at
81
+ \`big\` its padding stacked onto the panel content's own 8px into a 26px
82
+ dead band between the completion meter and the first status box —
83
+ rejected on build #140 as "unnecessarily big". \`normal\` lands it at
84
+ the boxes' own 10px rhythm, and scoping the change here keeps every
85
+ search-bearing form pixel-identical. */
86
+ padding-bottom: ${({ $tightHeader }) =>
87
+ $tightHeader ? GAP_FROM_SIZE.normal : GAP_FROM_SIZE[HEADER_GAP]}px;
75
88
  }
76
89
 
77
90
  /* Group framing. The HORIZONTAL rule is a real element in the header
@@ -437,6 +450,10 @@ export const StyledGroupBody = styled.div<{
437
450
  $success: string;
438
451
  $rowBg: string;
439
452
  $lineColor: string;
453
+ /** Edge colour for the "N more fields" row. Its own value because the row
454
+ HAS to be seen — the divider tint that separates two fields is ~8% of the
455
+ text colour, which disappears entirely as a 1px dashed border. */
456
+ $moreBorder: string;
440
457
  }>`
441
458
  display: flex;
442
459
  flex-flow: column;
@@ -516,6 +533,43 @@ export const StyledGroupBody = styled.div<{
516
533
  .readfirst-row-hidden {
517
534
  opacity: 0.65;
518
535
  }
536
+ /* The "N more fields" control IS a field row — same grid, same padding, same
537
+ hover — so it lines up with the rows above instead of floating as a scrap
538
+ of text in the box's corner. A dashed edge and a lower-contrast label are
539
+ what say it is not itself a field: nothing here can be set.
540
+
541
+ It spans both content columns because it has no label/value split, and it
542
+ drops the inter-row hairline (the ::after rule) since it always sits last. */
543
+ .readfirst-more-row {
544
+ grid-template-columns: 1fr auto;
545
+ border: 1px dashed ${({ $moreBorder }) => $moreBorder};
546
+ background: transparent;
547
+ width: 100%;
548
+ text-align: left;
549
+ font: inherit;
550
+ color: inherit;
551
+ /* The dashed edge replaces the hairline; two lines would read as a divider
552
+ AND a border stacked. */
553
+ margin-top: 2px;
554
+ }
555
+ .readfirst-more-row::after {
556
+ display: none;
557
+ }
558
+ .readfirst-more-row:hover {
559
+ background: ${({ $hover }) => $hover};
560
+ border-style: solid;
561
+ }
562
+ .readfirst-more-row:focus-visible {
563
+ outline: 2px solid ${({ $focus }) => $focus};
564
+ outline-offset: -2px;
565
+ }
566
+ /* The chevron points the way the click goes: down to reveal, up to fold. */
567
+ .readfirst-more-row .readfirst-more-chevron {
568
+ transition: transform 0.15s ease;
569
+ }
570
+ .readfirst-more-row[aria-expanded='true'] .readfirst-more-chevron {
571
+ transform: rotate(180deg);
572
+ }
519
573
  /* A disabled field (schema disabled flag or unmet dependencies) is not a
520
574
  click target — no hover invite, not-allowed cursor; a lock replaces the
521
575
  pencil. */
@@ -24,6 +24,14 @@ export interface ICompactToolbarOptionalField {
24
24
  */
25
25
  export interface ICompactToolbarContext {
26
26
  readOnly?: boolean;
27
+ /** Which parts to render, already resolved from the engine's `compactToolbar`
28
+ * prop — every key present, no undefined to re-default here. */
29
+ parts: {
30
+ completion: boolean;
31
+ search: boolean;
32
+ fields: boolean;
33
+ help: boolean;
34
+ };
27
35
  // Completion meter
28
36
  invalidCount: number;
29
37
  attentionCount: number;