fix(files): review round 3, the page — a search waits for the server, the viewer keeps its file, a drop ends the drag
- past the list's cap a search waits in a skeleton, never showing the newest files' 'no match' for a file the server is about to find; a sort keeps its stand-in rows - the viewer keeps the file it was opened on when a server answer replaces the rows under it - a drop another zone took still clears the page's drop overlay - 'The first N items' only for a server sort; the past-the-end notice no longer claims the file exists - a failed server search is tested to say so
This commit is contained in:
@@ -38,6 +38,8 @@ const mocks = vi.hoisted(() => ({
|
|||||||
searched: [] as unknown[],
|
searched: [] as unknown[],
|
||||||
// The server's search/sort answer is still on its way (no data yet).
|
// The server's search/sort answer is still on its way (no data yet).
|
||||||
wideLoading: false,
|
wideLoading: false,
|
||||||
|
// The server's search/sort answer failed.
|
||||||
|
wideFailed: false,
|
||||||
foldersFailed: false,
|
foldersFailed: false,
|
||||||
notify: vi.fn(),
|
notify: vi.fn(),
|
||||||
}));
|
}));
|
||||||
@@ -73,6 +75,14 @@ vi.mock("../lib/useLiveQuery", () => ({
|
|||||||
if (opts?.enabled === false) return { data: undefined, isError: false, isLoading: false };
|
if (opts?.enabled === false) return { data: undefined, isError: false, isLoading: false };
|
||||||
mocks.keys.push(key);
|
mocks.keys.push(key);
|
||||||
const asked = key[1] as { q?: string; sort?: string } | undefined;
|
const asked = key[1] as { q?: string; sort?: string } | undefined;
|
||||||
|
if (key[0] === "files" && (asked?.q || asked?.sort) && mocks.wideFailed)
|
||||||
|
return {
|
||||||
|
data: undefined,
|
||||||
|
isError: true,
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
};
|
||||||
if (key[0] === "files" && (asked?.q || asked?.sort) && mocks.wideLoading)
|
if (key[0] === "files" && (asked?.q || asked?.sort) && mocks.wideLoading)
|
||||||
return {
|
return {
|
||||||
data: undefined,
|
data: undefined,
|
||||||
@@ -154,6 +164,7 @@ afterEach(() => {
|
|||||||
mocks.fetching = false;
|
mocks.fetching = false;
|
||||||
mocks.titleArgs = [];
|
mocks.titleArgs = [];
|
||||||
mocks.wideLoading = false;
|
mocks.wideLoading = false;
|
||||||
|
mocks.wideFailed = false;
|
||||||
mocks.notify.mockClear();
|
mocks.notify.mockClear();
|
||||||
resetQueueWatch();
|
resetQueueWatch();
|
||||||
});
|
});
|
||||||
@@ -476,11 +487,55 @@ describe("Files · a library past the list's cap", () => {
|
|||||||
expect(screen.getByText("files.countFirst")).toBeTruthy();
|
expect(screen.getByText("files.countFirst")).toBeTruthy();
|
||||||
cleanup();
|
cleanup();
|
||||||
|
|
||||||
|
// A search with no sort of the server's: it answers the newest matches first.
|
||||||
|
mount(ALL_FILES, vi.fn(), "clip");
|
||||||
|
expect(screen.getByText("files.countNewest")).toBeTruthy();
|
||||||
|
cleanup();
|
||||||
|
|
||||||
mocks.searched = [file(9999, { display_name: "Régi recept" })];
|
mocks.searched = [file(9999, { display_name: "Régi recept" })];
|
||||||
mount(ALL_FILES, vi.fn(), "recept");
|
mount(ALL_FILES, vi.fn(), "recept");
|
||||||
expect(screen.getByText("files.count")).toBeTruthy();
|
expect(screen.getByText("files.count")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a search past the cap waits for the server's answer — never a 'no match' it has not given", () => {
|
||||||
|
mocks.folders = [YT];
|
||||||
|
mocks.files = capped();
|
||||||
|
mocks.wideLoading = true;
|
||||||
|
mount(ALL_FILES, vi.fn(), "recept");
|
||||||
|
expect(screen.getByRole("status")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("files.empty.search")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a server search that failed says so, not the newest files searched here", () => {
|
||||||
|
mocks.folders = [YT];
|
||||||
|
mocks.files = capped();
|
||||||
|
mocks.wideFailed = true;
|
||||||
|
mount(ALL_FILES, vi.fn(), "clip");
|
||||||
|
expect(screen.getByText("common.loadError")).toBeTruthy();
|
||||||
|
expect(screen.queryByText("Clip 1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a file opened while the server's sort was on its way stays open when the answer leaves it out", async () => {
|
||||||
|
mocks.folders = [YT];
|
||||||
|
const doc = file(7, {
|
||||||
|
job_kind: "upload",
|
||||||
|
display_name: "Doc 7",
|
||||||
|
asset: { media_kind: "file", preview: "pdf", container: "pdf", size_bytes: 10 },
|
||||||
|
} as never);
|
||||||
|
mocks.files = [doc, ...capped().slice(1)];
|
||||||
|
mocks.wideLoading = true;
|
||||||
|
mount();
|
||||||
|
fireEvent.click(nameHeader());
|
||||||
|
fireEvent.click(screen.getByText("Doc 7"));
|
||||||
|
expect(await screen.findByRole("dialog")).toBeTruthy();
|
||||||
|
// The answer: the first files by name, without it.
|
||||||
|
mocks.wideLoading = false;
|
||||||
|
mocks.searched = Array.from({ length: FILES_LIST_CAP }, (_, i) => file(i + 1000));
|
||||||
|
fireEvent.click(nameHeader());
|
||||||
|
expect(screen.getByText("Clip 1499")).toBeTruthy(); // by name, from the end
|
||||||
|
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("an arrival waits for the server's answer: the table can only turn to a file it holds", async () => {
|
it("an arrival waits for the server's answer: the table can only turn to a file it holds", async () => {
|
||||||
mocks.folders = [YT];
|
mocks.folders = [YT];
|
||||||
mocks.files = capped();
|
mocks.files = capped();
|
||||||
@@ -1013,6 +1068,24 @@ describe("Files · dropping a file on the page", () => {
|
|||||||
expect(drag("drop", [new File(["y"], "masik.pdf")]).defaultPrevented).toBe(true);
|
expect(drag("drop", [new File(["y"], "masik.pdf")]).defaultPrevented).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a drop another zone took still ends the page's drag", () => {
|
||||||
|
mocks.files = [];
|
||||||
|
mocks.folders = [YT, OWN];
|
||||||
|
mount({ kind: "folder", id: 2 });
|
||||||
|
drag("dragenter");
|
||||||
|
expect(screen.getByText("files.drop.here:Cimboráknak")).toBeTruthy();
|
||||||
|
// Taken by a zone over the page (the issue composer's screenshot box): no dragleave follows.
|
||||||
|
const e = new Event("drop", { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(e, "dataTransfer", {
|
||||||
|
value: { types: ["Files"], files: Object.assign([], { item: () => null }) },
|
||||||
|
});
|
||||||
|
e.preventDefault();
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(e);
|
||||||
|
});
|
||||||
|
expect(screen.queryByText("files.drop.here:Cimboráknak")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("the open Upload dialog's own zone keeps the drop it accepted", async () => {
|
it("the open Upload dialog's own zone keeps the drop it accepted", async () => {
|
||||||
mocks.files = [];
|
mocks.files = [];
|
||||||
mocks.folders = [YT, OWN];
|
mocks.folders = [YT, OWN];
|
||||||
|
|||||||
@@ -117,6 +117,19 @@ function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number |
|
|||||||
return f && (f.kind === "own" || f.source_key === "upload") ? f.id : null;
|
return f && (f.kind === "own" || f.source_key === "upload") ? f.id : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The files the viewer pages through: the list's, and the open one even when the list moved on
|
||||||
|
* without it — a server sort's answer replacing the rows it was opened from. It closes only once the
|
||||||
|
* library no longer has it (deleted). */
|
||||||
|
function previewFiles(
|
||||||
|
listed: LibraryFile[],
|
||||||
|
openId: number | null,
|
||||||
|
library: readonly (readonly LibraryFile[] | undefined)[],
|
||||||
|
): LibraryFile[] {
|
||||||
|
if (openId === null || listed.some((f) => f.id === openId)) return listed;
|
||||||
|
const open = library.flatMap((l) => l ?? []).find((f) => f.id === openId);
|
||||||
|
return open ? [open, ...listed] : listed;
|
||||||
|
}
|
||||||
|
|
||||||
function NameCell({
|
function NameCell({
|
||||||
file,
|
file,
|
||||||
renaming,
|
renaming,
|
||||||
@@ -216,9 +229,11 @@ export default function Files() {
|
|||||||
intervalMs: 15000,
|
intervalMs: 15000,
|
||||||
enabled: wide,
|
enabled: wide,
|
||||||
});
|
});
|
||||||
// Until the server's first answer is in, the rows already on screen stay, searched and sorted here:
|
// Until a server sort's first answer is in, the rows already on screen stay, sorted here: a skeleton
|
||||||
// a skeleton in their place would unmount the table under the header the user just pressed.
|
// in their place would unmount the table under the header the user just pressed. A search waits in
|
||||||
const wideIn = wide && (wideQ.data !== undefined || wideQ.isError);
|
// a skeleton instead — the newest files searched here would say "no match" for a file the server is
|
||||||
|
// about to find — its keyboard being in the search box, not the table.
|
||||||
|
const wideIn = wide && (!!typed || wideQ.data !== undefined || wideQ.isError);
|
||||||
const listQ = wideIn ? wideQ : filesQ;
|
const listQ = wideIn ? wideQ : filesQ;
|
||||||
const all = listQ.data ?? [];
|
const all = listQ.data ?? [];
|
||||||
const rows = wideIn ? all : all.filter((f) => matches(f, q, lang));
|
const rows = wideIn ? all : all.filter((f) => matches(f, q, lang));
|
||||||
@@ -351,7 +366,14 @@ export default function Files() {
|
|||||||
const dragDepth = useRef(0);
|
const dragDepth = useRef(0);
|
||||||
const dropReady = canUpload && upload === null;
|
const dropReady = canUpload && upload === null;
|
||||||
const onDrag = useEffectEvent((e: DragEvent) => {
|
const onDrag = useEffectEvent((e: DragEvent) => {
|
||||||
if (!e.dataTransfer?.types.includes("Files") || e.defaultPrevented) return;
|
if (!e.dataTransfer?.types.includes("Files")) return;
|
||||||
|
// A drop ends the drag wherever it lands, taken by another zone too (no dragleave follows it): the
|
||||||
|
// page's "drop here" goes with it.
|
||||||
|
if (e.type === "drop") {
|
||||||
|
dragDepth.current = 0;
|
||||||
|
setDragging(false);
|
||||||
|
}
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!dropReady) {
|
if (!dropReady) {
|
||||||
e.dataTransfer.dropEffect = "none";
|
e.dataTransfer.dropEffect = "none";
|
||||||
@@ -360,7 +382,6 @@ export default function Files() {
|
|||||||
if (e.type === "dragenter") dragDepth.current += 1;
|
if (e.type === "dragenter") dragDepth.current += 1;
|
||||||
if (e.type === "dragleave") dragDepth.current = Math.max(0, dragDepth.current - 1);
|
if (e.type === "dragleave") dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||||
if (e.type === "drop") {
|
if (e.type === "drop") {
|
||||||
dragDepth.current = 0;
|
|
||||||
const dropped = e.dataTransfer.files;
|
const dropped = e.dataTransfer.files;
|
||||||
const first = dropped.item(0);
|
const first = dropped.item(0);
|
||||||
if (first) setUpload({ file: first });
|
if (first) setUpload({ file: first });
|
||||||
@@ -666,11 +687,18 @@ export default function Files() {
|
|||||||
)}
|
)}
|
||||||
{listQ.data && (
|
{listQ.data && (
|
||||||
<span className="text-xs tabular-nums text-muted">
|
<span className="text-xs tabular-nums text-muted">
|
||||||
{/* A list the cap cut says which files it holds: the newest, or the first by the
|
{/* A list the cap cut says which files it holds: the first by the server's sort, else
|
||||||
server's sort or search. */}
|
the newest (a search's too — the server answers it newest first). */}
|
||||||
{t(cut ? (wideIn ? "files.countFirst" : "files.countNewest") : "files.count", {
|
{t(
|
||||||
count: rows.length,
|
cut
|
||||||
})}
|
? wideIn && serverSort
|
||||||
|
? "files.countFirst"
|
||||||
|
: "files.countNewest"
|
||||||
|
: "files.count",
|
||||||
|
{
|
||||||
|
count: rows.length,
|
||||||
|
},
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isGrid && (
|
{isGrid && (
|
||||||
@@ -744,8 +772,12 @@ export default function Files() {
|
|||||||
{viewing !== null && (
|
{viewing !== null && (
|
||||||
<FilePreview
|
<FilePreview
|
||||||
// The viewer pages the list as it reads (its sort), through the files it can open.
|
// The viewer pages the list as it reads (its sort), through the files it can open.
|
||||||
files={sortRows(rows, columns, sort, lang).filter(
|
files={previewFiles(
|
||||||
(f) => (f.shared ? sharedRowActions(f) : libraryRowActions(f)).view,
|
sortRows(rows, columns, sort, lang).filter(
|
||||||
|
(f) => (f.shared ? sharedRowActions(f) : libraryRowActions(f)).view,
|
||||||
|
),
|
||||||
|
viewing,
|
||||||
|
[filesQ.data, wideQ.data],
|
||||||
)}
|
)}
|
||||||
fileId={viewing}
|
fileId={viewing}
|
||||||
onFileChange={setViewing}
|
onFileChange={setViewing}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"folderUnknown": "This folder could not be loaded",
|
"folderUnknown": "This folder could not be loaded",
|
||||||
"arrival": {
|
"arrival": {
|
||||||
"gone": "That file is no longer in your library",
|
"gone": "That file is no longer in your library",
|
||||||
"notListed": "That file is past the end of this list — search for it by name"
|
"notListed": "That file isn't among the ones listed here — search for it by name"
|
||||||
},
|
},
|
||||||
"phoneOk": "Plays on a phone too",
|
"phoneOk": "Plays on a phone too",
|
||||||
"rail": {
|
"rail": {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
"folderUnknown": "Ez a mappa nem tölthető be",
|
"folderUnknown": "Ez a mappa nem tölthető be",
|
||||||
"arrival": {
|
"arrival": {
|
||||||
"gone": "Ez a fájl már nincs a könyvtáradban",
|
"gone": "Ez a fájl már nincs a könyvtáradban",
|
||||||
"notListed": "Ez a fájl kimaradt a listából — keress rá a nevére"
|
"notListed": "Ez a fájl nincs a listázottak között — keress rá a nevére"
|
||||||
},
|
},
|
||||||
"phoneOk": "Telefonon is lejátszható",
|
"phoneOk": "Telefonon is lejátszható",
|
||||||
"rail": {
|
"rail": {
|
||||||
|
|||||||
Reference in New Issue
Block a user