-
Notifications
You must be signed in to change notification settings - Fork 696
4. Add shared playground UI and JSON editor #3110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| import { fireEvent, render, screen } from "@testing-library/react"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import { SegmentedTabs } from "@/components/SegmentedTabs"; | ||
|
|
||
| describe("SegmentedTabs", () => { | ||
| it("reports the selected Kumo tab", () => { | ||
| const onChange = vi.fn(); | ||
| render( | ||
| <SegmentedTabs | ||
| id="modes" | ||
| label="Modes" | ||
| items={[ | ||
| { value: "form", label: "Form" }, | ||
| { value: "json", label: "JSON" }, | ||
| ]} | ||
| value="form" | ||
| onChange={onChange} | ||
| />, | ||
| ); | ||
|
|
||
| const form = screen.getByRole("tab", { name: "Form" }); | ||
| const json = screen.getByRole("tab", { name: "JSON" }); | ||
| expect(screen.getByRole("tablist", { name: "Modes" })).toBeVisible(); | ||
| expect(form).toHaveAttribute("aria-selected", "true"); | ||
| expect(form).toHaveAttribute("id", "modes-tab-form"); | ||
| expect(form).toHaveAttribute("aria-controls", "modes-panel-form"); | ||
| expect(json).toHaveAttribute("aria-selected", "false"); | ||
|
|
||
| fireEvent.click(json); | ||
| expect(onChange).toHaveBeenCalledWith("json"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import { Tabs } from "@cloudflare/kumo/components/tabs"; | ||
| import { useEffect, useRef } from "react"; | ||
|
|
||
| type TabItem<Value extends string> = { | ||
| value: Value; | ||
| label: string; | ||
| }; | ||
|
|
||
| type Props<Value extends string> = { | ||
| id: string; | ||
| label: string; | ||
| items: readonly TabItem<Value>[]; | ||
| value: Value; | ||
| onChange: (value: Value) => void; | ||
| }; | ||
|
|
||
| /** Generates the ID referenced by a matching panel's `aria-labelledby`. */ | ||
| export function tabId(id: string, value: string): string { | ||
| return `${id}-tab-${value}`; | ||
| } | ||
|
|
||
| /** Generates the panel ID referenced by a matching tab's `aria-controls`. */ | ||
| export function tabPanelId(id: string, value: string): string { | ||
| return `${id}-panel-${value}`; | ||
| } | ||
|
|
||
| /** Adds stable ARIA tab/panel relationships around Kumo's controlled tabs. */ | ||
| export function SegmentedTabs<Value extends string>({ | ||
| id, | ||
| label, | ||
| items, | ||
| value, | ||
| onChange, | ||
| }: Props<Value>) { | ||
| const root = useRef<HTMLDivElement>(null); | ||
| useEffect(() => { | ||
| root.current?.querySelector('[role="tablist"]')?.setAttribute("aria-label", label); | ||
| }, [label]); | ||
|
|
||
| return ( | ||
| <div ref={root} className="playground-segmented"> | ||
| <Tabs | ||
| tabs={items.map((item) => ({ | ||
| ...item, | ||
| render: ( | ||
| <button | ||
| id={tabId(id, item.value)} | ||
| type="button" | ||
| aria-label={item.label} | ||
| aria-controls={tabPanelId(id, item.value)} | ||
| /> | ||
| ), | ||
| }))} | ||
| value={value} | ||
| activateOnFocus | ||
| onValueChange={(next) => { | ||
| const item = items.find((candidate) => candidate.value === next); | ||
| if (item) onChange(item.value); | ||
| }} | ||
| variant="segmented" | ||
| size="sm" | ||
| /> | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { render, screen } from "@testing-library/react"; | ||
| import { describe, expect, it } from "vitest"; | ||
|
|
||
| import { StatusBadge } from "@/components/StatusBadge"; | ||
|
|
||
| describe("StatusBadge", () => { | ||
| it.each([ | ||
| ["READY", "ready"], | ||
| ["SETUP_FAILED", "setup_failed"], | ||
| ["canceled", "canceled"], | ||
| [undefined, "unknown"], | ||
| ])("normalizes %s", (status, label) => { | ||
| render(<StatusBadge status={status} />); | ||
| expect(screen.getByText(label)).toBeVisible(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { Badge, type BadgeVariant } from "@cloudflare/kumo/components/badge"; | ||
|
|
||
| /** Maps known Cog statuses to semantic colors and renders unknown values neutrally. */ | ||
| export function StatusBadge({ status }: { status?: string }) { | ||
| const normalized = status?.toLowerCase() || "unknown"; | ||
| const variants: Record<string, BadgeVariant> = { | ||
| ready: "success", | ||
| succeeded: "success", | ||
| busy: "warning", | ||
| starting: "warning", | ||
| processing: "warning", | ||
| defunct: "error", | ||
| failed: "error", | ||
| error: "error", | ||
| setup_failed: "error", | ||
| unhealthy: "error", | ||
| unreachable: "error", | ||
| canceled: "neutral", | ||
| unknown: "neutral", | ||
| }; | ||
| return ( | ||
| <output> | ||
| <Badge variant={variants[normalized] ?? "neutral"} appearance="dot"> | ||
| {normalized} | ||
| </Badge> | ||
| </output> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,157 @@ | ||
| import { fireEvent, render, screen, waitFor } from "@testing-library/react"; | ||
| import { describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import { JsonEditor } from "@/components/editor/JsonEditor"; | ||
|
|
||
| describe("JsonEditor", () => { | ||
| it("renders highlighted read-only JSON and follows controlled updates", async () => { | ||
| const { container, rerender } = render( | ||
| <JsonEditor value={'{\n "answer": 42\n}'} label="Response JSON" readOnly />, | ||
| ); | ||
|
|
||
| const editor = screen.getByRole("textbox", { name: "Response JSON" }); | ||
| expect(editor).toHaveAttribute("aria-readonly", "true"); | ||
| expect(editor).toHaveAttribute("contenteditable", "false"); | ||
| expect(container.querySelector(".cm-gutters")).toBeInTheDocument(); | ||
| expect(container.querySelector(".cm-foldGutter")).toBeInTheDocument(); | ||
| expect(container.querySelector(".cm-content span")).toBeInTheDocument(); | ||
|
|
||
| rerender(<JsonEditor value={'{"answer":43}'} label="Response JSON" readOnly />); | ||
| await waitFor(() => expect(editor).toHaveTextContent('"answer":43')); | ||
| }); | ||
|
|
||
| it("follows the tail until the user scrolls away", async () => { | ||
| const { container, rerender } = render( | ||
| <JsonEditor value="first" label="Live response" readOnly followTail />, | ||
| ); | ||
| const scroller = container.querySelector<HTMLElement>(".cm-scroller"); | ||
| if (!scroller) throw new Error("CodeMirror scroller was not rendered"); | ||
|
|
||
| let scrollHeight = 1_000; | ||
| Object.defineProperties(scroller, { | ||
| clientHeight: { configurable: true, value: 200 }, | ||
| scrollHeight: { configurable: true, get: () => scrollHeight }, | ||
| scrollTop: { configurable: true, value: 0, writable: true }, | ||
| }); | ||
|
|
||
| rerender(<JsonEditor value="first\nsecond" label="Live response" readOnly followTail />); | ||
| await waitFor(() => expect(scroller.scrollTop).toBe(scrollHeight)); | ||
| await nextAnimationFrame(); | ||
|
|
||
| scroller.scrollTop = 300; | ||
| fireEvent.scroll(scroller); | ||
| scrollHeight = 1_200; | ||
| rerender(<JsonEditor value="first\nsecond\nthird" label="Live response" readOnly followTail />); | ||
| await nextAnimationFrame(); | ||
|
|
||
| expect(scroller.scrollTop).toBe(300); | ||
|
|
||
| scrollHeight = 1_300; | ||
| rerender( | ||
| <JsonEditor | ||
| value="first\nsecond\nthird\nfourth" | ||
| label="Live response" | ||
| readOnly | ||
| followTail | ||
| active={false} | ||
| />, | ||
| ); | ||
| rerender( | ||
| <JsonEditor value="first\nsecond\nthird\nfourth" label="Live response" readOnly followTail />, | ||
| ); | ||
| await nextAnimationFrame(); | ||
|
|
||
| expect(scroller.scrollTop).toBe(300); | ||
|
|
||
| rerender( | ||
| <JsonEditor | ||
| value="first\nsecond\nthird\nfourth" | ||
| label="Live response" | ||
| readOnly | ||
| followTail={false} | ||
| />, | ||
| ); | ||
| scrollHeight = 1_400; | ||
| rerender( | ||
| <JsonEditor | ||
| value="first\nsecond\nthird\nfourth\nfifth" | ||
| label="Live response" | ||
| readOnly | ||
| followTail | ||
| />, | ||
| ); | ||
|
|
||
| await waitFor(() => expect(scroller.scrollTop).toBe(scrollHeight)); | ||
| }); | ||
|
|
||
| it("does not override a user scroll while a tail measurement is queued", async () => { | ||
| const { container, rerender } = render( | ||
| <JsonEditor value="first" label="Queued response" readOnly followTail={false} />, | ||
| ); | ||
| const scroller = container.querySelector<HTMLElement>(".cm-scroller"); | ||
| if (!scroller) throw new Error("CodeMirror scroller was not rendered"); | ||
| Object.defineProperties(scroller, { | ||
| clientHeight: { configurable: true, value: 200 }, | ||
| scrollHeight: { configurable: true, value: 1_000 }, | ||
| scrollTop: { configurable: true, value: 0, writable: true }, | ||
| }); | ||
|
|
||
| rerender(<JsonEditor value="first\nsecond" label="Queued response" readOnly followTail />); | ||
| fireEvent.wheel(scroller, { deltaY: -100 }); | ||
| scroller.scrollTop = 300; | ||
| fireEvent.scroll(scroller); | ||
| await nextAnimationFrame(); | ||
|
|
||
| expect(scroller.scrollTop).toBe(300); | ||
| }); | ||
|
|
||
| it("copies the complete document", async () => { | ||
| const writeText = vi.fn().mockResolvedValue(undefined); | ||
| Object.defineProperty(navigator, "clipboard", { | ||
| configurable: true, | ||
| value: { writeText }, | ||
| }); | ||
| render(<JsonEditor value={'{"answer":42}'} label="Copy JSON" />); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: "Copy Copy JSON" })); | ||
|
|
||
| await waitFor(() => expect(writeText).toHaveBeenCalledWith('{"answer":42}')); | ||
| }); | ||
|
|
||
| it("removes disabled editors and copy controls from keyboard navigation", () => { | ||
| render( | ||
| <JsonEditor value="{}" label="Disabled JSON" disabled invalid describedBy="disabled-help" />, | ||
| ); | ||
|
|
||
| const editor = screen.getByRole("textbox", { name: "Disabled JSON" }); | ||
| expect(editor).toHaveAttribute("tabindex", "-1"); | ||
| expect(editor).toHaveAttribute("aria-disabled", "true"); | ||
| expect(editor).toHaveAttribute("aria-invalid", "true"); | ||
| expect(editor).toHaveAttribute("aria-describedby", "disabled-help"); | ||
| expect(screen.getByRole("button", { name: "Copy Disabled JSON" })).toBeDisabled(); | ||
| }); | ||
|
|
||
| it("leaves Tab available for keyboard navigation", () => { | ||
| render(<JsonEditor value="{}" label="Input JSON" />); | ||
| const editor = screen.getByRole("textbox", { name: "Input JSON" }); | ||
| editor.focus(); | ||
|
|
||
| expect(fireEvent.keyDown(editor, { key: "Tab", code: "Tab" })).toBe(true); | ||
| }); | ||
|
|
||
| it("does not add controlled updates to undo history", async () => { | ||
| const { rerender } = render(<JsonEditor value={'{"answer":42}'} label="Updated JSON" />); | ||
| const editor = screen.getByRole("textbox", { name: "Updated JSON" }); | ||
|
|
||
| rerender(<JsonEditor value={'{"answer":43}'} label="Updated JSON" />); | ||
| await waitFor(() => expect(editor).toHaveTextContent('"answer":43')); | ||
| editor.focus(); | ||
| fireEvent.keyDown(editor, { key: "z", code: "KeyZ", metaKey: true }); | ||
|
|
||
| expect(editor).toHaveTextContent('"answer":43'); | ||
| }); | ||
| }); | ||
|
|
||
| function nextAnimationFrame(): Promise<void> { | ||
| return new Promise((resolve) => window.requestAnimationFrame(() => resolve())); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.