Compare commits

..

3 commits

Author SHA1 Message Date
Brent Schroeter
647ce5fc52 truncate long field header labels 2026-01-14 02:15:12 +00:00
Brent Schroeter
f5ee31c175 display "follow link" button for url-like values 2026-01-14 02:12:27 +00:00
Brent Schroeter
4b547faf7a implement url query sub-filters 2026-01-14 02:09:52 +00:00
7 changed files with 138 additions and 50 deletions

View file

@ -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(),

View file

@ -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()?,

View file

@ -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 %}

View file

@ -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;

View file

@ -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;
@ -69,7 +75,10 @@
}
.field-header__label {
overflow: hidden;
padding: 4px;
text-overflow: ellipsis;
white-space: nowrap;
}
.field-header__type-indicator {
@ -122,7 +131,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 +158,13 @@
padding: var(--default-padding);
}
/* ======== Subfilter Indicator ======== */
.subfilter-indicator {
grid-area: subfilter-indicator;
padding: 8px;
}
/* ======== Table Body ======== */
.table-viewer__main {
@ -160,11 +176,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;
@ -245,6 +267,10 @@
}
}
}
.table-viewer__cell-indicator {
padding-right: 4px;
}
}
.table-viewer__notice {

View file

@ -156,6 +156,12 @@
<div>UNKNOWN</div>
</div>
{/if}
{#if value.t === "Text" && /^[a-z+.-]+:\/\/\S+$/i.test(value.c ?? "")}
<a class="table-viewer__cell-indicator" href={value.c}
><i class="ti ti-external-link"><div class="sr-only">Open link</div></i
></a
>
{/if}
{#if invalid_value}
<div class="table-viewer__cell-notice">
<i class="ti ti-alert-circle"></i>

View file

@ -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="?">&hellip;</a>
</div>
{/if}
<div class="table-viewer__main">
{@render table_region({
region: "main",
@ -654,46 +663,44 @@
},
})}
</div>
<form method="post" action="insert">
<div class="table-viewer__inserter">
<h3 class="table-viewer__inserter-help">
Insert rows &mdash; press "shift + enter" to jump here or add a row
</h3>
<div class="table-viewer__inserter-main">
<div class="table-viewer__inserter-rows">
{@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);
}
},
})}
</div>
<button
aria-label="Insert rows"
class="table-viewer__inserter-submit"
onkeydown={(ev) => {
// Prevent keypress (e.g. pressing Enter on the button to submit
// it) from triggering a table interaction.
ev.stopPropagation();
}}
title="Insert rows"
type="submit"
>
<i class="ti ti-upload"></i>
</button>
<form method="post" action="insert" class="table-viewer__inserter">
<h3 class="table-viewer__inserter-help">
Insert rows &mdash; press "shift + enter" to jump here or add a row
</h3>
<div class="table-viewer__inserter-main">
<div class="table-viewer__inserter-rows">
{@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);
}
},
})}
</div>
<button
aria-label="Insert rows"
class="table-viewer__inserter-submit"
onkeydown={(ev) => {
// Prevent keypress (e.g. pressing Enter on the button to submit
// it) from triggering a table interaction.
ev.stopPropagation();
}}
title="Insert rows"
type="submit"
>
<i class="ti ti-upload"></i>
</button>
</div>
{#each inserter_rows as row}
{#each lazy_data.fields as field, field_index}