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:
2026-09-18 19:43:13 +03:00
parent fe9fb71860
commit d10b1d263f
13 changed files with 856 additions and 34 deletions
@@ -16,8 +16,19 @@ export default function ExtraCombinationsTable({ nomenclatures, onApply }) {
const subscribeRef = useRef(subscribeToUpdates);
subscribeRef.current = subscribeToUpdates;
// Only lookup nomenclatures form a combination
const lookupNomenclatures = nomenclatures.filter(n => n.kind === 'lookup');
// Lookups and selectors form a combination: the selector value is what makes
// "Ship + cargo" and "Warehouse + cargo" distinct rows rather than one.
const lookupNomenclatures = nomenclatures.filter(
n => n.kind === 'lookup' || n.kind === 'selector'
);
// A selector stores its option value, not an entry id, so it resolves to the
// option's label instead of going through getDisplayValue.
const cellLabel = (nom, value) => {
if (nom.kind !== 'selector') return getDisplayValue(nom.code, value);
const option = (nom.selector_options || []).find(o => o.value === value);
return option ? option.label : '';
};
const load = useCallback(async () => {
try {
@@ -91,7 +102,7 @@ export default function ExtraCombinationsTable({ nomenclatures, onApply }) {
>
{lookupNomenclatures.map(nom => {
const entryId = combo.data[nom.code];
const label = entryId != null ? getDisplayValue(nom.code, entryId) : '';
const label = entryId != null ? cellLabel(nom, entryId) : '';
return (
<td key={nom.code}>
{entryId != null && !label
+65 -19
View File
@@ -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>
))}
@@ -624,3 +624,78 @@
grid-template-columns: 1fr;
}
}
/* --- Selector options editor --- */
.nm-options-empty {
margin: 0;
padding: 10px 8px;
font-size: 13px;
color: #888;
line-height: 1.5;
}
.nm-option-row {
border-top: 1px solid #f0f0f0;
padding: 8px;
}
.nm-option-head {
display: grid;
grid-template-columns: 1fr 1fr 32px;
gap: 6px;
align-items: center;
}
.nm-option-head input[type="text"] {
padding: 5px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
width: 100%;
box-sizing: border-box;
}
.nm-option-head input[type="text"]:focus {
outline: none;
border-color: #667eea;
}
.nm-option-remove {
background: none;
border: none;
color: #c0392b;
font-size: 18px;
cursor: pointer;
padding: 0;
line-height: 1;
}
.nm-option-remove:disabled {
color: #ccc;
cursor: not-allowed;
}
.nm-option-reveals {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 6px;
padding-left: 2px;
}
.nm-option-reveals-label {
font-size: 12px;
color: #888;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.nm-option-reveal {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #333;
cursor: pointer;
}
@@ -11,6 +11,7 @@ const EMPTY_FORM = {
display_field: 'name',
sort_order: 0,
fields: [{ key: 'name', label: 'Name', field_type: 'text', required: true, choices: null }],
selector_options: [],
};
function NomenclatureManager({ onClose }) {
@@ -61,6 +62,12 @@ function NomenclatureManager({ onClose }) {
required: f.required || false,
choices: f.choices || null,
})),
selector_options: (nom.selector_options || []).map(o => ({
value: o.value,
label: o.label || '',
reveals: o.reveals || [],
sort_order: o.sort_order || 0,
})),
});
};
@@ -68,7 +75,7 @@ function NomenclatureManager({ onClose }) {
const handleAdd = () => {
setSelectedId(null);
setError('');
setForm({ ...EMPTY_FORM, fields: [{ ...EMPTY_FORM.fields[0] }] });
setForm({ ...EMPTY_FORM, fields: [{ ...EMPTY_FORM.fields[0] }], selector_options: [] });
};
// Cancel editing
@@ -98,6 +105,14 @@ function NomenclatureManager({ onClose }) {
required: f.required,
choices: f.field_type === 'choice' ? f.choices : null,
})),
selector_options: form.kind === 'selector'
? form.selector_options.map((o, idx) => ({
value: o.value,
label: o.label,
reveals: o.reveals,
sort_order: idx,
}))
: [],
};
try {
@@ -144,6 +159,65 @@ function NomenclatureManager({ onClose }) {
}
};
// --- Selector option management ---
const addOption = () => {
setForm(prev => ({
...prev,
selector_options: [...prev.selector_options, { value: '', label: '', reveals: [], sort_order: prev.selector_options.length }],
}));
};
const updateOption = (index, key, value) => {
setForm(prev => ({
...prev,
selector_options: prev.selector_options.map((o, i) => i === index ? { ...o, [key]: value } : o),
}));
};
const removeOption = (index) => {
setForm(prev => ({
...prev,
selector_options: prev.selector_options.filter((_, i) => i !== index),
}));
};
const toggleReveal = (index, code) => {
setForm(prev => ({
...prev,
selector_options: prev.selector_options.map((o, i) => {
if (i !== index) return o;
const reveals = o.reveals.includes(code)
? o.reveals.filter(c => c !== code)
: [...o.reveals, code];
return { ...o, reveals };
}),
}));
};
// Revealable = same scope, not a selector (no nesting), and not already claimed
// by a different selector. Mirrors the server-side rule so the UI cannot build
// a configuration the API will reject.
const revealableFor = (optionIndex) => {
const claimedElsewhere = new Set();
for (const nom of nomenclatures) {
if (nom.kind !== 'selector' || nom.id === selectedId) continue;
for (const opt of nom.selector_options || []) {
for (const code of opt.reveals || []) claimedElsewhere.add(code);
}
}
const claimedHere = new Set();
(form?.selector_options || []).forEach((o, i) => {
if (i !== optionIndex) for (const code of o.reveals || []) claimedHere.add(code);
});
return nomenclatures.filter(n =>
n.kind !== 'selector' &&
n.applies_to === form?.applies_to &&
!claimedElsewhere.has(n.code) &&
!claimedHere.has(n.code)
);
};
// --- Field management ---
const addField = () => {
setForm(prev => ({
@@ -262,7 +336,7 @@ function NomenclatureManager({ onClose }) {
<div className="nm-list-item-info">
<span className="nm-list-item-name">{nom.name}</span>
<span className="nm-list-item-meta">
{nom.kind === 'lookup' ? 'Lookup' : 'Field'} / {nom.applies_to}
{nom.kind === 'lookup' ? 'Lookup' : nom.kind === 'selector' ? 'Selector' : 'Field'} / {nom.applies_to}
</span>
</div>
{canEdit && (
@@ -342,6 +416,7 @@ function NomenclatureManager({ onClose }) {
>
<option value="lookup">Lookup Table</option>
<option value="field">Custom Field</option>
<option value="selector">Selector</option>
</select>
</div>
@@ -361,7 +436,74 @@ function NomenclatureManager({ onClose }) {
)}
</div>
{/* Options section - a selector's choices replace the field editor */}
{form.kind === 'selector' && (
<div className="nm-section">
<div className="nm-section-header">
<h3>Options</h3>
{canEdit && (
<button className="nm-section-add" onClick={addOption} disabled={isSaving}>
+ Add Option
</button>
)}
</div>
{form.selector_options.length === 0 && (
<p className="nm-options-empty">
No options yet. Each option is a choice the operator can pick,
and the nomenclatures it reveals appear beneath it.
</p>
)}
{form.selector_options.map((opt, idx) => (
<div key={idx} className="nm-option-row">
<div className="nm-option-head">
<input
type="text"
placeholder="Label (e.g. Ship)"
value={opt.label}
onChange={e => updateOption(idx, 'label', e.target.value)}
disabled={isSaving || !canEdit}
/>
<input
type="text"
placeholder="Value (e.g. ship)"
value={opt.value}
onChange={e => updateOption(idx, 'value', e.target.value)}
disabled={isSaving || !canEdit}
/>
{canEdit && (
<button
className="nm-option-remove"
onClick={() => removeOption(idx)}
disabled={isSaving}
title="Remove option"
>
&times;
</button>
)}
</div>
<div className="nm-option-reveals">
<span className="nm-option-reveals-label">Reveals:</span>
{revealableFor(idx).map(n => (
<label key={n.code} className="nm-option-reveal">
<input
type="checkbox"
checked={opt.reveals.includes(n.code)}
onChange={() => toggleReveal(idx, n.code)}
disabled={isSaving || !canEdit}
/>
{n.name}
</label>
))}
</div>
</div>
))}
</div>
)}
{/* Fields section */}
{form.kind !== 'selector' && (
<div className="nm-section">
<div className="nm-section-header">
<h3>Fields</h3>
@@ -424,6 +566,7 @@ function NomenclatureManager({ onClose }) {
))}
</div>
</div>
)}
{/* Actions */}
<div className="nm-form-actions">
@@ -35,6 +35,33 @@ export default function NomenclatureDropdown({
const displayLabel = label || nomenclature.name;
// SELECTOR type: choices come from selector_options, value is the option string.
// No manage button - options are configured in the admin screen, not while weighing.
if (nomenclature.kind === 'selector') {
const options = nomenclature.selector_options || [];
const isUnknown = value && !options.some(o => o.value === value);
return (
<div className="nui-dropdown">
<label className="nui-dropdown-label">{displayLabel}</label>
<select
className="nui-dropdown-select"
value={value || ''}
onChange={(e) => onChange(e.target.value || undefined)}
disabled={disabled}
>
<option value="">-- Select --</option>
{options.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
{isUnknown && (
<option value={value}>{value} (unknown)</option>
)}
</select>
</div>
);
}
// LOOKUP type: select dropdown + manage button
if (nomenclature.kind === 'lookup') {
const handleSelectChange = (e) => {
@@ -0,0 +1,82 @@
/**
* The one rule deciding which nomenclatures are visible for a given extraData.
*
* A nomenclature is hidden if it is revealed by some selector option and that
* selector's current value is not the option revealing it. Anything no selector
* references is always visible, so installations without selectors are unchanged.
*
* Mirrored in backend/nomenclatures/visibility.py - keep the two in step.
*/
/** Build { revealedCode: { selectorCode: Set(values) } } from nomenclature definitions. */
export function buildRevealMap(definitions) {
const revealMap = {};
for (const nom of Object.values(definitions || {})) {
if (nom.kind !== 'selector') continue;
for (const option of nom.selector_options || []) {
for (const revealedCode of option.reveals || []) {
if (!revealMap[revealedCode]) revealMap[revealedCode] = {};
if (!revealMap[revealedCode][nom.code]) revealMap[revealedCode][nom.code] = new Set();
revealMap[revealedCode][nom.code].add(option.value);
}
}
}
return revealMap;
}
/** True if `code` should be shown for this extraData. */
export function isVisible(code, extraData, revealMap) {
const controllers = revealMap[code];
if (!controllers) return true;
return Object.entries(controllers).some(
([selectorCode, values]) => values.has((extraData || {})[selectorCode])
);
}
/** The codes revealed by `selectorCode` when it holds `value`. */
export function codesRevealedBy(definitions, selectorCode, value) {
if (value === undefined || value === null) return [];
const nom = (definitions || {})[selectorCode];
if (!nom || nom.kind !== 'selector') return [];
const option = (nom.selector_options || []).find(o => o.value === value);
return option ? (option.reveals || []) : [];
}
/**
* Order nomenclatures so each revealed one sits directly beneath its selector,
* and drop those hidden by the current extraData.
*/
export function visibleNomenclatures(nomenclatures, definitions, extraData) {
const revealMap = buildRevealMap(definitions);
const visible = nomenclatures.filter(n => isVisible(n.code, extraData, revealMap));
const byCode = new Map(visible.map(n => [n.code, n]));
const claimed = new Set();
const ordered = [];
// A revealed nomenclature is emitted by its selector, never on its own: it
// may sort ahead of the selector, and must still appear beneath it.
const revealedByAnyone = new Set(Object.keys(revealMap));
for (const nom of visible) {
if (claimed.has(nom.code)) continue;
if (revealedByAnyone.has(nom.code)) continue;
ordered.push(nom);
claimed.add(nom.code);
if (nom.kind !== 'selector') continue;
// Revealed children follow their selector immediately, in option order.
for (const option of nom.selector_options || []) {
if (option.value !== (extraData || {})[nom.code]) continue;
for (const revealedCode of option.reveals || []) {
const child = byCode.get(revealedCode);
if (child && !claimed.has(revealedCode)) {
ordered.push(child);
claimed.add(revealedCode);
}
}
}
}
return ordered;
}