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",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"strum",
|
||||||
"thiserror 2.0.12",
|
"thiserror 2.0.12",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
@ -3122,6 +3123,27 @@ version = "0.11.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
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]]
|
[[package]]
|
||||||
name = "subtle"
|
name = "subtle"
|
||||||
version = "2.6.1"
|
version = "2.6.1"
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ derive_builder = "0.20.2"
|
||||||
futures = "0.3.31"
|
futures = "0.3.31"
|
||||||
interim-models = { path = "./interim-models" }
|
interim-models = { path = "./interim-models" }
|
||||||
interim-pgtypes = { path = "./interim-pgtypes" }
|
interim-pgtypes = { path = "./interim-pgtypes" }
|
||||||
interim-server = { path = "./interim-server" }
|
|
||||||
rand = "0.8.5"
|
rand = "0.8.5"
|
||||||
regex = "1.11.1"
|
regex = "1.11.1"
|
||||||
reqwest = { version = "0.12.8", features = ["json"] }
|
reqwest = { version = "0.12.8", features = ["json"] }
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,6 @@ regex = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
|
strum = { version = "0.27.2", features = ["derive"] }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ create table if not exists lenses (
|
||||||
name text not null,
|
name text not null,
|
||||||
base_id uuid not null references bases(id) on delete cascade,
|
base_id uuid not null references bases(id) on delete cascade,
|
||||||
class_oid oid not null,
|
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,
|
order_by jsonb not null default '[]'::jsonb,
|
||||||
display_type lens_display_type not null default 'table'
|
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::InterimUser {} => "cell-interim-user",
|
||||||
FieldType::Text {} => "cell-text",
|
FieldType::Text {} => "cell-text",
|
||||||
FieldType::Timestamp { .. } => "cell-timestamp",
|
FieldType::Timestamp { .. } => "cell-timestamp",
|
||||||
FieldType::Uuid { .. } => "cell-uuid",
|
FieldType::Uuid {} => "cell-uuid",
|
||||||
FieldType::Unknown => "cell-unknown",
|
FieldType::Unknown => "cell-unknown",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn webc_custom_attrs(&self) -> Vec<(String, String)> {
|
pub fn webc_custom_attrs(&self) -> Vec<(String, String)> {
|
||||||
match self.field_type.clone() {
|
vec![]
|
||||||
sqlx::types::Json(FieldType::Uuid {
|
|
||||||
default_with_version: Some(_),
|
|
||||||
}) => vec![("has_default".to_owned(), "true".to_owned())],
|
|
||||||
_ => vec![],
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_value_encodable(&self, row: &PgRow) -> Result<Encodable, ParseError> {
|
pub fn get_value_encodable(&self, row: &PgRow) -> Result<Encodable, ParseError> {
|
||||||
|
|
@ -109,9 +104,7 @@ pub enum FieldType {
|
||||||
Timestamp {
|
Timestamp {
|
||||||
format: String,
|
format: String,
|
||||||
},
|
},
|
||||||
Uuid {
|
Uuid {},
|
||||||
default_with_version: Option<String>,
|
|
||||||
},
|
|
||||||
/// A special variant for when the field type is not specified and cannot be
|
/// 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
|
/// inferred. This isn't represented as an error, because we still want to
|
||||||
/// be able to define display behavior via the .render() method.
|
/// be able to define display behavior via the .render() method.
|
||||||
|
|
@ -125,9 +118,7 @@ impl FieldType {
|
||||||
"timestamp" => Self::Timestamp {
|
"timestamp" => Self::Timestamp {
|
||||||
format: RFC_3339_S.to_owned(),
|
format: RFC_3339_S.to_owned(),
|
||||||
},
|
},
|
||||||
"uuid" => Self::Uuid {
|
"uuid" => Self::Uuid {},
|
||||||
default_with_version: None,
|
|
||||||
},
|
|
||||||
_ => Self::Unknown,
|
_ => Self::Unknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
use derive_builder::Builder;
|
use derive_builder::Builder;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use sqlx::{postgres::types::Oid, query_as};
|
use sqlx::{postgres::types::Oid, query, query_as, types::Json};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::client::AppDbClient;
|
use crate::{client::AppDbClient, expression::PgExpressionAny};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Clone, Debug, Serialize)]
|
||||||
pub struct Lens {
|
pub struct Lens {
|
||||||
|
|
@ -12,6 +12,7 @@ pub struct Lens {
|
||||||
pub base_id: Uuid,
|
pub base_id: Uuid,
|
||||||
pub class_oid: Oid,
|
pub class_oid: Oid,
|
||||||
pub display_type: LensDisplayType,
|
pub display_type: LensDisplayType,
|
||||||
|
pub filter: Json<Option<PgExpressionAny>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Lens {
|
impl Lens {
|
||||||
|
|
@ -19,6 +20,10 @@ impl Lens {
|
||||||
InsertableLensBuilder::default()
|
InsertableLensBuilder::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update() -> LensUpdateBuilder {
|
||||||
|
LensUpdateBuilder::default()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_id(id: Uuid) -> WithIdQuery {
|
pub fn with_id(id: Uuid) -> WithIdQuery {
|
||||||
WithIdQuery { id }
|
WithIdQuery { id }
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +51,8 @@ select
|
||||||
name,
|
name,
|
||||||
base_id,
|
base_id,
|
||||||
class_oid,
|
class_oid,
|
||||||
display_type as "display_type: LensDisplayType"
|
display_type as "display_type: LensDisplayType",
|
||||||
|
filter as "filter: Json<Option<PgExpressionAny>>"
|
||||||
from lenses
|
from lenses
|
||||||
where id = $1
|
where id = $1
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -65,7 +71,8 @@ select
|
||||||
name,
|
name,
|
||||||
base_id,
|
base_id,
|
||||||
class_oid,
|
class_oid,
|
||||||
display_type as "display_type: LensDisplayType"
|
display_type as "display_type: LensDisplayType",
|
||||||
|
filter as "filter: Json<Option<PgExpressionAny>>"
|
||||||
from lenses
|
from lenses
|
||||||
where id = $1
|
where id = $1
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -106,7 +113,8 @@ select
|
||||||
name,
|
name,
|
||||||
base_id,
|
base_id,
|
||||||
class_oid,
|
class_oid,
|
||||||
display_type as "display_type: LensDisplayType"
|
display_type as "display_type: LensDisplayType",
|
||||||
|
filter as "filter: Json<Option<PgExpressionAny>>"
|
||||||
from lenses
|
from lenses
|
||||||
where base_id = $1 and class_oid = $2
|
where base_id = $1 and class_oid = $2
|
||||||
"#,
|
"#,
|
||||||
|
|
@ -145,7 +153,8 @@ returning
|
||||||
name,
|
name,
|
||||||
base_id,
|
base_id,
|
||||||
class_oid,
|
class_oid,
|
||||||
display_type as "display_type: LensDisplayType"
|
display_type as "display_type: LensDisplayType",
|
||||||
|
filter as "filter: Json<Option<PgExpressionAny>>"
|
||||||
"#,
|
"#,
|
||||||
Uuid::now_v7(),
|
Uuid::now_v7(),
|
||||||
self.base_id,
|
self.base_id,
|
||||||
|
|
@ -157,3 +166,25 @@ returning
|
||||||
.await
|
.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 base;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
pub mod expression;
|
||||||
pub mod field;
|
pub mod field;
|
||||||
pub mod lens;
|
pub mod lens;
|
||||||
pub mod rel_invitation;
|
pub mod rel_invitation;
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ pub fn new_router(state: AppState) -> Router<()> {
|
||||||
)
|
)
|
||||||
.route_with_tsr(
|
.route_with_tsr(
|
||||||
"/d/{base_id}/r/{class_oid}/l/{lens_id}/",
|
"/d/{base_id}/r/{class_oid}/l/{lens_id}/",
|
||||||
get(routes::lenses::lens_page),
|
get(routes::lens_index::lens_page_get),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/d/{base_id}/r/{class_oid}/l/{lens_id}/get-data",
|
"/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",
|
"/d/{base_id}/r/{class_oid}/l/{lens_id}/update-value",
|
||||||
post(routes::lenses::update_value_page_post),
|
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(
|
.route(
|
||||||
"/d/{base_id}/r/{class_oid}/l/{lens_id}/insert",
|
"/d/{base_id}/r/{class_oid}/l/{lens_id}/insert",
|
||||||
post(routes::lens_insert::insert_page_post),
|
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())
|
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(
|
pub async fn get_data_page_get(
|
||||||
State(settings): State<Settings>,
|
State(settings): State<Settings>,
|
||||||
State(mut base_pooler): State<BasePooler>,
|
State(mut base_pooler): State<BasePooler>,
|
||||||
|
|
@ -232,8 +188,8 @@ pub async fn get_data_page_get(
|
||||||
};
|
};
|
||||||
|
|
||||||
const FRONTEND_ROW_LIMIT: i64 = 1000;
|
const FRONTEND_ROW_LIMIT: i64 = 1000;
|
||||||
let rows: Vec<PgRow> = query(&format!(
|
let mut sql_raw = format!(
|
||||||
"select {0} from {1}.{2} limit $1",
|
"select {0} from {1}.{2}",
|
||||||
pkey_attrs
|
pkey_attrs
|
||||||
.iter()
|
.iter()
|
||||||
.chain(attrs.iter())
|
.chain(attrs.iter())
|
||||||
|
|
@ -242,10 +198,28 @@ pub async fn get_data_page_get(
|
||||||
.join(", "),
|
.join(", "),
|
||||||
escape_identifier(&rel.regnamespace),
|
escape_identifier(&rel.regnamespace),
|
||||||
escape_identifier(&rel.relname),
|
escape_identifier(&rel.relname),
|
||||||
))
|
);
|
||||||
.bind(FRONTEND_ROW_LIMIT)
|
let rows: Vec<PgRow> = if let Some(filter_expr) = lens.filter.0 {
|
||||||
.fetch_all(base_client.get_conn())
|
let filter_fragment = filter_expr.into_query_fragment();
|
||||||
.await?;
|
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)]
|
#[derive(Serialize)]
|
||||||
struct DataRow {
|
struct DataRow {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
pub mod bases;
|
pub mod bases;
|
||||||
|
pub mod lens_index;
|
||||||
pub mod lens_insert;
|
pub mod lens_insert;
|
||||||
|
pub mod lens_set_filter;
|
||||||
pub mod lenses;
|
pub mod lenses;
|
||||||
pub mod relations;
|
pub mod relations;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
<body>
|
<body>
|
||||||
{% block main %}{% endblock main %}
|
{% block main %}{% endblock main %}
|
||||||
{% if settings.dev != 0 %}
|
{% if settings.dev != 0 %}
|
||||||
<script type="module" src="{{ settings.root_path }}/dev_reloader.mjs"></script>
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { initDevReloader } from "{{ settings.root_path }}/dev_reloader.mjs";
|
import { initDevReloader } from "{{ settings.root_path }}/dev_reloader.mjs";
|
||||||
initDevReloader("ws://127.0.0.1:8080{{ settings.root_path }}/__dev-healthz");
|
initDevReloader("ws://127.0.0.1:8080{{ settings.root_path }}/__dev-healthz");
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/viewer.css">
|
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/viewer.css">
|
||||||
<div class="page-grid">
|
<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">
|
<div class="page-grid__sidebar">
|
||||||
{{ navbar | safe }}
|
{{ navbar | safe }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -11,6 +13,7 @@
|
||||||
<table-viewer root-path="{{ settings.root_path }}"></table-viewer>
|
<table-viewer root-path="{{ settings.root_path }}"></table-viewer>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
</li>
|
</li>
|
||||||
<li class="navbar__menu-item">
|
<li class="navbar__menu-item">
|
||||||
<collapsible-menu class="navbar__collapsible-menu" root-path="{{ root_path }}">
|
<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">
|
<menu slot="content" class="navbar__menu">
|
||||||
{% for lens in rel.lenses %}
|
{% for lens in rel.lenses %}
|
||||||
<li class="navbar__menu-item
|
<li class="navbar__menu-item
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
$button-primary-background: #07f;
|
$button-primary-background: #07f;
|
||||||
$button-primary-color: #fff;
|
$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-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-data: 'Funnel Sans', 'Open Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||||
$font-family-mono: Menlo, 'Courier New', Courier, mono;
|
$font-family-mono: Menlo, 'Courier New', Courier, mono;
|
||||||
|
|
@ -12,6 +13,7 @@ $border-radius-rounded-sm: 0.25rem;
|
||||||
$border-radius-rounded: 0.5rem;
|
$border-radius-rounded: 0.5rem;
|
||||||
$link-color: #069;
|
$link-color: #069;
|
||||||
$notice-color-info: #39d;
|
$notice-color-info: #39d;
|
||||||
|
$hover-lightness-scale-factor: -10%;
|
||||||
|
|
||||||
@mixin reset-button {
|
@mixin reset-button {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
|
@ -39,7 +41,42 @@ $notice-color-info: #39d;
|
||||||
color: $button-primary-color;
|
color: $button-primary-color;
|
||||||
|
|
||||||
&:hover {
|
&: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;
|
@include button-base;
|
||||||
|
|
||||||
&:hover {
|
&: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"] {
|
button, input[type="submit"] {
|
||||||
font-family: inherit;
|
@include globals.reset-button;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
|
|
@ -44,6 +44,16 @@ button, input[type="submit"] {
|
||||||
width: 1px;
|
width: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
&--primary {
|
||||||
|
@include globals.button-primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--secondary {
|
||||||
|
@include globals.button-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.page-grid {
|
.page-grid {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
|
@ -53,8 +63,12 @@ button, input[type="submit"] {
|
||||||
'sidebar main' 1fr / max-content 1fr;
|
'sidebar main' 1fr / max-content 1fr;
|
||||||
|
|
||||||
&__toolbar {
|
&__toolbar {
|
||||||
grid-area: toolbar;
|
align-items: center;
|
||||||
border-bottom: globals.$default-border;
|
border-bottom: globals.$default-border;
|
||||||
|
display: grid;
|
||||||
|
grid-area: toolbar;
|
||||||
|
grid-template:
|
||||||
|
'utilities user' 1fr / 1fr max-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__sidebar {
|
&__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 {
|
.section {
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
@ -109,3 +133,48 @@ button, input[type="submit"] {
|
||||||
padding-left: 1rem;
|
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 'globals';
|
||||||
@use 'sass:color';
|
@use 'sass:color';
|
||||||
|
@use 'condition-editor';
|
||||||
|
|
||||||
$table-border-color: #ccc;
|
$table-border-color: #ccc;
|
||||||
|
|
||||||
|
|
@ -172,3 +173,27 @@ $table-border-color: #ccc;
|
||||||
flex: 1;
|
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";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
type Assert<_T extends true> = void;
|
||||||
|
|
||||||
// -------- Encodable -------- //
|
// -------- 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({
|
const encodable_text_schema = z.object({
|
||||||
t: z.literal("Text"),
|
t: z.literal("Text"),
|
||||||
c: z.string().nullish().transform((x) => x ?? undefined),
|
c: z.string().nullish().transform((x) => x ?? undefined),
|
||||||
|
|
@ -27,37 +40,58 @@ export type Encodable = z.infer<typeof encodable_schema>;
|
||||||
|
|
||||||
// -------- FieldType -------- //
|
// -------- FieldType -------- //
|
||||||
|
|
||||||
const integer_field_type_schema = z.object({
|
export const all_field_types = [
|
||||||
t: z.literal("Integer"),
|
"Text",
|
||||||
c: z.unknown(),
|
"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"),
|
t: z.literal("Text"),
|
||||||
c: z.unknown(),
|
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"),
|
t: z.literal("Uuid"),
|
||||||
c: z.unknown(),
|
c: z.unknown(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type FieldTypeUuid = z.infer<typeof field_type_uuid_schema>;
|
||||||
|
|
||||||
export const field_type_schema = z.union([
|
export const field_type_schema = z.union([
|
||||||
integer_field_type_schema,
|
field_type_text_schema,
|
||||||
text_field_type_schema,
|
field_type_timestamp_schema,
|
||||||
uuid_field_type_schema,
|
field_type_uuid_schema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type FieldType = z.infer<typeof field_type_schema>;
|
export type FieldType = z.infer<typeof field_type_schema>;
|
||||||
|
|
||||||
export function get_empty_encodable_for(field_type: FieldType): Encodable {
|
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") {
|
if (field_type.t === "Text") {
|
||||||
return { t: "Text", c: undefined };
|
return { t: "Text", c: undefined };
|
||||||
}
|
}
|
||||||
if (field_type.t === "Uuid") {
|
if (field_type.t === "Uuid") {
|
||||||
return { t: "Uuid", c: undefined };
|
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 -------- //
|
// -------- 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 * as uuid from "uuid";
|
||||||
import { z } from "zod";
|
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 {
|
import {
|
||||||
type Coords,
|
type Coords,
|
||||||
type Encodable,
|
type Encodable,
|
||||||
|
|
@ -16,8 +18,12 @@
|
||||||
get_empty_encodable_for,
|
get_empty_encodable_for,
|
||||||
} from "./field.svelte";
|
} from "./field.svelte";
|
||||||
import FieldHeader from "./field-header.svelte";
|
import FieldHeader from "./field-header.svelte";
|
||||||
import icon_cloud_arrow_up from "../assets/heroicons/24/outline/cloud-arrow-up.svg?raw";
|
import EncodableEditor from "./encodable-editor.svelte";
|
||||||
import icon_exclamation_circle from "../assets/heroicons/24/outline/exclamation-circle.svg?raw";
|
import {
|
||||||
|
DEFAULT_EDITOR_STATE,
|
||||||
|
encodable_from_editor_state,
|
||||||
|
type EditorState,
|
||||||
|
} from "./editor-state.svelte";
|
||||||
|
|
||||||
type CommittedChange = {
|
type CommittedChange = {
|
||||||
coords_initial: Coords;
|
coords_initial: Coords;
|
||||||
|
|
@ -43,7 +49,7 @@
|
||||||
|
|
||||||
let selections = $state<Selection[]>([]);
|
let selections = $state<Selection[]>([]);
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let editor_input_value = $state("");
|
let editor_state = $state<EditorState>(DEFAULT_EDITOR_STATE);
|
||||||
let committed_changes = $state<CommittedChange[][]>([]);
|
let committed_changes = $state<CommittedChange[][]>([]);
|
||||||
let reverted_changes = $state<CommittedChange[][]>([]);
|
let reverted_changes = $state<CommittedChange[][]>([]);
|
||||||
let editor_input_element = $state<HTMLInputElement | undefined>();
|
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 -------- //
|
// -------- Updates and Effects -------- //
|
||||||
|
|
||||||
function set_selections(arr: Omit<Selection, "original_value">[]) {
|
function set_selections(arr: Omit<Selection, "original_value">[]) {
|
||||||
|
|
@ -118,12 +101,12 @@
|
||||||
cell_data = inserter_rows[sel.coords[0]].data[sel.coords[1]];
|
cell_data = inserter_rows[sel.coords[0]].data[sel.coords[1]];
|
||||||
}
|
}
|
||||||
if (cell_data?.t === "Text" || cell_data?.t === "Uuid") {
|
if (cell_data?.t === "Text" || cell_data?.t === "Uuid") {
|
||||||
editor_input_value = cell_data.c ?? "";
|
editor_state.text_value = cell_data.c ?? "";
|
||||||
} else {
|
} else {
|
||||||
editor_input_value = "";
|
editor_state.text_value = "";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
editor_input_value = "";
|
editor_state.text_value = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,7 +205,8 @@
|
||||||
function try_sync_edit_to_cells() {
|
function try_sync_edit_to_cells() {
|
||||||
if (lazy_data && editing && selections.length === 1) {
|
if (lazy_data && editing && selections.length === 1) {
|
||||||
const [sel] = selections;
|
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,
|
lazy_data.fields[sel.coords[1]].field.field_type,
|
||||||
);
|
);
|
||||||
if (parsed !== undefined) {
|
if (parsed !== undefined) {
|
||||||
|
|
@ -246,10 +230,13 @@
|
||||||
|
|
||||||
function try_commit_edit() {
|
function try_commit_edit() {
|
||||||
(async function () {
|
(async function () {
|
||||||
if (lazy_data && editing && selections.length === 1) {
|
if (lazy_data && editing && editor_state && selections.length === 1) {
|
||||||
const [sel] = selections;
|
const [sel] = selections;
|
||||||
const field = lazy_data.fields[sel.coords[1]];
|
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 (parsed !== undefined) {
|
||||||
if (sel.region === "main") {
|
if (sel.region === "main") {
|
||||||
const pkey = JSON.parse(
|
const pkey = JSON.parse(
|
||||||
|
|
@ -577,6 +564,13 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="lens-editor">
|
<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
|
<input
|
||||||
bind:this={editor_input_element}
|
bind:this={editor_input_element}
|
||||||
bind:value={editor_input_value}
|
bind:value={editor_input_value}
|
||||||
|
|
@ -588,7 +582,9 @@
|
||||||
onfocus={handle_editor_focus}
|
onfocus={handle_editor_focus}
|
||||||
oninput={handle_editor_input}
|
oninput={handle_editor_input}
|
||||||
onkeydown={handle_editor_keydown}
|
onkeydown={handle_editor_keydown}
|
||||||
|
tabindex="-1"
|
||||||
/>
|
/>
|
||||||
|
-->
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ export default defineConfig({
|
||||||
],
|
],
|
||||||
output: {
|
output: {
|
||||||
dir: path.fromFileUrl(new URL("../js_dist", import.meta.url)),
|
dir: path.fromFileUrl(new URL("../js_dist", import.meta.url)),
|
||||||
entryFileNames: "[name].js",
|
entryFileNames: "[name].mjs",
|
||||||
chunkFileNames: "[name].js",
|
chunkFileNames: "[name].mjs",
|
||||||
assetFileNames: "[name].[ext]",
|
assetFileNames: "[name].[ext]",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue