From 5e0cfa8b3019c09d2556d1601ccac3a03da8d2a4 Mon Sep 17 00:00:00 2001 From: Simon Willison Date: Sat, 8 Nov 2025 16:43:47 -0800 Subject: [PATCH] Initial prototype of row side panel, refs #2589 --- datasette/static/table.js | 313 +++++++++++++++++++ datasette/templates/_table.html | 190 +++++++++++- pyproject.toml | 1 + tests/test_row_detail_panel.py | 531 ++++++++++++++++++++++++++++++++ 4 files changed, 1034 insertions(+), 1 deletion(-) create mode 100644 tests/test_row_detail_panel.py diff --git a/datasette/static/table.js b/datasette/static/table.js index 0caeeb91..be03673c 100644 --- a/datasette/static/table.js +++ b/datasette/static/table.js @@ -330,6 +330,316 @@ function initAutocompleteForFilterValues(manager) { }); } +/** Initialize row detail side panel functionality */ +function initRowDetailPanel() { + const dialog = document.getElementById('rowDetailPanel'); + const closeButton = document.getElementById('closeRowDetail'); + const contentDiv = document.getElementById('rowDetailContent'); + const prevButton = document.getElementById('prevRowButton'); + const nextButton = document.getElementById('nextRowButton'); + const positionSpan = document.getElementById('rowPosition'); + + if (!dialog || !closeButton || !contentDiv || !prevButton || !nextButton) { + // Not on a table page with the panel + return; + } + + // State for navigation + let currentRowIndex = 0; + let allRows = []; // Array of objects: { element: DOMElement, pkValues: [...] } + let nextPageUrl = null; + let isLoadingMore = false; + let hasMoreRows = true; + + // Get primary key column names + function getPrimaryKeyNames() { + const headers = document.querySelectorAll('.rows-and-columns thead th[data-is-pk="1"]'); + return Array.from(headers).map(th => th.getAttribute('data-column')); + } + + const primaryKeyNames = getPrimaryKeyNames(); + + // Initialize the row list + function initializeRows() { + const domRows = document.querySelectorAll('.table-row-clickable'); + allRows = Array.from(domRows).map(row => ({ + element: row, + pkValues: extractPkValues(row) + })); + + // Check if there's a next page link + const nextLink = document.querySelector('a[href*="_next="]'); + nextPageUrl = nextLink ? nextLink.getAttribute('href') : null; + hasMoreRows = !!nextPageUrl; + } + + // Extract primary key values from a DOM row + function extractPkValues(row) { + const pkColumns = getPrimaryKeyColumns(); + const cells = row.querySelectorAll('td'); + return pkColumns.map(pk => { + const cell = cells[pk.index]; + if (!cell) return null; + return cell.getAttribute('data-value') || cell.textContent.trim(); + }); + } + + initializeRows(); + + // Prevent default cancel behavior (ESC key) to handle animation + dialog.addEventListener('cancel', (event) => { + event.preventDefault(); + animateCloseDialog(); + }); + + function animateCloseDialog() { + dialog.style.transform = 'translateX(100%)'; + setTimeout(() => { + dialog.close(); + }, 100); + } + + closeButton.addEventListener('click', () => { + animateCloseDialog(); + }); + + // Close on backdrop click + dialog.addEventListener('click', (event) => { + if (event.target === dialog) { + animateCloseDialog(); + } + }); + + // Get primary key column indices + function getPrimaryKeyColumns() { + const headers = document.querySelectorAll('.rows-and-columns thead th[data-is-pk="1"]'); + return Array.from(headers).map(th => { + const columnName = th.getAttribute('data-column'); + const index = Array.from(th.parentElement.children).indexOf(th); + return { name: columnName, index: index }; + }); + } + + // Construct row URL from row object (which has pkValues) + function getRowUrl(rowObj) { + if (!rowObj || !rowObj.pkValues || rowObj.pkValues.length === 0) { + return null; + } + + const pkValues = rowObj.pkValues; + + if (pkValues.some(v => v === null || v === '')) { + return null; + } + + // Construct the row path by joining PK values + const rowPath = pkValues.map(v => encodeURIComponent(v)).join(','); + + // Get current path and construct row URL + const currentPath = window.location.pathname; + return currentPath + '/' + rowPath + '.json'; + } + + // Fetch more rows from the next page using JSON API + async function fetchMoreRows() { + if (!nextPageUrl || isLoadingMore) { + return false; + } + + isLoadingMore = true; + try { + // Convert URL to JSON by adding .json before query params + let jsonUrl = nextPageUrl; + const urlParts = nextPageUrl.split('?'); + if (urlParts.length === 2) { + jsonUrl = urlParts[0] + '.json?' + urlParts[1]; + } else { + jsonUrl = nextPageUrl + '.json'; + } + + const response = await fetch(jsonUrl); + if (!response.ok) { + throw new Error(`Failed to fetch next page: ${response.status}`); + } + + const data = await response.json(); + + // Extract new rows from JSON + if (data.rows && data.rows.length > 0) { + const newRowObjects = data.rows.map(rowData => { + // Extract primary key values from the row data + const pkValues = primaryKeyNames.map(pkName => { + const value = rowData[pkName]; + return value !== null && value !== undefined ? String(value) : null; + }); + + return { + element: null, // No DOM element for paginated rows + pkValues: pkValues + }; + }); + + allRows.push(...newRowObjects); + } + + // Update next page URL from the response + nextPageUrl = data.next_url || null; + hasMoreRows = !!nextPageUrl; + + isLoadingMore = false; + return data.rows && data.rows.length > 0; + } catch (error) { + console.error('Error fetching more rows:', error); + isLoadingMore = false; + hasMoreRows = false; + return false; + } + } + + // Update navigation button states + function updateNavigationState() { + prevButton.disabled = currentRowIndex === 0; + + // Disable next if we're at the end and there are no more pages + const isAtEnd = currentRowIndex >= allRows.length - 1; + nextButton.disabled = isAtEnd && !hasMoreRows; + + // Update position display + if (allRows.length > 0) { + const displayIndex = currentRowIndex + 1; + positionSpan.textContent = `Row ${displayIndex}`; + } else { + positionSpan.textContent = ''; + } + } + + // Fetch and display row details + async function showRowDetails(rowIndex) { + if (rowIndex < 0 || rowIndex >= allRows.length) { + return; + } + + currentRowIndex = rowIndex; + const rowObj = allRows[rowIndex]; + const rowUrl = getRowUrl(rowObj); + + if (!rowUrl) { + contentDiv.innerHTML = '

Cannot display row: No primary key found

'; + showDialog(); + updateNavigationState(); + return; + } + + // Show loading state + contentDiv.innerHTML = '

Loading...

'; + updateNavigationState(); + + try { + const response = await fetch(rowUrl); + + if (!response.ok) { + throw new Error(`Failed to fetch row: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + // Display the row data + if (data.rows && data.rows.length > 0) { + const rowData = data.rows[0]; + let html = '
'; + + for (const [key, value] of Object.entries(rowData)) { + html += `
${escapeHtml(key)}
`; + + if (value === null) { + html += '
null
'; + } else if (typeof value === 'object') { + html += `
${escapeHtml(JSON.stringify(value, null, 2))}
`; + } else { + html += `
${escapeHtml(String(value))}
`; + } + } + + html += '
'; + contentDiv.innerHTML = html; + } else { + contentDiv.innerHTML = '

No row data found

'; + } + } catch (error) { + console.error('Error fetching row details:', error); + contentDiv.innerHTML = `

Error loading row details: ${escapeHtml(error.message)}

`; + } + + updateNavigationState(); + } + + // Handle previous button click + prevButton.addEventListener('click', () => { + if (currentRowIndex > 0) { + showRowDetails(currentRowIndex - 1); + } + }); + + // Handle next button click + nextButton.addEventListener('click', async () => { + const nextIndex = currentRowIndex + 1; + + // If we're at the end of current rows, try to fetch more + if (nextIndex >= allRows.length && hasMoreRows && !isLoadingMore) { + nextButton.disabled = true; + nextButton.textContent = 'Loading...'; + + const fetched = await fetchMoreRows(); + + nextButton.textContent = 'Next →'; + + if (fetched && nextIndex < allRows.length) { + showRowDetails(nextIndex); + } else { + updateNavigationState(); + } + } else if (nextIndex < allRows.length) { + showRowDetails(nextIndex); + } + }); + + function showDialog() { + // Reset transform before opening + dialog.style.transition = 'none'; + dialog.style.transform = 'translateX(100%)'; + + // Open the dialog + dialog.showModal(); + + // Trigger animation + void dialog.offsetWidth; + + dialog.style.transition = 'transform 0.1s cubic-bezier(0.2, 0, 0.38, 0.9)'; + dialog.style.transform = 'translateX(0)'; + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Add click handlers to all table rows (only for rows with DOM elements) + allRows.forEach((rowObj, index) => { + if (rowObj.element) { + rowObj.element.addEventListener('click', (event) => { + // Don't trigger if clicking on a link or button within the row + if (event.target.tagName === 'A' || event.target.tagName === 'BUTTON') { + return; + } + + showDialog(); + showRowDetails(index); + }); + } + }); +} + // Ensures Table UI is initialized only after the Manager is ready. document.addEventListener("datasette_init", function (evt) { const { detail: manager } = evt; @@ -340,4 +650,7 @@ document.addEventListener("datasette_init", function (evt) { // Other UI functions with interactive JS needs addButtonsToFilterRows(manager); initAutocompleteForFilterValues(manager); + + // Row detail panel + initRowDetailPanel(); }); diff --git a/datasette/templates/_table.html b/datasette/templates/_table.html index a1329ba7..3748c99c 100644 --- a/datasette/templates/_table.html +++ b/datasette/templates/_table.html @@ -22,7 +22,7 @@ {% for row in display_rows %} - + {% for cell in row %} {{ cell.value }} {% endfor %} @@ -34,3 +34,191 @@ {% else %}

0 records

{% endif %} + + + +
+
+

Row details

+ +
+
+ + + +
+
+

Loading...

+
+
+
+ + diff --git a/pyproject.toml b/pyproject.toml index 1395ce82..715ce3c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ test = [ "pytest-timeout>=1.4.2", "trustme>=0.7", "cogapp>=3.3.0", + "pytest-playwright>=0.7.1" ] rich = ["rich"] diff --git a/tests/test_row_detail_panel.py b/tests/test_row_detail_panel.py new file mode 100644 index 00000000..01424938 --- /dev/null +++ b/tests/test_row_detail_panel.py @@ -0,0 +1,531 @@ +""" +Playwright tests for the row detail side panel feature. +""" + +import pytest +import subprocess +import sys +import tempfile +import time +import httpx +from playwright.sync_api import expect + + +def wait_until_responds(url, timeout=5.0): + """Wait until a URL responds to HTTP requests""" + start = time.time() + while time.time() - start < timeout: + try: + httpx.get(url) + return + except httpx.ConnectError: + time.sleep(0.1) + raise AssertionError(f"Timed out waiting for {url} to respond") + + +@pytest.fixture(scope="module") +def datasette_server(): + """Start a Datasette server for testing""" + # Create a simple test database + import sqlite3 + import os + + db_path = os.path.join(tempfile.gettempdir(), "test_products.db") + # Remove if exists + if os.path.exists(db_path): + os.remove(db_path) + + conn = sqlite3.connect(db_path) + conn.execute( + """ + CREATE TABLE products ( + id INTEGER PRIMARY KEY, + name TEXT, + description TEXT, + price REAL, + category TEXT + ) + """ + ) + conn.execute( + """ + INSERT INTO products (name, description, price, category) VALUES + ('Laptop', 'High-performance laptop', 999.99, 'Electronics'), + ('Mouse', 'Wireless mouse', 29.99, 'Electronics'), + ('Desk', 'Standing desk', 499.99, 'Furniture'), + ('Chair', 'Ergonomic chair', 299.99, 'Furniture'), + ('Notebook', 'Spiral notebook', 4.99, 'Stationery') + """ + ) + conn.commit() + conn.close() + + # Start Datasette server + ds_proc = subprocess.Popen( + [sys.executable, "-m", "datasette", db_path, "-p", "8042"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + cwd=tempfile.gettempdir(), + ) + wait_until_responds("http://localhost:8042/") + + # Check it started successfully + assert not ds_proc.poll(), ds_proc.stdout.read().decode("utf-8") + + yield {"base_url": "http://localhost:8042", "db_name": "test_products"} + + # Shut down the server + ds_proc.terminate() + ds_proc.wait() + + # Clean up + if os.path.exists(db_path): + os.remove(db_path) + + +def test_row_detail_panel_elements_exist(page, datasette_server): + """Test that the row detail panel HTML elements exist""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the page to load + page.wait_for_selector(".rows-and-columns") + + # Check that the dialog element exists + dialog = page.locator("#rowDetailPanel") + assert dialog.count() == 1 + + # Check that the close button exists + close_button = page.locator("#closeRowDetail") + assert close_button.count() == 1 + + # Check that the content div exists + content_div = page.locator("#rowDetailContent") + assert content_div.count() == 1 + + +def test_row_click_opens_panel(page, datasette_server): + """Test that clicking a table row opens the side panel""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Get the dialog + dialog = page.locator("#rowDetailPanel") + + # Dialog should not be open initially + assert not dialog.evaluate("el => el.hasAttribute('open')") + + # Click the first row + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]", timeout=2000) + + # Dialog should now be open + assert dialog.evaluate("el => el.hasAttribute('open')") + + # Content should be loaded (not showing "Loading...") + content = page.locator("#rowDetailContent") + expect(content).not_to_contain_text("Loading...") + + +def test_row_panel_displays_data(page, datasette_server): + """Test that the row panel displays the correct data""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the first row (Laptop) + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open and content to load + page.wait_for_selector("#rowDetailPanel[open]") + page.wait_for_selector("#rowDetailContent dl") + + # Check that the content includes the expected data + content = page.locator("#rowDetailContent") + expect(content).to_contain_text("Laptop") + expect(content).to_contain_text("High-performance laptop") + expect(content).to_contain_text("999.99") + expect(content).to_contain_text("Electronics") + + +def test_close_button_closes_panel(page, datasette_server): + """Test that clicking the close button closes the panel""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click a row to open the panel + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Click the close button + close_button = page.locator("#closeRowDetail") + close_button.click() + + # Wait for the dialog to close + page.wait_for_timeout(200) # Wait for animation + + # Dialog should be closed + dialog = page.locator("#rowDetailPanel") + assert not dialog.evaluate("el => el.hasAttribute('open')") + + +def test_escape_key_closes_panel(page, datasette_server): + """Test that pressing Escape closes the panel""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click a row to open the panel + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Press Escape + page.keyboard.press("Escape") + + # Wait for the dialog to close + page.wait_for_timeout(200) # Wait for animation + + # Dialog should be closed + dialog = page.locator("#rowDetailPanel") + assert not dialog.evaluate("el => el.hasAttribute('open')") + + +@pytest.mark.skip( + reason="Backdrop click is difficult to test programmatically - works in manual testing" +) +def test_backdrop_click_closes_panel(page, datasette_server): + """Test that clicking the backdrop closes the panel""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click a row to open the panel + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Click the dialog backdrop (the dialog element itself, not the content) + dialog = page.locator("#rowDetailPanel") + # Get the bounding box and click outside the content area + box = dialog.bounding_box() + if box: + # Click on the left side of the dialog (the backdrop) + page.mouse.click(box["x"] + 10, box["y"] + box["height"] / 2) + + # Wait for the dialog to close + page.wait_for_timeout(200) # Wait for animation + + # Dialog should be closed + assert not dialog.evaluate("el => el.hasAttribute('open')") + + +def test_multiple_rows_different_data(page, datasette_server): + """Test that clicking different rows shows different data""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the first row + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + page.wait_for_selector("#rowDetailContent dl") + + # Check for first row data + content = page.locator("#rowDetailContent") + expect(content).to_contain_text("Laptop") + + # Close the panel + close_button = page.locator("#closeRowDetail") + close_button.click() + page.wait_for_timeout(200) + + # Click the second row + second_row = page.locator(".table-row-clickable").nth(1) + second_row.click() + + # Wait for the dialog to open again + page.wait_for_selector("#rowDetailPanel[open]") + page.wait_for_selector("#rowDetailContent dl") + + # Check for second row data + expect(content).to_contain_text("Mouse") + expect(content).to_contain_text("Wireless mouse") + + +def test_row_hover_state(page, datasette_server): + """Test that rows have hover state styling""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Get the first row + first_row = page.locator(".table-row-clickable").first + + # Check that the row has cursor: pointer + cursor_style = first_row.evaluate("el => window.getComputedStyle(el).cursor") + assert cursor_style == "pointer" + + +def test_navigation_buttons_exist(page, datasette_server): + """Test that navigation buttons are present""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click a row to open the panel + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Check that navigation buttons exist + prev_button = page.locator("#prevRowButton") + next_button = page.locator("#nextRowButton") + position = page.locator("#rowPosition") + + assert prev_button.count() == 1 + assert next_button.count() == 1 + assert position.count() == 1 + + +def test_previous_button_disabled_on_first_row(page, datasette_server): + """Test that previous button is disabled on the first row""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the first row + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Previous button should be disabled + prev_button = page.locator("#prevRowButton") + assert prev_button.is_disabled() + + # Next button should be enabled + next_button = page.locator("#nextRowButton") + assert not next_button.is_disabled() + + +def test_next_button_navigation(page, datasette_server): + """Test that next button navigates to the next row""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the first row + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open and content to load + page.wait_for_selector("#rowDetailPanel[open]") + page.wait_for_selector("#rowDetailContent dl") + + # Should show Laptop data + content = page.locator("#rowDetailContent") + expect(content).to_contain_text("Laptop") + + # Click next button + next_button = page.locator("#nextRowButton") + next_button.click() + + # Wait for content to update + page.wait_for_timeout(300) + + # Should now show Mouse data + expect(content).to_contain_text("Mouse") + expect(content).to_contain_text("29.99") + + +def test_previous_button_navigation(page, datasette_server): + """Test that previous button navigates to the previous row""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the second row + second_row = page.locator(".table-row-clickable").nth(1) + second_row.click() + + # Wait for the dialog to open and content to load + page.wait_for_selector("#rowDetailPanel[open]") + page.wait_for_selector("#rowDetailContent dl") + + # Should show Mouse data + content = page.locator("#rowDetailContent") + expect(content).to_contain_text("Mouse") + + # Previous button should be enabled now + prev_button = page.locator("#prevRowButton") + assert not prev_button.is_disabled() + + # Click previous button + prev_button.click() + + # Wait for content to update + page.wait_for_timeout(300) + + # Should now show Laptop data + expect(content).to_contain_text("Laptop") + + # Previous button should now be disabled (we're at first row) + assert prev_button.is_disabled() + + +def test_row_position_updates(page, datasette_server): + """Test that row position indicator updates correctly""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the first row + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Check position indicator shows "Row 1" + position = page.locator("#rowPosition") + expect(position).to_have_text("Row 1") + + # Click next + next_button = page.locator("#nextRowButton") + next_button.click() + page.wait_for_timeout(300) + + # Position should update to "Row 2" + expect(position).to_have_text("Row 2") + + +def test_pagination_navigation(page, datasette_server): + """Test that navigation works across pagination boundaries""" + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + + # Add page_size parameter to force pagination + page.goto(f"{base_url}/{db_name}/products?_size=2") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click the second (last visible) row + second_row = page.locator(".table-row-clickable").nth(1) + second_row.click() + + # Wait for the dialog to open and content to load + page.wait_for_selector("#rowDetailPanel[open]") + page.wait_for_selector("#rowDetailContent dl") + + # Should show Mouse data (second row) + content = page.locator("#rowDetailContent") + expect(content).to_contain_text("Mouse") + + # Next button should be enabled (there are more rows via pagination) + next_button = page.locator("#nextRowButton") + assert not next_button.is_disabled() + + # Click next button - should load the third row from the next page + next_button.click() + + # Wait for loading and content update + page.wait_for_timeout(1000) # Give time for pagination fetch + + # Should now show Desk data (third row, from next page) + expect(content).to_contain_text("Desk") + + # Previous button should work to go back + prev_button = page.locator("#prevRowButton") + assert not prev_button.is_disabled() + prev_button.click() + page.wait_for_timeout(300) + + # Should be back to Mouse + expect(content).to_contain_text("Mouse") + + +@pytest.mark.skip(reason="Mobile viewport test - enable if needed") +def test_panel_responsive_on_mobile(page, datasette_server): + """Test that the panel is responsive on mobile viewports""" + # Set mobile viewport + page.set_viewport_size({"width": 375, "height": 667}) + + base_url = datasette_server["base_url"] + db_name = datasette_server["db_name"] + page.goto(f"{base_url}/{db_name}/products") + + # Wait for the table to load + page.wait_for_selector(".rows-and-columns tbody tr") + + # Click a row + first_row = page.locator(".table-row-clickable").first + first_row.click() + + # Wait for the dialog to open + page.wait_for_selector("#rowDetailPanel[open]") + + # Check that the panel width is appropriate for mobile + dialog = page.locator("#rowDetailPanel") + width = dialog.evaluate("el => el.offsetWidth") + viewport_width = page.viewport_size["width"] + + # Panel should take most of the width on mobile (90%) + assert width > viewport_width * 0.85 # Allow some margin