d10b1d263f
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>
186 lines
10 KiB
Markdown
186 lines
10 KiB
Markdown
# 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 `<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
|
|
|
|
1. **Migration** applies; existing nomenclatures unaffected and still render.
|
|
2. **Configure** a SOURCE selector with Ship → SHIP, VOYAGE and Warehouse → WAREHOUSE, BAY.
|
|
3. **Visibility** — selecting Ship shows SHIP and VOYAGE and hides the warehouse pair; selecting Warehouse inverts it.
|
|
4. **Clearing** — pick a ship, switch to Warehouse, switch back: the ship is empty, not remembered.
|
|
5. **Serializer rejection** — POST a vehicle with `source=warehouse` and a `SHIP` value directly via the API; expect 400. This is the real enforcement.
|
|
6. **Nesting rejected** — try to configure a selector revealing another selector; expect a clear error.
|
|
7. **Double-reveal rejected** — try to reveal one nomenclature from two selectors.
|
|
8. **Quick-fill** — save a Ship combination, apply it to another vehicle: the selector and both revealed fields populate together.
|
|
9. **Unconfigured nomenclatures** — one not referenced by any selector still always shows.
|
|
10. **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** — `required` stays a `NomenclatureField` concern
|