From 6286793c6e241180b9efe41f1ecd72a5e10204ff Mon Sep 17 00:00:00 2001 From: robinvrd Date: Fri, 30 Oct 2026 11:25:12 +0100 Subject: [PATCH 1/6] feat: add metric and finding app starters --- REFERENCE.md | 7 +- bun.lock | 42 ++++++ docs/app-authoring.md | 9 +- docs/local-data-apps.md | 4 +- docs/stories-and-export.md | 12 +- knip.ts | 6 +- package.json | 6 +- packages/data-app-examples/README.md | 12 +- packages/data-app-examples/src/index.ts | 24 +++ .../starter-finding-data-app/LICENSE | 21 +++ .../starter-finding-data-app/index.tsx | 139 ++++++++++++++++++ .../.bun-version | 1 + .../.node-version | 1 + .../.oxfmtrc.json | 11 ++ .../.oxlintrc.json | 33 +++++ .../starter-local-finding-data-app/AGENTS.md | 18 +++ .../starter-local-finding-data-app/LICENSE | 21 +++ .../starter-local-finding-data-app/README.md | 38 +++++ .../starter-local-finding-data-app/_gitignore | 3 + .../starter-local-finding-data-app/app.ts | 25 ++++ .../bunfig.toml | 2 + .../package.json | 36 +++++ .../src/App.tsx | 83 +++++++++++ .../src/assets.d.ts | 1 + .../src/data-context.ts | 14 ++ .../src/favicon.svg | 13 ++ .../src/index.html | 14 ++ .../src/main.tsx | 8 + .../src/operations.ts | 17 +++ .../src/server.ts | 7 + .../tsconfig.json | 21 +++ .../.bun-version | 1 + .../.node-version | 1 + .../.oxfmtrc.json | 11 ++ .../.oxlintrc.json | 33 +++++ .../starter-local-metric-data-app/AGENTS.md | 18 +++ .../starter-local-metric-data-app/LICENSE | 21 +++ .../starter-local-metric-data-app/README.md | 38 +++++ .../starter-local-metric-data-app/_gitignore | 3 + .../starter-local-metric-data-app/app.ts | 24 +++ .../starter-local-metric-data-app/bunfig.toml | 2 + .../package.json | 36 +++++ .../starter-local-metric-data-app/src/App.tsx | 65 ++++++++ .../src/assets.d.ts | 1 + .../src/data-context.ts | 14 ++ .../src/favicon.svg | 13 ++ .../src/index.html | 14 ++ .../src/main.tsx | 8 + .../src/operations.ts | 17 +++ .../src/server.ts | 7 + .../tsconfig.json | 21 +++ .../templates/starter-metric-data-app/LICENSE | 21 +++ .../starter-metric-data-app/index.tsx | 120 +++++++++++++++ packages/data-app-react/src/ui/DataApp.tsx | 38 +++-- .../data-app-react/src/ui/DataAppFrame.tsx | 127 ++++++++-------- release-please-config.json | 46 ++++++ scripts/check-boundaries.ts | 76 ++++++---- scripts/check-package.ts | 8 + scripts/check-starter.ts | 40 ++--- tests/public-api/apps-starters.test.ts | 73 +++++++++ tests/public-api/authoring-entry.types.tsx | 2 +- tests/public-api/browser.ts | 6 +- tests/public-api/fixtures/bundle-host.tsx | 8 +- tests/public-api/fixtures/declared-app.tsx | 7 +- tests/public-api/fixtures/server.ts | 41 +++++- tests/public-api/manifest.test.ts | 10 +- tsconfig.json | 4 +- 67 files changed, 1469 insertions(+), 155 deletions(-) create mode 100644 packages/data-app-examples/templates/starter-finding-data-app/LICENSE create mode 100644 packages/data-app-examples/templates/starter-finding-data-app/index.tsx create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/.bun-version create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/.node-version create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/.oxfmtrc.json create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/.oxlintrc.json create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/AGENTS.md create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/LICENSE create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/README.md create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/_gitignore create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/app.ts create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/bunfig.toml create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/package.json create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/src/App.tsx create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/src/assets.d.ts create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/src/data-context.ts create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/src/favicon.svg create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/src/index.html create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/src/main.tsx create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/src/operations.ts create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/src/server.ts create mode 100644 packages/data-app-examples/templates/starter-local-finding-data-app/tsconfig.json create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/.bun-version create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/.node-version create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/.oxfmtrc.json create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/.oxlintrc.json create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/AGENTS.md create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/LICENSE create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/README.md create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/_gitignore create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/app.ts create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/bunfig.toml create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/package.json create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/src/App.tsx create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/src/assets.d.ts create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/src/data-context.ts create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/src/favicon.svg create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/src/index.html create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/src/main.tsx create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/src/operations.ts create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/src/server.ts create mode 100644 packages/data-app-examples/templates/starter-local-metric-data-app/tsconfig.json create mode 100644 packages/data-app-examples/templates/starter-metric-data-app/LICENSE create mode 100644 packages/data-app-examples/templates/starter-metric-data-app/index.tsx create mode 100644 tests/public-api/apps-starters.test.ts diff --git a/REFERENCE.md b/REFERENCE.md index 8921612f..2096b10f 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -298,6 +298,7 @@ CsvTable.rows: readonly (readonly CsvCell[])[] DataApp (component, @altertable/data-app-react): (props: DataAppProps) — Executes the primary view and owns its controls, inspection, exports, and stories. DataApp.children: ReactNode DataApp.description?: ReactNode — Override the declared subtitle; null hides it. +DataApp.showToolbar?: boolean — Show page actions. Defaults to true; does not hide filters or data states. DataApp.toolbarActions?: ReactNode DataApp.footerActions?: ReactNode DataApp.footer?: ReactNode — Optional source, coverage, or methodology note below the app content. Compose prose and inline components; typography and spacing are provided. @@ -305,13 +306,14 @@ DataApp.showSearch?: boolean — Show declared search controls. Defaults to true DataApp.showFilters?: boolean — Show declared filter controls, including display options. Defaults to true; hiding preserves their values. DataApp.view: DeclaredView & { scope(snapshot: DisplayedSnapshot): string; } DataApp.showSearchSuggestions?: boolean — Show supplied datasets’ record suggestions on the first declared search. Defaults to true. -DataApp.story: (snapshot: DisplayedSnapshot) => readonly (Omit & { evidence: RegisteredBinding; })[] -DataApp.datasets: readonly [ExportDataset, ...ExportDataset[]] +DataApp.story?: ((snapshot: DisplayedSnapshot) => readonly (Omit & { evidence: RegisteredBinding; })[]) — Omit to leave presentation out of the toolbar. +DataApp.datasets?: readonly [ExportDataset, ...ExportDataset[]] — Omit to leave CSV export out of the toolbar. DataApp.details?: readonly DetailContent[] — Detail destinations available to dataset actions and metric record inspection. DataApp (component, @altertable/data-app-react/ui): (props: DataAppProps) — Setup and static screens; fetched data uses the declared-view DataApp. DataApp.children: ReactNode DataApp.dataContext: DataContext DataApp.description?: ReactNode — Override the declared subtitle; null hides it. +DataApp.showToolbar?: boolean — Show page actions. Defaults to true; does not hide filters or data states. DataApp.toolbarActions?: ReactNode DataApp.footerActions?: ReactNode DataApp.footer?: ReactNode — Optional source, coverage, or methodology note below the app content. Compose prose and inline components; typography and spacing are provided. @@ -342,6 +344,7 @@ DataAppProps (type, @altertable/data-app-react/ui) DataAppProps.children: ReactNode DataAppProps.dataContext: DataContext DataAppProps.description?: ReactNode — Override the declared subtitle; null hides it. +DataAppProps.showToolbar?: boolean — Show page actions. Defaults to true; does not hide filters or data states. DataAppProps.toolbarActions?: ReactNode DataAppProps.footerActions?: ReactNode DataAppProps.footer?: ReactNode — Optional source, coverage, or methodology note below the app content. Compose prose and inline components; typography and spacing are provided. diff --git a/bun.lock b/bun.lock index 46128b30..c9b5fd45 100644 --- a/bun.lock +++ b/bun.lock @@ -97,6 +97,44 @@ "typescript": "7.0.2", }, }, + "packages/data-app-examples/templates/starter-local-finding-data-app": { + "name": "data-app-finding-starter", + "dependencies": { + "@altertable/data-app": "0.74.0", + "@altertable/data-app-bun": "0.74.0", + "@altertable/data-app-react": "0.74.0", + "react": "19.3.1", + "react-dom": "19.3.1", + }, + "devDependencies": { + "@types/bun": "1.4.3", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "oxfmt": "0.71.0", + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003", + "typescript": "7.0.2", + }, + }, + "packages/data-app-examples/templates/starter-local-metric-data-app": { + "name": "data-app-metric-starter", + "dependencies": { + "@altertable/data-app": "0.74.0", + "@altertable/data-app-bun": "0.74.0", + "@altertable/data-app-react": "0.74.0", + "react": "19.3.1", + "react-dom": "19.3.1", + }, + "devDependencies": { + "@types/bun": "1.4.3", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "oxfmt": "0.71.0", + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003", + "typescript": "7.0.2", + }, + }, "packages/data-app-manifest": { "name": "@altertable/data-app-manifest", "version": "0.74.0", @@ -759,6 +797,10 @@ "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "data-app-finding-starter": ["data-app-finding-starter@workspace:packages/data-app-examples/templates/starter-local-finding-data-app"], + + "data-app-metric-starter": ["data-app-metric-starter@workspace:packages/data-app-examples/templates/starter-local-metric-data-app"], + "data-app-starter": ["data-app-starter@workspace:packages/data-app-examples/templates/starter-local-data-app"], "dead-or-alive": ["dead-or-alive@1.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "fast-content-type-parse": "^3.0.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-is-element": "^3.0.0", "hast-util-select": "^6.0.0", "html-url-attributes": "^3.0.0", "levenshtein-edit-distance": "^3.0.0", "undici": "^8.0.0", "unist-util-visit": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-WTw0SWjsRlwhEK34dQgiXCwfoVH7C4DcViKYT/K43ShApOsMiQDryup3SJ/Fp7fVsDA/NqeW810oUnuICYYzNA=="], diff --git a/docs/app-authoring.md b/docs/app-authoring.md index c12ea6fc..5b2ad839 100644 --- a/docs/app-authoring.md +++ b/docs/app-authoring.md @@ -13,10 +13,13 @@ for the requested change. For a new app, use the starter supplied by the authori | ----------------------- | ------------------------------------------------------------------- | | Hosted / remote / cloud | `starter-data-app/index.tsx` | | Local | `starter-local-data-app` | +| Hosted metric | `starter-metric-data-app/index.tsx` | +| Hosted Finding | `starter-finding-data-app/index.tsx` | +| Local metric | `starter-local-metric-data-app` | +| Local Finding | `starter-local-finding-data-app` | -These are the currently shipped runtime starters. Select a use-case starter when -one is supplied by the authoring workflow; otherwise adapt the shared starter's -sample content to the app brief. The hosted starter demonstrates query → operation +Choose the runtime and use case supplied by the app brief. Each starter is editable +source; replace fixture queries and sample content before publishing. The hosted starter demonstrates query → operation → context → view → dataset → widget composition, including a story example. Hosted apps stay in one file with public package imports and a literal `defineDataApp()` declaration. Local apps use the same composition diff --git a/docs/local-data-apps.md b/docs/local-data-apps.md index af2e9796..e38af81a 100644 --- a/docs/local-data-apps.md +++ b/docs/local-data-apps.md @@ -2,8 +2,8 @@ Use the local scaffold supplied by your authoring workflow. Its README covers setup and its AGENTS.md identifies app-owned files. -Replace the connectivity screen with the content specified by the app brief or -authoring skill, using the [shared SDK patterns](app-authoring.md). Local execution +Replace the connectivity screen or fixture examples with the content specified by +the app brief, using the [shared SDK patterns](app-authoring.md). Local execution does not require a particular content structure or presentation story. | Task | Documentation | diff --git a/docs/stories-and-export.md b/docs/stories-and-export.md index 4ab4d9aa..2bdfdc05 100644 --- a/docs/stories-and-export.md +++ b/docs/stories-and-export.md @@ -1,10 +1,12 @@ # Stories and CSV export -The current bound `` from `@altertable/data-app-react` requires a `story` -callback and a nonempty `datasets` list. Both use the displayed snapshot, including -during refresh or failure. A story callback returning `[]` leaves a disabled -presentation action; it does not hide the toolbar. Setup and static screens can use `` -from `/ui`, which may omit both. +The bound `` from `@altertable/data-app-react` accepts an optional `story` +callback and optional nonempty `datasets` list. Omit either prop to omit its toolbar +control. Supplied features use the displayed snapshot, including during refresh or +failure. A story callback returning `[]` leaves a disabled presentation action. +Set `showToolbar={false}` to omit page actions together; data loading, error states, +and declared filters remain available. Setup and static screens can use `` +from `/ui`. These are component contracts. The app brief determines the intended presentation and export coverage. A presentation story is distinct from diff --git a/knip.ts b/knip.ts index e62dcca8..b2bddf2f 100644 --- a/knip.ts +++ b/knip.ts @@ -85,10 +85,10 @@ export default { }, 'packages/data-app-examples': { ...packages['packages/data-app-examples'], - entry: ['src/index.ts', 'templates/starter-data-app/index.tsx'], - project: ['src/**/*.ts', 'templates/starter-data-app/*.tsx'], + entry: ['src/index.ts', 'templates/starter-*/index.tsx'], + project: ['src/**/*.ts', 'templates/starter-*/*.tsx'], }, - 'packages/data-app-examples/templates/starter-local-data-app': { + 'packages/data-app-examples/templates/starter-local-*': { entry: ['src/server.ts', 'src/main.tsx'], project: ['src/**/*.{ts,tsx}'], }, diff --git a/package.json b/package.json index ec229a52..8bea84cf 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "workspaces": [ "packages/*", - "packages/data-app-examples/templates/starter-local-data-app" + "packages/data-app-examples/templates/starter-local-*" ], "type": "module", "scripts": { @@ -14,14 +14,14 @@ "dev:file": "bun run scripts/dev-file.ts", "dev:dir": "bun run scripts/dev-dir.ts", "typecheck": "tsc --noEmit && bun run --filter '@altertable/*' typecheck", - "lint": "oxlint --type-aware packages/*/src packages/data-app-examples/templates/starter-data-app tests scripts dev vitest.config.ts knip.ts", + "lint": "oxlint --type-aware packages/*/src packages/data-app-examples/templates/starter-data-app packages/data-app-examples/templates/starter-metric-data-app packages/data-app-examples/templates/starter-finding-data-app tests scripts dev vitest.config.ts knip.ts", "format": "oxfmt .", "format:check": "oxfmt --check .", "test": "vitest run", "check": "bun run build && bun run check:boundaries && bun run typecheck && bun run lint && bun run knip && bun run check:styles && bun run lint:md && bun run check:links && bun run format:check && bun run test && bun run check:reference && bun run check:package && bun run check:starter", "check:package": "bun run scripts/check-package.ts", "check:workflows": "bash scripts/check-workflows.sh", - "lint:fix": "oxlint --type-aware --fix packages/*/src packages/data-app-examples/templates/starter-data-app tests scripts dev vitest.config.ts knip.ts", + "lint:fix": "oxlint --type-aware --fix packages/*/src packages/data-app-examples/templates/starter-data-app packages/data-app-examples/templates/starter-metric-data-app packages/data-app-examples/templates/starter-finding-data-app tests scripts dev vitest.config.ts knip.ts", "check:starter": "bun run scripts/check-starter.ts", "lint:md": "markdownlint-cli2", "check:links": "remark . --ext md --rc-path .remarkrc.json --frail --no-stdout", diff --git a/packages/data-app-examples/README.md b/packages/data-app-examples/README.md index 90162bb5..567a99bf 100644 --- a/packages/data-app-examples/README.md +++ b/packages/data-app-examples/README.md @@ -17,10 +17,14 @@ on this package. ## Starters -| ID | Use | -| ------------------------ | ------------------------------------------------------------------------- | -| `starter-data-app` | [Hosted single-file app](templates/starter-data-app/index.tsx) | -| `starter-local-data-app` | [Local Bun and React project](templates/starter-local-data-app/README.md) | +| ID | Use | +| -------------------------------- | ------------------------------------------------------------------------- | +| `starter-data-app` | [Hosted single-file app](templates/starter-data-app/index.tsx) | +| `starter-local-data-app` | [Local Bun and React project](templates/starter-local-data-app/README.md) | +| `starter-metric-data-app` | [Hosted metric app](templates/starter-metric-data-app/index.tsx) | +| `starter-finding-data-app` | [Hosted finding app](templates/starter-finding-data-app/index.tsx) | +| `starter-local-metric-data-app` | [Local metric app](templates/starter-local-metric-data-app/README.md) | +| `starter-local-finding-data-app` | [Local finding app](templates/starter-local-finding-data-app/README.md) | Pin to the same exact version as `@altertable/data-app`. Authoring guides and API reference ship in that base package. diff --git a/packages/data-app-examples/src/index.ts b/packages/data-app-examples/src/index.ts index 410df6f0..8cecd4b4 100644 --- a/packages/data-app-examples/src/index.ts +++ b/packages/data-app-examples/src/index.ts @@ -18,6 +18,30 @@ export const dataAppExamples = [ description: 'A Bun and React project with server-side lakehouse access.', mode: 'local', }, + { + id: 'starter-metric-data-app', + title: 'Hosted metric illustration', + description: 'A focused metric app for hosted execution.', + mode: 'hosted', + }, + { + id: 'starter-finding-data-app', + title: 'Hosted Finding document', + description: 'A focused finding app for hosted execution.', + mode: 'hosted', + }, + { + id: 'starter-local-metric-data-app', + title: 'Local metric illustration', + description: 'A focused metric app for local execution.', + mode: 'local', + }, + { + id: 'starter-local-finding-data-app', + title: 'Local Finding document', + description: 'A focused finding app for local execution.', + mode: 'local', + }, ] as const; /** Stable identifier for a canonical starter. */ diff --git a/packages/data-app-examples/templates/starter-finding-data-app/LICENSE b/packages/data-app-examples/templates/starter-finding-data-app/LICENSE new file mode 100644 index 00000000..845a62ce --- /dev/null +++ b/packages/data-app-examples/templates/starter-finding-data-app/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Altertable + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/data-app-examples/templates/starter-finding-data-app/index.tsx b/packages/data-app-examples/templates/starter-finding-data-app/index.tsx new file mode 100644 index 00000000..aa8628f4 --- /dev/null +++ b/packages/data-app-examples/templates/starter-finding-data-app/index.tsx @@ -0,0 +1,139 @@ +import { defineDataApp } from '@altertable/data-app'; +import { + createDataContext, + injectDataAppStyles, + mountDataApp, +} from '@altertable/data-app-react'; +import { + createDataHooks, + DataApp, + DataSection, + VisualizationWidget, + Stack, + TextContent, +} from '@altertable/data-app-react'; +import { BarChart } from '@altertable/data-app-react/ui'; +import { createDataClient } from '@altertable/data-app/client'; +import { parseCount } from '@altertable/data-app/contract'; +const dataApp = defineDataApp({ + title: 'Sample finding', + description: + 'Fixture evidence; replace with the Finding and its supporting queries.', + scope: { organization: 'demo', environment: 'sample' }, + appearance: { theme: 'system' }, + queries: { + sampleCountsByGroup: { + projections: { + group_name: { type: 'string' }, + sample_count: { type: 'number' }, + }, + statement: ` +WITH sample_counts(group_name, sample_count) AS (VALUES ('Alpha', 2200000), ('Beta', 0)) +SELECT group_name, sample_count FROM sample_counts +WHERE $groupName = '' OR group_name = $groupName +ORDER BY group_name LIMIT $limit`, + params: { + groupName: { type: 'string', defaultValue: '' }, + limit: { type: 'number', defaultValue: 10 }, + }, + }, + }, +}); +const operations = { + sampleCountsByGroup: dataApp.defineOperation({ + input: () => ({}), + checks: [{}], + policy: { maxQueryRows: 10, maxDurationMs: 15000 }, + async run({ query }) { + const result = await query('sampleCountsByGroup', {}); + return result.rows.map(row => ({ + groupName: row.group_name, + sampleCount: parseCount(row.sample_count), + })); + }, + }), +}; +const queryNames = operations.sampleCountsByGroup.queryNames; +const sampleDataContext = createDataContext(queryNames)({ + description: + 'Two SQL VALUES rows demonstrate the host query path. Replace them with inspected source data before publishing findings.', + glossary: { + sampleCount: { + term: 'Sample count', + definition: + 'A fixture value: Alpha is 2,200,000 and Beta is a measured zero.', + queryNames: [queryNames.sampleCountsByGroup], + }, + }, +}); +const { defineDataView } = createDataHooks(createDataClient({ operations })); +const sampleCountsView = defineDataView({ + dataContext: sampleDataContext, + operation: 'sampleCountsByGroup', + describeInput: () => 'fixture groups', + isEmpty: sampleCounts => sampleCounts.length === 0, + emptyFallback: { + title: 'No matching groups', + description: 'No observations are available for this query.', + }, +}); +const sampleCounts = sampleCountsView.dataset({ + name: 'Sample counts', + select: rows => rows, + rowKey: row => row.groupName, + columns: { + groupName: { label: 'Group', value: row => row.groupName }, + sampleCount: { value: row => row.sampleCount, format: { kind: 'count' } }, + }, + evidence: { + id: 'counts-by-group', + glossaryIds: ['sampleCount'], + }, +}); +const sampleContent = sampleCountsView.content(result => ( + + +

Supporting evidence

+

+ These fixture rows demonstrate how a Finding can include a small + supporting visual. Replace this section with the Finding's own argument + and evidence. +

+
+ + {(rows, actions) => ( + ({ + ...row, + id: row.groupName, + label: row.groupName, + value: row.sampleCount, + }))} + itemActions={actions} + /> + )} + + +

Limitations

+

+ SQL VALUES are demonstration data and support no business conclusion. +

+
+
+)); +function App() { + return ( + + + + ); +} +injectDataAppStyles(); +mountDataApp({ app: dataApp, component: App }); diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/.bun-version b/packages/data-app-examples/templates/starter-local-finding-data-app/.bun-version new file mode 100644 index 00000000..1c99cf0e --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/.bun-version @@ -0,0 +1 @@ +1.4.4 diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/.node-version b/packages/data-app-examples/templates/starter-local-finding-data-app/.node-version new file mode 100644 index 00000000..ca5c3500 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/.node-version @@ -0,0 +1 @@ +24.18.0 diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/.oxfmtrc.json b/packages/data-app-examples/templates/starter-local-finding-data-app/.oxfmtrc.json new file mode 100644 index 00000000..569314e7 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/.oxfmtrc.json @@ -0,0 +1,11 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": ["dist/**", "bun.lock"], + "singleQuote": true, + "trailingComma": "es5", + "arrowParens": "avoid", + "printWidth": 80, + "endOfLine": "lf", + "sortImports": true, + "sortPackageJson": false +} diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/.oxlintrc.json b/packages/data-app-examples/templates/starter-local-finding-data-app/.oxlintrc.json new file mode 100644 index 00000000..ce4285ad --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/.oxlintrc.json @@ -0,0 +1,33 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "jsx-a11y"], + "categories": { + "correctness": "error" + }, + "options": { + "typeAware": true + }, + "rules": { + "react/unsupported-syntax": "error", + "eslint/no-restricted-imports": [ + "error", + { + "patterns": [ + { + "regex": "^\\.\\.?/", + "message": "Use #app/* or #config for app-owned files." + } + ] + } + ], + "func-style": [ + "error", + "declaration", + { + "overrides": { + "namedExports": "declaration" + } + } + ] + } +} diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/AGENTS.md b/packages/data-app-examples/templates/starter-local-finding-data-app/AGENTS.md new file mode 100644 index 00000000..56d374be --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/AGENTS.md @@ -0,0 +1,18 @@ +# Build this local data app + +Read `node_modules/@altertable/data-app/docs/app-authoring.md` and use this project +as the selected scaffold. If dependencies are unavailable, read the +[version-matched recipe](https://github.com/altertable-ai/data-app/blob/v0.74.0/docs/app-authoring.md). + +| Task | App-owned files | +| ------------------------------------------ | -------------------------------------------------------- | +| Define queries and inputs | `app.ts`, `src/operations.ts`, optional shared contracts | +| Compose app content and supported features | `src/App.tsx` | +| Bind definitions and source evidence | `src/data-context.ts` | +| Change identity and appearance | `app.ts` | +| Inject styles and mount React | `src/main.tsx` | +| Configure local serving | `src/server.ts` | + +See [README.md](README.md) for setup and checks. + +Keep reusable app-specific conventions here. diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/LICENSE b/packages/data-app-examples/templates/starter-local-finding-data-app/LICENSE new file mode 100644 index 00000000..845a62ce --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Altertable + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/README.md b/packages/data-app-examples/templates/starter-local-finding-data-app/README.md new file mode 100644 index 00000000..f3f40655 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/README.md @@ -0,0 +1,38 @@ +# Local finding data app starter + +Use `node_modules/@altertable/data-app/docs/app-authoring.md` for this SDK version. +Otherwise, read the [version-matched authoring recipe](https://github.com/altertable-ai/data-app/blob/v0.74.0/docs/app-authoring.md). +See [AGENTS.md](AGENTS.md) for app-owned files. + +## Run the app + +From the copied starter directory, install dependencies and set server-only +credentials before starting: + +```fish +bun install +set -gx ALTERTABLE_LAKEHOUSE_USERNAME your-username +read --silent --prompt-str 'Lakehouse password: ' ALTERTABLE_LAKEHOUSE_PASSWORD +set -gx ALTERTABLE_LAKEHOUSE_PASSWORD $ALTERTABLE_LAKEHOUSE_PASSWORD +bun run dev +``` + +Open . Alternatively, configure +`ALTERTABLE_DATA_PROXY_URL` and `ALTERTABLE_DATA_PROXY_TOKEN` for an existing +CLI proxy. Missing credentials stop startup. + +## Build the app + +Replace the fixture queries and example content with the evidence and content +specified by the app brief. Change identity, appearance, and queries in `app.ts`. + +```fish +bun run check +``` + +`bun run check` includes the build, which writes browser assets to `dist/`. Hosting them requires an authorized +`/api/data` backend; the local adapter does not authenticate hosted viewers. + +## License + +[MIT](LICENSE) diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/_gitignore b/packages/data-app-examples/templates/starter-local-finding-data-app/_gitignore new file mode 100644 index 00000000..8854acb7 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/_gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.env* diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/app.ts b/packages/data-app-examples/templates/starter-local-finding-data-app/app.ts new file mode 100644 index 00000000..d3a284ed --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/app.ts @@ -0,0 +1,25 @@ +import { defineDataApp } from '@altertable/data-app'; +export const dataApp = defineDataApp({ + title: 'Sample finding', + description: + 'Fixture evidence; replace with the Finding and its supporting queries.', + scope: { organization: 'demo', environment: 'sample' }, + appearance: { theme: 'system' }, + queries: { + sampleCountsByGroup: { + projections: { + group_name: { type: 'string' }, + sample_count: { type: 'number' }, + }, + statement: ` +WITH sample_counts(group_name, sample_count) AS (VALUES ('Alpha', 2200000), ('Beta', 0)) +SELECT group_name, sample_count FROM sample_counts +WHERE $groupName = '' OR group_name = $groupName +ORDER BY group_name LIMIT $limit`, + params: { + groupName: { type: 'string', defaultValue: '' }, + limit: { type: 'number', defaultValue: 10 }, + }, + }, + }, +}); diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/bunfig.toml b/packages/data-app-examples/templates/starter-local-finding-data-app/bunfig.toml new file mode 100644 index 00000000..b6874be1 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/bunfig.toml @@ -0,0 +1,2 @@ +[install] +exact = true diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/package.json b/packages/data-app-examples/templates/starter-local-finding-data-app/package.json new file mode 100644 index 00000000..76230eb9 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/package.json @@ -0,0 +1,36 @@ +{ + "name": "data-app-finding-starter", + "private": true, + "type": "module", + "imports": { + "#app/*": "./src/*", + "#config": "./app.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "oxlint --type-aware src", + "format": "oxfmt src app.ts", + "format:check": "oxfmt --check src app.ts", + "dev": "bun --hot src/server.ts", + "build": "bun run typecheck && bun build src/index.html --outdir=dist --production", + "check": "bun run lint && bun run format:check && bun run build", + "lint:fix": "oxlint --type-aware --fix src" + }, + "dependencies": { + "@altertable/data-app": "0.74.0", + "@altertable/data-app-bun": "0.74.0", + "@altertable/data-app-react": "0.74.0", + "react": "19.3.1", + "react-dom": "19.3.1" + }, + "devDependencies": { + "@types/bun": "1.4.3", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "oxfmt": "0.71.0", + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003", + "typescript": "7.0.2" + }, + "packageManager": "bun@1.4.4" +} diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/src/App.tsx b/packages/data-app-examples/templates/starter-local-finding-data-app/src/App.tsx new file mode 100644 index 00000000..f17e73a6 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/src/App.tsx @@ -0,0 +1,83 @@ +import { + createDataHooks, + DataApp, + DataSection, + VisualizationWidget, + Stack, + TextContent, +} from '@altertable/data-app-react'; +import { BarChart } from '@altertable/data-app-react/ui'; +import { createDataClient } from '@altertable/data-app/client'; + +import { sampleDataContext } from '#app/data-context.ts'; +import type { operations } from '#app/operations.ts'; +const { defineDataView } = + createDataHooks(createDataClient()); +const sampleCountsView = defineDataView({ + dataContext: sampleDataContext, + operation: 'sampleCountsByGroup', + describeInput: () => 'fixture groups', + isEmpty: sampleCounts => sampleCounts.length === 0, + emptyFallback: { + title: 'No matching groups', + description: 'No observations are available for this query.', + }, +}); +const sampleCounts = sampleCountsView.dataset({ + name: 'Sample counts', + select: rows => rows, + rowKey: row => row.groupName, + columns: { + groupName: { label: 'Group', value: row => row.groupName }, + sampleCount: { value: row => row.sampleCount, format: { kind: 'count' } }, + }, + evidence: { + id: 'counts-by-group', + glossaryIds: ['sampleCount'], + }, +}); +const sampleContent = sampleCountsView.content(result => ( + + +

Supporting evidence

+

+ These fixture rows demonstrate how a Finding can include a small + supporting visual. Replace this section with the Finding's own argument + and evidence. +

+
+ + {(rows, actions) => ( + ({ + ...row, + id: row.groupName, + label: row.groupName, + value: row.sampleCount, + }))} + itemActions={actions} + /> + )} + + +

Limitations

+

+ SQL VALUES are demonstration data and support no business conclusion. +

+
+
+)); +export function App() { + return ( + + + + ); +} diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/src/assets.d.ts b/packages/data-app-examples/templates/starter-local-finding-data-app/src/assets.d.ts new file mode 100644 index 00000000..35306c6f --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/src/assets.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/src/data-context.ts b/packages/data-app-examples/templates/starter-local-finding-data-app/src/data-context.ts new file mode 100644 index 00000000..6e29aaa8 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/src/data-context.ts @@ -0,0 +1,14 @@ +import { createDataContext } from '@altertable/data-app-react'; +const queryNames = { sampleCountsByGroup: 'sampleCountsByGroup' } as const; +export const sampleDataContext = createDataContext(queryNames)({ + description: + 'Two SQL VALUES rows demonstrate the host query path. Replace them with inspected source data before publishing findings.', + glossary: { + sampleCount: { + term: 'Sample count', + definition: + 'A fixture value: Alpha is 2,200,000 and Beta is a measured zero.', + queryNames: [queryNames.sampleCountsByGroup], + }, + }, +}); diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/src/favicon.svg b/packages/data-app-examples/templates/starter-local-finding-data-app/src/favicon.svg new file mode 100644 index 00000000..11ce6117 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/src/favicon.svg @@ -0,0 +1,13 @@ + + + + diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/src/index.html b/packages/data-app-examples/templates/starter-local-finding-data-app/src/index.html new file mode 100644 index 00000000..1edaa873 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/src/index.html @@ -0,0 +1,14 @@ + + + + + + + Altertable app + + + + +
+ + diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/src/main.tsx b/packages/data-app-examples/templates/starter-local-finding-data-app/src/main.tsx new file mode 100644 index 00000000..b71279bc --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/src/main.tsx @@ -0,0 +1,8 @@ +import { injectDataAppStyles, mountDataApp } from '@altertable/data-app-react'; + +import { App } from '#app/App.tsx'; +import { dataApp } from '#config'; + +injectDataAppStyles(); + +mountDataApp({ app: dataApp, component: App }); diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/src/operations.ts b/packages/data-app-examples/templates/starter-local-finding-data-app/src/operations.ts new file mode 100644 index 00000000..af9f2a08 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/src/operations.ts @@ -0,0 +1,17 @@ +import { parseCount } from '@altertable/data-app/contract'; + +import { dataApp } from '#config'; +export const operations = { + sampleCountsByGroup: dataApp.defineOperation({ + input: () => ({}), + checks: [{}], + policy: { maxQueryRows: 10, maxDurationMs: 15000 }, + async run({ query }) { + const result = await query('sampleCountsByGroup', {}); + return result.rows.map(row => ({ + groupName: row.group_name, + sampleCount: parseCount(row.sample_count), + })); + }, + }), +}; diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/src/server.ts b/packages/data-app-examples/templates/starter-local-finding-data-app/src/server.ts new file mode 100644 index 00000000..da9c04f3 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/src/server.ts @@ -0,0 +1,7 @@ +import { serveLocalApp } from '@altertable/data-app-bun'; + +import page from '#app/index.html'; +import { operations } from '#app/operations.ts'; +import { dataApp } from '#config'; + +serveLocalApp({ page, operations, title: dataApp.title }); diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/tsconfig.json b/packages/data-app-examples/templates/starter-local-finding-data-app/tsconfig.json new file mode 100644 index 00000000..86196070 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "Preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["bun"], + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "allowUnreachableCode": false, + "noUncheckedSideEffectImports": true + }, + "include": ["src", "app.ts"] +} diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/.bun-version b/packages/data-app-examples/templates/starter-local-metric-data-app/.bun-version new file mode 100644 index 00000000..1c99cf0e --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/.bun-version @@ -0,0 +1 @@ +1.4.4 diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/.node-version b/packages/data-app-examples/templates/starter-local-metric-data-app/.node-version new file mode 100644 index 00000000..ca5c3500 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/.node-version @@ -0,0 +1 @@ +24.18.0 diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/.oxfmtrc.json b/packages/data-app-examples/templates/starter-local-metric-data-app/.oxfmtrc.json new file mode 100644 index 00000000..569314e7 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/.oxfmtrc.json @@ -0,0 +1,11 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": ["dist/**", "bun.lock"], + "singleQuote": true, + "trailingComma": "es5", + "arrowParens": "avoid", + "printWidth": 80, + "endOfLine": "lf", + "sortImports": true, + "sortPackageJson": false +} diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/.oxlintrc.json b/packages/data-app-examples/templates/starter-local-metric-data-app/.oxlintrc.json new file mode 100644 index 00000000..ce4285ad --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/.oxlintrc.json @@ -0,0 +1,33 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "unicorn", "oxc", "react", "jsx-a11y"], + "categories": { + "correctness": "error" + }, + "options": { + "typeAware": true + }, + "rules": { + "react/unsupported-syntax": "error", + "eslint/no-restricted-imports": [ + "error", + { + "patterns": [ + { + "regex": "^\\.\\.?/", + "message": "Use #app/* or #config for app-owned files." + } + ] + } + ], + "func-style": [ + "error", + "declaration", + { + "overrides": { + "namedExports": "declaration" + } + } + ] + } +} diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/AGENTS.md b/packages/data-app-examples/templates/starter-local-metric-data-app/AGENTS.md new file mode 100644 index 00000000..56d374be --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/AGENTS.md @@ -0,0 +1,18 @@ +# Build this local data app + +Read `node_modules/@altertable/data-app/docs/app-authoring.md` and use this project +as the selected scaffold. If dependencies are unavailable, read the +[version-matched recipe](https://github.com/altertable-ai/data-app/blob/v0.74.0/docs/app-authoring.md). + +| Task | App-owned files | +| ------------------------------------------ | -------------------------------------------------------- | +| Define queries and inputs | `app.ts`, `src/operations.ts`, optional shared contracts | +| Compose app content and supported features | `src/App.tsx` | +| Bind definitions and source evidence | `src/data-context.ts` | +| Change identity and appearance | `app.ts` | +| Inject styles and mount React | `src/main.tsx` | +| Configure local serving | `src/server.ts` | + +See [README.md](README.md) for setup and checks. + +Keep reusable app-specific conventions here. diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/LICENSE b/packages/data-app-examples/templates/starter-local-metric-data-app/LICENSE new file mode 100644 index 00000000..845a62ce --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Altertable + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/README.md b/packages/data-app-examples/templates/starter-local-metric-data-app/README.md new file mode 100644 index 00000000..e975a0e1 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/README.md @@ -0,0 +1,38 @@ +# Local metric data app starter + +Use `node_modules/@altertable/data-app/docs/app-authoring.md` for this SDK version. +Otherwise, read the [version-matched authoring recipe](https://github.com/altertable-ai/data-app/blob/v0.74.0/docs/app-authoring.md). +See [AGENTS.md](AGENTS.md) for app-owned files. + +## Run the app + +From the copied starter directory, install dependencies and set server-only +credentials before starting: + +```fish +bun install +set -gx ALTERTABLE_LAKEHOUSE_USERNAME your-username +read --silent --prompt-str 'Lakehouse password: ' ALTERTABLE_LAKEHOUSE_PASSWORD +set -gx ALTERTABLE_LAKEHOUSE_PASSWORD $ALTERTABLE_LAKEHOUSE_PASSWORD +bun run dev +``` + +Open . Alternatively, configure +`ALTERTABLE_DATA_PROXY_URL` and `ALTERTABLE_DATA_PROXY_TOKEN` for an existing +CLI proxy. Missing credentials stop startup. + +## Build the app + +Replace the fixture queries and example content with the evidence and content +specified by the app brief. Change identity, appearance, and queries in `app.ts`. + +```fish +bun run check +``` + +`bun run check` includes the build, which writes browser assets to `dist/`. Hosting them requires an authorized +`/api/data` backend; the local adapter does not authenticate hosted viewers. + +## License + +[MIT](LICENSE) diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/_gitignore b/packages/data-app-examples/templates/starter-local-metric-data-app/_gitignore new file mode 100644 index 00000000..8854acb7 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/_gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.env* diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/app.ts b/packages/data-app-examples/templates/starter-local-metric-data-app/app.ts new file mode 100644 index 00000000..296eaba8 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/app.ts @@ -0,0 +1,24 @@ +import { defineDataApp } from '@altertable/data-app'; +export const dataApp = defineDataApp({ + title: 'Sample metric', + description: 'Fixture counts by group; replace with the requested metric.', + scope: { organization: 'demo', environment: 'sample' }, + appearance: { theme: 'system' }, + queries: { + sampleCountsByGroup: { + projections: { + group_name: { type: 'string' }, + sample_count: { type: 'number' }, + }, + statement: ` +WITH sample_counts(group_name, sample_count) AS (VALUES ('Alpha', 2200000), ('Beta', 0)) +SELECT group_name, sample_count FROM sample_counts +WHERE $groupName = '' OR group_name = $groupName +ORDER BY group_name LIMIT $limit`, + params: { + groupName: { type: 'string', defaultValue: '' }, + limit: { type: 'number', defaultValue: 10 }, + }, + }, + }, +}); diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/bunfig.toml b/packages/data-app-examples/templates/starter-local-metric-data-app/bunfig.toml new file mode 100644 index 00000000..b6874be1 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/bunfig.toml @@ -0,0 +1,2 @@ +[install] +exact = true diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/package.json b/packages/data-app-examples/templates/starter-local-metric-data-app/package.json new file mode 100644 index 00000000..667d2c5c --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/package.json @@ -0,0 +1,36 @@ +{ + "name": "data-app-metric-starter", + "private": true, + "type": "module", + "imports": { + "#app/*": "./src/*", + "#config": "./app.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit", + "lint": "oxlint --type-aware src", + "format": "oxfmt src app.ts", + "format:check": "oxfmt --check src app.ts", + "dev": "bun --hot src/server.ts", + "build": "bun run typecheck && bun build src/index.html --outdir=dist --production", + "check": "bun run lint && bun run format:check && bun run build", + "lint:fix": "oxlint --type-aware --fix src" + }, + "dependencies": { + "@altertable/data-app": "0.74.0", + "@altertable/data-app-bun": "0.74.0", + "@altertable/data-app-react": "0.74.0", + "react": "19.3.1", + "react-dom": "19.3.1" + }, + "devDependencies": { + "@types/bun": "1.4.3", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "oxfmt": "0.71.0", + "oxlint": "1.86.0", + "oxlint-tsgolint": "7.0.2003", + "typescript": "7.0.2" + }, + "packageManager": "bun@1.4.4" +} diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/src/App.tsx b/packages/data-app-examples/templates/starter-local-metric-data-app/src/App.tsx new file mode 100644 index 00000000..ace28d4b --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/App.tsx @@ -0,0 +1,65 @@ +import { + createDataHooks, + DataApp, + DataSection, + VisualizationWidget, +} from '@altertable/data-app-react'; +import { BarChart } from '@altertable/data-app-react/ui'; +import { createDataClient } from '@altertable/data-app/client'; + +import { sampleDataContext } from '#app/data-context.ts'; +import type { operations } from '#app/operations.ts'; +const { defineDataView } = + createDataHooks(createDataClient()); +const sampleCountsView = defineDataView({ + dataContext: sampleDataContext, + operation: 'sampleCountsByGroup', + describeInput: () => 'fixture groups', + isEmpty: sampleCounts => sampleCounts.length === 0, + emptyFallback: { + title: 'No matching groups', + description: 'No observations are available for this query.', + }, +}); +const sampleCounts = sampleCountsView.dataset({ + name: 'Sample counts', + select: rows => rows, + rowKey: row => row.groupName, + columns: { + groupName: { label: 'Group', value: row => row.groupName }, + sampleCount: { value: row => row.sampleCount, format: { kind: 'count' } }, + }, + evidence: { + id: 'counts-by-group', + glossaryIds: ['sampleCount'], + }, +}); +const sampleContent = sampleCountsView.content(result => ( + + {(rows, actions) => ( + ({ + ...row, + id: row.groupName, + label: row.groupName, + value: row.sampleCount, + }))} + itemActions={actions} + /> + )} + +)); +export function App() { + return ( + + + + ); +} diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/src/assets.d.ts b/packages/data-app-examples/templates/starter-local-metric-data-app/src/assets.d.ts new file mode 100644 index 00000000..35306c6f --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/assets.d.ts @@ -0,0 +1 @@ +declare module '*.css'; diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/src/data-context.ts b/packages/data-app-examples/templates/starter-local-metric-data-app/src/data-context.ts new file mode 100644 index 00000000..6e29aaa8 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/data-context.ts @@ -0,0 +1,14 @@ +import { createDataContext } from '@altertable/data-app-react'; +const queryNames = { sampleCountsByGroup: 'sampleCountsByGroup' } as const; +export const sampleDataContext = createDataContext(queryNames)({ + description: + 'Two SQL VALUES rows demonstrate the host query path. Replace them with inspected source data before publishing findings.', + glossary: { + sampleCount: { + term: 'Sample count', + definition: + 'A fixture value: Alpha is 2,200,000 and Beta is a measured zero.', + queryNames: [queryNames.sampleCountsByGroup], + }, + }, +}); diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/src/favicon.svg b/packages/data-app-examples/templates/starter-local-metric-data-app/src/favicon.svg new file mode 100644 index 00000000..11ce6117 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/favicon.svg @@ -0,0 +1,13 @@ + + + + diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/src/index.html b/packages/data-app-examples/templates/starter-local-metric-data-app/src/index.html new file mode 100644 index 00000000..1edaa873 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/index.html @@ -0,0 +1,14 @@ + + + + + + + Altertable app + + + + +
+ + diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/src/main.tsx b/packages/data-app-examples/templates/starter-local-metric-data-app/src/main.tsx new file mode 100644 index 00000000..b71279bc --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/main.tsx @@ -0,0 +1,8 @@ +import { injectDataAppStyles, mountDataApp } from '@altertable/data-app-react'; + +import { App } from '#app/App.tsx'; +import { dataApp } from '#config'; + +injectDataAppStyles(); + +mountDataApp({ app: dataApp, component: App }); diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/src/operations.ts b/packages/data-app-examples/templates/starter-local-metric-data-app/src/operations.ts new file mode 100644 index 00000000..af9f2a08 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/operations.ts @@ -0,0 +1,17 @@ +import { parseCount } from '@altertable/data-app/contract'; + +import { dataApp } from '#config'; +export const operations = { + sampleCountsByGroup: dataApp.defineOperation({ + input: () => ({}), + checks: [{}], + policy: { maxQueryRows: 10, maxDurationMs: 15000 }, + async run({ query }) { + const result = await query('sampleCountsByGroup', {}); + return result.rows.map(row => ({ + groupName: row.group_name, + sampleCount: parseCount(row.sample_count), + })); + }, + }), +}; diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/src/server.ts b/packages/data-app-examples/templates/starter-local-metric-data-app/src/server.ts new file mode 100644 index 00000000..da9c04f3 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/server.ts @@ -0,0 +1,7 @@ +import { serveLocalApp } from '@altertable/data-app-bun'; + +import page from '#app/index.html'; +import { operations } from '#app/operations.ts'; +import { dataApp } from '#config'; + +serveLocalApp({ page, operations, title: dataApp.title }); diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/tsconfig.json b/packages/data-app-examples/templates/starter-local-metric-data-app/tsconfig.json new file mode 100644 index 00000000..86196070 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "Preserve", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["bun"], + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "allowUnreachableCode": false, + "noUncheckedSideEffectImports": true + }, + "include": ["src", "app.ts"] +} diff --git a/packages/data-app-examples/templates/starter-metric-data-app/LICENSE b/packages/data-app-examples/templates/starter-metric-data-app/LICENSE new file mode 100644 index 00000000..845a62ce --- /dev/null +++ b/packages/data-app-examples/templates/starter-metric-data-app/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Altertable + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/data-app-examples/templates/starter-metric-data-app/index.tsx b/packages/data-app-examples/templates/starter-metric-data-app/index.tsx new file mode 100644 index 00000000..ee48a9a0 --- /dev/null +++ b/packages/data-app-examples/templates/starter-metric-data-app/index.tsx @@ -0,0 +1,120 @@ +import { defineDataApp } from '@altertable/data-app'; +import { + createDataContext, + injectDataAppStyles, + mountDataApp, +} from '@altertable/data-app-react'; +import { + createDataHooks, + DataApp, + DataSection, + VisualizationWidget, +} from '@altertable/data-app-react'; +import { BarChart } from '@altertable/data-app-react/ui'; +import { createDataClient } from '@altertable/data-app/client'; +import { parseCount } from '@altertable/data-app/contract'; +const dataApp = defineDataApp({ + title: 'Sample metric', + description: 'Fixture counts by group; replace with the requested metric.', + scope: { organization: 'demo', environment: 'sample' }, + appearance: { theme: 'system' }, + queries: { + sampleCountsByGroup: { + projections: { + group_name: { type: 'string' }, + sample_count: { type: 'number' }, + }, + statement: ` +WITH sample_counts(group_name, sample_count) AS (VALUES ('Alpha', 2200000), ('Beta', 0)) +SELECT group_name, sample_count FROM sample_counts +WHERE $groupName = '' OR group_name = $groupName +ORDER BY group_name LIMIT $limit`, + params: { + groupName: { type: 'string', defaultValue: '' }, + limit: { type: 'number', defaultValue: 10 }, + }, + }, + }, +}); +const operations = { + sampleCountsByGroup: dataApp.defineOperation({ + input: () => ({}), + checks: [{}], + policy: { maxQueryRows: 10, maxDurationMs: 15000 }, + async run({ query }) { + const result = await query('sampleCountsByGroup', {}); + return result.rows.map(row => ({ + groupName: row.group_name, + sampleCount: parseCount(row.sample_count), + })); + }, + }), +}; +const queryNames = operations.sampleCountsByGroup.queryNames; +const sampleDataContext = createDataContext(queryNames)({ + description: + 'Two SQL VALUES rows demonstrate the host query path. Replace them with inspected source data before publishing findings.', + glossary: { + sampleCount: { + term: 'Sample count', + definition: + 'A fixture value: Alpha is 2,200,000 and Beta is a measured zero.', + queryNames: [queryNames.sampleCountsByGroup], + }, + }, +}); +const { defineDataView } = createDataHooks(createDataClient({ operations })); +const sampleCountsView = defineDataView({ + dataContext: sampleDataContext, + operation: 'sampleCountsByGroup', + describeInput: () => 'fixture groups', + isEmpty: sampleCounts => sampleCounts.length === 0, + emptyFallback: { + title: 'No matching groups', + description: 'No observations are available for this query.', + }, +}); +const sampleCounts = sampleCountsView.dataset({ + name: 'Sample counts', + select: rows => rows, + rowKey: row => row.groupName, + columns: { + groupName: { label: 'Group', value: row => row.groupName }, + sampleCount: { value: row => row.sampleCount, format: { kind: 'count' } }, + }, + evidence: { + id: 'counts-by-group', + glossaryIds: ['sampleCount'], + }, +}); +const sampleContent = sampleCountsView.content(result => ( + + {(rows, actions) => ( + ({ + ...row, + id: row.groupName, + label: row.groupName, + value: row.sampleCount, + }))} + itemActions={actions} + /> + )} + +)); +function App() { + return ( + + + + ); +} +injectDataAppStyles(); +mountDataApp({ app: dataApp, component: App }); diff --git a/packages/data-app-react/src/ui/DataApp.tsx b/packages/data-app-react/src/ui/DataApp.tsx index a241c503..50e88f09 100644 --- a/packages/data-app-react/src/ui/DataApp.tsx +++ b/packages/data-app-react/src/ui/DataApp.tsx @@ -34,13 +34,15 @@ export type DataAppProps = Omit< }; /** Show supplied datasets’ record suggestions on the first declared search. Defaults to true. */ showSearchSuggestions?: boolean; - story: (snapshot: DisplayedSnapshot) => readonly (Omit< + /** Omit to leave presentation out of the toolbar. */ + story?: (snapshot: DisplayedSnapshot) => readonly (Omit< StoryFinding, 'evidence' > & { evidence: RegisteredBinding; })[]; - datasets: readonly [ + /** Omit to leave CSV export out of the toolbar. */ + datasets?: readonly [ ExportDataset, ...ExportDataset[], ]; @@ -67,7 +69,7 @@ export function DataApp(props: DataAppProps) { ); const suggestionGroups = showSearchSuggestions && frame.showSearch !== false && result.searchControls - ? datasets.flatMap((dataset, index) => { + ? (datasets ?? []).flatMap((dataset, index) => { const reading = dataset.readSearchSuggestions(source); if (!reading) return []; return [ @@ -98,19 +100,25 @@ export function DataApp(props: DataAppProps) { {...frame} request={result} dataContext={result.dataContext} - story={snapshot => - story(ownedSource(view, snapshot)).map(finding => ({ - ...finding, - evidence: registeredEvidence(finding.evidence, view), - })) + story={ + story + ? snapshot => + story(ownedSource(view, snapshot)).map(finding => ({ + ...finding, + evidence: registeredEvidence(finding.evidence, view), + })) + : undefined } - csvExport={(snapshot: DisplayedSnapshot) => - exportDatasets( - datasets, - ownedSource(view, snapshot), - view.scope(snapshot), - app.title - ) + csvExport={ + datasets + ? (snapshot: DisplayedSnapshot) => + exportDatasets( + datasets, + ownedSource(view, snapshot), + view.scope(snapshot), + app.title + ) + : undefined } > {children} diff --git a/packages/data-app-react/src/ui/DataAppFrame.tsx b/packages/data-app-react/src/ui/DataAppFrame.tsx index f823bba4..d6478de5 100644 --- a/packages/data-app-react/src/ui/DataAppFrame.tsx +++ b/packages/data-app-react/src/ui/DataAppFrame.tsx @@ -31,6 +31,8 @@ export type DataAppBaseProps = { dataContext: DataContext; /** Override the declared subtitle; null hides it. */ description?: ReactNode; + /** Show page actions. Defaults to true; does not hide filters or data states. */ + showToolbar?: boolean; toolbarActions?: ReactNode; footerActions?: ReactNode; /** Optional source, coverage, or methodology note below the app content. @@ -65,9 +67,9 @@ export type DataAppProps = DataAppControlProps & { request: DataAppRequest; /** Findings are always derived from the result currently visible to the user. */ - story: BoundStory; + story?: BoundStory; /** Export every distinct dataset in the displayed result. */ - csvExport: (snapshot: DisplayedSnapshot) => CsvExport; + csvExport?: (snapshot: DisplayedSnapshot) => CsvExport; queries?: never; }) | (StaticDataAppProps & { @@ -100,7 +102,7 @@ export function DataAppFrame(props: DataAppProps) { }); const snapshot = request && displayedSnapshot(request.view); - const findings = request && snapshot ? props.story(snapshot) : undefined; + const findings = request && snapshot ? props.story?.(snapshot) : undefined; const story = findings?.length ? { findings } : undefined; const scope = ( (props: DataAppProps) { environment={app.scope.environment} /> ); - const toolbar = ( - void request.refetch(), - onCancel: request.cancel - ? () => void request.cancel?.() - : undefined, - } - : undefined - } - story={ - story - ? { - ...story, - title: app.title, - scope, - dataContext, - theme: themeController, - } - : request - ? null + const toolbar = + props.showToolbar === false ? null : ( + void request.refetch(), + onCancel: request.cancel + ? () => void request.cancel?.() + : undefined, + } : undefined - } - aboutData={ - - } - > - {presentation?.annotations?.enabled && ( - - )} - {toolbarActions} - - ); + } + story={ + story + ? { + ...story, + title: app.title, + scope, + dataContext, + theme: themeController, + } + : request && props.story + ? null + : undefined + } + aboutData={ + + } + > + {presentation?.annotations?.enabled && ( + + )} + {toolbarActions} + + ); const searchRegion = request?.searchControls && props.showSearch !== false ? ( diff --git a/release-please-config.json b/release-please-config.json index 6d5c7a85..323a2d9e 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -126,6 +126,52 @@ "type": "json", "path": "packages/data-app-manifest/package.json", "jsonpath": "$.version" + }, + { + "type": "json", + "path": "packages/data-app-examples/templates/starter-local-metric-data-app/package.json", + "jsonpath": "$.dependencies['@altertable/data-app']" + }, + { + "type": "json", + "path": "packages/data-app-examples/templates/starter-local-metric-data-app/package.json", + "jsonpath": "$.dependencies['@altertable/data-app-react']" + }, + { + "type": "json", + "path": "packages/data-app-examples/templates/starter-local-metric-data-app/package.json", + "jsonpath": "$.dependencies['@altertable/data-app-bun']" + }, + { + "type": "generic", + "path": "packages/data-app-examples/templates/starter-local-metric-data-app/AGENTS.md" + }, + { + "type": "generic", + "path": "packages/data-app-examples/templates/starter-local-metric-data-app/README.md" + }, + { + "type": "json", + "path": "packages/data-app-examples/templates/starter-local-finding-data-app/package.json", + "jsonpath": "$.dependencies['@altertable/data-app']" + }, + { + "type": "json", + "path": "packages/data-app-examples/templates/starter-local-finding-data-app/package.json", + "jsonpath": "$.dependencies['@altertable/data-app-react']" + }, + { + "type": "json", + "path": "packages/data-app-examples/templates/starter-local-finding-data-app/package.json", + "jsonpath": "$.dependencies['@altertable/data-app-bun']" + }, + { + "type": "generic", + "path": "packages/data-app-examples/templates/starter-local-finding-data-app/AGENTS.md" + }, + { + "type": "generic", + "path": "packages/data-app-examples/templates/starter-local-finding-data-app/README.md" } ] } diff --git a/scripts/check-boundaries.ts b/scripts/check-boundaries.ts index 7738572a..18b906cb 100644 --- a/scripts/check-boundaries.ts +++ b/scripts/check-boundaries.ts @@ -5,6 +5,8 @@ import { relative, resolve } from 'node:path'; import released from '@/.release-please-manifest.json'; import workspace from '@/package.json'; import starter from '@/packages/data-app-examples/templates/starter-local-data-app/package.json'; +import findingStarter from '@/packages/data-app-examples/templates/starter-local-finding-data-app/package.json'; +import metricStarter from '@/packages/data-app-examples/templates/starter-local-metric-data-app/package.json'; import release from '@/release-please-config.json'; import { packages } from '@/scripts/packages'; @@ -27,37 +29,43 @@ function releaseVersion(path: string, field: string) { `Release Please does not update ${path}:${field}` ); } -for (const filename of ['AGENTS.md', 'README.md']) { - const path = `packages/data-app-examples/templates/starter-local-data-app/${filename}`; - assert( - release.packages['.']['extra-files'].some( - update => update.type === 'generic' && update.path === path - ), - `Release Please does not update starter guidance: ${path}` - ); - const source = await Bun.file(path).text(); - const taggedLinks = [ - ...source.matchAll( - /github\.com\/altertable-ai\/data-app\/blob\/v([^/]+)\//g - ), - ]; - assert(taggedLinks.length > 0, `Missing release-matched guidance: ${path}`); - for (const match of taggedLinks) - assert.equal( - match[1], - workspace.version, - `${path}: guidance version differs` - ); - const annotatedLines = source - .split('\n') - .filter(line => line.includes('')); - assert(annotatedLines.length > 0, `${path}: missing release annotation`); - for (const line of annotatedLines) - assert.equal( - [...line.matchAll(/\d+\.\d+\.\d+/g)].length, - 1, - `${path}: release annotations must contain exactly one version` +for (const id of [ + 'starter-local-data-app', + 'starter-local-metric-data-app', + 'starter-local-finding-data-app', +]) { + for (const filename of ['AGENTS.md', 'README.md']) { + const path = `packages/data-app-examples/templates/${id}/${filename}`; + assert( + release.packages['.']['extra-files'].some( + update => update.type === 'generic' && update.path === path + ), + `Release Please does not update starter guidance: ${path}` ); + const source = await Bun.file(path).text(); + const taggedLinks = [ + ...source.matchAll( + /github\.com\/altertable-ai\/data-app\/blob\/v([^/]+)\//g + ), + ]; + assert(taggedLinks.length > 0, `Missing release-matched guidance: ${path}`); + for (const match of taggedLinks) + assert.equal( + match[1], + workspace.version, + `${path}: guidance version differs` + ); + const annotatedLines = source + .split('\n') + .filter(line => line.includes('')); + assert(annotatedLines.length > 0, `${path}: missing release annotation`); + for (const line of annotatedLines) + assert.equal( + [...line.matchAll(/\d+\.\d+\.\d+/g)].length, + 1, + `${path}: release annotations must contain exactly one version` + ); + } } for (const { directory } of packages) releaseVersion(`${directory}/package.json`, '$.version'); @@ -67,6 +75,14 @@ for (const [path, manifest] of [ 'packages/data-app-examples/templates/starter-local-data-app/package.json', starter, ], + [ + 'packages/data-app-examples/templates/starter-local-metric-data-app/package.json', + metricStarter, + ], + [ + 'packages/data-app-examples/templates/starter-local-finding-data-app/package.json', + findingStarter, + ], ...packages.map( pkg => [`${pkg.directory}/package.json`, pkg.manifest] as const ), diff --git a/scripts/check-package.ts b/scripts/check-package.ts index bd963cc4..d1708a9f 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -196,6 +196,10 @@ try { if (manifest.name === '@altertable/data-app-examples') { for (const path of [ 'templates/starter-data-app/index.tsx', + 'templates/starter-metric-data-app/index.tsx', + 'templates/starter-finding-data-app/index.tsx', + 'templates/starter-local-metric-data-app/src/App.tsx', + 'templates/starter-local-finding-data-app/src/App.tsx', 'templates/starter-local-data-app/package.json', 'templates/starter-local-data-app/.bun-version', 'templates/starter-local-data-app/_gitignore', @@ -387,6 +391,10 @@ console.log(JSON.stringify(dataAppExamples.map(example => ({...example, director [ { id: 'starter-data-app', mode: 'hosted' }, { id: 'starter-local-data-app', mode: 'local' }, + { id: 'starter-metric-data-app', mode: 'hosted' }, + { id: 'starter-finding-data-app', mode: 'hosted' }, + { id: 'starter-local-metric-data-app', mode: 'local' }, + { id: 'starter-local-finding-data-app', mode: 'local' }, ] ); const localExample = catalog.find(example => example.mode === 'local'); diff --git a/scripts/check-starter.ts b/scripts/check-starter.ts index a073c06b..99ce66fd 100644 --- a/scripts/check-starter.ts +++ b/scripts/check-starter.ts @@ -4,30 +4,36 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; -import { copyExample } from '@altertable/data-app-examples'; +import { copyExample, dataAppExamples } from '@altertable/data-app-examples'; const root = resolve(import.meta.dir, '..'); -// Verify the starter against the already installed workspace packages. -const child = Bun.spawn(['bun', 'run', 'check'], { - cwd: resolve( - root, - 'packages/data-app-examples/templates/starter-local-data-app' - ), - stdout: 'inherit', - stderr: 'inherit', -}); -if ((await child.exited) !== 0) throw new Error('Starter check failed.'); +// Verify every local starter against the installed workspace packages. +for (const example of dataAppExamples.filter( + example => example.mode === 'local' +)) { + const child = Bun.spawn(['bun', 'run', 'check'], { + cwd: resolve(root, 'packages/data-app-examples/templates', example.id), + stdout: 'inherit', + stderr: 'inherit', + }); + if ((await child.exited) !== 0) + throw new Error(`${example.id} check failed.`); +} // Linked consumers must also omit the build output just created in the source template. const temporary = await mkdtemp(join(tmpdir(), 'data-app-starter-')); try { - const destination = join(temporary, 'app'); - await copyExample('starter-local-data-app', destination); - assert(existsSync(join(destination, '.gitignore'))); - assert(existsSync(join(destination, 'package.json'))); - assert(!existsSync(join(destination, 'dist'))); - assert(!existsSync(join(destination, 'node_modules'))); + for (const example of dataAppExamples.filter( + example => example.mode === 'local' + )) { + const destination = join(temporary, example.id); + await copyExample(example.id, destination); + assert(existsSync(join(destination, '.gitignore'))); + assert(existsSync(join(destination, 'package.json'))); + assert(!existsSync(join(destination, 'dist'))); + assert(!existsSync(join(destination, 'node_modules'))); + } } finally { await rm(temporary, { recursive: true, force: true }); } diff --git a/tests/public-api/apps-starters.test.ts b/tests/public-api/apps-starters.test.ts new file mode 100644 index 00000000..fa653dc4 --- /dev/null +++ b/tests/public-api/apps-starters.test.ts @@ -0,0 +1,73 @@ +import { expect } from 'vitest'; + +import { + test, + metricLocalURL, + findingLocalURL, +} from '@/tests/public-api/browser'; + +for (const kind of ['metric', 'finding'] as const) { + for (const mode of ['hosted', 'local'] as const) { + test(`${mode} ${kind} starter renders fixture evidence without dashboard actions`, async ({ + page, + }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto( + mode === 'hosted' + ? `/starter-${kind}-data-app` + : kind === 'metric' + ? metricLocalURL + : findingLocalURL + ); + const app = mode === 'hosted' ? page.frameLocator('iframe') : page; + await expect + .poll(() => + app + .getByRole('region', { name: 'Sample count', exact: true }) + .getByText('Alpha', { exact: true }) + .isVisible() + ) + .toBe(true); + expect( + await app + .getByRole('group', { name: 'Page actions', exact: true }) + .count() + ).toBe(0); + expect( + await app.getByRole('button', { name: 'Present story' }).count() + ).toBe(0); + expect(await app.locator('h1').count()).toBe(mode === 'hosted' ? 0 : 1); + expect( + await app.getByRole('heading', { name: 'Supporting evidence' }).count() + ).toBe(kind === 'finding' ? 1 : 0); + expect(errors).toEqual([]); + }); + } +} + +test('omitting presentation and export retains refresh and loaded content', async ({ + page, +}) => { + await page.goto('/declared-app?no-exports'); + await page + .getByRole('button', { name: 'Resolve alpha 1', exact: true }) + .click(); + await page.getByRole('button', { name: 'Show primary', exact: true }).click(); + await expect + .poll(() => + page + .getByRole('button', { name: 'Refresh data', exact: true }) + .isEnabled() + ) + .toBe(true); + expect( + await page.getByRole('button', { name: 'Present story' }).count() + ).toBe(0); + expect(await page.getByRole('button', { name: /Export/ }).count()).toBe(0); + await expect + .poll(() => + page.getByRole('region', { name: 'Alpha visualization' }).isVisible() + ) + .toBe(true); +}); diff --git a/tests/public-api/authoring-entry.types.tsx b/tests/public-api/authoring-entry.types.tsx index b121fe5e..2bfb679c 100644 --- a/tests/public-api/authoring-entry.types.tsx +++ b/tests/public-api/authoring-entry.types.tsx @@ -274,12 +274,12 @@ view.dataset({ />; [ { id: 'raw', headline: 'Raw', visual: null, - // @ts-expect-error Story evidence must be the registered binding, not raw metadata. evidence: boundMetric.definition, }, ]} diff --git a/tests/public-api/browser.ts b/tests/public-api/browser.ts index b8443299..4c06cebe 100644 --- a/tests/public-api/browser.ts +++ b/tests/public-api/browser.ts @@ -6,6 +6,8 @@ import { afterAll, beforeAll, vi, test as vitestTest } from 'vitest'; let browser: Browser; let baseURL: string; export let localURL: string; +export let metricLocalURL: string; +export let findingLocalURL: string; let server: ReturnType; let output = ''; beforeAll(async () => { @@ -23,7 +25,9 @@ beforeAll(async () => { if (server.exitCode !== null) throw new Error(output); const ready = output.match(/DATA_APP_TEST_SERVER (.*)/); if (!ready) throw new Error(`Browser fixture is not ready: ${output}`); - ({ baseURL, localURL } = JSON.parse(ready[1]!)); + ({ baseURL, localURL, metricLocalURL, findingLocalURL } = JSON.parse( + ready[1]! + )); }, { timeout: 30000 } ); diff --git a/tests/public-api/fixtures/bundle-host.tsx b/tests/public-api/fixtures/bundle-host.tsx index 6df4d1e1..4849d399 100644 --- a/tests/public-api/fixtures/bundle-host.tsx +++ b/tests/public-api/fixtures/bundle-host.tsx @@ -22,10 +22,14 @@ const { frameURL } = await fetch('/__test/config').then(response => response.json() ); const params = new URLSearchParams(location.search); -const starter = location.pathname === '/starter-data-app'; +const starter = [ + '/starter-data-app', + '/starter-metric-data-app', + '/starter-finding-data-app', +].includes(location.pathname); const javascript = await fetch( starter - ? '/__test/starter-data-app' + ? `/__test${location.pathname}` : params.has('manual-sizing') ? '/__test/content-sizing' : '/__test/bundle' diff --git a/tests/public-api/fixtures/declared-app.tsx b/tests/public-api/fixtures/declared-app.tsx index e2955e56..a6d7f92d 100644 --- a/tests/public-api/fixtures/declared-app.tsx +++ b/tests/public-api/fixtures/declared-app.tsx @@ -158,7 +158,12 @@ export const dataApp = defineDataApp({ export function DeclaredApp() { const [shown, setShown] = useState(false); return ( - []} datasets={[alphaDataset]}> + [], datasets: [alphaDataset] as const })} + > diff --git a/tests/public-api/fixtures/server.ts b/tests/public-api/fixtures/server.ts index a50a5c96..4b2e3b7d 100644 --- a/tests/public-api/fixtures/server.ts +++ b/tests/public-api/fixtures/server.ts @@ -4,6 +4,10 @@ import { serveLocalApp } from '@altertable/data-app-bun'; import starterPage from '@/packages/data-app-examples/templates/starter-local-data-app/src/index.html'; import { operations } from '@/packages/data-app-examples/templates/starter-local-data-app/src/operations'; +import findingPage from '@/packages/data-app-examples/templates/starter-local-finding-data-app/src/index.html'; +import { operations as findingOperations } from '@/packages/data-app-examples/templates/starter-local-finding-data-app/src/operations'; +import metricPage from '@/packages/data-app-examples/templates/starter-local-metric-data-app/src/index.html'; +import { operations as metricOperations } from '@/packages/data-app-examples/templates/starter-local-metric-data-app/src/operations'; import annotationsHost from '@/tests/public-api/fixtures/annotations-host.html'; import appearance from '@/tests/public-api/fixtures/appearance.html'; import bridgeFrame from '@/tests/public-api/fixtures/bridge-frame.html'; @@ -61,6 +65,12 @@ const [ const { default: worker } = await import( import.meta.resolve('@altertable/data-app-worker') ); +const metricStarter = await bundle( + 'packages/data-app-examples/templates/starter-metric-data-app/index.tsx' +); +const findingStarter = await bundle( + 'packages/data-app-examples/templates/starter-finding-data-app/index.tsx' +); const database = new Database(':memory:'); process.env.ALTERTABLE_DATA_PROXY_TOKEN = 'fixture-token'; @@ -83,6 +93,8 @@ const server = Bun.serve({ '/date-axes': dateAxes, '/bundle-host': bundleHost, '/starter-data-app': bundleHost, + '/starter-finding-data-app': bundleHost, + '/starter-metric-data-app': bundleHost, '/bridge-host': bridgeHost, '/bridge-frame': bridgeFrame, '/inspection-app': inspectionApp, @@ -111,6 +123,8 @@ const server = Bun.serve({ return Response.json({ frameURL: frameServer.url.href, localURL: local.url.href, + metricLocalURL: metricLocal.url.href, + findingLocalURL: findingLocal.url.href, }); if (url.pathname === '/__test/annotations') return new Response(annotations); @@ -121,6 +135,10 @@ const server = Bun.serve({ if (url.pathname === '/__test/bundle') return new Response(app); if (url.pathname === '/__test/content-sizing') return new Response(contentSizing); + if (url.pathname === '/__test/starter-metric-data-app') + return new Response(metricStarter); + if (url.pathname === '/__test/starter-finding-data-app') + return new Response(findingStarter); if (url.pathname === '/__test/starter-data-app') return new Response(starter); if (url.pathname === '/__test/silent') @@ -141,6 +159,10 @@ const server = Bun.serve({ if (url.pathname === '/proxy/query') { if (request.headers.get('authorization') !== 'Bearer fixture-token') return new Response('Denied', { status: 403 }); + if ((await request.text()).includes('sample_counts')) + return new Response( + '{"query_id":"sample-query"}\n[{"name":"group_name","type":"VARCHAR"},{"name":"sample_count","type":"INTEGER"}]\n["Alpha",2200000]\n["Beta",0]\n' + ); return new Response( '{"query_id":"starter-query"}\n[{"name":"connection_check","type":"INTEGER"}]\n[1]\n' ); @@ -190,7 +212,24 @@ const local = serveLocalApp({ title: 'Local starter', port: 0, }); +const metricLocal = serveLocalApp({ + page: metricPage, + operations: metricOperations, + title: 'Local metric', + port: 0, +}); +const findingLocal = serveLocalApp({ + page: findingPage, + operations: findingOperations, + title: 'Local finding', + port: 0, +}); console.log( 'DATA_APP_TEST_SERVER ' + - JSON.stringify({ baseURL: server.url.href, localURL: local.url.href }) + JSON.stringify({ + baseURL: server.url.href, + localURL: local.url.href, + metricLocalURL: metricLocal.url.href, + findingLocalURL: findingLocal.url.href, + }) ); diff --git a/tests/public-api/manifest.test.ts b/tests/public-api/manifest.test.ts index cbadaee9..f8d1d066 100644 --- a/tests/public-api/manifest.test.ts +++ b/tests/public-api/manifest.test.ts @@ -300,10 +300,14 @@ test('preserves the shared Node and Bun archive consumer contract', async () => verifyManifestConsumer(); }); -test('extracts the documented hosted starter declaration', async () => { +test.each([ + ['starter-data-app', 'Sample counts'], + ['starter-metric-data-app', 'Sample metric'], + ['starter-finding-data-app', 'Sample finding'], +])('extracts the %s declaration', async (starter, title) => { const text = await readFile( new URL( - '../../packages/data-app-examples/templates/starter-data-app/index.tsx', + `../../packages/data-app-examples/templates/${starter}/index.tsx`, import.meta.url ), 'utf8' @@ -311,7 +315,7 @@ test('extracts the documented hosted starter declaration', async () => { const result = extractDataAppManifest(text); expect(result.diagnostics).toEqual([]); expect(result.manifest).toMatchObject({ - title: 'Sample counts', + title, scope: { organization: 'demo', environment: 'sample' }, queries: { sampleCountsByGroup: { diff --git a/tsconfig.json b/tsconfig.json index b2ef2821..bcb22c63 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,8 @@ "tests", "scripts", "dev", - "packages/data-app-examples/templates/starter-data-app/index.tsx" + "packages/data-app-examples/templates/starter-data-app/index.tsx", + "packages/data-app-examples/templates/starter-metric-data-app/index.tsx", + "packages/data-app-examples/templates/starter-finding-data-app/index.tsx" ] } From 3637df2b2194d4a889d1bc09bedde2e5a3e65195 Mon Sep 17 00:00:00 2001 From: robinvrd Date: Fri, 30 Oct 2026 12:03:02 +0100 Subject: [PATCH 2/6] feat: demonstrate metric drill-down and simplify finding starter --- .../starter-finding-data-app/index.tsx | 6 - .../src/App.tsx | 6 - .../starter-local-metric-data-app/app.ts | 17 +- .../starter-local-metric-data-app/src/App.tsx | 105 +++++++--- .../src/data-context.ts | 15 +- .../src/operations.ts | 49 ++++- .../starter-metric-data-app/index.tsx | 181 ++++++++++++++---- tests/public-api/apps-starters.test.ts | 43 +++++ tests/public-api/fixtures/server.ts | 49 ++++- 9 files changed, 382 insertions(+), 89 deletions(-) diff --git a/packages/data-app-examples/templates/starter-finding-data-app/index.tsx b/packages/data-app-examples/templates/starter-finding-data-app/index.tsx index aa8628f4..d6ab4c45 100644 --- a/packages/data-app-examples/templates/starter-finding-data-app/index.tsx +++ b/packages/data-app-examples/templates/starter-finding-data-app/index.tsx @@ -120,12 +120,6 @@ const sampleContent = sampleCountsView.content(result => ( /> )} - -

Limitations

-

- SQL VALUES are demonstration data and support no business conclusion. -

-
)); function App() { diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/src/App.tsx b/packages/data-app-examples/templates/starter-local-finding-data-app/src/App.tsx index f17e73a6..ef7c83fd 100644 --- a/packages/data-app-examples/templates/starter-local-finding-data-app/src/App.tsx +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/src/App.tsx @@ -66,12 +66,6 @@ const sampleContent = sampleCountsView.content(result => ( /> )} - -

Limitations

-

- SQL VALUES are demonstration data and support no business conclusion. -

-
)); export function App() { diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/app.ts b/packages/data-app-examples/templates/starter-local-metric-data-app/app.ts index 296eaba8..f9baf289 100644 --- a/packages/data-app-examples/templates/starter-local-metric-data-app/app.ts +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/app.ts @@ -5,13 +5,28 @@ export const dataApp = defineDataApp({ scope: { organization: 'demo', environment: 'sample' }, appearance: { theme: 'system' }, queries: { + sampleCountsBySegment: { + projections: { + segment_name: { type: 'string' }, + sample_count: { type: 'number' }, + }, + statement: ` +WITH sample_segments(group_name, segment_name, sample_count) AS (VALUES ('Alpha', 'Online', 1500000), ('Alpha', 'Retail', 700000), ('Beta', 'Partner', 600000)) +SELECT segment_name, SUM(sample_count) AS sample_count FROM sample_segments +WHERE $groupName = '' OR group_name = $groupName +GROUP BY segment_name ORDER BY segment_name LIMIT $limit`, + params: { + groupName: { type: 'string', defaultValue: '' }, + limit: { type: 'number', defaultValue: 10 }, + }, + }, sampleCountsByGroup: { projections: { group_name: { type: 'string' }, sample_count: { type: 'number' }, }, statement: ` -WITH sample_counts(group_name, sample_count) AS (VALUES ('Alpha', 2200000), ('Beta', 0)) +WITH sample_counts(group_name, sample_count) AS (VALUES ('Alpha', 2200000), ('Beta', 600000)) SELECT group_name, sample_count FROM sample_counts WHERE $groupName = '' OR group_name = $groupName ORDER BY group_name LIMIT $limit`, diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/src/App.tsx b/packages/data-app-examples/templates/starter-local-metric-data-app/src/App.tsx index ace28d4b..e51e3f66 100644 --- a/packages/data-app-examples/templates/starter-local-metric-data-app/src/App.tsx +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/App.tsx @@ -3,6 +3,8 @@ import { DataApp, DataSection, VisualizationWidget, + Stack, + choiceVariable, } from '@altertable/data-app-react'; import { BarChart } from '@altertable/data-app-react/ui'; import { createDataClient } from '@altertable/data-app/client'; @@ -14,8 +16,21 @@ const { defineDataView } = const sampleCountsView = defineDataView({ dataContext: sampleDataContext, operation: 'sampleCountsByGroup', - describeInput: () => 'fixture groups', - isEmpty: sampleCounts => sampleCounts.length === 0, + variables: { + groupName: choiceVariable({ + key: 'group', + label: 'Group', + defaultValue: 'all', + options: [ + { id: 'all', label: 'All groups' }, + { id: 'Alpha', label: 'Alpha' }, + { id: 'Beta', label: 'Beta' }, + ], + }), + }, + describeInput: ({ groupName }) => + groupName === 'all' ? 'all groups' : groupName, + isEmpty: result => result.groups.length === 0, emptyFallback: { title: 'No matching groups', description: 'No observations are available for this query.', @@ -23,7 +38,7 @@ const sampleCountsView = defineDataView({ }); const sampleCounts = sampleCountsView.dataset({ name: 'Sample counts', - select: rows => rows, + select: result => result.groups, rowKey: row => row.groupName, columns: { groupName: { label: 'Group', value: row => row.groupName }, @@ -34,27 +49,71 @@ const sampleCounts = sampleCountsView.dataset({ glossaryIds: ['sampleCount'], }, }); +const segments = sampleCountsView.dataset({ + name: 'Counts by segment', + select: result => result.segments, + rowKey: row => row.segmentName, + columns: { + segmentName: { label: 'Segment', value: row => row.segmentName }, + sampleCount: { value: row => row.sampleCount, format: { kind: 'count' } }, + }, + evidence: { id: 'counts-by-segment', glossaryIds: ['sampleCount'] }, +}); +const groupActions = sampleCounts.actions( + action => ({ + drill: action.filter({ + label: 'Drill into group', + target: sampleCountsView.filter(), + values: row => ({ groupName: row.groupName }), + }), + }), + { primary: 'drill' } +); +// Keep only the breakdowns and interactions relevant to the requested metric. const sampleContent = sampleCountsView.content(result => ( - - {(rows, actions) => ( - ({ - ...row, - id: row.groupName, - label: row.groupName, - value: row.sampleCount, - }))} - itemActions={actions} - /> - )} - + + + {(rows, actions) => ( + ({ + ...row, + id: row.groupName, + label: row.groupName, + value: row.sampleCount, + }))} + itemActions={actions} + /> + )} + + + {(rows, actions) => ( + ({ + ...row, + id: row.segmentName, + label: row.segmentName, + value: row.sampleCount, + }))} + itemActions={actions} + /> + )} + + )); export function App() { return ( diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/src/data-context.ts b/packages/data-app-examples/templates/starter-local-metric-data-app/src/data-context.ts index 6e29aaa8..7b0d873e 100644 --- a/packages/data-app-examples/templates/starter-local-metric-data-app/src/data-context.ts +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/data-context.ts @@ -1,14 +1,19 @@ import { createDataContext } from '@altertable/data-app-react'; -const queryNames = { sampleCountsByGroup: 'sampleCountsByGroup' } as const; +const queryNames = { + sampleCountsByGroup: 'sampleCountsByGroup', + sampleCountsBySegment: 'sampleCountsBySegment', +} as const; export const sampleDataContext = createDataContext(queryNames)({ description: - 'Two SQL VALUES rows demonstrate the host query path. Replace them with inspected source data before publishing findings.', + 'SQL VALUES demonstrate group and segment breakdowns. Replace them with inspected source data before publishing findings.', glossary: { sampleCount: { term: 'Sample count', - definition: - 'A fixture value: Alpha is 2,200,000 and Beta is a measured zero.', - queryNames: [queryNames.sampleCountsByGroup], + definition: 'A fixture value: Alpha is 2,200,000 and Beta is 600,000.', + queryNames: [ + queryNames.sampleCountsByGroup, + queryNames.sampleCountsBySegment, + ], }, }, }); diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/src/operations.ts b/packages/data-app-examples/templates/starter-local-metric-data-app/src/operations.ts index af9f2a08..f6d0f052 100644 --- a/packages/data-app-examples/templates/starter-local-metric-data-app/src/operations.ts +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/operations.ts @@ -3,15 +3,48 @@ import { parseCount } from '@altertable/data-app/contract'; import { dataApp } from '#config'; export const operations = { sampleCountsByGroup: dataApp.defineOperation({ - input: () => ({}), - checks: [{}], + input: value => { + if ( + !value || + typeof value !== 'object' || + !('groupName' in value) || + typeof value.groupName !== 'string' || + !['all', 'Alpha', 'Beta'].includes(value.groupName) + ) + throw new Error('Expected a fixture group.'); + return { groupName: value.groupName }; + }, + checks: [ + { groupName: 'all' }, + { groupName: 'Alpha' }, + { groupName: 'Beta' }, + ], policy: { maxQueryRows: 10, maxDurationMs: 15000 }, - async run({ query }) { - const result = await query('sampleCountsByGroup', {}); - return result.rows.map(row => ({ - groupName: row.group_name, - sampleCount: parseCount(row.sample_count), - })); + async run({ query }, { groupName }) { + const [groups, segments] = await Promise.all([ + query('sampleCountsByGroup', { + groupName: groupName === 'all' ? '' : groupName, + }), + query('sampleCountsBySegment', { + groupName: groupName === 'all' ? '' : groupName, + }), + ]); + return { + groups: groups.rows.map( + (row): { groupName: 'Alpha' | 'Beta'; sampleCount: number } => { + if (row.group_name !== 'Alpha' && row.group_name !== 'Beta') + throw new Error('Unexpected fixture group.'); + return { + groupName: row.group_name, + sampleCount: parseCount(row.sample_count), + }; + } + ), + segments: segments.rows.map(row => ({ + segmentName: row.segment_name, + sampleCount: parseCount(row.sample_count), + })), + }; }, }), }; diff --git a/packages/data-app-examples/templates/starter-metric-data-app/index.tsx b/packages/data-app-examples/templates/starter-metric-data-app/index.tsx index ee48a9a0..da13e873 100644 --- a/packages/data-app-examples/templates/starter-metric-data-app/index.tsx +++ b/packages/data-app-examples/templates/starter-metric-data-app/index.tsx @@ -9,6 +9,8 @@ import { DataApp, DataSection, VisualizationWidget, + Stack, + choiceVariable, } from '@altertable/data-app-react'; import { BarChart } from '@altertable/data-app-react/ui'; import { createDataClient } from '@altertable/data-app/client'; @@ -19,13 +21,28 @@ const dataApp = defineDataApp({ scope: { organization: 'demo', environment: 'sample' }, appearance: { theme: 'system' }, queries: { + sampleCountsBySegment: { + projections: { + segment_name: { type: 'string' }, + sample_count: { type: 'number' }, + }, + statement: ` +WITH sample_segments(group_name, segment_name, sample_count) AS (VALUES ('Alpha', 'Online', 1500000), ('Alpha', 'Retail', 700000), ('Beta', 'Partner', 600000)) +SELECT segment_name, SUM(sample_count) AS sample_count FROM sample_segments +WHERE $groupName = '' OR group_name = $groupName +GROUP BY segment_name ORDER BY segment_name LIMIT $limit`, + params: { + groupName: { type: 'string', defaultValue: '' }, + limit: { type: 'number', defaultValue: 10 }, + }, + }, sampleCountsByGroup: { projections: { group_name: { type: 'string' }, sample_count: { type: 'number' }, }, statement: ` -WITH sample_counts(group_name, sample_count) AS (VALUES ('Alpha', 2200000), ('Beta', 0)) +WITH sample_counts(group_name, sample_count) AS (VALUES ('Alpha', 2200000), ('Beta', 600000)) SELECT group_name, sample_count FROM sample_counts WHERE $groupName = '' OR group_name = $groupName ORDER BY group_name LIMIT $limit`, @@ -38,28 +55,63 @@ ORDER BY group_name LIMIT $limit`, }); const operations = { sampleCountsByGroup: dataApp.defineOperation({ - input: () => ({}), - checks: [{}], + input: value => { + if ( + !value || + typeof value !== 'object' || + !('groupName' in value) || + typeof value.groupName !== 'string' || + !['all', 'Alpha', 'Beta'].includes(value.groupName) + ) + throw new Error('Expected a fixture group.'); + return { groupName: value.groupName }; + }, + checks: [ + { groupName: 'all' }, + { groupName: 'Alpha' }, + { groupName: 'Beta' }, + ], policy: { maxQueryRows: 10, maxDurationMs: 15000 }, - async run({ query }) { - const result = await query('sampleCountsByGroup', {}); - return result.rows.map(row => ({ - groupName: row.group_name, - sampleCount: parseCount(row.sample_count), - })); + async run({ query }, { groupName }) { + const [groups, segments] = await Promise.all([ + query('sampleCountsByGroup', { + groupName: groupName === 'all' ? '' : groupName, + }), + query('sampleCountsBySegment', { + groupName: groupName === 'all' ? '' : groupName, + }), + ]); + return { + groups: groups.rows.map( + (row): { groupName: 'Alpha' | 'Beta'; sampleCount: number } => { + if (row.group_name !== 'Alpha' && row.group_name !== 'Beta') + throw new Error('Unexpected fixture group.'); + return { + groupName: row.group_name, + sampleCount: parseCount(row.sample_count), + }; + } + ), + segments: segments.rows.map(row => ({ + segmentName: row.segment_name, + sampleCount: parseCount(row.sample_count), + })), + }; }, }), }; const queryNames = operations.sampleCountsByGroup.queryNames; const sampleDataContext = createDataContext(queryNames)({ description: - 'Two SQL VALUES rows demonstrate the host query path. Replace them with inspected source data before publishing findings.', + 'SQL VALUES demonstrate group and segment breakdowns. Replace them with inspected source data before publishing findings.', glossary: { sampleCount: { term: 'Sample count', - definition: - 'A fixture value: Alpha is 2,200,000 and Beta is a measured zero.', - queryNames: [queryNames.sampleCountsByGroup], + definition: 'A fixture value: Alpha is 2,200,000 and Beta is 600,000.', + queryNames: [ + queryNames.sampleCountsByGroup, + queryNames.sampleCountsBySegment, + ], }, }, }); @@ -67,8 +119,21 @@ const { defineDataView } = createDataHooks(createDataClient({ operations })); const sampleCountsView = defineDataView({ dataContext: sampleDataContext, operation: 'sampleCountsByGroup', - describeInput: () => 'fixture groups', - isEmpty: sampleCounts => sampleCounts.length === 0, + variables: { + groupName: choiceVariable({ + key: 'group', + label: 'Group', + defaultValue: 'all', + options: [ + { id: 'all', label: 'All groups' }, + { id: 'Alpha', label: 'Alpha' }, + { id: 'Beta', label: 'Beta' }, + ], + }), + }, + describeInput: ({ groupName }) => + groupName === 'all' ? 'all groups' : groupName, + isEmpty: result => result.groups.length === 0, emptyFallback: { title: 'No matching groups', description: 'No observations are available for this query.', @@ -76,7 +141,7 @@ const sampleCountsView = defineDataView({ }); const sampleCounts = sampleCountsView.dataset({ name: 'Sample counts', - select: rows => rows, + select: result => result.groups, rowKey: row => row.groupName, columns: { groupName: { label: 'Group', value: row => row.groupName }, @@ -87,27 +152,71 @@ const sampleCounts = sampleCountsView.dataset({ glossaryIds: ['sampleCount'], }, }); +const segments = sampleCountsView.dataset({ + name: 'Counts by segment', + select: result => result.segments, + rowKey: row => row.segmentName, + columns: { + segmentName: { label: 'Segment', value: row => row.segmentName }, + sampleCount: { value: row => row.sampleCount, format: { kind: 'count' } }, + }, + evidence: { id: 'counts-by-segment', glossaryIds: ['sampleCount'] }, +}); +const groupActions = sampleCounts.actions( + action => ({ + drill: action.filter({ + label: 'Drill into group', + target: sampleCountsView.filter(), + values: row => ({ groupName: row.groupName }), + }), + }), + { primary: 'drill' } +); +// Keep only the breakdowns and interactions relevant to the requested metric. const sampleContent = sampleCountsView.content(result => ( - - {(rows, actions) => ( - ({ - ...row, - id: row.groupName, - label: row.groupName, - value: row.sampleCount, - }))} - itemActions={actions} - /> - )} - + + + {(rows, actions) => ( + ({ + ...row, + id: row.groupName, + label: row.groupName, + value: row.sampleCount, + }))} + itemActions={actions} + /> + )} + + + {(rows, actions) => ( + ({ + ...row, + id: row.segmentName, + label: row.segmentName, + value: row.sampleCount, + }))} + itemActions={actions} + /> + )} + + )); function App() { return ( diff --git a/tests/public-api/apps-starters.test.ts b/tests/public-api/apps-starters.test.ts index fa653dc4..93f6accc 100644 --- a/tests/public-api/apps-starters.test.ts +++ b/tests/public-api/apps-starters.test.ts @@ -41,6 +41,49 @@ for (const kind of ['metric', 'finding'] as const) { expect( await app.getByRole('heading', { name: 'Supporting evidence' }).count() ).toBe(kind === 'finding' ? 1 : 0); + expect( + await app + .getByRole('heading', { name: 'Limitations', exact: true }) + .count() + ).toBe(0); + if (kind === 'metric') { + const main = app.getByRole('region', { + name: 'Sample count', + exact: true, + }); + const secondary = app.getByRole('region', { + name: 'Samples by segment', + exact: true, + }); + await expect + .poll(() => + secondary.getByText('Retail', { exact: true }).isVisible() + ) + .toBe(true); + await main + .getByRole('button', { name: /^Beta:.*Drill into group/ }) + .click(); + await expect + .poll(() => main.getByText('Alpha', { exact: true }).count()) + .toBe(0); + await expect + .poll(() => secondary.getByText('Retail', { exact: true }).count()) + .toBe(0); + await app + .getByRole('button', { name: 'Beta Group', exact: true }) + .click(); + await app + .getByRole('option', { name: 'All groups', exact: true }) + .click(); + await expect + .poll(() => main.getByText('Alpha', { exact: true }).isVisible()) + .toBe(true); + await expect + .poll(() => + secondary.getByText('Retail', { exact: true }).isVisible() + ) + .toBe(true); + } expect(errors).toEqual([]); }); } diff --git a/tests/public-api/fixtures/server.ts b/tests/public-api/fixtures/server.ts index 4b2e3b7d..d8fc47d1 100644 --- a/tests/public-api/fixtures/server.ts +++ b/tests/public-api/fixtures/server.ts @@ -159,10 +159,40 @@ const server = Bun.serve({ if (url.pathname === '/proxy/query') { if (request.headers.get('authorization') !== 'Bearer fixture-token') return new Response('Denied', { status: 403 }); - if ((await request.text()).includes('sample_counts')) + const { statement, params } = (await request.json()) as { + statement: string; + params?: Record; + }; + if ( + statement.includes('sample_counts') || + statement.includes('sample_segments') + ) { + const query = database.query(statement); + const rows = query.values( + Object.fromEntries( + Object.entries(params ?? {}).map(([key, value]) => [ + `$${key}`, + value, + ]) + ) + ); + const columns = [ + { + name: statement.includes('sample_segments') + ? 'segment_name' + : 'group_name', + type: 'VARCHAR', + }, + { name: 'sample_count', type: 'INTEGER' }, + ]; return new Response( - '{"query_id":"sample-query"}\n[{"name":"group_name","type":"VARCHAR"},{"name":"sample_count","type":"INTEGER"}]\n["Alpha",2200000]\n["Beta",0]\n' + [ + JSON.stringify({ query_id: 'sample-query' }), + JSON.stringify(columns), + ...rows.map(row => JSON.stringify(row)), + ].join('\n') + '\n' ); + } return new Response( '{"query_id":"starter-query"}\n[{"name":"connection_check","type":"INTEGER"}]\n[1]\n' ); @@ -173,9 +203,20 @@ const server = Bun.serve({ limit: number; params?: Record; }; - if (statement.trim().startsWith('WITH sample_counts(') && limit === 10) + if ( + (statement.trim().startsWith('WITH sample_counts(') || + statement.trim().startsWith('WITH sample_segments(')) && + limit === 10 + ) return Response.json({ - columns: [{ name: 'group_name' }, { name: 'sample_count' }], + columns: [ + { + name: statement.includes('sample_segments') + ? 'segment_name' + : 'group_name', + }, + { name: 'sample_count' }, + ], rows: database .query(statement) .values( From bd970a709fbd7280c42df31cdceede383980ac9d Mon Sep 17 00:00:00 2001 From: robinvrd Date: Fri, 30 Oct 2026 13:23:27 +0100 Subject: [PATCH 3/6] feat: exercise starters against semantic mock data --- dev/fixtures/bundle-host.tsx | 2 + dev/server.ts | 31 +++++++ docs/local-data-apps.md | 24 ++++++ .../starter-finding-data-app/index.tsx | 80 ++++++++++++++----- .../starter-local-finding-data-app/README.md | 5 ++ .../starter-local-finding-data-app/app.ts | 6 +- .../src/App.tsx | 69 +++++++++++++--- .../src/data-context.ts | 5 +- .../starter-local-metric-data-app/README.md | 5 ++ .../starter-local-metric-data-app/app.ts | 16 ++-- .../src/data-context.ts | 2 +- .../starter-metric-data-app/index.tsx | 18 ++--- scripts/dev-gallery.ts | 10 ++- tests/fixtures/starter-mock.ts | 30 +++++++ tests/public-api/apps-starters.test.ts | 46 ++++++++++- tests/public-api/browser.ts | 79 +++++++++++------- tests/public-api/fixtures/server.ts | 27 ++++++- vitest.config.ts | 9 +++ 18 files changed, 376 insertions(+), 88 deletions(-) create mode 100644 tests/fixtures/starter-mock.ts diff --git a/dev/fixtures/bundle-host.tsx b/dev/fixtures/bundle-host.tsx index 32122ec3..418170a9 100644 --- a/dev/fixtures/bundle-host.tsx +++ b/dev/fixtures/bundle-host.tsx @@ -69,6 +69,8 @@ if (isPlayground) document.title = `${isContentSizing ? 'Content sizing' : 'Playground'} · Altertable`; const appPreview = [ '/starter-data-app', + '/starter-metric-data-app', + '/starter-finding-data-app', '/playground', '/content-sizing', ].includes(location.pathname); diff --git a/dev/server.ts b/dev/server.ts index 3818f33b..19b48da5 100644 --- a/dev/server.ts +++ b/dev/server.ts @@ -24,6 +24,10 @@ import styles from '@/dev/fixtures/styles.html'; import { dataApp as starterApp } from '@/packages/data-app-examples/templates/starter-local-data-app/app'; import starterPage from '@/packages/data-app-examples/templates/starter-local-data-app/src/index.html'; import { operations as starterOperations } from '@/packages/data-app-examples/templates/starter-local-data-app/src/operations'; +import findingStarterPage from '@/packages/data-app-examples/templates/starter-local-finding-data-app/src/index.html'; +import { operations as findingStarterOperations } from '@/packages/data-app-examples/templates/starter-local-finding-data-app/src/operations'; +import metricStarterPage from '@/packages/data-app-examples/templates/starter-local-metric-data-app/src/index.html'; +import { operations as metricStarterOperations } from '@/packages/data-app-examples/templates/starter-local-metric-data-app/src/operations'; async function bundle(entry: string) { const result = await Bun.build({ @@ -93,6 +97,12 @@ const hostedApps = { }, }; const hostedApp = hostedApps[isDevelopment ? 'development' : 'test']; +const loadMetricStarterBundle = await createBundleLoader( + '../packages/data-app-examples/templates/starter-metric-data-app/index.tsx' +); +const loadFindingStarterBundle = await createBundleLoader( + '../packages/data-app-examples/templates/starter-finding-data-app/index.tsx' +); const loadHostedAppBundle = await createBundleLoader(hostedApp.entry); const loadPlaygroundBundle = isDevelopment ? loadHostedAppBundle @@ -101,6 +111,7 @@ const serveReloadEvents = isDevelopment && createReloadHandler([ '../dev', + '../packages/data-app-examples/templates', './fixtures/bundle-app.tsx', './fixtures/bridge-frame.ts', ]); @@ -120,6 +131,20 @@ serveLocalApp({ title: starterApp.title, port: port + 2, }); +if (isDevelopment) + serveLocalApp({ + page: metricStarterPage, + operations: metricStarterOperations, + title: 'Local metric starter', + port: port + 3, + }); +if (isDevelopment) + serveLocalApp({ + page: findingStarterPage, + operations: findingStarterOperations, + title: 'Local finding starter', + port: port + 4, + }); const loadFrameBundle = await createBundleLoader('./fixtures/bridge-frame.ts'); Bun.serve({ hostname: '127.0.0.1', @@ -153,6 +178,8 @@ Bun.serve({ '/playground': bundleHost, '/content-sizing': bundleHost, [hostedApp.path]: bundleHost, + '/starter-finding-data-app': bundleHost, + '/starter-metric-data-app': bundleHost, }, async fetch(request, server) { const path = new URL(request.url).pathname; @@ -204,6 +231,10 @@ Bun.serve({ } if (path === '/__test/content-sizing') return new Response(await loadHostedAppBundle()); + if (path === '/__test/starter-metric-data-app') + return new Response(await loadMetricStarterBundle()); + if (path === '/__test/starter-finding-data-app') + return new Response(await loadFindingStarterBundle()); if (path === '/__test/playground') return new Response(await loadPlaygroundBundle()); if (path === `/__test${hostedApp.path}`) diff --git a/docs/local-data-apps.md b/docs/local-data-apps.md index e38af81a..d9c724c2 100644 --- a/docs/local-data-apps.md +++ b/docs/local-data-apps.md @@ -13,3 +13,27 @@ does not require a particular content structure or presentation story. | Customize the browser client | [HTTP client](client.md#http-operations) | You can also [convert the local app to a hosted data app](hosted-apps.md#convert-a-local-data-app). + +## Preview the use-case starters + +From the SDK repository, run `bun run dev:gallery` with Docker available. It starts +and seeds the Altertable mock with fictional sample rows and the +`starter_sample_counts` semantic view. The mock image includes DuckDB 2 and the +`semantic_layer` extension; an older cached image is not sufficient. + +| Starter | Hosted preview | Local preview | +| ------- | ------------------------------------------------- | ------------------------ | +| Metric | | | +| Finding | | | + +The metric starter queries `SEMANTIC_VIEW` for group and segment breakdowns. +Its group action filters both charts; choose All groups to reset. The Finding +starter uses ordinary SQL over the same sample rows to support prose, inline +values, a compact ranking, and a table. Its example headings are not a prescribed +Finding structure. + +Copied starters use the normal query connection. Replace their example queries +with verified sources in the target lakehouse; the mock and seed belong only to +development and tests. Never create demo objects in a user's lakehouse. +Run `bun run test --project starters` to verify both runtimes against a fresh, +seeded mock, including semantic queries, drill-down, reset, and displayed evidence. diff --git a/packages/data-app-examples/templates/starter-finding-data-app/index.tsx b/packages/data-app-examples/templates/starter-finding-data-app/index.tsx index d6ab4c45..68b8d9bb 100644 --- a/packages/data-app-examples/templates/starter-finding-data-app/index.tsx +++ b/packages/data-app-examples/templates/starter-finding-data-app/index.tsx @@ -9,10 +9,12 @@ import { DataApp, DataSection, VisualizationWidget, + DataValue, + TableWidget, + Ranking, Stack, TextContent, } from '@altertable/data-app-react'; -import { BarChart } from '@altertable/data-app-react/ui'; import { createDataClient } from '@altertable/data-app/client'; import { parseCount } from '@altertable/data-app/contract'; const dataApp = defineDataApp({ @@ -28,10 +30,10 @@ const dataApp = defineDataApp({ sample_count: { type: 'number' }, }, statement: ` -WITH sample_counts(group_name, sample_count) AS (VALUES ('Alpha', 2200000), ('Beta', 0)) -SELECT group_name, sample_count FROM sample_counts +SELECT group_name, SUM(sample_count) AS sample_count +FROM starter_demo.sample_counts WHERE $groupName = '' OR group_name = $groupName -ORDER BY group_name LIMIT $limit`, +GROUP BY group_name ORDER BY sample_count DESC LIMIT $limit`, params: { groupName: { type: 'string', defaultValue: '' }, limit: { type: 'number', defaultValue: 10 }, @@ -56,12 +58,11 @@ const operations = { const queryNames = operations.sampleCountsByGroup.queryNames; const sampleDataContext = createDataContext(queryNames)({ description: - 'Two SQL VALUES rows demonstrate the host query path. Replace them with inspected source data before publishing findings.', + 'Fictional sample volumes grouped from starter_demo.sample_counts. Replace this evidence with the actual Finding queries.', glossary: { sampleCount: { term: 'Sample count', - definition: - 'A fixture value: Alpha is 2,200,000 and Beta is a measured zero.', + definition: 'A fixture value: Alpha is 2,200,000 and Beta is 600,000.', queryNames: [queryNames.sampleCountsByGroup], }, }, @@ -90,36 +91,79 @@ const sampleCounts = sampleCountsView.dataset({ glossaryIds: ['sampleCount'], }, }); +const totalSamples = sampleCountsView.metric( + { + id: 'total-samples', + label: 'Total samples', + glossaryId: 'sampleCount', + format: { kind: 'count' }, + }, + rows => ({ current: rows.reduce((sum, row) => sum + row.sampleCount, 0) }) +); +const alphaSamples = sampleCountsView.metric( + { + id: 'alpha-samples', + label: 'Alpha samples', + glossaryId: 'sampleCount', + format: { kind: 'count' }, + }, + rows => { + const alpha = rows.find(row => row.groupName === 'Alpha'); + if (!alpha) + throw new Error('The sample Finding requires evidence for Alpha.'); + return { current: alpha.sampleCount }; + } +); +// This fictional Finding demonstrates prose, inline evidence, and a Markdown-table equivalent. +// Preserve each real Finding's own headings and argument instead of reusing these sections. const sampleContent = sampleCountsView.content(result => ( -

Supporting evidence

+

Contribution by group

- These fixture rows demonstrate how a Finding can include a small - supporting visual. Replace this section with the Finding's own argument - and evidence. + Alpha contributed{' '} + of the{' '} + recorded samples. + The group comparison below supports that concentration; it does not + explain its cause.

- {(rows, actions) => ( - ( + ({ - ...row, id: row.groupName, label: row.groupName, value: row.sampleCount, }))} - itemActions={actions} /> )} + +

Supporting evidence

+

+ The source rows are grouped by group_name and summed using{' '} + sample_count. The table retains the exact values behind the + comparison. +

+
    +
  • Compare the groups within the same sample population.
  • +
  • + Volume alone does not establish performance or causality. +
  • +
+
+
)); function App() { diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/README.md b/packages/data-app-examples/templates/starter-local-finding-data-app/README.md index f3f40655..39e80ff6 100644 --- a/packages/data-app-examples/templates/starter-local-finding-data-app/README.md +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/README.md @@ -4,6 +4,11 @@ Use `node_modules/@altertable/data-app/docs/app-authoring.md` for this SDK versi Otherwise, read the [version-matched authoring recipe](https://github.com/altertable-ai/data-app/blob/v0.74.0/docs/app-authoring.md). See [AGENTS.md](AGENTS.md) for app-owned files. +The example queries require the seeded sample data described in the +[local preview guide](https://github.com/altertable-ai/data-app/blob/v0.74.0/docs/local-data-apps.md#preview-the-use-case-starters). +Use that mock for development, or replace the example queries with verified data +in your lakehouse. The starter does not install a mock or create demo tables. + ## Run the app From the copied starter directory, install dependencies and set server-only diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/app.ts b/packages/data-app-examples/templates/starter-local-finding-data-app/app.ts index d3a284ed..c4eba4cf 100644 --- a/packages/data-app-examples/templates/starter-local-finding-data-app/app.ts +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/app.ts @@ -12,10 +12,10 @@ export const dataApp = defineDataApp({ sample_count: { type: 'number' }, }, statement: ` -WITH sample_counts(group_name, sample_count) AS (VALUES ('Alpha', 2200000), ('Beta', 0)) -SELECT group_name, sample_count FROM sample_counts +SELECT group_name, SUM(sample_count) AS sample_count +FROM starter_demo.sample_counts WHERE $groupName = '' OR group_name = $groupName -ORDER BY group_name LIMIT $limit`, +GROUP BY group_name ORDER BY sample_count DESC LIMIT $limit`, params: { groupName: { type: 'string', defaultValue: '' }, limit: { type: 'number', defaultValue: 10 }, diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/src/App.tsx b/packages/data-app-examples/templates/starter-local-finding-data-app/src/App.tsx index ef7c83fd..744daeae 100644 --- a/packages/data-app-examples/templates/starter-local-finding-data-app/src/App.tsx +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/src/App.tsx @@ -3,10 +3,12 @@ import { DataApp, DataSection, VisualizationWidget, + DataValue, + TableWidget, + Ranking, Stack, TextContent, } from '@altertable/data-app-react'; -import { BarChart } from '@altertable/data-app-react/ui'; import { createDataClient } from '@altertable/data-app/client'; import { sampleDataContext } from '#app/data-context.ts'; @@ -36,36 +38,79 @@ const sampleCounts = sampleCountsView.dataset({ glossaryIds: ['sampleCount'], }, }); +const totalSamples = sampleCountsView.metric( + { + id: 'total-samples', + label: 'Total samples', + glossaryId: 'sampleCount', + format: { kind: 'count' }, + }, + rows => ({ current: rows.reduce((sum, row) => sum + row.sampleCount, 0) }) +); +const alphaSamples = sampleCountsView.metric( + { + id: 'alpha-samples', + label: 'Alpha samples', + glossaryId: 'sampleCount', + format: { kind: 'count' }, + }, + rows => { + const alpha = rows.find(row => row.groupName === 'Alpha'); + if (!alpha) + throw new Error('The sample Finding requires evidence for Alpha.'); + return { current: alpha.sampleCount }; + } +); +// This fictional Finding demonstrates prose, inline evidence, and a Markdown-table equivalent. +// Preserve each real Finding's own headings and argument instead of reusing these sections. const sampleContent = sampleCountsView.content(result => ( -

Supporting evidence

+

Contribution by group

- These fixture rows demonstrate how a Finding can include a small - supporting visual. Replace this section with the Finding's own argument - and evidence. + Alpha contributed{' '} + of the{' '} + recorded samples. + The group comparison below supports that concentration; it does not + explain its cause.

- {(rows, actions) => ( - ( + ({ - ...row, id: row.groupName, label: row.groupName, value: row.sampleCount, }))} - itemActions={actions} /> )} + +

Supporting evidence

+

+ The source rows are grouped by group_name and summed using{' '} + sample_count. The table retains the exact values behind the + comparison. +

+
    +
  • Compare the groups within the same sample population.
  • +
  • + Volume alone does not establish performance or causality. +
  • +
+
+
)); export function App() { diff --git a/packages/data-app-examples/templates/starter-local-finding-data-app/src/data-context.ts b/packages/data-app-examples/templates/starter-local-finding-data-app/src/data-context.ts index 6e29aaa8..4f0ebd05 100644 --- a/packages/data-app-examples/templates/starter-local-finding-data-app/src/data-context.ts +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/src/data-context.ts @@ -2,12 +2,11 @@ import { createDataContext } from '@altertable/data-app-react'; const queryNames = { sampleCountsByGroup: 'sampleCountsByGroup' } as const; export const sampleDataContext = createDataContext(queryNames)({ description: - 'Two SQL VALUES rows demonstrate the host query path. Replace them with inspected source data before publishing findings.', + 'Fictional sample volumes grouped from starter_demo.sample_counts. Replace this evidence with the actual Finding queries.', glossary: { sampleCount: { term: 'Sample count', - definition: - 'A fixture value: Alpha is 2,200,000 and Beta is a measured zero.', + definition: 'A fixture value: Alpha is 2,200,000 and Beta is 600,000.', queryNames: [queryNames.sampleCountsByGroup], }, }, diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/README.md b/packages/data-app-examples/templates/starter-local-metric-data-app/README.md index e975a0e1..ca073ab3 100644 --- a/packages/data-app-examples/templates/starter-local-metric-data-app/README.md +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/README.md @@ -4,6 +4,11 @@ Use `node_modules/@altertable/data-app/docs/app-authoring.md` for this SDK versi Otherwise, read the [version-matched authoring recipe](https://github.com/altertable-ai/data-app/blob/v0.74.0/docs/app-authoring.md). See [AGENTS.md](AGENTS.md) for app-owned files. +The example queries require the seeded sample data described in the +[local preview guide](https://github.com/altertable-ai/data-app/blob/v0.74.0/docs/local-data-apps.md#preview-the-use-case-starters). +Use that mock for development, or replace the example queries with verified data +in your lakehouse. The starter does not install a mock or create demo tables. + ## Run the app From the copied starter directory, install dependencies and set server-only diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/app.ts b/packages/data-app-examples/templates/starter-local-metric-data-app/app.ts index f9baf289..beb7766b 100644 --- a/packages/data-app-examples/templates/starter-local-metric-data-app/app.ts +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/app.ts @@ -11,10 +11,10 @@ export const dataApp = defineDataApp({ sample_count: { type: 'number' }, }, statement: ` -WITH sample_segments(group_name, segment_name, sample_count) AS (VALUES ('Alpha', 'Online', 1500000), ('Alpha', 'Retail', 700000), ('Beta', 'Partner', 600000)) -SELECT segment_name, SUM(sample_count) AS sample_count FROM sample_segments -WHERE $groupName = '' OR group_name = $groupName -GROUP BY segment_name ORDER BY segment_name LIMIT $limit`, +SELECT segment_name, sample_count FROM SEMANTIC_VIEW( + starter_sample_counts DIMENSIONS samples.segment_name METRICS samples.sample_count + WHERE $groupName = '' OR samples.group_name = $groupName +) ORDER BY segment_name LIMIT $limit`, params: { groupName: { type: 'string', defaultValue: '' }, limit: { type: 'number', defaultValue: 10 }, @@ -26,10 +26,10 @@ GROUP BY segment_name ORDER BY segment_name LIMIT $limit`, sample_count: { type: 'number' }, }, statement: ` -WITH sample_counts(group_name, sample_count) AS (VALUES ('Alpha', 2200000), ('Beta', 600000)) -SELECT group_name, sample_count FROM sample_counts -WHERE $groupName = '' OR group_name = $groupName -ORDER BY group_name LIMIT $limit`, +SELECT group_name, sample_count FROM SEMANTIC_VIEW( + starter_sample_counts DIMENSIONS samples.group_name METRICS samples.sample_count + WHERE $groupName = '' OR samples.group_name = $groupName +) ORDER BY group_name LIMIT $limit`, params: { groupName: { type: 'string', defaultValue: '' }, limit: { type: 'number', defaultValue: 10 }, diff --git a/packages/data-app-examples/templates/starter-local-metric-data-app/src/data-context.ts b/packages/data-app-examples/templates/starter-local-metric-data-app/src/data-context.ts index 7b0d873e..1ef9b431 100644 --- a/packages/data-app-examples/templates/starter-local-metric-data-app/src/data-context.ts +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/data-context.ts @@ -5,7 +5,7 @@ const queryNames = { } as const; export const sampleDataContext = createDataContext(queryNames)({ description: - 'SQL VALUES demonstrate group and segment breakdowns. Replace them with inspected source data before publishing findings.', + 'The starter_sample_counts semantic view measures fictional samples by group and segment. Replace it with the requested semantic metric before publishing.', glossary: { sampleCount: { term: 'Sample count', diff --git a/packages/data-app-examples/templates/starter-metric-data-app/index.tsx b/packages/data-app-examples/templates/starter-metric-data-app/index.tsx index da13e873..4da7bd40 100644 --- a/packages/data-app-examples/templates/starter-metric-data-app/index.tsx +++ b/packages/data-app-examples/templates/starter-metric-data-app/index.tsx @@ -27,10 +27,10 @@ const dataApp = defineDataApp({ sample_count: { type: 'number' }, }, statement: ` -WITH sample_segments(group_name, segment_name, sample_count) AS (VALUES ('Alpha', 'Online', 1500000), ('Alpha', 'Retail', 700000), ('Beta', 'Partner', 600000)) -SELECT segment_name, SUM(sample_count) AS sample_count FROM sample_segments -WHERE $groupName = '' OR group_name = $groupName -GROUP BY segment_name ORDER BY segment_name LIMIT $limit`, +SELECT segment_name, sample_count FROM SEMANTIC_VIEW( + starter_sample_counts DIMENSIONS samples.segment_name METRICS samples.sample_count + WHERE $groupName = '' OR samples.group_name = $groupName +) ORDER BY segment_name LIMIT $limit`, params: { groupName: { type: 'string', defaultValue: '' }, limit: { type: 'number', defaultValue: 10 }, @@ -42,10 +42,10 @@ GROUP BY segment_name ORDER BY segment_name LIMIT $limit`, sample_count: { type: 'number' }, }, statement: ` -WITH sample_counts(group_name, sample_count) AS (VALUES ('Alpha', 2200000), ('Beta', 600000)) -SELECT group_name, sample_count FROM sample_counts -WHERE $groupName = '' OR group_name = $groupName -ORDER BY group_name LIMIT $limit`, +SELECT group_name, sample_count FROM SEMANTIC_VIEW( + starter_sample_counts DIMENSIONS samples.group_name METRICS samples.sample_count + WHERE $groupName = '' OR samples.group_name = $groupName +) ORDER BY group_name LIMIT $limit`, params: { groupName: { type: 'string', defaultValue: '' }, limit: { type: 'number', defaultValue: 10 }, @@ -103,7 +103,7 @@ const operations = { const queryNames = operations.sampleCountsByGroup.queryNames; const sampleDataContext = createDataContext(queryNames)({ description: - 'SQL VALUES demonstrate group and segment breakdowns. Replace them with inspected source data before publishing findings.', + 'The starter_sample_counts semantic view measures fictional samples by group and segment. Replace it with the requested semantic metric before publishing.', glossary: { sampleCount: { term: 'Sample count', diff --git a/scripts/dev-gallery.ts b/scripts/dev-gallery.ts index 736c8ff5..ae04b262 100644 --- a/scripts/dev-gallery.ts +++ b/scripts/dev-gallery.ts @@ -5,12 +5,16 @@ import { GenericContainer, Wait } from 'testcontainers'; import { seedStatements } from '@/dev/seed'; import { packages } from '@/scripts/packages'; +import { + starterMockImage, + starterSeedStatements, +} from '@/tests/fixtures/starter-mock'; const root = resolve(import.meta.dir, '..'); const mockApiPort = 15000; async function seedMockApi(apiUrl: string) { - for (const statement of seedStatements) { + for (const statement of [...seedStatements, ...starterSeedStatements]) { const response = await fetch(`${apiUrl}/query`, { method: 'POST', headers: { @@ -28,9 +32,7 @@ async function seedMockApi(apiUrl: string) { async function startMockApi() { try { - const container = await new GenericContainer( - 'ghcr.io/altertable-ai/altertable-mock:latest' - ) + const container = await new GenericContainer(starterMockImage) .withEnvironment({ ALTERTABLE_MOCK_USERS: 'dev:dev' }) .withExposedPorts(mockApiPort) .withWaitStrategy(Wait.forListeningPorts()) diff --git a/tests/fixtures/starter-mock.ts b/tests/fixtures/starter-mock.ts new file mode 100644 index 00000000..82d26235 --- /dev/null +++ b/tests/fixtures/starter-mock.ts @@ -0,0 +1,30 @@ +/** Mock image with DuckDB 2 and semantic_layer (altertable-mock PR #31). */ +export const starterMockImage = + 'ghcr.io/altertable-ai/altertable-mock@sha256:fc23b3fc07865b4d469f0451e36ee1f514e321848abdc38cc4a267e8ccfa33d0'; + +/** Fictional evidence shared by the metric and Finding examples. Never run on a user's lakehouse. */ +export const starterSeedStatements = [ + 'CREATE SCHEMA starter_demo', + 'CREATE TABLE starter_demo.sample_counts (group_name VARCHAR, segment_name VARCHAR, sample_count BIGINT)', + "INSERT INTO starter_demo.sample_counts VALUES ('Alpha', 'Online', 1500000), ('Alpha', 'Retail', 700000), ('Beta', 'Partner', 600000)", + `CREATE SEMANTIC VIEW starter_sample_counts + TABLES (samples AS starter_demo.sample_counts) + DIMENSIONS (samples.group_name AS group_name, samples.segment_name AS segment_name) + METRICS (samples.sample_count AS SUM(samples.sample_count))`, +]; + +export async function seedStarterMock(apiUrl: string) { + for (const statement of starterSeedStatements) { + const response = await fetch(`${apiUrl}/query`, { + method: 'POST', + headers: { + authorization: `Basic ${btoa('dev:dev')}`, + 'content-type': 'application/json', + }, + body: JSON.stringify({ statement }), + }); + const body = await response.text(); + if (!response.ok || body.includes('\n{"error":')) + throw new Error(`Starter seed failed: ${body}`); + } +} diff --git a/tests/public-api/apps-starters.test.ts b/tests/public-api/apps-starters.test.ts index 93f6accc..96484dc9 100644 --- a/tests/public-api/apps-starters.test.ts +++ b/tests/public-api/apps-starters.test.ts @@ -8,11 +8,16 @@ import { for (const kind of ['metric', 'finding'] as const) { for (const mode of ['hosted', 'local'] as const) { - test(`${mode} ${kind} starter renders fixture evidence without dashboard actions`, async ({ + test(`${mode} ${kind} starter queries the mock and renders evidence without dashboard actions`, async ({ page, }) => { const errors: string[] = []; page.on('pageerror', error => errors.push(error.message)); + const statements: string[] = []; + page.on('request', request => { + if (request.url().endsWith('/api/sql')) + statements.push(request.postDataJSON().statement); + }); await page.goto( mode === 'hosted' ? `/starter-${kind}-data-app` @@ -69,6 +74,16 @@ for (const kind of ['metric', 'finding'] as const) { await expect .poll(() => secondary.getByText('Retail', { exact: true }).count()) .toBe(0); + await expect + .poll(() => + secondary + .getByRole('button', { + name: 'Partner: 600,000 samples', + exact: true, + }) + .isVisible() + ) + .toBe(true); await app .getByRole('button', { name: 'Beta Group', exact: true }) .click(); @@ -84,6 +99,35 @@ for (const kind of ['metric', 'finding'] as const) { ) .toBe(true); } + if (kind === 'metric' && mode === 'hosted') { + expect( + statements.some( + statement => + statement.includes('SEMANTIC_VIEW(') && + statement.includes('DIMENSIONS samples.group_name') + ) + ).toBe(true); + expect( + statements.some( + statement => + statement.includes('SEMANTIC_VIEW(') && + statement.includes('DIMENSIONS samples.segment_name') + ) + ).toBe(true); + } + if (kind === 'finding') { + const narrative = app + .locator('p') + .filter({ hasText: 'recorded samples' }); + await expect.poll(() => narrative.textContent()).toContain('2,200,000'); + expect(await narrative.textContent()).toContain('2,800,000'); + expect( + await app + .getByRole('region', { name: 'Counts by group', exact: true }) + .getByRole('table') + .isVisible() + ).toBe(true); + } expect(errors).toEqual([]); }); } diff --git a/tests/public-api/browser.ts b/tests/public-api/browser.ts index 4c06cebe..cc51a7c7 100644 --- a/tests/public-api/browser.ts +++ b/tests/public-api/browser.ts @@ -1,43 +1,65 @@ import { spawn } from 'node:child_process'; import { chromium, firefox, webkit, type Browser, type Page } from 'playwright'; +import type { StartedTestContainer } from 'testcontainers'; import { afterAll, beforeAll, vi, test as vitestTest } from 'vitest'; +import { + seedStarterMock, + starterMockImage, +} from '@/tests/fixtures/starter-mock'; + let browser: Browser; let baseURL: string; export let localURL: string; export let metricLocalURL: string; export let findingLocalURL: string; let server: ReturnType; +let starterMock: StartedTestContainer | undefined; let output = ''; -beforeAll(async () => { - server = spawn('bun', ['tests/public-api/fixtures/server.ts'], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - server.stdout?.on('data', chunk => { - output += chunk; - }); - server.stderr?.on('data', chunk => { - output += chunk; - }); - await vi.waitFor( - () => { - if (server.exitCode !== null) throw new Error(output); - const ready = output.match(/DATA_APP_TEST_SERVER (.*)/); - if (!ready) throw new Error(`Browser fixture is not ready: ${output}`); - ({ baseURL, localURL, metricLocalURL, findingLocalURL } = JSON.parse( - ready[1]! - )); - }, - { timeout: 30000 } - ); - const name = process.env.DATA_APP_TEST_BROWSER ?? 'chromium'; - const engine = [chromium, firefox, webkit].find( - engine => engine.name() === name - ); - if (!engine) throw new Error(`Unsupported test browser: ${name}`); - browser = await engine.launch(); -}, 30000); +beforeAll( + async () => { + let mockUrl: string | undefined; + if (process.env.DATA_APP_STARTER_MOCK === '1') { + const { GenericContainer, Wait } = await import('testcontainers'); + starterMock = await new GenericContainer(starterMockImage) + .withEnvironment({ ALTERTABLE_MOCK_USERS: 'dev:dev' }) + .withExposedPorts(15000) + .withWaitStrategy(Wait.forListeningPorts()) + .start(); + mockUrl = `http://${starterMock.getHost()}:${starterMock.getMappedPort(15000)}`; + await seedStarterMock(mockUrl); + } + server = spawn('bun', ['tests/public-api/fixtures/server.ts'], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, STARTER_MOCK_URL: mockUrl }, + }); + server.stdout?.on('data', chunk => { + output += chunk; + }); + server.stderr?.on('data', chunk => { + output += chunk; + }); + await vi.waitFor( + () => { + if (server.exitCode !== null) throw new Error(output); + const ready = output.match(/DATA_APP_TEST_SERVER (.*)/); + if (!ready) throw new Error(`Browser fixture is not ready: ${output}`); + ({ baseURL, localURL, metricLocalURL, findingLocalURL } = JSON.parse( + ready[1]! + )); + }, + { timeout: 30000 } + ); + const name = process.env.DATA_APP_TEST_BROWSER ?? 'chromium'; + const engine = [chromium, firefox, webkit].find( + engine => engine.name() === name + ); + if (!engine) throw new Error(`Unsupported test browser: ${name}`); + browser = await engine.launch(); + }, + process.env.DATA_APP_STARTER_MOCK === '1' ? 120000 : 30000 +); afterAll(async () => { await browser?.close(); @@ -48,6 +70,7 @@ afterAll(async () => { server.kill(); await exited; } + await starterMock?.stop(); }); export const test = vitestTest.extend<{ page: Page; mobilePage: Page }>({ diff --git a/tests/public-api/fixtures/server.ts b/tests/public-api/fixtures/server.ts index d8fc47d1..3acb2783 100644 --- a/tests/public-api/fixtures/server.ts +++ b/tests/public-api/fixtures/server.ts @@ -1,6 +1,6 @@ import { Database } from 'bun:sqlite'; -import { serveLocalApp } from '@altertable/data-app-bun'; +import { localLakehouse, serveLocalApp } from '@altertable/data-app-bun'; import starterPage from '@/packages/data-app-examples/templates/starter-local-data-app/src/index.html'; import { operations } from '@/packages/data-app-examples/templates/starter-local-data-app/src/operations'; @@ -72,6 +72,14 @@ const findingStarter = await bundle( 'packages/data-app-examples/templates/starter-finding-data-app/index.tsx' ); const database = new Database(':memory:'); +const starterMockUrl = process.env.STARTER_MOCK_URL; +const starterLakehouse = starterMockUrl + ? localLakehouse({ + ALTERTABLE_API_BASE: starterMockUrl, + ALTERTABLE_LAKEHOUSE_USERNAME: 'dev', + ALTERTABLE_LAKEHOUSE_PASSWORD: 'dev', + }) + : undefined; process.env.ALTERTABLE_DATA_PROXY_TOKEN = 'fixture-token'; process.env.NODE_ENV = 'production'; @@ -159,6 +167,15 @@ const server = Bun.serve({ if (url.pathname === '/proxy/query') { if (request.headers.get('authorization') !== 'Bearer fixture-token') return new Response('Denied', { status: 403 }); + if (starterMockUrl) + return fetch(`${starterMockUrl}/query`, { + method: 'POST', + headers: { + authorization: `Basic ${btoa('dev:dev')}`, + 'content-type': 'application/json', + }, + body: await request.text(), + }); const { statement, params } = (await request.json()) as { statement: string; params?: Record; @@ -203,6 +220,14 @@ const server = Bun.serve({ limit: number; params?: Record; }; + if (starterLakehouse) + return Response.json( + await starterLakehouse.queryAll(statement, { + params, + limit, + signal: request.signal, + }) + ); if ( (statement.trim().startsWith('WITH sample_counts(') || statement.trim().startsWith('WITH sample_segments(')) && diff --git a/vitest.config.ts b/vitest.config.ts index 50fb35a7..cd61ccb2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,15 @@ export default defineConfig({ test: { name: 'public-api', include: ['tests/public-api/**/*.test.{ts,tsx}'], + exclude: ['tests/public-api/apps-starters.test.ts'], + }, + }, + { + extends: true, + test: { + name: 'starters', + include: ['tests/public-api/apps-starters.test.ts'], + env: { DATA_APP_STARTER_MOCK: '1' }, }, }, { From 69357646475de2c5068ab87f2a9a761314a3d362 Mon Sep 17 00:00:00 2001 From: robinvrd Date: Fri, 30 Oct 2026 13:55:01 +0100 Subject: [PATCH 4/6] docs: clarify Docker prerequisite for starter tests --- CONTRIBUTING.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cbb835ce..538cdb48 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,11 @@ bun install --frozen-lockfile bash scripts/install-test-browser.sh ``` +Start a Docker-compatible container runtime before `bun run test` or +`bun run check`. The starter tests launch a mock API container and seed its sample +data. To run only the public API tests that do not need Docker, use +`bun run test --project public-api`; this excludes the mock-backed starter tests. + ## Workflow 1. **Locate the owner.** Use the code map below. Read additional guidance only From 8a77f82cfd1673156b4a583f52976203a65a39e6 Mon Sep 17 00:00:00 2001 From: robinvrd Date: Fri, 30 Oct 2026 14:59:27 +0100 Subject: [PATCH 5/6] test: remove starter-specific browser coverage --- CONTRIBUTING.md | 5 - {tests/fixtures => dev}/starter-mock.ts | 16 --- docs/local-data-apps.md | 4 +- scripts/dev-gallery.ts | 5 +- tests/public-api/apps-export.test.ts | 26 ++++ tests/public-api/apps-starters.test.ts | 160 ---------------------- tests/public-api/browser.ts | 79 ++++------- tests/public-api/fixtures/bundle-host.tsx | 8 +- tests/public-api/fixtures/server.ts | 113 +-------------- vitest.config.ts | 9 -- 10 files changed, 60 insertions(+), 365 deletions(-) rename {tests/fixtures => dev}/starter-mock.ts (63%) delete mode 100644 tests/public-api/apps-starters.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 538cdb48..cbb835ce 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,11 +7,6 @@ bun install --frozen-lockfile bash scripts/install-test-browser.sh ``` -Start a Docker-compatible container runtime before `bun run test` or -`bun run check`. The starter tests launch a mock API container and seed its sample -data. To run only the public API tests that do not need Docker, use -`bun run test --project public-api`; this excludes the mock-backed starter tests. - ## Workflow 1. **Locate the owner.** Use the code map below. Read additional guidance only diff --git a/tests/fixtures/starter-mock.ts b/dev/starter-mock.ts similarity index 63% rename from tests/fixtures/starter-mock.ts rename to dev/starter-mock.ts index 82d26235..5c0c2864 100644 --- a/tests/fixtures/starter-mock.ts +++ b/dev/starter-mock.ts @@ -12,19 +12,3 @@ export const starterSeedStatements = [ DIMENSIONS (samples.group_name AS group_name, samples.segment_name AS segment_name) METRICS (samples.sample_count AS SUM(samples.sample_count))`, ]; - -export async function seedStarterMock(apiUrl: string) { - for (const statement of starterSeedStatements) { - const response = await fetch(`${apiUrl}/query`, { - method: 'POST', - headers: { - authorization: `Basic ${btoa('dev:dev')}`, - 'content-type': 'application/json', - }, - body: JSON.stringify({ statement }), - }); - const body = await response.text(); - if (!response.ok || body.includes('\n{"error":')) - throw new Error(`Starter seed failed: ${body}`); - } -} diff --git a/docs/local-data-apps.md b/docs/local-data-apps.md index d9c724c2..77455a15 100644 --- a/docs/local-data-apps.md +++ b/docs/local-data-apps.md @@ -34,6 +34,4 @@ Finding structure. Copied starters use the normal query connection. Replace their example queries with verified sources in the target lakehouse; the mock and seed belong only to -development and tests. Never create demo objects in a user's lakehouse. -Run `bun run test --project starters` to verify both runtimes against a fresh, -seeded mock, including semantic queries, drill-down, reset, and displayed evidence. +local development. Never create demo objects in a user's lakehouse. diff --git a/scripts/dev-gallery.ts b/scripts/dev-gallery.ts index ae04b262..2c9b4927 100644 --- a/scripts/dev-gallery.ts +++ b/scripts/dev-gallery.ts @@ -4,11 +4,8 @@ import { resolve } from 'node:path'; import { GenericContainer, Wait } from 'testcontainers'; import { seedStatements } from '@/dev/seed'; +import { starterMockImage, starterSeedStatements } from '@/dev/starter-mock'; import { packages } from '@/scripts/packages'; -import { - starterMockImage, - starterSeedStatements, -} from '@/tests/fixtures/starter-mock'; const root = resolve(import.meta.dir, '..'); const mockApiPort = 15000; diff --git a/tests/public-api/apps-export.test.ts b/tests/public-api/apps-export.test.ts index 5421796a..72f8da98 100644 --- a/tests/public-api/apps-export.test.ts +++ b/tests/public-api/apps-export.test.ts @@ -228,3 +228,29 @@ describe('export', () => { .toBe(true); }); }); + +test('omitting presentation and export retains refresh and loaded content', async ({ + page, +}) => { + await page.goto('/declared-app?no-exports'); + await page + .getByRole('button', { name: 'Resolve alpha 1', exact: true }) + .click(); + await page.getByRole('button', { name: 'Show primary', exact: true }).click(); + await expect + .poll(() => + page + .getByRole('button', { name: 'Refresh data', exact: true }) + .isEnabled() + ) + .toBe(true); + expect( + await page.getByRole('button', { name: 'Present story' }).count() + ).toBe(0); + expect(await page.getByRole('button', { name: /Export/ }).count()).toBe(0); + await expect + .poll(() => + page.getByRole('region', { name: 'Alpha visualization' }).isVisible() + ) + .toBe(true); +}); diff --git a/tests/public-api/apps-starters.test.ts b/tests/public-api/apps-starters.test.ts deleted file mode 100644 index 96484dc9..00000000 --- a/tests/public-api/apps-starters.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { expect } from 'vitest'; - -import { - test, - metricLocalURL, - findingLocalURL, -} from '@/tests/public-api/browser'; - -for (const kind of ['metric', 'finding'] as const) { - for (const mode of ['hosted', 'local'] as const) { - test(`${mode} ${kind} starter queries the mock and renders evidence without dashboard actions`, async ({ - page, - }) => { - const errors: string[] = []; - page.on('pageerror', error => errors.push(error.message)); - const statements: string[] = []; - page.on('request', request => { - if (request.url().endsWith('/api/sql')) - statements.push(request.postDataJSON().statement); - }); - await page.goto( - mode === 'hosted' - ? `/starter-${kind}-data-app` - : kind === 'metric' - ? metricLocalURL - : findingLocalURL - ); - const app = mode === 'hosted' ? page.frameLocator('iframe') : page; - await expect - .poll(() => - app - .getByRole('region', { name: 'Sample count', exact: true }) - .getByText('Alpha', { exact: true }) - .isVisible() - ) - .toBe(true); - expect( - await app - .getByRole('group', { name: 'Page actions', exact: true }) - .count() - ).toBe(0); - expect( - await app.getByRole('button', { name: 'Present story' }).count() - ).toBe(0); - expect(await app.locator('h1').count()).toBe(mode === 'hosted' ? 0 : 1); - expect( - await app.getByRole('heading', { name: 'Supporting evidence' }).count() - ).toBe(kind === 'finding' ? 1 : 0); - expect( - await app - .getByRole('heading', { name: 'Limitations', exact: true }) - .count() - ).toBe(0); - if (kind === 'metric') { - const main = app.getByRole('region', { - name: 'Sample count', - exact: true, - }); - const secondary = app.getByRole('region', { - name: 'Samples by segment', - exact: true, - }); - await expect - .poll(() => - secondary.getByText('Retail', { exact: true }).isVisible() - ) - .toBe(true); - await main - .getByRole('button', { name: /^Beta:.*Drill into group/ }) - .click(); - await expect - .poll(() => main.getByText('Alpha', { exact: true }).count()) - .toBe(0); - await expect - .poll(() => secondary.getByText('Retail', { exact: true }).count()) - .toBe(0); - await expect - .poll(() => - secondary - .getByRole('button', { - name: 'Partner: 600,000 samples', - exact: true, - }) - .isVisible() - ) - .toBe(true); - await app - .getByRole('button', { name: 'Beta Group', exact: true }) - .click(); - await app - .getByRole('option', { name: 'All groups', exact: true }) - .click(); - await expect - .poll(() => main.getByText('Alpha', { exact: true }).isVisible()) - .toBe(true); - await expect - .poll(() => - secondary.getByText('Retail', { exact: true }).isVisible() - ) - .toBe(true); - } - if (kind === 'metric' && mode === 'hosted') { - expect( - statements.some( - statement => - statement.includes('SEMANTIC_VIEW(') && - statement.includes('DIMENSIONS samples.group_name') - ) - ).toBe(true); - expect( - statements.some( - statement => - statement.includes('SEMANTIC_VIEW(') && - statement.includes('DIMENSIONS samples.segment_name') - ) - ).toBe(true); - } - if (kind === 'finding') { - const narrative = app - .locator('p') - .filter({ hasText: 'recorded samples' }); - await expect.poll(() => narrative.textContent()).toContain('2,200,000'); - expect(await narrative.textContent()).toContain('2,800,000'); - expect( - await app - .getByRole('region', { name: 'Counts by group', exact: true }) - .getByRole('table') - .isVisible() - ).toBe(true); - } - expect(errors).toEqual([]); - }); - } -} - -test('omitting presentation and export retains refresh and loaded content', async ({ - page, -}) => { - await page.goto('/declared-app?no-exports'); - await page - .getByRole('button', { name: 'Resolve alpha 1', exact: true }) - .click(); - await page.getByRole('button', { name: 'Show primary', exact: true }).click(); - await expect - .poll(() => - page - .getByRole('button', { name: 'Refresh data', exact: true }) - .isEnabled() - ) - .toBe(true); - expect( - await page.getByRole('button', { name: 'Present story' }).count() - ).toBe(0); - expect(await page.getByRole('button', { name: /Export/ }).count()).toBe(0); - await expect - .poll(() => - page.getByRole('region', { name: 'Alpha visualization' }).isVisible() - ) - .toBe(true); -}); diff --git a/tests/public-api/browser.ts b/tests/public-api/browser.ts index cc51a7c7..b8443299 100644 --- a/tests/public-api/browser.ts +++ b/tests/public-api/browser.ts @@ -1,65 +1,39 @@ import { spawn } from 'node:child_process'; import { chromium, firefox, webkit, type Browser, type Page } from 'playwright'; -import type { StartedTestContainer } from 'testcontainers'; import { afterAll, beforeAll, vi, test as vitestTest } from 'vitest'; -import { - seedStarterMock, - starterMockImage, -} from '@/tests/fixtures/starter-mock'; - let browser: Browser; let baseURL: string; export let localURL: string; -export let metricLocalURL: string; -export let findingLocalURL: string; let server: ReturnType; -let starterMock: StartedTestContainer | undefined; let output = ''; -beforeAll( - async () => { - let mockUrl: string | undefined; - if (process.env.DATA_APP_STARTER_MOCK === '1') { - const { GenericContainer, Wait } = await import('testcontainers'); - starterMock = await new GenericContainer(starterMockImage) - .withEnvironment({ ALTERTABLE_MOCK_USERS: 'dev:dev' }) - .withExposedPorts(15000) - .withWaitStrategy(Wait.forListeningPorts()) - .start(); - mockUrl = `http://${starterMock.getHost()}:${starterMock.getMappedPort(15000)}`; - await seedStarterMock(mockUrl); - } - server = spawn('bun', ['tests/public-api/fixtures/server.ts'], { - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, STARTER_MOCK_URL: mockUrl }, - }); - server.stdout?.on('data', chunk => { - output += chunk; - }); - server.stderr?.on('data', chunk => { - output += chunk; - }); - await vi.waitFor( - () => { - if (server.exitCode !== null) throw new Error(output); - const ready = output.match(/DATA_APP_TEST_SERVER (.*)/); - if (!ready) throw new Error(`Browser fixture is not ready: ${output}`); - ({ baseURL, localURL, metricLocalURL, findingLocalURL } = JSON.parse( - ready[1]! - )); - }, - { timeout: 30000 } - ); - const name = process.env.DATA_APP_TEST_BROWSER ?? 'chromium'; - const engine = [chromium, firefox, webkit].find( - engine => engine.name() === name - ); - if (!engine) throw new Error(`Unsupported test browser: ${name}`); - browser = await engine.launch(); - }, - process.env.DATA_APP_STARTER_MOCK === '1' ? 120000 : 30000 -); +beforeAll(async () => { + server = spawn('bun', ['tests/public-api/fixtures/server.ts'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + server.stdout?.on('data', chunk => { + output += chunk; + }); + server.stderr?.on('data', chunk => { + output += chunk; + }); + await vi.waitFor( + () => { + if (server.exitCode !== null) throw new Error(output); + const ready = output.match(/DATA_APP_TEST_SERVER (.*)/); + if (!ready) throw new Error(`Browser fixture is not ready: ${output}`); + ({ baseURL, localURL } = JSON.parse(ready[1]!)); + }, + { timeout: 30000 } + ); + const name = process.env.DATA_APP_TEST_BROWSER ?? 'chromium'; + const engine = [chromium, firefox, webkit].find( + engine => engine.name() === name + ); + if (!engine) throw new Error(`Unsupported test browser: ${name}`); + browser = await engine.launch(); +}, 30000); afterAll(async () => { await browser?.close(); @@ -70,7 +44,6 @@ afterAll(async () => { server.kill(); await exited; } - await starterMock?.stop(); }); export const test = vitestTest.extend<{ page: Page; mobilePage: Page }>({ diff --git a/tests/public-api/fixtures/bundle-host.tsx b/tests/public-api/fixtures/bundle-host.tsx index 4849d399..6df4d1e1 100644 --- a/tests/public-api/fixtures/bundle-host.tsx +++ b/tests/public-api/fixtures/bundle-host.tsx @@ -22,14 +22,10 @@ const { frameURL } = await fetch('/__test/config').then(response => response.json() ); const params = new URLSearchParams(location.search); -const starter = [ - '/starter-data-app', - '/starter-metric-data-app', - '/starter-finding-data-app', -].includes(location.pathname); +const starter = location.pathname === '/starter-data-app'; const javascript = await fetch( starter - ? `/__test${location.pathname}` + ? '/__test/starter-data-app' : params.has('manual-sizing') ? '/__test/content-sizing' : '/__test/bundle' diff --git a/tests/public-api/fixtures/server.ts b/tests/public-api/fixtures/server.ts index 3acb2783..a50a5c96 100644 --- a/tests/public-api/fixtures/server.ts +++ b/tests/public-api/fixtures/server.ts @@ -1,13 +1,9 @@ import { Database } from 'bun:sqlite'; -import { localLakehouse, serveLocalApp } from '@altertable/data-app-bun'; +import { serveLocalApp } from '@altertable/data-app-bun'; import starterPage from '@/packages/data-app-examples/templates/starter-local-data-app/src/index.html'; import { operations } from '@/packages/data-app-examples/templates/starter-local-data-app/src/operations'; -import findingPage from '@/packages/data-app-examples/templates/starter-local-finding-data-app/src/index.html'; -import { operations as findingOperations } from '@/packages/data-app-examples/templates/starter-local-finding-data-app/src/operations'; -import metricPage from '@/packages/data-app-examples/templates/starter-local-metric-data-app/src/index.html'; -import { operations as metricOperations } from '@/packages/data-app-examples/templates/starter-local-metric-data-app/src/operations'; import annotationsHost from '@/tests/public-api/fixtures/annotations-host.html'; import appearance from '@/tests/public-api/fixtures/appearance.html'; import bridgeFrame from '@/tests/public-api/fixtures/bridge-frame.html'; @@ -65,21 +61,7 @@ const [ const { default: worker } = await import( import.meta.resolve('@altertable/data-app-worker') ); -const metricStarter = await bundle( - 'packages/data-app-examples/templates/starter-metric-data-app/index.tsx' -); -const findingStarter = await bundle( - 'packages/data-app-examples/templates/starter-finding-data-app/index.tsx' -); const database = new Database(':memory:'); -const starterMockUrl = process.env.STARTER_MOCK_URL; -const starterLakehouse = starterMockUrl - ? localLakehouse({ - ALTERTABLE_API_BASE: starterMockUrl, - ALTERTABLE_LAKEHOUSE_USERNAME: 'dev', - ALTERTABLE_LAKEHOUSE_PASSWORD: 'dev', - }) - : undefined; process.env.ALTERTABLE_DATA_PROXY_TOKEN = 'fixture-token'; process.env.NODE_ENV = 'production'; @@ -101,8 +83,6 @@ const server = Bun.serve({ '/date-axes': dateAxes, '/bundle-host': bundleHost, '/starter-data-app': bundleHost, - '/starter-finding-data-app': bundleHost, - '/starter-metric-data-app': bundleHost, '/bridge-host': bridgeHost, '/bridge-frame': bridgeFrame, '/inspection-app': inspectionApp, @@ -131,8 +111,6 @@ const server = Bun.serve({ return Response.json({ frameURL: frameServer.url.href, localURL: local.url.href, - metricLocalURL: metricLocal.url.href, - findingLocalURL: findingLocal.url.href, }); if (url.pathname === '/__test/annotations') return new Response(annotations); @@ -143,10 +121,6 @@ const server = Bun.serve({ if (url.pathname === '/__test/bundle') return new Response(app); if (url.pathname === '/__test/content-sizing') return new Response(contentSizing); - if (url.pathname === '/__test/starter-metric-data-app') - return new Response(metricStarter); - if (url.pathname === '/__test/starter-finding-data-app') - return new Response(findingStarter); if (url.pathname === '/__test/starter-data-app') return new Response(starter); if (url.pathname === '/__test/silent') @@ -167,49 +141,6 @@ const server = Bun.serve({ if (url.pathname === '/proxy/query') { if (request.headers.get('authorization') !== 'Bearer fixture-token') return new Response('Denied', { status: 403 }); - if (starterMockUrl) - return fetch(`${starterMockUrl}/query`, { - method: 'POST', - headers: { - authorization: `Basic ${btoa('dev:dev')}`, - 'content-type': 'application/json', - }, - body: await request.text(), - }); - const { statement, params } = (await request.json()) as { - statement: string; - params?: Record; - }; - if ( - statement.includes('sample_counts') || - statement.includes('sample_segments') - ) { - const query = database.query(statement); - const rows = query.values( - Object.fromEntries( - Object.entries(params ?? {}).map(([key, value]) => [ - `$${key}`, - value, - ]) - ) - ); - const columns = [ - { - name: statement.includes('sample_segments') - ? 'segment_name' - : 'group_name', - type: 'VARCHAR', - }, - { name: 'sample_count', type: 'INTEGER' }, - ]; - return new Response( - [ - JSON.stringify({ query_id: 'sample-query' }), - JSON.stringify(columns), - ...rows.map(row => JSON.stringify(row)), - ].join('\n') + '\n' - ); - } return new Response( '{"query_id":"starter-query"}\n[{"name":"connection_check","type":"INTEGER"}]\n[1]\n' ); @@ -220,28 +151,9 @@ const server = Bun.serve({ limit: number; params?: Record; }; - if (starterLakehouse) - return Response.json( - await starterLakehouse.queryAll(statement, { - params, - limit, - signal: request.signal, - }) - ); - if ( - (statement.trim().startsWith('WITH sample_counts(') || - statement.trim().startsWith('WITH sample_segments(')) && - limit === 10 - ) + if (statement.trim().startsWith('WITH sample_counts(') && limit === 10) return Response.json({ - columns: [ - { - name: statement.includes('sample_segments') - ? 'segment_name' - : 'group_name', - }, - { name: 'sample_count' }, - ], + columns: [{ name: 'group_name' }, { name: 'sample_count' }], rows: database .query(statement) .values( @@ -278,24 +190,7 @@ const local = serveLocalApp({ title: 'Local starter', port: 0, }); -const metricLocal = serveLocalApp({ - page: metricPage, - operations: metricOperations, - title: 'Local metric', - port: 0, -}); -const findingLocal = serveLocalApp({ - page: findingPage, - operations: findingOperations, - title: 'Local finding', - port: 0, -}); console.log( 'DATA_APP_TEST_SERVER ' + - JSON.stringify({ - baseURL: server.url.href, - localURL: local.url.href, - metricLocalURL: metricLocal.url.href, - findingLocalURL: findingLocal.url.href, - }) + JSON.stringify({ baseURL: server.url.href, localURL: local.url.href }) ); diff --git a/vitest.config.ts b/vitest.config.ts index cd61ccb2..50fb35a7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,15 +9,6 @@ export default defineConfig({ test: { name: 'public-api', include: ['tests/public-api/**/*.test.{ts,tsx}'], - exclude: ['tests/public-api/apps-starters.test.ts'], - }, - }, - { - extends: true, - test: { - name: 'starters', - include: ['tests/public-api/apps-starters.test.ts'], - env: { DATA_APP_STARTER_MOCK: '1' }, }, }, { From cf067a57b8808e952ce1c2d48d543d22aaacd545 Mon Sep 17 00:00:00 2001 From: robinvrd Date: Fri, 30 Oct 2026 15:09:25 +0100 Subject: [PATCH 6/6] docs: simplify starter preview instructions --- docs/local-data-apps.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/docs/local-data-apps.md b/docs/local-data-apps.md index 77455a15..7801854f 100644 --- a/docs/local-data-apps.md +++ b/docs/local-data-apps.md @@ -17,21 +17,13 @@ You can also [convert the local app to a hosted data app](hosted-apps.md#convert ## Preview the use-case starters From the SDK repository, run `bun run dev:gallery` with Docker available. It starts -and seeds the Altertable mock with fictional sample rows and the -`starter_sample_counts` semantic view. The mock image includes DuckDB 2 and the -`semantic_layer` extension; an older cached image is not sufficient. +and seeds the Altertable mock with fictional sample data. | Starter | Hosted preview | Local preview | | ------- | ------------------------------------------------- | ------------------------ | | Metric | | | | Finding | | | -The metric starter queries `SEMANTIC_VIEW` for group and segment breakdowns. -Its group action filters both charts; choose All groups to reset. The Finding -starter uses ordinary SQL over the same sample rows to support prose, inline -values, a compact ranking, and a table. Its example headings are not a prescribed -Finding structure. - Copied starters use the normal query connection. Replace their example queries with verified sources in the target lakehouse; the mock and seed belong only to local development. Never create demo objects in a user's lakehouse.