fix(files): the token list is fresh after the sessions end, a copy again reads Copied again, the Copied button says so
This commit is contained in:
@@ -20,6 +20,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
||||
}),
|
||||
}));
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
||||
vi.mock("../lib/clipboard", () => ({ copyText: vi.fn(async () => true) }));
|
||||
|
||||
import ApiTokens from "./ApiTokens";
|
||||
|
||||
@@ -192,6 +193,11 @@ describe("API tokens · making one", () => {
|
||||
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.token)" }),
|
||||
).toBe(document.activeElement);
|
||||
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.tryIt)" });
|
||||
// Once copied, its name says what it shows, so a voice command saying "Copied" finds it.
|
||||
fireEvent.click(document.activeElement!);
|
||||
await screen.findByRole("button", {
|
||||
name: "settings.tokens.copiedField(settings.tokens.token)",
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.done" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -489,8 +489,11 @@ function CopyField({
|
||||
size="sm"
|
||||
autoFocus={autoFocus}
|
||||
icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
// Two of these sit in one dialog: each is named by the value it copies.
|
||||
aria-label={t("settings.tokens.copyField", { field: label })}
|
||||
// Two of these sit in one dialog: each is named by the value it copies, and by the word
|
||||
// it shows, so a voice command saying what is on screen finds it.
|
||||
aria-label={t(copied ? "settings.tokens.copiedField" : "settings.tokens.copyField", {
|
||||
field: label,
|
||||
})}
|
||||
onClick={() => void copy(value)}
|
||||
>
|
||||
{copied ? t("settings.tokens.copied") : t("settings.tokens.copy")}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Ending every other session, or changing the password, revokes every API token on the server — so
|
||||
// the Tokens tab must not then show them from its cache as if they still worked.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { Me } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
logoutOthers: vi.fn(async () => ({ ok: true })),
|
||||
setPassword: vi.fn(async (_p: string, _c?: string) => ({ ok: true })),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/api")>()),
|
||||
api: h,
|
||||
}));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
||||
return { ...actual, notify: vi.fn() };
|
||||
});
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => vi.fn(async () => true) }));
|
||||
|
||||
import { SignInMethods } from "./SettingsPanel";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const me = { email: "a@example.com", has_password: true, google_enabled: false } as Me;
|
||||
|
||||
function renderIt() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.apiTokens(), [{ id: 1 }]);
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SignInMethods me={me} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return qc;
|
||||
}
|
||||
|
||||
const tokensStale = (qc: QueryClient) => qc.getQueryState(qk.apiTokens())?.isInvalidated;
|
||||
|
||||
describe("SignInMethods", () => {
|
||||
it("signing the other sessions out marks the token list stale", async () => {
|
||||
const qc = renderIt();
|
||||
fireEvent.click(screen.getByText("settings.account.sessions.action"));
|
||||
await waitFor(() => expect(h.logoutOthers).toHaveBeenCalled());
|
||||
await waitFor(() => expect(tokensStale(qc)).toBe(true));
|
||||
});
|
||||
|
||||
it("a password changed marks the token list stale", async () => {
|
||||
const qc = renderIt();
|
||||
fireEvent.click(screen.getByText("settings.account.password.change"));
|
||||
fireEvent.change(screen.getByPlaceholderText("settings.account.password.current"), {
|
||||
target: { value: "old one" },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("settings.account.password.new"), {
|
||||
target: { value: "a brand new one" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("settings.account.password.save"));
|
||||
await waitFor(() => expect(h.setPassword).toHaveBeenCalled());
|
||||
await waitFor(() => expect(tokensStale(qc)).toBe(true));
|
||||
});
|
||||
});
|
||||
@@ -361,7 +361,7 @@ function AccessRow({
|
||||
// so either method can reach the same account. Google connect is a full-page OAuth round-trip
|
||||
// (auth.py attaches the identity to the current session via /auth/link); the password form posts
|
||||
// directly with inline errors.
|
||||
function SignInMethods({ me }: { me: Me }) {
|
||||
export function SignInMethods({ me }: { me: Me }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
@@ -387,6 +387,8 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
setSessBusy(true);
|
||||
try {
|
||||
await api.logoutOthers();
|
||||
// Every API token went with the other sessions: the Tokens tab must not show them from cache.
|
||||
void qc.invalidateQueries({ queryKey: qk.apiTokens() });
|
||||
notify({ level: "success", message: t("settings.account.sessions.done") });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("settings.account.sessions.failed"));
|
||||
@@ -404,7 +406,9 @@ function SignInMethods({ me }: { me: Me }) {
|
||||
setOpen(false);
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
// Refresh `me` so has_password flips and the section switches to "Change password".
|
||||
// Refresh `me` so has_password flips and the section switches to "Change password". Every API
|
||||
// token went with the old password, so the Tokens tab must not show them from cache either.
|
||||
void qc.invalidateQueries({ queryKey: qk.apiTokens() });
|
||||
await qc.invalidateQueries({ queryKey: qk.me() });
|
||||
notify({
|
||||
level: "success",
|
||||
|
||||
@@ -122,6 +122,20 @@ describe("UploadDialog", () => {
|
||||
expect(h.uploadEnded).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("an upload that failed or was cut off still says it ended, so later finishes are announced", async () => {
|
||||
h.uploadFile.mockRejectedValueOnce(new Error("network"));
|
||||
h.uploadStarted.mockClear();
|
||||
h.uploadEnded.mockClear();
|
||||
wrap();
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File(["x"], "clip.mp4", { type: "video/mp4" })] },
|
||||
});
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.uploadEnded).toHaveBeenCalledTimes(1));
|
||||
expect(h.uploadEnded).toHaveBeenCalledWith(null);
|
||||
expect(h.uploadStarted).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("puts the file in the folder it was opened from, and its notice opens where it now is", async () => {
|
||||
// Done: Files opens on the file itself, in its folder. Still converting: the tray opens on it.
|
||||
h.uploadFile.mockResolvedValueOnce({ id: 42, status: "done", folder_id: 5 } as never);
|
||||
|
||||
@@ -32,6 +32,18 @@ describe("useCopyFeedback", () => {
|
||||
expect(result.current.copied).toBe(false);
|
||||
});
|
||||
|
||||
it("a copy again within the moment reads Copied for a whole moment again", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useCopyFeedback());
|
||||
await act(() => result.current.copy("sfl_x"));
|
||||
act(() => void vi.advanceTimersByTime(1500));
|
||||
await act(() => result.current.copy("sfl_x"));
|
||||
act(() => void vi.advanceTimersByTime(1000)); // past the first copy's moment
|
||||
expect(result.current.copied).toBe(true);
|
||||
act(() => void vi.advanceTimersByTime(1000));
|
||||
expect(result.current.copied).toBe(false);
|
||||
});
|
||||
|
||||
it("a copy the clipboard refused is a warning, and nothing reads Copied", async () => {
|
||||
h.copyText.mockResolvedValueOnce(false);
|
||||
const { result } = renderHook(() => useCopyFeedback());
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { copyText } from "../lib/clipboard";
|
||||
@@ -17,11 +17,15 @@ const COPIED_MS = 2000;
|
||||
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
// One reset at a time: a copy again within the moment starts it over, not cut short by the last.
|
||||
const timer = useRef<number | undefined>(undefined);
|
||||
useEffect(() => () => window.clearTimeout(timer.current), []);
|
||||
const copy = async (value: string) => {
|
||||
if (await copyText(value)) {
|
||||
setCopied(true);
|
||||
notify({ level: "success", message: t("common.copy.done") });
|
||||
window.setTimeout(() => setCopied(false), COPIED_MS);
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
|
||||
return;
|
||||
}
|
||||
notify({ level: "warning", message: t("common.copy.failed") });
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"copy": "Copy",
|
||||
"copied": "Copied",
|
||||
"copyField": "Copy: {{field}}",
|
||||
"copiedField": "Copied: {{field}}",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,6 +285,7 @@
|
||||
"copy": "Másolás",
|
||||
"copied": "Másolva",
|
||||
"copyField": "Másolás: {{field}}",
|
||||
"copiedField": "Másolva: {{field}}",
|
||||
"done": "Kész"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,14 @@ describe("downloadQueue · a finish", () => {
|
||||
expect(takeUnannounced().map((j) => j.id)).toEqual([9]);
|
||||
});
|
||||
|
||||
it("a download finishing during an upload is announced at once, not held for the upload", () => {
|
||||
noteQueue([]);
|
||||
uploadStarted();
|
||||
noteQueue([job(8, "done")]);
|
||||
expect(takeUnannounced().map((j) => j.id)).toEqual([8]);
|
||||
uploadEnded(null);
|
||||
});
|
||||
|
||||
it("an upload that failed announced nothing: what landed meanwhile is announced", () => {
|
||||
noteQueue([]);
|
||||
uploadStarted();
|
||||
|
||||
Reference in New Issue
Block a user