From 2fe7c055d9321d53108ffa89bfde7eb14ab6a7a7 Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Sun, 21 Jun 2026 19:36:42 +0200 Subject: [PATCH 1/2] feat(ui): hide logout in a header user menu + export/import saved queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header: replace the standalone email + "Log Out" button with a compact short-name control (email local-part + chevron) that opens a dropdown holding the full email and a red Log out item. Esc / outside-click close it; Log out signs out immediately (no confirm), per design handoff. Saved panel: pinned Export / Import row. Export downloads all saved queries as JSON ({format,version,exportedAt,queries}); Import merges + dedupes (skip exact name+SQL dups, update on id-match, add the rest) and reports "Added N · updated N · skipped N". New pure modules carry the logic at 100% coverage: - core/format.js: userShortName(email) - core/saved-io.js: buildExportDoc / parseImportDoc / mergeSaved - state.js: importSaved (persists via injected save + genId seams) FileReader is injected through createApp(env) like the other side-effects. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/core/format.js | 11 ++++++ src/core/saved-io.js | Bin 0 -> 2606 bytes src/state.js | 12 ++++++ src/styles.css | 41 +++++++++++++++++++- src/ui/app.js | 64 +++++++++++++++++++++++++++++-- src/ui/icons.js | 2 + src/ui/saved-history.js | 21 +++++++++- tests/helpers/fake-app.js | 2 + tests/unit/app.test.js | 62 +++++++++++++++++++++++++++--- tests/unit/format.test.js | 14 ++++++- tests/unit/saved-history.test.js | 33 +++++++++++++++- tests/unit/saved-io.test.js | 62 ++++++++++++++++++++++++++++++ tests/unit/state.test.js | 19 ++++++++- 13 files changed, 329 insertions(+), 14 deletions(-) create mode 100644 src/core/saved-io.js create mode 100644 tests/unit/saved-io.test.js diff --git a/src/core/format.js b/src/core/format.js index 08abbd8..dce19bd 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -75,3 +75,14 @@ export function shortVersion(v) { const parts = String(v || '').split('.'); return parts.length > 3 ? parts.slice(0, 3).join('.') : String(v || ''); } + +/** + * Short display name for the header user control: the local-part of an email + * (before '@'). Falls back to the whole string when there's no '@', and '' for + * empty/nullish input. + */ +export function userShortName(email) { + const s = String(email || ''); + const at = s.indexOf('@'); + return at > 0 ? s.slice(0, at) : s; +} diff --git a/src/core/saved-io.js b/src/core/saved-io.js new file mode 100644 index 0000000000000000000000000000000000000000..7c6a39ffb5488afc92a4f5122b7a7d91342c18d2 GIT binary patch literal 2606 zcmb7GU2p0}5bZO+V)Ama6JpX=5UEn#RH9Xqt8iQOk;NWhTYGJG*C8~Q|K6F|wFA0Q zr9NSMXU}|{Im37iUs{WxtERSojIaCy^%|Ud!2)(IT8%D)#~MCAJ&eHAuqo@6DqS`h zkKr!bq4D+r+QA#>f~-`9BXGNN1Z(wDTkR2!*G|RAE6iZ1O0SLfhifvu zUfFu@(2m9Pb@V)(#!G+PKQHD_k96zD?$g8FpZt1rb2A+b#+R4ybF0e&{1zeRf#wCv zy1@*VrrysNPfLzbo4+vkl-;_vP@qR!Q54SW96UNt*e? zm%{885-v2IWL>%Wxi34voK=4U7ZSTGc`fmj23?YH3Q*=&RoR}@n^0;;;-a9b^QUIQyWZyO08g}s5#^`lfckk6M?E8w$jcq9$@&a>3x=zOj!iFa|DWvq+jW)aexRcVjWHH-<|saFJ(2L1jdL zXprWY0VCZ6acJ6Vg?89E>&|XFE`I3DwG9hG=PO=%cXn}StvY1d$#23=BP-Gm@JK{D z&S-N|3=aF`YDTTw5OZ#UCj0}KuXPQTGKU^4snjbBiMy-_S1?RNfI)3Kk25Jre3QOr zL@^rvZlreb=@Z22g;|0bwPF}(Hat6Jx}6@T0r2|)?eTPRb^(5p*@X$Trogo}7-jSS z&M}jz)1AH`XM_EKc7w3gCa+27Hp_EqCpGjnEb)~EkWXorJyfkH2`JJG7Juu;k)EpD z^Dwf+@EAkUHl-%^qnkq8uwXb=jIanu`0_20W<@)pfc+LpO1uMVh$jOm^m%Abnxsb+1wjbe14?4Cl|ffjBu$;8SVIo7WU!=5%MF@&k)+F1wBlWyD$bf}N~ru3 zLZk;r`LICHI{HW% u{ q); } +/** + * Merge imported queries into savedQueries (dedupe by content, update by id, + * else add). Returns { added, updated, skipped }. + */ +export function importSaved(state, queries, save = saveJSON, genId = () => 's' + Date.now() + rnd()) { + const { merged, added, updated, skipped } = mergeSaved(state.savedQueries, queries, genId); + state.savedQueries = merged; + save(KEYS.saved, state.savedQueries); + return { added, updated, skipped }; +} + /** Delete a saved query by id and clear any tab pointer to it. */ export function deleteSaved(state, id, save = saveJSON) { state.savedQueries = state.savedQueries.filter((q) => q.id !== id); diff --git a/src/styles.css b/src/styles.css index 0f4570f..631aae8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -189,6 +189,31 @@ body { } .hd-btn:hover { background: var(--bg-hover); color: var(--fg); } .hd-btn.text { width: auto; padding: 0 10px; font-size: 11.5px; font-family: inherit; border: 1px solid var(--border); } +.hd-btn.user-btn { + width: auto; gap: 5px; padding: 0 8px; + font-size: 11.5px; font-family: var(--mono); +} +.hd-btn.user-btn .user-short { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.user-menu { + z-index: 50; min-width: 200px; padding: 5px; + background: var(--bg-panel, var(--bg-editor)); border: 1px solid var(--border); + border-radius: 8px; box-shadow: 0 8px 28px rgba(0,0,0,.4); + display: flex; flex-direction: column; gap: 2px; +} +.user-menu .um-id { + padding: 7px 9px; font-size: 11px; color: var(--fg-faint); font-family: var(--mono); + border-bottom: 1px solid var(--border-faint); margin-bottom: 3px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.user-menu .um-item { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 7px 9px; border: none; background: transparent; + font: inherit; font-size: 12px; color: var(--fg); cursor: pointer; + border-radius: 5px; text-align: left; +} +.user-menu .um-item:hover { background: var(--bg-hover); } +.user-menu .um-item.danger { color: #ef4444; } +.user-menu .um-item.danger:hover { background: color-mix(in oklab, #ef4444 12%, transparent); } /* ------------ main row ------------ */ .main-row { flex: 1; display: flex; min-height: 0; overflow: hidden; } @@ -232,7 +257,7 @@ body { } .side-tab.active { color: var(--fg); border-bottom-color: var(--accent); } .side-tab:hover { color: var(--fg); } -.saved-list { flex: 1; overflow: auto; padding: 0; } +.saved-list { flex: 1; overflow: auto; padding: 0; display: flex; flex-direction: column; } .saved-empty { padding: 18px 14px; color: var(--fg-faint); font-size: 11.5px; text-align: center; line-height: 1.5; @@ -273,6 +298,20 @@ body { .saved-row:hover .sv-act { display: inline-flex; } .sv-act:hover { color: var(--fg); background: var(--bg-hover); } .side-count { color: var(--fg-faint); font-weight: 400; } +/* Export/Import row, pinned at the bottom of the Saved panel (sticky over the + scrolling list; margin-top:auto keeps it at the bottom when the list is short). */ +.saved-actions { + margin-top: auto; position: sticky; bottom: 0; flex-shrink: 0; + display: flex; gap: 6px; padding: 6px 10px; + border-top: 1px solid var(--border); background: var(--bg-panel, var(--bg-sidebar, var(--bg-editor))); +} +.sv-io { + flex: 1; height: 24px; border: 1px solid var(--border); border-radius: 5px; + background: transparent; color: var(--fg-mute); font-size: 11px; font-family: inherit; + cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 5px; +} +.sv-io:hover:not([disabled]) { background: var(--bg-hover); color: var(--fg); } +.sv-io[disabled] { opacity: .45; cursor: not-allowed; } .history-row { position: relative; padding: 8px 10px; cursor: pointer; user-select: none; diff --git a/src/ui/app.js b/src/ui/app.js index 12bd02c..40b3269 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -7,11 +7,12 @@ import { h } from './dom.js'; import { Icon } from './icons.js'; import { - createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab, + createState, activeTab, KEYS, recordHistory, saveQuery, savedForTab, importSaved, } from '../state.js'; import { saveJSON, saveStr } from '../core/storage.js'; import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; -import { sqlString, inferQueryName, shortVersion } from '../core/format.js'; +import { sqlString, inferQueryName, shortVersion, userShortName } from '../core/format.js'; +import { buildExportDoc, parseImportDoc } from '../core/saved-io.js'; import { toTSV, toCSV } from '../core/export.js'; import { newResult, applyStreamLine } from '../core/stream.js'; import { encodeSqlForHash } from '../core/share.js'; @@ -458,6 +459,57 @@ export function createApp(env = {}) { } app.openSavePopover = openSavePopover; + // User menu: dropdown under the header user button, holding the identity and + // a Log out item. Same close model as the save popover (Esc + outside click). + function openUserMenu() { + if (app.dom.userMenu) return; + const close = () => { + doc.removeEventListener('keydown', onKey, true); + doc.removeEventListener('mousedown', onOutside, true); + if (app.dom.userMenu) { app.dom.userMenu.remove(); app.dom.userMenu = null; } + }; + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } }; + const onOutside = (e) => { if (app.dom.userMenu && !app.dom.userMenu.contains(e.target) && e.target !== app.dom.userBtn && !app.dom.userBtn.contains(e.target)) close(); }; + const menu = h('div', { class: 'user-menu' }, + h('div', { class: 'um-id' }, app.email()), + h('button', { class: 'um-item danger', onclick: () => { close(); app.signOut(); } }, Icon.logout(), h('span', null, 'Log out'))); + app.dom.userMenu = menu; + const r = app.dom.userBtn.getBoundingClientRect(); + menu.style.position = 'fixed'; + menu.style.top = (r.bottom + 6) + 'px'; + menu.style.right = Math.max(8, (win.innerWidth || 0) - r.right) + 'px'; + doc.body.appendChild(menu); + doc.addEventListener('keydown', onKey, true); + doc.addEventListener('mousedown', onOutside, true); + } + 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); @@ -477,6 +529,9 @@ export function createApp(env = {}) { copyResult, exportResult, save: openSavePopover, + openUserMenu, + exportSaved, + importSavedFile, formatQuery, insertCreate, openShortcuts: () => openShortcuts(app), @@ -502,6 +557,8 @@ export function renderApp(app, helpers) { app.dom.connStatus = h('div', { class: 'conn-status dim' }, h('span', { class: 'ver' }, 'Connecting…')); app.dom.themeBtn = h('button', { class: 'hd-btn', title: 'Toggle theme', onclick: helpers.toggleTheme }); app.dom.themeBtn.appendChild(state.theme === 'dark' ? Icon.sun() : Icon.moon()); + app.dom.userBtn = h('button', { class: 'hd-btn user-btn', title: app.email(), onclick: () => app.actions.openUserMenu() }, + h('span', { class: 'user-short' }, userShortName(app.email())), Icon.chevDown()); const header = h('div', { class: 'app-header' }, h('div', { class: 'logo-mark' }, 'A'), @@ -515,8 +572,7 @@ export function renderApp(app, helpers) { }, Icon.github()), h('button', { class: 'hd-btn', title: 'Keyboard shortcuts (?)', onclick: () => app.actions.openShortcuts() }, Icon.shortcuts()), app.dom.themeBtn, - h('div', { class: 'user-email', title: app.email() }, app.email()), - h('button', { class: 'hd-btn text', title: 'Log out', onclick: () => app.signOut() }, 'Log Out')); + app.dom.userBtn); app.dom.schemaSearchInput = h('input', { type: 'text', placeholder: 'Search tables, columns…', diff --git a/src/ui/icons.js b/src/ui/icons.js index a3e96a2..39eaf35 100644 --- a/src/ui/icons.js +++ b/src/ui/icons.js @@ -81,6 +81,8 @@ export const Icon = { shortcuts: () => iconEl('', 12, 12, 1.3), copy: () => iconEl('', 12, 12), download: () => iconEl('', 12, 12), + upload: () => iconEl('', 12, 12), + logout: () => iconEl('', 12, 12), // Same glyph as the JSON view tab so the Format button's { } matches it. braces: () => svg('M4 1.5C2.5 1.5 2.5 3 2.5 4S2.5 5 1.5 6c1 1 1 2 1 2s0 1.5 1.5 1.5M8 1.5c1.5 0 1.5 1.5 1.5 2.5s0 1 1 2c-1 1-1 2-1 2s0 1.5-1.5 1.5', 12, 12), bookmark: () => iconEl('', 12, 12, 1.3), diff --git a/src/ui/saved-history.js b/src/ui/saved-history.js index ff2822e..c8f3462 100644 --- a/src/ui/saved-history.js +++ b/src/ui/saved-history.js @@ -35,7 +35,6 @@ function renderSaved(app, list) { if (state.savedQueries.length === 0) { list.appendChild(h('div', { class: 'saved-empty' }, 'No saved queries yet.', h('br'), 'Click ', Icon.bookmark(), ' Save next to Run.')); - return; } for (const q of sortedSaved(state)) { const editing = app.editingSavedId === q.id; @@ -84,6 +83,26 @@ function renderSaved(app, list) { h('div', { class: 'preview' }, q.sql.split('\n')[0])); list.appendChild(row); } + list.appendChild(savedActions(app)); +} + +/** 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) { diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index 8272b80..2cf1ace 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -42,6 +42,8 @@ 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 d62c20d..1c89321 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -93,7 +93,9 @@ describe('renderApp shell', () => { expect(app.root.querySelector('.app-header')).not.toBeNull(); expect(app.root.querySelector('.sidebar')).not.toBeNull(); expect(app.root.querySelector('.sql-editor')).not.toBeNull(); - expect(app.root.querySelector('.user-email').textContent).toBe('me@example.com'); + // user control shows the short name (local-part) + full email on hover + expect(app.dom.userBtn.querySelector('.user-short').textContent).toBe('me'); + expect(app.dom.userBtn.getAttribute('title')).toBe('me@example.com'); await Promise.resolve(); }); it('toggles theme via the header button', () => { @@ -103,16 +105,28 @@ describe('renderApp shell', () => { expect(app.savePref).toBeUndefined; // savePref is internal; theme attr set expect(document.documentElement.getAttribute('data-theme')).toBe('light'); }); - it('sign-out clears tokens and shows login', () => { + it('user menu: open → Log out clears tokens and shows login', () => { const { app, e } = rendered(); - app.root.querySelector('.hd-btn.text').dispatchEvent(new Event('click')); + app.dom.userBtn.dispatchEvent(new Event('click')); + const menu = document.querySelector('.user-menu'); + expect(menu).not.toBeNull(); + expect(menu.querySelector('.um-id').textContent).toBe('me@example.com'); + menu.querySelector('.um-item.danger').dispatchEvent(new Event('click', { bubbles: true })); expect(app.token).toBeNull(); expect(e.sessionStorage.getItem('oauth_id_token')).toBeNull(); expect(app.root.querySelector('.login-screen')).not.toBeNull(); + expect(document.querySelector('.user-menu')).toBeNull(); // closed }); - it('header has a Log Out button and a GitHub source link', () => { + it('user menu closes on Escape and outside-click; header has a GitHub source link', () => { const { app } = rendered(); - expect(app.root.querySelector('.hd-btn.text').textContent).toContain('Log Out'); + app.dom.userBtn.dispatchEvent(new Event('click')); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(document.querySelector('.user-menu')).toBeNull(); + app.actions.openUserMenu(); + app.actions.openUserMenu(); // idempotent while open + expect(document.querySelectorAll('.user-menu')).toHaveLength(1); + document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + expect(document.querySelector('.user-menu')).toBeNull(); const gh = app.root.querySelector('a.hd-btn[href*="github.com"]'); expect(gh).not.toBeNull(); expect(gh.getAttribute('target')).toBe('_blank'); @@ -452,6 +466,44 @@ describe('share + star + columns', () => { expect(app.dom.saveBtn.classList.contains('saved')).toBe(true); expect(app.dom.saveBtn.textContent).toContain('Saved'); }); + 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/format.test.js b/tests/unit/format.test.js index 2d342ed..44730e8 100644 --- a/tests/unit/format.test.js +++ b/tests/unit/format.test.js @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, + clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, } from '../../src/core/format.js'; describe('clamp', () => { @@ -113,3 +113,15 @@ describe('shortVersion', () => { expect(shortVersion(null)).toBe(''); }); }); + +describe('userShortName', () => { + it('returns the email local-part', () => { + expect(userShortName('btyshkevich@altinity.com')).toBe('btyshkevich'); + }); + it('falls back to the whole string with no @, and "" for empty/nullish', () => { + expect(userShortName('justname')).toBe('justname'); + expect(userShortName('@nolocal')).toBe('@nolocal'); // at index 0 → no split + expect(userShortName('')).toBe(''); + expect(userShortName(null)).toBe(''); + }); +}); diff --git a/tests/unit/saved-history.test.js b/tests/unit/saved-history.test.js index 60c4d0b..f1a5453 100644 --- a/tests/unit/saved-history.test.js +++ b/tests/unit/saved-history.test.js @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { renderSavedHistory } from '../../src/ui/saved-history.js'; import { makeApp } from '../helpers/fake-app.js'; @@ -75,6 +75,37 @@ describe('renderSavedHistory', () => { // clicking the row while editing another does not load (guard) — covered by Enter path above }); + it('saved: Export/Import row — Export disabled when empty, enabled with queries, wired', () => { + 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); + }); it('history: empty state', () => { const app = makeApp(); app.state.sidePanel = 'history'; diff --git a/tests/unit/saved-io.test.js b/tests/unit/saved-io.test.js new file mode 100644 index 0000000..9236cdd --- /dev/null +++ b/tests/unit/saved-io.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest'; +import { buildExportDoc, parseImportDoc, mergeSaved } from '../../src/core/saved-io.js'; + +describe('buildExportDoc', () => { + it('wraps queries in the envelope, keeps only id/name/sql/favorite, coerces favorite', () => { + const doc = buildExportDoc([{ id: 's1', name: 'A', sql: 'SELECT 1', favorite: 1, extra: 'x' }], '2026-06-21T00:00:00.000Z'); + expect(doc).toEqual({ + format: 'altinity-sql-browser/saved-queries', + version: 1, + exportedAt: '2026-06-21T00:00:00.000Z', + queries: [{ id: 's1', name: 'A', sql: 'SELECT 1', favorite: true }], + }); + }); + it('handles an empty list', () => { + expect(buildExportDoc([], 'T').queries).toEqual([]); + }); +}); + +describe('parseImportDoc', () => { + const env = (over) => JSON.stringify({ format: 'altinity-sql-browser/saved-queries', version: 1, queries: [], ...over }); + it('parses a valid doc and normalizes entries (drops invalid ones)', () => { + const { queries } = parseImportDoc(env({ queries: [ + { id: 's1', name: 'A', sql: 'SELECT 1', favorite: 1 }, + { name: 'B', sql: 'SELECT 2' }, // no id → id undefined + { name: 'bad', sql: 5 }, // non-string sql → dropped + { sql: 'no name' }, // no name → dropped + ] })); + expect(queries).toEqual([ + { id: 's1', name: 'A', sql: 'SELECT 1', favorite: true }, + { id: undefined, name: 'B', sql: 'SELECT 2', favorite: false }, + ]); + }); + it('throws a user message for each invalid envelope', () => { + expect(() => parseImportDoc('{not json')).toThrow('Not a valid JSON file'); + expect(() => parseImportDoc(JSON.stringify({ format: 'other' }))).toThrow('Unrecognized file format'); + expect(() => parseImportDoc(env({ version: 2 }))).toThrow('Unsupported file version'); + expect(() => parseImportDoc(env({ version: 'x' }))).toThrow('Unsupported file version'); + expect(() => parseImportDoc(env({ queries: 'nope' }))).toThrow('No queries in file'); + expect(() => parseImportDoc(env({ queries: Array.from({ length: 1001 }, () => ({ name: 'n', sql: 's' })) }))).toThrow('Too many queries'); + expect(() => parseImportDoc('null')).toThrow('Unrecognized file format'); // doc falsy + }); +}); + +describe('mergeSaved', () => { + const gen = (() => { let n = 0; return () => 'gen' + (++n); }); + it('adds new, skips content dup, updates by id, generates id when missing', () => { + const existing = [{ id: 's1', name: 'A', sql: '1', favorite: false }]; + const incoming = [ + { id: 's1', name: 'A', sql: '1', favorite: false }, // identical → skip (content) + { id: 's1', name: 'A2', sql: '1b', favorite: true }, // same id, differs → update + { name: 'B', sql: '2', favorite: false }, // no id → genId, add + { id: 's2', name: 'C', sql: '3', favorite: false }, // new id → add (keeps id) + ]; + const r = mergeSaved(existing, incoming, gen()); + expect(r).toMatchObject({ skipped: 1, updated: 1, added: 2 }); + expect(r.merged.find((q) => q.id === 's1')).toMatchObject({ name: 'A2', sql: '1b', favorite: true }); + expect(r.merged.find((q) => q.name === 'B').id).toBe('gen1'); // genId for the id-less entry + expect(r.merged.find((q) => q.name === 'C').id).toBe('s2'); // given id kept + expect(r.merged.map((q) => q.name)).toEqual(['A2', 'B', 'C']); + expect(existing[0]).toEqual({ id: 's1', name: 'A', sql: '1', favorite: false }); // not mutated + }); +}); diff --git a/tests/unit/state.test.js b/tests/unit/state.test.js index 8e44a4e..65b2141 100644 --- a/tests/unit/state.test.js +++ b/tests/unit/state.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { KEYS, newTabObj, createState, activeTab, allocTabId, - saveQuery, savedForTab, renameSaved, toggleFavorite, sortedSaved, + saveQuery, savedForTab, renameSaved, toggleFavorite, sortedSaved, importSaved, deleteSaved, recordHistory, clearHistory, deleteHistory, } from '../../src/state.js'; @@ -142,6 +142,23 @@ describe('saved queries', () => { expect(sortedSaved(s).map((q) => q.id)).toEqual(['c', 'a', 'b']); expect(save).toHaveBeenCalledTimes(1); }); + it('importSaved merges (add/skip/update), persists, and uses injected genId', () => { + const s = createState(reader()); + s.savedQueries = [{ id: 's1', name: 'A', sql: '1', favorite: false }]; + const save = vi.fn(); + const r = importSaved(s, [ + { id: 's1', name: 'A', sql: '1' }, // skip (content dup) + { id: 's1', name: 'A2', sql: '1b' }, // update by id + { name: 'B', sql: '2' }, // add (genId) + ], save, () => 'gx'); + expect(r).toEqual({ added: 1, updated: 1, skipped: 1 }); + expect(s.savedQueries.map((q) => q.name)).toEqual(['A2', 'B']); + expect(s.savedQueries.find((q) => q.name === 'B').id).toBe('gx'); + expect(save).toHaveBeenCalledWith(KEYS.saved, s.savedQueries); + // default save + genId (no injection) — exercises the default id generator + importSaved(s, [{ name: 'Z', sql: 'zz' }]); + expect(s.savedQueries.find((q) => q.name === 'Z').id).toMatch(/^s/); + }); it('deleteSaved removes + clears tab pointers', () => { const s = createState(reader()); s.savedQueries = [{ id: 's1', sql: 'x', name: 'n' }]; From 13207f74d323292c106121c48a7698bdbccb0685 Mon Sep 17 00:00:00 2001 From: Isolator acm Date: Sun, 21 Jun 2026 19:46:11 +0200 Subject: [PATCH 2/2] fix(ui): let the Saved Export/Import row scroll away with a long list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was position:sticky; bottom:0, so it stayed glued to the viewport bottom over a scrolling list. Drop sticky — margin-top:auto still sinks it to the bottom when the list is short, but a long list now scrolls it out of view. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QennTvGKAtJZrv9EpQagef --- src/styles.css | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/styles.css b/src/styles.css index 631aae8..5450eac 100644 --- a/src/styles.css +++ b/src/styles.css @@ -298,10 +298,11 @@ body { .saved-row:hover .sv-act { display: inline-flex; } .sv-act:hover { color: var(--fg); background: var(--bg-hover); } .side-count { color: var(--fg-faint); font-weight: 400; } -/* Export/Import row, pinned at the bottom of the Saved panel (sticky over the - scrolling list; margin-top:auto keeps it at the bottom when the list is short). */ +/* Export/Import row at the end of the Saved panel. margin-top:auto sinks it to + the bottom when the list is short, but it scrolls away with a long list (not + sticky) — no need to keep it on screen once there's plenty to scroll. */ .saved-actions { - margin-top: auto; position: sticky; bottom: 0; flex-shrink: 0; + margin-top: auto; flex-shrink: 0; display: flex; gap: 6px; padding: 6px 10px; border-top: 1px solid var(--border); background: var(--bg-panel, var(--bg-sidebar, var(--bg-editor))); }