v4: a playlist's editor has a URL, as a dump's has — it opened over the playlist's header as a state of the page, which a reload or a shared link lost, while a dump's editor is /@name/dumps/<slug>/edit. It is now /@name/playlists/<slug>/edit: Edit goes there, and Cancel or Save come back to the page in its place. Both paths render PlaylistDetail at the same place, so the playlist stays loaded, and a new slug keeps the view. Anyone who may not edit it gets the page, a guest the sign-in first. playlistPath takes the view, and parsePagePath reads it, so its link has no preview, as a dump editor's has none.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1
API.md
1
API.md
@@ -33,6 +33,7 @@ has one word, the same as in pages.
|
|||||||
| `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` |
|
| `/@<name>/dumps/<slug>` | | anyone; a private dump, its poster | `Dump` |
|
||||||
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
|
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
|
||||||
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
|
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
|
||||||
|
| `/@<name>/playlists/<slug>/edit` | | its owner, `playlist:moderate` | `PlaylistDetail` |
|
||||||
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=` | anyone | `Search` |
|
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=` | anyone | `Search` |
|
||||||
| `/notifications` | | signed in | `Notifications` |
|
| `/notifications` | | signed in | `Notifications` |
|
||||||
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |
|
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |
|
||||||
|
|||||||
@@ -30,12 +30,12 @@ export function isSelfHost(host: string): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether `url` links to one of this site's own dump, user or playlist
|
* Checks whether `url` links to one of this site's own dump, user or playlist
|
||||||
* pages, by `parsePagePath`; a dump's editor doesn't count. Matched on the
|
* pages, by `parsePagePath`; an editor doesn't count. Matched on the
|
||||||
* host as well as the path: `/@bob` on some other site is someone else's
|
* host as well as the path: `/@bob` on some other site is someone else's
|
||||||
* page, not ours.
|
* page, not ours.
|
||||||
*/
|
*/
|
||||||
export function isSelfUrl(url: URL): boolean {
|
export function isSelfUrl(url: URL): boolean {
|
||||||
if (!isSelfHost(url.host)) return false;
|
if (!isSelfHost(url.host)) return false;
|
||||||
const page = parsePagePath(url.pathname);
|
const page = parsePagePath(url.pathname);
|
||||||
return !!page && !(page.kind === "dump" && page.edit);
|
return !!page && !(page.kind !== "user" && page.edit);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export async function ogMiddleware(
|
|||||||
if (pathname.startsWith("/api/")) return await next();
|
if (pathname.startsWith("/api/")) return await next();
|
||||||
|
|
||||||
const page = parsePagePath(pathname);
|
const page = parsePagePath(pathname);
|
||||||
if (!page || (page.kind === "dump" && page.edit)) return await next();
|
if (!page || (page.kind !== "user" && page.edit)) return await next();
|
||||||
|
|
||||||
const origin = PUBLIC_URL;
|
const origin = PUBLIC_URL;
|
||||||
let meta: OGMeta | null = null;
|
let meta: OGMeta | null = null;
|
||||||
|
|||||||
@@ -111,9 +111,15 @@ export function dumpPath(dump: OwnedItem, view?: "edit"): string {
|
|||||||
return view ? `${path}/${view}` : path;
|
return view ? `${path}/${view}` : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the path of a playlist's page, `/@owner/playlists/<slug>`. */
|
/**
|
||||||
export function playlistPath(playlist: OwnedItem): string {
|
* Returns the path of a playlist's page, `/@owner/playlists/<slug>`, or of
|
||||||
return `${userPath(playlist.ownerUsername)}/playlists/${seg(playlist.slug)}`;
|
* the page with its editor open, one segment more.
|
||||||
|
*/
|
||||||
|
export function playlistPath(playlist: OwnedItem, view?: "edit"): string {
|
||||||
|
const path = `${userPath(playlist.ownerUsername)}/playlists/${
|
||||||
|
seg(playlist.slug)
|
||||||
|
}`;
|
||||||
|
return view ? `${path}/${view}` : path;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -171,11 +177,11 @@ export function communityPath(view: DigestCadence = "weekly"): string {
|
|||||||
export type PagePath =
|
export type PagePath =
|
||||||
| { kind: "user"; username: string; rest: string[] }
|
| { kind: "user"; username: string; rest: string[] }
|
||||||
| { kind: "dump"; username: string; slug: string; edit: boolean }
|
| { kind: "dump"; username: string; slug: string; edit: boolean }
|
||||||
| { kind: "playlist"; username: string; slug: string };
|
| { kind: "playlist"; username: string; slug: string; edit: boolean };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns what a page path under a user names: a profile (with its tab), a
|
* Returns what a page path under a user names: a profile (with its tab), a
|
||||||
* dump (or its editor) or a playlist. It returns null for any other path, a
|
* dump or a playlist (or its editor). It returns null for any other path, a
|
||||||
* feed or an app page, and for one under a user that no page answers. The
|
* feed or an app page, and for one under a user that no page answers. The
|
||||||
* names are decoded but not looked up, so the user or the item may not exist.
|
* names are decoded but not looked up, so the user or the item may not exist.
|
||||||
*/
|
*/
|
||||||
@@ -195,18 +201,11 @@ export function parsePagePath(pathname: string): PagePath | null {
|
|||||||
const username = handle.slice(1);
|
const username = handle.slice(1);
|
||||||
|
|
||||||
if ((section === "dumps" || section === "playlists") && slug) {
|
if ((section === "dumps" || section === "playlists") && slug) {
|
||||||
if (section === "dumps" && more.length <= 1) {
|
if (more.length > 1 || (more.length === 1 && more[0] !== "edit")) {
|
||||||
if (more.length === 0) {
|
return null;
|
||||||
return { kind: "dump", username, slug, edit: false };
|
|
||||||
}
|
|
||||||
if (more[0] === "edit") {
|
|
||||||
return { kind: "dump", username, slug, edit: true };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (section === "playlists" && more.length === 0) {
|
const kind = section === "dumps" ? "dump" : "playlist";
|
||||||
return { kind: "playlist", username, slug };
|
return { kind, username, slug, edit: more.length === 1 };
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const rest = segments.slice(1);
|
const rest = segments.slice(1);
|
||||||
|
|||||||
@@ -66,7 +66,18 @@ Deno.test("parsePagePath: what a path under a user names", () => {
|
|||||||
kind: "playlist",
|
kind: "playlist",
|
||||||
username: "bob",
|
username: "bob",
|
||||||
slug: "mix",
|
slug: "mix",
|
||||||
|
edit: false,
|
||||||
});
|
});
|
||||||
|
assertEquals(parsePagePath("/@bob/playlists/mix/edit"), {
|
||||||
|
kind: "playlist",
|
||||||
|
username: "bob",
|
||||||
|
slug: "mix",
|
||||||
|
edit: true,
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
playlistPath({ ownerUsername: "bob", slug: "mix" }, "edit"),
|
||||||
|
"/@bob/playlists/mix/edit",
|
||||||
|
);
|
||||||
for (
|
for (
|
||||||
const other of [
|
const other of [
|
||||||
"/",
|
"/",
|
||||||
@@ -78,7 +89,7 @@ Deno.test("parsePagePath: what a path under a user names", () => {
|
|||||||
"/@bob/nope",
|
"/@bob/nope",
|
||||||
"/@bob/follow/users",
|
"/@bob/follow/users",
|
||||||
"/@bob/dumps/x/y",
|
"/@bob/dumps/x/y",
|
||||||
"/@bob/playlists/x/edit",
|
"/@bob/playlists/x/y",
|
||||||
"/@bob/%E0%A4%A",
|
"/@bob/%E0%A4%A",
|
||||||
]
|
]
|
||||||
) {
|
) {
|
||||||
@@ -96,6 +107,7 @@ Deno.test("handles: a user from another instance is /@name@host", () => {
|
|||||||
kind: "playlist",
|
kind: "playlist",
|
||||||
username: "alice@music.example",
|
username: "alice@music.example",
|
||||||
slug: "mix",
|
slug: "mix",
|
||||||
|
edit: false,
|
||||||
});
|
});
|
||||||
assertEquals(parsePagePath("/@alice%40music.example"), {
|
assertEquals(parsePagePath("/@alice%40music.example"), {
|
||||||
kind: "user",
|
kind: "user",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
|
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
|
||||||
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
|
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
|
||||||
* signed in only), and their playlists at `playlists/:slug`
|
* signed in only), and their playlists at `playlists/:slug`
|
||||||
* (`PlaylistDetail`).
|
* (`PlaylistDetail`), with its editor open at `playlists/:slug/edit`.
|
||||||
* - `/:categorySlug` and `/:categorySlug/:feedTab` are a dump category's feed
|
* - `/:categorySlug` and `/:categorySlug/:feedTab` are a dump category's feed
|
||||||
* (`Index`), with its `/follow/playlists` too.
|
* (`Index`), with its `/follow/playlists` too.
|
||||||
* - Any other path is `NotFound`.
|
* - Any other path is `NotFound`.
|
||||||
@@ -185,6 +185,7 @@ function UserRoutes() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="playlists/:slug" element={<PlaylistDetail />} />
|
<Route path="playlists/:slug" element={<PlaylistDetail />} />
|
||||||
|
<Route path="playlists/:slug/edit" element={<PlaylistDetail editing />} />
|
||||||
<Route path="*" element={<UserPublicProfile />} />
|
<Route path="*" element={<UserPublicProfile />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -100,12 +100,14 @@ function withDetails(
|
|||||||
* see in it. The socket's events keep its details and dumps live: a dump
|
* see in it. The socket's events keep its details and dumps live: a dump
|
||||||
* another client removes fades out, one it adds is fetched, a new order is
|
* another client removes fades out, one it adds is fetched, a new order is
|
||||||
* applied, one that goes private leaves and comes back at its place, and a
|
* applied, one that goes private leaves and comes back at its place, and a
|
||||||
* deleted playlist sends the page home. A new slug replaces the URL, and a
|
* deleted playlist sends the page home. A new slug replaces the URL, keeping
|
||||||
* path that names the playlist on screen, as that one does, fetches nothing.
|
* the page's `view`, and a path that names the playlist on screen, as that
|
||||||
|
* one does, fetches nothing.
|
||||||
*/
|
*/
|
||||||
export function usePlaylist(
|
export function usePlaylist(
|
||||||
username: string | undefined,
|
username: string | undefined,
|
||||||
slug: string | undefined,
|
slug: string | undefined,
|
||||||
|
view?: "edit",
|
||||||
): UsePlaylistResult {
|
): UsePlaylistResult {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, authFetch } = useAuth();
|
const { user, authFetch } = useAuth();
|
||||||
@@ -142,7 +144,7 @@ export function usePlaylist(
|
|||||||
) {
|
) {
|
||||||
setTarget({ username, slug });
|
setTarget({ username, slug });
|
||||||
}
|
}
|
||||||
useCanonicalPath(playlist && playlistUrl(playlist));
|
useCanonicalPath(playlist && playlistUrl(playlist, view));
|
||||||
// The socket and the API's writes name playlists by id.
|
// The socket and the API's writes name playlists by id.
|
||||||
const id = playlist?.id;
|
const id = playlist?.id;
|
||||||
const url = `${API_URL}/api/playlists/${id}`;
|
const url = `${API_URL}/api/playlists/${id}`;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { startTransition, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { Navigate, useLocation, useNavigate, useParams } from "react-router";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { can } from "../utils/auth/permissions.ts";
|
import { can } from "../utils/auth/permissions.ts";
|
||||||
@@ -21,16 +21,31 @@ import { ConfirmModal } from "../components/shared/ConfirmModal.tsx";
|
|||||||
import { PlaylistHeader } from "../components/playlist/PlaylistHeader.tsx";
|
import { PlaylistHeader } from "../components/playlist/PlaylistHeader.tsx";
|
||||||
import { PlaylistEditForm } from "../components/playlist/PlaylistEditForm.tsx";
|
import { PlaylistEditForm } from "../components/playlist/PlaylistEditForm.tsx";
|
||||||
import { PlaylistDumpList } from "../components/playlist/PlaylistDumpList.tsx";
|
import { PlaylistDumpList } from "../components/playlist/PlaylistDumpList.tsx";
|
||||||
|
import { playlistUrl } from "../utils/shared/urls.ts";
|
||||||
|
import type { OwnedItem } from "../../shared/paths.ts";
|
||||||
|
import { loginPath } from "../utils/auth/next.ts";
|
||||||
|
|
||||||
|
/** Passed to {@link PlaylistDetail} by `App`'s route. */
|
||||||
|
export interface PlaylistDetailProps {
|
||||||
|
/** Set at `/edit`, where the editor is open. */
|
||||||
|
editing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders `/@:username/playlists/:slug`, for anyone who may see the playlist:
|
* Renders `/@:username/playlists/:slug`, for anyone who may see the playlist:
|
||||||
* a private one only to its owner. `usePlaylist` loads it, by a former name or
|
* a private one only to its owner. `usePlaylist` loads it, by a former name or
|
||||||
* slug too, and keeps it live. Its owner, or a user with `playlist:moderate`, can edit or
|
* slug too, and keeps it live. Its owner, or a user with `playlist:moderate`,
|
||||||
* delete it, drag its dumps into a new order, and remove one, with a short
|
* can drag its dumps into a new order and remove one, with a short window to
|
||||||
* window to undo.
|
* undo, and edit or delete it in the editor that opens over its header at
|
||||||
|
* `/@:username/playlists/:slug/edit`. Both paths render the page at the same
|
||||||
|
* place, so opening the editor keeps the playlist loaded. Anyone else there
|
||||||
|
* gets the page, or is asked to sign in first.
|
||||||
*/
|
*/
|
||||||
export function PlaylistDetail(): React.JSX.Element {
|
export function PlaylistDetail(
|
||||||
|
{ editing = false }: PlaylistDetailProps,
|
||||||
|
): React.JSX.Element {
|
||||||
const { handle, slug } = useParams();
|
const { handle, slug } = useParams();
|
||||||
|
const { pathname } = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const {
|
const {
|
||||||
@@ -43,9 +58,12 @@ export function PlaylistDetail(): React.JSX.Element {
|
|||||||
move,
|
move,
|
||||||
saveOrder,
|
saveOrder,
|
||||||
deletePlaylist,
|
deletePlaylist,
|
||||||
} = usePlaylist(handle?.slice(1), slug);
|
} = usePlaylist(handle?.slice(1), slug, editing ? "edit" : undefined);
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const loaded = state.status === "loaded" ? state.data : null;
|
||||||
|
const canEdit = !!loaded &&
|
||||||
|
((!!user && user.id === loaded.userId) || can(user, "playlist:moderate"));
|
||||||
|
const editOpen = editing && canEdit;
|
||||||
const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
|
const headerRef = useHeightTransition<HTMLDivElement>(editOpen);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
@@ -85,8 +103,15 @@ export function PlaylistDetail(): React.JSX.Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const playlist = state.data;
|
const playlist = state.data;
|
||||||
const canEdit = (!!user && user.id === playlist.userId) ||
|
if (editing && !canEdit) {
|
||||||
can(user, "playlist:moderate");
|
return user
|
||||||
|
? <Navigate to={playlistUrl(playlist)} replace />
|
||||||
|
: <Navigate to={loginPath(pathname)} replace />;
|
||||||
|
}
|
||||||
|
// The editor is a view of the page: opening it adds a history entry, which
|
||||||
|
// closing it, or saving, takes the place of.
|
||||||
|
const closeEditor = (current: OwnedItem = playlist) =>
|
||||||
|
navigate(playlistUrl(current), { replace: true });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -100,13 +125,17 @@ export function PlaylistDetail(): React.JSX.Element {
|
|||||||
? (
|
? (
|
||||||
<PlaylistEditForm
|
<PlaylistEditForm
|
||||||
playlist={playlist}
|
playlist={playlist}
|
||||||
onCancel={() => setEditOpen(false)}
|
onCancel={() => closeEditor()}
|
||||||
onRequestDelete={() => setConfirmDelete(true)}
|
onRequestDelete={() => setConfirmDelete(true)}
|
||||||
onSaved={(updated) => {
|
onSaved={(updated) => {
|
||||||
setEditOpen(false);
|
// Shown at once rather than left to the socket's echo, which
|
||||||
// Shown at once rather than left to the socket's echo,
|
// may never come (disconnected). The router navigates in a
|
||||||
// which may never come (disconnected).
|
// transition; were the details applied apart, the page would
|
||||||
applyDetails(updated);
|
// render them still at /edit, and put a new slug's /edit back.
|
||||||
|
startTransition(() => {
|
||||||
|
applyDetails(updated);
|
||||||
|
closeEditor({ ...playlist, slug: updated.slug });
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -114,7 +143,9 @@ export function PlaylistDetail(): React.JSX.Element {
|
|||||||
<div className="playlist-detail-header-top">
|
<div className="playlist-detail-header-top">
|
||||||
<PlaylistHeader
|
<PlaylistHeader
|
||||||
playlist={playlist}
|
playlist={playlist}
|
||||||
onEdit={canEdit ? () => setEditOpen(true) : undefined}
|
onEdit={canEdit
|
||||||
|
? () => navigate(playlistUrl(playlist, "edit"))
|
||||||
|
: undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -59,7 +59,15 @@ function open(
|
|||||||
path: "/:handle/playlists/:slug",
|
path: "/:handle/playlists/:slug",
|
||||||
session,
|
session,
|
||||||
categories,
|
categories,
|
||||||
extraRoutes: <Route path="/" element={<p>home</p>} />,
|
extraRoutes: (
|
||||||
|
<>
|
||||||
|
<Route path="/" element={<p>home</p>} />
|
||||||
|
<Route
|
||||||
|
path="/:handle/playlists/:slug/edit"
|
||||||
|
element={<PlaylistDetail editing />}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
),
|
||||||
});
|
});
|
||||||
const socket = FakeWebSocket.last;
|
const socket = FakeWebSocket.last;
|
||||||
socket.welcome();
|
socket.welcome();
|
||||||
@@ -142,12 +150,39 @@ describe("PlaylistDetail", () => {
|
|||||||
await user.clear(box);
|
await user.clear(box);
|
||||||
await user.type(box, "Better tunes");
|
await user.type(box, "Better tunes");
|
||||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||||
expect(await screen.findByText("Better tunes")).toBeInTheDocument();
|
// Saving closes the editor, whose text would be found first.
|
||||||
|
await waitFor(() => expect(currentPath()).toBe("/@owner/playlists/mix"));
|
||||||
|
expect(screen.getByText("Better tunes")).toBeInTheDocument();
|
||||||
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({
|
expect(calls("PUT /api/playlists/:id")[0].body).toEqual({
|
||||||
description: "Better tunes",
|
description: "Better tunes",
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the editor is a page: /edit opens it, closing goes back to the page", async () => {
|
||||||
|
const { user } = open({ url: "/@owner/playlists/mix/edit" });
|
||||||
|
expect(await screen.findByRole("textbox", { name: "Description" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
expect(currentPath()).toBe("/@owner/playlists/mix");
|
expect(currentPath()).toBe("/@owner/playlists/mix");
|
||||||
|
await user.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
expect(currentPath()).toBe("/@owner/playlists/mix/edit");
|
||||||
|
expect(calls("GET /api/users/:u/playlists/:slug")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("at /edit, anyone else gets the page", async () => {
|
||||||
|
open({ session: visitor, url: "/@owner/playlists/mix/edit" });
|
||||||
|
await waitFor(() => expect(currentPath()).toBe("/@owner/playlists/mix"));
|
||||||
|
expect(screen.queryByRole("textbox", { name: "Description" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("at /edit, a guest is asked to sign in first", async () => {
|
||||||
|
open({ session: null, url: "/@owner/playlists/mix/edit" });
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(currentPath()).toBe(
|
||||||
|
"/login?next=%2F%40owner%2Fplaylists%2Fmix%2Fedit",
|
||||||
|
)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("its categories show, and editing them sends the new set", async () => {
|
test("its categories show, and editing them sends the new set", async () => {
|
||||||
@@ -444,7 +479,7 @@ describe("PlaylistDetail", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
|
expect(await screen.findByRole("alert")).toHaveTextContent("Forbidden");
|
||||||
expect(currentPath()).toBe("/@owner/playlists/mix");
|
expect(currentPath()).toBe("/@owner/playlists/mix/edit");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("live: added dumps appear, removed ones fade, a deleted playlist sends you home", async () => {
|
test("live: added dumps appear, removed ones fade, a deleted playlist sends you home", async () => {
|
||||||
|
|||||||
@@ -138,9 +138,12 @@ export function userApiUrl(
|
|||||||
.join("/");
|
.join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the path of the page of `playlist`, `/@owner/playlists/<slug>`. */
|
/**
|
||||||
export function playlistUrl(playlist: OwnedItem): string {
|
* Returns the path of the page of `playlist`, `/@owner/playlists/<slug>`, or
|
||||||
return playlistPath(playlist);
|
* of the page with its editor open (`playlistPath`).
|
||||||
|
*/
|
||||||
|
export function playlistUrl(playlist: OwnedItem, view?: "edit"): string {
|
||||||
|
return playlistPath(playlist, view);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user