Skip to content
Merged
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
531 changes: 531 additions & 0 deletions webapp/e2e/editorial-pages.spec.ts

Large diffs are not rendered by default.

76 changes: 60 additions & 16 deletions webapp/e2e/studio-nocturne.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6959,6 +6959,20 @@ test("closes a folder's form when another folder is opened", async ({ page }) =>
expect(state.writes).toEqual([]);
});

/**
* Opens the artist header's image menu, where the portrait and banner actions
* live behind one button, and returns the menu.
*/
async function openImageMenu(page: Page) {
await page
.locator(".artist-hero")
.getByRole("button", { name: "Edit images", exact: true })
.click();
const menu = page.getByRole("menu", { name: "Images of Björk" });
await expect(menu).toBeVisible();
return menu;
}

/**
* A stand-in for the artist routes of RFC-013 with state: the page re-reads
* after every write, and what it shows must be what the write did.
Expand Down Expand Up @@ -7029,9 +7043,13 @@ test("changes an artist's banner and portrait from the artist page", async ({
const { state, png } = await serveArtistImages(page);
await page.goto("/artists/artist-1");
const hero = page.locator(".artist-hero");
await expect(hero.getByRole("button", { name: "Change portrait" })).toBeVisible();
await expect(hero.getByRole("button", { name: "Change banner" })).toBeVisible();
await expect(hero.getByRole("button", { name: "No banner" })).toHaveCount(0);
// The actions sit behind one button in the header's corner.
let menu = await openImageMenu(page);
await expect(menu.getByRole("menuitem", { name: "Change portrait" })).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "Change banner" })).toBeVisible();
await expect(menu.getByRole("menuitem", { name: "No banner" })).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(menu).toHaveCount(0);
// With no banner, the portrait is the backdrop, blurred.
await expect(hero.locator(".artist-hero-ambient")).toBeAttached();

Expand Down Expand Up @@ -7064,10 +7082,12 @@ test("changes an artist's banner and portrait from the artist page", async ({
await expect(hero.getByRole("alert")).toHaveText("Choose a JPEG, PNG or WebP image.");

// Back to automatic: no banner, and the fetched portrait again.
await hero.getByRole("button", { name: "No banner" }).click();
menu = await openImageMenu(page);
await menu.getByRole("menuitem", { name: "No banner" }).click();
await expect(hero.getByRole("status")).toHaveText("Banner removed.");
await expect(hero.locator("img.artist-banner")).toHaveCount(0);
await hero.getByRole("button", { name: "Automatic portrait" }).click();
menu = await openImageMenu(page);
await menu.getByRole("menuitem", { name: "Automatic portrait" }).click();
await expect(hero.getByRole("status")).toHaveText("Back to the automatic portrait.");
await expect(hero.getByText("Portrait chosen by hand")).toHaveCount(0);
expect(state.writes.slice(-2)).toEqual([
Expand All @@ -7086,6 +7106,8 @@ test("shows a listener the artist's banner with nothing to change", async ({
await page.goto("/artists/artist-1");
const hero = page.locator(".artist-hero");
await expect(hero.locator("img.artist-banner")).toBeVisible();
// Not even the button the actions sit behind.
await expect(hero.getByRole("button", { name: "Edit images" })).toHaveCount(0);
await expect(hero.getByRole("button", { name: /Change/ })).toHaveCount(0);
await expect(hero.getByRole("button", { name: "Search Deezer" })).toHaveCount(0);
});
Expand Down Expand Up @@ -7198,7 +7220,9 @@ test("chooses an artist's portrait from a Deezer search", async ({ page }) => {

await page.goto("/artists/artist-1");
const hero = page.locator(".artist-hero");
await hero.getByRole("button", { name: "Search Deezer" }).click();
await (await openImageMenu(page))
.getByRole("menuitem", { name: "Search Deezer" })
.click();
const picker = page.getByRole("dialog", { name: "Choose a portrait from Deezer" });
await expect(picker).toBeVisible();
const results = picker.getByRole("list", { name: "Deezer artists" });
Expand Down Expand Up @@ -7575,7 +7599,9 @@ test("keeps the artist hero and the Deezer picker readable in the light", async

// Opened from the hero, the picker sits inside it in the tree, and took
// the hero's light-on-dark ink: the fan count was white on white.
await hero.getByRole("button", { name: "Search Deezer" }).click();
await (await openImageMenu(page))
.getByRole("menuitem", { name: "Search Deezer" })
.click();
const picker = page.getByRole("dialog", { name: "Choose a portrait from Deezer" });
await expect(picker.getByText("1500000 fans")).toBeVisible();
await expectNoWcagViolations(page);
Expand Down Expand Up @@ -7765,20 +7791,38 @@ for (const { theme, banner, portrait } of [
] as const) {
expect(await lowestContrast(page, text), label).toBeGreaterThanOrEqual(4.5);
}
// Every button is measured; these two are required, the plain one and the
// quiet one, so the loop below cannot pass over an empty list.
for (const required of ["Change portrait", "Automatic portrait"]) {
const button = hero.getByRole("button", { name: required, exact: true });
await expect(button).toHaveCount(1);
await expect(button).toBeVisible();
}
for (const button of await hero.locator(".artist-image-button").all()) {
// Every button over the picture is measured. The image actions sit
// behind one menu button now, which is required, so the loop below
// cannot pass over an empty list.
const trigger = hero.getByRole("button", { name: "Edit images", exact: true });
await expect(trigger).toHaveCount(1);
await expect(trigger).toBeVisible();
const overPicture = await hero.locator(".artist-image-button").all();
expect(overPicture.length).toBeGreaterThan(0);
for (const button of overPicture) {
if (!(await button.isVisible())) continue;
expect(
await lowestContrast(page, button, true),
`the button “${await button.innerText()}”`,
).toBeGreaterThanOrEqual(4.5);
}
// And what it opens: the plain entry and the quiet one, both required —
// a portrait chosen by hand is why "Automatic portrait" is offered. The
// menu is drawn on its own panel, outside the header, and is measured
// over whatever it covers all the same.
await trigger.click();
const menu = page.getByRole("menu", { name: "Images of Björk" });
for (const required of ["Change portrait", "Automatic portrait"]) {
const entry = menu.getByRole("menuitem", { name: required, exact: true });
await expect(entry).toHaveCount(1);
await expect(entry).toBeVisible();
}
for (const entry of await menu.getByRole("menuitem").all()) {
expect(
await lowestContrast(page, entry.locator("span")),
`the menu entry “${await entry.innerText()}”`,
).toBeGreaterThanOrEqual(4.5);
}
});
}

Expand Down Expand Up @@ -8819,7 +8863,7 @@ test("offers the day's mixes above the albums, and plays one", async ({
await page.goto("/");
const shelf = page.locator(".mix-shelf");
await expect(
shelf.getByRole("heading", { name: "Made for you today" }),
shelf.getByRole("heading", { name: "Mixes for you" }),
).toBeVisible();
// Named by the client, in the listener's language, from what the server
// sent: a kind and its parameter, never a title.
Expand Down
162 changes: 109 additions & 53 deletions webapp/src/components/artist-image-controls.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useQueryClient } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { createPortal } from "react-dom";

import {
ApiError,
Expand All @@ -12,6 +13,7 @@ import { artistQuery } from "../api/queries";
import { type TranslationKey, useI18n } from "../state/i18n";
import { DeezerPortraitPicker } from "./deezer-portrait-picker";
import { Icon } from "./icons";
import { PopupMenu, type PopupMenuItem } from "./popup-menu";

/** What the server takes: recognised from the bytes, these three only. */
const ACCEPT = "image/jpeg,image/png,image/webp";
Expand Down Expand Up @@ -42,7 +44,8 @@ const LABELS: Record<
/**
* Change an artist's portrait or banner, or go back to the automatic one
* (RFC-013). Drawn only for the library's owners and managers; the server
* refuses anyone else regardless.
* refuses anyone else regardless. The actions sit behind one menu button in
* the header's corner; what they report stays in the header, beside it.
*
* A file is sent as it is: no crop, no resize. The banner is framed by the
* page at a fixed position, as the desktop frames it. The portrait may also
Expand All @@ -57,6 +60,9 @@ export function ArtistImageControls({ artist }: { artist: ArtistDetail }) {
};
const [busy, setBusy] = useState(false);
const [searching, setSearching] = useState(false);
// Where the menu hangs, under the button's right edge; null while closed.
const [menu, setMenu] = useState<{ x: number; y: number } | null>(null);
const trigger = useRef<HTMLButtonElement>(null);
const [notice, setNotice] = useState<{ error: boolean; text: string } | null>(
null,
);
Expand Down Expand Up @@ -108,60 +114,110 @@ export function ArtistImageControls({ artist }: { artist: ArtistDetail }) {
}

const kinds: ArtistImageKind[] = ["portrait", "banner"];
// In the order a reader meets them: the portrait's three ways, then the
// banner's two. A reset is offered only where there is a choice to undo.
const items: PopupMenuItem[] = kinds.flatMap((kind) => [
{
key: `change-${kind}`,
icon: "edit" as const,
label: t(LABELS[kind].change),
run: () => pickers[kind].current?.click(),
},
...(kind === "portrait"
? [
{
key: "deezer",
icon: "search" as const,
label: t("artistImage.searchDeezer"),
run: () => {
setNotice(null);
setSearching(true);
},
},
]
: []),
...(artist.image_choice?.[kind]
? [
{
key: `reset-${kind}`,
icon: "close" as const,
label: t(LABELS[kind].reset),
run: () => void reset(kind),
},
]
: []),
]);

function toggle() {
if (menu) {
setMenu(null);
return;
}
const box = trigger.current?.getBoundingClientRect();
if (!box) return;
setMenu({ x: box.right, y: box.bottom + 6 });
}

function closeMenu() {
setMenu(null);
trigger.current?.focus();
}

return (
<div className="artist-image-controls">
<div className="artist-image-buttons">
{kinds.map((kind) => (
<span key={kind} className="artist-image-pair">
<button
type="button"
className="artist-image-button"
disabled={busy}
onClick={() => pickers[kind].current?.click()}
>
<Icon name="edit" size={15} />
{t(LABELS[kind].change)}
</button>
{kind === "portrait" ? (
<button
type="button"
className="artist-image-button"
disabled={busy}
onClick={() => {
setNotice(null);
setSearching(true);
}}
>
<Icon name="search" size={15} />
{t("artistImage.searchDeezer")}
</button>
) : null}
{artist.image_choice?.[kind] ? (
<button
type="button"
className="artist-image-button subtle"
disabled={busy}
onClick={() => void reset(kind)}
>
{t(LABELS[kind].reset)}
</button>
) : null}
<input
ref={pickers[kind]}
type="file"
accept={ACCEPT}
hidden
aria-label={t(LABELS[kind].change)}
onChange={(event) => {
const file = event.target.files?.[0];
// Cleared, so the same file chosen again is a change too.
event.target.value = "";
if (file) void choose(kind, file);
}}
/>
</span>
))}
</div>
{/* One discreet button over the picture rather than a row of five:
the photograph is what the header is for, and the actions are for
the few who may take them. */}
<button
ref={trigger}
type="button"
className="artist-image-button artist-image-menu"
aria-haspopup="menu"
aria-expanded={menu !== null}
// The short word on the button, the whole of it in the name: what
// the button says is how its name begins (WCAG 2.5.3), and the name
// says what is being edited.
aria-label={t("artistImage.menu")}
disabled={busy}
onClick={toggle}
>
<Icon name="more" size={16} />
{t("artistImage.menuShort")}
</button>
{kinds.map((kind) => (
<input
key={kind}
ref={pickers[kind]}
type="file"
accept={ACCEPT}
hidden
aria-label={t(LABELS[kind].change)}
onChange={(event) => {
const file = event.target.files?.[0];
// Cleared, so the same file chosen again is a change too.
event.target.value = "";
if (file) void choose(kind, file);
}}
/>
))}
{/* Portalled for the reason the track menu is: the header clips its
overflow, and the menu would be cut off at its lower edge. And
outside the header in the tree, so none of its light-on-picture ink
reaches the menu's own panel. */}
{menu
? createPortal(
<PopupMenu
label={t("artistImage.menuLabel", { name: artist.name })}
x={menu.x}
y={menu.y}
align="end"
items={items}
onClose={closeMenu}
anchor={trigger}
/>,
document.body,
)
: null}
{notice ? (
<p
className={notice.error ? "error" : "muted artist-image-notice"}
Expand Down
Loading
Loading