@visns-studio/visns-components 6.3.4 → 6.3.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.
package/package.json CHANGED
@@ -91,7 +91,7 @@
91
91
  "react-dom": "^17.0.0 || ^18.0.0"
92
92
  },
93
93
  "name": "@visns-studio/visns-components",
94
- "version": "6.3.4",
94
+ "version": "6.3.6",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -139,6 +139,11 @@ function Field({
139
139
  onDeleteJsonTableRow,
140
140
  onChangeToggle,
141
141
  onFileDownload,
142
+ // Renders another of the form's fields inside this one (today: inside a
143
+ // `multi-checkbox-ajax` option card, via `settings.optionFields`).
144
+ // Supplied by Form so the nested field keeps the form's own state,
145
+ // handlers and validation — see Form.renderCompanionField.
146
+ renderOptionField,
142
147
  setFormData,
143
148
  settings,
144
149
  style,
@@ -1161,6 +1166,13 @@ function Field({
1161
1166
  onValueChange={(values) => {
1162
1167
  onChangeNumberFormat(values, settings.id);
1163
1168
  }}
1169
+ // Numeric inputs ignored both of these: a
1170
+ // `placeholder` in the config never rendered, and a
1171
+ // read-only field stayed typable. The placeholder is
1172
+ // also how a `sumEquals` group offers its remainder
1173
+ // as a hint (see SumEqualsSummary).
1174
+ placeholder={settings.placeholder}
1175
+ disabled={isFieldReadOnly}
1164
1176
  thousandSeparator={
1165
1177
  settings.hasOwnProperty('thousandSeperator')
1166
1178
  ? settings.thousandSeperator
@@ -2182,6 +2194,21 @@ function Field({
2182
2194
  isEditable={settings.isEditable ?? false}
2183
2195
  creatableConfig={settings.creatableConfig || {}}
2184
2196
  editableConfig={settings.editableConfig || {}}
2197
+ // The checkbox renderer previously ignored the field's
2198
+ // read-only state, leaving `readOnlyWhen` configs
2199
+ // claiming a lock the UI didn't enforce — the same gap
2200
+ // the radio renderers had.
2201
+ disabled={isFieldReadOnly}
2202
+ optionFields={settings.optionFields}
2203
+ // A read-only host locks the fields in its cards too
2204
+ renderOptionField={
2205
+ renderOptionField
2206
+ ? (fieldId) =>
2207
+ renderOptionField(fieldId, {
2208
+ readOnly: isFieldReadOnly,
2209
+ })
2210
+ : undefined
2211
+ }
2185
2212
  style={style}
2186
2213
  />
2187
2214
  );
@@ -18,6 +18,10 @@ import '@visns-studio/visns-datagrid-enterprise/index.css';
18
18
  import CustomFetch from './Fetch';
19
19
  import Download from './Download';
20
20
  import Field from './Field';
21
+ import SumEqualsSummary, {
22
+ computeSumEqualsState,
23
+ sumEqualsHintFor,
24
+ } from './SumEqualsSummary';
21
25
  import Table from './DataGrid';
22
26
 
23
27
  import styles from './styles/Form.module.scss';
@@ -91,6 +95,56 @@ const evaluateDisableWhen = (rule, data) => {
91
95
  }
92
96
  };
93
97
 
98
+ /**
99
+ * Field visibility rules (`show`).
100
+ *
101
+ * A field carries `show: [{ id: '<other field id>', <condition> }]`, meaning
102
+ * "when MY value satisfies this condition, reveal the named field; otherwise
103
+ * hide it". With `self: true` the roles swap: the condition is read from the
104
+ * NAMED field's value and applied to the field carrying the rule.
105
+ *
106
+ * Supported conditions:
107
+ *
108
+ * { value: [...] } the value equals one of the listed literals. What
109
+ * toggles ({ value: [true] }) and radios
110
+ * ({ value: [1, 2] }) use.
111
+ *
112
+ * { minSelected: n } the value is an array holding at least n entries.
113
+ * For the multi-* types (multi-checkbox-ajax,
114
+ * multi-dropdown-ajax, …) whose value is a list of
115
+ * selected options — e.g. reveal a pair of per-item
116
+ * quantity inputs only once two boxes are ticked.
117
+ *
118
+ * Rules are evaluated when the record loads (the effect keyed on
119
+ * `fetchTrigger`) and again on every change to the field that carries them,
120
+ * so a revealed field cannot linger after the value that revealed it goes
121
+ * away. See also `required_rely` (required-ness that follows another field's
122
+ * value) and `sumEquals` (a group of numbers that must total a target).
123
+ */
124
+ const hasShowCondition = (condition) =>
125
+ !!condition &&
126
+ (condition.minSelected !== undefined ||
127
+ (Array.isArray(condition.value) && condition.value.length > 0));
128
+
129
+ const matchesShowCondition = (condition, value) => {
130
+ if (!condition) {
131
+ return false;
132
+ }
133
+
134
+ if (condition.minSelected !== undefined) {
135
+ return (
136
+ Array.isArray(value) &&
137
+ value.length >= Number(condition.minSelected)
138
+ );
139
+ }
140
+
141
+ if (Array.isArray(condition.value)) {
142
+ return condition.value.includes(value);
143
+ }
144
+
145
+ return false;
146
+ };
147
+
94
148
  function Form({
95
149
  ajaxSetting,
96
150
  api,
@@ -465,18 +519,71 @@ function Form({
465
519
  });
466
520
  };
467
521
 
522
+ /**
523
+ * Re-evaluate this field's `show` rules against the value it has just
524
+ * been given. The other two change handlers do this from the DOM
525
+ * node's `data-show`, which a select / multi-checkbox never emits, so
526
+ * without this a rule on one of those types would only ever run at
527
+ * load time — the reveal would not follow the user's clicks.
528
+ */
529
+ const applyShowConditions = (newValue) => {
530
+ const sourceField = formSettings.fields.find((f) => f.id === id);
531
+ const conditions = sourceField?.show || [];
532
+
533
+ if (conditions.length === 0) {
534
+ return;
535
+ }
536
+
537
+ const _fields = [...formSettings.fields];
538
+ let changed = false;
539
+
540
+ conditions.forEach((showObject) => {
541
+ if (!showObject?.id || !hasShowCondition(showObject)) {
542
+ return;
543
+ }
544
+
545
+ const targetIndex = _fields.findIndex(
546
+ (f) => f.id === showObject.id
547
+ );
548
+
549
+ if (targetIndex < 0) {
550
+ return;
551
+ }
552
+
553
+ const shouldShow = matchesShowCondition(showObject, newValue);
554
+ const target = showObject.self
555
+ ? _fields.find((f) => f.id === id)
556
+ : _fields[targetIndex];
557
+
558
+ if (target) {
559
+ target.hide = !shouldShow;
560
+ changed = true;
561
+ }
562
+ });
563
+
564
+ if (changed && updateForm) {
565
+ updateForm((prevState) => ({
566
+ ...prevState,
567
+ fields: _fields,
568
+ }));
569
+ }
570
+ };
571
+
468
572
  // // Switch cases to handle different actions
469
573
  switch (action.action) {
470
574
  case 'select-option':
471
575
  case 'create-option': // New case to handle create-option
472
576
  case 'deselect-option': // Handle unchecking tags
473
577
  handleSelectOption();
578
+ applyShowConditions(inputValue);
474
579
  break;
475
580
  case 'clear':
476
581
  updateFormData({ [id]: '' });
582
+ applyShowConditions([]);
477
583
  break;
478
584
  case 'remove-value':
479
585
  updateFormData({ [id]: inputValue });
586
+ applyShowConditions(inputValue);
480
587
  break;
481
588
  }
482
589
  };
@@ -1303,6 +1410,82 @@ function Form({
1303
1410
  _inputClass[item.id] = styles.inputError;
1304
1411
  }
1305
1412
  }
1413
+
1414
+ /**
1415
+ * `sumEquals`: a set of numeric fields that must add
1416
+ * up to a target.
1417
+ *
1418
+ * sumEquals: {
1419
+ * fields: ['qty_a', 'qty_b'],
1420
+ * equals: 'qty_total', // field id, or a number
1421
+ * message: '…' // optional override
1422
+ * }
1423
+ *
1424
+ * Declared once, on any one of the fields in the
1425
+ * group. Every named field is marked in error so the
1426
+ * user sees which inputs to reconcile, not just a
1427
+ * message.
1428
+ *
1429
+ * The rule is skipped while its own field is hidden,
1430
+ * so a group that only appears in a revealed section
1431
+ * (see the `show` documentation above) cannot block an
1432
+ * ordinary save. While visible it also stands in for
1433
+ * `required` on the whole group: a blank member is as
1434
+ * wrong as a wrong total.
1435
+ *
1436
+ * Declaring the rule also turns on live feedback
1437
+ * while the operator types, all of it automatic —
1438
+ * no extra config, and nothing a form has to opt
1439
+ * into beyond `sumEquals` itself:
1440
+ *
1441
+ * - an allocation line under the group, reading
1442
+ * "Item Qty 40 · 25 entered · 15 remaining",
1443
+ * "40 of 40 allocated ✓" or "over by 5" (see
1444
+ * SumEqualsSummary);
1445
+ * - the remainder offered as the placeholder of the
1446
+ * group's one empty field — a hint only, never an
1447
+ * auto-filled value.
1448
+ *
1449
+ * `targetLabel` on the rule names the total in that
1450
+ * copy; it otherwise takes the label of the field
1451
+ * `equals` points at.
1452
+ */
1453
+ if (item.sumEquals && item.hide !== true) {
1454
+ const rule = item.sumEquals;
1455
+ // Same computation the live summary and the
1456
+ // placeholder hint use, so what the operator was
1457
+ // told while typing is exactly what is enforced
1458
+ const state = computeSumEqualsState(rule, formData);
1459
+
1460
+ if (state) {
1461
+ const labelFor = (fieldId) =>
1462
+ fields.find((f) => f.id === fieldId)
1463
+ ?.label || fieldId;
1464
+ const groupLabels = state.ids
1465
+ .map(labelFor)
1466
+ .join(' and ');
1467
+
1468
+ const markGroup = () =>
1469
+ state.ids.forEach((fieldId) => {
1470
+ _inputClass[fieldId] =
1471
+ styles.inputError;
1472
+ });
1473
+
1474
+ if (state.emptyIds.length > 0) {
1475
+ validation += `${groupLabels} are required.<br/>`;
1476
+ markGroup();
1477
+ } else if (
1478
+ state.hasTarget &&
1479
+ state.status !== 'balanced'
1480
+ ) {
1481
+ validation +=
1482
+ (rule.message ||
1483
+ `${groupLabels} must add up to ${state.target} - they add up to ${state.sum}.`) +
1484
+ '<br/>';
1485
+ markGroup();
1486
+ }
1487
+ }
1488
+ }
1306
1489
  });
1307
1490
 
1308
1491
  setInputClass(_inputClass);
@@ -2676,9 +2859,9 @@ function Form({
2676
2859
  const showConditions = field.show || [];
2677
2860
 
2678
2861
  showConditions.forEach((showObject) => {
2679
- const { id, value, self } = showObject;
2862
+ const { id, self } = showObject;
2680
2863
 
2681
- if (id != '' && value.length > 0) {
2864
+ if (id != '' && hasShowCondition(showObject)) {
2682
2865
  const targetField = formSettings.fields.find(
2683
2866
  (f) => f.id === id
2684
2867
  );
@@ -2695,7 +2878,10 @@ function Form({
2695
2878
  valueChecker !== undefined &&
2696
2879
  valueChecker !== null
2697
2880
  ) {
2698
- const shouldShow = value.includes(valueChecker);
2881
+ const shouldShow = matchesShowCondition(
2882
+ showObject,
2883
+ valueChecker
2884
+ );
2699
2885
 
2700
2886
  if (self) {
2701
2887
  field.hide = !shouldShow;
@@ -2716,6 +2902,193 @@ function Form({
2716
2902
  });
2717
2903
  }, [fetchTrigger]);
2718
2904
 
2905
+ /**
2906
+ * The `sumEquals` rule a field belongs to, wherever it was declared (the
2907
+ * rule sits on one member and names the whole group).
2908
+ */
2909
+ const sumEqualsRuleFor = (fieldId) =>
2910
+ formSettings?.fields?.find(
2911
+ (f) =>
2912
+ f.sumEquals &&
2913
+ Array.isArray(f.sumEquals.fields) &&
2914
+ f.sumEquals.fields.includes(fieldId)
2915
+ )?.sumEquals || null;
2916
+
2917
+ /**
2918
+ * Field ids a `multi-checkbox-ajax` host has claimed through
2919
+ * `optionFields`: those render inside the option cards, not as rows of
2920
+ * their own, so the main loop must skip them.
2921
+ */
2922
+ const optionHostedFieldIds = new Set(
2923
+ (formSettings?.fields || []).flatMap((f) =>
2924
+ Array.isArray(f.optionFields) ? f.optionFields : []
2925
+ )
2926
+ );
2927
+
2928
+ /**
2929
+ * The field the group's allocation line renders under: the last member
2930
+ * that the main loop draws itself, or — when every member is nested in an
2931
+ * option card — the host that draws them.
2932
+ */
2933
+ const sumEqualsAnchorId = (rule) => {
2934
+ const ids = Array.isArray(rule?.fields) ? rule.fields : [];
2935
+ const ownRows = ids.filter((id) => !optionHostedFieldIds.has(id));
2936
+
2937
+ if (ownRows.length > 0) {
2938
+ return ownRows[ownRows.length - 1];
2939
+ }
2940
+
2941
+ return (
2942
+ (formSettings?.fields || []).find(
2943
+ (f) =>
2944
+ Array.isArray(f.optionFields) &&
2945
+ ids.some((id) => f.optionFields.includes(id))
2946
+ )?.id || null
2947
+ );
2948
+ };
2949
+
2950
+ /**
2951
+ * The allocation line to draw after `item`, or null. Rendered once per
2952
+ * group, under the group's last field (or under the checkbox host whose
2953
+ * cards contain them), and never while that anchor is hidden.
2954
+ */
2955
+ const renderSumEqualsSummary = (item) => {
2956
+ const rules = (formSettings?.fields || [])
2957
+ .map((f) => f.sumEquals)
2958
+ .filter(Boolean)
2959
+ .filter((rule) => sumEqualsAnchorId(rule) === item.id);
2960
+
2961
+ if (rules.length === 0) {
2962
+ return null;
2963
+ }
2964
+
2965
+ return rules.map((rule, i) => {
2966
+ // A group whose fields are all hidden says nothing: the section
2967
+ // it belongs to has not been revealed yet
2968
+ const anyVisible = (rule.fields || []).some(
2969
+ (id) =>
2970
+ formSettings.fields.find((f) => f.id === id)?.hide !== true
2971
+ );
2972
+
2973
+ if (!anyVisible) {
2974
+ return null;
2975
+ }
2976
+
2977
+ return (
2978
+ <SumEqualsSummary
2979
+ key={`${item.id}-sumequals-${i}`}
2980
+ rule={rule}
2981
+ formData={formData}
2982
+ fields={formSettings.fields}
2983
+ />
2984
+ );
2985
+ });
2986
+ };
2987
+
2988
+ /**
2989
+ * `item`, with the remainder offered as its placeholder when it is the
2990
+ * one field of its `sumEquals` group still empty. Returns the original
2991
+ * object when there is nothing to add, so React sees no new prop.
2992
+ */
2993
+ const withSumEqualsHint = (item) => {
2994
+ const rule = sumEqualsRuleFor(item.id);
2995
+
2996
+ if (!rule) {
2997
+ return item;
2998
+ }
2999
+
3000
+ const hint = sumEqualsHintFor(item.id, rule, formData);
3001
+
3002
+ if (hint === undefined) {
3003
+ return item;
3004
+ }
3005
+
3006
+ return { ...item, placeholder: hint };
3007
+ };
3008
+
3009
+ /**
3010
+ * One `<Field>`, wired to this form's state and handlers. Single factory
3011
+ * so a field rendered inside an option card (see renderCompanionField)
3012
+ * is wired identically to one rendered on a row of its own.
3013
+ */
3014
+ const renderFormField = (item, key) => (
3015
+ <Field
3016
+ api={api}
3017
+ autocompleteCallback={autocompleteSelect}
3018
+ childDropdownCallback={childDropdownCallback}
3019
+ fetchData={fetchData}
3020
+ formData={formData}
3021
+ formSettings={formSettings}
3022
+ inputClass={inputClass}
3023
+ inputValue={renderInputValue(item, formData)}
3024
+ key={key}
3025
+ mapbox={mapbox}
3026
+ onChange={handleChange}
3027
+ onChangeCanvas={handleChangeCanvas}
3028
+ onChangeCheckbox={handleChangeCheckbox}
3029
+ onChangeCheckboxManual={handleChangeCheckboxManual}
3030
+ onChangeColour={handleChangeColour}
3031
+ onChangeDate={handleChangeDate}
3032
+ onChangeNumberFormat={handleChangeNumberFormat}
3033
+ onChangeRicheditor={handleChangeRicheditor}
3034
+ onChangeSignature={handleChangeSignature}
3035
+ onChangeSelect={handleChangeSelect}
3036
+ onChangeToggle={handleChangeToggle}
3037
+ onChangeJsonTable={handleChangeJsonTable}
3038
+ onAddJsonTableRow={handleAddJsonTableRow}
3039
+ onDeleteJsonTableRow={handleDeleteJsonTableRow}
3040
+ onFileDownload={handleFileDownload}
3041
+ renderOptionField={renderCompanionField}
3042
+ settings={item}
3043
+ setFormData={setFormData}
3044
+ style={style}
3045
+ uploadProgress={uploadProgress}
3046
+ userProfile={userProfile}
3047
+ />
3048
+ );
3049
+
3050
+ /**
3051
+ * Render one of this form's fields somewhere other than its own row —
3052
+ * today, inside a `multi-checkbox-ajax` option card (see `optionFields`
3053
+ * in MultiCheckbox). It is the SAME field: same id, same formData entry,
3054
+ * same handlers, same validation and same `sumEquals` membership. Only
3055
+ * the mount point differs, so there is no second source of truth.
3056
+ *
3057
+ * @param {string} fieldId
3058
+ * @param {{readOnly?: boolean}} options host state to inherit
3059
+ */
3060
+ const renderCompanionField = (fieldId, options = {}) => {
3061
+ const field = formSettings?.fields?.find((f) => f.id === fieldId);
3062
+
3063
+ // A field the `show` rules have not revealed stays hidden wherever it
3064
+ // is mounted — ticking one box must not surface the qty inputs
3065
+ if (!field || field.hide === true) {
3066
+ return null;
3067
+ }
3068
+
3069
+ const settings = {
3070
+ ...withSumEqualsHint(field),
3071
+ // The card is the field's context ("Station A"), so the label
3072
+ // inside it can be short. The full label stays the group's
3073
+ // accessible name below.
3074
+ label: field.compactLabel || field.label,
3075
+ // Companion fields fill their card, whatever width they were
3076
+ // given for a row of their own
3077
+ size: 'full',
3078
+ // A read-only host locks what it contains
3079
+ readOnly: field.readOnly === true || options.readOnly === true,
3080
+ // Cards do not nest: a companion never hosts companions of
3081
+ // its own
3082
+ optionFields: undefined,
3083
+ };
3084
+
3085
+ return (
3086
+ <div role="group" aria-label={field.label}>
3087
+ {renderFormField(settings, `companion-${fieldId}`)}
3088
+ </div>
3089
+ );
3090
+ };
3091
+
2719
3092
  if (type && type === 'inline') {
2720
3093
  return (
2721
3094
  <div className={styles.formcontainer}>
@@ -2774,67 +3147,36 @@ function Form({
2774
3147
  />
2775
3148
  </div>
2776
3149
  );
2777
- default:
2778
- return (
2779
- <Field
2780
- api={api}
2781
- autocompleteCallback={
2782
- autocompleteSelect
2783
- }
2784
- childDropdownCallback={
2785
- childDropdownCallback
2786
- }
2787
- fetchData={fetchData}
2788
- formData={formData}
2789
- formSettings={formSettings}
2790
- inputClass={inputClass}
2791
- inputValue={renderInputValue(
2792
- item,
2793
- formData
3150
+ default: {
3151
+ // Drawn inside a checkbox option card
3152
+ // instead of on a row of its own
3153
+ if (optionHostedFieldIds.has(item.id)) {
3154
+ return null;
3155
+ }
3156
+
3157
+ const key =
3158
+ index +
3159
+ '-fields-' +
3160
+ (item.hasOwnProperty('key')
3161
+ ? item.id + '-' + item.key
3162
+ : item.id);
3163
+ const summary = renderSumEqualsSummary(item);
3164
+
3165
+ return summary ? (
3166
+ <React.Fragment key={`${key}-group`}>
3167
+ {renderFormField(
3168
+ withSumEqualsHint(item),
3169
+ key
2794
3170
  )}
2795
- key={
2796
- index +
2797
- '-fields-' +
2798
- (item.hasOwnProperty('key')
2799
- ? item.id + '-' + item.key
2800
- : item.id)
2801
- }
2802
- mapbox={mapbox}
2803
- onChange={handleChange}
2804
- onChangeCanvas={handleChangeCanvas}
2805
- onChangeCheckbox={handleChangeCheckbox}
2806
- onChangeCheckboxManual={
2807
- handleChangeCheckboxManual
2808
- }
2809
- onChangeDate={handleChangeDate}
2810
- onChangeNumberFormat={
2811
- handleChangeNumberFormat
2812
- }
2813
- onChangeRicheditor={
2814
- handleChangeRicheditor
2815
- }
2816
- onChangeSelect={handleChangeSelect}
2817
- onChangeSignature={
2818
- handleChangeSignature
2819
- }
2820
- onChangeToggle={handleChangeToggle}
2821
- onChangeJsonTable={
2822
- handleChangeJsonTable
2823
- }
2824
- onAddJsonTableRow={
2825
- handleAddJsonTableRow
2826
- }
2827
- onDeleteJsonTableRow={
2828
- handleDeleteJsonTableRow
2829
- }
2830
- onFileDownload={handleFileDownload}
2831
- settings={item}
2832
- setFormData={setFormData}
2833
- style={style}
2834
- uploadProgress={uploadProgress}
2835
- userProfile={userProfile}
2836
- />
3171
+ {summary}
3172
+ </React.Fragment>
3173
+ ) : (
3174
+ renderFormField(
3175
+ withSumEqualsHint(item),
3176
+ key
3177
+ )
2837
3178
  );
3179
+ }
2838
3180
  }
2839
3181
  })}
2840
3182
  <div className={`${styles.formItem} ${styles.fwItem}`}>
@@ -3444,68 +3786,34 @@ function Form({
3444
3786
  return null;
3445
3787
  }
3446
3788
 
3447
- return (
3448
- <Field
3449
- api={api}
3450
- autocompleteCallback={
3451
- autocompleteSelect
3452
- }
3453
- childDropdownCallback={
3454
- childDropdownCallback
3455
- }
3456
- fetchData={fetchData}
3457
- formData={formData}
3458
- formSettings={formSettings}
3459
- inputClass={inputClass}
3460
- inputValue={renderInputValue(
3461
- item,
3462
- formData
3789
+ // Drawn inside a checkbox option card
3790
+ // instead of on a row of its own
3791
+ if (optionHostedFieldIds.has(item.id)) {
3792
+ return null;
3793
+ }
3794
+
3795
+ const key =
3796
+ index +
3797
+ '-fields-' +
3798
+ (item.hasOwnProperty('key')
3799
+ ? item.id + '-' + item.key
3800
+ : item.id);
3801
+ const summary =
3802
+ renderSumEqualsSummary(item);
3803
+
3804
+ return summary ? (
3805
+ <React.Fragment key={`${key}-group`}>
3806
+ {renderFormField(
3807
+ withSumEqualsHint(item),
3808
+ key
3463
3809
  )}
3464
- key={
3465
- index +
3466
- '-fields-' +
3467
- (item.hasOwnProperty('key')
3468
- ? item.id + '-' + item.key
3469
- : item.id)
3470
- }
3471
- mapbox={mapbox}
3472
- onChange={handleChange}
3473
- onChangeCanvas={handleChangeCanvas}
3474
- onChangeCheckbox={
3475
- handleChangeCheckbox
3476
- }
3477
- onChangeCheckboxManual={
3478
- handleChangeCheckboxManual
3479
- }
3480
- onChangeColour={handleChangeColour}
3481
- onChangeDate={handleChangeDate}
3482
- onChangeNumberFormat={
3483
- handleChangeNumberFormat
3484
- }
3485
- onChangeRicheditor={
3486
- handleChangeRicheditor
3487
- }
3488
- onChangeSignature={
3489
- handleChangeSignature
3490
- }
3491
- onChangeSelect={handleChangeSelect}
3492
- onChangeToggle={handleChangeToggle}
3493
- onChangeJsonTable={
3494
- handleChangeJsonTable
3495
- }
3496
- onAddJsonTableRow={
3497
- handleAddJsonTableRow
3498
- }
3499
- onDeleteJsonTableRow={
3500
- handleDeleteJsonTableRow
3501
- }
3502
- onFileDownload={handleFileDownload}
3503
- settings={item}
3504
- setFormData={setFormData}
3505
- style={style}
3506
- uploadProgress={uploadProgress}
3507
- userProfile={userProfile}
3508
- />
3810
+ {summary}
3811
+ </React.Fragment>
3812
+ ) : (
3813
+ renderFormField(
3814
+ withSumEqualsHint(item),
3815
+ key
3816
+ )
3509
3817
  );
3510
3818
  })}
3511
3819
  <div
@@ -16,6 +16,31 @@ function MultiCheckbox({
16
16
  isEditable = false,
17
17
  creatableConfig = {},
18
18
  editableConfig = {},
19
+ // The checkbox renderer used to ignore the field's read-only state, so a
20
+ // `readOnlyWhen` config claimed a lock the UI never enforced (the same
21
+ // gap the radio renderers had). Disabling the boxes also takes the
22
+ // create/edit affordances away — they mutate the option list.
23
+ disabled = false,
24
+ /**
25
+ * Companion form fields to draw INSIDE the option cards, so each option
26
+ * reads as one self-contained unit ("Station A [✓] Qty 25") rather than a
27
+ * detached row of inputs below the group.
28
+ *
29
+ * optionFields: ['split_station_qty_1', 'split_station_qty_2']
30
+ *
31
+ * Positional against the SELECTED options **in the order the options are
32
+ * rendered**, not the order they were clicked — so the mapping is stable
33
+ * however the operator ticks the boxes, and lines up with a backend
34
+ * reading the same list in the same order.
35
+ *
36
+ * The fields are the form's own: `renderOptionField(fieldId)` mounts the
37
+ * real field (same id, same value, same validation, same sumEquals
38
+ * membership), so there is no second source of truth. It returns null for
39
+ * a field the form's `show` rules have not revealed — which is what keeps
40
+ * the quantity inputs hidden until a second box is ticked.
41
+ */
42
+ optionFields = [],
43
+ renderOptionField,
19
44
  }) {
20
45
  const [checkboxOptions, setCheckboxOptions] = useState([]);
21
46
  const [selectedValues, setSelectedValues] = useState([]);
@@ -75,8 +100,12 @@ function MultiCheckbox({
75
100
  const filteredOptions = checkboxOptions;
76
101
 
77
102
  const handleCheckboxChange = (option, isChecked) => {
103
+ if (disabled) {
104
+ return;
105
+ }
106
+
78
107
  let newSelected;
79
-
108
+
80
109
  if (isChecked) {
81
110
  // Add to selection
82
111
  newSelected = [...selectedValues, option];
@@ -182,11 +211,33 @@ function MultiCheckbox({
182
211
 
183
212
  const maxColumns = settings.maxColumns || 3;
184
213
 
214
+ /**
215
+ * The companion field id for an option: its position among the SELECTED
216
+ * options, counted in render order (see the `optionFields` docs above).
217
+ * Null when the option is unselected, unmapped, or nothing was supplied
218
+ * to render it with.
219
+ */
220
+ const selectedInRenderOrder = filteredOptions.filter((option) =>
221
+ isSelected(option.id)
222
+ );
223
+
224
+ const optionFieldIdFor = (option) => {
225
+ if (!renderOptionField || optionFields.length === 0) {
226
+ return null;
227
+ }
228
+
229
+ const position = selectedInRenderOrder.findIndex(
230
+ (selected) => String(selected.id) === String(option.id)
231
+ );
232
+
233
+ return position >= 0 ? optionFields[position] ?? null : null;
234
+ };
235
+
185
236
  return (
186
237
  <div className={`${styles.multiCheckbox} ${className || ''}`} style={style}>
187
238
 
188
239
  {/* Create new tag section */}
189
- {isCreatable && (
240
+ {isCreatable && !disabled && (
190
241
  <div className={styles.createSection}>
191
242
  {!isCreating ? (
192
243
  <button
@@ -248,8 +299,19 @@ function MultiCheckbox({
248
299
  gridTemplateColumns: `repeat(${maxColumns}, 1fr)`
249
300
  }}
250
301
  >
251
- {filteredOptions.map((option, index) => (
252
- <div key={option.id || `option-${index}`} className={styles.checkboxItem}>
302
+ {filteredOptions.map((option, index) => {
303
+ const optionFieldId = optionFieldIdFor(option);
304
+ const optionField = optionFieldId
305
+ ? renderOptionField(optionFieldId, option)
306
+ : null;
307
+
308
+ return (
309
+ <div
310
+ key={option.id || `option-${index}`}
311
+ className={`${styles.checkboxItem} ${
312
+ optionField ? styles.hasOptionField : ''
313
+ }`}
314
+ >
253
315
  {isEditing === option.id ? (
254
316
  <div className={styles.editForm}>
255
317
  <input
@@ -294,9 +356,10 @@ function MultiCheckbox({
294
356
  checked={isSelected(option.id)}
295
357
  onChange={(e) => handleCheckboxChange(option, e.target.checked)}
296
358
  className={styles.checkbox}
359
+ disabled={disabled}
297
360
  />
298
361
  <span className={styles.labelText}>{option.label}</span>
299
- {isEditable && (
362
+ {isEditable && !disabled && (
300
363
  <button
301
364
  type="button"
302
365
  onClick={(e) => {
@@ -312,8 +375,21 @@ function MultiCheckbox({
312
375
  )}
313
376
  </label>
314
377
  )}
378
+ {/*
379
+ * Outside the <label> on purpose: an input nested in
380
+ * a label toggles that label's checkbox, so typing a
381
+ * quantity would untick the station it belongs to.
382
+ * Sitting here also gives the natural tab order —
383
+ * checkbox, its own quantity, then the next card.
384
+ */}
385
+ {optionField && (
386
+ <div className={styles.optionField}>
387
+ {optionField}
388
+ </div>
389
+ )}
315
390
  </div>
316
- ))}
391
+ );
392
+ })}
317
393
  </div>
318
394
 
319
395
  {/* No options message */}
@@ -0,0 +1,147 @@
1
+ import React from 'react';
2
+ import styles from './styles/SumEqualsSummary.module.scss';
3
+
4
+ /**
5
+ * Live feedback for a `sumEquals` field group — see the rule's documentation
6
+ * in Form.jsx.
7
+ *
8
+ * The rule declares a set of numeric fields that must add up to a target:
9
+ *
10
+ * sumEquals: { fields: ['qty_a', 'qty_b'], equals: 'qty_total' }
11
+ *
12
+ * At submit time Form validates it. Everything here is the progressive half:
13
+ * while the operator types, the group says what the target is, how much is
14
+ * already allocated and how much is left, and the one empty field offers the
15
+ * remainder as a placeholder. None of it replaces the validation — a form
16
+ * that never renders the summary still refuses a wrong total.
17
+ */
18
+
19
+ const isBlank = (value) =>
20
+ value === null ||
21
+ value === undefined ||
22
+ value === '' ||
23
+ isNaN(Number(value));
24
+
25
+ /**
26
+ * The group's running state, or null when the rule names no fields.
27
+ *
28
+ * @param {object} rule the field's `sumEquals` declaration
29
+ * @param {object} formData current form values
30
+ * @returns {{
31
+ * ids: string[], target: number, hasTarget: boolean, sum: number,
32
+ * remaining: number|null, entered: number, emptyIds: string[],
33
+ * status: 'under'|'balanced'|'over'|'unknown'
34
+ * }|null}
35
+ */
36
+ export const computeSumEqualsState = (rule, formData = {}) => {
37
+ const ids = Array.isArray(rule?.fields) ? rule.fields : [];
38
+
39
+ if (ids.length === 0) {
40
+ return null;
41
+ }
42
+
43
+ const target =
44
+ typeof rule.equals === 'number'
45
+ ? rule.equals
46
+ : Number(formData[rule.equals]);
47
+ const hasTarget = !isNaN(target);
48
+
49
+ const emptyIds = ids.filter((id) => isBlank(formData[id]));
50
+ const sum = ids.reduce(
51
+ (total, id) => (isBlank(formData[id]) ? total : total + Number(formData[id])),
52
+ 0
53
+ );
54
+ const remaining = hasTarget ? target - sum : null;
55
+
56
+ return {
57
+ ids,
58
+ target,
59
+ hasTarget,
60
+ sum,
61
+ remaining,
62
+ entered: ids.length - emptyIds.length,
63
+ emptyIds,
64
+ status: !hasTarget
65
+ ? 'unknown'
66
+ : remaining > 0
67
+ ? 'under'
68
+ : remaining < 0
69
+ ? 'over'
70
+ : 'balanced',
71
+ };
72
+ };
73
+
74
+ /**
75
+ * The remainder to offer as `fieldId`'s placeholder, or undefined.
76
+ *
77
+ * Only when that field is the ONLY empty one in its group and something is
78
+ * actually left to allocate — a placeholder on two empty fields would be a
79
+ * guess, and one showing "0" or a negative would be wrong. Deliberately a
80
+ * placeholder and never a value: the operator types the number themselves.
81
+ */
82
+ export const sumEqualsHintFor = (fieldId, rule, formData = {}) => {
83
+ const state = computeSumEqualsState(rule, formData);
84
+
85
+ if (
86
+ !state ||
87
+ !state.hasTarget ||
88
+ state.emptyIds.length !== 1 ||
89
+ state.emptyIds[0] !== fieldId ||
90
+ state.remaining <= 0
91
+ ) {
92
+ return undefined;
93
+ }
94
+
95
+ return String(state.remaining);
96
+ };
97
+
98
+ /**
99
+ * One line under a `sumEquals` group:
100
+ *
101
+ * under "Item Qty 40 · 25 entered · 15 remaining" (muted)
102
+ * balanced "40 of 40 allocated ✓" (success)
103
+ * over "Item Qty 40 · 45 entered · over by 5" (error)
104
+ *
105
+ * The height is reserved in CSS so moving between the three never shifts the
106
+ * form. `targetLabel` names the total in the copy: the rule's own
107
+ * `targetLabel`, else the label of the field `equals` points at, else
108
+ * "Total".
109
+ */
110
+ const SumEqualsSummary = ({ rule, formData, fields = [] }) => {
111
+ const state = computeSumEqualsState(rule, formData);
112
+
113
+ if (!state) {
114
+ return null;
115
+ }
116
+
117
+ const targetLabel =
118
+ rule.targetLabel ||
119
+ fields.find((f) => f.id === rule.equals)?.label ||
120
+ 'Total';
121
+
122
+ let text = ' ';
123
+ let tone = styles.neutral;
124
+
125
+ if (state.hasTarget) {
126
+ if (state.status === 'balanced') {
127
+ text = `${state.sum} of ${state.target} allocated ✓`;
128
+ tone = styles.balanced;
129
+ } else if (state.status === 'over') {
130
+ text = `${targetLabel} ${state.target} · ${state.sum} entered · over by ${Math.abs(
131
+ state.remaining
132
+ )}`;
133
+ tone = styles.over;
134
+ } else {
135
+ text = `${targetLabel} ${state.target} · ${state.sum} entered · ${state.remaining} remaining`;
136
+ tone = styles.neutral;
137
+ }
138
+ }
139
+
140
+ return (
141
+ <div className={`${styles.summary} ${tone}`} aria-live="polite">
142
+ {text}
143
+ </div>
144
+ );
145
+ };
146
+
147
+ export default SumEqualsSummary;
@@ -99,8 +99,65 @@
99
99
  display: grid;
100
100
  gap: 8px;
101
101
  margin-bottom: 12px;
102
-
102
+ /*
103
+ * Cards carrying a companion field grow taller than bare ones.
104
+ * Stretching keeps every card in a row the same height, so two
105
+ * stations stay aligned while one of them is being filled in.
106
+ */
107
+ align-items: stretch;
108
+
103
109
  .checkboxItem {
110
+ /*
111
+ * A card is one unit: the option's checkbox, and — when
112
+ * `optionFields` maps one — its companion input underneath.
113
+ */
114
+ display: flex;
115
+ flex-direction: column;
116
+
117
+ /*
118
+ * With a companion inside, the card takes the frame and the label
119
+ * sheds its own, so the two read as one control instead of a box
120
+ * inside a box.
121
+ */
122
+ &.hasOptionField {
123
+ border: 1px solid var(--border-color, #e2e8f0);
124
+ border-radius: 6px;
125
+ background: var(--alternate-color, #fafbfc);
126
+ overflow: hidden;
127
+
128
+ > .checkboxLabel {
129
+ border: 0;
130
+ border-radius: 0;
131
+ background: transparent;
132
+
133
+ &:hover {
134
+ transform: none;
135
+ box-shadow: none;
136
+ }
137
+ }
138
+ }
139
+
140
+ .optionField {
141
+ padding: 0 12px 10px;
142
+
143
+ /*
144
+ * The companion is an ordinary form field that fills the card
145
+ * instead of a row. Its own error styling (`.inputError`, a
146
+ * red border on the input) is untouched, so a wrong quantity
147
+ * still reads inside the card.
148
+ */
149
+ > div {
150
+ width: 100%;
151
+ margin: 0;
152
+ padding: 0;
153
+ }
154
+
155
+ label {
156
+ font-size: var(--font-size-xs, 0.75rem);
157
+ color: var(--muted-color, #6e7276);
158
+ }
159
+ }
160
+
104
161
  .checkboxLabel {
105
162
  display: flex;
106
163
  align-items: center;
@@ -0,0 +1,38 @@
1
+ /*
2
+ * Live allocation line under a `sumEquals` field group.
3
+ *
4
+ * The height is fixed rather than content-driven: the line moves between
5
+ * "remaining", "allocated" and "over by" on every keystroke, and a form that
6
+ * grew and shrank a row underneath the inputs would push the fields around
7
+ * while the operator is typing into them.
8
+ */
9
+ .summary {
10
+ display: flex;
11
+ align-items: center;
12
+ min-height: 1.25rem;
13
+ line-height: 1.25rem;
14
+ margin-top: var(--spacing-xs, 0.25rem);
15
+ font-size: var(--font-size-xs, 0.75rem);
16
+ font-weight: var(--font-weight-medium, 500);
17
+ white-space: nowrap;
18
+ overflow: hidden;
19
+ text-overflow: ellipsis;
20
+ }
21
+
22
+ /* Still to allocate — informational, not a problem yet. */
23
+ .neutral {
24
+ color: var(--muted-color, #6e7276);
25
+ }
26
+
27
+ /* Adds up: the same green the package uses for success elsewhere. */
28
+ .balanced {
29
+ color: var(--success-color, #28a745);
30
+ }
31
+
32
+ /*
33
+ * Over the target. Matches the red `.inputError` puts on the inputs
34
+ * themselves, so the line and the fields read as one error.
35
+ */
36
+ .over {
37
+ color: var(--danger-color, #dc3545);
38
+ }