implement filtering for lenses

This commit is contained in:
Brent Schroeter 2025-08-24 23:24:01 -07:00
parent 07d4987f3c
commit 10dee07a43
30 changed files with 1489 additions and 125 deletions

22
Cargo.lock generated
View file

@ -1643,6 +1643,7 @@ dependencies = [
"serde",
"serde_json",
"sqlx",
"strum",
"thiserror 2.0.12",
"uuid",
]
@ -3122,6 +3123,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "subtle"
version = "2.6.1"

View file

@ -13,7 +13,6 @@ derive_builder = "0.20.2"
futures = "0.3.31"
interim-models = { path = "./interim-models" }
interim-pgtypes = { path = "./interim-pgtypes" }
interim-server = { path = "./interim-server" }
rand = "0.8.5"
regex = "1.11.1"
reqwest = { version = "0.12.8", features = ["json"] }

View file

@ -11,5 +11,6 @@ regex = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sqlx = { workspace = true }
strum = { version = "0.27.2", features = ["derive"] }
thiserror = { workspace = true }
uuid = { workspace = true }

View file

@ -5,7 +5,7 @@ create table if not exists lenses (
name text not null,
base_id uuid not null references bases(id) on delete cascade,
class_oid oid not null,
filter jsonb not null default '{}'::jsonb,
filter jsonb not null default 'null'::jsonb,
order_by jsonb not null default '[]'::jsonb,
display_type lens_display_type not null default 'table'
);

View file

@ -0,0 +1,250 @@
use std::fmt::Display;
use interim_pgtypes::escape_identifier;
use serde::{Deserialize, Serialize};
use crate::field::Encodable;
#[derive(Clone, Debug, PartialEq)]
pub struct QueryFragment {
/// SQL string, split wherever there is a query parameter. For example,
/// `select * from foo where id = $1 and status = $2` is represented along
/// the lines of `["select * from foo where id = ", " and status = ", ""]`.
/// `plain_sql` should always have exactly one more element than `params`.
plain_sql: Vec<String>,
params: Vec<Encodable>,
}
impl QueryFragment {
pub fn to_sql(&self, first_param_idx: usize) -> String {
assert!(self.plain_sql.len() == self.params.len() + 1);
self.plain_sql
.iter()
.cloned()
.zip((first_param_idx..).map(|n| format!("${n}")))
.fold(
Vec::with_capacity(2 * self.plain_sql.len()),
|mut acc, pair| {
acc.extend([pair.0, pair.1]);
acc
},
)
.get(0..(2 * self.plain_sql.len() - 1))
.expect("already asserted sufficient length")
.join("")
}
pub fn to_params(&self) -> Vec<Encodable> {
self.params.clone()
}
pub fn from_sql(sql: &str) -> Self {
Self {
plain_sql: vec![sql.to_owned()],
params: vec![],
}
}
pub fn from_param(param: Encodable) -> Self {
Self {
plain_sql: vec!["".to_owned(), "".to_owned()],
params: vec![param],
}
}
pub fn push(&mut self, mut other: QueryFragment) {
assert!(self.plain_sql.len() == self.params.len() + 1);
assert!(other.plain_sql.len() == other.params.len() + 1);
let tail = self
.plain_sql
.pop()
.expect("already asserted that vec contains at least 1 item");
let head = other
.plain_sql
.first()
.expect("already asserted that vec contains at least 1 item");
self.plain_sql.push(format!("{tail}{head}"));
for value in other.plain_sql.drain(1..) {
self.plain_sql.push(value);
}
self.params.append(&mut other.params);
}
/// Combine multiple QueryFragments with a separator, similar to Vec::join().
pub fn join<I: IntoIterator<Item = Self>>(fragments: I, sep: Self) -> Self {
let mut acc = QueryFragment::from_sql("");
let mut iter = fragments.into_iter();
let mut fragment = match iter.next() {
Some(value) => value,
None => return acc,
};
for next_fragment in iter {
acc.push(fragment);
acc.push(sep.clone());
fragment = next_fragment;
}
acc.push(fragment);
acc
}
/// Convenience method equivalent to:
/// `QueryFragment::concat(fragments, QueryFragment::from_sql(""))`
pub fn concat<I: IntoIterator<Item = Self>>(fragments: I) -> Self {
Self::join(fragments, Self::from_sql(""))
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "t", content = "c")]
pub enum PgExpressionAny {
Comparison(PgComparisonExpression),
Identifier(PgIdentifierExpression),
Literal(Encodable),
ToJson(PgToJsonExpression),
}
impl PgExpressionAny {
pub fn into_query_fragment(self) -> QueryFragment {
match self {
Self::Comparison(expr) => expr.into_query_fragment(),
Self::Identifier(expr) => expr.into_query_fragment(),
Self::Literal(expr) => {
if expr.is_none() {
QueryFragment::from_sql("null")
} else {
QueryFragment::from_param(expr)
}
}
Self::ToJson(expr) => expr.into_query_fragment(),
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "t", content = "c")]
pub enum PgComparisonExpression {
Infix(PgInfixExpression<PgComparisonOperator>),
IsNull(PgIsNullExpression),
IsNotNull(PgIsNotNullExpression),
}
impl PgComparisonExpression {
fn into_query_fragment(self) -> QueryFragment {
match self {
Self::Infix(expr) => expr.into_query_fragment(),
Self::IsNull(expr) => expr.into_query_fragment(),
Self::IsNotNull(expr) => expr.into_query_fragment(),
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct PgInfixExpression<T: Display> {
pub operator: T,
pub lhs: Box<PgExpressionAny>,
pub rhs: Box<PgExpressionAny>,
}
impl<T: Display> PgInfixExpression<T> {
fn into_query_fragment(self) -> QueryFragment {
QueryFragment::concat([
QueryFragment::from_sql("(("),
self.lhs.into_query_fragment(),
QueryFragment::from_sql(&format!(") {} (", self.operator)),
self.rhs.into_query_fragment(),
QueryFragment::from_sql("))"),
])
}
}
#[derive(Clone, Debug, strum::Display, Deserialize, PartialEq, Serialize)]
pub enum PgComparisonOperator {
#[strum(to_string = "and")]
And,
#[strum(to_string = "=")]
Eq,
#[strum(to_string = ">")]
Gt,
#[strum(to_string = "<")]
Lt,
#[strum(to_string = "<>")]
Neq,
#[strum(to_string = "or")]
Or,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct PgIsNullExpression {
lhs: Box<PgExpressionAny>,
}
impl PgIsNullExpression {
fn into_query_fragment(self) -> QueryFragment {
QueryFragment::concat([
QueryFragment::from_sql("(("),
self.lhs.into_query_fragment(),
QueryFragment::from_sql(") is null)"),
])
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct PgIsNotNullExpression {
lhs: Box<PgExpressionAny>,
}
impl PgIsNotNullExpression {
fn into_query_fragment(self) -> QueryFragment {
QueryFragment::concat([
QueryFragment::from_sql("(("),
self.lhs.into_query_fragment(),
QueryFragment::from_sql(") is not null)"),
])
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct PgIdentifierExpression {
pub parts_raw: Vec<String>,
}
impl PgIdentifierExpression {
fn into_query_fragment(self) -> QueryFragment {
QueryFragment::join(
self.parts_raw
.iter()
.map(|part| QueryFragment::from_sql(&escape_identifier(part))),
QueryFragment::from_sql("."),
)
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct PgToJsonExpression {
entries: Vec<(String, PgExpressionAny)>,
}
impl PgToJsonExpression {
/// Generates a query fragment to the effect of:
/// `to_json((select ($expr) as "ident", ($expr2) as "ident2"))`
fn into_query_fragment(self) -> QueryFragment {
if self.entries.is_empty() {
QueryFragment::from_sql("'{}'")
} else {
QueryFragment::concat([
QueryFragment::from_sql("to_json((select "),
QueryFragment::join(
self.entries.into_iter().map(|(key, value)| {
QueryFragment::concat([
QueryFragment::from_sql("("),
value.into_query_fragment(),
QueryFragment::from_sql(&format!(") as {}", escape_identifier(&key))),
])
}),
QueryFragment::from_sql(", "),
),
QueryFragment::from_sql("))"),
])
}
}
}

View file

@ -39,18 +39,13 @@ impl Field {
FieldType::InterimUser {} => "cell-interim-user",
FieldType::Text {} => "cell-text",
FieldType::Timestamp { .. } => "cell-timestamp",
FieldType::Uuid { .. } => "cell-uuid",
FieldType::Uuid {} => "cell-uuid",
FieldType::Unknown => "cell-unknown",
}
}
pub fn webc_custom_attrs(&self) -> Vec<(String, String)> {
match self.field_type.clone() {
sqlx::types::Json(FieldType::Uuid {
default_with_version: Some(_),
}) => vec![("has_default".to_owned(), "true".to_owned())],
_ => vec![],
}
vec![]
}
pub fn get_value_encodable(&self, row: &PgRow) -> Result<Encodable, ParseError> {
@ -109,9 +104,7 @@ pub enum FieldType {
Timestamp {
format: String,
},
Uuid {
default_with_version: Option<String>,
},
Uuid {},
/// A special variant for when the field type is not specified and cannot be
/// inferred. This isn't represented as an error, because we still want to
/// be able to define display behavior via the .render() method.
@ -125,9 +118,7 @@ impl FieldType {
"timestamp" => Self::Timestamp {
format: RFC_3339_S.to_owned(),
},
"uuid" => Self::Uuid {
default_with_version: None,
},
"uuid" => Self::Uuid {},
_ => Self::Unknown,
}
}

View file

@ -1,9 +1,9 @@
use derive_builder::Builder;
use serde::Serialize;
use sqlx::{postgres::types::Oid, query_as};
use sqlx::{postgres::types::Oid, query, query_as, types::Json};
use uuid::Uuid;
use crate::client::AppDbClient;
use crate::{client::AppDbClient, expression::PgExpressionAny};
#[derive(Clone, Debug, Serialize)]
pub struct Lens {
@ -12,6 +12,7 @@ pub struct Lens {
pub base_id: Uuid,
pub class_oid: Oid,
pub display_type: LensDisplayType,
pub filter: Json<Option<PgExpressionAny>>,
}
impl Lens {
@ -19,6 +20,10 @@ impl Lens {
InsertableLensBuilder::default()
}
pub fn update() -> LensUpdateBuilder {
LensUpdateBuilder::default()
}
pub fn with_id(id: Uuid) -> WithIdQuery {
WithIdQuery { id }
}
@ -46,7 +51,8 @@ select
name,
base_id,
class_oid,
display_type as "display_type: LensDisplayType"
display_type as "display_type: LensDisplayType",
filter as "filter: Json<Option<PgExpressionAny>>"
from lenses
where id = $1
"#,
@ -65,7 +71,8 @@ select
name,
base_id,
class_oid,
display_type as "display_type: LensDisplayType"
display_type as "display_type: LensDisplayType",
filter as "filter: Json<Option<PgExpressionAny>>"
from lenses
where id = $1
"#,
@ -106,7 +113,8 @@ select
name,
base_id,
class_oid,
display_type as "display_type: LensDisplayType"
display_type as "display_type: LensDisplayType",
filter as "filter: Json<Option<PgExpressionAny>>"
from lenses
where base_id = $1 and class_oid = $2
"#,
@ -145,7 +153,8 @@ returning
name,
base_id,
class_oid,
display_type as "display_type: LensDisplayType"
display_type as "display_type: LensDisplayType",
filter as "filter: Json<Option<PgExpressionAny>>"
"#,
Uuid::now_v7(),
self.base_id,
@ -157,3 +166,25 @@ returning
.await
}
}
#[derive(Builder, Clone, Debug)]
pub struct LensUpdate {
id: Uuid,
#[builder(setter(strip_option = true))]
filter: Option<Option<PgExpressionAny>>,
}
impl LensUpdate {
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> {
if let Some(filter) = self.filter {
query!(
"update lenses set filter = $1 where id = $2",
Json(filter) as Json<Option<PgExpressionAny>>,
self.id
)
.execute(&mut *app_db.conn)
.await?;
}
Ok(())
}
}

View file

@ -1,5 +1,6 @@
pub mod base;
pub mod client;
pub mod expression;
pub mod field;
pub mod lens;
pub mod rel_invitation;

View file

@ -63,7 +63,7 @@ pub fn new_router(state: AppState) -> Router<()> {
)
.route_with_tsr(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/",
get(routes::lenses::lens_page),
get(routes::lens_index::lens_page_get),
)
.route(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/get-data",
@ -81,6 +81,10 @@ pub fn new_router(state: AppState) -> Router<()> {
"/d/{base_id}/r/{class_oid}/l/{lens_id}/update-value",
post(routes::lenses::update_value_page_post),
)
.route(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/set-filter",
post(routes::lens_set_filter::lens_set_filter_page_post),
)
.route(
"/d/{base_id}/r/{class_oid}/l/{lens_id}/insert",
post(routes::lens_insert::insert_page_post),

View file

@ -0,0 +1,72 @@
use askama::Template;
use axum::{
extract::{Path, State},
response::{Html, IntoResponse as _, Response},
};
use interim_models::{base::Base, expression::PgExpressionAny, lens::Lens};
use interim_pgtypes::pg_attribute::PgAttribute;
use sqlx::postgres::types::Oid;
use crate::{
app_error::AppError,
app_state::AppDbConn,
base_pooler::{BasePooler, RoleAssignment},
navbar::{NavLocation, Navbar, RelLocation},
settings::Settings,
user::CurrentUser,
};
use super::LensPagePath;
pub async fn lens_page_get(
State(settings): State<Settings>,
State(mut base_pooler): State<BasePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(LensPagePath {
lens_id,
base_id,
class_oid,
}): Path<LensPagePath>,
) -> Result<Response, AppError> {
// FIXME auth
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?;
let mut base_client = base_pooler
.acquire_for(lens.base_id, RoleAssignment::User(current_user.id))
.await?;
let attrs = PgAttribute::all_for_rel(lens.class_oid)
.fetch_all(&mut base_client)
.await?;
let attr_names: Vec<String> = attrs.iter().map(|attr| attr.attname.clone()).collect();
#[derive(Template)]
#[template(path = "lens0_2.html")]
struct ResponseTemplate {
attr_names: Vec<String>,
filter: Option<PgExpressionAny>,
settings: Settings,
navbar: Navbar,
}
Ok(Html(
ResponseTemplate {
attr_names,
filter: lens.filter.0,
navbar: Navbar::builder()
.root_path(settings.root_path.clone())
.base(base.clone())
.populate_rels(&mut app_db, &mut base_client)
.await?
.current(NavLocation::Rel(
Oid(class_oid),
Some(RelLocation::Lens(lens.id)),
))
.build()?,
settings,
}
.render()?,
)
.into_response())
}

View file

@ -0,0 +1,35 @@
use axum::{extract::Path, response::Response};
use axum_extra::extract::Form;
use interim_models::{expression::PgExpressionAny, lens::Lens};
use serde::Deserialize;
use crate::{app_error::AppError, app_state::AppDbConn, navigator::Navigator, user::CurrentUser};
use super::LensPagePath;
#[derive(Deserialize)]
pub struct FormBody {
filter_expression: String,
}
pub async fn lens_set_filter_page_post(
navigator: Navigator,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(_): CurrentUser,
Path(LensPagePath { lens_id, .. }): Path<LensPagePath>,
Form(body): Form<FormBody>,
) -> Result<Response, AppError> {
// FIXME auth, csrf
let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?;
let filter: Option<PgExpressionAny> = serde_json::from_str(&body.filter_expression)?;
Lens::update()
.id(lens.id)
.filter(filter)
.build()?
.execute(&mut app_db)
.await?;
Ok(navigator.lens_page(&lens).redirect_to())
}

View file

@ -141,50 +141,6 @@ pub async fn add_lens_page_post(
Ok(navigator.lens_page(&lens).redirect_to())
}
pub async fn lens_page(
State(settings): State<Settings>,
State(mut base_pooler): State<BasePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(current_user): CurrentUser,
Path(LensPagePath {
lens_id,
base_id,
class_oid,
}): Path<LensPagePath>,
) -> Result<Response, AppError> {
// FIXME auth
let base = Base::with_id(base_id).fetch_one(&mut app_db).await?;
let lens = Lens::with_id(lens_id).fetch_one(&mut app_db).await?;
let mut base_client = base_pooler
.acquire_for(lens.base_id, RoleAssignment::User(current_user.id))
.await?;
#[derive(Template)]
#[template(path = "lens0_2.html")]
struct ResponseTemplate {
settings: Settings,
navbar: Navbar,
}
Ok(Html(
ResponseTemplate {
navbar: Navbar::builder()
.root_path(settings.root_path.clone())
.base(base.clone())
.populate_rels(&mut app_db, &mut base_client)
.await?
.current(NavLocation::Rel(
Oid(class_oid),
Some(RelLocation::Lens(lens.id)),
))
.build()?,
settings,
}
.render()?,
)
.into_response())
}
pub async fn get_data_page_get(
State(settings): State<Settings>,
State(mut base_pooler): State<BasePooler>,
@ -232,8 +188,8 @@ pub async fn get_data_page_get(
};
const FRONTEND_ROW_LIMIT: i64 = 1000;
let rows: Vec<PgRow> = query(&format!(
"select {0} from {1}.{2} limit $1",
let mut sql_raw = format!(
"select {0} from {1}.{2}",
pkey_attrs
.iter()
.chain(attrs.iter())
@ -242,10 +198,28 @@ pub async fn get_data_page_get(
.join(", "),
escape_identifier(&rel.regnamespace),
escape_identifier(&rel.relname),
))
.bind(FRONTEND_ROW_LIMIT)
.fetch_all(base_client.get_conn())
.await?;
);
let rows: Vec<PgRow> = if let Some(filter_expr) = lens.filter.0 {
let filter_fragment = filter_expr.into_query_fragment();
let filter_params = filter_fragment.to_params();
sql_raw = format!(
"{sql_raw} where {0} limit ${1}",
filter_fragment.to_sql(1),
filter_params.len() + 1
);
let mut q = query(&sql_raw);
for param in filter_params {
q = param.bind_onto(q);
}
q = q.bind(FRONTEND_ROW_LIMIT);
q.fetch_all(base_client.get_conn()).await?
} else {
sql_raw = format!("{sql_raw} limit $1");
query(&sql_raw)
.bind(FRONTEND_ROW_LIMIT)
.fetch_all(base_client.get_conn())
.await?
};
#[derive(Serialize)]
struct DataRow {

View file

@ -2,7 +2,9 @@ use serde::Deserialize;
use uuid::Uuid;
pub mod bases;
pub mod lens_index;
pub mod lens_insert;
pub mod lens_set_filter;
pub mod lenses;
pub mod relations;

View file

@ -8,7 +8,6 @@
<body>
{% block main %}{% endblock main %}
{% if settings.dev != 0 %}
<script type="module" src="{{ settings.root_path }}/dev_reloader.mjs"></script>
<script type="module">
import { initDevReloader } from "{{ settings.root_path }}/dev_reloader.mjs";
initDevReloader("ws://127.0.0.1:8080{{ settings.root_path }}/__dev-healthz");

View file

@ -3,7 +3,9 @@
{% block main %}
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/viewer.css">
<div class="page-grid">
<div class="page-grid__toolbar"></div>
<div class="page-grid__toolbar">
<filter-menu identifier-hints="{{ attr_names | json }}" initial-value="{{ filter | json }}"></filter-menu>
</div>
<div class="page-grid__sidebar">
{{ navbar | safe }}
</div>
@ -11,6 +13,7 @@
<table-viewer root-path="{{ settings.root_path }}"></table-viewer>
</main>
</div>
<script src="{{ settings.root_path }}/js_dist/table-viewer.webc.js"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/table-viewer.webc.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/filter-menu.webc.mjs"></script>
{% endblock %}

View file

@ -45,7 +45,7 @@
</li>
<li class="navbar__menu-item">
<collapsible-menu class="navbar__collapsible-menu" root-path="{{ root_path }}">
<h5 slot="summary" class="navbar__heading">Interfaces</h5>
<h5 slot="summary" class="navbar__heading">Tabs</h5>
<menu slot="content" class="navbar__menu">
{% for lens in rel.lenses %}
<li class="navbar__menu-item

View file

@ -2,7 +2,8 @@
$button-primary-background: #07f;
$button-primary-color: #fff;
$default-border: solid 1px #ccc;
$default-border-color: #ccc;
$default-border: solid 1px $default-border-color;
$font-family-default: 'Averia Serif Libre', 'Open Sans', 'Helvetica Neue', Arial, sans-serif;
$font-family-data: 'Funnel Sans', 'Open Sans', 'Helvetica Neue', Arial, sans-serif;
$font-family-mono: Menlo, 'Courier New', Courier, mono;
@ -12,6 +13,7 @@ $border-radius-rounded-sm: 0.25rem;
$border-radius-rounded: 0.5rem;
$link-color: #069;
$notice-color-info: #39d;
$hover-lightness-scale-factor: -10%;
@mixin reset-button {
appearance: none;
@ -39,7 +41,42 @@ $notice-color-info: #39d;
color: $button-primary-color;
&:hover {
background: color.scale($button-primary-background, $lightness: -10%, $space: oklch);
background: color.scale(
$button-primary-background,
$lightness: $hover-lightness-scale-factor,
$space: oklch
);
}
}
@mixin button-outline {
@include button-base;
background: $button-primary-color;
border: solid 1px $button-primary-background;
color: $button-primary-background;
&:hover {
border-color: color.scale(
$button-primary-background,
$lightness: $hover-lightness-scale-factor,
$space: oklch
);
}
}
@mixin button-secondary {
@include button-base;
background: #fff;
color: #000;
border: $default-border;
&:hover {
border-color: color.scale(
$default-border-color,
$lightness: $hover-lightness-scale-factor,
$space: oklch
);
}
}
@ -47,7 +84,7 @@ $notice-color-info: #39d;
@include button-base;
&:hover {
background: #0002;
background: #0000001f;
}
}

View file

@ -0,0 +1,92 @@
@use 'sass:color';
@use 'globals';
.expression-editor {
&__container {
@include globals.rounded;
background: #eee;
display: flex;
}
&__sidebar {
display: grid;
grid-template:
'padding-top' 1fr
'operator-selector' max-content
'actions' minmax(max-content, 1fr);
}
&__main {
@include globals.rounded;
background: #fff;
border: globals.$default-border;
flex: 1;
padding: 0.5rem;
}
&__action-button {
padding: 0.5rem;
svg path {
fill: currentColor;
}
}
&__params {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}
.expression-selector {
&__container {
grid-area: operator-selector;
}
&__expression-button {
@include globals.button-clear;
align-items: center;
display: flex;
justify-content: center;
height: 2.5rem;
padding: 0;
width: 2.5rem;
svg path {
fill: currentColor;
}
}
&__popover {
&:popover-open {
@include globals.rounded;
inset: unset;
border: globals.$popover-border;
margin: 0;
margin-top: 0.25rem;
position: fixed;
display: flex;
flex-direction: column;
padding: 0;
background: #fff;
box-shadow: globals.$popover-shadow;
}
}
&__section {
align-items: center;
display: grid;
grid-template-columns: repeat(3, 1fr);
justify-content: center;
list-style-type: none;
margin: 1rem;
padding: 0;
}
&__li {
align-items: center;
display: flex;
justify-content: center;
}
}

View file

@ -8,7 +8,7 @@ html {
}
button, input[type="submit"] {
font-family: inherit;
@include globals.reset-button;
}
@font-face {
@ -44,6 +44,16 @@ button, input[type="submit"] {
width: 1px;
}
.button {
&--primary {
@include globals.button-primary;
}
&--secondary {
@include globals.button-secondary;
}
}
.page-grid {
height: 100vh;
width: 100vw;
@ -53,8 +63,12 @@ button, input[type="submit"] {
'sidebar main' 1fr / max-content 1fr;
&__toolbar {
grid-area: toolbar;
align-items: center;
border-bottom: globals.$default-border;
display: grid;
grid-area: toolbar;
grid-template:
'utilities user' 1fr / 1fr max-content;
}
&__sidebar {
@ -71,6 +85,16 @@ button, input[type="submit"] {
}
}
.toolbar__utilities {
align-items: center;
display: flex;
justify-content: flex-start;
}
.toolbar-item {
flex: 0;
}
.section {
padding: 1rem 2rem;
}
@ -109,3 +133,48 @@ button, input[type="submit"] {
padding-left: 1rem;
}
}
.button-menu {
&__toggle-button {
@include globals.button-outline;
align-items: center;
display: flex;
&-icon {
display: flex;
svg path {
stroke: currentColor;
}
}
}
&__popover {
&:popover-open {
@include globals.rounded;
inset: unset;
border: globals.$popover-border;
margin: 0;
margin-top: 0.25rem;
position: fixed;
display: flex;
flex-direction: column;
width: 16rem;
padding: 0;
background: #fff;
box-shadow: globals.$popover-shadow;
// FIXME: This makes button border radius work correctly, but also hides
// the outline that appears when each button is focused, particularly
// when there is only one button present.
overflow: hidden;
}
}
// Palindrome humor! Anyone? No? Okay nvm.
&__unem-nottub {
@include globals.button-clear;
border-radius: 0;
padding: 1rem;
text-align: left;
}
}

View file

@ -1,5 +1,6 @@
@use 'globals';
@use 'sass:color';
@use 'condition-editor';
$table-border-color: #ccc;
@ -172,3 +173,27 @@ $table-border-color: #ccc;
flex: 1;
}
}
.toolbar-item {
padding: 0.5rem;
&__button {
@include globals.button-secondary;
}
}
.toolbar-popover {
&:popover-open {
@include globals.rounded;
inset: unset;
border: globals.$popover-border;
margin: 0;
margin-top: 0.25rem;
position: fixed;
display: block;
width: 24rem;
padding: 0.5rem;
background: #fff;
box-shadow: globals.$popover-shadow;
}
}

View file

@ -0,0 +1,50 @@
<script lang="ts">
import chevron_down from "../assets/heroicons/16/solid/chevron-down.svg?raw";
type Props = {
label: string;
on_click(value: string): void;
options: {
label: string;
value: string;
}[];
};
let { label, on_click, options }: Props = $props();
let toggle_button_element = $state<HTMLButtonElement | undefined>();
let popover_element = $state<HTMLDivElement | undefined>();
function handle_toggle_button_click() {
popover_element?.togglePopover();
}
</script>
<div style:display="inline-block">
<button
bind:this={toggle_button_element}
class="button-menu__toggle-button"
onclick={handle_toggle_button_click}
type="button"
>
<div>{label}</div>
<div class="button-menu__toggle-button-icon" aria-hidden="true">
{@html chevron_down}
</div>
</button>
<div bind:this={popover_element} class="button-menu__popover" popover="auto">
{#each options as option}
<button
class="button-menu__unem-nottub"
onclick={() => {
popover_element?.hidePopover();
toggle_button_element?.focus();
on_click(option.value);
}}
type="button"
>
{option.label}
</button>
{/each}
</div>
</div>

View file

@ -0,0 +1,76 @@
import * as uuid from "uuid";
import { type Encodable, type FieldType } from "./field.svelte.ts";
type Assert<_T extends true> = void;
// This should be a discriminated union type, but TypeScript isn't
// sophisticated enough to discriminate based on the nested field_type's tag,
// causing a huge pain in the ass.
export type EditorState = {
date_value: string;
text_value: string;
time_value: string;
is_null: boolean;
};
export const DEFAULT_EDITOR_STATE: EditorState = {
date_value: "",
text_value: "",
time_value: "",
is_null: false,
};
export function editor_state_from_encodable(value: Encodable): EditorState {
if (value.t === "Text") {
return {
...DEFAULT_EDITOR_STATE,
text_value: value.c ?? "",
is_null: value.c === undefined,
};
} else if (value.t === "Timestamp") {
return {
...DEFAULT_EDITOR_STATE,
date_value: value.c
? `${value.c.getFullYear()}-${
value.c.getMonth() + 1
}-${value.c.getDate()}`
: "",
is_null: value.c === undefined,
time_value: value.c
? `${value.c.getHours()}:${value.c.getMinutes()}`
: "",
};
} else if (value.t === "Uuid") {
return {
...DEFAULT_EDITOR_STATE,
text_value: value.c ?? "",
is_null: value.c === undefined,
};
}
type _ = Assert<typeof value extends never ? true : false>;
throw new Error("this should be unreachable");
}
export function encodable_from_editor_state(
value: EditorState,
field_type: FieldType,
): Encodable | undefined {
if (field_type.t === "Text") {
return { t: "Text", c: value.text_value };
}
if (field_type.t === "Timestamp") {
// FIXME
throw new Error("not yet implemented");
}
if (field_type.t === "Uuid") {
try {
return { t: "Uuid", c: uuid.stringify(uuid.parse(value.text_value)) };
} catch {
// uuid.parse() throws a TypeError if unsuccessful.
return undefined;
}
}
type _ = Assert<typeof field_type extends never ? true : false>;
throw new Error("this should be unreachable");
}

View file

@ -0,0 +1,69 @@
<script lang="ts">
import { type EditorState } from "./editor-state.svelte";
import { type FieldInfo } from "./field.svelte";
type Props = {
assignable_fields?: ReadonlyArray<FieldInfo>;
editor_state: EditorState;
field_info: FieldInfo;
};
let {
assignable_fields = [],
editor_state = $bindable(),
field_info = $bindable(),
}: Props = $props();
let type_selector_menu_button_element = $state<
HTMLButtonElement | undefined
>();
let type_selector_popover_element = $state<HTMLDivElement | undefined>();
function handle_type_selector_menu_button_click() {
type_selector_popover_element?.togglePopover();
}
function handle_type_selector_field_button_click(value: FieldInfo) {
field_info = value;
type_selector_popover_element?.hidePopover();
type_selector_menu_button_element?.focus();
}
</script>
<div class="encodable-editor__container">
{#if assignable_fields.length > 0}
<div class="encodable-editor__type-selector">
<button
bind:this={type_selector_menu_button_element}
class="encodable-editor__type-selector-menu-button"
onclick={handle_type_selector_menu_button_click}
type="button"
>
{field_info.field.field_type.t}
</button>
<div
bind:this={type_selector_popover_element}
class="encodable-editor__type-selector-popover"
popover="auto"
>
{#each assignable_fields as assignable_field_info}
<button
onclick={() =>
handle_type_selector_field_button_click(assignable_field_info)}
type="button"
>
{assignable_field_info.field.field_type.t}
</button>
{/each}
</div>
</div>
{/if}
<div class="encodable-editor__content">
{#if field_info.field.field_type.t === "Text" || field_info.field.field_type.t === "Uuid"}
<input bind:value={editor_state.text_value} type="text" />
{:else if field_info.field.field_type.t === "Timestamp"}
<input bind:value={editor_state.date_value} type="date" />
<input bind:value={editor_state.time_value} type="time" />
{/if}
</div>
</div>

View file

@ -0,0 +1,112 @@
<svelte:options
customElement={{
props: {
identifier_hints: { attribute: "identifier-hints", type: "Array" },
value: { reflect: true, type: "Object" },
},
shadow: "none",
tag: "expression-editor",
}}
/>
<script lang="ts">
import EncodableEditor from "./encodable-editor.svelte";
import ExpressionSelector from "./expression-selector.svelte";
import { type PgExpressionAny } from "./expression.svelte";
import ExpressionEditor from "./expression-editor.webc.svelte";
import {
DEFAULT_EDITOR_STATE,
editor_state_from_encodable,
type EditorState,
encodable_from_editor_state,
} from "./editor-state.svelte";
import { type FieldInfo, type FieldType } from "./field.svelte";
const ASSIGNABLE_FIELD_TYPES: FieldType[] = [
{ t: "Text", c: {} },
{ t: "Timestamp", c: {} },
{ t: "Uuid", c: {} },
];
const ASSIGNABLE_FIELDS: FieldInfo[] = ASSIGNABLE_FIELD_TYPES.map(
(field_type) => ({
field: {
id: "",
label: "",
name: "",
field_type,
width_px: -1,
},
not_null: true,
has_default: false,
}),
);
type Props = {
identifier_hints?: string[];
value?: PgExpressionAny;
};
let { identifier_hints = [], value = $bindable() }: Props = $props();
let editor_state = $state<EditorState>(
value?.t === "Literal"
? editor_state_from_encodable(value.c)
: DEFAULT_EDITOR_STATE,
);
let editor_field_info = $state<FieldInfo>(ASSIGNABLE_FIELDS[0]);
$effect(() => {
if (value?.t === "Literal" && editor_field_info) {
const encodable_value = encodable_from_editor_state(
editor_state,
editor_field_info.field.field_type,
);
if (encodable_value) {
value.c = encodable_value;
}
}
});
function handle_identifier_selector_change(
ev: Event & { currentTarget: HTMLSelectElement },
) {
if (value?.t === "Identifier") {
value.c.parts_raw = [ev.currentTarget.value];
}
}
</script>
<div class="expression-editor__container">
<div class="expression-editor__sidebar">
<ExpressionSelector bind:value />
</div>
{#if value !== undefined}
<div class="expression-editor__main">
<div class="expression-editor__params">
{#if value.t === "Comparison"}
{#if value.c.t === "Infix"}
<ExpressionEditor bind:value={value.c.c.lhs} {identifier_hints} />
<ExpressionEditor bind:value={value.c.c.rhs} {identifier_hints} />
{:else if value.c.t === "IsNull" || value.c.t === "IsNotNull"}
<ExpressionEditor bind:value={value.c.c.lhs} {identifier_hints} />
{/if}
{:else if value.t === "Identifier"}
<select
onchange={handle_identifier_selector_change}
value={value.c.parts_raw[0]}
>
{#each identifier_hints as hint}
<option value={hint}>{hint}</option>
{/each}
</select>
{:else if value.t === "Literal"}
<EncodableEditor
bind:editor_state
bind:field_info={editor_field_info}
assignable_fields={ASSIGNABLE_FIELDS}
/>
{/if}
</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,175 @@
<script lang="ts">
import plus_circle_icon from "../assets/heroicons/20/solid/plus-circle.svg?raw";
import { type PgExpressionAny, expression_icon } from "./expression.svelte";
type Props = {
on_change?(new_value: PgExpressionAny): void;
value?: PgExpressionAny;
};
let { on_change, value = $bindable() }: Props = $props();
let menu_button_element = $state<HTMLButtonElement | undefined>();
let popover_element = $state<HTMLDivElement | undefined>();
const expressions: ReadonlyArray<{
section_label: string;
expressions: ReadonlyArray<PgExpressionAny>;
}> = [
{
section_label: "Comparisons",
expressions: [
{
t: "Comparison",
c: {
t: "Infix",
c: {
lhs: { t: "Identifier", c: { parts_raw: [] } },
operator: "Eq",
rhs: { t: "Literal", c: { t: "Text", c: "" } },
},
},
},
{
t: "Comparison",
c: {
t: "Infix",
c: {
lhs: { t: "Identifier", c: { parts_raw: [] } },
operator: "Neq",
rhs: { t: "Literal", c: { t: "Text", c: "" } },
},
},
},
{
t: "Comparison",
c: {
t: "Infix",
c: {
lhs: { t: "Identifier", c: { parts_raw: [] } },
operator: "Lt",
rhs: { t: "Literal", c: { t: "Text", c: "" } },
},
},
},
{
t: "Comparison",
c: {
t: "Infix",
c: {
lhs: { t: "Identifier", c: { parts_raw: [] } },
operator: "Gt",
rhs: { t: "Literal", c: { t: "Text", c: "" } },
},
},
},
{
t: "Comparison",
c: {
t: "IsNull",
c: {
lhs: { t: "Identifier", c: { parts_raw: [] } },
},
},
},
{
t: "Comparison",
c: {
t: "IsNotNull",
c: {
lhs: { t: "Identifier", c: { parts_raw: [] } },
},
},
},
],
},
{
section_label: "Conjunctions",
expressions: [
{
t: "Comparison",
c: { t: "Infix", c: { operator: "And" } },
},
{
t: "Comparison",
c: { t: "Infix", c: { operator: "Or" } },
},
],
},
{
section_label: "Values",
expressions: [
{
t: "Identifier",
c: { parts_raw: [] },
},
{
t: "Literal",
c: { t: "Text", c: "" },
},
],
},
{
section_label: "Transformations",
expressions: [
{
t: "ToJson",
c: { entries: [] },
},
],
},
];
let iconography_current = $derived(value && expression_icon(value));
function handle_menu_button_click() {
popover_element?.togglePopover();
}
function handle_expression_button_click(expr: PgExpressionAny) {
value = expr;
popover_element?.hidePopover();
menu_button_element?.focus();
on_change?.(value);
}
</script>
<div class="expression-selector__container">
<button
aria-label={`Select expression type (current: ${iconography_current?.label ?? "None"})`}
bind:this={menu_button_element}
class="expression-selector__expression-button"
onclick={handle_menu_button_click}
title={iconography_current?.label}
type="button"
>
{#if value}
{@html iconography_current?.html}
{:else}
{@html plus_circle_icon}
{/if}
</button>
<div
bind:this={popover_element}
class="expression-selector__popover"
popover="auto"
>
{#each expressions as section}
<ul class="expression-selector__section">
{#each section.expressions as expr}
{@const iconography = expression_icon(expr)}
<li class="expression-selector__li">
<button
class="expression-selector__expression-button"
onclick={() => handle_expression_button_click(expr)}
title={iconography.label}
type="button"
>
{@html iconography.html}
</button>
</li>
{/each}
</ul>
{/each}
</div>
</div>

View file

@ -0,0 +1,180 @@
import { z } from "zod";
import code_bracket_square_icon from "../assets/heroicons/20/solid/code-bracket-square.svg?raw";
import cube_icon from "../assets/heroicons/20/solid/cube.svg?raw";
import cube_transparent_icon from "../assets/heroicons/20/solid/cube-transparent.svg?raw";
import hashtag_icon from "../assets/heroicons/20/solid/hashtag.svg?raw";
import variable_icon from "../assets/heroicons/20/solid/variable.svg?raw";
import { encodable_schema } from "./field.svelte.ts";
export const all_expression_types = [
"Comparison",
"Identifier",
"Literal",
"ToJson",
] as const;
// Type checking to ensure that all valid enum tags are included.
type Assert<_T extends true> = void;
type _ = Assert<PgExpressionAny["t"] extends PgExpressionType ? true : false>;
export const expression_type_schema = z.enum(all_expression_types);
export const all_infix_comparison_operators = [
"Eq",
"Neq",
"Gt",
"Lt",
"And",
"Or",
] as const;
const pg_comparison_operator_schema = z.enum(all_infix_comparison_operators);
const pg_infix_expression_schema = z.object({
operator: z.union([pg_comparison_operator_schema]),
get lhs() {
return pg_expression_any_schema.optional();
},
get rhs() {
return pg_expression_any_schema.optional();
},
});
const pg_comparison_expression_infix_schema = z.object({
t: z.literal("Infix"),
c: pg_infix_expression_schema,
});
const pg_is_null_expression_schema = z.object({
get lhs() {
return pg_expression_any_schema.optional();
},
});
const pg_comparison_expression_is_null_schema = z.object({
t: z.literal("IsNull"),
c: pg_is_null_expression_schema,
});
const pg_is_not_null_expression_schema = z.object({
get lhs() {
return pg_expression_any_schema.optional();
},
});
const pg_comparison_expression_is_not_null_schema = z.object({
t: z.literal("IsNotNull"),
c: pg_is_not_null_expression_schema,
});
const pg_comparison_expression_schema = z.union([
pg_comparison_expression_infix_schema,
pg_comparison_expression_is_null_schema,
pg_comparison_expression_is_not_null_schema,
]);
const pg_expression_any_comparison_schema = z.object({
t: z.literal("Comparison"),
c: pg_comparison_expression_schema,
});
const pg_identifier_expression_schema = z.object({
parts_raw: z.array(z.string()),
});
const pg_expression_any_identifier_schema = z.object({
t: z.literal("Identifier"),
c: pg_identifier_expression_schema,
});
const pg_expression_any_literal_schema = z.object({
t: z.literal("Literal"),
c: encodable_schema,
});
const pg_to_json_expression_schema = z.object({
get entries() {
return z.array(z.tuple([z.string(), pg_expression_any_schema.optional()]));
},
});
const pg_expression_any_to_json_expression_schema = z.object({
t: z.literal("ToJson"),
c: pg_to_json_expression_schema,
});
export const pg_expression_any_schema = z.union([
pg_expression_any_comparison_schema,
pg_expression_any_identifier_schema,
pg_expression_any_literal_schema,
pg_expression_any_to_json_expression_schema,
]);
export type PgExpressionAny = z.infer<typeof pg_expression_any_schema>;
export type PgExpressionType = z.infer<typeof expression_type_schema>;
export function expression_human_name(expr_type: PgExpressionType): string {
if (expr_type === "Comparison") {
return "Condition";
}
if (expr_type === "Identifier") {
return "Identifier";
}
if (expr_type === "Literal") {
return "Literal";
}
if (expr_type === "ToJson") {
return "JSON";
}
// Type guard to check for exhaustive matching.
type _ = Assert<typeof expr_type extends never ? true : false>;
throw new Error("this should be unreachable");
}
export function expression_icon(expr: PgExpressionAny): {
html: string;
label: string;
} {
if (expr.t === "Comparison") {
if (expr.c.t === "Infix") {
const op = expr.c.c.operator;
if (op === "And") {
return { html: "&&", label: "And" };
}
if (op === "Eq") {
return { html: "=", label: "Is Equal To" };
}
if (op === "Gt") {
return { html: ">", label: "Is Greater Than" };
}
if (op === "Lt") {
return { html: "<", label: "Is Less Than" };
}
if (op === "Or") {
return { html: "||", label: "Or" };
}
if (op === "Neq") {
return { html: "\u2260", label: "Is Not Equal To" };
}
// Type guard to check for exhaustive matching.
type _ = Assert<typeof op extends never ? true : false>;
throw new Error("this should be unreachable");
} else if (expr.c.t === "IsNull") {
return { html: cube_transparent_icon, label: "Is Null" };
} else if (expr.c.t === "IsNotNull") {
return { html: cube_icon, label: "Is Not Null" };
}
// Type guard to check for exhaustive matching.
type _ = Assert<typeof expr.c extends never ? true : false>;
throw new Error("this should be unreachable");
} else if (expr.t === "Identifier") {
return { html: variable_icon, label: "Dynamic Value" };
} else if (expr.t === "Literal") {
return { html: hashtag_icon, label: "Static Value" };
} else if (expr.t === "ToJson") {
return { html: code_bracket_square_icon, label: "JSON String" };
}
// Type guard to check for exhaustive matching.
type _ = Assert<typeof expr extends never ? true : false>;
throw new Error("this should be unreachable");
}

View file

@ -1,7 +1,20 @@
import { z } from "zod";
type Assert<_T extends true> = void;
// -------- Encodable -------- //
export const all_encodable_types = [
"Text",
"Timestamp",
"Uuid",
] as const;
// Type checking to ensure that all valid enum tags are included.
type _1 = Assert<
Encodable["t"] extends (typeof all_encodable_types)[number] ? true : false
>;
const encodable_text_schema = z.object({
t: z.literal("Text"),
c: z.string().nullish().transform((x) => x ?? undefined),
@ -27,37 +40,58 @@ export type Encodable = z.infer<typeof encodable_schema>;
// -------- FieldType -------- //
const integer_field_type_schema = z.object({
t: z.literal("Integer"),
c: z.unknown(),
});
export const all_field_types = [
"Text",
"Timestamp",
"Uuid",
] as const;
const text_field_type_schema = z.object({
// Type checking to ensure that all valid enum tags are included.
type _2 = Assert<
FieldType["t"] extends (typeof all_field_types)[number] ? true : false
>;
const field_type_text_schema = z.object({
t: z.literal("Text"),
c: z.unknown(),
});
const uuid_field_type_schema = z.object({
export type FieldTypeText = z.infer<typeof field_type_text_schema>;
const field_type_timestamp_schema = z.object({
t: z.literal("Timestamp"),
c: z.unknown(),
});
export type FieldTypeTimestamp = z.infer<typeof field_type_timestamp_schema>;
const field_type_uuid_schema = z.object({
t: z.literal("Uuid"),
c: z.unknown(),
});
export type FieldTypeUuid = z.infer<typeof field_type_uuid_schema>;
export const field_type_schema = z.union([
integer_field_type_schema,
text_field_type_schema,
uuid_field_type_schema,
field_type_text_schema,
field_type_timestamp_schema,
field_type_uuid_schema,
]);
export type FieldType = z.infer<typeof field_type_schema>;
export function get_empty_encodable_for(field_type: FieldType): Encodable {
if (field_type.t === "Timestamp") {
return { t: "Timestamp", c: undefined };
}
if (field_type.t === "Text") {
return { t: "Text", c: undefined };
}
if (field_type.t === "Uuid") {
return { t: "Uuid", c: undefined };
}
throw new Error("Unknown field type");
type _ = Assert<typeof field_type extends never ? true : false>;
throw new Error("this should be unreachable");
}
// -------- Field -------- //

View file

@ -0,0 +1,65 @@
<svelte:options
customElement={{
props: {
identifier_hints: { attribute: "identifier-hints", type: "Array" },
initialValue: { attribute: "initial-value", type: "Object" },
},
shadow: "none",
tag: "filter-menu",
}}
/>
<script lang="ts">
import { type PgExpressionAny } from "./expression.svelte";
import ExpressionEditor from "./expression-editor.webc.svelte";
type Props = {
identifier_hints?: string[];
initialValue?: PgExpressionAny | null;
};
let { identifier_hints = [], initialValue }: Props = $props();
let popover_element = $state<HTMLDivElement | undefined>();
let expr = $state<PgExpressionAny | undefined>(initialValue ?? undefined);
function handle_toolbar_button_click() {
popover_element?.togglePopover();
}
function handle_clear_button_click() {
expr = undefined;
}
</script>
<div class="toolbar__utilities">
<div class="toolbar-item">
<button
class="toolbar-item__button"
onclick={handle_toolbar_button_click}
type="button"
>
Filter
</button>
<div bind:this={popover_element} class="toolbar-popover" popover="auto">
<form action="set-filter" method="post">
<ExpressionEditor bind:value={expr} {identifier_hints} />
<div class="toolbar-popover__form-actions">
<input
name="filter_expression"
type="hidden"
value={JSON.stringify(expr)}
/>
<button
class="button--secondary"
onclick={handle_clear_button_click}
type="button"
>
Clear
</button>
<button class="button--primary" type="submit">Apply</button>
</div>
</form>
</div>
</div>
</div>

View file

@ -4,6 +4,8 @@
import * as uuid from "uuid";
import { z } from "zod";
import icon_cloud_arrow_up from "../assets/heroicons/24/outline/cloud-arrow-up.svg?raw";
import icon_exclamation_circle from "../assets/heroicons/24/outline/exclamation-circle.svg?raw";
import {
type Coords,
type Encodable,
@ -16,8 +18,12 @@
get_empty_encodable_for,
} from "./field.svelte";
import FieldHeader from "./field-header.svelte";
import icon_cloud_arrow_up from "../assets/heroicons/24/outline/cloud-arrow-up.svg?raw";
import icon_exclamation_circle from "../assets/heroicons/24/outline/exclamation-circle.svg?raw";
import EncodableEditor from "./encodable-editor.svelte";
import {
DEFAULT_EDITOR_STATE,
encodable_from_editor_state,
type EditorState,
} from "./editor-state.svelte";
type CommittedChange = {
coords_initial: Coords;
@ -43,7 +49,7 @@
let selections = $state<Selection[]>([]);
let editing = $state(false);
let editor_input_value = $state("");
let editor_state = $state<EditorState>(DEFAULT_EDITOR_STATE);
let committed_changes = $state<CommittedChange[][]>([]);
let reverted_changes = $state<CommittedChange[][]>([]);
let editor_input_element = $state<HTMLInputElement | undefined>();
@ -69,29 +75,6 @@
}
}
function try_parse_editor_value(
field_type: FieldType,
): Encodable | undefined {
if (field_type.t === "Text") {
return {
t: "Text",
c: editor_input_value,
};
}
if (field_type.t === "Uuid") {
try {
return {
t: "Uuid",
c: uuid.stringify(uuid.parse(editor_input_value)),
};
} catch {
// uuid.parse() throws a TypeError if unsuccessful.
return undefined;
}
}
throw new Error("Unknown field type");
}
// -------- Updates and Effects -------- //
function set_selections(arr: Omit<Selection, "original_value">[]) {
@ -118,12 +101,12 @@
cell_data = inserter_rows[sel.coords[0]].data[sel.coords[1]];
}
if (cell_data?.t === "Text" || cell_data?.t === "Uuid") {
editor_input_value = cell_data.c ?? "";
editor_state.text_value = cell_data.c ?? "";
} else {
editor_input_value = "";
editor_state.text_value = "";
}
} else {
editor_input_value = "";
editor_state.text_value = "";
}
}
@ -222,7 +205,8 @@
function try_sync_edit_to_cells() {
if (lazy_data && editing && selections.length === 1) {
const [sel] = selections;
const parsed = try_parse_editor_value(
const parsed = encodable_from_editor_state(
editor_state,
lazy_data.fields[sel.coords[1]].field.field_type,
);
if (parsed !== undefined) {
@ -246,10 +230,13 @@
function try_commit_edit() {
(async function () {
if (lazy_data && editing && selections.length === 1) {
if (lazy_data && editing && editor_state && selections.length === 1) {
const [sel] = selections;
const field = lazy_data.fields[sel.coords[1]];
const parsed = try_parse_editor_value(field.field.field_type);
const parsed = encodable_from_editor_state(
editor_state,
field.field.field_type,
);
if (parsed !== undefined) {
if (sel.region === "main") {
const pkey = JSON.parse(
@ -577,6 +564,13 @@
</form>
</div>
<div class="lens-editor">
{#if selections.length === 1 && editor_state}
<EncodableEditor
bind:editor_state
field_info={lazy_data.fields[selections[0].coords[1]]}
/>
{/if}
<!--
<input
bind:this={editor_input_element}
bind:value={editor_input_value}
@ -588,7 +582,9 @@
onfocus={handle_editor_focus}
oninput={handle_editor_input}
onkeydown={handle_editor_keydown}
tabindex="-1"
/>
-->
</div>
{/if}
</div>

View file

@ -14,8 +14,8 @@ export default defineConfig({
],
output: {
dir: path.fromFileUrl(new URL("../js_dist", import.meta.url)),
entryFileNames: "[name].js",
chunkFileNames: "[name].js",
entryFileNames: "[name].mjs",
chunkFileNames: "[name].mjs",
assetFileNames: "[name].[ext]",
},
},