Просмотр исходного кода

fix(failure-reason): consistent camelCase keys across UI surfaces

  The Stats page's Failure Analysis widget and the per-archive run
  sub-table rendered the raw PrintLogEntry.failure_reason value
  without translating, so the camelCase keys saved by the new
  Print Log row editor (#1687 part 4) surfaced as literal
  "filamentRunout" / "cloggedNozzle" text. The Print Log table did
  translate the value, so the inconsistency was visible from one
  surface to the next.

  EditArchiveModal was also still saving the localised label as the
  column value while the new editor saved the key - same column,
  two formats, two failure modes (group fragmentation on language
  switch, new PATCH validation rejection on round-trip).

  Three surfaces fixed in one drop:

  1. StatsPage.tsx and PrintLogTable.tsx wrap the value in
     t('editArchive.failureReasons.${reason}', { defaultValue: reason }) -
     the defaultValue path keeps legacy translated-text rows rendering
     unchanged.

  2. EditArchiveModal stores the camelCase key on save and reverse-
     looks up any legacy translated-text value against the current
     locale on open. Every save thereafter converts that row forward
     to the key format, so the column self-heals over time.

  3. Added htmlFor/id to the failure-reason label/select pair (a11y
     plus testability).
maziggy 2 месяцев назад
Родитель
Сommit
60b253f98a

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
CHANGELOG.md


+ 43 - 0
frontend/src/__tests__/components/EditArchiveModal.test.tsx

@@ -210,4 +210,47 @@ describe('EditArchiveModal', () => {
       expect(nameInput).toHaveValue('New Name');
     });
   });
+
+  describe('failure_reason vocabulary (#1687 follow-up)', () => {
+    // The Stats page's Failure Analysis widget groups by the raw column value.
+    // Before this fix this modal saved the translated label, so a language
+    // switch fragmented historical buckets and any round-trip through the
+    // new PATCH /print-log endpoint (which validates against camelCase keys)
+    // would reject the value. The dropdown now saves the key.
+
+    const failedArchive = { ...mockArchive, status: 'failed', failure_reason: 'filamentRunout' };
+    const legacyArchive = { ...mockArchive, status: 'failed', failure_reason: 'Filament runout' };
+
+    it('preselects the option when the stored value is already a camelCase key', () => {
+      render(<EditArchiveModal archive={failedArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      const select = screen.getByLabelText(/failure reason/i) as HTMLSelectElement;
+      expect(select.value).toBe('filamentRunout');
+    });
+
+    it('reverse-looks-up a legacy translated value back to its key', () => {
+      render(<EditArchiveModal archive={legacyArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      const select = screen.getByLabelText(/failure reason/i) as HTMLSelectElement;
+      expect(select.value).toBe('filamentRunout');
+    });
+
+    it('sends the camelCase key on save, not the translated label', async () => {
+      const user = userEvent.setup();
+      let patched: { failure_reason?: string } | undefined;
+      server.use(
+        http.patch('/api/v1/archives/:id', async ({ request }) => {
+          patched = (await request.json()) as { failure_reason?: string };
+          return HttpResponse.json({ ...failedArchive, ...patched });
+        }),
+      );
+
+      render(<EditArchiveModal archive={failedArchive} onClose={mockOnClose} onSave={mockOnSave} />);
+      const select = screen.getByLabelText(/failure reason/i);
+      await user.selectOptions(select, 'cloggedNozzle');
+      await user.click(screen.getByRole('button', { name: /save/i }));
+
+      await waitFor(() => {
+        expect(patched?.failure_reason).toBe('cloggedNozzle');
+      });
+    });
+  });
 });

+ 17 - 0
frontend/src/__tests__/components/PrintLogModal.test.tsx

@@ -97,6 +97,23 @@ describe('PrintLogModal', () => {
     });
   });
 
+  it('translates camelCase failure_reason keys (#1687 follow-up)', async () => {
+    vi.mocked(api.getArchiveRuns).mockResolvedValue({
+      total: 1,
+      items: [
+        {
+          ...sampleRuns.items[0],
+          failure_reason: 'filamentRunout',
+        },
+      ],
+    });
+    render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);
+    await waitFor(() => {
+      expect(screen.getByText('Filament runout')).toBeInTheDocument();
+    });
+    expect(screen.queryByText('filamentRunout')).not.toBeInTheDocument();
+  });
+
   it('shows the empty state when there are no runs', async () => {
     vi.mocked(api.getArchiveRuns).mockResolvedValue({ total: 0, items: [] });
     render(<PrintLogModal archiveId={42} archiveName="Benchy" onClose={vi.fn()} />);

+ 43 - 0
frontend/src/__tests__/pages/StatsPage.test.tsx

@@ -293,6 +293,49 @@ describe('StatsPage', () => {
       });
     });
 
+    it('translates camelCase failure-reason keys instead of rendering them raw (#1687 follow-up)', async () => {
+      // The widget groups by the raw PrintLogEntry.failure_reason column.
+      // The new editor stores camelCase keys (`filamentRunout`), so the widget
+      // must translate them — otherwise users see the literal key text.
+      server.use(
+        http.get('/api/v1/archives/analysis/failures', () => {
+          return HttpResponse.json({
+            ...mockFailureAnalysis,
+            failures_by_reason: { filamentRunout: 2, cloggedNozzle: 1 },
+          });
+        }),
+      );
+
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Filament runout')).toBeInTheDocument();
+        expect(screen.getByText('Clogged nozzle')).toBeInTheDocument();
+      });
+      expect(screen.queryByText('filamentRunout')).not.toBeInTheDocument();
+      expect(screen.queryByText('cloggedNozzle')).not.toBeInTheDocument();
+    });
+
+    it('renders legacy translated-text failure reasons unchanged (#1687 follow-up)', async () => {
+      // Old rows from before the key/value migration stored the translated
+      // text. The defaultValue fallback in the t() call must surface them
+      // as-is rather than turning them into the literal key string.
+      server.use(
+        http.get('/api/v1/archives/analysis/failures', () => {
+          return HttpResponse.json({
+            ...mockFailureAnalysis,
+            failures_by_reason: { 'Custom legacy reason': 4 },
+          });
+        }),
+      );
+
+      render(<StatsPage />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Custom legacy reason')).toBeInTheDocument();
+      });
+    });
+
     it('shows printer stats widget', async () => {
       render(<StatsPage />);
 

+ 16 - 3
frontend/src/components/EditArchiveModal.tsx

@@ -51,7 +51,19 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
   const [projectId, setProjectId] = useState<number | null>(archive.project_id ?? null);
   const [notes, setNotes] = useState(archive.notes || '');
   const [tags, setTags] = useState(archive.tags || '');
-  const [failureReason, setFailureReason] = useState(archive.failure_reason || '');
+  // Failure reason is stored as a camelCase key (`filamentRunout`), but earlier
+  // versions of this modal saved the translated label as the value. Reverse-
+  // lookup any legacy translated text against the current locale so the
+  // dropdown pre-selects the right option, then any save converts it forward.
+  const [failureReason, setFailureReason] = useState(() => {
+    const raw = archive.failure_reason || '';
+    if (!raw) return '';
+    if ((FAILURE_REASON_KEYS as readonly string[]).includes(raw)) return raw;
+    const match = FAILURE_REASON_KEYS.find(
+      (k) => t(`editArchive.failureReasons.${k}`) === raw,
+    );
+    return match || '';
+  });
   const [status, setStatus] = useState(archive.status);
   const [quantity, setQuantity] = useState(archive.quantity ?? 1);
   const [photos, setPhotos] = useState<string[]>(archive.photos || []);
@@ -417,15 +429,16 @@ export function EditArchiveModal({ archive, onClose, existingTags = [] }: EditAr
           {/* Failure Reason - only show for failed/aborted prints */}
           {(status === 'failed' || status === 'aborted') && (
             <div>
-              <label className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
+              <label htmlFor="failure-reason-select" className="block text-sm text-bambu-gray mb-1">{t('editArchive.failureReason')}</label>
               <select
+                id="failure-reason-select"
                 value={failureReason}
                 onChange={(e) => setFailureReason(e.target.value)}
                 className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
               >
                 <option value="">{t('editArchive.selectReason')}</option>
                 {FAILURE_REASON_KEYS.map((reasonKey) => (
-                  <option key={reasonKey} value={t(`editArchive.failureReasons.${reasonKey}`)}>
+                  <option key={reasonKey} value={reasonKey}>
                     {t(`editArchive.failureReasons.${reasonKey}`)}
                   </option>
                 ))}

+ 1 - 1
frontend/src/components/PrintLogTable.tsx

@@ -78,7 +78,7 @@ export function PrintLogTable({ archiveId }: PrintLogTableProps) {
                   {t(`archives.runLog.status.${run.status}`, { defaultValue: run.status })}
                   {run.failure_reason && (
                     <span className="block text-[10px] text-bambu-gray font-normal">
-                      {run.failure_reason}
+                      {t(`editArchive.failureReasons.${run.failure_reason}`, { defaultValue: run.failure_reason })}
                     </span>
                   )}
                 </td>

+ 3 - 1
frontend/src/pages/StatsPage.tsx

@@ -814,7 +814,9 @@ function FailureAnalysisWidget({ size = 1, dateFrom, dateTo, createdById }: {
             {topReasons.map(([reason, count]) => (
               <div key={reason} className="flex items-center justify-between text-sm">
                 <span className={`text-white truncate ${size === 4 ? 'max-w-[200px]' : 'max-w-[160px]'}`}>
-                  {reason || t('common.unknown')}
+                  {reason
+                    ? t(`editArchive.failureReasons.${reason}`, { defaultValue: reason })
+                    : t('common.unknown')}
                 </span>
                 <span className="text-bambu-gray ml-2">{count}</span>
               </div>

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
static/assets/index-DdAEkh5e.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-QVjYxA_R.js"></script>
+    <script type="module" crossorigin src="/assets/index-DdAEkh5e.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-7s3X35pi.css">
   </head>
   <body>

Некоторые файлы не были показаны из-за большого количества измененных файлов