diff --git a/README.md b/README.md index 50fb28f..451d61e 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,38 @@ of scope for a textarea and tracked separately (CodeMirror, issue #21). > work. The `.jsx` files there are React prototypes; production is the vanilla > ES-module code under `src/`. +## Saved queries & the Library + +Queries you save (★ **Save** next to Run, or `⌘S`) land in the sidebar **★ Library** +panel. Each carries a name, an optional **description**, and — when set — its +remembered result view and chart config. Saving or editing a query opens a small +form with both a name and a description field; the description shows under the +row and is included in Markdown/SQL exports. + +The whole collection is treated as a **document — the Library** — with a name and +an unsaved-changes dot, managed from the header **File ▾** menu: + +- **New Library** — clears to an empty, default-named library (confirms first + when non-empty). Open editor tabs are unaffected. +- **Save JSON** (`.json`) — downloads the whole Library in the versioned + `altinity-sql-browser/saved-queries` envelope (lossless: keeps id, name, + description, sql, favorite, chart, view). The filename derives from the Library + name; saving clears the unsaved-changes dot. +- **Replace… / Append…** — load a `.json` file: Replace swaps the Library and + adopts the file's base name (confirms when the current Library is non-empty); + Append merges via the existing dedupe and reports `Added N · updated N · + skipped N`. **JSON is the only importable format**, and imported SQL is never + run automatically. +- **Share / publish** — **Download Markdown** (`.md`, a `### heading` + fenced + ` ```sql ` cookbook) and **Download SQL** (`.sql`, `/* name + description */` + comment blocks, `;`-delimited). Both are **one-way** — lossy by design (no ids, + chart, or view), so JSON stays the canonical round-trip format. + +The Library name is editable inline (click it in the header) and is persisted +separately from the queries. The **•** dot appears after any change that hasn't +been written to a file yet (save/rename/delete/favorite/append/rename) and clears +on Save JSON / Replace / New. + ## Quick start (development) ```bash diff --git a/src/core/saved-io.js b/src/core/saved-io.js index d824bd6..15f6b6a 100644 Binary files a/src/core/saved-io.js and b/src/core/saved-io.js differ diff --git a/src/state.js b/src/state.js index a1f6323..115b049 100644 --- a/src/state.js +++ b/src/state.js @@ -5,7 +5,7 @@ import { clamp } from './core/format.js'; import { mergeSaved } from './core/saved-io.js'; import { cloneChartCfg } from './core/chart-data.js'; -import { loadJSON, saveJSON, loadStr } from './core/storage.js'; +import { loadJSON, saveJSON, loadStr, saveStr } from './core/storage.js'; /** A tab's chart state as a persistable payload `{ cfg, key }`, or null. */ export function tabChart(tab) { @@ -24,8 +24,12 @@ export const KEYS = { sidePanel: 'asb:sidePanel', saved: 'asb:saved', history: 'asb:history', + libraryName: 'asb:libraryName', }; +/** Default name for a fresh / unnamed saved-query library. */ +export const DEFAULT_LIBRARY_NAME = 'SQL Library'; + /** A blank query tab. `chartCfg`/`chartKey` hold the per-tab chart config and * the schema signature it was derived for (re-derived when the schema changes). */ export function newTabObj(id) { @@ -60,6 +64,11 @@ export function createState(read = { loadJSON, loadStr }) { sidePanel: read.loadStr(KEYS.sidePanel, 'saved'), savedQueries: read.loadJSON(KEYS.saved, []), history: read.loadJSON(KEYS.history, []), + // The saved-query collection treated as a named document ("the Library"). + // `libraryName` is persisted; `libraryDirty` (unsaved changes since the last + // file Save/Replace/New) is session-only and resets on reload. + libraryName: read.loadStr(KEYS.libraryName, DEFAULT_LIBRARY_NAME), + libraryDirty: false, shortcutsOpen: false, }; } @@ -115,6 +124,7 @@ export function saveQuery(state, tab, name, description, save = saveJSON, now = tab.savedId = entry.id; } tab.name = nm; + state.libraryDirty = true; save(KEYS.saved, state.savedQueries); return entry; } @@ -134,6 +144,7 @@ export function renameSaved(state, id, name, description, save = saveJSON) { if (desc) entry.description = desc; else delete entry.description; } for (const t of tabsForSaved(state, id)) t.name = nm; + state.libraryDirty = true; save(KEYS.saved, state.savedQueries); } @@ -142,6 +153,7 @@ export function toggleFavorite(state, id, save = saveJSON) { const entry = state.savedQueries.find((q) => q.id === id); if (!entry) return; entry.favorite = !entry.favorite; + state.libraryDirty = true; save(KEYS.saved, state.savedQueries); } @@ -160,6 +172,7 @@ export function sortedSaved(state) { export function importSaved(state, queries, save = saveJSON, genId = () => makeId('s', Date.now())) { const { merged, added, updated, skipped } = mergeSaved(state.savedQueries, queries, genId); state.savedQueries = merged; + state.libraryDirty = true; save(KEYS.saved, state.savedQueries); return { added, updated, skipped }; } @@ -168,7 +181,76 @@ export function importSaved(state, queries, save = saveJSON, genId = () => makeI export function deleteSaved(state, id, save = saveJSON) { state.savedQueries = state.savedQueries.filter((q) => q.id !== id); for (const t of tabsForSaved(state, id)) t.savedId = null; + state.libraryDirty = true; + save(KEYS.saved, state.savedQueries); +} + +// ── Library document ops ──────────────────────────────────────────────────── +// The saved-query collection is a named, savable document. These ops back the +// header File menu (New / Save / Replace / Append) and the editable library +// name + unsaved-changes dot. + +/** Clear tab→saved links whose entry no longer exists (after New/Replace), so a + * kept tab doesn't show "Saved" against a query that's gone. */ +function pruneTabLinks(state) { + const ids = new Set(state.savedQueries.map((q) => q.id)); + for (const t of state.tabs) if (t.savedId && !ids.has(t.savedId)) t.savedId = null; +} + +/** Rename the library (blank → the default name). Marks dirty; persists name. */ +export function renameLibrary(state, name, saveName = saveStr) { + state.libraryName = String(name || '').trim() || DEFAULT_LIBRARY_NAME; + state.libraryDirty = true; + saveName(KEYS.libraryName, state.libraryName); +} + +/** Start an empty, default-named library. Clears dirty; open tabs are kept + * (their now-dangling saved links are pruned). */ +export function newLibrary(state, save = saveJSON, saveName = saveStr) { + state.savedQueries = []; + pruneTabLinks(state); + state.libraryName = DEFAULT_LIBRARY_NAME; + state.libraryDirty = false; save(KEYS.saved, state.savedQueries); + saveName(KEYS.libraryName, state.libraryName); +} + +/** Replace the library with `queries`, adopting the loaded file's base name. + * Unique ids are kept (lossless round-trip); missing OR duplicate ids get a fresh id. + * Clears dirty; open tabs are kept (dangling links pruned). */ +export function replaceLibrary(state, queries, fileName, save = saveJSON, saveName = saveStr, genId = () => makeId('s', Date.now())) { + const seen = new Set(); + state.savedQueries = queries.map((q) => { + // Mint a fresh id for a missing OR already-seen id so every saved row has a + // unique id. The sidebar addresses rows by id (find/filter), so a duplicate + // id would let one delete remove several rows and rename/favorite hit the + // wrong one. (mergeSaved-based import already collapsed dup ids; keep parity.) + let id = q.id; + if (!id || seen.has(id)) { do { id = genId(); } while (seen.has(id)); } + seen.add(id); + return { + id, name: q.name, sql: q.sql, favorite: !!q.favorite, + ...(q.description ? { description: q.description } : {}), + ...(q.chart ? { chart: q.chart } : {}), ...(q.view ? { view: q.view } : {}), + }; + }); + pruneTabLinks(state); + const base = String(fileName || '').replace(/\.[^.]+$/, '').trim(); + state.libraryName = base || DEFAULT_LIBRARY_NAME; + state.libraryDirty = false; + save(KEYS.saved, state.savedQueries); + saveName(KEYS.libraryName, state.libraryName); +} + +/** Append `queries` into the library via the standard merge dedupe (sets dirty + * through importSaved). Returns { added, updated, skipped }. */ +export function appendLibrary(state, queries, save = saveJSON, genId = () => makeId('s', Date.now())) { + return importSaved(state, queries, save, genId); +} + +/** Mark the library as saved to a file (clears the unsaved-changes dot). */ +export function markLibrarySaved(state) { + state.libraryDirty = false; } /** Record a successful run in history (most-recent first, capped at 50). */ diff --git a/src/styles.css b/src/styles.css index d304ef2..3d557d5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -284,6 +284,79 @@ body { .user-menu .um-item.danger { color: #ef4444; } .user-menu .um-item.danger:hover { background: color-mix(in oklab, #ef4444 12%, transparent); } +/* ------------ header File menu + library title ------------ */ +.hd-divider { width: 1px; height: 18px; background: var(--border); flex-shrink: 0; } +.hd-file-btn { + display: flex; align-items: center; gap: 5px; height: 26px; padding: 0 8px; + border: none; border-radius: 6px; background: transparent; color: var(--fg-mute); + cursor: pointer; font-family: inherit; font-size: 12.5px; font-weight: 500; flex-shrink: 0; +} +.hd-file-btn:hover { background: var(--bg-hover); color: var(--fg); } +.lib-title { display: flex; align-items: center; min-width: 0; } +.lib-name { + display: flex; align-items: center; gap: 6px; height: 24px; padding: 0 7px; + border: none; border-radius: 6px; background: transparent; cursor: pointer; + font-family: inherit; font-size: 12.5px; font-weight: 500; color: var(--fg); + max-width: 280px; min-width: 0; +} +.lib-name:hover { background: var(--bg-hover); } +.lib-name-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.lib-dirty { width: 6px; height: 6px; border-radius: 3px; background: var(--accent); flex-shrink: 0; } +.lib-name-input { + height: 24px; min-width: 120px; max-width: 280px; padding: 0 8px; + background: var(--bg-input); border: 1px solid var(--accent); border-radius: 6px; + color: var(--fg); font-family: inherit; font-size: 12.5px; outline: none; +} +/* File dropdown */ +.fm-overlay { position: fixed; inset: 0; z-index: 60; } +.file-menu { + z-index: 61; width: 252px; padding-bottom: 4px; + background: var(--bg-modal); border: 1px solid var(--border); + border-radius: 9px; box-shadow: 0 12px 36px rgba(0,0,0,.35); overflow: hidden; +} +.fm-item { + width: 100%; display: flex; align-items: center; gap: 9px; padding: 7px 12px; + border: none; background: transparent; color: var(--fg); font-family: inherit; + font-size: 12.5px; cursor: pointer; text-align: left; +} +.fm-item:hover { background: var(--bg-hover); } +.fm-icon { display: flex; width: 15px; justify-content: center; color: var(--fg-mute); flex-shrink: 0; } +.fm-label { flex: 1; white-space: nowrap; } +.fm-meta { font-size: 10px; color: var(--fg-faint); font-family: var(--mono); flex-shrink: 0; } +.fm-sep { height: 1px; background: var(--border-faint); margin: 5px 0; } +.fm-section { + font-size: 10px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; + color: var(--fg-faint); padding: 9px 12px 4px; +} +.fm-count { + margin-top: 4px; padding: 8px 12px; border-top: 1px solid var(--border-faint); + font-size: 10.5px; color: var(--fg-faint); font-family: var(--mono); +} +/* Replace / New confirm dialog */ +.fm-dialog-backdrop { + position: fixed; inset: 0; z-index: 130; background: rgba(0,0,0,.5); + backdrop-filter: blur(4px); + display: flex; align-items: center; justify-content: center; +} +.fm-dialog-card { + width: 392px; max-width: calc(100vw - 32px); + background: var(--bg-modal); border: 1px solid var(--border); border-radius: 11px; + box-shadow: 0 20px 60px rgba(0,0,0,.45); padding: 20px 22px; +} +.fm-dialog-title { font-size: 14.5px; font-weight: 600; color: var(--fg); margin-bottom: 6px; } +.fm-dialog-body { font-size: 12.5px; color: var(--fg-mute); line-height: 1.55; margin-bottom: 18px; } +.fm-dialog-body b { color: var(--fg); } +.fm-mono { color: var(--fg); font-family: var(--mono); } +.fm-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; } +.fm-dialog-cancel, .fm-dialog-confirm { + height: 30px; padding: 0 14px; border-radius: 6px; font-family: inherit; + font-size: 12px; font-weight: 500; cursor: pointer; +} +.fm-dialog-cancel { border: 1px solid var(--border); background: transparent; color: var(--fg); } +.fm-dialog-cancel:hover { background: var(--bg-hover); } +.fm-dialog-confirm { border: none; background: var(--accent); color: #fff; font-weight: 600; } +.fm-dialog-confirm:hover { filter: brightness(1.08); } + /* ------------ main row ------------ */ .main-row { flex: 1; display: flex; min-height: 0; overflow: hidden; } .sidebar { diff --git a/src/ui/app.js b/src/ui/app.js index 23a4189..c4823da 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -7,13 +7,12 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; import { - createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab, importSaved, tabChart, + createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab, tabChart, } from '../state.js'; import { saveJSON, saveStr } from '../core/storage.js'; import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; import { sqlString, inferQueryName, shortVersion, userShortName } from '../core/format.js'; import { resolveTarget } from '../core/target.js'; -import { buildExportDoc, parseImportDoc } from '../core/saved-io.js'; import { toTSV, toCSV } from '../core/export.js'; import { newResult, applyStreamLine } from '../core/stream.js'; import { encodeShare } from '../core/share.js'; @@ -27,6 +26,7 @@ import { renderTabs, selectTab, newTab, closeTab, loadIntoNewTab } from './tabs. import { renderSchema } from './schema.js'; import { renderResults } from './results.js'; import { renderSavedHistory } from './saved-history.js'; +import { libraryControls, renderLibraryTitle } from './file-menu.js'; import { renderLogin } from './login.js'; import { openShortcuts } from './shortcuts.js'; import { startDrag } from './splitters.js'; @@ -81,7 +81,14 @@ export function createApp(env = {}) { // --- persistence ------------------------------------------------------- app.saveJSON = saveJSON; + app.saveStr = saveStr; app.savePref = (name, value) => saveStr(KEYS[name], String(value)); + app.FileReader = env.FileReader || win.FileReader; + // Exposed seams for the header File menu (file-menu.js): the file-download + // helper (defined below) and a library-title refresh (dirty dot + name) run + // after a library mutation made outside file-menu.js (e.g. the save popover). + app.downloadFile = downloadFile; + app.updateLibraryTitle = () => renderLibraryTitle(app); // --- identity ---------------------------------------------------------- app.host = () => (app.authMode === 'basic' @@ -597,6 +604,7 @@ export function createApp(env = {}) { app.updateSaveBtn(); app.actions.rerenderTabs(); renderSavedHistory(app); + app.updateLibraryTitle(); flashToast('Saved', { document: doc }); }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }); @@ -627,32 +635,6 @@ export function createApp(env = {}) { } app.openUserMenu = openUserMenu; - // --- export / import saved queries ------------------------------------- - function exportSaved() { - const qs = app.state.savedQueries; - if (!qs.length) { flashToast('Nothing to export', { document: doc }); return; } - const nowISO = new Date().toISOString(); - downloadFile('sql-browser-queries-' + nowISO.slice(0, 10) + '.json', 'application/json', - JSON.stringify(buildExportDoc(qs, nowISO), null, 2)); - flashToast('Exported ' + qs.length + (qs.length === 1 ? ' query' : ' queries'), { document: doc }); - } - function importSavedFile(file) { - const reader = new (env.FileReader || win.FileReader)(); - reader.onload = () => { - try { - const { queries } = parseImportDoc(String(reader.result)); - const { added, updated, skipped } = importSaved(app.state, queries, saveJSON); - app.updateSaveBtn(); - renderSavedHistory(app); - flashToast('Added ' + added + ' · updated ' + updated + ' · skipped ' + skipped, { document: doc }); - } catch (e) { - flashToast('✕ ' + ((e && e.message) || e), { document: doc }); - } - }; - reader.onerror = () => flashToast('✕ Could not read file', { document: doc }); - reader.readAsText(file); - } - function toggleTheme() { app.state.theme = app.state.theme === 'dark' ? 'light' : 'dark'; app.savePref('theme', app.state.theme); @@ -675,8 +657,6 @@ export function createApp(env = {}) { exportResult, save: openSavePopover, openUserMenu, - exportSaved, - importSavedFile, formatQuery, insertCreate, openShortcuts: () => openShortcuts(app), @@ -709,6 +689,8 @@ export function renderApp(app, helpers) { h('div', { class: 'logo-mark' }, 'A'), h('div', { class: 'logo-name' }, 'Altinity SQL Browser'), h('div', { class: 'env-chip' }, app.host()), + h('div', { class: 'hd-divider' }), + ...libraryControls(app), h('div', { style: { flex: '1' } }), app.dom.connStatus, h('a', { diff --git a/src/ui/file-menu.js b/src/ui/file-menu.js new file mode 100644 index 0000000..06634d4 --- /dev/null +++ b/src/ui/file-menu.js @@ -0,0 +1,243 @@ +// The header "File ▾" menu: the saved-query collection treated as a savable +// document ("the Library"). New / Save (JSON) / Replace / Append, plus one-way +// Markdown/SQL "share" downloads, an editable library name, and an +// unsaved-changes dot. Render module over the `app` controller; every side +// effect goes through an injected seam (app.saveJSON / app.saveStr / +// app.downloadFile / app.FileReader / app.document), so it is fully testable. + +import { h, zoomScale } from './dom.js'; +import { Icon } from './icons.js'; +import { flashToast } from './toast.js'; +import { renderSavedHistory } from './saved-history.js'; +import { buildExportDoc, parseImportDoc, buildMarkdownDoc, buildSqlDoc } from '../core/saved-io.js'; +import { newLibrary, replaceLibrary, appendLibrary, renameLibrary, markLibrarySaved } from '../state.js'; + +/** Library name → safe file base (strips path/illegal chars, collapses spaces). */ +const fileBase = (name) => (name || 'queries').replace(/[\\/:*?"<>|]+/g, '-').replace(/\s+/g, ' ').trim() || 'queries'; +const queries = (n) => n + (n === 1 ? ' query' : ' queries'); + +/** Build the header File button + editable library title; returns the nodes to + * splice into the app header (after the connection chip). */ +export function libraryControls(app) { + app.dom.fileBtn = h('button', { + class: 'hd-file-btn', title: 'File — save or load your library', + onclick: () => openFileMenu(app), + }, h('span', null, 'File'), Icon.chevDown()); + app.dom.libraryTitle = h('div', { class: 'lib-title' }); + renderLibraryTitle(app); + return [app.dom.fileBtn, app.dom.libraryTitle]; +} + +/** (Re)render the library title into its slot: a click-to-rename name button + * with an unsaved-changes dot, or an inline rename input while editing. */ +export function renderLibraryTitle(app) { + const slot = app.dom.libraryTitle; + if (!slot) return; + const state = app.state; + slot.replaceChildren(); + if (app.editingLibrary) { + const input = h('input', { class: 'lib-name-input', value: state.libraryName }); + let done = false; + // Enter/blur commit; Escape cancels. The guard stops the blur fired by the + // re-render teardown from undoing a cancel (same pattern as saved rename). + const finish = (commit) => { + if (done) return; + done = true; + if (commit && input.value.trim()) renameLibrary(state, input.value, app.saveStr); + app.editingLibrary = false; + renderLibraryTitle(app); + }; + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); finish(true); } + else if (e.key === 'Escape') { e.preventDefault(); finish(false); } + }); + input.addEventListener('blur', () => finish(true)); + slot.appendChild(input); + setTimeout(() => { input.focus(); input.select(); }); + return; + } + slot.appendChild(h('button', { + class: 'lib-name', title: 'Rename library', + onclick: () => { app.editingLibrary = true; renderLibraryTitle(app); }, + }, h('span', { class: 'lib-name-text' }, state.libraryName), + state.libraryDirty ? h('span', { class: 'lib-dirty', title: 'Unsaved changes since last save / load' }) : null)); +} + +/** Open the File dropdown anchored under the File button (Esc / outside-click close). */ +export function openFileMenu(app) { + if (app.dom.fileMenu) return; + const doc = app.document || document; + const list = app.state.savedQueries; + const close = () => { + doc.removeEventListener('keydown', onKey, true); + if (app.dom.fileMenu) { app.dom.fileMenu.remove(); app.dom.fileMenu = null; } + if (app.dom.fileMenuOverlay) { app.dom.fileMenuOverlay.remove(); app.dom.fileMenuOverlay = null; } + }; + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } }; + + const replaceInput = pickerInput(app, (f) => onReplaceFile(app, f)); + const appendInput = pickerInput(app, (f) => onAppendFile(app, f)); + const item = (icon, label, meta, onClick) => h('button', { class: 'fm-item', onclick: onClick }, + h('span', { class: 'fm-icon' }, icon), h('span', { class: 'fm-label' }, label), + meta ? h('span', { class: 'fm-meta' }, meta) : null); + const sep = () => h('div', { class: 'fm-sep' }); + const empty = list.length === 0; + + const menu = h('div', { class: 'file-menu' }, + item(Icon.plus(), 'New Library', null, () => { close(); newLibraryAction(app); }), + sep(), + h('div', { class: 'fm-section' }, 'Save library'), + item(Icon.download(), 'Save JSON', '.json', () => { close(); saveJsonAction(app); }), + sep(), + h('div', { class: 'fm-section' }, 'Load from file'), + item(Icon.upload(), 'Replace…', null, () => { replaceInput.click(); close(); }), + item(Icon.upload(), 'Append…', null, () => { appendInput.click(); close(); }), + sep(), + h('div', { class: 'fm-section' }, 'Share / publish'), + item(Icon.download(), 'Download Markdown', '.md', () => { close(); downloadAction(app, 'md'); }), + item(Icon.download(), 'Download SQL', '.sql', () => { close(); downloadAction(app, 'sql'); }), + h('div', { class: 'fm-count' }, empty ? 'Library is empty' : queries(list.length) + ' in Library'), + replaceInput, appendInput); + + const overlay = h('div', { class: 'fm-overlay', onclick: close }); + app.dom.fileMenuOverlay = overlay; + app.dom.fileMenu = menu; + doc.body.appendChild(overlay); + const r = app.dom.fileBtn.getBoundingClientRect(); + // Bridge the shipped html{zoom}: getBoundingClientRect is post-zoom px, but a + // fixed element's top/left are re-scaled by zoom on paint — divide by scale so + // the menu anchors under the button (same as the editor popovers via zoomScale). + const scale = zoomScale(app.dom.fileBtn); + menu.style.position = 'fixed'; + menu.style.top = (r.bottom / scale + 6) + 'px'; + menu.style.left = Math.max(8, r.left / scale) + 'px'; + doc.body.appendChild(menu); + doc.addEventListener('keydown', onKey, true); +} + +// ── file pickers + JSON read ──────────────────────────────────────────────── + +function pickerInput(app, onPick) { + return h('input', { + type: 'file', accept: '.json,application/json', style: { display: 'none' }, + onchange: (e) => { const f = e.target.files && e.target.files[0]; e.target.value = ''; if (f) onPick(f); }, + }); +} + +/** Read + parse a JSON library file, then `cb(queries)`. Bad files toast. */ +function readJsonFile(app, file, cb) { + const reader = new (app.FileReader || globalThis.FileReader)(); + reader.onload = () => { + try { cb(parseImportDoc(String(reader.result)).queries); } + catch (e) { flashToast('✕ ' + ((e && e.message) || e), { document: app.document }); } + }; + reader.onerror = () => flashToast('✕ Could not read file', { document: app.document }); + reader.readAsText(file); +} + +// ── actions ───────────────────────────────────────────────────────────────── + +function saveJsonAction(app) { + const qs = app.state.savedQueries; + if (!qs.length) { flashToast('Nothing to save', { document: app.document }); return; } + app.downloadFile(fileBase(app.state.libraryName) + '.json', 'application/json', + JSON.stringify(buildExportDoc(qs, new Date().toISOString()), null, 2)); + markLibrarySaved(app.state); + renderLibraryTitle(app); + flashToast('Saved ' + queries(qs.length) + ' → .json', { document: app.document }); +} + +function downloadAction(app, fmt) { + const qs = app.state.savedQueries; + if (!qs.length) { flashToast('Nothing to save', { document: app.document }); return; } + if (fmt === 'md') app.downloadFile(fileBase(app.state.libraryName) + '.md', 'text/markdown', buildMarkdownDoc(qs)); + else app.downloadFile(fileBase(app.state.libraryName) + '.sql', 'application/sql', buildSqlDoc(qs)); + flashToast('Saved ' + queries(qs.length) + ' → .' + fmt, { document: app.document }); +} + +function onReplaceFile(app, file) { + readJsonFile(app, file, (qs) => { + if (!qs.length) { flashToast('✕ No queries in file', { document: app.document }); return; } + if (app.state.savedQueries.length) confirmReplace(app, file.name, qs); + else doReplace(app, qs, file.name); + }); +} + +function doReplace(app, qs, fileName) { + replaceLibrary(app.state, qs, fileName, app.saveJSON, app.saveStr); + afterLibraryChange(app); + flashToast('Replaced library · ' + queries(qs.length), { document: app.document }); +} + +function onAppendFile(app, file) { + readJsonFile(app, file, (qs) => { + if (!qs.length) { flashToast('✕ No queries in file', { document: app.document }); return; } + const { added, updated, skipped } = appendLibrary(app.state, qs, app.saveJSON); + afterLibraryChange(app); + flashToast('Added ' + added + ' · updated ' + updated + ' · skipped ' + skipped, { document: app.document }); + }); +} + +function newLibraryAction(app) { + if (app.state.savedQueries.length) { confirmNew(app); return; } + doNew(app); +} + +function doNew(app) { + newLibrary(app.state, app.saveJSON, app.saveStr); + afterLibraryChange(app); + flashToast('Started a new library', { document: app.document }); +} + +/** Re-sync the surfaces a library change touches: Save button (tab links may be + * pruned), the saved list (count + rows), and the title (name + dirty dot). */ +function afterLibraryChange(app) { + app.updateSaveBtn(); + renderSavedHistory(app); + renderLibraryTitle(app); +} + +// ── confirm dialogs (reuse the modal-backdrop/card visual language) ────────── + +function confirmReplace(app, fileName, qs) { + const cur = app.state.savedQueries.length; + openConfirm(app, { + title: 'Replace saved queries?', + body: [h('span', { class: 'fm-mono' }, fileName), ' contains ', h('b', null, String(qs.length)), ' ', + qs.length === 1 ? 'query' : 'queries', '. Loading it will replace your current ', + h('b', null, String(cur)), ' saved ', cur === 1 ? 'query' : 'queries', + '. Open editor tabs are unaffected. Use Append instead to keep both.'], + confirmLabel: 'Replace', + onConfirm: () => doReplace(app, qs, fileName), + }); +} + +function confirmNew(app) { + const cur = app.state.savedQueries.length; + openConfirm(app, { + title: 'Start a new library?', + body: ['This clears your current ', h('b', null, String(cur)), ' saved ', cur === 1 ? 'query' : 'queries', + ' and starts an empty library. Open editor tabs are unaffected. Save first if you want to keep them.'], + confirmLabel: 'New Library', + onConfirm: () => doNew(app), + }); +} + +function openConfirm(app, { title, body, confirmLabel, onConfirm }) { + const doc = app.document || document; + const close = () => { + doc.removeEventListener('keydown', onKey, true); + if (app.dom.fileDialog) { app.dom.fileDialog.remove(); app.dom.fileDialog = null; } + }; + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } }; + const card = h('div', { class: 'fm-dialog-card', onclick: (e) => e.stopPropagation() }, + h('div', { class: 'fm-dialog-title' }, title), + h('div', { class: 'fm-dialog-body' }, body), + h('div', { class: 'fm-dialog-actions' }, + h('button', { class: 'fm-dialog-cancel', onclick: close }, 'Cancel'), + h('button', { class: 'fm-dialog-confirm', onclick: () => { close(); onConfirm(); } }, confirmLabel))); + const backdrop = h('div', { class: 'fm-dialog-backdrop', onclick: close }, card); + app.dom.fileDialog = backdrop; + doc.body.appendChild(backdrop); + doc.addEventListener('keydown', onKey, true); +} diff --git a/src/ui/saved-history.js b/src/ui/saved-history.js index c4e630c..9083a92 100644 --- a/src/ui/saved-history.js +++ b/src/ui/saved-history.js @@ -17,7 +17,7 @@ export function renderSavedHistory(app) { h('button', { class: 'side-tab' + (state.sidePanel === 'saved' ? ' active' : ''), onclick: () => { state.sidePanel = 'saved'; app.savePref('sidePanel', 'saved'); renderSavedHistory(app); }, - }, Icon.star(state.sidePanel === 'saved'), h('span', null, 'Saved'), + }, Icon.star(state.sidePanel === 'saved'), h('span', null, 'Library'), count ? h('span', { class: 'side-count' }, '· ' + count) : null), h('button', { class: 'side-tab' + (state.sidePanel === 'history' ? ' active' : ''), @@ -40,7 +40,7 @@ function renderSaved(app, list) { if (app.editingSavedId === q.id) { list.appendChild(savedEditForm(app, q)); continue; } const star = h('button', { class: 'sv-star' + (q.favorite ? ' on' : ''), title: q.favorite ? 'Unfavorite' : 'Favorite', - onclick: (e) => { e.stopPropagation(); toggleFavorite(state, q.id, app.saveJSON); renderSavedHistory(app); }, + onclick: (e) => { e.stopPropagation(); toggleFavorite(state, q.id, app.saveJSON); renderSavedHistory(app); app.updateLibraryTitle(); }, }, Icon.star(q.favorite)); const row = h('div', { class: 'saved-row', onclick: () => { app.actions.loadIntoNewTab(q.name, q.sql, q.id, q.chart); app.actions.run({ view: q.view }); } }, @@ -53,13 +53,12 @@ function renderSaved(app, list) { }, Icon.pencil()), h('button', { class: 'sv-act', title: 'Delete', - onclick: (e) => { e.stopPropagation(); deleteSaved(state, q.id, app.saveJSON); app.updateSaveBtn(); renderSavedHistory(app); }, + onclick: (e) => { e.stopPropagation(); deleteSaved(state, q.id, app.saveJSON); app.updateSaveBtn(); renderSavedHistory(app); app.updateLibraryTitle(); }, }, Icon.trash())), q.description ? h('div', { class: 'desc' }, q.description) : null, h('div', { class: 'preview' }, q.sql.split('\n')[0])); list.appendChild(row); } - list.appendChild(savedActions(app)); } /** @@ -84,6 +83,7 @@ function savedEditForm(app, q) { } app.editingSavedId = null; renderSavedHistory(app); + app.updateLibraryTitle(); }; nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); finish(true); } @@ -104,25 +104,6 @@ function savedEditForm(app, q) { h('button', { class: 'sv-edit-save', onclick: () => finish(true) }, 'Save'))); } -/** Export / Import row pinned at the bottom of the Saved panel. */ -function savedActions(app) { - const empty = app.state.savedQueries.length === 0; - const fileInput = h('input', { - type: 'file', accept: 'application/json,.json', style: { display: 'none' }, - onchange: (e) => { const f = e.target.files && e.target.files[0]; if (f) app.actions.importSavedFile(f); e.target.value = ''; }, - }); - return h('div', { class: 'saved-actions' }, - h('button', { - class: 'sv-io', disabled: empty ? true : null, title: 'Download all saved queries as JSON', - onclick: () => app.actions.exportSaved(), - }, Icon.download(), h('span', null, 'Export')), - h('button', { - class: 'sv-io', title: 'Import saved queries from a JSON file', - onclick: () => fileInput.click(), - }, Icon.upload(), h('span', null, 'Import')), - fileInput); -} - function renderHistory(app, list) { const state = app.state; if (state.history.length === 0) { diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index 1b02789..79d2dbe 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -31,7 +31,10 @@ export function makeApp(over = {}) { email: () => 'me@example.com', savePref: vi.fn(), saveJSON: vi.fn(), + saveStr: vi.fn(), + downloadFile: vi.fn(), updateSaveBtn: vi.fn(), + updateLibraryTitle: vi.fn(), elapsedMs: () => 0, editingSavedId: null, showLogin: vi.fn(), @@ -61,8 +64,6 @@ export function makeApp(over = {}) { copyResult: vi.fn(), exportResult: vi.fn(), save: vi.fn(), - exportSaved: vi.fn(), - importSavedFile: vi.fn(), formatQuery: vi.fn(), insertCreate: vi.fn(), openShortcuts: vi.fn(), diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index 73b9a21..b8050d5 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -698,44 +698,6 @@ describe('share + star + columns', () => { expect(document.querySelector('.save-popover')).toBeNull(); // committed + closed expect(app.state.savedQueries[0].description).toBe('updated reason'); }); - const fakeReader = (content, fail) => class { - readAsText() { this.result = content; if (fail) this.onerror && this.onerror(); else this.onload && this.onload(); } - }; - it('exportSaved downloads the envelope; empty list → toast only', () => { - const download = vi.fn(); - const app = createApp(env({ download })); - app.renderApp(); - app.actions.exportSaved(); // empty - expect(download).not.toHaveBeenCalled(); - expect(document.querySelector('.share-toast').textContent).toBe('Nothing to export'); - app.state.savedQueries = [{ id: 's1', name: 'A', sql: 'SELECT 1', favorite: true }]; - app.actions.exportSaved(); - const [fname, mime, content] = download.mock.calls[0]; - expect(fname).toMatch(/^sql-browser-queries-\d{4}-\d{2}-\d{2}\.json$/); - expect(mime).toBe('application/json'); - const docObj = JSON.parse(content); - expect(docObj.format).toBe('altinity-sql-browser/saved-queries'); - expect(docObj.queries).toEqual([{ id: 's1', name: 'A', sql: 'SELECT 1', favorite: true }]); - expect(document.querySelector('.share-toast').textContent).toBe('Exported 1 query'); - }); - it('importSavedFile merges a valid file and toasts counts', () => { - const text = JSON.stringify({ format: 'altinity-sql-browser/saved-queries', version: 1, queries: [{ id: 'x1', name: 'New', sql: 'SELECT 9' }] }); - const app = createApp(env({ FileReader: fakeReader(text) })); - app.renderApp(); - app.actions.importSavedFile({}); - expect(app.state.savedQueries.some((q) => q.name === 'New')).toBe(true); - expect(document.querySelector('.share-toast').textContent).toBe('Added 1 · updated 0 · skipped 0'); - }); - it('importSavedFile reports parse errors and read errors with ✕', () => { - const bad = createApp(env({ FileReader: fakeReader('{not json') })); - bad.renderApp(); - bad.actions.importSavedFile({}); - expect(document.querySelector('.share-toast').textContent).toBe('✕ Not a valid JSON file'); - const err = createApp(env({ FileReader: fakeReader('', true) })); - err.renderApp(); - err.actions.importSavedFile({}); - expect(document.querySelector('.share-toast').textContent).toBe('✕ Could not read file'); - }); it('loadColumns fills the table object', async () => { const e = env({ fetch: makeFetch([[(u, sql) => /system\.columns/.test(sql), resp({ json: { data: [{ name: 'id', type: 'UInt64', comment: '' }] } })]]) }); const app = createApp(e); diff --git a/tests/unit/file-menu.test.js b/tests/unit/file-menu.test.js new file mode 100644 index 0000000..7e81e59 --- /dev/null +++ b/tests/unit/file-menu.test.js @@ -0,0 +1,287 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { libraryControls, renderLibraryTitle, openFileMenu } from '../../src/ui/file-menu.js'; +import { makeApp } from '../helpers/fake-app.js'; + +const click = (el) => el.dispatchEvent(new Event('click', { bubbles: true })); +const key = (el, k, mods = {}) => el.dispatchEvent(new KeyboardEvent('keydown', { key: k, bubbles: true, ...mods })); +const item = (re) => [...document.querySelectorAll('.fm-item')].find((b) => re.test(b.textContent)); +const toast = () => document.querySelector('.share-toast').textContent; + +// A FileReader stub: readAsText resolves synchronously with `content` (or errors). +const fakeReader = (content, fail) => class { + readAsText() { this.result = content; if (fail) this.onerror && this.onerror(); else this.onload && this.onload(); } +}; +const envFile = (queries) => JSON.stringify({ format: 'altinity-sql-browser/saved-queries', version: 1, queries }); + +// Build an app with the header controls mounted (File button + title slot in the DOM). +function mount(over = {}) { + const app = makeApp(over); + for (const node of libraryControls(app)) document.body.appendChild(node); + return app; +} +const picker = (i) => document.querySelectorAll('.file-menu input[type=file]')[i]; + +afterEach(() => document.body.replaceChildren()); + +describe('library title', () => { + it('renders the name + dirty dot; inline rename commits on Enter and persists', () => { + const app = mount(); + app.state.libraryName = 'My queries'; + app.state.libraryDirty = true; + renderLibraryTitle(app); + expect(app.dom.libraryTitle.querySelector('.lib-name-text').textContent).toBe('My queries'); + expect(app.dom.libraryTitle.querySelector('.lib-dirty')).not.toBeNull(); + click(app.dom.libraryTitle.querySelector('.lib-name')); + expect(app.editingLibrary).toBe(true); + const input = app.dom.libraryTitle.querySelector('.lib-name-input'); + expect(input.value).toBe('My queries'); + input.value = 'Renamed'; + key(input, 'Enter'); + expect(app.state.libraryName).toBe('Renamed'); + expect(app.editingLibrary).toBe(false); + expect(app.saveStr).toHaveBeenCalled(); + app.state.libraryDirty = false; + renderLibraryTitle(app); + expect(app.dom.libraryTitle.querySelector('.lib-dirty')).toBeNull(); + }); + + it('inline rename: Escape cancels, blur commits, empty commit is a no-op, double-fire guarded', () => { + const app = mount(); + app.state.libraryName = 'Orig'; + renderLibraryTitle(app); + // Escape cancels + click(app.dom.libraryTitle.querySelector('.lib-name')); + let input = app.dom.libraryTitle.querySelector('.lib-name-input'); + input.value = 'X'; + key(input, 'Escape'); + expect(app.state.libraryName).toBe('Orig'); + // empty name commit → no rename + click(app.dom.libraryTitle.querySelector('.lib-name')); + input = app.dom.libraryTitle.querySelector('.lib-name-input'); + input.value = ' '; + key(input, 'Enter'); + expect(app.state.libraryName).toBe('Orig'); + // blur commits, then a second event on the detached input is guarded + click(app.dom.libraryTitle.querySelector('.lib-name')); + input = app.dom.libraryTitle.querySelector('.lib-name-input'); + input.value = 'Blurred'; + input.dispatchEvent(new Event('blur')); + expect(app.state.libraryName).toBe('Blurred'); + key(input, 'Enter'); + expect(app.state.libraryName).toBe('Blurred'); + }); + + it('renderLibraryTitle no-ops without a slot', () => { + expect(() => renderLibraryTitle(makeApp())).not.toThrow(); + }); +}); + +describe('file menu', () => { + it('lists every section + item, reflects the (pluralized) count, and re-open is a no-op', () => { + const app = mount(); + app.state.savedQueries = [ + { id: 's1', name: 'A', sql: '1', favorite: false }, + { id: 's2', name: 'B', sql: '2', favorite: false }, + ]; + openFileMenu(app); + expect([...document.querySelectorAll('.fm-label')].map((l) => l.textContent)).toEqual( + ['New Library', 'Save JSON', 'Replace…', 'Append…', 'Download Markdown', 'Download SQL']); + expect([...document.querySelectorAll('.fm-section')].map((s) => s.textContent)).toEqual( + ['Save library', 'Load from file', 'Share / publish']); + expect(document.querySelector('.fm-count').textContent).toBe('2 queries in Library'); + openFileMenu(app); + expect(document.querySelectorAll('.file-menu')).toHaveLength(1); + }); + + it('footer shows the empty state when there are no queries', () => { + const app = mount(); + openFileMenu(app); + expect(document.querySelector('.fm-count').textContent).toBe('Library is empty'); + }); + + it('closes on overlay click and on Escape (ignores other keys)', () => { + const app = mount(); + openFileMenu(app); + key(document, 'a'); // not Escape → stays open + expect(document.querySelector('.file-menu')).not.toBeNull(); + click(document.querySelector('.fm-overlay')); + expect(document.querySelector('.file-menu')).toBeNull(); + openFileMenu(app); + key(document, 'Escape'); + expect(document.querySelector('.file-menu')).toBeNull(); + }); +}); + +describe('Save JSON / Markdown / SQL downloads', () => { + it('Save JSON: empty → toast; non-empty → download envelope, clear dirty', () => { + const app = mount(); + openFileMenu(app); + click(item(/Save JSON/)); + expect(app.downloadFile).not.toHaveBeenCalled(); + expect(toast()).toBe('Nothing to save'); + app.state.savedQueries = [{ id: 's1', name: 'A', sql: '1', favorite: true }]; + app.state.libraryName = 'My Lib'; + app.state.libraryDirty = true; + openFileMenu(app); + click(item(/Save JSON/)); + const [fname, mime, content] = app.downloadFile.mock.calls[0]; + expect(fname).toBe('My Lib.json'); + expect(mime).toBe('application/json'); + expect(JSON.parse(content).format).toBe('altinity-sql-browser/saved-queries'); + expect(app.state.libraryDirty).toBe(false); + expect(toast()).toBe('Saved 1 query → .json'); + }); + + it('Download Markdown + SQL: empty → toast; non-empty → files named from the library', () => { + const app = mount(); + openFileMenu(app); + click(item(/Download Markdown/)); + expect(app.downloadFile).not.toHaveBeenCalled(); + expect(toast()).toBe('Nothing to save'); + app.state.savedQueries = [{ id: 's1', name: 'A', sql: 'SELECT 1', favorite: false, description: 'd' }]; + app.state.libraryName = 'Lib'; + openFileMenu(app); + click(item(/Download Markdown/)); + expect(app.downloadFile.mock.calls.at(-1).slice(0, 2)).toEqual(['Lib.md', 'text/markdown']); + openFileMenu(app); + click(item(/Download SQL/)); + expect(app.downloadFile.mock.calls.at(-1).slice(0, 2)).toEqual(['Lib.sql', 'application/sql']); + // an unnamed / whitespace-only library name falls back to "queries" + app.state.libraryName = ''; + openFileMenu(app); + click(item(/Download Markdown/)); + expect(app.downloadFile.mock.calls.at(-1)[0]).toBe('queries.md'); + app.state.libraryName = ' '; + openFileMenu(app); + click(item(/Download SQL/)); + expect(app.downloadFile.mock.calls.at(-1)[0]).toBe('queries.sql'); + }); +}); + +describe('Replace / Append (JSON only)', () => { + it('Replace item closes the menu and opens the picker; a non-empty library confirms first', () => { + const app = mount({ FileReader: fakeReader(envFile([{ id: 'x', name: 'New', sql: 'S' }, { name: 'New2', sql: 'S2' }])) }); + app.state.savedQueries = [ + { id: 's1', name: 'Old', sql: '1', favorite: false }, + { id: 's2', name: 'Old2', sql: '2', favorite: false }, + ]; + openFileMenu(app); + const replaceInput = picker(0); + replaceInput.click = vi.fn(); + click(item(/Replace/)); + expect(document.querySelector('.file-menu')).toBeNull(); // menu closed + expect(replaceInput.click).toHaveBeenCalled(); + // user picks a file → confirm dialog (current library non-empty, plural copy) + Object.defineProperty(replaceInput, 'files', { configurable: true, value: [{ name: 'team.json' }] }); + replaceInput.dispatchEvent(new Event('change', { bubbles: true })); + const dialog = document.querySelector('.fm-dialog-card'); + expect(dialog.textContent).toContain('Replace saved queries?'); + expect(dialog.textContent).toContain('contains 2 queries'); + expect(dialog.textContent).toContain('current 2 saved queries'); + click(document.querySelector('.fm-dialog-confirm')); + expect(app.state.savedQueries.map((q) => q.name)).toEqual(['New', 'New2']); + expect(app.state.libraryName).toBe('team'); + expect(app.updateSaveBtn).toHaveBeenCalled(); + expect(toast()).toBe('Replaced library · 2 queries'); + }); + + it('Replace into an empty library loads directly (no confirm); cancelling the picker is a no-op', () => { + const app = mount({ FileReader: fakeReader(envFile([{ name: 'New', sql: 'S' }])) }); + openFileMenu(app); + const input = picker(0); + // cancel (no file chosen) → nothing happens + Object.defineProperty(input, 'files', { configurable: true, value: [] }); + input.dispatchEvent(new Event('change', { bubbles: true })); + expect(app.state.savedQueries).toEqual([]); + // pick a file → loaded directly, name adopted, no dialog + Object.defineProperty(input, 'files', { configurable: true, value: [{ name: 'lib.json' }] }); + input.dispatchEvent(new Event('change', { bubbles: true })); + expect(document.querySelector('.fm-dialog-card')).toBeNull(); + expect(app.state.savedQueries.map((q) => q.name)).toEqual(['New']); + expect(app.state.libraryName).toBe('lib'); + }); + + it('Append item closes the menu, merges the file, and toasts counts', () => { + const app = mount({ FileReader: fakeReader(envFile([{ id: 's1', name: 'A', sql: '1' }, { name: 'B', sql: '2' }])) }); + app.state.savedQueries = [{ id: 's1', name: 'A', sql: '1', favorite: false }]; + openFileMenu(app); + const appendInput = picker(1); + appendInput.click = vi.fn(); + click(item(/Append/)); + expect(document.querySelector('.file-menu')).toBeNull(); + expect(appendInput.click).toHaveBeenCalled(); + Object.defineProperty(appendInput, 'files', { configurable: true, value: [{ name: 'more.json' }] }); + appendInput.dispatchEvent(new Event('change', { bubbles: true })); + expect(app.state.savedQueries.map((q) => q.name)).toEqual(['A', 'B']); + expect(toast()).toBe('Added 1 · updated 0 · skipped 1'); // the duplicate A is skipped + }); + + it('Replace / Append with no queries in the file → error toast', () => { + const app = mount({ FileReader: fakeReader(envFile([])) }); + openFileMenu(app); + Object.defineProperty(picker(0), 'files', { configurable: true, value: [{ name: 'empty.json' }] }); + picker(0).dispatchEvent(new Event('change', { bubbles: true })); + expect(toast()).toBe('✕ No queries in file'); + openFileMenu(app); + Object.defineProperty(picker(1), 'files', { configurable: true, value: [{ name: 'empty.json' }] }); + picker(1).dispatchEvent(new Event('change', { bubbles: true })); + expect(toast()).toBe('✕ No queries in file'); + }); + + it('invalid JSON → error toast; a read error → error toast', () => { + const bad = mount({ FileReader: fakeReader('{not json') }); + openFileMenu(bad); + Object.defineProperty(picker(0), 'files', { configurable: true, value: [{ name: 'bad.json' }] }); + picker(0).dispatchEvent(new Event('change', { bubbles: true })); + expect(toast()).toBe('✕ Not a valid JSON file'); + document.body.replaceChildren(); + const err = mount({ FileReader: fakeReader('', true) }); + openFileMenu(err); + Object.defineProperty(picker(0), 'files', { configurable: true, value: [{ name: 'x.json' }] }); + picker(0).dispatchEvent(new Event('change', { bubbles: true })); + expect(toast()).toBe('✕ Could not read file'); + }); +}); + +describe('New Library + confirm dialogs', () => { + it('New Library: empty → clears directly; non-empty → confirm → New resets to the default', () => { + const app = mount(); + openFileMenu(app); + click(item(/New Library/)); + expect(document.querySelector('.fm-dialog-backdrop')).toBeNull(); + expect(toast()).toBe('Started a new library'); + app.state.savedQueries = [{ id: 's1', name: 'A', sql: '1', favorite: false }]; + app.state.libraryName = 'Old'; + openFileMenu(app); + click(item(/New Library/)); + expect(document.querySelector('.fm-dialog-card').textContent).toContain('Start a new library?'); + click(document.querySelector('.fm-dialog-confirm')); + expect(app.state.savedQueries).toEqual([]); + expect(app.state.libraryName).toBe('SQL Library'); + }); + + it('confirm dialog: Cancel, backdrop click, and Escape all dismiss; a card click does not', () => { + const app = mount(); + app.state.savedQueries = [ // two queries → exercises the plural dialog copy + { id: 's1', name: 'A', sql: '1', favorite: false }, + { id: 's2', name: 'B', sql: '2', favorite: false }, + ]; + const openNew = () => { openFileMenu(app); click(item(/New Library/)); }; + // Cancel + openNew(); + click(document.querySelector('.fm-dialog-cancel')); + expect(document.querySelector('.fm-dialog-backdrop')).toBeNull(); + expect(app.state.savedQueries).toHaveLength(2); + // backdrop click + openNew(); + click(document.querySelector('.fm-dialog-backdrop')); + expect(document.querySelector('.fm-dialog-backdrop')).toBeNull(); + // card click keeps it open; Escape closes it + openNew(); + click(document.querySelector('.fm-dialog-card')); + expect(document.querySelector('.fm-dialog-backdrop')).not.toBeNull(); + key(document, 'Escape'); + expect(document.querySelector('.fm-dialog-backdrop')).toBeNull(); + expect(app.state.savedQueries).toHaveLength(2); + }); +}); diff --git a/tests/unit/saved-history.test.js b/tests/unit/saved-history.test.js index febbd47..9394d28 100644 --- a/tests/unit/saved-history.test.js +++ b/tests/unit/saved-history.test.js @@ -134,36 +134,18 @@ describe('renderSavedHistory', () => { expect(rows[1].querySelector('.desc')).toBeNull(); }); - it('saved: Export/Import row — Export disabled when empty, enabled with queries, wired', () => { + it('saved: the tab is labelled "Library" with a live count and no Export/Import row', () => { const app = makeApp(); app.state.sidePanel = 'saved'; - renderSavedHistory(app); - let exportBtn = [...app.dom.savedList.querySelectorAll('.sv-io')].find((b) => /Export/.test(b.textContent)); - expect(exportBtn.disabled).toBe(true); // empty list app.state.savedQueries = [{ id: 's1', name: 'A', sql: '1', favorite: false }]; renderSavedHistory(app); - exportBtn = [...app.dom.savedList.querySelectorAll('.sv-io')].find((b) => /Export/.test(b.textContent)); - expect(exportBtn.disabled).toBe(false); - click(exportBtn); - expect(app.actions.exportSaved).toHaveBeenCalled(); - }); - it('saved: Import button opens the file input; change with a file imports it', () => { - const app = makeApp(); - app.state.sidePanel = 'saved'; - renderSavedHistory(app); - const input = app.dom.savedList.querySelector('.saved-actions input[type="file"]'); - input.click = vi.fn(); - const importBtn = [...app.dom.savedList.querySelectorAll('.sv-io')].find((b) => /Import/.test(b.textContent)); - click(importBtn); - expect(input.click).toHaveBeenCalled(); - // change with a file → importSavedFile(file); without → no call - const file = { name: 'q.json' }; - Object.defineProperty(input, 'files', { configurable: true, value: [file] }); - input.dispatchEvent(new Event('change', { bubbles: true })); - expect(app.actions.importSavedFile).toHaveBeenCalledWith(file); - Object.defineProperty(input, 'files', { configurable: true, value: [] }); - input.dispatchEvent(new Event('change', { bubbles: true })); - expect(app.actions.importSavedFile).toHaveBeenCalledTimes(1); + const savedTab = app.dom.savedTabsRow.querySelectorAll('.side-tab')[0]; + expect(savedTab.textContent).toContain('Library'); + expect(savedTab.textContent).not.toContain('Saved'); + expect(savedTab.querySelector('.side-count').textContent).toContain('1'); + // the old bottom Export/Import row is gone (moved to the header File menu) + expect(app.dom.savedList.querySelector('.saved-actions')).toBeNull(); + expect(app.dom.savedList.querySelector('.sv-io')).toBeNull(); }); it('history: empty state', () => { const app = makeApp(); diff --git a/tests/unit/saved-io.test.js b/tests/unit/saved-io.test.js index 84269ea..54165e4 100644 --- a/tests/unit/saved-io.test.js +++ b/tests/unit/saved-io.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { buildExportDoc, parseImportDoc, mergeSaved } from '../../src/core/saved-io.js'; +import { buildExportDoc, parseImportDoc, mergeSaved, buildMarkdownDoc, buildSqlDoc } from '../../src/core/saved-io.js'; describe('buildExportDoc', () => { it('wraps queries in the envelope, keeps only id/name/sql/favorite, coerces favorite', () => { @@ -145,3 +145,37 @@ describe('mergeSaved', () => { expect(r.merged.find((q) => q.name === 'C').description).toBe('added'); }); }); + +describe('buildMarkdownDoc', () => { + it('renders a ### heading, an optional description paragraph, and a fenced sql block', () => { + const md = buildMarkdownDoc([ + { name: 'A', sql: 'SELECT 1', description: 'does A' }, + { name: 'B', sql: 'SELECT 2' }, + ]); + expect(md).toContain('### A'); + expect(md).toContain('does A'); + expect(md).toContain('```sql\nSELECT 1\n```'); + expect(md).toMatch(/### B\n\n```sql/); // B has no description paragraph + }); + it('widens the fence to four backticks when the sql contains a triple backtick', () => { + const md = buildMarkdownDoc([{ name: 'C', sql: 'SELECT ```x```' }]); + expect(md).toContain('````sql\n'); + expect(md).toContain('\n````'); + }); +}); + +describe('buildSqlDoc', () => { + it('renders a /* name + description */ comment then the statement, ;-terminated (trailing ; trimmed)', () => { + const out = buildSqlDoc([ + { name: 'A', sql: 'SELECT 1;; ', description: 'does A' }, + { name: 'B', sql: 'SELECT 2' }, + ]); + expect(out).toContain('/* A\ndoes A */\nSELECT 1;'); + expect(out).toContain('/* B */\nSELECT 2;'); + expect(out).not.toContain(';;'); + }); + it('defangs a */ sequence inside the comment so the block cannot close early', () => { + const out = buildSqlDoc([{ name: 'edge */ name', sql: 'SELECT 1' }]); + expect(out).toContain('/* edge * / name */'); + }); +}); diff --git a/tests/unit/state.test.js b/tests/unit/state.test.js index fb1d8be..200afd0 100644 --- a/tests/unit/state.test.js +++ b/tests/unit/state.test.js @@ -1,8 +1,9 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { - KEYS, newTabObj, createState, activeTab, allocTabId, + KEYS, DEFAULT_LIBRARY_NAME, newTabObj, createState, activeTab, allocTabId, saveQuery, savedForTab, renameSaved, toggleFavorite, sortedSaved, importSaved, deleteSaved, recordHistory, clearHistory, deleteHistory, tabChart, + renameLibrary, newLibrary, replaceLibrary, appendLibrary, markLibrarySaved, } from '../../src/state.js'; afterEach(() => vi.unstubAllGlobals()); @@ -34,6 +35,8 @@ describe('createState', () => { expect(s.tabs).toHaveLength(1); expect(s.savedQueries).toEqual([]); expect(s.expandedTables).toBeInstanceOf(Set); + expect(s.libraryName).toBe(DEFAULT_LIBRARY_NAME); + expect(s.libraryDirty).toBe(false); }); it('reads + clamps persisted prefs', () => { const s = createState(reader({ @@ -45,8 +48,10 @@ describe('createState', () => { [KEYS.sidePanel]: 'history', [KEYS.saved]: [{ id: 's1', sql: 'x', name: 'n', starred: true }], [KEYS.history]: [{ id: 'h1', sql: 'y', ts: 1, rows: 1, ms: 2 }], + [KEYS.libraryName]: 'My team queries', })); expect(s.theme).toBe('light'); + expect(s.libraryName).toBe('My team queries'); expect(s.sidebarPx).toBe(420); expect(s.editorPct).toBe(15); expect(s.sideSplitPct).toBe(85); @@ -245,6 +250,127 @@ describe('saved queries', () => { }); }); +describe('library document', () => { + it('dirty flag: saved-query mutations set it; markLibrarySaved clears it', () => { + const s = createState(reader()); + const tab = s.tabs[0]; tab.sql = 'SELECT 1'; + expect(s.libraryDirty).toBe(false); + saveQuery(s, tab, 'Q', '', vi.fn()); + expect(s.libraryDirty).toBe(true); + markLibrarySaved(s); + expect(s.libraryDirty).toBe(false); + toggleFavorite(s, tab.savedId, vi.fn()); // favorite the just-saved entry + expect(s.libraryDirty).toBe(true); + markLibrarySaved(s); + renameSaved(s, tab.savedId, 'Q2', undefined, vi.fn()); + expect(s.libraryDirty).toBe(true); + markLibrarySaved(s); + deleteSaved(s, tab.savedId, vi.fn()); + expect(s.libraryDirty).toBe(true); + markLibrarySaved(s); + importSaved(s, [{ name: 'I', sql: 'i' }], vi.fn(), () => 'gi'); + expect(s.libraryDirty).toBe(true); + }); + + it('renameLibrary trims + persists + marks dirty; blank falls back to the default', () => { + const s = createState(reader()); + const saveName = vi.fn(); + renameLibrary(s, ' My queries ', saveName); + expect(s.libraryName).toBe('My queries'); + expect(s.libraryDirty).toBe(true); + expect(saveName).toHaveBeenCalledWith(KEYS.libraryName, 'My queries'); + renameLibrary(s, ' ', saveName); + expect(s.libraryName).toBe(DEFAULT_LIBRARY_NAME); + }); + + it('newLibrary clears queries + name, clears dirty, prunes dangling tab links', () => { + const s = createState(reader()); + s.savedQueries = [{ id: 's1', name: 'A', sql: '1', favorite: false }]; + s.libraryName = 'Old'; s.libraryDirty = true; + s.tabs[0].savedId = 's1'; // dangling after clear → pruned + s.tabs.push(newTabObj('t2')); // no savedId → skipped by prune + const save = vi.fn(), saveName = vi.fn(); + newLibrary(s, save, saveName); + expect(s.savedQueries).toEqual([]); + expect(s.libraryName).toBe(DEFAULT_LIBRARY_NAME); + expect(s.libraryDirty).toBe(false); + expect(s.tabs[0].savedId).toBeNull(); + expect(save).toHaveBeenCalledWith(KEYS.saved, []); + expect(saveName).toHaveBeenCalledWith(KEYS.libraryName, DEFAULT_LIBRARY_NAME); + }); + + it('replaceLibrary keeps ids (mints for id-less), carries metadata, adopts the base name, clears dirty, prunes links', () => { + const s = createState(reader()); + s.savedQueries = [{ id: 'old', name: 'X', sql: 'x', favorite: false }]; + s.tabs[0].savedId = 'old'; // becomes dangling → pruned + s.libraryDirty = true; + const chart = { cfg: { type: 'bar' }, key: 'k' }; + const incoming = [ + { id: 'keep', name: 'A', sql: '1', favorite: true, description: 'd', chart, view: 'json' }, + { name: 'B', sql: '2', favorite: false }, // id-less → genId + ]; + let n = 0; + const save = vi.fn(), saveName = vi.fn(); + replaceLibrary(s, incoming, 'My Library.json', save, saveName, () => 'g' + (++n)); + expect(s.savedQueries.map((q) => q.id)).toEqual(['keep', 'g1']); + expect(s.savedQueries[0]).toMatchObject({ name: 'A', sql: '1', favorite: true, description: 'd', chart, view: 'json' }); + expect(s.libraryName).toBe('My Library'); // extension stripped + expect(s.libraryDirty).toBe(false); + expect(s.tabs[0].savedId).toBeNull(); + expect(save).toHaveBeenCalledWith(KEYS.saved, s.savedQueries); + expect(saveName).toHaveBeenCalledWith(KEYS.libraryName, 'My Library'); + }); + + it('replaceLibrary mints fresh ids for duplicate incoming ids, keeping every id unique', () => { + const s = createState(reader()); + const incoming = [ + { id: 'dup', name: 'A', sql: '1' }, + { id: 'dup', name: 'B', sql: '2' }, // same id → must be reassigned + { id: 'uniq', name: 'C', sql: '3' }, + { name: 'D', sql: '4' }, // id-less → minted + ]; + // first mint collides with an already-seen id → the retry loop must skip it + let n = 0; + const genId = () => { n += 1; return n === 1 ? 'dup' : 'g' + n; }; + replaceLibrary(s, incoming, 'lib.json', vi.fn(), vi.fn(), genId); + const ids = s.savedQueries.map((q) => q.id); + expect(ids[0]).toBe('dup'); // first occurrence keeps its id + expect(ids[2]).toBe('uniq'); // unique id preserved + expect(ids).toHaveLength(4); + expect(new Set(ids).size).toBe(4); // all unique (no duplicate 'dup') + }); + + it('replaceLibrary with no usable file name falls back to the default', () => { + const s = createState(reader()); + replaceLibrary(s, [{ name: 'A', sql: '1' }], '.json', vi.fn(), vi.fn(), () => 'g'); + expect(s.libraryName).toBe(DEFAULT_LIBRARY_NAME); + }); + + it('appendLibrary merges via importSaved (dedupe), returns counts, sets dirty', () => { + const s = createState(reader()); + s.savedQueries = [{ id: 's1', name: 'A', sql: '1', favorite: false }]; + const r = appendLibrary(s, [ + { id: 's1', name: 'A', sql: '1' }, // content dup → skip + { name: 'B', sql: '2' }, // add + ], vi.fn(), () => 'gb'); + expect(r).toEqual({ added: 1, updated: 0, skipped: 1 }); + expect(s.savedQueries.map((q) => q.name)).toEqual(['A', 'B']); + expect(s.libraryDirty).toBe(true); + }); + + it('library ops default their persistence seams (real storage helpers)', () => { + vi.stubGlobal('localStorage', memStore()); + const s = createState(reader()); + s.tabs[0].sql = 'SELECT 1'; + const e = saveQuery(s, s.tabs[0], 'Q'); // default save/now/description + renameLibrary(s, 'Lib'); // default saveName + replaceLibrary(s, [{ id: e.id, name: 'Q', sql: 'SELECT 1' }], 'f.json'); // default seams + newLibrary(s); // default seams + appendLibrary(s, [{ name: 'Z', sql: 'z' }]); // default seam + expect(s.savedQueries.some((q) => q.name === 'Z')).toBe(true); + }); +}); + describe('history', () => { const tab = (over = {}) => ({ sql: 'SELECT 1',