implement basic dropdown presentation ui
This commit is contained in:
parent
5a24454787
commit
cc285aaaaa
13 changed files with 461 additions and 96 deletions
|
|
@ -2,7 +2,11 @@ use chrono::{DateTime, Utc};
|
||||||
use derive_builder::Builder;
|
use derive_builder::Builder;
|
||||||
use interim_pgtypes::pg_attribute::PgAttribute;
|
use interim_pgtypes::pg_attribute::PgAttribute;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::{Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _, postgres::PgRow, query_as};
|
use sqlx::Acquire as _;
|
||||||
|
use sqlx::{
|
||||||
|
Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _, postgres::PgRow, query, query_as,
|
||||||
|
types::Json,
|
||||||
|
};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -80,12 +84,19 @@ impl Field {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct BelongingToPortalQuery {
|
pub struct BelongingToPortalQuery {
|
||||||
portal_id: Uuid,
|
portal_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BelongingToPortalQuery {
|
impl BelongingToPortalQuery {
|
||||||
|
pub fn with_id(self, id: Uuid) -> WithIdQuery {
|
||||||
|
WithIdQuery {
|
||||||
|
id,
|
||||||
|
portal_id: self.portal_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result<Vec<Field>, sqlx::Error> {
|
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result<Vec<Field>, sqlx::Error> {
|
||||||
query_as!(
|
query_as!(
|
||||||
Field,
|
Field,
|
||||||
|
|
@ -94,7 +105,7 @@ select
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
table_label,
|
table_label,
|
||||||
presentation as "presentation: sqlx::types::Json<Presentation>",
|
presentation as "presentation: Json<Presentation>",
|
||||||
table_width_px
|
table_width_px
|
||||||
from fields
|
from fields
|
||||||
where portal_id = $1
|
where portal_id = $1
|
||||||
|
|
@ -106,6 +117,34 @@ where portal_id = $1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct WithIdQuery {
|
||||||
|
id: Uuid,
|
||||||
|
portal_id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WithIdQuery {
|
||||||
|
pub async fn fetch_one(self, app_db: &mut AppDbClient) -> Result<Field, sqlx::Error> {
|
||||||
|
query_as!(
|
||||||
|
Field,
|
||||||
|
r#"
|
||||||
|
select
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
table_label,
|
||||||
|
presentation as "presentation: Json<Presentation>",
|
||||||
|
table_width_px
|
||||||
|
from fields
|
||||||
|
where portal_id = $1 and id = $2
|
||||||
|
"#,
|
||||||
|
self.portal_id,
|
||||||
|
self.id,
|
||||||
|
)
|
||||||
|
.fetch_one(&mut *app_db.conn)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Builder, Clone, Debug)]
|
#[derive(Builder, Clone, Debug)]
|
||||||
pub struct InsertableField {
|
pub struct InsertableField {
|
||||||
portal_id: Uuid,
|
portal_id: Uuid,
|
||||||
|
|
@ -129,13 +168,13 @@ returning
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
table_label,
|
table_label,
|
||||||
presentation as "presentation: sqlx::types::Json<Presentation>",
|
presentation as "presentation: Json<Presentation>",
|
||||||
table_width_px
|
table_width_px
|
||||||
"#,
|
"#,
|
||||||
self.portal_id,
|
self.portal_id,
|
||||||
self.name,
|
self.name,
|
||||||
self.table_label,
|
self.table_label,
|
||||||
sqlx::types::Json::<_>(self.presentation) as sqlx::types::Json<Presentation>,
|
Json::<_>(self.presentation) as Json<Presentation>,
|
||||||
self.table_width_px,
|
self.table_width_px,
|
||||||
)
|
)
|
||||||
.fetch_one(&mut *app_db.conn)
|
.fetch_one(&mut *app_db.conn)
|
||||||
|
|
@ -161,13 +200,45 @@ pub struct Update {
|
||||||
#[builder(default, setter(strip_option))]
|
#[builder(default, setter(strip_option))]
|
||||||
presentation: Option<Presentation>,
|
presentation: Option<Presentation>,
|
||||||
#[builder(default, setter(strip_option))]
|
#[builder(default, setter(strip_option))]
|
||||||
table_width_px: i32,
|
table_width_px: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Update {
|
impl Update {
|
||||||
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> {
|
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> {
|
||||||
// TODO: consolidate
|
// TODO: consolidate statements instead of using transaction
|
||||||
todo!();
|
let mut tx = app_db.get_conn().begin().await?;
|
||||||
|
|
||||||
|
if let Some(table_label) = self.table_label {
|
||||||
|
query!(
|
||||||
|
"update fields set table_label = $1 where id = $2",
|
||||||
|
table_label,
|
||||||
|
self.id
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
if let Some(presentation) = self.presentation {
|
||||||
|
query!(
|
||||||
|
"update fields set presentation = $1 where id = $2",
|
||||||
|
Json::<_>(presentation) as Json<Presentation>,
|
||||||
|
self.id
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
if let Some(table_width_px) = self.table_width_px {
|
||||||
|
query!(
|
||||||
|
"update fields set table_width_px = $1 where id = $2",
|
||||||
|
table_width_px,
|
||||||
|
self.id
|
||||||
|
)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,25 @@ pub const RFC_3339_S: &str = "%Y-%m-%dT%H:%M:%S";
|
||||||
#[derive(Clone, Debug, Deserialize, EnumIter, EnumString, PartialEq, Serialize, strum::Display)]
|
#[derive(Clone, Debug, Deserialize, EnumIter, EnumString, PartialEq, Serialize, strum::Display)]
|
||||||
#[serde(tag = "t", content = "c")]
|
#[serde(tag = "t", content = "c")]
|
||||||
pub enum Presentation {
|
pub enum Presentation {
|
||||||
Dropdown { allow_custom: bool },
|
Dropdown {
|
||||||
Text { input_mode: TextInputMode },
|
allow_custom: bool,
|
||||||
Timestamp { format: String },
|
options: Vec<DropdownOption>,
|
||||||
|
},
|
||||||
|
Text {
|
||||||
|
input_mode: TextInputMode,
|
||||||
|
},
|
||||||
|
Timestamp {
|
||||||
|
format: String,
|
||||||
|
},
|
||||||
Uuid {},
|
Uuid {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||||
|
pub struct DropdownOption {
|
||||||
|
pub color: String,
|
||||||
|
pub value: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl Presentation {
|
impl Presentation {
|
||||||
/// Returns a SQL fragment for the default data type for creating or
|
/// Returns a SQL fragment for the default data type for creating or
|
||||||
/// altering a backing column, such as "integer", or "timestamptz".
|
/// altering a backing column, such as "integer", or "timestamptz".
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ mod extractors;
|
||||||
mod field_info;
|
mod field_info;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod navigator;
|
mod navigator;
|
||||||
|
mod presentation_form;
|
||||||
mod renderable_role_tree;
|
mod renderable_role_tree;
|
||||||
mod routes;
|
mod routes;
|
||||||
mod sessions;
|
mod sessions;
|
||||||
|
|
|
||||||
54
interim-server/src/presentation_form.rs
Normal file
54
interim-server/src/presentation_form.rs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
use std::iter::zip;
|
||||||
|
|
||||||
|
use interim_models::presentation::{DropdownOption, Presentation, RFC_3339_S, TextInputMode};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::errors::AppError;
|
||||||
|
|
||||||
|
/// A subset of an HTTP form that represents a [`Presentation`] in its component
|
||||||
|
/// parts. It may be merged into a larger HTTP form using `serde(flatten)`.
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
pub(crate) struct PresentationForm {
|
||||||
|
pub(crate) presentation_tag: String,
|
||||||
|
pub(crate) dropdown_option_colors: Vec<String>,
|
||||||
|
pub(crate) dropdown_option_values: Vec<String>,
|
||||||
|
pub(crate) text_input_mode: Option<String>,
|
||||||
|
pub(crate) timestamp_format: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<PresentationForm> for Presentation {
|
||||||
|
type Error = AppError;
|
||||||
|
|
||||||
|
fn try_from(form_value: PresentationForm) -> Result<Self, Self::Error> {
|
||||||
|
// Parses the presentation tag into the correct enum variant, but without
|
||||||
|
// meaningful inner value(s). Match arms should all use the
|
||||||
|
// `MyVariant { .. }` pattern to pay attention to only the tag.
|
||||||
|
let presentation_default = Presentation::try_from(form_value.presentation_tag.as_str())?;
|
||||||
|
Ok(match presentation_default {
|
||||||
|
Presentation::Dropdown { .. } => Presentation::Dropdown {
|
||||||
|
allow_custom: false,
|
||||||
|
options: zip(
|
||||||
|
form_value.dropdown_option_colors,
|
||||||
|
form_value.dropdown_option_values,
|
||||||
|
)
|
||||||
|
.map(|(color, value)| DropdownOption { color, value })
|
||||||
|
.collect(),
|
||||||
|
},
|
||||||
|
Presentation::Text { .. } => Presentation::Text {
|
||||||
|
input_mode: form_value
|
||||||
|
.text_input_mode
|
||||||
|
.clone()
|
||||||
|
.map(|value| TextInputMode::try_from(value.as_str()))
|
||||||
|
.transpose()?
|
||||||
|
.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
Presentation::Timestamp { .. } => Presentation::Timestamp {
|
||||||
|
format: form_value
|
||||||
|
.timestamp_format
|
||||||
|
.clone()
|
||||||
|
.unwrap_or(RFC_3339_S.to_owned()),
|
||||||
|
},
|
||||||
|
Presentation::Uuid { .. } => Presentation::Uuid {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ use axum_extra::extract::Form;
|
||||||
use interim_models::{
|
use interim_models::{
|
||||||
field::Field,
|
field::Field,
|
||||||
portal::Portal,
|
portal::Portal,
|
||||||
presentation::{Presentation, RFC_3339_S, TextInputMode},
|
presentation::Presentation,
|
||||||
workspace::Workspace,
|
workspace::Workspace,
|
||||||
workspace_user_perm::{self, WorkspaceUserPerm},
|
workspace_user_perm::{self, WorkspaceUserPerm},
|
||||||
};
|
};
|
||||||
|
|
@ -22,6 +22,7 @@ use crate::{
|
||||||
app::{App, AppDbConn},
|
app::{App, AppDbConn},
|
||||||
errors::{AppError, forbidden},
|
errors::{AppError, forbidden},
|
||||||
navigator::{Navigator, NavigatorPage},
|
navigator::{Navigator, NavigatorPage},
|
||||||
|
presentation_form::PresentationForm,
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||||
};
|
};
|
||||||
|
|
@ -37,11 +38,10 @@ pub(super) struct PathParams {
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct FormBody {
|
pub(super) struct FormBody {
|
||||||
name: String,
|
name: String,
|
||||||
label: String,
|
table_label: String,
|
||||||
presentation_tag: String,
|
|
||||||
dropdown_allow_custom: Option<bool>,
|
#[serde(flatten)]
|
||||||
text_input_mode: Option<String>,
|
presentation_form: PresentationForm,
|
||||||
timestamp_format: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HTTP POST handler for adding a [`Field`] to a [`Portal`]. If the field name
|
/// HTTP POST handler for adding a [`Field`] to a [`Portal`]. If the field name
|
||||||
|
|
@ -88,7 +88,7 @@ pub(super) async fn post(
|
||||||
.fetch_one(&mut workspace_client)
|
.fetch_one(&mut workspace_client)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let presentation = try_presentation_from_form(&form)?;
|
let presentation = Presentation::try_from(form.presentation_form)?;
|
||||||
|
|
||||||
query(&format!(
|
query(&format!(
|
||||||
"alter table {ident} add column if not exists {col} {typ}",
|
"alter table {ident} add column if not exists {col} {typ}",
|
||||||
|
|
@ -102,10 +102,10 @@ pub(super) async fn post(
|
||||||
Field::insert()
|
Field::insert()
|
||||||
.portal_id(portal.id)
|
.portal_id(portal.id)
|
||||||
.name(form.name)
|
.name(form.name)
|
||||||
.table_label(if form.label.is_empty() {
|
.table_label(if form.table_label.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(form.label)
|
Some(form.table_label)
|
||||||
})
|
})
|
||||||
.presentation(presentation)
|
.presentation(presentation)
|
||||||
.build()?
|
.build()?
|
||||||
|
|
@ -120,28 +120,3 @@ pub(super) async fn post(
|
||||||
.build()?
|
.build()?
|
||||||
.redirect_to())
|
.redirect_to())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn try_presentation_from_form(form: &FormBody) -> Result<Presentation, AppError> {
|
|
||||||
// Parses the presentation tag into the correct enum variant, but without
|
|
||||||
// meaningful inner value(s). Match arms should all use the
|
|
||||||
// `MyVariant { .. }` pattern to pay attention to only the tag.
|
|
||||||
let presentation_default = Presentation::try_from(form.presentation_tag.as_str())?;
|
|
||||||
Ok(match presentation_default {
|
|
||||||
Presentation::Dropdown { .. } => Presentation::Dropdown { allow_custom: true },
|
|
||||||
Presentation::Text { .. } => Presentation::Text {
|
|
||||||
input_mode: form
|
|
||||||
.text_input_mode
|
|
||||||
.clone()
|
|
||||||
.map(|value| TextInputMode::try_from(value.as_str()))
|
|
||||||
.transpose()?
|
|
||||||
.unwrap_or_default(),
|
|
||||||
},
|
|
||||||
Presentation::Timestamp { .. } => Presentation::Timestamp {
|
|
||||||
format: form
|
|
||||||
.timestamp_format
|
|
||||||
.clone()
|
|
||||||
.unwrap_or(RFC_3339_S.to_owned()),
|
|
||||||
},
|
|
||||||
Presentation::Uuid { .. } => Presentation::Uuid {},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,21 @@
|
||||||
use axum::{
|
use axum::{debug_handler, extract::Path, response::Response};
|
||||||
debug_handler,
|
|
||||||
extract::{Path, State},
|
|
||||||
response::Response,
|
|
||||||
};
|
|
||||||
// [`axum_extra`]'s form extractor is preferred:
|
|
||||||
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
|
|
||||||
use axum_extra::extract::Form;
|
|
||||||
use interim_models::{
|
use interim_models::{
|
||||||
field::Field,
|
field::Field,
|
||||||
portal::Portal,
|
presentation::Presentation,
|
||||||
presentation::{Presentation, RFC_3339_S, TextInputMode},
|
|
||||||
workspace::Workspace,
|
|
||||||
workspace_user_perm::{self, WorkspaceUserPerm},
|
workspace_user_perm::{self, WorkspaceUserPerm},
|
||||||
};
|
};
|
||||||
use interim_pgtypes::{escape_identifier, pg_class::PgClass};
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sqlx::{postgres::types::Oid, query};
|
use sqlx::postgres::types::Oid;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
app::{App, AppDbConn},
|
app::{App, AppDbConn},
|
||||||
errors::{AppError, forbidden},
|
errors::{AppError, forbidden},
|
||||||
|
extractors::ValidatedForm,
|
||||||
navigator::{Navigator, NavigatorPage},
|
navigator::{Navigator, NavigatorPage},
|
||||||
|
presentation_form::PresentationForm,
|
||||||
user::CurrentUser,
|
user::CurrentUser,
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -33,14 +25,13 @@ pub(super) struct PathParams {
|
||||||
workspace_id: Uuid,
|
workspace_id: Uuid,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Validate)]
|
||||||
pub(super) struct FormBody {
|
pub(super) struct FormBody {
|
||||||
field_id: Uuid,
|
field_id: Uuid,
|
||||||
label: String,
|
table_label: String,
|
||||||
presentation_tag: String,
|
|
||||||
dropdown_allow_custom: Option<bool>,
|
#[serde(flatten)]
|
||||||
text_input_mode: Option<String>,
|
presentation_form: PresentationForm,
|
||||||
timestamp_format: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HTTP POST handler for updating an existing [`Field`].
|
/// HTTP POST handler for updating an existing [`Field`].
|
||||||
|
|
@ -49,7 +40,6 @@ pub(super) struct FormBody {
|
||||||
/// [`PathParams`].
|
/// [`PathParams`].
|
||||||
#[debug_handler(state = App)]
|
#[debug_handler(state = App)]
|
||||||
pub(super) async fn post(
|
pub(super) async fn post(
|
||||||
State(mut workspace_pooler): State<WorkspacePooler>,
|
|
||||||
AppDbConn(mut app_db): AppDbConn,
|
AppDbConn(mut app_db): AppDbConn,
|
||||||
CurrentUser(user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
navigator: Navigator,
|
navigator: Navigator,
|
||||||
|
|
@ -58,8 +48,14 @@ pub(super) async fn post(
|
||||||
rel_oid,
|
rel_oid,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
}): Path<PathParams>,
|
}): Path<PathParams>,
|
||||||
Form(form): Form<FormBody>,
|
ValidatedForm(FormBody {
|
||||||
|
field_id,
|
||||||
|
table_label,
|
||||||
|
presentation_form,
|
||||||
|
}): ValidatedForm<FormBody>,
|
||||||
) -> Result<Response, AppError> {
|
) -> Result<Response, AppError> {
|
||||||
|
// FIXME CSRF
|
||||||
|
|
||||||
// Check workspace authorization.
|
// Check workspace authorization.
|
||||||
let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id)
|
let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id)
|
||||||
.fetch_all(&mut app_db)
|
.fetch_all(&mut app_db)
|
||||||
|
|
@ -72,14 +68,29 @@ pub(super) async fn post(
|
||||||
// FIXME ensure workspace corresponds to rel/portal, and that user has
|
// FIXME ensure workspace corresponds to rel/portal, and that user has
|
||||||
// permission to access/alter both as needed.
|
// permission to access/alter both as needed.
|
||||||
|
|
||||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
// Ensure field exists and belongs to portal.
|
||||||
let workspace = Workspace::with_id(portal.workspace_id)
|
Field::belonging_to_portal(portal_id)
|
||||||
|
.with_id(field_id)
|
||||||
.fetch_one(&mut app_db)
|
.fetch_one(&mut app_db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut workspace_client = workspace_pooler
|
Field::update()
|
||||||
.acquire_for(workspace.id, RoleAssignment::User(user.id))
|
.id(field_id)
|
||||||
|
.table_label(if table_label.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(table_label)
|
||||||
|
})
|
||||||
|
.presentation(Presentation::try_from(presentation_form)?)
|
||||||
|
.build()?
|
||||||
|
.execute(&mut app_db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
todo!();
|
Ok(navigator
|
||||||
|
.portal_page()
|
||||||
|
.workspace_id(workspace_id)
|
||||||
|
.rel_oid(Oid(rel_oid))
|
||||||
|
.portal_id(portal_id)
|
||||||
|
.build()?
|
||||||
|
.redirect_to())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ button, input[type="submit"] {
|
||||||
src: url("../funnel_sans/funnel_sans_variable.ttf");
|
src: url("../funnel_sans/funnel_sans_variable.ttf");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@view-transitions {
|
||||||
|
navigation: auto;
|
||||||
|
}
|
||||||
|
|
||||||
// https://css-tricks.com/inclusively-hidden/
|
// https://css-tricks.com/inclusively-hidden/
|
||||||
.sr-only:not(:focus):not(:active) {
|
.sr-only:not(:focus):not(:active) {
|
||||||
clip: rect(0 0 0 0);
|
clip: rect(0 0 0 0);
|
||||||
|
|
|
||||||
|
|
@ -84,11 +84,16 @@ $table-border-color: #ccc;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-family: globals.$font-family-data;
|
font-family: globals.$font-family-data;
|
||||||
|
|
||||||
|
&--dropdown {
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
&--text {
|
&--text {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
padding: 0 0.5rem;
|
padding: 0 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--uuid {
|
&--uuid {
|
||||||
|
|
@ -211,6 +216,59 @@ $table-border-color: #ccc;
|
||||||
height: 6rem;
|
height: 6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dropdown-option-badge {
|
||||||
|
background: #ccc;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
|
||||||
|
&:not(:has(button)) {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--red {
|
||||||
|
background: #f99;
|
||||||
|
color: color.scale(#f99, $lightness: -66%, $space: oklch);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--orange {
|
||||||
|
background: color.adjust(#f99, $hue: 50deg, $space: oklch);
|
||||||
|
color: color.scale(color.adjust(#f99, $hue: 50deg, $space: oklch), $lightness: -66%, $space: oklch);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--yellow {
|
||||||
|
background: color.adjust(#f99, $hue: 100deg, $space: oklch);
|
||||||
|
color: color.scale(color.adjust(#f99, $hue: 100deg, $space: oklch), $lightness: -66%, $space: oklch);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--green {
|
||||||
|
background: color.adjust(#f99, $hue: 150deg, $space: oklch);
|
||||||
|
color: color.scale(color.adjust(#f99, $hue: 150deg, $space: oklch), $lightness: -66%, $space: oklch);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--blue {
|
||||||
|
background: color.adjust(#f99, $hue: 200deg, $space: oklch);
|
||||||
|
color: color.scale(color.adjust(#f99, $hue: 200deg, $space: oklch), $lightness: -66%, $space: oklch);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--indigo {
|
||||||
|
background: color.adjust(#f99, $hue: 250deg, $space: oklch);
|
||||||
|
color: color.scale(color.adjust(#f99, $hue: 250deg, $space: oklch), $lightness: -66%, $space: oklch);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--violet {
|
||||||
|
background: color.adjust(#f99, $hue: 300deg, $space: oklch);
|
||||||
|
color: color.scale(color.adjust(#f99, $hue: 300deg, $space: oklch), $lightness: -66%, $space: oklch);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
@include globals.button-clear;
|
||||||
|
|
||||||
|
color: inherit;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.datum-editor {
|
.datum-editor {
|
||||||
&__container {
|
&__container {
|
||||||
border-left: solid 4px transparent;
|
border-left: solid 4px transparent;
|
||||||
|
|
@ -250,6 +308,21 @@ $table-border-color: #ccc;
|
||||||
font-family: globals.$font-family-data;
|
font-family: globals.$font-family-data;
|
||||||
padding: 0.75rem 0.5rem;
|
padding: 0.75rem 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__helpers {
|
||||||
|
grid-area: helpers;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__dropdown-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-item {
|
.toolbar-item {
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@
|
||||||
editor_state,
|
editor_state,
|
||||||
field_info.field.presentation,
|
field_info.field.presentation,
|
||||||
);
|
);
|
||||||
|
console.log(value);
|
||||||
on_change?.(value);
|
on_change?.(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,15 +125,20 @@
|
||||||
{@html icon_cube}
|
{@html icon_cube}
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if field_info.field.presentation.t === "Text" || field_info.field.presentation.t === "Uuid"}
|
{#if field_info.field.presentation.t === "Dropdown" || field_info.field.presentation.t === "Text" || field_info.field.presentation.t === "Uuid"}
|
||||||
<input
|
<input
|
||||||
bind:this={text_input_element}
|
bind:this={text_input_element}
|
||||||
value={editor_state.text_value}
|
value={editor_state.text_value}
|
||||||
oninput={({ currentTarget: { value } }) => {
|
oninput={({ currentTarget }) => {
|
||||||
if (editor_state) {
|
if (!editor_state) {
|
||||||
editor_state.text_value = value;
|
console.warn("text input oninput() preconditions not met");
|
||||||
handle_input();
|
return;
|
||||||
}
|
}
|
||||||
|
editor_state.text_value = currentTarget.value;
|
||||||
|
if (currentTarget.value !== "") {
|
||||||
|
editor_state.is_null = false;
|
||||||
|
}
|
||||||
|
handle_input();
|
||||||
}}
|
}}
|
||||||
class="datum-editor__text-input"
|
class="datum-editor__text-input"
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -141,6 +147,42 @@
|
||||||
<input value={editor_state.date_value} type="date" />
|
<input value={editor_state.date_value} type="date" />
|
||||||
<input value={editor_state.time_value} type="time" />
|
<input value={editor_state.time_value} type="time" />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="datum-editor__helpers"></div>
|
<div class="datum-editor__helpers" tabindex="-1">
|
||||||
|
{#if field_info.field.presentation.t === "Dropdown"}
|
||||||
|
<!-- TODO: This is an awkward way to implement a keyboard-navigable listbox. -->
|
||||||
|
<menu class="datum-editor__dropdown-options">
|
||||||
|
{#each field_info.field.presentation.c.options as dropdown_option}
|
||||||
|
<!-- FIXME: validate or escape dropdown_option.color -->
|
||||||
|
<li
|
||||||
|
class={[
|
||||||
|
"dropdown-option-badge",
|
||||||
|
`dropdown-option-badge--${dropdown_option.color.toLocaleLowerCase("en-US")}`,
|
||||||
|
]}
|
||||||
|
role="option"
|
||||||
|
aria-selected={dropdown_option.value === value?.c}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="datum-editor__dropdown-option-button"
|
||||||
|
onclick={() => {
|
||||||
|
if (!editor_state || !text_input_element) {
|
||||||
|
console.warn(
|
||||||
|
"dropdown option onclick() preconditions not met",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editor_state.text_value = dropdown_option.value;
|
||||||
|
editor_state.is_null = false;
|
||||||
|
text_input_element.focus();
|
||||||
|
handle_input();
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{dropdown_option.value}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</menu>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,21 @@ export function datum_from_editor_state(
|
||||||
presentation: Presentation,
|
presentation: Presentation,
|
||||||
): Datum | undefined {
|
): Datum | undefined {
|
||||||
if (presentation.t === "Dropdown") {
|
if (presentation.t === "Dropdown") {
|
||||||
return { t: "Text", c: value.is_null ? undefined : value.text_value };
|
if (value.is_null) {
|
||||||
|
return { t: "Text", c: undefined };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!presentation.c.allow_custom &&
|
||||||
|
presentation.c.options.every((option) =>
|
||||||
|
option.value !== value.text_value
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
t: "Text",
|
||||||
|
c: value.text_value,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (presentation.t === "Text") {
|
if (presentation.t === "Text") {
|
||||||
return { t: "Text", c: value.is_null ? undefined : value.text_value };
|
return { t: "Text", c: value.is_null ? undefined : value.text_value };
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,23 @@ field. This is typically rendered within a popover component, and within an HTML
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import icon_trash from "../assets/heroicons/20/solid/trash.svg?raw";
|
||||||
import {
|
import {
|
||||||
type Presentation,
|
type Presentation,
|
||||||
all_presentation_tags,
|
all_presentation_tags,
|
||||||
all_text_input_modes,
|
all_text_input_modes,
|
||||||
} from "./presentation.svelte";
|
} from "./presentation.svelte";
|
||||||
|
|
||||||
|
const COLORS: string[] = [
|
||||||
|
"Red",
|
||||||
|
"Orange",
|
||||||
|
"Yellow",
|
||||||
|
"Green",
|
||||||
|
"Blue",
|
||||||
|
"Indigo",
|
||||||
|
"Violet",
|
||||||
|
] as const;
|
||||||
|
|
||||||
type Assert<_T extends true> = void;
|
type Assert<_T extends true> = void;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -47,7 +58,7 @@ field. This is typically rendered within a popover component, and within an HTML
|
||||||
tag: (typeof all_presentation_tags)[number],
|
tag: (typeof all_presentation_tags)[number],
|
||||||
): Presentation {
|
): Presentation {
|
||||||
if (tag === "Dropdown") {
|
if (tag === "Dropdown") {
|
||||||
return { t: "Dropdown", c: { allow_custom: true } };
|
return { t: "Dropdown", c: { allow_custom: false, options: [] } };
|
||||||
}
|
}
|
||||||
if (tag === "Text") {
|
if (tag === "Text") {
|
||||||
return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } };
|
return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } };
|
||||||
|
|
@ -103,7 +114,7 @@ field. This is typically rendered within a popover component, and within an HTML
|
||||||
<input
|
<input
|
||||||
bind:value={label_value}
|
bind:value={label_value}
|
||||||
class="form-section__input form-section__input--text"
|
class="form-section__input form-section__input--text"
|
||||||
name="label"
|
name="table_label"
|
||||||
oninput={on_name_input}
|
oninput={on_name_input}
|
||||||
type="text"
|
type="text"
|
||||||
/>
|
/>
|
||||||
|
|
@ -123,7 +134,65 @@ field. This is typically rendered within a popover component, and within an HTML
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{#if presentation?.t === "Text"}
|
{#if presentation?.t === "Dropdown"}
|
||||||
|
<div class="form-section">
|
||||||
|
<ul class="field-details__dropdown-options-list">
|
||||||
|
{#each presentation.c.options as option, i}
|
||||||
|
<li class="field-details__dropdown-option">
|
||||||
|
<select
|
||||||
|
class="field-details__dropdown-option-color"
|
||||||
|
name="dropdown_option_colors"
|
||||||
|
>
|
||||||
|
{#each COLORS as color}
|
||||||
|
<option value={color}>{color}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="dropdown_option_values"
|
||||||
|
class="form-section__input--text"
|
||||||
|
value={option.value}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="button--clear"
|
||||||
|
onclick={() => {
|
||||||
|
if (presentation?.t !== "Dropdown") {
|
||||||
|
console.warn(
|
||||||
|
"remove dropdown option onclick() preconditions not met",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
presentation.c.options = presentation.c.options.filter(
|
||||||
|
(_, j) => j !== i,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{@html icon_trash}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
class="button--secondary"
|
||||||
|
onclick={() => {
|
||||||
|
if (presentation?.t !== "Dropdown") {
|
||||||
|
console.warn(
|
||||||
|
"remove dropdown option onclick() preconditions not met",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
presentation.c.options = [
|
||||||
|
...presentation.c.options,
|
||||||
|
{ color: COLORS[0], value: "" },
|
||||||
|
];
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Add option
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if presentation?.t === "Text"}
|
||||||
<label class="form-section">
|
<label class="form-section">
|
||||||
<div class="form-section__label">Input Mode</div>
|
<div class="form-section__label">Input Mode</div>
|
||||||
<select
|
<select
|
||||||
|
|
|
||||||
|
|
@ -49,10 +49,16 @@ const text_input_mode_schema = z.union([
|
||||||
|
|
||||||
export type TextInputMode = z.infer<typeof text_input_mode_schema>;
|
export type TextInputMode = z.infer<typeof text_input_mode_schema>;
|
||||||
|
|
||||||
|
const dropdown_option_schema = z.object({
|
||||||
|
color: z.string(),
|
||||||
|
value: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
const presentation_dropdown_schema = z.object({
|
const presentation_dropdown_schema = z.object({
|
||||||
t: z.literal("Dropdown"),
|
t: z.literal("Dropdown"),
|
||||||
c: z.object({
|
c: z.object({
|
||||||
allow_custom: z.boolean(),
|
allow_custom: z.boolean(),
|
||||||
|
options: z.array(dropdown_option_schema),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -218,17 +218,20 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function try_sync_edit_to_cells() {
|
function try_sync_edit_to_cells() {
|
||||||
if (lazy_data && selections.length === 1) {
|
if (!lazy_data || selections.length !== 1) {
|
||||||
const [sel] = selections;
|
console.warn("preconditions for try_sync_edit_to_cells() not met");
|
||||||
if (editor_value !== undefined) {
|
return;
|
||||||
if (sel.region === "main") {
|
}
|
||||||
lazy_data.rows[sel.coords[0]].data[sel.coords[1]] = editor_value;
|
if (editor_value === undefined) {
|
||||||
} else if (sel.region === "inserter") {
|
return;
|
||||||
inserter_rows[sel.coords[0]].data[sel.coords[1]] = editor_value;
|
}
|
||||||
} else {
|
const [sel] = selections;
|
||||||
throw new Error("Unknown region");
|
if (sel.region === "main") {
|
||||||
}
|
lazy_data.rows[sel.coords[0]].data[sel.coords[1]] = editor_value;
|
||||||
}
|
} else if (sel.region === "inserter") {
|
||||||
|
inserter_rows[sel.coords[0]].data[sel.coords[1]] = editor_value;
|
||||||
|
} else {
|
||||||
|
throw new Error("Unknown region");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,6 +239,7 @@
|
||||||
// Copy `editor_value` so that it can be used intuitively within closures.
|
// Copy `editor_value` so that it can be used intuitively within closures.
|
||||||
const editor_value_scoped = editor_value;
|
const editor_value_scoped = editor_value;
|
||||||
if (editor_value_scoped === undefined) {
|
if (editor_value_scoped === undefined) {
|
||||||
|
console.debug("not a valid cell value");
|
||||||
cancel_edit();
|
cancel_edit();
|
||||||
} else {
|
} else {
|
||||||
if (selections.length > 0) {
|
if (selections.length > 0) {
|
||||||
|
|
@ -251,6 +255,7 @@
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
console.debug("Commit queue:", deltas.commit_queued);
|
||||||
selections = selections.map((sel) => ({
|
selections = selections.map((sel) => ({
|
||||||
...sel,
|
...sel,
|
||||||
original_value: editor_value_scoped,
|
original_value: editor_value_scoped,
|
||||||
|
|
@ -449,7 +454,34 @@
|
||||||
class="lens-cell__container"
|
class="lens-cell__container"
|
||||||
class:lens-cell__container--selected={cell_selected}
|
class:lens-cell__container--selected={cell_selected}
|
||||||
>
|
>
|
||||||
{#if cell_data.t === "Text"}
|
{#if field.field.presentation.t === "Dropdown"}
|
||||||
|
{#if cell_data.t === "Text"}
|
||||||
|
<div
|
||||||
|
class="lens-cell__content lens-cell__content--dropdown"
|
||||||
|
class:lens-cell__content--null={cell_data.c === undefined}
|
||||||
|
>
|
||||||
|
{#if cell_data.c === undefined}
|
||||||
|
{@html null_value_html}
|
||||||
|
{:else}
|
||||||
|
<!-- FIXME: validate or escape dropdown_option.color -->
|
||||||
|
<div
|
||||||
|
class={[
|
||||||
|
"dropdown-option-badge",
|
||||||
|
`dropdown-option-badge--${
|
||||||
|
field.field.presentation.c.options
|
||||||
|
.find((option) => option.value === cell_data.c)
|
||||||
|
?.color.toLocaleLowerCase("en-US") ?? "grey"
|
||||||
|
}`,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{cell_data.c}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
UNKNOWN
|
||||||
|
{/if}
|
||||||
|
{:else if field.field.presentation.t === "Text"}
|
||||||
<div
|
<div
|
||||||
class="lens-cell__content lens-cell__content--text"
|
class="lens-cell__content lens-cell__content--text"
|
||||||
class:lens-cell__content--null={cell_data.c === undefined}
|
class:lens-cell__content--null={cell_data.c === undefined}
|
||||||
|
|
@ -460,7 +492,7 @@
|
||||||
{cell_data.c}
|
{cell_data.c}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if cell_data.t === "Uuid"}
|
{:else if field.field.presentation.t === "Uuid"}
|
||||||
<div
|
<div
|
||||||
class="lens-cell__content lens-cell__content--uuid"
|
class="lens-cell__content lens-cell__content--uuid"
|
||||||
class:lens-cell__content--null={cell_data.c === undefined}
|
class:lens-cell__content--null={cell_data.c === undefined}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue