From 0f2c5f7aa1e36f3d9724b9e94a33986709ec20c5 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Thu, 5 Mar 2026 12:49:00 +0100 Subject: [PATCH 1/5] First small steps to separate out the playground the initial generated code is bad ... and the model gets constatly lost ... so we need to do this step by step --- .../src/components/MiniPlaygroundModal.tsx | 12 + .../src/components/PlaygroundWrapper.tsx | 4 +- .../docs/reference/30-wiring-routing.mdx | 3 + packages/docs-site/src/styles/custom.css | 24 + packages/docs-site/worker-configuration.d.ts | 8 +- packages/playground/package.json | 2 +- packages/playground/src/App.tsx | 912 +----------------- packages/playground/src/DialogPlayground.tsx | 41 + packages/playground/src/Playground.tsx | 637 +++++++++++- packages/playground/src/index.ts | 6 + packages/playground/src/usePlaygroundState.ts | 347 +++++++ 11 files changed, 1093 insertions(+), 903 deletions(-) create mode 100644 packages/docs-site/src/components/MiniPlaygroundModal.tsx create mode 100644 packages/playground/src/DialogPlayground.tsx create mode 100644 packages/playground/src/index.ts create mode 100644 packages/playground/src/usePlaygroundState.ts diff --git a/packages/docs-site/src/components/MiniPlaygroundModal.tsx b/packages/docs-site/src/components/MiniPlaygroundModal.tsx new file mode 100644 index 00000000..03164ae8 --- /dev/null +++ b/packages/docs-site/src/components/MiniPlaygroundModal.tsx @@ -0,0 +1,12 @@ +import { DialogPlayground } 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() { + 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..33396e99 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,8 @@ 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..00611740 100644 --- a/packages/docs-site/src/styles/custom.css +++ b/packages/docs-site/src/styles/custom.css @@ -1,3 +1,27 @@ +@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..db3d140f 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..f88ad868 --- /dev/null +++ b/packages/playground/src/DialogPlayground.tsx @@ -0,0 +1,41 @@ +import { useState } 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 example to load initially (default: 0). */ + initialExample?: number; +}; + +// ── inner playground — mounts state only when dialog is opened ──────────────── +function PlaygroundInner({ initialExample = 0 }: { initialExample?: number }) { + const state = usePlaygroundState(initialExample); + return ( +
+ +
+ ); +} + +// ── main ────────────────────────────────────────────────────────────────────── +export function DialogPlayground({ + label = "Open Playground", + initialExample = 0, +}: DialogPlaygroundProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + + {open && } + + + ); +} diff --git a/packages/playground/src/Playground.tsx b/packages/playground/src/Playground.tsx index 91de13dd..ff2a4629 100644 --- a/packages/playground/src/Playground.tsx +++ b/packages/playground/src/Playground.tsx @@ -1,8 +1,629 @@ -/** - * 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, +}: { + 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 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[]; +}; + +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, +}: 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 ? ( +
+ +
+ +
+
+ ) : ( + /* When in standalone, show a collapsed "GraphQL Schema" bar with the toggle */ +
+ +
+ )} + + {/* 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 */ +
+
+ +
+ + +
+ +
+
+
+ ) : ( + /* 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/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..b2456d97 --- /dev/null +++ b/packages/playground/src/usePlaygroundState.ts @@ -0,0 +1,347 @@ +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; +} + +export function usePlaygroundState(initialExampleIndex = 0) { + const [exampleIndex, setExampleIndex] = useState(initialExampleIndex); + 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); + + const queryCounterRef = useRef(ex.queries.length); + 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 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; + 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, + }; +} From 87bdd463c3cb88638e781737f3686af922316cca Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Thu, 5 Mar 2026 16:52:55 +0100 Subject: [PATCH 2/5] Dialog now working --- .../src/components/MiniPlaygroundModal.tsx | 8 +++++++- packages/docs-site/src/styles/custom.css | 3 ++- packages/playground/src/App.tsx | 2 +- packages/playground/src/DialogPlayground.tsx | 14 ++++++++++++-- packages/playground/src/Playground.tsx | 2 +- 5 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/docs-site/src/components/MiniPlaygroundModal.tsx b/packages/docs-site/src/components/MiniPlaygroundModal.tsx index 03164ae8..61956e34 100644 --- a/packages/docs-site/src/components/MiniPlaygroundModal.tsx +++ b/packages/docs-site/src/components/MiniPlaygroundModal.tsx @@ -8,5 +8,11 @@ import "@stackables/bridge-playground/style.css"; * Used in documentation to provide interactive examples. */ export default function MiniPlaygroundModal() { - return ; + // 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/styles/custom.css b/packages/docs-site/src/styles/custom.css index 00611740..cfd190ce 100644 --- a/packages/docs-site/src/styles/custom.css +++ b/packages/docs-site/src/styles/custom.css @@ -1,6 +1,7 @@ +@layer theme, base, starlight, catppuccin, components, utilities; @import "tailwindcss"; /* Source playground components for Tailwind to detect their classes */ -@source "../../playground/src"; +/* @source "../../../playground/src"; */ @source "../components"; @theme { diff --git a/packages/playground/src/App.tsx b/packages/playground/src/App.tsx index db3d140f..4adcb4a0 100644 --- a/packages/playground/src/App.tsx +++ b/packages/playground/src/App.tsx @@ -41,7 +41,7 @@ export function App() { const isStandalone = mode === "standalone"; return ( -
+
{/* ── Header ── */}
{/* Row 1: logo + (desktop: example picker + info) + share */} diff --git a/packages/playground/src/DialogPlayground.tsx b/packages/playground/src/DialogPlayground.tsx index f88ad868..cd7d8768 100644 --- a/packages/playground/src/DialogPlayground.tsx +++ b/packages/playground/src/DialogPlayground.tsx @@ -15,7 +15,7 @@ export type DialogPlaygroundProps = { function PlaygroundInner({ initialExample = 0 }: { initialExample?: number }) { const state = usePlaygroundState(initialExample); return ( -
+
); @@ -33,7 +33,17 @@ export function DialogPlayground({ - + { + // 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 overflow-hidden" + > {open && } diff --git a/packages/playground/src/Playground.tsx b/packages/playground/src/Playground.tsx index ff2a4629..709226cf 100644 --- a/packages/playground/src/Playground.tsx +++ b/packages/playground/src/Playground.tsx @@ -333,7 +333,7 @@ export function Playground({ return ( <> {/* ── Mobile layout: vertical scrollable stack ── */} -
+
{/* Schema panel — hidden in standalone mode, shows mode toggle */} {!isStandalone ? (
From 3c2dcc111e8fe68a22c13847199d835627085d06 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Thu, 5 Mar 2026 17:22:29 +0100 Subject: [PATCH 3/5] Polish the standalone look a bit --- packages/playground/src/DialogPlayground.tsx | 15 ++- packages/playground/src/Playground.tsx | 94 ++++++++++++------- .../src/components/FieldSelector.tsx | 8 +- .../src/components/StandaloneQueryPanel.tsx | 47 ++++++---- packages/playground/src/usePlaygroundState.ts | 41 ++++---- 5 files changed, 128 insertions(+), 77 deletions(-) diff --git a/packages/playground/src/DialogPlayground.tsx b/packages/playground/src/DialogPlayground.tsx index cd7d8768..609333e8 100644 --- a/packages/playground/src/DialogPlayground.tsx +++ b/packages/playground/src/DialogPlayground.tsx @@ -9,14 +9,22 @@ export type DialogPlaygroundProps = { label?: string; /** Index of the example to load initially (default: 0). */ initialExample?: number; + /** Disables switching to GraphQL mode. */ + hideGqlSwitch?: boolean; }; // ── inner playground — mounts state only when dialog is opened ──────────────── -function PlaygroundInner({ initialExample = 0 }: { initialExample?: number }) { - const state = usePlaygroundState(initialExample); +function PlaygroundInner({ + initialExample = 0, + hideGqlSwitch = true, // By default, hide it in dialog +}: { + initialExample?: number; + hideGqlSwitch?: boolean; +}) { + const state = usePlaygroundState(initialExample, hideGqlSwitch); return (
- +
); } @@ -25,6 +33,7 @@ function PlaygroundInner({ initialExample = 0 }: { initialExample?: number }) { export function DialogPlayground({ label = "Open Playground", initialExample = 0, + hideGqlSwitch = true, }: DialogPlaygroundProps) { const [open, setOpen] = useState(false); diff --git a/packages/playground/src/Playground.tsx b/packages/playground/src/Playground.tsx index 709226cf..a7acbd0f 100644 --- a/packages/playground/src/Playground.tsx +++ b/packages/playground/src/Playground.tsx @@ -84,12 +84,12 @@ function QueryTabBar({ ); return ( -
+
{/* Context tab — always first */} + {!hideGqlSwitch && ( + + )}
); } @@ -257,7 +263,7 @@ function PanelBox({ children }: { children: React.ReactNode }) { // ── panel header label ───────────────────────────────────────────────────────── function PanelLabel({ children }: { children: React.ReactNode }) { return ( -
+
{children}
); @@ -294,6 +300,7 @@ export type PlaygroundProps = { graphqlSchema?: GraphQLSchema; bridgeOperations: BridgeOperation[]; availableOutputFields: OutputFieldNode[]; + hideGqlSwitch?: boolean; }; export function Playground({ @@ -322,6 +329,7 @@ export function Playground({ graphqlSchema, bridgeOperations, availableOutputFields, + hideGqlSwitch, }: PlaygroundProps) { const hLayout = useDefaultLayout({ id: "bridge-playground-h" }); const leftVLayout = useDefaultLayout({ id: "bridge-playground-left-v" }); @@ -337,7 +345,11 @@ export function Playground({ {/* Schema panel — hidden in standalone mode, shows mode toggle */} {!isStandalone ? (
- +
- ) : ( - /* When in standalone, show a collapsed "GraphQL Schema" bar with the toggle */ + ) : !hideGqlSwitch ? ( + /* When in standalone, show a collapsed "GraphQL Schema" bar with the toggle (if not hidden) */
- +
- )} + ) : null} {/* Bridge DSL panel */}
@@ -470,9 +486,15 @@ export function Playground({ {isStandalone ? ( /* Standalone: collapsed schema header + bridge fills left column */
-
- -
+ {!hideGqlSwitch && ( +
+ +
+ )}
@@ -497,7 +519,11 @@ export function Playground({ {/* Schema panel */} - +
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/usePlaygroundState.ts b/packages/playground/src/usePlaygroundState.ts index b2456d97..5a918407 100644 --- a/packages/playground/src/usePlaygroundState.ts +++ b/packages/playground/src/usePlaygroundState.ts @@ -43,11 +43,16 @@ function extractOperationName(query: string): string | null { return null; } -export function usePlaygroundState(initialExampleIndex = 0) { +export function usePlaygroundState( + initialExampleIndex = 0, + forceStandalone = false, +) { const [exampleIndex, setExampleIndex] = useState(initialExampleIndex); const ex = examples[exampleIndex] ?? examples[0]!; - const [mode, setMode] = useState(ex.mode ?? "standalone"); + const [mode, setMode] = useState( + forceStandalone ? "standalone" : (ex.mode ?? "standalone"), + ); const [schema, setSchema] = useState(ex.schema); const [bridge, setBridge] = useState(ex.bridge); const [context, setContext] = useState(ex.context); @@ -74,20 +79,24 @@ export function usePlaygroundState(initialExampleIndex = 0) { const activeQuery = queries.find((q) => q.id === activeTabId); - 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 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) => From bc8035725b337d73927d572268c413ce15697b8c Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Thu, 5 Mar 2026 17:26:48 +0100 Subject: [PATCH 4/5] Finally --- packages/playground/src/DialogPlayground.tsx | 4 ++-- packages/playground/src/components/ui/dialog.tsx | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/playground/src/DialogPlayground.tsx b/packages/playground/src/DialogPlayground.tsx index 609333e8..83d8b342 100644 --- a/packages/playground/src/DialogPlayground.tsx +++ b/packages/playground/src/DialogPlayground.tsx @@ -23,7 +23,7 @@ function PlaygroundInner({ }) { const state = usePlaygroundState(initialExample, hideGqlSwitch); return ( -
+
); @@ -51,7 +51,7 @@ export function DialogPlayground({ e.preventDefault(); } }} - className="max-w-[97vw] w-[97vw] h-[95vh] max-h-[95vh] p-0 overflow-hidden" + className="max-w-[97vw] w-[97vw] h-[95vh] max-h-[95vh] p-0" > {open && } 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"; From 119247a27719c2ce916cd32d5f5668ab1f1e4d01 Mon Sep 17 00:00:00 2001 From: Aarne Laur Date: Thu, 5 Mar 2026 17:50:20 +0100 Subject: [PATCH 5/5] First example with dialog playground --- .../src/components/MiniPlaygroundModal.tsx | 9 ++- .../docs/reference/30-wiring-routing.mdx | 43 ++++++++++- packages/playground/src/DialogPlayground.tsx | 74 ++++++++++++++++++- packages/playground/src/usePlaygroundState.ts | 50 ++++++++++++- 4 files changed, 165 insertions(+), 11 deletions(-) diff --git a/packages/docs-site/src/components/MiniPlaygroundModal.tsx b/packages/docs-site/src/components/MiniPlaygroundModal.tsx index 61956e34..4812f951 100644 --- a/packages/docs-site/src/components/MiniPlaygroundModal.tsx +++ b/packages/docs-site/src/components/MiniPlaygroundModal.tsx @@ -1,4 +1,7 @@ -import { DialogPlayground } from "@stackables/bridge-playground"; +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"; @@ -7,12 +10,12 @@ 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() { +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/content/docs/reference/30-wiring-routing.mdx b/packages/docs-site/src/content/docs/reference/30-wiring-routing.mdx index 33396e99..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 @@ -33,7 +33,48 @@ o.country = "Germany" ``` - + ## 2. Path Scoping Blocks (Nested Objects) diff --git a/packages/playground/src/DialogPlayground.tsx b/packages/playground/src/DialogPlayground.tsx index 83d8b342..8b286d59 100644 --- a/packages/playground/src/DialogPlayground.tsx +++ b/packages/playground/src/DialogPlayground.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect, useRef } from "react"; import { Playground } from "./Playground"; import { usePlaygroundState } from "./usePlaygroundState"; import { Button } from "@/components/ui/button"; @@ -7,21 +7,70 @@ import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; export type DialogPlaygroundProps = { /** Label shown on the trigger button. */ label?: string; - /** Index of the example to load initially (default: 0). */ + /** 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); + 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 (
@@ -34,6 +83,12 @@ export function DialogPlayground({ label = "Open Playground", initialExample = 0, hideGqlSwitch = true, + bridge, + contextStr, + operation, + inputJson, + outputFields, + autoRun, }: DialogPlaygroundProps) { const [open, setOpen] = useState(false); @@ -53,7 +108,18 @@ export function DialogPlayground({ }} className="max-w-[97vw] w-[97vw] h-[95vh] max-h-[95vh] p-0" > - {open && } + {open && ( + + )} ); diff --git a/packages/playground/src/usePlaygroundState.ts b/packages/playground/src/usePlaygroundState.ts index 5a918407..78437631 100644 --- a/packages/playground/src/usePlaygroundState.ts +++ b/packages/playground/src/usePlaygroundState.ts @@ -43,9 +43,25 @@ function extractOperationName(query: string): string | null { 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]!; @@ -53,12 +69,40 @@ export function usePlaygroundState( 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(ex.bridge); - const [context, setContext] = useState(ex.context); + const [bridge, setBridge] = useState(initialBridge); + const [context, setContext] = useState(overrides?.contextStr ?? ex.context); const queryCounterRef = useRef(ex.queries.length); - const [queries, setQueries] = useState(() => buildQueryTabs(ex)); + 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", );