From 4b547faf7afc62cc809eb2c5764f95e5d0a9915c Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Tue, 13 Jan 2026 22:31:50 +0000 Subject: [PATCH] implement url query sub-filters --- .../relations_single/get_data_handler.rs | 37 +++++++- .../routes/relations_single/portal_handler.rs | 13 +++ phono-server/templates/portal_table.html | 7 +- static/main.css | 4 +- static/portal-table.css | 25 +++++- svelte/src/table-viewer.webc.svelte | 89 ++++++++++--------- 6 files changed, 125 insertions(+), 50 deletions(-) diff --git a/phono-server/src/routes/relations_single/get_data_handler.rs b/phono-server/src/routes/relations_single/get_data_handler.rs index 207d478..7013a13 100644 --- a/phono-server/src/routes/relations_single/get_data_handler.rs +++ b/phono-server/src/routes/relations_single/get_data_handler.rs @@ -11,7 +11,7 @@ use phono_backends::{ use phono_models::{ accessors::{Accessor, Actor, portal::PortalAccessor}, datum::Datum, - expression::QueryFragment, + expression::{PgExpressionAny, QueryFragment}, field::Field, }; use serde::{Deserialize, Serialize}; @@ -19,11 +19,14 @@ use sqlx::{ postgres::{PgRow, types::Oid}, query, }; +use tracing::debug; use uuid::Uuid; +use validator::Validate; use crate::{ app::AppDbConn, errors::AppError, + extractors::ValidatedForm, field_info::TableFieldInfo, user::CurrentUser, workspace_pooler::{RoleAssignment, WorkspacePooler}, @@ -36,10 +39,16 @@ pub(super) struct PathParams { workspace_id: Uuid, } +#[derive(Debug, Deserialize, Validate)] +pub(super) struct FormBody { + subfilter: Option, +} + const FRONTEND_ROW_LIMIT: i64 = 1000; /// HTTP GET handler for an API endpoint returning a JSON encoding of portal -/// data to display in a table or similar form. +/// data to display in a table or similar form. If the `subfilter` URL parameter +/// is specified, it is `&&`-ed with the portal's stored filter. /// /// Only queries up to the first [`FRONTEND_ROW_LIMIT`] rows. pub(super) async fn get( @@ -51,6 +60,7 @@ pub(super) async fn get( rel_oid, workspace_id, }): Path, + ValidatedForm(form): ValidatedForm, ) -> Result { let mut workspace_client = workspace_pooler .acquire_for(workspace_id, RoleAssignment::User(user.id)) @@ -109,10 +119,31 @@ pub(super) async fn get( .join(", "), rel.get_identifier(), )); - if let Some(filter_expr) = portal.table_filter.0 { + if let Some(filter_expr) = portal.table_filter.0.clone() { sql_fragment.push(QueryFragment::from_sql(" where ")); sql_fragment.push(filter_expr.into_query_fragment()); } + if let Some(subfilter_expr) = form.subfilter.and_then(|value| { + if value.is_empty() { + None + } else { + serde_json::from_str::>(&value) + // Ignore invalid input. A user likely pasted incorrectly + // or made a typo. + .inspect_err(|_| debug!("ignoring invalid subfilter expression")) + .ok() + .flatten() + } + }) { + sql_fragment.push(QueryFragment::from_sql( + if portal.table_filter.0.is_some() { + " and " + } else { + " where " + }, + )); + sql_fragment.push(subfilter_expr.into_query_fragment()); + } sql_fragment.push(QueryFragment::from_sql(" order by _id limit ")); sql_fragment.push(QueryFragment::from_param(Datum::Numeric(Some( FRONTEND_ROW_LIMIT.into(), diff --git a/phono-server/src/routes/relations_single/portal_handler.rs b/phono-server/src/routes/relations_single/portal_handler.rs index d420ec8..14030a5 100644 --- a/phono-server/src/routes/relations_single/portal_handler.rs +++ b/phono-server/src/routes/relations_single/portal_handler.rs @@ -12,10 +12,12 @@ use phono_models::{ use serde::{Deserialize, Serialize}; use sqlx::postgres::types::Oid; use uuid::Uuid; +use validator::Validate; use crate::{ app::AppDbConn, errors::AppError, + extractors::ValidatedForm, navigator::Navigator, settings::Settings, user::CurrentUser, @@ -30,6 +32,12 @@ pub(super) struct PathParams { workspace_id: Uuid, } +#[derive(Debug, Deserialize, Validate)] +pub(super) struct FormBody { + #[serde(default)] + subfilter: String, +} + /// HTTP GET handler for the table viewer page of a [`Portal`]. This handler /// performs some relatively simple queries pertaining to table structure, but /// the bulk of the query logic resides in the [`super::get_data_handler`] @@ -45,6 +53,9 @@ pub(super) async fn get( rel_oid, workspace_id, }): Path, + ValidatedForm(FormBody { + subfilter: subfilter_str, + }): ValidatedForm, ) -> Result { let mut workspace_client = pooler .acquire_for(workspace_id, RoleAssignment::User(user.id)) @@ -89,6 +100,7 @@ pub(super) async fn get( attr_names: Vec, filter: Option, settings: Settings, + subfilter_str: String, navbar: WorkspaceNav, } Ok(Html( @@ -106,6 +118,7 @@ pub(super) async fn get( Some(RelLocation::Portal(portal.id)), )) .build()?, + subfilter_str, settings, } .render()?, diff --git a/phono-server/templates/portal_table.html b/phono-server/templates/portal_table.html index 7c629cc..f933c5d 100644 --- a/phono-server/templates/portal_table.html +++ b/phono-server/templates/portal_table.html @@ -26,7 +26,12 @@
- +
{% endblock %} diff --git a/static/main.css b/static/main.css index 331ba63..593376e 100644 --- a/static/main.css +++ b/static/main.css @@ -8,7 +8,7 @@ :root { --accent-color: #fc0; - --default-border-color: #ccc; + --default-border-color: #aaa; --default-border-radius--rounded: 8px; --default-border-radius--rounded-sm: 4px; --default-font-family: @@ -39,7 +39,7 @@ --button-background--primary: var(--accent-color); --button-background--secondary: #fff; --button-border-color--primary: oklch(from var(--button-background--primary) calc(l * 0.9) c h); - --button-border-color--secondary: oklch(from var(--button-background--secondary) calc(l * 0.85) c h); + --button-border-color--secondary: oklch(from var(--button-background--secondary) calc(l * 0.8) c h); --button-border-radius: var(--default-border-radius--rounded); --button-color--primary: #000; --button-color--secondary: #000; diff --git a/static/portal-table.css b/static/portal-table.css index ce7d92a..723be5d 100644 --- a/static/portal-table.css +++ b/static/portal-table.css @@ -1,5 +1,10 @@ @import "./expression-editor.css"; +:root { + --table-header-border-color: var(--default-border-color); + --table-cell-border-color: oklch(from var(--default-border-color) calc(l * 1.15) c h); +} + /* ======== Toolbar ======== */ .filter-menu { @@ -24,6 +29,7 @@ grid-area: table; grid-template: 'headers' max-content + 'subfilter-indicator' max-content 'main' 1fr 'inserter' max-content; height: 100%; @@ -49,7 +55,7 @@ .field-adder__header-lookalike { align-items: center; background: #0001; - border: solid 1px #ccc; + border: solid 1px var(--table-header-border-color); border-top: none; border-left: none; display: flex; @@ -122,7 +128,7 @@ .field-adder__completions { align-items: stretch; - border-right: solid 1px var(--default-border-color); + border-right: solid 1px var(--table-header-border-color); grid-area: completions; display: flex; flex-direction: column; @@ -149,6 +155,13 @@ padding: var(--default-padding); } +/* ======== Subfilter Indicator ======== */ + +.subfilter-indicator { + grid-area: subfilter-indicator; + padding: 8px; +} + /* ======== Table Body ======== */ .table-viewer__main { @@ -160,11 +173,17 @@ align-items: stretch; display: flex; height: 2.25rem; + + &:first-child { + .table-viewer__cell { + border-top: solid 1px var(--table-cell-border-color); + } + } } .table-viewer__cell { align-items: stretch; - border: solid 1px var(--default-border-color); + border: solid 1px var(--table-cell-border-color); border-left: none; border-top: none; display: flex; diff --git a/svelte/src/table-viewer.webc.svelte b/svelte/src/table-viewer.webc.svelte index abcb2ef..6181408 100644 --- a/svelte/src/table-viewer.webc.svelte +++ b/svelte/src/table-viewer.webc.svelte @@ -2,6 +2,7 @@ customElement={{ props: { columns: { type: "Array" }, + subfilter: { type: "String" }, }, shadow: "none", tag: "table-viewer", @@ -34,9 +35,10 @@ name: string; regtype: string; }[]; + subfilter?: string; }; - let { columns = [] }: Props = $props(); + let { columns = [], subfilter = "null" }: Props = $props(); type CellDelta = { // Assumes that primary keys are immutable and that rows are only added or @@ -533,7 +535,9 @@ ), fields: z.array(field_info_schema), }); - const resp = await fetch("get-data"); + const resp = await fetch( + `get-data?subfilter=${encodeURIComponent(subfilter)}`, + ); const body = get_data_response_schema.parse(await resp.json()); lazy_data = { fields: body.fields, @@ -634,6 +638,11 @@ + {#if subfilter && subfilter !== "null"} +
+ +
+ {/if}
{@render table_region({ region: "main", @@ -654,46 +663,44 @@ }, })}
-
-
-

- Insert rows — press "shift + enter" to jump here or add a row -

-
-
- {@render table_region({ - region: "inserter", - rows: inserter_rows, - on_cell_click: (ev: MouseEvent, coords: Coords) => { - if (ev.metaKey || ev.ctrlKey) { - set_selections([ - coords, - ...selections - .filter((sel) => !coords_eq(sel.coords, coords)) - .map((sel) => sel.coords), - ]); - } else if (ev.shiftKey) { - move_cursor(coords, { additive: true }); - } else { - move_cursor(coords); - } - }, - })} -
- + +

+ Insert rows — press "shift + enter" to jump here or add a row +

+
+
+ {@render table_region({ + region: "inserter", + rows: inserter_rows, + on_cell_click: (ev: MouseEvent, coords: Coords) => { + if (ev.metaKey || ev.ctrlKey) { + set_selections([ + coords, + ...selections + .filter((sel) => !coords_eq(sel.coords, coords)) + .map((sel) => sel.coords), + ]); + } else if (ev.shiftKey) { + move_cursor(coords, { additive: true }); + } else { + move_cursor(coords); + } + }, + })}
+
{#each inserter_rows as row} {#each lazy_data.fields as field, field_index}