v4: playlists have categories of their own — a set apart from the dumps', which admins manage on /manage under "Playlist categories", next to "Dump categories". Each category now has a kind, dump or playlist, its slug and name unique within its kind only; migration 0025 rebuilds categories with the column, every existing one becoming a dump category with its dumps, and adds playlist_categories. A playlist's owner files it under any of them in the create and edit forms, and it shows them as chips on its card and its page, each leading to /playlists/<slug>.
/playlists is the new page of every public playlist, newest first (GET /api/playlists), and /playlists/<slug> a playlist category's, with a menu of the categories, linked from the user menu as "All playlists"; `playlists` is reserved as a dump category's slug, and one that had it is renamed. A profile's Playlists tab narrows to a playlist category with the same menu as its Dumps tab (?category=), and so do the Dumps and Playlists tabs of /search, each with its own kind (category and playlistCategory for GET /api/search), another tab dropping it. The category service takes the kind throughout, so a dump can't be filed under a playlist category nor the other way round, and categoryJoin is the one filter join the dump, follow and playlist services share. useCategories(kind) gives one kind's set from the one fetch, CategoryChips replaces the chips the dump card and page each drew, useCategoryFilter keeps a list to its category as the socket brings more, and useCategorySelection holds a form's picked categories. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,12 @@ vi.mock("../../config/instance.ts", async (importOriginal) => ({
|
||||
|
||||
const admin = makeSession({ id: "ann", username: "ann", role: "admin" });
|
||||
|
||||
/** Queries within the `/manage` group headed `label`. */
|
||||
async function group(label: string) {
|
||||
const heading = await screen.findByRole("heading", { name: label });
|
||||
return within(heading.closest("section")!);
|
||||
}
|
||||
|
||||
function open(session = admin) {
|
||||
route("GET /api/federation/peers", () => [], { fallback: true });
|
||||
const user = userEvent.setup();
|
||||
@@ -49,8 +55,9 @@ describe("Manage", () => {
|
||||
return null;
|
||||
});
|
||||
const user = open();
|
||||
const dumps = await group("Dump categories");
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Add category" }),
|
||||
await dumps.findByRole("button", { name: "Add category" }),
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "Cinema");
|
||||
await user.type(
|
||||
@@ -61,6 +68,7 @@ describe("Manage", () => {
|
||||
expect(await screen.findByRole("button", { name: "Cinema" }))
|
||||
.toBeInTheDocument();
|
||||
expect(calls("POST /api/categories")[0].body).toEqual({
|
||||
kind: "dump",
|
||||
name: "Cinema",
|
||||
slug: "cinema",
|
||||
});
|
||||
@@ -82,8 +90,9 @@ describe("Manage", () => {
|
||||
route("GET /api/categories", () => []);
|
||||
route("POST /api/categories", () => fail("Slug already taken", 409));
|
||||
const user = open();
|
||||
const dumps = await group("Dump categories");
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "Add category" }),
|
||||
await dumps.findByRole("button", { name: "Add category" }),
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "X");
|
||||
await user.type(
|
||||
@@ -94,10 +103,51 @@ describe("Manage", () => {
|
||||
"Slug already taken",
|
||||
);
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "{Escape}");
|
||||
expect(screen.getByRole("button", { name: "Add category" }))
|
||||
expect(dumps.getByRole("button", { name: "Add category" }))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the playlist categories are a set of their own", async () => {
|
||||
let list = [
|
||||
makeCategory({ id: "d1", slug: "music", name: "Music" }),
|
||||
makeCategory({
|
||||
id: "p1",
|
||||
kind: "playlist",
|
||||
slug: "mixes",
|
||||
name: "Mixes",
|
||||
}),
|
||||
];
|
||||
route("GET /api/categories", () => wire(list));
|
||||
route("POST /api/categories", (req) => {
|
||||
const c = makeCategory({ id: "p2", ...(req.body as object) });
|
||||
list = [...list, c];
|
||||
return wire(c);
|
||||
});
|
||||
const user = open();
|
||||
const dumps = await group("Dump categories");
|
||||
const playlists = await group("Playlist categories");
|
||||
expect(await dumps.findByRole("button", { name: "Music" }))
|
||||
.toBeInTheDocument();
|
||||
expect(dumps.queryByRole("button", { name: "Mixes" })).toBeNull();
|
||||
expect(playlists.getByRole("button", { name: "Mixes" }))
|
||||
.toBeInTheDocument();
|
||||
expect(playlists.queryByRole("button", { name: "Music" })).toBeNull();
|
||||
|
||||
await user.click(playlists.getByRole("button", { name: "Add category" }));
|
||||
await user.type(screen.getByRole("textbox", { name: "Name" }), "Live");
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "URL slug" }),
|
||||
"live{Enter}",
|
||||
);
|
||||
expect(await playlists.findByRole("button", { name: "Live" }))
|
||||
.toBeInTheDocument();
|
||||
expect(calls("POST /api/categories")[0].body).toEqual({
|
||||
kind: "playlist",
|
||||
name: "Live",
|
||||
slug: "live",
|
||||
});
|
||||
});
|
||||
|
||||
test("an admin trusts, blocks and removes other instances", async () => {
|
||||
route("GET /api/categories", () => []);
|
||||
const music = {
|
||||
@@ -189,7 +239,7 @@ describe("Manage", () => {
|
||||
instance.federationOutgoing = false;
|
||||
route("GET /api/categories", () => []);
|
||||
open();
|
||||
expect(await screen.findByText("Categories")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Dump categories")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Instances")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,13 @@ import { PlaylistDetail } from "../../pages/PlaylistDetail.tsx";
|
||||
import type { Dump } from "../../../shared/dump.ts";
|
||||
import type { Playlist } from "../../../shared/playlist.ts";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import { makeDump, makePlaylist, makeSession, wire } from "../fixtures.ts";
|
||||
import {
|
||||
makeCategory,
|
||||
makeDump,
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
wire,
|
||||
} from "../fixtures.ts";
|
||||
import { currentPath, renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
|
||||
@@ -31,6 +37,7 @@ function open(
|
||||
playlist = {} as Partial<Playlist>,
|
||||
items = dumps() as Dump[],
|
||||
url = "/@owner/playlists/mix",
|
||||
categories = [] as ReturnType<typeof makeCategory>[],
|
||||
} = {},
|
||||
) {
|
||||
const pl = makePlaylist({
|
||||
@@ -51,6 +58,7 @@ function open(
|
||||
route: url,
|
||||
path: "/:handle/playlists/:slug",
|
||||
session,
|
||||
categories,
|
||||
extraRoutes: <Route path="/" element={<p>home</p>} />,
|
||||
});
|
||||
const socket = FakeWebSocket.last;
|
||||
@@ -142,6 +150,56 @@ describe("PlaylistDetail", () => {
|
||||
expect(currentPath()).toBe("/@owner/playlists/mix");
|
||||
});
|
||||
|
||||
test("its categories show, and editing them sends the new set", async () => {
|
||||
const mixes = makeCategory({
|
||||
id: "pc1",
|
||||
kind: "playlist",
|
||||
slug: "mixes",
|
||||
name: "Mixes",
|
||||
});
|
||||
const live = makeCategory({
|
||||
id: "pc2",
|
||||
kind: "playlist",
|
||||
slug: "live",
|
||||
name: "Live",
|
||||
});
|
||||
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
|
||||
route(
|
||||
"PUT /api/playlists/:id",
|
||||
(req) =>
|
||||
wire(
|
||||
makePlaylist({
|
||||
id: "p1",
|
||||
slug: "mix",
|
||||
title: "Mix",
|
||||
userId: "owner",
|
||||
...(req.body as object),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { user } = open({
|
||||
playlist: { categoryIds: ["pc1"] },
|
||||
categories: [music, mixes, live],
|
||||
});
|
||||
expect(await screen.findByRole("link", { name: "Mixes" }))
|
||||
.toHaveAttribute("href", "/playlists/mixes");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||
// The dump categories aren't offered.
|
||||
expect(screen.queryByRole("button", { name: "Music" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Mixes" }))
|
||||
.toHaveAttribute("aria-pressed", "true");
|
||||
await user.click(screen.getByRole("button", { name: "Mixes" }));
|
||||
await user.click(screen.getByRole("button", { name: "Live" }));
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(await screen.findByRole("link", { name: "Live" }))
|
||||
.toHaveAttribute("href", "/playlists/live");
|
||||
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({
|
||||
categoryIds: ["pc2"],
|
||||
isPublic: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("a new title proposes a new link, which saving moves to", async () => {
|
||||
route(
|
||||
"PUT /api/playlists/:id",
|
||||
|
||||
94
src/test/pages/Playlists.test.tsx
Normal file
94
src/test/pages/Playlists.test.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { Playlists } from "../../pages/Playlists.tsx";
|
||||
import { calls, route } from "../fetch.ts";
|
||||
import { makeCategory, makePlaylist, page, wire } from "../fixtures.ts";
|
||||
import { currentPath, renderApp } from "../render.tsx";
|
||||
import { FakeWebSocket } from "../ws.ts";
|
||||
|
||||
const mixes = makeCategory({
|
||||
id: "pc1",
|
||||
kind: "playlist",
|
||||
slug: "mixes",
|
||||
name: "Mixes",
|
||||
});
|
||||
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
|
||||
|
||||
function open(url: string) {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Playlists />, {
|
||||
route: url,
|
||||
path: "/playlists/:categorySlug?",
|
||||
categories: [music, mixes],
|
||||
});
|
||||
return { user, socket: FakeWebSocket.last };
|
||||
}
|
||||
|
||||
describe("Playlists", () => {
|
||||
test("every public playlist; the menu offers the playlist categories only", async () => {
|
||||
route(
|
||||
"GET /api/playlists",
|
||||
() => page([makePlaylist({ title: "Summer", categoryIds: ["pc1"] })]),
|
||||
);
|
||||
const { user } = open("/playlists");
|
||||
expect(await screen.findByText("Summer")).toBeInTheDocument();
|
||||
expect(calls("GET /api/playlists")[0].url.searchParams.get("category"))
|
||||
.toBeNull();
|
||||
// Its chip leads to its category's playlists.
|
||||
expect(screen.getByRole("link", { name: "Mixes" })).toHaveAttribute(
|
||||
"href",
|
||||
"/playlists/mixes",
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
expect(screen.getByRole("option", { name: "Mixes" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("option", { name: "Music" })).toBeNull();
|
||||
await user.click(screen.getByRole("option", { name: "Mixes" }));
|
||||
expect(currentPath()).toBe("/playlists/mixes");
|
||||
});
|
||||
|
||||
test("a category's playlists, kept to it as the socket brings more", async () => {
|
||||
route(
|
||||
"GET /api/playlists",
|
||||
() =>
|
||||
page([
|
||||
makePlaylist({ id: "p1", title: "Filed", categoryIds: ["pc1"] }),
|
||||
]),
|
||||
);
|
||||
const { socket } = open("/playlists/mixes");
|
||||
expect(await screen.findByText("Filed")).toBeInTheDocument();
|
||||
expect(calls("GET /api/playlists")[0].url.searchParams.get("category"))
|
||||
.toBe("mixes");
|
||||
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "playlist_created",
|
||||
playlist: wire(makePlaylist({ title: "Elsewhere", categoryIds: [] })),
|
||||
});
|
||||
socket.receive({
|
||||
type: "playlist_created",
|
||||
playlist: wire(
|
||||
makePlaylist({ title: "Also filed", categoryIds: ["pc1"] }),
|
||||
),
|
||||
});
|
||||
expect(screen.getByText("Also filed")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Elsewhere")).toBeNull();
|
||||
|
||||
// Taken out of the category, it leaves.
|
||||
socket.receive({
|
||||
type: "playlist_updated",
|
||||
playlist: wire(
|
||||
makePlaylist({ id: "p1", title: "Filed", categoryIds: [] }),
|
||||
),
|
||||
});
|
||||
expect(screen.queryByText("Filed")).toBeNull();
|
||||
});
|
||||
|
||||
test("a slug that names no playlist category is not found", async () => {
|
||||
route("GET /api/playlists", () => page([]));
|
||||
open("/playlists/music");
|
||||
expect(await screen.findByText("This page does not exist."))
|
||||
.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { act, screen } from "@testing-library/react";
|
||||
import { act, screen, waitFor } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { Search } from "../../pages/Search.tsx";
|
||||
import { calls, fail, route } from "../fetch.ts";
|
||||
import {
|
||||
makeCategory,
|
||||
makeDump,
|
||||
makePlaylist,
|
||||
makeSession,
|
||||
@@ -35,9 +36,10 @@ function results(
|
||||
function open(
|
||||
url: string,
|
||||
session: ReturnType<typeof makeSession> | null = null,
|
||||
categories: ReturnType<typeof makeCategory>[] = [],
|
||||
) {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Search />, { route: url, path: "/search/*", session });
|
||||
renderApp(<Search />, { route: url, path: "/search/*", session, categories });
|
||||
return user;
|
||||
}
|
||||
|
||||
@@ -70,6 +72,50 @@ describe("Search", () => {
|
||||
expect(screen.getByText("Cats")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("each tab narrows to a category of its own kind", async () => {
|
||||
route("GET /api/search", () =>
|
||||
results({
|
||||
dumps: [makeDump({ title: "Cat song" })],
|
||||
playlists: [makePlaylist({ title: "Cat mix" })],
|
||||
}));
|
||||
const user = open("/search?q=cat", null, [
|
||||
makeCategory({ id: "dc1", slug: "music", name: "Music" }),
|
||||
makeCategory({
|
||||
id: "pc1",
|
||||
kind: "playlist",
|
||||
slug: "mixes",
|
||||
name: "Mixes",
|
||||
}),
|
||||
]);
|
||||
await screen.findByText("Cat song");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
expect(screen.queryByRole("option", { name: "Mixes" })).toBeNull();
|
||||
await user.click(screen.getByRole("option", { name: "Music" }));
|
||||
expect(currentPath()).toBe("/search?q=cat&category=music");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
calls("GET /api/search").at(-1)!.url.searchParams.get("category"),
|
||||
).toBe("music")
|
||||
);
|
||||
|
||||
// Another tab drops it, and offers its own.
|
||||
await user.click(screen.getByRole("button", { name: /^Playlists/ }));
|
||||
expect(currentPath()).toBe("/search/playlists?q=cat");
|
||||
await user.click(await screen.findByRole("button", { name: "All" }));
|
||||
expect(screen.queryByRole("option", { name: "Music" })).toBeNull();
|
||||
await user.click(screen.getByRole("option", { name: "Mixes" }));
|
||||
await waitFor(() => {
|
||||
const req = calls("GET /api/search").at(-1)!;
|
||||
expect(req.url.searchParams.get("playlistCategory")).toBe("mixes");
|
||||
expect(req.url.searchParams.get("category")).toBeNull();
|
||||
});
|
||||
|
||||
// The users tab has none.
|
||||
await user.click(screen.getByRole("button", { name: /^Users/ }));
|
||||
expect(screen.queryByRole("button", { name: "All" })).toBeNull();
|
||||
});
|
||||
|
||||
test("nothing found", async () => {
|
||||
route("GET /api/search", () => results());
|
||||
const user = open("/search?q=zzz");
|
||||
|
||||
@@ -217,6 +217,47 @@ describe("UserPublicProfile", () => {
|
||||
expect(currentPath()).toBe("/@ann/playlists");
|
||||
});
|
||||
|
||||
test("the playlists tab narrows to a playlist category, in its URL", async () => {
|
||||
const music = makeCategory({ id: "c1", slug: "music", name: "Music" });
|
||||
const mixes = makeCategory({
|
||||
id: "pc1",
|
||||
kind: "playlist",
|
||||
slug: "mixes",
|
||||
name: "Mixes",
|
||||
});
|
||||
const filed = makePlaylist({
|
||||
id: "p1",
|
||||
title: "Filed mix",
|
||||
userId: "ann",
|
||||
categoryIds: ["pc1"],
|
||||
});
|
||||
const { user, socket } = open({
|
||||
url: "/@ann/playlists",
|
||||
categories: [music, mixes],
|
||||
});
|
||||
route("GET /api/users/:u/playlists", (req) =>
|
||||
page(
|
||||
req.url.searchParams.get("category") === "mixes"
|
||||
? [filed]
|
||||
: [filed, makePlaylist({ id: "p2", title: "Loose", userId: "ann" })],
|
||||
));
|
||||
await screen.findByText("Loose");
|
||||
await user.click(screen.getByRole("button", { name: "All" }));
|
||||
expect(screen.queryByRole("option", { name: "Music" })).toBeNull();
|
||||
await user.click(screen.getByRole("option", { name: "Mixes" }));
|
||||
expect(currentPath()).toBe("/@ann/playlists?category=mixes");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Loose")).not.toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByText("Filed mix")).toBeInTheDocument();
|
||||
socket.welcome();
|
||||
socket.receive({
|
||||
type: "playlist_updated",
|
||||
playlist: wire({ ...filed, categoryIds: [] }),
|
||||
});
|
||||
expect(screen.queryByText("Filed mix")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("the dumps tab loads more as its end scrolls into view", async () => {
|
||||
open({ url: "/@ann/dumps" });
|
||||
// After open's, so that it wins.
|
||||
|
||||
Reference in New Issue
Block a user