# 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 `