diff --git a/package.json b/package.json index 1517b55..2637a0f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graph-lens-lite", - "version": "1.15.1", + "version": "1.15.3", "main": "src/package/electron_app.js", "description": "Visualise and explore property graphs in a lightweight desktop app.", "homepage": "https://github.com/Delta4AI/GraphLensLite", diff --git a/src/config.js b/src/config.js index d6ab403..f3efb03 100644 --- a/src/config.js +++ b/src/config.js @@ -1,7 +1,7 @@ /** * Defaults for the graph, layouts and UI */ -const VERSION = "1.15.1"; +const VERSION = "1.15.3"; const DEFAULTS = { NODE: { diff --git a/src/managers/query.js b/src/managers/query.js index a70d378..a260e99 100644 --- a/src/managers/query.js +++ b/src/managers/query.js @@ -236,9 +236,8 @@ class QueryManager { ); /* 5-3 Categorical Values (Dropdown): "IN [" up to "]" --------- */ - asciiStr = asciiStr.replace(/IN\s*\[([^\]]*?)]/g, (_match, list) => { - const encodedCategories = list - .split(",") + asciiStr = asciiStr.replace(/IN\s*\[((?:\\.|[^\]\\])*)]/g, (_match, list) => { + const encodedCategories = StaticUtilities.splitQueryList(list) .map(cat => `${StaticUtilities.escapeHtml(cat)}`) .join(`,`); @@ -408,7 +407,7 @@ class QueryManager { for (const [propID, fo] of this.cache.data.layouts[this.cache.data.selectedLayout].filters.entries()) { if (fo.active) { if (fo.isCategory) { - queryEntries.push(`${propID} IN [${[...fo.categories].map(cat => cat).join(",")}]`); + queryEntries.push(`${propID} IN [${[...fo.categories].map(cat => StaticUtilities.escapeQueryValue(cat)).join(",")}]`); } else if (fo.isInverted) { queryEntries.push(`${propID} LOWER THAN ${fo.upperThreshold} OR GREATER THAN ${fo.lowerThreshold}`); } else { @@ -629,7 +628,7 @@ class QueryManager { 'q-in-cat-bracket-open': () => ({type: 'KW', value: 'IN ['}), // category strings - 'q-string': el => ({type: 'STR', value: el.textContent}), + 'q-string': el => ({type: 'STR', value: StaticUtilities.unescapeQueryValue(el.textContent)}), // whole property path ("A::B::C") 'q-property-wrapper': el => { diff --git a/src/managers/ui_components.js b/src/managers/ui_components.js index d2b686a..c661a69 100644 --- a/src/managers/ui_components.js +++ b/src/managers/ui_components.js @@ -133,17 +133,18 @@ class DropdownChecklist { this.itemsList.style.display = ""; this.itemsList.style.visibility = ""; - const anchorRect = this.anchor.getBoundingClientRect(); - const viewportHeight = window.innerHeight; - const availableHeight = viewportHeight - anchorRect.bottom; + const {left, top, maxHeight} = StaticUtilities.computeDropdownPlacement({ + anchorRect: this.anchor.getBoundingClientRect(), + dropdownHeight, + viewportHeight: window.innerHeight, + }); - // Set position of the dropdown - this.itemsList.style.top = `${anchorRect.bottom}px`; - this.itemsList.style.left = `${anchorRect.left - 3}px`; + this.itemsList.style.left = `${left}px`; + this.itemsList.style.top = `${top}px`; - // Make the dropdown scrollable if there's not enough space - if (dropdownHeight > availableHeight) { - this.itemsList.style.maxHeight = `${availableHeight}px`; + // Cap height and scroll only when even the chosen side is too small. + if (maxHeight != null) { + this.itemsList.style.maxHeight = `${maxHeight}px`; this.itemsList.style.overflowY = "auto"; } else { this.itemsList.style.maxHeight = ""; @@ -941,9 +942,9 @@ class UIComponentManager { } } else if (dropdown) { if (this.cache.CFG.QUERY_BTN_USE_CURRENT_FILTER) { - queryFragment = `${propID} IN [${[...dropdown.selectedCategories].join(",")}]` + queryFragment = `${propID} IN [${[...dropdown.selectedCategories].map(cat => StaticUtilities.escapeQueryValue(cat)).join(",")}]` } else { - queryFragment = `${propID} IN [${[...dropdown.categories].join(",")}]` + queryFragment = `${propID} IN [${[...dropdown.categories].map(cat => StaticUtilities.escapeQueryValue(cat)).join(",")}]` } } diff --git a/src/style.css b/src/style.css index b0ad320..0cdcee8 100644 --- a/src/style.css +++ b/src/style.css @@ -3160,6 +3160,10 @@ input:checked + .slider:before { .dropdown-check-list ul.items { position: fixed; + /* Include padding+border in the height so the JS max-height cap (which uses + the available space) matches the rendered box and the panel never spills + over the anchor when a huge list opens upward. */ + box-sizing: border-box; padding: 2px; display: none; margin: 0 0 0 3px; diff --git a/src/utilities/static.js b/src/utilities/static.js index fb0d48f..57f861f 100644 --- a/src/utilities/static.js +++ b/src/utilities/static.js @@ -146,6 +146,59 @@ class StaticUtilities { return propId.split("::"); } + // The query DSL uses [ ] , ( ) and \ as grammar. Categorical values can + // contain any of these (e.g. free-text "Evidence sample" cells), so they are + // backslash-escaped when written into a query string and unescaped when the + // query is decoded back into category tokens. + static escapeQueryValue(value) { + return String(value).replace(/[\\[\],()]/g, "\\$&"); + } + + static unescapeQueryValue(value) { + return String(value).replace(/\\(.)/g, "$1"); + } + + // Split a category list on unescaped commas only, keeping escape sequences + // intact in each token (they are unescaped later, at decode time). + static splitQueryList(listStr) { + const out = []; + let cur = ""; + for (let i = 0; i < listStr.length; i++) { + const ch = listStr[i]; + if (ch === "\\" && i + 1 < listStr.length) { + cur += ch + listStr[i + 1]; + i++; + continue; + } + if (ch === ",") { + out.push(cur); + cur = ""; + continue; + } + cur += ch; + } + out.push(cur); + return out; + } + + // Decide where a dropdown panel should open relative to its anchor: flip + // upward when there is more room above than below, and cap the height (with + // scroll) to the chosen side so it never spills past the window edge. + // Pure function of measured geometry so the flip logic stays unit-testable. + static computeDropdownPlacement({anchorRect, dropdownHeight, viewportHeight, margin = 4}) { + const spaceBelow = viewportHeight - anchorRect.bottom - margin; + const spaceAbove = anchorRect.top - margin; + const openUp = dropdownHeight > spaceBelow && spaceAbove > spaceBelow; + const available = Math.max(0, openUp ? spaceAbove : spaceBelow); + const height = Math.min(dropdownHeight, available); + return { + openUp, + left: anchorRect.left - 3, + top: openUp ? anchorRect.top - height : anchorRect.bottom, + maxHeight: dropdownHeight > available ? available : null, + }; + } + static setsAreEqual(setA, setB) { if (setA.size !== setB.size) return false; for (let item of setA) { diff --git a/tests/query-categorical-escaping.test.js b/tests/query-categorical-escaping.test.js new file mode 100644 index 0000000..d8bf575 --- /dev/null +++ b/tests/query-categorical-escaping.test.js @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest' +import { QueryManager } from '../src/managers/query.js' +import { StaticUtilities } from '../src/utilities/static.js' + +// ========================================================================== +// Categorical values containing the query-DSL grammar characters +// ( [ ] , ( ) \ ) must survive a full encodeQuery -> decodeQuery round-trip. +// Regression: bracketed "Relations detail" / prose "Evidence sample" values +// broke the auto-generated query and hid every node and edge (0/N shown). +// ========================================================================== + +function makeQM() { + for (const id of ['queryUpdateBtn', 'querySelectBtn']) { + const btn = document.createElement('button') + btn.id = id + document.body.appendChild(btn) + } + const cache = { + query: { valid: true, overlay: document.createElement('div') }, + uniquePropHierarchy: { + 'Edge filters': { relation: new Set(['Relations detail']) }, + }, + } + return new QueryManager(cache) +} + +// Pull every decoded STR (category) token value out of the nested instructions. +function collectCategories(instructions) { + const out = [] + const walk = (node) => { + if (Array.isArray(node)) return node.forEach(walk) + if (node && node.type === 'STR') out.push(node.value) + } + walk(instructions) + return out +} + +describe('categorical query escaping (encode -> decode round-trip)', () => { + let qm + beforeEach(() => { + document.body.innerHTML = '' + qm = makeQM() + }) + + it('preserves bracketed, comma, paren and backslash categories exactly', () => { + const cats = [ + 'protects [B,12]; located_in [B,4]; binds [B,2]', + 'Jacob et al. (HES vs saline) [PMID 30654825]', + 'weird \\ backslash, and (paren)', + ] + // Build the query string exactly as updateQueryTextArea does. + const list = cats.map((c) => StaticUtilities.escapeQueryValue(c)).join(',') + const queryStr = `(Edge filters::relation::Relations detail IN [${list}])` + + qm.cache.query.overlay.innerHTML = qm.encodeQuery(queryStr) + const decoded = collectCategories(qm.decodeQuery()) + + expect(qm.cache.query.valid).toBe(true) + expect(decoded).toEqual(cats) + }) +}) diff --git a/tests/static-utilities.test.js b/tests/static-utilities.test.js index 8ba414b..3919d4e 100644 --- a/tests/static-utilities.test.js +++ b/tests/static-utilities.test.js @@ -1,6 +1,90 @@ import { describe, it, expect } from 'vitest' import { StaticUtilities } from '../src/utilities/static.js' +// ========================================================================== +// Query DSL category escaping — round-trips values containing the grammar +// characters [ ] , ( ) and \ so categorical filters survive auto-query +// generation (regression: bracketed values hid the entire graph). +// ========================================================================== + +describe('StaticUtilities query-value escaping', () => { + const roundTrip = (v) => + StaticUtilities.unescapeQueryValue(StaticUtilities.escapeQueryValue(v)) + + it('escapes every DSL grammar character', () => { + expect(StaticUtilities.escapeQueryValue('a[b]c,d(e)f\\g')).toBe( + 'a\\[b\\]c\\,d\\(e\\)f\\\\g' + ) + }) + + it('round-trips bracketed/comma/paren values exactly', () => { + for (const v of [ + 'protects [B,12]; located_in [B,4]; binds [B,2]', + 'Jacob et al. (HES vs saline) effect [PMID 30654825]', + 'weird \\ backslash, and (paren)', + 'plain value', + '', + ]) { + expect(roundTrip(v)).toBe(v) + } + }) + + it('splits a category list only on unescaped commas', () => { + const escaped = ['protects [B,12]', 'located_in [B,3]'] + .map((c) => StaticUtilities.escapeQueryValue(c)) + .join(',') + const tokens = StaticUtilities.splitQueryList(escaped) + expect(tokens.map((t) => StaticUtilities.unescapeQueryValue(t))).toEqual([ + 'protects [B,12]', + 'located_in [B,3]', + ]) + }) +}) + +// ========================================================================== +// Dropdown placement — flips a filter dropdown upward when it would be cut +// off at the bottom of the window, scrolling only when even the larger side +// is too small (regression: bottom-of-panel dropdowns were unusable). +// ========================================================================== + +describe('StaticUtilities.computeDropdownPlacement', () => { + const rect = (top, bottom, left = 100) => ({ top, bottom, left }) + + it('opens below the anchor when there is room', () => { + const p = StaticUtilities.computeDropdownPlacement({ + anchorRect: rect(100, 120), + dropdownHeight: 200, + viewportHeight: 800, + }) + expect(p.openUp).toBe(false) + expect(p.top).toBe(120) // anchor bottom + expect(p.left).toBe(97) // anchor left - 3 + expect(p.maxHeight).toBeNull() // fits, no scroll + }) + + it('flips upward when the anchor sits near the bottom edge', () => { + const p = StaticUtilities.computeDropdownPlacement({ + anchorRect: rect(560, 580), + dropdownHeight: 200, + viewportHeight: 600, // only 16px below, 556px above + }) + expect(p.openUp).toBe(true) + expect(p.top).toBe(360) // anchorTop(560) - height(200) + expect(p.maxHeight).toBeNull() // fits above without scroll + }) + + it('caps height and scrolls when even the larger side is too small', () => { + const p = StaticUtilities.computeDropdownPlacement({ + anchorRect: rect(300, 320), + dropdownHeight: 400, + viewportHeight: 600, // below: 276, above: 296 -> flip up, still < 400 + }) + expect(p.openUp).toBe(true) + expect(p.maxHeight).toBe(296) // spaceAbove - margin + expect(p.top).toBe(4) // anchorTop(300) - height(296) + }) +}) + // ========================================================================== // StaticUtilities — pure function unit tests // ==========================================================================