diff --git a/docs/docs/api/appkit-ui/data/AreaChart.mdx b/docs/docs/api/appkit-ui/data/AreaChart.mdx index b11887d4d..f9a9084ff 100644 --- a/docs/docs/api/appkit-ui/data/AreaChart.mdx +++ b/docs/docs/api/appkit-ui/data/AreaChart.mdx @@ -55,12 +55,12 @@ Supports both query mode (queryKey + parameters) and data mode (static data). | `parameters` | `Record` | | - | Query parameters passed to the analytics endpoint | | `format` | `"auto" \| "json_array" \| "arrow_stream" \| "json" \| "arrow"` | | `"auto"` | Data format to use - "json_array": Use JSON format (smaller payloads, simpler) - "arrow_stream": Use Arrow format (faster for large datasets) - "auto": Automatically select based on expected data size | | `transformer` | `((data: T) => T)` | | - | Transform raw data before rendering | -| `data` | `ChartData` | | - | Arrow Table or JSON array | +| `data` | `ChartDataInput` | | - | Arrow Table or JSON array | | `title` | `string` | | - | Chart title | | `showLegend` | `boolean` | | - | Show legend | | `colorPalette` | `"categorical" \| "sequential" \| "diverging"` | | - | Color palette to use. Auto-selected based on chart type if not specified. - "categorical": Distinct colors for different categories (bar, pie, line) - "sequential": Gradient for magnitude/intensity (heatmap) - "diverging": Two-tone for positive/negative values | | `colors` | `string[]` | | - | Custom colors for series (overrides colorPalette) | -| `height` | `number` | | `300` | Chart height in pixels | +| `height` | `number` | | - | Chart height in pixels. Defaults to filling its parent (see `chartSizeStyle`). | | `className` | `string` | | - | Additional CSS classes | | `xKey` | `string` | | - | X-axis field key. Auto-detected from schema if not provided. | | `yKey` | `string \| string[]` | | - | Y-axis field key(s). Auto-detected from schema if not provided. | diff --git a/docs/docs/api/appkit-ui/data/BarChart.mdx b/docs/docs/api/appkit-ui/data/BarChart.mdx index 8b287bea0..e4d832bf1 100644 --- a/docs/docs/api/appkit-ui/data/BarChart.mdx +++ b/docs/docs/api/appkit-ui/data/BarChart.mdx @@ -52,12 +52,12 @@ Supports both query mode (queryKey + parameters) and data mode (static data). | `parameters` | `Record` | | - | Query parameters passed to the analytics endpoint | | `format` | `"auto" \| "json_array" \| "arrow_stream" \| "json" \| "arrow"` | | `"auto"` | Data format to use - "json_array": Use JSON format (smaller payloads, simpler) - "arrow_stream": Use Arrow format (faster for large datasets) - "auto": Automatically select based on expected data size | | `transformer` | `((data: T) => T)` | | - | Transform raw data before rendering | -| `data` | `ChartData` | | - | Arrow Table or JSON array | +| `data` | `ChartDataInput` | | - | Arrow Table or JSON array | | `title` | `string` | | - | Chart title | | `showLegend` | `boolean` | | - | Show legend | | `colorPalette` | `"categorical" \| "sequential" \| "diverging"` | | - | Color palette to use. Auto-selected based on chart type if not specified. - "categorical": Distinct colors for different categories (bar, pie, line) - "sequential": Gradient for magnitude/intensity (heatmap) - "diverging": Two-tone for positive/negative values | | `colors` | `string[]` | | - | Custom colors for series (overrides colorPalette) | -| `height` | `number` | | `300` | Chart height in pixels | +| `height` | `number` | | - | Chart height in pixels. Defaults to filling its parent (see `chartSizeStyle`). | | `className` | `string` | | - | Additional CSS classes | | `xKey` | `string` | | - | X-axis field key. Auto-detected from schema if not provided. | | `yKey` | `string \| string[]` | | - | Y-axis field key(s). Auto-detected from schema if not provided. | diff --git a/docs/docs/api/appkit-ui/data/DonutChart.mdx b/docs/docs/api/appkit-ui/data/DonutChart.mdx index 2578f7511..e7416551e 100644 --- a/docs/docs/api/appkit-ui/data/DonutChart.mdx +++ b/docs/docs/api/appkit-ui/data/DonutChart.mdx @@ -49,12 +49,12 @@ Supports both query mode (queryKey + parameters) and data mode (static data). | `parameters` | `Record` | | - | Query parameters passed to the analytics endpoint | | `format` | `"auto" \| "json_array" \| "arrow_stream" \| "json" \| "arrow"` | | `"auto"` | Data format to use - "json_array": Use JSON format (smaller payloads, simpler) - "arrow_stream": Use Arrow format (faster for large datasets) - "auto": Automatically select based on expected data size | | `transformer` | `((data: T) => T)` | | - | Transform raw data before rendering | -| `data` | `ChartData` | | - | Arrow Table or JSON array | +| `data` | `ChartDataInput` | | - | Arrow Table or JSON array | | `title` | `string` | | - | Chart title | | `showLegend` | `boolean` | | - | Show legend | | `colorPalette` | `"categorical" \| "sequential" \| "diverging"` | | - | Color palette to use. Auto-selected based on chart type if not specified. - "categorical": Distinct colors for different categories (bar, pie, line) - "sequential": Gradient for magnitude/intensity (heatmap) - "diverging": Two-tone for positive/negative values | | `colors` | `string[]` | | - | Custom colors for series (overrides colorPalette) | -| `height` | `number` | | `300` | Chart height in pixels | +| `height` | `number` | | - | Chart height in pixels. Defaults to filling its parent (see `chartSizeStyle`). | | `className` | `string` | | - | Additional CSS classes | | `xKey` | `string` | | - | X-axis field key. Auto-detected from schema if not provided. | | `yKey` | `string \| string[]` | | - | Y-axis field key(s). Auto-detected from schema if not provided. | diff --git a/docs/docs/api/appkit-ui/data/HeatmapChart.mdx b/docs/docs/api/appkit-ui/data/HeatmapChart.mdx index 7da082cad..e62e6e35c 100644 --- a/docs/docs/api/appkit-ui/data/HeatmapChart.mdx +++ b/docs/docs/api/appkit-ui/data/HeatmapChart.mdx @@ -66,12 +66,12 @@ Supports both query mode (queryKey + parameters) and data mode (static data). | `parameters` | `Record` | | - | Query parameters passed to the analytics endpoint | | `format` | `"auto" \| "json_array" \| "arrow_stream" \| "json" \| "arrow"` | | `"auto"` | Data format to use - "json_array": Use JSON format (smaller payloads, simpler) - "arrow_stream": Use Arrow format (faster for large datasets) - "auto": Automatically select based on expected data size | | `transformer` | `((data: T) => T)` | | - | Transform raw data before rendering | -| `data` | `ChartData` | | - | Arrow Table or JSON array | +| `data` | `ChartDataInput` | | - | Arrow Table or JSON array | | `title` | `string` | | - | Chart title | | `showLegend` | `boolean` | | - | Show legend | | `colorPalette` | `"categorical" \| "sequential" \| "diverging"` | | - | Color palette to use. Auto-selected based on chart type if not specified. - "categorical": Distinct colors for different categories (bar, pie, line) - "sequential": Gradient for magnitude/intensity (heatmap) - "diverging": Two-tone for positive/negative values | | `colors` | `string[]` | | - | Custom colors for series (overrides colorPalette) | -| `height` | `number` | | `300` | Chart height in pixels | +| `height` | `number` | | - | Chart height in pixels. Defaults to filling its parent (see `chartSizeStyle`). | | `className` | `string` | | - | Additional CSS classes | | `xKey` | `string` | | - | X-axis field key. Auto-detected from schema if not provided. | | `yKey` | `string \| string[]` | | - | Y-axis field key(s). Auto-detected from schema if not provided. | diff --git a/docs/docs/api/appkit-ui/data/LineChart.mdx b/docs/docs/api/appkit-ui/data/LineChart.mdx index 8c817f054..992acd3e1 100644 --- a/docs/docs/api/appkit-ui/data/LineChart.mdx +++ b/docs/docs/api/appkit-ui/data/LineChart.mdx @@ -54,12 +54,12 @@ Supports both query mode (queryKey + parameters) and data mode (static data). | `parameters` | `Record` | | - | Query parameters passed to the analytics endpoint | | `format` | `"auto" \| "json_array" \| "arrow_stream" \| "json" \| "arrow"` | | `"auto"` | Data format to use - "json_array": Use JSON format (smaller payloads, simpler) - "arrow_stream": Use Arrow format (faster for large datasets) - "auto": Automatically select based on expected data size | | `transformer` | `((data: T) => T)` | | - | Transform raw data before rendering | -| `data` | `ChartData` | | - | Arrow Table or JSON array | +| `data` | `ChartDataInput` | | - | Arrow Table or JSON array | | `title` | `string` | | - | Chart title | | `showLegend` | `boolean` | | - | Show legend | | `colorPalette` | `"categorical" \| "sequential" \| "diverging"` | | - | Color palette to use. Auto-selected based on chart type if not specified. - "categorical": Distinct colors for different categories (bar, pie, line) - "sequential": Gradient for magnitude/intensity (heatmap) - "diverging": Two-tone for positive/negative values | | `colors` | `string[]` | | - | Custom colors for series (overrides colorPalette) | -| `height` | `number` | | `300` | Chart height in pixels | +| `height` | `number` | | - | Chart height in pixels. Defaults to filling its parent (see `chartSizeStyle`). | | `className` | `string` | | - | Additional CSS classes | | `xKey` | `string` | | - | X-axis field key. Auto-detected from schema if not provided. | | `yKey` | `string \| string[]` | | - | Y-axis field key(s). Auto-detected from schema if not provided. | diff --git a/docs/docs/api/appkit-ui/data/PieChart.mdx b/docs/docs/api/appkit-ui/data/PieChart.mdx index e6b7fd039..c5168fa07 100644 --- a/docs/docs/api/appkit-ui/data/PieChart.mdx +++ b/docs/docs/api/appkit-ui/data/PieChart.mdx @@ -49,12 +49,12 @@ Supports both query mode (queryKey + parameters) and data mode (static data). | `parameters` | `Record` | | - | Query parameters passed to the analytics endpoint | | `format` | `"auto" \| "json_array" \| "arrow_stream" \| "json" \| "arrow"` | | `"auto"` | Data format to use - "json_array": Use JSON format (smaller payloads, simpler) - "arrow_stream": Use Arrow format (faster for large datasets) - "auto": Automatically select based on expected data size | | `transformer` | `((data: T) => T)` | | - | Transform raw data before rendering | -| `data` | `ChartData` | | - | Arrow Table or JSON array | +| `data` | `ChartDataInput` | | - | Arrow Table or JSON array | | `title` | `string` | | - | Chart title | | `showLegend` | `boolean` | | - | Show legend | | `colorPalette` | `"categorical" \| "sequential" \| "diverging"` | | - | Color palette to use. Auto-selected based on chart type if not specified. - "categorical": Distinct colors for different categories (bar, pie, line) - "sequential": Gradient for magnitude/intensity (heatmap) - "diverging": Two-tone for positive/negative values | | `colors` | `string[]` | | - | Custom colors for series (overrides colorPalette) | -| `height` | `number` | | `300` | Chart height in pixels | +| `height` | `number` | | - | Chart height in pixels. Defaults to filling its parent (see `chartSizeStyle`). | | `className` | `string` | | - | Additional CSS classes | | `xKey` | `string` | | - | X-axis field key. Auto-detected from schema if not provided. | | `yKey` | `string \| string[]` | | - | Y-axis field key(s). Auto-detected from schema if not provided. | diff --git a/docs/docs/api/appkit-ui/data/RadarChart.mdx b/docs/docs/api/appkit-ui/data/RadarChart.mdx index 7e6958ae6..1da2ee306 100644 --- a/docs/docs/api/appkit-ui/data/RadarChart.mdx +++ b/docs/docs/api/appkit-ui/data/RadarChart.mdx @@ -53,12 +53,12 @@ Supports both query mode (queryKey + parameters) and data mode (static data). | `parameters` | `Record` | | - | Query parameters passed to the analytics endpoint | | `format` | `"auto" \| "json_array" \| "arrow_stream" \| "json" \| "arrow"` | | `"auto"` | Data format to use - "json_array": Use JSON format (smaller payloads, simpler) - "arrow_stream": Use Arrow format (faster for large datasets) - "auto": Automatically select based on expected data size | | `transformer` | `((data: T) => T)` | | - | Transform raw data before rendering | -| `data` | `ChartData` | | - | Arrow Table or JSON array | +| `data` | `ChartDataInput` | | - | Arrow Table or JSON array | | `title` | `string` | | - | Chart title | | `showLegend` | `boolean` | | - | Show legend | | `colorPalette` | `"categorical" \| "sequential" \| "diverging"` | | - | Color palette to use. Auto-selected based on chart type if not specified. - "categorical": Distinct colors for different categories (bar, pie, line) - "sequential": Gradient for magnitude/intensity (heatmap) - "diverging": Two-tone for positive/negative values | | `colors` | `string[]` | | - | Custom colors for series (overrides colorPalette) | -| `height` | `number` | | `300` | Chart height in pixels | +| `height` | `number` | | - | Chart height in pixels. Defaults to filling its parent (see `chartSizeStyle`). | | `className` | `string` | | - | Additional CSS classes | | `xKey` | `string` | | - | X-axis field key. Auto-detected from schema if not provided. | | `yKey` | `string \| string[]` | | - | Y-axis field key(s). Auto-detected from schema if not provided. | diff --git a/docs/docs/api/appkit-ui/data/ScatterChart.mdx b/docs/docs/api/appkit-ui/data/ScatterChart.mdx index 4752db05b..4803d6520 100644 --- a/docs/docs/api/appkit-ui/data/ScatterChart.mdx +++ b/docs/docs/api/appkit-ui/data/ScatterChart.mdx @@ -55,12 +55,12 @@ Supports both query mode (queryKey + parameters) and data mode (static data). | `parameters` | `Record` | | - | Query parameters passed to the analytics endpoint | | `format` | `"auto" \| "json_array" \| "arrow_stream" \| "json" \| "arrow"` | | `"auto"` | Data format to use - "json_array": Use JSON format (smaller payloads, simpler) - "arrow_stream": Use Arrow format (faster for large datasets) - "auto": Automatically select based on expected data size | | `transformer` | `((data: T) => T)` | | - | Transform raw data before rendering | -| `data` | `ChartData` | | - | Arrow Table or JSON array | +| `data` | `ChartDataInput` | | - | Arrow Table or JSON array | | `title` | `string` | | - | Chart title | | `showLegend` | `boolean` | | - | Show legend | | `colorPalette` | `"categorical" \| "sequential" \| "diverging"` | | - | Color palette to use. Auto-selected based on chart type if not specified. - "categorical": Distinct colors for different categories (bar, pie, line) - "sequential": Gradient for magnitude/intensity (heatmap) - "diverging": Two-tone for positive/negative values | | `colors` | `string[]` | | - | Custom colors for series (overrides colorPalette) | -| `height` | `number` | | `300` | Chart height in pixels | +| `height` | `number` | | - | Chart height in pixels. Defaults to filling its parent (see `chartSizeStyle`). | | `className` | `string` | | - | Additional CSS classes | | `xKey` | `string` | | - | X-axis field key. Auto-detected from schema if not provided. | | `yKey` | `string \| string[]` | | - | Y-axis field key(s). Auto-detected from schema if not provided. | diff --git a/docs/docs/api/appkit/Variable.design.md b/docs/docs/api/appkit/Variable.design.md new file mode 100644 index 000000000..755e09049 --- /dev/null +++ b/docs/docs/api/appkit/Variable.design.md @@ -0,0 +1,5 @@ +# Variable: design + +```ts +const design: ToPlugin; +``` diff --git a/docs/docs/api/appkit/index.md b/docs/docs/api/appkit/index.md index ee6db8b67..fab63f199 100644 --- a/docs/docs/api/appkit/index.md +++ b/docs/docs/api/appkit/index.md @@ -190,6 +190,7 @@ surface with `@databricks/appkit/beta`. Not meant for application imports. | ------ | ------ | | [agents](Variable.agents.md) | Plugin factory for the agents plugin. Discovers agents from `server/agents//agent.{ts,md}` by default (markdown still in `config/agents/` is read as a deprecated fallback), resolves toolkits/tools from registered plugins, exposes the `appkit.agents.*` runtime API and mounts `POST /invocations` and `POST /responses` (aliased non-streaming invoke endpoints) plus `POST /chat` (streaming, HITL-capable). | | [aiSearch](Variable.aiSearch.md) | - | +| [design](Variable.design.md) | - | | [READ\_ACTIONS](Variable.READ_ACTIONS.md) | Actions that only read data. | | [sql](Variable.sql.md) | SQL helper namespace | | [SUPERVISOR\_EXTENSION\_KEY](Variable.SUPERVISOR_EXTENSION_KEY.md) | Namespace key under which the adapter reads its hosted-tool payload from [AgentInput.extensions](Interface.AgentInput.md#extensions). Exported so the agents plugin and standalone `runAgent` (the producers) can write under the same key the adapter reads. | diff --git a/docs/docs/api/appkit/typedoc-sidebar.ts b/docs/docs/api/appkit/typedoc-sidebar.ts index dca9af23b..8f7b03f36 100644 --- a/docs/docs/api/appkit/typedoc-sidebar.ts +++ b/docs/docs/api/appkit/typedoc-sidebar.ts @@ -847,6 +847,11 @@ const typedocSidebar: SidebarsConfig = { id: "api/appkit/Variable.aiSearch", label: "aiSearch" }, + { + type: "doc", + id: "api/appkit/Variable.design", + label: "design" + }, { type: "doc", id: "api/appkit/Variable.READ_ACTIONS", diff --git a/docs/docs/plugins/analytics.md b/docs/docs/plugins/analytics.md index b144a5c08..61bbe1ba5 100644 --- a/docs/docs/plugins/analytics.md +++ b/docs/docs/plugins/analytics.md @@ -694,6 +694,13 @@ When multiple series share one value axis, its ticks use the first `yKey`; each The `selected` prop adds declarative emphasis only to bar, pie, and donut charts. Line, area, scatter, heatmap, and radar charts ignore it because category-selection semantics are not defined for those chart types. +**Chart size.** By default a chart fills its container's height, so it fits the card or grid cell it's placed in, and is at least 160px tall when the container's height comes from its content. In a fixed-height card, give the chart's parent the remaining space (e.g. `className="flex-1 min-h-0"` on `CardContent` in a `flex flex-col` card). Pass `height` (pixels) for an exact size instead: + +```tsx + {/* fills its container */} + {/* exactly 208px */} +``` + **[Plotly](https://plotly.com/javascript/)** — pass the numeric specifier as `tickformat` and the literal currency symbol as `tickprefix`. Keeping them separate is necessary because d3's `$` marker is locale-driven and cannot represent arbitrary symbols: ```tsx diff --git a/docs/docs/plugins/design.md b/docs/docs/plugins/design.md new file mode 100644 index 000000000..35f9ca49d --- /dev/null +++ b/docs/docs/plugins/design.md @@ -0,0 +1,228 @@ +--- +sidebar_position: 20 +--- + +# Design plugin + + +:::warning Beta plugin +This plugin is currently **beta**. APIs may change between minor releases. Import from `@databricks/appkit/beta`. See [Plugin Stability Tiers](./stability.md). +::: + + +Dev-only design pages, for building a layout before it becomes part of the real app. The plugin only runs with `NODE_ENV=development`. In any other environment it does nothing, and `/design` returns 404. + +## Basic usage + +```ts +import { createApp, server } from "@databricks/appkit"; +import { design } from "@databricks/appkit/beta"; + +await createApp({ + plugins: [server(), design({ builderOrigin: "https://builder.example.com" })], +}); +``` + +## Configuration + +| Option | Type | Description | +| ------ | ---- | ----------- | +| `builderOrigin` | `string \| string[]` | Origin(s) of the app builder that embeds the app. The [design](#readiness) and [app](#app-readiness) ready messages are posted only to these origins, and `/design/__status` answers CORS for them. Must be a bare origin like `"https://builder.example.com"`; `"*"` is rejected. Defaults to the `APPKIT_DESIGN_BUILDER_ORIGIN` environment variable (one origin, or several separated by commas), so a builder's runtime can set it for a scaffolded `design()` with no options; an explicit `builderOrigin` wins. Optional: without either, no message is posted, and `/design/__status` still works. | + +## Pages + +| URL | File | +| --- | ---- | +| `/design` | `client/src/design/index.tsx` | +| `/design/` | `client/src/design/pages/.tsx` | + +Each file's default export is the page, rendered on its own at full size with the app's styles (`client/src/index.css`). Anything else under `/design` returns 404. The page names `index` and anything starting with `__` are reserved (`pages/index.tsx` would collide with `/design`, and `__status` is the plugin's own endpoint), so such files aren't served or listed. Pages hot-reload like the rest of the app. The requested page is loaded directly and other pages only when navigated to, so an unfinished draft only breaks its own URL. + +When the app has `react-router` (the app template does), design pages share a router whose basename is `/design`. Links between them stay inside `/design`, and keep working when the pages are later promoted into the app's own routes: + +```tsx +import { Link } from "react-router"; + +Orders; // goes to /design/orders +``` + +Without `react-router`, each page renders on its own; link with ``. + +```tsx +// client/src/design/index.tsx +import { Button, Card, CardContent, CardHeader, CardTitle } from "@databricks/appkit-ui/react"; + +export default function DesignPage() { + return ( + + + Orders + + + + + + ); +} +``` + +## Mapping elements to source + +In development, the dev server adds `data-source="file:line:col"` to every native JSX element, on design pages and app pages alike. A builder's element inspector can use it to map what was clicked back to the code that renders it. + +## Readiness + +A builder that embeds `/design` can wait until there is a real design to show, instead of revealing an empty page. `/design` is **ready** once `client/src/design/index.tsx` exists, isn't the template placeholder, and has rendered in a browser since it last changed. **`ready` needs a browser, but not a visible one**: a client has to load `/design`, and a hidden iframe is enough (nothing waits for a painted frame, and nothing looks at pixels). To wait without a browser at all, use `compiled` and `waitingFor` (below). A page that throws while rendering (in the page file or any component it uses) shows the error in place, reports it, and turns `ready` back to `false` until a render succeeds again. + +### What "ready" waits for + +After a successful render the page waits until it shows its real content before reporting: + +- web fonts have loaded (`document.fonts.ready`); +- no `Suspense` fallback is on screen (lazy components and data that suspend); +- no `fetch` started by the page is still in flight (its data, whatever kind); +- the page's DOM hasn't changed for 300 ms (lists filling in, content appearing); +- then two animation frames, or 100 ms without any: a hidden page (a builder's preview kept out of sight) gets no animation frames, and still reports. + +It never reports while the page is the template placeholder or is showing a render error. The automatic waits give up after 10 seconds, so a stream that never ends can't hold the page back forever. + +### Holding readiness back: `useDesignReady` + +For content the page can't detect on its own (a chart that draws on a timer, an animation, data from a source that isn't `fetch`), hold readiness back explicitly. While any mounted component passes `false`, the page isn't ready, with no time limit: + +```tsx +import { useDesignReady } from "@databricks/appkit-ui/react/beta"; + +function TripsPerDay() { + const { data, loading } = useAnalyticsQuery("trips_per_day", {}); + useDesignReady(!loading && data != null); + return ; +} +``` + +It also holds back [app readiness](#app-readiness) on the app's own pages in development. Elsewhere (production, tests) it does nothing, so it can stay in components that are later promoted into the app. + +### Placeholder + +The app template's starter page is marked as a placeholder by this export. Delete it when you write the real page: + +```tsx +export const designPlaceholder = true; +``` + +### `GET /design/__status` + +```json +{ + "ready": true, + "compiled": true, + "pages": [ + { "name": "index", "path": "/design", "compiled": true, "rendered": true, "errors": [], "state": "ready" }, + { "name": "orders", "path": "/design/orders", "compiled": true, "rendered": true, "errors": [{ "error": "Unhandled rejection: save failed" }], "state": "failed" } + ], + "app": { "entry": "/src/main.tsx", "compiled": true, "path": "/", "rendered": true, "template": false, "errors": [] } +} +``` + +`pages` lists every design page, index first, so a builder can show a page switcher. For each, as a browser (hidden is fine) last reported it for the file's current version: + +- `rendered`: React mounted the page without a render error and it settled (see above). +- `errors`: what the page hit: its render error (caught by the page's error boundary), uncaught errors (event handlers, timers), unhandled promise rejections and Vite compile errors, each `{ "error": message }`. `null` until a browser has loaded this version of the page (unknown, not "no errors"). Errors are cleared when the page loads again or takes a hot update (also one to a component it imports): what's still wrong is reported again, so a fixed error disappears without a reload. + +`app` is the same for the app's own pages (not `/design`): + +- `entry`: the app's entry module, from `client/index.html`. +- `compiled`: that module and everything it imports build, checked server-side like `compiled` below (`compileError` when they don't). +- `path`: the page last reported. Each path is tracked on its own, so two tabs on different pages don't reset each other; `rendered`, `template` and `errors` are this path's. +- `rendered`: something is mounted in `#root` and the page settled (a crash outside an error boundary leaves `#root` empty: `false`, and its error is listed). +- `template`: it's the template's default home page. +- `errors`: as for `pages` (`null` until a page has loaded since the dev server started). + +The status also carries: + +| Field | Meaning | +| ----- | ------- | +| `compiled` | The index page and every module of the app it imports, transitively, build (compiled server-side as a browser would load them, so no browser is needed; dependencies in `node_modules` aren't recompiled). | +| `compileError` | Why it doesn't, prefixed with the failing module (`src/design/Card.tsx: ...`). | +| `waitingFor` | What `ready` is waiting on: `"index"` (no `index.tsx`), `"placeholder"` (still the template page), `"compile"` (build error), `"paint"` (no browser has rendered it yet, visible or not; the name is kept for compatibility), or `null` when ready. | +| `placeholder` | `index.tsx` is still the template placeholder. | + +An agent writing the page waits for `compiled: true`; a builder that wants to reveal it loads `/design` (hidden is fine) and waits for `ready` or the `appkit:design-ready` message. To check that a page works, an agent reads `rendered` and `errors` here, for any kind of app (a game, a form, a chat, a dashboard); it never needs the preview to be visible, a resize, or a look at its pixels: + +```ts +const s = await (await fetch(`${appUrl}/design/__status`)).json(); +const appWorks = s.app.compiled && s.app.rendered && s.app.errors?.length === 0; +const pagesWork = s.pages.every((p) => p.rendered && p.errors?.length === 0); +``` + +### `appkit:design-ready` message + +Once a design page is ready (see above) and again after each hot update that leaves it ready, the page posts this to `window.parent` (or `window.opener`), only to the configured `builderOrigin`s: + +```ts +interface DesignReadyMessage { + type: "appkit:design-ready"; + version: 1; + page: string; // name of the page that painted ("index" or ) + pages: { name: string; path: string }[]; +} +``` + +```ts +// In the app builder: +window.addEventListener("message", (event) => { + if (event.origin !== appOrigin) return; + if (event.data?.type === "appkit:design-ready" && event.data.version === 1) { + showDesign(event.data.pages); + } +}); +``` + +## App readiness + +The same idea for the real app, so a builder never reveals the template's default home page. In development the plugin adds a small script to the app's own pages (not `/design`). It always reports to `/design/__status` (`app`, above); with `builderOrigin` set it also posts to the builder. Once a page has settled (the same waits as [design readiness](#what-ready-waits-for), including `useDesignReady`), and again after client-side navigation and hot updates, it posts: + +```ts +interface AppReadyMessage { + type: "appkit:app-ready"; + version: 1; + path: string; // location.pathname of the page that is ready +} +``` + +This is the signal for an app that skips `/design` (e.g. one that needs no data): the builder can load the app's `/` in a hidden frame and reveal it when `appkit:app-ready` arrives. It isn't posted while nothing is mounted in `#root` (a crash), and it's sent from a hidden frame too (the final wait for a paint gives up after 100 ms, as on `/design`). + +It isn't posted while an element with the `data-appkit-template-home` attribute is on screen. The app template's default home page (`HomePage` in `client/src/App.tsx`) carries it; a builder can also look for it directly. Remove the attribute, or the page, when the app gets its real home page, and the next settle (a hot update counts) posts `appkit:app-ready`. + +```ts +// In the app builder: +window.addEventListener("message", (event) => { + if (event.origin !== appOrigin) return; + if (event.data?.type === "appkit:app-ready" && event.data.version === 1) { + showApp(event.data.path); + } +}); +``` + +## TSX drafts + +TSX drafts are design pages written with real appkit-ui components. The plugin only renders them; whatever writes them (an app builder) owns the prompt, the model calls, the mock data and the type-check. A builder writes: + +- `client/src/design/mock.ts`: the sample data the drafts share (any module works; the pages import it as `../mock`). +- `client/src/design/pages/.tsx` per draft, default-exporting the page, served at `/design/`. Write a page there only once it type-checks (e.g. write elsewhere, then move it in), so a half-written file never shows. Use theme tokens (`bg-background`, `text-foreground`, ...), so drafts work in dark mode. +- After moving a page in, touch `client/src/index.css` (update its modification time). Tailwind's Vite plugin only rescans sources for classes when the stylesheet changes, so until then a new page renders without its Tailwind styles. + +Each page renders in its own error boundary and posts `appkit:design-ready` once rendered. Its state is in `GET /design/__status` under `pages` (see above): `compiled` / `compileError` (built server-side, no browser needed), `rendered`, `errors`, and `state`: `compile-error`, `rendering` (builds, not rendered yet), `ready` or `failed` (a browser reported errors). + +### Streaming a draft while it's written + +A builder can show a draft while it's still being written, instead of only once it's finished: + +- Append the page's source, as it arrives, to `client/src/design/.staging/.tsx` (and the shared mock's to `.staging/mock.ts`). +- Show `/design/__stream/`. The dev server keeps the latest renderable version of the staging file under the page's real module (`pages/.tsx`): it closes whatever is still open at the cut (JSX tags, brackets, template literals) and stubs what the page uses that hasn't arrived yet. Components not written yet show a `DraftPlaceholder` skeleton of the right shape. Each version reaches the page as a hot update, so it fills in in place, with no reload and with its state kept. +- Until `mock.ts` exists, the page's `../mock` import is what `.staging/mock.ts` has so far, plus stubs for whatever the page imports from it that isn't there yet. +- When the page type-checks, write it to `pages/.tsx` (and `mock.ts`): the same module then loads from disk. Deleting the staging file starts that draft over. + +Every design page, streamed or not, renders through `DesignPage` (from `@databricks/appkit-ui/react/beta`; the plugin adds it, pages don't). It applies the app's theme before the first paint (`?theme=dark` or `?theme=light` on the URL, else the system setting), so a dark page never flashes light. If a render fails mid-stream, it keeps the last good frame on screen instead of a broken page, and holds readiness back until a render succeeds. + +Lock-in is a file operation for the builder: keep the chosen page as `client/src/design/index.tsx` (`/design`, repointing `../mock` to `./mock`), or as the app's home page for apps with no data, and delete the other drafts. diff --git a/docs/static/appkit-ui/styles.gen.css b/docs/static/appkit-ui/styles.gen.css index 0cc301c72..47d36b685 100644 --- a/docs/static/appkit-ui/styles.gen.css +++ b/docs/static/appkit-ui/styles.gen.css @@ -404,6 +404,9 @@ .m-1 { margin: calc(var(--spacing) * 1); } + .m-10 { + margin: calc(var(--spacing) * 10); + } .-mx-1 { margin-inline: calc(var(--spacing) * -1); } @@ -648,6 +651,9 @@ .h-32 { height: calc(var(--spacing) * 32); } + .h-56 { + height: calc(var(--spacing) * 56); + } .h-72 { height: calc(var(--spacing) * 72); } @@ -711,6 +717,9 @@ .min-h-\[100vh\] { min-height: 100vh; } + .min-h-screen { + min-height: 100vh; + } .min-h-svh { min-height: 100svh; } @@ -726,6 +735,9 @@ .w-1\/2 { width: calc(1/2 * 100%); } + .w-1\/3 { + width: calc(1/3 * 100%); + } .w-2 { width: calc(var(--spacing) * 2); } diff --git a/packages/appkit-ui/src/react/__tests__/design-page.test.tsx b/packages/appkit-ui/src/react/__tests__/design-page.test.tsx new file mode 100644 index 000000000..03e83ea06 --- /dev/null +++ b/packages/appkit-ui/src/react/__tests__/design-page.test.tsx @@ -0,0 +1,94 @@ +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { DesignPage, DraftPlaceholder } from "../design-page"; + +type Host = { blockers: number; changed?: () => void }; +const win = window as { __appkitDesign?: Host }; + +const frame = () => + act(() => new Promise((r) => requestAnimationFrame(() => r(undefined)))); + +function Page({ text }: { text: string }) { + if (text === "boom") throw new Error("boom"); + return

{text}

; +} + +afterEach(() => { + cleanup(); + delete win.__appkitDesign; + document.documentElement.classList.remove("dark"); + history.replaceState(null, "", "/"); +}); + +describe("DesignPage", () => { + test("keeps the last good frame on a render error, holds readiness, and retries", async () => { + win.__appkitDesign = { blockers: 0 }; + const onError = vi.fn(); + vi.spyOn(console, "error").mockImplementation(() => {}); + const { rerender } = render( + + + , + ); + await frame(); + + rerender( + + + , + ); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ message: "boom" }), + ); + // The copy of the last good frame, not an error. + expect(screen.getByRole("heading").textContent).toBe("Trips"); + expect(document.querySelector("[data-design-last-good]")).not.toBeNull(); + expect(win.__appkitDesign.blockers).toBe(1); + + rerender( + + + , + ); + expect(screen.getByRole("heading").textContent).toBe("Trips today"); + expect(win.__appkitDesign.blockers).toBe(0); + }); + + test("shows the error when nothing rendered yet, or the given fallback", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + const { rerender } = render( + + + , + ); + expect(screen.getByText(/Design page failed to render: boom/)).toBeTruthy(); + rerender( + null} resetKey={1}> + + , + ); + expect(screen.queryByText(/failed to render/)).toBeNull(); + }); + + test("applies ?theme=dark before the first paint", () => { + history.replaceState(null, "", "/design?theme=dark"); + render( + + + , + ); + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); +}); + +describe("DraftPlaceholder", () => { + test("renders a skeleton of the given shape", () => { + const { container } = render(); + expect( + container.querySelector( + '[data-draft-placeholder="chart"] [data-slot="skeleton"]', + ), + ).not.toBeNull(); + }); +}); diff --git a/packages/appkit-ui/src/react/beta.ts b/packages/appkit-ui/src/react/beta.ts index 5b1053b14..368800f6d 100644 --- a/packages/appkit-ui/src/react/beta.ts +++ b/packages/appkit-ui/src/react/beta.ts @@ -75,3 +75,12 @@ export type { UseDatabaseWriteOptions, UseDatabaseWriteState, } from "./hooks/use-database-write"; + +// For pages of the dev-only `design` plugin ('@databricks/appkit/beta'). +export { useDesignReady } from "./hooks/use-design-ready"; +export { + DesignPage, + type DesignPageProps, + DraftPlaceholder, + type DraftPlaceholderKind, +} from "./design-page"; diff --git a/packages/appkit-ui/src/react/charts/__tests__/base.test.tsx b/packages/appkit-ui/src/react/charts/__tests__/base.test.tsx index 06439bee2..49daa4668 100644 --- a/packages/appkit-ui/src/react/charts/__tests__/base.test.tsx +++ b/packages/appkit-ui/src/react/charts/__tests__/base.test.tsx @@ -294,3 +294,40 @@ describe("BaseChart ECharts registration", () => { expect(registrationErrors).toEqual([]); }); }); + +describe("BaseChart sizing", () => { + test("fills its parent's height by default, with a minimum height", async () => { + const { container } = render( + , + ); + await waitFor(() => + expect(container.querySelector("canvas")).not.toBeNull(), + ); + const root = container.firstElementChild as HTMLElement; + expect(root.style.height).toBe("100%"); + expect(root.style.minHeight).toBe("160px"); + }); + + test("an explicit height is exactly that tall", async () => { + const { container } = render( + , + ); + await waitFor(() => + expect(container.querySelector("canvas")).not.toBeNull(), + ); + const root = container.firstElementChild as HTMLElement; + expect(root.style.height).toBe("208px"); + expect(root.style.minHeight).toBe(""); + }); +}); diff --git a/packages/appkit-ui/src/react/charts/__tests__/chart-data-input.types.test.ts b/packages/appkit-ui/src/react/charts/__tests__/chart-data-input.types.test.ts new file mode 100644 index 000000000..9bddcfbc2 --- /dev/null +++ b/packages/appkit-ui/src/react/charts/__tests__/chart-data-input.types.test.ts @@ -0,0 +1,28 @@ +import { describe, expectTypeOf, test } from "vitest"; + +import type { BarChartProps, ChartDataInput, ChartRow } from "../types"; + +// A pure data module, typed with an interface (no index signature) and kept +// readonly: it must be usable as chart data without refactoring. +interface Trip { + day: string; + trips: number; +} +const trips: readonly Trip[] = [ + { day: "Mon", trips: 12 }, + { day: "Tue", trips: 19 }, +] as const; + +describe("chart data input types", () => { + test("interface-typed and readonly rows are valid chart data", () => { + expectTypeOf(trips).toMatchTypeOf(); + expectTypeOf<{ data: readonly Trip[] }>().toMatchTypeOf< + Pick, "data"> + >(); + }); + + test("ChartRow has an index signature for annotating data modules", () => { + const rows: ChartRow[] = [{ day: "Mon", trips: 12 }]; + expectTypeOf(rows[0]).toEqualTypeOf>(); + }); +}); diff --git a/packages/appkit-ui/src/react/charts/__tests__/sizing.test.tsx b/packages/appkit-ui/src/react/charts/__tests__/sizing.test.tsx new file mode 100644 index 000000000..c99f86454 --- /dev/null +++ b/packages/appkit-ui/src/react/charts/__tests__/sizing.test.tsx @@ -0,0 +1,44 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; + +import { CHART_MIN_HEIGHT, chartSizeStyle } from "../constants"; +import { ChartWrapper } from "../wrapper"; + +afterEach(cleanup); + +describe("chartSizeStyle", () => { + test("fills the parent by default, never below the minimum", () => { + expect(chartSizeStyle()).toEqual({ + height: "100%", + minHeight: CHART_MIN_HEIGHT, + }); + }); + + test("an explicit height wins", () => { + expect(chartSizeStyle(208)).toEqual({ height: 208 }); + }); +}); + +describe("ChartWrapper sizing", () => { + test("its box fills the parent by default", () => { + render( + + {() => chart} + , + ); + const box = screen.getByTestId("w"); + expect(box.style.height).toBe("100%"); + expect(box.style.minHeight).toBe(`${CHART_MIN_HEIGHT}px`); + }); + + test("and is exactly `height` tall when given", () => { + render( + + {() => chart} + , + ); + const box = screen.getByTestId("w"); + expect(box.style.height).toBe("208px"); + expect(box.style.minHeight).toBe(""); + }); +}); diff --git a/packages/appkit-ui/src/react/charts/base.tsx b/packages/appkit-ui/src/react/charts/base.tsx index 814fd4b67..28b1ca831 100644 --- a/packages/appkit-ui/src/react/charts/base.tsx +++ b/packages/appkit-ui/src/react/charts/base.tsx @@ -28,6 +28,7 @@ import { LegacyGridContainLabel } from "echarts/features"; import { CanvasRenderer } from "echarts/renderers"; import { useCallback, useMemo, useRef } from "react"; +import { chartSizeStyle } from "./constants"; import { normalizeChartData, normalizeHeatmapData } from "./normalize"; import { applySelectionEmphasis, @@ -123,7 +124,7 @@ export interface BaseChartProps { yKey?: string | string[]; /** Chart orientation @default "vertical" */ orientation?: Orientation; - /** Chart height in pixels @default 300 */ + /** Chart height in pixels. Defaults to filling its parent (see `chartSizeStyle`). */ height?: number; /** Chart title */ title?: string; @@ -204,7 +205,7 @@ export function BaseChart({ xKey, yKey, orientation, - height = 300, + height, title, showLegend = true, colorPalette, @@ -433,7 +434,10 @@ export function BaseChart({ if (!option) { return ( -
+
No data
); @@ -444,7 +448,7 @@ export function BaseChart({ ref={chartRefCallback} echarts={echarts} option={option} - style={{ height }} + style={chartSizeStyle(height)} className={className} opts={{ renderer: "canvas" }} notMerge={false} diff --git a/packages/appkit-ui/src/react/charts/constants.ts b/packages/appkit-ui/src/react/charts/constants.ts index 83674251a..8eda4ac0f 100644 --- a/packages/appkit-ui/src/react/charts/constants.ts +++ b/packages/appkit-ui/src/react/charts/constants.ts @@ -122,3 +122,19 @@ export const FALLBACK_UI_TOKENS: ChartUITokens = { grid: "hsla(240, 5%, 90%, 1)", // ≈ --border tooltipBg: "hsla(0, 0%, 100%, 1)", // ≈ --popover }; + +/** Smallest height a chart takes when it fills its parent (no `height` prop). */ +export const CHART_MIN_HEIGHT = 160; + +/** + * A chart's box: exactly `height` pixels, or its parent's height, at least + * {@link CHART_MIN_HEIGHT} when that comes from its content. + */ +export function chartSizeStyle(height?: number): { + height: number | string; + minHeight?: number; +} { + return height !== undefined + ? { height } + : { height: "100%", minHeight: CHART_MIN_HEIGHT }; +} diff --git a/packages/appkit-ui/src/react/charts/create-chart.tsx b/packages/appkit-ui/src/react/charts/create-chart.tsx index bca331947..d040257ba 100644 --- a/packages/appkit-ui/src/react/charts/create-chart.tsx +++ b/packages/appkit-ui/src/react/charts/create-chart.tsx @@ -1,5 +1,5 @@ import { BaseChart } from "./base"; -import type { ChartType, UnifiedChartProps } from "./types"; +import type { ChartData, ChartType, UnifiedChartProps } from "./types"; import { ChartWrapper } from "./wrapper"; /** @@ -30,7 +30,7 @@ export function createChart( // Data props data, // Common props - height = 300, + height, className, ariaLabel, testId, @@ -50,7 +50,9 @@ export function createChart( const wrapperProps = data !== undefined - ? { data, height, className, ariaLabel, testId } + ? // ChartDataInput also admits interface-typed and readonly rows; at + // runtime they're the same plain objects the chart reads. + { data: data as ChartData, height, className, ariaLabel, testId } : { queryKey: queryKey as string, parameters, diff --git a/packages/appkit-ui/src/react/charts/index.ts b/packages/appkit-ui/src/react/charts/index.ts index 589a9cd90..0cd6723b4 100644 --- a/packages/appkit-ui/src/react/charts/index.ts +++ b/packages/appkit-ui/src/react/charts/index.ts @@ -108,6 +108,8 @@ export type { ChartClickDatum, ChartColorPalette, ChartData, + ChartDataInput, + ChartRow, ChartType, ChartUITokens, ChartValueFormatter, diff --git a/packages/appkit-ui/src/react/charts/types.ts b/packages/appkit-ui/src/react/charts/types.ts index 3def1b913..c2e6ff3a1 100644 --- a/packages/appkit-ui/src/react/charts/types.ts +++ b/packages/appkit-ui/src/react/charts/types.ts @@ -38,6 +38,12 @@ export type ChartType = /** Data that can be passed to unified charts */ export type ChartData = Table | Record[]; +/** One row of chart data (indexable, to type plain data modules). */ +export type ChartRow = Record; + +/** A chart's `data`: an Arrow Table, or rows (interfaces and `as const` arrays too). */ +export type ChartDataInput = Table | readonly object[]; + /** Formats a measure value for chart axes, tooltips, and value labels. */ export type ChartValueFormatter = ( value: string | number, @@ -78,7 +84,7 @@ export interface ChartBaseProps { colorPalette?: ChartColorPalette; /** Custom colors for series (overrides colorPalette) */ colors?: string[]; - /** Chart height in pixels @default 300 */ + /** Chart height in pixels. Defaults to filling its parent (see `chartSizeStyle`). */ height?: number; /** Additional CSS classes */ className?: string; @@ -188,7 +194,7 @@ export interface QueryProps extends ChartBaseProps { /** Props for direct data injection */ export interface DataProps extends ChartBaseProps { /** Arrow Table or JSON array */ - data: ChartData; + data: ChartDataInput; // Discriminator: cannot use query props with direct data queryKey?: never; diff --git a/packages/appkit-ui/src/react/charts/wrapper.tsx b/packages/appkit-ui/src/react/charts/wrapper.tsx index ead715c6c..3b5ef24a0 100644 --- a/packages/appkit-ui/src/react/charts/wrapper.tsx +++ b/packages/appkit-ui/src/react/charts/wrapper.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from "react"; import type { WarehouseStatus } from "../hooks/types"; import { useChartData } from "../hooks/use-chart-data"; import { ChartErrorBoundary } from "./chart-error-boundary"; +import { chartSizeStyle } from "./constants"; import { EmptyState } from "./empty"; import { ErrorState } from "./error"; import { LoadingSkeleton, ResourceWaitingPlaceholder } from "./loading"; @@ -54,7 +55,7 @@ interface ChartWrapperDataProps { } interface CommonProps { - /** Chart height in pixels */ + /** Chart height in pixels. By default the chart fills its parent's height (at least 160px). */ height?: number; /** Additional CSS classes */ className?: string; @@ -92,9 +93,19 @@ function QueryModeContent({ }); if (isWarehouseWaiting(loading, data, warehouseStatus)) { - return ; + return ( +
+ +
+ ); + } + if (loading) { + return ( +
+ +
+ ); } - if (loading) return ; if (error) return ; if (isEmpty || !data) return ; @@ -104,7 +115,7 @@ function QueryModeContent({ >
{ + const param = new URLSearchParams(location.search).get("theme"); + const media = window.matchMedia?.("(prefers-color-scheme: dark)"); + const apply = () => + document.documentElement.classList.toggle( + "dark", + param ? param === "dark" : !!media?.matches, + ); + apply(); + if (param || !media) return; + media.addEventListener("change", apply); + return () => media.removeEventListener("change", apply); + }, []); +} + +/** The last good frame: the page's DOM, with chart canvases as pixels. */ +interface Frame { + nodes: Node[]; + pixels: HTMLCanvasElement[]; +} + +function capture(root: HTMLElement): Frame { + return { + nodes: Array.from(root.childNodes).map((n) => n.cloneNode(true)), + pixels: Array.from(root.querySelectorAll("canvas")).map((c) => { + const copy = document.createElement("canvas"); + copy.width = c.width; + copy.height = c.height; + // A canvas not laid out yet (0x0) can't be drawn from. + if (c.width && c.height) copy.getContext("2d")?.drawImage(c, 0, 0); + return copy; + }), + }; +} + +function restore(holder: HTMLElement | null, frame: Frame) { + if (!holder) return; + holder.replaceChildren(...frame.nodes.map((n) => n.cloneNode(true))); + holder.querySelectorAll("canvas").forEach((c, i) => { + const px = frame.pixels[i]; + if (!px?.width || !px.height) return; + c.width = px.width; + c.height = px.height; + c.getContext("2d")?.drawImage(px, 0, 0); + }); +} + +function HoldReady() { + useDesignReady(false); + return null; +} + +interface LastGoodFrameProps { + children: ReactNode; + frame: { current: Frame | null }; + resetKey: unknown; + onError?: (error: Error) => void; + errorFallback: (error: Error) => ReactNode; +} + +/** On a render error, shows the last good frame (or `errorFallback`) and holds readiness back. */ +interface LastGoodFrameState { + error: Error | null; + resetKey: unknown; +} + +class LastGoodFrame extends Component { + state: LastGoodFrameState = { error: null, resetKey: this.props.resetKey }; + static getDerivedStateFromError(error: Error) { + return { error }; + } + // A new resetKey retries: render the children again. + static getDerivedStateFromProps( + props: LastGoodFrameProps, + state: LastGoodFrameState, + ): Partial | null { + return props.resetKey === state.resetKey + ? null + : { error: null, resetKey: props.resetKey }; + } + componentDidCatch(error: Error) { + this.props.onError?.(error); + } + render() { + const { error } = this.state; + if (!error) return this.props.children; + const frame = this.props.frame.current; + return ( + <> + + {frame ? ( +
restore(el, frame)} + /> + ) : ( +
+ {this.props.errorFallback(error)} +
+ )} + + ); + } +} + +const defaultErrorFallback = (error: Error) => ( +
+    Design page failed to render: {error.message}
+  
+); + +export interface DesignPageProps { + children: ReactNode; + /** Called with each render error the page hits. */ + onError?: (error: Error) => void; + /** Changing it retries rendering after an error (e.g. after a hot update). */ + resetKey?: unknown; + /** Shown for an error before anything rendered. Defaults to the message. */ + errorFallback?: (error: Error) => ReactNode; +} + +/** + * The shell the `design()` plugin renders every `/design` page in: the app's + * theme from the first paint, and the last good frame instead of a broken one. + */ +export function DesignPage({ + children, + onError, + resetKey, + errorFallback = defaultErrorFallback, +}: DesignPageProps) { + useDesignTheme(); + const root = useRef(null); + const frame = useRef(null); + // Hot updates re-render the page without this component: copy every frame. + useLayoutEffect(() => { + const el = root.current; + if (!el) return; + // Only what the page rendered, not an error or a restored copy. + const keep = () => { + if (!el.querySelector("[data-design-last-good], [data-design-error]")) { + frame.current = capture(el); + } + }; + // The first frame is already in place: no mutation reports it. + if (el.childNodes.length > 0) keep(); + let pending = false; + const observer = new MutationObserver(() => { + if (pending) return; + pending = true; + requestAnimationFrame(() => { + pending = false; + keep(); + }); + }); + observer.observe(el, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + }); + return () => observer.disconnect(); + }, []); + return ( +
+ + {children} + +
+ ); +} + +export type DraftPlaceholderKind = "chart" | "table" | "card" | "generic"; + +/** Skeleton the `design()` plugin shows for a component a streaming draft hasn't defined yet. */ +export function DraftPlaceholder({ + kind = "generic", +}: { + kind?: DraftPlaceholderKind; +}) { + if (kind === "chart") { + return ( +
+ {[40, 65, 50, 80, 60, 90, 70].map((h) => ( + + ))} +
+ ); + } + if (kind === "table") { + return ( +
+ + {[0, 1, 2, 3].map((r) => ( + + ))} +
+ ); + } + if (kind === "card") { + return ( +
+ + + +
+ ); + } + return ( +
+ + +
+ ); +} diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-design-ready.test.tsx b/packages/appkit-ui/src/react/hooks/__tests__/use-design-ready.test.tsx new file mode 100644 index 000000000..338e145aa --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-design-ready.test.tsx @@ -0,0 +1,47 @@ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { useDesignReady } from "../use-design-ready"; + +type Host = { blockers: number; changed?: () => void }; +const win = window as { __appkitDesign?: Host }; + +function Probe({ ready }: { ready?: boolean }) { + useDesignReady(ready); + return null; +} + +afterEach(() => { + cleanup(); + delete win.__appkitDesign; +}); + +describe("useDesignReady", () => { + test("holds readiness while false and releases it, notifying the page", () => { + const changed = vi.fn(); + win.__appkitDesign = { blockers: 0, changed }; + + const { rerender, unmount } = render(); + expect(win.__appkitDesign.blockers).toBe(1); + + rerender(); + expect(win.__appkitDesign.blockers).toBe(0); + expect(changed).toHaveBeenCalledTimes(1); + + rerender(); + unmount(); + expect(win.__appkitDesign.blockers).toBe(0); + expect(changed).toHaveBeenCalledTimes(2); + }); + + test("ready by default", () => { + win.__appkitDesign = { blockers: 0 }; + render(); + expect(win.__appkitDesign.blockers).toBe(0); + }); + + test("does nothing outside /design", () => { + expect(() => render()).not.toThrow(); + expect(win.__appkitDesign).toBeUndefined(); + }); +}); diff --git a/packages/appkit-ui/src/react/hooks/use-design-ready.ts b/packages/appkit-ui/src/react/hooks/use-design-ready.ts new file mode 100644 index 000000000..61cde31b5 --- /dev/null +++ b/packages/appkit-ui/src/react/hooks/use-design-ready.ts @@ -0,0 +1,34 @@ +import { useLayoutEffect } from "react"; + +/** CONTRACT: same key and shape as `window.__appkitDesign` in the design plugin's vite-plugin.ts. */ +interface DesignHost { + blockers: number; + changed?: () => void; +} + +/** + * Holds back a page's readiness (`/design/__status`, `appkit:design-ready`, + * `appkit:app-ready`) while `ready` is `false`, for content the page can't + * detect on its own. Does nothing without the `design()` plugin's dev scripts. + * + * @example + * ```tsx + * function TripsChart() { + * const { data, loading } = useAnalyticsQuery("trips_per_day", {}); + * useDesignReady(!loading && data != null); + * return ; + * } + * ``` + */ +export function useDesignReady(ready = true): void { + useLayoutEffect(() => { + if (ready) return; + const host = (window as { __appkitDesign?: DesignHost }).__appkitDesign; + if (!host) return; + host.blockers++; + return () => { + host.blockers--; + host.changed?.(); + }; + }, [ready]); +} diff --git a/packages/appkit/src/plugins/analytics/query.ts b/packages/appkit/src/plugins/analytics/query.ts index 4b57b051b..34c2f3f24 100644 --- a/packages/appkit/src/plugins/analytics/query.ts +++ b/packages/appkit/src/plugins/analytics/query.ts @@ -4,6 +4,7 @@ import { isSQLTypeMarker, type SQLTypeMarker, sql as sqlHelpers } from "shared"; import { getWorkspaceId } from "../../context"; import { ValidationError } from "../../errors"; +import { extractParameters } from "../../utils/sql-parameters"; import type { StatementParameter } from "../../workspace-client"; type SQLParameterValue = SQLTypeMarker | null | undefined; @@ -16,8 +17,7 @@ export class QueryProcessor { const processed = { ...parameters }; // extract all params from the query - const paramMatches = query.matchAll(/:([a-zA-Z_]\w*)/g); - const queryParams = new Set(Array.from(paramMatches, (m) => m[1])); + const queryParams = new Set(extractParameters(query)); // auto-inject workspaceId if needed and not provided if (queryParams.has("workspaceId") && !processed.workspaceId) { @@ -42,8 +42,7 @@ export class QueryProcessor { if (parameters) { // extract all params from the query - const queryParamMatches = query.matchAll(/:([a-zA-Z_]\w*)/g); - const queryParams = new Set(Array.from(queryParamMatches, (m) => m[1])); + const queryParams = new Set(extractParameters(query)); // only allow parameters that exist in the query for (const key of Object.keys(parameters)) { diff --git a/packages/appkit/src/plugins/beta-exports.generated.ts b/packages/appkit/src/plugins/beta-exports.generated.ts index 7e556ebd9..aab6fb478 100644 --- a/packages/appkit/src/plugins/beta-exports.generated.ts +++ b/packages/appkit/src/plugins/beta-exports.generated.ts @@ -7,3 +7,4 @@ export { agents } from "./agents"; export { aiSearch } from "./ai-search"; +export { design } from "./design"; diff --git a/packages/appkit/src/plugins/design/index.ts b/packages/appkit/src/plugins/design/index.ts new file mode 100644 index 000000000..794f81ec5 --- /dev/null +++ b/packages/appkit/src/plugins/design/index.ts @@ -0,0 +1,81 @@ +import type { BasePluginConfig } from "shared"; + +import { Plugin, toPlugin } from "../../plugin"; +import { defineManifest } from "../../registry"; +import manifest from "./manifest.json"; +import { designVitePlugin } from "./vite-plugin"; + +const isDev = () => process.env.NODE_ENV === "development"; + +/** Set by a builder's runtime, so a scaffolded `design()` needs no options. */ +const BUILDER_ORIGIN_ENV = "APPKIT_DESIGN_BUILDER_ORIGIN"; + +interface DesignConfig extends BasePluginConfig { + /** + * Origin(s) of the app builder that embeds the app, e.g. + * `"https://builder.example.com"`: the only targets of the ready messages. + * Bare origins only (`"*"` is rejected). Defaults to the + * `APPKIT_DESIGN_BUILDER_ORIGIN` environment variable (comma-separated). + */ + builderOrigin?: string | string[]; +} + +function parseOrigins(value: DesignConfig["builderOrigin"]): string[] { + const fromEnv = value === undefined; + const origins = fromEnv + ? (process.env[BUILDER_ORIGIN_ENV] ?? "") + .split(",") + .map((o) => o.trim()) + .filter(Boolean) + : [value].flat(); + return origins.map((origin) => { + let parsed: URL | undefined; + try { + parsed = new URL(origin); + } catch {} + if (!parsed || parsed.origin === "null" || parsed.origin !== origin) { + throw new Error( + `design(): ${fromEnv ? BUILDER_ORIGIN_ENV : "builderOrigin"} "${origin}" must be a bare origin like "https://builder.example.com" ("*" is not allowed)`, + ); + } + return origin; + }); +} + +/** + * Dev-only design pages: `/design` (`client/src/design/index.tsx`), + * `/design/` (`pages/.tsx`), `/design/__stream/` (a draft + * while it's written) and `GET /design/__status`. Outside development, + * `/design` answers 404. + * + * Not `devOnly` in the manifest: a dropped plugin couldn't claim `/design`, and + * the SPA fallback would answer it with index.html. + */ +class DesignPlugin extends Plugin { + static manifest = defineManifest<"design">(manifest); + + protected static description = "Dev-only design pages at /design"; + + private readonly builderOrigins: string[]; + + constructor(config: DesignConfig = {} as DesignConfig) { + super(config); + this.builderOrigins = parseOrigins(config.builderOrigin); + } + + async setup() { + if (isDev()) return; + this.context?.addMiddleware("/design", (_req, res) => { + res.status(404).end(); + }); + } + + /** Consumed by the server plugin when it starts the Vite dev server. */ + vitePlugins() { + return isDev() + ? [designVitePlugin({ builderOrigins: this.builderOrigins })] + : []; + } +} + +export const design = toPlugin(DesignPlugin); diff --git a/packages/appkit/src/plugins/design/manifest.json b/packages/appkit/src/plugins/design/manifest.json new file mode 100644 index 000000000..542aca2b5 --- /dev/null +++ b/packages/appkit/src/plugins/design/manifest.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://databricks.github.io/appkit/schemas/plugin-manifest.schema.json", + "name": "design", + "displayName": "Design Plugin", + "description": "Dev-only design pages: /design renders client/src/design/index.tsx and /design/ renders client/src/design/pages/.tsx, with a readiness signal for an embedding app builder; 404 in production", + "stability": "beta", + "resources": { + "required": [], + "optional": [] + }, + "config": { + "schema": { + "type": "object", + "properties": { + "builderOrigin": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Origins of the app builder that embeds the app (e.g. [\"https://builder.example.com\"]; a single string is also accepted). After a design page first paints, an appkit:design-ready message is posted to that window, only to these origins. '*' is not allowed. Defaults to the APPKIT_DESIGN_BUILDER_ORIGIN environment variable (comma-separated origins)." + } + } + } + } +} diff --git a/packages/appkit/src/plugins/design/stream.ts b/packages/appkit/src/plugins/design/stream.ts new file mode 100644 index 000000000..73419f4e2 --- /dev/null +++ b/packages/appkit/src/plugins/design/stream.ts @@ -0,0 +1,444 @@ +/** + * Streamed TSX drafts (SPIKE, KIT-40): the latest renderable version of + * `.staging/.tsx` is served as `pages/.tsx` and hot-swapped in place. + */ + +type Frame = + | { k: "brace" | "paren" | "bracket" | "tpl" | "tplExpr" | "jsxExpr" } + | { k: "tag" | "el"; name: string }; + +const CLOSER: Record = { + brace: "}", + tplExpr: "}", + jsxExpr: "}", + paren: ")", + bracket: "]", + tpl: "`", +}; + +/** A `<` after one of these (or at the start) opens JSX rather than comparing. */ +const JSX_AFTER = new Set([ + "", + "(", + ",", + "=", + ":", + "?", + "[", + "{", + "!", + "&", + "|", + ">", + ";", + "}", +]); +const JSX_AFTER_WORD = /(?:^|[^\w$])(?:return|yield|default|case)$/; + +/** + * Closes the brackets, template literals and JSX still open at the end of `src`; + * null when it ends inside a string or comment, or has a stray closer. + * + * @internal + */ +export function closeOpen(src: string): string | null { + const stack: Frame[] = []; + const top = () => stack[stack.length - 1]; + // Last non-space character (or keyword) seen in code, to tell JSX from `<`. + let prev = ""; + let i = 0; + const n = src.length; + + const skipString = (q: string) => { + for (i++; i < n; i++) { + if (src[i] === "\\") i++; + else if (src[i] === q) return true; + else if (src[i] === "\n") return false; + } + return false; + }; + const readName = () => { + const m = /^[\w$.:-]*/.exec(src.slice(i)); + i += m?.[0].length ?? 0; + return m?.[0] ?? ""; + }; + + while (i < n) { + const c = src[i]; + const f = top(); + const mode = f?.k ?? "brace"; + + if (mode === "tpl") { + if (c === "\\") i += 2; + else if (c === "`") { + stack.pop(); + prev = "`"; + i++; + } else if (c === "$" && src[i + 1] === "{") { + stack.push({ k: "tplExpr" }); + prev = "{"; + i += 2; + } else i++; + continue; + } + + if (mode === "tag") { + if (c === '"' || c === "'") { + if (!skipString(c)) return null; + i++; + } else if (c === "{") { + stack.push({ k: "jsxExpr" }); + prev = "{"; + i++; + } else if (c === "/" && src[i + 1] === ">") { + stack.pop(); + prev = ">"; + i += 2; + } else if (c === ">") { + const tag = stack.pop() as { name: string }; + stack.push({ k: "el", name: tag.name }); + i++; + } else i++; + continue; + } + + if (mode === "el") { + if (c === "{") { + stack.push({ k: "jsxExpr" }); + prev = "{"; + i++; + } else if (c === "<" && src[i + 1] === "/") { + const end = src.indexOf(">", i); + if (end === -1) return null; + stack.pop(); + prev = ">"; + i = end + 1; + } else if (c === "<") { + i++; + stack.push({ k: "tag", name: readName() }); + } else i++; + continue; + } + + // Code (top level, brackets, ${...} and JSX {...}). + if (c === "/" && src[i + 1] === "/") { + const end = src.indexOf("\n", i); + i = end === -1 ? n : end; + continue; + } + if (c === "/" && src[i + 1] === "*") { + const end = src.indexOf("*/", i + 2); + if (end === -1) return null; + i = end + 2; + continue; + } + if (/\s/.test(c)) { + i++; + continue; + } + if (c === '"' || c === "'") { + if (!skipString(c)) return null; + prev = c; + i++; + continue; + } + if (c === "`") { + stack.push({ k: "tpl" }); + i++; + continue; + } + if (c === "/" && JSX_AFTER.has(prev) && prev !== ">" && prev !== "}") { + // A regex literal. + for (i++; i < n && src[i] !== "/"; i++) { + if (src[i] === "\\") i++; + else if (src[i] === "\n") return null; + } + i++; + prev = "/"; + continue; + } + if ( + c === "<" && + /[A-Za-z>]/.test(src[i + 1] ?? "") && + (JSX_AFTER.has(prev) || + JSX_AFTER_WORD.test(src.slice(Math.max(0, i - 12), i).trimEnd())) + ) { + // `(` / `(` is a generic arrow's type parameters, not a tag. + if (/^<[\w$]+\s*(?:\bextends\b|,)/.test(src.slice(i, i + 80))) { + let depth = 0; + for (; i < n; i++) { + if (src[i] === "<") depth++; + else if (src[i] === ">" && src[i - 1] !== "=" && --depth === 0) break; + } + if (i >= n) return null; + i++; + prev = ">"; + continue; + } + i++; + stack.push({ k: "tag", name: readName() }); + continue; + } + if (c === "{" || c === "(" || c === "[") { + stack.push({ k: c === "{" ? "brace" : c === "(" ? "paren" : "bracket" }); + prev = c; + i++; + continue; + } + if (c === "}" || c === ")" || c === "]") { + const want = + c === "}" + ? ["brace", "tplExpr", "jsxExpr"] + : c === ")" + ? ["paren"] + : ["bracket"]; + if (!f || !want.includes(f.k)) return null; + stack.pop(); + prev = c; + i++; + continue; + } + if (/[\w$]/.test(c)) { + const m = /^[\w$]+/.exec(src.slice(i)) as RegExpExecArray; + prev = m[0]; + i += m[0].length; + continue; + } + prev = c; + i++; + } + + let out = src; + for (let j = stack.length - 1; j >= 0; j--) { + const fr = stack[j]; + if (fr.k === "tag") out += " />"; + else if (fr.k === "el") out += ``; + else out += CLOSER[fr.k]; + } + return out; +} + +/** Strips a leading markdown fence (and a closing one), as writers sometimes add. */ +function stripFence(src: string): string { + return src.replace(/^\s*```[\w-]*\n/, "").replace(/\n```\s*$/, "\n"); +} + +const DECLARED = + /(?:function|class|const|let|var)\s+([A-Z][\w$]*)|import\s+([A-Z][\w$]*)|import\s*\{([^}]*)\}|import\s+\*\s+as\s+([A-Z][\w$]*)/g; + +/** The skeleton shape a stubbed component gets, from its name. */ +export function placeholderKind( + name: string, +): "chart" | "table" | "card" | "generic" { + if (/Chart|Graph|Trend|Spark|Plot|Heatmap/.test(name)) return "chart"; + if (/Table|List|Grid|Rows|Log|Board|Feed|Queue/.test(name)) return "table"; + if (/Card|Kpi|KPI|Stat|Tile|Metric|Summary|Panel/.test(name)) return "card"; + return "generic"; +} + +/** Undeclared JSX components (helpers not written yet), stubbed with DraftPlaceholder. */ +function stubComponents(code: string): string { + const declared = new Set(); + for (const m of code.matchAll(DECLARED)) { + for (const name of [m[1], m[2], m[4]]) if (name) declared.add(name); + for (const spec of m[3]?.split(",") ?? []) { + const local = spec + .trim() + .split(/\s+as\s+/) + .pop() + ?.replace(/^type\s+/, ""); + if (local) declared.add(local); + } + } + const missing = new Set(); + for (const m of code.matchAll( + /(? + `function ${name}() { return <__DraftPlaceholder kind="${placeholderKind(name)}" />; }`, + ); + return `import { DraftPlaceholder as __DraftPlaceholder } from "@databricks/appkit-ui/react/beta";\n${code}\n${stubs.join("\n")}\n`; +} + +/** Checks that a candidate parses; the dev server's own TSX transform. */ +type IsValid = (code: string) => Promise; + +/** How many line cuts to try, newest first, before keeping the previous version. */ +const MAX_CUTS = 60; + +/** + * The longest renderable line-boundary cut of a streamed page, open constructs + * closed and undeclared components stubbed; null until one works. + * + * @internal + */ +export async function renderablePartial( + source: string, + isValid: IsValid, + { + page = true, + complete = false, + }: { + page?: boolean; + complete?: boolean; + } = {}, +): Promise { + const src = stripFence(source); + if (complete && (await isValid(src))) return src; + const lines = src.split("\n"); + // The last line is unfinished unless the source ends with a newline. + let end = lines.length - 1; + for (let tries = 0; end > 0 && tries < MAX_CUTS; end--, tries++) { + const cut = lines.slice(0, end).join("\n"); + if (page && !/export\s+default\b/.test(cut)) return null; + const closed = closeOpen(cut); + if (closed === null) continue; + const code = page ? stubComponents(closed) : closed; + if (await isValid(code)) return code; + } + return null; +} + +/** Value names a page imports from `../` (type-only imports excluded). */ +export function mockImports(page: string, mock = "mock"): string[] { + const names: string[] = []; + for (const m of page.matchAll( + /import\s*\{([^}]*)\}\s*from\s*["']\.\.\/([\w-]+?)(?:\.ts)?["']/g, + )) { + if (m[2] !== mock) continue; + for (const spec of m[1].split(",")) { + const s = spec.trim(); + if (!s || s.startsWith("type ")) continue; + names.push(s.split(/\s+as\s+/)[0]); + } + } + return names; +} + +/** Stands in for mock values not written yet: never throws, renders as nothing. */ +const DRAFT_VALUE = `const __draftValue: any = (() => { + const target: any = function () {}; + const value: any = new Proxy(target, { + get: (_t, key) => + key === Symbol.toPrimitive + ? (hint: string) => (hint === "number" ? 0 : "") + : key === Symbol.iterator + ? function* () {} + : key === "then" + ? undefined + : value, + apply: () => value, + construct: () => value, + }); + return value; +})();`; + +/** + * The partial mock plus a placeholder for each value a page imports from it that + * isn't defined yet (a missing named export would be a SyntaxError). + * + * @internal + */ +export function stubMock(mock: string, imported: Iterable): string { + const exported = new Set( + [ + ...mock + .replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, "") + .matchAll(/export\s+(?:const|let|var|function|class|enum)\s+([\w$]+)/g), + ].map((m) => m[1]), + ); + const stubs = [...new Set(imported)].filter((name) => !exported.has(name)); + return stubs.length === 0 + ? mock + : `${mock}\n${DRAFT_VALUE}\n${stubs.map((name) => `export const ${name}: any = __draftValue;`).join("\n")}\n`; +} + +/** + * Browser entry of `/design/__stream/`: renders each published version + * through DesignPage. + * + * @internal + */ +export function streamHostModule(name: string): string { + return ` +import { Component, createElement } from "react"; +import { createRoot } from "react-dom/client"; +import { DesignPage } from "@databricks/appkit-ui/react/beta"; +import.meta.glob("/src/index.css", { eager: true }); + +const NAME = ${JSON.stringify(name)}; +const rootEl = document.getElementById("root"); + +let host = null; +class Host extends Component { + // reset retries after an error; generation remounts the page. + state = { reset: 0, generation: 0 }; + failed = false; + componentDidMount() { + host = this; + } + onError = () => { + this.failed = true; + }; + retry() { + if (!this.failed) return; + this.failed = false; + this.setState((s) => ({ reset: s.reset + 1 })); + } + remount() { + this.failed = false; + this.setState((s) => ({ reset: s.reset + 1, generation: s.generation + 1 })); + } + render() { + return createElement( + DesignPage, + { onError: this.onError, resetKey: this.state.reset, errorFallback: () => null }, + createElement(this.props.page, { key: this.state.generation }), + ); + } +} + +// Removes Vite's error overlay: a version that doesn't build stays unpublished. +new MutationObserver(() => document.querySelector("vite-error-overlay")?.remove()).observe(document.body, { childList: true }); + +// The finished file mounts afresh, so kept chart state can't carry over. +let final = false; +let remountTimer; +const remount = (ms) => { + clearTimeout(remountTimer); + remountTimer = setTimeout(() => { + final = false; + host?.remount(); + }, ms); +}; +import.meta.hot?.on("vite:afterUpdate", () => { + if (final) remount(100); + // Fast Refresh applies the update a moment later; then retry. + setTimeout(() => host?.retry(), 50); +}); + +let mounted = false; +async function mount() { + if (mounted) return; + try { + const mod = await import(/* @vite-ignore */ "/src/design/pages/" + NAME + ".tsx?t=" + Date.now()); + if (mounted) return; + mounted = true; + createRoot(rootEl).render(createElement(Host, { page: mod.default })); + } catch {} +} +import.meta.hot?.on("appkit:stream", (data) => { + if (data?.name !== NAME) return; + if (data.done) { + final = true; + remount(1000); + } + mount(); +}); +mount(); +`; +} diff --git a/packages/appkit/src/plugins/design/tests/app-ready.dom.test.ts b/packages/appkit/src/plugins/design/tests/app-ready.dom.test.ts new file mode 100644 index 000000000..b85f06cf4 --- /dev/null +++ b/packages/appkit/src/plugins/design/tests/app-ready.dom.test.ts @@ -0,0 +1,158 @@ +// @vitest-environment jsdom +import { afterEach, expect, test, vi } from "vitest"; + +import { appReadyModule } from "../vite-plugin"; + +// jsdom globals; this package compiles without the DOM typings. +const win = globalThis as any; +const ORIGIN = "https://builder.example.com"; + +// Runs the generated script in this page, as the app's index.html would, with +// a builder window as the parent. +function load(html: string) { + win.document.body.innerHTML = html; + const postMessage = vi.fn(); + Object.defineProperty(win, "parent", { + configurable: true, + value: { postMessage }, + }); + // The status endpoint, as the script reports to it. + statusReports.length = 0; + win.fetch = async (_url: string, init?: { body?: string }) => { + if (init?.body) statusReports.push(JSON.parse(init.body)); + return {}; + }; + // Its one module-only piece is Vite's import.meta.hot: a stand-in whose + // events the test can fire. + hot.handlers = {}; + win.__testHot = { + on: (event: string, fn: () => void) => (hot.handlers[event] = fn), + }; + new Function( + appReadyModule([ORIGIN]).replaceAll("import.meta.hot", "window.__testHot"), + )(); + return postMessage; +} +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const statusReports: Record[] = []; +const hot: { handlers: Record void> } = { handlers: {} }; + +afterEach(() => { + delete win.__appkitDesign; +}); + +test("posts appkit:app-ready for / in a hidden frame, where no animation frame runs", async () => { + win.history.replaceState(null, "", "/"); + win.requestAnimationFrame = vi.fn(); // a hidden frame: never called back + const postMessage = load('

Tetris

'); + + await wait(700); // the 300 ms quiet window, then the 100 ms paint fallback + expect(postMessage).toHaveBeenCalledTimes(1); + expect(postMessage).toHaveBeenCalledWith( + { type: "appkit:app-ready", version: 1, path: "/" }, + ORIGIN, + ); +}); + +test("says nothing while the template home is up, then posts once the real page shows", async () => { + win.history.replaceState(null, "", "/"); + win.requestAnimationFrame = vi.fn(); + const postMessage = load( + '
Welcome
', + ); + await wait(700); + expect(postMessage).not.toHaveBeenCalled(); + + // The agent's finished page replaces it (here via client-side navigation). + win.document.getElementById("root").innerHTML = "

Tetris

"; + win.history.pushState(null, "", "/play"); + await wait(700); + expect(postMessage).toHaveBeenCalledWith( + { type: "appkit:app-ready", version: 1, path: "/play" }, + ORIGIN, + ); +}); + +// Not a dashboard: a to-do form app, in a hidden preview (no animation frames). +test("a form app reports rendered and its runtime errors to the status endpoint, hidden", async () => { + win.history.replaceState(null, "", "/"); + win.requestAnimationFrame = vi.fn(); + load( + '
    ', + ); + await wait(700); + // (Earlier tests' copies of the script are still live on this window and + // add reports of their own: check for these, not the exact list.) + expect(statusReports[0]).toEqual({ app: "/", reset: true }); + expect(statusReports).toContainEqual({ app: "/", ok: true, template: false }); + const before = statusReports.length; + + // A click handler that throws: not a render error, still reported. + win.dispatchEvent( + new win.ErrorEvent("error", { + error: new Error("Cannot read properties of undefined (reading 'title')"), + message: "Cannot read properties of undefined (reading 'title')", + }), + ); + win.dispatchEvent( + Object.assign(new win.Event("unhandledrejection"), { + reason: new Error("save failed"), + }), + ); + await wait(10); + const errors = statusReports.slice(before).filter((r) => "error" in r); + expect(errors).toContainEqual({ + app: "/", + error: "Cannot read properties of undefined (reading 'title')", + }); + expect(errors).toContainEqual({ + app: "/", + error: "Unhandled rejection: save failed", + }); +}); + +test("a crash that leaves nothing mounted is reported as not rendered, and no ready signal", async () => { + win.history.replaceState(null, "", "/tools/convert"); + win.requestAnimationFrame = vi.fn(); + const postMessage = load('
    '); + await wait(700); + expect(statusReports).toContainEqual({ + app: "/tools/convert", + ok: false, + template: false, + }); + expect(statusReports).not.toContainEqual( + expect.objectContaining({ app: "/tools/convert", ok: true }), + ); + expect(postMessage).not.toHaveBeenCalled(); +}); + +// Not a dashboard: a quiz game whose answer handler threw, then got fixed by a +// hot update (no page load). +test("a hot update drops the errors from before it, then reports the page again", async () => { + win.history.replaceState(null, "", "/quiz"); + win.requestAnimationFrame = vi.fn(); + load('

    Quiz

    '); + await wait(700); + win.dispatchEvent( + new win.ErrorEvent("error", { + error: new Error("answers is undefined"), + message: "answers is undefined", + }), + ); + await wait(10); + expect(statusReports).toContainEqual({ + app: "/quiz", + error: "answers is undefined", + }); + const before = statusReports.length; + + hot.handlers["vite:afterUpdate"](); + await wait(700); + const after = statusReports.slice(before); + expect(after[0]).toEqual({ app: "/quiz", reset: true }); + expect(after).toContainEqual({ app: "/quiz", ok: true, template: false }); + expect(after).not.toContainEqual( + expect.objectContaining({ error: expect.anything() }), + ); +}); diff --git a/packages/appkit/src/plugins/design/tests/compile-graph.test.ts b/packages/appkit/src/plugins/design/tests/compile-graph.test.ts new file mode 100644 index 000000000..04d6190f8 --- /dev/null +++ b/packages/appkit/src/plugins/design/tests/compile-graph.test.ts @@ -0,0 +1,177 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { createServer, type ViteDevServer } from "vite"; +import { afterEach, describe, expect, test } from "vitest"; + +import { compileGraph, designVitePlugin } from "../vite-plugin"; + +// A real Vite dev server: the status check must catch errors anywhere in the +// page's import graph, which a mocked transformRequest can't show. The sample +// modules avoid JSX so the temp app doesn't need React installed. +let server: ViteDevServer | undefined; +afterEach(async () => { + await server?.close(); + server = undefined; +}); + +async function serve(files: Record) { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "appkit-compile-")), + ); + for (const [file, text] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + } + server = await createServer({ + root, + configFile: false, + logLevel: "silent", + server: { middlewareMode: true, hmr: false, ws: false }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + return server; +} + +const INDEX = "/src/design/index.tsx"; + +describe("compileGraph on a real Vite server", () => { + test("a page whose whole import graph builds", async () => { + const s = await serve({ + "src/design/index.tsx": + 'import { Card } from "./Card";\nexport default () => Card();\n', + "src/design/Card.tsx": + 'import { label } from "../lib/label";\nexport const Card = () => label;\n', + "src/lib/label.ts": 'export const label = "ok";\n', + }); + expect(await compileGraph(s, INDEX)).toBeUndefined(); + }); + + test("a syntax error in a component two imports deep is reported", async () => { + const s = await serve({ + "src/design/index.tsx": + 'import { Card } from "./Card";\nexport default () => Card();\n', + "src/design/Card.tsx": + 'import { Bad } from "./Bad";\nexport const Card = () => Bad();\n', + "src/design/Bad.tsx": "const x = {;\nexport const Bad = () => null;\n", + }); + const error = await compileGraph(s, INDEX); + // Names the broken module, not just the parser message. + expect(error).toMatch(/Bad\.tsx/); + expect(error).toMatch(/Unexpected/); + }); + + test("an unresolvable import in an imported module is reported", async () => { + const s = await serve({ + "src/design/index.tsx": + 'import { Card } from "./Card";\nexport default () => Card();\n', + "src/design/Card.tsx": + 'import { Nope } from "./nope";\nexport const Card = () => Nope();\n', + }); + expect(await compileGraph(s, INDEX)).toMatch( + /Failed to resolve import "\.\/nope"/, + ); + }); + + test("/design/__status reports it: compiled false, waitingFor compile", async () => { + const root = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "appkit-compile-")), + ); + const write = (file: string, text: string) => { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), text); + }; + write( + "src/design/index.tsx", + 'import { Bad } from "./Bad";\nexport default () => Bad();\n', + ); + write( + "src/design/Bad.tsx", + "const x = {;\nexport const Bad = () => null;\n", + ); + server = await createServer({ + root, + configFile: false, + logLevel: "silent", + server: { middlewareMode: true, hmr: false, ws: false }, + optimizeDeps: { noDiscovery: true, include: [] }, + }); + // The plugin's own handler, on top of the real server's transformRequest, + // moduleGraph and config (Vite's request pipeline isn't under test). + type Handler = ( + req: unknown, + res: unknown, + next: () => void, + ) => Promise; + let handler: Handler | undefined; + const proxy = Object.create(server, { + middlewares: { value: { use: (h: Handler) => (handler = h) } }, + }); + const plugin = designVitePlugin(); + (plugin.configResolved as (c: { root: string }) => void)({ root }); + (plugin.configureServer as (s: unknown) => void)(proxy); + + let body = ""; + await (handler as Handler)( + { url: "/design/__status", method: "GET" }, + { + statusCode: 200, + setHeader() {}, + end: (b?: string) => (body = b ?? ""), + }, + () => {}, + ); + const status = JSON.parse(body); + expect(status).toMatchObject({ compiled: false, waitingFor: "compile" }); + expect(String(status.compileError)).toMatch(/Bad\.tsx.*Unexpected/); + }); + + test("an app's react-router 7 is found when Vite serves its pre-bundled copy", async () => { + const app = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), "appkit-router-")), + ); + const write = (file: string, text: string) => { + fs.mkdirSync(path.dirname(path.join(app, file)), { recursive: true }); + fs.writeFileSync(path.join(app, file), text); + }; + write( + "node_modules/react-router/package.json", + JSON.stringify({ + name: "react-router", + version: "7.18.0", + type: "module", + exports: { ".": "./index.js" }, + }), + ); + write( + "node_modules/react-router/index.js", + "export const createBrowserRouter = () => {};\nexport const RouterProvider = () => null;\n", + ); + write("client/src/design/index.tsx", "export default () => null;\n"); + server = await createServer({ + root: path.join(app, "client"), + configFile: false, + logLevel: "silent", + plugins: [designVitePlugin()], + server: { middlewareMode: true, hmr: false, ws: false }, + optimizeDeps: { include: ["react-router"], noDiscovery: true }, + cacheDir: path.join(app, "node_modules/.vite"), + }); + await server.environments.client.depsOptimizer?.init(); + await server.environments.client.depsOptimizer?.scanProcessing; + // What made the old check miss it: Vite resolves to the pre-bundled copy. + const resolved = await server.pluginContainer.resolveId( + "react-router", + path.join(app, "client/src/design/index.tsx"), + ); + expect(resolved?.id).toMatch(/[/]\.vite[/]deps[/]/); + + const entry = await server.pluginContainer.load( + "\0virtual:appkit-design-page:index.tsx.mjs", + ); + expect(String(typeof entry === "string" ? entry : entry?.code)).toContain( + 'from "react-router"', + ); + }); +}); diff --git a/packages/appkit/src/plugins/design/tests/design.test.ts b/packages/appkit/src/plugins/design/tests/design.test.ts new file mode 100644 index 000000000..ceb7f4873 --- /dev/null +++ b/packages/appkit/src/plugins/design/tests/design.test.ts @@ -0,0 +1,464 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { createTestApp, type TestApp } from "../../../testing"; +import { server } from "../../server"; +import { design } from "../index"; +import { + appReadyModule, + designStatus, + designVitePlugin, + entryModule, + listDesignPages, + resolveDesignPage, + pageState, +} from "../vite-plugin"; + +const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "appkit-design-")); + +const PAGE = "export default function Page() { return null; }"; + +function clientWith(files: string[] | Record) { + const root = tmp(); + const entries = Array.isArray(files) + ? files.map((f) => [f, PAGE] as const) + : Object.entries(files); + for (const [file, text] of entries) { + const full = path.join(root, file); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, text); + } + return root; +} + +describe("resolveDesignPage", () => { + const root = clientWith([ + "src/design/index.tsx", + "src/design/pages/orders.tsx", + "src/design/pages/legacy.jsx", + ]); + + test("/design is the index page", () => { + expect(resolveDesignPage(root, "/design")).toBe( + path.join(root, "src/design/index.tsx"), + ); + expect(resolveDesignPage(root, "/design/")).toBe( + path.join(root, "src/design/index.tsx"), + ); + }); + + test("/design/ is pages/", () => { + expect(resolveDesignPage(root, "/design/orders")).toBe( + path.join(root, "src/design/pages/orders.tsx"), + ); + expect(resolveDesignPage(root, "/design/legacy")).toBe( + path.join(root, "src/design/pages/legacy.jsx"), + ); + }); + + test("anything under /design that isn't a page is null (served as 404)", () => { + expect(resolveDesignPage(clientWith([]), "/design")).toBeNull(); + for (const p of [ + "/design/missing", + "/design/a/b", + "/design/../secret", + "/design/orders.tsx", + ]) { + expect(resolveDesignPage(root, p)).toBeNull(); + } + }); + + test('"index" and "__*" are reserved, not page names', () => { + const r = clientWith([ + "src/design/index.tsx", + "src/design/pages/index.tsx", + "src/design/pages/__status.tsx", + ]); + expect(resolveDesignPage(r, "/design/index")).toBeNull(); + expect(resolveDesignPage(r, "/design/__status")).toBeNull(); + expect(listDesignPages(r)).toEqual([{ name: "index", path: "/design" }]); + }); + + test("paths outside /design are left alone", () => { + for (const p of ["/", "/designer", "/api/design", "/orders"]) { + expect(resolveDesignPage(root, p)).toBeUndefined(); + } + }); +}); + +describe("listDesignPages", () => { + test("index first, then pages by name", () => { + const root = clientWith([ + "src/design/index.tsx", + "src/design/pages/settings.tsx", + "src/design/pages/orders.jsx", + "src/design/pages/notes.md", + ]); + expect(listDesignPages(root)).toEqual([ + { name: "index", path: "/design" }, + { name: "orders", path: "/design/orders" }, + { name: "settings", path: "/design/settings" }, + ]); + }); + + test("no design folder, no pages", () => { + expect(listDesignPages(clientWith([]))).toEqual([]); + }); +}); + +describe("designStatus", () => { + const mtime = (root: string) => + fs.statSync(path.join(root, "src/design/index.tsx")).mtimeMs; + + test("ready only for a real index page painted since its last change", () => { + const root = clientWith({ + "src/design/index.tsx": PAGE, + "src/design/pages/orders.tsx": PAGE, + }); + expect(designStatus(root, undefined)).toEqual({ + ready: false, + placeholder: false, + pages: [ + { name: "index", path: "/design" }, + { name: "orders", path: "/design/orders" }, + ], + }); + expect(designStatus(root, mtime(root)).ready).toBe(true); + // A paint of an older version of the file doesn't count. + expect(designStatus(root, mtime(root) - 1000).ready).toBe(false); + }); + + test("never ready while index is the template placeholder", () => { + const root = clientWith({ + "src/design/index.tsx": `export const designPlaceholder = true;\n${PAGE}`, + }); + expect(designStatus(root, mtime(root))).toMatchObject({ + ready: false, + placeholder: true, + }); + }); + + test("never ready without an index page", () => { + const root = clientWith(["src/design/pages/orders.tsx"]); + expect(designStatus(root, 1)).toEqual({ + ready: false, + placeholder: false, + pages: [{ name: "orders", path: "/design/orders" }], + }); + }); +}); + +describe("entryModule", () => { + test("router mode: basename /design, other pages lazy", () => { + const code = entryModule("pages/orders.tsx", [], true); + expect(code).toContain( + 'import * as initial from "/src/design/pages/orders.tsx"', + ); + expect(code).toContain( + 'createBrowserRouter(routes, { basename: "/design" })', + ); + expect(code).toContain("lazy: async () =>"); + expect(code).not.toContain("eager: true }),"); + }); + + test("routes and lists only non-reserved page files", () => { + const code = entryModule("index.tsx", [], true); + expect(code).toContain('"!/src/design/pages/index.{tsx,jsx}"'); + expect(code).toContain('"!/src/design/pages/__*"'); + }); + + test("waits for real content before reporting ready", () => { + const code = entryModule("index.tsx", [], false); + expect(code).toContain("await document.fonts?.ready"); + expect(code).toContain("new MutationObserver("); + expect(code).toContain("pendingFetches === 0 && quiet"); + expect(code).toContain("host.blockers === 0"); + // A Suspense fallback holds readiness while it's on screen. + expect(code).toContain( + "createElement(Suspense, { fallback: createElement(Pending) }", + ); + // The useDesignReady() contract with appkit-ui. + expect(code).toContain("window.__appkitDesign ??= { blockers: 0 }"); + }); + + test("without react-router it renders just the page", () => { + const code = entryModule("index.tsx", [], false); + expect(code).not.toContain("react-router"); + expect(code).toContain('import * as initial from "/src/design/index.tsx"'); + }); + + test("posts appkit:design-ready only to the configured origins", () => { + const code = entryModule( + "index.tsx", + ["https://builder.example.com"], + false, + ); + expect(code).toContain('const ORIGINS = ["https://builder.example.com"]'); + expect(code).toContain('type: "appkit:design-ready", version: 1'); + expect(code).toContain("target.postMessage("); + expect(code).not.toContain('"*"'); + expect(code).toContain( + "const placeholder = mod.designPlaceholder === true", + ); + expect(code).toContain("current = { name, ok: !placeholder };"); + // Every page renders through appkit-ui's DesignPage (theme, last good + // frame), and a render error anywhere in the page's tree un-readies the + // server, with its message. + expect(code).toContain( + 'import { DesignPage } from "@databricks/appkit-ui/react/beta";', + ); + expect(code).toContain("createElement(\n DesignPage,"); + expect(code).toContain( + "postStatus(name, false, error?.message ?? String(error))", + ); + // A hot update (e.g. a fix in an imported component) drops the page's + // earlier errors before it reports again. + expect(code).toContain( + "sendStatus({ page: current?.name ?? initialPage, reset: true });", + ); + // ...and retries a boundary still showing the old error, so a fix in an + // imported component renders the page again. + expect(code).toContain("if (boundary.failed) boundary.retry();"); + }); +}); + +describe("app-ready script", () => { + test("posts appkit:app-ready v1 to the builder origins, never on the template home", () => { + const code = appReadyModule(["https://builder.example.com"]); + expect(code).toContain('const ORIGINS = ["https://builder.example.com"]'); + expect(code).toContain( + 'type: "appkit:app-ready", version: 1, path: location.pathname', + ); + expect(code).toContain( + 'const template = !!document.querySelector("[data-appkit-template-home]")', + ); + expect(code).toContain("if (!ok || template) return;"); + expect(code).toContain("await document.fonts?.ready"); + expect(code).not.toContain('"*"'); + }); + + test("re-checks after client-side navigation and hot updates", () => { + const code = appReadyModule(["https://builder.example.com"]); + expect(code).toContain( + 'for (const method of ["pushState", "replaceState"])', + ); + expect(code).toContain('addEventListener("popstate", schedule)'); + expect(code).toContain('import.meta.hot?.on("vite:afterUpdate"'); + }); + + type HtmlHook = ( + html: string, + ctx: { path: string }, + ) => { attrs: { src: string } }[] | undefined; + const inject = (origins: string[], urlPath: string) => { + const plugin = designVitePlugin({ builderOrigins: origins }); + (plugin.configResolved as (c: { root: string }) => void)({ + root: clientWith(["src/design/index.tsx"]), + }); + return (plugin.transformIndexHtml as HtmlHook)("", { + path: urlPath, + }); + }; + + test("is added to app pages only (with or without a builder origin)", () => { + const tags = inject(["https://builder.example.com"], "/orders?x=1"); + expect(tags?.[0].attrs.src).toBe( + "/@id/__x00__virtual:appkit-app-ready.mjs", + ); + expect(inject(["https://builder.example.com"], "/design")).toBeUndefined(); + expect( + inject(["https://builder.example.com"], "/design/x"), + ).toBeUndefined(); + // Without a builder it still reports to /design/__status. + expect(inject([], "/")?.[0].attrs.src).toBe( + "/@id/__x00__virtual:appkit-app-ready.mjs", + ); + }); +}); + +describe("builderOrigin", () => { + const construct = (builderOrigin: string | string[]) => { + const { plugin: DesignPlugin, config } = design({ builderOrigin }); + return new DesignPlugin(config); + }; + + describe("APPKIT_DESIGN_BUILDER_ORIGIN, when builderOrigin isn't passed", () => { + const ENV = "APPKIT_DESIGN_BUILDER_ORIGIN"; + afterEach(() => { + delete process.env[ENV]; + }); + // What the plugin posts to and allows CORS for. + const originsOf = (config: Parameters[0] = {}) => { + const { plugin: DesignPlugin, config: c } = design(config); + return (new DesignPlugin(c) as unknown as { builderOrigins: string[] }) + .builderOrigins; + }; + + test("is the builder origin of a bare design(), as scaffolded", () => { + process.env[ENV] = "https://builder.example.com"; + expect(originsOf()).toEqual(["https://builder.example.com"]); + }); + + test("takes several origins, comma-separated", () => { + process.env[ENV] = " https://a.example.com , http://localhost:3000,"; + expect(originsOf()).toEqual([ + "https://a.example.com", + "http://localhost:3000", + ]); + }); + + test("an explicit builderOrigin wins", () => { + process.env[ENV] = "https://env.example.com"; + expect(originsOf({ builderOrigin: "https://own.example.com" })).toEqual([ + "https://own.example.com", + ]); + }); + + test("unset or empty means no builder", () => { + expect(originsOf()).toEqual([]); + process.env[ENV] = ""; + expect(originsOf()).toEqual([]); + }); + + test("is checked like the option, and the error names the variable", () => { + process.env[ENV] = "*"; + expect(() => originsOf()).toThrow( + /APPKIT_DESIGN_BUILDER_ORIGIN "\*" must be a bare origin/, + ); + }); + + test("turns on the app-ready script, which needs a builder origin", () => { + process.env[ENV] = "https://builder.example.com"; + const plugin = designVitePlugin({ builderOrigins: originsOf() }); + const tags = ( + plugin.transformIndexHtml as ( + html: string, + ctx: { path: string }, + ) => { attrs?: { src?: string } }[] | undefined + )("", { path: "/index.html" }); + expect(JSON.stringify(tags)).toContain("virtual:appkit-app-ready.mjs"); + }); + }); + + test("accepts bare origins", () => { + expect(() => + construct(["https://builder.example.com", "http://localhost:3000"]), + ).not.toThrow(); + }); + + test.each([ + "*", + "https://builder.example.com/path", + "builder.example.com", + "null", + ])("rejects %s", (origin) => { + expect(() => construct(origin)).toThrow(/bare origin/); + }); +}); + +describe("page entry", () => { + // root: the app's client folder (default: a fresh one, no react-router). + const load = (file: string, root = path.join(tmp(), "client")) => { + const plugin = designVitePlugin(); + (plugin.configResolved as (c: { root: string }) => void)({ root }); + return (plugin.load as (id: string) => Promise)( + `\0virtual:appkit-design-page:${file}.mjs`, + ); + }; + + test("statically imports only the requested page, so other drafts can't break it", async () => { + for (const file of ["index.tsx", "pages/orders.jsx"]) { + const code = await load(file); + expect(code?.match(/^import .* from "\/src\/design\/.*"/gm)).toEqual([ + `import * as initial from "/src/design/${file}"`, + ]); + } + }); + + test("refuses anything that isn't a design page file", async () => { + for (const file of [ + "../main.tsx", + "pages/a/b.tsx", + "index.ts", + "pages/x.css", + ]) { + expect(await load(file)).toBeUndefined(); + } + }); + + // An app with react-router at a given version installed; returns its client + // folder. With a stale pre-bundled copy in node_modules/.vite/deps, as the + // dev server leaves it. + const routerAt = (version: string) => { + const app = tmp(); + const dir = path.join(app, "node_modules/react-router"); + fs.mkdirSync(path.join(dir, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ name: "react-router", version, main: "dist/index.js" }), + ); + fs.writeFileSync(path.join(dir, "dist/index.js"), ""); + const deps = path.join(app, "node_modules/.vite/deps"); + fs.mkdirSync(deps, { recursive: true }); + fs.writeFileSync(path.join(deps, "package.json"), '{"type":"module"}'); + fs.writeFileSync(path.join(deps, "react-router.js"), ""); + return path.join(app, "client"); + }; + + test("uses the router only when the app has react-router 7+", async () => { + expect(await load("index.tsx", routerAt("7.18.0"))).toContain( + 'from "react-router"', + ); + // An older react-router (e.g. hoisted by pnpm) has no createBrowserRouter. + expect(await load("index.tsx", routerAt("5.3.4"))).not.toContain( + "react-router", + ); + expect(await load("index.tsx")).not.toContain("react-router"); + }); +}); + +describe("design plugin outside development", () => { + let app: TestApp | undefined; + afterEach(async () => { + await app?.close(); + app = undefined; + }); + + function staticDir() { + const dir = tmp(); + fs.writeFileSync( + path.join(dir, "index.html"), + "", + ); + return dir; + } + + test("/design is 404 and nothing else is mounted", async () => { + app = (await createTestApp({ + nodeEnv: "production", + plugins: [server({ staticPath: staticDir(), port: 0 }), design()], + })) as unknown as TestApp; + + expect((await app.get("/design")).status).toBe(404); + expect((await app.get("/design/orders")).status).toBe(404); + // The SPA fallback still serves everything else. + expect((await app.get("/anything")).status).toBe(200); + }); +}); + +describe("pageState", () => { + test("from compile, render and error reports", () => { + const state = ( + compiled: boolean, + rendered: boolean, + errors: { error: string }[] | null, + ) => pageState({ compiled, rendered, errors }); + expect(state(false, false, null)).toBe("compile-error"); + expect(state(true, false, null)).toBe("rendering"); + expect(state(true, true, [])).toBe("ready"); + expect(state(true, true, [{ error: "boom" }])).toBe("failed"); + }); +}); diff --git a/packages/appkit/src/plugins/design/tests/status.test.ts b/packages/appkit/src/plugins/design/tests/status.test.ts new file mode 100644 index 000000000..a364fdd96 --- /dev/null +++ b/packages/appkit/src/plugins/design/tests/status.test.ts @@ -0,0 +1,296 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { designVitePlugin } from "../vite-plugin"; + +const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), "appkit-status-")); + +describe("/design/__status", () => { + const cwd = process.cwd(); + afterEach(() => process.chdir(cwd)); + + type Handler = ( + req: { url: string; method: string; body?: unknown }, + res: { + statusCode: number; + setHeader(k: string, v: string): void; + end(body?: string): void; + }, + next: () => void, + ) => Promise; + + async function call( + handler: Handler, + method: string, + url: string, + body?: unknown, + headers: Record = {}, + ) { + let out = ""; + const set: Record = {}; + const res = { + statusCode: 200, + setHeader: (k: string, v: string) => (set[k.toLowerCase()] = v), + end: (b?: string) => (out = b ?? ""), + }; + await handler({ url, method, body, headers } as never, res, () => {}); + let json: unknown; + try { + json = out ? JSON.parse(out) : undefined; + } catch {} + return { status: res.statusCode, json: json as any, headers: set }; + } + function start( + transformRequest: (url: string) => Promise = async () => ({}), + builderOrigins: string[] = [], + ) { + const app = tmp(); + process.chdir(app); + const plugin = designVitePlugin({ builderOrigins }); + let handler: Handler | undefined; + (plugin.configureServer as (s: unknown) => void)({ + config: { root: path.join(app, "client") }, + ws: { send() {} }, + middlewares: { use: (h: Handler) => (handler = h) }, + transformRequest, + moduleGraph: { getModuleByUrl: async () => undefined }, + }); + return { app, handler: handler as Handler }; + } + + const writeIndex = (app: string, text: string) => { + const file = path.join(app, "client/src/design/index.tsx"); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); + }; + + test("compiled and waitingFor tell an agent what /design waits on, without a browser", async () => { + const S = "/design/__status"; + const ok = start(); + expect((await call(ok.handler, "GET", S)).json).toMatchObject({ + ready: false, + compiled: false, + waitingFor: "index", + }); + writeIndex( + ok.app, + "export const designPlaceholder = true;\nexport default () => null;", + ); + expect((await call(ok.handler, "GET", S)).json.waitingFor).toBe( + "placeholder", + ); + writeIndex(ok.app, "export default () => null;"); + expect((await call(ok.handler, "GET", S)).json).toMatchObject({ + ready: false, + compiled: true, + waitingFor: "paint", + }); + + const broken = start(async () => { + throw new Error( + 'Failed to resolve import "./nope" from "src/design/index.tsx"', + ); + }); + writeIndex(broken.app, 'import "./nope";\nexport default () => null;'); + expect((await call(broken.handler, "GET", S)).json).toMatchObject({ + compiled: false, + compileError: expect.stringContaining("Failed to resolve import"), + waitingFor: "compile", + }); + }); + + // Not a dashboard: a multi-page tool with a settings form. + // Not a dashboard: a multi-page tool with a settings form. + test("reports each page and the app as rendered, with errors, from browser reports only", async () => { + const compileErrors: Record = {}; + const { app, handler } = start(async (url) => { + if (compileErrors[url]) throw new Error(compileErrors[url]); + return {}; + }); + writeIndex(app, "export default () => null;"); + fs.mkdirSync(path.join(app, "client/src/design/pages"), { + recursive: true, + }); + fs.writeFileSync( + path.join(app, "client/src/design/pages/settings.tsx"), + "export default () => null;", + ); + fs.writeFileSync( + path.join(app, "client/index.html"), + '
    ', + ); + const S = "/design/__status"; + const get = async () => (await call(handler, "GET", S)).json; + + // Nothing reported yet: not rendered, errors unknown. + let status = await get(); + expect(status.pages).toEqual([ + { + name: "index", + path: "/design", + compiled: true, + rendered: false, + errors: null, + state: "rendering", + }, + { + name: "settings", + path: "/design/settings", + compiled: true, + rendered: false, + errors: null, + state: "rendering", + }, + ]); + expect(status.app).toEqual({ + entry: "/src/main.tsx", + compiled: true, + path: null, + rendered: false, + template: false, + errors: null, + }); + + // A hidden preview loads both pages and the app; the settings form's + // submit handler throws later. + for (const page of ["index", "settings"]) { + await call(handler, "POST", S, { page, reset: true }); + await call(handler, "POST", S, { page, ok: true }); + } + await call(handler, "POST", S, { + page: "settings", + error: "Unhandled rejection: save failed", + }); + await call(handler, "POST", S, { app: "/", reset: true }); + await call(handler, "POST", S, { app: "/", ok: true, template: false }); + status = await get(); + expect(status).toMatchObject({ ready: true, waitingFor: null }); + expect(status.pages).toEqual([ + { + name: "index", + path: "/design", + compiled: true, + rendered: true, + errors: [], + state: "ready", + }, + { + name: "settings", + path: "/design/settings", + compiled: true, + rendered: true, + errors: [{ error: "Unhandled rejection: save failed" }], + state: "failed", + }, + ]); + expect(status.app).toMatchObject({ path: "/", rendered: true, errors: [] }); + + // Editing a page makes its report stale: unknown again until it reports. + const settings = path.join(app, "client/src/design/pages/settings.tsx"); + fs.utimesSync(settings, new Date(), new Date(Date.now() + 5000)); + expect((await get()).pages[1]).toMatchObject({ + rendered: false, + errors: null, + }); + + // A render error, with its message; and the app failing to build. + await call(handler, "POST", S, { + page: "settings", + ok: false, + error: "Settings is not a function", + }); + compileErrors["/src/main.tsx"] = + 'Failed to resolve import "./api" from "src/main.tsx"'; + status = await get(); + expect(status.pages[1]).toEqual({ + name: "settings", + path: "/design/settings", + compiled: true, + rendered: false, + errors: [{ error: "Settings is not a function" }], + state: "failed", + }); + + // A page that doesn't build, known without a browser. + compileErrors["/src/design/pages/settings.tsx"] = "Unexpected token"; + expect((await get()).pages[1]).toMatchObject({ + compiled: false, + compileError: expect.stringContaining("Unexpected token"), + state: "compile-error", + }); + expect(status.app).toMatchObject({ + compiled: false, + compileError: expect.stringContaining("Failed to resolve import"), + }); + }); + + test("a reset (a load or a hot update) clears the app's errors, for that path only", async () => { + const { handler } = start(); + const S = "/design/__status"; + const app = async () => (await call(handler, "GET", S)).json.app; + // Two tabs of a CRUD app on different paths. + await call(handler, "POST", S, { app: "/orders", reset: true }); + await call(handler, "POST", S, { + app: "/orders", + ok: true, + template: false, + }); + await call(handler, "POST", S, { + app: "/orders", + error: "orders.map is not a function", + }); + await call(handler, "POST", S, { app: "/orders/new", reset: true }); + await call(handler, "POST", S, { + app: "/orders/new", + ok: true, + template: false, + }); + // The last path reported is shown; the other tab didn't wipe /orders. + expect(await app()).toMatchObject({ + path: "/orders/new", + rendered: true, + errors: [], + }); + await call(handler, "POST", S, { + app: "/orders", + ok: true, + template: false, + }); + expect(await app()).toMatchObject({ + path: "/orders", + errors: [{ error: "orders.map is not a function" }], + }); + + // The fix arrives as a hot update: /orders resets and reports again. + await call(handler, "POST", S, { app: "/orders", reset: true }); + await call(handler, "POST", S, { + app: "/orders", + ok: true, + template: false, + }); + expect(await app()).toMatchObject({ + path: "/orders", + rendered: true, + errors: [], + }); + }); + + test("/design/__status answers CORS for the builder origins only", async () => { + const { handler } = start(undefined, ["https://builder.example.com"]); + const pre = await call(handler, "OPTIONS", "/design/__status", undefined, { + origin: "https://builder.example.com", + }); + expect(pre.status).toBe(204); + expect(pre.headers["access-control-allow-origin"]).toBe( + "https://builder.example.com", + ); + expect(pre.headers["access-control-allow-headers"]).toBe("Content-Type"); + const other = await call(handler, "GET", "/design/__status", undefined, { + origin: "https://evil.example.com", + }); + expect(other.headers["access-control-allow-origin"]).toBeUndefined(); + }); +}); diff --git a/packages/appkit/src/plugins/design/tests/stream-watch.test.ts b/packages/appkit/src/plugins/design/tests/stream-watch.test.ts new file mode 100644 index 000000000..141206b9f --- /dev/null +++ b/packages/appkit/src/plugins/design/tests/stream-watch.test.ts @@ -0,0 +1,64 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, expect, test, vi } from "vitest"; + +import { designVitePlugin } from "../vite-plugin"; + +const cwd = process.cwd(); +afterEach(() => process.chdir(cwd)); + +/** The plugin on a fake dev server whose watcher the test drives. */ +function start() { + const app = fs.mkdtempSync(path.join(os.tmpdir(), "design-stream-")); + const root = path.join(app, "client"); + fs.mkdirSync(path.join(root, "src/design/.staging"), { recursive: true }); + fs.mkdirSync(path.join(root, "src/design/pages"), { recursive: true }); + process.chdir(app); + const plugin = designVitePlugin(); + (plugin.configResolved as (c: { root: string }) => void)({ root }); + const watcher = new EventEmitter(); + const sent: { event: string; data: { name: string; done?: boolean } }[] = []; + const warn = vi.fn(); + (plugin.configureServer as (s: unknown) => void)({ + config: { root, logger: { warn } }, + watcher, + ws: { send: (m: (typeof sent)[number]) => sent.push(m) }, + middlewares: { use() {} }, + moduleGraph: { + getModulesByFile: () => [], + getModuleById: async () => undefined, + }, + reloadModule: async () => {}, + }); + return { root, watcher, sent, warn }; +} + +const until = async (check: () => boolean) => { + for (let i = 0; i < 100 && !check(); i++) + await new Promise((r) => setTimeout(r, 20)); +}; + +test("a staging write is published, and a finished file deleted before it's read is ignored", async () => { + const { root, watcher, sent, warn } = start(); + const staging = path.join(root, "src/design/.staging/dash.tsx"); + fs.writeFileSync( + staging, + 'export default function Page() {\n return

    Trips

    ;\n}\n', + ); + watcher.emit("change", staging); + await until(() => sent.some((m) => m.data?.name === "dash")); + expect(sent.find((m) => m.data?.name === "dash")?.event).toBe( + "appkit:stream", + ); + + // The builder writes the finished file and deletes it again (starting over) + // before the plugin reads it: no unhandled rejection, no crash. + watcher.emit("add", path.join(root, "src/design/pages/dash.tsx")); + watcher.emit("add", path.join(root, "src/design/mock.ts")); + await new Promise((r) => setTimeout(r, 100)); + expect(sent.some((m) => m.data?.done)).toBe(false); + expect(warn).not.toHaveBeenCalled(); +}); diff --git a/packages/appkit/src/plugins/design/tests/stream.test.ts b/packages/appkit/src/plugins/design/tests/stream.test.ts new file mode 100644 index 000000000..de815631d --- /dev/null +++ b/packages/appkit/src/plugins/design/tests/stream.test.ts @@ -0,0 +1,156 @@ +import { transformWithOxc } from "vite"; +import { describe, expect, test } from "vitest"; + +import { + closeOpen, + mockImports, + placeholderKind, + renderablePartial, + stubMock, +} from "../stream"; + +const parses = async (code: string) => { + try { + await transformWithOxc(code, "page.tsx", { lang: "tsx" }); + return true; + } catch { + return false; + } +}; + +const PAGE = `import { Card, CardTitle } from "@databricks/appkit-ui/react"; +import { useState } from "react"; + +import { trips, type Trip } from "../mock"; + +export default function Page() { + const [tab, setTab] = useState("all"); + const rows: Trip[] = tab === "all" ? trips : trips.filter((t) => t.borough === tab && t.fare > 1); + return ( +
    +

    Trips {\`(\${rows.length})\`}

    + {rows.length > 0 && ( + + Don't miss it + {rows.map((t) => ( + setTab(t.borough)} /> + ))} + + )} +
    +
    + ); +} + +function Row({ trip, onPick }: { trip: Trip; onPick: () => void }) { + return ; +} + +function Footer() { + return

    / end

    ; +} +`; + +describe("closeOpen", () => { + test("closes JSX elements, expression containers and brackets", () => { + expect( + closeOpen( + 'return (\n
    \n {rows.map((t) => (\n ', + ), + ).toBe( + 'return (\n
    \n {rows.map((t) => (\n ))}
    )', + ); + }); + + test("an unfinished opening tag is self-closed", () => { + expect(closeOpen('
    \n \n
    ', + ); + }); + + test("generics and comparisons are not JSX", () => { + expect(closeOpen("const [a] = useState(0);\nif (a < b) {")).toBe( + "const [a] = useState(0);\nif (a < b) {}", + ); + }); + + test("a generic arrow's type parameters are not a tag", () => { + expect( + closeOpen( + 'const setE = (k: K, v: D["e"][K]) =>\n setDraft((d) => ({ ...d, [k]: v }));\nconst f = (x: T) => x;\nreturn (\n
    ', + ), + ).toMatch(/return \(\n {2}
    <\/div>\)$/); + }); + + test("a cut inside a string or comment can't be closed", () => { + expect(closeOpen('const a = "abc')).toBeNull(); + expect(closeOpen("/* note")).toBeNull(); + }); +}); + +describe("renderablePartial", () => { + test("every line prefix of a page is renderable once its default export starts", async () => { + const lines = PAGE.split("\n"); + const start = lines.findIndex((l) => l.startsWith("export default")); + for (let i = start + 2; i <= lines.length; i++) { + const prefix = `${lines.slice(0, i).join("\n")}\n`; + const code = await renderablePartial(prefix, parses); + expect(code, `prefix of ${i} lines`).not.toBeNull(); + expect(await parses(code as string)).toBe(true); + } + }); + + test("helpers not written yet are stubbed", async () => { + const cut = PAGE.slice(0, PAGE.indexOf("function Row")); + const code = (await renderablePartial(cut, parses)) as string; + expect(code).toContain( + 'import { DraftPlaceholder as __DraftPlaceholder } from "@databricks/appkit-ui/react/beta";', + ); + expect(code).toContain( + 'function Row() { return <__DraftPlaceholder kind="generic" />; }', + ); + expect(code).toContain( + 'function Footer() { return <__DraftPlaceholder kind="generic" />; }', + ); + expect(await parses(code)).toBe(true); + }); + + test("a generic arrow's type parameter isn't stubbed as a component", async () => { + const src = `export default function Page() {\n const pick = (k: K) => k;\n const id = (x: T) => x;\n return

    {pick("a")}{id(1)}

    ;\n}\n`; + const code = (await renderablePartial(src, parses)) as string; + expect(code).not.toContain("__DraftPlaceholder"); + }); + + test("nothing before the default export", async () => { + expect( + await renderablePartial(PAGE.slice(0, PAGE.indexOf("export")), parses), + ).toBeNull(); + }); + + test("a complete source is used as is", async () => { + expect(await renderablePartial(PAGE, parses, { complete: true })).toBe( + PAGE, + ); + }); +}); + +describe("placeholderKind", () => { + test("the skeleton follows the component's name", () => { + expect(placeholderKind("TripsChart")).toBe("chart"); + expect(placeholderKind("RecentTripsTable")).toBe("table"); + expect(placeholderKind("KpiCard")).toBe("card"); + expect(placeholderKind("Header")).toBe("generic"); + }); +}); + +describe("stubMock", () => { + test("stubs the values a page imports that the mock doesn't export yet", () => { + expect(mockImports(PAGE)).toEqual(["trips"]); + // The contract comment lists trips before it is defined. + const mock = + "export type Trip = { id: number };\n// export const trips: Trip[]\nexport const kpis = {};"; + const code = stubMock(mock, ["trips", "kpis"]); + expect(code).toContain("export const trips: any = __draftValue;"); + expect(code).not.toContain("export const kpis: any"); + }); +}); diff --git a/packages/appkit/src/plugins/design/vite-plugin.ts b/packages/appkit/src/plugins/design/vite-plugin.ts new file mode 100644 index 000000000..b317f8ec1 --- /dev/null +++ b/packages/appkit/src/plugins/design/vite-plugin.ts @@ -0,0 +1,1124 @@ +import fs from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; + +import type { Plugin, ViteDevServer } from "vite"; + +import { + mockImports, + renderablePartial, + streamHostModule, + stubMock, +} from "./stream"; + +/** Virtual page entry; `.mjs` so plugin-react doesn't inject Fast Refresh into it. */ +const ENTRY_PREFIX = "virtual:appkit-design-page:"; +const ENTRY_SUFFIX = ".mjs"; +/** Script added to the app's own pages (not /design) to post appkit:app-ready. */ +const APP_READY_ID = "virtual:appkit-app-ready.mjs"; +/** Host page of a streamed TSX draft: `virtual:appkit-design-stream:.mjs`. */ +const STREAM_HOST_PREFIX = "virtual:appkit-design-stream:"; + +/** Marks the template's placeholder home: the app isn't reported ready while it's shown. */ +const TEMPLATE_HOME_ATTR = "data-appkit-template-home"; +const PAGE_FILE = /^(index|pages\/[A-Za-z0-9_-]+)\.(tsx|jsx)$/; +const DESIGN_ROUTE = /^\/design(?:\/([A-Za-z0-9_-]+))?\/?$/; +const STATUS_PATH = "/design/__status"; +/** `/design/__stream/`: a TSX draft rendered while it's being written. */ +const STREAM_ROUTE = /^\/design\/__stream\/([A-Za-z0-9_-]+)\/?$/; +/** At most one published version per stream in this long. */ +const STREAM_INTERVAL_MS = 150; +const EXTENSIONS = [".tsx", ".jsx"]; + +/** `pages/index` would collide with `/design`, `__*` with the plugin's endpoints. */ +const isReservedName = (name: string) => + name === "index" || name.startsWith("__"); + +/** Page files the browser entry may route to (reserved names excluded). */ +const PAGE_GLOBS = [ + "/src/design/index.{tsx,jsx}", + "/src/design/pages/*.{tsx,jsx}", + "!/src/design/pages/index.{tsx,jsx}", + "!/src/design/pages/__*", +]; + +/** Marks the template's starter page: never reported ready while exported. */ +const PLACEHOLDER_EXPORT = /export\s+const\s+designPlaceholder\s*=\s*true/; + +/** A design page as listed in the status and ready payloads. */ +interface DesignPageInfo { + /** `index`, or the `` of `pages/.tsx`. */ + name: string; + /** URL of the page: `/design` or `/design/`. */ + path: string; +} + +/** + * `/design` → `index.tsx`, `/design/` → `pages/.tsx`; `undefined` outside + * `/design`, `null` for a missing page (404). + * + * @internal + */ +export function resolveDesignPage( + clientRoot: string, + pathname: string, +): string | null | undefined { + if (pathname !== "/design" && !pathname.startsWith("/design/")) { + return undefined; + } + const match = DESIGN_ROUTE.exec(pathname); + if (!match || (match[1] && isReservedName(match[1]))) return null; + const base = match[1] + ? path.join(clientRoot, "src/design/pages", match[1]) + : path.join(clientRoot, "src/design/index"); + for (const ext of EXTENSIONS) { + if (fs.existsSync(base + ext)) return base + ext; + } + return null; +} + +/** Every existing design page, index first. @internal */ +export function listDesignPages(clientRoot: string): DesignPageInfo[] { + const pages: DesignPageInfo[] = []; + if (resolveDesignPage(clientRoot, "/design")) { + pages.push({ name: "index", path: "/design" }); + } + const dir = path.join(clientRoot, "src/design/pages"); + const names = fs.existsSync(dir) + ? fs + .readdirSync(dir) + .map((f) => /^([A-Za-z0-9_-]+)\.(tsx|jsx)$/.exec(f)?.[1]) + .filter((n): n is string => !!n && !isReservedName(n)) + : []; + for (const name of [...new Set(names)].sort()) { + pages.push({ name, path: `/design/${name}` }); + } + return pages; +} + +/** Modification time of a design page's file, or undefined when there is none. */ +function pageVersion(clientRoot: string, name: string): number | undefined { + const file = resolveDesignPage( + clientRoot, + name === "index" ? "/design" : `/design/${name}`, + ); + return file ? fs.statSync(file).mtimeMs : undefined; +} + +/** The app's entry module, from the ` + + +`; + +/** @internal `/design`, `/design/`, `/design/__stream/` and `/design/__status`. */ +export function designVitePlugin({ + builderOrigins = [], +}: { builderOrigins?: string[] } = {}): Plugin { + let root = process.cwd(); + // The app folder (the dev server's working directory). + const projectRoot = process.cwd(); + // Per design page, for one version of its file: rendered and errors. + const pageReports = new Map< + string, + { version: number | undefined; rendered: boolean; errors: ReportedError[] } + >(); + // The app's pages, per path, and the path reported last. + const appReports = new Map< + string, + { rendered: boolean; template: boolean; errors: ReportedError[] } + >(); + let appPath: string | undefined; + + // Streamed drafts (.staging/.tsx, .staging/mock.ts): by name, the + // published version and the staging mtime it came from. + const streams = new Map< + string, + { + code: string | null; + // The last watcher event: the staging file's mtime then, and when it came. + written: number; + seen: number; + busy: boolean; + pending: boolean; + last: number; + } + >(); + const stagingFile = (name: string) => + path.join( + root, + "src/design/.staging", + name === "mock" ? "mock.ts" : `${name}.tsx`, + ); + const finalFile = (name: string) => + name === "mock" + ? path.join(root, "src/design/mock.ts") + : path.join(root, "src/design/pages", `${name}.tsx`); + const streamName = (file: string): string | undefined => { + const rel = path + .relative(path.join(root, "src/design"), file) + .split(path.sep) + .join("/"); + if (rel === ".staging/mock.ts" || rel === "mock.ts") return "mock"; + const name = /^(?:\.staging|pages)\/([A-Za-z0-9_-]+)\.tsx$/.exec(rel)?.[1]; + return name && !isReservedName(name) ? name : undefined; + }; + const streamCssDir = path.join( + projectRoot, + "node_modules/.appkit-design-stream", + ); + const streamCssIds = new Set(); + // Words in streamed string literals: Tailwind rebuilds only for new ones. + const streamTokens = new Set(); + const newTokens = (code: string) => { + let added = false; + for (const m of code.matchAll(/["'`]([^"'`\n]*)["'`]/g)) { + for (const token of m[1].split(/\s+/)) { + if (token && !streamTokens.has(token)) { + streamTokens.add(token); + added = true; + } + } + } + return added; + }; + let viteServer: ViteDevServer | undefined; + /** The published version of a page while it streams, else null (from disk). */ + const streamedCode = (name: string) => { + const code = streams.get(name)?.code ?? null; + // A builder starting over deletes it; the watcher reports that later. + return code !== null && + fs.existsSync(stagingFile(name)) && + !fs.existsSync(finalFile(name)) + ? code + : null; + }; + /** The mock before mock.ts exists: .staging/mock.ts so far, plus stubs. */ + const mockCode = (): string | null => { + if (fs.existsSync(finalFile("mock"))) return null; + const pages = [...streams.keys()] + .filter((n) => n !== "mock") + .map(streamedCode) + .filter((c): c is string => c !== null); + const mock = fs.existsSync(stagingFile("mock")) + ? (streams.get("mock")?.code ?? null) + : null; + if (mock === null && pages.length === 0) return null; + return stubMock( + mock ?? "", + pages.flatMap((p) => mockImports(p)), + ); + }; + let servedMock: string | null = null; + const reloadFile = async (file: string) => { + for (const mod of viteServer?.moduleGraph.getModulesByFile(file) ?? []) { + // Not imported (page closed): a hot update would reload every page. + if (mod.importers.size === 0 && !mod.isSelfAccepting) continue; + await viteServer?.reloadModule(mod); + } + }; + const reloadMock = async () => { + const code = mockCode(); + if (code === servedMock) return; + servedMock = code; + await reloadFile(finalFile("mock")); + }; + const reloadCss = async () => { + for (const id of streamCssIds) { + const mod = await viteServer?.moduleGraph.getModuleById(id); + if (mod) await viteServer?.reloadModule(mod); + } + }; + const isValid = async (code: string) => { + const vite = (await import("vite")) as typeof import("vite") & { + transformWithOxc?: ( + code: string, + file: string, + opts: object, + ) => Promise; + }; + try { + if (vite.transformWithOxc) { + await vite.transformWithOxc(code, "draft.tsx", { lang: "tsx" }); + } else { + await vite.transformWithEsbuild(code, "draft.tsx", { loader: "tsx" }); + } + return true; + } catch { + return false; + } + }; + /** Publishes the renderable version of a staging file, if it changed. */ + const publish = async (name: string) => { + const s = streams.get(name); + if (!s) return; + const wait = s.last + STREAM_INTERVAL_MS - Date.now(); + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); + if (fs.existsSync(finalFile(name))) return; + let raw: string; + try { + raw = fs.readFileSync(stagingFile(name), "utf-8"); + } catch { + return; + } + const mock = name === "mock"; + const code = await renderablePartial(raw, isValid, { page: !mock }); + if (code === null || code === s.code) return; + s.code = code; + s.last = Date.now(); + // Styles first, so the version never shows without its classes. + if (!mock && newTokens(code)) { + fs.writeFileSync(path.join(streamCssDir, `${name}.tsx`), code); + await reloadCss(); + } + if (!mock) await reloadFile(finalFile(name)); + await reloadMock(); + viteServer?.ws.send({ + type: "custom", + event: "appkit:stream", + // Times for measuring: written (staging mtime), seen by the watcher, published. + data: { name, written: s.written, seen: s.seen, published: Date.now() }, + }); + }; + const onStagingChange = async (name: string) => { + const seen = Date.now(); + let s = streams.get(name); + if (!s) { + s = { + code: null, + written: 0, + seen: 0, + busy: false, + pending: false, + last: 0, + }; + streams.set(name, s); + } + s.seen = seen; + try { + s.written = fs.statSync(stagingFile(name)).mtimeMs; + } catch {} + if (s.busy) { + s.pending = true; + return; + } + s.busy = true; + try { + do { + s.pending = false; + await publish(name); + } while (s.pending); + } finally { + s.busy = false; + } + }; + /** The finished file landed: Vite hot-updates the module from disk. */ + const onFinal = async (name: string) => { + const file = finalFile(name); + let code: string; + let written: number; + try { + code = fs.readFileSync(file, "utf-8"); + written = fs.statSync(file).mtimeMs; + } catch { + // Deleted again already (a builder starting the draft over). + return; + } + if (name !== "mock") { + if (newTokens(code)) { + fs.writeFileSync(path.join(streamCssDir, `${name}.tsx`), code); + await reloadCss(); + } + } + await reloadMock(); + viteServer?.ws.send({ + type: "custom", + event: "appkit:stream", + data: { + name, + done: true, + written, + seen: Date.now(), + }, + }); + }; + + return { + name: "appkit-design", + apply: "serve", + + configResolved(config) { + root = config.root; + }, + + resolveId: { + // Before Vite's resolver, which can't find a streamed file not on disk yet. + order: "pre", + handler(id, importer) { + if ( + id.startsWith(ENTRY_PREFIX) || + id.startsWith(STREAM_HOST_PREFIX) || + id === APP_READY_ID + ) { + return `\0${id}`; + } + const clean = id.split("?")[0]; + // As the host imports it, or by file (Fast Refresh imports itself). + const page = + /^(?:\/src\/design\/pages\/|.*\/src\/design\/pages\/)([A-Za-z0-9_-]+)\.tsx$/.exec( + clean, + )?.[1]; + if ( + page && + (clean.startsWith("/src/") || clean === finalFile(page)) && + streamedCode(page) !== null + ) { + return finalFile(page); + } + if ( + /^\.\.\/mock(?:\.ts)?$/.test(clean) && + importer?.startsWith(path.join(root, "src/design/pages")) && + mockCode() !== null + ) { + return finalFile("mock"); + } + }, + }, + + transformIndexHtml(_html, ctx) { + // App pages only (design pages report themselves); also without a builder. + if (resolveDesignPage(root, ctx.path.split("?")[0]) !== undefined) return; + return [ + { + tag: "script", + attrs: { type: "module", src: `/@id/__x00__${APP_READY_ID}` }, + injectTo: "body", + }, + ]; + }, + + async load(id) { + if (id === `\0${APP_READY_ID}`) return appReadyModule(builderOrigins); + if ( + id.startsWith(`\0${STREAM_HOST_PREFIX}`) && + id.endsWith(ENTRY_SUFFIX) + ) { + const name = id.slice( + STREAM_HOST_PREFIX.length + 1, + -ENTRY_SUFFIX.length, + ); + return /^[A-Za-z0-9_-]+$/.test(name) + ? streamHostModule(name) + : undefined; + } + // A streamed page or mock: its latest renderable version. + const loaded = id.split("?")[0]; + const streamed = + loaded === finalFile("mock") + ? mockCode() + : streams.has(path.basename(loaded, ".tsx")) && + loaded === finalFile(path.basename(loaded, ".tsx")) + ? streamedCode(path.basename(loaded, ".tsx")) + : null; + if (streamed !== null) return streamed; + if (!id.startsWith(`\0${ENTRY_PREFIX}`)) return; + if (!id.endsWith(ENTRY_SUFFIX)) return; + const file = id.slice(ENTRY_PREFIX.length + 1, -ENTRY_SUFFIX.length); + if (PAGE_FILE.test(file)) { + const router = hasReactRouter(root); + return entryModule(file, builderOrigins, router); + } + }, + + // A deleted draft still in the graph would reload every page. + hotUpdate({ type, file, modules }) { + const name = streamName(file); + if ( + type === "delete" && + name && + (file === finalFile(name) || file === stagingFile(name)) + ) { + // Forget it, and its mock import, or a mock update reloads every page. + for (const mod of modules) { + this.environment.moduleGraph.invalidateModule(mod); + for (const dep of mod.importedModules) dep.importers.delete(mod); + } + return []; + } + // Nothing imports it yet: no boundary, Vite would reload every page. + if (name && file === finalFile(name)) { + const live = modules.filter( + (mod) => mod.importers.size > 0 || mod.isSelfAccepting, + ); + if (live.length < modules.length) return live; + } + }, + + // Tailwind scans files: point it at the streamed versions too. + transform: { + order: "pre", + handler(code, id) { + const file = id.split("?")[0]; + if ( + !file.endsWith(".css") || + !file.startsWith(path.join(root, "src")) || + !code.includes("@import") + ) { + return; + } + streamCssIds.add(id); + return `${code}\n@source "${streamCssDir}";\n`; + }, + }, + + configureServer(server) { + viteServer = server; + fs.mkdirSync(streamCssDir, { recursive: true }); + const onFile = (file: string) => { + const name = streamName(file); + if (!name) return; + // A draft file must never take the dev server down. + const failed = (error: unknown) => + server.config.logger.warn( + `[appkit-design] ${name}: ${String(error)}`, + ); + if (file === stagingFile(name)) onStagingChange(name).catch(failed); + else if (file === finalFile(name) && streams.has(name)) + onFinal(name).catch(failed); + }; + server.watcher?.on("add", onFile); + server.watcher?.on("change", onFile); + // A new draft by the same name starts from nothing. + server.watcher?.on("unlink", (file) => { + const name = streamName(file); + if (name && file === stagingFile(name)) streams.delete(name); + }); + server.middlewares.use(async (req, res, next) => { + const pathname = req.url?.split("?")[0] ?? ""; + + // Deleted a moment ago: drop Vite's cached transform. + const deleted = /^\/src\/design\/pages\/([A-Za-z0-9_-]+)\.tsx$/.exec( + pathname, + )?.[1]; + if ( + deleted && + streamedCode(deleted) === null && + !fs.existsSync(finalFile(deleted)) + ) { + for (const mod of server.moduleGraph.getModulesByFile( + finalFile(deleted), + ) ?? []) { + server.moduleGraph.invalidateModule(mod); + } + } + + // The status endpoint is also read by builder pages on other origins. + const origin = req.headers?.origin; + if ( + typeof origin === "string" && + builderOrigins.includes(origin) && + pathname === STATUS_PATH + ) { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + if (req.method === "OPTIONS") { + res.setHeader("Access-Control-Allow-Methods", "GET, POST"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + res.statusCode = 204; + res.end(); + return; + } + } + + const streamPage = STREAM_ROUTE.exec(pathname)?.[1]; + if (streamPage && !isReservedName(streamPage)) { + const html = await server.transformIndexHtml( + req.url ?? pathname, + pageHtml( + `Streaming: ${streamPage}`, + `/@id/__x00__${STREAM_HOST_PREFIX}${streamPage}${ENTRY_SUFFIX}`, + ), + ); + res.setHeader("Content-Type", "text/html"); + res.end(html); + return; + } + + if (pathname === STATUS_PATH) { + if (req.method === "POST") { + // express.json() may have parsed it already (AppKit's server). + let body = (req as { body?: { page?: unknown } }).body; + if (body === undefined) { + let raw = ""; + for await (const chunk of req) raw += chunk; + try { + body = JSON.parse(raw); + } catch {} + } + const r = body as { + page?: unknown; + app?: unknown; + reset?: unknown; + ok?: unknown; + template?: unknown; + error?: unknown; + }; + if (typeof r?.page === "string") { + const version = pageVersion(server.config.root, r.page); + let entry = pageReports.get(r.page); + if (r.reset === true || !entry || entry.version !== version) { + entry = { version, rendered: false, errors: [] }; + pageReports.set(r.page, entry); + } + if (typeof r.ok === "boolean") entry.rendered = r.ok; + if (typeof r.error === "string") + entry.errors.push({ error: r.error }); + } + if (typeof r?.app === "string") { + let entry = appReports.get(r.app); + if (r.reset === true || !entry) { + entry = { rendered: false, template: false, errors: [] }; + appReports.set(r.app, entry); + } + appPath = r.app; + if (typeof r.ok === "boolean") { + entry.rendered = r.ok; + entry.template = r.template === true; + } + if (typeof r.error === "string") { + entry.errors.push({ error: r.error }); + } + } + res.statusCode = 204; + res.end(); + return; + } + res.setHeader("Content-Type", "application/json"); + res.setHeader("Cache-Control", "no-store"); + const indexReport = pageReports.get("index"); + const status = designStatus( + server.config.root, + indexReport?.rendered ? indexReport.version : undefined, + ); + // Per page, for the version on disk (null until a browser reported). + const pages = await Promise.all( + status.pages.map(async (page) => { + const report = pageReports.get(page.name); + const known = + !!report && + report.version === pageVersion(server.config.root, page.name); + const file = resolveDesignPage(server.config.root, page.path); + const pageCompileError = file + ? await compileGraph( + server, + `/${path.relative(server.config.root, file).split(path.sep).join("/")}`, + ) + : "missing"; + const info = { + ...page, + compiled: pageCompileError === undefined, + ...(pageCompileError ? { compileError: pageCompileError } : {}), + rendered: known && report.rendered, + errors: known ? report.errors : null, + }; + return { ...info, state: pageState(info) }; + }), + ); + // The app: compiled from index.html's entry, plus its reports. + const entry = appEntry(server.config.root); + const appCompileError = entry + ? await compileGraph(server, entry) + : undefined; + const appReport = + appPath === undefined ? undefined : appReports.get(appPath); + const app = { + entry: entry ?? null, + compiled: !!entry && appCompileError === undefined, + ...(appCompileError ? { compileError: appCompileError } : {}), + path: appPath ?? null, + rendered: appReport?.rendered ?? false, + template: appReport?.template ?? false, + errors: appReport ? appReport.errors : null, + }; + // `compiled`: the index page's server-side build, no browser needed. + const index = resolveDesignPage(server.config.root, "/design"); + const indexPage = + index && !status.placeholder + ? pages.find((page) => page.name === "index") + : undefined; + const compiled = indexPage?.compiled ?? false; + const compileError = indexPage?.compileError; + const waitingFor = !index + ? "index" + : status.placeholder + ? "placeholder" + : !compiled + ? "compile" + : !status.ready + ? "paint" + : null; + res.end( + JSON.stringify({ + ...status, + pages, + compiled, + ...(compileError ? { compileError } : {}), + waitingFor, + app, + }), + ); + return; + } + + const file = resolveDesignPage(server.config.root, pathname); + if (file === undefined) return next(); + if (file === null) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain"); + res.end( + `No design page for ${pathname}. Add client/src/design/index.tsx (for /design) or client/src/design/pages/.tsx (for /design/) with a default export. "index" and names starting with "__" are reserved for page files.`, + ); + return; + } + try { + const name = path.basename(file, path.extname(file)); + const title = name === "index" ? "Design" : `Design: ${name}`; + const rel = path + .relative(path.join(server.config.root, "src/design"), file) + .split(path.sep) + .join("/"); + const html = await server.transformIndexHtml( + req.url ?? pathname, + pageHtml(title, `/@id/__x00__${ENTRY_PREFIX}${rel}${ENTRY_SUFFIX}`), + ); + res.setHeader("Content-Type", "text/html"); + res.end(html); + } catch (error) { + next(error); + } + }); + }, + }; +} diff --git a/packages/appkit/src/plugins/server/index.ts b/packages/appkit/src/plugins/server/index.ts index 5482228f8..bbc7b42d8 100644 --- a/packages/appkit/src/plugins/server/index.ts +++ b/packages/appkit/src/plugins/server/index.ts @@ -7,6 +7,7 @@ import express from "express"; import getPort, { portNumbers } from "get-port"; import type { PluginClientConfigs, PluginPhase } from "shared"; import { camelToKebab } from "shared"; +import type { PluginOption } from "vite"; import { AppKitError, ServerError } from "../../errors"; import { httpStatusOf } from "../../errors/http-status"; @@ -354,10 +355,14 @@ export class ServerPlugin extends Plugin { // auto-detection based on environment if (isDev) { + const vitePlugins = [ + ...(this.context?.getPlugins().values() ?? []), + ].flatMap((plugin) => plugin.vitePlugins?.() ?? []); this.viteDevServer = new ViteDevServer( this.serverApplication, endpoints, pluginConfigs, + vitePlugins as PluginOption[], ); await this.viteDevServer.setup(); return; diff --git a/packages/appkit/src/plugins/server/vite-dev-server.ts b/packages/appkit/src/plugins/server/vite-dev-server.ts index c0239d9bc..c5f0c3d8a 100644 --- a/packages/appkit/src/plugins/server/vite-dev-server.ts +++ b/packages/appkit/src/plugins/server/vite-dev-server.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type express from "express"; -import type { ViteDevServer as ViteDevServerType } from "vite"; +import type { PluginOption, ViteDevServer as ViteDevServerType } from "vite"; import { ServerError } from "../../errors"; import { createLogger } from "../../logging/logger"; @@ -35,6 +35,7 @@ export class ViteDevServer extends BaseServer { app: express.Application, endpoints: PluginEndpoints = {}, pluginConfigs: PluginClientConfigs = {}, + private readonly extraPlugins: PluginOption[] = [], ) { super(app, endpoints, pluginConfigs); this.vite = null; @@ -90,6 +91,7 @@ export class ViteDevServer extends BaseServer { typecheckPlugin({ projectRoot }), appKitTypesPlugin(), appKitServingTypesPlugin(), + ...this.extraPlugins, ], appType: "custom", }; diff --git a/packages/appkit/src/type-generator/query-registry.ts b/packages/appkit/src/type-generator/query-registry.ts index d91b8cabe..8ccdb1ef0 100644 --- a/packages/appkit/src/type-generator/query-registry.ts +++ b/packages/appkit/src/type-generator/query-registry.ts @@ -5,6 +5,11 @@ import { tableFromIPC } from "apache-arrow"; import pc from "picocolors"; import { createLogger } from "../logging/logger"; +import { + extractParameters, + getProtectedRanges, + isInsideProtectedRange, +} from "../utils/sql-parameters"; import { CACHE_VERSION, hashSQL, loadCache, saveCache } from "./cache"; import { classifyBlockingFailure, @@ -40,20 +45,6 @@ const logger = createLogger("type-generator:query-registry"); */ const PREFLIGHT_WAIT_MAX_MS = 300_000; -/** - * Regex breakdown: - * '(?:[^']|'')*' — matches a SQL string literal, including escaped '' pairs - * | — alternation: whichever branch matches first at a position wins - * --[^\n]* — matches a single-line SQL comment - * - * Because the regex engine scans left-to-right, a `'` is consumed as a string - * literal before any `--` inside it could match as a comment — giving us - * correct single-pass ordering without a manual state machine. - * - * V1: no block-comment support (deferred to next PR). - */ -const PROTECTED_RANGE_RE = /'(?:[^']|'')*'|--[^\n]*/g; - /** * Numeric-context patterns for positional type inference. * Hoisted to module scope — safe because matchAll() clones the regex internally. @@ -70,21 +61,6 @@ const NUMERIC_PATTERNS: RegExp[] = [ /:([a-zA-Z_]\w*)\s*[+\-*/]/g, ]; -export function getProtectedRanges(sql: string): Array<[number, number]> { - const ranges: Array<[number, number]> = []; - for (const m of sql.matchAll(PROTECTED_RANGE_RE)) { - ranges.push([m.index, m.index + m[0].length]); - } - return ranges; -} - -function isInsideProtectedRange( - offset: number, - ranges: Array<[number, number]>, -): boolean { - return ranges.some(([start, end]) => offset >= start && offset < end); -} - /** * Parse a raw API/SDK error into a structured code + message. * Handles Databricks-style JSON bodies embedded in the message string, @@ -108,25 +84,7 @@ function parseError(raw: string): { code?: string; message: string } { return { message: raw }; } -/** - * Extract parameters from a SQL query - * @param sql - the SQL query to extract parameters from - * @returns an array of parameter names - */ -export function extractParameters( - sql: string, - ranges?: Array<[number, number]>, -): string[] { - const protectedRanges = ranges ?? getProtectedRanges(sql); - const matches = sql.matchAll(/(?(); - for (const match of matches) { - if (!isInsideProtectedRange(match.index, protectedRanges)) { - params.add(match[1]); - } - } - return Array.from(params); -} +export { extractParameters, getProtectedRanges }; // parameters that are injected by the server export const SERVER_INJECTED_PARAMS = ["workspaceId"]; diff --git a/packages/appkit/src/utils/sql-parameters.ts b/packages/appkit/src/utils/sql-parameters.ts new file mode 100644 index 000000000..023f1336f --- /dev/null +++ b/packages/appkit/src/utils/sql-parameters.ts @@ -0,0 +1,41 @@ +/** + * Spans where `:name` isn't a parameter: '...' and "..." literals, `...` + * identifiers and `--` comments (not block comments). The first branch to + * match wins, so `"HH:mm'T'"` is one span. + */ +const PROTECTED_RANGE_RE = + /'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"|`(?:[^`]|``)*`|--[^\n]*/g; + +/** Start/end offsets of every span in {@link PROTECTED_RANGE_RE}. */ +export function getProtectedRanges(sql: string): Array<[number, number]> { + const ranges: Array<[number, number]> = []; + for (const m of sql.matchAll(PROTECTED_RANGE_RE)) { + ranges.push([m.index, m.index + m[0].length]); + } + return ranges; +} + +export function isInsideProtectedRange( + offset: number, + ranges: Array<[number, number]>, +): boolean { + return ranges.some(([start, end]) => offset >= start && offset < end); +} + +/** + * Names of the `:name` parameters in a SQL statement, ignoring `::` casts and + * anything inside string literals, quoted identifiers and comments. + */ +export function extractParameters( + sql: string, + ranges?: Array<[number, number]>, +): string[] { + const protectedRanges = ranges ?? getProtectedRanges(sql); + const params = new Set(); + for (const match of sql.matchAll(/(? { + test("colons inside a double-quoted literal aren't parameters", () => { + expect( + extractParameters( + `SELECT date_format(ts, "yyyy-MM-dd'T'HH:mm:ss") AS t FROM trips WHERE day >= :start_date`, + ), + ).toEqual(["start_date"]); + }); + + test.each([ + ["single-quoted", `SELECT 'at 10:30' AS t, :a`], + ["'' escape", `SELECT 'it''s 10:30' AS t, :a`], + ["\\' escape", `SELECT 'it\\'s 10:30' AS t, :a`], + ['"" escape', `SELECT "say ""hi"" at 10:30" AS t, :a`], + ["backtick identifier", "SELECT `col:name` AS t, :a"], + ["line comment", "SELECT 1 -- run at 10:30 :not_me\n, :a"], + [":: cast", "SELECT x::int AS t, :a"], + ])("%s", (_, sql) => { + expect(extractParameters(sql)).toEqual(["a"]); + }); + + test("parameters next to literals still count", () => { + expect( + extractParameters( + `SELECT * FROM t WHERE a = :a AND b = 'x:y' AND c = :c`, + ), + ).toEqual(["a", "c"]); + }); +}); + +describe("QueryProcessor with literals containing colons", () => { + const processor = new QueryProcessor(); + const sql = `SELECT date_format(ts, "yyyy-MM-dd'T'HH:mm:ss") AS t FROM trips WHERE day >= :start_date`; + + test("only the real parameter is accepted", () => { + expect( + processor.convertToSQLParameters(sql, { + start_date: { __sql_type: "DATE", value: "2026-01-01" }, + } as never).parameters, + ).toHaveLength(1); + expect(() => + processor.convertToSQLParameters(sql, { + mm: { __sql_type: "STRING", value: "x" }, + } as never), + ).toThrow(/valid: start_date/); + }); +}); diff --git a/packages/shared/src/plugin.ts b/packages/shared/src/plugin.ts index 1a4e1306f..a56045b42 100644 --- a/packages/shared/src/plugin.ts +++ b/packages/shared/src/plugin.ts @@ -51,6 +51,9 @@ export interface BasePlugin { clientConfig?(): Record; + /** Vite plugins added to the dev server (development only). Typed loosely so `shared` doesn't depend on `vite`. */ + vitePlugins?(): unknown[]; + /** * Binds runtime dependencies (telemetry, cache, plugin context) after the * plugin has been constructed. Called by the AppKit core before `setup()`. diff --git a/template/README.md.tmpl b/template/README.md.tmpl index 5d3f9ce20..c6564cb81 100644 --- a/template/README.md.tmpl +++ b/template/README.md.tmpl @@ -15,6 +15,9 @@ A Databricks App powered by [AppKit](https://developers.databricks.com/docs/appk {{- if .plugins.genie}} - **Genie** -- AI/BI Genie conversational interface for natural language data queries {{- end}} +{{- if .plugins.design}} +- **Design** -- Dev-only design pages: `/design` renders `client/src/design/index.tsx`, `/design/` renders `client/src/design/pages/.tsx`, and `/design/__stream/` shows a draft page while it's being written (404 in production) +{{- end}} - **Server** -- Express HTTP server with static file serving and Vite dev mode ## Prerequisites diff --git a/template/_gitignore b/template/_gitignore index 29895e7c0..cb3013bfb 100644 --- a/template/_gitignore +++ b/template/_gitignore @@ -5,6 +5,7 @@ dist/ build/ .env .databricks/ +.appkit/ .smoke-test/ test-results/ playwright-report/ diff --git a/template/appkit.plugins.json b/template/appkit.plugins.json index db53b96b7..aa9e6cae6 100644 --- a/template/appkit.plugins.json +++ b/template/appkit.plugins.json @@ -196,6 +196,17 @@ } } }, + "design": { + "name": "design", + "displayName": "Design Plugin", + "description": "Dev-only design pages: /design renders client/src/design/index.tsx and /design/ renders client/src/design/pages/.tsx, with a readiness signal for an embedding app builder; 404 in production", + "package": "@databricks/appkit", + "resources": { + "required": [], + "optional": [] + }, + "stability": "beta" + }, "files": { "name": "files", "displayName": "Files Plugin", diff --git a/template/client/index.html b/template/client/index.html index a3da7c2b9..0ca2f3667 100644 --- a/template/client/index.html +++ b/template/client/index.html @@ -9,6 +9,8 @@ + + {{.projectName}} diff --git a/template/client/src/App.tsx b/template/client/src/App.tsx index 7dad5a351..e0499841e 100644 --- a/template/client/src/App.tsx +++ b/template/client/src/App.tsx @@ -184,9 +184,10 @@ export default function App() { return ; } +// `data-appkit-template-home` marks the placeholder home: remove it with the page. function HomePage() { return ( -
    +

    Welcome to your Databricks App diff --git a/template/client/src/design/index.tsx b/template/client/src/design/index.tsx new file mode 100644 index 000000000..84b38b806 --- /dev/null +++ b/template/client/src/design/index.tsx @@ -0,0 +1,34 @@ +{{if .plugins.design -}} +import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@databricks/appkit-ui/react'; + +/* + * /design (dev only); more pages go in pages/.tsx (/design/), linked + * with (router basename /design). Content that renders late: + * useDesignReady(isDone) from '@databricks/appkit-ui/react/beta'. + */ + +// Marks this starter page: /design isn't reported ready while it's exported. Delete it. +export const designPlaceholder = true; + +export default function DesignPage() { + return ( +
    +
    +
    +

    {{.projectName}}

    +

    A starting point for your design.

    +
    + + + Get started + Replace this page with your layout. + + + + + +
    +
    + ); +} +{{- end}} diff --git a/template/vitest.config.ts b/template/vitest.config.ts index c99a4069c..f934fe6ec 100644 --- a/template/vitest.config.ts +++ b/template/vitest.config.ts @@ -7,6 +7,12 @@ export default defineConfig({ globals: true, environment: 'node', exclude: ['**/node_modules/**', '**/dist/**', '**/.databricks/**'], + server: { + deps: { + // echarts-for-react's ESM build has extensionless imports Node rejects. + inline: [/@databricks\/appkit-ui/, 'echarts-for-react'], + }, + }, }, resolve: { alias: { diff --git a/tools/generate-app-templates.ts b/tools/generate-app-templates.ts index d78a0e34b..4ecd99534 100644 --- a/tools/generate-app-templates.ts +++ b/tools/generate-app-templates.ts @@ -121,6 +121,12 @@ const APP_TEMPLATES: AppTemplate[] = [ description: "Node.js app with Lakebase Autoscaling (Postgres) CRUD operations", }, + { + name: "ui-only", + features: ["design"], + description: + "Empty UI-only app (server + dev-only design pages) for quick PoCs", + }, ]; function run(cmd: string, args: string[], opts?: { cwd?: string }): number {