From 482f7b34fa234e5a81883a78cff980740443d0f0 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Mon, 2 Mar 2026 16:16:54 -0500 Subject: [PATCH 1/6] advanced search with filter over muliple columns --- static/css/app.css | 93 +++++++++++++++++ static/index.html | 20 ++++ static/js/app.js | 241 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 338 insertions(+), 16 deletions(-) diff --git a/static/css/app.css b/static/css/app.css index b3aaebdfe..ebfd8e7b7 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -914,3 +914,96 @@ .ace_autocomplete .ace_active-line { background: #eee !important; } + +/* ------------------------------------------------------------------ */ +/* Advanced Search Panel */ +/* ------------------------------------------------------------------ */ + +#pagination.adv-search-open { + height: auto; + min-height: 50px; +} + +#advanced_search_panel { + clear: both; + padding: 8px 10px 4px 10px; + border-top: 1px solid #eee; + background: #fafafa; +} + +.adv-search-header { + font-size: 12px; + margin-bottom: 6px; + line-height: 26px; +} + +.adv-match-label { + color: #777; + margin: 0 6px; +} + +.adv-conjunction-toggle { + display: inline-block; + vertical-align: middle; +} + +.adv-search-row { + display: flex; + align-items: center; + margin-bottom: 4px; + gap: 6px; +} + +.adv-search-row .adv-col { + width: 150px; + flex-shrink: 0; + height: 28px; + font-size: 12px; + padding: 2px 6px; +} + +.adv-search-row .adv-op { + width: 110px; + flex-shrink: 0; + height: 28px; + font-size: 12px; + padding: 2px 6px; +} + +.adv-search-row .adv-val { + flex: 1; + min-width: 80px; + max-width: 220px; + height: 28px; + font-size: 12px; + padding: 2px 6px; +} + +.adv-search-row .adv-remove-row { + flex-shrink: 0; + padding: 2px 7px; + height: 28px; + line-height: 22px; +} + +.adv-search-footer { + margin-top: 6px; + padding-bottom: 4px; +} + +.adv-search-footer .btn { + margin-right: 6px; +} + +#advanced-search-toggle.adv-open { + background: #f0e8ff; + border-color: #79589f; + color: #79589f; +} + +#adv_search_active_badge { + font-size: 11px; + margin-right: 6px; + vertical-align: middle; + background-color: #79589f; +} diff --git a/static/index.html b/static/index.html index 71ccaf7c0..19d47f290 100644 --- a/static/index.html +++ b/static/index.html @@ -133,13 +133,33 @@ + +
+ rows
diff --git a/static/js/app.js b/static/js/app.js index d5b26d4ef..102910427 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -8,6 +8,7 @@ var currentObject = null; var autocompleteObjects = []; var inputResizing = false; var inputResizeOffset = null; +var advancedSearchActive = false; var filterOptions = { "equal": "= 'DATA'", @@ -22,6 +23,12 @@ var filterOptions = { "not_null": "IS NOT NULL" }; +// Escape a filter value for inline SQL string literals. +// Doubles single-quotes to prevent basic SQL injection via filter values. +function escapeSqlLiteral(val) { + return String(val).replace(/'/g, "''"); +} + function getSessionId() { var id = sessionStorage.getItem("session_id"); @@ -623,20 +630,26 @@ function showTableContent(sortColumn, sortOrder) { sort_order: sortOrder }; - var filter = { - column: $(".filters select.column").val(), - op: $(".filters select.filter").val(), - input: $(".filters input").val() - }; + // Advanced search takes precedence over the simple filter + if (advancedSearchActive) { + var advWhere = $("#advanced_search_panel").data("where"); + if (advWhere) opts["where"] = advWhere; + } else { + var filter = { + column: $(".filters select.column").val(), + op: $(".filters select.filter").val(), + input: $(".filters input").val() + }; - // Apply filtering only if column is selected - if (filter.column && filter.op) { - var where = [ - '"' + filter.column + '"', - filterOptions[filter.op].replace("DATA", filter.input) - ].join(" "); + // Apply filtering only if column is selected + if (filter.column && filter.op) { + var where = [ + '"' + filter.column + '"', + filterOptions[filter.op].replace("DATA", escapeSqlLiteral(filter.input)) + ].join(" "); - opts["where"] = where; + opts["where"] = where; + } } getTableRows(name, opts, function(data) { @@ -1023,6 +1036,131 @@ function buildTableFilters(name, type) { var el = $(""; + + var opHtml = [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '' + ].join(""); + + var row = $('
'); + row.append(''); + row.append(''); + row.append(''); + row.append(''); + + return row; +} + +// Build a combined SQL WHERE clause from all advanced search condition rows. +function buildAdvancedWhereClause() { + var conjunction = $(".adv-conjunction-toggle .btn.active").data("conj") || "AND"; + var parts = []; + + $("#adv_search_rows .adv-search-row").each(function() { + var col = $(this).find(".adv-col").val(); + var op = $(this).find(".adv-op").val(); + var val = $.trim($(this).find(".adv-val").val()); + + if (!col || !op) return; + + var template = filterOptions[op]; + if (!template) return; + + var fragment; + if (template.indexOf("DATA") >= 0) { + if (val === "") return; + fragment = '"' + col + '" ' + template.replace("DATA", escapeSqlLiteral(val)); + } else { + fragment = '"' + col + '" ' + template; + } + + parts.push("(" + fragment + ")"); + }); + + if (parts.length === 0) return null; + return parts.join(" " + conjunction + " "); +} + +// Apply the advanced search conditions and reload table rows. +function applyAdvancedSearch() { + var where = buildAdvancedWhereClause(); + + if (where === null) { + alert("Please complete at least one filter condition (column and operator are required)."); + return; + } + + $("#advanced_search_panel").data("where", where); + advancedSearchActive = true; + + $("#adv_search_active_badge").show(); + $("#advanced-search-toggle").addClass("adv-open"); + + $(".current-page").data("page", 1); + showTableContent(); +} + +// Clear the advanced search state and reset the panel to one empty row. +function resetAdvancedSearch() { + advancedSearchActive = false; + $("#advanced_search_panel").data("where", null); + $("#adv_search_active_badge").hide(); + $("#advanced-search-toggle").removeClass("adv-open"); + + $("#adv_search_rows").empty(); + $("#adv_search_rows").append(buildAdvancedSearchRow()); +} + +// Recalculate #output top offset to account for the advanced panel height. +function adjustOutputTop() { + if ($("#pagination").is(":visible")) { + var h = $("#pagination").outerHeight(true); + $("#output").css("top", h + "px"); + } +} + +// Bind operator change handlers for advanced rows (delegated, called once). +function bindAdvancedOpHandlers() { + if ($("#adv_search_rows").data("op-handler-bound")) return; + $("#adv_search_rows").data("op-handler-bound", true); + + $("#adv_search_rows").on("change", ".adv-op", function() { + var val = $(this).val(); + var valInput = $(this).closest(".adv-search-row").find(".adv-val"); + if (val === "null" || val === "not_null") { + valInput.hide().val(""); + } else { + valInput.show(); + } }); } @@ -1270,10 +1408,19 @@ function bindTableHeaderMenu() { var colValue = $(context).text(); var colName = $("#results_header th").eq(colIdx).data("name"); - $("select.column").val(colName); - $("select.filter").val("equal"); - $("#table_filter_value").val(colValue); - $("#rows_filter").submit(); + if ($("#advanced_search_panel").is(":visible")) { + var newRow = buildAdvancedSearchRow(); + newRow.find(".adv-col").val(colName); + newRow.find(".adv-op").val("equal"); + newRow.find(".adv-val").val(colValue); + $("#adv_search_rows").append(newRow); + } else { + $("select.column").val(colName); + $("select.filter").val("equal"); + $("#table_filter_value").val(colValue); + $("#rows_filter").submit(); + } + break; } } }); @@ -1594,6 +1741,7 @@ $(document).ready(function() { $(this).addClass("active"); $(".current-page").data("page", 1); $(".filters select, .filters input").val(""); + resetAdvancedSearch(); if (currentObject.type == "function") { sessionStorage.setItem("tab", "table_structure"); @@ -1681,6 +1829,67 @@ $(document).ready(function() { $("button.reset-filters").on("click", function() { $(".filters select, .filters input").val(""); + resetAdvancedSearch(); + showTableContent(); + }); + + // ---- Advanced Search ------------------------------------------------ + + // Toggle the advanced search panel open/closed + $("#advanced-search-toggle").on("click", function() { + var panel = $("#advanced_search_panel"); + if (panel.is(":visible")) { + panel.slideUp(150, function() { + $("#pagination").removeClass("adv-search-open"); + adjustOutputTop(); + }); + $(this).find("i").removeClass("fa-caret-up").addClass("fa-caret-down"); + } else { + if ($("#adv_search_rows .adv-search-row").length === 0) { + $("#adv_search_rows").append(buildAdvancedSearchRow()); + bindAdvancedOpHandlers(); + } + panel.slideDown(150, function() { + $("#pagination").addClass("adv-search-open"); + adjustOutputTop(); + }); + $(this).find("i").removeClass("fa-caret-down").addClass("fa-caret-up"); + } + }); + + // Add a new condition row + $("#adv-add-condition").on("click", function() { + var newRow = buildAdvancedSearchRow(); + $("#adv_search_rows").append(newRow); + newRow.find(".adv-col").focus(); + }); + + // Remove a condition row (clear instead of remove if it's the last row) + $("#adv_search_rows").on("click", ".adv-remove-row", function() { + var rows = $("#adv_search_rows .adv-search-row"); + if (rows.length <= 1) { + $(this).closest(".adv-search-row").find("select").val(""); + $(this).closest(".adv-search-row").find("input").val("").show(); + } else { + $(this).closest(".adv-search-row").remove(); + } + adjustOutputTop(); + }); + + // Toggle AND/OR conjunction + $(".adv-conjunction-toggle").on("click", "button", function() { + $(".adv-conjunction-toggle button").removeClass("active"); + $(this).addClass("active"); + }); + + // Apply advanced search + $("#adv-apply").on("click", function() { + applyAdvancedSearch(); + }); + + // Clear advanced search (keep panel open) + $("#adv-reset").on("click", function() { + resetAdvancedSearch(); showTableContent(); }); From c5c56cdc45ba98c7b574127fbb9a9cf8da8e4de1 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Mon, 2 Mar 2026 16:42:45 -0500 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20=F0=9F=94=8E=20add=20advanced?= =?UTF-8?q?=20search=20panel=20to=20Rows=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advanced Search panel** (`Advanced` toggle button next to the existing filter bar): - **Multiple conditions** — add unlimited filter rows with `+ Add Condition` - **Per-row AND/OR connectors** — each row after the first has AND/OR toggle pills, enabling mixed logic like `(A AND B) OR (C AND D)` - **18 filter operators** grouped by category: - Comparison: `=`, `<>`, `<`, `>`, `<=`, `>=` - List: `IN`, `NOT IN` (comma-separated values) - Null: `IS NULL`, `IS NOT NULL` - Range: `BETWEEN`, `NOT BETWEEN` (From / To inputs) - Pattern: Contains, Not contains, Has prefix, Has suffix — plus case-insensitive (ILIKE) variants for all four - **Show Query** — previews the full `SELECT * FROM ... WHERE ...;` SQL inline, with a Copy button - **"Advanced Filter Active"** badge in the pagination row when a multi-condition filter is applied - **Right-click → Filter Rows By Value** adds a pre-filled condition row when the panel is open - Filter state resets automatically on table switch or clicking the basic `×` reset button - No backend changes — uses the existing `where` parameter on `GET /api/tables/:table/rows` **Files changed:** `static/index.html`, `static/js/app.js`, `static/css/app.css --- .claude/memory/advanced_search.md | 63 ++++++++ .claude/settings.local.json | 3 +- static/css/app.css | 80 +++++++++++ static/index.html | 12 +- static/js/app.js | 231 ++++++++++++++++++++++++------ 5 files changed, 336 insertions(+), 53 deletions(-) create mode 100644 .claude/memory/advanced_search.md diff --git a/.claude/memory/advanced_search.md b/.claude/memory/advanced_search.md new file mode 100644 index 000000000..84eeac3e0 --- /dev/null +++ b/.claude/memory/advanced_search.md @@ -0,0 +1,63 @@ +# Advanced Search Feature + +## Status: Implemented and working (v2) + +## What was built +Multi-condition advanced search panel for the Rows tab. Accessible via an "Advanced" toggle button next to the existing Apply/× buttons. + +### Features (v1) +- Multiple filter conditions (unlimited rows) +- "Advanced Filter Active" badge in pagination row when active +- Right-click "Filter Rows By Value" appends a pre-filled row when panel is open +- Resets on table switch and basic × reset button +- `#output` top offset recalculated when panel opens/closes + +### Features added in v2 +- **Per-row AND/OR connector pills** — each row after the first has AND/OR buttons; first row shows "WHERE" label; enables `(A) AND (B) OR (C)` style expressions +- **Show Query button** — toggles a `
` box showing the full `SELECT * FROM "schema"."table" WHERE ...;` with a Copy button (reuses `copyToClipboard()`)
+- **Expanded operator set** (18 operators, grouped with ``):
+  - Comparison: `=`, `<>`, `<`, `>`, `<=`, `>=`
+  - List: `IN` (comma-sep → `IN ('a','b')`), `NOT IN`
+  - Null: `IS NULL`, `IS NOT NULL`
+  - Range: `BETWEEN` (From/To inputs), `NOT BETWEEN`
+  - Pattern: Contains, Not contains, Has prefix, Has suffix + case-insensitive variants (ILIKE)
+- **`getOpInputType(op)`** helper: returns `"none" | "single" | "list" | "range"` — controls which input variant is shown
+- **`buildFullQuery()`** — builds full SELECT string using `getCurrentObject().name`
+
+## Files changed
+- `static/index.html` — Advanced button, `#advanced_search_panel`, `#adv_search_active_badge`, Show Query button, `#adv_query_display`
+- `static/css/app.css` — appended styles for panel, connector pills, range inputs, query display box
+- `static/js/app.js` — see key functions below
+
+## Key JS functions (app.js)
+- `var advancedSearchActive = false` — global flag
+- `escapeSqlLiteral(val)` — doubles single-quotes (also applied to simple filter)
+- `getOpInputType(op)` — returns input variant type for an operator
+- `buildAdvancedSearchRow(isFirst)` — builds a condition row; `isFirst=true` → WHERE label, no pill
+- `buildAdvancedWhereClause()` — iterates rows, reads `data-row-conj` per row, builds SQL
+- `buildFullQuery()` — wraps WHERE clause in full SELECT statement
+- `applyAdvancedSearch()` — stores WHERE in panel `.data("where")`, sets flag, reloads
+- `resetAdvancedSearch()` — clears flag/badge, empties rows, adds `buildAdvancedSearchRow(true)`
+- `adjustOutputTop()` — sets `#output` CSS top to `#pagination` outerHeight
+- `bindAdvancedOpHandlers()` — delegated handler showing correct input variant per operator
+
+## Key JS edits (app.js)
+- `showTableContent()` — advanced takes precedence over simple filter when `advancedSearchActive`
+- `buildTableFilters()` — syncs columns into existing advanced rows; passes `isFirst=true`
+- Objects click handler — calls `resetAdvancedSearch()` on table switch
+- `reset-filters` button — calls `resetAdvancedSearch()`
+- `filter_by_value` context menu — appends pre-filled row when panel visible
+
+## No backend changes needed
+The existing `where` param on `GET /api/tables/:table/rows` accepts raw SQL.
+`TableRows()` in `pkg/client/client.go` appends `WHERE ` directly.
+
+## Build command (GOROOT is broken in this environment)
+```bash
+GOROOT=/opt/homebrew/Cellar/go/1.25.7_1/libexec \
+GOPROXY=https://proxy.golang.org,direct \
+GONOSUMDB='*' \
+GOOS=linux GOARCH=amd64 \
+go build -o ./bin/pgweb_linux_amd64
+```
+Output: `bin/pgweb_linux_amd64` (~28MB, ELF 64-bit, statically linked)
diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 73572edfe..7bf165bca 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -6,7 +6,8 @@
       "Bash(git checkout:*)",
       "Bash(go test:*)",
       "Bash(go build:*)",
-      "Bash(make:*)"
+      "Bash(make:*)",
+      "Bash(go env:*)"
     ]
   }
 }
diff --git a/static/css/app.css b/static/css/app.css
index ebfd8e7b7..93af973e5 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -1007,3 +1007,83 @@
   vertical-align: middle;
   background-color: #79589f;
 }
+
+/* Per-row AND/OR connector pill */
+.adv-row-conj {
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+  width: 72px;
+  gap: 2px;
+}
+
+.adv-row-conj-first {
+  justify-content: flex-end;
+}
+
+.adv-row-conj-first span {
+  font-size: 11px;
+  color: #999;
+  font-weight: bold;
+  padding-right: 4px;
+}
+
+.adv-conj-btn {
+  font-size: 10px;
+  padding: 1px 5px;
+  border: 1px solid #ccc;
+  background: #fff;
+  color: #777;
+  border-radius: 3px;
+  cursor: pointer;
+  line-height: 16px;
+}
+
+.adv-conj-btn.active {
+  background: #79589f;
+  border-color: #79589f;
+  color: #fff;
+}
+
+/* Range input pair */
+.adv-val-range {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  min-width: 0;
+  gap: 4px;
+}
+
+.adv-val-range .adv-val-from,
+.adv-val-range .adv-val-to {
+  flex: 1;
+  min-width: 60px;
+  height: 28px;
+  font-size: 12px;
+  padding: 2px 6px;
+}
+
+.adv-range-sep {
+  flex-shrink: 0;
+  font-size: 11px;
+  color: #999;
+}
+
+/* Query preview box */
+#adv_query_display {
+  margin-top: 6px;
+  padding: 6px 8px;
+  background: #f5f5f5;
+  border: 1px solid #e0e0e0;
+  border-radius: 3px;
+}
+
+#adv_query_display pre {
+  margin: 0 0 6px 0;
+  font-size: 11px;
+  white-space: pre-wrap;
+  word-break: break-all;
+  color: #333;
+  max-height: 80px;
+  overflow-y: auto;
+}
diff --git a/static/index.html b/static/index.html
index 19d47f290..6afce809c 100644
--- a/static/index.html
+++ b/static/index.html
@@ -137,12 +137,7 @@
         
         
         
diff --git a/static/js/app.js b/static/js/app.js index 102910427..7aec415c2 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -11,18 +11,48 @@ var inputResizeOffset = null; var advancedSearchActive = false; var filterOptions = { - "equal": "= 'DATA'", - "not_equal": "!= 'DATA'", - "greater": "> 'DATA'" , - "greater_eq": ">= 'DATA'", - "less": "< 'DATA'", - "less_eq": "<= 'DATA'", - "like": "LIKE 'DATA'", - "ilike": "ILIKE 'DATA'", - "null": "IS NULL", - "not_null": "IS NOT NULL" + // Standard comparison (single value) — also used by simple filter + "equal": "= 'DATA'", + "not_equal": "!= 'DATA'", + "greater": "> 'DATA'", + "greater_eq": ">= 'DATA'", + "less": "< 'DATA'", + "less_eq": "<= 'DATA'", + "like": "LIKE 'DATA'", + "ilike": "ILIKE 'DATA'", + + // NULL checks (no value) + "null": "IS NULL", + "not_null": "IS NOT NULL", + + // List operators — comma-separated values → IN (...) + "in": "IN (DATA)", + "not_in": "NOT IN (DATA)", + + // Range operators — two values + "between": "BETWEEN 'DATA1' AND 'DATA2'", + "not_between": "NOT BETWEEN 'DATA1' AND 'DATA2'", + + // Pattern operators + "contains": "LIKE '%DATA%'", + "not_contains": "NOT LIKE '%DATA%'", + "icontains": "ILIKE '%DATA%'", + "not_icontains": "NOT ILIKE '%DATA%'", + "starts_with": "LIKE 'DATA%'", + "ends_with": "LIKE '%DATA'", + "istarts_with": "ILIKE 'DATA%'", + "iends_with": "ILIKE '%DATA'" }; +// Returns the input type required for an operator: "none" | "single" | "list" | "range" +function getOpInputType(op) { + if (!op || !filterOptions[op]) return "single"; + if (op === "null" || op === "not_null") return "none"; + if (op === "in" || op === "not_in") return "list"; + if (op === "between" || op === "not_between") return "range"; + return "single"; +} + // Escape a filter value for inline SQL string literals. // Doubles single-quotes to prevent basic SQL injection via filter values. function escapeSqlLiteral(val) { @@ -1047,34 +1077,75 @@ function buildTableFilters(name, type) { // Ensure at least one empty row exists in the panel if ($("#adv_search_rows .adv-search-row").length === 0) { - $("#adv_search_rows").append(buildAdvancedSearchRow()); + $("#adv_search_rows").append(buildAdvancedSearchRow(true)); bindAdvancedOpHandlers(); } }); } // Build a new advanced search condition row element. -function buildAdvancedSearchRow() { +// isFirst=true omits the AND/OR connector pill and shows a "WHERE" label instead. +function buildAdvancedSearchRow(isFirst) { var colHtml = $("#pagination select.column").html() || ""; var opHtml = [ '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '' + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '' ].join(""); - var row = $('
'); + var row = $('
'); + + if (isFirst) { + row.append('
WHERE
'); + } else { + row.append( + '
' + + '' + + '' + + '
' + ); + } + row.append(''); row.append(''); row.append(''); + row.append(''); + row.append( + '' + ); row.append(''); return row; @@ -1082,13 +1153,13 @@ function buildAdvancedSearchRow() { // Build a combined SQL WHERE clause from all advanced search condition rows. function buildAdvancedWhereClause() { - var conjunction = $(".adv-conjunction-toggle .btn.active").data("conj") || "AND"; - var parts = []; + var parts = []; // array of {expr, conj} $("#adv_search_rows .adv-search-row").each(function() { - var col = $(this).find(".adv-col").val(); - var op = $(this).find(".adv-op").val(); - var val = $.trim($(this).find(".adv-val").val()); + var col = $(this).find(".adv-col").val(); + var op = $(this).find(".adv-op").val(); + var rowConj = $(this).data("row-conj") || "AND"; + var inputType = getOpInputType(op); if (!col || !op) return; @@ -1096,18 +1167,42 @@ function buildAdvancedWhereClause() { if (!template) return; var fragment; - if (template.indexOf("DATA") >= 0) { - if (val === "") return; - fragment = '"' + col + '" ' + template.replace("DATA", escapeSqlLiteral(val)); - } else { + + if (inputType === "none") { fragment = '"' + col + '" ' + template; + + } else if (inputType === "list") { + var raw = $.trim($(this).find(".adv-val-list").val()); + if (!raw) return; + var items = raw.split(",").map(function(s) { + return "'" + escapeSqlLiteral($.trim(s)) + "'"; + }); + fragment = '"' + col + '" ' + template.replace("DATA", items.join(", ")); + + } else if (inputType === "range") { + var from = $.trim($(this).find(".adv-val-from").val()); + var to = $.trim($(this).find(".adv-val-to").val()); + if (!from || !to) return; + fragment = '"' + col + '" ' + template + .replace("DATA1", escapeSqlLiteral(from)) + .replace("DATA2", escapeSqlLiteral(to)); + + } else { + var val = $.trim($(this).find(".adv-val").val()); + if (!val) return; + fragment = '"' + col + '" ' + template.replace("DATA", escapeSqlLiteral(val)); } - parts.push("(" + fragment + ")"); + parts.push({ expr: "(" + fragment + ")", conj: rowConj }); }); if (parts.length === 0) return null; - return parts.join(" " + conjunction + " "); + + var sql = parts[0].expr; + for (var i = 1; i < parts.length; i++) { + sql += " " + parts[i].conj + " " + parts[i].expr; + } + return sql; } // Apply the advanced search conditions and reload table rows. @@ -1137,7 +1232,23 @@ function resetAdvancedSearch() { $("#advanced-search-toggle").removeClass("adv-open"); $("#adv_search_rows").empty(); - $("#adv_search_rows").append(buildAdvancedSearchRow()); + $("#adv_search_rows").append(buildAdvancedSearchRow(true)); +} + +// Build the full SELECT query string for the current advanced search conditions. +function buildFullQuery() { + var where = buildAdvancedWhereClause(); + if (!where) return null; + + var table = getCurrentObject().name; + var nameParts = table.split("."); + var sql; + if (nameParts.length === 2) { + sql = 'SELECT * FROM "' + nameParts[0] + '"."' + nameParts[1] + '"'; + } else { + sql = 'SELECT * FROM "' + table + '"'; + } + return sql + " WHERE " + where + ";"; } // Recalculate #output top offset to account for the advanced panel height. @@ -1154,12 +1265,14 @@ function bindAdvancedOpHandlers() { $("#adv_search_rows").data("op-handler-bound", true); $("#adv_search_rows").on("change", ".adv-op", function() { - var val = $(this).val(); - var valInput = $(this).closest(".adv-search-row").find(".adv-val"); - if (val === "null" || val === "not_null") { - valInput.hide().val(""); - } else { - valInput.show(); + var row = $(this).closest(".adv-search-row"); + var inputType = getOpInputType($(this).val()); + row.find(".adv-val").toggle(inputType === "single"); + row.find(".adv-val-list").toggle(inputType === "list"); + row.find(".adv-val-range").toggle(inputType === "range"); + if (inputType === "none") { + row.find(".adv-val, .adv-val-list").val(""); + row.find(".adv-val-from, .adv-val-to").val(""); } }); } @@ -1846,7 +1959,7 @@ $(document).ready(function() { $(this).find("i").removeClass("fa-caret-up").addClass("fa-caret-down"); } else { if ($("#adv_search_rows .adv-search-row").length === 0) { - $("#adv_search_rows").append(buildAdvancedSearchRow()); + $("#adv_search_rows").append(buildAdvancedSearchRow(true)); bindAdvancedOpHandlers(); } panel.slideDown(150, function() { @@ -1859,9 +1972,10 @@ $(document).ready(function() { // Add a new condition row $("#adv-add-condition").on("click", function() { - var newRow = buildAdvancedSearchRow(); + var newRow = buildAdvancedSearchRow(false); $("#adv_search_rows").append(newRow); newRow.find(".adv-col").focus(); + adjustOutputTop(); }); // Remove a condition row (clear instead of remove if it's the last row) @@ -1876,10 +1990,11 @@ $(document).ready(function() { adjustOutputTop(); }); - // Toggle AND/OR conjunction - $(".adv-conjunction-toggle").on("click", "button", function() { - $(".adv-conjunction-toggle button").removeClass("active"); + // Toggle per-row AND/OR connector + $("#adv_search_rows").on("click", ".adv-conj-btn", function() { + $(this).closest(".adv-row-conj").find(".adv-conj-btn").removeClass("active"); $(this).addClass("active"); + $(this).closest(".adv-search-row").data("row-conj", $(this).data("conj")); }); // Apply advanced search @@ -1890,9 +2005,33 @@ $(document).ready(function() { // Clear advanced search (keep panel open) $("#adv-reset").on("click", function() { resetAdvancedSearch(); + $("#adv_query_display").hide(); showTableContent(); }); + // Show/hide the raw SQL query preview + $("#adv-show-query").on("click", function() { + var display = $("#adv_query_display"); + if (display.is(":visible")) { + display.slideUp(100, adjustOutputTop); + $(this).find("i").removeClass("fa-chevron-up").addClass("fa-code"); + } else { + var sql = buildFullQuery(); + if (!sql) { + alert("No valid conditions to preview. Fill in at least one complete condition."); + return; + } + $("#adv_query_text").text(sql); + display.slideDown(100, adjustOutputTop); + $(this).find("i").removeClass("fa-code").addClass("fa-chevron-up"); + } + }); + + // Copy raw query to clipboard + $("#adv-copy-query").on("click", function() { + copyToClipboard($("#adv_query_text").text()); + }); + // Automatically prefill the filter if it's not set yet $("select.column").on("change", function() { if ($("select.filter").val() == "") { From b5edcd970428c7654510ae5dd08c8030e8667d45 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Tue, 3 Mar 2026 08:17:55 -0500 Subject: [PATCH 3/6] update version --- pkg/command/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/command/version.go b/pkg/command/version.go index 143a8194c..b83ca8bc0 100644 --- a/pkg/command/version.go +++ b/pkg/command/version.go @@ -8,7 +8,7 @@ import ( const ( // Version is the current Pgweb application version - Version = "0.17.0" + Version = "0.17.0p1" ) var ( From 25a9c28bdcf2767bf040512cba1ad789769b3a05 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Tue, 3 Mar 2026 09:18:01 -0500 Subject: [PATCH 4/6] offset table when search is expanded --- .claude/memory/advanced_search.md | 9 +++++++-- static/css/app.css | 2 +- static/js/app.js | 1 + 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.claude/memory/advanced_search.md b/.claude/memory/advanced_search.md index 84eeac3e0..d8c6f9d97 100644 --- a/.claude/memory/advanced_search.md +++ b/.claude/memory/advanced_search.md @@ -1,6 +1,6 @@ # Advanced Search Feature -## Status: Implemented and working (v2) +## Status: Implemented and working (v3) ## What was built Multi-condition advanced search panel for the Rows tab. Accessible via an "Advanced" toggle button next to the existing Apply/× buttons. @@ -41,8 +41,13 @@ Multi-condition advanced search panel for the Rows tab. Accessible via an "Advan - `adjustOutputTop()` — sets `#output` CSS top to `#pagination` outerHeight - `bindAdvancedOpHandlers()` — delegated handler showing correct input variant per operator +## Bug fix (v3): advanced panel obscuring table rows +- **Root cause**: `.with-pagination #output { top: 50px !important }` in `app.css` — the `!important` beat jQuery's inline style set by `adjustOutputTop()` +- **Fix 1**: removed `!important` from that CSS rule so JS inline style wins +- **Fix 2**: added `adjustOutputTop()` call immediately after `$("#body").prop("class", "with-pagination")` in `showTableContent()` — so offset is recalculated on every table load, not just on panel open/close + ## Key JS edits (app.js) -- `showTableContent()` — advanced takes precedence over simple filter when `advancedSearchActive` +- `showTableContent()` — advanced takes precedence over simple filter when `advancedSearchActive`; calls `adjustOutputTop()` after setting `with-pagination` class - `buildTableFilters()` — syncs columns into existing advanced rows; passes `isFirst=true` - Objects click handler — calls `resetAdvancedSearch()` on table switch - `reset-filters` button — calls `resetAdvancedSearch()` diff --git a/static/css/app.css b/static/css/app.css index 93af973e5..b5d8a205a 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -615,7 +615,7 @@ } .with-pagination #output { - top: 50px !important; + top: 50px; } .with-pagination #pagination { diff --git a/static/js/app.js b/static/js/app.js index 7aec415c2..8fce6a42d 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -685,6 +685,7 @@ function showTableContent(sortColumn, sortOrder) { getTableRows(name, opts, function(data) { $("#input").hide(); $("#body").prop("class", "with-pagination"); + adjustOutputTop(); buildTable(data, sortColumn, sortOrder); setCurrentTab("table_content"); From e08aec27920c95e49ad1512496d83a8ba0df775d Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Tue, 3 Mar 2026 09:30:03 -0500 Subject: [PATCH 5/6] regex operators --- .claude/memory/advanced_search.md | 8 +++++++- static/js/app.js | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.claude/memory/advanced_search.md b/.claude/memory/advanced_search.md index d8c6f9d97..670a2eb58 100644 --- a/.claude/memory/advanced_search.md +++ b/.claude/memory/advanced_search.md @@ -1,6 +1,6 @@ # Advanced Search Feature -## Status: Implemented and working (v3) +## Status: Implemented and working (v4) ## What was built Multi-condition advanced search panel for the Rows tab. Accessible via an "Advanced" toggle button next to the existing Apply/× buttons. @@ -21,6 +21,7 @@ Multi-condition advanced search panel for the Rows tab. Accessible via an "Advan - Null: `IS NULL`, `IS NOT NULL` - Range: `BETWEEN` (From/To inputs), `NOT BETWEEN` - Pattern: Contains, Not contains, Has prefix, Has suffix + case-insensitive variants (ILIKE) + - Regex: Matches regex (`~`), Matches regex case insensitive (`~*`) - **`getOpInputType(op)`** helper: returns `"none" | "single" | "list" | "range"` — controls which input variant is shown - **`buildFullQuery()`** — builds full SELECT string using `getCurrentObject().name` @@ -41,6 +42,11 @@ Multi-condition advanced search panel for the Rows tab. Accessible via an "Advan - `adjustOutputTop()` — sets `#output` CSS top to `#pagination` outerHeight - `bindAdvancedOpHandlers()` — delegated handler showing correct input variant per operator +## Added in v4: regex operators +- `"regex": "~ 'DATA'"` and `"iregex": "~* 'DATA'"` added to `filterOptions` +- Two new options appended to the Pattern `` in `buildAdvancedSearchRow()` +- No other changes needed — `getOpInputType()` returns `"single"` by default, `buildAdvancedWhereClause()` handles it unchanged + ## Bug fix (v3): advanced panel obscuring table rows - **Root cause**: `.with-pagination #output { top: 50px !important }` in `app.css` — the `!important` beat jQuery's inline style set by `adjustOutputTop()` - **Fix 1**: removed `!important` from that CSS rule so JS inline style wins diff --git a/static/js/app.js b/static/js/app.js index 8fce6a42d..eb3ba3fe9 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -41,7 +41,11 @@ var filterOptions = { "starts_with": "LIKE 'DATA%'", "ends_with": "LIKE '%DATA'", "istarts_with": "ILIKE 'DATA%'", - "iends_with": "ILIKE '%DATA'" + "iends_with": "ILIKE '%DATA'", + + // Regex operators + "regex": "~ 'DATA'", + "iregex": "~* 'DATA'" }; // Returns the input type required for an operator: "none" | "single" | "list" | "range" @@ -1120,6 +1124,8 @@ function buildAdvancedSearchRow(isFirst) { '', '', '', + '', + '', '' ].join(""); From 1e4d9fd3569aec17ced7fae5908cebd40b96f801 Mon Sep 17 00:00:00 2001 From: Samir B Amin Date: Tue, 3 Mar 2026 09:40:08 -0500 Subject: [PATCH 6/6] always keep first operator --- .claude/memory/advanced_search.md | 3 +++ static/js/app.js | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.claude/memory/advanced_search.md b/.claude/memory/advanced_search.md index 670a2eb58..bcb762464 100644 --- a/.claude/memory/advanced_search.md +++ b/.claude/memory/advanced_search.md @@ -42,6 +42,9 @@ Multi-condition advanced search panel for the Rows tab. Accessible via an "Advan - `adjustOutputTop()` — sets `#output` CSS top to `#pagination` outerHeight - `bindAdvancedOpHandlers()` — delegated handler showing correct input variant per operator +## Bug fix (v4b): first row showed unnecessary − delete button +- `buildAdvancedSearchRow(isFirst)` now only appends the remove button when `isFirst=false` + ## Added in v4: regex operators - `"regex": "~ 'DATA'"` and `"iregex": "~* 'DATA'"` added to `filterOptions` - Two new options appended to the Pattern `` in `buildAdvancedSearchRow()` diff --git a/static/js/app.js b/static/js/app.js index eb3ba3fe9..3336eb38c 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1153,7 +1153,9 @@ function buildAdvancedSearchRow(isFirst) { '' + '' ); - row.append(''); + if (!isFirst) { + row.append(''); + } return row; }