diff --git a/.claude/plans/selector-nomenclature.md b/.claude/plans/selector-nomenclature.md new file mode 100644 index 0000000..6068f8b --- /dev/null +++ b/.claude/plans/selector-nomenclature.md @@ -0,0 +1,185 @@ +# Selector Nomenclature: Conditional Field Visibility + +## Context + +Cargo arrives from different sources, and each source needs different data. Cargo from a ship needs the ship (and its voyage); cargo from a warehouse needs the warehouse (and its bay). Today every vehicle nomenclature renders unconditionally, so the operator sees all of them at once and has to know which are relevant — and a vehicle can silently carry both a ship and a warehouse. + +This adds a **selector**: a nomenclature the admin configures whose value decides which *other* nomenclatures appear beneath it. + +``` +Source: [ Ship ▼ ] ← selector + Ship: [ Varna Star ▼ ] ← revealed by the "Ship" choice + Voyage: [ V-2291 ▼ ] + +Source: [ Warehouse ▼ ] + Warehouse: [ WH-3 ▼ ] ← revealed instead + Bay: [ Bay 7 ▼ ] +``` + +The admin defines the choices and what each reveals; it then renders as a dropdown among the extra fields like any other nomenclature. + +**Decisions made with the user:** +- **Switching clears hidden values.** Changing Source from Ship to Warehouse clears the ship. A vehicle must never carry data contradicting its own source, and nothing invisible is ever saved. +- **A choice may reveal several nomenclatures.** Ship → SHIP + VOYAGE. +- **No nesting.** A selector reveals only `lookup`/`field` nomenclatures, never another selector. One level deep, so no cycle detection and no recursive rendering. + +--- + +## Model + +A third `kind` on the existing `Nomenclature` model — it already distinguishes `lookup` from `field`, so `selector` belongs there rather than in a new entity. + +```python +SELECTOR = "selector" +KIND_CHOICES = [ + (LOOKUP, "Lookup Table"), + (FIELD, "Custom Field"), + (SELECTOR, "Selector"), # value decides which other nomenclatures show +] +``` + +The choices and what they reveal: + +```python +class SelectorOption(models.Model): + """One choice of a selector, and the nomenclatures it reveals.""" + selector = models.ForeignKey(Nomenclature, on_delete=models.CASCADE, + related_name='selector_options') + value = models.CharField(max_length=50) # stored in extra.data, e.g. "ship" + label = models.CharField(max_length=255) # shown to the operator, e.g. "Ship" + reveals = models.ManyToManyField(Nomenclature, blank=True, + related_name='revealed_by') + sort_order = models.IntegerField(default=0) + + class Meta: + ordering = ['sort_order', 'id'] + unique_together = [('selector', 'value')] +``` + +`value` is stored in `vehicle.extra.data` (a short string like `"ship"`), not the option's id. It survives an option being deleted and recreated, and reads meaningfully in reports and raw data — which matters because these values end up in printed documents. + +**Validation to enforce on save** (`clean()` and the serializer): +- A selector's `reveals` must not contain another selector (the no-nesting rule) — reject rather than silently ignore, or an admin will configure something that never appears. +- A nomenclature revealed by one selector should not be revealed by another; otherwise its visibility is ambiguous when both selectors are set. + +Migration: `nomenclatures/0003_selector.py` (latest is `0002_`). + +--- + +## Which fields are visible + +One rule, applied in both the frontend and the serializer: + +> A nomenclature is **hidden** if it appears in any `SelectorOption.reveals` *and* its selector's current value is not the option that reveals it. Everything else is visible. + +So a nomenclature not referenced by any selector always shows, exactly as today. This keeps every existing installation behaving unchanged. + +Expose it as a single reusable helper rather than reimplementing the rule per call site — the frontend and backend disagreeing about visibility is the most likely bug in this feature. + +--- + +## Backend + +`api/serializers.py` — `validate_vehicle_extra_data()` gains the rule: + +- **A hidden nomenclature's value is rejected.** If Source is `warehouse`, a `SHIP` key in the payload is an error. This is what actually guarantees the "never carries contradictory data" property; the frontend clearing values is convenience, the serializer is the rule. +- **A selector's own value** must match one of its `SelectorOption.value`s. +- **Revealed-but-unset is allowed.** Revealing a field does not make it required — `required` stays a separate `NomenclatureField` concern. + +`api/views.py` — `_record_extra_combination()` needs no change: it already filters to lookup kinds, and `selector` is not one. But **the selector value should be part of the combination**, otherwise "Ship + Varna Star" and "Warehouse + WH-3" could collapse together. Include `selector` kinds alongside `lookup` in the projection, and update the quick-fill table's columns to match. + +`api/serializers.py` — `NomenclatureSerializer` gains nested `selector_options` (value, label, sort_order, and the revealed nomenclature codes) so the frontend gets the whole configuration in the existing single fetch. + +--- + +## Frontend + +### Rendering — `Main.jsx` + +`vehicleNomenclatures` currently maps over every definition. It gains a filter using the shared visibility helper, driven by the current `extraData`. Because `extraData` is already state, changing the selector re-renders with the new set immediately — no extra machinery. + +Order matters: a revealed nomenclature should render directly beneath its selector, not in global `sort_order` position, or the relationship is invisible to the operator. + +### Clearing on switch + +When a selector's value changes, clear the values of everything its *previous* option revealed: + +```jsx +const handleSelectorChange = (selectorCode, newValue) => { + setExtraData(prev => { + const next = { ...prev, [selectorCode]: newValue }; + for (const code of codesRevealedBy(selectorCode, prev[selectorCode])) { + delete next[code]; + } + return next; + }); +}; +``` + +Clear on the **previous** value, not by diffing visible sets — a nomenclature revealed by both the old and new option (if the admin allows overlap) should keep its value rather than being cleared and immediately re-shown empty. + +Applies to both `extraData` and `newExtraData`. + +### `NomenclatureDropdown.jsx` + +A `selector` kind renders a ` @@ -361,7 +436,74 @@ function NomenclatureManager({ onClose }) { )} + {/* Options section - a selector's choices replace the field editor */} + {form.kind === 'selector' && ( +
+
+

Options

+ {canEdit && ( + + )} +
+ + {form.selector_options.length === 0 && ( +

+ No options yet. Each option is a choice the operator can pick, + and the nomenclatures it reveals appear beneath it. +

+ )} + + {form.selector_options.map((opt, idx) => ( +
+
+ updateOption(idx, 'label', e.target.value)} + disabled={isSaving || !canEdit} + /> + updateOption(idx, 'value', e.target.value)} + disabled={isSaving || !canEdit} + /> + {canEdit && ( + + )} +
+
+ Reveals: + {revealableFor(idx).map(n => ( + + ))} +
+
+ ))} +
+ )} + {/* Fields section */} + {form.kind !== 'selector' && (

Fields

@@ -424,6 +566,7 @@ function NomenclatureManager({ onClose }) { ))}
+ )} {/* Actions */}
diff --git a/frontend/src/components/NomenclatureUI/NomenclatureDropdown.jsx b/frontend/src/components/NomenclatureUI/NomenclatureDropdown.jsx index 673e426..a2e15fd 100644 --- a/frontend/src/components/NomenclatureUI/NomenclatureDropdown.jsx +++ b/frontend/src/components/NomenclatureUI/NomenclatureDropdown.jsx @@ -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 ( +
+ + +
+ ); + } + // LOOKUP type: select dropdown + manage button if (nomenclature.kind === 'lookup') { const handleSelectChange = (e) => { diff --git a/frontend/src/services/nomenclatureVisibility.js b/frontend/src/services/nomenclatureVisibility.js new file mode 100644 index 0000000..e90f1c3 --- /dev/null +++ b/frontend/src/services/nomenclatureVisibility.js @@ -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; +}