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("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
||||||
|
vi.mock("../lib/clipboard", () => ({ copyText: vi.fn(async () => true) }));
|
||||||
|
|
||||||
import ApiTokens from "./ApiTokens";
|
import ApiTokens from "./ApiTokens";
|
||||||
|
|
||||||
@@ -192,6 +193,11 @@ describe("API tokens · making one", () => {
|
|||||||
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.token)" }),
|
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.token)" }),
|
||||||
).toBe(document.activeElement);
|
).toBe(document.activeElement);
|
||||||
screen.getByRole("button", { name: "settings.tokens.copyField(settings.tokens.tryIt)" });
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "settings.tokens.done" }));
|
||||||
expect(screen.queryByRole("dialog")).toBeNull();
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -489,8 +489,11 @@ function CopyField({
|
|||||||
size="sm"
|
size="sm"
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
icon={copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
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.
|
// Two of these sit in one dialog: each is named by the value it copies, and by the word
|
||||||
aria-label={t("settings.tokens.copyField", { field: label })}
|
// 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)}
|
onClick={() => void copy(value)}
|
||||||
>
|
>
|
||||||
{copied ? t("settings.tokens.copied") : t("settings.tokens.copy")}
|
{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
|
// 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
|
// (auth.py attaches the identity to the current session via /auth/link); the password form posts
|
||||||
// directly with inline errors.
|
// directly with inline errors.
|
||||||
function SignInMethods({ me }: { me: Me }) {
|
export function SignInMethods({ me }: { me: Me }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
@@ -387,6 +387,8 @@ function SignInMethods({ me }: { me: Me }) {
|
|||||||
setSessBusy(true);
|
setSessBusy(true);
|
||||||
try {
|
try {
|
||||||
await api.logoutOthers();
|
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") });
|
notify({ level: "success", message: t("settings.account.sessions.done") });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyActionError(e, t("settings.account.sessions.failed"));
|
notifyActionError(e, t("settings.account.sessions.failed"));
|
||||||
@@ -404,7 +406,9 @@ function SignInMethods({ me }: { me: Me }) {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
setCurrent("");
|
setCurrent("");
|
||||||
setNext("");
|
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() });
|
await qc.invalidateQueries({ queryKey: qk.me() });
|
||||||
notify({
|
notify({
|
||||||
level: "success",
|
level: "success",
|
||||||
|
|||||||
@@ -122,6 +122,20 @@ describe("UploadDialog", () => {
|
|||||||
expect(h.uploadEnded).toHaveBeenCalledWith(null);
|
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 () => {
|
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.
|
// 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);
|
h.uploadFile.mockResolvedValueOnce({ id: 42, status: "done", folder_id: 5 } as never);
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ describe("useCopyFeedback", () => {
|
|||||||
expect(result.current.copied).toBe(false);
|
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 () => {
|
it("a copy the clipboard refused is a warning, and nothing reads Copied", async () => {
|
||||||
h.copyText.mockResolvedValueOnce(false);
|
h.copyText.mockResolvedValueOnce(false);
|
||||||
const { result } = renderHook(() => useCopyFeedback());
|
const { result } = renderHook(() => useCopyFeedback());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../lib/clipboard";
|
||||||
@@ -17,11 +17,15 @@ const COPIED_MS = 2000;
|
|||||||
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
|
export function useCopyFeedback(): { copied: boolean; copy: (value: string) => Promise<void> } {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [copied, setCopied] = useState(false);
|
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) => {
|
const copy = async (value: string) => {
|
||||||
if (await copyText(value)) {
|
if (await copyText(value)) {
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
notify({ level: "success", message: t("common.copy.done") });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
notify({ level: "warning", message: t("common.copy.failed") });
|
notify({ level: "warning", message: t("common.copy.failed") });
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied",
|
"copied": "Copied",
|
||||||
"copyField": "Copy: {{field}}",
|
"copyField": "Copy: {{field}}",
|
||||||
|
"copiedField": "Copied: {{field}}",
|
||||||
"done": "Done"
|
"done": "Done"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,6 +285,7 @@
|
|||||||
"copy": "Másolás",
|
"copy": "Másolás",
|
||||||
"copied": "Másolva",
|
"copied": "Másolva",
|
||||||
"copyField": "Másolás: {{field}}",
|
"copyField": "Másolás: {{field}}",
|
||||||
|
"copiedField": "Másolva: {{field}}",
|
||||||
"done": "Kész"
|
"done": "Kész"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,14 @@ describe("downloadQueue · a finish", () => {
|
|||||||
expect(takeUnannounced().map((j) => j.id)).toEqual([9]);
|
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", () => {
|
it("an upload that failed announced nothing: what landed meanwhile is announced", () => {
|
||||||
noteQueue([]);
|
noteQueue([]);
|
||||||
uploadStarted();
|
uploadStarted();
|
||||||
|
|||||||
Reference in New Issue
Block a user