diff --git a/src/core/saved-io.js b/src/core/saved-io.js index 21c84cb..d824bd6 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 bb8899a..a1f6323 100644 --- a/src/state.js +++ b/src/state.js @@ -84,15 +84,17 @@ export function savedForTab(state, tab) { } /** - * Save the tab's SQL under `name`. If the tab is already linked to a saved - * entry, update that entry in place; otherwise create a new one (newest first) - * and link the tab to it. The tab's name mirrors the saved name. Returns the - * saved entry, or null for empty SQL/name. + * Save the tab's SQL under `name` (+ an optional free-text `description`). If + * the tab is already linked to a saved entry, update that entry in place; + * otherwise create a new one (newest first) and link the tab to it. The tab's + * name mirrors the saved name. Returns the saved entry, or null for empty + * SQL/name. */ -export function saveQuery(state, tab, name, save = saveJSON, now = Date.now()) { +export function saveQuery(state, tab, name, description, save = saveJSON, now = Date.now()) { const sql = String(tab.sql || '').trim(); const nm = String(name || '').trim(); if (!sql || !nm) return null; + const desc = String(description || '').trim(); const chart = tabChart(tab); // Remember the current result view (Table/JSON/Chart) so a restore reopens the // same data representation; the transient raw view isn't persisted. @@ -101,10 +103,12 @@ export function saveQuery(state, tab, name, save = saveJSON, now = Date.now()) { if (entry) { entry.name = nm; entry.sql = sql; + if (desc) entry.description = desc; else delete entry.description; if (chart) entry.chart = chart; else delete entry.chart; if (view) entry.view = view; else delete entry.view; } else { entry = { id: makeId('s', now), name: nm, sql, favorite: false }; + if (desc) entry.description = desc; if (chart) entry.chart = chart; if (view) entry.view = view; state.savedQueries.unshift(entry); @@ -115,12 +119,20 @@ export function saveQuery(state, tab, name, save = saveJSON, now = Date.now()) { return entry; } -/** Rename a saved query, keeping any linked tab's name in sync. */ -export function renameSaved(state, id, name, save = saveJSON) { +/** + * Rename a saved query, keeping any linked tab's name in sync. When + * `description` is provided (not undefined) it is set/cleared too; pass + * undefined to leave the existing description untouched (name-only rename). + */ +export function renameSaved(state, id, name, description, save = saveJSON) { const nm = String(name || '').trim(); const entry = state.savedQueries.find((q) => q.id === id); if (!entry || !nm) return; entry.name = nm; + if (description !== undefined) { + const desc = String(description || '').trim(); // match saveQuery: null/non-string → '' → cleared + if (desc) entry.description = desc; else delete entry.description; + } for (const t of tabsForSaved(state, id)) t.name = nm; save(KEYS.saved, state.savedQueries); } diff --git a/src/styles.css b/src/styles.css index e05cbaf..d304ef2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -349,11 +349,37 @@ body { } .saved-row .sv-star.on { color: var(--accent); } .saved-row .sv-star:hover { color: var(--fg); } -.saved-row .sv-edit { - flex: 1; min-width: 0; font: inherit; font-size: 12px; font-weight: 500; - color: var(--fg); background: var(--bg-editor); - border: 1px solid var(--accent); border-radius: 4px; padding: 1px 5px; outline: none; -} +.saved-row .desc { + font-size: 11px; color: var(--fg-mute); line-height: 1.4; padding-left: 18px; + overflow: hidden; text-overflow: ellipsis; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; +} +/* Expanded "edit name & description" form, shown in place of a saved row. */ +.saved-edit { + padding: 9px 10px; background: var(--bg-hover); + border-bottom: 1px solid var(--border-faint); border-left: 2px solid var(--accent); + display: flex; flex-direction: column; gap: 5px; +} +.saved-edit .sv-field { + font-size: 9.5px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; + color: var(--fg-faint); margin-top: 3px; +} +.saved-edit .sv-edit-name, .saved-edit .sv-edit-desc { + font: inherit; color: var(--fg); background: var(--bg-input, var(--bg-editor)); + border: 1px solid var(--border); border-radius: 5px; padding: 4px 7px; outline: none; + box-sizing: border-box; width: 100%; +} +.saved-edit .sv-edit-name { font-size: 12px; font-weight: 500; border-color: var(--accent); } +.saved-edit .sv-edit-desc { font-size: 11.5px; line-height: 1.45; resize: vertical; min-height: 48px; } +.saved-edit .sv-edit-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 3px; } +.saved-edit .sv-edit-cancel, .saved-edit .sv-edit-save { + font: inherit; font-size: 11px; font-weight: 500; cursor: pointer; + padding: 4px 11px; border-radius: 5px; border: 1px solid var(--border); +} +.saved-edit .sv-edit-cancel { background: transparent; color: var(--fg); } +.saved-edit .sv-edit-cancel:hover { background: var(--bg-hover); } +.saved-edit .sv-edit-save { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } +.saved-edit .sv-edit-save:hover { filter: brightness(1.08); } .saved-row .preview { font-size: 10.5px; font-family: var(--mono); color: var(--fg-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -417,6 +443,13 @@ body { border-radius: 5px; padding: 6px 8px; outline: none; } .save-popover .sp-input:focus { border-color: var(--accent); } +.save-popover .sp-opt { text-transform: none; letter-spacing: 0; font-weight: 400; color: var(--fg-faint); } +.save-popover .sp-desc { + font: inherit; font-size: 12px; line-height: 1.45; color: var(--fg); + background: var(--bg-editor); border: 1px solid var(--border); + border-radius: 5px; padding: 6px 8px; outline: none; resize: vertical; min-height: 52px; +} +.save-popover .sp-desc:focus { border-color: var(--accent); } .save-popover .sp-actions { display: flex; justify-content: flex-end; gap: 8px; } .save-popover .sp-cancel, .save-popover .sp-save { font: inherit; font-size: 12px; cursor: pointer; diff --git a/src/ui/app.js b/src/ui/app.js index 2804958..23a4189 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -587,10 +587,12 @@ export function createApp(env = {}) { const entry = savedForTab(app.state, tab); const prefill = entry ? entry.name : (tab.name && tab.name !== 'Untitled' ? tab.name : inferQueryName(tab.sql)); const input = h('input', { class: 'sp-input', value: prefill }); + const descInput = h('textarea', { class: 'sp-desc', rows: '3', placeholder: 'What this query does — included in Markdown export' }); + if (entry && entry.description) descInput.value = entry.description; let close; const commit = () => { if (!input.value.trim()) return; - saveQuery(app.state, tab, input.value, saveJSON); + saveQuery(app.state, tab, input.value, descInput.value, saveJSON); close(); app.updateSaveBtn(); app.actions.rerenderTabs(); @@ -598,9 +600,13 @@ export function createApp(env = {}) { flashToast('Saved', { document: doc }); }; input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); commit(); } }); + // In the multiline description, plain Enter inserts a newline; ⌘/Ctrl+Enter commits. + descInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); commit(); } }); const pop = h('div', { class: 'save-popover' }, h('div', { class: 'sp-label' }, 'Save query as'), input, + h('div', { class: 'sp-label' }, 'Description', h('span', { class: 'sp-opt' }, ' — optional')), + descInput, h('div', { class: 'sp-actions' }, h('button', { class: 'sp-cancel', onclick: () => close() }, 'Cancel'), h('button', { class: 'sp-save', onclick: commit }, 'Save'))); diff --git a/src/ui/saved-history.js b/src/ui/saved-history.js index 44e989c..c4e630c 100644 --- a/src/ui/saved-history.js +++ b/src/ui/saved-history.js @@ -37,55 +37,73 @@ function renderSaved(app, list) { 'No saved queries yet.', h('br'), 'Click ', Icon.bookmark(), ' Save next to Run.')); } for (const q of sortedSaved(state)) { - const editing = app.editingSavedId === q.id; + 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); }, }, Icon.star(q.favorite)); - let nameEl; - if (editing) { - const input = h('input', { class: 'sv-edit', value: q.name }); - let done = false; - // `commit` (Enter/blur) renames; `!commit` (Escape) cancels. The guard - // stops the blur fired by the re-render teardown from undoing a cancel. - const finish = (commit) => { - if (done) return; - done = true; - if (commit && input.value.trim()) { renameSaved(state, q.id, input.value, app.saveJSON); app.actions.rerenderTabs(); } - app.editingSavedId = null; - renderSavedHistory(app); - }; - input.addEventListener('click', (e) => e.stopPropagation()); - 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)); - nameEl = input; - setTimeout(() => { input.focus(); input.select(); }); - } else { - nameEl = h('span', { class: 'name' }, q.name); - } - - const row = h('div', { class: 'saved-row', onclick: () => { if (!editing) { app.actions.loadIntoNewTab(q.name, q.sql, q.id, q.chart); app.actions.run({ view: q.view }); } } }, + 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 }); } }, h('div', { class: 'top' }, star, - nameEl, - editing ? null : h('button', { - class: 'sv-act', title: 'Rename', + h('span', { class: 'name' }, q.name), + h('button', { + class: 'sv-act', title: 'Edit name & description', onclick: (e) => { e.stopPropagation(); app.editingSavedId = q.id; renderSavedHistory(app); }, }, Icon.pencil()), - editing ? null : h('button', { + h('button', { class: 'sv-act', title: 'Delete', onclick: (e) => { e.stopPropagation(); deleteSaved(state, q.id, app.saveJSON); app.updateSaveBtn(); renderSavedHistory(app); }, }, 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)); } +/** + * The expanded "edit name & description" form shown in place of a saved row + * while `app.editingSavedId === q.id`. The Name field commits on Enter, the + * Description field on ⌘/Ctrl+Enter (plain Enter inserts a newline); Escape or + * Cancel reverts. Clicks inside the form don't load the query. A `done` guard + * keeps the re-render teardown from double-firing the commit. + */ +function savedEditForm(app, q) { + const state = app.state; + const nameInput = h('input', { class: 'sv-edit-name', value: q.name, placeholder: 'Query name' }); + const descInput = h('textarea', { class: 'sv-edit-desc', rows: '3', placeholder: 'What this query does (shown in Markdown export)' }); + descInput.value = q.description || ''; + let done = false; + const finish = (commit) => { + if (done) return; + done = true; + if (commit && nameInput.value.trim()) { + renameSaved(state, q.id, nameInput.value, descInput.value, app.saveJSON); + app.actions.rerenderTabs(); + } + app.editingSavedId = null; + renderSavedHistory(app); + }; + nameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); finish(true); } + else if (e.key === 'Escape') { e.preventDefault(); finish(false); } + }); + descInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); finish(true); } + else if (e.key === 'Escape') { e.preventDefault(); finish(false); } + }); + setTimeout(() => { nameInput.focus(); nameInput.select(); }); + return h('div', { class: 'saved-edit', onclick: (e) => e.stopPropagation() }, + h('div', { class: 'sv-field' }, 'Name'), + nameInput, + h('div', { class: 'sv-field' }, 'Description'), + descInput, + h('div', { class: 'sv-edit-actions' }, + h('button', { class: 'sv-edit-cancel', onclick: () => finish(false) }, 'Cancel'), + 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; diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index b17b99c..73b9a21 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -685,6 +685,19 @@ describe('share + star + columns', () => { expect(app.dom.saveBtn.classList.contains('saved')).toBe(true); expect(app.dom.saveBtn.textContent).toContain('Saved'); }); + it('save popover: prefills the linked query description; ⌘Enter on the textarea commits', () => { + const app = createApp(env()); + app.renderApp(); + app.state.savedQueries = [{ id: 's9', name: 'Fav', sql: 'SELECT 9', favorite: false, description: 'why' }]; + app.actions.loadIntoNewTab('Fav', 'SELECT 9', 's9'); + app.actions.save(); + const desc = document.querySelector('.save-popover .sp-desc'); + expect(desc.value).toBe('why'); // prefilled from the linked entry + desc.value = 'updated reason'; + desc.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', metaKey: true })); + 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(); } }; diff --git a/tests/unit/saved-history.test.js b/tests/unit/saved-history.test.js index b8a79cf..febbd47 100644 --- a/tests/unit/saved-history.test.js +++ b/tests/unit/saved-history.test.js @@ -54,28 +54,84 @@ describe('renderSavedHistory', () => { expect(names()).toEqual(['B', 'A']); }); - it('saved: pencil → inline rename; Enter commits, Escape cancels', () => { + it('saved: pencil opens the edit form; Name(Enter)+Description commit via renameSaved; double-fire is guarded', () => { const app = makeApp(); app.state.sidePanel = 'saved'; app.state.savedQueries = [{ id: 's1', name: 'Old', sql: '1', favorite: false }]; renderSavedHistory(app); - byTitle(app.dom.savedList, 'Rename').dispatchEvent(new Event('click', { bubbles: true })); + byTitle(app.dom.savedList, 'Edit name & description').dispatchEvent(new Event('click', { bubbles: true })); expect(app.editingSavedId).toBe('s1'); - let input = app.dom.savedList.querySelector('.sv-edit'); - expect(input.value).toBe('Old'); - input.value = 'New'; - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - expect(app.state.savedQueries[0].name).toBe('New'); + const nameInput = app.dom.savedList.querySelector('.sv-edit-name'); + const descInput = app.dom.savedList.querySelector('.sv-edit-desc'); + expect(nameInput.value).toBe('Old'); + expect(descInput.value).toBe(''); // no description yet + nameInput.value = 'New'; + descInput.value = 'a description'; + nameInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(app.state.savedQueries[0]).toMatchObject({ name: 'New', description: 'a description' }); expect(app.editingSavedId).toBeNull(); expect(app.actions.rerenderTabs).toHaveBeenCalled(); - // re-open, edit, Escape → unchanged - byTitle(app.dom.savedList, 'Rename').dispatchEvent(new Event('click', { bubbles: true })); - input = app.dom.savedList.querySelector('.sv-edit'); - input.value = 'XXX'; - input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + // a second commit on the now-detached field is a no-op (the `done` guard) + nameInput.value = 'AGAIN'; + nameInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(app.state.savedQueries[0].name).toBe('New'); + // re-open and press Escape on the name field → cancels without saving + byTitle(app.dom.savedList, 'Edit name & description').dispatchEvent(new Event('click', { bubbles: true })); + const reName = app.dom.savedList.querySelector('.sv-edit-name'); + reName.value = 'XYZ'; + reName.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); expect(app.editingSavedId).toBeNull(); expect(app.state.savedQueries[0].name).toBe('New'); - // clicking the row while editing another does not load (guard) — covered by Enter path above + }); + it('saved: edit form — description prefilled; ⌘/Ctrl+Enter + Save commit, Escape/Cancel + empty name revert', () => { + const app = makeApp(); + app.state.sidePanel = 'saved'; + app.state.savedQueries = [{ id: 's1', name: 'Old', sql: '1', favorite: false, description: 'd0' }]; + renderSavedHistory(app); + const open = () => byTitle(app.dom.savedList, 'Edit name & description').dispatchEvent(new Event('click', { bubbles: true })); + // ⌘Enter on the description commits (and prefills the existing description) + open(); + let descInput = app.dom.savedList.querySelector('.sv-edit-desc'); + expect(descInput.value).toBe('d0'); + descInput.value = 'd1'; + descInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', metaKey: true, bubbles: true })); + expect(app.state.savedQueries[0].description).toBe('d1'); + // Ctrl+Enter also commits + open(); + descInput = app.dom.savedList.querySelector('.sv-edit-desc'); + descInput.value = 'd2'; + descInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true, bubbles: true })); + expect(app.state.savedQueries[0].description).toBe('d2'); + // Escape on the description cancels without saving + open(); + descInput = app.dom.savedList.querySelector('.sv-edit-desc'); + descInput.value = 'nope'; + descInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(app.state.savedQueries[0].description).toBe('d2'); + expect(app.editingSavedId).toBeNull(); + // Save button with a blank name does not rename (commit guard) + open(); + app.dom.savedList.querySelector('.sv-edit-name').value = ' '; + app.dom.savedList.querySelector('.sv-edit-save').dispatchEvent(new Event('click', { bubbles: true })); + expect(app.state.savedQueries[0].name).toBe('Old'); + expect(app.editingSavedId).toBeNull(); + // Cancel button reverts an edited name + open(); + app.dom.savedList.querySelector('.sv-edit-name').value = 'ZZZ'; + app.dom.savedList.querySelector('.sv-edit-cancel').dispatchEvent(new Event('click', { bubbles: true })); + expect(app.state.savedQueries[0].name).toBe('Old'); + }); + it('saved: renders a 2-line description preview when present, omits it otherwise', () => { + const app = makeApp(); + app.state.sidePanel = 'saved'; + app.state.savedQueries = [ + { id: 's1', name: 'A', sql: '1', favorite: false, description: 'explains A' }, + { id: 's2', name: 'B', sql: '2', favorite: false }, + ]; + renderSavedHistory(app); + const rows = app.dom.savedList.querySelectorAll('.saved-row'); + expect(rows[0].querySelector('.desc').textContent).toBe('explains A'); + expect(rows[1].querySelector('.desc')).toBeNull(); }); it('saved: Export/Import row — Export disabled when empty, enabled with queries, wired', () => { diff --git a/tests/unit/saved-io.test.js b/tests/unit/saved-io.test.js index fe6c468..84269ea 100644 --- a/tests/unit/saved-io.test.js +++ b/tests/unit/saved-io.test.js @@ -27,6 +27,14 @@ describe('buildExportDoc', () => { expect('chart' in doc.queries[2]).toBe(false); expect('view' in doc.queries[2]).toBe(false); }); + it('carries a description when present, omits it when absent', () => { + const doc = buildExportDoc([ + { id: 's1', name: 'A', sql: '1', favorite: false, description: 'note' }, + { id: 's2', name: 'B', sql: '2', favorite: false }, + ], 'T'); + expect(doc.queries[0].description).toBe('note'); + expect('description' in doc.queries[1]).toBe(false); + }); }); describe('parseImportDoc', () => { @@ -62,6 +70,16 @@ describe('parseImportDoc', () => { expect(queries[0].view).toBe('json'); expect(queries[1].view).toBeUndefined(); }); + it('trims a string description, dropping a whitespace-only or non-string one', () => { + const { queries } = parseImportDoc(env({ queries: [ + { name: 'A', sql: '1', description: ' a note ' }, // trimmed + { name: 'B', sql: '2', description: 123 }, // non-string → dropped + { name: 'C', sql: '3', description: ' ' }, // whitespace-only → dropped (#1 review) + ] })); + expect(queries[0].description).toBe('a note'); + expect(queries[1].description).toBeUndefined(); + expect(queries[2].description).toBeUndefined(); + }); 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'); @@ -111,4 +129,19 @@ describe('mergeSaved', () => { expect(r.merged.find((q) => q.name === 'C').chart).toEqual(chart); expect(r.merged.find((q) => q.name === 'C').view).toBe('chart'); }); + it('carries description on add, replaces it by id, and drops it when an update omits it', () => { + const existing = [ + { id: 's1', name: 'A', sql: '1', favorite: false, description: 'old' }, + { id: 's2', name: 'B', sql: '2', favorite: false, description: 'old2' }, + ]; + const incoming = [ + { id: 's1', name: 'A2', sql: '1b', favorite: false }, // no description → drop + { id: 's2', name: 'B2', sql: '2b', favorite: false, description: 'new' }, // replace + { name: 'C', sql: '3', favorite: false, description: 'added' }, // add with description + ]; + const r = mergeSaved(existing, incoming, () => 'g'); + expect('description' in r.merged.find((q) => q.id === 's1')).toBe(false); + expect(r.merged.find((q) => q.id === 's2').description).toBe('new'); + expect(r.merged.find((q) => q.name === 'C').description).toBe('added'); + }); }); diff --git a/tests/unit/state.test.js b/tests/unit/state.test.js index 3994b22..fb1d8be 100644 --- a/tests/unit/state.test.js +++ b/tests/unit/state.test.js @@ -82,9 +82,9 @@ describe('saved queries', () => { const s = createState(reader()); const save = vi.fn(); s.tabs[0].sql = ''; - expect(saveQuery(s, s.tabs[0], 'name', save)).toBeNull(); + expect(saveQuery(s, s.tabs[0], 'name', '', save)).toBeNull(); s.tabs[0].sql = 'SELECT 1'; - expect(saveQuery(s, s.tabs[0], ' ', save)).toBeNull(); + expect(saveQuery(s, s.tabs[0], ' ', '', save)).toBeNull(); expect(save).not.toHaveBeenCalled(); }); it('saveQuery creates + links the tab, then updates in place on re-save', () => { @@ -92,7 +92,7 @@ describe('saved queries', () => { const save = vi.fn(); const tab = s.tabs[0]; tab.sql = 'SELECT 1'; - const e1 = saveQuery(s, tab, 'My query', save, 100); + const e1 = saveQuery(s, tab, 'My query', '', save, 100); expect(e1).toMatchObject({ name: 'My query', sql: 'SELECT 1', favorite: false }); expect(tab.savedId).toBe(e1.id); expect(tab.name).toBe('My query'); @@ -100,12 +100,28 @@ describe('saved queries', () => { expect(save).toHaveBeenLastCalledWith(KEYS.saved, s.savedQueries); // re-save the linked tab → updates the same entry in place tab.sql = 'SELECT 2'; - const e2 = saveQuery(s, tab, 'My query v2', save, 200); + const e2 = saveQuery(s, tab, 'My query v2', '', save, 200); expect(e2.id).toBe(e1.id); expect(s.savedQueries).toHaveLength(1); expect(s.savedQueries[0]).toMatchObject({ name: 'My query v2', sql: 'SELECT 2' }); expect(tab.name).toBe('My query v2'); }); + it('saveQuery stores/updates/clears an optional description', () => { + const s = createState(reader()); + const save = vi.fn(); + const tab = s.tabs[0]; + tab.sql = 'SELECT 1'; + const e = saveQuery(s, tab, 'Q', ' what it does ', save, 100); // trimmed + expect(e.description).toBe('what it does'); + saveQuery(s, tab, 'Q', 'changed', save, 200); // update in place + expect(s.savedQueries[0].description).toBe('changed'); + saveQuery(s, tab, 'Q', ' ', save, 300); // blank → dropped + expect('description' in s.savedQueries[0]).toBe(false); + // create with no description arg → no description field + const t2 = newTabObj('t2'); t2.sql = 'SELECT 2'; s.tabs.push(t2); + const e2 = saveQuery(s, t2, 'Q2', undefined, save, 400); + expect('description' in e2).toBe(false); + }); it('savedForTab resolves the linked entry (or null)', () => { const s = createState(reader()); s.savedQueries = [{ id: 's1', sql: 'x', name: 'n', favorite: false }]; @@ -120,14 +136,29 @@ describe('saved queries', () => { s.savedQueries = [{ id: 's1', sql: 'x', name: 'old', favorite: false }]; s.tabs[0].savedId = 's1'; const save = vi.fn(); - renameSaved(s, 's1', ' new ', save); + renameSaved(s, 's1', ' new ', undefined, save); expect(s.savedQueries[0].name).toBe('new'); expect(s.tabs[0].name).toBe('new'); - renameSaved(s, 's1', ' ', save); // blank ignored + renameSaved(s, 's1', ' ', undefined, save); // blank ignored expect(s.savedQueries[0].name).toBe('new'); - renameSaved(s, 'missing', 'x', save); // unknown id ignored + renameSaved(s, 'missing', 'x', undefined, save); // unknown id ignored expect(save).toHaveBeenCalledTimes(1); }); + it('renameSaved sets/clears description when given, leaves it untouched when undefined', () => { + const s = createState(reader()); + s.savedQueries = [{ id: 's1', sql: 'x', name: 'A', favorite: false }]; + const save = vi.fn(); + renameSaved(s, 's1', 'A', ' a note ', save); // set (trimmed) + expect(s.savedQueries[0].description).toBe('a note'); + renameSaved(s, 's1', 'A', undefined, save); // name-only → description kept + expect(s.savedQueries[0].description).toBe('a note'); + renameSaved(s, 's1', 'A', '', save); // explicit empty → cleared + expect('description' in s.savedQueries[0]).toBe(false); + renameSaved(s, 's1', 'A', ' re ', save); // re-set + expect(s.savedQueries[0].description).toBe('re'); + renameSaved(s, 's1', 'A', null, save); // null (not undefined) → cleared, not stored as 'null' (#4 review) + expect('description' in s.savedQueries[0]).toBe(false); + }); it('toggleFavorite flips the flag; sortedSaved puts favorites first (stable)', () => { const s = createState(reader()); s.savedQueries = [ @@ -173,16 +204,16 @@ describe('saved queries', () => { tab.sql = 'SELECT a, b'; tab.chartCfg = { type: 'pie', x: 0, y: [1], series: null }; tab.chartKey = 'a:String|b:UInt64'; - const e1 = saveQuery(s, tab, 'Chartd', save, 100); + const e1 = saveQuery(s, tab, 'Chartd', '', save, 100); expect(e1.chart).toEqual({ cfg: tab.chartCfg, key: tab.chartKey }); expect(e1.chart.cfg).not.toBe(tab.chartCfg); // cloned into the entry // re-save with a different chart → entry.chart updates in place tab.chartCfg = { type: 'line', x: 0, y: [1], series: null }; - saveQuery(s, tab, 'Chartd', save, 200); + saveQuery(s, tab, 'Chartd', '', save, 200); expect(s.savedQueries[0].chart.cfg.type).toBe('line'); // re-save after the chart is cleared → entry.chart is dropped tab.chartCfg = null; - saveQuery(s, tab, 'Chartd', save, 300); + saveQuery(s, tab, 'Chartd', '', save, 300); expect(s.savedQueries[0].chart).toBeUndefined(); }); it('saveQuery persists the result view (Table/JSON/Chart), updates it, and ignores the transient raw view', () => { @@ -191,15 +222,15 @@ describe('saved queries', () => { const tab = s.tabs[0]; tab.sql = 'SELECT 1'; s.resultView = 'chart'; - const e = saveQuery(s, tab, 'V', save, 100); + const e = saveQuery(s, tab, 'V', '', save, 100); expect(e.view).toBe('chart'); // re-save under a different view → updates s.resultView = 'json'; - saveQuery(s, tab, 'V', save, 200); + saveQuery(s, tab, 'V', '', save, 200); expect(s.savedQueries[0].view).toBe('json'); // raw view (TSV/JSON output) is not a saved view → dropped s.resultView = 'raw'; - saveQuery(s, tab, 'V', save, 300); + saveQuery(s, tab, 'V', '', save, 300); expect(s.savedQueries[0].view).toBeUndefined(); }); it('deleteSaved removes + clears tab pointers', () => {