From cc285aaaaaa8558bd8110608be7b04ded22dcf8d Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Tue, 21 Oct 2025 18:58:09 +0000 Subject: [PATCH] implement basic dropdown presentation ui --- interim-models/src/field.rs | 87 +++++++++++++++++-- interim-models/src/presentation.rs | 19 +++- interim-server/src/main.rs | 1 + interim-server/src/presentation_form.rs | 54 ++++++++++++ .../relations_single/add_field_handler.rs | 43 ++------- .../relations_single/update_field_handler.rs | 65 ++++++++------ sass/main.scss | 4 + sass/viewer.scss | 75 +++++++++++++++- svelte/src/datum-editor.svelte | 54 ++++++++++-- svelte/src/editor-state.svelte.ts | 16 +++- svelte/src/field-details.svelte | 75 +++++++++++++++- svelte/src/presentation.svelte.ts | 6 ++ svelte/src/table-viewer.webc.svelte | 58 ++++++++++--- 13 files changed, 461 insertions(+), 96 deletions(-) create mode 100644 interim-server/src/presentation_form.rs diff --git a/interim-models/src/field.rs b/interim-models/src/field.rs index c61c54a..179dd0b 100644 --- a/interim-models/src/field.rs +++ b/interim-models/src/field.rs @@ -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, sqlx::Error> { query_as!( Field, @@ -94,7 +105,7 @@ select id, name, table_label, - presentation as "presentation: sqlx::types::Json", + presentation as "presentation: Json", 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 { + query_as!( + Field, + r#" +select + id, + name, + table_label, + presentation as "presentation: Json", + 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 as "presentation: Json", table_width_px "#, self.portal_id, self.name, self.table_label, - sqlx::types::Json::<_>(self.presentation) as sqlx::types::Json, + Json::<_>(self.presentation) as Json, self.table_width_px, ) .fetch_one(&mut *app_db.conn) @@ -161,13 +200,45 @@ pub struct Update { #[builder(default, setter(strip_option))] presentation: Option, #[builder(default, setter(strip_option))] - table_width_px: i32, + table_width_px: Option, } 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, + 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(()) } } diff --git a/interim-models/src/presentation.rs b/interim-models/src/presentation.rs index 870be48..7717be6 100644 --- a/interim-models/src/presentation.rs +++ b/interim-models/src/presentation.rs @@ -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, + }, + 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". diff --git a/interim-server/src/main.rs b/interim-server/src/main.rs index 6b5112d..1959811 100644 --- a/interim-server/src/main.rs +++ b/interim-server/src/main.rs @@ -18,6 +18,7 @@ mod extractors; mod field_info; mod middleware; mod navigator; +mod presentation_form; mod renderable_role_tree; mod routes; mod sessions; diff --git a/interim-server/src/presentation_form.rs b/interim-server/src/presentation_form.rs new file mode 100644 index 0000000..a985f98 --- /dev/null +++ b/interim-server/src/presentation_form.rs @@ -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, + pub(crate) dropdown_option_values: Vec, + pub(crate) text_input_mode: Option, + pub(crate) timestamp_format: Option, +} + +impl TryFrom for Presentation { + type Error = AppError; + + fn try_from(form_value: PresentationForm) -> Result { + // 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 {}, + }) + } +} diff --git a/interim-server/src/routes/relations_single/add_field_handler.rs b/interim-server/src/routes/relations_single/add_field_handler.rs index fc15e27..e04df07 100644 --- a/interim-server/src/routes/relations_single/add_field_handler.rs +++ b/interim-server/src/routes/relations_single/add_field_handler.rs @@ -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, - text_input_mode: Option, - timestamp_format: Option, + 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 { - // 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 {}, - }) -} diff --git a/interim-server/src/routes/relations_single/update_field_handler.rs b/interim-server/src/routes/relations_single/update_field_handler.rs index 2e27c79..3bae145 100644 --- a/interim-server/src/routes/relations_single/update_field_handler.rs +++ b/interim-server/src/routes/relations_single/update_field_handler.rs @@ -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, - text_input_mode: Option, - timestamp_format: Option, + 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, AppDbConn(mut app_db): AppDbConn, CurrentUser(user): CurrentUser, navigator: Navigator, @@ -58,8 +48,14 @@ pub(super) async fn post( rel_oid, workspace_id, }): Path, - Form(form): Form, + ValidatedForm(FormBody { + field_id, + table_label, + presentation_form, + }): ValidatedForm, ) -> Result { + // 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()) } diff --git a/sass/main.scss b/sass/main.scss index 5cf014b..2d79418 100644 --- a/sass/main.scss +++ b/sass/main.scss @@ -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); diff --git a/sass/viewer.scss b/sass/viewer.scss index adfe201..a394260 100644 --- a/sass/viewer.scss +++ b/sass/viewer.scss @@ -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 { diff --git a/svelte/src/datum-editor.svelte b/svelte/src/datum-editor.svelte index 24ecb13..3c7dc45 100644 --- a/svelte/src/datum-editor.svelte +++ b/svelte/src/datum-editor.svelte @@ -57,6 +57,7 @@ editor_state, field_info.field.presentation, ); + console.log(value); on_change?.(value); } @@ -124,15 +125,20 @@ {@html icon_cube} {/if} - {#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"} { - 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 @@ {/if} -
+
+ {#if field_info.field.presentation.t === "Dropdown"} + + + {#each field_info.field.presentation.c.options as dropdown_option} + +
  • + +
  • + {/each} +
    + {/if} +
    {/if} diff --git a/svelte/src/editor-state.svelte.ts b/svelte/src/editor-state.svelte.ts index ab15e3d..d9e4e05 100644 --- a/svelte/src/editor-state.svelte.ts +++ b/svelte/src/editor-state.svelte.ts @@ -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 }; diff --git a/svelte/src/field-details.svelte b/svelte/src/field-details.svelte index 6f94078..cc030b7 100644 --- a/svelte/src/field-details.svelte +++ b/svelte/src/field-details.svelte @@ -6,12 +6,23 @@ field. This is typically rendered within a popover component, and within an HTML -->