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/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/dev/starter-mock.ts b/dev/starter-mock.ts new file mode 100644 index 00000000..5c0c2864 --- /dev/null +++ b/dev/starter-mock.ts @@ -0,0 +1,14 @@ +/** 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))`, +]; 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..7801854f 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 | @@ -13,3 +13,17 @@ 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 data. + +| Starter | Hosted preview | Local preview | +| ------- | ------------------------------------------------- | ------------------------ | +| Metric | | | +| Finding | | | + +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. 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..68b8d9bb --- /dev/null +++ b/packages/data-app-examples/templates/starter-finding-data-app/index.tsx @@ -0,0 +1,177 @@ +import { defineDataApp } from '@altertable/data-app'; +import { + createDataContext, + injectDataAppStyles, + mountDataApp, +} from '@altertable/data-app-react'; +import { + createDataHooks, + DataApp, + DataSection, + VisualizationWidget, + DataValue, + TableWidget, + Ranking, + Stack, + TextContent, +} from '@altertable/data-app-react'; +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: ` +SELECT group_name, SUM(sample_count) AS sample_count +FROM starter_demo.sample_counts +WHERE $groupName = '' OR group_name = $groupName +GROUP BY group_name ORDER BY sample_count DESC 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: + '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 600,000.', + 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 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 => ( + + +

Contribution by group

+

+ Alpha contributed{' '} + of the{' '} + recorded samples. + The group comparison below supports that concentration; it does not + explain its cause. +

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

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() { + 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..39e80ff6 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/README.md @@ -0,0 +1,43 @@ +# 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. + +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 +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..c4eba4cf --- /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: ` +SELECT group_name, SUM(sample_count) AS sample_count +FROM starter_demo.sample_counts +WHERE $groupName = '' OR group_name = $groupName +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/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..744daeae --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/src/App.tsx @@ -0,0 +1,122 @@ +import { + createDataHooks, + DataApp, + DataSection, + VisualizationWidget, + DataValue, + TableWidget, + Ranking, + Stack, + TextContent, +} from '@altertable/data-app-react'; +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 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 => ( + + +

Contribution by group

+

+ Alpha contributed{' '} + of the{' '} + recorded samples. + The group comparison below supports that concentration; it does not + explain its cause. +

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

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() { + 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..4f0ebd05 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-finding-data-app/src/data-context.ts @@ -0,0 +1,13 @@ +import { createDataContext } from '@altertable/data-app-react'; +const queryNames = { sampleCountsByGroup: 'sampleCountsByGroup' } as const; +export const sampleDataContext = createDataContext(queryNames)({ + description: + '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 600,000.', + 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..ca073ab3 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/README.md @@ -0,0 +1,43 @@ +# 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. + +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 +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..beb7766b --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/app.ts @@ -0,0 +1,39 @@ +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: { + sampleCountsBySegment: { + projections: { + segment_name: { type: 'string' }, + sample_count: { type: 'number' }, + }, + statement: ` +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 }, + }, + }, + sampleCountsByGroup: { + projections: { + group_name: { type: 'string' }, + sample_count: { type: 'number' }, + }, + statement: ` +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/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..e51e3f66 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/App.tsx @@ -0,0 +1,124 @@ +import { + createDataHooks, + 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'; + +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', + 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.', + }, +}); +const sampleCounts = sampleCountsView.dataset({ + name: 'Sample counts', + select: result => result.groups, + 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 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.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/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..1ef9b431 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/data-context.ts @@ -0,0 +1,19 @@ +import { createDataContext } from '@altertable/data-app-react'; +const queryNames = { + sampleCountsByGroup: 'sampleCountsByGroup', + sampleCountsBySegment: 'sampleCountsBySegment', +} as const; +export const sampleDataContext = createDataContext(queryNames)({ + description: + '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', + 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/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..f6d0f052 --- /dev/null +++ b/packages/data-app-examples/templates/starter-local-metric-data-app/src/operations.ts @@ -0,0 +1,50 @@ +import { parseCount } from '@altertable/data-app/contract'; + +import { dataApp } from '#config'; +export const operations = { + sampleCountsByGroup: dataApp.defineOperation({ + 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 }, { 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-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..4da7bd40 --- /dev/null +++ b/packages/data-app-examples/templates/starter-metric-data-app/index.tsx @@ -0,0 +1,229 @@ +import { defineDataApp } from '@altertable/data-app'; +import { + createDataContext, + injectDataAppStyles, + mountDataApp, +} from '@altertable/data-app-react'; +import { + createDataHooks, + 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'; +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: { + sampleCountsBySegment: { + projections: { + segment_name: { type: 'string' }, + sample_count: { type: 'number' }, + }, + statement: ` +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 }, + }, + }, + sampleCountsByGroup: { + projections: { + group_name: { type: 'string' }, + sample_count: { type: 'number' }, + }, + statement: ` +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 }, + }, + }, + }, +}); +const operations = { + sampleCountsByGroup: dataApp.defineOperation({ + 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 }, { 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: + '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', + definition: 'A fixture value: Alpha is 2,200,000 and Beta is 600,000.', + queryNames: [ + queryNames.sampleCountsByGroup, + queryNames.sampleCountsBySegment, + ], + }, + }, +}); +const { defineDataView } = createDataHooks(createDataClient({ operations })); +const sampleCountsView = defineDataView({ + dataContext: sampleDataContext, + operation: 'sampleCountsByGroup', + 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.', + }, +}); +const sampleCounts = sampleCountsView.dataset({ + name: 'Sample counts', + select: result => result.groups, + 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 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.segmentName, + label: row.segmentName, + 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/scripts/dev-gallery.ts b/scripts/dev-gallery.ts index 736c8ff5..2c9b4927 100644 --- a/scripts/dev-gallery.ts +++ b/scripts/dev-gallery.ts @@ -4,13 +4,14 @@ 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'; 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 +29,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/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/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/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/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" ] }