feature: Add error badge with auto-opening popover for machine errors

- Replace error button with red error badge in AppHeader
- Add auto-open popover when any error occurs (machine, pairing, or Pyodide)
- Popover auto-closes when errors are cleared
- Respect user dismissals (won't reopen for same error)
- Remove error display from WorkflowStepper (single source of truth)
- Add shortName field to error definitions (max 15 chars)
- Add unit tests to validate error shortName length constraints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-12-25 16:48:47 +01:00
parent ea879640a2
commit dabfa3b35a
4 changed files with 200 additions and 59 deletions

View file

@ -1,3 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useShallow } from "zustand/react/shallow";
import { useMachineStore } from "../stores/useMachineStore";
import { useUIStore } from "../stores/useUIStore";
@ -7,6 +8,7 @@ import {
getStateVisualInfo,
getStatusIndicatorState,
} from "../utils/machineStateHelpers";
import { hasError, getErrorDetails } from "../utils/errorCodeHelpers";
import {
CheckCircleIcon,
BoltIcon,
@ -58,6 +60,15 @@ export function AppHeader() {
})),
);
// State management for error popover auto-open/close
const [errorPopoverOpen, setErrorPopoverOpen] = useState(false);
const [dismissedErrorCode, setDismissedErrorCode] = useState<number | null>(
null,
);
const prevMachineErrorRef = useRef<number | undefined>(undefined);
const prevErrorMessageRef = useRef<string | null>(null);
const prevPyodideErrorRef = useRef<string | null>(null);
// Get state visual info for header status badge
const stateVisual = getStateVisualInfo(machineStatus);
const stateIcons = {
@ -75,6 +86,66 @@ export function AppHeader() {
? getStatusIndicatorState(machineStatus)
: "idle";
// Auto-open/close error popover based on error state changes
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
const currentError = machineError;
const prevError = prevMachineErrorRef.current;
const currentErrorMessage = machineErrorMessage;
const prevErrorMessage = prevErrorMessageRef.current;
const currentPyodideError = pyodideError;
const prevPyodideError = prevPyodideErrorRef.current;
// Check if there's any error now
const hasAnyError =
machineErrorMessage || pyodideError || hasError(currentError);
// Check if there was any error before
const hadAnyError =
prevErrorMessage || prevPyodideError || hasError(prevError);
// Auto-open popover when new error appears
const isNewMachineError =
hasError(currentError) &&
currentError !== prevError &&
currentError !== dismissedErrorCode;
const isNewErrorMessage =
currentErrorMessage && currentErrorMessage !== prevErrorMessage;
const isNewPyodideError =
currentPyodideError && currentPyodideError !== prevPyodideError;
if (isNewMachineError || isNewErrorMessage || isNewPyodideError) {
setErrorPopoverOpen(true);
}
// Auto-close popover when all errors are cleared
if (!hasAnyError && hadAnyError) {
setErrorPopoverOpen(false);
setDismissedErrorCode(null); // Reset dismissed tracking
}
// Update refs for next comparison
prevMachineErrorRef.current = currentError;
prevErrorMessageRef.current = currentErrorMessage;
prevPyodideErrorRef.current = currentPyodideError;
}, [machineError, machineErrorMessage, pyodideError, dismissedErrorCode]);
/* eslint-enable react-hooks/set-state-in-effect */
// Handle manual popover dismiss
const handlePopoverOpenChange = (open: boolean) => {
setErrorPopoverOpen(open);
// If user manually closes it, remember the current error state to prevent reopening
if (!open) {
// For machine errors, track the error code
if (hasError(machineError)) {
setDismissedErrorCode(machineError);
}
// Update refs so we don't reopen for the same error message/pyodide error
prevErrorMessageRef.current = machineErrorMessage;
prevPyodideErrorRef.current = pyodideError;
}
};
return (
<TooltipProvider>
<header className="bg-gradient-to-r from-primary-600 via-primary-700 to-primary-800 dark:from-primary-700 dark:via-primary-800 dark:to-primary-900 px-4 sm:px-6 lg:px-8 py-3 shadow-lg border-b-2 border-primary-900/20 dark:border-primary-800/30 flex-shrink-0">
@ -166,22 +237,22 @@ export function AppHeader() {
)}
{/* Error indicator - always render to prevent layout shift */}
<Popover>
<Popover
open={errorPopoverOpen}
onOpenChange={handlePopoverOpenChange}
>
<PopoverTrigger asChild>
<Button
size="sm"
variant="destructive"
<button
className={cn(
"gap-1.5 flex-shrink-0",
"inline-flex items-center rounded-full border border-transparent bg-destructive text-white px-2.5 py-1.5 text-xs font-semibold gap-1.5 cursor-pointer hover:bg-destructive/90 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive focus-visible:ring-offset-2",
machineErrorMessage || pyodideError
? "animate-pulse hover:animate-none"
: "invisible pointer-events-none",
)}
aria-label="View error details"
disabled={!(machineErrorMessage || pyodideError)}
>
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span>
<span className="font-semibold">
{(() => {
if (pyodideError) return "Python Error";
if (isPairingError) return "Pairing Required";
@ -202,17 +273,19 @@ export function AppHeader() {
return "Pattern Error";
}
if (machineError !== undefined) {
return `Machine Error`;
// Get short name from error details
const errorDetails = getErrorDetails(machineError);
return errorDetails?.shortName || "Machine Error";
}
// Default fallback
return "Error";
})()}
</span>
</Button>
</button>
</PopoverTrigger>
{/* Error popover content */}
{/* Error popover content - unchanged */}
{(machineErrorMessage || pyodideError) && (
<ErrorPopoverContent
machineError={

View file

@ -8,7 +8,6 @@ import {
ExclamationTriangleIcon,
} from "@heroicons/react/24/solid";
import { MachineStatus } from "../types/machine";
import { getErrorDetails, hasError } from "../utils/errorCodeHelpers";
interface Step {
id: number;
@ -28,38 +27,7 @@ const steps: Step[] = [
];
// Helper function to get guide content for a step
function getGuideContent(
stepId: number,
machineStatus: MachineStatus,
hasError: boolean,
errorCode?: number,
errorMessage?: string,
) {
// Check for errors first
if (hasError) {
const errorDetails = getErrorDetails(errorCode);
if (errorDetails?.isInformational) {
return {
type: "info" as const,
title: errorDetails.title,
description: errorDetails.description,
items: errorDetails.solutions || [],
};
}
return {
type: "error" as const,
title: errorDetails?.title || "Error Occurred",
description:
errorDetails?.description ||
errorMessage ||
"An error occurred. Please check the machine and try again.",
items: errorDetails?.solutions || [],
errorCode,
};
}
function getGuideContent(stepId: number, machineStatus: MachineStatus) {
// Return content based on step
switch (stepId) {
case 1:
@ -273,17 +241,10 @@ function getCurrentStep(
export function WorkflowStepper() {
// Machine store
const {
machineStatus,
isConnected,
machineError,
error: errorMessage,
} = useMachineStore(
const { machineStatus, isConnected } = useMachineStore(
useShallow((state) => ({
machineStatus: state.machineStatus,
isConnected: state.isConnected,
machineError: state.machineError,
error: state.error,
})),
);
@ -297,7 +258,6 @@ export function WorkflowStepper() {
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
const hasPattern = pesData !== null;
const hasErrorFlag = hasError(machineError);
const currentStep = getCurrentStep(
machineStatus,
isConnected,
@ -443,13 +403,7 @@ export function WorkflowStepper() {
aria-label="Step guidance"
>
{(() => {
const content = getGuideContent(
popoverStep,
machineStatus,
hasErrorFlag,
machineError,
errorMessage || undefined,
);
const content = getGuideContent(popoverStep, machineStatus);
if (!content) return null;
const colorClasses = {

View file

@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { getErrorDetails, SewingMachineError } from "./errorCodeHelpers";
describe("errorCodeHelpers", () => {
describe("shortName validation", () => {
it("should ensure all error shortNames are 15 characters or less", () => {
// Get all error codes except None (0xDD) and Unknown (0xEE) since they might not have details
const errorCodes = Object.values(SewingMachineError).filter(
(code) =>
code !== SewingMachineError.None &&
code !== SewingMachineError.Unknown &&
code !== SewingMachineError.OtherError,
);
const violations: Array<{
code: number;
shortName: string;
length: number;
}> = [];
errorCodes.forEach((code) => {
const details = getErrorDetails(code);
if (details?.shortName) {
const length = details.shortName.length;
if (length > 15) {
violations.push({
code,
shortName: details.shortName,
length,
});
}
}
});
// If there are violations, create a helpful error message
if (violations.length > 0) {
const violationMessages = violations
.map(
(v) =>
`Error code 0x${v.code.toString(16).toUpperCase()}: "${v.shortName}" (${v.length} chars)`,
)
.join("\n ");
expect.fail(
`The following error shortNames exceed 15 characters:\n ${violationMessages}`,
);
}
// Assertion to confirm test ran
expect(violations).toHaveLength(0);
});
it("should ensure all error details have a shortName", () => {
// Get all error codes except None (0xDD) and Unknown (0xEE)
const errorCodes = Object.values(SewingMachineError).filter(
(code) =>
code !== SewingMachineError.None &&
code !== SewingMachineError.Unknown &&
code !== SewingMachineError.OtherError,
);
const missing: number[] = [];
errorCodes.forEach((code) => {
const details = getErrorDetails(code);
if (details && !details.shortName) {
missing.push(code);
}
});
if (missing.length > 0) {
const missingCodes = missing
.map((code) => `0x${code.toString(16).toUpperCase()}`)
.join(", ");
expect.fail(
`The following error codes are missing shortName: ${missingCodes}`,
);
}
expect(missing).toHaveLength(0);
});
});
});

View file

@ -42,6 +42,8 @@ export const SewingMachineError = {
*/
interface ErrorInfo {
title: string;
/** Short name for badge display (max 15 characters) */
shortName: string;
description: string;
solutions: string[];
/** If true, this "error" is really just an informational step, not a real error */
@ -55,12 +57,14 @@ interface ErrorInfo {
const ERROR_DETAILS: Record<number, ErrorInfo> = {
[SewingMachineError.NeedlePositionError]: {
title: "The Needle is Down",
shortName: "Needle Down",
description:
"The needle is in the down position and needs to be raised before continuing.",
solutions: ["Press the needle position switch to raise the needle"],
},
[SewingMachineError.SafetyError]: {
title: "Safety Error",
shortName: "Safety Error",
description: "The machine is sensing an operational issue.",
solutions: [
"Remove the thread on the top of the fabric and then remove the needle",
@ -72,21 +76,25 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.LowerThreadSafetyError]: {
title: "Lower Thread Safety Error",
shortName: "Lower Thread",
description: "The bobbin winder safety device is activated.",
solutions: ["Check if the thread is tangled"],
},
[SewingMachineError.LowerThreadFreeError]: {
title: "Lower Thread Free Error",
shortName: "Lower Thread",
description: "Problem with lower thread.",
solutions: ["Slide the bobbin winder shaft toward the front"],
},
[SewingMachineError.RestartError10]: {
title: "Restart Required",
shortName: "Restart Needed",
description: "A malfunction occurred.",
solutions: ["Turn the machine off, then on again"],
},
[SewingMachineError.RestartError11]: {
title: "Restart Required (M519411)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519411",
solutions: [
"Turn the machine off, then on again",
@ -95,6 +103,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError12]: {
title: "Restart Required (M519412)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519412",
solutions: [
"Turn the machine off, then on again",
@ -103,6 +112,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError13]: {
title: "Restart Required (M519413)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519413",
solutions: [
"Turn the machine off, then on again",
@ -111,6 +121,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError14]: {
title: "Restart Required (M519414)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519414",
solutions: [
"Turn the machine off, then on again",
@ -119,6 +130,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError15]: {
title: "Restart Required (M519415)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519415",
solutions: [
"Turn the machine off, then on again",
@ -127,6 +139,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError16]: {
title: "Restart Required (M519416)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519416",
solutions: [
"Turn the machine off, then on again",
@ -135,6 +148,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError17]: {
title: "Restart Required (M519417)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519417",
solutions: [
"Turn the machine off, then on again",
@ -143,6 +157,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError18]: {
title: "Restart Required (M519418)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519418",
solutions: [
"Turn the machine off, then on again",
@ -151,6 +166,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError19]: {
title: "Restart Required (M519419)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M519419",
solutions: [
"Turn the machine off, then on again",
@ -159,6 +175,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError1A]: {
title: "Restart Required (M51941A)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M51941A",
solutions: [
"Turn the machine off, then on again",
@ -167,6 +184,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError1B]: {
title: "Restart Required (M51941B)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M51941B",
solutions: [
"Turn the machine off, then on again",
@ -175,6 +193,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RestartError1C]: {
title: "Restart Required (M51941C)",
shortName: "Restart Needed",
description: "A malfunction occurred. Error code: M51941C",
solutions: [
"Turn the machine off, then on again",
@ -183,6 +202,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.NeedlePlateError]: {
title: "Needle Plate Error",
shortName: "Needle Plate",
description: "Check the needle plate cover.",
solutions: [
"Reattach the needle plate cover",
@ -191,11 +211,13 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.ThreadLeverError]: {
title: "Thread Lever Error",
shortName: "Thread Lever",
description: "The needle threading lever is not in its original position.",
solutions: ["Return the needle threading lever to its original position"],
},
[SewingMachineError.UpperThreadError]: {
title: "Upper Thread Error",
shortName: "Upper Thread",
description: "Check and rethread the upper thread.",
solutions: [
"Check the upper thread and rethread it",
@ -204,6 +226,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.LowerThreadError]: {
title: "Lower Thread Error",
shortName: "Lower Thread",
description: "The bobbin thread is almost empty.",
solutions: [
"Replace the bobbin thread",
@ -212,6 +235,7 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.UpperThreadSewingStartError]: {
title: "Upper Thread Error at Sewing Start",
shortName: "Upper Thread",
description: "Check and rethread the upper thread.",
solutions: [
"Press the Accept button to resolve the error",
@ -221,21 +245,25 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.PRWiperError]: {
title: "PR Wiper Error",
shortName: "PR Wiper",
description: "PR Wiper Error.",
solutions: ["Press the Accept button to resolve the error"],
},
[SewingMachineError.HoopError]: {
title: "Hoop Error",
shortName: "Hoop Error",
description: "This embroidery frame cannot be used.",
solutions: ["Use another frame that fits the pattern"],
},
[SewingMachineError.NoHoopError]: {
title: "No Hoop Detected",
shortName: "No Hoop",
description: "No hoop attached.",
solutions: ["Attach the embroidery hoop"],
},
[SewingMachineError.InitialHoopError]: {
title: "Machine Initialization Required",
shortName: "Init Required",
description: "An initial homing procedure must be performed.",
solutions: [
"Remove the embroidery hoop from the machine completely",
@ -248,12 +276,14 @@ const ERROR_DETAILS: Record<number, ErrorInfo> = {
},
[SewingMachineError.RegularInspectionError]: {
title: "Regular Inspection Required",
shortName: "Inspection Due",
description:
"Preventive maintenance is recommended. This message is displayed when maintenance is due.",
solutions: ["Please contact the service center"],
},
[SewingMachineError.Setting]: {
title: "Settings Error",
shortName: "Settings Error",
description: "Stitch count cannot be changed.",
solutions: ["This setting cannot be modified at this time"],
},