@visns-studio/visns-components 6.3.5 → 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 +1 -1
- package/src/components/Field.jsx +22 -0
- package/src/components/Form.jsx +280 -155
- package/src/components/MultiCheckbox.jsx +69 -3
- package/src/components/SumEqualsSummary.jsx +147 -0
- package/src/components/styles/MultiCheckbox.module.scss +58 -1
- package/src/components/styles/SumEqualsSummary.module.scss +38 -0
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.
|
|
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": [
|
package/src/components/Field.jsx
CHANGED
|
@@ -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
|
|
@@ -2187,6 +2199,16 @@ function Field({
|
|
|
2187
2199
|
// claiming a lock the UI didn't enforce — the same gap
|
|
2188
2200
|
// the radio renderers had.
|
|
2189
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
|
+
}
|
|
2190
2212
|
style={style}
|
|
2191
2213
|
/>
|
|
2192
2214
|
);
|
package/src/components/Form.jsx
CHANGED
|
@@ -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';
|
|
@@ -1428,58 +1432,57 @@ function Form({
|
|
|
1428
1432
|
* ordinary save. While visible it also stands in for
|
|
1429
1433
|
* `required` on the whole group: a blank member is as
|
|
1430
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.
|
|
1431
1452
|
*/
|
|
1432
1453
|
if (item.sumEquals && item.hide !== true) {
|
|
1433
1454
|
const rule = item.sumEquals;
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
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);
|
|
1437
1459
|
|
|
1438
|
-
if (
|
|
1460
|
+
if (state) {
|
|
1439
1461
|
const labelFor = (fieldId) =>
|
|
1440
1462
|
fields.find((f) => f.id === fieldId)
|
|
1441
1463
|
?.label || fieldId;
|
|
1442
|
-
const groupLabels =
|
|
1464
|
+
const groupLabels = state.ids
|
|
1443
1465
|
.map(labelFor)
|
|
1444
1466
|
.join(' and ');
|
|
1445
|
-
const groupValues = groupIds.map(
|
|
1446
|
-
(fieldId) => formData[fieldId]
|
|
1447
|
-
);
|
|
1448
|
-
const incomplete = groupValues.some(
|
|
1449
|
-
(v) =>
|
|
1450
|
-
v === null ||
|
|
1451
|
-
v === undefined ||
|
|
1452
|
-
v === '' ||
|
|
1453
|
-
isNaN(Number(v))
|
|
1454
|
-
);
|
|
1455
|
-
|
|
1456
|
-
const target =
|
|
1457
|
-
typeof rule.equals === 'number'
|
|
1458
|
-
? rule.equals
|
|
1459
|
-
: Number(formData[rule.equals]);
|
|
1460
1467
|
|
|
1461
1468
|
const markGroup = () =>
|
|
1462
|
-
|
|
1469
|
+
state.ids.forEach((fieldId) => {
|
|
1463
1470
|
_inputClass[fieldId] =
|
|
1464
1471
|
styles.inputError;
|
|
1465
1472
|
});
|
|
1466
1473
|
|
|
1467
|
-
if (
|
|
1474
|
+
if (state.emptyIds.length > 0) {
|
|
1468
1475
|
validation += `${groupLabels} are required.<br/>`;
|
|
1469
1476
|
markGroup();
|
|
1470
|
-
} else if (
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
`${groupLabels} must add up to ${target} - they add up to ${sum}.`) +
|
|
1480
|
-
'<br/>';
|
|
1481
|
-
markGroup();
|
|
1482
|
-
}
|
|
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();
|
|
1483
1486
|
}
|
|
1484
1487
|
}
|
|
1485
1488
|
}
|
|
@@ -2899,6 +2902,193 @@ function Form({
|
|
|
2899
2902
|
});
|
|
2900
2903
|
}, [fetchTrigger]);
|
|
2901
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
|
+
|
|
2902
3092
|
if (type && type === 'inline') {
|
|
2903
3093
|
return (
|
|
2904
3094
|
<div className={styles.formcontainer}>
|
|
@@ -2957,67 +3147,36 @@ function Form({
|
|
|
2957
3147
|
/>
|
|
2958
3148
|
</div>
|
|
2959
3149
|
);
|
|
2960
|
-
default:
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
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
|
|
2977
3170
|
)}
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
mapbox={mapbox}
|
|
2986
|
-
onChange={handleChange}
|
|
2987
|
-
onChangeCanvas={handleChangeCanvas}
|
|
2988
|
-
onChangeCheckbox={handleChangeCheckbox}
|
|
2989
|
-
onChangeCheckboxManual={
|
|
2990
|
-
handleChangeCheckboxManual
|
|
2991
|
-
}
|
|
2992
|
-
onChangeDate={handleChangeDate}
|
|
2993
|
-
onChangeNumberFormat={
|
|
2994
|
-
handleChangeNumberFormat
|
|
2995
|
-
}
|
|
2996
|
-
onChangeRicheditor={
|
|
2997
|
-
handleChangeRicheditor
|
|
2998
|
-
}
|
|
2999
|
-
onChangeSelect={handleChangeSelect}
|
|
3000
|
-
onChangeSignature={
|
|
3001
|
-
handleChangeSignature
|
|
3002
|
-
}
|
|
3003
|
-
onChangeToggle={handleChangeToggle}
|
|
3004
|
-
onChangeJsonTable={
|
|
3005
|
-
handleChangeJsonTable
|
|
3006
|
-
}
|
|
3007
|
-
onAddJsonTableRow={
|
|
3008
|
-
handleAddJsonTableRow
|
|
3009
|
-
}
|
|
3010
|
-
onDeleteJsonTableRow={
|
|
3011
|
-
handleDeleteJsonTableRow
|
|
3012
|
-
}
|
|
3013
|
-
onFileDownload={handleFileDownload}
|
|
3014
|
-
settings={item}
|
|
3015
|
-
setFormData={setFormData}
|
|
3016
|
-
style={style}
|
|
3017
|
-
uploadProgress={uploadProgress}
|
|
3018
|
-
userProfile={userProfile}
|
|
3019
|
-
/>
|
|
3171
|
+
{summary}
|
|
3172
|
+
</React.Fragment>
|
|
3173
|
+
) : (
|
|
3174
|
+
renderFormField(
|
|
3175
|
+
withSumEqualsHint(item),
|
|
3176
|
+
key
|
|
3177
|
+
)
|
|
3020
3178
|
);
|
|
3179
|
+
}
|
|
3021
3180
|
}
|
|
3022
3181
|
})}
|
|
3023
3182
|
<div className={`${styles.formItem} ${styles.fwItem}`}>
|
|
@@ -3627,68 +3786,34 @@ function Form({
|
|
|
3627
3786
|
return null;
|
|
3628
3787
|
}
|
|
3629
3788
|
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
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
|
|
3646
3809
|
)}
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
mapbox={mapbox}
|
|
3655
|
-
onChange={handleChange}
|
|
3656
|
-
onChangeCanvas={handleChangeCanvas}
|
|
3657
|
-
onChangeCheckbox={
|
|
3658
|
-
handleChangeCheckbox
|
|
3659
|
-
}
|
|
3660
|
-
onChangeCheckboxManual={
|
|
3661
|
-
handleChangeCheckboxManual
|
|
3662
|
-
}
|
|
3663
|
-
onChangeColour={handleChangeColour}
|
|
3664
|
-
onChangeDate={handleChangeDate}
|
|
3665
|
-
onChangeNumberFormat={
|
|
3666
|
-
handleChangeNumberFormat
|
|
3667
|
-
}
|
|
3668
|
-
onChangeRicheditor={
|
|
3669
|
-
handleChangeRicheditor
|
|
3670
|
-
}
|
|
3671
|
-
onChangeSignature={
|
|
3672
|
-
handleChangeSignature
|
|
3673
|
-
}
|
|
3674
|
-
onChangeSelect={handleChangeSelect}
|
|
3675
|
-
onChangeToggle={handleChangeToggle}
|
|
3676
|
-
onChangeJsonTable={
|
|
3677
|
-
handleChangeJsonTable
|
|
3678
|
-
}
|
|
3679
|
-
onAddJsonTableRow={
|
|
3680
|
-
handleAddJsonTableRow
|
|
3681
|
-
}
|
|
3682
|
-
onDeleteJsonTableRow={
|
|
3683
|
-
handleDeleteJsonTableRow
|
|
3684
|
-
}
|
|
3685
|
-
onFileDownload={handleFileDownload}
|
|
3686
|
-
settings={item}
|
|
3687
|
-
setFormData={setFormData}
|
|
3688
|
-
style={style}
|
|
3689
|
-
uploadProgress={uploadProgress}
|
|
3690
|
-
userProfile={userProfile}
|
|
3691
|
-
/>
|
|
3810
|
+
{summary}
|
|
3811
|
+
</React.Fragment>
|
|
3812
|
+
) : (
|
|
3813
|
+
renderFormField(
|
|
3814
|
+
withSumEqualsHint(item),
|
|
3815
|
+
key
|
|
3816
|
+
)
|
|
3692
3817
|
);
|
|
3693
3818
|
})}
|
|
3694
3819
|
<div
|
|
@@ -21,6 +21,26 @@ function MultiCheckbox({
|
|
|
21
21
|
// gap the radio renderers had). Disabling the boxes also takes the
|
|
22
22
|
// create/edit affordances away — they mutate the option list.
|
|
23
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,
|
|
24
44
|
}) {
|
|
25
45
|
const [checkboxOptions, setCheckboxOptions] = useState([]);
|
|
26
46
|
const [selectedValues, setSelectedValues] = useState([]);
|
|
@@ -191,6 +211,28 @@ function MultiCheckbox({
|
|
|
191
211
|
|
|
192
212
|
const maxColumns = settings.maxColumns || 3;
|
|
193
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
|
+
|
|
194
236
|
return (
|
|
195
237
|
<div className={`${styles.multiCheckbox} ${className || ''}`} style={style}>
|
|
196
238
|
|
|
@@ -257,8 +299,19 @@ function MultiCheckbox({
|
|
|
257
299
|
gridTemplateColumns: `repeat(${maxColumns}, 1fr)`
|
|
258
300
|
}}
|
|
259
301
|
>
|
|
260
|
-
{filteredOptions.map((option, index) =>
|
|
261
|
-
|
|
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
|
+
>
|
|
262
315
|
{isEditing === option.id ? (
|
|
263
316
|
<div className={styles.editForm}>
|
|
264
317
|
<input
|
|
@@ -322,8 +375,21 @@ function MultiCheckbox({
|
|
|
322
375
|
)}
|
|
323
376
|
</label>
|
|
324
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
|
+
)}
|
|
325
390
|
</div>
|
|
326
|
-
|
|
391
|
+
);
|
|
392
|
+
})}
|
|
327
393
|
</div>
|
|
328
394
|
|
|
329
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
|
+
}
|