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
20 changes: 20 additions & 0 deletions .patterns/frontend-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ Routes fall into two visibility classes:

`/lab/*` is a third, deliberately-public class — see below.

### A product's sections are path segments, not page state

A product zone that switches between sections gives each one a URL under the product's
segment. `/divan` is the çaylak roster and `/divan/raporlar` the reports pane; `App.tsx`
mounts both off the `DIVAN_PATH` / `DIVAN_RAPORLAR_PATH` constants in
[`divanSection.ts`](../apps/web/src/components/divan/divanSection.ts), passing the section
down as a prop, so the page parses no path itself. The in-page and Subnav switchers
navigate rather than set state.

Two things this buys, and one it does not:

- A moderator can link or reload into raporlar.
- `review-ui` can paint the pane. Its renderer navigates and never clicks, so a section
held in `useState` is invisible to the design gate however the preview is seeded —
that is the defect this shape retired (#8776).
- It buys **no entitlement**. `visibleDivanSection` folds a raporlar URL down to the roster
whenever the server's `isModerator` is not true, and the fold is a render decision, not a
redirect: `me` reads `false` while it is still unread, so a redirect would spend a
moderator's own URL on the loading frame.

## Per-product Subnav zones — nested layout routes

Each product (`/sozluk`, `/pano`, `/mecmua`, `/divan`) mounts its routes under a
Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {authClient, clearBearerToken, useSession} from "./auth/client";
import {useMe} from "./auth/useMe";
import {useBildirimUnread} from "./components/bildirim/useBildirimUnread";
import {DivanSubnavLayout} from "./components/divan/DivanSubnavLayout";
import {DIVAN_PATH, DIVAN_RAPORLAR_PATH} from "./components/divan/divanSection";
import {useDivanAccess} from "./components/divan/useDivanAccess";
import {useDivanPendingCount} from "./components/divan/useDivanPendingCount";
import {AppShell, Main} from "./components/layout/AppShell";
Expand Down Expand Up @@ -324,7 +325,7 @@ function LayoutContent() {
: undefined,
karma: selfKarma,
caylakMeter: meterEligible && meterStanding ? caylakMeter(meterStanding) : undefined,
divanTo: showDivan ? "/divan" : undefined,
divanTo: showDivan ? DIVAN_PATH : undefined,
divanCount: showDivan ? divanCount : undefined,
bildirim: bildirimOn && isSignedIn ? {to: "/bildirimler", unread: bildirimUnread} : undefined,
}),
Expand Down Expand Up @@ -444,7 +445,17 @@ export function App() {
<Route key="sozluk-letter" path="/sozluk/harf/:letter" element={<SozlukLetter />} />,
<Route key="sozluk-slug" path="/sozluk/:slug" element={<SozlukTermPage />} />,
];
const divanRoutes = [<Route key="divan" path="/divan" element={<DivanPage />} />];
// The section is a path segment, not page state (#8776), so both divan panes are
// addressable by URL. `raporlar` is still gated on the server's `isModerator` inside the
// page — the route only names which section the reader asked for.
const divanRoutes = [
<Route key="divan" path={DIVAN_PATH} element={<DivanPage />} />,
<Route
key="divan-raporlar"
path={DIVAN_RAPORLAR_PATH}
element={<DivanPage section="raporlar" />}
/>,
];
return (
<ThemeProvider>
<LocaleProvider>
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/components/divan/divanSection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import {describe, expect, it} from "vitest";
import {
DIVAN_PATH,
DIVAN_RAPORLAR_PATH,
divanSectionFromFilterId,
divanSectionHref,
visibleDivanSection,
} from "./divanSection";

describe("divan section hrefs", () => {
it("gives each section a URL of its own", () => {
expect(divanSectionHref("caylaklar")).toBe(DIVAN_PATH);
expect(divanSectionHref("raporlar")).toBe(DIVAN_RAPORLAR_PATH);
});

it("reads the Subnav zone's filter id back as a section", () => {
expect(divanSectionFromFilterId("raporlar")).toBe("raporlar");
expect(divanSectionFromFilterId("caylaklar")).toBe("caylaklar");
expect(divanSectionFromFilterId("bir-sey")).toBe("caylaklar");
});
});

describe("visibleDivanSection — the moderator fold", () => {
it("opens raporlar only for a moderator on the raporlar URL", () => {
expect(visibleDivanSection("raporlar", true)).toBe("raporlar");
});

// The URL is not the entitlement: an unentitled reader who types or is linked the raporlar
// path lands on the roster, same as before the path existed.
it("folds the raporlar URL down to caylaklar for a non-moderator", () => {
expect(visibleDivanSection("raporlar", false)).toBe("caylaklar");
});

it("leaves the roster URL alone for either viewer", () => {
expect(visibleDivanSection("caylaklar", true)).toBe("caylaklar");
expect(visibleDivanSection("caylaklar", false)).toBe("caylaklar");
});
});
39 changes: 39 additions & 0 deletions apps/web/src/components/divan/divanSection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* The divan's section switch, resolved DOM-free (#8776). The section used to be plain
* component state, so raporlar was reachable only by an in-page click — a renderer that
* only navigates could never paint `.kp-divan__raporlar-pane` or
* `.kp-divan__decisions-pane`, and no moderator could link or reload into them.
*
* The URL now carries the section and this module is the one place that reads it back.
* {@link visibleDivanSection} keeps the moderator gate where it already was: it folds a
* raporlar URL down to `caylaklar` for a viewer the server has not called a moderator, so
* the pane's visibility stays keyed on `isModerator` rather than on the path.
*/

export type DivanSection = "caylaklar" | "raporlar";

export const DIVAN_PATH = "/divan";
export const DIVAN_RAPORLAR_PATH = "/divan/raporlar";

export function divanSectionHref(section: DivanSection): string {
return section === "raporlar" ? DIVAN_RAPORLAR_PATH : DIVAN_PATH;
}

/**
* Which section actually renders. `isModerator` is the server's signal read off `me`, and
* it is `false` while `me` is still unread — so a moderator's direct navigation paints
* `caylaklar` for that first frame and flips once the read lands. That is why the fold is a
* render decision and not a redirect: a redirect would spend the moderator's own URL on the
* loading frame.
*/
export function visibleDivanSection(
routeSection: DivanSection,
isModerator: boolean,
): DivanSection {
return routeSection === "raporlar" && isModerator ? "raporlar" : "caylaklar";
}

/** The Subnav zone's filter ids are plain strings; anything but raporlar is the roster. */
export function divanSectionFromFilterId(id: string): DivanSection {
return id === "raporlar" ? "raporlar" : "caylaklar";
}
100 changes: 100 additions & 0 deletions apps/web/src/pages/DivanPage.route.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/**
* The divan section is addressable by URL (#8776). Before this, raporlar existed only after
* an in-page click, so a renderer that only navigates — `review-ui` — could never paint
* `.kp-divan__raporlar-pane` or `.kp-divan__decisions-pane`, and no moderator could share a
* link into them.
*
* The routes below are wired off the same `DIVAN_PATH` / `DIVAN_RAPORLAR_PATH` constants
* `App.tsx` mounts, so a path drift on either side fails here. The fate-backed children are
* stubbed inert: what is under test is which panes the route puts in the tree.
*/
import {fireEvent, render, screen} from "@testing-library/react";
import {MemoryRouter, Route, Routes, useLocation} from "react-router";
import {describe, expect, it, vi} from "vitest";
import {DIVAN_PATH, DIVAN_RAPORLAR_PATH} from "../components/divan/divanSection";
import {DivanPage} from "./DivanPage";

let isModerator = false;

vi.mock("../auth/useMe", () => ({
useMe: () => ({
me: {id: "u-1", tier: "yazar", isModerator},
status: "ok",
loading: false,
refetch: async () => {},
}),
}));

vi.mock("../components/divan/DivanRoster", () => ({
DivanRoster: () => <div data-testid="roster-stub" />,
}));
vi.mock("../components/divan/Raporlar", () => ({Raporlar: () => <div data-testid="grid-stub" />}));
vi.mock("../components/divan/TriageLoop", () => ({
TriageLoop: () => <div data-testid="loop-stub" />,
}));
vi.mock("../components/divan/DecisionFeed", () => ({
DecisionFeed: () => <div data-testid="decisions-stub" />,
}));
vi.mock("../components/divan/CaylakDetail", () => ({
CaylakDetail: () => <div data-testid="detail-stub" />,
}));

function LocationProbe() {
return <span data-testid="live-path">{useLocation().pathname}</span>;
}

function renderAt(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<LocationProbe />
<Routes>
<Route path={DIVAN_PATH} element={<DivanPage />} />
<Route path={DIVAN_RAPORLAR_PATH} element={<DivanPage section="raporlar" />} />
</Routes>
</MemoryRouter>,
);
}

describe("the divan's raporlar URL", () => {
it("mounts both mod panes for a moderator navigating straight to it, with no click", () => {
isModerator = true;
const {container} = renderAt(DIVAN_RAPORLAR_PATH);
expect(container.querySelector(".kp-divan__raporlar-pane")).toBeTruthy();
expect(container.querySelector(".kp-divan__decisions-pane")).toBeTruthy();
expect(container.querySelector(".kp-divan__decisions-title")).toBeTruthy();
expect(container.querySelector(".kp-divan__roster-pane")).toBeNull();
});

// The server's `isModerator` stays the gate — the path only names what was asked for.
it("lands a non-moderator on the roster and shows neither mod pane", () => {
isModerator = false;
const {container} = renderAt(DIVAN_RAPORLAR_PATH);
expect(container.querySelector(".kp-divan__roster-pane")).toBeTruthy();
expect(container.querySelector(".kp-divan__raporlar-pane")).toBeNull();
expect(container.querySelector(".kp-divan__decisions-pane")).toBeNull();
expect(container.querySelector(".kp-divan__nav")).toBeNull();
});

it("keeps the bare /divan URL on the roster for a moderator", () => {
isModerator = true;
const {container} = renderAt(DIVAN_PATH);
expect(container.querySelector(".kp-divan__roster-pane")).toBeTruthy();
expect(container.querySelector(".kp-divan__raporlar-pane")).toBeNull();
});

// The URL moving is what makes a reload and a shared link land back on the same pane; the
// direct-navigation test above is the other half of that round trip.
it("moves the URL when the in-page nav is clicked", () => {
isModerator = true;
const {container} = renderAt(DIVAN_PATH);
expect(screen.getByTestId("live-path").textContent).toBe(DIVAN_PATH);

fireEvent.click(screen.getByTestId("divan-nav-raporlar"));
expect(screen.getByTestId("live-path").textContent).toBe(DIVAN_RAPORLAR_PATH);
expect(container.querySelector(".kp-divan__raporlar-pane")).toBeTruthy();

fireEvent.click(screen.getByTestId("divan-nav-caylaklar"));
expect(screen.getByTestId("live-path").textContent).toBe(DIVAN_PATH);
expect(container.querySelector(".kp-divan__roster-pane")).toBeTruthy();
});
});
60 changes: 38 additions & 22 deletions apps/web/src/pages/DivanPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,27 @@
* which `<Screen>` renders as "yetkin yok". Deliberately no client-side role check.
* - It reads ONLY the `sandboxBacklogWhere` destination, never the inline `{mod, author}`
* filter, so çaylak work stays visible only inside the divan.
*
* The section comes from the route, never from component state (#8776): `/divan` is the
* roster and `/divan/raporlar` the reports pane, so both panes are addressable by URL and
* the switch survives a reload. `divanSection.ts` owns the resolution, including the
* moderator fold.
*/

import {Alert, Button} from "@kampus/design";
import {useEffect, useMemo, useState} from "react";
import {useCallback, useEffect, useMemo, useState} from "react";
import {useNavigate} from "react-router";
import {useMe} from "../auth/useMe";
import {CaylakDetail} from "../components/divan/CaylakDetail";
import {DecisionFeed} from "../components/divan/DecisionFeed";
import {DivanRoster} from "../components/divan/DivanRoster";
import {useSetDivanSubnavContent} from "../components/divan/DivanSubnavLayout";
import {
type DivanSection,
divanSectionFromFilterId,
divanSectionHref,
visibleDivanSection,
} from "../components/divan/divanSection";
import {Raporlar} from "../components/divan/Raporlar";
import {TriageLoop} from "../components/divan/TriageLoop";
import type {SubnavFilter} from "../components/layout/Subnav";
Expand All @@ -26,12 +38,17 @@ const DIVAN_SECTION_KEYS: ReadonlyArray<{readonly id: string; readonly labelKey:
{id: "raporlar", labelKey: "divan.nav.raporlar"},
];

export function DivanPage() {
return <DivanWorkspace />;
/**
* `section` is supplied by the route that mounted the page — `App.tsx` mounts `/divan`
* plainly and `/divan/raporlar` with `section="raporlar"`, so no path parsing lives here.
*/
export function DivanPage({section = "caylaklar"}: {readonly section?: DivanSection}) {
return <DivanWorkspace routeSection={section} />;
}

function DivanWorkspace() {
function DivanWorkspace({routeSection}: {readonly routeSection: DivanSection}) {
const t = useT();
const navigate = useNavigate();
const {me} = useMe();
// The open çaylak carries the roster row's viewer-scoped `viewerVouched` with it, so the
// detail's "kefil oldun" state comes off the roster's batched read rather than a second
Expand All @@ -43,13 +60,22 @@ function DivanWorkspace() {
const selectedId = selected?.authorId ?? null;
// Gate raporlar on the server-side isModerator signal, never on tier.
const raporlarVisible = me?.isModerator ?? false;
const [section, setSection] = useState<"caylaklar" | "raporlar">("caylaklar");
const showRaporlarPane = raporlarVisible && section === "raporlar";
const section = visibleDivanSection(routeSection, raporlarVisible);
const showRaporlarPane = section === "raporlar";
// The loop is the product, the grid its Esc fallback — see ADR 0138.
const [raporlarMode, setRaporlarMode] = useState<"loop" | "grid">("loop");
// Switching sections is a navigation, so a shared link and a reload both land back here.
const goToSection = useCallback(
(next: DivanSection) => {
if (next === "raporlar") setRaporlarMode("loop");
navigate(divanSectionHref(next));
},
[navigate],
);

// The page owns the switch state, so it publishes the switchers UP into divan's persistent
// Subnav zone. No zone ancestor ⇒ setter null ⇒ the in-page nav below renders instead.
// The page knows which section is live, so it publishes the switchers UP into divan's
// persistent Subnav zone. No zone ancestor ⇒ setter null ⇒ the in-page nav below renders
// instead.
const setDivanSubnav = useSetDivanSubnavContent();
const inZone = setDivanSubnav != null;

Expand All @@ -65,18 +91,11 @@ function DivanWorkspace() {
? {
filters: sectionFilters,
activeFilter: section,
onFilterChange: (id) => {
if (id === "raporlar") {
setSection("raporlar");
setRaporlarMode("loop");
} else {
setSection("caylaklar");
}
},
onFilterChange: (id) => goToSection(divanSectionFromFilterId(id)),
}
: null,
);
}, [inZone, setDivanSubnav, raporlarVisible, section, sectionFilters]);
}, [inZone, setDivanSubnav, raporlarVisible, section, sectionFilters, goToSection]);
useEffect(() => {
return () => setDivanSubnav?.(null);
}, [setDivanSubnav]);
Expand All @@ -97,7 +116,7 @@ function DivanWorkspace() {
size="sm"
className="kp-divan__nav-tab"
aria-current={section === "caylaklar" ? "true" : undefined}
onClick={() => setSection("caylaklar")}
onClick={() => goToSection("caylaklar")}
data-testid="divan-nav-caylaklar"
>
{t("divan.nav.caylaklar")}
Expand All @@ -108,10 +127,7 @@ function DivanWorkspace() {
size="sm"
className="kp-divan__nav-tab"
aria-current={section === "raporlar" ? "true" : undefined}
onClick={() => {
setSection("raporlar");
setRaporlarMode("loop");
}}
onClick={() => goToSection("raporlar")}
data-testid="divan-nav-raporlar"
>
{t("divan.nav.raporlar")}
Expand Down
Loading