diff --git a/docs/pr-screenshots/pr-15773/README.md b/docs/pr-screenshots/pr-15773/README.md new file mode 100644 index 00000000000..ae0ff5c3645 --- /dev/null +++ b/docs/pr-screenshots/pr-15773/README.md @@ -0,0 +1,15 @@ +# Overview copy control + +These Chromium screenshots render the production `RoutineSectionHeading` and `CopyText` components with the app color tokens. The header and description frame is a preview around that heading. It is not a capture of a signed-in routine page. + +Each theme shows the same heading in two states: + +- View: the saved description is visible, and the header shows Edit routine. +- Edit: the description source is visible, and the header shows Cancel editing. + +The copy icon stays next to the Overview title in both states. Desktop and mobile widths were checked. These files are the desktop captures. + +- [View, light](overview-view-light.png) +- [Edit, light](overview-edit-light.png) +- [View, dark](overview-view-dark.png) +- [Edit, dark](overview-edit-dark.png) diff --git a/docs/pr-screenshots/pr-15773/overview-edit-dark.png b/docs/pr-screenshots/pr-15773/overview-edit-dark.png new file mode 100644 index 00000000000..1f9a65da489 Binary files /dev/null and b/docs/pr-screenshots/pr-15773/overview-edit-dark.png differ diff --git a/docs/pr-screenshots/pr-15773/overview-edit-light.png b/docs/pr-screenshots/pr-15773/overview-edit-light.png new file mode 100644 index 00000000000..0ed726e7e8a Binary files /dev/null and b/docs/pr-screenshots/pr-15773/overview-edit-light.png differ diff --git a/docs/pr-screenshots/pr-15773/overview-view-dark.png b/docs/pr-screenshots/pr-15773/overview-view-dark.png new file mode 100644 index 00000000000..a333928bb0f Binary files /dev/null and b/docs/pr-screenshots/pr-15773/overview-view-dark.png differ diff --git a/docs/pr-screenshots/pr-15773/overview-view-light.png b/docs/pr-screenshots/pr-15773/overview-view-light.png new file mode 100644 index 00000000000..a1e9c8c457f Binary files /dev/null and b/docs/pr-screenshots/pr-15773/overview-view-light.png differ diff --git a/ui/src/components/MarkdownEditor.test.tsx b/ui/src/components/MarkdownEditor.test.tsx index ac179b36c08..1d442922983 100644 --- a/ui/src/components/MarkdownEditor.test.tsx +++ b/ui/src/components/MarkdownEditor.test.tsx @@ -931,6 +931,17 @@ describe("MarkdownEditor", () => { expect(mdxEditorMockState.insertedMarkdownValues).toEqual([pasted]); expect(innerPaste).not.toHaveBeenCalled(); + const dashed = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(dashed, "clipboardData", { + value: { types: ["text/plain"], getData: () => "- a single dashed line" }, + }); + await act(async () => { editable.dispatchEvent(dashed); }); + expect(dashed.defaultPrevented).toBe(true); + expect(mdxEditorMockState.insertedMarkdownValues).toEqual([ + pasted, + "- a single dashed line", + ]); + const plain = new Event("paste", { bubbles: true, cancelable: true }); Object.defineProperty(plain, "clipboardData", { value: { types: ["text/plain"], getData: () => "ordinary text" }, @@ -941,6 +952,54 @@ describe("MarkdownEditor", () => { await act(async () => { root.unmount(); }); }); + it("leaves a markdown paste inside a nested code block", async () => { + const root = createRoot(container); + await act(async () => { + root.render( {}} />); + }); + await flush(); + + const editable = container.querySelector('[data-testid="mdx-editor"]') as HTMLDivElement; + const code = document.createElement("div"); + code.className = "cm-content"; + code.setAttribute("contenteditable", "true"); + const text = document.createTextNode(""); + code.appendChild(text); + editable.appendChild(code); + + const range = document.createRange(); + range.setStart(text, 0); + range.collapse(true); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + code.focus(); + + const pasted = "- keep this line in the code block\n- and this one"; + const event = new Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + configurable: true, + value: { + types: ["text/plain"], + getData: (type: string) => (type === "text/plain" ? pasted : ""), + }, + }); + const innerPaste = vi.fn(); + code.addEventListener("paste", innerPaste); + + await act(async () => { + code.dispatchEvent(event); + }); + + expect(event.defaultPrevented).toBe(false); + expect(mdxEditorMockState.insertedMarkdownValues).toEqual([]); + expect(innerPaste).toHaveBeenCalledOnce(); + + await act(async () => { + root.unmount(); + }); + }); + it("escapes angle brackets in pasted markdown", async () => { const root = createRoot(container); diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index f856063c2af..80d13c6b5a3 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -510,12 +510,22 @@ function getMentionMenuSize(optionCount: number): MentionMenuSize { }; } +// CodeMirror's editable surface is `.cm-content`, not a `pre` or `code` node. +// MDXEditor wraps that surface in a hashed `codeMirrorWrapper` class. A paste +// that starts inside either of those belongs to the nested editor. +const CODE_LIKE_SELECTOR = [ + "pre", + "code", + ".cm-editor", + ".cm-content", + "[class*='codeMirrorWrapper']", + "[class*='codeBlockEditor']", +].join(","); + function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean { if (!node || !container.contains(node)) return false; - const el = node.nodeType === Node.ELEMENT_NODE - ? (node as HTMLElement) - : node.parentElement; - return Boolean(el?.closest("pre, code")); + const el = node instanceof Element ? node : node.parentElement; + return Boolean(el?.closest(CODE_LIKE_SELECTOR)); } function isSelectionInsideCodeLikeElement(container: HTMLElement | null) { @@ -528,6 +538,17 @@ function isSelectionInsideCodeLikeElement(container: HTMLElement | null) { return false; } +function isPasteInsideCodeLikeElement( + container: HTMLElement | null, + event: { target: EventTarget | null }, +): boolean { + if (!container) return false; + if (event.target instanceof Node && nodeInsideCodeLike(container, event.target)) return true; + const active = typeof document !== "undefined" ? document.activeElement : null; + if (active instanceof Node && nodeInsideCodeLike(container, active)) return true; + return isSelectionInsideCodeLikeElement(container); +} + /** The human title of an issue mention — `name` minus its leading identifier. */ export function issueMentionTitle(option: MentionOption): string { const name = option.name.trim(); @@ -1280,7 +1301,7 @@ export const MarkdownEditor = forwardRef if (!clipboard || !ref.current) return; const types = new Set(Array.from(clipboard.types)); if (types.has("Files") || types.has("text/html")) return; - if (isSelectionInsideCodeLikeElement(containerRef.current)) return; + if (isPasteInsideCodeLikeElement(containerRef.current, event)) return; const rawText = clipboard.getData("text/plain"); if (!looksLikeMarkdownPaste(rawText)) return; diff --git a/ui/src/components/RoutineSectionHeading.test.tsx b/ui/src/components/RoutineSectionHeading.test.tsx new file mode 100644 index 00000000000..210edbdae1b --- /dev/null +++ b/ui/src/components/RoutineSectionHeading.test.tsx @@ -0,0 +1,73 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { RoutineSectionHeading } from "./RoutineSectionHeading"; + +const copyTextToClipboard = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("@/lib/clipboard", () => ({ + copyTextToClipboard, +})); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +async function act(callback: () => void | Promise) { + let result: void | Promise = undefined; + flushSync(() => { + result = callback(); + }); + await result; +} + +describe("RoutineSectionHeading", () => { + let container: HTMLDivElement; + + afterEach(() => { + container?.remove(); + copyTextToClipboard.mockClear(); + }); + + it("copies the description markdown from the button next to the heading", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const description = "```text\n- keep this line\n```"; + + await act(async () => { + root.render(); + }); + + const button = container.querySelector('button[aria-label="Copy description"]'); + expect(button).not.toBeNull(); + expect(container.querySelector("#routine-section-title")?.textContent).toBe("Overview"); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(copyTextToClipboard).toHaveBeenCalledWith(description); + + await act(async () => { + root.unmount(); + }); + }); + + it("omits the copy button on sections that are not the overview", async () => { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(); + }); + + expect(container.querySelector('button[aria-label="Copy description"]')).toBeNull(); + expect(container.querySelector("#routine-section-title")?.textContent).toBe("Triggers"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/components/RoutineSectionHeading.tsx b/ui/src/components/RoutineSectionHeading.tsx new file mode 100644 index 00000000000..053012feba8 --- /dev/null +++ b/ui/src/components/RoutineSectionHeading.tsx @@ -0,0 +1,30 @@ +import { Copy } from "lucide-react"; +import { CopyText } from "./CopyText"; + +export function RoutineSectionHeading({ + title, + copyText = null, +}: { + title: string; + /** Description markdown to copy. Omit the button when this is null. */ + copyText?: string | null; +}) { + return ( +
+

+ {title} +

+ {copyText != null ? ( + + + ) : null} +
+ ); +} diff --git a/ui/src/lib/routine-description-copy.test.ts b/ui/src/lib/routine-description-copy.test.ts new file mode 100644 index 00000000000..33f56dd58e3 --- /dev/null +++ b/ui/src/lib/routine-description-copy.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { routineOverviewCopyText } from "./routine-description-copy"; + +describe("routineOverviewCopyText", () => { + it("copies the draft while the description is being edited", () => { + expect(routineOverviewCopyText({ + editing: true, + draft: "- draft line", + saved: "- saved line", + })).toBe("- draft line"); + }); + + it("copies the saved description when the overview is not being edited", () => { + expect(routineOverviewCopyText({ + editing: false, + draft: "- draft line", + saved: "- saved line", + })).toBe("- saved line"); + }); + + it("copies an empty string when there is no saved description", () => { + expect(routineOverviewCopyText({ + editing: false, + draft: "", + saved: null, + })).toBe(""); + }); +}); diff --git a/ui/src/lib/routine-description-copy.ts b/ui/src/lib/routine-description-copy.ts new file mode 100644 index 00000000000..3ab51150d27 --- /dev/null +++ b/ui/src/lib/routine-description-copy.ts @@ -0,0 +1,12 @@ +/** + * Markdown copied from a routine Overview heading. + * While the description editor is open, copy the draft. Otherwise copy the + * saved description so it can be pasted into a routine on another instance. + */ +export function routineOverviewCopyText(input: { + editing: boolean; + draft: string; + saved: string | null | undefined; +}): string { + return input.editing ? input.draft : input.saved ?? ""; +} diff --git a/ui/src/pages/RoutineDetail.production.tsx b/ui/src/pages/RoutineDetail.production.tsx index 992f226d240..a3ef2185955 100644 --- a/ui/src/pages/RoutineDetail.production.tsx +++ b/ui/src/pages/RoutineDetail.production.tsx @@ -20,6 +20,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { useToastActions } from "../context/ToastContext"; import { queryKeys } from "../lib/queryKeys"; import { copyTextToClipboard } from "../lib/clipboard"; +import { routineOverviewCopyText } from "../lib/routine-description-copy"; import { buildMarkdownMentionOptions } from "../lib/company-members"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; @@ -39,6 +40,7 @@ import { RoutineSectionPicker, } from "../components/RoutineSubSidebar"; import { RoutineSaveBar } from "../components/RoutineSaveBar"; +import { RoutineSectionHeading } from "../components/RoutineSectionHeading"; import { EDITABLE_SECTIONS, ROUTINE_SECTION_KEYS, @@ -879,9 +881,16 @@ export function RoutineDetail() { aria-labelledby="routine-section-title" className={isEditableSection ? "mx-auto w-full max-w-3xl" : "w-full"} > -

- {SECTION_TITLES[section]} -

+ {section === "overview" && } {section === "triggers" && } diff --git a/ui/src/pages/RoutineDetail.tsx b/ui/src/pages/RoutineDetail.tsx index 4bc3573325f..e12a01787e3 100644 --- a/ui/src/pages/RoutineDetail.tsx +++ b/ui/src/pages/RoutineDetail.tsx @@ -21,6 +21,7 @@ import { useToastActions } from "../context/ToastContext"; import { useStreamlinedUiEnabled } from "../hooks/useStreamlinedUiEnabled"; import { queryKeys } from "../lib/queryKeys"; import { copyTextToClipboard } from "../lib/clipboard"; +import { routineOverviewCopyText } from "../lib/routine-description-copy"; import { buildMarkdownMentionOptions } from "../lib/company-members"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { EmptyState } from "../components/EmptyState"; @@ -37,6 +38,7 @@ import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects" import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { RoutineOverview } from "../components/RoutineOverview"; +import { RoutineSectionHeading } from "../components/RoutineSectionHeading"; import { RoutineSectionPicker, RoutineSubSidebar } from "../components/RoutineSubSidebar"; import { isRoutineDetailView, @@ -899,9 +901,16 @@ export function RoutineDetail() { : "w-full" } > -

- {SECTION_TITLES[section]} -

+ {section === "overview" && (overviewEditing ? : )} {section === "triggers" && }