Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 27 additions & 11 deletions browser-tests/authoring.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ test('single-file hosted example queries through the host and preserves displaye
}) => {
const errors: string[] = [];
page.on('pageerror', error => errors.push(error.message));
const requests: { statement: string; limit: number; host: boolean }[] = [];
const requests: {
operation: string;
variables: { groupName: string };
limit: number;
host: boolean;
}[] = [];
let fail = false;
let release: (() => void) | undefined;
let gate = new Promise<void>(resolve => {
release = resolve;
});
await page.route('**/api/sql', async route => {
await page.route('**/api/registered-query', async route => {
const payload = route.request().postDataJSON();
requests.push({
...payload,
Expand Down Expand Up @@ -95,11 +100,20 @@ test('single-file hosted example queries through the host and preserves displaye
.getByRole('button', { name: 'Exit presentation', exact: true })
.click();
expect(requests.length).toBe(queryCountBeforeStory);
const group = app.getByRole('searchbox', { name: 'Group', exact: true });
async function selectGroup(value: string) {
await app.getByRole('button', { name: /^Group:/ }).click();
if (value === '') {
await app.getByRole('option', { name: 'Empty', exact: true }).click();
return;
}
const search = app.getByRole('searchbox', { name: 'Search group values' });
await search.fill(value);
await search.press('Enter');
}
gate = new Promise<void>(resolve => {
release = resolve;
});
await group.fill('Beta');
await selectGroup('Beta');
await expect(
app.getByText('Showing all groups', { exact: true })
).toBeVisible();
Expand All @@ -113,7 +127,7 @@ test('single-file hosted example queries through the host and preserves displaye
app.getByText('Alpha: 3', { exact: true }).filter({ visible: true })
).toHaveCount(0);
fail = true;
await group.fill('Alpha');
await selectGroup('Alpha');
await expect(app.getByText('Showing Beta', { exact: true })).toBeVisible();
await expect(
app.getByText(
Expand All @@ -133,19 +147,21 @@ test('single-file hosted example queries through the host and preserves displaye
fail = false;
await app.getByRole('button', { name: 'Try again', exact: true }).click();
await expect(app.getByText('Showing Alpha', { exact: true })).toBeVisible();
await group.fill('missing');
await selectGroup('missing');
await expect(
app.getByText('No matching groups', { exact: true })
).toBeVisible();
await expect(
app.getByRole('button', { name: 'Present story', exact: true })
).toBeDisabled();
await group.fill("O'Reilly");
await expect.poll(() => requests.at(-1)?.statement).toContain("O''Reilly");
await selectGroup("O'Reilly");
await expect
.poll(() => requests.at(-1)?.variables.groupName)
.toBe("O'Reilly");
await expect(
app.getByText('No matching groups', { exact: true })
).toBeVisible();
await group.fill('');
await selectGroup('');
await expect(
app.getByText('Alpha: 3', { exact: true }).filter({ visible: true })
).toBeVisible();
Expand All @@ -160,7 +176,7 @@ test('single-file hosted example queries through the host and preserves displaye
request =>
request.host &&
request.limit === 10 &&
request.statement.includes('LIMIT 10')
request.operation === 'sample-counts-by-group'
)
).toBe(true);
await expect(
Expand All @@ -178,7 +194,7 @@ test('single-file hosted example queries through the host and preserves displaye

test('hosted initial query error recovers by retry', async ({ page }) => {
let fail = true;
await page.route('**/api/sql', async route => {
await page.route('**/api/registered-query', async route => {
if (fail) await route.fulfill({ status: 503, json: failure });
else await route.continue();
});
Expand Down
19 changes: 11 additions & 8 deletions browser-tests/export.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ test('embedded toolbar exports displayed results through its host during updates
release = resolve;
});
let fail = false;
await page.route('**/api/sql', async route => {
await page.route('**/api/registered-query', async route => {
await gate;
if (fail)
await route.fulfill({ status: 503, json: { error: 'Unavailable' } });
Expand All @@ -49,6 +49,13 @@ test('embedded toolbar exports displayed results through its host during updates
'allow-scripts'
);

async function selectGroup(value: string) {
await app.getByRole('button', { name: /^Group:/ }).click();
const search = app.getByRole('searchbox', { name: 'Search group values' });
await search.fill(value);
await search.press('Enter');
}

async function expectExport(filename: string, csv: string) {
const downloaded = page.waitForEvent('download');
await app.getByRole('button', { name: 'Export CSV', exact: true }).click();
Expand All @@ -67,7 +74,7 @@ test('embedded toolbar exports displayed results through its host during updates
gate = new Promise<void>(resolve => {
release = resolve;
});
await app.getByRole('searchbox', { name: 'Group', exact: true }).fill('Beta');
await selectGroup('Beta');
await expect(
app.getByText('Showing all groups while loading group Beta…')
).toBeVisible();
Expand All @@ -79,9 +86,7 @@ test('embedded toolbar exports displayed results through its host during updates
gate = Promise.resolve();
await expect(app.getByText('Showing Beta', { exact: true })).toBeVisible();
fail = true;
await app
.getByRole('searchbox', { name: 'Group', exact: true })
.fill('Alpha');
await selectGroup('Alpha');
await expect(
app.getByText(
'Couldn’t refresh. Showing group Beta while group Alpha is unavailable.'
Expand All @@ -92,9 +97,7 @@ test('embedded toolbar exports displayed results through its host during updates
'\uFEFFGroup,Sample count\r\nBeta,0\r\n'
);
fail = false;
await app
.getByRole('searchbox', { name: 'Group', exact: true })
.fill('missing');
await selectGroup('missing');
await expect(
app.getByText('No matching groups', { exact: true })
).toBeVisible();
Expand Down
31 changes: 30 additions & 1 deletion browser-tests/fixtures/bundle-host.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
createMessageRouter,
defineMessageRoute,
sqlQueryRoute,
registeredQueryRoute,
navigationUpdateRoute,
DataSourceError,
} from '@altertable/data-app/contract';
import { createHttpTransport } from '@altertable/data-app/client';
Expand Down Expand Up @@ -125,7 +127,7 @@ function Host() {
setTimeout(() => URL.revokeObjectURL(url), 1000);
return null;
}
const router = createMessageRouter(
const legacyRouter = createMessageRouter(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove all things legacy

{
...bridgeRoutes,
'data:sql': sqlQueryRoute,
Expand Down Expand Up @@ -157,6 +159,33 @@ function Host() {
}
);

const router =
location.pathname === '/starter-data-app'
? createMessageRouter(
{
'export:csv': fileExportRoute,
'export:zip': fileExportRoute,
'data:query': registeredQueryRoute,
'navigation:update': navigationUpdateRoute,
},
{
'export:csv': downloadExport,
'export:zip': downloadExport,
'data:query': async (query, { signal }) => {
const response = await fetch('/api/registered-query', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(query),
signal,
});
if (!response.ok) throw new DataSourceError('unavailable');
return response.json();
},
'navigation:update': createNavigationHandler(),
}
)
: legacyRouter;

const presentationControls = (
<>
<button
Expand Down
1 change: 1 addition & 0 deletions browser-tests/fixtures/gallery-cases.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ export function GalleryCases({ category }: { category: GalleryCategory }) {
<Case title="Bounded date range and comparison">
<DateRangePicker
label="Gallery dates"
resetValue={{ start: '2026-09-24', end: '2026-09-30' }}
value={dates}
onChange={setDates}
minDate="2026-09-01"
Expand Down
12 changes: 12 additions & 0 deletions browser-tests/fixtures/query-variables.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Query variables</title>
</head>
<body style="margin: 0">
<div id="root"></div>
<script type="module" src="./query-variables.tsx"></script>
</body>
</html>
91 changes: 91 additions & 0 deletions browser-tests/fixtures/query-variables.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { applyAppearance, type Theme } from '@altertable/data-app/appearance';
import { useEffect, useState } from 'react';
import { createRoot } from 'react-dom/client';
import {
type QueryVariableDefinitions,
type VariableValue,
} from '@altertable/data-app/contract';
import {
injectDataAppStyles,
VariableValueSelector,
} from '@altertable/data-app/react';
const definitions = [
{ name: 'Text', nullable: false, type: 'STRING', default: 'hello' },
{ name: 'Count', nullable: false, type: 'INTEGER', default: 2 },
{ name: 'Fraction', nullable: false, type: 'FLOAT', default: 1.5 },
{ name: 'Enabled', type: 'BOOLEAN', default: false, nullable: true },
{ name: 'Interval', nullable: false, type: 'INTERVAL', default: 'DAILY' },
{
name: 'Duration',
type: 'DURATION',
default: { amount: 1, unit: 'WEEK' },
nullable: true,
},
{
name: 'Date',
nullable: false,
type: 'DATETIME',
default: new Date('2026-10-01T10:00:00Z'),
},
{
name: 'Period',
nullable: false,
type: 'DATETIMERANGE',
default: { from: new Date('2026-09-01T00:00:00Z'), to: null },
},
] as const satisfies QueryVariableDefinitions;
function App() {
const [values, setValues] = useState<Record<string, VariableValue | null>>(
Object.fromEntries(
definitions.map(definition => [definition.name, definition.default])
)
);
const [theme, setTheme] = useState<Theme>('light');
useEffect(() => applyAppearance({ theme }), [theme]);
return (
<main
className="altertable-data-app"
data-theme={theme}
style={{
padding: 20,
minHeight: '100vh',
boxSizing: 'border-box',
color: 'var(--at-text)',
background: 'var(--at-background)',
fontFamily: 'var(--at-font)',
}}
>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle theme
</button>
<h1>Query variables</h1>
<p>
Try each variable selector. The values below update with their SQL
variable types.
</p>
<div style={{ display: 'grid', justifyItems: 'start', gap: 16 }}>
{definitions
.map(definition => [definition.name, definition] as const)
.map(([label, definition]) => (
<VariableValueSelector
key={label}
label={label}
definition={definition}
value={values[label]!}
onChange={value =>
setValues(previous => ({ ...previous, [label]: value }))
}
/>
))}
</div>
<pre
data-testid="values"
style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}
>
{JSON.stringify(values, null, 2)}
</pre>
</main>
);
}
injectDataAppStyles();
createRoot(document.getElementById('root')!).render(<App />);
Loading
Loading