From 332dd34cca805ceac138f44ef5d0ccea5a9e6ec8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:22:10 +0000 Subject: [PATCH 1/5] feat(ui-v2): improve task run details page parity with Vue - Add copy button to Task Inputs tab using JsonInput copy prop - Add usePageTitle hook to set browser tab title based on task run name - Add useFavicon hook to change favicon based on task run state - Add state-specific SVG icons for favicon (cancelled, completed, crashed, failed, pending, running, scheduled) - Fix breadcrumb to link to /runs?tab=task-runs instead of just /runs - Show all detail fields consistently with 'None' when empty instead of hiding them - Add Cache Key, Cache Expiration, Retry Delay, and Retry Jitter Factor fields to always display Co-Authored-By: alex.s@prefect.io --- ui-v2/public/cancelled.svg | 4 + ui-v2/public/completed.svg | 4 + ui-v2/public/crashed.svg | 4 + ui-v2/public/failed.svg | 4 + ui-v2/public/pending.svg | 4 + ui-v2/public/running.svg | 4 + ui-v2/public/scheduled.svg | 4 + .../task-runs/task-run-details-page/index.tsx | 20 +- .../task-run-details/task-run-details.tsx | 175 +++++++++--------- ui-v2/src/hooks/use-favicon.ts | 66 +++++++ 10 files changed, 199 insertions(+), 90 deletions(-) create mode 100644 ui-v2/public/cancelled.svg create mode 100644 ui-v2/public/completed.svg create mode 100644 ui-v2/public/crashed.svg create mode 100644 ui-v2/public/failed.svg create mode 100644 ui-v2/public/pending.svg create mode 100644 ui-v2/public/running.svg create mode 100644 ui-v2/public/scheduled.svg create mode 100644 ui-v2/src/hooks/use-favicon.ts diff --git a/ui-v2/public/cancelled.svg b/ui-v2/public/cancelled.svg new file mode 100644 index 000000000000..ee3ca39fdb8c --- /dev/null +++ b/ui-v2/public/cancelled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-v2/public/completed.svg b/ui-v2/public/completed.svg new file mode 100644 index 000000000000..e777b55eaa58 --- /dev/null +++ b/ui-v2/public/completed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-v2/public/crashed.svg b/ui-v2/public/crashed.svg new file mode 100644 index 000000000000..0ea5a9c140c1 --- /dev/null +++ b/ui-v2/public/crashed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-v2/public/failed.svg b/ui-v2/public/failed.svg new file mode 100644 index 000000000000..38a5b0a7fb24 --- /dev/null +++ b/ui-v2/public/failed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-v2/public/pending.svg b/ui-v2/public/pending.svg new file mode 100644 index 000000000000..fa3b0227c7e1 --- /dev/null +++ b/ui-v2/public/pending.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-v2/public/running.svg b/ui-v2/public/running.svg new file mode 100644 index 000000000000..a4b6ecffa860 --- /dev/null +++ b/ui-v2/public/running.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-v2/public/scheduled.svg b/ui-v2/public/scheduled.svg new file mode 100644 index 000000000000..da7aa3034d66 --- /dev/null +++ b/ui-v2/public/scheduled.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-v2/src/components/task-runs/task-run-details-page/index.tsx b/ui-v2/src/components/task-runs/task-run-details-page/index.tsx index e1f316227fd3..d84b2018b39f 100644 --- a/ui-v2/src/components/task-runs/task-run-details-page/index.tsx +++ b/ui-v2/src/components/task-runs/task-run-details-page/index.tsx @@ -45,6 +45,8 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useFavicon } from "@/hooks/use-favicon"; +import { usePageTitle } from "@/hooks/use-page-title"; type TaskRunDetailsPageProps = { id: string; @@ -67,6 +69,12 @@ export const TaskRunDetailsPage = ({ const { deleteTaskRun } = useDeleteTaskRun(); const { navigate } = useRouter(); + // Set page title based on task run name + usePageTitle(taskRun?.name ? `Task Run: ${taskRun.name}` : "Task Run"); + + // Set favicon based on task run state + useFavicon(taskRun?.state_type); + useEffect(() => { if (taskRun.state_type === "RUNNING" || taskRun.state_type === "PENDING") { setRefetchInterval(5000); @@ -183,7 +191,11 @@ const Header = ({ - + Runs @@ -365,6 +377,10 @@ const ArtifactsSkeleton = () => { const TaskInputs = ({ taskRun }: { taskRun: TaskRun }) => { return ( - + ); }; diff --git a/ui-v2/src/components/task-runs/task-run-details/task-run-details.tsx b/ui-v2/src/components/task-runs/task-run-details/task-run-details.tsx index d58668d00883..80f5cc5832cc 100644 --- a/ui-v2/src/components/task-runs/task-run-details/task-run-details.tsx +++ b/ui-v2/src/components/task-runs/task-run-details/task-run-details.tsx @@ -42,7 +42,7 @@ export const TaskRunDetails = ({ taskRun }: TaskRunDetailsProps) => { return (
- {taskRun.flow_run_name && taskRun.flow_run_id && ( + {taskRun.flow_run_id ? (
Flow Run
@@ -56,85 +56,84 @@ export const TaskRunDetails = ({ taskRun }: TaskRunDetailsProps) => {
- )} - - {taskRun.start_time && ( + ) : (
-
Start Time
-
{formatTaskDate(taskRun.start_time)}
+
Flow Run
+
None
)} - {taskRun.estimated_run_time !== null && - taskRun.estimated_run_time !== undefined && ( -
-
Duration
-
- - - {formatTaskDuration(taskRun.total_run_time)} - -
-
- )} - - {taskRun.run_count !== null && taskRun.run_count !== undefined && ( -
-
Run Count
-
{taskRun.run_count.toString()}
-
- )} +
+
Start Time
+
+ {taskRun.start_time ? formatTaskDate(taskRun.start_time) : "None"} +
+
- {taskRun.estimated_run_time !== null && - taskRun.estimated_run_time !== undefined && ( -
-
Estimated Run Time
-
- {formatTaskDuration(taskRun.estimated_run_time)} -
-
- )} - - {taskRun.created && ( -
-
Created
-
{formatTaskDate(taskRun.created)}
-
- )} +
+
Duration
+
+ + + {taskRun.total_run_time !== null && + taskRun.total_run_time !== undefined + ? formatTaskDuration(taskRun.total_run_time) + : "None"} + +
+
- {taskRun.updated && ( -
-
Last Updated
-
{formatTaskDate(taskRun.updated)}
-
- )} +
+
Run Count
+
{taskRun.run_count ?? 0}
+
- {taskRun.cache_key && ( -
-
Cache Key
-
{taskRun.cache_key}
-
- )} +
+
Estimated Run Time
+
+ {taskRun.estimated_run_time !== null && + taskRun.estimated_run_time !== undefined + ? formatTaskDuration(taskRun.estimated_run_time) + : "None"} +
+
- {taskRun.cache_expiration && ( -
-
Cache Expiration
-
- {formatTaskDate(taskRun.cache_expiration)} -
-
- )} +
+
Created
+
+ {taskRun.created ? formatTaskDate(taskRun.created) : "None"} +
+
- {taskRun.dynamic_key && ( -
-
Dynamic Key
-
{taskRun.dynamic_key}
-
- )} +
+
Last Updated
+
+ {taskRun.updated ? formatTaskDate(taskRun.updated) : "None"} +
+
+ +
+
Cache Key
+
{taskRun.cache_key || "None"}
+
+ +
+
Cache Expiration
+
+ {taskRun.cache_expiration + ? formatTaskDate(taskRun.cache_expiration) + : "None"} +
+
-
Task Run ID
-
{taskRun.id}
+
Dynamic Key
+
{taskRun.dynamic_key || "None"}
+
+ +
+
Task Run ID
+
{taskRun.id}
{resultArtifact?.description && ( @@ -159,30 +158,30 @@ export const TaskRunDetails = ({ taskRun }: TaskRunDetailsProps) => {
-
Retries
+
Retries
- {taskRun.empirical_policy?.retries?.toString() || "0"} + {taskRun.empirical_policy?.retries?.toString() ?? "0"}
- {typeof taskRun.empirical_policy?.retry_delay === "number" && ( -
-
Retry Delay
-
- {formatTaskDuration(taskRun.empirical_policy.retry_delay)} -
-
- )} +
+
Retry Delay
+
+ {typeof taskRun.empirical_policy?.retry_delay === "number" + ? formatTaskDuration(taskRun.empirical_policy.retry_delay) + : "None"} +
+
- {taskRun.empirical_policy?.retry_jitter_factor !== null && - taskRun.empirical_policy?.retry_jitter_factor !== undefined && ( -
-
Retry Jitter Factor
-
- {taskRun.empirical_policy.retry_jitter_factor.toString()} -
-
- )} +
+
Retry Jitter Factor
+
+ {taskRun.empirical_policy?.retry_jitter_factor !== null && + taskRun.empirical_policy?.retry_jitter_factor !== undefined + ? taskRun.empirical_policy.retry_jitter_factor.toString() + : "None"} +
+
Tags
diff --git a/ui-v2/src/hooks/use-favicon.ts b/ui-v2/src/hooks/use-favicon.ts new file mode 100644 index 000000000000..c5b123001b5e --- /dev/null +++ b/ui-v2/src/hooks/use-favicon.ts @@ -0,0 +1,66 @@ +import { useEffect } from "react"; + +type StateType = + | "SCHEDULED" + | "PENDING" + | "RUNNING" + | "COMPLETED" + | "FAILED" + | "CANCELLED" + | "CANCELLING" + | "CRASHED" + | "PAUSED"; + +function getPreferredColorScheme(): "dark" | "light" | "no-preference" { + if (window.matchMedia("(prefers-color-scheme: dark)").matches) { + return "dark"; + } + if (window.matchMedia("(prefers-color-scheme: light)").matches) { + return "light"; + } + return "no-preference"; +} + +/** + * A hook that sets the browser favicon based on the provided state type. + * Resets the favicon to the default when the component unmounts. + * + * @param stateType - The state type to display in the favicon (e.g., "COMPLETED", "FAILED") + * @returns void + * + * @example + * ```tsx + * // Set favicon based on task run state + * useFavicon(taskRun.state_type); + * ``` + */ +export function useFavicon(stateType: StateType | null | undefined): void { + useEffect(() => { + const colorScheme = getPreferredColorScheme(); + const favicon16 = + colorScheme === "dark" + ? document.getElementById("favicon-16-dark") + : document.getElementById("favicon-16"); + const favicon32 = + colorScheme === "dark" + ? document.getElementById("favicon-32-dark") + : document.getElementById("favicon-32"); + + if (stateType) { + const faviconPath = `/${stateType.toLowerCase()}.svg`; + favicon16?.setAttribute("href", faviconPath); + favicon32?.setAttribute("href", faviconPath); + } + + return () => { + // Reset to default favicon on unmount + if (colorScheme === "dark") { + favicon16?.setAttribute("href", "/favicon-16x16-dark.png"); + favicon32?.setAttribute("href", "/favicon-32x32-dark.png"); + } else { + favicon16?.setAttribute("href", "/favicon-16x16.png"); + favicon32?.setAttribute("href", "/favicon-32x32.png"); + } + }; + }, [stateType]); +} From 41d13e953eb37135e5db0fbe5d51ff3874ed5fb0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 19:58:07 +0000 Subject: [PATCH 2/5] test(ui-v2): add tests for use-favicon hook Co-Authored-By: alex.s@prefect.io --- ui-v2/src/hooks/use-favicon.test.ts | 206 ++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 ui-v2/src/hooks/use-favicon.test.ts diff --git a/ui-v2/src/hooks/use-favicon.test.ts b/ui-v2/src/hooks/use-favicon.test.ts new file mode 100644 index 000000000000..f188052eb1cc --- /dev/null +++ b/ui-v2/src/hooks/use-favicon.test.ts @@ -0,0 +1,206 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useFavicon } from "./use-favicon"; + +describe("useFavicon", () => { + let favicon16: HTMLLinkElement; + let favicon32: HTMLLinkElement; + let favicon16Dark: HTMLLinkElement; + let favicon32Dark: HTMLLinkElement; + let matchMediaMock: ReturnType; + + beforeEach(() => { + // Create mock favicon elements + favicon16 = document.createElement("link"); + favicon16.id = "favicon-16"; + favicon16.setAttribute("href", "/favicon-16x16.png"); + document.head.appendChild(favicon16); + + favicon32 = document.createElement("link"); + favicon32.id = "favicon-32"; + favicon32.setAttribute("href", "/favicon-32x32.png"); + document.head.appendChild(favicon32); + + favicon16Dark = document.createElement("link"); + favicon16Dark.id = "favicon-16-dark"; + favicon16Dark.setAttribute("href", "/favicon-16x16-dark.png"); + document.head.appendChild(favicon16Dark); + + favicon32Dark = document.createElement("link"); + favicon32Dark.id = "favicon-32-dark"; + favicon32Dark.setAttribute("href", "/favicon-32x32-dark.png"); + document.head.appendChild(favicon32Dark); + + // Mock matchMedia to return light mode by default + matchMediaMock = vi.fn().mockImplementation((query: string) => ({ + matches: query === "(prefers-color-scheme: light)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + window.matchMedia = matchMediaMock; + }); + + afterEach(() => { + // Clean up favicon elements + favicon16.remove(); + favicon32.remove(); + favicon16Dark.remove(); + favicon32Dark.remove(); + vi.restoreAllMocks(); + }); + + it("sets favicon to state-specific SVG when stateType is provided", () => { + renderHook(() => useFavicon("COMPLETED")); + + expect(favicon16.getAttribute("href")).toBe("/completed.svg"); + expect(favicon32.getAttribute("href")).toBe("/completed.svg"); + }); + + it("converts state type to lowercase for favicon path", () => { + renderHook(() => useFavicon("FAILED")); + + expect(favicon16.getAttribute("href")).toBe("/failed.svg"); + expect(favicon32.getAttribute("href")).toBe("/failed.svg"); + }); + + it("does not change favicon when stateType is null", () => { + renderHook(() => useFavicon(null)); + + expect(favicon16.getAttribute("href")).toBe("/favicon-16x16.png"); + expect(favicon32.getAttribute("href")).toBe("/favicon-32x32.png"); + }); + + it("does not change favicon when stateType is undefined", () => { + renderHook(() => useFavicon(undefined)); + + expect(favicon16.getAttribute("href")).toBe("/favicon-16x16.png"); + expect(favicon32.getAttribute("href")).toBe("/favicon-32x32.png"); + }); + + it("resets favicon to default on unmount in light mode", () => { + const { unmount } = renderHook(() => useFavicon("RUNNING")); + + expect(favicon16.getAttribute("href")).toBe("/running.svg"); + expect(favicon32.getAttribute("href")).toBe("/running.svg"); + + act(() => { + unmount(); + }); + + expect(favicon16.getAttribute("href")).toBe("/favicon-16x16.png"); + expect(favicon32.getAttribute("href")).toBe("/favicon-32x32.png"); + }); + + it("uses dark favicon elements when prefers-color-scheme is dark", () => { + // Mock dark mode + matchMediaMock.mockImplementation((query: string) => ({ + matches: query === "(prefers-color-scheme: dark)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + + renderHook(() => useFavicon("SCHEDULED")); + + expect(favicon16Dark.getAttribute("href")).toBe("/scheduled.svg"); + expect(favicon32Dark.getAttribute("href")).toBe("/scheduled.svg"); + }); + + it("resets to dark favicon on unmount when in dark mode", () => { + // Mock dark mode + matchMediaMock.mockImplementation((query: string) => ({ + matches: query === "(prefers-color-scheme: dark)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + + const { unmount } = renderHook(() => useFavicon("CRASHED")); + + expect(favicon16Dark.getAttribute("href")).toBe("/crashed.svg"); + expect(favicon32Dark.getAttribute("href")).toBe("/crashed.svg"); + + act(() => { + unmount(); + }); + + expect(favicon16Dark.getAttribute("href")).toBe("/favicon-16x16-dark.png"); + expect(favicon32Dark.getAttribute("href")).toBe("/favicon-32x32-dark.png"); + }); + + it("updates favicon when stateType changes", () => { + const { rerender } = renderHook(({ stateType }) => useFavicon(stateType), { + initialProps: { stateType: "PENDING" as const }, + }); + + expect(favicon16.getAttribute("href")).toBe("/pending.svg"); + expect(favicon32.getAttribute("href")).toBe("/pending.svg"); + + rerender({ stateType: "COMPLETED" as const }); + + expect(favicon16.getAttribute("href")).toBe("/completed.svg"); + expect(favicon32.getAttribute("href")).toBe("/completed.svg"); + }); + + it("handles all state types correctly", () => { + const stateTypes = [ + "SCHEDULED", + "PENDING", + "RUNNING", + "COMPLETED", + "FAILED", + "CANCELLED", + "CANCELLING", + "CRASHED", + "PAUSED", + ] as const; + + for (const stateType of stateTypes) { + const { unmount } = renderHook(() => useFavicon(stateType)); + + expect(favicon16.getAttribute("href")).toBe( + `/${stateType.toLowerCase()}.svg`, + ); + expect(favicon32.getAttribute("href")).toBe( + `/${stateType.toLowerCase()}.svg`, + ); + + act(() => { + unmount(); + }); + } + }); + + it("handles no-preference color scheme by using light mode favicons", () => { + // Mock no-preference + matchMediaMock.mockImplementation(() => ({ + matches: false, + media: "", + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + + renderHook(() => useFavicon("FAILED")); + + // Should use light mode favicons (favicon-16, favicon-32) + expect(favicon16.getAttribute("href")).toBe("/failed.svg"); + expect(favicon32.getAttribute("href")).toBe("/failed.svg"); + }); +}); From c9cb7c3caf6d0c870401f3557b3a91ff90fa7519 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 20:03:12 +0000 Subject: [PATCH 3/5] fix(ui-v2): fix TypeScript errors in use-favicon tests Co-Authored-By: alex.s@prefect.io --- ui-v2/src/hooks/use-favicon.test.ts | 102 +++++++++++++++------------- 1 file changed, 55 insertions(+), 47 deletions(-) diff --git a/ui-v2/src/hooks/use-favicon.test.ts b/ui-v2/src/hooks/use-favicon.test.ts index f188052eb1cc..5ceddb5f8b3c 100644 --- a/ui-v2/src/hooks/use-favicon.test.ts +++ b/ui-v2/src/hooks/use-favicon.test.ts @@ -2,12 +2,30 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useFavicon } from "./use-favicon"; +type FaviconArg = Parameters[0]; + describe("useFavicon", () => { let favicon16: HTMLLinkElement; let favicon32: HTMLLinkElement; let favicon16Dark: HTMLLinkElement; let favicon32Dark: HTMLLinkElement; - let matchMediaMock: ReturnType; + const originalMatchMedia = window.matchMedia; + + const createMatchMediaMock = (prefersDark: boolean) => { + return vi.fn().mockImplementation((query: string) => ({ + matches: + prefersDark === true + ? query === "(prefers-color-scheme: dark)" + : query === "(prefers-color-scheme: light)", + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; + }; beforeEach(() => { // Create mock favicon elements @@ -32,17 +50,10 @@ describe("useFavicon", () => { document.head.appendChild(favicon32Dark); // Mock matchMedia to return light mode by default - matchMediaMock = vi.fn().mockImplementation((query: string) => ({ - matches: query === "(prefers-color-scheme: light)", - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })); - window.matchMedia = matchMediaMock; + Object.defineProperty(window, "matchMedia", { + writable: true, + value: createMatchMediaMock(false), + }); }); afterEach(() => { @@ -51,6 +62,11 @@ describe("useFavicon", () => { favicon32.remove(); favicon16Dark.remove(); favicon32Dark.remove(); + // Restore original matchMedia + Object.defineProperty(window, "matchMedia", { + writable: true, + value: originalMatchMedia, + }); vi.restoreAllMocks(); }); @@ -98,16 +114,10 @@ describe("useFavicon", () => { it("uses dark favicon elements when prefers-color-scheme is dark", () => { // Mock dark mode - matchMediaMock.mockImplementation((query: string) => ({ - matches: query === "(prefers-color-scheme: dark)", - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: createMatchMediaMock(true), + }); renderHook(() => useFavicon("SCHEDULED")); @@ -117,16 +127,10 @@ describe("useFavicon", () => { it("resets to dark favicon on unmount when in dark mode", () => { // Mock dark mode - matchMediaMock.mockImplementation((query: string) => ({ - matches: query === "(prefers-color-scheme: dark)", - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })); + Object.defineProperty(window, "matchMedia", { + writable: true, + value: createMatchMediaMock(true), + }); const { unmount } = renderHook(() => useFavicon("CRASHED")); @@ -142,14 +146,15 @@ describe("useFavicon", () => { }); it("updates favicon when stateType changes", () => { - const { rerender } = renderHook(({ stateType }) => useFavicon(stateType), { - initialProps: { stateType: "PENDING" as const }, - }); + const { rerender } = renderHook( + ({ stateType }: { stateType: FaviconArg }) => useFavicon(stateType), + { initialProps: { stateType: "PENDING" } }, + ); expect(favicon16.getAttribute("href")).toBe("/pending.svg"); expect(favicon32.getAttribute("href")).toBe("/pending.svg"); - rerender({ stateType: "COMPLETED" as const }); + rerender({ stateType: "COMPLETED" }); expect(favicon16.getAttribute("href")).toBe("/completed.svg"); expect(favicon32.getAttribute("href")).toBe("/completed.svg"); @@ -185,17 +190,20 @@ describe("useFavicon", () => { }); it("handles no-preference color scheme by using light mode favicons", () => { - // Mock no-preference - matchMediaMock.mockImplementation(() => ({ - matches: false, - media: "", - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })); + // Mock no-preference (neither dark nor light matches) + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation(() => ({ + matches: false, + media: "", + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia, + }); renderHook(() => useFavicon("FAILED")); From 98e305d2ab72e93294298243016d51dec8046f2a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 20:11:14 +0000 Subject: [PATCH 4/5] refactor(ui-v2): rename useFavicon to useStateFavicon for clarity Co-Authored-By: alex.s@prefect.io --- .../task-runs/task-run-details-page/index.tsx | 4 +-- ...icon.test.ts => use-state-favicon.test.ts} | 26 +++++++++---------- .../{use-favicon.ts => use-state-favicon.ts} | 4 +-- 3 files changed, 17 insertions(+), 17 deletions(-) rename ui-v2/src/hooks/{use-favicon.test.ts => use-state-favicon.test.ts} (89%) rename ui-v2/src/hooks/{use-favicon.ts => use-state-favicon.ts} (93%) diff --git a/ui-v2/src/components/task-runs/task-run-details-page/index.tsx b/ui-v2/src/components/task-runs/task-run-details-page/index.tsx index d84b2018b39f..ebf9c01509d4 100644 --- a/ui-v2/src/components/task-runs/task-run-details-page/index.tsx +++ b/ui-v2/src/components/task-runs/task-run-details-page/index.tsx @@ -45,8 +45,8 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useFavicon } from "@/hooks/use-favicon"; import { usePageTitle } from "@/hooks/use-page-title"; +import { useStateFavicon } from "@/hooks/use-state-favicon"; type TaskRunDetailsPageProps = { id: string; @@ -73,7 +73,7 @@ export const TaskRunDetailsPage = ({ usePageTitle(taskRun?.name ? `Task Run: ${taskRun.name}` : "Task Run"); // Set favicon based on task run state - useFavicon(taskRun?.state_type); + useStateFavicon(taskRun?.state_type); useEffect(() => { if (taskRun.state_type === "RUNNING" || taskRun.state_type === "PENDING") { diff --git a/ui-v2/src/hooks/use-favicon.test.ts b/ui-v2/src/hooks/use-state-favicon.test.ts similarity index 89% rename from ui-v2/src/hooks/use-favicon.test.ts rename to ui-v2/src/hooks/use-state-favicon.test.ts index 5ceddb5f8b3c..50acbd9cfd07 100644 --- a/ui-v2/src/hooks/use-favicon.test.ts +++ b/ui-v2/src/hooks/use-state-favicon.test.ts @@ -1,10 +1,10 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useFavicon } from "./use-favicon"; +import { useStateFavicon } from "./use-state-favicon"; -type FaviconArg = Parameters[0]; +type FaviconArg = Parameters[0]; -describe("useFavicon", () => { +describe("useStateFavicon", () => { let favicon16: HTMLLinkElement; let favicon32: HTMLLinkElement; let favicon16Dark: HTMLLinkElement; @@ -71,35 +71,35 @@ describe("useFavicon", () => { }); it("sets favicon to state-specific SVG when stateType is provided", () => { - renderHook(() => useFavicon("COMPLETED")); + renderHook(() => useStateFavicon("COMPLETED")); expect(favicon16.getAttribute("href")).toBe("/completed.svg"); expect(favicon32.getAttribute("href")).toBe("/completed.svg"); }); it("converts state type to lowercase for favicon path", () => { - renderHook(() => useFavicon("FAILED")); + renderHook(() => useStateFavicon("FAILED")); expect(favicon16.getAttribute("href")).toBe("/failed.svg"); expect(favicon32.getAttribute("href")).toBe("/failed.svg"); }); it("does not change favicon when stateType is null", () => { - renderHook(() => useFavicon(null)); + renderHook(() => useStateFavicon(null)); expect(favicon16.getAttribute("href")).toBe("/favicon-16x16.png"); expect(favicon32.getAttribute("href")).toBe("/favicon-32x32.png"); }); it("does not change favicon when stateType is undefined", () => { - renderHook(() => useFavicon(undefined)); + renderHook(() => useStateFavicon(undefined)); expect(favicon16.getAttribute("href")).toBe("/favicon-16x16.png"); expect(favicon32.getAttribute("href")).toBe("/favicon-32x32.png"); }); it("resets favicon to default on unmount in light mode", () => { - const { unmount } = renderHook(() => useFavicon("RUNNING")); + const { unmount } = renderHook(() => useStateFavicon("RUNNING")); expect(favicon16.getAttribute("href")).toBe("/running.svg"); expect(favicon32.getAttribute("href")).toBe("/running.svg"); @@ -119,7 +119,7 @@ describe("useFavicon", () => { value: createMatchMediaMock(true), }); - renderHook(() => useFavicon("SCHEDULED")); + renderHook(() => useStateFavicon("SCHEDULED")); expect(favicon16Dark.getAttribute("href")).toBe("/scheduled.svg"); expect(favicon32Dark.getAttribute("href")).toBe("/scheduled.svg"); @@ -132,7 +132,7 @@ describe("useFavicon", () => { value: createMatchMediaMock(true), }); - const { unmount } = renderHook(() => useFavicon("CRASHED")); + const { unmount } = renderHook(() => useStateFavicon("CRASHED")); expect(favicon16Dark.getAttribute("href")).toBe("/crashed.svg"); expect(favicon32Dark.getAttribute("href")).toBe("/crashed.svg"); @@ -147,7 +147,7 @@ describe("useFavicon", () => { it("updates favicon when stateType changes", () => { const { rerender } = renderHook( - ({ stateType }: { stateType: FaviconArg }) => useFavicon(stateType), + ({ stateType }: { stateType: FaviconArg }) => useStateFavicon(stateType), { initialProps: { stateType: "PENDING" } }, ); @@ -174,7 +174,7 @@ describe("useFavicon", () => { ] as const; for (const stateType of stateTypes) { - const { unmount } = renderHook(() => useFavicon(stateType)); + const { unmount } = renderHook(() => useStateFavicon(stateType)); expect(favicon16.getAttribute("href")).toBe( `/${stateType.toLowerCase()}.svg`, @@ -205,7 +205,7 @@ describe("useFavicon", () => { })) as unknown as typeof window.matchMedia, }); - renderHook(() => useFavicon("FAILED")); + renderHook(() => useStateFavicon("FAILED")); // Should use light mode favicons (favicon-16, favicon-32) expect(favicon16.getAttribute("href")).toBe("/failed.svg"); diff --git a/ui-v2/src/hooks/use-favicon.ts b/ui-v2/src/hooks/use-state-favicon.ts similarity index 93% rename from ui-v2/src/hooks/use-favicon.ts rename to ui-v2/src/hooks/use-state-favicon.ts index c5b123001b5e..0047dd5d1db4 100644 --- a/ui-v2/src/hooks/use-favicon.ts +++ b/ui-v2/src/hooks/use-state-favicon.ts @@ -31,10 +31,10 @@ function getPreferredColorScheme(): "dark" | "light" | "no-preference" { * @example * ```tsx * // Set favicon based on task run state - * useFavicon(taskRun.state_type); + * useStateFavicon(taskRun.state_type); * ``` */ -export function useFavicon(stateType: StateType | null | undefined): void { +export function useStateFavicon(stateType: StateType | null | undefined): void { useEffect(() => { const colorScheme = getPreferredColorScheme(); const favicon16 = From 73405db785401f325ea8d60408bee88e535759a6 Mon Sep 17 00:00:00 2001 From: tomerqodo Date: Sun, 25 Jan 2026 12:11:06 +0200 Subject: [PATCH 5/5] update pr --- .../task-run-details/task-run-details.tsx | 7 +- ui-v2/src/hooks/use-state-favicon.test.ts | 214 ------------------ 2 files changed, 3 insertions(+), 218 deletions(-) delete mode 100644 ui-v2/src/hooks/use-state-favicon.test.ts diff --git a/ui-v2/src/components/task-runs/task-run-details/task-run-details.tsx b/ui-v2/src/components/task-runs/task-run-details/task-run-details.tsx index 80f5cc5832cc..646fcc4e01b6 100644 --- a/ui-v2/src/components/task-runs/task-run-details/task-run-details.tsx +++ b/ui-v2/src/components/task-runs/task-run-details/task-run-details.tsx @@ -85,7 +85,7 @@ export const TaskRunDetails = ({ taskRun }: TaskRunDetailsProps) => {
Run Count
-
{taskRun.run_count ?? 0}
+
{taskRun.run_count || 0}
@@ -160,7 +160,7 @@ export const TaskRunDetails = ({ taskRun }: TaskRunDetailsProps) => {
Retries
- {taskRun.empirical_policy?.retries?.toString() ?? "0"} + {taskRun.empirical_policy?.retries ?? "0"}
@@ -176,8 +176,7 @@ export const TaskRunDetails = ({ taskRun }: TaskRunDetailsProps) => {
Retry Jitter Factor
- {taskRun.empirical_policy?.retry_jitter_factor !== null && - taskRun.empirical_policy?.retry_jitter_factor !== undefined + {taskRun.empirical_policy?.retry_jitter_factor ? taskRun.empirical_policy.retry_jitter_factor.toString() : "None"}
diff --git a/ui-v2/src/hooks/use-state-favicon.test.ts b/ui-v2/src/hooks/use-state-favicon.test.ts deleted file mode 100644 index 50acbd9cfd07..000000000000 --- a/ui-v2/src/hooks/use-state-favicon.test.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { act, renderHook } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { useStateFavicon } from "./use-state-favicon"; - -type FaviconArg = Parameters[0]; - -describe("useStateFavicon", () => { - let favicon16: HTMLLinkElement; - let favicon32: HTMLLinkElement; - let favicon16Dark: HTMLLinkElement; - let favicon32Dark: HTMLLinkElement; - const originalMatchMedia = window.matchMedia; - - const createMatchMediaMock = (prefersDark: boolean) => { - return vi.fn().mockImplementation((query: string) => ({ - matches: - prefersDark === true - ? query === "(prefers-color-scheme: dark)" - : query === "(prefers-color-scheme: light)", - media: query, - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })) as unknown as typeof window.matchMedia; - }; - - beforeEach(() => { - // Create mock favicon elements - favicon16 = document.createElement("link"); - favicon16.id = "favicon-16"; - favicon16.setAttribute("href", "/favicon-16x16.png"); - document.head.appendChild(favicon16); - - favicon32 = document.createElement("link"); - favicon32.id = "favicon-32"; - favicon32.setAttribute("href", "/favicon-32x32.png"); - document.head.appendChild(favicon32); - - favicon16Dark = document.createElement("link"); - favicon16Dark.id = "favicon-16-dark"; - favicon16Dark.setAttribute("href", "/favicon-16x16-dark.png"); - document.head.appendChild(favicon16Dark); - - favicon32Dark = document.createElement("link"); - favicon32Dark.id = "favicon-32-dark"; - favicon32Dark.setAttribute("href", "/favicon-32x32-dark.png"); - document.head.appendChild(favicon32Dark); - - // Mock matchMedia to return light mode by default - Object.defineProperty(window, "matchMedia", { - writable: true, - value: createMatchMediaMock(false), - }); - }); - - afterEach(() => { - // Clean up favicon elements - favicon16.remove(); - favicon32.remove(); - favicon16Dark.remove(); - favicon32Dark.remove(); - // Restore original matchMedia - Object.defineProperty(window, "matchMedia", { - writable: true, - value: originalMatchMedia, - }); - vi.restoreAllMocks(); - }); - - it("sets favicon to state-specific SVG when stateType is provided", () => { - renderHook(() => useStateFavicon("COMPLETED")); - - expect(favicon16.getAttribute("href")).toBe("/completed.svg"); - expect(favicon32.getAttribute("href")).toBe("/completed.svg"); - }); - - it("converts state type to lowercase for favicon path", () => { - renderHook(() => useStateFavicon("FAILED")); - - expect(favicon16.getAttribute("href")).toBe("/failed.svg"); - expect(favicon32.getAttribute("href")).toBe("/failed.svg"); - }); - - it("does not change favicon when stateType is null", () => { - renderHook(() => useStateFavicon(null)); - - expect(favicon16.getAttribute("href")).toBe("/favicon-16x16.png"); - expect(favicon32.getAttribute("href")).toBe("/favicon-32x32.png"); - }); - - it("does not change favicon when stateType is undefined", () => { - renderHook(() => useStateFavicon(undefined)); - - expect(favicon16.getAttribute("href")).toBe("/favicon-16x16.png"); - expect(favicon32.getAttribute("href")).toBe("/favicon-32x32.png"); - }); - - it("resets favicon to default on unmount in light mode", () => { - const { unmount } = renderHook(() => useStateFavicon("RUNNING")); - - expect(favicon16.getAttribute("href")).toBe("/running.svg"); - expect(favicon32.getAttribute("href")).toBe("/running.svg"); - - act(() => { - unmount(); - }); - - expect(favicon16.getAttribute("href")).toBe("/favicon-16x16.png"); - expect(favicon32.getAttribute("href")).toBe("/favicon-32x32.png"); - }); - - it("uses dark favicon elements when prefers-color-scheme is dark", () => { - // Mock dark mode - Object.defineProperty(window, "matchMedia", { - writable: true, - value: createMatchMediaMock(true), - }); - - renderHook(() => useStateFavicon("SCHEDULED")); - - expect(favicon16Dark.getAttribute("href")).toBe("/scheduled.svg"); - expect(favicon32Dark.getAttribute("href")).toBe("/scheduled.svg"); - }); - - it("resets to dark favicon on unmount when in dark mode", () => { - // Mock dark mode - Object.defineProperty(window, "matchMedia", { - writable: true, - value: createMatchMediaMock(true), - }); - - const { unmount } = renderHook(() => useStateFavicon("CRASHED")); - - expect(favicon16Dark.getAttribute("href")).toBe("/crashed.svg"); - expect(favicon32Dark.getAttribute("href")).toBe("/crashed.svg"); - - act(() => { - unmount(); - }); - - expect(favicon16Dark.getAttribute("href")).toBe("/favicon-16x16-dark.png"); - expect(favicon32Dark.getAttribute("href")).toBe("/favicon-32x32-dark.png"); - }); - - it("updates favicon when stateType changes", () => { - const { rerender } = renderHook( - ({ stateType }: { stateType: FaviconArg }) => useStateFavicon(stateType), - { initialProps: { stateType: "PENDING" } }, - ); - - expect(favicon16.getAttribute("href")).toBe("/pending.svg"); - expect(favicon32.getAttribute("href")).toBe("/pending.svg"); - - rerender({ stateType: "COMPLETED" }); - - expect(favicon16.getAttribute("href")).toBe("/completed.svg"); - expect(favicon32.getAttribute("href")).toBe("/completed.svg"); - }); - - it("handles all state types correctly", () => { - const stateTypes = [ - "SCHEDULED", - "PENDING", - "RUNNING", - "COMPLETED", - "FAILED", - "CANCELLED", - "CANCELLING", - "CRASHED", - "PAUSED", - ] as const; - - for (const stateType of stateTypes) { - const { unmount } = renderHook(() => useStateFavicon(stateType)); - - expect(favicon16.getAttribute("href")).toBe( - `/${stateType.toLowerCase()}.svg`, - ); - expect(favicon32.getAttribute("href")).toBe( - `/${stateType.toLowerCase()}.svg`, - ); - - act(() => { - unmount(); - }); - } - }); - - it("handles no-preference color scheme by using light mode favicons", () => { - // Mock no-preference (neither dark nor light matches) - Object.defineProperty(window, "matchMedia", { - writable: true, - value: vi.fn().mockImplementation(() => ({ - matches: false, - media: "", - onchange: null, - addListener: vi.fn(), - removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn(), - })) as unknown as typeof window.matchMedia, - }); - - renderHook(() => useStateFavicon("FAILED")); - - // Should use light mode favicons (favicon-16, favicon-32) - expect(favicon16.getAttribute("href")).toBe("/failed.svg"); - expect(favicon32.getAttribute("href")).toBe("/failed.svg"); - }); -});