Detailed analysis of the side panel implementation for displaying row details (#2589), covering security, correctness, accessibility, and architecture concerns with prioritized recommendations. https://claude.ai/code/session_01Vb1fG6H2ZxsX3NpNxuVAgd
10 KiB
Review: row-panel branch — Side Panel for Row Details
Branch: origin/row-panel
Commits: 2 (5e0cfa8b Initial prototype, 472caf4e Install Playwright in CI)
Reference: #2589
Summary
The row-panel branch adds a side panel that slides in from the right when a user clicks a table row. It fetches the row's JSON data via the existing /{db}/{table}/{pk}.json API and displays it in a <dialog> element with prev/next navigation. The implementation spans:
datasette/static/table.js— +310 lines:initRowDetailPanel()functiondatasette/templates/_table.html— +190 lines: dialog HTML + inline<style>blocktests/test_row_detail_panel.py— +531 lines: Playwright browser testspyproject.toml/.github/workflows/test.yml— Addspytest-playwrightdependency and CI setup
What works well
- Uses the native
<dialog>element withshowModal(), which gives correct focus trapping, backdrop, and Escape key behavior for free. - Prev/next navigation with automatic pagination — when the user navigates past the last visible row, it fetches the next page via the JSON API.
- The
escapeHtml()function properly creates a text node to escape HTML before inserting viainnerHTML, preventing XSS from row values. - Comprehensive Playwright test suite covering open/close, navigation, pagination, keyboard, and responsive scenarios.
Issues to address for production readiness
1. Security: XSS via error messages
In showRowDetails(), the error path interpolates error.message directly into innerHTML:
contentDiv.innerHTML = `<p class="error">Error loading row details: ${escapeHtml(error.message)}</p>`;
This is safe because it uses escapeHtml(). However, the "No primary key found" and "No row data found" messages are hardcoded strings set via innerHTML — while safe today, this pattern is fragile. Consider using textContent for all static messages and reserving innerHTML only for the structured <dl> rendering.
2. _table.html is included on the row detail page too
The row.html template also does {% include custom_table_templates %}, which resolves to _table.html. This means the side panel <dialog>, all its CSS, and the initRowDetailPanel() initialization will also run on /db/table/pk pages. On that page:
- The table has a single row, so clicking it would open a panel showing the same data already visible on the page.
- The
<dialog>markup (with idrowDetailPanel) would be present but provide no value.
Fix: Either guard the dialog HTML with a template conditional (e.g., {% if is_table_view %}), or move the dialog markup into table.html instead of _table.html.
3. Missing data-value attribute on <td> elements
The extractPkValues() function reads cell.getAttribute('data-value') with a fallback to cell.textContent.trim():
return cell.getAttribute('data-value') || cell.textContent.trim();
But the _table.html template does not emit a data-value attribute:
<td class="col-{{ cell.column|to_css_class }} type-{{ cell.value_type }}">{{ cell.value }}</td>
This means PK extraction always falls back to textContent. This works for simple cases, but will break when:
- The
render_cellplugin hook transforms the display value (e.g., adding links or formatting) - The value contains HTML entities
- Binary/blob primary keys are displayed differently from their raw value
Fix: Add data-value="{{ cell.raw|e }}" to <td> elements (at least for PK columns), using the raw field that already exists in the cell context.
4. Compound and non-integer primary keys
getRowUrl() joins PK values with commas:
const rowPath = pkValues.map(v => encodeURIComponent(v)).join(',');
The comma separator for compound PKs is correct (path_from_row_pks at datasette/utils/__init__.py:192 uses ",".join(bits)). However, each PK component is tilde-encoded on the server side — e.g., a PK value of foo/bar becomes foo~2Fbar, and a comma in a PK value becomes ~2C. The JS code uses encodeURIComponent(v) which produces percent-encoding (foo%2Fbar, %2C). The server expects tilde-encoded paths, so this mismatch will cause 404 errors for any PK value containing characters that tilde-encoding and percent-encoding handle differently (which includes /, ,, +, space, and many others).
Fix: Either implement a JS equivalent of tilde_encode() (see datasette/utils/__init__.py:1278), or — more robustly — extract the row URL from the existing <a> tag that Datasette already renders in the PK column of each row.
5. Row URL construction assumes table view path structure
const currentPath = window.location.pathname;
return currentPath + '/' + rowPath + '.json';
This naively appends to window.location.pathname. Issues:
- If the URL has a
base_urlprefix configured, this still works (pathname includes it). OK. - If query parameters like
_sort,_size, etc. are in the URL,pathnamewon't include them. OK. - But if the table name itself needs encoding (e.g., tables with dots or special chars), the current pathname may not match what
getRowUrlexpects. - Most critically: this doesn't work for views/canned queries, only for actual table pages. Since the panel is injected via
_table.html, it could theoretically appear in contexts where this path construction is wrong.
Fix: Extract the row link directly from the rendered <a> tag that Datasette already puts in PK columns, rather than constructing it from scratch. This would be more resilient.
6. Inline styles should be in a CSS file
The branch adds ~120 lines of CSS inside a <style> tag in _table.html. Datasette has a datasette/static/ directory for static assets. Inline styles in a template:
- Can't be cached independently by browsers
- Are duplicated on every page load
- Mix concerns between template structure and presentation
- Will be duplicated if the template is included multiple times
Fix: Move the CSS to a separate file like datasette/static/row-panel.css and include it via a <link> tag or append it to an existing stylesheet.
7. No keyboard navigation within the panel
While Escape closes the panel (via native <dialog> behavior), there are no keyboard shortcuts for:
- Arrow left/right to navigate between rows
- Pressing Enter on a row to open the panel
These would be expected for accessibility (WCAG).
8. Accessibility gaps
- The panel has
aria-labelon buttons, which is good. - Missing
roleattributes oraria-liveregion for the content area. When row data loads asynchronously, screen readers won't announce the new content. - The "Row 1" position indicator should be more descriptive (e.g., "Row 1 of 5" or "Row 1 of 5, Laptop").
- No visible focus indicator for the dialog itself.
- The
×close button character should use×or an SVG icon with properaria-label(it does havearia-label="Close", which is good).
9. Animation timing issues
function animateCloseDialog() {
dialog.style.transform = 'translateX(100%)';
setTimeout(() => {
dialog.close();
}, 100);
}
The 100ms timeout is hardcoded and races against the CSS transition (also 100ms). If the JS event loop is busy, the close() call may fire before or during the animation.
Fix: Use the transitionend event instead of setTimeout:
dialog.addEventListener('transitionend', () => { dialog.close(); }, { once: true });
dialog.style.transform = 'translateX(100%)';
10. Playwright test infrastructure is heavy for this project
The existing test suite uses pytest + httpx async client (no browser). This branch introduces Playwright, which:
- Adds a significant CI dependency (browser binaries ~300MB+ cached)
- Changes the test.yml workflow for all CI runs, not just the panel tests
- The tests spawn a real Datasette subprocess on a fixed port (8042), which could conflict in parallel test runs or CI
- The
scope="module"fixture means all tests share one server but get freshpagefixtures — this is fine for Playwright but the fixed port is fragile.
Suggestion: Consider whether the simpler tests (elements exist, CSS cursor style) could be unit tests checking the HTML output via ds_client.get(). Reserve Playwright for the interactive behaviors (click-open, navigation, pagination). Also use a random available port instead of hardcoding 8042.
11. No feature flag or way to disable
There's no setting to disable the side panel. If a Datasette instance customizes _table.html via template overrides, the panel markup would need to be manually added. Conversely, users who don't want the click behavior have no way to opt out.
Suggestion: Consider a metadata/settings flag like "row_detail_panel": false to disable it, and/or make it a plugin rather than a core feature.
12. Panel doesn't reflect applied filters or column selections
When a user has ?_col=name&_col=price or filters applied, the side panel still fetches the full row JSON (all columns). This could be confusing if the user has deliberately hidden columns.
13. No link to the full row page
The panel shows row data but doesn't provide a link to the actual row detail page (/db/table/pk). Users might want to navigate there for the full view with foreign key links, related rows, etc.
Recommended priority order
| Priority | Issue | Effort |
|---|---|---|
| P0 | #4 — Tilde encoding for PK values (will cause 404s for PKs with special chars) | Small |
| P0 | #2 — Panel appears on row detail page unnecessarily | Small |
| P0 | #5 — Fragile URL construction (extract from existing links instead) | Medium |
| P1 | #3 — Add data-value to PK <td> elements |
Small |
| P1 | #6 — Move inline CSS to static file | Small |
| P1 | #9 — Fix animation race condition | Small |
| P1 | #13 — Add link to full row page in panel | Small |
| P2 | #8 — Accessibility (aria-live, keyboard nav) | Medium |
| P2 | #10 — Test infrastructure refinements | Medium |
| P2 | #7 — Keyboard shortcuts for navigation | Small |
| P3 | #11 — Feature flag / opt-out mechanism | Medium |
| P3 | #12 — Reflect column selections in panel | Medium |