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>
10 KiB
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/fieldnomenclatures, 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.
SELECTOR = "selector"
KIND_CHOICES = [
(LOOKUP, "Lookup Table"),
(FIELD, "Custom Field"),
(SELECTOR, "Selector"), # value decides which other nomenclatures show
]
The choices and what they reveal:
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
revealsmust 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.revealsand 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, aSHIPkey 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.values. - Revealed-but-unset is allowed. Revealing a field does not make it required —
requiredstays a separateNomenclatureFieldconcern.
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:
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 <select> of its options — same markup as the lookup branch, but the options come from selector_options and the value is the option's value string rather than an entry id. No manage button: options are configured in the admin screen, not created ad hoc while weighing.
NomenclatureManager.jsx
The kind dropdown gains "Selector". When selected, the fields editor is replaced by an options editor: add/remove/reorder rows of label + value + a multi-select of nomenclatures to reveal.
The reveal list must exclude selectors (no nesting) and anything already revealed by a different selector — filtered in the UI and rejected server-side.
Interaction with existing features
Quick-fill combinations — combinations now carry the selector value, so applying one sets the source and reveals the right fields in a single click. This is a genuine improvement: the whole point of the feature is repeat trucks, and source is exactly the kind of thing that repeats. Verify the combinations table's dynamic columns handle a selector column.
Report editor — a hidden nomenclature has no value in extra.data, so DBTextElement bindings to it resolve empty. Existing reports that print a field which is now conditionally hidden will show blank rather than breaking. Worth a note to the user rather than code.
Edge cases
| Case | Behaviour |
|---|---|
| Selector has no value yet | Nothing it reveals is shown |
| Option deleted while vehicles reference its value | Stored value stays; selector shows it as unknown rather than silently blank, so the data is not quietly misread |
| Nomenclature revealed by two selectors | Rejected at configuration time |
| Selector reveals a selector | Rejected at configuration time |
| Existing vehicles predating the selector | No selector key in extra.data; revealed fields hidden, previously-entered values remain stored but invisible — call this out, since it is the one case where old data becomes unreachable |
| Admin changes reveals after data exists | Existing vehicles keep their stored values; only new edits follow the new configuration |
The fifth row is the one to raise with the user before building: introducing a selector over existing data can hide values that were legitimately entered. A one-off data check before configuring the first selector is wise.
Verification
- Migration applies; existing nomenclatures unaffected and still render.
- Configure a SOURCE selector with Ship → SHIP, VOYAGE and Warehouse → WAREHOUSE, BAY.
- Visibility — selecting Ship shows SHIP and VOYAGE and hides the warehouse pair; selecting Warehouse inverts it.
- Clearing — pick a ship, switch to Warehouse, switch back: the ship is empty, not remembered.
- Serializer rejection — POST a vehicle with
source=warehouseand aSHIPvalue directly via the API; expect 400. This is the real enforcement. - Nesting rejected — try to configure a selector revealing another selector; expect a clear error.
- Double-reveal rejected — try to reveal one nomenclature from two selectors.
- Quick-fill — save a Ship combination, apply it to another vehicle: the selector and both revealed fields populate together.
- Unconfigured nomenclatures — one not referenced by any selector still always shows.
- Old vehicle — one created before the selector opens without errors.
Files
Backend — nomenclatures/models.py (SELECTOR kind, SelectorOption), migration, api/serializers.py (visibility rule in validate_vehicle_extra_data, nested options in NomenclatureSerializer), api/views.py (include selector in the combination projection)
Frontend — components/Main.jsx (filter, clear-on-switch), components/NomenclatureUI/NomenclatureDropdown.jsx (selector branch), components/NomenclatureManager/NomenclatureManager.jsx (options editor), contexts/NomenclatureDataContext.jsx (carry selector_options), shared visibility helper
Out of scope
- Nesting — deliberately excluded
- Conditions beyond equality (ranges, multiple values) — a choice reveals a fixed set; anything richer is a rule engine
- Required-when-revealed —
requiredstays aNomenclatureFieldconcern