Add selector nomenclature for conditional field visibility
A selector is a nomenclature whose value decides which other nomenclatures appear beneath it: picking "Ship" reveals the ship fields, "Warehouse" reveals the warehouse ones. Previously every vehicle nomenclature rendered at once, so the operator had to know which were relevant and a vehicle could silently carry both a ship and a warehouse. Visibility is one rule - a nomenclature is hidden if some selector option reveals it and that selector's value is not the revealing option - kept in nomenclatures/visibility.py and mirrored in services/nomenclatureVisibility.js. A nomenclature no selector references always shows, so installations without selectors are unchanged. The serializer, not the UI, enforces the invariant: a value for a hidden nomenclature is rejected, as is a selector value that matches no option. The frontend clearing values on switch and pruning hidden keys before save is convenience on top of that. Selector values join lookups in the quick-fill combination projection, so applying a saved combination sets the source and its revealed fields in one click rather than collapsing "Ship + cargo" and "Warehouse + cargo" into one indistinguishable row. Configuration rejects nesting (a selector revealing another selector) and double-reveal (one nomenclature revealed by two selectors, leaving its visibility ambiguous). The admin UI filters both out of the reveal list as well, so an invalid configuration cannot be built in the first place. Note: introducing a selector over existing data hides values already entered - vehicles predating the selector have no selector key, so anything it reveals becomes invisible until a source is chosen. The values remain stored. Worth a data check before configuring the first selector. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import { parseReading } from '../services/scaleProtocols';
|
||||
import NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown';
|
||||
import ReportPreviewModal from './ReportPreviewModal';
|
||||
import ExtraCombinationsTable from './ExtraCombinationsTable';
|
||||
import { visibleNomenclatures, codesRevealedBy } from '../services/nomenclatureVisibility';
|
||||
import api from '../services/api';
|
||||
import './Main.css';
|
||||
import './License/LicenseManager.css';
|
||||
@@ -44,6 +45,45 @@ export default function Main() {
|
||||
);
|
||||
const [extraData, setExtraData] = useState({});
|
||||
|
||||
// A selector's value decides which other nomenclatures appear, so the visible
|
||||
// set is derived per panel from that panel's own extra data.
|
||||
const visibleForSelected = useMemo(
|
||||
() => visibleNomenclatures(vehicleNomenclatures, definitions, extraData),
|
||||
[vehicleNomenclatures, definitions, extraData]
|
||||
);
|
||||
const visibleForNew = useMemo(
|
||||
() => visibleNomenclatures(vehicleNomenclatures, definitions, newExtraData),
|
||||
[vehicleNomenclatures, definitions, newExtraData]
|
||||
);
|
||||
|
||||
// Switching a selector clears whatever its PREVIOUS choice revealed, so a
|
||||
// vehicle never carries data contradicting its own selector. Clearing on the
|
||||
// previous value (rather than diffing visible sets) keeps a nomenclature that
|
||||
// both choices reveal from being needlessly emptied.
|
||||
const applySelectorChange = (setter) => (code, newValue) => {
|
||||
setter(prev => {
|
||||
const next = { ...prev, [code]: newValue };
|
||||
if (definitions[code]?.kind === 'selector') {
|
||||
for (const revealed of codesRevealedBy(definitions, code, prev[code])) {
|
||||
delete next[revealed];
|
||||
}
|
||||
}
|
||||
if (newValue === undefined) delete next[code];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const handleExtraChange = applySelectorChange(setExtraData);
|
||||
const handleNewExtraChange = applySelectorChange(setNewExtraData);
|
||||
|
||||
// Never send a value the selector state hides - the serializer rejects those,
|
||||
// and stale keys can survive on vehicles that predate the selector.
|
||||
const prunedExtra = (data, visible) => {
|
||||
const allowed = new Set(visible.map(n => n.code));
|
||||
return Object.fromEntries(
|
||||
Object.entries(data).filter(([code]) => allowed.has(code))
|
||||
);
|
||||
};
|
||||
|
||||
// Current reading from the serial bridge. The bridge is pure transport - how
|
||||
// to read a line comes from the station protocol definition, so a new scale
|
||||
// type never requires updating the bridge on the station PC.
|
||||
@@ -128,9 +168,10 @@ export default function Main() {
|
||||
driver_pid: newForm.driver_pid.trim() || null,
|
||||
};
|
||||
// Include extra data if any values set
|
||||
const hasExtra = Object.values(newExtraData).some(v => v !== undefined && v !== null && v !== '');
|
||||
const visibleNewExtra = prunedExtra(newExtraData, visibleForNew);
|
||||
const hasExtra = Object.values(visibleNewExtra).some(v => v !== undefined && v !== null && v !== '');
|
||||
if (hasExtra) {
|
||||
payload.extra = { data: newExtraData };
|
||||
payload.extra = { data: visibleNewExtra };
|
||||
}
|
||||
const res = await api.post('/api/vehicles/', payload);
|
||||
setSelectedVehicleId(res.data.id);
|
||||
@@ -197,7 +238,7 @@ export default function Main() {
|
||||
setError('');
|
||||
try {
|
||||
await api.patch(`/api/vehicles/${selectedVehicle.id}/`, {
|
||||
extra: { data: extraData },
|
||||
extra: { data: prunedExtra(extraData, visibleForSelected) },
|
||||
});
|
||||
} catch (err) {
|
||||
const data = err.response?.data;
|
||||
@@ -208,13 +249,24 @@ export default function Main() {
|
||||
};
|
||||
|
||||
// Apply a saved combination - merge so non-lookup field values are preserved
|
||||
const handleApplyCombination = (data) => {
|
||||
setExtraData(prev => ({ ...prev, ...data }));
|
||||
};
|
||||
|
||||
const handleApplyCombinationNew = (data) => {
|
||||
setNewExtraData(prev => ({ ...prev, ...data }));
|
||||
// A combination carries its selector value, so applying one must also drop
|
||||
// whatever the previous selection revealed - otherwise a stale field survives
|
||||
// the merge and the payload contradicts the new source.
|
||||
const applyCombination = (setter) => (data) => {
|
||||
setter(prev => {
|
||||
const next = { ...prev, ...data };
|
||||
for (const [code, nom] of Object.entries(definitions)) {
|
||||
if (nom.kind !== 'selector') continue;
|
||||
if (!(code in data) || data[code] === prev[code]) continue;
|
||||
for (const revealed of codesRevealedBy(definitions, code, prev[code])) {
|
||||
if (!(revealed in data)) delete next[revealed];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const handleApplyCombination = applyCombination(setExtraData);
|
||||
const handleApplyCombinationNew = applyCombination(setNewExtraData);
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateStr) => {
|
||||
@@ -385,15 +437,12 @@ export default function Main() {
|
||||
onApply={handleApplyCombinationNew}
|
||||
/>
|
||||
<div className="extra-fields">
|
||||
{vehicleNomenclatures.map(nom => (
|
||||
{visibleForNew.map(nom => (
|
||||
<div key={nom.code} className="extra-field">
|
||||
<NomenclatureDropdown
|
||||
nomenclatureCode={nom.code}
|
||||
value={newExtraData[nom.code]}
|
||||
onChange={val => setNewExtraData(prev => ({
|
||||
...prev,
|
||||
[nom.code]: val,
|
||||
}))}
|
||||
onChange={val => handleNewExtraChange(nom.code, val)}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
@@ -563,15 +612,12 @@ export default function Main() {
|
||||
onApply={handleApplyCombination}
|
||||
/>
|
||||
<div className="extra-fields">
|
||||
{vehicleNomenclatures.map(nom => (
|
||||
{visibleForSelected.map(nom => (
|
||||
<div key={nom.code} className="extra-field">
|
||||
<NomenclatureDropdown
|
||||
nomenclatureCode={nom.code}
|
||||
value={extraData[nom.code]}
|
||||
onChange={val => setExtraData(prev => ({
|
||||
...prev,
|
||||
[nom.code]: val,
|
||||
}))}
|
||||
onChange={val => handleExtraChange(nom.code, val)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user