feat(comments) : add newest and oldest sorting to admin inbox

This commit is contained in:
2026-10-13 00:48:43 +07:00 Unverified
parent d1dff1a763
commit 7933c08068
8 changed files with 98 additions and 7 deletions
@@ -72,6 +72,26 @@ async function click(label: string) {
function singleRequests() { return fetchMock.mock.calls.filter(([url]) => String(url) === `/api/comments/${selectedId}`); }
describe("notification conversations in the admin inbox", () => {
it("defaults to newest and keeps oldest across pagination and refresh", async () => {
await mount(page(inbox.items, "newest-page"), "");
await click("รีเฟรช");
expect(new URL(String(fetchMock.mock.calls.at(-1)![0]), "https://guide.sudloh.com").searchParams.get("sort")).toBe("newest");
inbox = page([comment("oldest")], "oldest-page");
await click("เก่าสุด");
expect(rows()).toEqual(["comment-oldest"]);
nextPage = page([comment("next-oldest")]);
await click("ดูความคิดเห็นเพิ่มเติม");
const pagination = new URL(String(fetchMock.mock.calls.at(-1)![0]), "https://guide.sudloh.com").searchParams;
expect(pagination.get("sort")).toBe("oldest");
expect(pagination.get("cursor")).toBe("oldest-page");
await click("รีเฟรช");
expect(new URL(String(fetchMock.mock.calls.at(-1)![0]), "https://guide.sudloh.com").searchParams.get("sort")).toBe("oldest");
await click("ล่าสุด");
const newest = new URL(String(fetchMock.mock.calls.at(-1)![0]), "https://guide.sudloh.com").searchParams;
expect(newest.get("sort")).toBe("newest");
expect(newest.has("cursor")).toBe(false);
});
it("moves a loaded conversation to the first normal row, expands it, and avoids a second lookup", async () => {
await mount(page([...inbox.items, selected]));
expect(rows()).toEqual([`comment-${selectedId}`, "comment-newer"]);
@@ -107,18 +127,19 @@ describe("notification conversations in the admin inbox", () => {
expect(rows()).toEqual([`comment-${selectedId}`, "comment-newer"]);
});
it.each(["visibility", "answered", "target"])("clears the notification selection when the %s filter changes", async (filter) => {
it.each(["visibility", "answered", "target", "sort"])("clears the notification selection when the %s filter changes", async (filter) => {
await mount();
inbox = page([comment("filtered")]);
if (filter === "target") {
const clear = container.querySelector<HTMLButtonElement>('[data-slot="combobox-clear"]');
expect(clear).not.toBeNull();
await act(async () => clear!.click());
} else await click(filter === "visibility" ? "ซ่อนอยู่" : "ยังไม่ได้ตอบ");
} else await click(filter === "visibility" ? "ซ่อนอยู่" : filter === "sort" ? "เก่าสุด" : "ยังไม่ได้ตอบ");
expect(rows()).toEqual(["comment-filtered"]);
expect(singleRequests()).toHaveLength(1);
const query = new URL(String(fetchMock.mock.calls.at(-1)![0]), "https://guide.sudloh.com").searchParams;
if (filter === "target") expect(query.has("target")).toBe(false);
else if (filter === "sort") expect(query.get("sort")).toBe("oldest");
else expect(query.get(filter === "visibility" ? "status" : "unanswered")).toBe(filter === "visibility" ? "hidden" : "true");
});
+6 -2
View File
@@ -27,9 +27,10 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
const [target, setTarget] = useState<TargetOption | null>(targets.find((item) => item.value === initialTarget) ?? null);
const [status, setStatus] = useState("all");
const [answered, setAnswered] = useState("all");
const [sort, setSort] = useState("newest");
const [selectedId, setSelectedId] = useState(initialTarget ? initialComment : undefined);
const [selectedResult, setSelectedResult] = useState<{ item: CommentItem | null; error: string }>({ item: null, error: "" });
const query = new URLSearchParams({ view: "inbox", grouped: "true", status, unanswered: String(answered === "unanswered") });
const query = new URLSearchParams({ view: "inbox", grouped: "true", status, sort, unanswered: String(answered === "unanswered") });
if (target) query.set("target", target.value);
const feed = useCommentFeed(`/api/admin/comments?${query}`, initial);
const loadedSelection = feed.page.items.find((item) => item.id === selectedId && item.target === initialTarget && !item.rootId);
@@ -58,7 +59,7 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
const markRead = useMarkCommentsRead();
useEffect(() => { void markRead(target?.value ?? "all"); }, [markRead, target, feed.revision]);
return <div className="flex min-w-0 flex-col gap-6 max-md:[&_button]:min-h-11 max-md:[&_button]:min-w-11">
<FieldGroup className="lg:grid lg:grid-cols-[repeat(3,minmax(0,1fr))_auto] lg:items-end">
<FieldGroup className="lg:grid lg:grid-cols-2 xl:grid-cols-[repeat(4,minmax(0,1fr))_auto] lg:items-end">
<Field><FieldLabel>{"ไกด์หรือรอบ Stygian"}</FieldLabel>
<FuzzyCombobox items={targets} value={target} onValueChange={(value) => { if (value?.value !== target?.value) setSelectedId(undefined); setTarget(value); }} itemToStringLabel={(item) => item.label} itemToStringValue={(item) => item.value}
searchNames={(item) => [item.label]} autoHighlight>
@@ -72,6 +73,9 @@ export function AdminCommentInbox({ initial, targets, initialTarget, initialComm
<Field><FieldLabel>{"การตอบกลับจากผู้ดูแล"}</FieldLabel><ToggleGroup className="max-w-full flex-wrap" value={[answered]} onValueChange={(values) => { if (values[0] && values[0] !== answered) { setSelectedId(undefined); setAnswered(values[0]); } }} variant="outline" aria-label={"กรองการตอบกลับจากผู้ดูแล"}>
<ToggleGroupItem value="all">{"ทุกความคิดเห็น"}</ToggleGroupItem><ToggleGroupItem value="unanswered">{"ยังไม่ได้ตอบ"}</ToggleGroupItem>
</ToggleGroup></Field>
<Field><FieldLabel>{"เรียงตาม"}</FieldLabel><ToggleGroup className="max-w-full flex-wrap" value={[sort]} onValueChange={(values) => { if (values[0] && values[0] !== sort) { setSelectedId(undefined); setSort(values[0]); } }} variant="outline" aria-label={"เรียงความคิดเห็น"}>
<ToggleGroupItem value="newest">{"ล่าสุด"}</ToggleGroupItem><ToggleGroupItem value="oldest">{"เก่าสุด"}</ToggleGroupItem>
</ToggleGroup></Field>
<Button variant="outline" className="self-end" disabled={feed.busy} onClick={() => void feed.refresh({ silent: false, forceRevision: true })}><RefreshCwIcon data-icon="inline-start" />{"รีเฟรช"}</Button>
</FieldGroup>
{feed.error && <Alert variant="destructive"><AlertDescription>{feed.error}</AlertDescription></Alert>}
+1 -1
View File
@@ -29,7 +29,7 @@ export function AdminCommentRowsSkeleton({ count = 3 }: { count?: number }) {
export function AdminCommentInboxSkeleton() {
return <div className="flex min-w-0 flex-col gap-6">
<div className="flex flex-col gap-2"><Skeleton className="h-9 w-48 max-w-full" /><Skeleton className="h-5 w-72 max-w-full" /></div>
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-[repeat(3,minmax(0,1fr))_auto] lg:items-end">{Array.from({ length: 3 }, (_, index) =>
<div className="flex flex-col gap-6 lg:grid lg:grid-cols-2 xl:grid-cols-[repeat(4,minmax(0,1fr))_auto] lg:items-end">{Array.from({ length: 4 }, (_, index) =>
<div key={index} className="flex min-w-0 flex-col gap-2"><Skeleton className="h-4 w-36 max-w-full" /><Skeleton className="h-11 w-full md:h-8" /></div>)}
<Skeleton className="h-11 w-24 self-end md:h-8" />
</div>