Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion src/bundled/channels/ChannelCanvasDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import { StrictMode } from "react";
import { StrictMode, useRef, useState } from "react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { keypair, signed } from "../../features/relay/testing";
Expand Down Expand Up @@ -163,3 +163,57 @@ it("ignores the stale StrictMode read after the current read enables typing and
release(head);
}
});

it("returns focus to the ingress on close and retains edits without publishing", async () => {
const user = userEvent.setup();
const canvas = {
available: true,
read: vi.fn(async () => head),
save: vi.fn(async () => head),
};
function Harness() {
const [open, setOpen] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
return (
<>
<button type="button" ref={trigger} onClick={() => setOpen(true)}>
Canvas
</button>
{open && (
<ChannelCanvasDialog
canvas={canvas}
scope={scope}
channelId={channelId}
open={open}
onOpenChange={setOpen}
finalFocus={trigger}
/>
)}
</>
);
}
render(<Harness />);
const trigger = screen.getByRole("button", { name: "Canvas" });
await user.click(trigger);
const text = screen.getByRole("textbox", { name: "Canvas Markdown" });
await waitFor(() => expect(text).toHaveValue("Saved"));
await user.clear(text);
await user.type(text, "Local draft");
await user.keyboard("{Escape}");
await waitFor(() => expect(trigger).toHaveFocus());
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(canvas.save).not.toHaveBeenCalled();
await user.keyboard("{Enter}");
await waitFor(() =>
expect(
screen.getByRole("textbox", { name: "Canvas Markdown" }),
).toBeEnabled(),
);
expect(screen.getByRole("textbox", { name: "Canvas Markdown" })).toHaveValue(
"Local draft",
);
await user.click(screen.getByRole("button", { name: "Close Canvas" }));
await waitFor(() => expect(trigger).toHaveFocus());
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(canvas.save).not.toHaveBeenCalled();
});
5 changes: 4 additions & 1 deletion src/bundled/channels/ChannelCanvasDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { ChannelCanvas } from "../../features/channel-templates/capability"
import type { RelayEvent } from "../../features/relay/events";
import { readView, writeView } from "../../shared/view-state";
import { Button } from "../../shared/design-system/ui/Button";
import { Dialog } from "../../shared/design-system/ui/Dialog";
import { Dialog, type DialogProps } from "../../shared/design-system/ui/Dialog";
import { Textarea } from "../../shared/design-system/ui/Textarea";
import styles from "./ChannelTemplates.module.css";

Expand All @@ -14,12 +14,14 @@ export function ChannelCanvasDialog({
channelId,
open,
onOpenChange,
finalFocus,
}: {
canvas: ChannelCanvas;
scope: string;
channelId: string;
open: boolean;
onOpenChange(open: boolean): void;
finalFocus?: DialogProps["finalFocus"];
}) {
const key = `canvas-draft-v1:${channelId}`;
const [saved] = useState(() => {
Expand Down Expand Up @@ -99,6 +101,7 @@ export function ChannelCanvasDialog({
<Dialog
open={open}
onOpenChange={onOpenChange}
finalFocus={finalFocus}
preventClose={busy}
title="Channel Canvas"
closeLabel="Close Canvas"
Expand Down
18 changes: 16 additions & 2 deletions src/bundled/channels/ChannelDetailsEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,20 +299,34 @@ it("keeps details readable without editing authority or host support", async ()
).toBeVisible();
});

it("Escape dismisses the select, then the edit dialog, then Settings", async () => {
it("orders metadata, Canvas, and actions and dismisses each edit layer with Escape", async () => {
const h = harness();
const user = userEvent.setup();
const close = vi.fn();
render(
<ChannelSettingsPanel
channel={channel}
details={h.capability}
openCanvas={() => {}}
setupTools={<button type="button">Leave channel</button>}
close={close}
>
Diagnostics
</ChannelSettingsPanel>,
);
await user.click(await screen.findByRole("button", { name: "Edit details" }));
const edit = await screen.findByRole("button", { name: "Edit details" });
const canvas = screen.getByRole("button", { name: "Canvas" });
expect(
screen.getByText(channel.description).compareDocumentPosition(canvas) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
await user.tab();
expect(canvas).toHaveFocus();
await user.tab();
expect(edit).toHaveFocus();
await user.tab();
expect(screen.getByRole("button", { name: "Leave channel" })).toHaveFocus();
await user.click(edit);
expect(
screen.getByRole("dialog", { name: "Edit channel details" }),
).toBeVisible();
Expand Down
211 changes: 210 additions & 1 deletion src/bundled/channels/ChannelSettingsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { keypair, signed } from "../../features/relay/testing";
import type { ChannelCanvas } from "../../features/channel-templates/capability";
import { ChannelSettingsPanel } from "./ChannelSettingsPanel";

afterEach(cleanup);
Expand Down Expand Up @@ -77,3 +79,210 @@ it("does not invent unknown metadata and still exposes diagnostics without a cha
await user.click(screen.getByText("Diagnostics"));
expect(screen.getByText("Diagnostics content")).toBeVisible();
});

it("opens Canvas from its own keyboard-accessible row before setup actions", async () => {
const user = userEvent.setup();
const openCanvas = vi.fn();
render(
<ChannelSettingsPanel
channel={{ id: "alpha", name: "Alpha", channelType: "stream" }}
close={() => {}}
openCanvas={openCanvas}
setupTools={<button type="button">Leave channel</button>}
>
Diagnostics content
</ChannelSettingsPanel>,
);
const canvas = screen.getByRole("button", { name: "Canvas" });
expect(canvas).toHaveAttribute("aria-haspopup", "dialog");
expect(canvas).not.toHaveTextContent("Shared notes and plans");
expect(openCanvas).not.toHaveBeenCalled();
await user.tab();
expect(canvas).toHaveFocus();
await user.keyboard("{Enter}");
expect(openCanvas).toHaveBeenCalledTimes(1);
expect(openCanvas).toHaveBeenLastCalledWith(canvas);
await user.tab();
expect(screen.getByRole("button", { name: "Leave channel" })).toHaveFocus();
await user.click(canvas);
expect(openCanvas).toHaveBeenCalledTimes(2);
});

it("omits Canvas without a writable channel or an opener", () => {
const channel = { id: "alpha", name: "Alpha" };
const { rerender } = render(
<ChannelSettingsPanel channel={channel} close={() => {}}>
Diagnostics content
</ChannelSettingsPanel>,
);
expect(
screen.queryByRole("button", { name: "Canvas" }),
).not.toBeInTheDocument();
for (const unavailable of [
undefined,
{ ...channel, readOnly: true as const },
]) {
rerender(
<ChannelSettingsPanel
channel={unavailable}
close={() => {}}
openCanvas={() => {}}
>
Diagnostics content
</ChannelSettingsPanel>,
);
expect(
screen.queryByRole("button", { name: "Canvas" }),
).not.toBeInTheDocument();
}
});

const head = signed(keypair(), {
kind: 40100,
content: "# Google root-link preview submission deep dive",
tags: [["h", "alpha"]],
});
function previewFixture() {
return {
available: true,
read: vi.fn<ChannelCanvas["read"]>().mockResolvedValue(head),
save: vi.fn<ChannelCanvas["save"]>(),
};
}
function panel(
canvas: ChannelCanvas,
id = "alpha",
canvasOpen = false,
readOnly?: true,
) {
return (
<ChannelSettingsPanel
canvas={canvas}
channel={{ id, name: id, ...(readOnly ? { readOnly } : {}) }}
canvasOpen={canvasOpen}
openCanvas={() => {}}
close={() => {}}
>
Diagnostics
</ChannelSettingsPanel>
);
}

it("shows Canvas as the first line and saved plain text as the secondary preview", async () => {
const canvas = previewFixture();
const { container } = render(panel(canvas));
expect(screen.getByText("Loading preview…")).toBeVisible();
await screen.findByText("Google root-link preview submission deep dive");
const row = screen.getByRole("button", { name: "Canvas" });
expect(row).toHaveAccessibleDescription(
"Google root-link preview submission deep dive",
);
expect(row.querySelector(".buzz-choice-row-label")).toHaveTextContent(
/^Canvas$/,
);
expect(row.querySelector(".buzz-choice-row-description")).toHaveTextContent(
"Google root-link preview submission deep dive",
);
expect(canvas.read).toHaveBeenCalledWith("alpha");
expect(canvas.save).not.toHaveBeenCalled();
expect(container.querySelector("img, script")).toBeNull();
});

it.each([
undefined,
{ ...head, content: " \n" },
{ ...head, content: "<!-- comment -->" },
])("uses neutral empty copy for %j", async (event) => {
const canvas = previewFixture();
canvas.read.mockResolvedValue(event);
render(panel(canvas));
expect(await screen.findByText("No content yet")).toBeVisible();
expect(screen.getByRole("button", { name: "Canvas" })).toBeEnabled();
});

it("keeps a failed preview openable and refreshes saved content after closing the editor", async () => {
const canvas = previewFixture();
canvas.read.mockRejectedValueOnce(new Error("Offline"));
const { rerender } = render(panel(canvas));
expect(
await screen.findByText("Preview unavailable. Open to retry."),
).toBeVisible();
expect(screen.getByRole("button", { name: "Canvas" })).toBeEnabled();
rerender(panel(canvas, "alpha", true));
expect(canvas.read).toHaveBeenCalledTimes(1);
rerender(panel(canvas));
expect(
await screen.findByText("Google root-link preview submission deep dive"),
).toBeVisible();
rerender(panel(canvas, "alpha", true));
canvas.read.mockResolvedValue({ ...head, content: "# Updated document" });
rerender(panel(canvas));
expect(await screen.findByText("Updated document")).toBeVisible();
expect(canvas.save).not.toHaveBeenCalled();
});

it("ignores an old channel's pending preview and hides content immediately on scope or access changes", async () => {
const canvas = previewFixture();
let release!: (event: typeof head) => void;
canvas.read.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve;
}),
);
const { rerender } = render(panel(canvas));
try {
canvas.read.mockResolvedValue({ ...head, content: "# Beta document" });
rerender(panel(canvas, "beta"));
expect(await screen.findByText("Beta document")).toBeVisible();
} finally {
await act(async () => release(head));
}
expect(
screen.queryByText("Google root-link preview submission deep dive"),
).not.toBeInTheDocument();
const otherCommunity = previewFixture();
otherCommunity.read.mockResolvedValue(undefined);
rerender(panel(otherCommunity, "beta"));
expect(screen.queryByText("Beta document")).not.toBeInTheDocument();
expect(await screen.findByText("No content yet")).toBeVisible();
rerender(panel(otherCommunity, "beta", false, true));
expect(
screen.queryByRole("button", { name: /^Canvas/ }),
).not.toBeInTheDocument();
await waitFor(() => expect(otherCommunity.read).toHaveBeenCalledTimes(1));
});

it("replaces a saved preview with loading and failure states during refresh", async () => {
const canvas = previewFixture();
const { rerender } = render(panel(canvas));
await screen.findByText("Google root-link preview submission deep dive");
rerender(panel(canvas, "alpha", true));
let reject!: (error: Error) => void;
canvas.read.mockReturnValueOnce(
new Promise((_, rejectRead) => {
reject = rejectRead;
}),
);
try {
rerender(panel(canvas));
expect(canvas.read).toHaveBeenCalledTimes(2);
expect(screen.getByText("Loading preview…")).toBeVisible();
expect(
screen.queryByText("Google root-link preview submission deep dive"),
).not.toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Canvas" }),
).toHaveAccessibleDescription("Loading preview…");
} finally {
await act(async () => reject(new Error("Offline")));
}
expect(screen.getByText("Preview unavailable. Open to retry.")).toBeVisible();
expect(
screen.getByRole("button", { name: "Canvas" }),
).toHaveAccessibleDescription("Preview unavailable. Open to retry.");
expect(screen.getByRole("button", { name: "Canvas" })).toBeEnabled();
expect(
screen.queryByText("Google root-link preview submission deep dive"),
).not.toBeInTheDocument();
expect(canvas.save).not.toHaveBeenCalled();
});
Loading
Loading