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 interim_pgtypes::pg_attribute::PgAttribute;
|
||||
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 uuid::Uuid;
|
||||
|
||||
|
|
@ -80,12 +84,19 @@ impl Field {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BelongingToPortalQuery {
|
||||
portal_id: Uuid,
|
||||
}
|
||||
|
||||
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> {
|
||||
query_as!(
|
||||
Field,
|
||||
|
|
@ -94,7 +105,7 @@ select
|
|||
id,
|
||||
name,
|
||||
table_label,
|
||||
presentation as "presentation: sqlx::types::Json<Presentation>",
|
||||
presentation as "presentation: Json<Presentation>",
|
||||
table_width_px
|
||||
from fields
|
||||
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)]
|
||||
pub struct InsertableField {
|
||||
portal_id: Uuid,
|
||||
|
|
@ -129,13 +168,13 @@ returning
|
|||
id,
|
||||
name,
|
||||
table_label,
|
||||
presentation as "presentation: sqlx::types::Json<Presentation>",
|
||||
presentation as "presentation: Json<Presentation>",
|
||||
table_width_px
|
||||
"#,
|
||||
self.portal_id,
|
||||
self.name,
|
||||
self.table_label,
|
||||
sqlx::types::Json::<_>(self.presentation) as sqlx::types::Json<Presentation>,
|
||||
Json::<_>(self.presentation) as Json<Presentation>,
|
||||
self.table_width_px,
|
||||
)
|
||||
.fetch_one(&mut *app_db.conn)
|
||||
|
|
@ -161,13 +200,45 @@ pub struct Update {
|
|||
#[builder(default, setter(strip_option))]
|
||||
presentation: Option<Presentation>,
|
||||
#[builder(default, setter(strip_option))]
|
||||
table_width_px: i32,
|
||||
table_width_px: Option<i32>,
|
||||
}
|
||||
|
||||
impl Update {
|
||||
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> {
|
||||
// TODO: consolidate
|
||||
todo!();
|
||||
// TODO: consolidate statements instead of using transaction
|
||||
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)]
|
||||
#[serde(tag = "t", content = "c")]
|
||||
pub enum Presentation {
|
||||
Dropdown { allow_custom: bool },
|
||||
Text { input_mode: TextInputMode },
|
||||
Timestamp { format: String },
|
||||
Dropdown {
|
||||
allow_custom: bool,
|
||||
options: Vec<DropdownOption>,
|
||||
},
|
||||
Text {
|
||||
input_mode: TextInputMode,
|
||||
},
|
||||
Timestamp {
|
||||
format: String,
|
||||
},
|
||||
Uuid {},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
|
||||
pub struct DropdownOption {
|
||||
pub color: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl Presentation {
|
||||
/// Returns a SQL fragment for the default data type for creating or
|
||||
/// altering a backing column, such as "integer", or "timestamptz".
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ mod extractors;
|
|||
mod field_info;
|
||||
mod middleware;
|
||||
mod navigator;
|
||||
mod presentation_form;
|
||||
mod renderable_role_tree;
|
||||
mod routes;
|
||||
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::{
|
||||
field::Field,
|
||||
portal::Portal,
|
||||
presentation::{Presentation, RFC_3339_S, TextInputMode},
|
||||
presentation::Presentation,
|
||||
workspace::Workspace,
|
||||
workspace_user_perm::{self, WorkspaceUserPerm},
|
||||
};
|
||||
|
|
@ -22,6 +22,7 @@ use crate::{
|
|||
app::{App, AppDbConn},
|
||||
errors::{AppError, forbidden},
|
||||
navigator::{Navigator, NavigatorPage},
|
||||
presentation_form::PresentationForm,
|
||||
user::CurrentUser,
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
};
|
||||
|
|
@ -37,11 +38,10 @@ pub(super) struct PathParams {
|
|||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct FormBody {
|
||||
name: String,
|
||||
label: String,
|
||||
presentation_tag: String,
|
||||
dropdown_allow_custom: Option<bool>,
|
||||
text_input_mode: Option<String>,
|
||||
timestamp_format: Option<String>,
|
||||
table_label: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
presentation_form: PresentationForm,
|
||||
}
|
||||
|
||||
/// 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)
|
||||
.await?;
|
||||
|
||||
let presentation = try_presentation_from_form(&form)?;
|
||||
let presentation = Presentation::try_from(form.presentation_form)?;
|
||||
|
||||
query(&format!(
|
||||
"alter table {ident} add column if not exists {col} {typ}",
|
||||
|
|
@ -102,10 +102,10 @@ pub(super) async fn post(
|
|||
Field::insert()
|
||||
.portal_id(portal.id)
|
||||
.name(form.name)
|
||||
.table_label(if form.label.is_empty() {
|
||||
.table_label(if form.table_label.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.label)
|
||||
Some(form.table_label)
|
||||
})
|
||||
.presentation(presentation)
|
||||
.build()?
|
||||
|
|
@ -120,28 +120,3 @@ pub(super) async fn post(
|
|||
.build()?
|
||||
.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::{
|
||||
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 axum::{debug_handler, extract::Path, response::Response};
|
||||
use interim_models::{
|
||||
field::Field,
|
||||
portal::Portal,
|
||||
presentation::{Presentation, RFC_3339_S, TextInputMode},
|
||||
workspace::Workspace,
|
||||
presentation::Presentation,
|
||||
workspace_user_perm::{self, WorkspaceUserPerm},
|
||||
};
|
||||
use interim_pgtypes::{escape_identifier, pg_class::PgClass};
|
||||
use serde::Deserialize;
|
||||
use sqlx::{postgres::types::Oid, query};
|
||||
use sqlx::postgres::types::Oid;
|
||||
use uuid::Uuid;
|
||||
use validator::Validate;
|
||||
|
||||
use crate::{
|
||||
app::{App, AppDbConn},
|
||||
errors::{AppError, forbidden},
|
||||
extractors::ValidatedForm,
|
||||
navigator::{Navigator, NavigatorPage},
|
||||
presentation_form::PresentationForm,
|
||||
user::CurrentUser,
|
||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -33,14 +25,13 @@ pub(super) struct PathParams {
|
|||
workspace_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Deserialize, Validate)]
|
||||
pub(super) struct FormBody {
|
||||
field_id: Uuid,
|
||||
label: String,
|
||||
presentation_tag: String,
|
||||
dropdown_allow_custom: Option<bool>,
|
||||
text_input_mode: Option<String>,
|
||||
timestamp_format: Option<String>,
|
||||
table_label: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
presentation_form: PresentationForm,
|
||||
}
|
||||
|
||||
/// HTTP POST handler for updating an existing [`Field`].
|
||||
|
|
@ -49,7 +40,6 @@ pub(super) struct FormBody {
|
|||
/// [`PathParams`].
|
||||
#[debug_handler(state = App)]
|
||||
pub(super) async fn post(
|
||||
State(mut workspace_pooler): State<WorkspacePooler>,
|
||||
AppDbConn(mut app_db): AppDbConn,
|
||||
CurrentUser(user): CurrentUser,
|
||||
navigator: Navigator,
|
||||
|
|
@ -58,8 +48,14 @@ pub(super) async fn post(
|
|||
rel_oid,
|
||||
workspace_id,
|
||||
}): Path<PathParams>,
|
||||
Form(form): Form<FormBody>,
|
||||
ValidatedForm(FormBody {
|
||||
field_id,
|
||||
table_label,
|
||||
presentation_form,
|
||||
}): ValidatedForm<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
// FIXME CSRF
|
||||
|
||||
// Check workspace authorization.
|
||||
let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id)
|
||||
.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
|
||||
// permission to access/alter both as needed.
|
||||
|
||||
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
|
||||
let workspace = Workspace::with_id(portal.workspace_id)
|
||||
// Ensure field exists and belongs to portal.
|
||||
Field::belonging_to_portal(portal_id)
|
||||
.with_id(field_id)
|
||||
.fetch_one(&mut app_db)
|
||||
.await?;
|
||||
|
||||
let mut workspace_client = workspace_pooler
|
||||
.acquire_for(workspace.id, RoleAssignment::User(user.id))
|
||||
Field::update()
|
||||
.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?;
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@view-transitions {
|
||||
navigation: auto;
|
||||
}
|
||||
|
||||
// https://css-tricks.com/inclusively-hidden/
|
||||
.sr-only:not(:focus):not(:active) {
|
||||
clip: rect(0 0 0 0);
|
||||
|
|
|
|||
|
|
@ -84,11 +84,16 @@ $table-border-color: #ccc;
|
|||
flex: 1;
|
||||
font-family: globals.$font-family-data;
|
||||
|
||||
&--dropdown {
|
||||
overflow: hidden;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
&--text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0 0.5rem;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
&--uuid {
|
||||
|
|
@ -211,6 +216,59 @@ $table-border-color: #ccc;
|
|||
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 {
|
||||
&__container {
|
||||
border-left: solid 4px transparent;
|
||||
|
|
@ -250,6 +308,21 @@ $table-border-color: #ccc;
|
|||
font-family: globals.$font-family-data;
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
editor_state,
|
||||
field_info.field.presentation,
|
||||
);
|
||||
console.log(value);
|
||||
on_change?.(value);
|
||||
}
|
||||
|
||||
|
|
@ -124,15 +125,20 @@
|
|||
{@html icon_cube}
|
||||
{/if}
|
||||
</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
|
||||
bind:this={text_input_element}
|
||||
value={editor_state.text_value}
|
||||
oninput={({ currentTarget: { value } }) => {
|
||||
if (editor_state) {
|
||||
editor_state.text_value = value;
|
||||
handle_input();
|
||||
oninput={({ currentTarget }) => {
|
||||
if (!editor_state) {
|
||||
console.warn("text input oninput() preconditions not met");
|
||||
return;
|
||||
}
|
||||
editor_state.text_value = currentTarget.value;
|
||||
if (currentTarget.value !== "") {
|
||||
editor_state.is_null = false;
|
||||
}
|
||||
handle_input();
|
||||
}}
|
||||
class="datum-editor__text-input"
|
||||
type="text"
|
||||
|
|
@ -141,6 +147,42 @@
|
|||
<input value={editor_state.date_value} type="date" />
|
||||
<input value={editor_state.time_value} type="time" />
|
||||
{/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}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,21 @@ export function datum_from_editor_state(
|
|||
presentation: Presentation,
|
||||
): Datum | undefined {
|
||||
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") {
|
||||
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">
|
||||
import icon_trash from "../assets/heroicons/20/solid/trash.svg?raw";
|
||||
import {
|
||||
type Presentation,
|
||||
all_presentation_tags,
|
||||
all_text_input_modes,
|
||||
} from "./presentation.svelte";
|
||||
|
||||
const COLORS: string[] = [
|
||||
"Red",
|
||||
"Orange",
|
||||
"Yellow",
|
||||
"Green",
|
||||
"Blue",
|
||||
"Indigo",
|
||||
"Violet",
|
||||
] as const;
|
||||
|
||||
type Assert<_T extends true> = void;
|
||||
|
||||
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],
|
||||
): Presentation {
|
||||
if (tag === "Dropdown") {
|
||||
return { t: "Dropdown", c: { allow_custom: true } };
|
||||
return { t: "Dropdown", c: { allow_custom: false, options: [] } };
|
||||
}
|
||||
if (tag === "Text") {
|
||||
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
|
||||
bind:value={label_value}
|
||||
class="form-section__input form-section__input--text"
|
||||
name="label"
|
||||
name="table_label"
|
||||
oninput={on_name_input}
|
||||
type="text"
|
||||
/>
|
||||
|
|
@ -123,7 +134,65 @@ field. This is typically rendered within a popover component, and within an HTML
|
|||
{/each}
|
||||
</select>
|
||||
</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">
|
||||
<div class="form-section__label">Input Mode</div>
|
||||
<select
|
||||
|
|
|
|||
|
|
@ -49,10 +49,16 @@ const text_input_mode_schema = z.union([
|
|||
|
||||
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({
|
||||
t: z.literal("Dropdown"),
|
||||
c: z.object({
|
||||
allow_custom: z.boolean(),
|
||||
options: z.array(dropdown_option_schema),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -218,9 +218,14 @@
|
|||
}
|
||||
|
||||
function try_sync_edit_to_cells() {
|
||||
if (lazy_data && selections.length === 1) {
|
||||
if (!lazy_data || selections.length !== 1) {
|
||||
console.warn("preconditions for try_sync_edit_to_cells() not met");
|
||||
return;
|
||||
}
|
||||
if (editor_value === undefined) {
|
||||
return;
|
||||
}
|
||||
const [sel] = selections;
|
||||
if (editor_value !== undefined) {
|
||||
if (sel.region === "main") {
|
||||
lazy_data.rows[sel.coords[0]].data[sel.coords[1]] = editor_value;
|
||||
} else if (sel.region === "inserter") {
|
||||
|
|
@ -229,13 +234,12 @@
|
|||
throw new Error("Unknown region");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function try_queue_delta() {
|
||||
// Copy `editor_value` so that it can be used intuitively within closures.
|
||||
const editor_value_scoped = editor_value;
|
||||
if (editor_value_scoped === undefined) {
|
||||
console.debug("not a valid cell value");
|
||||
cancel_edit();
|
||||
} else {
|
||||
if (selections.length > 0) {
|
||||
|
|
@ -251,6 +255,7 @@
|
|||
})),
|
||||
},
|
||||
];
|
||||
console.debug("Commit queue:", deltas.commit_queued);
|
||||
selections = selections.map((sel) => ({
|
||||
...sel,
|
||||
original_value: editor_value_scoped,
|
||||
|
|
@ -449,7 +454,34 @@
|
|||
class="lens-cell__container"
|
||||
class:lens-cell__container--selected={cell_selected}
|
||||
>
|
||||
{#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
|
||||
class="lens-cell__content lens-cell__content--text"
|
||||
class:lens-cell__content--null={cell_data.c === undefined}
|
||||
|
|
@ -460,7 +492,7 @@
|
|||
{cell_data.c}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if cell_data.t === "Uuid"}
|
||||
{:else if field.field.presentation.t === "Uuid"}
|
||||
<div
|
||||
class="lens-cell__content lens-cell__content--uuid"
|
||||
class:lens-cell__content--null={cell_data.c === undefined}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue