milestone 23 s3: convert features/lookup/LookupPage.tsx to stylex
This commit is contained in:
@@ -88,6 +88,8 @@ test("fetches nothing until submit, then renders the blocked verdict", async ()
|
|||||||
|
|
||||||
test("defaults the group select to the default group (id 1)", async () => {
|
test("defaults the group select to the default group (id 1)", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
const select = (await screen.findByLabelText("Group")) as HTMLSelectElement;
|
// A RAC Select names its trigger with the current value and then the label, so
|
||||||
expect(select.value).toBe("1");
|
// the selected group's name is the only thing the trigger shows.
|
||||||
|
const trigger = await screen.findByRole("button", { name: /Group$/ });
|
||||||
|
expect(trigger.textContent).toContain("default");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import { useState, type FormEvent, type ReactNode } from "react";
|
import { useState, type FormEvent, type ReactNode } from "react";
|
||||||
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
|
||||||
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { ApiError } from "@/lib/api";
|
import { ApiError } from "@/lib/api";
|
||||||
import { groupsQuery, lookupQuery } from "@/lib/queries";
|
import { groupsQuery, lookupQuery } from "@/lib/queries";
|
||||||
import type { Group, LookupResult } from "@/lib/types";
|
import type { Group, LookupResult } from "@/lib/types";
|
||||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||||
import { inputClass, largePrimaryButtonClass } from "@/ui/classes";
|
import Select from "@/ui/Select";
|
||||||
|
import { styles as shared } from "@/ui/styles";
|
||||||
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
|
|
||||||
|
const DARK = "@media (prefers-color-scheme: dark)";
|
||||||
|
|
||||||
interface Submitted {
|
interface Submitted {
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -13,10 +18,123 @@ interface Submitted {
|
|||||||
|
|
||||||
interface Verdict {
|
interface Verdict {
|
||||||
label: string;
|
label: string;
|
||||||
className: string;
|
tone: "local" | "blocked" | "forwarded" | "allowed";
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = stylex.create({
|
||||||
|
heading: {
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
lineHeight: "2rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
intro: {
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
display: "flex",
|
||||||
|
maxWidth: "42rem",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
gap: "0.75rem",
|
||||||
|
},
|
||||||
|
domainField: {
|
||||||
|
minWidth: "14rem",
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
fieldLabel: {
|
||||||
|
display: "block",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
note: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
color: colors.danger,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
marginTop: "1.5rem",
|
||||||
|
borderRadius: "0.25rem",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: colors.border,
|
||||||
|
},
|
||||||
|
banner: {
|
||||||
|
borderStartStartRadius: "0.25rem",
|
||||||
|
borderStartEndRadius: "0.25rem",
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.75rem",
|
||||||
|
},
|
||||||
|
/** Four verdicts need four tints; only "blocked" maps onto a token role. */
|
||||||
|
local: {
|
||||||
|
backgroundColor: { default: "oklch(93.2% 0.032 255.585)", [DARK]: "oklch(28.2% 0.091 267.935)" },
|
||||||
|
color: { default: "oklch(42.4% 0.199 265.638)", [DARK]: "oklch(80.9% 0.105 251.813)" },
|
||||||
|
},
|
||||||
|
blocked: {
|
||||||
|
backgroundColor: { default: "oklch(93.6% 0.032 17.717)", [DARK]: "oklch(25.8% 0.092 26.042)" },
|
||||||
|
color: { default: "oklch(44.4% 0.177 26.899)", [DARK]: "oklch(80.8% 0.114 19.571)" },
|
||||||
|
},
|
||||||
|
forwarded: {
|
||||||
|
backgroundColor: { default: "oklch(96.2% 0.059 95.617)", [DARK]: "oklch(27.9% 0.077 45.635)" },
|
||||||
|
color: { default: "oklch(47.3% 0.137 46.201)", [DARK]: "oklch(87.9% 0.169 91.605)" },
|
||||||
|
},
|
||||||
|
allowed: {
|
||||||
|
backgroundColor: { default: "oklch(96.2% 0.044 156.743)", [DARK]: "oklch(26.6% 0.065 152.934)" },
|
||||||
|
color: { default: "oklch(44.8% 0.119 151.328)", [DARK]: "oklch(87.1% 0.15 154.449)" },
|
||||||
|
},
|
||||||
|
verdictLabel: {
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
verdictDescription: {
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
paddingInline: "1rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
lineHeight: "1.25rem",
|
||||||
|
},
|
||||||
|
/** `divide-y`: a hairline between rows, so the first row carries none. */
|
||||||
|
detailRow: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "1rem",
|
||||||
|
paddingBlock: "0.5rem",
|
||||||
|
borderTopWidth: { default: 1, ":first-child": 0 },
|
||||||
|
borderTopStyle: "solid",
|
||||||
|
borderTopColor: colors.border,
|
||||||
|
},
|
||||||
|
detailTerm: {
|
||||||
|
width: "10rem",
|
||||||
|
flexShrink: 0,
|
||||||
|
color: colors.textMuted,
|
||||||
|
},
|
||||||
|
detailValue: {
|
||||||
|
minWidth: 0,
|
||||||
|
overflowWrap: "break-word",
|
||||||
|
},
|
||||||
|
sourceLink: {
|
||||||
|
color: colors.primaryOnSurface,
|
||||||
|
textDecorationLine: "underline",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function toneStyle(tone: Verdict["tone"]) {
|
||||||
|
if (tone === "local") return styles.local;
|
||||||
|
if (tone === "blocked") return styles.blocked;
|
||||||
|
return tone === "forwarded" ? styles.forwarded : styles.allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header priority follows the pipeline order the lookup handler documents
|
* Header priority follows the pipeline order the lookup handler documents
|
||||||
* (PLAN §6): local records answer first, then the block decision, then
|
* (PLAN §6): local records answer first, then the block decision, then
|
||||||
@@ -26,27 +144,27 @@ export function verdictOf(result: LookupResult): Verdict {
|
|||||||
if (result.local_records) {
|
if (result.local_records) {
|
||||||
return {
|
return {
|
||||||
label: "Local answer",
|
label: "Local answer",
|
||||||
className: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300",
|
tone: "local",
|
||||||
description: "A local record answers this name directly.",
|
description: "A local record answers this name directly.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (result.blocked) {
|
if (result.blocked) {
|
||||||
return {
|
return {
|
||||||
label: "Blocked",
|
label: "Blocked",
|
||||||
className: "bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300",
|
tone: "blocked",
|
||||||
description: "Queries for this name get a blocked response.",
|
description: "Queries for this name get a blocked response.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (result.forward_zone !== null) {
|
if (result.forward_zone !== null) {
|
||||||
return {
|
return {
|
||||||
label: "Forwarded",
|
label: "Forwarded",
|
||||||
className: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300",
|
tone: "forwarded",
|
||||||
description: `Queries go to the resolver for zone ${result.forward_zone}.`,
|
description: `Queries go to the resolver for zone ${result.forward_zone}.`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
label: "Allowed",
|
label: "Allowed",
|
||||||
className: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300",
|
tone: "allowed",
|
||||||
description: "Queries resolve through the upstream pool.",
|
description: "Queries resolve through the upstream pool.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -68,9 +186,9 @@ function errorMessage(error: unknown): string {
|
|||||||
|
|
||||||
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 py-2">
|
<div {...stylex.props(styles.detailRow)}>
|
||||||
<dt className="w-40 shrink-0 text-zinc-500">{label}</dt>
|
<dt {...stylex.props(styles.detailTerm)}>{label}</dt>
|
||||||
<dd className="min-w-0 break-words">{children}</dd>
|
<dd {...stylex.props(styles.detailValue)}>{children}</dd>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,26 +198,30 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
|
|||||||
const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`;
|
const groupName = groups.find((group) => group.id === result.group_id)?.name ?? `#${result.group_id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 rounded border border-zinc-200 dark:border-zinc-800">
|
<div {...stylex.props(styles.card)}>
|
||||||
<div className={`rounded-t px-4 py-3 ${verdict.className}`}>
|
<div {...stylex.props(styles.banner, toneStyle(verdict.tone))}>
|
||||||
<h2 className="text-lg font-semibold">{verdict.label}</h2>
|
<h2 {...stylex.props(styles.verdictLabel)}>{verdict.label}</h2>
|
||||||
<p className="text-sm">{verdict.description}</p>
|
<p {...stylex.props(styles.verdictDescription)}>{verdict.description}</p>
|
||||||
</div>
|
</div>
|
||||||
<dl className="divide-y divide-zinc-100 px-4 py-2 text-sm dark:divide-zinc-900">
|
<dl {...stylex.props(styles.details)}>
|
||||||
<DetailRow label="Domain">
|
<DetailRow label="Domain">
|
||||||
<span className="font-mono">{result.domain}</span>
|
<span {...stylex.props(shared.mono)}>{result.domain}</span>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label="Group">{groupName}</DetailRow>
|
<DetailRow label="Group">{groupName}</DetailRow>
|
||||||
<DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow>
|
<DetailRow label="Local record">{result.local_records ? "Yes" : "No"}</DetailRow>
|
||||||
<DetailRow label="Forward zone">
|
<DetailRow label="Forward zone">
|
||||||
{result.forward_zone !== null ? <span className="font-mono">{result.forward_zone}</span> : "—"}
|
{result.forward_zone !== null ? (
|
||||||
|
<span {...stylex.props(shared.mono)}>{result.forward_zone}</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow>
|
<DetailRow label="Blocked">{result.blocked ? "Yes" : "No"}</DetailRow>
|
||||||
<DetailRow label="Reason">
|
<DetailRow label="Reason">
|
||||||
<span className="font-mono">{result.reason}</span>
|
<span {...stylex.props(shared.mono)}>{result.reason}</span>
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label="Matched pattern">
|
<DetailRow label="Matched pattern">
|
||||||
{result.matched !== "" ? <span className="font-mono">{result.matched}</span> : "—"}
|
{result.matched !== "" ? <span {...stylex.props(shared.mono)}>{result.matched}</span> : "—"}
|
||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label="Blocklist source">
|
<DetailRow label="Blocklist source">
|
||||||
{result.source_url !== null ? (
|
{result.source_url !== null ? (
|
||||||
@@ -107,7 +229,7 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
|
|||||||
href={result.source_url}
|
href={result.source_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-blue-600 underline dark:text-blue-400"
|
{...stylex.props(styles.sourceLink, shared.focusRing)}
|
||||||
>
|
>
|
||||||
{result.source_url}
|
{result.source_url}
|
||||||
</a>
|
</a>
|
||||||
@@ -117,7 +239,7 @@ function VerdictCard({ result, groups }: { result: LookupResult; groups: Group[]
|
|||||||
</DetailRow>
|
</DetailRow>
|
||||||
<DetailRow label="Safe search rewrite">
|
<DetailRow label="Safe search rewrite">
|
||||||
{result.safe_search_rewrite !== null ? (
|
{result.safe_search_rewrite !== null ? (
|
||||||
<span className="font-mono">{result.safe_search_rewrite}</span>
|
<span {...stylex.props(shared.mono)}>{result.safe_search_rewrite}</span>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
@@ -153,13 +275,13 @@ export default function LookupPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1 className="text-2xl font-semibold">Lookup</h1>
|
<h1 {...stylex.props(styles.heading)}>Lookup</h1>
|
||||||
<p className="mt-2 text-zinc-500">
|
<p {...stylex.props(styles.intro)}>
|
||||||
What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
|
What the pipeline would do with a domain: local records, forward zones, block decision, safe search.
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={onSubmit} className="mt-6 flex max-w-2xl flex-wrap items-end gap-3">
|
<form onSubmit={onSubmit} {...stylex.props(styles.form)}>
|
||||||
<div className="min-w-56 grow">
|
<div {...stylex.props(styles.domainField)}>
|
||||||
<label htmlFor="lookup-domain" className="block text-sm font-medium">
|
<label htmlFor="lookup-domain" {...stylex.props(styles.fieldLabel)}>
|
||||||
Domain
|
Domain
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -168,33 +290,28 @@ export default function LookupPage() {
|
|||||||
value={domain}
|
value={domain}
|
||||||
onChange={(event) => setDomain(event.target.value)}
|
onChange={(event) => setDomain(event.target.value)}
|
||||||
placeholder="ads.example.com"
|
placeholder="ads.example.com"
|
||||||
className={inputClass}
|
{...stylex.props(shared.input, shared.focusRing)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="lookup-group" className="block text-sm font-medium">
|
<Select
|
||||||
Group
|
label="Group"
|
||||||
</label>
|
value={String(groupId)}
|
||||||
<select
|
onChange={(value) => setGroupId(Number(value))}
|
||||||
id="lookup-group"
|
options={groups.map((group) => ({ value: String(group.id), label: group.name }))}
|
||||||
value={groupId}
|
/>
|
||||||
onChange={(event) => setGroupId(Number(event.target.value))}
|
|
||||||
className={inputClass}
|
|
||||||
>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<option key={group.id} value={group.id}>
|
|
||||||
{group.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className={largePrimaryButtonClass} disabled={lookup.isFetching}>
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={lookup.isFetching}
|
||||||
|
{...stylex.props(shared.largePrimaryButton, shared.focusRing)}
|
||||||
|
>
|
||||||
Look up
|
Look up
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{lookup.isFetching && <p className="mt-6 text-zinc-500">Looking up…</p>}
|
{lookup.isFetching && <p {...stylex.props(styles.note)}>Looking up…</p>}
|
||||||
{!lookup.isFetching && lookup.isError && (
|
{!lookup.isFetching && lookup.isError && (
|
||||||
<p role="alert" className="mt-6 text-sm text-red-600 dark:text-red-400">
|
<p role="alert" {...stylex.props(styles.error)}>
|
||||||
{errorMessage(lookup.error)}
|
{errorMessage(lookup.error)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user