implement filtering for lenses
This commit is contained in:
parent
07d4987f3c
commit
10dee07a43
30 changed files with 1489 additions and 125 deletions
22
Cargo.lock
generated
22
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
|
|
|
|||
250
interim-models/src/expression.rs
Normal file
250
interim-models/src/expression.rs
Normal 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("))"),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod base;
|
||||
pub mod client;
|
||||
pub mod expression;
|
||||
pub mod field;
|
||||
pub mod lens;
|
||||
pub mod rel_invitation;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
72
interim-server/src/routes/lens_index.rs
Normal file
72
interim-server/src/routes/lens_index.rs
Normal 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())
|
||||
}
|
||||
35
interim-server/src/routes/lens_set_filter.rs
Normal file
35
interim-server/src/routes/lens_set_filter.rs
Normal 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())
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
92
sass/condition-editor.scss
Normal file
92
sass/condition-editor.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
svelte/src/button-menu.svelte
Normal file
50
svelte/src/button-menu.svelte
Normal 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>
|
||||
76
svelte/src/editor-state.svelte.ts
Normal file
76
svelte/src/editor-state.svelte.ts
Normal 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");
|
||||
}
|
||||
69
svelte/src/encodable-editor.svelte
Normal file
69
svelte/src/encodable-editor.svelte
Normal 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>
|
||||
112
svelte/src/expression-editor.webc.svelte
Normal file
112
svelte/src/expression-editor.webc.svelte
Normal 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>
|
||||
175
svelte/src/expression-selector.svelte
Normal file
175
svelte/src/expression-selector.svelte
Normal 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>
|
||||
180
svelte/src/expression.svelte.ts
Normal file
180
svelte/src/expression.svelte.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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 -------- //
|
||||
|
|
|
|||
65
svelte/src/filter-menu.webc.svelte
Normal file
65
svelte/src/filter-menu.webc.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue