fix(jobs): a failed clone's chip is readable and clickable
A failed or stopped clone also raised a toast on the same corner, a layer above the dock, so it covered the chip's x / Retry / Stop and repeated the same long message; the chip now says it alone, and every toast stacks above the dock (--jobs-dock-h). A URL with no space to break at (user@host:/PATH) wraps inside the chip and inside a toast instead of running off the screen. Open Settings -> Git is a router link; a plain <a href> reloaded the app.
This commit is contained in:
@@ -13,9 +13,9 @@
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { ChevronDown, FolderGit2, Square, Upload, X } from "lucide-react";
|
||||
import type { CloneJob } from "../lib/api";
|
||||
import {
|
||||
@@ -156,15 +156,16 @@ function CloneJobChip({ job }: { job: CloneJob }) {
|
||||
<>
|
||||
<p
|
||||
role={job.state === "cancelled" ? undefined : "alert"}
|
||||
className={`text-[11px] ${
|
||||
className={`text-[11px] [overflow-wrap:anywhere] ${
|
||||
job.state === "cancelled" ? "text-fg-secondary" : "text-status-danger"
|
||||
}`}
|
||||
>
|
||||
{cloneJobError(job)}{" "}
|
||||
{code === "ESSHAUTH" && (
|
||||
<a href="/settings/git#git-identity" className="underline">
|
||||
// A router link: a plain <a href> reloaded the whole app.
|
||||
<Link to="/settings/git#git-identity" className="underline">
|
||||
{t("clone.sshSettings")}
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
{code === "ECREDENTIALS" && job.state === "failed" ? (
|
||||
@@ -226,7 +227,7 @@ function CloneJobChip({ job }: { job: CloneJob }) {
|
||||
)
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="text-[11px] text-status-danger">
|
||||
<p role="alert" className="text-[11px] text-status-danger [overflow-wrap:anywhere]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
@@ -244,7 +245,7 @@ function UploadChip({ job }: { job: UploadJob }) {
|
||||
<div className={chipClass} data-testid="upload-job" data-state="interrupted">
|
||||
<div className="flex items-start gap-2">
|
||||
<Upload className="mt-0.5 h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||||
<p className="min-w-0 flex-1 text-[11px] text-fg-secondary">
|
||||
<p className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
|
||||
{t("uploads.interrupted", {
|
||||
total: job.total,
|
||||
folder: job.folder,
|
||||
@@ -305,15 +306,35 @@ export function BackgroundJobsDock() {
|
||||
const jobs = useCloneJobs();
|
||||
const uploads = useUploadJobs();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const dockRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => ensureCloneJobs(), []);
|
||||
|
||||
const count = jobs.length + uploads.length;
|
||||
// Toasts stack ABOVE the dock (Toaster reads --jobs-dock-h): they used to
|
||||
// sit on the same corner and cover a chip's × / Retry / Stop.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const el = dockRef.current;
|
||||
if (!count || !el) {
|
||||
root.style.removeProperty("--jobs-dock-h");
|
||||
return;
|
||||
}
|
||||
const set = () => root.style.setProperty("--jobs-dock-h", `${el.offsetHeight + 8}px`);
|
||||
set();
|
||||
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(set);
|
||||
ro?.observe(el);
|
||||
return () => {
|
||||
ro?.disconnect();
|
||||
root.style.removeProperty("--jobs-dock-h");
|
||||
};
|
||||
}, [count, collapsed]);
|
||||
if (!count) return null;
|
||||
const running =
|
||||
jobs.filter(isActiveJob).length + uploads.filter((u) => u.state !== "interrupted").length;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dockRef}
|
||||
role="region"
|
||||
aria-label={t("jobs.dock")}
|
||||
data-testid="jobs-dock"
|
||||
|
||||
@@ -25,7 +25,9 @@ function Item({ item }: { item: ToastItem }) {
|
||||
className={`pointer-events-auto rounded-lg border px-3 py-2 shadow-lg flex items-start gap-2 ${TONE[item.kind]}`}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0 mt-0.5" aria-hidden />
|
||||
<span className="text-xs leading-relaxed flex-1 break-words">{item.message}</span>
|
||||
<span className="text-xs leading-relaxed flex-1 min-w-0 break-words [overflow-wrap:anywhere]">
|
||||
{item.message}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismissToast(item.id)}
|
||||
@@ -44,8 +46,10 @@ export function Toaster() {
|
||||
useEffect(() => subscribeToasts(setItems), []);
|
||||
const errors = items.filter((i) => i.kind === "error");
|
||||
const rest = items.filter((i) => i.kind !== "error");
|
||||
// Above the background-jobs dock (BackgroundJobsDock sets --jobs-dock-h
|
||||
// to its height), so a toast never covers a chip's buttons.
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-10 right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
|
||||
<div className="pointer-events-none fixed bottom-[calc(2.5rem+var(--jobs-dock-h,0px))] right-4 z-[70] flex w-[min(28rem,calc(100vw-2rem))] flex-col gap-2">
|
||||
<div role="status" aria-live="polite" className="flex flex-col gap-2">
|
||||
{rest.map((i) => (
|
||||
<Item key={i.id} item={i} />
|
||||
|
||||
@@ -94,7 +94,10 @@ describe("cloneJobs store", () => {
|
||||
const listen = (e: Event) => events.push((e as CustomEvent<CloneJob>).detail);
|
||||
window.addEventListener(CLONE_JOB_EVENT, listen);
|
||||
vi.mocked(api.lanes.clone).mockResolvedValue({ job: job({ state: "queued", position: 1 }) });
|
||||
await startClone({ kind: "workspace", body: { url: base.url, parent: "/home/an" } }, { onDone });
|
||||
await startClone(
|
||||
{ kind: "workspace", body: { url: base.url, parent: "/home/an" } },
|
||||
{ onDone }
|
||||
);
|
||||
expect(api.lanes.clone).toHaveBeenCalledWith(expect.objectContaining({ background: true }));
|
||||
expect(toast.success).not.toHaveBeenCalled();
|
||||
|
||||
@@ -205,7 +208,8 @@ describe("BackgroundJobsDock", () => {
|
||||
expect(api.lanes.cancelCloneJob).toHaveBeenCalledWith("j1");
|
||||
const chip = await screen.findByText("You stopped this clone.");
|
||||
expect(chip).toBeInTheDocument();
|
||||
expect(toast.info).toHaveBeenCalledWith("Stopped cloning repo.");
|
||||
// The chip says it; no toast repeats it on top of the chip.
|
||||
expect(toast.info).not.toHaveBeenCalled();
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(api.lanes.retryCloneJob).toHaveBeenCalledWith("j1", undefined);
|
||||
@@ -287,4 +291,27 @@ describe("BackgroundJobsDock", () => {
|
||||
await user.click(screen.getByTestId("jobs-dock-pill"));
|
||||
expect(screen.getAllByTestId("clone-job")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("wraps a long SSH error, links to Settings in-app and lifts toasts above itself", async () => {
|
||||
const long = "mdpartners@mdpartners.git.backlog.jp:/PETITGIFT_CONSOLE4/console4-specs.git";
|
||||
vi.mocked(api.lanes.cloneJobs).mockResolvedValue({
|
||||
jobs: [
|
||||
job({
|
||||
state: "failed",
|
||||
url: long,
|
||||
error: { code: "ESSHAUTH", message: "the SSH login was refused" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
renderDock();
|
||||
const alert = await screen.findByRole("alert");
|
||||
// A URL with no space to break at must still wrap inside the chip.
|
||||
expect(alert.className).toContain("[overflow-wrap:anywhere]");
|
||||
// A router link, not a full page reload.
|
||||
const link = screen.getByRole("link", { name: /Settings/ });
|
||||
expect(link).toHaveAttribute("href", "/settings/git#git-identity");
|
||||
await waitFor(() =>
|
||||
expect(document.documentElement.style.getPropertyValue("--jobs-dock-h")).not.toBe("")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,10 +33,7 @@ export const isActiveJob = (j: Pick<CloneJob, "state">) =>
|
||||
type OnDone = (job: CloneJob, lane?: Lane) => void;
|
||||
type Creds = { username: string; password: string; remember?: boolean };
|
||||
type WorkspaceBody = Omit<Parameters<typeof api.lanes.clone>[0], "background" | "progressToken">;
|
||||
type SourceBody = Omit<
|
||||
Parameters<typeof api.lanes.cloneFolder>[1],
|
||||
"background" | "progressToken"
|
||||
>;
|
||||
type SourceBody = Omit<Parameters<typeof api.lanes.cloneFolder>[1], "background" | "progressToken">;
|
||||
|
||||
let jobs: CloneJob[] = [];
|
||||
/** The last state seen per job, to tell a transition from a repeat. */
|
||||
@@ -79,6 +76,8 @@ export function cloneJobError(job: CloneJob): string {
|
||||
function announce(job: CloneJob, lane?: Lane) {
|
||||
const t = i18n.t.bind(i18n);
|
||||
const path = job.result?.path || job.target;
|
||||
// A failure or a stop is told by its chip alone (reason + Retry): a toast
|
||||
// repeated the same long message on top of the chip, covering its buttons.
|
||||
if (job.state === "done") {
|
||||
toast.success(
|
||||
job.kind === "workspace"
|
||||
@@ -89,10 +88,6 @@ function announce(job: CloneJob, lane?: Lane) {
|
||||
})
|
||||
: t("lanes:cloneJobs.doneSource", { name: job.name, path })
|
||||
);
|
||||
} else if (job.state === "cancelled") {
|
||||
toast.info(t("lanes:cloneJobs.cancelledToast", { name: job.name }));
|
||||
} else {
|
||||
toast.error(t("lanes:cloneJobs.failedToast", { name: job.name, message: cloneJobError(job) }));
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent<CloneJob>(CLONE_JOB_EVENT, { detail: job }));
|
||||
if (job.state === "done") {
|
||||
|
||||
Reference in New Issue
Block a user