Fix cross-model queue items being misrepresented and editable into a broken state (#671)

The edit dialog offered a printer picker and target-model dropdown for
an item with alternatives. Saving left a row with variants AND a
printer_id, and the scheduler's fixed-printer branch wins that race, so
it dispatched a row whose library_file_id is still null and failed in
the upload. PATCH now refuses printer/model changes on such an item —
comparing against the current value, since the dialog re-sends
target_model unchanged — and the route eager-loads variants, without
which the guard could not see them and every PATCH response dropped the
alternatives from its payload.

Names come from a shared helper now. A cross-model item holds neither
archive_id nor library_file_id until dispatch, so five separate inlined
fallbacks all rendered "File #null"; they now read "x1c.gcode.3mf +1
more".

The queue also grouped these under "Any H2D" — the first candidate
mirrored onto the row — filing a job under a printer it might never run
on. It groups as "Any H2D / X1C", matching the row beneath it.
This commit is contained in:
maziggy
2026-08-03 12:33:19 +02:00
parent ef7c1b21f1
commit ea63355fde
25 changed files with 593 additions and 198 deletions
@@ -17,6 +17,11 @@ interface VariantCandidatesProps {
/** file id -> chosen plate, for the multi-plate candidates only. */
plateByFile: Record<number, number | null>;
onPlateChange: (fileId: number, plateId: number | null) => void;
/** Show the set without offering to change it — the edit-queue-item case,
* where reordering would need a variant-level API that doesn't exist. */
readOnly?: boolean;
/** Replaces the help line under the heading when read-only. */
readOnlyNote?: string;
}
/**
@@ -40,15 +45,21 @@ export function VariantCandidates({
onReorder,
plateByFile,
onPlateChange,
readOnly = false,
readOnlyNote,
}: VariantCandidatesProps) {
const { t } = useTranslation();
const plateQueries = useQueries({
queries: candidates.map((c) => ({
queryKey: ['library-file-plates', c.id],
queryFn: () => api.getLibraryFilePlates(c.id),
staleTime: 60_000,
})),
// Read-only mode shows a job that is already queued — its plates were
// chosen when it was created, so there is nothing to fetch or offer.
queries: readOnly
? []
: candidates.map((c) => ({
queryKey: ['library-file-plates', c.id],
queryFn: () => api.getLibraryFilePlates(c.id),
staleTime: 60_000,
})),
});
const platesByFile = useMemo(() => {
@@ -76,7 +87,9 @@ export function VariantCandidates({
<PrinterIcon className="w-4 h-4 text-bambu-gray" />
<span className="text-sm text-bambu-gray">{t('printModal.variants.title')}</span>
</div>
<p className="text-xs text-bambu-gray mb-2">{t('printModal.variants.help')}</p>
<p className="text-xs text-bambu-gray mb-2">
{readOnly ? (readOnlyNote ?? t('printModal.variants.help')) : t('printModal.variants.help')}
</p>
<div className="space-y-2">
{candidates.map((candidate, index) => {
@@ -116,26 +129,28 @@ export function VariantCandidates({
</label>
)}
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => move(index, index - 1)}
disabled={index === 0}
className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
aria-label={t('printModal.variants.moveUp')}
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => move(index, index + 1)}
disabled={index === candidates.length - 1}
className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
aria-label={t('printModal.variants.moveDown')}
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
{!readOnly && (
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => move(index, index - 1)}
disabled={index === 0}
className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
aria-label={t('printModal.variants.moveUp')}
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => move(index, index + 1)}
disabled={index === candidates.length - 1}
className="p-1 rounded text-bambu-gray hover:text-white disabled:opacity-30"
aria-label={t('printModal.variants.moveDown')}
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
})}
+70 -3
View File
@@ -73,6 +73,21 @@ export function PrintModal({
// Cross-model alternatives (#671). One candidate is not a choice, so a
// single-entry list behaves exactly like an ordinary print.
const isCrossModel = mode === 'create' && (variantFiles?.length ?? 0) > 1;
// Editing an already-queued cross-model item. The candidates are shown so the
// dialog doesn't misrepresent the job as a plain "Any H2D" — which is what it
// did before, offering a printer picker whose Save would have left a row with
// both variants and a printer_id. They are not editable here: changing the
// set after queueing needs a variant-level API that doesn't exist, and the
// backend refuses the printer/model change either way.
const editingVariants: VariantCandidate[] =
mode === 'edit-queue-item' && (queueItem?.variants?.length ?? 0) > 1
? queueItem!.variants!.map((v) => ({
id: v.library_file_id,
filename: v.filename,
sliced_for_model: v.target_model,
}))
: [];
const hasEditingVariants = editingVariants.length > 0;
const [candidates, setCandidates] = useState<VariantCandidate[]>(variantFiles ?? []);
const [candidatePlates, setCandidatePlates] = useState<Record<number, number | null>>({});
@@ -398,6 +413,42 @@ export function PrintModal({
enabled: assignmentMode === 'model' && !!targetModel,
});
// A cross-model job (#671) has no single target model, so the query above is
// disabled and the override UI would silently vanish — leaving less control
// than the ordinary "Any X1C" flow offers. Ask each candidate's model instead
// and offer the union: the job can land on any of them, so anything loaded on
// any of them is a legitimate choice. Picking one only some models have is
// allowed and meaningful — it narrows which candidates can match.
const candidateModels = useMemo(
() => Array.from(new Set(candidates.map((c) => c.sliced_for_model).filter((m): m is string => !!m))),
[candidates],
);
const candidateFilamentQueries = useQueries({
queries: isCrossModel
? candidateModels.map((model) => ({
queryKey: ['available-filaments', model, targetLocation],
queryFn: () => api.getAvailableFilaments(model, targetLocation ?? undefined),
}))
: [],
});
const crossModelFilaments = useMemo(() => {
const seen = new Set<string>();
const merged: NonNullable<typeof availableFilaments> = [];
for (const query of candidateFilamentQueries) {
for (const filament of query.data ?? []) {
// Same type+colour loaded on two models is one choice, not two.
const key = `${filament.type}|${filament.color}|${filament.tray_info_idx}`;
if (!seen.has(key)) {
seen.add(key);
merged.push(filament);
}
}
}
return merged;
}, [candidateFilamentQueries]);
const effectiveAvailableFilaments = isCrossModel ? crossModelFilaments : availableFilaments;
// Only fetch printer status when single printer selected (for filament mapping)
const { data: printerStatus, isLoading: printerStatusLoading } = useQuery({
queryKey: ['printer-status', effectivePrinterId],
@@ -1261,8 +1312,13 @@ export function PrintModal({
// is the filament each slot must be printed in, which the scheduler matches
// against whatever printer of the model it picks. Needs the model's loaded
// filaments to offer as alternatives.
// Cross-model items have no targetModel by design — their candidates each
// carry their own — so gate on having somewhere to source choices from.
const showFilamentOverride =
assignmentMode === 'model' && !!targetModel && !!availableFilaments && availableFilaments.length > 0;
assignmentMode === 'model'
&& (isCrossModel || !!targetModel)
&& !!effectiveAvailableFilaments
&& effectiveAvailableFilaments.length > 0;
// Dual-nozzle gate for the Nozzle Offset Calibration toggle (#1682).
// Mirrors backend `DUAL_NOZZLE_MODELS` so model-based assignment can show
@@ -1371,8 +1427,19 @@ export function PrintModal({
/>
)}
{hasEditingVariants && (
<VariantCandidates
candidates={editingVariants}
readOnly
readOnlyNote={t('printModal.variants.editNote')}
onReorder={() => {}}
plateByFile={{}}
onPlateChange={() => {}}
/>
)}
{/* Printer selection with per-printer mapping — hidden when printer is pre-selected via props */}
{!isCrossModel && !initialSelectedPrinterIds?.length && (
{!isCrossModel && !hasEditingVariants && !initialSelectedPrinterIds?.length && (
<PrinterSelector
printers={printers || []}
selectedPrinterIds={selectedPrinters}
@@ -1404,7 +1471,7 @@ export function PrintModal({
{showFilamentOverride && !isMultiPlateSelection && effectiveFilamentReqs && (
<FilamentOverride
filamentReqs={effectiveFilamentReqs}
availableFilaments={availableFilaments!}
availableFilaments={effectiveAvailableFilaments!}
overrides={filamentOverrides}
onChange={setFilamentOverrides}
forceColorMatch={forceColorMatch}