Kaynağa Gözat

fix(queue): surface force-color-match checkbox in specific-printer mode (#1717)

  The Print Queue's schedule dialog hid the per-slot "Force color match"
  checkbox when a specific printer was picked, even though the scheduler
  in print_scheduler.py:535 honours force_color_match regardless of how
  the queue item was created. Model-mode ("Any A1") rendered
  FilamentOverride which carries the checkbox; printer-mode rendered
  FilamentMapping which had no force-match UI at all. Pure UI gap.

  Extend FilamentMapping to accept optional forceColorMatch +
  onForceColorMatchChange props, mirroring FilamentOverride's signature,
  and render the same <Palette>-iconed checkbox under each filament row
  when a handler is wired. PrintModal/index.tsx passes the existing
  forceColorMatch state through — same object both modes write into so
  toggling between modes preserves the user's selection. No new i18n
  keys (printModal.forceColorMatch already ships in all 11 locales).
maziggy 2 ay önce
ebeveyn
işleme
3b1395e3c0

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
CHANGELOG.md


+ 83 - 1
frontend/src/__tests__/components/FilamentMapping.test.tsx

@@ -10,7 +10,7 @@
  */
 
 import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
-import { screen, waitFor, cleanup } from '@testing-library/react';
+import { screen, waitFor, cleanup, fireEvent } from '@testing-library/react';
 import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
 import { server } from '../mocks/server';
@@ -111,6 +111,88 @@ describe('FilamentMapping — FTS routing', () => {
     expect(plaOption.textContent).not.toMatch(/\[[LR]\]/);
   });
 
+  it('renders the per-slot force-color-match checkbox in printer mode (#1717)', async () => {
+    // Specific-printer assignment used to render FilamentMapping with no
+    // force-color-match UI even though the dispatcher honours the flag. Pin
+    // that the checkbox is now mounted and bubbles toggle events up.
+    server.use(
+      http.get(
+        '/api/v1/printers/:id/status',
+        () =>
+          HttpResponse.json(
+            createStatus({
+              fila_switch: null,
+              ams_extruder_map: { '0': 1 },  // AMS 0 → left nozzle, matching the requirement
+            }),
+          ),
+      ),
+    );
+
+    const onForceColorMatchChange = vi.fn();
+    render(
+      <FilamentMapping
+        printerId={1}
+        filamentReqs={mockFilamentReqs}
+        manualMappings={{}}
+        onManualMappingChange={() => {}}
+        currencySymbol="$"
+        defaultCostPerKg={0}
+        defaultExpanded
+        forceColorMatch={{}}
+        onForceColorMatchChange={onForceColorMatchChange}
+      />,
+    );
+
+    const checkbox = await waitFor(() => {
+      const cb = screen.getByLabelText(/Force color match/i) as HTMLInputElement;
+      expect(cb).toBeInTheDocument();
+      return cb;
+    });
+    expect(checkbox.checked).toBe(false);
+
+    fireEvent.click(checkbox);
+    expect(onForceColorMatchChange).toHaveBeenCalledTimes(1);
+    expect(onForceColorMatchChange).toHaveBeenCalledWith(1, true);
+  });
+
+  it('omits the force-color-match checkbox when no handler is provided', async () => {
+    // The checkbox is only meaningful when the caller is wired to persist the
+    // toggle; absent a handler we must not render dead UI.
+    server.use(
+      http.get(
+        '/api/v1/printers/:id/status',
+        () =>
+          HttpResponse.json(
+            createStatus({
+              fila_switch: null,
+              ams_extruder_map: { '0': 1 },
+            }),
+          ),
+      ),
+    );
+
+    render(
+      <FilamentMapping
+        printerId={1}
+        filamentReqs={mockFilamentReqs}
+        manualMappings={{}}
+        onManualMappingChange={() => {}}
+        currencySymbol="$"
+        defaultCostPerKg={0}
+        defaultExpanded
+      />,
+    );
+
+    // Wait for the panel to finish mounting (Re-read button only renders once
+    // printer status has loaded and the expanded view is open) before asserting
+    // the checkbox is absent — otherwise the queryByLabelText would pass
+    // trivially during the loading window.
+    await waitFor(() => {
+      expect(screen.getByText(/Re-read/i)).toBeInTheDocument();
+    });
+    expect(screen.queryByLabelText(/Force color match/i)).not.toBeInTheDocument();
+  });
+
   it('still applies the per-nozzle filter when FTS is null', async () => {
     server.use(
       http.get(

+ 105 - 82
frontend/src/components/PrintModal/FilamentMapping.tsx

@@ -1,7 +1,7 @@
 import { useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useQueryClient } from '@tanstack/react-query';
-import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp } from 'lucide-react';
+import { Circle, Check, AlertTriangle, RefreshCw, ChevronDown, ChevronUp, Palette } from 'lucide-react';
 import { api } from '../../api/client';
 import { useFilamentMapping } from '../../hooks/useFilamentMapping';
 import { getGlobalTrayId } from '../../utils/amsHelpers';
@@ -20,6 +20,8 @@ export function FilamentMapping({
   currencySymbol,
   defaultCostPerKg,
   defaultExpanded = false,
+  forceColorMatch,
+  onForceColorMatchChange,
 }: FilamentMappingProps & { defaultExpanded?: boolean }) {
   const { t } = useTranslation();
   const queryClient = useQueryClient();
@@ -184,92 +186,113 @@ export function FilamentMapping({
               <span>Re-read</span>
             </button>
           </div>
-          {filamentComparison.map((item, idx) => (
-            <div
-              key={idx}
-              className="grid items-center gap-2 text-xs"
-              style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
-            >
-              {/* Required color */}
-              <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
-                <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
-              </span>
-              {/* Required type + grams + nozzle badge */}
-              <span className="text-white truncate flex items-center gap-1">
-                {isDualNozzle && item.nozzle_id != null && (
-                  <span
-                    className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
-                    title={item.nozzle_id === 1 ? t('printModal.leftNozzleTooltip') : t('printModal.rightNozzleTooltip')}
-                  >
-                    {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
-                  </span>
-                )}
-                {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
-              </span>
-              {/* Arrow */}
-              <span className="text-bambu-gray">→</span>
-              {/* Slot selector dropdown */}
-              <select
-                value={item.loaded?.globalTrayId ?? ''}
-                onChange={(e) => handleSlotChange(item.slot_id || 0, e.target.value)}
-                className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
-                  item.status === 'match'
-                    ? 'border-bambu-green/50 text-bambu-green'
-                    : item.status === 'type_only'
-                    ? 'border-yellow-400/50 text-yellow-400'
-                    : 'border-orange-400/50 text-orange-400'
-                } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
-                title={item.isManual ? 'Manually selected' : 'Auto-matched'}
+          {filamentComparison.map((item, idx) => {
+            // #1717: surface the same per-slot force-color-match checkbox here
+            // that FilamentOverride exposes for model-mode dispatch. The
+            // scheduler honors the flag in both modes; only the UI was missing.
+            const slotId = item.slot_id ?? 0;
+            const canForceMatch = slotId > 0 && onForceColorMatchChange != null;
+            return (
+            <div key={idx} className="space-y-1">
+              <div
+                className="grid items-center gap-2 text-xs"
+                style={{ gridTemplateColumns: '16px minmax(70px, 1fr) auto 2fr 16px' }}
               >
-                <option value="" className="bg-bambu-dark text-bambu-gray">
-                  -- Select slot --
-                </option>
-                {loadedFilaments
-                  .filter(
-                    (f) =>
-                      item.nozzle_id == null ||
-                      ftsInstalled ||
-                      f.extruderId === item.nozzle_id,
-                  )
-                  .map((f) => {
-                    const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
-                    const remainingLabel = remainingWeight != null
-                      ? t('printModal.slotRemainingShort', {
-                          grams: remainingWeight,
-                          defaultValue: ` - ${remainingWeight}g left`,
-                        })
-                      : '';
-                    // FTS routing badge: if this slot is currently fed into an FTS
-                    // track, show the destination extruder. Idle (not-loaded) slots
-                    // get no badge — they can be routed to either extruder on demand.
-                    const ftsTargetExtruder = ftsInstalled
-                      ? ftsExtruderForSlot(f.globalTrayId)
-                      : null;
-                    const ftsBadge =
-                      ftsTargetExtruder == null
-                        ? ''
-                        : ` [${ftsTargetExtruder === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}]`;
-                    return (
-                      <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
-                        {f.label}: {f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
-                      </option>
-                    );
-                })}
-              </select>
-              {/* Status icon */}
-              {item.status === 'match' ? (
-                <Check className="w-3 h-3 text-bambu-green" />
-              ) : item.status === 'type_only' ? (
-                <span title="Same type, different color">
-                  <AlertTriangle className="w-3 h-3 text-yellow-400" />
+                {/* Required color */}
+                <span title={`Required: ${item.type} - ${getColorName(item.color)}`}>
+                  <Circle className="w-3 h-3" fill={item.color} stroke={item.color} />
                 </span>
-              ) : (
-                <span title="Filament type not loaded">
-                  <AlertTriangle className="w-3 h-3 text-orange-400" />
+                {/* Required type + grams + nozzle badge */}
+                <span className="text-white truncate flex items-center gap-1">
+                  {isDualNozzle && item.nozzle_id != null && (
+                    <span
+                      className="inline-flex items-center justify-center w-3.5 h-3.5 rounded text-[9px] font-bold leading-none bg-bambu-gray/20 text-bambu-gray shrink-0"
+                      title={item.nozzle_id === 1 ? t('printModal.leftNozzleTooltip') : t('printModal.rightNozzleTooltip')}
+                    >
+                      {item.nozzle_id === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}
+                    </span>
+                  )}
+                  {item.type} <span className="text-bambu-gray">({item.used_grams}g)</span>
                 </span>
+                {/* Arrow */}
+                <span className="text-bambu-gray">→</span>
+                {/* Slot selector dropdown */}
+                <select
+                  value={item.loaded?.globalTrayId ?? ''}
+                  onChange={(e) => handleSlotChange(slotId, e.target.value)}
+                  className={`flex-1 px-2 py-1 rounded border text-xs bg-bambu-dark-secondary focus:outline-none focus:ring-1 focus:ring-bambu-green ${
+                    item.status === 'match'
+                      ? 'border-bambu-green/50 text-bambu-green'
+                      : item.status === 'type_only'
+                      ? 'border-yellow-400/50 text-yellow-400'
+                      : 'border-orange-400/50 text-orange-400'
+                  } ${item.isManual ? 'ring-1 ring-blue-400/50' : ''}`}
+                  title={item.isManual ? 'Manually selected' : 'Auto-matched'}
+                >
+                  <option value="" className="bg-bambu-dark text-bambu-gray">
+                    -- Select slot --
+                  </option>
+                  {loadedFilaments
+                    .filter(
+                      (f) =>
+                        item.nozzle_id == null ||
+                        ftsInstalled ||
+                        f.extruderId === item.nozzle_id,
+                    )
+                    .map((f) => {
+                      const remainingWeight = trayRemainingWeightMap.get(f.globalTrayId);
+                      const remainingLabel = remainingWeight != null
+                        ? t('printModal.slotRemainingShort', {
+                            grams: remainingWeight,
+                            defaultValue: ` - ${remainingWeight}g left`,
+                          })
+                        : '';
+                      // FTS routing badge: if this slot is currently fed into an FTS
+                      // track, show the destination extruder. Idle (not-loaded) slots
+                      // get no badge — they can be routed to either extruder on demand.
+                      const ftsTargetExtruder = ftsInstalled
+                        ? ftsExtruderForSlot(f.globalTrayId)
+                        : null;
+                      const ftsBadge =
+                        ftsTargetExtruder == null
+                          ? ''
+                          : ` [${ftsTargetExtruder === 1 ? t('printModal.leftNozzle') : t('printModal.rightNozzle')}]`;
+                      return (
+                        <option key={f.globalTrayId} value={f.globalTrayId} className="bg-bambu-dark text-white">
+                          {f.label}: {f.traySubBrands || f.type} ({f.colorName}){remainingLabel}{ftsBadge}
+                        </option>
+                      );
+                  })}
+                </select>
+                {/* Status icon */}
+                {item.status === 'match' ? (
+                  <Check className="w-3 h-3 text-bambu-green" />
+                ) : item.status === 'type_only' ? (
+                  <span title="Same type, different color">
+                    <AlertTriangle className="w-3 h-3 text-yellow-400" />
+                  </span>
+                ) : (
+                  <span title="Filament type not loaded">
+                    <AlertTriangle className="w-3 h-3 text-orange-400" />
+                  </span>
+                )}
+              </div>
+              {/* Force Color Match checkbox — matches FilamentOverride's layout. */}
+              {canForceMatch && (
+                <label className="inline-flex items-center gap-1.5 text-xs text-bambu-gray cursor-pointer select-none pl-5">
+                  <input
+                    type="checkbox"
+                    checked={forceColorMatch?.[slotId] ?? false}
+                    onChange={(e) => onForceColorMatchChange(slotId, e.target.checked)}
+                    className="accent-bambu-green w-3 h-3"
+                  />
+                  <Palette className="w-3 h-3" />
+                  {t('printModal.forceColorMatch')}
+                </label>
               )}
             </div>
-          ))}
+            );
+          })}
           <div className="text-xs text-bambu-gray">
             {t('printModal.totalCost')}{' '}
             <span className="text-white">

+ 4 - 0
frontend/src/components/PrintModal/index.tsx

@@ -1106,6 +1106,10 @@ export function PrintModal({
                 defaultExpanded={!!initialSelectedPrinterIds?.length || (settings?.per_printer_mapping_expanded ?? false)}
                 currencySymbol={currencySymbol}
                 defaultCostPerKg={defaultCostPerKg}
+                forceColorMatch={forceColorMatch}
+                onForceColorMatchChange={(slotId, value) =>
+                  setForceColorMatch((prev) => ({ ...prev, [slotId]: value }))
+                }
               />
             )}
 

+ 6 - 0
frontend/src/components/PrintModal/types.ts

@@ -197,6 +197,12 @@ export interface FilamentMappingProps {
   onManualMappingChange: (mappings: Record<number, number>) => void;
   currencySymbol: string;
   defaultCostPerKg: number;
+  /** Per-slot force-color-match flags. The scheduler honors this flag in both
+   *  model-mode and printer-mode dispatch, but the checkbox was previously only
+   *  surfaced in FilamentOverride (model mode). #1717. */
+  forceColorMatch?: Record<number, boolean>;
+  /** Called when a slot's force-color-match checkbox is toggled. */
+  onForceColorMatchChange?: (slotId: number, value: boolean) => void;
 }
 
 /**

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-BBKQG4M7.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-C-y2WZwG.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
static/assets/index-ekVbQIUh.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-B3h_dfeT.js"></script>
+    <script type="module" crossorigin src="/assets/index-BBKQG4M7.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-7s3X35pi.css">
   </head>
   <body>

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor