diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 6885882..0237525 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -23,5 +23,3 @@ jobs: run: "yarn run lint" - name: "Run formatting check" run: "yarn run format:check" - - name: "Run Typescript type checking" - run: "yarn run typecheck" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b7b836b..b8b6c19 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,19 +10,25 @@ on: jobs: test: name: "Run Tests" - runs-on: "depot-ubuntu-24.04-small" + runs-on: "depot-ubuntu-24.04-4" steps: - uses: "actions/checkout@v6" with: + lfs: true submodules: true - - uses: "authzed/action-spicedb@v1" - with: - version: "latest" - uses: "actions/setup-node@v6" with: node-version: 24 cache-dependency-path: "yarn.lock" cache: "yarn" - uses: "bahmutov/npm-install@v1" + - name: "Install chromium for playwright" + run: "yarn test:install-chromium-headless" - name: "Run tests" run: "yarn test" + - name: "Upload test artifacts" + if: "failure()" + uses: "actions/upload-artifact@v4" + with: + name: "test-screenshots" + path: "src/tests/browser/__screenshots__" diff --git a/api/lookupshare.ts b/api/lookupshare.ts index faa0160..7632c82 100644 --- a/api/lookupshare.ts +++ b/api/lookupshare.ts @@ -1,7 +1,7 @@ import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3"; import type { VercelRequest, VercelResponse } from "@vercel/node"; -const encodeURL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; +export const encodeURL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; export default async function handler(req: VercelRequest, res: VercelResponse) { const shareid = req.query.shareid ?? ""; @@ -76,6 +76,27 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { } } + if ("check_watches" in shareData) { + if (!Array.isArray(shareData.check_watches)) { + return res.status(400).json({ error: "Share data is not supported" }); + } + for (const w of shareData.check_watches) { + if (typeof w !== "object" || w === null) { + return res.status(400).json({ error: "Share data is not supported" }); + } + if ( + typeof w.object !== "string" || + typeof w.action !== "string" || + typeof w.subject !== "string" + ) { + return res.status(400).json({ error: "Share data is not supported" }); + } + if ("context" in w && typeof w.context !== "string") { + return res.status(400).json({ error: "Share data is not supported" }); + } + } + } + return res.status(200).send(bodyContents); } catch (error) { if (error instanceof Error && error.name === "NoSuchKey") { diff --git a/api/share.ts b/api/share.ts index eca0a78..2072fa8 100644 --- a/api/share.ts +++ b/api/share.ts @@ -11,6 +11,12 @@ export type SharedDataV2 = { relationships_yaml?: string; validation_yaml?: string; assertions_yaml?: string; + check_watches?: Array<{ + object: string; + action: string; + subject: string; + context?: string; + }>; }; const hashPrefixSize = 12; @@ -51,6 +57,20 @@ function validateSharedDataV2(data: VercelRequestBody): data is SharedDataV2 { } } + if ("check_watches" in data) { + const watches = data.check_watches; + if (!Array.isArray(watches)) { + return false; + } + for (const w of watches) { + if (typeof w !== "object" || w === null) return false; + if (typeof w.object !== "string") return false; + if (typeof w.action !== "string") return false; + if (typeof w.subject !== "string") return false; + if ("context" in w && typeof w.context !== "string") return false; + } + } + return true; } diff --git a/cypress.config.ts b/cypress.config.ts deleted file mode 100644 index 6b1309a..0000000 --- a/cypress.config.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { defineConfig } from "cypress"; - -export default defineConfig({ - retries: 1, - defaultCommandTimeout: 10000, - requestTimeout: 11000, - responseTimeout: 60000, - viewportHeight: 768, - viewportWidth: 1400, - chromeWebSecurity: false, - - e2e: { - baseUrl: "http://localhost:3000", - specPattern: ["cypress/integration/**/*.spec.{js,ts}", "cypress/e2e/**/*.cy.{js,jsx,ts,tsx}"], - }, -}); diff --git a/cypress/integration/basic.spec.js b/cypress/integration/basic.spec.js deleted file mode 100644 index 4392b84..0000000 --- a/cypress/integration/basic.spec.js +++ /dev/null @@ -1,35 +0,0 @@ -/// - -describe("Playground", () => { - beforeEach(() => { - cy.visitPlayground(); - }); - - it("displays tutorial", () => { - cy.get(".react-joyride__tooltip").contains("Welcome!").should("have.length", 1); - }); - - it("can dismiss tutorial", () => { - cy.contains("Skip").click(); - cy.reload(); - cy.contains("Welcome!").should("not.exist"); - }); - - it("displays header buttons", () => { - cy.dismissTour(); - cy.get("a").contains("Discuss on Discord").should("exist"); - cy.get("header > button").contains("Select Example Schema").should("exist"); - cy.get("header > button").contains("Share").should("exist"); - cy.get("header > button").contains("Download").should("exist"); - cy.get("header > button").contains("Load From File").should("exist"); - cy.contains("Sign In To Import").should("exist"); - }); - - it("default validation succeeds", () => { - cy.dismissTour(); - cy.waitForWasm(); - cy.tab("Assertions"); - cy.get("button").contains("Run").click(); - cy.contains("Validated!").should("exist"); - }); -}); diff --git a/cypress/integration/nav.spec.js b/cypress/integration/nav.spec.js deleted file mode 100644 index 2758cdc..0000000 --- a/cypress/integration/nav.spec.js +++ /dev/null @@ -1,58 +0,0 @@ -/// - -describe("Navigation", () => { - beforeEach(() => { - cy.visitPlayground(); - cy.dismissTour(); - }); - - it("displays schema tab", () => { - cy.tab("Schema"); - // Default editor content - cy.editorText().containsAll(["definition user {}", "definition resource {", "}"]); - // Sub-menu buttons - cy.get("button").contains("Format").should("exist"); - }); - - it("displays relationships tab", () => { - cy.tab("Test Relationships"); - // Editor mode buttons - cy.get('[aria-label="relationship editor view"]'); - // Grid view - cy.contains("Highlight same types, objects and relations"); - cy.get('[aria-label="code editor"]').click(); - // Text view - cy.editorText().contains("resource:anotherresource#writer@user:somegal"); - }); - - it("displays assertions tab", () => { - cy.tab("Assertions"); - // Default editor content - cy.editorText().containsAll(["assertTrue", "assertFalse"]); - // Sub-menu buttons - cy.contains("Validation not run").should("exist"); - cy.get("button").contains("Run").should("exist"); - }); - - it("displays expected relations tab", () => { - cy.tab("Expected Relations"); - // No default editor content - // Sub-menu buttons - cy.contains("Validation not run").should("exist"); - cy.get("button").contains("Run").should("exist"); - cy.get("button").contains("Re-Generate").should("exist"); - cy.get("button").contains("Compute and Diff").should("exist"); - }); - - it("displays panels", () => { - cy.waitForWasm(); - cy.panel("Problems"); - cy.panelText().contains("No problems found"); - cy.panel("Check Watches"); - cy.panelText().find("table.MuiTable-root"); - cy.panel("System Visualization"); - cy.panelText().find("div.vis-network"); - cy.panel("Last Validation Run"); - cy.panelText().contains("Validation Not Run"); - }); -}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js deleted file mode 100644 index d857fd0..0000000 --- a/cypress/support/commands.js +++ /dev/null @@ -1,63 +0,0 @@ -import "cypress-wait-until"; - -// -- Parent commands -- -// Navigate to the playground URL. -Cypress.Commands.add("visitPlayground", () => { - cy.visit("/"); -}); - -// Dismiss the tour elements if displayed. -Cypress.Commands.add("dismissTour", () => { - cy.contains("Skip").click(); - cy.getCookie("dismiss-tour").then((val) => { - if (!val) { - cy.contains("Skip").click(); - } - }); -}); - -// Activate the tab with the given label. -Cypress.Commands.add("tab", (tabLabel) => { - cy.get("div[aria-label=Tabs]").contains(tabLabel).click(); -}); - -// Activate the panel with the given label. -Cypress.Commands.add("panel", (panelLabel) => { - cy.get("button").contains(panelLabel).click(); -}); - -// Get the text contents of the editor component. -Cypress.Commands.add("editorText", () => { - return cy.get(".monaco-editor"); -}); - -// Get the text contents of the panel component. -Cypress.Commands.add("panelText", () => { - return cy.get("div[role=tabpanel] > div"); -}); - -// Wait until WASM developer package is loaded -Cypress.Commands.add("waitForWasm", () => { - cy.waitUntil(() => cy.window().then((win) => !!win.runSpiceDBDeveloperRequest), { - errorMsg: "WASM development package not loaded", - timeout: 30000, - interval: 500, - }); - return; -}); - -// -- Child commands -- -// Asserts that all list items are present. -Cypress.Commands.add("containsAll", { prevSubject: "element" }, (subject, list) => { - list.forEach((line) => { - cy.wrap(subject).contains(line); - }); -}); - -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts deleted file mode 100644 index f4e487a..0000000 --- a/cypress/support/e2e.ts +++ /dev/null @@ -1,28 +0,0 @@ -// *********************************************************** -// This example support/e2e.ts is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import "./commands"; - -// Handle uncaught exceptions -Cypress.on("uncaught:exception", (err) => { - // TODO: Ignore transient network errors until either browser caching - // or js fixtures are supported - // https://github.com/cypress-io/cypress/issues/18335 - // https://github.com/cypress-io/cypress/issues/1271 - if (err.message.includes("Uncaught NetworkError")) { - return false; - } -}); diff --git a/dev-api/README.md b/dev-api/README.md new file mode 100644 index 0000000..8afa578 --- /dev/null +++ b/dev-api/README.md @@ -0,0 +1,6 @@ +## The Dev API + +The share logic in prod is taken care of by Vercel API routes +that write and read to object storage. This provides a similar +API, but stores the shares in-memory. It exists to make it possible +to test share logic in development. diff --git a/dev-api/index.ts b/dev-api/index.ts new file mode 100644 index 0000000..f26322a --- /dev/null +++ b/dev-api/index.ts @@ -0,0 +1,127 @@ +import { createHash } from "crypto"; +import type { IncomingMessage, ServerResponse } from "http"; + +import type { ViteDevServer } from "vite"; + +type SharedDataV2 = { + version: "2"; + schema: string; + relationships_yaml?: string; + validation_yaml?: string; + assertions_yaml?: string; + check_watches?: Array<{ + object: string; + action: string; + subject: string; + context?: string; + }>; +}; + +const encodeURL = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; +const hashPrefixSize = 12; + +const shares = new Map(); +const salt = process.env.SHARE_SALT ?? "dev"; + +function computeShareHash(s: string, data: string): string { + const hash = createHash("sha256"); + hash.update(s + ":", "utf8"); + hash.update(data, "utf8"); + const b64 = hash.digest().toString("base64url"); + let hashLen = hashPrefixSize; + while (hashLen <= b64.length && b64[hashLen - 1] === "_") { + hashLen++; + } + return b64.substring(0, hashLen); +} + +// TODO: zod this +function validateSharedDataV2(data: unknown): data is SharedDataV2 { + if (typeof data !== "object" || data === null) return false; + const d = data as Record; + if (d.version !== "2") return false; + if (typeof d.schema !== "string") return false; + for (const field of ["relationships_yaml", "validation_yaml", "assertions_yaml"]) { + if (field in d && typeof d[field] !== "string") return false; + } + if ("check_watches" in d) { + if (!Array.isArray(d.check_watches)) return false; + for (const w of d.check_watches) { + if (typeof w !== "object" || w === null) return false; + const ww = w as Record; + if (typeof ww.object !== "string") return false; + if (typeof ww.action !== "string") return false; + if (typeof ww.subject !== "string") return false; + if ("context" in ww && typeof ww.context !== "string") return false; + } + } + return true; +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function json( + res: ServerResponse, + status: number, + body: unknown, + contentType = "application/json", +) { + const payload = JSON.stringify(body); + res.writeHead(status, { "Content-Type": contentType }); + res.end(payload); +} + +export function configureServer(server: ViteDevServer) { + server.middlewares.use(async (req: IncomingMessage, res: ServerResponse, next: () => void) => { + const url = new URL(req.url!, `http://${req.headers.host}`); + + if (req.method === "POST" && url.pathname === "/api/share") { + let body: unknown; + try { + body = JSON.parse(await readBody(req)); + } catch { + return json(res, 400, { error: "Invalid JSON" }); + } + + if (!validateSharedDataV2(body)) { + return json(res, 400, { error: "Invalid share data format" }); + } + + const dataString = JSON.stringify(body); + const hash = computeShareHash(salt, dataString); + shares.set(hash, dataString); + console.log("current shares: ", shares.keys()); + return json(res, 200, { hash }); + } + + if (req.method === "GET" && url.pathname === "/api/lookupshare") { + const shareid = url.searchParams.get("shareid"); + if (!shareid) { + return json(res, 400, { error: "Share ID is required" }); + } + + for (const char of shareid) { + if (!encodeURL.includes(char)) { + return json(res, 400, { error: "Invalid characters in share ID" }); + } + } + + const data = shares.get(shareid); + if (!data) { + console.log("yeah this wasn't found"); + return json(res, 404, { error: "Share not found" }); + } + + return json(res, 200, JSON.parse(data)); + } + + next(); + }); +} diff --git a/package.json b/package.json index 26ac21b..93ad5f4 100644 --- a/package.json +++ b/package.json @@ -4,103 +4,109 @@ "private": true, "type": "module", "scripts": { - "dev": "HTTPS=true vite", - "build": "tsc -b && vite build", + "dev": "vite", + "build": "vite build", "test": "vitest", + "test:unit": "vitest --project unit", + "test:browser": "vitest --project browser", + "test:install-chromium-headless": "playwright install chromium --only-shell", "lint": "oxlint --type-aware --type-check", "lint-fix": "oxlint --type-aware --type-check --fix", - "typecheck": "tsc --noEmit", "format": "oxfmt", "format:check": "oxfmt --check", - "cy:run": "cypress run --browser chrome", - "cy:open": "cypress open", "update:deps": "./scripts/update-spicedb.sh && buf generate && ./scripts/update-zed.sh" }, "dependencies": { "@authzed/spicedb-parser-js": "^1.1.0", "@aws-sdk/client-s3": "^3.997.0", "@bufbuild/protobuf": "^2.4.0", - "@dagrejs/dagre": "^2.0.4", + "@dagrejs/dagre": "^3.0.0", + "@fontsource/inter": "^5", "@fontsource/roboto": "^5.1.1", "@glideapps/glide-data-grid": "^6.0.3", "@glideapps/glide-data-grid-cells": "^6.0.3", - "@material-ui/core": "^4.12.4", - "@material-ui/icons": "^4.11.3", - "@material-ui/lab": "^4.0.0-alpha.61", "@monaco-editor/react": "^4.7.0", "@posthog/react": "^1.8.0", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-context-menu": "^2.2.16", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", "@tailwindcss/vite": "^4.2.1", - "@tanstack/react-pacer": "^0.20.0", - "@tanstack/react-router": "^1.163.2", - "@tanstack/react-router-devtools": "^1.163.2", + "@tanstack/react-pacer": "^0.22.0", + "@tanstack/react-router": "^1.169.2", + "@tanstack/react-router-devtools": "^1.166.13", "@vercel/node": "^5.2.0", "@xyflow/react": "^12.10.1", "ajv": "8.18.0", "ansi-to-html": "^0.7.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "d3-scale-chromatic": "^2.0.0", "dequal": "^2.0.2", "file-saver": "^2.0.5", "file-select-dialog": "^1.5.4", "line-column": "^1.0.2", - "lucide-react": "^0.575.0", + "lodash": "^4.18.1", + "lucide-react": "^1.14.0", "monaco-editor": "~0.55.1", "next-themes": "^0.4.6", "parsimmon": "^1.18.1", "posthog-js": "^1.353.1", "radix-ui": "^1.4.3", - "react": "^18.3.1", - "react-cookie": "^8.0.1", - "react-dom": "^18.3.1", - "react-joyride": "^2.5.3", - "react-reflex": "^4.2.7", + "react": "^19.2.6", + "react-cookie": "^8.1.2", + "react-dom": "^19.2.6", "react-responsive-carousel": "^3.2.23", + "serve": "^14.2.6", "sjcl": "^1.0.8", "sonner": "^2.0.7", "string-to-color": "^2.2.2", "string.prototype.replaceall": "^1.0.6", - "styled-components": "^6.1.14", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.1", "typeface-roboto-mono": "^1.1.13", "use-deep-compare": "^1.1.0", "use-deep-compare-effect": "^1.8.1", "yaml": "^2.0.1", - "zod": "^4.3.6" + "zod": "^4.3.6", + "zustand": "^5.0.12" }, "devDependencies": { - "@testing-library/react": "^14.0.0", - "@testing-library/user-event": "^14.4.3", "@types/d3-scale-chromatic": "^3.0.0", - "@types/dagre": "^0.7.53", + "@types/dagre": "^0.7.54", "@types/file-saver": "^2.0.5", "@types/line-column": "^1.0.0", - "@types/node": "^25.3.0", + "@types/node": "^25.6.2", "@types/parsimmon": "^1.10.6", - "@types/react": "^18.3.1", + "@types/react": "^19.2.14", "@types/react-copy-to-clipboard": "^5.0.4", - "@types/react-dom": "^18.3.1", + "@types/react-dom": "^19.2.3", "@types/sjcl": "^1.0.30", - "@types/styled-components": "^5.1.26", "@types/use-deep-compare-effect": "^1.5.1", "@types/uuid": "^11.0.0", - "@vitejs/plugin-react": "^5.1.4", - "cypress": "^12.9.0", - "cypress-wait-until": "^1.7.2", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/browser-playwright": "^4.1.6", "globals": "^15.14.0", - "oxfmt": "^0.35.0", - "oxlint": "^1.50.0", - "oxlint-tsgolint": "^0.15.0", + "jsdom": "^29.1.1", + "oxfmt": "^0.48.0", + "oxlint": "^1.63.0", + "oxlint-tsgolint": "^0.22.1", + "playwright": "^1.59.1", "tw-animate-css": "^1.2.8", - "typescript": "~5.9.3", - "vite": "^7.3.1", - "vite-plugin-svgr": "^4.5.0", - "vitest": "^2.1.8" + "typescript": "~6.0.3", + "vite": "^8.0.12", + "vite-plugin-svgr": "^5.2.0", + "vitest": "^4.1.6", + "vitest-browser-react": "^2.2.0" }, "browserslist": { "production": [ diff --git a/src/App.css b/src/App.css index 329cc34..97a6f1d 100644 --- a/src/App.css +++ b/src/App.css @@ -3,42 +3,3 @@ body { overflow: hidden; } - -.App { - text-align: center; -} - -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; -} - -.App-link { - color: #61dafb; -} - -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} diff --git a/src/App.tsx b/src/App.tsx index 1695825..42a5469 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,58 +6,77 @@ import { createRoute, createRootRoute, } from "@tanstack/react-router"; -import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import posthog from "posthog-js"; import { PropsWithChildren, useEffect } from "react"; import { CookiesProvider } from "react-cookie"; -import "react-reflex/styles.css"; import "typeface-roboto-mono/index.css"; // Import the Roboto Mono font. +import { EmbeddedPlayground } from "@/components/EmbeddedPlayground"; +import { FullPlayground } from "@/components/FullPlayground"; +import { InlinePlayground } from "@/components/InlinePlayground"; +import { SettingsProvider } from "@/components/SettingsProvider"; +import { ShareLoader } from "@/components/ShareLoader"; import { ThemeProvider } from "@/components/ThemeProvider"; +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; import { isEUVisitor, shouldOptOutCapturing } from "@/lib/consent"; +import { ErrorComponent as ShareErrorComponent, shareLoader } from "@/loaders/share"; +import { useGoogleAnalytics } from "@/playground-ui/GoogleAnalyticsHook"; +import LoadingView from "@/playground-ui/LoadingView"; +import AppConfig from "@/services/configservice"; import "./App.css"; -import { EmbeddedPlayground } from "./components/EmbeddedPlayground"; -import { FullPlayground } from "./components/FullPlayground"; -import { InlinePlayground } from "./components/InlinePlayground"; -import { Toaster } from "./components/ui/sonner"; -import { ConfirmDialogProvider } from "./playground-ui/ConfirmDialogProvider"; -import { useGoogleAnalytics } from "./playground-ui/GoogleAnalyticsHook"; -import PlaygroundUIThemed from "./playground-ui/PlaygroundUIThemed"; -import AppConfig from "./services/configservice"; -import { PLAYGROUND_UI_COLORS } from "./theme"; const rootRoute = createRootRoute({ - component: () => ( - <> - - - - ), + component: Outlet, }); -// TODO: extend the routing; the $s are catchalls. const indexRoute = createRoute({ getParentRoute: () => rootRoute, - path: "$", + path: "/", component: FullPlayground, }); + +const shareRoute = createRoute({ + getParentRoute: () => rootRoute, + component: ShareLoader, + path: "/s/$shareId", + loader: ({ params: { shareId } }) => shareLoader(shareId), + pendingComponent: LoadingView, + errorComponent: ShareErrorComponent, +}); + const inlineRoute = createRoute({ getParentRoute: () => rootRoute, - path: "/i/$", + path: "/i/$shareId", component: InlinePlayground, + loader: ({ params: { shareId } }) => shareLoader(shareId), + pendingComponent: LoadingView, + errorComponent: ShareErrorComponent, }); + const embeddedRoute = createRoute({ getParentRoute: () => rootRoute, - path: "/e/$", + path: "/e/$shareId", component: EmbeddedPlayground, + loader: ({ params: { shareId } }) => shareLoader(shareId), + pendingComponent: LoadingView, + errorComponent: ShareErrorComponent, }); -const routeTree = rootRoute.addChildren([indexRoute, inlineRoute, embeddedRoute]); +// TODO: check all this routing behavior +const routeTree = rootRoute.addChildren([shareRoute, inlineRoute, embeddedRoute, indexRoute]); const router = createRouter({ routeTree }); const config = AppConfig(); +// TODO: set up a baby API that does "share" logic locally in the dev env +if (config.shareApiEndpoint) { + console.log(`[playground] sharing: enabled (${config.shareApiEndpoint})`); +} else { + console.log("[playground] sharing: disabled (VITE_SHARE_API_ENDPOINT is not set)"); +} + function PHProvider({ children }: PropsWithChildren) { useEffect(() => { if (config.posthog.apiKey && config.posthog.host) { @@ -91,19 +110,17 @@ function App() { // Register GA hook. useGoogleAnalytics(config.ga.measurementId); - const isEmbeddedPlayground = window.location.pathname.indexOf("/e/") >= 0; return ( <> - {/* @ts-ignore-error react-cookie's types are screwy; CI and (local and vercel) disagree about whether there's an error or not. */} - - + + - - + + diff --git a/src/components/CheckDebugTraceView.tsx b/src/components/CheckDebugTraceView.tsx index ec297a9..236f0c2 100644 --- a/src/components/CheckDebugTraceView.tsx +++ b/src/components/CheckDebugTraceView.tsx @@ -1,13 +1,8 @@ import type { JsonObject, JsonValue } from "@bufbuild/protobuf"; -import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; -import CheckCircleIcon from "@material-ui/icons/CheckCircle"; -import ChevronRightIcon from "@material-ui/icons/ChevronRight"; -import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; -import HelpOutlineIcon from "@material-ui/icons/HelpOutline"; -import HighlightOffIcon from "@material-ui/icons/HighlightOff"; -import TreeItem from "@material-ui/lab/TreeItem"; -import TreeView from "@material-ui/lab/TreeView"; -import clsx from "clsx"; +import { CheckCircle2, ChevronDown, ChevronRight, HelpCircle, XCircle } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "@/lib/utils"; import { LocalParseService } from "../services/localparse"; import { @@ -18,57 +13,6 @@ import { CheckDebugTrace_PermissionType, } from "../spicedb-common/protodefs/authzed/api/v1/debug_pb"; -const useStyles = makeStyles((theme: Theme) => - createStyles({ - root: { - backgroundColor: theme.palette.background.default, - padding: theme.spacing(0.5), - }, - dispatch: { - padding: theme.spacing(0.5), - }, - dispatchHeader: { - display: "grid", - gridTemplateColumns: "auto auto auto auto 1fr", - columnGap: "4px", - alignItems: "center", - }, - success: { - color: theme.palette.success.main, - }, - subdispatches: { - paddingLeft: theme.spacing(2), - }, - permission: { - color: "#1acc92", - }, - relation: { - color: "#ffa887", - }, - resourceType: { - color: "#ccc", - }, - caveat: { - color: "#ff4271", - }, - subject: { - color: "#9676ff", - display: "grid", - gridTemplateColumns: "auto auto auto auto 1fr", - columnGap: "4px", - alignItems: "center", - }, - missingRequiredContext: { - color: theme.palette.getContrastText(theme.palette.info.dark), - backgroundColor: theme.palette.info.dark, - padding: theme.spacing(1), - margin: theme.spacing(1), - marginLeft: theme.spacing(3), - marginBottom: theme.spacing(2), - }, - }), -); - const hasPermission = (t: CheckDebugTrace) => { return ( t.result === CheckDebugTrace_Permissionship.HAS_PERMISSION || @@ -85,12 +29,47 @@ const hasNotPermission = (t: CheckDebugTrace) => { ); }; +interface TreeItemProps { + nodeId: string; + label: React.ReactNode; + defaultExpanded?: boolean; + expandedSet?: Set; + children?: React.ReactNode; +} + +function TreeItem({ nodeId, label, defaultExpanded, expandedSet, children }: TreeItemProps) { + const initial = + defaultExpanded !== undefined ? defaultExpanded : expandedSet ? expandedSet.has(nodeId) : false; + const [expanded, setExpanded] = useState(initial); + const hasChildren = !!children && (Array.isArray(children) ? children.length > 0 : true); + + return ( +
+
+ {hasChildren ? ( + + ) : ( + + )} +
{label}
+
+ {expanded && hasChildren &&
{children}
} +
+ ); +} + export function CheckDebugTraceView(props: { trace: CheckDebugTrace; localParseService: LocalParseService; }) { - const classes = useStyles(); - const defaultExpanded: string[] = []; + const expandedSet = new Set(); const appendExpanded = (t: CheckDebugTrace) => { if (!hasPermission(t)) { @@ -98,7 +77,7 @@ export function CheckDebugTraceView(props: { } t.resource?.objectId.split(",").forEach((resourceID: string) => { - defaultExpanded.push(`${t.resource?.objectType}:${resourceID}#${t.permission}`); + expandedSet.add(`${t.resource?.objectType}:${resourceID}#${t.permission}`); }); if (t.resolution.case === "subProblems") { @@ -112,17 +91,8 @@ export function CheckDebugTraceView(props: { const key = `${props.trace.resource?.objectType}:${props.trace.resource?.objectId}#${props.trace.permission}@${props.trace.subject?.object?.objectType}:${props.trace.subject?.object?.objectId}#${props.trace.subject?.optionalRelation}`; return ( -
- } - defaultExpandIcon={} - defaultExpanded={defaultExpanded} - > - - +
+
); } @@ -130,9 +100,8 @@ export function CheckDebugTraceView(props: { function CheckDebugTraceItems(props: { trace: CheckDebugTrace; localParseService: LocalParseService; + expandedSet: Set; }) { - const classes = useStyles(); - return ( <> {props.trace.resource?.objectId.split(",").map((resourceID) => { @@ -140,7 +109,7 @@ function CheckDebugTraceItems(props: { const isMember = hasPermission(props.trace); const isNotMember = hasNotPermission(props.trace); - const children = + const children: React.ReactNode[] = props.trace.resolution.case === "subProblems" ? props.trace.resolution.value.traces.map((subTrace, index) => { return ( @@ -148,6 +117,7 @@ function CheckDebugTraceItems(props: { key={index} trace={subTrace} localParseService={props.localParseService} + expandedSet={props.expandedSet} /> ); }) @@ -161,10 +131,18 @@ function CheckDebugTraceItems(props: { ) { children.push( - +
+ {props.trace.subject?.object?.objectType}:{props.trace.subject?.object?.objectId} {props.trace.subject?.optionalRelation && `#${props.trace.subject?.optionalRelation}`} @@ -180,24 +158,28 @@ function CheckDebugTraceItems(props: { - {isMember && } - {isNotMember && } +
+ {isMember && } + {isNotMember && } {result === CheckDebugTrace_Permissionship.CONDITIONAL_PERMISSION && props.trace.caveatEvaluationInfo?.result === CaveatEvalInfo_Result.MISSING_SOME_CONTEXT && ( - + )} - {props.trace.resource?.objectType}: + {props.trace.resource?.objectType}: {resourceID} @@ -218,29 +200,30 @@ function CheckDebugTraceItems(props: { } function CaveatTreeItem(props: { evalInfo: CaveatEvalInfo; nodeIDPrefix: string }) { - const classes = useStyles(); - return ( +
{props.evalInfo.result === CaveatEvalInfo_Result.TRUE && ( - + )} {props.evalInfo.result === CaveatEvalInfo_Result.FALSE && ( - + )} {props.evalInfo.result === CaveatEvalInfo_Result.MISSING_SOME_CONTEXT && ( - + )} {props.evalInfo.caveatName} - caveat + caveat
} > {props.evalInfo.partialCaveatInfo?.missingRequiredContext && ( -
+
Missing required caveat context fields:{" "} {props.evalInfo.partialCaveatInfo.missingRequiredContext.join(", ")}
@@ -278,23 +261,21 @@ function ContextTreeView(context: JsonObject | undefined) { }); } -function ContextTreeValue(value: JsonValue) { +function ContextTreeValue(value: JsonValue): [React.ReactNode, boolean] { if (value === null) { - // NOTE: i'm not sure why this triggers on array literals. - // oxlint-disable-next-line eslint-plugin-react(jsx-key) - return [null, false]; + return [null, false]; } if (typeof value === "boolean") { - // oxlint-disable-next-line eslint-plugin-react(jsx-key) - return [{value.toString()}, false]; + return [{value.toString()}, false]; } if (Array.isArray(value)) { return [ - // NOTE: not sure what the key would be in this case. I think I'd rather get rid - // of this code. - // oxlint-disable-next-line eslint-plugin-react(jsx-key) - value.map((v) => { - return {ContextTreeValue(v)}; + value.map((v, idx) => { + return ( + }> + {ContextTreeValue(v)[0]} + + ); }), true, ]; @@ -304,6 +285,5 @@ function ContextTreeValue(value: JsonValue) { return [ContextTreeView(value), true]; } // If we've gotten this far, we have a number or a string and we can render it straight out. - // oxlint-disable-next-line eslint-plugin-react(jsx-key) - return [{value}, false]; + return [{value}, false]; } diff --git a/src/components/DatastoreRelationshipEditor.tsx b/src/components/DatastoreRelationshipEditor.tsx index 4b7155a..9eac2e9 100644 --- a/src/components/DatastoreRelationshipEditor.tsx +++ b/src/components/DatastoreRelationshipEditor.tsx @@ -1,6 +1,6 @@ import { Theme } from "@glideapps/glide-data-grid"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; -import { useCallback, useMemo, useState } from "react"; +import { useMemo, useState } from "react"; import useDeepCompareEffect from "use-deep-compare-effect"; import { @@ -70,7 +70,7 @@ export type DatastoreRelationshipEditorProps = { } & { dimensions?: { width: number; height: number } }; export function DatastoreRelationshipEditor(props: DatastoreRelationshipEditorProps) { - const debouncedUpdateDatastore = useDebouncedCallback( + const handleDataUpdated = useDebouncedCallback( (data: RelationshipDatum[]) => { const editableContents = toRelationshipsString(data); props.datastore.update(relationshipsItem, editableContents); @@ -79,13 +79,6 @@ export function DatastoreRelationshipEditor(props: DatastoreRelationshipEditorPr { wait: 50 }, ); - const handleDataUpdated = useCallback( - (data: RelationshipDatum[]) => { - debouncedUpdateDatastore(data); - }, - [debouncedUpdateDatastore], - ); - const relErrors = useMemo(() => { return props.services.problemService.requestErrors.filter( (error: DeveloperError) => error.source === DeveloperError_Source.RELATIONSHIP, diff --git a/src/components/EditorDisplay.tsx b/src/components/EditorDisplay.tsx index 1ce9f24..47e61d1 100644 --- a/src/components/EditorDisplay.tsx +++ b/src/components/EditorDisplay.tsx @@ -1,23 +1,23 @@ import { TextRange } from "@authzed/spicedb-parser-js"; -import { Editor, DiffEditor, useMonaco } from "@monaco-editor/react"; +import { Editor, DiffEditor } from "@monaco-editor/react"; import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer"; -import { useNavigate, useLocation } from "@tanstack/react-router"; +import { useLocation } from "@tanstack/react-router"; import lineColumn from "line-column"; import * as monaco from "monaco-editor"; import { useEffect, useMemo, useRef, useState } from "react"; import { flushSync } from "react-dom"; -import "react-reflex/styles.css"; -import { useMediaQuery } from "@/hooks/use-media-query"; +import { useSettings } from "@/components/SettingsProvider"; +import { useResolvedTheme } from "@/hooks/use-resolved-theme"; import { ScrollLocation, useCookieService } from "../services/cookieservice"; import { DataStore, DataStoreItem, DataStoreItemKind } from "../services/datastore"; import { LocalParseState } from "../services/localparse"; import { Services } from "../services/services"; import registerDSLanguage, { - DS_DARK_THEME_NAME, DS_LANGUAGE_NAME, - DS_THEME_NAME, + PLAYGROUND_DARK_THEME_NAME, + PLAYGROUND_LIGHT_THEME_NAME, } from "../spicedb-common/lang/dslang"; import { RelationshipFound } from "../spicedb-common/parsing"; import { @@ -25,12 +25,18 @@ import { DeveloperWarning, } from "../spicedb-common/protodefs/developer/v1/developer_pb"; +import { useDrawerStore } from "./drawer/state"; import { ERROR_SOURCE_TO_ITEM } from "./panels/errordisplays"; -import registerTupleLanguage, { - TUPLE_DARK_THEME_NAME, - TUPLE_LANGUAGE_NAME, - TUPLE_THEME_NAME, -} from "./relationshipeditor/tuplelang"; +import registerTupleLanguage, { TUPLE_LANGUAGE_NAME } from "./relationshipeditor/tuplelang"; + +// Module-level singletons for one-shot language registration. Monaco's +// `register*` calls are global; calling them on every editor mount can stack +// providers (each registration adds a new completion/definition/semantic-tokens +// provider rather than replacing). The `latestLocalParseStateRef` lets the +// registered tuple completion provider always read the most recent parse +// state without re-registering. +let languagesRegistered = false; +const latestLocalParseStateRef: { current: LocalParseState | null } = { current: null }; export type EditorDisplayProps = { datastore: DataStore; @@ -58,66 +64,43 @@ interface LocationState { * EditorDisplays display the editor in the playground. */ export function EditorDisplay(props: EditorDisplayProps) { - const monacoRef = useMonaco(); - const [monacoReady, setMonacoReady] = useState(false); + const monacoInstanceRef = useRef(null); const [localIndex, setLocalIndex] = useState(0); - const localParseState = useRef(props.services.localParseService.state); - // Effect: Register the languages in monaco. - useEffect(() => { - if (monacoRef) { - registerDSLanguage(monacoRef); - registerTupleLanguage(monacoRef, () => localParseState.current); - setMonacoReady(true); - } - }, [monacoRef]); + // Keep the module-level ref in sync so the (one-shot) tuple completion + // provider always reads the latest parse state. + latestLocalParseStateRef.current = props.services.localParseService.state; useEffect(() => { - localParseState.current = props.services.localParseService.state; + latestLocalParseStateRef.current = props.services.localParseService.state; }, [props.services.localParseService.state]); - const navigate = useNavigate(); const location = useLocation(); const datastore = props.datastore; const currentItem = props.currentItem; const editorRefs = useRef>({}); + const containerRef = useRef(null); // Select the theme and language. - const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); + const resolvedTheme = useResolvedTheme(); + const prefersDarkMode = resolvedTheme === "dark"; + + const { minimapEnabled } = useSettings(); + const minimapVisible = + props.hideMinimap === true ? false : props.hideMinimap === false ? true : minimapEnabled; + // A single unified theme is used for every editor instance regardless of + // language. Monaco's `setTheme` is global — applying different themes per + // editor causes the last mount/update to win and visually corrupt the + // others. The unified theme contains rules for every language we render. const themeName = useMemo(() => { if (props.themeName) { return props.themeName; } - - switch (currentItem?.kind) { - case DataStoreItemKind.SCHEMA: - // Schema. - return prefersDarkMode ? DS_DARK_THEME_NAME : DS_THEME_NAME; - - case DataStoreItemKind.RELATIONSHIPS: - // Validation tuples. - return prefersDarkMode ? TUPLE_DARK_THEME_NAME : TUPLE_THEME_NAME; - - case DataStoreItemKind.EXPECTED_RELATIONS: - // Expected Relations YAML. - return prefersDarkMode ? "vs-dark" : "vs"; - - case DataStoreItemKind.ASSERTIONS: - // Assertions YAML. - return prefersDarkMode ? "vs-dark" : "vs"; - - case undefined: - // Schema. - return prefersDarkMode ? DS_DARK_THEME_NAME : DS_THEME_NAME; - - default: - console.log(`Unknown item kind ${currentItem?.kind} in theme name`); - return "vs"; - } - }, [prefersDarkMode, currentItem?.kind, props.themeName]); + return prefersDarkMode ? PLAYGROUND_DARK_THEME_NAME : PLAYGROUND_LIGHT_THEME_NAME; + }, [prefersDarkMode, props.themeName]); const languageName = useMemo(() => { switch (currentItem?.kind) { @@ -156,15 +139,19 @@ export function EditorDisplay(props: EditorDisplayProps) { // ms. To avoid this behavior, we tell React we want these updates to occur immediately // via the `flushSync` call. // See: https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#automatic-batching + // + // NOTE: We do NOT navigate the URL based on the edited item's pathname here. + // With the editor-groups split layout the URL is owned by the primary group's + // active tab. Typing in the secondary group must not change the URL — doing + // so triggers the URL->primary bridge in FullPlayground which would force the + // primary group to switch to whatever document the secondary is editing, + // causing both Monaco editors to swap models on every keystroke (perceived + // as a hard freeze). `datastore.update` never mutates `pathname`, so the + // previous navigate-on-mismatch was also dead code for the single-editor + // case. flushSync(() => { setLocalIndex(localIndex + 1); - - // TODO: this shouldn't be necessary. Moving to redux may make this less painful. - const updated = datastore.update(currentItem!, value || ""); - if (updated && updated.pathname !== location.pathname) { - void navigate({ to: updated.pathname, replace: true }); - } - + datastore.update(currentItem!, value || ""); props.datastoreUpdated(); }); }; @@ -191,14 +178,14 @@ export function EditorDisplay(props: EditorDisplayProps) { return; } - if (monacoRef) { + if (monacoInstanceRef.current) { markers.push({ startLineNumber: invalid.lineNumber + 1, startColumn: 0, endLineNumber: invalid.lineNumber + 1, endColumn: invalid.text.length + 1, message: `Malformed or invalid test data relationship: ${invalid.parsed.errorMessage}`, - severity: monacoRef.MarkerSeverity.Error, + severity: monacoInstanceRef.current.MarkerSeverity.Error, }); } }); @@ -211,18 +198,28 @@ export function EditorDisplay(props: EditorDisplayProps) { // Generate markers for warnings. if (currentItem.kind === DataStoreItemKind.SCHEMA) { props.services.problemService.warnings.forEach((warning: DeveloperWarning) => { - const line = lines[warning.line - 1]; - const index = line.indexOf(warning.sourceCode, warning.column - 1); - if (monacoRef) { - markers.push({ - startLineNumber: warning.line, - startColumn: index + 1, - endLineNumber: warning.line, - endColumn: index + warning.sourceCode.length + 1, - message: warning.message, - severity: monacoRef.MarkerSeverity.Warning, - }); + const lineText = lines[warning.line - 1]; + if (lineText === undefined || !monacoInstanceRef.current) { + return; + } + // Locate the source code on the line, starting near the reported column. + // Falls back to a full-line search if the reported column lands past the token. + const searchFrom = Math.max(0, (warning.column ?? 1) - 1); + let index = lineText.indexOf(warning.sourceCode, searchFrom); + if (index < 0) { + index = lineText.indexOf(warning.sourceCode); } + if (index < 0) { + return; + } + markers.push({ + startLineNumber: warning.line, + startColumn: index + 1, + endLineNumber: warning.line, + endColumn: index + warning.sourceCode.length + 1, + message: warning.message, + severity: monacoInstanceRef.current.MarkerSeverity.Warning, + }); }); } @@ -241,62 +238,88 @@ export function EditorDisplay(props: EditorDisplayProps) { let column = de.column; let endColumn = column; - if (de.context) { - // If there is no line information, then search for the first occurrence of the context. + // Trim leading/trailing whitespace from the context. Note: per the + // developer.v1 proto, `context` may be a broad string (e.g. the full + // relationship for relationship issues, or the object type name for + // schema issues) — NOT necessarily the offending token. So we only + // use it to refine width when it's a clean single-word token; otherwise + // we fall back to a tight 1-char squiggle at the reported column. + const rawContext = de.context ?? ""; + const trimmedContext = rawContext.trim(); + const isSingleWordToken = !!trimmedContext && !/\s/.test(trimmedContext); + + if (isSingleWordToken) { + // If there is no line information, search the entire document for the + // first occurrence of the trimmed context. if (!line) { - const index = contents.indexOf(de.context); - if (index !== undefined && index >= 0) { + const index = contents.indexOf(trimmedContext); + if (index >= 0) { const found = finder.fromIndex(index); if (found) { line = found.line; column = found.col; - endColumn = column + de.context.length; + endColumn = column + trimmedContext.length; } } } else { - // If there is, ensure the position is still valid. - endColumn = column + de.context.length; - const index = finder.toIndex(line, column); - if (index === undefined) { - return; + // Anchor to the actual occurrence of the trimmed context on (or near) + // the reported line. This is robust against off-by-one / 0-vs-1 + // indexed columns coming from different error producers. + const lineText = lines[line - 1] ?? ""; + const searchFrom = Math.max(0, (column ?? 1) - 1); + let onLineIndex = lineText.indexOf(trimmedContext, searchFrom); + if (onLineIndex < 0) { + onLineIndex = lineText.indexOf(trimmedContext); } - - if (contents.substring(index, de.context.length + index) !== de.context) { - const updatedIndex = contents.indexOf(de.context, index); - if (updatedIndex < index) { - return; - } - - const translated = finder.fromIndex(updatedIndex); - if (translated?.line !== line) { - return; - } - - line = translated.line; - column = translated.col; - endColumn = column + de.context.length; + if (onLineIndex >= 0) { + column = onLineIndex + 1; + endColumn = column + trimmedContext.length; + } else { + // Token not found on the reported line — trust the column and + // underline a single character there. + endColumn = (column ?? 1) + 1; } } + } else { + // Context is empty or multi-word (e.g. a full relationship string). + // Trust the reported line/column and use a tight 1-char squiggle. + // A narrow marker is far better than a wrong wide one. + if (line && column !== undefined) { + endColumn = column + 1; + } } if (!line || column === undefined) { return; } - if (monacoRef) { + // Clamp endColumn to the line's actual content length so the squiggle + // does not visually run onto the next line when context is empty or + // miscalculated. + const targetLineText = lines[line - 1] ?? ""; + const maxEndColumn = targetLineText.length + 1; + if (endColumn <= column || endColumn > maxEndColumn) { + endColumn = Math.max(column + 1, Math.min(endColumn, maxEndColumn)); + } + + if (monacoInstanceRef.current) { markers.push({ startLineNumber: line, startColumn: column, endLineNumber: line, endColumn: endColumn, message: de.message, - severity: monacoRef.MarkerSeverity.Error, + severity: monacoInstanceRef.current.MarkerSeverity.Error, code: de.context, }); } }); - monacoRef?.editor.setModelMarkers(editors[currentItem.id].getModel()!, "someowner", markers); + monacoInstanceRef.current?.editor.setModelMarkers( + editors[currentItem.id].getModel()!, + "someowner", + markers, + ); }; const locationState = location.state as LocationState | undefined | null; @@ -323,11 +346,68 @@ export function EditorDisplay(props: EditorDisplayProps) { { wait: 250 }, ); - const handleEditorMounted = (editor: monaco.editor.IStandaloneCodeEditor) => { + // Manual layout: explicitly drive editor.layout() from a ResizeObserver on the + // container. This avoids contention between Monaco's per-instance internal + // observer (`automaticLayout: true`) when multiple editors are visible at once + // (e.g. in split view), which can stall the UI. + const resizeObserversRef = useRef>({}); + + const attachResizeObserver = (editor: monaco.editor.IStandaloneCodeEditor, key: string) => { + // Observe our React-owned outer wrapper, NOT the Monaco internal DOM. + // Monaco caches its rendered size after each layout() call, so observing + // its own DOM means resize events from CSS-driven parent shrinkage + // (e.g. drawer resize) are missed — the outer flex container shrinks + // but Monaco's frozen-size DOM doesn't, leading to overlap. Observing + // containerRef catches every CSS layout change since it's the outer + //
that we render in JSX. + const observeTarget = containerRef.current; + if (!observeTarget) return; + // Tear down any prior observer for this key before re-attaching. + resizeObserversRef.current[key]?.disconnect(); + // Dedupe identical sizes and coalesce to a single rAF to avoid the + // ResizeObserver -> editor.layout() -> reflow -> ResizeObserver feedback + // loop that can stall the UI when two editors are visible at once. + let lastWidth = 0; + let lastHeight = 0; + let rafId: number | null = null; + const ro = new ResizeObserver((entries) => { + const entry = entries[entries.length - 1]; + if (!entry) return; + const { width, height } = entry.contentRect; + if (width === lastWidth && height === lastHeight) return; + lastWidth = width; + lastHeight = height; + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + editor.layout({ width: lastWidth, height: lastHeight }); + }); + }); + ro.observe(observeTarget); + resizeObserversRef.current[key] = ro; + // Initial layout so the editor sizes itself once parent is laid out. + editor.layout(); + }; + + const handleEditorMounted = ( + editor: monaco.editor.IStandaloneCodeEditor, + monacoInstance: typeof monaco, + ) => { + monacoInstanceRef.current = monacoInstance; + if (!languagesRegistered) { + registerDSLanguage(monacoInstance); + registerTupleLanguage(monacoInstance, () => latestLocalParseStateRef.current!); + languagesRegistered = true; + // Themes are defined inside registerDSLanguage. The Editor already rendered + // with the theme prop before defineTheme ran, so Monaco fell back to its + // built-in default. Re-apply now that the theme is defined. + monacoInstance.editor.setTheme(themeName); + } if (currentItem !== undefined && props.diff === undefined) { + const itemId = currentItem.id; editorRefs.current = { ...editorRefs.current, - [currentItem.id]: editor, + [itemId]: editor, }; editor.onDidChangeCursorPosition((e: monaco.editor.ICursorPositionChangedEvent) => { @@ -341,11 +421,64 @@ export function EditorDisplay(props: EditorDisplayProps) { debouncedSetEditorScroll([e.scrollTop, e.scrollLeft]); }); + attachResizeObserver(editor, itemId); + + // Clean up our refs when this editor instance is disposed (e.g. when + // the host pane unmounts). Avoids unbounded growth of editorRefs and + // dangling ResizeObservers pointing at detached DOM. + editor.onDidDispose(() => { + if (editorRefs.current[itemId] === editor) { + delete editorRefs.current[itemId]; + } + resizeObserversRef.current[itemId]?.disconnect(); + delete resizeObserversRef.current[itemId]; + }); + updateMarkers(); updatePosition(); } }; + const handleDiffEditorMounted = (editor: monaco.editor.IStandaloneDiffEditor) => { + if (currentItem === undefined) return; + const modified = editor.getModifiedEditor(); + const key = `${currentItem.id}-diff`; + attachResizeObserver(modified, key); + modified.onDidDispose(() => { + resizeObserversRef.current[key]?.disconnect(); + delete resizeObserversRef.current[key]; + }); + }; + + // Tear down all observers on unmount. + useEffect(() => { + return () => { + Object.values(resizeObserversRef.current).forEach((ro) => ro.disconnect()); + resizeObserversRef.current = {}; + }; + }, []); + + // Drawer-driven relayout: the bottom drawer's resize handle mutates zustand + // state synchronously during mousemove, but the drawer's height change + // doesn't reliably propagate as a contentRect change to ResizeObserver + // observed on our outer wrapper during a drag (the browser batches resize + // observation and may skip frames mid-drag). Subscribe directly to the + // drawer's open/active-panel/per-panel-height state and force a relayout + // on every change, on the next animation frame so layout has settled. + const drawerOpen = useDrawerStore((s) => s.open); + const drawerActivePanel = useDrawerStore((s) => s.activePanel); + const drawerHeight = useDrawerStore((s) => (s.activePanel ? s.perPanelHeight[s.activePanel] : 0)); + useEffect(() => { + const editors = editorRefs.current; + if (Object.keys(editors).length === 0) return; + const rafId = requestAnimationFrame(() => { + for (const ed of Object.values(editors)) { + ed.layout(); + } + }); + return () => cancelAnimationFrame(rafId); + }, [drawerOpen, drawerActivePanel, drawerHeight]); + const updatePosition = () => { const editors = editorRefs.current; if (currentItem?.id === undefined || !(currentItem?.id in editors)) { @@ -419,17 +552,22 @@ export function EditorDisplay(props: EditorDisplayProps) { updateMarkers(); } - // NOTE: We only care if the currentItem changes or the errors change. + // NOTE: We depend on the actual problem arrays (not just stateKey/count) + // so the markers re-render whenever errors/warnings change identity even + // if the count happens to stay the same (e.g. one error replaced by another). // eslint-disable-next-line react-hooks/exhaustive-deps }, [ currentItem?.pathname, props.services.problemService.isUpdating, - props.services.problemService.stateKey, + props.services.problemService.requestErrors, + props.services.problemService.warnings, + props.services.problemService.validationErrors, + props.services.problemService.invalidRelationships, ]); return ( -
- {monacoReady && currentItem && ( +
+ {currentItem && (
{props.diff ? ( ) : ( )}
diff --git a/src/components/EmbeddedPlayground.tsx b/src/components/EmbeddedPlayground.tsx index a89d5c8..da01fec 100644 --- a/src/components/EmbeddedPlayground.tsx +++ b/src/components/EmbeddedPlayground.tsx @@ -1,16 +1,26 @@ import type { ParsedObjectDefinition } from "@authzed/spicedb-parser-js"; import { create } from "@bufbuild/protobuf"; -import { Button, Menu, MenuItem } from "@material-ui/core"; -import { createStyles, makeStyles } from "@material-ui/core/styles"; -import CheckCircleIcon from "@material-ui/icons/CheckCircle"; -import ErrorOutlineIcon from "@material-ui/icons/ErrorOutline"; -import HelpOutlineIcon from "@material-ui/icons/HelpOutline"; +import { getRouteApi } from "@tanstack/react-router"; import clsx from "clsx"; -import { ChevronDown, Loader, File, User, ThumbsUp, Database } from "lucide-react"; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + Database, + File, + HelpCircle, + Loader, + ThumbsUp, + User, +} from "lucide-react"; import React, { PropsWithChildren, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import { useLiveCheckService } from "../services/check"; +import { + liveCheckItemToWatch, + type LiveCheckService, + useLiveCheckService, +} from "../services/check"; import AppConfig from "../services/configservice"; import { DataStore, DataStoreItemKind, useReadonlyDatastore } from "../services/datastore"; import { useLocalParseService } from "../services/localparse"; @@ -25,190 +35,74 @@ import { CheckOperationsResult_Membership, CheckOperationsResultSchema, } from "../spicedb-common/protodefs/developer/v1/developer_pb"; -import { useDeveloperService } from "../spicedb-common/services/developerservice"; +import { + type DeveloperService, + useDeveloperService, +} from "../spicedb-common/services/developerservice"; import { DatastoreRelationshipEditor } from "./DatastoreRelationshipEditor"; import { EditorDisplay } from "./EditorDisplay"; import "./fonts.css"; -import { ShareLoader } from "./ShareLoader"; - -const useStyles = makeStyles(() => - createStyles({ - root: { - backgroundColor: "rgb(14,13,17)", - height: "100vh", - width: "100vw", - display: "flex", - alignItems: "center", - justifyContent: "center", - position: "relative", - "&:hover": { - "& $openButton": { - opacity: 1, - }, - }, - fontFamily: - 'Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"', - }, - openButton: { - position: "absolute", - bottom: "10px", - right: "10px", - opacity: 0, - transition: "opacity ease-in-out 200ms", - }, - loadedroot: { - border: "1px outset #6d49ac", - borderRadius: "16px", - height: "100vh", - width: "100vw", - display: "grid", - gridTemplateColumns: "1fr 1px 1fr", - columnGap: "10px", - padding: "24px", - }, - column: { - display: "grid", - gridTemplateRows: "auto 1fr", - }, - header: { - fontSize: "85%", - display: "inline-grid", - gridTemplateColumns: "auto auto auto", - columnGap: "6px", - alignItems: "center", - marginBottom: "16px", - border: `1px solid rgba(232, 232, 232, 0.21)`, - borderRadius: "8px", - textTransform: "uppercase", - padding: "8px", - background: "linear-gradient(90deg, rgba(243,241,255,0.1) 0%, rgba(243,241,255,0) 100%)", - }, - queryBox: { - border: "1px outset #6d49ac", - borderRadius: "16px", - marginBottom: "16px", - padding: "8px", - display: "grid", - gridTemplateColumns: "6px 1fr", - columnGap: "10px", - }, - queryBoxColor: { - borderRadius: "16px", - backgroundColor: "#444", - }, - queryEditor: { - padding: "6px", - }, - resultBoxColor: { - borderRadius: "16px", - backgroundColor: "#ccc", - }, - selector: { - fontSize: "85%", - display: "inline-grid", - gridTemplateColumns: "auto auto auto", - columnGap: "6px", - alignItems: "center", - marginLeft: "0.5em", - marginRight: "0.5em", - border: `1px solid rgba(232, 232, 232, 0.21)`, - borderRadius: "8px", - padding: "8px", - background: "linear-gradient(90deg, rgba(243,241,255,0.1) 0%, rgba(243,241,255,0) 100%)", - verticalAlign: "middle", - cursor: "pointer", - "&:hover": { - borderColor: `rgba(232, 232, 232, 0.21) !important`, - }, - }, - resource: { - borderColor: "#E9786E", - }, - permission: { - borderColor: "#1acc92", - }, - subject: { - borderColor: "#cec2f3", - }, - indeterminate: { - backgroundColor: "#aaa", - }, - noPermission: { - backgroundColor: "#f44336", - }, - hasPermission: { - backgroundColor: "#4caf50", - }, - hasPermissionIcon: { - color: "#4caf50", - }, - maybePermission: { - backgroundColor: "#8787ff", - }, - maybePermissionIcon: { - color: "#8787ff", - }, - noPermissionIcon: { - color: "#f44336", - }, - queryResult: { - display: "grid", - gridTemplateColumns: "auto 1fr", - alignItems: "center", - columnGap: "6px", - }, - caret: { - opacity: 0.5, - }, - buttonHeader: { - cursor: "pointer", - }, - menuItem: { - display: "grid", - gridTemplateColumns: "auto 1fr", - alignItems: "center", - columnGap: "6px", - }, - display: {}, - resourceDisplay: { - color: "#E9786E", - }, - permissionDisplay: { - color: "#1acc92", - }, - subjectDisplay: { - color: "#cec2f3", - }, - caveatFieldDisplay: { - color: "#8787ff", - }, - }), -); +import { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; + +const FONT_FAMILY = + 'Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"'; export function EmbeddedPlayground() { - const classes = useStyles(); const datastore = useReadonlyDatastore(); + const developerService = useDeveloperService(); + const liveCheckService = useLiveCheckService(developerService, datastore); return ( -
- - - +
+
); } -function EmbeddedPlaygroundUI(props: { datastore: DataStore }) { - const classes = useStyles(); +function EmbeddedPlaygroundUI(props: { + datastore: DataStore; + developerService: DeveloperService; + liveCheckService: LiveCheckService; +}) { const datastore = props.datastore; - - const developerService = useDeveloperService(); + const developerService = props.developerService; + const liveCheckService = props.liveCheckService; const localParseService = useLocalParseService(datastore); - const liveCheckService = useLiveCheckService(developerService, datastore); const validationService = useValidationService(developerService, datastore); const problemService = useProblemService(localParseService, liveCheckService, validationService); const zedTerminalService = undefined; // not used + const routeApi = getRouteApi("/e/$shareId"); + const shareData = routeApi.useLoaderData(); + const { shareId } = routeApi.useParams(); + + // Load the datastore from what's loaded by the route loader + useEffect(() => { + datastore.load({ + schema: shareData.schema || "", + relationshipsYaml: shareData.relationships_yaml || "", + assertionsYaml: shareData.assertions_yaml || "", + verificationYaml: shareData.validation_yaml || "", + }); + datastore.setBaseline("shared", shareId); + if (liveCheckService) { + liveCheckService.loadWatches(shareData.check_watches ?? []); + } + }, [shareData, datastore, shareId, liveCheckService]); + const services = { localParseService, liveCheckService, @@ -235,21 +129,7 @@ function EmbeddedPlaygroundUI(props: { datastore: DataStore }) { }; }, [resizeIndex, setResizeIndex]); - const [anchorEl, setAnchorEl] = React.useState(null); - - const handleCloseMenu = () => { - setAnchorEl(null); - }; - - const handleOpenMenu = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - const [mode, setMode] = useState<"schema" | "relationships">("schema"); - const setCurrentMode = (mode: "schema" | "relationships") => { - setMode(mode); - setAnchorEl(null); - }; const shareAndOpen = async () => { const shareApiEndpoint = AppConfig().shareApiEndpoint; @@ -268,6 +148,8 @@ function EmbeddedPlaygroundUI(props: { datastore: DataStore }) { DataStoreItemKind.EXPECTED_RELATIONS, ).editableContents!; + const checkWatches = liveCheckService.items.map(liveCheckItemToWatch); + // Invoke sharing. try { const response = await fetch(`${shareApiEndpoint}/api/share`, { @@ -281,6 +163,7 @@ function EmbeddedPlaygroundUI(props: { datastore: DataStore }) { relationships_yaml: relationshipsYaml, assertions_yaml: assertionsYaml, validation_yaml: validationYaml, + ...(checkWatches.length > 0 ? { check_watches: checkWatches } : {}), }), }); @@ -305,56 +188,56 @@ function EmbeddedPlaygroundUI(props: { datastore: DataStore }) { return ( <> - - - setCurrentMode("schema")}> - - Definitions - - setCurrentMode("relationships")}> - - Relationships - -
setDisableMouseWheelScrolling(false)} - className={clsx(classes.loadedroot)} + className="[border-style:outset] border border-[#6d49ac] rounded-2xl h-screen w-screen grid grid-cols-[1fr_1px_1fr] gap-x-2.5 p-6" > -
+
-
- {mode === "schema" && ( - <> + + +
+ {mode === "schema" && ( + <> + + Example Definitions + + )} + {mode === "relationships" && ( + <> + + Example Relationships + + )} + + + +
+
+ + setMode("schema")} + > - Example Definitions - - )} - {mode === "relationships" && ( - <> - - Example Relationships - - )} - - - -
+ Definitions + + setMode("relationships")} + > + + Relationships + + +
<> {mode === "schema" && ( @@ -392,20 +275,13 @@ function EmbeddedPlaygroundUI(props: { datastore: DataStore }) { )}
-
-
+
+
-
-
+
+
-
- -
+
+ +
Can{" "}
-
- -
+
+ +
{devService.state.status === "loading" && ( <> - + Loading developer system )} @@ -528,7 +404,7 @@ function EmbeddedQuery(props: { services: Services }) { href="https://play.authzed.com" target="_blank" rel="noopener nofollow noreferrer" - style={{ color: "white" }} + className="text-white" > standalone Playground @@ -539,13 +415,13 @@ function EmbeddedQuery(props: { services: Services }) { )} {queryResult?.checkError !== undefined && ( <> - +
{queryResult.checkError.message}
)} {queryResult?.membership === CheckOperationsResult_Membership.NOT_MEMBER && ( <> - +
{subject} does not have permission{" "} {permission} on{" "} @@ -555,7 +431,7 @@ function EmbeddedQuery(props: { services: Services }) { )} {queryResult?.membership === CheckOperationsResult_Membership.MEMBER && ( <> - +
{subject} has permission{" "} {permission} on{" "} @@ -565,7 +441,7 @@ function EmbeddedQuery(props: { services: Services }) { )} {queryResult?.membership === CheckOperationsResult_Membership.CAVEATED_MEMBER && ( <> - +
{subject} might have permission{" "} {permission} on{" "} @@ -587,17 +463,14 @@ function Display( kind: "subject" | "permission" | "resource" | "caveatfields"; }>, ) { - const classes = useStyles(); - const kindClass = useMemo(() => { - return { - resource: classes.resourceDisplay, - subject: classes.subjectDisplay, - permission: classes.permissionDisplay, - caveatfields: classes.caveatFieldDisplay, - }[props.kind]; - }, [props.kind, classes]); - - return {props.children}; + const colorClass = { + resource: "text-[#E9786E]", + subject: "text-[#cec2f3]", + permission: "text-[#1acc92]", + caveatfields: "text-[#8787ff]", + }[props.kind]; + + return {props.children}; } function Selector(props: { @@ -607,7 +480,6 @@ function Selector(props: { currentResource?: string; onChange: (value: string) => void; }) { - const classes = useStyles(); const relationships = props.services.localParseService.state.relationships; const filter = (values: (string | null)[]): string[] => { @@ -698,67 +570,46 @@ function Selector(props: { const icon = useMemo(() => { return { - resource: , - subject: , - permission: , + resource: , + subject: , + permission: , }[props.type]; }, [props.type]); - const typeClass = useMemo(() => { - return { - resource: classes.resource, - subject: classes.subject, - permission: classes.permission, - }[props.type]; - }, [props.type, classes]); - - const [anchorEl, setAnchorEl] = React.useState(null); - const handleSelect = (value: string) => { - setAnchorEl(null); - props.onChange(value); - }; - - const handleClose = () => { - setAnchorEl(null); - }; - - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; + const borderColorClass = { + resource: "border-[#E9786E]", + subject: "border-[#cec2f3]", + permission: "border-[#1acc92]", + }[props.type]; return ( - <> -
- {icon} - {current} - - - -
- - {values.map((v) => { - return ( - handleSelect(v)}> - {v} - - ); - })} - - + + +
+ {icon} + {current} + + + +
+
+ + {values.map((v) => ( + props.onChange(v)} + > + {v} + + ))} + +
); } diff --git a/src/components/FullPlayground.tsx b/src/components/FullPlayground.tsx index 741f615..67c4a5d 100644 --- a/src/components/FullPlayground.tsx +++ b/src/components/FullPlayground.tsx @@ -1,291 +1,115 @@ -import { LinearProgress, Tab, Tabs } from "@material-ui/core"; -import AppBar from "@material-ui/core/AppBar"; -import { Theme, createStyles, darken, makeStyles } from "@material-ui/core/styles"; -import { alpha } from "@material-ui/core/styles/colorManipulator"; -import TextField from "@material-ui/core/TextField"; -import useMediaQuery from "@material-ui/core/useMediaQuery"; -import CodeIcon from "@material-ui/icons/Code"; -import GridOnIcon from "@material-ui/icons/GridOn"; -import { useNavigate, useLocation } from "@tanstack/react-router"; -import clsx from "clsx"; import { saveAs } from "file-saver"; import { fileDialog } from "file-select-dialog"; import { BookOpenText, CircleCheck, CircleX, + Code, Download, - File, + File as FileIcon, Form, GitCompare, + Grid3x3, MessageCircleWarning, + Network, RefreshCw, Share2, + X, } from "lucide-react"; -import { useEffect, useMemo, useState, type ReactNode, type ChangeEvent } from "react"; -import { useCookies } from "react-cookie"; -import "react-reflex/styles.css"; +import { useEffect, useRef, useState, useMemo, type ComponentProps, type ReactNode } from "react"; import sjcl from "sjcl"; import { toast } from "sonner"; +import { AuthSlot } from "@/components/auth-slot"; +import { BreadcrumbPill } from "@/components/breadcrumb-pill"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { ButtonGroup } from "@/components/ui/button-group"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import useCopyToClipboard from "@/hooks/use-copy-to-clipboard"; import DISCORD from "../assets/discord.svg?react"; +import { useDocumentIdentity } from "../hooks/use-document-identity"; import { DiscordChatCrate } from "../playground-ui/DiscordChatCrate"; import { useGoogleAnalytics } from "../playground-ui/GoogleAnalyticsHook"; -import TabLabel from "../playground-ui/TabLabel"; -import { useLiveCheckService } from "../services/check"; +import { + useLiveCheckService, + type LiveCheckService, + liveCheckItemToWatch, +} from "../services/check"; import AppConfig from "../services/configservice"; import { RelationshipsEditorType, useCookieService } from "../services/cookieservice"; -import { - DataStore, - DataStoreItem, - DataStoreItemKind, - DataStorePaths, - usePlaygroundDatastore, -} from "../services/datastore"; +import { DataStore, DataStoreItemKind, usePlaygroundDatastore } from "../services/datastore"; import { useLocalParseService } from "../services/localparse"; -import { ProblemService, useProblemService } from "../services/problem"; -import { Services } from "../services/services"; +import { useProblemService } from "../services/problem"; import { ValidationResult, ValidationStatus, useValidationService } from "../services/validation"; import { createValidationYAML, normalizeValidationYAML } from "../services/validationfileformat"; -import { Example } from "../spicedb-common/examples"; -import { useDeveloperService } from "../spicedb-common/services/developerservice"; +import { Example, LoadExamples } from "../spicedb-common/examples"; +import { + useDeveloperService, + type DeveloperService, +} from "../spicedb-common/services/developerservice"; import { useZedTerminalService } from "../spicedb-common/services/zedterminalservice"; import { parseValidationYAML } from "../spicedb-common/validationfileformat"; import { DatastoreRelationshipEditor } from "./DatastoreRelationshipEditor"; -import { EditorDisplay, EditorDisplayProps } from "./EditorDisplay"; -import { ExamplesDropdown } from "./ExamplesDropdown"; -import { GuidedTour, TourElementClass } from "./GuidedTour"; -import { AT, ET, NS, VL } from "./KindIcons"; -import { NormalLogo, SmallLogo } from "./Logos"; -import { Panel, useSummaryStyles } from "./panels/base/common"; -import { ReflexedPanelDisplay } from "./panels/base/reflexed"; -import { ProblemsPanel, ProblemsSummary } from "./panels/problems"; -import { TerminalPanel, TerminalSummary } from "./panels/terminal"; -import { ValidationPanel, ValidationSummary } from "./panels/validation"; -import { VisualizerPanel, VisualizerSummary } from "./panels/visualizer"; -import { WatchesPanel, WatchesSummary } from "./panels/watches"; -import { ShareLoader } from "./ShareLoader"; +import { Drawer } from "./drawer/Drawer"; +import { StatusStrip } from "./drawer/StatusStrip"; +import { VisualizerDocument } from "./editor-groups/documents/Visualizer"; +import { EditorGroups } from "./editor-groups/EditorGroups"; +import { useEditorStore } from "./editor-groups/state"; +import type { DocumentRef } from "./editor-groups/types"; +import { EditorDisplay } from "./EditorDisplay"; +import { ProblemsPanel } from "./panels/problems"; +import { TerminalPanel } from "./panels/terminal"; +import { WatchesPanel } from "./panels/watches"; import { Alert, AlertTitle } from "./ui/alert"; import { ValidateButton } from "./ValidationButton"; -const TOOLBAR_BREAKPOINT = 1550; // pixels - -interface StyleProps { - prefersDarkMode: boolean; +function useElementSize() { + const ref = useRef(null); + const [size, setSize] = useState<{ width: number; height: number } | undefined>(); + useEffect(() => { + if (!ref.current) return; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + setSize({ width: entry.contentRect.width, height: entry.contentRect.height }); + } + }); + observer.observe(ref.current); + return () => observer.disconnect(); + }, []); + return [ref, size] as const; } -const useStyles = makeStyles((theme: Theme) => - createStyles({ - "@global": { - ".reflex-splitter": { - backgroundColor: theme.palette.divider + "!important", - borderColor: theme.palette.divider + "!important", - borderLeftWidth: "0px !important", - borderTopWidth: "0px !important", - }, - }, - root: { - position: "absolute", - top: "0px", - left: "0px", - right: "0px", - bottom: "0px", - }, - reflexContainerContainer: { - position: "absolute", - top: "98px", - left: "0px", - right: "0px", - bottom: "0px", - [theme.breakpoints.down(TOOLBAR_BREAKPOINT)]: { - top: "144px", - }, - }, - topBar: { - borderBottom: "1px solid transparent", - borderBottomColor: theme.palette.divider, - height: "48px", - zIndex: 4, - display: "grid", - alignItems: "center", - justifyContent: "flex-end", - flexDirection: "row", - columnGap: "10px", - gridTemplateColumns: "auto auto 1fr auto auto auto auto auto auto", - backgroundColor: (props: StyleProps) => - props.prefersDarkMode ? "#111" : theme.palette.background.default, - "& .MuiTab-root": { - minWidth: 0, - }, - "& .Mui-selected": { - backgroundColor: "#222", - color: "white !important", - }, - "& .MuiTabs-indicator": { - top: 0, - }, - }, - toolBar: { - backgroundColor: (props: StyleProps) => - props.prefersDarkMode ? "#202020" : theme.palette.background.default, - display: "grid", - flexDirection: "row", - columnGap: "10px", - gridTemplateColumns: "auto 1fr", - "& .MuiTab-root": { - minWidth: 0, - backgroundColor: (props: StyleProps) => - props.prefersDarkMode ? "#1b1b1b" : darken(theme.palette.background.default, 0.05), - }, - "& .Mui-selected": { - backgroundColor: () => alpha(theme.palette.primary.light, 0.15), - color: `${theme.palette.text.primary} !important`, - }, - [theme.breakpoints.down(TOOLBAR_BREAKPOINT)]: { - gridTemplateColumns: "100%", - gridTemplateRows: "auto auto", - backgroundColor: (props: StyleProps) => - props.prefersDarkMode ? "#1b1b1b" : darken(theme.palette.background.default, 0.05), - }, - }, - contextToolbar: { - display: "grid", - flexDirection: "row", - alignItems: "center", - gridTemplateColumns: "auto 1fr auto", - margin: "6px", - marginLeft: "0px", - [theme.breakpoints.down(TOOLBAR_BREAKPOINT)]: { - backgroundColor: (props: StyleProps) => - props.prefersDarkMode ? "#202020" : theme.palette.background.default, - padding: "6px", - margin: "0px", - }, - }, - contextTools: { - display: "grid", - flexDirection: "row", - alignItems: "center", - gridTemplateColumns: "auto auto auto", - columnGap: theme.spacing(1), - "& .MuiButton-root": { - borderColor: "transparent", - backgroundColor: "rgba(255, 255, 255, 0.12)", - color: `${theme.palette.text.primary} !important`, - }, - "& .MuiButton-root:hover": { - backgroundColor: "rgba(255, 255, 255, 0.25)", - }, - }, - logoContainer: { - display: "inline-flex", - alignItems: "center", - justifyContent: "center", - height: "1em", - fontSize: "125%", - padding: theme.spacing(1), - fontFamily: "Roboto Mono, monospace", - [theme.breakpoints.down("sm")]: { - paddingTop: theme.spacing(1), - }, - }, - normalLogo: { - "& svg": { - height: "1em", - marginRight: theme.spacing(1), - }, - [theme.breakpoints.down("sm")]: { - display: "none", - }, - "& a": { - textDecoration: "none", - color: "inherit", - }, - }, - smallLogo: { - display: "none", - [theme.breakpoints.down("sm")]: { - display: "flex", - alignItems: "center", - "& a": { - height: "1.5em", - }, - }, - "& svg": { - width: "1.5em", - height: "1.5em", - }, - }, - shareUrl: { - marginRight: theme.spacing(1), - width: "100%", - }, - mainContent: { - position: "absolute", - top: "0px", - left: "0px", - right: "0px", - bottom: "0px", - }, - landing: { - display: "flex", - alignItems: "center", - justifyContent: "center", - height: "60vh", - width: "100%", - }, - editorContainer: { - height: "60vh", - width: "100%", - }, - hide: { - display: "none", - }, - title: { - textAlign: "center", - padding: theme.spacing(0.5), - backgroundColor: theme.palette.background.default, - display: "grid", - gridTemplateColumns: "1fr auto", - alignItems: "center", - }, - tenantGraphContainer: { - width: "100%", - height: "100%", - backgroundColor: theme.palette.background.default, - backgroundSize: "20px 20px", - backgroundImage: ` - linear-gradient(to right, ${darken( - theme.palette.background.default, - 0.1, - )} 1px, transparent 1px), - linear-gradient(to bottom, ${darken( - theme.palette.background.default, - 0.1, - )} 1px, transparent 1px) - `, - }, - tenantGraphBar: { - padding: theme.spacing(1), - display: "grid", - gridTemplateColumns: "auto 1fr auto", - columnGap: theme.spacing(1), - alignItems: "center", - }, - loadBar: { - padding: theme.spacing(1), - display: "grid", - gridTemplateColumns: "auto 500px", - columnGap: theme.spacing(1), - alignItems: "center", - }, - }), -); +type GridContainerProps = Omit, "dimensions">; + +function GridContainer(props: GridContainerProps) { + const [ref, size] = useElementSize(); + return ( +
+ {size && } +
+ ); +} enum SharingStatus { NOT_RUN = 0, @@ -294,11 +118,6 @@ enum SharingStatus { SHARE_ERROR = 3, } -interface SharingState { - status: SharingStatus; - shareReference?: string; -} - export function FullPlayground() { return ( <> @@ -313,34 +132,38 @@ export function FullPlayground() { function ApolloedPlayground() { const datastore = usePlaygroundDatastore(); + const developerService = useDeveloperService(); + const liveCheckService = useLiveCheckService(developerService, datastore, { persist: true }); return ( - - - + ); } -export function ThemedAppView(props: { datastore: DataStore }) { +export function ThemedAppView(props: { + datastore: DataStore; + developerService: DeveloperService; + liveCheckService: LiveCheckService; +}) { const { pushEvent } = useGoogleAnalytics(); - const [sharingState, setSharingState] = useState({ - status: SharingStatus.NOT_RUN, - }); - - const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); - - const classes = useStyles({ prefersDarkMode: prefersDarkMode }); - const location = useLocation(); - const navigate = useNavigate(); + const [sharingStatus, setSharingStatus] = useState(SharingStatus.NOT_RUN); + const [confirmUpload, setConfirmUpload] = useState(false); + const [confirmLoadExample, setConfirmLoadExample] = useState(false); + const [chosenExample, setChosenExample] = useState(null); const datastore = props.datastore; - const developerService = useDeveloperService(); + const developerService = props.developerService; + const liveCheckService = props.liveCheckService; const localParseService = useLocalParseService(datastore); - const liveCheckService = useLiveCheckService(developerService, datastore); const validationService = useValidationService(developerService, datastore); const problemService = useProblemService(localParseService, liveCheckService, validationService); const zedTerminalService = useZedTerminalService(); + const copy = useCopyToClipboard({ successMessage: "Share link copied to clipboard!" }); const services = { localParseService, @@ -351,21 +174,11 @@ export function ThemedAppView(props: { datastore: DataStore }) { zedTerminalService, }; - const currentItem = datastore.get(location.pathname); - - const [cookies, setCookie] = useCookies(["dismiss-tour"]); - const [showTour, setShowTour] = useState(!cookies["dismiss-tour"]); - - // Effect: If the user lands on the `/` route, redirect them to the schema editor. - // TODO: this should probably be a redirect at the routing layer. - useEffect(() => { - if (currentItem === undefined) { - void navigate({ to: DataStorePaths.Schema(), replace: true }); - } - }, [datastore, currentItem, navigate]); - const conductDownload = () => { - const yamlContents = createValidationYAML(datastore); + const yamlContents = createValidationYAML( + datastore, + liveCheckService.items.map(liveCheckItemToWatch), + ); const bitArray = sjcl.hash.sha256.hash(yamlContents); const hash = sjcl.codec.hex.fromBits(bitArray).substring(0, 6); const blob = new Blob([yamlContents], { type: "text/yaml;charset=utf-8" }); @@ -373,6 +186,7 @@ export function ThemedAppView(props: { datastore: DataStore }) { }; const conductUpload = async () => { + // TODO: pause here const file = await fileDialog({ multiple: false, strict: true, @@ -392,12 +206,9 @@ export function ThemedAppView(props: { datastore: DataStore }) { return; } - services.liveCheckService.clear(); - datastore.loadFromParsed(uploaded); + services.liveCheckService.loadWatches(uploaded.checkWatches ?? []); datastoreUpdated(); - - void navigate({ to: DataStorePaths.Schema(), replace: true }); } }; @@ -415,13 +226,8 @@ export function ThemedAppView(props: { datastore: DataStore }) { const conductSharing = async () => { const shareApiEndpoint = AppConfig().shareApiEndpoint; - if (!shareApiEndpoint) { - return; - } - setSharingState({ - status: SharingStatus.SHARING, - }); + setSharingStatus(SharingStatus.SHARING); const schema = datastore.getSingletonByKind(DataStoreItemKind.SCHEMA).editableContents!; const relationshipsYaml = datastore.getSingletonByKind( @@ -434,6 +240,8 @@ export function ThemedAppView(props: { datastore: DataStore }) { DataStoreItemKind.EXPECTED_RELATIONS, ).editableContents!; + const checkWatches = liveCheckService.items.map(liveCheckItemToWatch); + // Invoke sharing. try { const response = await fetch(`${shareApiEndpoint}/api/share`, { @@ -447,6 +255,7 @@ export function ThemedAppView(props: { datastore: DataStore }) { relationships_yaml: relationshipsYaml, assertions_yaml: assertionsYaml, validation_yaml: validationYaml, + ...(checkWatches.length > 0 ? { check_watches: checkWatches } : {}), }), }); @@ -455,51 +264,58 @@ export function ThemedAppView(props: { datastore: DataStore }) { toast.error("Error sharing", { description: errorData.error || "Failed to share playground", }); - setSharingState({ - status: SharingStatus.SHARE_ERROR, - }); + setSharingStatus(SharingStatus.SHARE_ERROR); return; } const result = await response.json(); const reference = result.hash; pushEvent("shared", { - reference: reference, + reference, }); - setSharingState({ - status: SharingStatus.SHARED, - shareReference: reference, - }); + const newUrl = new URL(`/s/${reference}`, window.location.href); + await copy(newUrl.href); + + setSharingStatus(SharingStatus.SHARED); } catch (error: unknown) { toast.error("Error sharing", { description: error instanceof Error ? error.message : "Failed to share playground", }); - setSharingState({ - status: SharingStatus.SHARE_ERROR, - }); + setSharingStatus(SharingStatus.SHARE_ERROR); return; } }; const datastoreUpdated = () => { - if (sharingState.status !== SharingStatus.NOT_RUN) { - setSharingState({ - status: SharingStatus.NOT_RUN, - }); + if (sharingStatus !== SharingStatus.NOT_RUN) { + setSharingStatus(SharingStatus.NOT_RUN); } }; + const onExampleChange = (ex: Example) => { + if (!props.datastore.isModified()) { + void loadExampleData(ex); + return; + } + setChosenExample(ex); + // Defer opening the dialog until the DropdownMenu close animation completes. + // Opening a Radix AlertDialog synchronously inside a DropdownMenuItem click + // causes both portals' layer/pointer-events management to run concurrently, + // leaving a transparent, click-blocking overlay on the page after the dialog closes. + setTimeout(() => setConfirmLoadExample(true)); + }; + const loadExampleData = async (ex: Example) => { pushEvent("load-example", { id: ex.id, }); datastore.loadFromParsed(ex.data); + datastore.setBaseline("example", ex.id); datastoreUpdated(); - services.liveCheckService.clear(); - void navigate({ to: DataStorePaths.Schema(), replace: true }); + services.liveCheckService.loadWatches(ex.data.checkWatches ?? []); }; const [previousValidationForDiff, setPreviousValidationForDiff] = useState( @@ -568,28 +384,6 @@ export function ThemedAppView(props: { datastore: DataStore }) { const validationState = validationService.state; - const handleChangeTab = ( - // TODO: this should be a Link - _event: ChangeEvent, - selectedTabValue: string, - ) => { - const item = datastore.getById(selectedTabValue)!; - void navigate({ to: item.pathname }); - }; - - const setDismissTour = () => { - setShowTour(false); - setCookie("dismiss-tour", "true"); - void navigate({ to: DataStorePaths.Schema() }); - }; - - const handleTourBeforeStep = (selector: string) => { - // Activate the Assertions tab before the assertions dialogs - if (selector.includes(TourElementClass.assert)) { - void navigate({ to: DataStorePaths.Assertions() }); - } - }; - const isOutOfDate = props.datastore.isOutOfDate(); const cookieService = useCookieService(); @@ -611,11 +405,255 @@ export function ThemedAppView(props: { datastore: DataStore }) { } }; - const appConfig = AppConfig(); - const isSharingEnabled = !!appConfig.shareApiEndpoint; + const isReadOnly = sharingStatus === SharingStatus.SHARING || props.datastore.isOutOfDate(); + + // TODO: this is a component. + const renderDocument = (active: DocumentRef): ReactNode => { + if (active === "schema") { + const item = datastore.getSingletonByKind(DataStoreItemKind.SCHEMA); + return ( +
+ + + + + + Format the schema + + + + + + Open the schema visualizer + +
+ + +
+
+ +
+
+
+ ); + } + + if (active === "relationships") { + const item = datastore.getSingletonByKind(DataStoreItemKind.RELATIONSHIPS); + return ( +
+ + + 0} + > + + + + + + +
+ + +
+
+ {relationshipsEditor === "grid" ? ( + + ) : ( + + )} +
+
+
+ ); + } + + if (active === "assertions") { + const item = datastore.getSingletonByKind(DataStoreItemKind.ASSERTIONS); + return ( +
+ + +
+ + +
+
+ +
+
+
+ ); + } + + if (active === "expected") { + const item = datastore.getSingletonByKind(DataStoreItemKind.EXPECTED_RELATIONS); + return ( +
+ + + {previousValidationForDiff === undefined && ( + + + + + )} + {previousValidationForDiff !== undefined && ( + + + + + )} +
+ + +
+
+ +
+
+
+ ); + } + + if (active === "visualizer") { + return ; + } + + return null; + }; + + const drawerPanels = { + problems: , + watches: , + terminal: , + }; return ( -
+
+ { + if (chosenExample) { + void loadExampleData(chosenExample); + } + }} + /> + {!WebAssembly && ( @@ -634,480 +672,235 @@ export function ThemedAppView(props: { datastore: DataStore }) { )} - - -
-
- - - {" "} - Playground -
-
- - - -
-
+ {/* === Top bar === */} +
+ + SpiceDB + + / {!isOutOfDate && ( - <> - -
- {sharingState.status === SharingStatus.SHARED && ( - - )} -
- {AppConfig().discord.inviteUrl && ( - - )} - {isSharingEnabled && ( - - )} + + Discuss on Discord + + )} + + + + + + Docs + + + + + + + Share + + + + + + Download as YAML + + + + - - )} - - - {currentItem?.id && ( - // NOTE: Tabs doesn't like having an undefined value, so we wait to render - // until we've got it. - - } - title="Schema" - /> - } - /> - } - title="Test Relationships" - /> - } - /> - } - title="Assertions" - /> - } - /> - } - title="Expected Relations" - /> - } - /> - - )} - -
-
- {currentItem?.kind === DataStoreItemKind.SCHEMA && ( - - )} - - {currentItem?.kind === DataStoreItemKind.RELATIONSHIPS && ( -
- - 0} - > - - - - - - -
- )} - - {/* NOTE: the kind here is an enum, so 0 will render. */} - {currentItem?.kind !== undefined && - [DataStoreItemKind.ASSERTIONS, DataStoreItemKind.EXPECTED_RELATIONS].includes( - currentItem.kind, - ) && ( - - )} - - {currentItem?.kind === DataStoreItemKind.EXPECTED_RELATIONS && - previousValidationForDiff === undefined && ( - - - - - )} - {currentItem?.kind === DataStoreItemKind.EXPECTED_RELATIONS && - previousValidationForDiff !== undefined && ( - - - - - )} -
-
- {currentItem?.kind === DataStoreItemKind.SCHEMA && ( - - )} - - {currentItem?.kind === DataStoreItemKind.RELATIONSHIPS && ( - - )} - - {currentItem?.kind === DataStoreItemKind.ASSERTIONS && ( - - )} - - {currentItem?.kind === DataStoreItemKind.EXPECTED_RELATIONS && ( - - )} -
- - -
- + Load workspace from a YAML file + + + +
+ + {/* === Editor groups (tab strip + content per group) === */} +
+
-
- ); -} -const DocLink = ({ title, href }: { title: string; href: string }) => ( - -); + {/* === Bottom drawer (one panel at a time) === */} + -const TabLabelWithCount = (props: { - problemService: ProblemService; - kind: DataStoreItemKind; - icon: ReactNode; - title: string; -}) => { - const classes = useSummaryStyles(); - const problemService = props.problemService; - const errorCount = problemService.getErrorCount(props.kind); - const warningCount = props.kind === DataStoreItemKind.SCHEMA ? problemService.warnings.length : 0; - - return ( -
- - 0 ? "inline-flex" : "none" }} - className={clsx(classes.badge, { - [classes.failBadge]: errorCount > 0, - })} - > - {errorCount} - - 0 ? "inline-flex" : "none" }} - className={clsx(classes.badge, { - [classes.warningBadge]: warningCount > 0, - })} - > - {warningCount} - + {/* === Status strip === */} +
); -}; - -const panels: Panel[] = [ - { - id: "problems", - Summary: ProblemsSummary, - Content: ProblemsPanel, - }, - { - id: "watches", - Summary: WatchesSummary, - Content: WatchesPanel, - }, - { - id: "visualizer", - Summary: VisualizerSummary, - Content: VisualizerPanel, - }, - { - id: "validation", - Summary: ValidationSummary, - Content: ValidationPanel, - }, - { - id: "terminal", - Summary: TerminalSummary, - Content: TerminalPanel, - }, -]; - -function MainPanel( - props: { - datastore: DataStore; - services: Services; - currentItem: DataStoreItem | undefined; - sharingState: SharingState; - previousValidationForDiff: string | undefined; - relationshipsEditor: RelationshipsEditorType; - datastoreUpdated: () => void; - } & { dimensions?: { width: number; height: number } }, -) { - const prefersDarkMode = useMediaQuery("(prefers-color-scheme: dark)"); - const classes = useStyles({ prefersDarkMode: prefersDarkMode }); - - const datastore = props.datastore; - const currentItem = props.currentItem; - const sharingState = props.sharingState; - const devServerState = props.services.developerService.state; - - const devServerStatusDisplay = useMemo(() => { - switch (devServerState.status) { - case "initializing": - return
Initializing Development System
; - - case "loading": - return ( -
- Loading Developer System: - -
- ); - - case "loaderror": - return ( - - - - Could not start the Development System. Please make sure you have WebAssembly enabled. - - - ); - - case "unsupported": - return ( - - - Your browser does not support WebAssembly - - ); +} - case "ready": - return undefined; - } - }, [devServerState, classes.loadBar]); +function BreadcrumbDropdown({ + datastore, + onExampleChange, +}: { + datastore: DataStore; + onExampleChange: (example: Example) => void; +}) { + const examples = useMemo(() => LoadExamples(), []); + const identity = useDocumentIdentity(datastore, (id) => { + const example = examples.find((e) => e.id === id); + return example?.title; + }); return ( -
- {!currentItem && ( -
To get started, please add a namespace configuration.
- )} - - - {props.currentItem?.kind === DataStoreItemKind.RELATIONSHIPS && - props.relationshipsEditor === "grid" && ( - - )} - {(props.currentItem?.kind !== DataStoreItemKind.RELATIONSHIPS || - props.relationshipsEditor === "code") && ( - - )} - -
+ <> + + + + + + {examples.map((example) => ( + { + onExampleChange(example); + }} + > + {example.title} + + ))} + { + datastore.load({ + schema: "definition user {}\n", + relationshipsYaml: "", + assertionsYaml: "", + verificationYaml: "", + }); + datastore.clearBaseline(); + }} + > + Reset to blank + + + + ); } -// NOTE: This is isolated into its own component so that calling setLocalUpdateIndex only calls -// React rerendering on the editor itself, rather than the displays around it as well. -function IsolatedEditorDisplay(props: EditorDisplayProps) { - const [localUpdateIndex, setLocalUpdateIndex] = useState(0); +const DiscardConfirmationDialog = ({ + open, + setOpen, + onConfirm, +}: { + open: boolean; + setOpen: (open: boolean) => void; + onConfirm: () => void; +}) => ( + + + + Discard unsaved changes? + + You have unsaved edits. Loading will replace your current schema, relationships, and + assertions. Consider Share or Download first to keep a copy. + + + + Cancel + Discard and Load + + + +); - const datastoreUpdated = () => { - props.datastoreUpdated(); - setLocalUpdateIndex(localUpdateIndex + 1); - }; +const Toolbar = ({ children }: { children: ReactNode }) => ( +
+ {children} +
+); - return ; -} +const DocLink = ({ title, href }: { title: string; href: string }) => ( + +); diff --git a/src/components/GuidedTour.tsx b/src/components/GuidedTour.tsx deleted file mode 100644 index ad3f4f5..0000000 --- a/src/components/GuidedTour.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { Theme, useTheme } from "@material-ui/core/styles"; -import Joyride, { ACTIONS, EVENTS, type Step } from "react-joyride"; - -export const TourElementClass = { - schema: "tec-schema", - browse: "tec-browse", - testrel: "tec-testrel", - assert: "tec-assert", - run: "tec-run", - problems: "tec-problems", - checkwatch: "tec-checkwatch", - share: "tec-share", -}; - -const steps: Step[] = [ - { - target: `.${TourElementClass.schema}`, - title: "Welcome!", - content: "Begin by editing permission system's schema here...", - disableBeacon: true, - }, - { - target: `.${TourElementClass.browse}`, - title: "Browse", - content: "...or start with a pre-built example", - }, - { - target: `.${TourElementClass.testrel}`, - title: "Test Relationships", - content: "Add test relationships into your database...", - }, - { - target: `.${TourElementClass.assert}`, - title: "Assertions", - content: "...and then define assertions to validate your schema and relationships", - }, - { - target: `.${TourElementClass.run}`, - title: "Validate", - content: "Click Run to test your assertions against your permissions system", - }, - { - target: `.${TourElementClass.problems}`, - title: "Problems", - content: "Any problems in your schema or assertions will appear here", - }, - { - target: `.${TourElementClass.checkwatch}`, - title: "Check Watches", - content: "Permission checks can also be evaluated live by adding them here", - }, - { - target: `.${TourElementClass.share}`, - title: "Share", - content: - "Click Share to get a shareable link. Contact us with any questions as your build out your system!", - }, - { - target: `.${TourElementClass.schema}`, - title: "Start editing!", - content: "You are all ready to start editing your permission schema!", - }, -]; - -const styles = (theme: Theme) => { - return { - options: { - // modal arrow and background color - arrowColor: theme.palette.background.paper, - backgroundColor: theme.palette.background.paper, - // button color - primaryColor: theme.palette.secondary.main, - // text color - textColor: theme.palette.text.primary, - zIndex: 999, - }, - }; -}; - -const handleEvents = ( - onSkip: () => void, - onTourEnd: () => void, - onEnterStep: (className: string) => void, -) => { - return (data: { action: string; index: number; size: number; type: string; step: Step }) => { - const { action, type, step } = data; - - // Tour start - if (ACTIONS.START === action && EVENTS.TOUR_START === type) { - // No-op - } - // Tour finish - if (ACTIONS.NEXT === action && EVENTS.TOUR_END === type) { - onTourEnd(); - } - // Tour before step - if (ACTIONS.NEXT === action && EVENTS.STEP_BEFORE === type) { - if (typeof step.target === "string") { - onEnterStep(step.target); - } - } - // Tour step - if (ACTIONS.UPDATE === action && EVENTS.TOOLTIP === type) { - // No-op - } - // Tour skip - if ((ACTIONS.SKIP === action && EVENTS.TOUR_END === type) || ACTIONS.CLOSE === action) { - onSkip(); - } - }; -}; - -export function GuidedTour(props: { - show: boolean; - onSkip: () => void; - onTourEnd: () => void; - onEnterStep: (className: string) => void; -}) { - const theme = useTheme(); - const { show, onSkip, onTourEnd, onEnterStep } = props; - - return ( - <> - {show && ( - - )} - - ); -} diff --git a/src/components/InlinePlayground.tsx b/src/components/InlinePlayground.tsx index 0f85db8..63fab45 100644 --- a/src/components/InlinePlayground.tsx +++ b/src/components/InlinePlayground.tsx @@ -1,18 +1,14 @@ import { parseSchema } from "@authzed/spicedb-parser-js"; -import AppBar from "@material-ui/core/AppBar"; -import { createStyles, darken, makeStyles, Theme } from "@material-ui/core/styles"; -import Tab from "@material-ui/core/Tab"; -import Tabs from "@material-ui/core/Tabs"; -import BubbleChartIcon from "@material-ui/icons/BubbleChart"; -import clsx from "clsx"; +import { getRouteApi } from "@tanstack/react-router"; import { SquareArrowOutUpRight } from "lucide-react"; -import React, { useState } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; -// TODO: rename +import { ConnectedTabsList, ConnectedTabsTrigger, Tabs } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import TenantGraph from "@/components/visualizer/TenantGraph"; +import { cn } from "@/lib/utils"; -import TabLabel from "../playground-ui/TabLabel"; import { useLiveCheckService } from "../services/check"; import { DataStore, DataStoreItemKind, useReadonlyDatastore } from "../services/datastore"; import { useLocalParseService } from "../services/localparse"; @@ -23,67 +19,44 @@ import { useDeveloperService } from "../spicedb-common/services/developerservice import { DatastoreRelationshipEditor } from "./DatastoreRelationshipEditor"; import { EditorDisplay } from "./EditorDisplay"; -import { AT, ET, NS, VL } from "./KindIcons"; -import { ShareLoader } from "./ShareLoader"; export function InlinePlayground() { const datastore = useReadonlyDatastore(); + return ; +} + +type InlineTab = "schema" | "relationships" | "graph"; + +const INLINE_TAB_BADGES: Record = { + schema: "bg-violet-500", + relationships: "bg-rose-500", + graph: "bg-cyan-500", +}; + +const INLINE_TAB_CODES: Record = { + schema: "S", + relationships: "R", + graph: "V", +}; + +function InlineTabLabel({ tab, label }: { tab: InlineTab; label: string }) { return ( - - - + + + {INLINE_TAB_CODES[tab]} + + {label} + ); } -const useStyles = makeStyles((theme: Theme) => - createStyles({ - tabBar: { - display: "grid", - gridTemplateColumns: "1fr auto auto", - columnGap: theme.spacing(0.25), - }, - buttonContainer: { - padding: theme.spacing(1), - }, - root: { - display: "grid", - gridTemplateRows: "auto 1fr", - height: "100vh", - }, - tabPanel: { - height: "100%", - "& > div": { - height: "100%", - }, - "& > div > div": { - height: "100%", - }, - }, - tabRoot: { - minWidth: "0px", - }, - graphTab: {}, - tenantGraphContainer: { - width: "100%", - height: "98vh", - backgroundColor: theme.palette.background.default, - backgroundSize: "20px 20px", - backgroundImage: ` - linear-gradient(to right, ${darken( - theme.palette.background.default, - 0.1, - )} 1px, transparent 1px), - linear-gradient(to bottom, ${darken( - theme.palette.background.default, - 0.1, - )} 1px, transparent 1px) - `, - }, - }), -); - function InlinePlaygroundUI(props: { datastore: DataStore }) { - const classes = useStyles(); const datastore = props.datastore; const developerService = useDeveloperService(); @@ -91,7 +64,10 @@ function InlinePlaygroundUI(props: { datastore: DataStore }) { const liveCheckService = useLiveCheckService(developerService, datastore); const validationService = useValidationService(developerService, datastore); const problemService = useProblemService(localParseService, liveCheckService, validationService); - const zedTerminalService = undefined; // not used + + const routeApi = getRouteApi("/i/$shareId"); + const shareData = routeApi.useLoaderData(); + const { shareId } = routeApi.useParams(); const services = { localParseService, @@ -99,92 +75,82 @@ function InlinePlaygroundUI(props: { datastore: DataStore }) { validationService, problemService, developerService, - zedTerminalService, + zedTerminalService: undefined, }; - const [disableMouseWheelScrolling, setDisableMouseWheelScrolling] = useState(true); - const [currentTabName, setCurrentTabName] = useState( - datastore.getSingletonByKind(DataStoreItemKind.SCHEMA).id, - ); + // Load the datastore from what's loaded by the route loader + useEffect(() => { + datastore.load({ + schema: shareData.schema || "", + relationshipsYaml: shareData.relationships_yaml || "", + assertionsYaml: shareData.assertions_yaml || "", + verificationYaml: shareData.validation_yaml || "", + }); + datastore.setBaseline("shared", shareId); + if (liveCheckService) { + liveCheckService.loadWatches(shareData.check_watches ?? []); + } + }, [shareData, datastore, shareId, liveCheckService]); - const handleChangeTab = (_event: React.ChangeEvent, selectedTabName: string) => { - setCurrentTabName(selectedTabName); - }; + const [disableMouseWheelScrolling, setDisableMouseWheelScrolling] = useState(true); + const [tab, setTab] = useState("schema"); - const currentItem = datastore.getById(currentTabName); - const parsedSchema = parseSchema( - datastore.getSingletonByKind(DataStoreItemKind.SCHEMA).editableContents!, - ); + const schemaItem = datastore.getSingletonByKind(DataStoreItemKind.SCHEMA); + const parsedSchema = parseSchema(schemaItem.editableContents!); const relationships = parseRelationships( datastore.getSingletonByKind(DataStoreItemKind.RELATIONSHIPS).editableContents!, ); - const [resizeIndex, setResizeIndex] = useState(0); - - React.useEffect(() => { - const handler = () => { - // Force a rerender - setResizeIndex(resizeIndex + 1); - }; - - window.addEventListener("resize", handler); - return () => { - window.removeEventListener("resize", handler); - }; - }, [resizeIndex, setResizeIndex]); return ( -
setDisableMouseWheelScrolling(false)} className={clsx(classes.root)}> - - - } title="Schema" />} - /> - } title="Test Data" />} - /> - } title="Graph" />} - /> - } title="Assert" />} - /> - } title="Expected" />} - /> - -
- -
-
- - {currentTabName === "$graph" && ( -
- -
+
setDisableMouseWheelScrolling(false)} + className="grid h-screen grid-rows-[auto_1fr] bg-background" + > + setTab(value as InlineTab)}> + + + + + + + + + + + +
+ + + + + Open in Playground + +
+
+
+ + {tab === "schema" && ( + null} + disableMouseWheelScrolling={disableMouseWheelScrolling} + defaultWidth="100vw" + defaultHeight="100%" + /> )} - {currentItem?.kind === DataStoreItemKind.RELATIONSHIPS && ( + {tab === "relationships" && ( )} - {currentItem !== undefined && currentItem.kind !== DataStoreItemKind.RELATIONSHIPS && ( - null} - disableMouseWheelScrolling={disableMouseWheelScrolling} - defaultWidth="100vw" - defaultHeight="100%" - /> + {tab === "graph" && ( +
+ +
)}
); diff --git a/src/components/KindIcons.tsx b/src/components/KindIcons.tsx index 67410be..e74578f 100644 --- a/src/components/KindIcons.tsx +++ b/src/components/KindIcons.tsx @@ -1,66 +1,42 @@ -import { createStyles, makeStyles } from "@material-ui/core/styles"; import { Database, TriangleAlert } from "lucide-react"; -import "react-reflex/styles.css"; + +import { cn } from "@/lib/utils"; interface StyleProps { small?: boolean; } -const useStyles = makeStyles(() => - createStyles({ - ns: { - fontFamily: "Roboto Mono, monospace", - color: "#8787ff", - fontSize: (props: StyleProps) => (props.small ? "85%" : "125%"), - fontWeight: "bold", - }, - vl: { - fontFamily: "Roboto Mono, monospace", - color: "#87deff", - fontSize: (props: StyleProps) => (props.small ? "85%" : "125%"), - fontWeight: "bold", - }, - at: { - fontFamily: "Roboto Mono, monospace", - "& svg": { - color: "orange", - fontSize: (props: StyleProps) => (props.small ? "85%" : "125%"), - }, - fontWeight: "bold", - }, - et: { - fontFamily: "Roboto Mono, monospace", - color: "#3dc9b0", - fontSize: (props: StyleProps) => (props.small ? "85%" : "125%"), - fontWeight: "bold", - }, - }), -); +const baseClass = "font-mono font-bold"; +const sizeClass = (small?: boolean) => (small ? "text-[85%]" : "text-[125%]"); -export function NS(props: { small?: boolean }) { - const classes = useStyles(props); - return DEF; +export function NS(props: StyleProps) { + return ( + + DEF + + ); } -export function VL(props: { small?: boolean }) { - const classes = useStyles(props); +export function VL(props: StyleProps) { return ( - + ); } -export function AT(props: { small?: boolean }) { - const classes = useStyles(props); +export function AT(props: StyleProps) { return ( - + ); } -export function ET(props: { small?: boolean }) { - const classes = useStyles(props); - return []; +export function ET(props: StyleProps) { + return ( + + [] + + ); } diff --git a/src/components/Logos.tsx b/src/components/Logos.tsx index 78fc0de..8844615 100644 --- a/src/components/Logos.tsx +++ b/src/components/Logos.tsx @@ -1,4 +1,4 @@ -import useMediaQuery from "@material-ui/core/useMediaQuery"; +import { useMediaQuery } from "@/hooks/use-media-query"; import AUTHZED_DM_SMALL_LOGO from "../assets/favicon-dark-mode.svg?react"; import AUTHZED_SMALL_LOGO from "../assets/favicon.svg?react"; diff --git a/src/components/ReadOnlyRelationshipsGrid.tsx b/src/components/ReadOnlyRelationshipsGrid.tsx index 2ad77a9..92166d9 100644 --- a/src/components/ReadOnlyRelationshipsGrid.tsx +++ b/src/components/ReadOnlyRelationshipsGrid.tsx @@ -1,72 +1,55 @@ -import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; -import Table from "@material-ui/core/Table"; -import TableBody from "@material-ui/core/TableBody"; -import TableCell from "@material-ui/core/TableCell"; -import TableHead from "@material-ui/core/TableHead"; -import TableRow from "@material-ui/core/TableRow"; - +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import { RelationTuple as Relationship } from "@/spicedb-common/protodefs/core/v1/core_pb"; -const useStyles = makeStyles((theme: Theme) => - createStyles({ - table: { - backgroundColor: theme.palette.background.default, - }, - def: { - color: "#8787ff", - }, - rel: { - color: "#ffa887", - }, - cell: { - fontWeight: "bold", - width: "16%", - }, - }), -); - export function ReadOnlyRelationshipsGrid(props: { relationships: Relationship[]; hideSubjectRelation?: boolean; }) { - const classes = useStyles(); - return ( - - - - Object Type - Object ID - Relation - Subject Type - Subject ID - {props.hideSubjectRelation !== true && ( - Subject Relation - )} - - - - {props.relationships.map((relationship, index) => { - return ( - // NOTE: an index is appropriate here because a user could theoretically - // write a duplicate relationship, and the position makes some sense as a key - - - {relationship.resourceAndRelation?.namespace} - - {relationship.resourceAndRelation?.objectId} - - {relationship.resourceAndRelation?.relation} - - {relationship.subject?.namespace} - {relationship.subject?.objectId} - {props.hideSubjectRelation !== true && ( - {relationship.subject?.relation} - )} - - ); - })} - -
+
+ + + + Object Type + Object ID + Relation + Subject Type + Subject ID + {props.hideSubjectRelation !== true && ( + Subject Relation + )} + + + + {props.relationships.map((relationship, index) => { + return ( + // NOTE: an index is appropriate here because a user could theoretically + // write a duplicate relationship, and the position makes some sense as a key + + + {relationship.resourceAndRelation?.namespace} + + {relationship.resourceAndRelation?.objectId} + + {relationship.resourceAndRelation?.relation} + + {relationship.subject?.namespace} + {relationship.subject?.objectId} + {props.hideSubjectRelation !== true && ( + {relationship.subject?.relation} + )} + + ); + })} + +
+
); } diff --git a/src/components/SettingsProvider.tsx b/src/components/SettingsProvider.tsx new file mode 100644 index 0000000..2c46019 --- /dev/null +++ b/src/components/SettingsProvider.tsx @@ -0,0 +1,48 @@ +import { createContext, useContext, useEffect, useState } from "react"; + +type SettingsContextValue = { + minimapEnabled: boolean; + setMinimapEnabled: (enabled: boolean) => void; +}; + +const STORAGE_KEY = "playground:settings:minimap"; + +const SettingsContext = createContext(undefined); + +function readInitial(): boolean { + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw === "true") return true; + if (raw === "false") return false; + } catch { + // localStorage unavailable — fall through. + } + return false; +} + +export function SettingsProvider({ children }: { children: React.ReactNode }) { + const [minimapEnabled, setMinimapEnabledState] = useState(() => readInitial()); + + useEffect(() => { + try { + window.localStorage.setItem(STORAGE_KEY, String(minimapEnabled)); + } catch { + // Persisting failed (quota, private mode); state still applies in-session. + } + }, [minimapEnabled]); + + const value: SettingsContextValue = { + minimapEnabled, + setMinimapEnabled: setMinimapEnabledState, + }; + + return {children}; +} + +export function useSettings(): SettingsContextValue { + const ctx = useContext(SettingsContext); + if (ctx === undefined) { + throw new Error("useSettings must be used within a SettingsProvider"); + } + return ctx; +} diff --git a/src/components/ShareLoader.tsx b/src/components/ShareLoader.tsx index cc6fca5..f664d2d 100644 --- a/src/components/ShareLoader.tsx +++ b/src/components/ShareLoader.tsx @@ -1,202 +1,89 @@ -import { useNavigate, useLocation } from "@tanstack/react-router"; -import { CircleX } from "lucide-react"; -import React, { useEffect, useState } from "react"; -import "react-reflex/styles.css"; -import { toast } from "sonner"; - -import { useConfirmDialog } from "../playground-ui/ConfirmDialogProvider"; -import LoadingView from "../playground-ui/LoadingView"; -import AppConfig from "../services/configservice"; -import { DataStore } from "../services/datastore"; - -import { Alert, AlertTitle } from "./ui/alert"; - -enum SharedLoadingStatus { - NOT_CHECKED = -1, - NOT_APPLICABLE = 0, - LOADING = 1, - LOADED = 2, - LOAD_ERROR = 3, - CONFIRMING = 4, -} +import { useNavigate, getRouteApi } from "@tanstack/react-router"; +import { useEffect, useCallback, useState } from "react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useLiveCheckService } from "@/services/check"; +import { usePlaygroundDatastore } from "@/services/datastore"; +import { useDeveloperService } from "@/spicedb-common/services/developerservice"; /** - * ShareLoader is a component which loads the shared data (if any) before rendering - * the child playground. + * ShareLoader is a component which prompts the user around loading the shared data (if any) before redirecting + * to the loaded playground. Only used for full playground. */ -export function ShareLoader(props: { - shareUrlRoot: string; - datastore: DataStore; - children: React.ReactNode; - sharedRequired: boolean; -}) { - const { showConfirm } = useConfirmDialog(); +export function ShareLoader() { const navigate = useNavigate(); - const location = useLocation(); - - const datastore = props.datastore; - const urlPrefix = `/${props.shareUrlRoot}/`; - const [loadingStatus, setLoadingStatus] = useState(SharedLoadingStatus.NOT_CHECKED); - - // Register an effect to load shared data if the URL specifies to do so. - useEffect(() => { - if (loadingStatus !== SharedLoadingStatus.NOT_CHECKED) { - return; + // NOTE: these aren't singletons, which may cause problems? + const datastore = usePlaygroundDatastore(); + const developerService = useDeveloperService(); + const liveCheckService = useLiveCheckService(developerService, datastore); + + const routeApi = getRouteApi("/s/$shareId"); + const shareData = routeApi.useLoaderData(); + const { shareId } = routeApi.useParams(); + + const [replaceDialogOpen, setReplaceDialogOpen] = useState(false); + + const loadDatastore = useCallback(() => { + datastore.load({ + schema: shareData.schema || "", + relationshipsYaml: shareData.relationships_yaml || "", + assertionsYaml: shareData.assertions_yaml || "", + verificationYaml: shareData.validation_yaml || "", + }); + datastore.setBaseline("shared", shareId); + if (liveCheckService) { + liveCheckService.loadWatches(shareData.check_watches ?? []); } + void navigate({ to: "/", replace: true }); + }, [shareData, datastore, shareId, liveCheckService, navigate]); - if (!location.pathname.startsWith(urlPrefix)) { - setLoadingStatus(SharedLoadingStatus.NOT_APPLICABLE); + // On load, check whether the datastore is already populated. + // If it is, open the dialog asking whether the user wants to replace contents; + // if it's not, populate it directly. + useEffect(() => { + // If there isn't shareData to be had at this route, redirect + // TODO: see if this makes sense with the sharedata loader + if (!shareData) { + void navigate({ to: "/", replace: true }); return; } - - if (!AppConfig().shareApiEndpoint) { - setLoadingStatus(SharedLoadingStatus.NOT_APPLICABLE); - return; + if (datastore.isPopulated()) { + setReplaceDialogOpen(true); + } else { + loadDatastore(); } - - setLoadingStatus(SharedLoadingStatus.LOADING); - - // Load the shared data. - void (async () => { - const apiEndpoint = AppConfig().shareApiEndpoint; - if (!apiEndpoint) { - return; - } - - // TODO: use routing for this instead of string manipulation - const pieces = location.pathname.replace(urlPrefix, "").split("/"); - if (pieces.length < 1 && !props.sharedRequired) { - await navigate({ to: "/" }); - return; - } - - const shareReference = pieces[0]; - - try { - const response = await fetch( - `${apiEndpoint}/api/lookupshare?shareid=${encodeURIComponent(shareReference)}`, - ); - - if (!response.ok) { - if (response.status === 404) { - setLoadingStatus(SharedLoadingStatus.LOAD_ERROR); - if (props.sharedRequired) { - return; - } - - toast.error("Shared playground not found", { - description: "The shared playground specified does not exist", - action: { - label: "Okay", - onClick: () => navigate({ to: "/", replace: true }), - }, - }); - return; - } - - const errorData = await response.json().catch(() => ({ error: "Unknown error" })); - setLoadingStatus(SharedLoadingStatus.LOAD_ERROR); - if (props.sharedRequired) { - return; - } - - toast.error("Error loading shared playground", { - description: errorData.error || "Failed to load shared playground", - action: { - label: "Okay", - onClick: () => navigate({ to: "/", replace: true }), - }, - }); - return; - } - - const shareData = await response.json(); - - // Valid reference. - let updateDatastore = true; - if (!props.sharedRequired && datastore.isPopulated()) { - setLoadingStatus(SharedLoadingStatus.CONFIRMING); - const [result] = await showConfirm({ - title: `Replace your existing Playground content?`, - content: `Are you sure you want to replace your existing Playground contents with those from the shared link? They will overwrite your existing contents.`, - buttons: [ - { title: "Keep Existing", value: "nevermind" }, - { - title: `Replace Contents`, - variant: "contained", - color: "secondary", - value: "replace", - }, - ], - }); - updateDatastore = result === "replace"; - } - - if (updateDatastore) { - datastore.load({ - schema: shareData.schema || "", - relationshipsYaml: shareData.relationships_yaml || "", - assertionsYaml: shareData.assertions_yaml || "", - verificationYaml: shareData.validation_yaml || "", - }); - } - - if (!props.sharedRequired) { - // TODO: do this with routing as well - await navigate({ - to: location.pathname.slice(0, urlPrefix.length + shareReference.length), - replace: true, - }); - } - - setLoadingStatus(SharedLoadingStatus.LOADED); - } catch (error: unknown) { - setLoadingStatus(SharedLoadingStatus.LOAD_ERROR); - if (props.sharedRequired) { - return; - } - - toast.error("Error loading shared playground", { - description: error instanceof Error ? error.message : "Failed to load shared playground", - action: { - label: "Okay", - onClick: () => navigate({ to: "/", replace: true }), - }, - }); - return; - } - })(); - }, [ - location.pathname, - loadingStatus, - datastore, - navigate, - showConfirm, - urlPrefix, - props.sharedRequired, - ]); - - if (location.pathname.startsWith(urlPrefix) && loadingStatus !== SharedLoadingStatus.LOADED) { - return ( -
- {loadingStatus === SharedLoadingStatus.NOT_APPLICABLE && ( - - - Could not load shared playground - - )} - {loadingStatus === SharedLoadingStatus.LOADING && ( - - )} - {loadingStatus === SharedLoadingStatus.LOAD_ERROR && ( - - - Could not load shared playground - - )} -
- ); - } - - return
{props.children}
; + }, [loadDatastore, datastore, navigate, shareData]); + + return ( + + + + Replace your existing Playground content? + + Are you sure you want to replace your existing Playground contents with those from the + shared link? They will overwrite your existing contents. + + + + { + void navigate({ to: "/", replace: true }); + }} + > + Keep Existing + + Replace Contents + + + + ); } diff --git a/src/components/ValidationButton.tsx b/src/components/ValidationButton.tsx index 9e8d02f..fb716c2 100644 --- a/src/components/ValidationButton.tsx +++ b/src/components/ValidationButton.tsx @@ -25,7 +25,7 @@ export function ValidateButton({ return (
-
+
{valid && "Validated!"} {invalid && "Failed to Validate"} @@ -39,6 +39,7 @@ export function ValidateButton({ } onClick={conductValidation} variant="outline" + size="sm" > Run diff --git a/src/components/auth-slot.tsx b/src/components/auth-slot.tsx new file mode 100644 index 0000000..74b29cc --- /dev/null +++ b/src/components/auth-slot.tsx @@ -0,0 +1,35 @@ +import { Settings } from "lucide-react"; +import { useState } from "react"; + +import { SettingsDialog } from "@/components/settings-dialog"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +/** + * AuthSlot — top-right slot in the playground header. Currently houses + * the settings cog (which opens the SettingsDialog). The fixed footprint + * (size-9) is preserved so a future authentication affordance can swap + * in without reflow. + */ +export function AuthSlot() { + const [open, setOpen] = useState(false); + + return ( +
+ + + + + Settings + + +
+ ); +} diff --git a/src/components/breadcrumb-pill.tsx b/src/components/breadcrumb-pill.tsx new file mode 100644 index 0000000..22e0a8e --- /dev/null +++ b/src/components/breadcrumb-pill.tsx @@ -0,0 +1,64 @@ +import { ChevronDown, FileText } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +import type { DocumentIdentity } from "../hooks/use-document-identity"; + +interface BreadcrumbPillProps { + identity: DocumentIdentity; + /** Click handler for opening the dropdown. */ + onClick?: () => void; + className?: string; +} + +/** + * BreadcrumbPill — a clickable pill in the top bar that shows the current + * document state (Untitled / Example name / Shared #ref) with an optional + * "modified" dot. Used as a `DropdownMenu`'s trigger. + * + * Wrap it with a DropdownMenu's items at the call site; this component is + * just the visual trigger. + */ +export const BreadcrumbPill = React.forwardRef( + function BreadcrumbPill({ identity, onClick, className, ...rest }, ref) { + const label = labelFor(identity); + const showModifiedDot = identity.kind !== "untitled" && identity.modified; + + return ( + + ); + }, +); + +function labelFor(identity: DocumentIdentity): string { + switch (identity.kind) { + case "untitled": + return "Untitled"; + case "example": + return identity.name; + case "shared": + return `Shared #${identity.reference}`; + } +} diff --git a/src/components/document-link.tsx b/src/components/document-link.tsx new file mode 100644 index 0000000..a853238 --- /dev/null +++ b/src/components/document-link.tsx @@ -0,0 +1,44 @@ +import { cn } from "@/lib/utils"; + +import { useEditorStore } from "./editor-groups/state"; +import type { DocumentRef } from "./editor-groups/types"; + +/** + * A link-styled button that switches the primary editor group's active tab to + * the given document. If the document is in the closed pool, it is opened + * into the primary group first. Used by panels that previously routed via + * URL paths (`/schema`, `/relationships`, …) before tab→URL mapping was + * removed. + */ +export function DocumentLink({ + to, + className, + children, +}: { + to: DocumentRef; + className?: string; + children: React.ReactNode; +}) { + const handleClick = () => { + const s = useEditorStore.getState(); + const groupId = s.layout.kind === "single" ? s.layout.group.id : s.layout.primary.id; + if (s.closedPool.includes(to)) { + s.openInGroup(to, groupId); + } else { + s.setActiveTab(groupId, to); + } + }; + + return ( + + ); +} diff --git a/src/components/drawer/Drawer.tsx b/src/components/drawer/Drawer.tsx new file mode 100644 index 0000000..a56be37 --- /dev/null +++ b/src/components/drawer/Drawer.tsx @@ -0,0 +1,110 @@ +import { X } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +import { maxDrawerHeight, PanelId, useDrawerStore } from "./state"; + +interface DrawerProps { + panels: Record; + className?: string; +} + +const PANEL_LABELS: Record = { + problems: "Problems", + watches: "Check Watches", + terminal: ( + <> + zed terminal + + ), +}; + +export function Drawer({ panels, className }: DrawerProps) { + const open = useDrawerStore((s) => s.open); + const activePanel = useDrawerStore((s) => s.activePanel); + const perPanelHeight = useDrawerStore((s) => s.perPanelHeight); + const closeDrawer = useDrawerStore((s) => s.closeDrawer); + const setHeight = useDrawerStore((s) => s.setHeight); + + const [dragging, setDragging] = React.useState(false); + const [dragStart, setDragStart] = React.useState<{ + y: number; + startHeight: number; + } | null>(null); + + // Re-clamp the drawer height when the viewport changes so a user-shrunk + // window can't leave the drawer covering the whole screen. + const [maxH, setMaxH] = React.useState(() => maxDrawerHeight()); + React.useEffect(() => { + const onResize = () => setMaxH(maxDrawerHeight()); + window.addEventListener("resize", onResize); + return () => window.removeEventListener("resize", onResize); + }, []); + + React.useEffect(() => { + if (!dragging || !dragStart || !activePanel) return; + + const onMove = (e: MouseEvent) => { + const dy = dragStart.y - e.clientY; + setHeight(activePanel, dragStart.startHeight + dy); + }; + + const onUp = () => { + setDragging(false); + setDragStart(null); + }; + + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + return () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + }, [dragging, dragStart, activePanel, setHeight]); + + if (!open || !activePanel) return null; + + const height = Math.min(perPanelHeight[activePanel], maxH); + + const onMouseDown = (e: React.MouseEvent) => { + setDragging(true); + setDragStart({ y: e.clientY, startHeight: height }); + }; + + return ( +
+ {/* Resize handle */} +
+ + {/* Header */} +
+ {PANEL_LABELS[activePanel]} + + + + + Close + +
+ + {/* Active panel */} +
{panels[activePanel]}
+
+ ); +} diff --git a/src/components/drawer/StatusStrip.tsx b/src/components/drawer/StatusStrip.tsx new file mode 100644 index 0000000..9e19f96 --- /dev/null +++ b/src/components/drawer/StatusStrip.tsx @@ -0,0 +1,179 @@ +import { CircleAlert, Eye, Terminal } from "lucide-react"; +import * as React from "react"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +import { LiveCheckItemStatus } from "../../services/check"; +import { Services } from "../../services/services"; + +import { PanelId, useDrawerStore } from "./state"; + +interface StatusStripProps { + services: Services; +} + +export function StatusStrip({ services }: StatusStripProps) { + const open = useDrawerStore((s) => s.open); + const activePanel = useDrawerStore((s) => s.activePanel); + const togglePanel = useDrawerStore((s) => s.togglePanel); + + const errorCount = services.problemService.errorCount; + const warningCount = services.problemService.warnings.length; + const watches = services.liveCheckService.items; + const watchSuccess = watches.filter((i) => i.status === LiveCheckItemStatus.FOUND).length; + const watchFail = watches.filter((i) => i.status === LiveCheckItemStatus.NOT_FOUND).length; + const watchInvalid = watches.filter((i) => i.status === LiveCheckItemStatus.INVALID).length; + const watchUnable = watches.filter((i) => i.status === LiveCheckItemStatus.NOT_VALID).length; + const watchCaveated = watches.filter((i) => i.status === LiveCheckItemStatus.CAVEATED).length; + const watchPending = watches.filter((i) => i.status === LiveCheckItemStatus.NOT_CHECKED).length; + + const isActive = (id: PanelId) => open && activePanel === id; + + return ( +
+ + + togglePanel("problems")} + icon={ + 0 ? "text-destructive" : "text-muted-foreground"} + /> + } + label="Problems" + compact={errorCount === 0 && warningCount === 0} + badges={ + [ + errorCount > 0 && { value: errorCount, variant: "error" as const }, + warningCount > 0 && { value: warningCount, variant: "warning" as const }, + ].filter(Boolean) as Array<{ value: number; variant: "error" | "warning" }> + } + /> + + Problems and validation errors + + + + + togglePanel("watches")} + icon={ + + } + label="Check Watches" + compact={watches.length === 0} + badges={ + [ + watchSuccess > 0 && { value: watchSuccess, variant: "success" as const }, + watchFail > 0 && { value: watchFail, variant: "error" as const }, + watchInvalid > 0 && { value: watchInvalid, variant: "warning" as const }, + watchUnable > 0 && { value: watchUnable, variant: "unable" as const }, + watchCaveated > 0 && { value: watchCaveated, variant: "missing" as const }, + ].filter(Boolean) as Array<{ + value: number; + variant: "success" | "error" | "warning" | "unable" | "missing"; + }> + } + /> + + + {watches.length === 0 ? ( + "Live permission check watches" + ) : watchSuccess === watches.length ? ( + `All ${watches.length} check ${watches.length === 1 ? "watch" : "watches"} passed` + ) : ( +
+ {watchFail > 0 &&
{watchFail} failed
} + {watchInvalid > 0 &&
{watchInvalid} invalid
} + {watchUnable > 0 &&
{watchUnable} unable to run
} + {watchCaveated > 0 &&
{watchCaveated} missing context
} + {watchPending > 0 &&
{watchPending} pending
} +
+ )} +
+
+ +
+ + + + togglePanel("terminal")} + icon={} + label={ + <> + zed terminal + + } + compact={false} + badges={[]} + /> + + zed CLI terminal + +
+ ); +} + +interface PanelButtonProps extends React.ButtonHTMLAttributes { + id: PanelId; + active: boolean; + onClick: () => void; + icon: React.ReactNode; + label: React.ReactNode; + compact: boolean; + badges: Array<{ value: number; variant: "success" | "error" | "warning" | "unable" | "missing" }>; +} + +const PanelButton = React.forwardRef(function PanelButton( + { id: _id, active, onClick, icon, label, compact, badges, className, ...rest }, + ref, +) { + return ( + + ); +}); diff --git a/src/components/drawer/state.ts b/src/components/drawer/state.ts new file mode 100644 index 0000000..3afc58c --- /dev/null +++ b/src/components/drawer/state.ts @@ -0,0 +1,94 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export type PanelId = "problems" | "watches" | "terminal"; + +const STORAGE_KEY = "playground-drawer-state"; +const STATE_VERSION = 2; +const DEFAULT_HEIGHT = 280; +const MIN_HEIGHT = 120; +/** Maximum fraction of the viewport height the drawer is allowed to consume. */ +const MAX_HEIGHT_FRACTION = 0.6; + +/** + * Compute the maximum allowable drawer height based on the current viewport. + * Falls back to a generous default when window is unavailable (SSR / tests). + */ +export function maxDrawerHeight(): number { + if (typeof window === "undefined") return 600; + return Math.max(MIN_HEIGHT, Math.floor(window.innerHeight * MAX_HEIGHT_FRACTION)); +} + +/** Clamp a persisted/incoming height into the allowable range. */ +function clampHeight(h: number): number { + return Math.min(maxDrawerHeight(), Math.max(MIN_HEIGHT, h)); +} + +type DrawerState = { + open: boolean; + activePanel: PanelId | null; + perPanelHeight: Record; +}; + +type DrawerActions = { + openPanel: (panel: PanelId) => void; + togglePanel: (panel: PanelId) => void; + closeDrawer: () => void; + setHeight: (panel: PanelId, height: number) => void; +}; + +const DEFAULT_STATE: DrawerState = { + open: false, + activePanel: null, + perPanelHeight: { + problems: DEFAULT_HEIGHT, + watches: DEFAULT_HEIGHT, + terminal: DEFAULT_HEIGHT, + }, +}; + +export const useDrawerStore = create()( + persist( + (set, get) => ({ + ...DEFAULT_STATE, + + openPanel: (panel) => set({ open: true, activePanel: panel }), + + togglePanel: (panel) => { + const s = get(); + if (s.open && s.activePanel === panel) { + set({ open: false }); + } else { + set({ open: true, activePanel: panel }); + } + }, + + closeDrawer: () => set({ open: false }), + + setHeight: (panel, height) => { + const clamped = clampHeight(height); + set((s) => ({ + ...s, + perPanelHeight: { ...s.perPanelHeight, [panel]: clamped }, + })); + }, + }), + { + name: STORAGE_KEY, + version: STATE_VERSION, + migrate: () => DEFAULT_STATE, + // Clamp persisted heights on rehydrate to recover from any previously + // saved state that exceeds the current viewport. + onRehydrateStorage: () => (state) => { + if (!state) return; + state.perPanelHeight = { + problems: clampHeight(state.perPanelHeight.problems), + watches: clampHeight(state.perPanelHeight.watches), + terminal: clampHeight(state.perPanelHeight.terminal), + }; + }, + }, + ), +); + +export { DEFAULT_HEIGHT, MIN_HEIGHT }; diff --git a/src/components/editor-groups/EditorGroup.tsx b/src/components/editor-groups/EditorGroup.tsx new file mode 100644 index 0000000..dbbda50 --- /dev/null +++ b/src/components/editor-groups/EditorGroup.tsx @@ -0,0 +1,162 @@ +import { X } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +import { EditorTab, type TabDiagnostics } from "./EditorTab"; +import { OpenDocumentMenu } from "./OpenDocumentMenu"; +import { SplitMenu } from "./SplitMenu"; +import { useEditorStore } from "./state"; +import { TabContextMenu } from "./TabContextMenu"; +import { DocumentRef, EditorGroup as EditorGroupType } from "./types"; + +const DRAG_MIME = "application/x-playground-tab"; + +interface EditorGroupProps { + group: EditorGroupType; + /** True when this group can be closed (only true when split). */ + closable: boolean; + /** Render function for the active tab's content. */ + renderContent: (active: DocumentRef) => React.ReactNode; + /** Per-document diagnostic counts; undefined entries mean no badges. */ + tabDiagnostics?: Partial>; + className?: string; +} + +type DropIndicator = { + /** Index in the tab strip where the moving tab will land (0..tabs.length). */ + index: number; + /** Whether the drop is happening from the same group (visual hint only). */ + fromOtherGroup: boolean; +}; + +export function EditorGroup({ + group, + closable, + renderContent, + tabDiagnostics, + className, +}: EditorGroupProps) { + const setActiveTab = useEditorStore((s) => s.setActiveTab); + const closeTab = useEditorStore((s) => s.closeTab); + const moveTab = useEditorStore((s) => s.moveTab); + const closeGroup = useEditorStore((s) => s.closeGroup); + const layout = useEditorStore((s) => s.layout); + + const [dropIndicator, setDropIndicator] = React.useState(null); + + const handleDragStart = (e: React.DragEvent, tab: DocumentRef) => { + e.dataTransfer.setData(DRAG_MIME, tab); + e.dataTransfer.effectAllowed = "move"; + }; + + const isPlaygroundTabDrag = (e: React.DragEvent) => + e.dataTransfer.types.includes(DRAG_MIME); + + const handleTabDragOver = (e: React.DragEvent, overTab: DocumentRef) => { + if (!isPlaygroundTabDrag(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + const rect = e.currentTarget.getBoundingClientRect(); + const before = e.clientX < rect.left + rect.width / 2; + const overIndex = group.tabs.indexOf(overTab); + const targetIndex = before ? overIndex : overIndex + 1; + setDropIndicator({ index: targetIndex, fromOtherGroup: false }); + }; + + const handleStripDragOver = (e: React.DragEvent) => { + if (!isPlaygroundTabDrag(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + // Only set "drop at end" when the cursor isn't over a tab + // (which is handled by handleTabDragOver above). + setDropIndicator((prev) => prev ?? { index: group.tabs.length, fromOtherGroup: false }); + }; + + const handleDragLeave = (e: React.DragEvent) => { + // Only clear when leaving the strip entirely (relatedTarget outside) + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + setDropIndicator(null); + } + }; + + const handleDrop = (e: React.DragEvent) => { + const tab = e.dataTransfer.getData(DRAG_MIME) as DocumentRef; + setDropIndicator(null); + if (!tab) return; + e.preventDefault(); + const targetIndex = dropIndicator?.index ?? group.tabs.length; + const beforeTab = group.tabs[targetIndex]; + moveTab(tab, group.id, beforeTab); + }; + + const canCloseTabs = layout.kind === "split" || group.tabs.length > 1; // can't close the last tab in the only group + + return ( +
+ {/* Tab strip */} +
+ {group.tabs.map((tab, i) => ( + + {dropIndicator?.index === i && } + + setActiveTab(group.id, tab)} + onClose={() => closeTab(tab)} + onDragStart={(e) => handleDragStart(e, tab)} + onDragOver={(e) => handleTabDragOver(e, tab)} + onDrop={handleDrop} + /> + + + ))} + {dropIndicator?.index === group.tabs.length && } +
+ +
+
+ + {closable && ( + + + + + Close group + + )} +
+
+ + {/* Active tab content */} +
+ {renderContent(group.activeTab)} +
+
+ ); +} + +/** + * DropMarker — visual indicator showing where a dragged tab will land. + * Renders inline as a 2px-wide vertical bar in the tab strip. + */ +function DropMarker() { + return ; +} diff --git a/src/components/editor-groups/EditorGroups.tsx b/src/components/editor-groups/EditorGroups.tsx new file mode 100644 index 0000000..4b0cb9f --- /dev/null +++ b/src/components/editor-groups/EditorGroups.tsx @@ -0,0 +1,148 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +import { EditorGroup } from "./EditorGroup"; +import { type TabDiagnostics } from "./EditorTab"; +import { useEditorStore } from "./state"; +import { DocumentRef } from "./types"; + +interface EditorGroupsProps { + /** Render function for an active document's content (one per group). */ + renderContent: (active: DocumentRef) => React.ReactNode; + /** Per-document diagnostic counts, forwarded to every group. */ + tabDiagnostics?: Partial>; + className?: string; +} + +const SUBMINIMUM_WIDTH = 600; // px per group; below this, force collapse to single +const SPLIT_RATIO_STORAGE_KEY = "playground-editor-split-ratio"; +const MIN_RATIO = 0.2; +const MAX_RATIO = 0.8; + +function loadInitialRatio(): number { + if (typeof window === "undefined") return 0.5; + const raw = window.localStorage.getItem(SPLIT_RATIO_STORAGE_KEY); + if (!raw) return 0.5; + const parsed = parseFloat(raw); + if (Number.isNaN(parsed)) return 0.5; + return Math.max(MIN_RATIO, Math.min(MAX_RATIO, parsed)); +} + +export function EditorGroups({ renderContent, tabDiagnostics, className }: EditorGroupsProps) { + const layout = useEditorStore((s) => s.layout); + const closeGroup = useEditorStore((s) => s.closeGroup); + + const containerRef = React.useRef(null); + const [containerWidth, setContainerWidth] = React.useState(null); + const [primaryRatio, setPrimaryRatio] = React.useState(() => loadInitialRatio()); + + React.useEffect(() => { + if (!containerRef.current) return; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) setContainerWidth(entry.contentRect.width); + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + // Persist ratio to localStorage when it changes. + React.useEffect(() => { + try { + window.localStorage.setItem(SPLIT_RATIO_STORAGE_KEY, String(primaryRatio)); + } catch { + // Ignore storage failures (private mode, quota, etc). + } + }, [primaryRatio]); + + // Sub-minimum width collapse: force single group if width per group would be too small. + React.useEffect(() => { + if ( + layout.kind === "split" && + layout.direction === "horizontal" && + containerWidth !== null && + containerWidth / 2 < SUBMINIMUM_WIDTH + ) { + closeGroup(layout.secondary.id); + } + }, [layout, containerWidth, closeGroup]); + + const startDrag = React.useCallback( + (e: React.MouseEvent, direction: "horizontal" | "vertical") => { + e.preventDefault(); + + const onMove = (moveEvent: MouseEvent) => { + if (!containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + let ratio: number; + if (direction === "horizontal") { + ratio = (moveEvent.clientX - rect.left) / rect.width; + } else { + ratio = (moveEvent.clientY - rect.top) / rect.height; + } + setPrimaryRatio(Math.max(MIN_RATIO, Math.min(MAX_RATIO, ratio))); + }; + + const onUp = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + }; + + document.body.style.cursor = direction === "horizontal" ? "col-resize" : "row-resize"; + document.body.style.userSelect = "none"; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }, + [], + ); + + if (layout.kind === "single") { + return ( +
+ +
+ ); + } + + // Split layout + const direction = layout.direction; + const flexDirection = direction === "horizontal" ? "flex-row" : "flex-col"; + + return ( +
+
+ +
+
startDrag(e, direction)} + role="separator" + aria-orientation={direction === "horizontal" ? "vertical" : "horizontal"} + /> +
+ +
+
+ ); +} diff --git a/src/components/editor-groups/EditorTab.tsx b/src/components/editor-groups/EditorTab.tsx new file mode 100644 index 0000000..1ea12c4 --- /dev/null +++ b/src/components/editor-groups/EditorTab.tsx @@ -0,0 +1,145 @@ +import { X } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +import { DocumentRef } from "./types"; + +interface EditorTabProps { + document: DocumentRef; + active: boolean; + canClose: boolean; + /** Per-tab diagnostic counts, or undefined if none for this tab. */ + diagnostics?: TabDiagnostics; + /** Called when the tab is clicked (excluding the close button). */ + onClick: () => void; + /** Called when the close × is clicked. */ + onClose: () => void; + /** Drag-and-drop callbacks. */ + onDragStart: (e: React.DragEvent) => void; + onDragOver: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent) => void; +} + +export interface TabDiagnostics { + errors: number; + warnings: number; +} + +export const DOCUMENT_LABELS: Record = { + schema: "Schema", + relationships: "Relationships", + assertions: "Assertions", + expected: "Expected Relations", + visualizer: "Visualizer", +}; + +export const DOCUMENT_BADGE_COLORS: Record = { + schema: "bg-violet-500", + relationships: "bg-rose-500", + assertions: "bg-amber-500", + expected: "bg-emerald-500", + visualizer: "bg-cyan-500", +}; + +export const DOCUMENT_BADGE_CODES: Record = { + schema: "S", + relationships: "R", + assertions: "A", + expected: "E", + visualizer: "V", +}; + +type EditorTabRootProps = Omit< + React.HTMLAttributes, + "onClick" | "onDragStart" | "onDragOver" | "onDrop" | "onCopy" +>; + +export const EditorTab = React.forwardRef( + function EditorTab( + { + document, + active, + canClose, + diagnostics, + onClick, + onClose, + onDragStart, + onDragOver, + onDrop, + className, + ...rest + }, + ref, + ) { + return ( +
+ + {DOCUMENT_BADGE_CODES[document]} + + {DOCUMENT_LABELS[document]} + {diagnostics && diagnostics.errors > 0 && ( + + {diagnostics.errors} + + )} + {diagnostics && diagnostics.warnings > 0 && ( + + {diagnostics.warnings} + + )} + {canClose && ( + + + + + Close tab + + )} +
+ ); + }, +); diff --git a/src/components/editor-groups/OpenDocumentMenu.tsx b/src/components/editor-groups/OpenDocumentMenu.tsx new file mode 100644 index 0000000..cf8543c --- /dev/null +++ b/src/components/editor-groups/OpenDocumentMenu.tsx @@ -0,0 +1,56 @@ +import { Plus } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +import { DOCUMENT_BADGE_CODES, DOCUMENT_BADGE_COLORS, DOCUMENT_LABELS } from "./EditorTab"; +import { useEditorStore } from "./state"; + +interface OpenDocumentMenuProps { + groupId: string; +} + +export function OpenDocumentMenu({ groupId }: OpenDocumentMenuProps) { + const closedPool = useEditorStore((s) => s.closedPool); + const openInGroup = useEditorStore((s) => s.openInGroup); + + if (closedPool.length === 0) return null; + + return ( + + + + + + + + Open document + + + {closedPool.map((doc) => ( + openInGroup(doc, groupId)}> + + {DOCUMENT_BADGE_CODES[doc]} + + {DOCUMENT_LABELS[doc]} + + ))} + + + ); +} diff --git a/src/components/editor-groups/SplitMenu.tsx b/src/components/editor-groups/SplitMenu.tsx new file mode 100644 index 0000000..4bb3767 --- /dev/null +++ b/src/components/editor-groups/SplitMenu.tsx @@ -0,0 +1,55 @@ +import { SplitSquareHorizontal, SplitSquareVertical } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +import { useEditorStore } from "./state"; +import { DocumentRef } from "./types"; + +interface SplitMenuProps { + /** The tab whose Split action this menu controls. */ + tab: DocumentRef; +} + +export function SplitMenu({ tab }: SplitMenuProps) { + const splitTab = useEditorStore((s) => s.splitTab); + const layout = useEditorStore((s) => s.layout); + + // Hide the menu when already split (1 split max). + if (layout.kind === "split") return null; + // Splitting requires at least 2 tabs (we move one tab out into a new group), + // so hide the affordance when there's only one tab to avoid suggesting an + // action that would no-op. + if (layout.kind === "single" && layout.group.tabs.length <= 1) return null; + + return ( + + + + + + + + Split editor + + + splitTab(tab, "horizontal")}> + + Open to right + + splitTab(tab, "vertical")}> + + Open to bottom + + + + ); +} diff --git a/src/components/editor-groups/TabContextMenu.tsx b/src/components/editor-groups/TabContextMenu.tsx new file mode 100644 index 0000000..9ce2d52 --- /dev/null +++ b/src/components/editor-groups/TabContextMenu.tsx @@ -0,0 +1,96 @@ +import { SplitSquareHorizontal, SplitSquareVertical, X } from "lucide-react"; +import * as React from "react"; + +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; + +import { useEditorStore } from "./state"; +import { DocumentRef, EditorLayout } from "./types"; + +interface TabContextMenuProps { + tab: DocumentRef; + /** Group currently hosting this tab. */ + groupId: string; + /** True when this tab can be closed (only false for the last tab in the only group). */ + canClose: boolean; + children: React.ReactNode; +} + +/** + * Right-click menu for a tab. Wraps the tab element and offers move/split/close + * actions appropriate to the current layout. + */ +export function TabContextMenu({ tab, groupId, canClose, children }: TabContextMenuProps) { + const layout = useEditorStore((s) => s.layout); + const splitTab = useEditorStore((s) => s.splitTab); + const moveTab = useEditorStore((s) => s.moveTab); + const closeTab = useEditorStore((s) => s.closeTab); + + const moveTarget = getMoveTarget(layout, groupId); + const canSplit = layout.kind === "single" && layout.group.tabs.length > 1; + + const hasItems = canSplit || moveTarget !== undefined || canClose; + if (!hasItems) { + return <>{children}; + } + + return ( + + {children} + + {canSplit && ( + <> + splitTab(tab, "horizontal")}> + + Open to right + + splitTab(tab, "vertical")}> + + Open to bottom + + + )} + {moveTarget && ( + moveTab(tab, moveTarget.groupId)}> + Move to {moveTarget.side} + + )} + {canClose && (canSplit || moveTarget) && } + {canClose && ( + closeTab(tab)}> + + Close + + )} + + + ); +} + +/** + * For a tab hosted in `groupId` of a split layout, return the other group's id + * and a directional label ("left"/"right"/"top"/"bottom") for the user-facing + * "Move to ..." item. Returns undefined for single layouts (no other group). + */ +function getMoveTarget( + layout: EditorLayout, + groupId: string, +): { groupId: string; side: "left" | "right" | "top" | "bottom" } | undefined { + if (layout.kind !== "split") return undefined; + const inPrimary = layout.primary.id === groupId; + const otherId = inPrimary ? layout.secondary.id : layout.primary.id; + const side: "left" | "right" | "top" | "bottom" = + layout.direction === "horizontal" + ? inPrimary + ? "right" + : "left" + : inPrimary + ? "bottom" + : "top"; + return { groupId: otherId, side }; +} diff --git a/src/components/editor-groups/documents/Visualizer.tsx b/src/components/editor-groups/documents/Visualizer.tsx new file mode 100644 index 0000000..40f24e2 --- /dev/null +++ b/src/components/editor-groups/documents/Visualizer.tsx @@ -0,0 +1,31 @@ +import { useMemo } from "react"; + +import TenantGraph from "@/components/visualizer/TenantGraph"; + +import { Services } from "../../../services/services"; +import { ParseRelationshipError } from "../../../spicedb-common/parsing"; +import { RelationTuple } from "../../../spicedb-common/protodefs/core/v1/core_pb"; + +interface VisualizerDocumentProps { + services: Services; +} + +function isRelationship( + relOrError: RelationTuple | ParseRelationshipError, +): relOrError is RelationTuple { + return !("errorMessage" in relOrError); +} + +export function VisualizerDocument({ services }: VisualizerDocumentProps) { + const relationships = useMemo( + () => + services.localParseService.state.relationships.map((r) => r.parsed).filter(isRelationship), + [services.localParseService.state.relationships], + ); + + return ( +
+ +
+ ); +} diff --git a/src/components/editor-groups/state.test.ts b/src/components/editor-groups/state.test.ts new file mode 100644 index 0000000..f6206e5 --- /dev/null +++ b/src/components/editor-groups/state.test.ts @@ -0,0 +1,199 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from "vitest"; + +import { useEditorStore } from "./state"; + +describe("editor groups store", () => { + beforeEach(() => { + useEditorStore.getState().reset(); + localStorage.removeItem("playground-editor-state"); + }); + + it("starts with default state", () => { + const s = useEditorStore.getState(); + expect(s.layout.kind).toBe("single"); + expect(s.closedPool).toEqual(["visualizer"]); + }); + + it("setActiveTab updates the active tab in a group", () => { + useEditorStore.getState().setActiveTab("g1", "relationships"); + const s = useEditorStore.getState(); + expect(s.layout.kind === "single" && s.layout.group.activeTab).toBe("relationships"); + }); + + it("splitTab moves a tab to a new secondary group", () => { + useEditorStore.getState().splitTab("relationships", "horizontal"); + const s = useEditorStore.getState(); + expect(s.layout.kind).toBe("split"); + if (s.layout.kind === "split") { + expect(s.layout.primary.tabs).not.toContain("relationships"); + expect(s.layout.secondary.tabs).toEqual(["relationships"]); + } + }); + + it("splitTab is a no-op when only one tab is open", () => { + useEditorStore.getState().closeTab("relationships"); + useEditorStore.getState().closeTab("assertions"); + useEditorStore.getState().closeTab("expected"); + useEditorStore.getState().splitTab("schema", "horizontal"); + const s = useEditorStore.getState(); + expect(s.layout.kind).toBe("single"); + }); + + it("closeTab cannot close the last tab in the only group", () => { + useEditorStore.getState().closeTab("relationships"); + useEditorStore.getState().closeTab("assertions"); + useEditorStore.getState().closeTab("expected"); + useEditorStore.getState().closeTab("schema"); + const s = useEditorStore.getState(); + expect(s.layout.kind === "single" && s.layout.group.tabs).toEqual(["schema"]); + expect(s.closedPool).not.toContain("schema"); + }); + + it("openInGroup moves a doc from closed pool to a group's tabs", () => { + useEditorStore.getState().openInGroup("visualizer", "g1"); + const s = useEditorStore.getState(); + expect(s.closedPool).not.toContain("visualizer"); + expect(s.layout.kind === "single" && s.layout.group.tabs).toContain("visualizer"); + expect(s.layout.kind === "single" && s.layout.group.activeTab).toBe("visualizer"); + }); + + it("closeGroup merges tabs into the surviving group", () => { + useEditorStore.getState().splitTab("relationships", "horizontal"); + useEditorStore.getState().closeGroup("g2"); + const s = useEditorStore.getState(); + expect(s.layout.kind).toBe("single"); + if (s.layout.kind === "single") { + expect(s.layout.group.tabs).toContain("relationships"); + } + }); + + it("moveTab between groups", () => { + useEditorStore.getState().splitTab("relationships", "horizontal"); + useEditorStore.getState().moveTab("schema", "g2"); + const s = useEditorStore.getState(); + if (s.layout.kind === "split") { + expect(s.layout.primary.tabs).not.toContain("schema"); + expect(s.layout.secondary.tabs).toContain("schema"); + } + }); + + it("moveTab collapses to single when source group's last tab is dragged out", () => { + useEditorStore.getState().splitTab("relationships", "horizontal"); + useEditorStore.getState().moveTab("relationships", "g1"); + const s = useEditorStore.getState(); + expect(s.layout.kind).toBe("single"); + if (s.layout.kind === "single") { + expect(s.layout.group.tabs).toContain("relationships"); + } + }); + + it("moveTab from secondary to primary keeps the moved tab visible after collapse", () => { + useEditorStore.getState().splitTab("relationships", "horizontal"); + // drop "relationships" before "schema" in primary + useEditorStore.getState().moveTab("relationships", "g1", "schema"); + const s = useEditorStore.getState(); + expect(s.layout.kind).toBe("single"); + if (s.layout.kind === "single") { + expect(s.layout.group.tabs).toContain("relationships"); + expect(s.layout.group.activeTab).toBe("relationships"); + } + expect(s.closedPool).not.toContain("relationships"); + }); + + it("moveTab to a non-existent group recovers the orphaned tab into closedPool", () => { + // After a move-and-collapse, the surviving group is always "g1". A + // bubbled-up duplicate drop event can target the now-defunct "g2", which + // would otherwise leave the tab in neither a group nor closedPool. The + // reconciler must recover it so the user can reopen it from the menu. + useEditorStore.getState().splitTab("relationships", "horizontal"); + useEditorStore.getState().moveTab("relationships", "g1"); + expect(useEditorStore.getState().layout.kind).toBe("single"); + + useEditorStore.getState().moveTab("relationships", "g2"); + const s = useEditorStore.getState(); + const inGroup = s.layout.kind === "single" && s.layout.group.tabs.includes("relationships"); + const inClosedPool = s.closedPool.includes("relationships"); + expect(inGroup || inClosedPool).toBe(true); + }); + + it("showDocument opens a closed-pool doc into a new horizontal secondary group", () => { + useEditorStore.getState().showDocument("visualizer"); + const s = useEditorStore.getState(); + expect(s.closedPool).not.toContain("visualizer"); + expect(s.layout.kind).toBe("split"); + if (s.layout.kind === "split") { + expect(s.layout.direction).toBe("horizontal"); + expect(s.layout.primary.tabs).toEqual(["schema", "relationships", "assertions", "expected"]); + expect(s.layout.secondary.tabs).toEqual(["visualizer"]); + expect(s.layout.secondary.activeTab).toBe("visualizer"); + } + }); + + it("showDocument opens a closed-pool doc into the existing secondary group", () => { + useEditorStore.getState().splitTab("relationships", "horizontal"); + useEditorStore.getState().showDocument("visualizer"); + const s = useEditorStore.getState(); + expect(s.closedPool).not.toContain("visualizer"); + if (s.layout.kind === "split") { + expect(s.layout.secondary.tabs).toContain("visualizer"); + expect(s.layout.secondary.activeTab).toBe("visualizer"); + expect(s.layout.primary.tabs).not.toContain("visualizer"); + } + }); + + it("showDocument activates the doc in place when it's already in a group", () => { + useEditorStore.getState().openInGroup("visualizer", "g1"); + useEditorStore.getState().setActiveTab("g1", "schema"); + useEditorStore.getState().showDocument("visualizer"); + const s = useEditorStore.getState(); + expect(s.layout.kind).toBe("single"); + if (s.layout.kind === "single") { + expect(s.layout.group.activeTab).toBe("visualizer"); + } + }); + + it("reconcileTabs recovers orphaned tabs that are missing from groups and closedPool", () => { + // Construct a corrupt state directly: a tab that is in neither a group + // nor the closedPool. The next mutation must reconcile it back. + useEditorStore.setState({ + layout: { + kind: "single", + group: { id: "g1", tabs: ["schema"], activeTab: "schema" }, + }, + closedPool: [], + }); + // Trigger any action — setActiveTab is a no-op-ish action that still runs + // through the reconciler. + useEditorStore.getState().setActiveTab("g1", "schema"); + const s = useEditorStore.getState(); + expect(s.closedPool).toContain("relationships"); + expect(s.closedPool).toContain("assertions"); + expect(s.closedPool).toContain("expected"); + expect(s.closedPool).toContain("visualizer"); + }); + + it("moveTab from primary's only tab to secondary keeps the moved tab visible", () => { + // Set up: g1 = [schema], g2 = [relationships, assertions, expected] + useEditorStore.getState().splitTab("schema", "horizontal"); + // After splitTab("schema", ...), primary holds the rest, secondary holds schema. + // Move two more tabs to secondary so primary ends up with one tab. + useEditorStore.getState().moveTab("assertions", "g2"); + useEditorStore.getState().moveTab("expected", "g2"); + // Now primary should have only [relationships], secondary has [schema, assertions, expected] + const before = useEditorStore.getState(); + expect(before.layout.kind).toBe("split"); + if (before.layout.kind === "split") { + expect(before.layout.primary.tabs).toEqual(["relationships"]); + } + + // Drag the last tab from primary into secondary + useEditorStore.getState().moveTab("relationships", "g2"); + const s = useEditorStore.getState(); + expect(s.layout.kind).toBe("single"); + if (s.layout.kind === "single") { + expect(s.layout.group.tabs).toContain("relationships"); + } + expect(s.closedPool).not.toContain("relationships"); + }); +}); diff --git a/src/components/editor-groups/state.ts b/src/components/editor-groups/state.ts new file mode 100644 index 0000000..5ed0c75 --- /dev/null +++ b/src/components/editor-groups/state.ts @@ -0,0 +1,291 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +import { + ALL_DOCUMENT_REFS, + DocumentRef, + EditorGroup, + EditorState, + DEFAULT_EDITOR_STATE, +} from "./types"; + +const STORAGE_KEY = "playground-editor-state"; +const STATE_VERSION = 1; + +type EditorActions = { + /** Switch the active tab in a group. */ + setActiveTab: (groupId: string, tab: DocumentRef) => void; + /** Move a tab to a (possibly new) group, splitting if needed. */ + splitTab: (tab: DocumentRef, direction: "horizontal" | "vertical") => void; + /** Move a tab from one group to another (or reorder within). */ + moveTab: (tab: DocumentRef, targetGroupId: string, beforeTab?: DocumentRef) => void; + /** Close a tab — moves the document to the closed pool. */ + closeTab: (tab: DocumentRef) => void; + /** Open a closed-pool document into a group. */ + openInGroup: (tab: DocumentRef, groupId: string) => void; + /** + * Reveal a document. If it's already in a group, activate it there. Otherwise + * open it into the secondary group, creating one (horizontal split) if needed. + */ + showDocument: (tab: DocumentRef) => void; + /** Close an entire group — merges its tabs into the surviving group. */ + closeGroup: (groupId: string) => void; + /** Reset to default. */ + reset: () => void; +}; + +type EditorStore = EditorState & EditorActions; + +export const useEditorStore = create()( + persist( + (set) => { + // Wrap every mutation in `reconcileTabs` so an action that accidentally + // drops a tab on the floor (no group + not in closedPool) is auto-recovered. + // Without this, a stale-closure double-drop or any future action bug + // could leave a tab unreachable from the UI. + const setReconciled: typeof set = (updater) => + set((s) => + typeof updater === "function" + ? reconcileTabs((updater as (s: EditorStore) => EditorStore)(s)) + : reconcileTabs(updater as EditorStore), + ); + return { + ...DEFAULT_EDITOR_STATE, + + setActiveTab: (groupId, tab) => + setReconciled((s) => updateGroup(s, groupId, (g) => ({ ...g, activeTab: tab }))), + + splitTab: (tab, direction) => + setReconciled((s) => { + if (s.layout.kind === "split") return s; + const sourceGroup = s.layout.group; + if (!sourceGroup.tabs.includes(tab)) return s; + if (sourceGroup.tabs.length === 1) return s; + + const remainingTabs = sourceGroup.tabs.filter((t) => t !== tab); + const newPrimary: EditorGroup = { + ...sourceGroup, + tabs: remainingTabs, + activeTab: sourceGroup.activeTab === tab ? remainingTabs[0] : sourceGroup.activeTab, + }; + const newSecondary: EditorGroup = { + id: "g2", + tabs: [tab], + activeTab: tab, + }; + return { + ...s, + layout: { + kind: "split", + direction, + primary: newPrimary, + secondary: newSecondary, + }, + }; + }), + + moveTab: (tab, targetGroupId, beforeTab) => + setReconciled((s) => { + const removed = removeTabFromAllGroups(s, tab); + const inserted = insertTabIntoGroup(removed, targetGroupId, tab, beforeTab); + return collapseEmptyGroup(inserted); + }), + + closeTab: (tab) => + setReconciled((s) => { + if (s.layout.kind === "single" && s.layout.group.tabs.length === 1) return s; + + const next = collapseEmptyGroup(removeTabFromAllGroups(s, tab)); + + if (!next.closedPool.includes(tab)) { + return { ...next, closedPool: [...next.closedPool, tab] }; + } + return next; + }), + + openInGroup: (tab, groupId) => + setReconciled((s) => { + if (!s.closedPool.includes(tab)) return s; + const next: EditorState = { + ...s, + closedPool: s.closedPool.filter((t) => t !== tab), + }; + return updateGroup(next, groupId, (g) => ({ + ...g, + tabs: [...g.tabs, tab], + activeTab: tab, + })); + }), + + showDocument: (tab) => + setReconciled((s) => { + const hostingGroupId = findGroupContaining(s, tab); + if (hostingGroupId) { + return updateGroup(s, hostingGroupId, (g) => ({ ...g, activeTab: tab })); + } + if (!s.closedPool.includes(tab)) return s; + const withoutTab = s.closedPool.filter((t) => t !== tab); + if (s.layout.kind === "split") { + return updateGroup({ ...s, closedPool: withoutTab }, s.layout.secondary.id, (g) => ({ + ...g, + tabs: [...g.tabs, tab], + activeTab: tab, + })); + } + return { + ...s, + closedPool: withoutTab, + layout: { + kind: "split", + direction: "horizontal", + primary: s.layout.group, + secondary: { id: "g2", tabs: [tab], activeTab: tab }, + }, + }; + }), + + closeGroup: (groupId) => + setReconciled((s) => { + if (s.layout.kind !== "split") return s; + const surviving = + s.layout.primary.id === groupId ? s.layout.secondary : s.layout.primary; + const closing = s.layout.primary.id === groupId ? s.layout.primary : s.layout.secondary; + const newTabs = [ + ...surviving.tabs, + ...closing.tabs.filter((t) => !surviving.tabs.includes(t)), + ]; + return { + ...s, + layout: { + kind: "single", + group: { ...surviving, id: "g1", tabs: newTabs }, + }, + }; + }), + + reset: () => set(DEFAULT_EDITOR_STATE), + }; + }, + { + name: STORAGE_KEY, + version: STATE_VERSION, + migrate: () => DEFAULT_EDITOR_STATE, + }, + ), +); + +function findGroupContaining(state: EditorState, tab: DocumentRef): string | undefined { + if (state.layout.kind === "single") { + return state.layout.group.tabs.includes(tab) ? state.layout.group.id : undefined; + } + if (state.layout.primary.tabs.includes(tab)) return state.layout.primary.id; + if (state.layout.secondary.tabs.includes(tab)) return state.layout.secondary.id; + return undefined; +} + +function updateGroup( + state: EditorState, + groupId: string, + updater: (g: EditorGroup) => EditorGroup, +): EditorState { + if (state.layout.kind === "single") { + if (state.layout.group.id !== groupId) return state; + return { ...state, layout: { ...state.layout, group: updater(state.layout.group) } }; + } + if (state.layout.primary.id === groupId) { + return { + ...state, + layout: { ...state.layout, primary: updater(state.layout.primary) }, + }; + } + if (state.layout.secondary.id === groupId) { + return { + ...state, + layout: { ...state.layout, secondary: updater(state.layout.secondary) }, + }; + } + return state; +} + +/** + * Validate that every known DocumentRef is reachable: present in some group's + * tabs OR in the closedPool. Any tab missing from both is "orphaned" — it has + * no UI affordance to bring it back. Recover by appending orphans to closedPool + * so the user can reopen them from the open-document menu. + * + * Why: actions that mutate tabs piecewise (moveTab, etc.) can leave tabs in a + * half-state if they're called with stale args (e.g. a stale-closure duplicate + * drop targeting a now-collapsed group id). Reconciling after every mutation + * makes the orphan state unrepresentable in persisted state. + */ +function reconcileTabs(state: EditorState): EditorState { + const present = new Set(state.closedPool); + if (state.layout.kind === "single") { + state.layout.group.tabs.forEach((t) => present.add(t)); + } else { + state.layout.primary.tabs.forEach((t) => present.add(t)); + state.layout.secondary.tabs.forEach((t) => present.add(t)); + } + const orphans = ALL_DOCUMENT_REFS.filter((d) => !present.has(d)); + if (orphans.length === 0) return state; + return { ...state, closedPool: [...state.closedPool, ...orphans] }; +} + +/** + * Collapse a split layout to single when either group has no tabs, so we never + * leave an empty tab strip behind after a move or close. + */ +function collapseEmptyGroup(state: EditorState): EditorState { + if (state.layout.kind !== "split") return state; + if (state.layout.primary.tabs.length === 0) { + return { + ...state, + layout: { kind: "single", group: { ...state.layout.secondary, id: "g1" } }, + }; + } + if (state.layout.secondary.tabs.length === 0) { + return { + ...state, + layout: { kind: "single", group: { ...state.layout.primary, id: "g1" } }, + }; + } + return state; +} + +function removeTabFromAllGroups(state: EditorState, tab: DocumentRef): EditorState { + const removeFromGroup = (g: EditorGroup): EditorGroup => { + if (!g.tabs.includes(tab)) return g; + const newTabs = g.tabs.filter((t) => t !== tab); + const newActive = g.activeTab === tab ? (newTabs[0] ?? g.activeTab) : g.activeTab; + return { ...g, tabs: newTabs, activeTab: newActive }; + }; + + if (state.layout.kind === "single") { + return { ...state, layout: { ...state.layout, group: removeFromGroup(state.layout.group) } }; + } + return { + ...state, + layout: { + ...state.layout, + primary: removeFromGroup(state.layout.primary), + secondary: removeFromGroup(state.layout.secondary), + }, + }; +} + +function insertTabIntoGroup( + state: EditorState, + targetGroupId: string, + tab: DocumentRef, + beforeTab?: DocumentRef, +): EditorState { + return updateGroup(state, targetGroupId, (g) => { + const without = g.tabs.filter((t) => t !== tab); + if (beforeTab && without.includes(beforeTab)) { + const idx = without.indexOf(beforeTab); + const newTabs = [...without.slice(0, idx), tab, ...without.slice(idx)]; + return { ...g, tabs: newTabs, activeTab: tab }; + } + return { ...g, tabs: [...without, tab], activeTab: tab }; + }); +} diff --git a/src/components/editor-groups/types.ts b/src/components/editor-groups/types.ts new file mode 100644 index 0000000..14603da --- /dev/null +++ b/src/components/editor-groups/types.ts @@ -0,0 +1,54 @@ +/** + * DocumentRef identifies a document that can be opened in an editor group. + * In Spec 2, documents are the four singletons plus Visualizer. + * Multi-file later: DocumentRef becomes a sum type with a 'file' variant. + */ +export type DocumentRef = "schema" | "relationships" | "assertions" | "expected" | "visualizer"; + +export const ALL_DOCUMENT_REFS: DocumentRef[] = [ + "schema", + "relationships", + "assertions", + "expected", + "visualizer", +]; + +export type EditorGroup = { + /** Stable id for persistence and React keying. */ + id: string; + /** Documents currently in this group, ordered for tab display. */ + tabs: DocumentRef[]; + /** Active document; must be in `tabs`. */ + activeTab: DocumentRef; +}; + +export type EditorLayout = + | { kind: "single"; group: EditorGroup } + | { + kind: "split"; + direction: "horizontal" | "vertical"; + primary: EditorGroup; + secondary: EditorGroup; + }; + +/** Documents not currently in any group. */ +export type ClosedPool = DocumentRef[]; + +/** Complete editor area state. */ +export type EditorState = { + layout: EditorLayout; + closedPool: ClosedPool; +}; + +/** Default state on first load: 1 group with 4 default docs; Visualizer in closed pool. */ +export const DEFAULT_EDITOR_STATE: EditorState = { + layout: { + kind: "single", + group: { + id: "g1", + tabs: ["schema", "relationships", "assertions", "expected"], + activeTab: "schema", + }, + }, + closedPool: ["visualizer"], +}; diff --git a/src/components/panels/base/common.tsx b/src/components/panels/base/common.tsx deleted file mode 100644 index 67e614d..0000000 --- a/src/components/panels/base/common.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; -import { type ReactNode } from "react"; -import "react-reflex/styles.css"; - -import { DataStore } from "../../../services/datastore"; -import { Services } from "../../../services/services"; -import { ReflexedPanelLocation } from "../types"; - -/** - * Panel defines a single panel found in the panel display component. - */ -export interface Panel { - /** - * id is the unique ID for the panel. Must be stable across loads. - */ - id: string; - - /** - * summary is the React tag to render for displaying the summary of the panel. - */ - Summary: (props: PanelSummaryProps) => ReactNode; - - /** - * content is the React tag to render for displaying the contents of the panel. - */ - Content: (props: PanelProps) => ReactNode; -} - -/** - * PanelProps are the props passed to all panels content tags. - */ -export interface PanelProps { - datastore: DataStore; - services: Services; - location: ReflexedPanelLocation; -} - -/** - * PanelSummaryProps are the props passed to all panel summary tags. - */ -export interface PanelSummaryProps { - services: Services; - location: ReflexedPanelLocation; -} - -/** - * useSummaryStyles are common styles used by panel summary components. - */ -export const useSummaryStyles = makeStyles((theme: Theme) => - createStyles({ - throbber: { - color: theme.palette.text.primary, - }, - summaryBar: { - display: "grid", - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1), - columnGap: theme.spacing(1), - alignItems: "center", - }, - badge: { - display: "inline-flex", - padding: theme.spacing(0.5), - borderRadius: "6px", - width: "1.5em", - height: "1.5em", - alignItems: "center", - justifyContent: "center", - fontSize: "85%", - backgroundColor: theme.palette.divider, - color: theme.palette.getContrastText(theme.palette.divider), - }, - successBadge: { - backgroundColor: theme.palette.success.dark, - color: theme.palette.getContrastText(theme.palette.success.dark), - }, - caveatedBadge: { - backgroundColor: "#8787ff", - color: theme.palette.getContrastText("#8787ff"), - }, - invalidBadge: { - backgroundColor: theme.palette.warning.dark, - color: theme.palette.getContrastText(theme.palette.warning.dark), - }, - failBadge: { - backgroundColor: theme.palette.error.dark, - color: theme.palette.getContrastText(theme.palette.error.dark), - }, - warningBadge: { - backgroundColor: theme.palette.warning.dark, - color: theme.palette.getContrastText(theme.palette.warning.dark), - }, - checkTab: { - display: "grid", - alignItems: "center", - }, - checkTabWithItems: { - gridTemplateColumns: "auto auto auto auto auto", - columnGap: "10px", - }, - problemTab: { - display: "grid", - gridTemplateColumns: "auto auto auto", - columnGap: "10px", - alignItems: "center", - }, - }), -); diff --git a/src/components/panels/base/components.tsx b/src/components/panels/base/components.tsx deleted file mode 100644 index 1e54be8..0000000 --- a/src/components/panels/base/components.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { Button, Tooltip, Typography } from "@material-ui/core"; -import AppBar from "@material-ui/core/AppBar"; -import CircularProgress from "@material-ui/core/CircularProgress"; -import IconButton from "@material-ui/core/IconButton"; -import { Theme, createStyles, makeStyles } from "@material-ui/core/styles"; -import Tab from "@material-ui/core/Tab"; -import Tabs from "@material-ui/core/Tabs"; -import Toolbar from "@material-ui/core/Toolbar"; -import CloseIcon from "@material-ui/icons/Close"; -import { useCallback, type ReactNode } from "react"; - -import TabPanel from "../../../playground-ui/TabPanel"; -import { DataStore } from "../../../services/datastore"; -import { Services } from "../../../services/services"; -import { ReflexedPanelLocation } from "../types"; - -import { Panel, useSummaryStyles } from "./common"; -import { LocationData, PanelsCoordinator } from "./coordinator"; - -export const SUMMARY_BAR_HEIGHT = 48; // Pixels - -/** - * PanelSummaryBar is the summary bar displayed when a panel display is closed. - */ -export function PanelSummaryBar(props: { - location: ReflexedPanelLocation; - coordinator: PanelsCoordinator; - services: Services; - disabled?: boolean | undefined; - overrideSummaryDisplay?: ReactNode; -}) { - const classes = useSummaryStyles(); - - const coordinator = props.coordinator; - const panels = - props.overrideSummaryDisplay === undefined ? coordinator.panelsInLocation(props.location) : []; - - return ( - - "auto").join(" ")} 1fr auto`, - }} - variant="dense" - > - {props.overrideSummaryDisplay !== undefined && props.overrideSummaryDisplay} - {panels.map((panel: Panel) => { - // NOTE: Using this as a tag here is important for React's state system. Otherwise, - // it'll run hooks outside of the normal flow, which breaks things. - const Summary = panel.Summary; - return ( - - ); - })} - - - {props.services.problemService.isUpdating && ( - - )} - - - - ); -} - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - validationToolBar: { - display: "grid", - gridTemplateColumns: "1fr auto auto", - width: "100%", - padding: 0, - paddingRight: theme.spacing(1), - }, - apiOutput: { - fontFamily: "Roboto Mono, monospace", - padding: theme.spacing(2), - }, - tabContent: { - overflow: "auto", - borderRadius: 0, - height: "100%", - }, - notRun: { - color: theme.palette.grey[500], - }, - link: { - color: theme.palette.text.primary, - }, - validationErrorContainer: { - padding: theme.spacing(1), - marginBottom: theme.spacing(1), - }, - validationErrorContext: { - padding: theme.spacing(1), - backgroundColor: theme.palette.background.default, - }, - noPanels: { - padding: theme.spacing(2), - textAlign: "center", - }, - }), -); - -const TOOLBAR_HEIGHT = 48; // Pixels - -/** - * PanelDisplay displays the panels in a specific location. - */ -export function PanelDisplay(props: { - location: ReflexedPanelLocation; - coordinator: PanelsCoordinator; - datastore: DataStore; - services: Services; - dimensions?: { width: number; height: number }; -}) { - const classes = useStyles(); - const coordinator = props.coordinator; - - const currentTabName = coordinator.getActivePanel(props.location)?.id || ""; - - const handleChangeTab = useCallback( - (_event: object, selectedPanelId: string) => { - coordinator.setActivePanel(selectedPanelId, props.location); - }, - [coordinator, props.location], - ); - - const panels = coordinator.panelsInLocation(props.location); - const adjustedDimensions = props.dimensions - ? { - width: props.dimensions.width, - height: props.dimensions.height - TOOLBAR_HEIGHT, - } - : undefined; - - const contentProps = { - ...props, - dimensions: adjustedDimensions, - }; - - return ( -
- - - - {panels.map(({ id, Summary }: Panel) => { - // NOTE: Using this as a tag here is important for React's state system. Otherwise, - // it'll run hooks outside of the normal flow, which breaks things. - return } />; - })} - - - - {currentTabName && - coordinator.listLocations().map((locData: LocationData) => { - if (locData.location === props.location) { - return
; - } - return ( - - - coordinator.showPanel( - coordinator.getActivePanel(props.location)!, - locData.location, - ) - } - > - {locData.metadata.icon} - - - ); - })} - - - coordinator.closeDisplay(props.location)} - > - - - - - - {panels.map(({ id, Content }: Panel) => { - // NOTE: Using this as a tag here is important for React's state system. Otherwise, - // it'll run hooks outside of the normal flow, which breaks things. - const height = - (props.dimensions?.height ?? 0 >= 48) ? (props.dimensions?.height ?? 0) - 48 : "auto"; - - return ( - - {currentTabName === id && } - - ); - })} - - {panels.length === 0 && ( - - No Panels are attached here - - )} -
- ); -} diff --git a/src/components/panels/base/coordinator.tsx b/src/components/panels/base/coordinator.tsx deleted file mode 100644 index a6dc617..0000000 --- a/src/components/panels/base/coordinator.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import { useState } from "react"; -import z from "zod"; - -import { panelLocations, type ReflexedPanelLocation } from "../types"; - -import { Panel } from "./common"; - -/** - * LocationData is a location and its metadata. - */ -export interface LocationData { - location: ReflexedPanelLocation; - metadata: PanelLocation; -} - -/** - * PanelsCoordinator defines the interface for the panels coordinator. - */ -export interface PanelsCoordinator { - /** - * panelsInLocation returns all panels found in a specific location. - */ - panelsInLocation: (location: ReflexedPanelLocation) => Panel[]; - - /** - * showPanel displays that panel, moving it to the correct location if necessary. - */ - showPanel: (panel: Panel, location: ReflexedPanelLocation) => void; - - /** - * getActivePanel returns the active panel in a specific location. - */ - getActivePanel: (location: ReflexedPanelLocation) => Panel | undefined; - - /** - * setActivePanel sets the active panel for a location. Moves the panel to the location - * if necessary. - */ - setActivePanel: (panelId: string, location: ReflexedPanelLocation) => void; - - /** - * isDisplayVisible returns true if the display for a specific location is currently - * visible. - */ - isDisplayVisible: (location: ReflexedPanelLocation) => boolean; - - /** - * closeDisplay marks a display location as collapsed. - */ - closeDisplay: (location: ReflexedPanelLocation) => void; - - /** - * hasPanels returns true if the specified location has any panels. - */ - hasPanels: (location: ReflexedPanelLocation) => boolean; - - /** - * listLocations lists all the locations defined. - */ - listLocations: () => LocationData[]; -} - -/** - * PanelLocation defines a location for a panel. - */ -export interface PanelLocation { - /** - * title is the human-readable title for the location. - */ - title: string; - - /** - * icon is the icon to render for this location. - */ - icon: React.ReactNode; -} - -export interface PanelCoordinatorProps { - /** - * panels are the panels defined. - */ - panels: Panel[]; - - /** - * locations are the locations defined. - */ - locations: Record; - - /** - * defaultLocation is the location in which to place panels by default. - */ - defaultLocation: ReflexedPanelLocation; - - /** - * autoCloseDisplayWhenEmpty, if true, specifes that a display should be closed - * when it becomes empty. - */ - autoCloseDisplayWhenEmpty?: boolean; -} - -const reflexedPanelLocation = z.union([z.literal("horizontal"), z.literal("vertical")]); - -const CoordinatorState = z.object({ - panelLocations: z.record(z.string(), reflexedPanelLocation), - displaysVisible: z.record(reflexedPanelLocation, z.boolean()), - activeTabs: z.partialRecord(reflexedPanelLocation, z.string()), -}); - -type CoordinatorStateType = z.infer; - -const COORDINATOR_STATE_KEY = "panel-coordinator-state"; - -/** - * usePanelsCoordinator creates a coordinator for user with PanelsDisplay's and PanelsSummaryBar's. - * - * Example: - - ``` - enum SomeLocationEnum { - FIRST = 'first', - SECOND = 'second', - THIRD = 'third', - } - - const panels: Panel[] = [ - { - id: 'foo', - summary: FooSummary, - content: FooPanel, - }, - { - id: 'bar', - summary: BarSummary, - content: BarPanel, - }, - ]; - - const coordinator = usePanelsCoordinator({ - panels: props.panels, - locations: { - [SomeLocationEnum.FIRST]: { - title: 'First', - icon: - }, - [ReflexedPanelLocation.SECOND]: { - title: 'Second', - icon: - }, - [ReflexedPanelLocation.THIRD]: { - title: 'Special Panel', - icon: - } - }, - defaultLocation: SomeLocationEnum.THIRD - }); - - - location={ReflexedPanelLocation.FIRST} - coordinator={coordinator} - services={props.services} - datastore={props.datastore} /> - ``` - */ -export function usePanelsCoordinator(props: PanelCoordinatorProps): PanelsCoordinator { - const [coordinatorState, internalSetCoordinatorState] = useState< - z.infer - >(() => { - // Try to parse from local storage. - try { - const foundState = JSON.parse(localStorage.getItem(COORDINATOR_STATE_KEY) ?? ""); - // This will throw an error if the state can't be parsed properly. - const parsed = CoordinatorState.parse(foundState); - if ( - // Check that the number of panel locations matches the panels we've got - Object.keys(parsed.panelLocations).length === props.panels.length - ) { - return parsed; - } - } catch (e) { - // Do nothing. - console.error(e); - } - - const locations: Record = {}; - props.panels.forEach((panel: Panel) => { - locations[panel.id] = props.defaultLocation; - }); - - return { - panelLocations: locations, - displaysVisible: { - horizontal: false, - vertical: false, - }, - activeTabs: {}, - }; - }); - - const setCoordinatorStateAndSave = (state: CoordinatorStateType) => { - internalSetCoordinatorState(state); - localStorage.setItem(COORDINATOR_STATE_KEY, JSON.stringify(state)); - }; - - const panelsInLocation = (location: ReflexedPanelLocation, state?: CoordinatorStateType) => { - const checkState = state ?? coordinatorState; - return props.panels.filter((panel: Panel) => { - return checkState.panelLocations[panel.id] === location; - }); - }; - - const showPanel = (panel: Panel, location: ReflexedPanelLocation) => { - setActivePanel(panel.id, location); - }; - - const getActivePanel = (location: ReflexedPanelLocation) => { - const allowedPanels = panelsInLocation(location); - return allowedPanels.find((panel: Panel) => panel.id === coordinatorState.activeTabs[location]); - }; - - const setActivePanel = (panelId: string, location: ReflexedPanelLocation) => { - let updatedState = { - ...coordinatorState, - }; - - const existingLocation = coordinatorState.panelLocations[panelId]; - if (existingLocation !== location) { - // Update the active tab for the location. - const active = getActivePanel(existingLocation); - if (active?.id === panelId) { - const panels = panelsInLocation(existingLocation).filter( - (found: Panel) => found.id !== panelId, - ); - if (panels.length) { - updatedState = { - ...updatedState, - activeTabs: { - ...updatedState.activeTabs, - [existingLocation]: panels[0].id, - }, - }; - } else { - updatedState = { - ...updatedState, - activeTabs: { - ...updatedState.activeTabs, - [existingLocation]: undefined, - }, - }; - } - } - - updatedState = { - ...updatedState, - panelLocations: { - ...updatedState.panelLocations, - [panelId]: location, - }, - }; - - // Check for auto-close. - if (props.autoCloseDisplayWhenEmpty === true) { - panelLocations.forEach((location) => { - if (!panelsInLocation(location, updatedState).length) { - updatedState = { - ...updatedState, - displaysVisible: { - ...coordinatorState.displaysVisible, - [location]: false, - }, - }; - } - }); - } - } - - updatedState = { - ...updatedState, - activeTabs: { - ...updatedState.activeTabs, - [location]: panelId, - }, - }; - - if (!isDisplayVisible(location)) { - updatedState = { - ...updatedState, - displaysVisible: { - ...updatedState.displaysVisible, - [location]: true, - }, - }; - } - - setCoordinatorStateAndSave(updatedState); - }; - - const closeDisplay = (location: ReflexedPanelLocation) => { - let updatedState = { - ...coordinatorState, - displaysVisible: { - ...coordinatorState.displaysVisible, - [location]: false, - }, - }; - - const updatedLocations = { ...updatedState.panelLocations }; - Object.keys(coordinatorState.panelLocations).forEach((panelId: string) => { - if (coordinatorState.panelLocations[panelId] === location) { - updatedLocations[panelId] = props.defaultLocation; - } - }); - - updatedState = { - ...updatedState, - panelLocations: updatedLocations, - }; - - setCoordinatorStateAndSave(updatedState); - }; - - const isDisplayVisible = (location: ReflexedPanelLocation) => { - return coordinatorState.displaysVisible[location]; - }; - - const hasPanels = (location: ReflexedPanelLocation) => { - return panelsInLocation(location).length > 0; - }; - - const listLocations = () => { - return panelLocations.map((location) => { - return { - location, - metadata: props.locations[location], - }; - }); - }; - - return { - panelsInLocation, - hasPanels, - showPanel, - getActivePanel, - setActivePanel, - closeDisplay, - isDisplayVisible, - listLocations, - }; -} diff --git a/src/components/panels/base/reflexed.tsx b/src/components/panels/base/reflexed.tsx deleted file mode 100644 index 3027d32..0000000 --- a/src/components/panels/base/reflexed.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import { createStyles, makeStyles } from "@material-ui/core/styles"; -import { SquareSplitHorizontal, SquareSplitVertical } from "lucide-react"; -import { useEffect, useState, Children, isValidElement, cloneElement, type ReactNode } from "react"; -import { HandlerProps, ReflexContainer, ReflexElement, ReflexSplitter } from "react-reflex"; -import "react-reflex/styles.css"; - -import { DataStore } from "../../../services/datastore"; -import { Services } from "../../../services/services"; - -import { Panel } from "./common"; -import { PanelDisplay, PanelSummaryBar, SUMMARY_BAR_HEIGHT } from "./components"; -import { PanelsCoordinator, usePanelsCoordinator } from "./coordinator"; - -const HORIZONTAL_FLEX_KEY = "horizontal-flex"; -const VERTICAL_FLEX_KEY = "vertical-flex"; - -const DEFAULT_VERTICAL_FLEX = 0.3; -const DEFAULT_HORIZONTAL_FLEX = 0; - -const MINIMUM_HORIZONTAL_FLEX = 0.2; -const MINIMUM_VERTICAL_FLEX = 0.2; - -const MINIMUM_HORIZONTAL_SIZE = 200; -const MINIMUM_VERTICAL_SIZE = 200; - -const useStyles = makeStyles(() => - createStyles({ - noOverflow: { - overflow: "hidden !important", - }, - }), -); - -interface PanelDefProps { - panels: Panel[]; - disabled?: boolean | undefined; - overrideSummaryDisplay?: ReactNode; - datastore: DataStore; - services: Services; - children: ReactNode; -} - -interface Dimensions { - width: number; - height: number; -} - -/** - * ReflexedPanelDisplay is a panel display component with two pre-defined panels (horizontal - * and vertical), with the horizonal being collapsed into a summary bar when not visible, and with - * automatic support for user-resizing. - */ -export function ReflexedPanelDisplay(props: PanelDefProps) { - const classes = useStyles(); - - const coordinator = usePanelsCoordinator({ - panels: props.panels, - locations: { - horizontal: { - title: "Bottom", - icon: , - }, - vertical: { - title: "Side", - icon: , - }, - }, - defaultLocation: "horizontal", - autoCloseDisplayWhenEmpty: true, - }); - - const horizontalDisplayVisible = coordinator.isDisplayVisible("horizontal") && !props.disabled; - - const cachedHorizontalFlex = parseFloat( - localStorage.getItem(HORIZONTAL_FLEX_KEY) ?? DEFAULT_HORIZONTAL_FLEX.toString(), - ); - const [horizontalFlex, setHorizontalFlex] = useState( - horizontalDisplayVisible ? Math.max(cachedHorizontalFlex, MINIMUM_HORIZONTAL_FLEX) : 0, - ); - - const handleHorizontalResize = (event: HandlerProps) => { - const { flex } = event.component.props; - if (!flex || flex < MINIMUM_HORIZONTAL_FLEX) { - return; - } - - setHorizontalFlex(flex!); - localStorage.setItem(HORIZONTAL_FLEX_KEY, flex!.toString()); - }; - - useEffect(() => { - if (horizontalDisplayVisible && horizontalFlex < MINIMUM_HORIZONTAL_FLEX) { - setHorizontalFlex(Math.max(cachedHorizontalFlex, MINIMUM_HORIZONTAL_FLEX)); - } else if (!horizontalDisplayVisible && horizontalFlex > 0) { - setHorizontalFlex(0); - } - }, [horizontalFlex, horizontalDisplayVisible, cachedHorizontalFlex]); - - return ( -
- - - - - - {horizontalDisplayVisible && } - - 0 ? MINIMUM_HORIZONTAL_SIZE : undefined} - propagateDimensionsRate={50} - propagateDimensions={true} - onResize={handleHorizontalResize} - > - {horizontalDisplayVisible ? ( - - ) : ( - - )} - - -
- ); -} - -function MainDisplayWithSummaryBar(props: { - coordinator: PanelsCoordinator; - disabled?: boolean | undefined; - children: ReactNode; - dimensions?: Dimensions; - datastore: DataStore; - services: Services; -}) { - const coordinator = props.coordinator; - const displayVisible = coordinator.isDisplayVisible("horizontal") && !props.disabled; - - /* - * NOTE: The comparison to window.innerHeight below ensures that we only render the - * summary display once the height information has been propagated by the parent - * reflex component. Otherwise, it can show an annoying briefly "flash" of the bottom - * summary bar. - */ - const HEIGHT_COMPARISON = 10 + 144; // 144 is the max AppBar height. - const displaySummaryBar = - !displayVisible && - coordinator.hasPanels("horizontal") && - window.innerHeight - (props.dimensions?.height ?? 0) < HEIGHT_COMPARISON; - - return ( -
- - {displaySummaryBar && } -
- ); -} - -type EnrichedChildren = { - children?: React.ReactNode; - dimensions?: Dimensions; -}; - -function MainDisplayAndVertical(props: { - coordinator: PanelsCoordinator; - dimensions?: Dimensions; - children: ReactNode; - datastore: DataStore; - services: Services; -}) { - const classes = useStyles(); - - const coordinator = props.coordinator; - const verticalDisplayVisible = coordinator.isDisplayVisible("vertical"); - const horizontalDisplayVisible = coordinator.isDisplayVisible("horizontal"); - - const cachedVerticalFlex = parseFloat( - localStorage.getItem(VERTICAL_FLEX_KEY) ?? DEFAULT_VERTICAL_FLEX.toString(), - ); - const [verticalFlex, setVerticalFlex] = useState( - verticalDisplayVisible ? Math.max(cachedVerticalFlex, MINIMUM_VERTICAL_FLEX) : 0, - ); - - useEffect(() => { - if (verticalDisplayVisible && verticalFlex < MINIMUM_VERTICAL_FLEX) { - setVerticalFlex(Math.max(cachedVerticalFlex, MINIMUM_VERTICAL_FLEX)); - } else if (!verticalDisplayVisible && verticalFlex > 0) { - setVerticalFlex(0); - } - }, [verticalFlex, verticalDisplayVisible, cachedVerticalFlex]); - - const handleVerticalResize = (event: HandlerProps) => { - const { flex } = event.component.props; - if (!flex || flex < MINIMUM_VERTICAL_FLEX) { - return; - } - - setVerticalFlex(flex!); - localStorage.setItem(VERTICAL_FLEX_KEY, flex!.toString()); - }; - - const contentHeight = - horizontalDisplayVisible || !coordinator.hasPanels("horizontal") - ? (props.dimensions?.height ?? 0) - : (props.dimensions?.height ?? 0) - SUMMARY_BAR_HEIGHT; - const contentDimensions: Dimensions | undefined = props.dimensions - ? { width: props.dimensions.width, height: contentHeight } - : undefined; - - const adjustedChildren = Children.map(props.children, (child) => { - // Based on: https://stackoverflow.com/a/55486160 - if (!isValidElement(child)) { - return child; - } - - const elementChild: React.ReactElement = child; - return cloneElement(elementChild, { dimensions: contentDimensions, ...child.props }, null); - }); - - return ( -
- - - {adjustedChildren} - - - {verticalDisplayVisible && } - - 0 ? MINIMUM_VERTICAL_SIZE : undefined} - propagateDimensionsRate={200} - propagateDimensions={true} - onResize={handleVerticalResize} - > - - - -
- ); -} diff --git a/src/components/panels/errordisplays.tsx b/src/components/panels/errordisplays.tsx index f220811..134bc25 100644 --- a/src/components/panels/errordisplays.tsx +++ b/src/components/panels/errordisplays.tsx @@ -1,14 +1,10 @@ -import { Link } from "@tanstack/react-router"; -import { CircleX, MessageCircleWarning } from "lucide-react"; -import "react-reflex/styles.css"; - -import { DataStoreItemKind, DataStorePaths } from "../../services/datastore"; +import { DataStoreItemKind } from "../../services/datastore"; import { DeveloperError, DeveloperError_Source, DeveloperWarning, } from "../../spicedb-common/protodefs/developer/v1/developer_pb"; -import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { DocumentLink } from "../document-link"; export const ERROR_SOURCE_TO_ITEM = { [DeveloperError_Source.SCHEMA]: DataStoreItemKind.SCHEMA, @@ -19,78 +15,30 @@ export const ERROR_SOURCE_TO_ITEM = { [DeveloperError_Source.UNKNOWN_SOURCE]: undefined, }; -export function DeveloperErrorDisplay({ error }: { error: DeveloperError }) { - return ( - - - {error.message} - {error.path.length > 0 && ( - - Found Via: -
    - {error.path.map((item) => ( - // NOTE: the \2192 here is the → character; tailwind needs it as an escape sequence. -
  • - {item} -
  • - ))} -
-
- )} -
- ); -} - -export function DeveloperWarningDisplay({ warning }: { warning: DeveloperWarning }) { - return ( - - - {warning.message} - - ); -} - -export function DeveloperWarningSourceDisplay({ warning }: { warning: DeveloperWarning }) { - return ( -
- In - Schema - {/* NOTE: this is a guess; I think this was an unintentional omission. */}: {warning.message} -
- ); +/** + * Inline source-location label for a warning. Renders just a short link to + * the document in muted text — no leading "In " preamble. + */ +export function DeveloperWarningSourceDisplay(_props: { warning: DeveloperWarning }) { + return Schema; } +/** + * Inline source-location label for a developer error. Renders just the + * source name (linked) in muted text — no leading "In " preamble. + */ export function DeveloperSourceDisplay({ error }: { error: DeveloperError }) { - // TODO: unify with error source above. - return ( -
- {error.source === DeveloperError_Source.SCHEMA && ( -
- In - Schema: -
- )} - {error.source === DeveloperError_Source.ASSERTION && ( -
- In - Assertions: -
- )} - {error.source === DeveloperError_Source.RELATIONSHIP && ( -
- In - Test Data: -
- )} - {error.source === DeveloperError_Source.VALIDATION_YAML && ( -
- In - Expected Relations: -
- )} -
- ); + if (error.source === DeveloperError_Source.SCHEMA) { + return Schema; + } + if (error.source === DeveloperError_Source.ASSERTION) { + return Assertions; + } + if (error.source === DeveloperError_Source.RELATIONSHIP) { + return Test Data; + } + if (error.source === DeveloperError_Source.VALIDATION_YAML) { + return Expected Relations; + } + return null; } diff --git a/src/components/panels/problems.tsx b/src/components/panels/problems.tsx index 7a76270..08adfe7 100644 --- a/src/components/panels/problems.tsx +++ b/src/components/panels/problems.tsx @@ -1,143 +1,252 @@ -import Paper from "@material-ui/core/Paper"; -import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; -import ErrorOutlineIcon from "@material-ui/icons/ErrorOutline"; -import { Link } from "@tanstack/react-router"; -import clsx from "clsx"; -import "react-reflex/styles.css"; - -import TabLabel from "../../playground-ui/TabLabel"; -import { DataStorePaths } from "../../services/datastore"; +import { ChevronDown, ChevronRight, CircleX, Play, TriangleAlert } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +import { Services } from "../../services/services"; import { DeveloperError, + DeveloperError_Source, DeveloperWarning, } from "../../spicedb-common/protodefs/developer/v1/developer_pb"; -import { TourElementClass } from "../GuidedTour"; +import { DocumentLink } from "../document-link"; -import { PanelProps, PanelSummaryProps, useSummaryStyles } from "./base/common"; -import { - DeveloperErrorDisplay, - DeveloperSourceDisplay, - DeveloperWarningDisplay, - DeveloperWarningSourceDisplay, -} from "./errordisplays"; - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - apiOutput: { - fontFamily: "Roboto Mono, monospace", - padding: theme.spacing(2), - }, - link: { - color: theme.palette.text.primary, - }, - errorContainer: { - padding: theme.spacing(1), - marginBottom: theme.spacing(1), - display: "grid", - gridTemplateRows: "1fr auto", - width: "100%", - columnGap: theme.spacing(2), - }, - validationErrorContext: { - padding: theme.spacing(1), - backgroundColor: theme.palette.background.default, - }, - tupleError: { - padding: theme.spacing(1), - }, - helpButton: {}, - }), -); - -/** - * ProblemsSummary displays a summary of the problems found. - */ -export function ProblemsSummary(props: PanelSummaryProps) { - const classes = useSummaryStyles(); - const errorCount = props.services.problemService.errorCount; - const warningCount = props.services.problemService.warnings.length; +import { DeveloperSourceDisplay, DeveloperWarningSourceDisplay } from "./errordisplays"; + +interface ProblemsPanelProps { + services: Services; +} + +export function ProblemsPanel({ services }: ProblemsPanelProps) { + const requestErrors = services.problemService.requestErrors; + const warnings = services.problemService.warnings; + const invalidRels = services.problemService.invalidRelationships; + const validationErrors = services.problemService.validationErrors; + + const schemaErrors = requestErrors.filter((e) => e.source === DeveloperError_Source.SCHEMA); + const relationshipRequestErrors = requestErrors.filter( + (e) => e.source === DeveloperError_Source.RELATIONSHIP, + ); + const assertionErrors = requestErrors.filter((e) => e.source === DeveloperError_Source.ASSERTION); return ( -
- 0 ? "" : "grey"} - /> - } - title="Problems" - /> - 0, - })} +
+ + {schemaErrors.map((de, i) => ( + + ))} + {warnings.map((dw, i) => ( + + ))} + + + - {errorCount} - - 0, + {invalidRels.map((invalid, i) => { + if (!("errorMessage" in invalid.parsed)) return null; + return ( + + ); })} + {relationshipRequestErrors.map((de, i) => ( + + ))} + + + + {assertionErrors.map((de, i) => ( + + ))} + + + services.validationService.conductValidation(() => false)} + > + Re-run + + } > - {warningCount} - + {validationErrors.map((ve, i) => ( + + ))} +
); } -export function ProblemsPanel({ services }: PanelProps) { - const classes = useStyles(); +function Group({ + title, + errorCount, + warningCount = 0, + action, + children, +}: { + title: string; + errorCount: number; + warningCount?: number; + action?: React.ReactNode; + children: React.ReactNode; +}) { + const total = errorCount + warningCount; + const [expanded, setExpanded] = React.useState(total > 0); + + // Re-expand when count transitions from 0 to >0 so newly-introduced problems + // are not hidden inside a previously-empty collapsed group. + const prevTotalRef = React.useRef(total); + React.useEffect(() => { + if (prevTotalRef.current === 0 && total > 0) { + setExpanded(true); + } + prevTotalRef.current = total; + }, [total]); return ( -
- {!services.problemService.hasProblems && No problems found} - {services.problemService.invalidRelationships.map( - // NOTE: an index is appropriate here because a user could theoretically - // write a duplicate relationship, and the position makes some sense as a key - (invalid, index) => { - if (!("errorMessage" in invalid.parsed)) { - return
; - } +
+
+ +
{action}
+
+ {expanded &&
{children}
} +
+ ); +} - return ( - -
-
- In - - Test Relationships - - : -
-
- Invalid relationship {invalid.text} on line {invalid.lineNumber + 1}:{" "} - {invalid.parsed.errorMessage} -
-
-
- ); - }, +function CountBadge({ value, variant }: { value: number; variant: "error" | "warning" | "empty" }) { + return ( + { - return ( - -
- - -
-
- ); - })} - {services.problemService.warnings.map((dw: DeveloperWarning, index: number) => { - return ( - -
- - -
-
- ); - })} + > + {value} +
+ ); +} + +/** Splits the error message into a short summary (first sentence) and remainder. */ +function splitMessage(message: string): { summary: string; rest: string } { + // Prefer the first ; or . boundary if it appears within a reasonable length. + const trimmed = message.trim(); + const semi = trimmed.indexOf(";"); + const dot = trimmed.search(/\.\s/); + const candidates = [semi, dot].filter((i) => i > 0 && i < 120); + if (candidates.length > 0) { + const cut = Math.min(...candidates); + return { + summary: trimmed.slice(0, cut).trim(), + rest: trimmed.slice(cut + 1).trim(), + }; + } + if (trimmed.length > 100) { + return { summary: trimmed.slice(0, 100).trim() + "…", rest: trimmed }; + } + return { summary: trimmed, rest: "" }; +} + +function ErrorRow({ error }: { error: DeveloperError }) { + const { summary, rest } = splitMessage(error.message); + return ( +
+ +
+
{summary}
+
+ + {(error.line > 0 || error.column > 0) && ( + + :{error.line}:{error.column} + + )} + {(rest || error.context) && ·} + {rest && {rest}} + {error.context && ( + <> + {rest && " "} + {error.context} + + )} +
+
+
+ ); +} + +function WarningRow({ warning }: { warning: DeveloperWarning }) { + const { summary, rest } = splitMessage(warning.message); + return ( +
+ +
+
{summary}
+
+ + {(warning.line > 0 || warning.column > 0) && ( + + :{warning.line}:{warning.column} + + )} + {(rest || warning.sourceCode) && ·} + {rest && {rest}} + {warning.sourceCode && ( + <> + {rest && " "} + {warning.sourceCode} + + )} +
+
+
+ ); +} + +function InvalidRelationshipRow({ + text, + lineNumber, + errorMessage, +}: { + text: string; + lineNumber: number; + errorMessage: string; +}) { + return ( +
+ +
+
{errorMessage}
+
+ Test Relationships + :{lineNumber + 1} + · + {text} +
+
); } diff --git a/src/components/panels/terminal.tsx b/src/components/panels/terminal.tsx index e66e841..181c985 100644 --- a/src/components/panels/terminal.tsx +++ b/src/components/panels/terminal.tsx @@ -1,323 +1,72 @@ -import Input from "@material-ui/core/Input"; -import InputAdornment from "@material-ui/core/InputAdornment"; -import LinearProgress from "@material-ui/core/LinearProgress"; -import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; -import Convert from "ansi-to-html"; -import { Terminal } from "lucide-react"; -import { CircleX, MessageCircleWarning } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; -import type { MouseEvent, KeyboardEvent, ChangeEvent } from "react"; -import "react-reflex/styles.css"; -import useDeepCompareEffect from "use-deep-compare-effect"; +import { CircleX } from "lucide-react"; +import { useEffect } from "react"; -import TabLabel from "../../playground-ui/TabLabel"; -import { DataStoreItemKind } from "../../services/datastore"; -import { mergeRelationshipsStringAndComments } from "../../spicedb-common/parsing"; -import { TerminalSection } from "../../spicedb-common/services/zedterminalservice"; -import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; - -import { PanelProps } from "./base/common"; +import { Alert, AlertTitle } from "@/components/ui/alert"; +import { Progress } from "@/components/ui/progress"; -const useStyles = makeStyles((theme: Theme) => - createStyles({ - terminalOutputDisplay: { - fontFamily: "Roboto Mono, monospace", - overflowY: "auto", - }, - terminalOutput: { - padding: theme.spacing(1), - margin: theme.spacing(1), - backgroundColor: theme.palette.getContrastText(theme.palette.text.primary), - border: "1px solid transparent", - borderColor: theme.palette.divider, - }, - input: { - width: "100%", - fontFamily: "Roboto Mono, monospace", - }, - root: { - padding: theme.spacing(1), - position: "absolute", - top: "0px", - left: "0px", - right: "0px", - bottom: "0px", - overflow: "auto", - }, - loadBar: { - padding: theme.spacing(1), - display: "grid", - gridTemplateColumns: "auto 1fr", - columnGap: theme.spacing(1), - alignItems: "center", - }, - }), -); +import { DataStore, DataStoreItemKind } from "../../services/datastore"; +import { Services } from "../../services/services"; +import { mergeRelationshipsStringAndComments } from "../../spicedb-common/parsing"; +import { HtmlTerminalRenderer } from "../terminal/HtmlTerminalRenderer"; -export function TerminalSummary() { - return } title="Zed Terminal" />; +interface TerminalPanelProps { + services: Services; + datastore: DataStore; } -export function TerminalPanel(props: PanelProps) { - const classes = useStyles(); - const zts = props.services.zedTerminalService!; +export function TerminalPanel({ services, datastore }: TerminalPanelProps) { + const zts = services.zedTerminalService!; useEffect(() => { zts.start(); }, [zts]); - const [command, setCommand] = useState(""); - const [historyIndex, setHistoryIndex] = useState(zts.commandHistory.length); - - const datastore = props.datastore; - const endOfContainer = useRef(null); - - useDeepCompareEffect(() => { - if (endOfContainer.current) { - endOfContainer.current?.scrollIntoView(); - } - }, [zts.outputSections]); - - const handleKeyUp = (e: KeyboardEvent) => { - if (e.key.toLowerCase() === "arrowup") { - const updatedHistoryIndex = historyIndex - 1; - if (updatedHistoryIndex < 0) { - return; - } - - setCommand(zts.commandHistory[updatedHistoryIndex]); - setHistoryIndex(updatedHistoryIndex); - } - - if (e.key.toLowerCase() === "arrowdown") { - const updatedHistoryIndex = historyIndex + 1; - if (updatedHistoryIndex >= zts.commandHistory.length) { - setCommand(""); - return; - } - - setCommand(zts.commandHistory[updatedHistoryIndex]); - setHistoryIndex(updatedHistoryIndex); - } - - const cmd = command.trim(); - if (e.key.toLowerCase() === "enter" && cmd.length > 0) { - const schema = datastore.getSingletonByKind(DataStoreItemKind.SCHEMA).editableContents!; - const relationshipsString = datastore.getSingletonByKind( - DataStoreItemKind.RELATIONSHIPS, - ).editableContents!; - const [result, historyCount] = zts.runCommand(cmd, schema, relationshipsString); - setCommand(""); - setHistoryIndex(historyCount); - - if (result?.updatedRelationships) { - const relItem = datastore.getSingletonByKind(DataStoreItemKind.RELATIONSHIPS); - const merged = mergeRelationshipsStringAndComments( - relItem.editableContents, - result.updatedRelationships, - ); - datastore.update(relItem, merged); - } - } - }; - - const handleCommandChanged = (e: ChangeEvent) => { - setCommand(e.target.value); - }; - - const zedState = zts.state; - const zedStateStatusDisplay = useMemo(() => { - switch (zedState.status) { - case "initializing": - return
Initializing Terminal
; - - case "loading": - return ( -
- Loading Terminal: - -
- ); - - case "loaderror": - return ( - - - - Could not start the Terminal. Please make sure you have WebAssembly enabled. - - - ); - - case "unsupported": - return ( - - - Your browser does not support WebAssembly - - ); - - case "ready": - return undefined; - } - }, [zedState, classes.loadBar]); - - const inputRef = useRef(null); - - const handleRefocus = () => { - inputRef.current?.focus(); - }; - - const handleMouseUp = (event: MouseEvent) => { - if (event.target instanceof Element) { - const hasSelection = !!getSelectedTextWithin(event.target); - if (!hasSelection) { - inputRef.current?.focus(); - } + const handleSubmit = (cmd: string) => { + const schema = datastore.getSingletonByKind(DataStoreItemKind.SCHEMA).editableContents!; + const relString = datastore.getSingletonByKind( + DataStoreItemKind.RELATIONSHIPS, + ).editableContents!; + const [result] = zts.runCommand(cmd, schema, relString); + if (result?.updatedRelationships) { + const relItem = datastore.getSingletonByKind(DataStoreItemKind.RELATIONSHIPS); + const merged = mergeRelationshipsStringAndComments( + relItem.editableContents, + result.updatedRelationships, + ); + datastore.update(relItem, merged); } }; - // Focus the command input when the tab is shown. - useEffect(() => { - inputRef.current?.focus(); - }, []); - - return ( -
- {zedStateStatusDisplay} - {zedState.status === "ready" && ( - <> - - $} - onKeyUp={handleKeyUp} - value={command} - onChange={handleCommandChanged} - disableUnderline - /> -
- - )} -
- ); -} - -function convertStringOutput(convert: Convert, o: string, showLogs: boolean) { - let isLog = false; - if (o.startsWith("{")) { - try { - const parsed = JSON.parse(o); - isLog = parsed["is-log"]; - if (isLog) { - if (!showLogs) { - return undefined; - } - - const isError = parsed["level"] === "error"; - return ( - - {isError ? : } - - {Object.entries(parsed).map(([key, value]) => { - if (key === "is-log") { - return undefined; - } - - return ( - - {key}: {JSON.stringify(value)}  - - ); - })} - - - ); - } - } catch (e) { - // Do nothing. - console.error(e); - } + if (zts.state.status === "loading") { + return ( +
+ Loading Terminal: + +
+ ); } - const output = - // TODO: rewrite this to remove use of replaceAll - // @ts-expect-error replaceAll comes from a string polyfill. - convert.toHtml(o.replaceAll(" ", "\xa0").replaceAll("\t", "\xa0\xa0")) || " "; - return
; -} + if (zts.state.status === "loaderror" || zts.state.status === "unsupported") { + return ( + + + + {zts.state.status === "unsupported" + ? "Your browser does not support WebAssembly" + : "Could not start the Terminal"} + + + ); + } -function TerminalOutputDisplay({ - sections, - showLogs, - onRefocus, -}: { - sections: TerminalSection[]; - showLogs?: boolean; - onRefocus?: () => void; -}) { - const classes = useStyles(); - const convert = new Convert({ - escapeXML: true, - }); - const children = sections.flatMap((section, index) => { - if ("command" in section) { - return
$ {section.command}
; - } else { - return ( -
- {section.output - .split("\n") - .map((o) => convertStringOutput(convert, o, showLogs ?? false))} -
- ); - } - }); - const handleMouseUp = (event: MouseEvent) => { - if (event.target instanceof Element) { - const hasSelection = !!getSelectedTextWithin(event.target); - if (onRefocus && !hasSelection) { - onRefocus(); - } - if (hasSelection) { - event.stopPropagation(); - } - } - }; + if (zts.state.status !== "ready") return
Initializing Terminal
; return ( -
- {children} -
+ zts.clear()} + /> ); } - -// Based on: https://stackoverflow.com/a/5801903 -function getSelectedTextWithin(el: Element) { - let selectedText = ""; - if (typeof window.getSelection != "undefined") { - const sel = window.getSelection(); - let rangeCount: number; - if (sel && (rangeCount = sel.rangeCount) > 0) { - const range = document.createRange(); - for (let i = 0, selRange: Range; i < rangeCount; ++i) { - range.selectNodeContents(el); - selRange = sel.getRangeAt(i); - if ( - selRange.compareBoundaryPoints(range.START_TO_END, range) === 1 && - selRange.compareBoundaryPoints(range.END_TO_START, range) === -1 - ) { - if (selRange.compareBoundaryPoints(range.START_TO_START, range) === 1) { - range.setStart(selRange.startContainer, selRange.startOffset); - } - if (selRange.compareBoundaryPoints(range.END_TO_END, range) === -1) { - range.setEnd(selRange.endContainer, selRange.endOffset); - } - selectedText += range.toString(); - } - } - } - } - return selectedText; -} diff --git a/src/components/panels/types.ts b/src/components/panels/types.ts deleted file mode 100644 index 81b39bf..0000000 --- a/src/components/panels/types.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type ReflexedPanelLocation = "horizontal" | "vertical"; -export const panelLocations = ["horizontal", "vertical"] as const; diff --git a/src/components/panels/validation.tsx b/src/components/panels/validation.tsx deleted file mode 100644 index 9e0fc5d..0000000 --- a/src/components/panels/validation.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Paper } from "@material-ui/core"; -import CircularProgress from "@material-ui/core/CircularProgress"; -import { createStyles, makeStyles, Theme } from "@material-ui/core/styles"; -import PlaylistAddCheckIcon from "@material-ui/icons/PlaylistAddCheck"; -import clsx from "clsx"; -import "react-reflex/styles.css"; - -import TabLabel from "../../playground-ui/TabLabel"; -import { ValidationStatus } from "../../services/validation"; -import { DeveloperError } from "../../spicedb-common/protodefs/developer/v1/developer_pb"; - -import { PanelProps, PanelSummaryProps } from "./base/common"; -import { DeveloperErrorDisplay, DeveloperSourceDisplay } from "./errordisplays"; - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - apiOutput: { - fontFamily: "Roboto Mono, monospace", - padding: theme.spacing(2), - }, - notRun: { - color: theme.palette.grey[500], - }, - validationErrorContainer: { - padding: theme.spacing(1), - marginBottom: theme.spacing(1), - }, - }), -); - -export function ValidationSummary(props: PanelSummaryProps) { - return ( - - } - title="Last Validation Run" - /> - ); -} - -export function ValidationPanel(props: PanelProps) { - const classes = useStyles(); - const validationState = props.services.validationService.state; - - return ( -
- {validationState.status === ValidationStatus.NOT_RUN && Validation Not Run} - {validationState.status === ValidationStatus.CALL_ERROR && ( - Validation Call Failed. Please try again shortly. - )} - {validationState.status === ValidationStatus.RUNNING && } - {validationState.status === ValidationStatus.VALIDATED && ( - Validation Completed Successfully! - )} - {validationState.status === ValidationStatus.VALIDATION_ERROR && ( - - {validationState.validationErrors?.map((de: DeveloperError, index: number) => { - return ( - - - - - ); - })} - - )} -
- ); -} diff --git a/src/components/panels/visualizer.tsx b/src/components/panels/visualizer.tsx deleted file mode 100644 index 33cbf74..0000000 --- a/src/components/panels/visualizer.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Bubbles } from "lucide-react"; -import type { Position } from "monaco-editor"; -import "react-reflex/styles.css"; - -// TODO: rename -import TenantGraph from "@/components/visualizer/TenantGraph"; - -import TabLabel from "../../playground-ui/TabLabel"; -import { DataStoreItem } from "../../services/datastore"; -import { ParseRelationshipError } from "../../spicedb-common/parsing"; -import { RelationTuple } from "../../spicedb-common/protodefs/core/v1/core_pb"; - -import { PanelProps } from "./base/common"; - -export function VisualizerSummary() { - return } title="System Visualization" />; -} - -function isRelationship( - relOrError: RelationTuple | ParseRelationshipError, -): relOrError is RelationTuple { - return !("errorMessage" in relOrError); -} - -export function VisualizerPanel({ - services, - dimensions, -}: PanelProps & { - dimensions?: { width: number; height: number }; - editorPosition?: Position; - currentItem?: DataStoreItem; -}) { - const relationships = services.localParseService.state.relationships - .map((relFound) => relFound.parsed) - .filter(isRelationship); - - return ( -
- -
- ); -} diff --git a/src/components/panels/watches.tsx b/src/components/panels/watches.tsx index 204499b..7d0d8f3 100644 --- a/src/components/panels/watches.tsx +++ b/src/components/panels/watches.tsx @@ -1,37 +1,33 @@ import type { ParsedPermission, ParsedRelation } from "@authzed/spicedb-parser-js"; -import CircularProgress from "@material-ui/core/CircularProgress"; -import IconButton from "@material-ui/core/IconButton"; -import Paper from "@material-ui/core/Paper"; -import { Theme, createStyles, makeStyles, useTheme } from "@material-ui/core/styles"; -import Table from "@material-ui/core/Table"; -import TableBody from "@material-ui/core/TableBody"; -import TableCell from "@material-ui/core/TableCell"; -import TableContainer from "@material-ui/core/TableContainer"; -import TableHead from "@material-ui/core/TableHead"; -import TableRow from "@material-ui/core/TableRow"; -import TextField from "@material-ui/core/TextField"; -import Tooltip from "@material-ui/core/Tooltip"; -import CheckCircleIcon from "@material-ui/icons/CheckCircle"; -import ChevronRightIcon from "@material-ui/icons/ChevronRight"; -import ControlPointIcon from "@material-ui/icons/ControlPoint"; -import DeleteForeverIcon from "@material-ui/icons/DeleteForever"; -import ErrorOutlineIcon from "@material-ui/icons/ErrorOutline"; -import ExpandMoreIcon from "@material-ui/icons/ExpandMore"; -import HelpOutlineIcon from "@material-ui/icons/HelpOutline"; -import HighlightOffIcon from "@material-ui/icons/HighlightOff"; -import RadioButtonUncheckedIcon from "@material-ui/icons/RadioButtonUnchecked"; -import RemoveCircleOutlineIcon from "@material-ui/icons/RemoveCircleOutline"; -import VisibilityIcon from "@material-ui/icons/Visibility"; -import WarningIcon from "@material-ui/icons/Warning"; -import Autocomplete from "@material-ui/lab/Autocomplete"; -import clsx from "clsx"; import { interpolateBlues, interpolateOranges, interpolatePurples } from "d3-scale-chromatic"; -import { CircleX, Info, MessageCircleWarning } from "lucide-react"; -import { type ReactNode } from "react"; -import { useMemo, useState, type ChangeEvent } from "react"; -import "react-reflex/styles.css"; +import { + CheckCircle, + ChevronDown, + ChevronRight, + CircleAlert, + CircleHelp, + CircleX, + Info, + Loader2, + MessageCircleWarning, + PlusCircle, + Trash2, + TriangleAlert, +} from "lucide-react"; +import { type ChangeEvent, useMemo, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; -import TabLabel from "../../playground-ui/TabLabel"; import { LiveCheckItem, LiveCheckItemStatus, @@ -40,183 +36,42 @@ import { } from "../../services/check"; import { DataStore, DataStoreItemKind } from "../../services/datastore"; import { LocalParseService } from "../../services/localparse"; +import { Services } from "../../services/services"; import { parseRelationships } from "../../spicedb-common/parsing"; import { RelationTuple as Relationship } from "../../spicedb-common/protodefs/core/v1/core_pb"; import { CheckDebugTraceView } from "../CheckDebugTraceView"; -import { TourElementClass } from "../GuidedTour"; import { Alert, AlertTitle, AlertDescription } from "../ui/alert"; -import { PanelProps, PanelSummaryProps, useSummaryStyles } from "./base/common"; -import { ReflexedPanelLocation } from "./types"; - -const useStyles = makeStyles((theme: Theme) => - createStyles({ - validationError: { - border: 0, - }, - foundVia: { - marginTop: theme.spacing(1), - }, - foundViaList: { - margin: 0, - fontFamily: "Roboto Mono, monospace", - listStyleType: "none", - "& li::after": { - content: '" →"', - }, - "& li:last-child::after": { - content: '""', - }, - }, - editorContainer: { - display: "grid", - alignItems: "center", - gridTemplateColumns: "auto 1fr", - }, - dot: { - display: "inline-block", - marginRight: theme.spacing(1), - borderRadius: "50%", - width: "8px", - height: "8px", - }, - progress: { - color: theme.palette.text.primary, - }, - success: { - color: theme.palette.success.main, - }, - caveated: { - color: "#8787ff", - }, - gray: { - color: theme.palette.grey[500], - }, - warning: { - color: theme.palette.warning.main, - }, - verticalCell: { - padding: theme.spacing(1), - border: 0, - }, - }), -); - -/** - * WatchesSummary displays the a summary of the check watches. - */ -export function WatchesSummary(props: PanelSummaryProps) { - const classes = useSummaryStyles(); - - const liveCheckService = props.services.liveCheckService; - - const hasItems = liveCheckService.items.length > 0; - const foundItems = liveCheckService.items.filter( - (item: LiveCheckItem) => item.status === LiveCheckItemStatus.FOUND, - ); - const caveatedItems = liveCheckService.items.filter( - (item: LiveCheckItem) => item.status === LiveCheckItemStatus.CAVEATED, - ); - const notFoundItems = liveCheckService.items.filter( - (item: LiveCheckItem) => item.status === LiveCheckItemStatus.NOT_FOUND, - ); - const invalidItems = liveCheckService.items.filter( - (item: LiveCheckItem) => item.status === LiveCheckItemStatus.INVALID, - ); - const hasServerErr = !!liveCheckService.state.serverErr; - - return ( -
- } - title="Check Watches" - /> - {!hasServerErr && hasItems && ( - - 0, - })} - > - {foundItems.length} - - - )} - {!hasServerErr && hasItems && ( - - 0, - })} - > - {caveatedItems.length} - - - )} - {!hasServerErr && hasItems && ( - - 0, - })} - > - {invalidItems.length} - - - )} - {!hasServerErr && hasItems && ( - - 0, - })} - > - {notFoundItems.length} - - - )} - {hasServerErr && } -
- ); +interface WatchesPanelProps { + services: Services; + datastore: DataStore; } -export function WatchesPanel(props: PanelProps) { - const liveCheckService = props.services.liveCheckService; - const localParseService = props.services.localParseService; - const datastore = props.datastore; +export function WatchesPanel({ services, datastore }: WatchesPanelProps) { + const liveCheckService = services.liveCheckService; + const localParseService = services.localParseService; const editorUpdateIndex = -1; // FIXME return ( - - - +
+
+ - - - {props.location === "horizontal" && ( - <> - Resource - Permission - Subject - Context (optional) - - )} - {props.location === "vertical" && ( - Resource, Permission, Subject, Context - )} - - - - - + + + Resource + Permission + Subject + Context (optional) + + + - + {liveCheckService.state.status === LiveCheckStatus.PARSE_ERROR && ( - + @@ -228,10 +83,10 @@ export function WatchesPanel(props: PanelProps) { - + )} {liveCheckService.state.status === LiveCheckStatus.NEVER_RUN && ( - + @@ -240,10 +95,10 @@ export function WatchesPanel(props: PanelProps) { - + )} {liveCheckService.state.status === LiveCheckStatus.SERVICE_ERROR && ( - + @@ -252,26 +107,22 @@ export function WatchesPanel(props: PanelProps) { - + )} - {liveCheckService.items.length > 0 && - liveCheckService.items.map((item: LiveCheckItem) => { - return ( - - ); - })} + {liveCheckService.items.map((item: LiveCheckItem) => ( + + ))}
-
+
); } @@ -281,362 +132,191 @@ function notNull(value: string | null): value is string { const filter = (values: (string | null)[]): string[] => { const filtered = values.filter(notNull); - const set = new Set(filtered); - return Array.from(set); + return Array.from(new Set(filtered)); }; -function LiveCheckRow(props: { - location: ReflexedPanelLocation; +interface LiveCheckRowProps { service: LiveCheckService; item: LiveCheckItem; editorUpdateIndex?: number; datastore: DataStore; localParseService: LocalParseService; -}) { - const classes = useStyles(); +} + +function LiveCheckRow(props: LiveCheckRowProps) { const item = props.item; const datastore = props.datastore; const liveCheckService = props.service; - const handleDeleteRow = () => { - liveCheckService.removeItem(item); - }; - const [object, setObject] = useState(item.object); const [action, setAction] = useState(item.action); const [subject, setSubject] = useState(item.subject); - const [context, setContext] = useState(() => { - // Remove the `default:` prefix we add below. - if (item.context) { - return item.context.substring("default:".length); - } - - return ""; - }); + const [context, setContext] = useState(() => + item.context ? item.context.substring("default:".length) : "", + ); const [isExpanded, setIsExpanded] = useState(false); - const [objectInputValue, setObjectInputValue] = useState(item.object); - const [actionInputValue, setActionInputValue] = useState(item.action); - const [subjectInputValue, setSubjectInputValue] = useState(item.subject); - - const handleChangeObjectInput = (_event: object, newValue: string) => { - setObjectInputValue(newValue); - item.object = newValue; + const handleObjectChange = (next: string) => { + setObject(next); + item.object = next; liveCheckService.itemUpdated(item); }; - - const handleChangeObject = (_event: object, newValue: string | null) => { - setObject(newValue ?? ""); - item.object = newValue ?? ""; - }; - - const handleChangeActionInput = (_event: object, newValue: string) => { - setActionInputValue(newValue); - item.action = newValue; + const handleActionChange = (next: string) => { + setAction(next); + item.action = next; liveCheckService.itemUpdated(item); }; - - const handleChangeAction = (_event: object, newValue: string | null) => { - setAction(newValue ?? ""); - item.action = newValue ?? ""; - }; - - const handleChangeSubjectInput = (_event: object, newValue: string) => { - setSubjectInputValue(newValue); - item.subject = newValue; + const handleSubjectChange = (next: string) => { + setSubject(next); + item.subject = next; liveCheckService.itemUpdated(item); }; - - const handleChangeSubject = (_event: object, newValue: string | null) => { - setSubject(newValue ?? ""); - item.subject = newValue ?? ""; - }; - - const handleChangeContextInput = (event: ChangeEvent) => { - const newValue = event.target.value; - setContext(newValue ?? ""); - // NOTE: adding a dummy caveat name to support only specifying the context with checks - // while preserving the simple approach of parsing all checks as relationships - item.context = newValue ? `default:${newValue}` : ""; + const handleContextChange = (e: ChangeEvent) => { + const next = e.target.value; + setContext(next); + item.context = next ? `default:${next}` : ""; liveCheckService.itemUpdated(item); }; const relationshipContents = props.datastore.getSingletonByKind(DataStoreItemKind.RELATIONSHIPS).editableContents ?? ""; - const relationships = useMemo(() => { - return parseRelationships(relationshipContents); - }, [relationshipContents]); - - const objects = useMemo(() => { - return filter( - relationships.map((r: Relationship) => { - const onr = r.resourceAndRelation; - if (onr === undefined) { - return null; - } + const relationships = useMemo( + () => parseRelationships(relationshipContents), + [relationshipContents], + ); - return `${onr.namespace}:${onr.objectId}`; - }), - ); - }, [relationships]); + const objects = useMemo( + () => + filter( + relationships.map((r: Relationship) => { + const onr = r.resourceAndRelation; + return onr ? `${onr.namespace}:${onr.objectId}` : null; + }), + ), + [relationships], + ); const actions = useMemo(() => { - const [definitionPath] = objectInputValue.split(":", 2); + const [definitionPath] = object.split(":", 2); const definition = props.localParseService.lookupDefinition(definitionPath); - if (definition !== undefined) { + if (definition) { return definition .listRelationsAndPermissions() .map((r: ParsedRelation | ParsedPermission) => r.name); } - - return filter( - relationships.map((r: Relationship) => { - const onr = r.resourceAndRelation; - if (onr === undefined) { - return null; - } - - return onr.relation; - }), - ); - + return filter(relationships.map((r: Relationship) => r.resourceAndRelation?.relation ?? null)); // NOTE: we include editorUpdateIndex to ensure this is recomputed on // editor changes. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [datastore, relationships, objectInputValue, props.editorUpdateIndex]); - - const subjects = useMemo(() => { - return filter( - relationships.map((r: Relationship) => { - const subject = r.subject; - if (subject === undefined) { - return null; - } - - if (subject.objectId === "*") { - return null; - } - - if (subject.relation === "...") { - return `${subject.namespace}:${subject.objectId}`; - } - return `${subject.namespace}:${subject.objectId}#${subject.relation}`; - }), - ); - }, [relationships]); - - const renderOption = ( - option: string | undefined, - optionSet: (string | undefined)[], - colorSet: (n: number) => string, - ) => { - return ( -
- - {option} -
- ); - }; + }, [datastore, relationships, object, props.editorUpdateIndex, props.localParseService]); + + const subjects = useMemo( + () => + filter( + relationships.map((r: Relationship) => { + const s = r.subject; + if (!s || s.objectId === "*") return null; + if (s.relation === "...") return `${s.namespace}:${s.objectId}`; + return `${s.namespace}:${s.objectId}#${s.relation}`; + }), + ), + [relationships], + ); const status = liveCheckService.state.status; - const theme = useTheme(); - const wrap = (content: ReactNode, width: string) => { - if (props.location === "vertical") { - return ( - - - {content} - -
- ); + const statusIcon = (() => { + if (status === LiveCheckStatus.CHECKING) return ; + if (status === LiveCheckStatus.PARSE_ERROR) + return ; + if (status === LiveCheckStatus.SERVICE_ERROR) + return ; + if (status === LiveCheckStatus.NEVER_RUN) + return ; + if (status === LiveCheckStatus.NOT_CHECKING) { + switch (item.status) { + case LiveCheckItemStatus.FOUND: + return ; + case LiveCheckItemStatus.NOT_FOUND: + return ; + case LiveCheckItemStatus.NOT_CHECKED: + return ; + case LiveCheckItemStatus.NOT_VALID: + return ; + case LiveCheckItemStatus.INVALID: + return ; + case LiveCheckItemStatus.CAVEATED: + return ; + } } - - return {content}; - }; + return null; + })(); return ( <> {item.status === LiveCheckItemStatus.INVALID && ( - + {item.errorMessage}: )} - + {item.debugInformation !== undefined && ( - setIsExpanded(!isExpanded)}> - {isExpanded ? : } - + )} - - -
- {status === LiveCheckStatus.CHECKING && ( - - )} - {status === LiveCheckStatus.PARSE_ERROR && } - {status === LiveCheckStatus.SERVICE_ERROR && } - {status === LiveCheckStatus.NEVER_RUN && } - {status === LiveCheckStatus.NOT_CHECKING && - item.status === LiveCheckItemStatus.FOUND && ( - - )} - {status === LiveCheckStatus.NOT_CHECKING && - item.status === LiveCheckItemStatus.NOT_FOUND && } - {status === LiveCheckStatus.NOT_CHECKING && - item.status === LiveCheckItemStatus.NOT_CHECKED && } - {status === LiveCheckStatus.NOT_CHECKING && - item.status === LiveCheckItemStatus.NOT_VALID && ( - - )} - {status === LiveCheckStatus.NOT_CHECKING && - item.status === LiveCheckItemStatus.INVALID && ( - - )} - {status === LiveCheckStatus.NOT_CHECKING && - item.status === LiveCheckItemStatus.CAVEATED && ( - - )} -
-
- {wrap( -
- - option} - renderOption={(option) => renderOption(option, objects, interpolatePurples)} + {statusIcon} + +
+ + ({ value: o }))} value={object} - inputValue={objectInputValue} - onInputChange={handleChangeObjectInput} - onChange={handleChangeObject} - fullWidth - disableClearable - renderInput={(params) => ( - - )} + onValueChange={handleObjectChange} + placeholder="tenant/namespace:objectid" + inputClassName="font-mono placeholder:text-muted-foreground/50" /> -
, - "28%", - )} - {wrap( -
- - option} - renderOption={(option) => renderOption(option, actions, interpolateBlues)} +
+
+ +
+ + ({ value: a }))} value={action} - inputValue={actionInputValue} - onInputChange={handleChangeActionInput} - onChange={handleChangeAction} - fullWidth - disableClearable - renderInput={(params) => ( - - )} - /> -
, - "18%", - )} - {wrap( -
- - option} - renderOption={(option) => renderOption(option, subjects, interpolateOranges)} +
+
+ +
+ + ({ value: s }))} value={subject} - inputValue={subjectInputValue} - onInputChange={handleChangeSubjectInput} - onChange={handleChangeSubject} - fullWidth - disableClearable - renderInput={(params) => ( - - )} - /> -
, - "28%", - )} - {wrap( -
- -
, - "26%", - )} - - - - +
+
+ + + + +
{item.debugInformation !== undefined && isExpanded && ( @@ -658,14 +338,12 @@ function DotDisplay(props: { valueSet: (string | undefined)[]; value: string; }) { - const classes = useStyles(); - const color = props.colorSet(1 - props.valueSet.indexOf(props.value) / 9); + const found = props.valueSet.indexOf(props.value); + const color = found >= 0 ? props.colorSet(1 - found / 9) : "transparent"; return ( -
= 0 ? color : "transparent", - }} + ); } diff --git a/src/components/relationshipeditor/RelationshipEditor.tsx b/src/components/relationshipeditor/RelationshipEditor.tsx index 6fa59fe..68167f8 100644 --- a/src/components/relationshipeditor/RelationshipEditor.tsx +++ b/src/components/relationshipeditor/RelationshipEditor.tsx @@ -13,14 +13,17 @@ import DataEditor, { } from "@glideapps/glide-data-grid"; // Bring in the CSS for glide-data-grid import "@glideapps/glide-data-grid/dist/index.css"; -import { Checkbox, FormControlLabel, IconButton, Tooltip } from "@material-ui/core"; -import { createStyles, makeStyles, Theme as MuiTheme, useTheme } from "@material-ui/core/styles"; -import { Assignment, Comment, Delete } from "@material-ui/icons"; +import { ClipboardList, MessageSquare, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { useCookies } from "react-cookie"; import { toast } from "sonner"; import { useDeepCompareEffect, useDeepCompareMemo } from "use-deep-compare"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Label } from "@/components/ui/label"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useResolvedTheme } from "@/hooks/use-resolved-theme"; import { ParseRelationshipError, parseRelationshipsWithComments, @@ -65,48 +68,6 @@ import { TYPE_CELL_KIND, } from "./fieldcell"; -const useStyles = makeStyles((theme: MuiTheme) => - createStyles({ - root: { - "& input": { - backgroundColor: `${theme.palette.background.paper} !important`, - }, - position: "relative", - }, - fab: { - position: "absolute", - bottom: theme.spacing(4), - right: theme.spacing(2), - zIndex: 9999, - }, - speedDialTooltip: { - whiteSpace: "nowrap", - }, - tooltip: { - position: "fixed", - zIndex: 99999, - whiteSpace: "nowrap", - padding: "8px 12px", - color: "white", - font: "500 13px", - fontFamily: theme.typography.fontFamily, - backgroundColor: "rgba(0, 0, 0, 0.85)", - borderRadius: 9, - }, - toolbar: { - backgroundColor: theme.palette.background.default, - borderBottom: "1px solid transparent", - borderBottomColor: theme.palette.divider, - display: "grid", - gridTemplateColumns: "auto auto 1fr auto", - alignItems: "center", - }, - toolbarCheckbox: { - padding: "4px", - }, - }), -); - export type RelationTupleHighlight = { tupleString: string; color: string; @@ -171,12 +132,7 @@ export function RelationshipEditor({ useEffect(() => { inFlightData.current = data; dataUpdated(toExternalData(data)); - - // NOTE: we do not want to rerun this if the dataUpdated callback has changed (which it should - // not, ideally). - // TODO: dataUpdated is currently changing on every render because the debouncer isn't memoized. - // oxlint-disable-next-line eslint-plugin-react-hooks(exhaustive-deps) - }, [data]); + }, [data, dataUpdated]); // relationships holds a filtered form of the grid, containing only valid relationships. const relationships = useDeepCompareMemo(() => { @@ -376,42 +332,66 @@ export function RelationshipEditor({ return false; }; - const theme = useTheme(); - const dataEditorTheme: Partial = useMemo(() => { - return { - accentColor: theme.palette.primary.light, - accentFg: theme.palette.getContrastText(theme.palette.action.focus), - accentLight: theme.palette.action.focus, + const resolvedTheme = useResolvedTheme(); - textDark: theme.palette.text.primary, - textMedium: theme.palette.text.primary, - textLight: theme.palette.grey[500], - textBubble: theme.palette.text.primary, - - editorFontSize: `${theme.typography.fontSize}px`, - - bgIconHeader: theme.palette.text.primary, - fgIconHeader: theme.palette.text.primary, - textHeader: theme.palette.text.primary, - textGroupHeader: theme.palette.text.primary, - textHeaderSelected: theme.palette.text.primary, + const dataEditorTheme: Partial = useMemo(() => { + const isDark = resolvedTheme === "dark"; + + const palette = isDark + ? { + textDark: "#e5e5e5", + textMedium: "#e5e5e5", + textLight: "#a0a0a0", + textBubble: "#e5e5e5", + bgIconHeader: "#e5e5e5", + fgIconHeader: "#e5e5e5", + textHeader: "#e5e5e5", + textGroupHeader: "#e5e5e5", + textHeaderSelected: "#e5e5e5", + bgCell: "#0a0a0a", + bgCellMedium: "#141414", + bgHeader: "#1d1d1d", + bgHeaderHovered: "#1e3a8a", + bgBubble: "black", + bgBubbleSelected: "black", + borderColor: "#2a2a2a", + horizontalBorderColor: "#2a2a2a", + drilldownBorder: "#2a2a2a", + } + : { + textDark: "#1a1a1a", + textMedium: "#3a3a3a", + textLight: "#666666", + textBubble: "#1a1a1a", + bgIconHeader: "#3a3a3a", + fgIconHeader: "#3a3a3a", + textHeader: "#1a1a1a", + textGroupHeader: "#1a1a1a", + textHeaderSelected: "#1a1a1a", + bgCell: "#ffffff", + bgCellMedium: "#f7f7f7", + bgHeader: "#f5f5f5", + bgHeaderHovered: "#dbeafe", + bgBubble: "#f5f5f5", + bgBubbleSelected: "#e5e5e5", + borderColor: "#e5e5e5", + horizontalBorderColor: "#e5e5e5", + drilldownBorder: "#e5e5e5", + }; - bgCell: theme.palette.background.paper, - bgCellMedium: theme.palette.background.default, - bgHeader: theme.palette.background.default, - bgHeaderHasFocus: theme.palette.primary.main, - bgHeaderHovered: theme.palette.primary.dark, + return { + accentColor: "#3b82f6", + accentFg: "#ffffff", + accentLight: "rgba(59, 130, 246, 0.2)", - bgBubble: "black", - bgBubbleSelected: "black", + ...palette, - bgSearchResult: theme.palette.primary.light, + editorFontSize: "13px", - borderColor: theme.palette.divider, - horizontalBorderColor: theme.palette.divider, - drilldownBorder: theme.palette.divider, + bgHeaderHasFocus: "#3b82f6", + bgSearchResult: "#3b82f6", - linkColor: theme.palette.primary.main, + linkColor: "#3b82f6", cellHorizontalPadding: 8, cellVerticalPadding: 3, @@ -422,7 +402,7 @@ export function RelationshipEditor({ "Inter, Roboto, -apple-system, BlinkMacSystemFont, avenir next, avenir, segoe ui, helvetica neue, helvetica, Ubuntu, noto, arial, sans-serif", ...themeOverrides, }; - }, [theme, themeOverrides]); + }, [themeOverrides, resolvedTheme]); const getCellData = useCallback( ([col, row]: readonly [number, number]): GridCell => { @@ -581,7 +561,6 @@ export function RelationshipEditor({ [data], ); - const classes = useStyles(); const handleRowMoved = (startIndex: number, endIndex: number) => { if (isReadOnly) { return; @@ -881,11 +860,14 @@ export function RelationshipEditor({ ); return ( -
-
+
+
{!isReadOnly && ( } {hasCheckedRows && !isReadOnly && ( - - - - - + + + + + + Delete Rows - - - - + + + + + Copy Rows to Clipboard - - - - + + + + + Convert to/from comments )} {(!hasCheckedRows || isReadOnly) && } - - } - label="Highlight same types, objects and relations" - /> +
+ + +
{tooltip !== undefined && (
{ - // From: https://github.com/mui/material-ui/issues/12779 - // Ensures that the autofocus jumps to the end of the input's value. - const handleFocus = (event: React.ChangeEvent) => { - const lengthOfInput = event.target.value.length; - return event.target.setSelectionRange(lengthOfInput, lengthOfInput); - }; - let adjustedInitialValue = props.initialValue; if (adjustedInitialValue && !adjustedInitialValue.startsWith(CommentCellPrefix)) { adjustedInitialValue = `${CommentCellPrefix} ${adjustedInitialValue}`; @@ -52,38 +46,39 @@ const CommentCellEditor = (props: CommentCellEditorProps) => { ?.slice(props.value.data.col) .map((col: Column) => col.width) .reduce((n, m) => n + m); - const fieldRef = useRef(null); + const containerRef = useRef(null); + const [internalValue, setInternalValue] = useState(defaultValue); // NOTE: This is necessary to ensure that the container for the comment editor can span // the entire width of the comment cell span. The data grid by default sets a max width // of ~400px on the parent element, which was cutting off the editor. useEffect(() => { - if (fieldRef.current) { - if (fieldRef.current.parentElement?.parentElement) { - fieldRef.current.parentElement.parentElement.style.maxWidth = "none"; + if (containerRef.current) { + if (containerRef.current.parentElement?.parentElement) { + containerRef.current.parentElement.parentElement.style.maxWidth = "none"; } } }, []); return ( - { - props.onChange({ - ...props.value, - copyData: copyDataForCommentCell(e.target.value), - data: { - ...props.value.data, - dataValue: e.target.value, - }, - }); - }} - /> +
+ { + setInternalValue(e.target.value); + props.onChange({ + ...props.value, + copyData: copyDataForCommentCell(e.target.value), + data: { + ...props.value.data, + dataValue: e.target.value, + }, + }); + }} + /> +
); }; diff --git a/src/components/relationshipeditor/fieldcell.tsx b/src/components/relationshipeditor/fieldcell.tsx index bcaa4a9..827923e 100644 --- a/src/components/relationshipeditor/fieldcell.tsx +++ b/src/components/relationshipeditor/fieldcell.tsx @@ -5,12 +5,17 @@ import { DrawCellCallback, GridSelection, } from "@glideapps/glide-data-grid"; -import { Popper, PopperProps, alpha } from "@material-ui/core"; -import TextField from "@material-ui/core/TextField"; -import Autocomplete, { type AutocompleteRenderInputParams } from "@material-ui/lab/Autocomplete"; -import { RefObject, useRef } from "react"; +import { RefObject, useEffect, useRef, useState } from "react"; import stc from "string-to-color"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; import { RelationshipsService } from "@/spicedb-common/services/relationshipsservice"; import { COLUMNS, Column, DataKind, DataTitle, RelationshipSection } from "./columns"; @@ -227,8 +232,11 @@ function fieldCellRenderer, Q extends FieldCellProps>( ctx.lineWidth = 2; ctx.setLineDash([7, 5]); - ctx.fillStyle = alpha(similarColor, 0.2); + const previousAlpha = ctx.globalAlpha; + ctx.globalAlpha = 0.2; + ctx.fillStyle = similarColor; ctx.fillRect(rect.x, rect.y, rect.width, rect.height); + ctx.globalAlpha = previousAlpha; ctx.strokeRect(rect.x + 2, rect.y + 2, rect.width - 3, rect.height - 3); } @@ -277,6 +285,8 @@ const FieldCellEditor = , Q extends FieldCellProps>( props: FieldCellEditorProps, ) => { const edited = useRef(false); + const inputRef = useRef(null); + const [open, setOpen] = useState(true); // NOTE: In order to handle the initialValue correctly, we have to include it as // the default for the field, but *only* if the user hasn't manually edited the @@ -284,73 +294,121 @@ const FieldCellEditor = , Q extends FieldCellProps>( // always appearing for an otherwise empty value, preventing users from deleting // the contents completely. // We could probably work around this by setting a defaultValue, but that has odd - // interactions with the Autocomplete, so we use this approach instead. + // interactions with autocomplete, so we use this approach instead. const editableValue = edited.current ? props.value.data.dataValue : props.value.data.dataValue || props.initialValue; - const DecoratedPopperComponent = (props: PopperProps) => { - // NOTE: the special className of `click-outside-ignore` is necessary to prevent - // clicking the autocomplete from closing the editor. - // See: https://github.com/glideapps/glide-data-grid/blob/main/packages/core/src/click-outside-container/click-outside-container.tsx#L23 - return ; - }; + // Auto-focus on mount so the editor behaves like a typical grid cell editor. + useEffect(() => { + inputRef.current?.focus(); + }, []); - const handleKeyDown = () => { + const handleKeyDown = (event: React.KeyboardEvent) => { // Mark that a user edit has occurred. edited.current = true; + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + event.stopPropagation(); + const newValue = (event.target as HTMLInputElement | HTMLTextAreaElement).value; + props.onFinishedEditing({ + ...props.value, + copyData: newValue, + data: { + ...props.value.data, + dataValue: newValue, + }, + }); + } + }; + + const handleInputChange = (newValue: string) => { + props.onChange({ + ...props.value, + copyData: newValue, + data: { + ...props.value.data, + dataValue: newValue, + }, + }); + }; + + const handleSelect = (selected: string) => { + props.onFinishedEditing({ + ...props.value, + copyData: selected, + data: { + ...props.value.data, + dataValue: selected, + }, + }); }; const autocompleteOptions: string[] = props.getAutocompleteOptions( props.fieldPropsRef.current, props.value.data, ); - return ( - option} - style={{ width: "150px", zIndex: 9999999999 }} - freeSolo - onChange={(event, newValue) => { - props.onFinishedEditing({ - ...props.value, - copyData: newValue ?? "", - data: { - ...props.value.data, - dataValue: newValue ?? "", - }, - }); - event.stopPropagation(); - event.preventDefault(); - }} - onInputChange={(event, newValue) => { - // If the event and value are empty, this is a synthetic event created by - // the grid to "clear" the value; we only allow it if there is an initial - // value to replace the current value. - if (!newValue && !event && !props.initialValue) { - return; - } - props.onChange({ - ...props.value, - copyData: newValue ?? "", - data: { - ...props.value.data, - dataValue: newValue ?? "", - }, - }); - }} - inputValue={editableValue ?? ""} - renderInput={(params: AutocompleteRenderInputParams) => ( - + const isMultiline = props.kind === CAVEATCONTEXT_CELL_KIND; + const inputValue = editableValue ?? ""; + const inputClass = + "w-full bg-transparent px-3 py-2 text-sm outline-none border border-input rounded-md font-mono"; + + return ( + + +
+ {isMultiline ? ( +