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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 `<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
|
||||||
+107
-6
@@ -2,7 +2,10 @@ from rest_framework import serializers
|
|||||||
from django.contrib.auth.password_validation import validate_password
|
from django.contrib.auth.password_validation import validate_password
|
||||||
from .models import ComPortReading, User, Report, ScaleProtocol
|
from .models import ComPortReading, User, Report, ScaleProtocol
|
||||||
from vehicles.models import Vehicle, VehicleExtra, ExtraCombination
|
from vehicles.models import Vehicle, VehicleExtra, ExtraCombination
|
||||||
from nomenclatures.models import Nomenclature, NomenclatureField, NomenclatureEntry
|
from nomenclatures.models import (
|
||||||
|
Nomenclature, NomenclatureField, NomenclatureEntry, SelectorOption,
|
||||||
|
)
|
||||||
|
from nomenclatures.visibility import build_reveal_map, is_visible
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
@@ -79,15 +82,19 @@ class ComPortReadingSerializer(serializers.ModelSerializer):
|
|||||||
def validate_vehicle_extra_data(value, lookup_only=False):
|
def validate_vehicle_extra_data(value, lookup_only=False):
|
||||||
"""Validate a {nomenclature_code: value} dict against vehicle nomenclatures.
|
"""Validate a {nomenclature_code: value} dict against vehicle nomenclatures.
|
||||||
|
|
||||||
lookup_only=True rejects any key whose nomenclature is not kind='lookup'.
|
lookup_only=True rejects any key whose nomenclature is not kind='lookup'
|
||||||
|
or kind='selector' (the projection stored as a reusable combination).
|
||||||
"""
|
"""
|
||||||
if not value:
|
if not value:
|
||||||
return value
|
return value
|
||||||
|
|
||||||
nomenclatures = {
|
nomenclatures = {
|
||||||
n.code: n for n in
|
n.code: n for n in
|
||||||
Nomenclature.objects.filter(applies_to='vehicle').prefetch_related('fields', 'entries')
|
Nomenclature.objects.filter(applies_to='vehicle').prefetch_related(
|
||||||
|
'fields', 'entries', 'selector_options__reveals'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
reveal_map = build_reveal_map()
|
||||||
|
|
||||||
errors = {}
|
errors = {}
|
||||||
for key, val in value.items():
|
for key, val in value.items():
|
||||||
@@ -97,7 +104,20 @@ def validate_vehicle_extra_data(value, lookup_only=False):
|
|||||||
|
|
||||||
nom = nomenclatures[key]
|
nom = nomenclatures[key]
|
||||||
|
|
||||||
if nom.kind == Nomenclature.LOOKUP:
|
# A value for a nomenclature the current selector state hides is a
|
||||||
|
# contradiction: this is what actually enforces the rule, not the UI.
|
||||||
|
if val is not None and not is_visible(key, value, reveal_map):
|
||||||
|
errors[key] = (
|
||||||
|
f"'{key}' is not applicable for the current selector value."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if nom.kind == Nomenclature.SELECTOR:
|
||||||
|
allowed = {o.value for o in nom.selector_options.all()}
|
||||||
|
if val is not None and val not in allowed:
|
||||||
|
errors[key] = f"Must be one of {sorted(allowed)}."
|
||||||
|
|
||||||
|
elif nom.kind == Nomenclature.LOOKUP:
|
||||||
if not isinstance(val, int):
|
if not isinstance(val, int):
|
||||||
errors[key] = "Must be a nomenclature entry ID (integer)."
|
errors[key] = "Must be a nomenclature entry ID (integer)."
|
||||||
elif not nom.entries.filter(id=val, is_active=True).exists():
|
elif not nom.entries.filter(id=val, is_active=True).exists():
|
||||||
@@ -105,7 +125,7 @@ def validate_vehicle_extra_data(value, lookup_only=False):
|
|||||||
|
|
||||||
elif nom.kind == Nomenclature.FIELD:
|
elif nom.kind == Nomenclature.FIELD:
|
||||||
if lookup_only:
|
if lookup_only:
|
||||||
errors[key] = "Only lookup nomenclatures are allowed here."
|
errors[key] = "Only lookup and selector nomenclatures are allowed here."
|
||||||
continue
|
continue
|
||||||
field_def = nom.fields.first()
|
field_def = nom.fields.first()
|
||||||
if field_def:
|
if field_def:
|
||||||
@@ -317,27 +337,53 @@ class NomenclatureEntrySerializer(serializers.ModelSerializer):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class SelectorOptionSerializer(serializers.ModelSerializer):
|
||||||
|
"""One selector choice; `reveals` is a list of nomenclature codes."""
|
||||||
|
|
||||||
|
reveals = serializers.SlugRelatedField(
|
||||||
|
many=True,
|
||||||
|
slug_field='code',
|
||||||
|
queryset=Nomenclature.objects.all(),
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = SelectorOption
|
||||||
|
fields = ['id', 'value', 'label', 'reveals', 'sort_order']
|
||||||
|
read_only_fields = ['id']
|
||||||
|
|
||||||
|
def validate_value(self, value):
|
||||||
|
if not value or not value.strip():
|
||||||
|
raise serializers.ValidationError("Option value cannot be empty")
|
||||||
|
return value.strip()
|
||||||
|
|
||||||
|
|
||||||
class NomenclatureSerializer(serializers.ModelSerializer):
|
class NomenclatureSerializer(serializers.ModelSerializer):
|
||||||
fields = NomenclatureFieldSerializer(many=True, required=False)
|
fields = NomenclatureFieldSerializer(many=True, required=False)
|
||||||
|
selector_options = SelectorOptionSerializer(many=True, required=False)
|
||||||
entry_count = serializers.IntegerField(source='entries.count', read_only=True)
|
entry_count = serializers.IntegerField(source='entries.count', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Nomenclature
|
model = Nomenclature
|
||||||
fields = ['id', 'code', 'name', 'applies_to', 'kind', 'display_field',
|
fields = ['id', 'code', 'name', 'applies_to', 'kind', 'display_field',
|
||||||
'sort_order', 'fields', 'entry_count']
|
'sort_order', 'fields', 'selector_options', 'entry_count']
|
||||||
read_only_fields = ['id']
|
read_only_fields = ['id']
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
fields_data = validated_data.pop('fields', [])
|
fields_data = validated_data.pop('fields', [])
|
||||||
|
options_data = validated_data.pop('selector_options', [])
|
||||||
nomenclature = Nomenclature.objects.create(**validated_data)
|
nomenclature = Nomenclature.objects.create(**validated_data)
|
||||||
|
|
||||||
for field_data in fields_data:
|
for field_data in fields_data:
|
||||||
NomenclatureField.objects.create(nomenclature=nomenclature, **field_data)
|
NomenclatureField.objects.create(nomenclature=nomenclature, **field_data)
|
||||||
|
|
||||||
|
self._write_options(nomenclature, options_data)
|
||||||
|
|
||||||
return nomenclature
|
return nomenclature
|
||||||
|
|
||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
fields_data = validated_data.pop('fields', None)
|
fields_data = validated_data.pop('fields', None)
|
||||||
|
options_data = validated_data.pop('selector_options', None)
|
||||||
|
|
||||||
for attr, value in validated_data.items():
|
for attr, value in validated_data.items():
|
||||||
setattr(instance, attr, value)
|
setattr(instance, attr, value)
|
||||||
@@ -348,8 +394,20 @@ class NomenclatureSerializer(serializers.ModelSerializer):
|
|||||||
for field_data in fields_data:
|
for field_data in fields_data:
|
||||||
NomenclatureField.objects.create(nomenclature=instance, **field_data)
|
NomenclatureField.objects.create(nomenclature=instance, **field_data)
|
||||||
|
|
||||||
|
if options_data is not None:
|
||||||
|
instance.selector_options.all().delete()
|
||||||
|
self._write_options(instance, options_data)
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_options(nomenclature, options_data):
|
||||||
|
"""Recreate the selector's options; `reveals` is m2m so it is set after save."""
|
||||||
|
for option_data in options_data:
|
||||||
|
reveals = option_data.pop('reveals', [])
|
||||||
|
option = SelectorOption.objects.create(selector=nomenclature, **option_data)
|
||||||
|
option.reveals.set(reveals)
|
||||||
|
|
||||||
def validate_code(self, value):
|
def validate_code(self, value):
|
||||||
if not value or not value.strip():
|
if not value or not value.strip():
|
||||||
raise serializers.ValidationError("Code cannot be empty")
|
raise serializers.ValidationError("Code cannot be empty")
|
||||||
@@ -366,6 +424,14 @@ class NomenclatureSerializer(serializers.ModelSerializer):
|
|||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
kind = data.get('kind', getattr(self.instance, 'kind', Nomenclature.LOOKUP))
|
kind = data.get('kind', getattr(self.instance, 'kind', Nomenclature.LOOKUP))
|
||||||
fields_data = data.get('fields', [])
|
fields_data = data.get('fields', [])
|
||||||
|
options_data = data.get('selector_options')
|
||||||
|
|
||||||
|
if kind == Nomenclature.SELECTOR:
|
||||||
|
self._validate_selector_options(options_data or [])
|
||||||
|
elif options_data:
|
||||||
|
raise serializers.ValidationError({
|
||||||
|
'selector_options': "Only a 'selector' nomenclature can have options."
|
||||||
|
})
|
||||||
|
|
||||||
if kind == Nomenclature.FIELD and fields_data and len(fields_data) != 1:
|
if kind == Nomenclature.FIELD and fields_data and len(fields_data) != 1:
|
||||||
raise serializers.ValidationError(
|
raise serializers.ValidationError(
|
||||||
@@ -382,6 +448,41 @@ class NomenclatureSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def _validate_selector_options(self, options_data):
|
||||||
|
"""No nesting, and no nomenclature revealed by two different selectors."""
|
||||||
|
errors = []
|
||||||
|
seen_values = set()
|
||||||
|
|
||||||
|
for option in options_data:
|
||||||
|
value = option.get('value')
|
||||||
|
if value in seen_values:
|
||||||
|
errors.append(f"Duplicate option value '{value}'.")
|
||||||
|
seen_values.add(value)
|
||||||
|
|
||||||
|
for revealed in option.get('reveals', []):
|
||||||
|
if revealed.kind == Nomenclature.SELECTOR:
|
||||||
|
errors.append(
|
||||||
|
f"'{revealed.code}' is a selector; selectors cannot reveal "
|
||||||
|
f"other selectors."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
other = (
|
||||||
|
SelectorOption.objects
|
||||||
|
.filter(reveals=revealed)
|
||||||
|
.exclude(selector=self.instance)
|
||||||
|
.select_related('selector')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if other:
|
||||||
|
errors.append(
|
||||||
|
f"'{revealed.code}' is already revealed by selector "
|
||||||
|
f"'{other.selector.code}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise serializers.ValidationError({'selector_options': errors})
|
||||||
|
|
||||||
|
|
||||||
class ReportSerializer(serializers.ModelSerializer):
|
class ReportSerializer(serializers.ModelSerializer):
|
||||||
created_by_name = serializers.CharField(source='created_by.username', read_only=True, default=None)
|
created_by_name = serializers.CharField(source='created_by.username', read_only=True, default=None)
|
||||||
|
|||||||
@@ -188,12 +188,19 @@ def _assign_weighing(vehicle, value, user, manual=False, note=None):
|
|||||||
|
|
||||||
|
|
||||||
def _record_extra_combination(extra_data):
|
def _record_extra_combination(extra_data):
|
||||||
"""Record the lookup-only projection of a vehicle's extra data as a reusable combination."""
|
"""Record the lookup/selector projection of a vehicle's extra data as a reusable combination.
|
||||||
|
|
||||||
|
The selector value is part of the combination: without it, "Ship + Varna Star"
|
||||||
|
and "Warehouse + WH-3" would collapse into indistinguishable rows.
|
||||||
|
"""
|
||||||
if not extra_data:
|
if not extra_data:
|
||||||
return
|
return
|
||||||
|
|
||||||
lookup_codes = set(
|
lookup_codes = set(
|
||||||
Nomenclature.objects.filter(applies_to='vehicle', kind=Nomenclature.LOOKUP)
|
Nomenclature.objects.filter(
|
||||||
|
applies_to='vehicle',
|
||||||
|
kind__in=[Nomenclature.LOOKUP, Nomenclature.SELECTOR],
|
||||||
|
)
|
||||||
.values_list('code', flat=True)
|
.values_list('code', flat=True)
|
||||||
)
|
)
|
||||||
combo = {k: v for k, v in extra_data.items() if k in lookup_codes and v is not None}
|
combo = {k: v for k, v in extra_data.items() if k in lookup_codes and v is not None}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Nomenclature, NomenclatureField, NomenclatureEntry
|
from .models import Nomenclature, NomenclatureField, NomenclatureEntry, SelectorOption
|
||||||
|
|
||||||
|
|
||||||
class NomenclatureFieldInline(admin.TabularInline):
|
class NomenclatureFieldInline(admin.TabularInline):
|
||||||
@@ -7,12 +7,18 @@ class NomenclatureFieldInline(admin.TabularInline):
|
|||||||
extra = 1
|
extra = 1
|
||||||
|
|
||||||
|
|
||||||
|
class SelectorOptionInline(admin.TabularInline):
|
||||||
|
model = SelectorOption
|
||||||
|
extra = 1
|
||||||
|
filter_horizontal = ['reveals']
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Nomenclature)
|
@admin.register(Nomenclature)
|
||||||
class NomenclatureAdmin(admin.ModelAdmin):
|
class NomenclatureAdmin(admin.ModelAdmin):
|
||||||
list_display = ['code', 'name', 'applies_to', 'kind', 'sort_order']
|
list_display = ['code', 'name', 'applies_to', 'kind', 'sort_order']
|
||||||
list_filter = ['applies_to', 'kind']
|
list_filter = ['applies_to', 'kind']
|
||||||
search_fields = ['code', 'name']
|
search_fields = ['code', 'name']
|
||||||
inlines = [NomenclatureFieldInline]
|
inlines = [NomenclatureFieldInline, SelectorOptionInline]
|
||||||
|
|
||||||
|
|
||||||
@admin.register(NomenclatureEntry)
|
@admin.register(NomenclatureEntry)
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-09-18 16:26
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("nomenclatures", "0002_alter_nomenclature_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="nomenclature",
|
||||||
|
name="kind",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("lookup", "Lookup Table"),
|
||||||
|
("field", "Custom Field"),
|
||||||
|
("selector", "Selector"),
|
||||||
|
],
|
||||||
|
default="lookup",
|
||||||
|
max_length=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SelectorOption",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("value", models.CharField(max_length=50)),
|
||||||
|
("label", models.CharField(max_length=255)),
|
||||||
|
("sort_order", models.IntegerField(default=0)),
|
||||||
|
(
|
||||||
|
"reveals",
|
||||||
|
models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
related_name="revealed_by",
|
||||||
|
to="nomenclatures.nomenclature",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"selector",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="selector_options",
|
||||||
|
to="nomenclatures.nomenclature",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
"ordering": ["sort_order", "id"],
|
||||||
|
"unique_together": {("selector", "value")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -11,9 +11,11 @@ class Nomenclature(models.Model):
|
|||||||
|
|
||||||
LOOKUP = "lookup"
|
LOOKUP = "lookup"
|
||||||
FIELD = "field"
|
FIELD = "field"
|
||||||
|
SELECTOR = "selector"
|
||||||
KIND_CHOICES = [
|
KIND_CHOICES = [
|
||||||
(LOOKUP, "Lookup Table"),
|
(LOOKUP, "Lookup Table"),
|
||||||
(FIELD, "Custom Field"),
|
(FIELD, "Custom Field"),
|
||||||
|
(SELECTOR, "Selector"),
|
||||||
]
|
]
|
||||||
|
|
||||||
code = models.CharField(max_length=50, unique=True)
|
code = models.CharField(max_length=50, unique=True)
|
||||||
@@ -35,6 +37,31 @@ class Nomenclature(models.Model):
|
|||||||
return f"{self.name} ({self.code})"
|
return f"{self.name} ({self.code})"
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
label = models.CharField(max_length=255)
|
||||||
|
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")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.selector.code}={self.value}"
|
||||||
|
|
||||||
|
|
||||||
class NomenclatureField(models.Model):
|
class NomenclatureField(models.Model):
|
||||||
TEXT = "text"
|
TEXT = "text"
|
||||||
NUMBER = "number"
|
NUMBER = "number"
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""The one rule deciding which nomenclatures are visible for a given extra_data.
|
||||||
|
|
||||||
|
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 frontend/src/utils/nomenclatureVisibility.js - keep the two in step.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .models import Nomenclature, SelectorOption
|
||||||
|
|
||||||
|
|
||||||
|
def build_reveal_map(nomenclatures=None):
|
||||||
|
"""{revealed_code: {selector_code: {value, ...}}} for the given nomenclatures."""
|
||||||
|
options = (
|
||||||
|
SelectorOption.objects
|
||||||
|
.select_related('selector')
|
||||||
|
.prefetch_related('reveals')
|
||||||
|
)
|
||||||
|
if nomenclatures is not None:
|
||||||
|
codes = {n.code for n in nomenclatures}
|
||||||
|
options = [o for o in options if o.selector.code in codes]
|
||||||
|
|
||||||
|
reveal_map = {}
|
||||||
|
for option in options:
|
||||||
|
for revealed in option.reveals.all():
|
||||||
|
reveal_map.setdefault(revealed.code, {}).setdefault(
|
||||||
|
option.selector.code, set()
|
||||||
|
).add(option.value)
|
||||||
|
return reveal_map
|
||||||
|
|
||||||
|
|
||||||
|
def is_visible(code, extra_data, reveal_map):
|
||||||
|
"""True if the nomenclature `code` should be shown/accepted for this data."""
|
||||||
|
controllers = reveal_map.get(code)
|
||||||
|
if not controllers:
|
||||||
|
return True
|
||||||
|
for selector_code, revealing_values in controllers.items():
|
||||||
|
if extra_data.get(selector_code) in revealing_values:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def visible_codes(codes, extra_data, reveal_map=None):
|
||||||
|
"""Filter `codes` down to those visible for `extra_data`."""
|
||||||
|
if reveal_map is None:
|
||||||
|
reveal_map = build_reveal_map()
|
||||||
|
return [c for c in codes if is_visible(c, extra_data or {}, reveal_map)]
|
||||||
@@ -16,8 +16,19 @@ export default function ExtraCombinationsTable({ nomenclatures, onApply }) {
|
|||||||
const subscribeRef = useRef(subscribeToUpdates);
|
const subscribeRef = useRef(subscribeToUpdates);
|
||||||
subscribeRef.current = subscribeToUpdates;
|
subscribeRef.current = subscribeToUpdates;
|
||||||
|
|
||||||
// Only lookup nomenclatures form a combination
|
// Lookups and selectors form a combination: the selector value is what makes
|
||||||
const lookupNomenclatures = nomenclatures.filter(n => n.kind === 'lookup');
|
// "Ship + cargo" and "Warehouse + cargo" distinct rows rather than one.
|
||||||
|
const lookupNomenclatures = nomenclatures.filter(
|
||||||
|
n => n.kind === 'lookup' || n.kind === 'selector'
|
||||||
|
);
|
||||||
|
|
||||||
|
// A selector stores its option value, not an entry id, so it resolves to the
|
||||||
|
// option's label instead of going through getDisplayValue.
|
||||||
|
const cellLabel = (nom, value) => {
|
||||||
|
if (nom.kind !== 'selector') return getDisplayValue(nom.code, value);
|
||||||
|
const option = (nom.selector_options || []).find(o => o.value === value);
|
||||||
|
return option ? option.label : '';
|
||||||
|
};
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -91,7 +102,7 @@ export default function ExtraCombinationsTable({ nomenclatures, onApply }) {
|
|||||||
>
|
>
|
||||||
{lookupNomenclatures.map(nom => {
|
{lookupNomenclatures.map(nom => {
|
||||||
const entryId = combo.data[nom.code];
|
const entryId = combo.data[nom.code];
|
||||||
const label = entryId != null ? getDisplayValue(nom.code, entryId) : '';
|
const label = entryId != null ? cellLabel(nom, entryId) : '';
|
||||||
return (
|
return (
|
||||||
<td key={nom.code}>
|
<td key={nom.code}>
|
||||||
{entryId != null && !label
|
{entryId != null && !label
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { parseReading } from '../services/scaleProtocols';
|
|||||||
import NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown';
|
import NomenclatureDropdown from './NomenclatureUI/NomenclatureDropdown';
|
||||||
import ReportPreviewModal from './ReportPreviewModal';
|
import ReportPreviewModal from './ReportPreviewModal';
|
||||||
import ExtraCombinationsTable from './ExtraCombinationsTable';
|
import ExtraCombinationsTable from './ExtraCombinationsTable';
|
||||||
|
import { visibleNomenclatures, codesRevealedBy } from '../services/nomenclatureVisibility';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import './Main.css';
|
import './Main.css';
|
||||||
import './License/LicenseManager.css';
|
import './License/LicenseManager.css';
|
||||||
@@ -44,6 +45,45 @@ export default function Main() {
|
|||||||
);
|
);
|
||||||
const [extraData, setExtraData] = useState({});
|
const [extraData, setExtraData] = useState({});
|
||||||
|
|
||||||
|
// A selector's value decides which other nomenclatures appear, so the visible
|
||||||
|
// set is derived per panel from that panel's own extra data.
|
||||||
|
const visibleForSelected = useMemo(
|
||||||
|
() => visibleNomenclatures(vehicleNomenclatures, definitions, extraData),
|
||||||
|
[vehicleNomenclatures, definitions, extraData]
|
||||||
|
);
|
||||||
|
const visibleForNew = useMemo(
|
||||||
|
() => visibleNomenclatures(vehicleNomenclatures, definitions, newExtraData),
|
||||||
|
[vehicleNomenclatures, definitions, newExtraData]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Switching a selector clears whatever its PREVIOUS choice revealed, so a
|
||||||
|
// vehicle never carries data contradicting its own selector. Clearing on the
|
||||||
|
// previous value (rather than diffing visible sets) keeps a nomenclature that
|
||||||
|
// both choices reveal from being needlessly emptied.
|
||||||
|
const applySelectorChange = (setter) => (code, newValue) => {
|
||||||
|
setter(prev => {
|
||||||
|
const next = { ...prev, [code]: newValue };
|
||||||
|
if (definitions[code]?.kind === 'selector') {
|
||||||
|
for (const revealed of codesRevealedBy(definitions, code, prev[code])) {
|
||||||
|
delete next[revealed];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newValue === undefined) delete next[code];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleExtraChange = applySelectorChange(setExtraData);
|
||||||
|
const handleNewExtraChange = applySelectorChange(setNewExtraData);
|
||||||
|
|
||||||
|
// Never send a value the selector state hides - the serializer rejects those,
|
||||||
|
// and stale keys can survive on vehicles that predate the selector.
|
||||||
|
const prunedExtra = (data, visible) => {
|
||||||
|
const allowed = new Set(visible.map(n => n.code));
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(data).filter(([code]) => allowed.has(code))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Current reading from the serial bridge. The bridge is pure transport - how
|
// Current reading from the serial bridge. The bridge is pure transport - how
|
||||||
// to read a line comes from the station protocol definition, so a new scale
|
// to read a line comes from the station protocol definition, so a new scale
|
||||||
// type never requires updating the bridge on the station PC.
|
// type never requires updating the bridge on the station PC.
|
||||||
@@ -128,9 +168,10 @@ export default function Main() {
|
|||||||
driver_pid: newForm.driver_pid.trim() || null,
|
driver_pid: newForm.driver_pid.trim() || null,
|
||||||
};
|
};
|
||||||
// Include extra data if any values set
|
// Include extra data if any values set
|
||||||
const hasExtra = Object.values(newExtraData).some(v => v !== undefined && v !== null && v !== '');
|
const visibleNewExtra = prunedExtra(newExtraData, visibleForNew);
|
||||||
|
const hasExtra = Object.values(visibleNewExtra).some(v => v !== undefined && v !== null && v !== '');
|
||||||
if (hasExtra) {
|
if (hasExtra) {
|
||||||
payload.extra = { data: newExtraData };
|
payload.extra = { data: visibleNewExtra };
|
||||||
}
|
}
|
||||||
const res = await api.post('/api/vehicles/', payload);
|
const res = await api.post('/api/vehicles/', payload);
|
||||||
setSelectedVehicleId(res.data.id);
|
setSelectedVehicleId(res.data.id);
|
||||||
@@ -197,7 +238,7 @@ export default function Main() {
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await api.patch(`/api/vehicles/${selectedVehicle.id}/`, {
|
await api.patch(`/api/vehicles/${selectedVehicle.id}/`, {
|
||||||
extra: { data: extraData },
|
extra: { data: prunedExtra(extraData, visibleForSelected) },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const data = err.response?.data;
|
const data = err.response?.data;
|
||||||
@@ -208,13 +249,24 @@ export default function Main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Apply a saved combination - merge so non-lookup field values are preserved
|
// Apply a saved combination - merge so non-lookup field values are preserved
|
||||||
const handleApplyCombination = (data) => {
|
// A combination carries its selector value, so applying one must also drop
|
||||||
setExtraData(prev => ({ ...prev, ...data }));
|
// whatever the previous selection revealed - otherwise a stale field survives
|
||||||
};
|
// the merge and the payload contradicts the new source.
|
||||||
|
const applyCombination = (setter) => (data) => {
|
||||||
const handleApplyCombinationNew = (data) => {
|
setter(prev => {
|
||||||
setNewExtraData(prev => ({ ...prev, ...data }));
|
const next = { ...prev, ...data };
|
||||||
|
for (const [code, nom] of Object.entries(definitions)) {
|
||||||
|
if (nom.kind !== 'selector') continue;
|
||||||
|
if (!(code in data) || data[code] === prev[code]) continue;
|
||||||
|
for (const revealed of codesRevealedBy(definitions, code, prev[code])) {
|
||||||
|
if (!(revealed in data)) delete next[revealed];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
const handleApplyCombination = applyCombination(setExtraData);
|
||||||
|
const handleApplyCombinationNew = applyCombination(setNewExtraData);
|
||||||
|
|
||||||
// Format date
|
// Format date
|
||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
@@ -385,15 +437,12 @@ export default function Main() {
|
|||||||
onApply={handleApplyCombinationNew}
|
onApply={handleApplyCombinationNew}
|
||||||
/>
|
/>
|
||||||
<div className="extra-fields">
|
<div className="extra-fields">
|
||||||
{vehicleNomenclatures.map(nom => (
|
{visibleForNew.map(nom => (
|
||||||
<div key={nom.code} className="extra-field">
|
<div key={nom.code} className="extra-field">
|
||||||
<NomenclatureDropdown
|
<NomenclatureDropdown
|
||||||
nomenclatureCode={nom.code}
|
nomenclatureCode={nom.code}
|
||||||
value={newExtraData[nom.code]}
|
value={newExtraData[nom.code]}
|
||||||
onChange={val => setNewExtraData(prev => ({
|
onChange={val => handleNewExtraChange(nom.code, val)}
|
||||||
...prev,
|
|
||||||
[nom.code]: val,
|
|
||||||
}))}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -563,15 +612,12 @@ export default function Main() {
|
|||||||
onApply={handleApplyCombination}
|
onApply={handleApplyCombination}
|
||||||
/>
|
/>
|
||||||
<div className="extra-fields">
|
<div className="extra-fields">
|
||||||
{vehicleNomenclatures.map(nom => (
|
{visibleForSelected.map(nom => (
|
||||||
<div key={nom.code} className="extra-field">
|
<div key={nom.code} className="extra-field">
|
||||||
<NomenclatureDropdown
|
<NomenclatureDropdown
|
||||||
nomenclatureCode={nom.code}
|
nomenclatureCode={nom.code}
|
||||||
value={extraData[nom.code]}
|
value={extraData[nom.code]}
|
||||||
onChange={val => setExtraData(prev => ({
|
onChange={val => handleExtraChange(nom.code, val)}
|
||||||
...prev,
|
|
||||||
[nom.code]: val,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -624,3 +624,78 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Selector options editor --- */
|
||||||
|
.nm-options-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #888;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nm-option-row {
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nm-option-head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 32px;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nm-option-head input[type="text"] {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nm-option-head input[type="text"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nm-option-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #c0392b;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nm-option-remove:disabled {
|
||||||
|
color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nm-option-reveals {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nm-option-reveals-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nm-option-reveal {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const EMPTY_FORM = {
|
|||||||
display_field: 'name',
|
display_field: 'name',
|
||||||
sort_order: 0,
|
sort_order: 0,
|
||||||
fields: [{ key: 'name', label: 'Name', field_type: 'text', required: true, choices: null }],
|
fields: [{ key: 'name', label: 'Name', field_type: 'text', required: true, choices: null }],
|
||||||
|
selector_options: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function NomenclatureManager({ onClose }) {
|
function NomenclatureManager({ onClose }) {
|
||||||
@@ -61,6 +62,12 @@ function NomenclatureManager({ onClose }) {
|
|||||||
required: f.required || false,
|
required: f.required || false,
|
||||||
choices: f.choices || null,
|
choices: f.choices || null,
|
||||||
})),
|
})),
|
||||||
|
selector_options: (nom.selector_options || []).map(o => ({
|
||||||
|
value: o.value,
|
||||||
|
label: o.label || '',
|
||||||
|
reveals: o.reveals || [],
|
||||||
|
sort_order: o.sort_order || 0,
|
||||||
|
})),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,7 +75,7 @@ function NomenclatureManager({ onClose }) {
|
|||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
setSelectedId(null);
|
setSelectedId(null);
|
||||||
setError('');
|
setError('');
|
||||||
setForm({ ...EMPTY_FORM, fields: [{ ...EMPTY_FORM.fields[0] }] });
|
setForm({ ...EMPTY_FORM, fields: [{ ...EMPTY_FORM.fields[0] }], selector_options: [] });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cancel editing
|
// Cancel editing
|
||||||
@@ -98,6 +105,14 @@ function NomenclatureManager({ onClose }) {
|
|||||||
required: f.required,
|
required: f.required,
|
||||||
choices: f.field_type === 'choice' ? f.choices : null,
|
choices: f.field_type === 'choice' ? f.choices : null,
|
||||||
})),
|
})),
|
||||||
|
selector_options: form.kind === 'selector'
|
||||||
|
? form.selector_options.map((o, idx) => ({
|
||||||
|
value: o.value,
|
||||||
|
label: o.label,
|
||||||
|
reveals: o.reveals,
|
||||||
|
sort_order: idx,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -144,6 +159,65 @@ function NomenclatureManager({ onClose }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Selector option management ---
|
||||||
|
const addOption = () => {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
selector_options: [...prev.selector_options, { value: '', label: '', reveals: [], sort_order: prev.selector_options.length }],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOption = (index, key, value) => {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
selector_options: prev.selector_options.map((o, i) => i === index ? { ...o, [key]: value } : o),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeOption = (index) => {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
selector_options: prev.selector_options.filter((_, i) => i !== index),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleReveal = (index, code) => {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
selector_options: prev.selector_options.map((o, i) => {
|
||||||
|
if (i !== index) return o;
|
||||||
|
const reveals = o.reveals.includes(code)
|
||||||
|
? o.reveals.filter(c => c !== code)
|
||||||
|
: [...o.reveals, code];
|
||||||
|
return { ...o, reveals };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Revealable = same scope, not a selector (no nesting), and not already claimed
|
||||||
|
// by a different selector. Mirrors the server-side rule so the UI cannot build
|
||||||
|
// a configuration the API will reject.
|
||||||
|
const revealableFor = (optionIndex) => {
|
||||||
|
const claimedElsewhere = new Set();
|
||||||
|
for (const nom of nomenclatures) {
|
||||||
|
if (nom.kind !== 'selector' || nom.id === selectedId) continue;
|
||||||
|
for (const opt of nom.selector_options || []) {
|
||||||
|
for (const code of opt.reveals || []) claimedElsewhere.add(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const claimedHere = new Set();
|
||||||
|
(form?.selector_options || []).forEach((o, i) => {
|
||||||
|
if (i !== optionIndex) for (const code of o.reveals || []) claimedHere.add(code);
|
||||||
|
});
|
||||||
|
|
||||||
|
return nomenclatures.filter(n =>
|
||||||
|
n.kind !== 'selector' &&
|
||||||
|
n.applies_to === form?.applies_to &&
|
||||||
|
!claimedElsewhere.has(n.code) &&
|
||||||
|
!claimedHere.has(n.code)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// --- Field management ---
|
// --- Field management ---
|
||||||
const addField = () => {
|
const addField = () => {
|
||||||
setForm(prev => ({
|
setForm(prev => ({
|
||||||
@@ -262,7 +336,7 @@ function NomenclatureManager({ onClose }) {
|
|||||||
<div className="nm-list-item-info">
|
<div className="nm-list-item-info">
|
||||||
<span className="nm-list-item-name">{nom.name}</span>
|
<span className="nm-list-item-name">{nom.name}</span>
|
||||||
<span className="nm-list-item-meta">
|
<span className="nm-list-item-meta">
|
||||||
{nom.kind === 'lookup' ? 'Lookup' : 'Field'} / {nom.applies_to}
|
{nom.kind === 'lookup' ? 'Lookup' : nom.kind === 'selector' ? 'Selector' : 'Field'} / {nom.applies_to}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
@@ -342,6 +416,7 @@ function NomenclatureManager({ onClose }) {
|
|||||||
>
|
>
|
||||||
<option value="lookup">Lookup Table</option>
|
<option value="lookup">Lookup Table</option>
|
||||||
<option value="field">Custom Field</option>
|
<option value="field">Custom Field</option>
|
||||||
|
<option value="selector">Selector</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -361,7 +436,74 @@ function NomenclatureManager({ onClose }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Options section - a selector's choices replace the field editor */}
|
||||||
|
{form.kind === 'selector' && (
|
||||||
|
<div className="nm-section">
|
||||||
|
<div className="nm-section-header">
|
||||||
|
<h3>Options</h3>
|
||||||
|
{canEdit && (
|
||||||
|
<button className="nm-section-add" onClick={addOption} disabled={isSaving}>
|
||||||
|
+ Add Option
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.selector_options.length === 0 && (
|
||||||
|
<p className="nm-options-empty">
|
||||||
|
No options yet. Each option is a choice the operator can pick,
|
||||||
|
and the nomenclatures it reveals appear beneath it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.selector_options.map((opt, idx) => (
|
||||||
|
<div key={idx} className="nm-option-row">
|
||||||
|
<div className="nm-option-head">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Label (e.g. Ship)"
|
||||||
|
value={opt.label}
|
||||||
|
onChange={e => updateOption(idx, 'label', e.target.value)}
|
||||||
|
disabled={isSaving || !canEdit}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Value (e.g. ship)"
|
||||||
|
value={opt.value}
|
||||||
|
onChange={e => updateOption(idx, 'value', e.target.value)}
|
||||||
|
disabled={isSaving || !canEdit}
|
||||||
|
/>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
className="nm-option-remove"
|
||||||
|
onClick={() => removeOption(idx)}
|
||||||
|
disabled={isSaving}
|
||||||
|
title="Remove option"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="nm-option-reveals">
|
||||||
|
<span className="nm-option-reveals-label">Reveals:</span>
|
||||||
|
{revealableFor(idx).map(n => (
|
||||||
|
<label key={n.code} className="nm-option-reveal">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={opt.reveals.includes(n.code)}
|
||||||
|
onChange={() => toggleReveal(idx, n.code)}
|
||||||
|
disabled={isSaving || !canEdit}
|
||||||
|
/>
|
||||||
|
{n.name}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Fields section */}
|
{/* Fields section */}
|
||||||
|
{form.kind !== 'selector' && (
|
||||||
<div className="nm-section">
|
<div className="nm-section">
|
||||||
<div className="nm-section-header">
|
<div className="nm-section-header">
|
||||||
<h3>Fields</h3>
|
<h3>Fields</h3>
|
||||||
@@ -424,6 +566,7 @@ function NomenclatureManager({ onClose }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="nm-form-actions">
|
<div className="nm-form-actions">
|
||||||
|
|||||||
@@ -35,6 +35,33 @@ export default function NomenclatureDropdown({
|
|||||||
|
|
||||||
const displayLabel = label || nomenclature.name;
|
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 (
|
||||||
|
<div className="nui-dropdown">
|
||||||
|
<label className="nui-dropdown-label">{displayLabel}</label>
|
||||||
|
<select
|
||||||
|
className="nui-dropdown-select"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => onChange(e.target.value || undefined)}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<option value="">-- Select --</option>
|
||||||
|
{options.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
{isUnknown && (
|
||||||
|
<option value={value}>{value} (unknown)</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// LOOKUP type: select dropdown + manage button
|
// LOOKUP type: select dropdown + manage button
|
||||||
if (nomenclature.kind === 'lookup') {
|
if (nomenclature.kind === 'lookup') {
|
||||||
const handleSelectChange = (e) => {
|
const handleSelectChange = (e) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user