Skip to content
Draft
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
8 changes: 8 additions & 0 deletions .changeset/media-library-folder-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Adds flat-folder organization to the local Media Library. Editors can create, rename, and delete folders, while authors can move their own media and editors can move any local media through Media Details.

Uploads continue to enter the Main library. Deleting a folder returns its media to the Main library without deleting files or changing their URLs.
14 changes: 14 additions & 0 deletions docs/src/content/docs/guides/media-library.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,20 @@ Use the search box to find files by name. Search matches partial filenames.

Use the type menu to show all files, images, video, audio, or documents.

## Organizing media in folders

Editors can select **Add new folder** from the Main library. Open a folder by selecting its name.
Without a search term, folder pages show only the media assigned to that folder. Filename searches
cover the whole library, including other folders and the Main library.

To move a file, open its **Media Details**, choose a **Location**, and select **Save**. Authors can
move files they uploaded. Editors can move any local file.

Uploads enter the Main library. Move an uploaded file into a folder from **Media Details**.

Deleting a folder returns its media to the Main library. The media files, URLs, and content
references remain unchanged.

## Using Media in Content

### In the Rich Text Editor
Expand Down
501 changes: 501 additions & 0 deletions docs/technical-specs/media-library-folders-pr2.md

Large diffs are not rendered by default.

431 changes: 431 additions & 0 deletions docs/technical-specs/media-library-folders-visual-polish.md

Large diffs are not rendered by default.

118 changes: 113 additions & 5 deletions e2e/tests/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,12 +178,120 @@ test.describe("Accessibility Audit", () => {
await admin.waitForLoading();
await expect(admin.page).toHaveURL(MEDIA_URL);

const results = await new AxeBuilder({ page: admin.page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();
const analyze = () =>
new AxeBuilder({ page: admin.page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();
expect((await analyze()).violations).toEqual([]);

const folderName = `Accessibility ${Date.now()}`;
await admin.page.getByRole("button", { name: "Add new folder" }).click();
const folderDialog = admin.page.getByRole("dialog", { name: "Add new folder" });
expect((await analyze()).violations).toEqual([]);
await folderDialog.getByLabel("Name").fill(folderName);
await folderDialog.getByRole("button", { name: "Create" }).click();

await admin.page.getByRole("button", { name: `Edit folder ${folderName}` }).click();
expect((await analyze()).violations).toEqual([]);
const editDialog = admin.page.getByRole("dialog", { name: "Edit folder" });
await editDialog.getByRole("button", { name: "Delete folder" }).click();
expect((await analyze()).violations).toEqual([]);
await admin.page.getByRole("button", { name: "Cancel" }).last().click();
await editDialog.getByRole("button", { name: "Cancel" }).click();

await admin.page.getByRole("link", { name: `Open folder ${folderName}` }).click();
expect((await analyze()).violations).toEqual([]);
await admin.page.getByRole("button", { name: "Back to Main library" }).first().click();

await admin.page.locator("[data-media-grid] button").first().click();
const mediaDetails = admin.page.getByRole("dialog", { name: "Media Details" });
await mediaDetails.getByRole("combobox", { name: "Location" }).click();
expect((await analyze()).violations).toEqual([]);
await admin.page.keyboard.press("Escape");
await mediaDetails.getByRole("button", { name: "Close" }).click();

await admin.page.getByRole("button", { name: `Edit folder ${folderName}` }).click();
await editDialog.getByRole("button", { name: "Delete folder" }).click();
await admin.page
.getByRole("dialog", { name: `Delete “${folderName}”?` })
.getByRole("button", { name: "Delete folder" })
.click();
});

expect(results.violations).toEqual([]);
test("media list folder states should have no WCAG 2.x AA violations", async ({ admin }) => {
test.setTimeout(60_000);
const page = admin.page;
const folderPattern = "**/_emdash/api/media/folders?**";
let releaseFolders: () => void = () => {};
const folderGate = new Promise<void>((resolve) => {
releaseFolders = resolve;
});
await page.route(folderPattern, async (route) => {
if (route.request().method() !== "GET") return route.continue();
await folderGate;
await route.continue();
});

await admin.goToMedia();
await expect(page.getByRole("heading", { name: "Media Library" })).toBeVisible();
await page.getByRole("tab", { name: "List view" }).click();
const table = page.getByRole("table");
await expect(table.getByText("Loading folders")).toBeVisible();
const analyze = () =>
new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag21aa"])
.disableRules(KNOWN_A11Y_EXCLUSIONS)
.analyze();
expect((await analyze()).violations).toEqual([]);

releaseFolders();
await expect(table.getByText("Loading folders")).not.toBeVisible();
await page.unroute(folderPattern);
const folderName = `List accessibility ${Date.now()}`;
await page.getByRole("button", { name: "Add new folder" }).click();
const folderDialog = page.getByRole("dialog", { name: "Add new folder" });
await folderDialog.getByLabel("Name").fill(folderName);
await folderDialog.getByRole("button", { name: "Create" }).click();
await expect(page.getByRole("link", { name: `Open folder ${folderName}` })).toBeVisible();
expect((await analyze()).violations).toEqual([]);

await page.route(folderPattern, async (route) => {
if (route.request().method() !== "GET") return route.continue();
const url = new URL(route.request().url());
if (url.searchParams.has("cursor")) {
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({
success: false,
error: { code: "TEST_ERROR", message: "Folder list failed" },
}),
});
return;
}
const response = await route.fetch();
const body = (await response.json()) as { data: { nextCursor?: string } };
body.data.nextCursor = "forced-accessibility-page";
await route.fulfill({ response, json: body });
});
await page.reload();
const listTab = page.getByRole("tab", { name: "List view" });
if ((await listTab.getAttribute("aria-selected")) !== "true") await listTab.click();
await page.getByRole("button", { name: "Load more folders" }).click();
await expect(table.getByRole("alert")).toHaveText("Folders could not be loaded.");
await expect(table.getByRole("button", { name: "Retry" })).toBeVisible();
expect((await analyze()).violations).toEqual([]);

await page.unroute(folderPattern);
await page.reload();
await page.getByRole("button", { name: `Edit folder ${folderName}` }).click();
const editDialog = page.getByRole("dialog", { name: "Edit folder" });
await editDialog.getByRole("button", { name: "Delete folder" }).click();
await page
.getByRole("dialog", { name: `Delete “${folderName}”?` })
.getByRole("button", { name: "Delete folder" })
.click();
});

test("users page should have no WCAG 2.x AA violations", async ({ admin }) => {
Expand Down
Loading
Loading