implement url query sub-filters
This commit is contained in:
parent
1b4cbe8517
commit
4b547faf7a
6 changed files with 125 additions and 50 deletions
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
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<PathParams>,
|
||||
ValidatedForm(form): ValidatedForm<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
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::<Option<PgExpressionAny>>(&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(),
|
||||
|
|
|
|||
|
|
@ -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<PathParams>,
|
||||
ValidatedForm(FormBody {
|
||||
subfilter: subfilter_str,
|
||||
}): ValidatedForm<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
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<String>,
|
||||
filter: Option<PgExpressionAny>,
|
||||
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()?,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,12 @@
|
|||
</div>
|
||||
</div>
|
||||
<main class="page-grid__main">
|
||||
<table-viewer columns="{{ columns | json }}"></table-viewer>
|
||||
<table-viewer
|
||||
columns="{{ columns | json }}"
|
||||
{%- if subfilter_str != "" %}
|
||||
subfilter="{{ subfilter_str }}"
|
||||
{% endif -%}
|
||||
></table-viewer>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<FieldAdder {columns}></FieldAdder>
|
||||
</div>
|
||||
</div>
|
||||
{#if subfilter && subfilter !== "null"}
|
||||
<div class="subfilter-indicator">
|
||||
<a href="?">…</a>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="table-viewer__main">
|
||||
{@render table_region({
|
||||
region: "main",
|
||||
|
|
@ -654,8 +663,7 @@
|
|||
},
|
||||
})}
|
||||
</div>
|
||||
<form method="post" action="insert">
|
||||
<div class="table-viewer__inserter">
|
||||
<form method="post" action="insert" class="table-viewer__inserter">
|
||||
<h3 class="table-viewer__inserter-help">
|
||||
Insert rows — press "shift + enter" to jump here or add a row
|
||||
</h3>
|
||||
|
|
@ -694,7 +702,6 @@
|
|||
<i class="ti ti-upload"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{#each inserter_rows as row}
|
||||
{#each lazy_data.fields as field, field_index}
|
||||
<input
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue