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
// ==========================================================================