Files
scalesApp/frontend/src/components/ExtraCombinationsTable.jsx
T
gitea d10b1d263f 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>
2026-09-18 19:43:13 +03:00

133 lines
5.7 KiB
React

import { useState, useEffect, useRef, useCallback } from 'react';
import { useNomenclatures } from '../contexts/NomenclatureContext';
import { useNomenclatureData } from '../contexts/NomenclatureDataContext';
import api from '../services/api';
import './ExtraCombinationsTable.css';
export default function ExtraCombinationsTable({ nomenclatures, onApply }) {
const { subscribeToUpdates } = useNomenclatures();
const { getDisplayValue } = useNomenclatureData();
const [combinations, setCombinations] = useState([]);
const [error, setError] = useState('');
// subscribeToUpdates is not memoized in NomenclatureContext - hold it in a ref
// so the SSE effect below runs once instead of on every provider render.
const subscribeRef = useRef(subscribeToUpdates);
subscribeRef.current = subscribeToUpdates;
// Lookups and selectors form a combination: the selector value is what makes
// "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 () => {
try {
const res = await api.get('/api/extra-combinations/');
setCombinations(res.data.results || res.data);
} catch {
setError('Failed to load saved combinations');
}
}, []);
useEffect(() => { load(); }, [load]);
// Realtime: rows are created/bumped by the backend when extra data is saved
useEffect(() => {
const unsubscribe = subscribeRef.current((message) => {
if (message.object !== 'extra_combination') return;
const { operation, data } = message;
setCombinations(prev => {
if (operation === 'delete') {
return prev.filter(c => c.id !== data.id);
}
// insert or update: drop any existing copy, put it on top (most recent first)
return [data, ...prev.filter(c => c.id !== data.id)];
});
});
return unsubscribe;
}, []);
const handleDelete = async (e, id) => {
e.stopPropagation();
if (!window.confirm('Delete this combination?')) return;
setError('');
try {
await api.delete(`/api/extra-combinations/${id}/`);
} catch {
setError('Failed to delete combination');
}
};
if (lookupNomenclatures.length === 0) return null;
return (
<div className="extra-combos">
<h3>Previously Used</h3>
{error && <div className="extra-combos-error">{error}</div>}
<div className="extra-combos-table-container">
<table className="extra-combos-table">
<thead>
<tr>
{lookupNomenclatures.map(nom => (
<th key={nom.code}>{nom.name}</th>
))}
<th className="extra-combos-th-actions"></th>
</tr>
</thead>
<tbody>
{combinations.length === 0 ? (
<tr>
<td colSpan={lookupNomenclatures.length + 1} className="extra-combos-empty">
No saved combinations yet
</td>
</tr>
) : (
combinations.map(combo => (
<tr
key={combo.id}
className="extra-combos-row"
onClick={() => onApply(combo.data)}
title="Click to fill the fields below"
>
{lookupNomenclatures.map(nom => {
const entryId = combo.data[nom.code];
const label = entryId != null ? cellLabel(nom, entryId) : '';
return (
<td key={nom.code}>
{entryId != null && !label
? <span className="extra-combos-stale">#{entryId}</span>
: label}
</td>
);
})}
<td className="extra-combos-td-actions">
<button
className="extra-combos-delete-btn"
onClick={(e) => handleDelete(e, combo.id)}
title="Delete combination"
type="button"
>
&times;
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}