Skip to content

Commit 49c4aa7

Browse files
fix(frontend): preserve edit focus and assert filter URL contract
Only focus the stack detail heading after route loads, not successful saves. Strengthen filter tests to assert location.search, history restoration, and stale query removal.
1 parent b5e7c4f commit 49c4aa7

2 files changed

Lines changed: 183 additions & 6 deletions

File tree

frontend/src/features/stack/stack-detail-screen.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export function StackDetailScreen() {
2525
});
2626
const [reloadToken, setReloadToken] = useState(0);
2727
const headingRef = useRef<HTMLHeadingElement>(null);
28+
const focusHeadingOnReadyRef = useRef(false);
2829

2930
const reload = useCallback(() => {
3031
setReloadToken((current) => current + 1);
@@ -57,6 +58,7 @@ export function StackDetailScreen() {
5758
listStates("stack", signal),
5859
])
5960
.then(([stack, states]) => {
61+
focusHeadingOnReadyRef.current = true;
6062
setLoadState({ kind: "ready", data: { stack, states } });
6163
})
6264
.catch((error: unknown) => {
@@ -79,10 +81,11 @@ export function StackDetailScreen() {
7981
}, [stackId, reloadToken]);
8082

8183
useEffect(() => {
82-
if (loadState.kind !== "ready") {
84+
if (loadState.kind !== "ready" || !focusHeadingOnReadyRef.current) {
8385
return;
8486
}
8587

88+
focusHeadingOnReadyRef.current = false;
8689
headingRef.current?.focus();
8790
}, [loadState]);
8891

frontend/tests/stack-list.test.tsx

Lines changed: 179 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,28 @@ import {
77
} from "@testing-library/react";
88
import { userEvent } from "@testing-library/user-event";
99
import { afterEach, describe, expect, it, vi } from "vitest";
10-
import { MemoryRouter } from "react-router";
10+
import { MemoryRouter, useLocation } from "react-router";
11+
import type { RenderResult } from "@testing-library/react";
1112
import { App } from "../src/app/app.tsx";
1213
import type { Stack } from "../src/api/stacks.ts";
1314
import type { State } from "../src/api/states.ts";
1415

16+
function LocationProbe() {
17+
const location = useLocation();
18+
19+
return (
20+
<div
21+
data-testid="location-probe"
22+
data-search={location.search}
23+
data-pathname={location.pathname}
24+
hidden
25+
/>
26+
);
27+
}
28+
29+
const readLocationSearch = () =>
30+
screen.getByTestId("location-probe").getAttribute("data-search") ?? "";
31+
1532
const healthResponse = () =>
1633
new Response(JSON.stringify({ status: "ok", database: "ok" }), {
1734
status: 200,
@@ -55,12 +72,22 @@ type FetchHandler = (
5572
init?: RequestInit,
5673
) => Response | Promise<Response>;
5774

58-
const renderApp = (initialEntry: string) =>
59-
render(
60-
<MemoryRouter initialEntries={[initialEntry]}>
75+
const renderApp = (
76+
initialEntry: string | string[],
77+
initialIndex?: number,
78+
): RenderResult => {
79+
const entries = Array.isArray(initialEntry) ? initialEntry : [initialEntry];
80+
81+
return render(
82+
<MemoryRouter
83+
initialEntries={entries}
84+
initialIndex={initialIndex ?? entries.length - 1}
85+
>
86+
<LocationProbe />
6187
<App />
6288
</MemoryRouter>,
6389
);
90+
};
6491

6592
const mockFetch = (handler: FetchHandler) => {
6693
vi.stubGlobal(
@@ -590,6 +617,10 @@ describe("stack list screen", () => {
590617
renderApp(`/?stateId=${stackStates[1]!.id}`);
591618

592619
await waitFor(() => {
620+
expect(readLocationSearch()).toBe(
621+
`?stateId=${stackStates[1]!.id}`,
622+
);
623+
593624
const stackList = screen.getByRole("list");
594625
expect(
595626
within(stackList).getByRole("link", { name: /Side project/ }),
@@ -625,11 +656,16 @@ describe("stack list screen", () => {
625656

626657
await waitFor(() => {
627658
expect(screen.getByRole("list")).toBeInTheDocument();
659+
expect(readLocationSearch()).toBe("");
628660
});
629661

630662
await user.click(screen.getByRole("radio", { name: "Active" }));
631663

632664
await waitFor(() => {
665+
expect(readLocationSearch()).toBe(
666+
`?stateId=${stackStates[1]!.id}`,
667+
);
668+
633669
const stackList = screen.getByRole("list");
634670
expect(
635671
within(stackList).getByRole("link", { name: /Side project/ }),
@@ -642,6 +678,8 @@ describe("stack list screen", () => {
642678
await user.click(screen.getByRole("radio", { name: "All" }));
643679

644680
await waitFor(() => {
681+
expect(readLocationSearch()).toBe("");
682+
645683
const stackList = screen.getByRole("list");
646684
expect(
647685
within(stackList).getByRole("link", { name: /Stackdraft/ }),
@@ -652,16 +690,94 @@ describe("stack list screen", () => {
652690
});
653691
});
654692

693+
it("restores filtered stacks from browser history navigation", async () => {
694+
mockFetch(
695+
defaultStacksHandler({
696+
stacks: [
697+
existingStack,
698+
{
699+
...existingStack,
700+
id: "00000000-0000-4000-8000-000000000011",
701+
title: "Side project",
702+
description: "",
703+
stateId: stackStates[1]!.id,
704+
},
705+
],
706+
}),
707+
);
708+
709+
const user = userEvent.setup();
710+
const { unmount } = renderApp("/");
711+
712+
await waitFor(() => {
713+
expect(screen.getByRole("list")).toBeInTheDocument();
714+
});
715+
716+
await user.click(screen.getByRole("radio", { name: "Active" }));
717+
718+
await waitFor(() => {
719+
expect(readLocationSearch()).toBe(
720+
`?stateId=${stackStates[1]!.id}`,
721+
);
722+
723+
const stackList = screen.getByRole("list");
724+
expect(
725+
within(stackList).getByRole("link", { name: /Side project/ }),
726+
).toBeInTheDocument();
727+
expect(
728+
within(stackList).queryByRole("link", { name: /Stackdraft/ }),
729+
).not.toBeInTheDocument();
730+
});
731+
732+
unmount();
733+
734+
cleanup();
735+
renderApp(["/", `/?stateId=${stackStates[1]!.id}`], 0);
736+
737+
await waitFor(() => {
738+
expect(readLocationSearch()).toBe("");
739+
740+
const stackList = screen.getByRole("list");
741+
expect(
742+
within(stackList).getByRole("link", { name: /Stackdraft/ }),
743+
).toBeInTheDocument();
744+
expect(
745+
within(stackList).getByRole("link", { name: /Side project/ }),
746+
).toBeInTheDocument();
747+
});
748+
749+
cleanup();
750+
renderApp(["/", `/?stateId=${stackStates[1]!.id}`], 1);
751+
752+
await waitFor(() => {
753+
expect(readLocationSearch()).toBe(
754+
`?stateId=${stackStates[1]!.id}`,
755+
);
756+
757+
const stackList = screen.getByRole("list");
758+
expect(
759+
within(stackList).getByRole("link", { name: /Side project/ }),
760+
).toBeInTheDocument();
761+
expect(
762+
within(stackList).queryByRole("link", { name: /Stackdraft/ }),
763+
).not.toBeInTheDocument();
764+
});
765+
});
766+
655767
it("clears a stale state filter from the URL and shows all stacks", async () => {
656768
mockFetch(
657769
defaultStacksHandler({
658770
stacks: [existingStack],
659771
}),
660772
);
661773

662-
renderApp("/?stateId=00000000-0000-4000-8000-000000009999");
774+
renderApp(
775+
"/?stateId=00000000-0000-4000-8000-000000009999",
776+
);
663777

664778
await waitFor(() => {
779+
expect(readLocationSearch()).toBe("");
780+
665781
const stackList = screen.getByRole("list");
666782
expect(
667783
within(stackList).getByRole("link", { name: /Stackdraft/ }),
@@ -842,6 +958,64 @@ describe("stack detail screen", () => {
842958
});
843959
});
844960

961+
it("does not move focus to the heading after a successful save", async () => {
962+
mockFetch((input, init) => {
963+
const url = new URL(String(input), "http://stackdraft.local");
964+
const method = init?.method ?? "GET";
965+
966+
if (
967+
url.pathname === `/api/stacks/${existingStack.id}` &&
968+
method === "PATCH"
969+
) {
970+
return Promise.resolve(
971+
new Response(
972+
JSON.stringify({
973+
...existingStack,
974+
title: "Saved title",
975+
updatedAt: "2026-01-03T00:00:00.000Z",
976+
}),
977+
{
978+
status: 200,
979+
headers: { "Content-Type": "application/json" },
980+
},
981+
),
982+
);
983+
}
984+
985+
return Promise.resolve(
986+
defaultStacksHandler({ stacks: [existingStack] })(
987+
input,
988+
init,
989+
),
990+
);
991+
});
992+
993+
const user = userEvent.setup();
994+
renderApp(`/stacks/${existingStack.id}`);
995+
996+
const editForm = await screen.findByRole("form", { name: "Edit Stack" });
997+
const submitButton = within(editForm).getByRole("button", {
998+
name: "Save changes",
999+
});
1000+
const heading = await screen.findByRole("heading", {
1001+
name: "Stackdraft",
1002+
level: 1,
1003+
});
1004+
1005+
await user.clear(within(editForm).getByLabelText("Title"));
1006+
await user.type(within(editForm).getByLabelText("Title"), "Saved title");
1007+
await user.click(submitButton);
1008+
1009+
await waitFor(() => {
1010+
expect(
1011+
screen.getByRole("heading", { name: "Saved title", level: 1 }),
1012+
).toBeInTheDocument();
1013+
});
1014+
1015+
expect(heading).not.toHaveFocus();
1016+
expect(submitButton).toHaveFocus();
1017+
});
1018+
8451019
it("sends the default state id when changing back from a non-default state", async () => {
8461020
const activeStack: Stack = {
8471021
...existingStack,

0 commit comments

Comments
 (0)