From d10b1d263f9e36b64e4eb13605ec5b867f46cf5b Mon Sep 17 00:00:00 2001 From: kikimor Date: Fri, 18 Sep 2026 19:43:13 +0300 Subject: [PATCH] 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) --- .claude/plans/selector-nomenclature.md | 185 ++++++++++++++++++ backend/api/serializers.py | 113 ++++++++++- backend/api/views.py | 11 +- backend/nomenclatures/admin.py | 10 +- ..._alter_nomenclature_kind_selectoroption.py | 64 ++++++ backend/nomenclatures/models.py | 27 +++ backend/nomenclatures/visibility.py | 48 +++++ .../src/components/ExtraCombinationsTable.jsx | 17 +- frontend/src/components/Main.jsx | 84 ++++++-- .../NomenclatureManager.css | 75 +++++++ .../NomenclatureManager.jsx | 147 +++++++++++++- .../NomenclatureUI/NomenclatureDropdown.jsx | 27 +++ .../src/services/nomenclatureVisibility.js | 82 ++++++++ 13 files changed, 856 insertions(+), 34 deletions(-) create mode 100644 .claude/plans/selector-nomenclature.md create mode 100644 backend/nomenclatures/migrations/0003_alter_nomenclature_kind_selectoroption.py create mode 100644 backend/nomenclatures/visibility.py create mode 100644 frontend/src/services/nomenclatureVisibility.js 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; +}