diff --git a/src/styles.css b/src/styles.css index 6a9fc64..75cc72b 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; diff --git a/src/ui/explain-graph.js b/src/ui/explain-graph.js index 9584454..df8abd2 100644 --- a/src/ui/explain-graph.js +++ b/src/ui/explain-graph.js @@ -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 @@ -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). @@ -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); }); @@ -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 diff --git a/src/ui/schema-detail.js b/src/ui/schema-detail.js index 5f9f5f7..80ca22f 100644 --- a/src/ui/schema-detail.js +++ b/src/ui/schema-detail.js @@ -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'; @@ -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); @@ -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 || []; @@ -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, diff --git a/tests/unit/explain-graph.test.js b/tests/unit/explain-graph.test.js index 9f1fc7a..5704a1a 100644 --- a/tests/unit/explain-graph.test.js +++ b/tests/unit/explain-graph.test.js @@ -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 }); diff --git a/tests/unit/schema-detail.test.js b/tests/unit/schema-detail.test.js index 9e80d14..fbe2481 100644 --- a/tests/unit/schema-detail.test.js +++ b/tests/unit/schema-detail.test.js @@ -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 = ''; }); @@ -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: [ @@ -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', () => { @@ -107,7 +157,7 @@ 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 @@ -115,7 +165,7 @@ describe('openDetailPane', () => { 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)']); });