Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"@types/node": "^22.13.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"happy-dom": "^20.11.2",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"typescript": "^5.9.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React, { useState, useEffect, useMemo, useCallback } from "react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Rocket, Activity, CheckCircle2, XCircle, Loader2, Zap, ArrowRight } from "lucide-react";
import { deployApi, projectsApi } from "@/lib/api";
import { useI18n, interpolate } from "@/components/i18n-provider";
Expand All @@ -16,6 +17,40 @@ import {
mapRowToDeployment,
} from "../utils";

type StatusFilter =
| "all"
| "success"
| "failed"
| "building"
| "pending"
| "canceled";

const STATUS_FILTERS: readonly StatusFilter[] = [
"all",
"success",
"failed",
"building",
"pending",
"canceled",
];

/** Query keys the standalone /deployments view keeps its filters in. */
const P_STATUS = "status";
const P_PROJECT = "project";
const P_QUERY = "q";

/** Only accept a status the filter actually has, so a hand-edited URL can't wedge
* the list on a value `filterDeployments` will never match. */
function readStatus(raw: string | null): StatusFilter {
return STATUS_FILTERS.includes(raw as StatusFilter) ? (raw as StatusFilter) : "all";
}

/** Keep the URL clean: a filter at its default is absent, not `?status=all`. */
function setOrDelete(params: URLSearchParams, key: string, value: string, dflt: string) {
if (value === dflt) params.delete(key);
else params.set(key, value);
}

interface DeploymentsContentProps {
/** When set, scope to this project and hide the project selector */
projectId?: string;
Expand All @@ -35,15 +70,54 @@ export const DeploymentsContent: React.FC<DeploymentsContentProps> = ({
}) => {
const { t } = useI18n();
const isProject = !!projectId;
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();

/**
* Filters are mirrored into the URL on the standalone /deployments view, so
* opening a deployment and pressing Back returns to the list you were looking at.
* They used to be component state only: Back remounts this component, every
* filter reset to "all", and a filtered-down list silently became the full one.
*
* Scoped to the standalone view on purpose. Embedded in a project (`isProject`)
* the list is already one project's, the project selector is hidden, and that
* page owns its own URL — it rewrites it to `/projects/:id/:tab` after reading
* its params, which would strip anything written here and fight the sync below.
*
* Read once per mount: a Back navigation IS a fresh mount, which is exactly when
* the URL should seed the state.
*/
const urlFilters = !isProject;

const [deployments, setDeployments] = useState<Deployment[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState<
"all" | "success" | "failed" | "building" | "pending" | "canceled"
>("all");
const [searchQuery, setSearchQuery] = useState("");
const [selectedProjectId, setSelectedProjectId] = useState<string | "all">("all");
const [filter, setFilter] = useState<StatusFilter>(() =>
urlFilters ? readStatus(searchParams.get(P_STATUS)) : "all",
);
const [searchQuery, setSearchQuery] = useState(() =>
urlFilters ? (searchParams.get(P_QUERY) ?? "") : "",
);
const [selectedProjectId, setSelectedProjectId] = useState<string | "all">(() =>
urlFilters ? (searchParams.get(P_PROJECT) ?? "all") : "all",
);

// Write the current filters back to the URL. `replace`, not `push`, so filtering
// never builds up history entries the Back button has to chew through — and
// `scroll: false` so re-filtering doesn't jump the list to the top. Bails when the
// query string is already correct, which is the mount case: no redundant
// navigation, and no loop with the effect's own dependency on searchParams.
useEffect(() => {
if (!urlFilters) return;
const next = new URLSearchParams(Array.from(searchParams.entries()));
setOrDelete(next, P_STATUS, filter, "all");
setOrDelete(next, P_QUERY, searchQuery, "");
setOrDelete(next, P_PROJECT, selectedProjectId, "all");
const qs = next.toString();
if (qs === searchParams.toString()) return;
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
}, [urlFilters, filter, searchQuery, selectedProjectId, searchParams, pathname, router]);

const fetchDeployments = useCallback(async () => {
setIsLoading(true);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// @vitest-environment happy-dom
/**
* Deployment filters must survive a Back navigation.
*
* Reported repro: open /deployments, filter by one project, click a deployment, hit
* Back — the list came back showing ALL projects. The filters were component state
* only, and Back remounts this component, so every one of them reset to its default
* and a deliberately narrowed list silently became the full one.
*
* The fix mirrors them into the query string, so this asserts both halves: changing
* a filter writes the URL, and mounting with that URL restores the filter (which is
* what a Back navigation actually does — it remounts at the previous URL).
*/
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { I18nProvider } from "@/components/i18n-provider";

(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

/** Every URL the component asked the router to put in the address bar. */
let replaced: string[] = [];
let searchParams = new URLSearchParams();

vi.mock("next/navigation", () => ({
useRouter: () => ({
replace: (url: string) => replaced.push(url),
push: (url: string) => replaced.push(url),
back: () => {},
refresh: () => {},
}),
usePathname: () => "/deployments",
useSearchParams: () => searchParams,
}));

// The row's overflow menu is stubbed for ONE reason: it imports `@/utils/icons`,
// which is JSX inside a .js file that the test transform cannot parse, so pulling it
// in takes down any test that renders a deployment card (the monitoring suite
// documents the same constraint). Nothing here asserts on the menu; the card itself
// stays real, because the card is what proves a filter was applied.
vi.mock("./DeploymentMenu", () => ({ DeploymentMenu: () => null }));

const DEPLOYMENTS = [
{
id: "d1",
projectId: "p1",
projectName: "alpha",
status: "success",
createdAt: "2026-08-11T10:00:00Z",
},
{
id: "d2",
projectId: "p2",
projectName: "beta",
status: "failed",
createdAt: "2026-08-11T11:00:00Z",
},
];

function stubFetch() {
return vi.fn(async (input: unknown) => {
const url = String(typeof input === "string" ? input : (input as Request)?.url ?? input);
const json = (body: unknown) =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
});
if (url.includes("deployments")) return json({ data: DEPLOYMENTS });
return json({ data: [] });
});
}

let container: HTMLDivElement;
let root: Root | undefined;
const errors: unknown[] = [];

beforeEach(() => {
errors.length = 0;
replaced = [];
searchParams = new URLSearchParams();
vi.stubGlobal("fetch", stubFetch());
container = document.createElement("div");
document.body.appendChild(container);
});

afterEach(() => {
if (root) act(() => root!.unmount());
root = undefined;
container.remove();
vi.unstubAllGlobals();
});

async function mountDeployments() {
const { DeploymentsContent } = await import("./DeploymentsContent");
await act(async () => {
root = createRoot(container, {
onUncaughtError: (e) => errors.push(e),
onCaughtError: (e) => errors.push(e),
});
root.render(
<I18nProvider>
<DeploymentsContent />
</I18nProvider>,
);
});
for (let i = 0; i < 3; i++) {
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
}

/** Click the status-filter chip with this exact label. */
async function clickChip(label: RegExp) {
const chip = Array.from(container.querySelectorAll("button")).find((b) =>
label.test((b.textContent ?? "").trim()),
);
expect(chip, `a filter chip matching ${label} should render`).toBeTruthy();
await act(async () => {
chip!.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
}

describe("deployments filters ↔ URL", () => {
it("starts clean: no filter params for an unfiltered list", async () => {
await mountDeployments();
expect(errors).toEqual([]);
// A default view must not rewrite the URL at all — otherwise every visit
// pushes ?status=all and the 'is anything filtered' check drifts.
expect(replaced).toEqual([]);
expect(container.textContent).toContain("alpha");
expect(container.textContent).toContain("beta");
});

it("writes the status filter to the URL when it changes", async () => {
await mountDeployments();
await clickChip(/^failed$/i);

expect(errors).toEqual([]);
expect(replaced.at(-1)).toBe("/deployments?status=failed");
});

/** The actual regression: this is the state a Back navigation remounts into. */
it("restores the project filter from the URL on mount", async () => {
searchParams = new URLSearchParams({ project: "p2" });
await mountDeployments();

expect(errors).toEqual([]);
// Only the filtered project's deployment survives...
expect(container.textContent).toContain("beta");
expect(container.textContent).not.toContain("alpha");
// ...and restoring must not itself rewrite the URL.
expect(replaced).toEqual([]);
});

it("restores the status filter from the URL on mount", async () => {
searchParams = new URLSearchParams({ status: "failed" });
await mountDeployments();

expect(errors).toEqual([]);
expect(container.textContent).toContain("beta");
expect(container.textContent).not.toContain("alpha");
expect(replaced).toEqual([]);
});

it("ignores a status the filter doesn't have instead of emptying the list", async () => {
searchParams = new URLSearchParams({ status: "not-a-status" });
await mountDeployments();

expect(errors).toEqual([]);
// Falls back to "all" — a hand-edited or stale URL must not wedge the view on a
// value nothing will ever match.
expect(container.textContent).toContain("alpha");
expect(container.textContent).toContain("beta");
});

it("drops a filter param when it goes back to its default", async () => {
searchParams = new URLSearchParams({ status: "failed" });
await mountDeployments();
await clickChip(/^all$/i);

expect(errors).toEqual([]);
// Back to the default → the param is removed, not left as ?status=all.
expect(replaced.at(-1)).toBe("/deployments");
});

it("keeps unrelated query params intact", async () => {
searchParams = new URLSearchParams({ ref: "email" });
await mountDeployments();
await clickChip(/^failed$/i);

expect(errors).toEqual([]);
expect(replaced.at(-1)).toBe("/deployments?ref=email&status=failed");
});
});
Loading