datajunction-ui
Version:
DataJunction UI
359 lines (326 loc) • 12.1 kB
JavaScript
/**
* CodeMirror 6 extension that styles DJ node references in the SQL editor
* as inline chips with a status indicator.
*
* Catalog-qualified tables (`catalog.schema.table`, where `catalog` is in
* the known-catalogs list) get wrapped in a chip whose color + trailing icon
* reflect the auto-register flow:
*
* ⟳ registering ✓ valid (registered or already known) ✗ invalid
*
* The extension is purely a display layer — it reads status from a
* caller-provided `getStatus(key)` and never writes data itself. The host
* component (NodeQueryField) drives the registration flow and dispatches
* `refreshBadges` whenever status changes so decorations rebuild even when
* the doc itself hasn't changed.
*/
import { ViewPlugin, Decoration, hoverTooltip } from '@codemirror/view';
import { StateEffect } from '@codemirror/state';
// Match dotted identifiers of any length (2+ segments). Greedy `+` keeps
// `a.b.c.d` as a single match instead of splitting into `a.b` + `.c.d`.
// Each segment is `[a-zA-Z_]\w*`, optionally backtick-quoted.
const DOTTED_REF_RE =
/`?[a-zA-Z_][a-zA-Z0-9_]*`?(?:\.`?[a-zA-Z_][a-zA-Z0-9_]*`?)+/g;
function unbacktick(s) {
return s.replace(/`/g, '');
}
export const refreshBadges = StateEffect.define();
function classForStatus(statusKind) {
switch (statusKind) {
case 'registering':
return 'dj-node-chip--registering';
case 'valid':
return 'dj-node-chip--valid';
case 'warning':
return 'dj-node-chip--warning';
case 'invalid':
return 'dj-node-chip--invalid';
default:
return 'dj-node-chip--unknown';
}
}
function buildDecorations(view, getStatus, getKnownCatalogs) {
const known = new Set((getKnownCatalogs() || []).map(c => c.toLowerCase()));
const doc = view.state.doc;
const ranges = [];
for (const { from, to } of view.visibleRanges) {
const slice = doc.sliceString(from, to);
DOTTED_REF_RE.lastIndex = 0;
let m;
while ((m = DOTTED_REF_RE.exec(slice)) !== null) {
const whole = m[0];
const key = unbacktick(whole);
const status = getStatus(key);
const segments = key.split('.');
let refType;
if (status?.refType) {
// validateNode told us what this is; honor that.
refType = status.refType;
} else if (
segments.length === 3 &&
known.has(segments[0].toLowerCase())
) {
// 3-part with a known catalog: candidate source registration.
refType = 'source';
} else {
// No status entry and not a catalog-qualified table — skip.
// This avoids false positives on `alias.column` patterns.
continue;
}
const cls = `dj-node-chip dj-node-chip--${refType} ${classForStatus(
status?.kind,
)}`;
const start = from + m.index;
const end = start + whole.length;
ranges.push({
from: start,
to: end,
deco: Decoration.mark({
class: cls,
attributes: status?.message ? { title: status.message } : undefined,
}),
});
}
}
ranges.sort((a, b) => a.from - b.from || a.to - b.to);
return Decoration.set(ranges.map(r => r.deco.range(r.from, r.to)));
}
/**
* Factory. Returns a CodeMirror extension that decorates DJ node refs.
*
* getStatus(key) — returns { kind, message? } | undefined for `catalog.schema.table`
* getKnownCatalogs() — returns string[] (lowercase catalog names)
*
* Both are read on every rebuild, so pass ref-backed closures and update
* the ref synchronously before dispatching `refreshBadges` — otherwise the
* extension reads pre-update state.
*/
export function djNodeBadges({ getStatus, getKnownCatalogs }) {
return ViewPlugin.fromClass(
class {
constructor(view) {
this.decorations = buildDecorations(view, getStatus, getKnownCatalogs);
}
update(update) {
const refreshed = update.transactions.some(tr =>
tr.effects.some(e => e.is(refreshBadges)),
);
if (update.docChanged || update.viewportChanged || refreshed) {
this.decorations = buildDecorations(
update.view,
getStatus,
getKnownCatalogs,
);
}
}
},
{ decorations: v => v.decorations },
);
}
/**
* Find the dotted ref under a given document position. Returns
* `{ key, from, to }` if the position falls inside a 2+-segment dotted
* identifier, else null. Used by the hover tooltip to identify which chip
* the cursor is on.
*/
export function refAtPos(view, pos) {
const line = view.state.doc.lineAt(pos);
DOTTED_REF_RE.lastIndex = 0;
let m;
while ((m = DOTTED_REF_RE.exec(line.text)) !== null) {
const start = line.from + m.index;
const end = start + m[0].length;
if (pos >= start && pos <= end) {
return { key: unbacktick(m[0]), from: start, to: end };
}
}
return null;
}
export function renderTooltipDom(status, refKey) {
const wrap = document.createElement('div');
wrap.className = 'dj-node-tooltip';
const node = status.node || {};
// Header — type pill + qualified name.
const header = document.createElement('div');
header.className = 'dj-node-tooltip__header';
const pill = document.createElement('span');
pill.className = `dj-node-chip dj-node-chip--${status.refType || 'node'}`;
pill.textContent = status.refType || 'node';
header.appendChild(pill);
const name = document.createElement('span');
name.className = 'dj-node-tooltip__name';
name.textContent = node.name || refKey;
header.appendChild(name);
wrap.appendChild(header);
// Special-case transient + error states — no metadata grid, just one line.
if (status.kind === 'invalid') {
const note = document.createElement('div');
note.className = 'dj-node-tooltip__note';
note.textContent = 'Not found in DJ.';
wrap.appendChild(note);
return wrap;
}
if (status.kind === 'registering') {
const note = document.createElement('div');
note.className = 'dj-node-tooltip__note';
note.textContent = 'Registering…';
wrap.appendChild(note);
return wrap;
}
// Metadata grid (2 columns of label/value pairs).
const grid = document.createElement('div');
grid.className = 'dj-node-tooltip__grid';
const addCell = (label, value) => {
if (value == null || value === '') return;
const cell = document.createElement('div');
cell.className = 'dj-node-tooltip__cell';
const l = document.createElement('span');
l.className = 'dj-node-tooltip__label';
l.textContent = label;
const v = document.createElement('span');
v.className = 'dj-node-tooltip__value';
v.textContent = value;
cell.appendChild(l);
cell.appendChild(v);
grid.appendChild(cell);
};
if (node.status) addCell('Status', node.status);
if (node.version) addCell('Version', node.version);
if (node.mode) addCell('Mode', node.mode);
if (Array.isArray(node.columns)) {
addCell('Columns', String(node.columns.length));
}
if (grid.childElementCount > 0) wrap.appendChild(grid);
// Description (truncated if long).
if (node.description) {
const d = document.createElement('div');
d.className = 'dj-node-tooltip__description';
const text = node.description;
d.textContent = text.length > 280 ? text.slice(0, 277) + '…' : text;
wrap.appendChild(d);
}
// Column list — scrollable. The point of the popover for someone writing
// SQL is to see what's available to SELECT without leaving the editor.
if (Array.isArray(node.columns) && node.columns.length > 0) {
const list = document.createElement('div');
list.className = 'dj-node-tooltip__columns';
for (const col of node.columns) {
const row = document.createElement('div');
row.className = 'dj-node-tooltip__col-row';
const n = document.createElement('span');
n.className = 'dj-node-tooltip__col-name';
n.textContent = col.name;
row.appendChild(n);
// Mark columns that link to a dimension — useful signal for cube/SQL work.
if (col.dimension || col.dimension_column) {
const tag = document.createElement('span');
tag.className = 'dj-node-tooltip__col-tag';
tag.textContent = 'dim';
tag.title = col.dimension?.name
? `Links to ${col.dimension.name}`
: 'Linked dimension';
row.appendChild(tag);
}
const t = document.createElement('span');
t.className = 'dj-node-tooltip__col-type';
// Struct/array types can be enormous (multi-line) — keep it on one line
// and let CSS clamp; full info is on the node page.
t.textContent = (col.type || '').split('\n')[0];
t.title = col.type || '';
row.appendChild(t);
list.appendChild(row);
}
wrap.appendChild(list);
}
// Footer link.
if (node.name) {
const foot = document.createElement('div');
foot.className = 'dj-node-tooltip__footer';
const link = document.createElement('a');
link.href = `/nodes/${encodeURIComponent(node.name)}`;
link.target = '_blank';
link.rel = 'noreferrer noopener';
link.textContent = 'Open node →';
foot.appendChild(link);
wrap.appendChild(foot);
}
return wrap;
}
// Cache of node-detail fetches so re-hovering the same chip doesn't refetch.
// Lives at module scope (one editor + dom = one cache) — small enough that
// not bothering with an LRU.
const nodeDetailsCache = new Map();
/**
* Hover tooltip extension. Pairs with djNodeBadges so the same
* `getStatus` source of truth drives the popover content. Additionally
* lazy-fetches the full node (columns, description, mode, version) on
* hover via `fetchNodeDetails`, and re-renders the tooltip in place
* when the promise resolves.
*/
export function djNodeHoverTooltip({
getStatus,
getKnownCatalogs,
fetchNodeDetails,
}) {
return hoverTooltip(
(view, pos) => {
const hit = refAtPos(view, pos);
if (!hit) return null;
const status = getStatus(hit.key);
if (!status) {
// No status entry; only show tooltip for catalog-qualified refs that
// would render as a chip — otherwise we'd pop up on every `t.column`.
const known = new Set(
(getKnownCatalogs() || []).map(c => c.toLowerCase()),
);
const segments = hit.key.split('.');
if (segments.length !== 3 || !known.has(segments[0].toLowerCase())) {
return null;
}
}
return {
pos: hit.from,
end: hit.to,
above: true,
create: () => {
const baseStatus = status || { refType: 'source' };
const dom = renderTooltipDom(baseStatus, hit.key);
// Lazy-fetch the full node so we can show columns + description.
// The validateNode dep object only carries {name, type, status}.
if (
fetchNodeDetails &&
baseStatus.kind !== 'invalid' &&
baseStatus.kind !== 'registering'
) {
const cached = nodeDetailsCache.get(hit.key);
const promise = cached || fetchNodeDetails(hit.key);
if (!cached) nodeDetailsCache.set(hit.key, promise);
Promise.resolve(promise)
.then(full => {
if (!full) return;
const richDom = renderTooltipDom(
{
...baseStatus,
node: { ...(baseStatus.node || {}), ...full },
},
hit.key,
);
if (dom.parentNode) {
dom.parentNode.replaceChild(richDom, dom);
} else {
// Tooltip already detached — swap children so future refs
// see the rich content.
dom.replaceChildren(...richDom.childNodes);
}
})
.catch(() => {
// Soft-fail — leave the bare header tooltip in place.
});
}
return { dom };
},
};
},
{ hoverTime: 150 },
);
}