@definite-app/data-apps 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +686 -0
- package/LICENSE +201 -0
- package/README.md +643 -0
- package/build.mjs +459 -0
- package/examples/_refined_demo/app.json +15 -0
- package/examples/_refined_demo/data/sample.parquet +0 -0
- package/examples/_refined_demo/gen_preview_data.py +59 -0
- package/examples/_refined_demo/preview-data.json +13 -0
- package/examples/_refined_demo/src/App.tsx +188 -0
- package/examples/_refined_demo/src/main.tsx +12 -0
- package/examples/loan-portfolio/app.json +31 -0
- package/examples/loan-portfolio/data/loan_book.parquet +0 -0
- package/examples/loan-portfolio/gen_preview_data.py +454 -0
- package/examples/loan-portfolio/preview-data.json +84 -0
- package/examples/loan-portfolio/src/App.tsx +1103 -0
- package/examples/loan-portfolio/src/main.tsx +12 -0
- package/examples/revenue-explorer/app.json +23 -0
- package/examples/revenue-explorer/data/transactions.parquet +0 -0
- package/examples/revenue-explorer/gen_preview_data.py +129 -0
- package/examples/revenue-explorer/preview-data.json +49 -0
- package/examples/revenue-explorer/src/App.tsx +527 -0
- package/examples/revenue-explorer/src/main.tsx +12 -0
- package/package.json +55 -0
- package/preview.mjs +35 -0
- package/runtime/definite-runtime.tsx +5934 -0
- package/scripts/headless-smoke.mjs +196 -0
- package/templates/blank/app.json +15 -0
- package/templates/blank/src/App.tsx +41 -0
- package/templates/blank/src/main.tsx +12 -0
- package/templates/refined/app.json +15 -0
- package/templates/refined/src/App.tsx +198 -0
- package/templates/refined/src/main.tsx +12 -0
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
# Definite Data Apps
|
|
2
|
+
|
|
3
|
+
Build source-authored React data apps that compile to a single HTML file and run through the Definite iframe bridge with DuckDB WASM and Perspective.js.
|
|
4
|
+
|
|
5
|
+
## How data flows (read this first)
|
|
6
|
+
|
|
7
|
+
Understanding this pipeline prevents the most common bugs:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
app.json Definite platform Browser DuckDB WASM App.tsx
|
|
11
|
+
(manifest) --> (server-side fetch) --> (local tables) --> (client-side SQL)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
1. **app.json** declares every data resource the app needs. Each resource has a key, a `kind`, and a `source` (SQL, Cube, or file).
|
|
15
|
+
2. **The Definite platform** reads the manifest and fetches data server-side (from DuckLake, Cube, GCS, etc.). The app itself never talks to the warehouse directly.
|
|
16
|
+
3. **The runtime** loads the fetched data into a **browser-side DuckDB WASM** instance as local tables. Each `kind: "dataset"` resource becomes a DuckDB table; `kind: "json"` resources are returned as plain arrays.
|
|
17
|
+
4. **App.tsx** queries those local tables via `useSqlQuery(dataset, sql, deps)`. These SQL queries run in the browser against DuckDB WASM, not against the server.
|
|
18
|
+
|
|
19
|
+
**Key implication:** The column names in your `useSqlQuery` SQL must match the column names in the local DuckDB table, which are determined by how the data was fetched:
|
|
20
|
+
|
|
21
|
+
- **SQL resources (`type: "sql"`):** Column names are the aliases you define in the SQL. `SELECT foo AS myColumn` means the local table has a column called `myColumn`. **You control the names. Always use camelCase aliases.**
|
|
22
|
+
- **Cube resources (`type: "cube"`):** Column names are the Cube member **titles** (e.g., `"Credit Projects FICO Band"`), NOT the member names (e.g., `projects.fico_band`). These are long, have spaces, and require double-quoting in SQL. **Prefer SQL resources over Cube resources for data apps** to avoid this complexity.
|
|
23
|
+
|
|
24
|
+
### Manifest-backed access is enforced
|
|
25
|
+
|
|
26
|
+
Apps can only access data declared in `app.json`. This is validated at two levels:
|
|
27
|
+
|
|
28
|
+
- **Build time:** `build.mjs` scans App.tsx and checks that every `useDataset("key")` and `useJsonResource("key")` call uses a literal string key that exists in `app.json` with the correct `kind`. Build fails if not.
|
|
29
|
+
- **Runtime:** The platform only sends data for resources listed in the manifest. There is no way to run arbitrary SQL against the warehouse from the app.
|
|
30
|
+
|
|
31
|
+
The build **cannot** validate column names inside `useSqlQuery` strings. Column name mismatches are runtime errors that show as "Binder Error: Referenced column not found" in the app.
|
|
32
|
+
|
|
33
|
+
## What you build
|
|
34
|
+
|
|
35
|
+
Each app lives under `examples/{slug}/`:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
examples/{slug}/
|
|
39
|
+
app.json <-- manifest: declares all data resources
|
|
40
|
+
src/main.tsx <-- entry point (don't edit)
|
|
41
|
+
src/App.tsx <-- your UI code (imports from "@definite/runtime")
|
|
42
|
+
preview-data.json <-- optional: dummy data for local preview
|
|
43
|
+
dist/index.html <-- built artifact (generated by build.mjs)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Repo layout
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
definite-data-apps/
|
|
50
|
+
build.mjs <-- build script (compiles app to single HTML)
|
|
51
|
+
preview.mjs <-- preview script (builds with embedded dummy data)
|
|
52
|
+
Makefile <-- convenience commands
|
|
53
|
+
package.json
|
|
54
|
+
runtime/
|
|
55
|
+
definite-runtime.tsx <-- canonical runtime source
|
|
56
|
+
templates/
|
|
57
|
+
blank/ <-- starter template for new apps
|
|
58
|
+
app.json
|
|
59
|
+
src/main.tsx
|
|
60
|
+
src/App.tsx <-- imports from "@definite/runtime"
|
|
61
|
+
examples/
|
|
62
|
+
revenue-explorer/ <-- example app
|
|
63
|
+
starter/ <-- symlink to examples/revenue-explorer/
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Bootstrap flow
|
|
67
|
+
|
|
68
|
+
### 1. Create a new app
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
make new-app NAME=my-app # blank template (default)
|
|
72
|
+
make new-app NAME=my-app TEMPLATE=refined # sidebar shell + drill drawer
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
This copies `templates/<template>/` to `examples/my-app/`. The runtime is resolved via the `@definite/runtime` import alias; no per-app runtime file is needed.
|
|
76
|
+
|
|
77
|
+
**Pick the template by shape, not by app size:**
|
|
78
|
+
|
|
79
|
+
| Template | When to use |
|
|
80
|
+
|----------|-------------|
|
|
81
|
+
| `blank` (default) | Single-view dashboards, embedded Doc tiles, anything that doesn't need a navigation rail. Built on `AppShell`. |
|
|
82
|
+
| `refined` | Multi-view analytics apps with ≥ 2 views, a sidebar, or ≥ 5 filter dimensions. Built on `ShellLayout` + `Sidebar` + `DrillProvider`. |
|
|
83
|
+
|
|
84
|
+
A `refined` app that turns out to only need one view is fine — strip the nav down to one item and keep the shell. But a `blank` app that grows to 6 views will end up reinventing the sidebar; switch templates early if you see that coming.
|
|
85
|
+
|
|
86
|
+
### 2. Edit app.json and src/App.tsx
|
|
87
|
+
|
|
88
|
+
- Update `app.json` with your data resources (SQL queries, column aliases).
|
|
89
|
+
- Update `src/App.tsx` with your UI code and queries.
|
|
90
|
+
- Do NOT edit `src/main.tsx`. The runtime comes from the `@definite/runtime` alias, resolved at build time.
|
|
91
|
+
|
|
92
|
+
### 3. Build
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
make build NAME=my-app
|
|
96
|
+
# or directly:
|
|
97
|
+
node build.mjs examples/my-app
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
This produces `examples/my-app/dist/index.html`.
|
|
101
|
+
|
|
102
|
+
### 4. Preview with dummy data (optional)
|
|
103
|
+
|
|
104
|
+
Create a `preview-data.json` in your app directory, then:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
make preview NAME=my-app
|
|
108
|
+
# or directly:
|
|
109
|
+
node preview.mjs my-app
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
This builds with embedded preview data so you can open the HTML directly in a browser without the Definite platform.
|
|
113
|
+
|
|
114
|
+
### Build validation
|
|
115
|
+
|
|
116
|
+
The build validates manifest-backed resource usage before emitting `dist/index.html`. It fails if:
|
|
117
|
+
|
|
118
|
+
- `useDataset()` or `useJsonResource()` is called without a literal string key
|
|
119
|
+
- A hook key is missing from `app.json`
|
|
120
|
+
- A hook key exists in `app.json` but has the wrong `kind`
|
|
121
|
+
|
|
122
|
+
## Critical constraints
|
|
123
|
+
|
|
124
|
+
1. Keep DuckDB WASM at `1.29.0`, Apache Arrow at `17.0.0`, and Perspective at `4.3.0`. These are pinned because the current Arrow ingestion path is version-sensitive.
|
|
125
|
+
2. **Resource access is manifest-backed.** Every dataset or JSON resource must be declared in `app.json` before App.tsx can use it. Do not hardcode arbitrary bridge SQL in the component tree.
|
|
126
|
+
3. **Always use SQL resources (`type: "sql"`) instead of Cube resources (`type: "cube"`) for data apps.** SQL resources give you full control over column names via aliases. Cube resources return columns named by their Cube *titles* (e.g., `"Credit Projects FICO Band"`) which are long, have spaces, and require double-quoting everywhere. This is the #1 source of bugs.
|
|
127
|
+
4. **Column names in `useSqlQuery` must exactly match the aliases in your app.json SQL.** If app.json has `SELECT fico::INTEGER AS fico`, the local table column is `fico`. If you write `useSqlQuery(data, "SELECT ficoBand FROM ...")` but `ficoBand` isn't an alias in app.json, you get a runtime Binder Error.
|
|
128
|
+
5. Keep Perspective table columns camelCase and convert DuckDB dates/timestamps to `VARCHAR` via `STRFTIME(col, '%Y-%m-%d')` in the app.json SQL. Do not pass raw underscored warehouse column names through.
|
|
129
|
+
6. If a resource is a `.duckdb` file, prefer manifest fields like `alias` and `table` so the app can address it cleanly after attach.
|
|
130
|
+
7. Public embeds are snapshot-only. If a resource needs to work publicly, give it a `snapshot` block in `app.json` or use a downloadable `duckdbFile` / Arrow / Parquet source.
|
|
131
|
+
8. The runtime uses IndexedDB with a 24-hour TTL for datasets and JSON resources. Cache keys include the resource key, mode, and the embedded manifest resource definition so rebuilt apps invalidate naturally.
|
|
132
|
+
9. `refresh()` on `useDataset()` and `useJsonResource()` is a hard refresh. It bypasses IndexedDB and repopulates the cache.
|
|
133
|
+
10. **Never use complex CASE WHEN expressions in client-side `useSqlQuery`.** DuckDB WASM silently returns 0 for compound boolean conditions (especially `NOT IN` with many values, chained `AND`/`LIKE` conditions). Pre-compute all flags as 0/1 integer columns in the app.json SQL (server-side), then use `SUM(flag)::INTEGER` in client-side queries. See "DuckDB WASM limitations" below.
|
|
134
|
+
11. **Always cast `SUM` results to `::INTEGER`** in client-side SQL. DuckDB WASM may return HUGEINT from SUM which JavaScript cannot handle cleanly. Write `SUM(col)::INTEGER AS myCount`, not `SUM(col) AS myCount`.
|
|
135
|
+
|
|
136
|
+
## Manifest shape
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{
|
|
140
|
+
"version": 2,
|
|
141
|
+
"name": "Revenue Explorer",
|
|
142
|
+
"entry": "src/main.tsx",
|
|
143
|
+
"resources": {
|
|
144
|
+
"transactions": {
|
|
145
|
+
"kind": "dataset",
|
|
146
|
+
"source": {
|
|
147
|
+
"type": "sql",
|
|
148
|
+
"sql": "SELECT id AS transactionId, STRFTIME(created_at, '%Y-%m-%d') AS transactionDate, amount::DOUBLE AS amount FROM LAKE.SCHEMA.transactions LIMIT 200000"
|
|
149
|
+
},
|
|
150
|
+
"public": false
|
|
151
|
+
},
|
|
152
|
+
"semanticModel": {
|
|
153
|
+
"kind": "dataset",
|
|
154
|
+
"source": {
|
|
155
|
+
"type": "duckdbFile",
|
|
156
|
+
"drivePath": "apps-v2/revenue-explorer/snapshots/model.duckdb",
|
|
157
|
+
"alias": "model",
|
|
158
|
+
"table": "transactions"
|
|
159
|
+
},
|
|
160
|
+
"public": true
|
|
161
|
+
},
|
|
162
|
+
"branches": {
|
|
163
|
+
"kind": "json",
|
|
164
|
+
"source": {
|
|
165
|
+
"type": "sql",
|
|
166
|
+
"sql": "SELECT branch_id AS branchId, branch_name AS branchName FROM LAKE.SCHEMA.branches ORDER BY 2 LIMIT 3000"
|
|
167
|
+
},
|
|
168
|
+
"snapshot": {
|
|
169
|
+
"format": "json",
|
|
170
|
+
"drivePath": "apps-v2/revenue-explorer/snapshots/branches.json"
|
|
171
|
+
},
|
|
172
|
+
"public": true
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
### Resource kinds
|
|
179
|
+
|
|
180
|
+
- **`kind: "dataset"`**: Loaded into browser-side DuckDB WASM as a table. Queried via `useSqlQuery()`.
|
|
181
|
+
- **`kind: "json"`**: Returned as a plain JavaScript array. Accessed via `useJsonResource()`.
|
|
182
|
+
|
|
183
|
+
### Source types
|
|
184
|
+
|
|
185
|
+
- **`type: "sql"`**: SQL query run server-side against DuckLake. Column names are your `AS` aliases.
|
|
186
|
+
- **`type: "duckdbFile"`**: A `.duckdb` file downloaded and attached locally. Use `alias` and `table` fields.
|
|
187
|
+
- **`type: "cube"`**: Cube semantic layer query. **Avoid for data apps** (see constraint #3).
|
|
188
|
+
|
|
189
|
+
## Runtime helpers
|
|
190
|
+
|
|
191
|
+
Exported from `@definite/runtime`:
|
|
192
|
+
|
|
193
|
+
### Hooks
|
|
194
|
+
|
|
195
|
+
| Hook | Purpose |
|
|
196
|
+
|------|---------|
|
|
197
|
+
| `useDataset(key, opts?)` | Load a `kind: "dataset"` resource into DuckDB WASM. Returns `{ loading, error, tableRef, perspectiveTable, refresh }`. |
|
|
198
|
+
| `useJsonResource(key, opts?)` | Load a `kind: "json"` resource. Returns `{ loading, error, data, refresh }`. |
|
|
199
|
+
| `useSqlQuery(dataset, sql, deps?)` | Run SQL against a loaded dataset's DuckDB table. Returns `{ loading, error, data }`. `sql` must reference columns by their app.json aliases. |
|
|
200
|
+
| `useTheme()` | Returns current theme (`"dark"` or `"light"`). |
|
|
201
|
+
| `usePerspective(dataset)` | Get Perspective client and table for a dataset. Returns `{ client, table }`. |
|
|
202
|
+
|
|
203
|
+
### Components
|
|
204
|
+
|
|
205
|
+
See the "UI components" section below for full details on each.
|
|
206
|
+
|
|
207
|
+
**Layout:** `AppShell`, `Card`, `TabGroup`
|
|
208
|
+
|
|
209
|
+
**Data display:** `KpiCard`, `DataTable`, `ReportTable`, `Badge`, `EChart`, `PerspectivePanel`
|
|
210
|
+
|
|
211
|
+
**Inputs:** `MultiSelect`, `Select`, `FilterPills`, `TextInput`, `DateInput`
|
|
212
|
+
|
|
213
|
+
**Overlay:** `Tooltip`
|
|
214
|
+
|
|
215
|
+
**Feedback:** `LoadingState`, `ErrorState`, `ResourceCacheBadge`
|
|
216
|
+
|
|
217
|
+
## UI components
|
|
218
|
+
|
|
219
|
+
### Layout
|
|
220
|
+
|
|
221
|
+
- **`AppShell`**: Page wrapper with title, subtitle, theme toggle, meta slot.
|
|
222
|
+
- **`Card`**: Container with optional `title`, `headerRight`, `noPadding`. Use for all content sections.
|
|
223
|
+
- **`TabGroup`**: Tab bar with accent underline. Props: `tabs` (Array\<string\>, NOT objects), `activeTab` (string), `onTabChange` (string => void), `children`. Example: `<TabGroup tabs={["Approved", "Funded"]} activeTab={tab} onTabChange={setTab}>`. Do NOT pass `{key, label}` objects; `tabs` must be a plain string array.
|
|
224
|
+
|
|
225
|
+
### Data display
|
|
226
|
+
|
|
227
|
+
- **`KpiCard`**: Metric card with hover lift, accent top-line, shimmer loading. Props: `title`, `value`, `format` ("number" | "currency" | "percent"), `loading`, `detail` (ReactNode for comparison deltas, badges, etc. below the value). The `format` prop is **required**. Behavior:
|
|
228
|
+
- `format="number"`: formats with commas, no decimals (e.g., `15,212`)
|
|
229
|
+
- `format="currency"`: formats as USD (e.g., `$1,234`)
|
|
230
|
+
- `format="percent"`: appends "%" with 1 decimal (e.g., `value={82.2} format="percent"` renders `82.2%`)
|
|
231
|
+
- **Pre-formatted strings** (e.g., `"53.6s"`, `"82.2%"`) are passed through as-is when they can't be parsed as a number
|
|
232
|
+
- NaN/Infinity values display as a dash
|
|
233
|
+
- **`DataTable`**: Simple table with row hover. Props: `columns` (key/label), `rows`, `emptyState`.
|
|
234
|
+
- **`ReportTable`**: Rich management report table. Supports grouped column headers with colored bands, section dividers, subtotal/total rows, and per-cell conditional styling. Props: `headerGroups` (label, colSpan, color, subHeaders[]), `rows` (type: data/section/subtotal/total, indent, cells), `emptyState`.
|
|
235
|
+
- **`Badge`**: Status indicator with colored dot. Props: `variant` ("default" | "success" | "warning" | "error" | "info"), `dot`, `children`.
|
|
236
|
+
- **`EChart`**: Apache ECharts wrapper. Handles init/dispose lifecycle, theme switching, resize, and click events. Pass the full ECharts option spec. Props: `option`, `height`, `theme`, `onClick`. See "ECharts configuration" section below.
|
|
237
|
+
- **`PerspectivePanel`**: Wrapper for perspective-viewer. Props: `client`, `table`, `theme`, `config`, `onSelect`. See "Perspective chart configuration" section below.
|
|
238
|
+
|
|
239
|
+
### Inputs
|
|
240
|
+
|
|
241
|
+
- **`MultiSelect`**: Searchable checkbox dropdown. Props: `options`, `selected`, `onChange`, `labelKey`, `valueKey`, `label`, `placeholder`.
|
|
242
|
+
- **`Select`**: Single-select dropdown. Props: `options` ({value, label}[]), `value`, `onChange`, `label`, `placeholder`.
|
|
243
|
+
- **`FilterPills`**: Single-select horizontal toggle group. Props: `options` ({value, label}[]), `value`, `onChange`, `label`.
|
|
244
|
+
- **`TextInput`**: Input with focus ring and optional icon. Props: `value`, `onChange`, `placeholder`, `label`, `icon`.
|
|
245
|
+
- **`DateInput`**: Native date picker with design system styling. Props: `value`, `onChange`, `label`, `max`, `min`.
|
|
246
|
+
|
|
247
|
+
### Overlay
|
|
248
|
+
|
|
249
|
+
- **`Tooltip`**: Hover tooltip with arbitrary ReactNode content. Use for metric definitions, column explanations, filter descriptions. Props: `content` (ReactNode), `children` (trigger element), `position` ("top" | "bottom"), `maxWidth`.
|
|
250
|
+
|
|
251
|
+
### Feedback
|
|
252
|
+
|
|
253
|
+
- **`LoadingState`**: Full-page pulsing dots. Props: `message`.
|
|
254
|
+
- **`ErrorState`**: Full-page error with red left bar. Props: `title`, `message`.
|
|
255
|
+
- **`ResourceCacheBadge`**: Cache metadata popover. Props: `rows`, `cache`, `onClearAndReload`.
|
|
256
|
+
|
|
257
|
+
## Refined SaaS shell (multi-view apps)
|
|
258
|
+
|
|
259
|
+
A second track of primitives designed for apps with a sidebar and multiple views. Driven by a palette object instead of CSS vars, so customer brand accents flow through every chrome surface. Ships as the `refined` template.
|
|
260
|
+
|
|
261
|
+
Canonical structure:
|
|
262
|
+
|
|
263
|
+
```tsx
|
|
264
|
+
import {
|
|
265
|
+
buildPalette, PaletteProvider, usePalette,
|
|
266
|
+
ShellLayout, Sidebar, SaasKpiCard, CachePopover,
|
|
267
|
+
DrillProvider, useDrill,
|
|
268
|
+
callFiFast, buildDrillPrompt,
|
|
269
|
+
useDataset, useSqlQuery, useTheme,
|
|
270
|
+
} from "@definite/runtime";
|
|
271
|
+
|
|
272
|
+
export default function App() {
|
|
273
|
+
const { theme, toggleTheme } = useTheme();
|
|
274
|
+
const palette = useMemo(() => buildPalette(theme, { accent: "#FF006E" }), [theme]);
|
|
275
|
+
const data = useDataset("main");
|
|
276
|
+
if (data.loading) return <LoadingState />;
|
|
277
|
+
if (data.error) return <ErrorState title="..." message={data.error} />;
|
|
278
|
+
|
|
279
|
+
return (
|
|
280
|
+
<PaletteProvider value={palette}>
|
|
281
|
+
<DrillProvider
|
|
282
|
+
aiChat={{
|
|
283
|
+
onAsk: (q, entity) => callFiFast({ prompt: buildDrillPrompt(q, entity) }),
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
<InnerApp theme={theme} onThemeChange={(t) => t !== theme && toggleTheme()} dataset={data} />
|
|
287
|
+
</DrillProvider>
|
|
288
|
+
</PaletteProvider>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Shell primitives
|
|
294
|
+
|
|
295
|
+
| Export | Role |
|
|
296
|
+
|--------|------|
|
|
297
|
+
| `buildPalette(theme, { accent? })` | Palette tokens with optional brand accent. |
|
|
298
|
+
| `PaletteProvider` / `usePalette()` | Context for descendants. |
|
|
299
|
+
| `ShellLayout` | Flex container with sidebar slot, breadcrumb, title, headerRight slot. Wraps children in `PaletteProvider`. |
|
|
300
|
+
| `Sidebar` | Logo, nav, `dateRangeSlot`, `filterGroups` + `filters` (renders `FilterAccordion`), theme toggle, footer. |
|
|
301
|
+
| `FilterAccordion` | Collapsible groups with per-group counts, global + per-group search, selected chips, swatch dots. |
|
|
302
|
+
| `SaasKpiCard` | Accent top-line + sparkline + delta pill + loading shimmer. |
|
|
303
|
+
| `CachePopover` | Click-to-inspect cache pill. |
|
|
304
|
+
| `Sparkline` / `SkeletonShimmer` / `Breadcrumb` | Tiny primitives. |
|
|
305
|
+
|
|
306
|
+
### Drill drawer
|
|
307
|
+
|
|
308
|
+
Context-driven slide-over. Any descendant calls `useDrill().open(entity)`:
|
|
309
|
+
|
|
310
|
+
```tsx
|
|
311
|
+
drill.open({
|
|
312
|
+
kind: "kpi" | "row" | "chart",
|
|
313
|
+
id: "total_outstanding",
|
|
314
|
+
title: "Total outstanding",
|
|
315
|
+
value: "$50.8M",
|
|
316
|
+
breadcrumb: "Overview",
|
|
317
|
+
stats: [["Active", "2,511"]],
|
|
318
|
+
breakdown: [{ label: "2026-04", value: 1_780_000 }],
|
|
319
|
+
sql: "SELECT SUM(balance) FROM loans WHERE ...",
|
|
320
|
+
narrative: "Total principal balance across active contracts.",
|
|
321
|
+
});
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
Author drill content inline at the call site — don't try to compute it in a shared helper yet. The pattern is young; wait until we have 3+ apps before extracting.
|
|
325
|
+
|
|
326
|
+
### AI follow-up chat
|
|
327
|
+
|
|
328
|
+
Pass `aiChat` to `DrillProvider` to render a chat input at the bottom of the drawer. `onAsk(userMessage, entity)` returns a string; throw to show an error message. The `callFiFast()` helper wraps `/v4/fi-fast`, extracting `response.content.parts[0].text`:
|
|
329
|
+
|
|
330
|
+
```tsx
|
|
331
|
+
<DrillProvider
|
|
332
|
+
aiChat={{
|
|
333
|
+
onAsk: (q, entity) => callFiFast({
|
|
334
|
+
prompt: buildDrillPrompt(q, entity),
|
|
335
|
+
// endpoint defaults to "/v4/fi-fast"; authToken optional
|
|
336
|
+
}),
|
|
337
|
+
placeholder: "Ask a follow-up…",
|
|
338
|
+
}}
|
|
339
|
+
>
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`callFiFast` defaults to same-origin cookies; pass `authToken` for Bearer auth. It's safe to leave wired in preview builds — the fetch will just 401 and the error bubbles into the chat as a message.
|
|
343
|
+
|
|
344
|
+
### When to use `ShellLayout` vs `AppShell`
|
|
345
|
+
|
|
346
|
+
| Use case | Component |
|
|
347
|
+
|----------|-----------|
|
|
348
|
+
| Multi-view analytics app (Loans, Risk, Originations...) | `ShellLayout` + `Sidebar` |
|
|
349
|
+
| Single-view dashboard tile inside a Definite Doc | `AppShell` |
|
|
350
|
+
| Embedded email-receipt-style full-width view | `AppShell` |
|
|
351
|
+
|
|
352
|
+
Both are supported; they co-exist.
|
|
353
|
+
|
|
354
|
+
## Best practices
|
|
355
|
+
|
|
356
|
+
### Default recommendation
|
|
357
|
+
|
|
358
|
+
- **Always use `type: "sql"` for dataset resources.** Write the SQL in app.json with camelCase aliases for all columns. This gives you full control over column names and avoids Cube title issues.
|
|
359
|
+
- Use `kind: "json"` resources for small lookup lists (installer names, dropdown options, config maps).
|
|
360
|
+
- Use `.duckdb` file resources when you need multiple tables, views, or semantic snapshots on the client.
|
|
361
|
+
- **Never use `type: "cube"` in data apps** unless you have a specific reason. Cube responses use long title-based column names that are painful to work with in client-side SQL.
|
|
362
|
+
|
|
363
|
+
### Column naming pattern
|
|
364
|
+
|
|
365
|
+
Always alias columns to camelCase in app.json SQL. Then reference those exact aliases in `useSqlQuery`.
|
|
366
|
+
|
|
367
|
+
```json
|
|
368
|
+
// app.json: alias everything to camelCase
|
|
369
|
+
"sql": "SELECT installer_account_name AS installerAccountName, fico::INTEGER AS fico, STRFTIME(application_date, '%Y-%m-%d') AS applicationDate, approval_cnt::INTEGER AS approvalCnt FROM LAKE.credit.projects WHERE ... LIMIT 500000"
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
```tsx
|
|
373
|
+
// App.tsx: useSqlQuery references the camelCase aliases, NOT warehouse column names
|
|
374
|
+
const result = useSqlQuery(data, data.tableRef
|
|
375
|
+
? `SELECT installerAccountName, SUM(approvalCnt)::INTEGER AS approvals
|
|
376
|
+
FROM ${data.tableRef} WHERE applicationDate >= '2025-01-01' GROUP BY 1`
|
|
377
|
+
: "", []);
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
The column names in `useSqlQuery` (`installerAccountName`, `approvalCnt`, `applicationDate`) must exactly match the `AS` aliases in the app.json SQL. If they don't match, you get a runtime "Binder Error: Referenced column not found".
|
|
381
|
+
|
|
382
|
+
### Pre-computed flags pattern (for conditional aggregation)
|
|
383
|
+
|
|
384
|
+
When your dashboard needs conditional counts (e.g., "handled calls", "SLA calls"), **always** pre-compute the flags server-side:
|
|
385
|
+
|
|
386
|
+
```json
|
|
387
|
+
// app.json: compute flags server-side where complex CASE WHEN works correctly
|
|
388
|
+
"sql": "SELECT ..., CASE WHEN Agent_Time > 0 AND Skill_No NOT IN ('123','456') AND Campaign NOT LIKE '%Outbound' THEN 1 ELSE 0 END AS isHandled, CASE WHEN Campaign LIKE '%Outbound' THEN 1 ELSE 0 END AS isOutbound FROM ..."
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
```tsx
|
|
392
|
+
// App.tsx: simple aggregation of pre-computed flags (always works in DuckDB WASM)
|
|
393
|
+
const kpis = useSqlQuery(data, data.tableRef ? `
|
|
394
|
+
SELECT
|
|
395
|
+
COUNT(*) AS totalCalls,
|
|
396
|
+
SUM(isHandled)::INTEGER AS handledCalls,
|
|
397
|
+
SUM(isOutbound)::INTEGER AS obAttempts,
|
|
398
|
+
ROUND(SUM(isHandled)::INTEGER * 100.0 / NULLIF(COUNT(*), 0), 1) AS handleRatePct
|
|
399
|
+
FROM ${data.tableRef}
|
|
400
|
+
WHERE startDate >= '${dateFrom}' AND startDate <= '${dateTo}'
|
|
401
|
+
` : "", [dateFrom, dateTo]);
|
|
402
|
+
|
|
403
|
+
// Display in KpiCard
|
|
404
|
+
<KpiCard title="Handled Calls" value={handledCalls} format="number" />
|
|
405
|
+
<KpiCard title="Handle Rate" value={handleRatePct} format="percent" />
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Do NOT put compound CASE WHEN logic in `useSqlQuery`. It will silently return 0 in DuckDB WASM.
|
|
409
|
+
|
|
410
|
+
### General guidance
|
|
411
|
+
|
|
412
|
+
- Keep client-side SQL in `useSqlQuery` as simple as possible: GROUP BY, SUM, COUNT, simple WHERE filters.
|
|
413
|
+
- Push all complex logic (joins, subqueries, CTEs, compound CASE WHEN, regex, LIKE patterns) into the app.json server-side SQL.
|
|
414
|
+
- Always cast SUM results to `::INTEGER` in client-side SQL.
|
|
415
|
+
- Convert dates to VARCHAR with `STRFTIME(col, '%Y-%m-%d')` in app.json SQL.
|
|
416
|
+
- Use the `AppShell` component as your top-level wrapper.
|
|
417
|
+
- Use `Card` for every content section.
|
|
418
|
+
|
|
419
|
+
## DuckDB WASM limitations
|
|
420
|
+
|
|
421
|
+
DuckDB WASM 1.29.0 has known issues with complex expressions in client-side SQL. These work correctly on the server but silently produce wrong results (usually 0) in the browser.
|
|
422
|
+
|
|
423
|
+
### Compound CASE WHEN returns 0
|
|
424
|
+
|
|
425
|
+
**This is the #1 source of silent data bugs.** Any `CASE WHEN` with compound boolean conditions (AND/OR chains, NOT IN with many values, LIKE patterns) may return 0 for all rows in DuckDB WASM, even though the same query returns correct results server-side.
|
|
426
|
+
|
|
427
|
+
**Bad (client-side SQL in App.tsx):**
|
|
428
|
+
```sql
|
|
429
|
+
-- This silently returns 0 in DuckDB WASM
|
|
430
|
+
SELECT SUM(CASE WHEN agent_time > 0
|
|
431
|
+
AND skill_no NOT IN ('123','456','789',...)
|
|
432
|
+
AND campaign NOT LIKE '%Outbound'
|
|
433
|
+
THEN 1 ELSE 0 END) AS handled
|
|
434
|
+
FROM ${t} WHERE ${DATE_FILTER}
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
**Good (pre-compute in app.json, aggregate client-side):**
|
|
438
|
+
```json
|
|
439
|
+
// app.json: compute the flag server-side
|
|
440
|
+
"sql": "SELECT ..., CASE WHEN Agent_Time > 0 AND Skill_No NOT IN ('123','456','789',...) AND Campaign_Name NOT LIKE '%Outbound' THEN 1 ELSE 0 END AS isHandled FROM ..."
|
|
441
|
+
```
|
|
442
|
+
```tsx
|
|
443
|
+
// App.tsx: simple SUM of pre-computed flag
|
|
444
|
+
const kpis = useSqlQuery(data, `
|
|
445
|
+
SELECT SUM(isHandled)::INTEGER AS handled
|
|
446
|
+
FROM ${t} WHERE ${DATE_FILTER}
|
|
447
|
+
`, [dateFrom, dateTo]);
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### General rule: keep client-side SQL simple
|
|
451
|
+
|
|
452
|
+
| Server-side (app.json SQL) | Client-side (useSqlQuery) |
|
|
453
|
+
|---|---|
|
|
454
|
+
| Complex joins, subqueries, CTEs | Simple GROUP BY + aggregation |
|
|
455
|
+
| CASE WHEN with compound booleans | SUM/COUNT of pre-computed columns |
|
|
456
|
+
| NOT IN, LIKE, regex filters | Simple WHERE on date/string equality |
|
|
457
|
+
| Type casts (BIGINT, DOUBLE) | `::INTEGER` on SUM results |
|
|
458
|
+
| Flag computation, banding logic | Date range filters |
|
|
459
|
+
|
|
460
|
+
If you find yourself writing a CASE WHEN with more than one condition in `useSqlQuery`, move it to app.json instead.
|
|
461
|
+
|
|
462
|
+
## Perspective chart configuration
|
|
463
|
+
|
|
464
|
+
`PerspectivePanel` accepts a `config` object passed to `viewer.restore()`. The key properties:
|
|
465
|
+
|
|
466
|
+
### Plugin types
|
|
467
|
+
|
|
468
|
+
| Plugin | Type | Best for |
|
|
469
|
+
|--------|------|----------|
|
|
470
|
+
| `"Datagrid"` | Table | Raw data, record views |
|
|
471
|
+
| `"Y Bar"` | Vertical bar | Category comparisons |
|
|
472
|
+
| `"X Bar"` | Horizontal bar | Ranked categories, long labels |
|
|
473
|
+
| `"Y Line"` | Line chart | Time series, trends |
|
|
474
|
+
| `"Y Area"` | Area chart | Stacked volumes over time |
|
|
475
|
+
| `"Y Scatter"` | Scatter plot | Correlations |
|
|
476
|
+
| `"Heatmap"` | Heatmap | Two-dimensional patterns |
|
|
477
|
+
| `"Treemap"` | Treemap | Hierarchical proportions |
|
|
478
|
+
| `"Sunburst"` | Sunburst | Hierarchical categories |
|
|
479
|
+
|
|
480
|
+
### Config properties
|
|
481
|
+
|
|
482
|
+
```tsx
|
|
483
|
+
<PerspectivePanel
|
|
484
|
+
client={perspective.client}
|
|
485
|
+
table={transactions.perspectiveTable}
|
|
486
|
+
theme={theme}
|
|
487
|
+
config={{
|
|
488
|
+
plugin: "Y Bar",
|
|
489
|
+
columns: ["amount"], // Measures to display
|
|
490
|
+
group_by: ["branchName"], // X-axis / grouping dimension
|
|
491
|
+
split_by: ["status"], // Optional: series breakdown
|
|
492
|
+
aggregates: { amount: "sum" }, // Aggregation per column
|
|
493
|
+
sort: [["amount", "desc"]], // Sort order
|
|
494
|
+
filter: [["status", "==", "Funded"]], // Filters
|
|
495
|
+
settings: false, // Hide settings panel
|
|
496
|
+
title: "Revenue by Branch", // Chart title
|
|
497
|
+
columns_config: { // Number formatting (Intl.NumberFormat)
|
|
498
|
+
amount: { number_format: { style: "currency", currency: "USD", maximumFractionDigits: 0 } },
|
|
499
|
+
},
|
|
500
|
+
}}
|
|
501
|
+
/>
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
### Chart patterns
|
|
505
|
+
|
|
506
|
+
**Bar chart (category comparison):**
|
|
507
|
+
```tsx
|
|
508
|
+
config={{ plugin: "Y Bar", columns: ["amount"], group_by: ["branchName"], aggregates: { amount: "sum" }, sort: [["amount", "desc"]] }}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
**Line/Area chart (time series):**
|
|
512
|
+
```tsx
|
|
513
|
+
config={{ plugin: "Y Line", columns: ["transactionId"], group_by: ["transactionDate"], split_by: ["status"], aggregates: { transactionId: "count" } }}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
**Stacked area (volume over time by category):**
|
|
517
|
+
```tsx
|
|
518
|
+
config={{ plugin: "Y Area", columns: ["amount"], group_by: ["transactionDate"], split_by: ["branchName"], aggregates: { amount: "sum" } }}
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
### Drill-down (click-to-filter)
|
|
522
|
+
|
|
523
|
+
Use `perspective-select` events on the viewer element. **Do NOT use `perspective-click`**; it only fires on Datagrid. For D3FC chart plugins (bar, line, area), use `perspective-select`.
|
|
524
|
+
|
|
525
|
+
Event payload:
|
|
526
|
+
```typescript
|
|
527
|
+
e.detail = {
|
|
528
|
+
selected: true, // true on select, false on deselect
|
|
529
|
+
row: { ... }, // data for clicked element
|
|
530
|
+
column_names: ["col"] // column name strings
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
**Gotcha:** Keys in `e.detail.row` may not match your column names. Always inspect `Object.keys(row)` first.
|
|
535
|
+
|
|
536
|
+
To wire drill-down in a React component, attach an event listener to the `perspective-viewer` ref inside a `PerspectivePanel`:
|
|
537
|
+
|
|
538
|
+
```tsx
|
|
539
|
+
// In a custom wrapper around PerspectivePanel, after the viewer loads:
|
|
540
|
+
viewerRef.current.addEventListener('perspective-select', (e) => {
|
|
541
|
+
const row = e.detail?.row;
|
|
542
|
+
if (!row) return;
|
|
543
|
+
const value = row['branchName'] ?? row[Object.keys(row)[0]];
|
|
544
|
+
if (value == null) return;
|
|
545
|
+
// Toggle: click same value to clear filter
|
|
546
|
+
setFilter(prev => prev === value ? '' : String(value));
|
|
547
|
+
});
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
For multi-chart drill-down (clicking a bar in one chart filters all others), maintain shared filter state in the parent component and pass Perspective `filter` arrays to each `PerspectivePanel` config.
|
|
551
|
+
|
|
552
|
+
### Perspective tips
|
|
553
|
+
|
|
554
|
+
- Keep using the provided `PerspectivePanel` / `usePerspective()` wiring unless you have a concrete reason not to.
|
|
555
|
+
- For `.duckdb` resources, set `source.table` if the UI needs a default table name for Perspective or local SQL.
|
|
556
|
+
- If the UI uses tabs, call `viewer.notifyResize?.()` when a hidden Perspective view becomes visible.
|
|
557
|
+
|
|
558
|
+
## ECharts configuration
|
|
559
|
+
|
|
560
|
+
Apache ECharts is loaded from CDN (`window.echarts`). The `EChart` component is a thin lifecycle wrapper; pass the full ECharts option spec as the `option` prop. Docs: https://echarts.apache.org/en/option.html
|
|
561
|
+
|
|
562
|
+
### CRITICAL: No functions in EChart options
|
|
563
|
+
|
|
564
|
+
The `EChart` component serializes the option object through `JSON.stringify` / `JSON.parse` for change detection. **This strips all JavaScript functions.** Any `formatter`, `valueFormatter`, or callback function in your option will be silently removed and ECharts will use its defaults (or crash).
|
|
565
|
+
|
|
566
|
+
**Do this instead:**
|
|
567
|
+
|
|
568
|
+
| Instead of | Do this |
|
|
569
|
+
|------------|---------|
|
|
570
|
+
| `yAxis.axisLabel.formatter: (v) => "$" + v + "M"` | Scale data to millions in your query, use string template: `formatter: "${value}M"` |
|
|
571
|
+
| `tooltip.valueFormatter: (v) => ...` | Remove it; ECharts will format the raw value. Or pre-format data. |
|
|
572
|
+
| `series.label.formatter: (params) => ...` | Pre-compute label strings and embed them per data point: `data: values.map((v, i) => ({ value: v, label: { formatter: precomputedLabels[i] } }))` |
|
|
573
|
+
| `tooltip.formatter: (params) => ...` | Cannot use custom tooltip formatters. Use default tooltip or restructure data so defaults look good. |
|
|
574
|
+
|
|
575
|
+
**String templates that work:** ECharts template syntax like `"{value}%"` and `"{b}: {c}"` survives JSON serialization because they're plain strings, not functions. Use `${value}` to prepend a literal dollar sign (the `$` is literal, `{value}` is replaced by ECharts).
|
|
576
|
+
|
|
577
|
+
### When to use EChart vs PerspectivePanel
|
|
578
|
+
|
|
579
|
+
| EChart | PerspectivePanel |
|
|
580
|
+
|--------|-----------------|
|
|
581
|
+
| Custom styled charts (conditional bar colors, combo bar+line, dashed forecast lines) | Interactive data grids with sort/filter/pivot |
|
|
582
|
+
| Chart.js-style management report charts | Exploratory data analysis |
|
|
583
|
+
| Small to medium datasets (pre-aggregated) | Large datasets (DuckDB-backed, client-side SQL) |
|
|
584
|
+
| Full control over every visual element | Quick setup with sensible defaults |
|
|
585
|
+
|
|
586
|
+
### Bar chart
|
|
587
|
+
|
|
588
|
+
```tsx
|
|
589
|
+
<EChart theme={theme} option={{
|
|
590
|
+
xAxis: { type: "category", data: ["Austin", "Denver", "Chicago"] },
|
|
591
|
+
yAxis: { type: "value" },
|
|
592
|
+
series: [{ type: "bar", data: [42000, 38000, 51000], itemStyle: { borderRadius: [4, 4, 0, 0] } }],
|
|
593
|
+
}} />
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
### Combo bar + line (actual vs forecast)
|
|
597
|
+
|
|
598
|
+
```tsx
|
|
599
|
+
<EChart theme={theme} option={{
|
|
600
|
+
tooltip: { trigger: "axis" },
|
|
601
|
+
legend: { data: ["Actual", "Target"] },
|
|
602
|
+
xAxis: { type: "category", data: ["Jan", "Feb", "Mar", "Apr"] },
|
|
603
|
+
yAxis: { type: "value" },
|
|
604
|
+
series: [
|
|
605
|
+
{ name: "Actual", type: "bar", data: [
|
|
606
|
+
{ value: 42000, itemStyle: { color: "#22c55e" } },
|
|
607
|
+
{ value: 28000, itemStyle: { color: "#f87171" } },
|
|
608
|
+
]},
|
|
609
|
+
{ name: "Target", type: "line", data: [35000, 35000, 40000, 40000],
|
|
610
|
+
lineStyle: { type: "dashed", color: "#eab308" },
|
|
611
|
+
itemStyle: { color: "#eab308" } },
|
|
612
|
+
],
|
|
613
|
+
}} />
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
### Per-bar conditional coloring
|
|
617
|
+
|
|
618
|
+
Use `data` as an array of objects with `itemStyle`:
|
|
619
|
+
```tsx
|
|
620
|
+
data: values.map(v => ({
|
|
621
|
+
value: v.amount,
|
|
622
|
+
itemStyle: { color: v.amount >= v.target ? "#22c55e" : "#f87171" },
|
|
623
|
+
}))
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
### Click events (drill-down)
|
|
627
|
+
|
|
628
|
+
```tsx
|
|
629
|
+
<EChart
|
|
630
|
+
theme={theme}
|
|
631
|
+
onClick={(params) => {
|
|
632
|
+
const name = params.name; // category label
|
|
633
|
+
setFilter(prev => prev === name ? "" : name);
|
|
634
|
+
}}
|
|
635
|
+
option={...}
|
|
636
|
+
/>
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
### Theme switching
|
|
640
|
+
|
|
641
|
+
The `EChart` component handles theme automatically. Pass `theme` from `useTheme()` and the chart re-initializes with the correct ECharts built-in theme ("dark" or "light") on toggle.
|
|
642
|
+
|
|
643
|
+
## Quick reference: starter App.tsx pattern
|
|
644
|
+
|
|
645
|
+
```tsx
|
|
646
|
+
import React from "react";
|
|
647
|
+
import {
|
|
648
|
+
useDataset,
|
|
649
|
+
useSqlQuery,
|
|
650
|
+
useTheme,
|
|
651
|
+
AppShell,
|
|
652
|
+
Card,
|
|
653
|
+
KpiCard,
|
|
654
|
+
LoadingState,
|
|
655
|
+
ErrorState,
|
|
656
|
+
} from "@definite/runtime";
|
|
657
|
+
|
|
658
|
+
export default function App() {
|
|
659
|
+
const theme = useTheme();
|
|
660
|
+
const data = useDataset("main");
|
|
661
|
+
|
|
662
|
+
const summary = useSqlQuery(
|
|
663
|
+
data,
|
|
664
|
+
data.tableRef
|
|
665
|
+
? `SELECT COUNT(*)::INTEGER AS totalRows FROM ${data.tableRef}`
|
|
666
|
+
: "",
|
|
667
|
+
[],
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
if (data.loading) return <LoadingState message="Loading data..." />;
|
|
671
|
+
if (data.error) return <ErrorState title="Load Error" message={data.error} />;
|
|
672
|
+
|
|
673
|
+
return (
|
|
674
|
+
<AppShell title="My App" subtitle="Dashboard subtitle here">
|
|
675
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
|
676
|
+
<KpiCard
|
|
677
|
+
title="Total Rows"
|
|
678
|
+
value={summary.data?.[0]?.totalRows}
|
|
679
|
+
format="number"
|
|
680
|
+
loading={summary.loading}
|
|
681
|
+
/>
|
|
682
|
+
</div>
|
|
683
|
+
</AppShell>
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
```
|