diff --git a/packages/docs-site/src/components/MiniPlaygroundModal.tsx b/packages/docs-site/src/components/MiniPlaygroundModal.tsx new file mode 100644 index 00000000..4812f951 --- /dev/null +++ b/packages/docs-site/src/components/MiniPlaygroundModal.tsx @@ -0,0 +1,21 @@ +import { + DialogPlayground, + type DialogPlaygroundProps, +} from "@stackables/bridge-playground"; +// Import playground styles - the docs-site custom.css overrides the base layer +// to prevent it from breaking Starlight layout +import "@stackables/bridge-playground/style.css"; + +/** + * A button that opens a modal with MiniPlayground. + * Used in documentation to provide interactive examples. + */ +export default function MiniPlaygroundModal(props: DialogPlaygroundProps) { + // not-content prevents Starlight's .sl-markdown-content styles (e.g. button + // height overrides, typography resets) from affecting the playground. + return ( +
+ +
+ ); +} diff --git a/packages/docs-site/src/components/PlaygroundWrapper.tsx b/packages/docs-site/src/components/PlaygroundWrapper.tsx index a3b6a022..a244bdcf 100644 --- a/packages/docs-site/src/components/PlaygroundWrapper.tsx +++ b/packages/docs-site/src/components/PlaygroundWrapper.tsx @@ -1,6 +1,6 @@ -import { Playground } from "@stackables/bridge-playground"; +import { App } from "@stackables/bridge-playground"; import "@stackables/bridge-playground/style.css"; export default function PlaygroundWrapper() { - return ; + return ; } diff --git a/packages/docs-site/src/content/docs/reference/30-wiring-routing.mdx b/packages/docs-site/src/content/docs/reference/30-wiring-routing.mdx index ac1c0dd2..ad8c3309 100644 --- a/packages/docs-site/src/content/docs/reference/30-wiring-routing.mdx +++ b/packages/docs-site/src/content/docs/reference/30-wiring-routing.mdx @@ -4,6 +4,7 @@ description: How to move data from sources to targets, build nested payloads, an --- import { Aside } from "@astrojs/starlight/components"; +import MiniPlaygroundModal from "../../../components/MiniPlaygroundModal.tsx"; Wires are the fundamental building blocks of The Bridge. They define the exact paths data takes as it flows through your graph. @@ -32,6 +33,49 @@ o.country = "Germany" ``` + + ## 2. Path Scoping Blocks (Nested Objects) When you are wiring deeply nested JSON objects (like a complex REST API request body), repeating long path prefixes over and over becomes tedious and difficult to read. diff --git a/packages/docs-site/src/styles/custom.css b/packages/docs-site/src/styles/custom.css index a19b1eee..cfd190ce 100644 --- a/packages/docs-site/src/styles/custom.css +++ b/packages/docs-site/src/styles/custom.css @@ -1,3 +1,28 @@ +@layer theme, base, starlight, catppuccin, components, utilities; +@import "tailwindcss"; +/* Source playground components for Tailwind to detect their classes */ +/* @source "../../../playground/src"; */ +@source "../components"; + +@theme { + /* Monospace font stack for code editors - same as playground */ + --font-mono: + "JetBrains Mono", "Fira Code", "Cascadia Code", Consolas, monospace; + + /* Custom accent colors from playground */ + --color-sky-badge: #164e63; + --color-sky-trace: #1e3a5f; +} + +/* Override Tailwind's base layer to prevent it breaking Starlight's layout */ +@layer base { + html, + body { + /* Reset to auto to let Starlight control layout */ + height: auto; + } +} + /* Widen the default content column (Starlight default: 45rem / 720px) */ /* :root { --sl-content-width: 55rem; diff --git a/packages/docs-site/worker-configuration.d.ts b/packages/docs-site/worker-configuration.d.ts index 134c788d..2113d00f 100644 --- a/packages/docs-site/worker-configuration.d.ts +++ b/packages/docs-site/worker-configuration.d.ts @@ -1,6 +1,6 @@ /* eslint-disable */ // Generated by Wrangler by running `wrangler types` (hash: e41227086db6ad8bb19b68d77b165868) -// Runtime types generated with workerd@1.20260305.0 2026-02-24 global_fetch_strictly_public,nodejs_compat +// Runtime types generated with workerd@1.20260301.1 2026-02-24 global_fetch_strictly_public,nodejs_compat declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./dist/_worker.js/index"); @@ -3328,6 +3328,12 @@ declare abstract class Performance { get timeOrigin(): number; /* [Cloudflare Docs Reference](https://developers.cloudflare.com/workers/runtime-apis/performance/#performancenow) */ now(): number; + /** + * The **`toJSON()`** method of the Performance interface is a Serialization; it returns a JSON representation of the Performance object. + * + * [MDN Reference](https://developer.mozilla.org/docs/Web/API/Performance/toJSON) + */ + toJSON(): object; } // AI Search V2 API Error Interfaces interface AiSearchInternalError extends Error { diff --git a/packages/playground/package.json b/packages/playground/package.json index f454f9ab..44c202b2 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "exports": { - ".": "./src/Playground.tsx", + ".": "./src/index.ts", "./style.css": "./src/index.css" }, "scripts": { diff --git a/packages/playground/src/App.tsx b/packages/playground/src/App.tsx index 2587d2e5..4adcb4a0 100644 --- a/packages/playground/src/App.tsx +++ b/packages/playground/src/App.tsx @@ -1,28 +1,7 @@ -import { useState, useCallback, useEffect, useMemo, useRef } from "react"; -import { - Panel, - Group, - Separator, - useDefaultLayout, -} from "react-resizable-panels"; -import { Editor } from "./components/Editor"; -import { ResultView } from "./components/ResultView"; -import { StandaloneQueryPanel } from "./components/StandaloneQueryPanel"; +import { useEffect } from "react"; +import { Playground } from "./Playground"; import { examples } from "./examples"; -import { - runBridge, - runBridgeStandalone, - getDiagnostics, - extractBridgeOperations, - extractOutputFields, - extractInputSkeleton, - mergeInputSkeleton, - clearHttpCache, - formatBridge, -} from "./engine"; -import type { RunResult } from "./engine"; -import { buildSchema, type GraphQLSchema } from "graphql"; -import { Button } from "@/components/ui/button"; +import { usePlaygroundState } from "./usePlaygroundState"; import { Select, SelectContent, @@ -30,330 +9,22 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { cn } from "@/lib/utils"; import { ShareDialog } from "./components/ShareDialog"; -import { - getShareIdFromUrl, - loadShare, - clearShareIdFromUrl, - type PlaygroundMode, -} from "./share"; +import { getShareIdFromUrl, loadShare, clearShareIdFromUrl } from "./share"; import { ChevronLeftIcon } from "lucide-react"; -// ── resize handle — transparent hit area, no visual indicator ──────────────── -function ResizeHandle({ direction }: { direction: "horizontal" | "vertical" }) { - return ( - - ); -} - -// ── query tab type ──────────────────────────────────────────────────────────── -type QueryTab = { - id: string; - name: string; - /** Whether the name was explicitly set by the user (disables auto-rename). */ - nameManual?: boolean; - /** GraphQL query text (graphql mode). */ - query: string; - /** Standalone mode fields — parallel to the graphql `query` field. */ - operation?: string; - outputFields?: string; - inputJson?: string; -}; - -// ── extract GraphQL operation name from query text ─────────────────────────── -function extractOperationName(query: string): string | null { - // Named operation: query/mutation/subscription OpName - const named = - /^\s*(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/m.exec( - query, - ); - if (named) return named[1]!; - // Anonymous shorthand { fieldName ... } — use first root field - const anon = /^\s*\{\s*([A-Za-z_][A-Za-z0-9_]*)/m.exec(query); - if (anon) return anon[1]!; - return null; -} - -// ── query tab bar ───────────────────────────────────────────────────────────── -type QueryTabBarProps = { - queries: QueryTab[]; - activeTabId: string; - onSelectTab: (id: string) => void; - onAddQuery: () => void; - onRemoveQuery: (id: string) => void; - onRenameQuery: (id: string, name: string) => void; - onRun: () => void; - runDisabled: boolean; - running: boolean; - showRunButton?: boolean; -}; -function QueryTabBar({ - queries, - activeTabId, - onSelectTab, - onAddQuery, - onRemoveQuery, - onRenameQuery, - onRun, - runDisabled, - running, - showRunButton = true, -}: QueryTabBarProps) { - const isQueryTab = activeTabId !== "context"; - const canRemove = queries.length > 1; - const [editingId, setEditingId] = useState(null); - const editRef = useRef(null); - - const commitRename = useCallback( - (id: string) => { - const val = editRef.current?.value.trim(); - if (val) onRenameQuery(id, val); - setEditingId(null); - }, - [onRenameQuery], - ); - - return ( -
- {/* Context tab — always first */} - - - {/* One tab per query */} - {queries.map((q) => ( -
- {editingId === q.id ? ( - commitRename(q.id)} - onKeyDown={(e) => { - if (e.key === "Enter") commitRename(q.id); - if (e.key === "Escape") setEditingId(null); - }} - /> - ) : ( - - )} - {canRemove && ( - - )} -
- ))} - - {/* Add query button */} - - -
- - {/* Run button — visible only when a query tab is active */} - {showRunButton && isQueryTab && ( - - )} -
- ); -} - -// ── bridge DSL panel header (label only) ───────────────────────────────────── -function BridgeDslHeader() { - return ( -
- - Bridge DSL - -
- ); -} - -// ── schema panel header with mode toggle ───────────────────────────────────── -function SchemaHeader({ - mode, - onModeChange, -}: { - mode: PlaygroundMode; - onModeChange: (m: PlaygroundMode) => void; -}) { - return ( -
- - GraphQL Schema - - -
- ); -} - -// ── panel wrapper ───────────────────────────────────────────────────────────── -function PanelBox({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - -// ── panel header label ───────────────────────────────────────────────────────── -function PanelLabel({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - // ── main ────────────────────────────────────────────────────────────────────── export function App() { - const [exampleIndex, setExampleIndex] = useState(0); - const ex = examples[exampleIndex] ?? examples[0]!; - - const [mode, setMode] = useState(ex.mode ?? "standalone"); - const [schema, setSchema] = useState(ex.schema); - const [bridge, setBridge] = useState(ex.bridge); - const [context, setContext] = useState(ex.context); - - // ── persisted panel layouts ── - const hLayout = useDefaultLayout({ id: "bridge-playground-h" }); - const leftVLayout = useDefaultLayout({ id: "bridge-playground-left-v" }); - const rightVLayout = useDefaultLayout({ id: "bridge-playground-right-v" }); - - // ── multi-query state ── - const queryCounterRef = useRef(ex.queries.length); - - function buildQueryTabs(e: (typeof examples)[number]): QueryTab[] { - return e.queries.map((q, i) => { - const sq = e.standaloneQueries?.[i]; - return { - id: crypto.randomUUID(), - name: q.name, - nameManual: true, - query: q.query, - operation: sq?.operation ?? "", - outputFields: sq?.outputFields ?? "", - inputJson: sq ? JSON.stringify(sq.input, null, 2) : "{}", - }; - }); - } - - const [queries, setQueries] = useState(() => buildQueryTabs(ex)); - const [activeTabId, setActiveTabId] = useState( - () => queries[0]?.id ?? "context", - ); - const [results, setResults] = useState>({}); - const [runningIds, setRunningIds] = useState>(new Set()); - - // Track the last active query so the result panel keeps showing when context tab is open - const lastQueryIdRef = useRef(queries[0]?.id); - if (activeTabId !== "context") lastQueryIdRef.current = activeTabId; - const displayQueryId = - activeTabId !== "context" ? activeTabId : lastQueryIdRef.current; - const displayResult = displayQueryId - ? (results[displayQueryId] ?? null) - : null; - const displayRunning = displayQueryId - ? runningIds.has(displayQueryId) - : false; - - const activeQuery = queries.find((q) => q.id === activeTabId); + const state = usePlaygroundState(); + const { + exampleIndex, + selectExample, + mode, + schema, + bridge, + queries, + context, + } = state; // Load shared playground state from ?s= on first mount useEffect(() => { @@ -361,266 +32,16 @@ export function App() { if (!id) return; clearShareIdFromUrl(); loadShare(id) - .then((payload) => { - setMode(payload.mode ?? "standalone"); - setSchema(payload.schema); - setBridge(payload.bridge); - queryCounterRef.current = payload.queries.length; - const newQ: QueryTab[] = payload.queries.map((q, i) => { - const sq = payload.standaloneQueries?.[i]; - return { - id: crypto.randomUUID(), - name: q.name, - nameManual: true, - query: q.query, - operation: sq?.operation ?? "", - outputFields: sq?.outputFields ?? "", - inputJson: sq?.inputJson ?? "{}", - }; - }); - setQueries(newQ); - setContext(payload.context); - setResults({}); - setRunningIds(new Set()); - setActiveTabId(newQ[0]?.id ?? "context"); - }) + .then((payload) => state.loadSharePayload(payload)) .catch(() => { // silently ignore — invalid/expired share id }); }, []); // eslint-disable-line react-hooks/exhaustive-deps - const selectExample = useCallback((index: number) => { - const e = examples[index] ?? examples[0]!; - setExampleIndex(index); - if (e.mode) setMode(e.mode); - setSchema(e.schema); - setBridge(e.bridge); - queryCounterRef.current = e.queries.length; - const newQ = buildQueryTabs(e); - setQueries(newQ); - setContext(e.context); - setResults({}); - setRunningIds(new Set()); - setActiveTabId(newQ[0]?.id ?? "context"); - }, []); - - const updateQuery = useCallback((id: string, text: string) => { - setQueries((prev) => - prev.map((q) => { - if (q.id !== id) return q; - // Only auto-rename from GQL operation name if the user hasn't manually renamed - if (!q.nameManual) { - const opName = extractOperationName(text); - if (opName) return { ...q, query: text, name: opName }; - } - return { ...q, query: text }; - }), - ); - }, []); - - const addQuery = useCallback(() => { - queryCounterRef.current += 1; - const tab: QueryTab = { - id: crypto.randomUUID(), - name: `Query ${queryCounterRef.current}`, - query: "", - operation: "", - outputFields: "", - inputJson: "{}", - }; - setQueries((prev) => [...prev, tab]); - setActiveTabId(tab.id); - }, []); - - const removeQuery = useCallback( - (id: string) => { - setQueries((prev) => { - if (prev.length <= 1) return prev; - const idx = prev.findIndex((q) => q.id === id); - const next = prev.filter((q) => q.id !== id); - if (activeTabId === id) { - const fallback = - next[Math.min(idx, next.length - 1)]?.id ?? "context"; - setActiveTabId(fallback); - } - return next; - }); - setResults((prev) => { - const next = { ...prev }; - delete next[id]; - return next; - }); - }, - [activeTabId], - ); - - const renameQuery = useCallback((id: string, name: string) => { - setQueries((prev) => - prev.map((q) => (q.id === id ? { ...q, name, nameManual: true } : q)), - ); - }, []); - - const updateStandaloneField = useCallback( - ( - id: string, - field: "operation" | "outputFields" | "inputJson", - value: string, - ) => { - setQueries((prev) => - prev.map((q) => { - if (q.id !== id) return q; - const updated = { ...q, [field]: value }; - // When changing operation, auto-fill input skeleton if input is default - if (field === "operation" && (!q.inputJson || q.inputJson === "{}")) { - updated.inputJson = extractInputSkeleton(bridge, value); - } - return updated; - }), - ); - }, - [bridge], - ); - - const handleRun = useCallback(async () => { - if (!activeQuery) return; - const qId = activeQuery.id; - setRunningIds((prev) => new Set(prev).add(qId)); - try { - let r: RunResult; - if (mode === "standalone") { - r = await runBridgeStandalone( - bridge, - activeQuery.operation ?? "", - activeQuery.inputJson ?? "{}", - activeQuery.outputFields ?? "", - context, - ); - } else { - r = await runBridge(schema, bridge, activeQuery.query, {}, context); - } - setResults((prev) => ({ ...prev, [qId]: r })); - } finally { - setRunningIds((prev) => { - const next = new Set(prev); - next.delete(qId); - return next; - }); - } - }, [activeQuery, mode, schema, bridge, context]); - - const handleFormatBridge = useCallback(() => { - const formatted = formatBridge(bridge); - setBridge(formatted); - }, [bridge]); - - const diagnostics = getDiagnostics(bridge).diagnostics; - const hasErrors = diagnostics.some((d) => d.severity === "error"); - const isActiveRunning = - activeTabId !== "context" && runningIds.has(activeTabId); - - // Build the GraphQL schema object for the query editor (autocomplete + linting). - // Returns undefined when the SDL is invalid so the query editor still works. - const graphqlSchema = useMemo(() => { - try { - return buildSchema(schema); - } catch { - return undefined; - } - }, [schema]); - - // Extract bridge operations for standalone mode's bridge selector - const bridgeOperations = useMemo( - () => extractBridgeOperations(bridge), - [bridge], - ); - - // Auto-select first operation when the list changes and current selection is invalid - useEffect(() => { - if (mode !== "standalone" || bridgeOperations.length === 0) return; - setQueries((prev) => - prev.map((q) => { - if ( - q.operation && - bridgeOperations.some((op) => op.label === q.operation) - ) - return q; - return { ...q, operation: bridgeOperations[0]!.label }; - }), - ); - }, [bridgeOperations, mode]); - - // Handle mode change: when switching to "standalone", auto-fill operation - // and input JSON skeleton for tabs that don't already have them. - const handleModeChange = useCallback( - (newMode: PlaygroundMode) => { - setMode(newMode); - if (newMode === "standalone") { - const ops = extractBridgeOperations(bridge); - const firstOp = ops[0]?.label ?? ""; - setQueries((prev) => - prev.map((q) => { - const op = - q.operation && ops.some((o) => o.label === q.operation) - ? q.operation - : firstOp; - const inputJson = - !q.inputJson || q.inputJson === "{}" - ? extractInputSkeleton(bridge, op) - : q.inputJson; - return { ...q, operation: op, inputJson }; - }), - ); - } - }, - [bridge], - ); - - // Extract all possible output field paths for the active standalone operation - const activeOperation = activeQuery?.operation ?? ""; - const availableOutputFields = useMemo( - () => extractOutputFields(bridge, activeOperation), - [bridge, activeOperation], - ); - - // When the bridge DSL changes in standalone mode, merge new input fields - // into each tab's inputJson (adds new fields, preserves user values). - // Also prune outputFields that no longer exist in the bridge. - const prevBridgeRef = useRef(bridge); - useEffect(() => { - if (prevBridgeRef.current === bridge) return; - prevBridgeRef.current = bridge; - if (mode !== "standalone") return; - - setQueries((prev) => - prev.map((q) => { - const op = q.operation ?? ""; - if (!op) return q; - - // Merge new input fields into existing JSON - const skeleton = extractInputSkeleton(bridge, op); - const mergedInput = mergeInputSkeleton(q.inputJson ?? "{}", skeleton); - - // Prune selected output fields that no longer exist - const currentFields = extractOutputFields(bridge, op); - const validPaths = new Set(currentFields.map((f) => f.path)); - const selectedFields = (q.outputFields ?? "") - .split(",") - .map((f) => f.trim()) - .filter((f) => f && validPaths.has(f)); - - return { - ...q, - inputJson: mergedInput, - outputFields: selectedFields.join(","), - }; - }), - ); - }, [bridge, mode]); - const isStandalone = mode === "standalone"; return ( -
+
{/* ── Header ── */}
{/* Row 1: logo + (desktop: example picker + info) + share */} @@ -642,9 +63,9 @@ export function App() { - {examples.map((ex, i) => ( + {examples.map((e, i) => ( - {ex.name} + {e.name} ))} @@ -685,9 +106,9 @@ export function App() { - {examples.map((ex, i) => ( + {examples.map((e, i) => ( - {ex.name} + {e.name} ))} @@ -695,298 +116,7 @@ export function App() {
- {/* ── Mobile layout: vertical scrollable stack ── */} -
- {/* Schema panel — hidden in standalone mode, shows mode toggle */} - {!isStandalone ? ( -
- -
- -
-
- ) : ( - /* When in standalone, show a collapsed "GraphQL Schema" bar with the toggle */ -
- -
- )} - - {/* Bridge DSL panel */} -
- -
- -
-
- - {/* Query / Context panel */} -
-
- -
-
- {activeTabId === "context" ? ( - - ) : activeQuery ? ( - isStandalone ? ( - - updateStandaloneField(activeTabId, "operation", v) - } - availableFields={availableOutputFields} - outputFields={activeQuery.outputFields ?? ""} - onOutputFieldsChange={(v) => - updateStandaloneField(activeTabId, "outputFields", v) - } - inputJson={activeQuery.inputJson ?? "{}"} - onInputJsonChange={(v) => - updateStandaloneField(activeTabId, "inputJson", v) - } - autoHeight - /> - ) : ( - updateQuery(activeTabId, v)} - language="graphql-query" - graphqlSchema={graphqlSchema} - autoHeight - /> - ) - ) : null} -
-
- - {/* Run button — full-width below query panel, mobile only */} - {activeTabId !== "context" && ( - - )} - - {/* Result panel */} -
- Result -
- -
-
-
- - {/* ── Desktop layout: resizable panels ── */} -
- - {/* ── LEFT column: Schema + Bridge (or Bridge only) ── */} - - {isStandalone ? ( - /* Standalone: collapsed schema header + bridge fills left column */ -
-
- -
- - -
- -
-
-
- ) : ( - /* GraphQL mode: schema + bridge in a vertical split */ - - {/* Schema panel */} - - - -
- -
-
-
- - - - {/* Bridge DSL panel */} - - - -
- -
-
-
-
- )} -
- - - - {/* ── RIGHT column: Query/Context + Results ── */} - - - {/* Query / Context tabbed panel */} - - - - - - -
- {activeTabId === "context" ? ( - - ) : activeQuery ? ( - isStandalone ? ( - - updateStandaloneField(activeTabId, "operation", v) - } - availableFields={availableOutputFields} - outputFields={activeQuery.outputFields ?? ""} - onOutputFieldsChange={(v) => - updateStandaloneField( - activeTabId, - "outputFields", - v, - ) - } - inputJson={activeQuery.inputJson ?? "{}"} - onInputJsonChange={(v) => - updateStandaloneField(activeTabId, "inputJson", v) - } - /> - ) : ( - updateQuery(activeTabId, v)} - language="graphql-query" - graphqlSchema={graphqlSchema} - /> - ) - ) : null} -
-
-
- - - - {/* Result panel */} - - - Result -
- -
-
-
-
-
-
-
+
); } diff --git a/packages/playground/src/DialogPlayground.tsx b/packages/playground/src/DialogPlayground.tsx new file mode 100644 index 00000000..8b286d59 --- /dev/null +++ b/packages/playground/src/DialogPlayground.tsx @@ -0,0 +1,126 @@ +import { useState, useEffect, useRef } from "react"; +import { Playground } from "./Playground"; +import { usePlaygroundState } from "./usePlaygroundState"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; + +export type DialogPlaygroundProps = { + /** Label shown on the trigger button. */ + label?: string; + /** Index of the built-in example to load initially (default: 0). */ + initialExample?: number; + /** Disables switching to GraphQL mode. */ + hideGqlSwitch?: boolean; + + /** Optional custom bridge DSL. If provided, overrides `initialExample`. */ + bridge?: string; + /** Optional custom context JSON string. */ + contextStr?: string; + /** Optional operation to select initially (if omitted, auto-selects first). */ + operation?: string; + /** Optional input JSON string. */ + inputJson?: string; + /** Optional output fields (comma-separated). */ + outputFields?: string; + /** Automatically run the query exactly once when the playground opens. */ + autoRun?: boolean; +}; + +// ── inner playground — mounts state only when dialog is opened ──────────────── +function PlaygroundInner({ + initialExample = 0, + hideGqlSwitch = true, // By default, hide it in dialog + bridge, + contextStr, + operation, + inputJson, + outputFields, + autoRun, +}: { + initialExample?: number; + hideGqlSwitch?: boolean; + bridge?: string; + contextStr?: string; + operation?: string; + inputJson?: string; + outputFields?: string; + autoRun?: boolean; +}) { + const state = usePlaygroundState(initialExample, hideGqlSwitch, { + bridge, + contextStr, + operation, + inputJson, + outputFields, + }); + + const { onRun } = state; + const hasRunRef = useRef(false); + const onRunRef = useRef(onRun); + + useEffect(() => { + onRunRef.current = onRun; + }, [onRun]); + + useEffect(() => { + if (autoRun && !hasRunRef.current) { + hasRunRef.current = true; + // Small timeout to ensure editor layout stabilizes before potentially + // showing loading overlays/results. Optional but safe. + setTimeout(() => onRunRef.current(), 50); + } + }, [autoRun]); + + return ( +
+ +
+ ); +} + +// ── main ────────────────────────────────────────────────────────────────────── +export function DialogPlayground({ + label = "Open Playground", + initialExample = 0, + hideGqlSwitch = true, + bridge, + contextStr, + operation, + inputJson, + outputFields, + autoRun, +}: DialogPlaygroundProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + { + // react-resizable-panels handle clicks are sometimes erroneously interpreted + // as outside interactions by the dialog when mounted in certain ways (e.g. Shadow DOM) + const target = e.target; + if (target instanceof Element && target.closest("[data-separator]")) { + e.preventDefault(); + } + }} + className="max-w-[97vw] w-[97vw] h-[95vh] max-h-[95vh] p-0" + > + {open && ( + + )} + + + ); +} diff --git a/packages/playground/src/Playground.tsx b/packages/playground/src/Playground.tsx index 91de13dd..a7acbd0f 100644 --- a/packages/playground/src/Playground.tsx +++ b/packages/playground/src/Playground.tsx @@ -1,8 +1,655 @@ -/** - * Reusable Playground component for embedding in other sites. - * - * Usage: - * import { Playground } from "@stackables/bridge-playground"; - * import "@stackables/bridge-playground/style.css"; - */ -export { App as Playground } from "./App"; +import { useState, useCallback, useRef } from "react"; +import { + Panel, + Group, + Separator, + useDefaultLayout, +} from "react-resizable-panels"; +import { Editor } from "./components/Editor"; +import { ResultView } from "./components/ResultView"; +import { StandaloneQueryPanel } from "./components/StandaloneQueryPanel"; +import { clearHttpCache } from "./engine"; +import type { RunResult, BridgeOperation, OutputFieldNode } from "./engine"; +import type { GraphQLSchema } from "graphql"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { PlaygroundMode } from "./share"; + +// ── resize handle — transparent hit area, no visual indicator ──────────────── +function ResizeHandle({ direction }: { direction: "horizontal" | "vertical" }) { + return ( + + ); +} + +// ── query tab type ──────────────────────────────────────────────────────────── +export type QueryTab = { + id: string; + name: string; + /** Whether the name was explicitly set by the user (disables auto-rename). */ + nameManual?: boolean; + /** GraphQL query text (graphql mode). */ + query: string; + /** Standalone mode fields — parallel to the graphql `query` field. */ + operation?: string; + outputFields?: string; + inputJson?: string; +}; + +// ── query tab bar ───────────────────────────────────────────────────────────── +type QueryTabBarProps = { + queries: QueryTab[]; + activeTabId: string; + onSelectTab: (id: string) => void; + onAddQuery: () => void; + onRemoveQuery: (id: string) => void; + onRenameQuery: (id: string, name: string) => void; + onRun: () => void; + runDisabled: boolean; + running: boolean; + showRunButton?: boolean; +}; + +function QueryTabBar({ + queries, + activeTabId, + onSelectTab, + onAddQuery, + onRemoveQuery, + onRenameQuery, + onRun, + runDisabled, + running, + showRunButton = true, +}: QueryTabBarProps) { + const isQueryTab = activeTabId !== "context"; + const canRemove = queries.length > 1; + const [editingId, setEditingId] = useState(null); + const editRef = useRef(null); + + const commitRename = useCallback( + (id: string) => { + const val = editRef.current?.value.trim(); + if (val) onRenameQuery(id, val); + setEditingId(null); + }, + [onRenameQuery], + ); + + return ( +
+ {/* Context tab — always first */} + + + {/* One tab per query */} + {queries.map((q) => ( +
+ {editingId === q.id ? ( + commitRename(q.id)} + onKeyDown={(e) => { + if (e.key === "Enter") commitRename(q.id); + if (e.key === "Escape") setEditingId(null); + }} + /> + ) : ( + + )} + {canRemove && ( + + )} +
+ ))} + + {/* Add query button */} + + +
+ + {/* Run button — visible only when a query tab is active */} + {showRunButton && isQueryTab && ( + + )} +
+ ); +} + +// ── bridge DSL panel header (label only) ───────────────────────────────────── +function BridgeDslHeader() { + return ( +
+ + Bridge DSL + +
+ ); +} + +// ── schema panel header with mode toggle ───────────────────────────────────── +function SchemaHeader({ + mode, + onModeChange, + hideGqlSwitch, +}: { + mode: PlaygroundMode; + onModeChange: (m: PlaygroundMode) => void; + hideGqlSwitch?: boolean; +}) { + return ( +
+ + GraphQL Schema + + {!hideGqlSwitch && ( + + )} +
+ ); +} + +// ── panel wrapper ───────────────────────────────────────────────────────────── +function PanelBox({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// ── panel header label ───────────────────────────────────────────────────────── +function PanelLabel({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +// ── main ────────────────────────────────────────────────────────────────────── +export type PlaygroundProps = { + mode: PlaygroundMode; + onModeChange: (m: PlaygroundMode) => void; + schema: string; + onSchemaChange: (s: string) => void; + bridge: string; + onBridgeChange: (b: string) => void; + onFormatBridge: () => void; + context: string; + onContextChange: (c: string) => void; + queries: QueryTab[]; + activeTabId: string; + onSelectTab: (id: string) => void; + onAddQuery: () => void; + onRemoveQuery: (id: string) => void; + onRenameQuery: (id: string, name: string) => void; + onUpdateQuery: (id: string, text: string) => void; + onUpdateStandaloneField: ( + id: string, + field: "operation" | "outputFields" | "inputJson", + value: string, + ) => void; + displayResult: RunResult | null; + displayRunning: boolean; + hasErrors: boolean; + isActiveRunning: boolean; + onRun: () => void; + graphqlSchema?: GraphQLSchema; + bridgeOperations: BridgeOperation[]; + availableOutputFields: OutputFieldNode[]; + hideGqlSwitch?: boolean; +}; + +export function Playground({ + mode, + onModeChange, + schema, + onSchemaChange, + bridge, + onBridgeChange, + onFormatBridge, + context, + onContextChange, + queries, + activeTabId, + onSelectTab, + onAddQuery, + onRemoveQuery, + onRenameQuery, + onUpdateQuery, + onUpdateStandaloneField, + displayResult, + displayRunning, + hasErrors, + isActiveRunning, + onRun, + graphqlSchema, + bridgeOperations, + availableOutputFields, + hideGqlSwitch, +}: PlaygroundProps) { + const hLayout = useDefaultLayout({ id: "bridge-playground-h" }); + const leftVLayout = useDefaultLayout({ id: "bridge-playground-left-v" }); + const rightVLayout = useDefaultLayout({ id: "bridge-playground-right-v" }); + + const activeQuery = queries.find((q) => q.id === activeTabId); + const isStandalone = mode === "standalone"; + + return ( + <> + {/* ── Mobile layout: vertical scrollable stack ── */} +
+ {/* Schema panel — hidden in standalone mode, shows mode toggle */} + {!isStandalone ? ( +
+ +
+ +
+
+ ) : !hideGqlSwitch ? ( + /* When in standalone, show a collapsed "GraphQL Schema" bar with the toggle (if not hidden) */ +
+ +
+ ) : null} + + {/* Bridge DSL panel */} +
+ +
+ +
+
+ + {/* Query / Context panel */} +
+
+ +
+
+ {activeTabId === "context" ? ( + + ) : activeQuery ? ( + isStandalone ? ( + + onUpdateStandaloneField(activeTabId, "operation", v) + } + availableFields={availableOutputFields} + outputFields={activeQuery.outputFields ?? ""} + onOutputFieldsChange={(v) => + onUpdateStandaloneField(activeTabId, "outputFields", v) + } + inputJson={activeQuery.inputJson ?? "{}"} + onInputJsonChange={(v) => + onUpdateStandaloneField(activeTabId, "inputJson", v) + } + autoHeight + /> + ) : ( + onUpdateQuery(activeTabId, v)} + language="graphql-query" + graphqlSchema={graphqlSchema} + autoHeight + /> + ) + ) : null} +
+
+ + {/* Run button — full-width below query panel, mobile only */} + {activeTabId !== "context" && ( + + )} + + {/* Result panel */} +
+ Result +
+ +
+
+
+ + {/* ── Desktop layout: resizable panels ── */} +
+ + {/* ── LEFT column: Schema + Bridge (or Bridge only) ── */} + + {isStandalone ? ( + /* Standalone: collapsed schema header + bridge fills left column */ +
+ {!hideGqlSwitch && ( +
+ +
+ )} + + +
+ +
+
+
+ ) : ( + /* GraphQL mode: schema + bridge in a vertical split */ + + {/* Schema panel */} + + + +
+ +
+
+
+ + + + {/* Bridge DSL panel */} + + + +
+ +
+
+
+
+ )} +
+ + + + {/* ── RIGHT column: Query/Context + Results ── */} + + + {/* Query / Context tabbed panel */} + + + + + + +
+ {activeTabId === "context" ? ( + + ) : activeQuery ? ( + isStandalone ? ( + + onUpdateStandaloneField(activeTabId, "operation", v) + } + availableFields={availableOutputFields} + outputFields={activeQuery.outputFields ?? ""} + onOutputFieldsChange={(v) => + onUpdateStandaloneField( + activeTabId, + "outputFields", + v, + ) + } + inputJson={activeQuery.inputJson ?? "{}"} + onInputJsonChange={(v) => + onUpdateStandaloneField(activeTabId, "inputJson", v) + } + /> + ) : ( + onUpdateQuery(activeTabId, v)} + language="graphql-query" + graphqlSchema={graphqlSchema} + /> + ) + ) : null} +
+
+
+ + + + {/* Result panel */} + + + Result +
+ +
+
+
+
+
+
+
+ + ); +} diff --git a/packages/playground/src/components/FieldSelector.tsx b/packages/playground/src/components/FieldSelector.tsx index 7b7b51a0..2265db6b 100644 --- a/packages/playground/src/components/FieldSelector.tsx +++ b/packages/playground/src/components/FieldSelector.tsx @@ -63,7 +63,7 @@ export function FieldSelector({ availableFields, value, onChange }: Props) { value={value} onChange={(e) => onChange(e.target.value)} placeholder="All fields (or: name, price, legs.*)" - className="flex-1 min-w-0 rounded-md border border-slate-700 bg-slate-900 px-2.5 py-1 text-xs text-slate-300 outline-none focus:border-sky-400 placeholder:text-slate-600" + className="flex flex-1 min-w-0 h-8 items-center rounded-md border border-slate-700 bg-slate-800 px-3 py-1.5 text-xs text-slate-200 outline-none placeholder:text-slate-500 focus:outline-none focus:ring-1 focus:ring-sky-400" /> ); } @@ -73,15 +73,15 @@ export function FieldSelector({ availableFields, value, onChange }: Props) { {/* Bridge operation dropdown */} -
- - -
+ {operations.length > 1 && ( +
+ + +
+ )} {/* Output fields dropdown */} -
+
diff --git a/packages/playground/src/components/ui/dialog.tsx b/packages/playground/src/components/ui/dialog.tsx index 83902655..31baf023 100644 --- a/packages/playground/src/components/ui/dialog.tsx +++ b/packages/playground/src/components/ui/dialog.tsx @@ -38,7 +38,7 @@ const DialogContent = React.forwardRef< {...props} > {children} - + Close @@ -47,8 +47,17 @@ const DialogContent = React.forwardRef< )); DialogContent.displayName = DialogPrimitive.Content.displayName; -const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
+const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
); DialogHeader.displayName = "DialogHeader"; diff --git a/packages/playground/src/index.ts b/packages/playground/src/index.ts new file mode 100644 index 00000000..a5d912af --- /dev/null +++ b/packages/playground/src/index.ts @@ -0,0 +1,6 @@ +export { Playground, type QueryTab, type PlaygroundProps } from "./Playground"; +export { App } from "./App"; +export { + DialogPlayground, + type DialogPlaygroundProps, +} from "./DialogPlayground"; diff --git a/packages/playground/src/usePlaygroundState.ts b/packages/playground/src/usePlaygroundState.ts new file mode 100644 index 00000000..78437631 --- /dev/null +++ b/packages/playground/src/usePlaygroundState.ts @@ -0,0 +1,400 @@ +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { examples } from "./examples"; +import type { QueryTab } from "./Playground"; +import { + runBridge, + runBridgeStandalone, + getDiagnostics, + extractBridgeOperations, + extractOutputFields, + extractInputSkeleton, + mergeInputSkeleton, + formatBridge, +} from "./engine"; +import type { RunResult } from "./engine"; +import { buildSchema, type GraphQLSchema } from "graphql"; +import type { PlaygroundMode, SharePayload } from "./share"; + +// ── build query tab array from an example ──────────────────────────────────── +export function buildQueryTabs(e: (typeof examples)[number]): QueryTab[] { + return e.queries.map((q, i) => { + const sq = e.standaloneQueries?.[i]; + return { + id: crypto.randomUUID(), + name: q.name, + nameManual: true, + query: q.query, + operation: sq?.operation ?? "", + outputFields: sq?.outputFields ?? "", + inputJson: sq ? JSON.stringify(sq.input, null, 2) : "{}", + }; + }); +} + +// ── extract GraphQL operation name from query text ──────────────────────────── +function extractOperationName(query: string): string | null { + const named = + /^\s*(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/m.exec( + query, + ); + if (named) return named[1]!; + const anon = /^\s*\{\s*([A-Za-z_][A-Za-z0-9_]*)/m.exec(query); + if (anon) return anon[1]!; + return null; +} + +function tryFormatJson(val?: string): string { + if (!val) return "{}"; + try { + return JSON.stringify(JSON.parse(val), null, 2); + } catch { + return val; + } +} + +export function usePlaygroundState( + initialExampleIndex = 0, + forceStandalone = false, + overrides?: { + bridge?: string; + contextStr?: string; + operation?: string; + inputJson?: string; + outputFields?: string; + }, +) { + const [exampleIndex, setExampleIndex] = useState(initialExampleIndex); + const ex = examples[exampleIndex] ?? examples[0]!; + + const [mode, setMode] = useState( + forceStandalone ? "standalone" : (ex.mode ?? "standalone"), + ); + + // Format the default bridge if provided via overrides so it's not messy. + const initialBridge = overrides?.bridge + ? formatBridge(overrides.bridge) + : ex.bridge; + const [schema, setSchema] = useState(ex.schema); + const [bridge, setBridge] = useState(initialBridge); + const [context, setContext] = useState(overrides?.contextStr ?? ex.context); + + const queryCounterRef = useRef(ex.queries.length); + const [queries, setQueries] = useState(() => { + if (overrides?.bridge) { + const ops = extractBridgeOperations(initialBridge); + const firstOp = ops[0]?.label ?? ""; + const op = overrides.operation || firstOp; + + let initialJson = overrides.inputJson; + if (!initialJson || initialJson === "{}") { + initialJson = extractInputSkeleton(initialBridge, op); + } + + return [ + { + id: crypto.randomUUID(), + name: "Query 1", + query: "", + operation: op, + outputFields: overrides.outputFields ?? "", + inputJson: tryFormatJson(initialJson), + }, + ]; + } + return buildQueryTabs(ex); + }); + const [activeTabId, setActiveTabId] = useState( + () => queries[0]?.id ?? "context", + ); + const [results, setResults] = useState>({}); + const [runningIds, setRunningIds] = useState>(new Set()); + + // Track the last active query so the result panel keeps showing when context tab is open + const lastQueryIdRef = useRef(queries[0]?.id); + if (activeTabId !== "context") lastQueryIdRef.current = activeTabId; + const displayQueryId = + activeTabId !== "context" ? activeTabId : lastQueryIdRef.current; + const displayResult = displayQueryId + ? (results[displayQueryId] ?? null) + : null; + const displayRunning = displayQueryId + ? runningIds.has(displayQueryId) + : false; + + const activeQuery = queries.find((q) => q.id === activeTabId); + + const selectExample = useCallback( + (index: number) => { + const e = examples[index] ?? examples[0]!; + setExampleIndex(index); + if (!forceStandalone && e.mode) setMode(e.mode); + else if (forceStandalone) setMode("standalone"); + setSchema(e.schema); + setBridge(e.bridge); + queryCounterRef.current = e.queries.length; + const newQ = buildQueryTabs(e); + setQueries(newQ); + setContext(e.context); + setResults({}); + setRunningIds(new Set()); + setActiveTabId(newQ[0]?.id ?? "context"); + }, + [forceStandalone], + ); + + const updateQuery = useCallback((id: string, text: string) => { + setQueries((prev) => + prev.map((q) => { + if (q.id !== id) return q; + if (!q.nameManual) { + const opName = extractOperationName(text); + if (opName) return { ...q, query: text, name: opName }; + } + return { ...q, query: text }; + }), + ); + }, []); + + const addQuery = useCallback(() => { + queryCounterRef.current += 1; + const tab: QueryTab = { + id: crypto.randomUUID(), + name: `Query ${queryCounterRef.current}`, + query: "", + operation: "", + outputFields: "", + inputJson: "{}", + }; + setQueries((prev) => [...prev, tab]); + setActiveTabId(tab.id); + }, []); + + const removeQuery = useCallback( + (id: string) => { + setQueries((prev) => { + if (prev.length <= 1) return prev; + const idx = prev.findIndex((q) => q.id === id); + const next = prev.filter((q) => q.id !== id); + if (activeTabId === id) { + const fallback = + next[Math.min(idx, next.length - 1)]?.id ?? "context"; + setActiveTabId(fallback); + } + return next; + }); + setResults((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + }, + [activeTabId], + ); + + const renameQuery = useCallback((id: string, name: string) => { + setQueries((prev) => + prev.map((q) => (q.id === id ? { ...q, name, nameManual: true } : q)), + ); + }, []); + + const updateStandaloneField = useCallback( + ( + id: string, + field: "operation" | "outputFields" | "inputJson", + value: string, + ) => { + setQueries((prev) => + prev.map((q) => { + if (q.id !== id) return q; + const updated = { ...q, [field]: value }; + if (field === "operation" && (!q.inputJson || q.inputJson === "{}")) { + updated.inputJson = extractInputSkeleton(bridge, value); + } + return updated; + }), + ); + }, + [bridge], + ); + + const handleRun = useCallback(async () => { + if (!activeQuery) return; + const qId = activeQuery.id; + setRunningIds((prev) => new Set(prev).add(qId)); + try { + let r: RunResult; + if (mode === "standalone") { + r = await runBridgeStandalone( + bridge, + activeQuery.operation ?? "", + activeQuery.inputJson ?? "{}", + activeQuery.outputFields ?? "", + context, + ); + } else { + r = await runBridge(schema, bridge, activeQuery.query, {}, context); + } + setResults((prev) => ({ ...prev, [qId]: r })); + } finally { + setRunningIds((prev) => { + const next = new Set(prev); + next.delete(qId); + return next; + }); + } + }, [activeQuery, mode, schema, bridge, context]); + + const handleFormatBridge = useCallback(() => { + setBridge(formatBridge(bridge)); + }, [bridge]); + + const diagnostics = getDiagnostics(bridge).diagnostics; + const hasErrors = diagnostics.some((d) => d.severity === "error"); + const isActiveRunning = + activeTabId !== "context" && runningIds.has(activeTabId); + + const graphqlSchema = useMemo(() => { + try { + return buildSchema(schema); + } catch { + return undefined; + } + }, [schema]); + + const bridgeOperations = useMemo( + () => extractBridgeOperations(bridge), + [bridge], + ); + + // Auto-select first operation when the list changes and current selection is invalid + useEffect(() => { + if (mode !== "standalone" || bridgeOperations.length === 0) return; + setQueries((prev) => + prev.map((q) => { + if ( + q.operation && + bridgeOperations.some((op) => op.label === q.operation) + ) + return q; + return { ...q, operation: bridgeOperations[0]!.label }; + }), + ); + }, [bridgeOperations, mode]); + + const handleModeChange = useCallback( + (newMode: PlaygroundMode) => { + setMode(newMode); + if (newMode === "standalone") { + const ops = extractBridgeOperations(bridge); + const firstOp = ops[0]?.label ?? ""; + setQueries((prev) => + prev.map((q) => { + const op = + q.operation && ops.some((o) => o.label === q.operation) + ? q.operation + : firstOp; + const inputJson = + !q.inputJson || q.inputJson === "{}" + ? extractInputSkeleton(bridge, op) + : q.inputJson; + return { ...q, operation: op, inputJson }; + }), + ); + } + }, + [bridge], + ); + + const activeOperation = activeQuery?.operation ?? ""; + const availableOutputFields = useMemo( + () => extractOutputFields(bridge, activeOperation), + [bridge, activeOperation], + ); + + // When the bridge DSL changes in standalone mode, merge input fields and prune output fields + const prevBridgeRef = useRef(bridge); + useEffect(() => { + if (prevBridgeRef.current === bridge) return; + prevBridgeRef.current = bridge; + if (mode !== "standalone") return; + + setQueries((prev) => + prev.map((q) => { + const op = q.operation ?? ""; + if (!op) return q; + + const skeleton = extractInputSkeleton(bridge, op); + const mergedInput = mergeInputSkeleton(q.inputJson ?? "{}", skeleton); + + const currentFields = extractOutputFields(bridge, op); + const validPaths = new Set(currentFields.map((f) => f.path)); + const selectedFields = (q.outputFields ?? "") + .split(",") + .map((f) => f.trim()) + .filter((f) => f && validPaths.has(f)); + + return { + ...q, + inputJson: mergedInput, + outputFields: selectedFields.join(","), + }; + }), + ); + }, [bridge, mode]); + + return { + // example picker + exampleIndex, + selectExample, + // load from share payload (used by App after loading a ?s= URL) + loadSharePayload(payload: SharePayload) { + setMode(payload.mode ?? "standalone"); + setSchema(payload.schema); + setBridge(payload.bridge); + const newQ: QueryTab[] = payload.queries.map((q, i) => { + const sq = payload.standaloneQueries?.[i]; + return { + id: crypto.randomUUID(), + name: q.name, + nameManual: true, + query: q.query, + operation: sq?.operation ?? "", + outputFields: sq?.outputFields ?? "", + inputJson: sq?.inputJson ?? "{}", + }; + }); + queryCounterRef.current = newQ.length; + setQueries(newQ); + setContext(payload.context); + setResults({}); + setRunningIds(new Set()); + setActiveTabId(newQ[0]?.id ?? "context"); + }, + // playground props + mode, + onModeChange: handleModeChange, + schema, + onSchemaChange: setSchema, + bridge, + onBridgeChange: setBridge, + onFormatBridge: handleFormatBridge, + context, + onContextChange: setContext, + queries, + activeTabId, + onSelectTab: setActiveTabId, + onAddQuery: addQuery, + onRemoveQuery: removeQuery, + onRenameQuery: renameQuery, + onUpdateQuery: updateQuery, + onUpdateStandaloneField: updateStandaloneField, + displayResult, + displayRunning, + hasErrors, + isActiveRunning, + onRun: handleRun, + graphqlSchema, + bridgeOperations, + availableOutputFields, + }; +}