Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,13 @@ body {
composed on top of the kind colour. */
.explain-graph .eg-node--ext { stroke-dasharray: 4 3; fill-opacity: 0.4; }

/* The card whose detail pane is open: an accent ring just outside the box, so
together with the card's own kind-coloured stroke it reads as a double border —
making the object you're working with unmistakable. The ring is injected by
schema-detail.js (markSelected); the marker class also thickens the own stroke. */
.explain-graph .eg-card-ring { fill: none; stroke: var(--accent); stroke-width: 2; }
.explain-graph .eg-card--selected .eg-node { stroke: var(--accent); stroke-width: 2; }

.schema-graph-view { position: relative; }
.schema-graph-legend {
position: absolute; top: 8px; left: 10px; pointer-events: none;
Expand Down
11 changes: 6 additions & 5 deletions src/ui/explain-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { qualifyIdent } from '../core/format.js';
import { fitBox, fitWidthBox, zoomBox, panBox, viewBoxStr } from '../core/panzoom.js';
import { straightEdgePoints, incidentEdges, dragDeltaToSvg, applyPositions, recordPosition, createMoveHistory } from '../core/graph-layout.js';
import { flashToast } from './toast.js';
import { clearSchemaSelection } from './schema-detail.js';

const ZOOM_STEP = 1.2; // per zoom-button press
const WHEEL_ZOOM_STEP = 1.04; // per ⌘/Ctrl+wheel notch — gentle, so trackpad/wheel zoom isn't jumpy
Expand Down Expand Up @@ -344,9 +345,9 @@ const schemaClick = (app) => (n) => {
};

// In the full schema view, clicking an object opens the detail pane (full columns
// / keys / partitions / DDL) instead of inserting SHOW CREATE — the pane carries
// its own "Insert SHOW CREATE" button. External (ext:) leaves have no detail; a
// ⌘/Ctrl-click is reserved for dragging the node, so it doesn't open the pane.
// / keys / partitions / DDL) instead of inserting SHOW CREATE, and rings the
// clicked card. External (ext:) leaves have no detail; a ⌘/Ctrl-click is reserved
// for dragging the node, so it doesn't open the pane.
// `targetDoc` is this view's own document (the tab or the overlay's host), threaded
// so a node click always opens the pane in the view it came from — even when
// several full views are open at once (no shared single-slot document).
Expand Down Expand Up @@ -601,7 +602,7 @@ function openInTab(app, win, childDoc, mainDoc) {
childDoc.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
const pane = childDoc.querySelector('.schema-detail');
if (pane) { e.stopPropagation(); pane.remove(); }
if (pane) { e.stopPropagation(); pane.remove(); clearSchemaSelection(childDoc); }
}, true);
return makeController(app, childDoc, mainDoc, canvas, bar, null);
});
Expand All @@ -617,7 +618,7 @@ function openInOverlay(app, mainDoc) {
if (e.key !== 'Escape') return;
e.stopPropagation();
const pane = mainDoc.querySelector('.schema-detail');
if (pane) pane.remove(); else close();
if (pane) { pane.remove(); clearSchemaSelection(mainDoc); } else close();
};
let backdrop;
// close() also tears down the interaction listeners attached to the main
Expand Down
62 changes: 52 additions & 10 deletions src/ui/schema-detail.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// The node detail pane for the fullscreen schema graph: a resizable strip docked
// at the bottom of the overlay panel, showing a clicked object's full columns
// (with key-role flags + compression sizes), per-partition part/row/byte sums, and
// its DDL — plus an "Insert SHOW CREATE" action. Pure DOM over the app controller;
// the data is fetched by app.actions.openNodeDetail (ch.loadTableDetail).
// its DDL. Pure DOM over the app controller; the data is fetched by
// app.actions.openNodeDetail (ch.loadTableDetail). Opening the pane also rings the
// clicked card in the graph so it's clear which object the pane describes.

import { h, withDocument, zoomScale } from './dom.js';
import { h, s, withDocument, zoomScale } from './dom.js';
import { Icon } from './icons.js';
import { clamp, formatRows, formatBytes, qualifyIdent } from '../core/format.js';
import { columnRoles } from '../core/schema-cards.js';
Expand All @@ -15,8 +16,9 @@ const TOP_MARGIN = 100;
/**
* Mount (or replace) the detail pane for `node` inside the live fullscreen overlay,
* populated from `detail` ({ columns, partitions, ddl }). Returns the pane element,
* or null when no overlay is open. The ✕ button closes just the pane; Esc closes
* the whole overlay (which removes the pane with it).
* or null when no overlay is open. The ✕ button and Esc both close just the pane
* and clear the card's selection ring (Esc is wired in explain-graph.js via the
* exported clearSchemaSelection); a further Esc / backdrop click closes the view.
*/
export function openDetailPane(app, node, detail, targetDoc) {
// `targetDoc` is the view's own document (a schema tab, or the overlay's host);
Expand All @@ -27,11 +29,52 @@ export function openDetailPane(app, node, detail, targetDoc) {
const prior = panel.querySelector('.schema-detail');
if (prior) prior.remove(); // re-opening for another node replaces the pane

return withDocument(doc, () => buildDetailPane(app, node, detail, panel));
return withDocument(doc, () => {
const pane = buildDetailPane(node, detail, panel);
markSelected(doc, node.id); // ring the clicked card so the selection is visible
return pane;
});
}

// Find a graph card by node id (a plain scan avoids escaping ids with dots/colons
// for an attribute selector). Only the rich full-view cards carry data-node-id.
function findCard(doc, nodeId) {
return [...doc.querySelectorAll('.eg-card[data-node-id]')].find((g) => g.getAttribute('data-node-id') === nodeId) || null;
}

// Clear the selection highlight in `doc`: drop the marker class and its ring rect
// from the selected card (the ring is always a child of that card). Exported so the
// graph's other pane-close paths — Esc in the schema tab / in-app overlay, in
// explain-graph.js — clear it too, not only the pane's own ✕ button.
export function clearSchemaSelection(doc) {
doc.querySelectorAll('.eg-card--selected').forEach((g) => {
g.classList.remove('eg-card--selected');
const ring = g.querySelector('.eg-card-ring');
if (ring) ring.remove();
});
}

// Mark `nodeId`'s card as selected: an accent ring drawn just outside its box (a
// "double border" alongside the card's own kind-coloured stroke) plus a class the
// CSS keys off. Replaces any prior selection. No-op when the card isn't drawn
// (e.g. the pane opened over a view without that card, or in a test harness).
function markSelected(doc, nodeId) {
clearSchemaSelection(doc);
const card = findCard(doc, nodeId);
if (!card) return;
card.classList.add('eg-card--selected');
const rect = card.querySelector('rect');
if (!rect) return;
const x = parseFloat(rect.getAttribute('x')) - 3;
const y = parseFloat(rect.getAttribute('y')) - 3;
const width = parseFloat(rect.getAttribute('width')) + 6;
const height = parseFloat(rect.getAttribute('height')) + 6;
// Behind the card content so the title/columns stay legible over the ring.
card.insertBefore(s('rect', { class: 'eg-card-ring', x, y, width, height, rx: '7' }), card.firstChild);
}

// Build + mount the pane (created in the active document via withDocument).
function buildDetailPane(app, node, detail, panel) {
function buildDetailPane(node, detail, panel) {
const doc = panel.ownerDocument;
const cols = detail.columns || [];
const parts = detail.partitions || [];
Expand Down Expand Up @@ -62,11 +105,10 @@ function buildDetailPane(app, node, detail, panel) {
const handle = h('div', { class: 'schema-detail-handle', title: 'Drag to resize' });
const pane = h('div', { class: 'schema-detail' },
handle,
h('button', { class: 'schema-detail-close', title: 'Close', onclick: () => pane.remove() }, Icon.close()),
h('button', { class: 'schema-detail-close', title: 'Close', onclick: () => { pane.remove(); clearSchemaSelection(doc); } }, Icon.close()),
h('div', { class: 'schema-detail-body' },
h('div', { class: 'schema-detail-head' },
h('b', null, ident), h('span', { class: 'schema-detail-kind' }, node.kind || 'table'),
h('button', { class: 'res-act', onclick: () => app.actions.insertCreate(ident) }, 'Insert SHOW CREATE')),
h('b', null, ident), h('span', { class: 'schema-detail-kind' }, node.kind || 'table')),
h('h4', null, 'Columns (' + cols.length + ')'),
colsTable,
partsSection,
Expand Down
17 changes: 13 additions & 4 deletions tests/unit/explain-graph.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -287,14 +287,23 @@ describe('schema lineage graph', () => {
expect(document.body.contains(overlay)).toBe(false);
});

it('Esc closes the open detail pane first, then the overlay', () => {
it('Esc closes the open detail pane first (clearing its card ring), then the overlay', () => {
openSchemaView(overlayApp({ openNodeDetail: vi.fn() })).render(GRAPH);
const overlay = overlayOf();
const panel = overlay.querySelector('.graph-overlay-panel');
const pane = document.createElement('div'); pane.className = 'schema-detail';
overlay.querySelector('.graph-overlay-panel').appendChild(pane);
panel.appendChild(pane);
// a selected card with a ring, as markSelected would have left it on the canvas
const card = document.createElementNS('http://www.w3.org/2000/svg', 'g');
card.setAttribute('class', 'eg-card eg-card--selected');
card.setAttribute('data-node-id', 'x.y');
card.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'rect')).setAttribute('class', 'eg-card-ring');
panel.appendChild(card);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(overlay.querySelector('.schema-detail')).toBeNull(); // pane closed
expect(document.body.contains(overlay)).toBe(true); // overlay stays
expect(overlay.querySelector('.schema-detail')).toBeNull(); // pane closed
expect(overlay.querySelector('.eg-card--selected')).toBeNull(); // selection class cleared
expect(overlay.querySelector('.eg-card-ring')).toBeNull(); // ring removed
expect(document.body.contains(overlay)).toBe(true); // overlay stays
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(document.body.contains(overlay)).toBe(false); // second Esc closes overlay
});
Expand Down
72 changes: 61 additions & 11 deletions tests/unit/schema-detail.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, afterEach } from 'vitest';
import { openDetailPane } from '../../src/ui/schema-detail.js';

afterEach(() => { document.body.innerHTML = ''; });
Expand All @@ -11,7 +11,22 @@ function mountPanel() {
document.body.appendChild(p);
return p;
}
const APP = (over = {}) => ({ document, actions: { insertCreate: vi.fn() }, ...over });
// A graph card stand-in (the rich full-view nodes carry data-node-id + a rect).
const SVG_NS = 'http://www.w3.org/2000/svg';
function mountCard(id, { rect = true } = {}) {
const g = document.createElementNS(SVG_NS, 'g');
g.setAttribute('class', 'eg-card');
g.setAttribute('data-node-id', id);
if (rect) {
const r = document.createElementNS(SVG_NS, 'rect');
r.setAttribute('class', 'eg-node eg-node--table');
r.setAttribute('x', '10'); r.setAttribute('y', '20'); r.setAttribute('width', '100'); r.setAttribute('height', '60');
g.appendChild(r);
}
document.body.appendChild(g);
return g;
}
const APP = (over = {}) => ({ document, actions: {}, ...over });
const NODE = { id: 'a.t', db: 'a', name: 't', kind: 'table' };
const DETAIL = {
columns: [
Expand Down Expand Up @@ -39,20 +54,55 @@ describe('openDetailPane', () => {
expect(pane.querySelector('.schema-detail-ddl').textContent).toContain('CREATE TABLE');
});

it('"Insert SHOW CREATE" runs insertCreate with the qualified ident', () => {
it('has no action button in the head (just the ident + kind)', () => {
mountPanel();
const pane = openDetailPane(APP(), NODE, DETAIL);
// the only button is the ✕ close affordance — no "Insert SHOW CREATE" etc.
const labels = [...pane.querySelectorAll('.schema-detail-head button')];
expect(labels).toHaveLength(0);
});

it('rings the clicked card with a double border (accent ring + selected class)', () => {
mountPanel();
const card = mountCard('a.t'); // NODE.id
openDetailPane(APP(), NODE, DETAIL);
expect(card.classList.contains('eg-card--selected')).toBe(true);
const ring = card.querySelector('.eg-card-ring');
expect(ring).not.toBeNull();
expect(card.firstChild).toBe(ring); // drawn behind the card content
expect(ring.getAttribute('x')).toBe('7'); // node x 10 − 3
expect(ring.getAttribute('width')).toBe('106'); // node w 100 + 6
});

it('moves the ring to the new card when another node is opened', () => {
mountPanel();
const app = APP();
const pane = openDetailPane(app, NODE, DETAIL);
const btn = [...pane.querySelectorAll('button')].find((b) => /Insert SHOW CREATE/.test(b.textContent));
btn.dispatchEvent(new Event('click', { bubbles: true }));
expect(app.actions.insertCreate).toHaveBeenCalledWith('a.t');
const cardA = mountCard('a.t');
const cardB = mountCard('a.u');
openDetailPane(APP(), NODE, DETAIL);
expect(cardA.classList.contains('eg-card--selected')).toBe(true);
openDetailPane(APP(), { id: 'a.u', db: 'a', name: 'u', kind: 'table' }, DETAIL);
expect(cardA.classList.contains('eg-card--selected')).toBe(false);
expect(cardA.querySelector('.eg-card-ring')).toBeNull();
expect(cardB.classList.contains('eg-card--selected')).toBe(true);
expect(cardB.querySelector('.eg-card-ring')).not.toBeNull();
});

it('marks a card with no rect (class only, no ring drawn)', () => {
mountPanel();
const card = mountCard('a.t', { rect: false });
openDetailPane(APP(), NODE, DETAIL);
expect(card.classList.contains('eg-card--selected')).toBe(true);
expect(card.querySelector('.eg-card-ring')).toBeNull();
});

it('the ✕ button removes just the pane', () => {
it('the ✕ button removes just the pane and clears the selection ring', () => {
mountPanel();
const card = mountCard('a.t');
const pane = openDetailPane(APP(), NODE, DETAIL);
pane.querySelector('.schema-detail-close').dispatchEvent(new Event('click', { bubbles: true }));
expect(document.querySelector('.schema-detail')).toBeNull();
expect(card.classList.contains('eg-card--selected')).toBe(false);
expect(card.querySelector('.eg-card-ring')).toBeNull();
});

it('dragging the handle resizes the pane, clamped to both bounds', () => {
Expand Down Expand Up @@ -107,15 +157,15 @@ describe('openDetailPane', () => {
const panel = childDoc.createElement('div');
panel.className = 'graph-overlay-panel';
childDoc.body.appendChild(panel);
const pane = openDetailPane({ document, actions: { insertCreate: vi.fn() } }, NODE, DETAIL, childDoc);
const pane = openDetailPane({ document, actions: {} }, NODE, DETAIL, childDoc);
expect(pane.ownerDocument).toBe(childDoc); // built in the child tab's document
expect(childDoc.querySelector('.schema-detail')).not.toBeNull();
expect(document.querySelector('.schema-detail')).toBeNull(); // not in the main document
});

it('falls back to the global document and tolerates missing columns/partitions', () => {
mountPanel();
const pane = openDetailPane({ actions: { insertCreate: vi.fn() } }, NODE, { ddl: '' }); // no document/detailDocument
const pane = openDetailPane({ actions: {} }, NODE, { ddl: '' }); // no document/detailDocument
expect(pane).not.toBeNull();
expect([...pane.querySelectorAll('h4')].map((e) => e.textContent)).toEqual(['Columns (0)']);
});
Expand Down
Loading