-
-
Notifications
You must be signed in to change notification settings - Fork 547
feat(web): add fullscreen table preview and export actions #1693
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
ab56255
3c18098
9b29c4d
c12f955
830e0fa
d9ea439
13d5c04
eec6d66
e693ea0
37cc1ba
01b9d2c
ee252a3
e3e1c0a
e1a961e
e2f3cea
538c12c
06a9b3a
bb6c311
638c0bd
b21a912
8dbd458
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <!doctype html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | ||
| <title>HAPI Markdown table fixture</title> | ||
| <style> | ||
| html { background: #fff; } | ||
| body { margin: 0; padding: 24px; font-family: system-ui, sans-serif; } | ||
| #root { max-width: 960px; margin: 0 auto; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <div id="root"></div> | ||
| <script type="module" src="./markdown-table-fixture.tsx"></script> | ||
| </body> | ||
| </html> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import React from 'react' | ||
| import ReactDOM from 'react-dom/client' | ||
| import '../src/index.css' | ||
| import { HappyChatProvider, type HappyChatContextValue } from '../src/components/AssistantChat/context' | ||
| import { I18nProvider } from '../src/lib/i18n-context' | ||
| import { MarkdownRenderer } from '../src/components/MarkdownRenderer' | ||
|
|
||
| const TABLE_MARKDOWN = `# Repository activity | ||
|
|
||
| | Project | Stars | Language | Latest release | Maintainer | Notes | | ||
| | --- | ---: | --- | --- | --- | --- | | ||
| | HAPI | 128 | TypeScript | 0.28.0 | Local-first team | Remote control for coding agents | | ||
| | HAPI, local-first | 42 | TypeScript | 0.27.3 | Community | A deliberately long description for horizontal table scrolling | | ||
| | Example | 7 | Rust | 1.2.0 | Open source | Stable fixture row |` | ||
|
|
||
| function MarkdownTableFixture() { | ||
| return ( | ||
| <HappyChatProvider value={{ sessionTitle: 'Table filename fixture' } as HappyChatContextValue}> | ||
| <main data-testid="markdown-table-fixture"> | ||
| <MarkdownRenderer standalone content={TABLE_MARKDOWN} /> | ||
| </main> | ||
| </HappyChatProvider> | ||
| ) | ||
| } | ||
|
|
||
| const root = document.getElementById('root') | ||
| if (root) { | ||
| ReactDOM.createRoot(root).render( | ||
| <React.StrictMode> | ||
| <I18nProvider> | ||
| <MarkdownTableFixture /> | ||
| </I18nProvider> | ||
| </React.StrictMode>, | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| import { devices, expect, test } from '@playwright/test' | ||
|
|
||
| test.use({ ...devices['Pixel 7'] }) | ||
|
|
||
| test('mobile markdown table viewer requests landscape and releases orientation controls', async ({ page }) => { | ||
| await page.goto('/e2e-fixtures/markdown-table-fixture.html') | ||
| await page.evaluate(() => { | ||
| const state = { requestFullscreen: 0, exitFullscreen: 0, locks: [] as string[], unlocks: 0 } | ||
| Object.defineProperty(window, '__hapiTableViewerState', { configurable: true, value: state }) | ||
| Object.defineProperty(document.documentElement, 'requestFullscreen', { | ||
| configurable: true, | ||
| value: () => { | ||
| state.requestFullscreen += 1 | ||
| return Promise.resolve() | ||
| }, | ||
| }) | ||
| Object.defineProperty(document, 'exitFullscreen', { | ||
| configurable: true, | ||
| value: () => { | ||
| state.exitFullscreen += 1 | ||
| return Promise.resolve() | ||
| }, | ||
| }) | ||
| Object.defineProperty(window.screen, 'orientation', { | ||
| configurable: true, | ||
| value: { | ||
| lock: (value: string) => { | ||
| state.locks.push(value) | ||
| return Promise.resolve() | ||
| }, | ||
| unlock: () => { | ||
| state.unlocks += 1 | ||
| }, | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| const inlineActions = page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-actions') | ||
| await expect(inlineActions).toBeVisible() | ||
| await expect(inlineActions.getByRole('button')).toHaveCount(1) | ||
| await page.getByRole('button', { name: 'Open table full screen' }).click() | ||
| const dialog = page.getByRole('dialog', { name: 'Table filename fixture' }) | ||
| await expect(dialog).toBeVisible() | ||
| // A real mobile browser can rotate to a landscape CSS viewport. Keep the | ||
| // mobile title unshifted even when its width becomes desktop-sized. | ||
| await page.setViewportSize({ width: 915, height: 412 }) | ||
| await expect(dialog.getByRole('button', { name: 'Copy table as Markdown' })).toBeVisible() | ||
| await expect(dialog.getByRole('button', { name: 'Save table as image' })).toBeVisible() | ||
| await expect(dialog.getByRole('button', { name: 'Download table as CSV' })).toBeVisible() | ||
| await expect.poll(() => dialog.locator('[data-hapi-table-viewer-toolbar="true"]').evaluate((element) => { | ||
| const style = getComputedStyle(element) | ||
| return `${style.paddingLeft}:${style.paddingRight}:${style.paddingTop}:${style.paddingBottom}` | ||
| })).toBe('6px:6px:0px:0px') | ||
| await expect.poll(() => dialog.locator('[data-hapi-table-viewer-toolbar="true"]').evaluate((element) => getComputedStyle(element).columnGap)).toBe('4px') | ||
| await expect.poll(() => dialog.locator('[data-hapi-table-viewer-heading="true"]').evaluate((element) => getComputedStyle(element).transform)).toBe('none') | ||
| await expect.poll(() => dialog.locator('[data-hapi-table-viewer="true"] thead th').first().evaluate((element) => { | ||
| const thead = element.closest('thead') | ||
| return `${getComputedStyle(thead ?? element).position}:${getComputedStyle(element).position}:${getComputedStyle(element).top}` | ||
| })).toBe('static:sticky:0px') | ||
| await expect.poll(() => page.evaluate(() => { | ||
| const state = (window as Window & { __hapiTableViewerState?: { requestFullscreen: number; locks: string[] } }).__hapiTableViewerState | ||
| return state ? `${state.requestFullscreen}:${state.locks.join(',')}` : '' | ||
| })).toBe('1:landscape') | ||
|
|
||
| const imageDownloadPromise = page.waitForEvent('download') | ||
| await dialog.getByRole('button', { name: 'Save table as image' }).click() | ||
| const imageDownload = await imageDownloadPromise | ||
| expect(imageDownload.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.png$/) | ||
|
|
||
| const csvDownloadPromise = page.waitForEvent('download') | ||
| await dialog.getByRole('button', { name: 'Download table as CSV' }).click() | ||
| const csvDownload = await csvDownloadPromise | ||
| expect(csvDownload.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.csv$/) | ||
|
|
||
| await dialog.getByRole('button', { name: 'Close table full screen' }).click() | ||
| await expect.poll(() => page.evaluate(() => { | ||
| const state = (window as Window & { __hapiTableViewerState?: { exitFullscreen: number; unlocks: number } }).__hapiTableViewerState | ||
| return state ? `${state.exitFullscreen}:${state.unlocks}` : '' | ||
| })).toBe('1:1') | ||
| }) | ||
|
|
||
| test('mobile markdown table viewer detects a phone that starts in landscape', async ({ page }) => { | ||
| await page.goto('/e2e-fixtures/markdown-table-fixture.html') | ||
| await page.setViewportSize({ width: 915, height: 412 }) | ||
| await page.evaluate(() => { | ||
| const state = { requestFullscreen: 0, locks: [] as string[] } | ||
| Object.defineProperty(window, '__hapiTableViewerState', { configurable: true, value: state }) | ||
| Object.defineProperty(document.documentElement, 'requestFullscreen', { | ||
| configurable: true, | ||
| value: () => { | ||
| state.requestFullscreen += 1 | ||
| return Promise.resolve() | ||
| }, | ||
| }) | ||
| Object.defineProperty(window.screen, 'orientation', { | ||
| configurable: true, | ||
| value: { | ||
| lock: (value: string) => { | ||
| state.locks.push(value) | ||
| return Promise.resolve() | ||
| }, | ||
| unlock: () => {}, | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| await page.getByRole('button', { name: 'Open table full screen' }).click() | ||
| await expect(page.getByRole('dialog', { name: 'Table filename fixture' })).toBeVisible() | ||
| await expect.poll(() => page.evaluate(() => { | ||
| const state = (window as Window & { __hapiTableViewerState?: { requestFullscreen: number; locks: string[] } }).__hapiTableViewerState | ||
| return state ? `${state.requestFullscreen}:${state.locks.join(',')}` : '' | ||
| })).toBe('1:landscape') | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import { expect, test } from '@playwright/test' | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [MINOR] These new specs are not run by PR automation. They live under Suggested fix: - run: cd web && bunx playwright install chromium
- run: cd web && bunx playwright test e2e/markdown-table.spec.ts e2e/markdown-table-mobile.spec.tsThat makes the fullscreen/export regression coverage effective on future PRs. |
||
|
|
||
| test.describe('markdown table actions', () => { | ||
| test('opens a viewport-sized PC viewer and downloads the CSV', async ({ page }) => { | ||
| await page.goto('/e2e-fixtures/markdown-table-fixture.html') | ||
|
|
||
| const inlineTable = page.locator('[data-testid="markdown-table-fixture"] table') | ||
| await expect(inlineTable).toBeVisible() | ||
| await expect(inlineTable.locator('thead')).toBeVisible() | ||
|
|
||
| const tableFrame = page.locator('[data-testid="markdown-table-fixture"] .aui-md-table-frame') | ||
| const actions = tableFrame.locator('.aui-md-table-actions') | ||
| await expect(actions).toBeAttached() | ||
| await expect(actions.getByRole('button')).toHaveCount(1) | ||
| const inlineButtonStyles = await actions.getByRole('button').evaluate((element) => { | ||
| const style = getComputedStyle(element) | ||
| return { backgroundColor: style.backgroundColor, borderWidth: style.borderTopWidth, backdropFilter: style.backdropFilter } | ||
| }) | ||
| expect(inlineButtonStyles.backgroundColor).toMatch(/rgba\(0, 0, 0, 0\)|transparent/) | ||
| expect(inlineButtonStyles.borderWidth).toBe('0px') | ||
| expect(inlineButtonStyles.backdropFilter).toBe('none') | ||
| await expect.poll(() => actions.evaluate((element) => { | ||
| const style = getComputedStyle(element) | ||
| return `${style.top}:${style.right}` | ||
| })).toBe('3px:3px') | ||
| await expect.poll(() => actions.evaluate((element) => getComputedStyle(element).opacity)).toBe('0') | ||
| await tableFrame.hover() | ||
| await expect.poll(() => actions.evaluate((element) => getComputedStyle(element).opacity)).toBe('1') | ||
|
|
||
| await page.getByRole('button', { name: 'Open table full screen' }).click() | ||
| const dialog = page.getByRole('dialog', { name: 'Table filename fixture' }) | ||
| await expect(dialog).toBeVisible() | ||
|
|
||
| const viewerHeading = dialog.locator('[data-hapi-table-viewer-heading="true"]') | ||
| await expect(viewerHeading).toHaveText('Table filename fixture') | ||
| await expect.poll(() => viewerHeading.evaluate((element) => getComputedStyle(element).fontSize)).toBe('18px') | ||
| await expect.poll(() => viewerHeading.evaluate((element) => getComputedStyle(element).transform)).toBe('matrix(1, 0, 0, 1, 0, -1)') | ||
| const toolbar = dialog.locator('[data-hapi-table-viewer-toolbar="true"]') | ||
| await expect.poll(() => toolbar.evaluate((element) => getComputedStyle(element).borderBottomWidth)).toBe('0px') | ||
| await expect.poll(() => toolbar.evaluate((element) => `${getComputedStyle(element).paddingLeft}:${getComputedStyle(element).paddingRight}`)).toBe('6px:6px') | ||
| await expect.poll(() => toolbar.evaluate((element) => getComputedStyle(element).columnGap)).toBe('4px') | ||
| await expect.poll(() => toolbar.evaluate((element) => getComputedStyle(element).paddingTop)).toBe('0px') | ||
| await expect.poll(() => toolbar.evaluate((element) => getComputedStyle(element).paddingBottom)).toBe('0px') | ||
| const toolbarEdges = await toolbar.evaluate((element) => { | ||
| const buttons = element.querySelectorAll('button') | ||
| const first = buttons[0]?.getBoundingClientRect() | ||
| const last = buttons[buttons.length - 1]?.getBoundingClientRect() | ||
| const toolbarRect = element.getBoundingClientRect() | ||
| return { | ||
| leftGap: Math.round((first?.left ?? 0) - toolbarRect.left), | ||
| rightGap: Math.round(toolbarRect.right - (last?.right ?? 0)), | ||
| } | ||
| }) | ||
| expect(toolbarEdges).toEqual({ leftGap: 6, rightGap: 6 }) | ||
|
|
||
| const box = await dialog.boundingBox() | ||
| expect(box?.width).toBeGreaterThanOrEqual(1400) | ||
| expect(box?.height).toBeGreaterThanOrEqual(850) | ||
| await expect(dialog.locator('[data-hapi-table-viewer="true"] .aui-md-thead')).toBeVisible() | ||
| await expect.poll(async () => { | ||
| const toolbarHeight = (await toolbar.boundingBox())?.height ?? 0 | ||
| const headerHeight = await dialog.locator('[data-hapi-table-viewer="true"] thead').evaluate((element) => element.getBoundingClientRect().height) | ||
| return Math.round(toolbarHeight) - Math.round(headerHeight) | ||
| }).toBe(0) | ||
| const viewerLeftOffset = await dialog.locator('[data-hapi-table-viewer="true"]').evaluate((element) => { | ||
| const table = element.querySelector('table') | ||
| if (!table) return -1 | ||
| return Math.round(table.getBoundingClientRect().left - element.getBoundingClientRect().left) | ||
| }) | ||
| expect(viewerLeftOffset).toBe(0) | ||
| await expect.poll(() => dialog.locator('[data-hapi-table-viewer="true"]').evaluate((element) => getComputedStyle(element).paddingRight)).toBe('0px') | ||
| await expect.poll(() => dialog.locator('[data-hapi-table-viewer="true"]').evaluate((element) => getComputedStyle(element).paddingBottom)).toBe('0px') | ||
|
|
||
| await page.evaluate(() => { | ||
| let copied = '' | ||
| Object.defineProperty(window, '__hapiCopiedTableMarkdown', { | ||
| configurable: true, | ||
| get: () => copied, | ||
| }) | ||
| Object.defineProperty(navigator, 'clipboard', { | ||
| configurable: true, | ||
| value: { writeText: async (text: string) => { copied = text } }, | ||
| }) | ||
| }) | ||
| await dialog.getByRole('button', { name: 'Copy table as Markdown' }).click() | ||
| await expect.poll(() => page.evaluate(() => (window as Window & { __hapiCopiedTableMarkdown?: string }).__hapiCopiedTableMarkdown ?? '')).toContain('| Project | Stars |') | ||
|
|
||
| const imageDownloadPromise = page.waitForEvent('download') | ||
| await dialog.getByRole('button', { name: 'Save table as image' }).click() | ||
| const imageDownload = await imageDownloadPromise | ||
| expect(imageDownload.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.png$/) | ||
|
|
||
| const downloadPromise = page.waitForEvent('download') | ||
| await dialog.getByRole('button', { name: 'Download table as CSV' }).click() | ||
| const download = await downloadPromise | ||
| expect(download.suggestedFilename()).toMatch(/^HAPI Table-Table filename fixture-\d{14}\.csv$/) | ||
|
|
||
| await dialog.getByRole('button', { name: 'Close table full screen' }).click() | ||
| await expect(dialog).toBeHidden() | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[MAJOR] Prebundle the remaining Workbox client before making this suite mandatory.
The clean current-head run fails here. Its log reports
new dependencies optimized: workbox-windowfollowed byoptimized dependencies changed. reloading; that reload tears down the mobile table dialog, and the close-button click times out on a detached element.web/vite.config.ts:183already prebundles Workbox modules specifically to prevent this E2E reload, but omitsworkbox-window.Suggested fix: