@qoretechnologies/reqraft 0.10.47-pr.108.gf8e602d → 0.10.47

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.
@@ -2734,7 +2734,11 @@ const FormEngineImpl = ({
2734
2734
  const absorbedBy: Record<string, string> = {};
2735
2735
  forEach(options, (option, optionName) => {
2736
2736
  const absorb = (option as { absorb_fields?: string[] } | undefined)?.absorb_fields;
2737
- if (!absorb?.length) return;
2737
+ // A read-only form absorbs nothing. A host row shows an absorbed
2738
+ // sibling only inside its EDITOR, and a read view never opens one — so
2739
+ // the language a code field had absorbed would be nowhere on the page.
2740
+ // Every set field gets a row of its own instead.
2741
+ if (!absorb?.length || readOnly) return;
2738
2742
  absorb.forEach((absorbedName) => {
2739
2743
  // Only absorb a sibling that exists and is not already spoken for; a
2740
2744
  // schema naming a missing field must not make the field disappear.
@@ -2769,11 +2773,15 @@ const FormEngineImpl = ({
2769
2773
  // only). availableOptions (listed) and filteredOptions (these) are disjoint —
2770
2774
  // the former is built from fixedValue keys, the latter excludes them — so a
2771
2775
  // field is never both a listed and a hidden row.
2772
- forEach(filteredOptions, (_schema, optionName) => {
2773
- if (matchesFilters(optionName)) {
2774
- pushRow(optionName, true);
2775
- }
2776
- });
2776
+ // Not in a read-only form: a not-yet-added field is an offer to add it,
2777
+ // and a reader cannot take it up.
2778
+ if (!readOnly) {
2779
+ forEach(filteredOptions, (_schema, optionName) => {
2780
+ if (matchesFilters(optionName)) {
2781
+ pushRow(optionName, true);
2782
+ }
2783
+ });
2784
+ }
2777
2785
 
2778
2786
  // User sort (Fields menu → "Sort by"), applied WITHIN each group so the
2779
2787
  // group sections and the required-group rails are preserved. Schema order is
@@ -2859,9 +2867,20 @@ const FormEngineImpl = ({
2859
2867
  settledBucket.current[entry.name] = fresh;
2860
2868
  return fresh;
2861
2869
  };
2870
+ // Read-only: one list, not three boxes. "Needs attention" is an
2871
+ // instruction, and a reader has nothing to do with it — a required field
2872
+ // nobody filled in is shown unset, without the amber. "Optional" holds
2873
+ // the fields nobody set, which in a read view is a list of things that
2874
+ // are not there, so it is left out altogether.
2875
+ const readBucketOf = (entry: TRowEntry): TFormEngineBoxKey | undefined => {
2876
+ const fresh = stableBucketOf(entry);
2877
+ if (!readOnly) return fresh;
2878
+ return fresh === 'optional' ? undefined : 'set';
2879
+ };
2862
2880
  groupKeys.forEach((groupName) => {
2863
2881
  grouped[groupName].forEach((entry) => {
2864
- const b = stableBucketOf(entry);
2882
+ const b = readBucketOf(entry);
2883
+ if (!b) return;
2865
2884
  if (!buckets[b][groupName]) {
2866
2885
  buckets[b][groupName] = [];
2867
2886
  bucketGroups[b].push(groupName);
@@ -3003,11 +3022,7 @@ const FormEngineImpl = ({
3003
3022
  /** @param only when given, render just these rows of the group — the group
3004
3023
  can be split across the shown and folded halves of a capped box, and its
3005
3024
  label must appear above whichever half holds its first row. */
3006
- const renderBoxGroup = (
3007
- boxKey: TFormEngineBoxKey,
3008
- groupName: string,
3009
- only?: Set<string>
3010
- ) => {
3025
+ const renderBoxGroup = (boxKey: TFormEngineBoxKey, groupName: string, only?: Set<string>) => {
3011
3026
  const groupConfig = groups?.[groupName];
3012
3027
  const rows =
3013
3028
  only ?
@@ -3093,142 +3108,23 @@ const FormEngineImpl = ({
3093
3108
  ...compactPanelProps?.contentStyle,
3094
3109
  }}
3095
3110
  >
3096
- {size(groupKeys) === 0 ?
3111
+ {size(groupKeys) === 0 || (readOnly && !bucketCount('set')) ?
3097
3112
  <ReqoreMessage flat opaque={false} size='small'>
3098
- No fields match the current filters.
3113
+ {readOnly && !query ?
3114
+ 'Nothing is set.'
3115
+ : 'No fields match the current filters.'}
3099
3116
  </ReqoreMessage>
3100
3117
  : null}
3101
3118
 
3102
- {STATUS_BOXES.map((box) => {
3103
- const groupsInBox = bucketGroups[box.key];
3104
- const count = bucketCount(box.key);
3105
- if (!count) return null;
3106
- const hasPreselectedRows = boxHasPreselectedRows(box.key);
3107
- const wouldCollapse =
3108
- compactCollapsedGroups.includes(box.key) &&
3109
- !query &&
3110
- !(box.key === 'optional' && onlyOptionalRows);
3111
- // Only a box that was opened SOLELY to reveal a preselected
3112
- // row splits its body; a box that renders open anyway shows
3113
- // all of its groups as before.
3114
- const splitForPreselected = wouldCollapse && hasPreselectedRows;
3115
-
3116
- // Which rows this box shows, and which fold away, decided at
3117
- // ROW level because `maxFieldsShown` counts fields — a
3118
- // group-level split can only ever cut at a group boundary.
3119
- // Group order is preserved so the visible rows read in the
3120
- // same order they would if nothing were folded.
3121
- const orderedRows: TRowEntry[] = groupsInBox.flatMap(
3122
- (groupName) => buckets[box.key][groupName] || []
3123
- );
3124
- // Optional's own rule first: a box opened solely to reveal
3125
- // preselected rows offers those, not an arbitrary first-N.
3126
- const candidateRows =
3127
- splitForPreselected ? orderedRows.filter(isPreselectedRow) : orderedRows;
3128
- // …then the cap applies to whatever that left.
3129
- const capped =
3130
- maxFieldsShown !== undefined && candidateRows.length > maxFieldsShown;
3131
- const shownRows = capped ? candidateRows.slice(0, maxFieldsShown) : candidateRows;
3132
- const shownRowSet = new Set(shownRows.map((entry) => entry.name));
3133
- // Everything the box holds that is not on show. Once the
3134
- // reader asks for more they get the whole box — withholding
3135
- // the non-preselected remainder behind a second control is
3136
- // the same defect one level down.
3137
- const deferredRowSet = new Set(
3138
- orderedRows
3139
- .filter((entry) => !shownRowSet.has(entry.name))
3140
- .map((entry) => entry.name)
3141
- );
3142
- const deferredCount = deferredRowSet.size;
3143
- const shownGroups = groupsInBox.filter((groupName) =>
3144
- (buckets[box.key][groupName] || []).some((entry) =>
3145
- shownRowSet.has(entry.name)
3146
- )
3147
- );
3148
- const deferredGroups = groupsInBox.filter((groupName) =>
3149
- (buckets[box.key][groupName] || []).some(
3150
- (entry) => !shownRowSet.has(entry.name)
3151
- )
3152
- );
3153
- const moreOpen = Boolean(query) || expandedMoreBoxes.includes(box.key);
3154
- const accent =
3155
- box.key === 'attention' ? cWarning
3156
- : box.key === 'set' ? cSuccess
3157
- : cMuted;
3158
- // The muted "Optional" box reads as a quieter, recessed
3159
- // surface — a touch darker than the page rather than the
3160
- // faint grey tint the accent would give.
3161
- const boxBg =
3162
- box.key === 'optional' ?
3163
- changeDarkness(getMainBackgroundColor(theme), 0.06)
3164
- : undefined;
3165
- return (
3166
- <StyledStatusBox
3167
- $accent={accent}
3168
- $bg={boxBg}
3169
- key={box.key}
3170
- ref={(node: HTMLDivElement | null) => {
3171
- statusBoxRefs.current[box.key] = node;
3172
- }}
3173
- // Opening arms the reveal; collapsing disarms it, so a
3174
- // close never scrolls.
3175
- onCollapseChange={(collapsed) =>
3176
- setOpenedStatusBox(collapsed ? undefined : box.key)
3177
- }
3178
- flat
3179
- minimal
3180
- collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}
3181
- collapsible
3182
- // Which boxes start closed is the consumer's call
3183
- // (`compactCollapsedGroups`, default `['optional']`: the
3184
- // Optional box holds every not-yet-added field, so a form
3185
- // opens on what is in use). Two rules override it:
3186
- //
3187
- // A SEARCH forces every box open — ReqorePanel unmounts
3188
- // collapsed content, so a match inside a closed box would
3189
- // be unreachable. (isCollapsed is the panel's controllable
3190
- // state; manual toggling still works with no query set.)
3191
- //
3192
- // The Optional box stays open when it is the ONLY box,
3193
- // because collapsing it renders a card that looks empty and
3194
- // broken: an auth profile's Authorization block, whose nine
3195
- // requirement fields are all optional, showed as a titled
3196
- // card containing one collapsed "Optional 9" strip and
3197
- // nothing else. This does not generalise to the other two —
3198
- // a lone Set box collapsed is a deliberate ask, and its
3199
- // header still reports what is in there.
3200
- //
3201
- // A PRESELECTED row overrides it too: the schema asked
3202
- // for that field to be visible on a new form, so the
3203
- // box it lands in opens. The groups it does NOT appear
3204
- // in stay behind an inner collapse below, so opening
3205
- // the box reveals the preselected fields without also
3206
- // dumping every not-yet-added optional field on the
3207
- // author.
3208
- isCollapsed={wouldCollapse && !hasPreselectedRows}
3209
- label={
3210
- <StyledGroupHeader>
3211
- <ReqoreP effect={{ weight: 'bold' }} size='normal'>
3212
- {box.label}
3213
- </ReqoreP>
3214
- <ReqoreTag
3215
- size='small'
3216
- minimal
3217
- compact
3218
- intent={box.intent}
3219
- label={String(count)}
3220
- />
3221
- </StyledGroupHeader>
3222
- }
3223
- icon={box.icon}
3224
- className='options-readfirst-group'
3225
- padded={false}
3226
- contentStyle={{ padding: '4px 4px 6px' }}
3227
- >
3228
- {/* ONE group body per box: every field block (and the
3229
- thin schema sub-labels) is a direct flex child, so the
3230
- inter-field gap is identical everywhere — including
3231
- across schema-group boundaries. */}
3119
+ {/* A read-only form is one flat list of what is set: no
3120
+ "Set" heading with a count over it (there is nothing else
3121
+ it could be), no green tint (a colour that means "done"
3122
+ on a form nobody is doing), nothing to collapse — and no
3123
+ `maxFieldsShown` fold: "12 more fields" behind a click is
3124
+ the reader's record withheld, and a page scrolls. */}
3125
+ {readOnly ?
3126
+ bucketCount('set') ?
3127
+ <div className='options-readfirst-read' style={{ padding: '4px 4px 6px' }}>
3232
3128
  <StyledGroupBody
3233
3129
  $divider={cDivider}
3234
3130
  $hover={cHover}
@@ -3239,52 +3135,198 @@ const FormEngineImpl = ({
3239
3135
  $moreBorder={cMoreBorder}
3240
3136
  className={compactNarrow ? 'readfirst-narrow' : undefined}
3241
3137
  >
3242
- {shownGroups.map((groupName) =>
3243
- renderBoxGroup(box.key, groupName, shownRowSet)
3244
- )}
3245
- {/* The control lives INSIDE the group body, as the last
3138
+ {bucketGroups.set.map((groupName) => renderBoxGroup('set', groupName))}
3139
+ </StyledGroupBody>
3140
+ </div>
3141
+ : null
3142
+ : STATUS_BOXES.map((box) => {
3143
+ const groupsInBox = bucketGroups[box.key];
3144
+ const count = bucketCount(box.key);
3145
+ if (!count) return null;
3146
+ const hasPreselectedRows = boxHasPreselectedRows(box.key);
3147
+ const wouldCollapse =
3148
+ compactCollapsedGroups.includes(box.key) &&
3149
+ !query &&
3150
+ !(box.key === 'optional' && onlyOptionalRows);
3151
+ // Only a box that was opened SOLELY to reveal a preselected
3152
+ // row splits its body; a box that renders open anyway shows
3153
+ // all of its groups as before.
3154
+ const splitForPreselected = wouldCollapse && hasPreselectedRows;
3155
+
3156
+ // Which rows this box shows, and which fold away, decided at
3157
+ // ROW level because `maxFieldsShown` counts fields — a
3158
+ // group-level split can only ever cut at a group boundary.
3159
+ // Group order is preserved so the visible rows read in the
3160
+ // same order they would if nothing were folded.
3161
+ const orderedRows: TRowEntry[] = groupsInBox.flatMap(
3162
+ (groupName) => buckets[box.key][groupName] || []
3163
+ );
3164
+ // Optional's own rule first: a box opened solely to reveal
3165
+ // preselected rows offers those, not an arbitrary first-N.
3166
+ const candidateRows =
3167
+ splitForPreselected ? orderedRows.filter(isPreselectedRow) : orderedRows;
3168
+ // …then the cap applies to whatever that left.
3169
+ const capped =
3170
+ maxFieldsShown !== undefined && candidateRows.length > maxFieldsShown;
3171
+ const shownRows =
3172
+ capped ? candidateRows.slice(0, maxFieldsShown) : candidateRows;
3173
+ const shownRowSet = new Set(shownRows.map((entry) => entry.name));
3174
+ // Everything the box holds that is not on show. Once the
3175
+ // reader asks for more they get the whole box — withholding
3176
+ // the non-preselected remainder behind a second control is
3177
+ // the same defect one level down.
3178
+ const deferredRowSet = new Set(
3179
+ orderedRows
3180
+ .filter((entry) => !shownRowSet.has(entry.name))
3181
+ .map((entry) => entry.name)
3182
+ );
3183
+ const deferredCount = deferredRowSet.size;
3184
+ const shownGroups = groupsInBox.filter((groupName) =>
3185
+ (buckets[box.key][groupName] || []).some((entry) =>
3186
+ shownRowSet.has(entry.name)
3187
+ )
3188
+ );
3189
+ const deferredGroups = groupsInBox.filter((groupName) =>
3190
+ (buckets[box.key][groupName] || []).some(
3191
+ (entry) => !shownRowSet.has(entry.name)
3192
+ )
3193
+ );
3194
+ const moreOpen = Boolean(query) || expandedMoreBoxes.includes(box.key);
3195
+ const accent =
3196
+ box.key === 'attention' ? cWarning
3197
+ : box.key === 'set' ? cSuccess
3198
+ : cMuted;
3199
+ // The muted "Optional" box reads as a quieter, recessed
3200
+ // surface — a touch darker than the page rather than the
3201
+ // faint grey tint the accent would give.
3202
+ const boxBg =
3203
+ box.key === 'optional' ?
3204
+ changeDarkness(getMainBackgroundColor(theme), 0.06)
3205
+ : undefined;
3206
+ return (
3207
+ <StyledStatusBox
3208
+ $accent={accent}
3209
+ $bg={boxBg}
3210
+ key={box.key}
3211
+ ref={(node: HTMLDivElement | null) => {
3212
+ statusBoxRefs.current[box.key] = node;
3213
+ }}
3214
+ // Opening arms the reveal; collapsing disarms it, so a
3215
+ // close never scrolls.
3216
+ onCollapseChange={(collapsed) =>
3217
+ setOpenedStatusBox(collapsed ? undefined : box.key)
3218
+ }
3219
+ flat
3220
+ minimal
3221
+ collapseButtonProps={{ flat: true, minimal: true, size: 'small' }}
3222
+ collapsible
3223
+ // Which boxes start closed is the consumer's call
3224
+ // (`compactCollapsedGroups`, default `['optional']`: the
3225
+ // Optional box holds every not-yet-added field, so a form
3226
+ // opens on what is in use). Two rules override it:
3227
+ //
3228
+ // A SEARCH forces every box open — ReqorePanel unmounts
3229
+ // collapsed content, so a match inside a closed box would
3230
+ // be unreachable. (isCollapsed is the panel's controllable
3231
+ // state; manual toggling still works with no query set.)
3232
+ //
3233
+ // The Optional box stays open when it is the ONLY box,
3234
+ // because collapsing it renders a card that looks empty and
3235
+ // broken: an auth profile's Authorization block, whose nine
3236
+ // requirement fields are all optional, showed as a titled
3237
+ // card containing one collapsed "Optional 9" strip and
3238
+ // nothing else. This does not generalise to the other two —
3239
+ // a lone Set box collapsed is a deliberate ask, and its
3240
+ // header still reports what is in there.
3241
+ //
3242
+ // A PRESELECTED row overrides it too: the schema asked
3243
+ // for that field to be visible on a new form, so the
3244
+ // box it lands in opens. The groups it does NOT appear
3245
+ // in stay behind an inner collapse below, so opening
3246
+ // the box reveals the preselected fields without also
3247
+ // dumping every not-yet-added optional field on the
3248
+ // author.
3249
+ isCollapsed={wouldCollapse && !hasPreselectedRows}
3250
+ label={
3251
+ <StyledGroupHeader>
3252
+ <ReqoreP effect={{ weight: 'bold' }} size='normal'>
3253
+ {box.label}
3254
+ </ReqoreP>
3255
+ <ReqoreTag
3256
+ size='small'
3257
+ minimal
3258
+ compact
3259
+ intent={box.intent}
3260
+ label={String(count)}
3261
+ />
3262
+ </StyledGroupHeader>
3263
+ }
3264
+ icon={box.icon}
3265
+ className='options-readfirst-group'
3266
+ padded={false}
3267
+ contentStyle={{ padding: '4px 4px 6px' }}
3268
+ >
3269
+ {/* ONE group body per box: every field block (and the
3270
+ thin schema sub-labels) is a direct flex child, so the
3271
+ inter-field gap is identical everywhere — including
3272
+ across schema-group boundaries. */}
3273
+ <StyledGroupBody
3274
+ $divider={cDivider}
3275
+ $hover={cHover}
3276
+ $focus={cWarning}
3277
+ $success={cSuccess}
3278
+ $rowBg={cRowBg}
3279
+ $lineColor={cGroupLine}
3280
+ $moreBorder={cMoreBorder}
3281
+ className={compactNarrow ? 'readfirst-narrow' : undefined}
3282
+ >
3283
+ {shownGroups.map((groupName) =>
3284
+ renderBoxGroup(box.key, groupName, shownRowSet)
3285
+ )}
3286
+ {/* The control lives INSIDE the group body, as the last
3246
3287
  row, so it inherits the rows' grid, padding and
3247
3288
  hover and lines up with them. Outside it was a bare
3248
3289
  line of text in the box's corner, which is what the
3249
3290
  build #154 review rejected. */}
3250
- {deferredCount ?
3251
- <button
3252
- type='button'
3253
- className='readfirst-row readfirst-more-row options-readfirst-more'
3254
- aria-expanded={moreOpen}
3255
- onClick={() =>
3256
- setExpandedMoreBoxes((prev) =>
3257
- prev.includes(box.key) ?
3258
- prev.filter((k) => k !== box.key)
3259
- : [...prev, box.key]
3260
- )
3261
- }
3262
- >
3263
- <ReqoreP size='small' effect={{ opacity: 0.8 }}>
3264
- {moreOpen ?
3265
- 'Show fewer'
3266
- : `${deferredCount} more ${box.key === 'optional' ? 'optional ' : ''}field${deferredCount === 1 ? '' : 's'}`
3291
+ {deferredCount ?
3292
+ <button
3293
+ type='button'
3294
+ className='readfirst-row readfirst-more-row options-readfirst-more'
3295
+ aria-expanded={moreOpen}
3296
+ onClick={() =>
3297
+ setExpandedMoreBoxes((prev) =>
3298
+ prev.includes(box.key) ?
3299
+ prev.filter((k) => k !== box.key)
3300
+ : [...prev, box.key]
3301
+ )
3267
3302
  }
3268
- </ReqoreP>
3269
- <ReqoreIcon
3270
- icon='ArrowDownSLine'
3271
- size='small'
3272
- className='readfirst-more-chevron'
3273
- effect={{ opacity: 0.8 }}
3274
- />
3275
- </button>
3276
- : null}
3277
- {/* A search forces the fold open for the same reason
3303
+ >
3304
+ <ReqoreP size='small' effect={{ opacity: 0.8 }}>
3305
+ {moreOpen ?
3306
+ 'Show fewer'
3307
+ : `${deferredCount} more ${box.key === 'optional' ? 'optional ' : ''}field${deferredCount === 1 ? '' : 's'}`
3308
+ }
3309
+ </ReqoreP>
3310
+ <ReqoreIcon
3311
+ icon='ArrowDownSLine'
3312
+ size='small'
3313
+ className='readfirst-more-chevron'
3314
+ effect={{ opacity: 0.8 }}
3315
+ />
3316
+ </button>
3317
+ : null}
3318
+ {/* A search forces the fold open for the same reason
3278
3319
  the boxes open: an unmounted row cannot be found. */}
3279
- {deferredCount && moreOpen ?
3280
- deferredGroups.map((groupName) =>
3281
- renderBoxGroup(box.key, groupName, deferredRowSet)
3282
- )
3283
- : null}
3284
- </StyledGroupBody>
3285
- </StyledStatusBox>
3286
- );
3287
- })}
3320
+ {deferredCount && moreOpen ?
3321
+ deferredGroups.map((groupName) =>
3322
+ renderBoxGroup(box.key, groupName, deferredRowSet)
3323
+ )
3324
+ : null}
3325
+ </StyledGroupBody>
3326
+ </StyledStatusBox>
3327
+ );
3328
+ })
3329
+ }
3288
3330
  </StyledCompactPanel>
3289
3331
 
3290
3332
  {/* Batched commit: the Save/Discard bar docks bottom-right, floating
@@ -354,6 +354,22 @@ export const StyledRowValue = styled.div<{ $color: string; $empty?: boolean }>`
354
354
  gap: 4px;
355
355
  margin-top: 4px;
356
356
  }
357
+ /* Read-only companions of the value: what a chosen option means, on its own
358
+ line under it; and a long text drawn in full in the inset. */
359
+ .options-readfirst-choice-desc {
360
+ flex-basis: 100%;
361
+ width: 100%;
362
+ min-width: 0;
363
+ font-size: 12px;
364
+ line-height: 1.4;
365
+ opacity: 0.6;
366
+ }
367
+ .options-readfirst-text {
368
+ color: ${({ $color }) => $color};
369
+ white-space: pre-wrap;
370
+ overflow-wrap: anywhere;
371
+ line-height: 1.5;
372
+ }
357
373
  `;
358
374
 
359
375
  export const StyledRowActions = styled.div`
@@ -634,6 +650,22 @@ export const StyledGroupBody = styled.div<{
634
650
  .readfirst-row-editing > div:nth-child(3) {
635
651
  padding-top: 3px;
636
652
  }
653
+ /* A read-only row is not a control. No pointer and no hover tint, because a
654
+ click does nothing; and its value wraps in full — the row is the only
655
+ rendering the value gets, so a line cut with an ellipsis would be the
656
+ reader's whole answer. */
657
+ .readfirst-row-read {
658
+ cursor: default;
659
+ }
660
+ .readfirst-row-read:hover {
661
+ background: transparent;
662
+ }
663
+ .readfirst-row-read .options-readfirst-valuetext {
664
+ white-space: pre-wrap;
665
+ overflow: visible;
666
+ text-overflow: clip;
667
+ overflow-wrap: anywhere;
668
+ }
637
669
  .readfirst-row-editing:hover {
638
670
  background: ${({ $hover }) => $hover};
639
671
  }
@@ -264,7 +264,7 @@ export const AutoComponent: StoryObj<typeof meta> = {
264
264
  docs: {
265
265
  description: {
266
266
  story:
267
- 'Renders TemplateField over an untyped (`auto`) field that accepts templates. While the field is empty it opens on the template selector rather than the data-type picker, so the author can say which value they mean before answering how it is stored.',
267
+ 'Renders TemplateField wrapping the AutoFormField dispatcher — the operator can pick a type from the auto picker or switch to a template value.',
268
268
  },
269
269
  },
270
270
  },