feat(files): the Downloads page goes — the tray, a Storage tab and a Downloads settings tab take its place
- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Below `md` a table is a list of cards, its columns flowing one after another. A column marked
|
||||
// `cardRow` takes a line of its own there — a row's lone "…" otherwise landed at the end of one
|
||||
// card's line and on the next line of another.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, screen } from "@testing-library/react";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const narrow = (matches: boolean) =>
|
||||
vi.stubGlobal(
|
||||
"matchMedia",
|
||||
(query: string) =>
|
||||
({
|
||||
matches,
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}) as unknown as MediaQueryList,
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
type Row = { id: string };
|
||||
const columns: Column<Row>[] = [
|
||||
{ key: "name", header: "Name", cardPrimary: true, render: (r) => r.id },
|
||||
{ key: "size", header: "Size", render: () => "1 MB" },
|
||||
{ key: "menu", header: "", cardLabel: false, cardRow: true, render: () => <button>more</button> },
|
||||
];
|
||||
|
||||
describe("DataTable cards", () => {
|
||||
it("gives a cardRow column a line of its own; the others flow", () => {
|
||||
narrow(false);
|
||||
render(<DataTable rows={[{ id: "a" }]} columns={columns} rowKey={(r) => r.id} />);
|
||||
expect(screen.getByText("more").parentElement!.className).toContain("basis-full");
|
||||
expect(screen.getByText("1 MB").parentElement!.className).not.toContain("basis-full");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A short list that never outgrows a screen opts out of paging: every row, and no pager — not even
|
||||
// the rows-per-page choice, which a paged table shows whenever it has rows. A size remembered for
|
||||
// the same table does not bring paging back.
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, screen } from "@testing-library/react";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
type Row = { id: string };
|
||||
const columns: Column<Row>[] = [{ key: "id", header: "Id", render: (r) => r.id }];
|
||||
const rows: Row[] = Array.from({ length: 12 }, (_, i) => ({ id: `row ${i + 1}` }));
|
||||
|
||||
describe("DataTable pager", () => {
|
||||
it("pages by default, rows-per-page shown", () => {
|
||||
render(<DataTable rows={rows} columns={columns} rowKey={(r) => r.id} pageSize={10} />);
|
||||
expect(screen.queryByText("row 11")).toBeNull();
|
||||
expect(screen.getByText("datatable.rowsPerPage")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("pager={false} shows every row and no pager, whatever size was remembered", () => {
|
||||
localStorage.setItem("t", JSON.stringify({ size: 10, page: 0 }));
|
||||
render(
|
||||
<DataTable rows={rows} columns={columns} rowKey={(r) => r.id} pager={false} persistKey="t" />,
|
||||
);
|
||||
expect(screen.getByText("row 12")).toBeTruthy();
|
||||
expect(screen.queryByText("datatable.rowsPerPage")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,7 @@ export default function DataTable<T>({
|
||||
rowKey,
|
||||
pageSize = 10,
|
||||
pageSizeOptions = [10, 25, 50, 100],
|
||||
pager = true,
|
||||
persistKey,
|
||||
emptyText,
|
||||
rowClassName,
|
||||
@@ -119,6 +120,10 @@ export default function DataTable<T>({
|
||||
rowKey: (row: T) => string;
|
||||
pageSize?: number;
|
||||
pageSizeOptions?: number[];
|
||||
// A short list that never outgrows a screen (a user's download formats) has nothing to page: false
|
||||
// shows every row and no pager — not even the rows-per-page choice, which there would only be
|
||||
// clutter. It overrides `pageSize` and a persisted size, so the two cannot disagree.
|
||||
pager?: boolean;
|
||||
persistKey?: string;
|
||||
emptyText?: string;
|
||||
rowClassName?: (row: T) => string;
|
||||
@@ -191,7 +196,8 @@ export default function DataTable<T>({
|
||||
const [internalFilters, setInternalFilters] = useState<FilterMap>(initial.filters);
|
||||
const filters = controlledFilters ? (filtersProp ?? EMPTY_FILTERS) : internalFilters;
|
||||
const [page, setPage] = useState(initial.page);
|
||||
const [size, setSize] = useState(initial.size ?? pageSize);
|
||||
// 0 = "All": an unpaged table is one page of every row.
|
||||
const [size, setSize] = useState(pager ? (initial.size ?? pageSize) : 0);
|
||||
const wide = useMediaQuery("(min-width: 48rem)");
|
||||
const bodyRef = useRef<HTMLTableSectionElement>(null);
|
||||
const cardsRef = useRef<HTMLDivElement>(null);
|
||||
@@ -315,20 +321,21 @@ export default function DataTable<T>({
|
||||
// Stays NULLABLE: the wrappers below decide whether to render a controls row at all, and an always
|
||||
// -truthy element would leave an empty `my-3` band (and an empty in-band PageToolbar) on a table
|
||||
// with nothing to page. The emptiness rule itself lives in Pager, so the two can't drift apart.
|
||||
const pagerNode = hasPagerContent(sorted.length, size) ? (
|
||||
<Pager
|
||||
page={safePage}
|
||||
size={size}
|
||||
total={sorted.length}
|
||||
pageSizeOptions={pageSizeOptions}
|
||||
onPageChange={setPage}
|
||||
onSizeChange={(s) => {
|
||||
setSize(s);
|
||||
setPage(0);
|
||||
}}
|
||||
sizeLabel={t("datatable.rowsPerPage")}
|
||||
/>
|
||||
) : null;
|
||||
const pagerNode =
|
||||
pager && hasPagerContent(sorted.length, size) ? (
|
||||
<Pager
|
||||
page={safePage}
|
||||
size={size}
|
||||
total={sorted.length}
|
||||
pageSizeOptions={pageSizeOptions}
|
||||
onPageChange={setPage}
|
||||
onSizeChange={(s) => {
|
||||
setSize(s);
|
||||
setPage(0);
|
||||
}}
|
||||
sizeLabel={t("datatable.rowsPerPage")}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const controls =
|
||||
controlsLeading != null || pagerNode ? (
|
||||
@@ -424,7 +431,10 @@ export default function DataTable<T>({
|
||||
{columns
|
||||
.filter((c) => !c.hideInCard && !c.cardPrimary)
|
||||
.map((col) => (
|
||||
<span key={col.key} className="inline-flex items-center gap-1">
|
||||
<span
|
||||
key={col.key}
|
||||
className={`inline-flex items-center gap-1${col.cardRow ? " basis-full" : ""}`}
|
||||
>
|
||||
{col.cardLabel !== false && (
|
||||
<span className="text-muted text-xs">{col.header}</span>
|
||||
)}
|
||||
|
||||
@@ -45,7 +45,6 @@ const PLAIN: Record<string, [number, string]> = {
|
||||
"UploadDialog.tsx": [1, "the drop zone"],
|
||||
"VideoEditor.tsx": [2, "timeline glyphs"],
|
||||
"Welcome.tsx": [1, "a screenshot (zoom)"],
|
||||
"downloadRow.tsx": [1, "the error-details link"],
|
||||
"playlistColumns.tsx": [1, "the thumbnail"],
|
||||
"plexDetailUi.tsx": [2, "a switch row and the poster"],
|
||||
"plexViews.tsx": [1, "the rating filter (a number)"],
|
||||
|
||||
Reference in New Issue
Block a user