Compare commits
3 commits
1b4cbe8517
...
647ce5fc52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
647ce5fc52 | ||
|
|
f5ee31c175 | ||
|
|
4b547faf7a |
7 changed files with 138 additions and 50 deletions
|
|
@ -11,7 +11,7 @@ use phono_backends::{
|
||||||
use phono_models::{
|
use phono_models::{
|
||||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
accessors::{Accessor, Actor, portal::PortalAccessor},
|
||||||
datum::Datum,
|
datum::Datum,
|
||||||
expression::QueryFragment,
|
expression::{PgExpressionAny, QueryFragment},
|
||||||
field::Field,
|
field::Field,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -19,11 +19,14 @@ use sqlx::{
|
||||||
postgres::{PgRow, types::Oid},
|
postgres::{PgRow, types::Oid},
|
||||||
query,
|
query,
|
||||||
};
|
};
|
||||||
|
use tracing::debug;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppDbConn,
|
app::AppDbConn,
|
||||||
errors::AppError,
|
errors::AppError,
|
||||||
|
extractors::ValidatedForm,
|
||||||
field_info::TableFieldInfo,
|
field_info::TableFieldInfo,
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
|
|
@ -36,10 +39,16 @@ pub(super) struct PathParams {
|
||||||
workspace_id: Uuid,
|
workspace_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
|
pub(super) struct FormBody {
|
||||||
|
subfilter: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
const FRONTEND_ROW_LIMIT: i64 = 1000;
|
const FRONTEND_ROW_LIMIT: i64 = 1000;
|
||||||
|
|
||||||
/// HTTP GET handler for an API endpoint returning a JSON encoding of portal
|
/// 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.
|
/// Only queries up to the first [`FRONTEND_ROW_LIMIT`] rows.
|
||||||
pub(super) async fn get(
|
pub(super) async fn get(
|
||||||
|
|
@ -51,6 +60,7 @@ pub(super) async fn get(
|
||||||
rel_oid,
|
rel_oid,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
}): Path<PathParams>,
|
}): Path<PathParams>,
|
||||||
|
ValidatedForm(form): ValidatedForm<FormBody>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let mut workspace_client = workspace_pooler
|
let mut workspace_client = workspace_pooler
|
||||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||||
|
|
@ -109,10 +119,31 @@ pub(super) async fn get(
|
||||||
.join(", "),
|
.join(", "),
|
||||||
rel.get_identifier(),
|
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(QueryFragment::from_sql(" where "));
|
||||||
sql_fragment.push(filter_expr.into_query_fragment());
|
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_sql(" order by _id limit "));
|
||||||
sql_fragment.push(QueryFragment::from_param(Datum::Numeric(Some(
|
sql_fragment.push(QueryFragment::from_param(Datum::Numeric(Some(
|
||||||
FRONTEND_ROW_LIMIT.into(),
|
FRONTEND_ROW_LIMIT.into(),
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,12 @@ use phono_models::{
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::postgres::types::Oid;
|
use sqlx::postgres::types::Oid;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::AppDbConn,
|
app::AppDbConn,
|
||||||
errors::AppError,
|
errors::AppError,
|
||||||
|
extractors::ValidatedForm,
|
||||||
navigator::Navigator,
|
navigator::Navigator,
|
||||||
settings::Settings,
|
settings::Settings,
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
|
|
@ -30,6 +32,12 @@ pub(super) struct PathParams {
|
||||||
workspace_id: Uuid,
|
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
|
/// HTTP GET handler for the table viewer page of a [`Portal`]. This handler
|
||||||
/// performs some relatively simple queries pertaining to table structure, but
|
/// performs some relatively simple queries pertaining to table structure, but
|
||||||
/// the bulk of the query logic resides in the [`super::get_data_handler`]
|
/// the bulk of the query logic resides in the [`super::get_data_handler`]
|
||||||
|
|
@ -45,6 +53,9 @@ pub(super) async fn get(
|
||||||
rel_oid,
|
rel_oid,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
}): Path<PathParams>,
|
}): Path<PathParams>,
|
||||||
|
ValidatedForm(FormBody {
|
||||||
|
subfilter: subfilter_str,
|
||||||
|
}): ValidatedForm<FormBody>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
let mut workspace_client = pooler
|
let mut workspace_client = pooler
|
||||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
||||||
|
|
@ -89,6 +100,7 @@ pub(super) async fn get(
|
||||||
attr_names: Vec<String>,
|
attr_names: Vec<String>,
|
||||||
filter: Option<PgExpressionAny>,
|
filter: Option<PgExpressionAny>,
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
|
subfilter_str: String,
|
||||||
navbar: WorkspaceNav,
|
navbar: WorkspaceNav,
|
||||||
}
|
}
|
||||||
Ok(Html(
|
Ok(Html(
|
||||||
|
|
@ -106,6 +118,7 @@ pub(super) async fn get(
|
||||||
Some(RelLocation::Portal(portal.id)),
|
Some(RelLocation::Portal(portal.id)),
|
||||||
))
|
))
|
||||||
.build()?,
|
.build()?,
|
||||||
|
subfilter_str,
|
||||||
settings,
|
settings,
|
||||||
}
|
}
|
||||||
.render()?,
|
.render()?,
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<main class="page-grid__main">
|
<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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
:root {
|
:root {
|
||||||
--accent-color: #fc0;
|
--accent-color: #fc0;
|
||||||
|
|
||||||
--default-border-color: #ccc;
|
--default-border-color: #aaa;
|
||||||
--default-border-radius--rounded: 8px;
|
--default-border-radius--rounded: 8px;
|
||||||
--default-border-radius--rounded-sm: 4px;
|
--default-border-radius--rounded-sm: 4px;
|
||||||
--default-font-family:
|
--default-font-family:
|
||||||
|
|
@ -39,7 +39,7 @@
|
||||||
--button-background--primary: var(--accent-color);
|
--button-background--primary: var(--accent-color);
|
||||||
--button-background--secondary: #fff;
|
--button-background--secondary: #fff;
|
||||||
--button-border-color--primary: oklch(from var(--button-background--primary) calc(l * 0.9) c h);
|
--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-border-radius: var(--default-border-radius--rounded);
|
||||||
--button-color--primary: #000;
|
--button-color--primary: #000;
|
||||||
--button-color--secondary: #000;
|
--button-color--secondary: #000;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
@import "./expression-editor.css";
|
@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 ======== */
|
/* ======== Toolbar ======== */
|
||||||
|
|
||||||
.filter-menu {
|
.filter-menu {
|
||||||
|
|
@ -24,6 +29,7 @@
|
||||||
grid-area: table;
|
grid-area: table;
|
||||||
grid-template:
|
grid-template:
|
||||||
'headers' max-content
|
'headers' max-content
|
||||||
|
'subfilter-indicator' max-content
|
||||||
'main' 1fr
|
'main' 1fr
|
||||||
'inserter' max-content;
|
'inserter' max-content;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
@ -49,7 +55,7 @@
|
||||||
.field-adder__header-lookalike {
|
.field-adder__header-lookalike {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #0001;
|
background: #0001;
|
||||||
border: solid 1px #ccc;
|
border: solid 1px var(--table-header-border-color);
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -69,7 +75,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-header__label {
|
.field-header__label {
|
||||||
|
overflow: hidden;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.field-header__type-indicator {
|
.field-header__type-indicator {
|
||||||
|
|
@ -122,7 +131,7 @@
|
||||||
|
|
||||||
.field-adder__completions {
|
.field-adder__completions {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
border-right: solid 1px var(--default-border-color);
|
border-right: solid 1px var(--table-header-border-color);
|
||||||
grid-area: completions;
|
grid-area: completions;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -149,6 +158,13 @@
|
||||||
padding: var(--default-padding);
|
padding: var(--default-padding);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ======== Subfilter Indicator ======== */
|
||||||
|
|
||||||
|
.subfilter-indicator {
|
||||||
|
grid-area: subfilter-indicator;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ======== Table Body ======== */
|
/* ======== Table Body ======== */
|
||||||
|
|
||||||
.table-viewer__main {
|
.table-viewer__main {
|
||||||
|
|
@ -160,11 +176,17 @@
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
.table-viewer__cell {
|
||||||
|
border-top: solid 1px var(--table-cell-border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-viewer__cell {
|
.table-viewer__cell {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
border: solid 1px var(--default-border-color);
|
border: solid 1px var(--table-cell-border-color);
|
||||||
border-left: none;
|
border-left: none;
|
||||||
border-top: none;
|
border-top: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -245,6 +267,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-viewer__cell-indicator {
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-viewer__notice {
|
.table-viewer__notice {
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,12 @@
|
||||||
<div>UNKNOWN</div>
|
<div>UNKNOWN</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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}
|
{#if invalid_value}
|
||||||
<div class="table-viewer__cell-notice">
|
<div class="table-viewer__cell-notice">
|
||||||
<i class="ti ti-alert-circle"></i>
|
<i class="ti ti-alert-circle"></i>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
customElement={{
|
customElement={{
|
||||||
props: {
|
props: {
|
||||||
columns: { type: "Array" },
|
columns: { type: "Array" },
|
||||||
|
subfilter: { type: "String" },
|
||||||
},
|
},
|
||||||
shadow: "none",
|
shadow: "none",
|
||||||
tag: "table-viewer",
|
tag: "table-viewer",
|
||||||
|
|
@ -34,9 +35,10 @@
|
||||||
name: string;
|
name: string;
|
||||||
regtype: string;
|
regtype: string;
|
||||||
}[];
|
}[];
|
||||||
|
subfilter?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { columns = [] }: Props = $props();
|
let { columns = [], subfilter = "null" }: Props = $props();
|
||||||
|
|
||||||
type CellDelta = {
|
type CellDelta = {
|
||||||
// Assumes that primary keys are immutable and that rows are only added or
|
// Assumes that primary keys are immutable and that rows are only added or
|
||||||
|
|
@ -533,7 +535,9 @@
|
||||||
),
|
),
|
||||||
fields: z.array(field_info_schema),
|
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());
|
const body = get_data_response_schema.parse(await resp.json());
|
||||||
lazy_data = {
|
lazy_data = {
|
||||||
fields: body.fields,
|
fields: body.fields,
|
||||||
|
|
@ -634,6 +638,11 @@
|
||||||
<FieldAdder {columns}></FieldAdder>
|
<FieldAdder {columns}></FieldAdder>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{#if subfilter && subfilter !== "null"}
|
||||||
|
<div class="subfilter-indicator">
|
||||||
|
<a href="?">…</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="table-viewer__main">
|
<div class="table-viewer__main">
|
||||||
{@render table_region({
|
{@render table_region({
|
||||||
region: "main",
|
region: "main",
|
||||||
|
|
@ -654,46 +663,44 @@
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<form method="post" action="insert">
|
<form method="post" action="insert" class="table-viewer__inserter">
|
||||||
<div class="table-viewer__inserter">
|
<h3 class="table-viewer__inserter-help">
|
||||||
<h3 class="table-viewer__inserter-help">
|
Insert rows — press "shift + enter" to jump here or add a row
|
||||||
Insert rows — press "shift + enter" to jump here or add a row
|
</h3>
|
||||||
</h3>
|
<div class="table-viewer__inserter-main">
|
||||||
<div class="table-viewer__inserter-main">
|
<div class="table-viewer__inserter-rows">
|
||||||
<div class="table-viewer__inserter-rows">
|
{@render table_region({
|
||||||
{@render table_region({
|
region: "inserter",
|
||||||
region: "inserter",
|
rows: inserter_rows,
|
||||||
rows: inserter_rows,
|
on_cell_click: (ev: MouseEvent, coords: Coords) => {
|
||||||
on_cell_click: (ev: MouseEvent, coords: Coords) => {
|
if (ev.metaKey || ev.ctrlKey) {
|
||||||
if (ev.metaKey || ev.ctrlKey) {
|
set_selections([
|
||||||
set_selections([
|
coords,
|
||||||
coords,
|
...selections
|
||||||
...selections
|
.filter((sel) => !coords_eq(sel.coords, coords))
|
||||||
.filter((sel) => !coords_eq(sel.coords, coords))
|
.map((sel) => sel.coords),
|
||||||
.map((sel) => sel.coords),
|
]);
|
||||||
]);
|
} else if (ev.shiftKey) {
|
||||||
} else if (ev.shiftKey) {
|
move_cursor(coords, { additive: true });
|
||||||
move_cursor(coords, { additive: true });
|
} else {
|
||||||
} else {
|
move_cursor(coords);
|
||||||
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>
|
</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>
|
</div>
|
||||||
{#each inserter_rows as row}
|
{#each inserter_rows as row}
|
||||||
{#each lazy_data.fields as field, field_index}
|
{#each lazy_data.fields as field, field_index}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue