From d4de42365d4f1a3c36a5657f127eb29d35043b8c Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Tue, 7 Oct 2025 06:23:50 +0000 Subject: [PATCH] match field adder default presentation to pg regtype --- interim-models/src/field.rs | 23 ++ interim-models/src/presentation.rs | 3 - .../relations_single/add_field_handler.rs | 2 +- .../src/routes/relations_single/mod.rs | 5 + .../routes/relations_single/portal_handler.rs | 16 +- .../relations_single/update_field_handler.rs | 146 +++++++++++++ interim-server/templates/portal_table.html | 7 +- sass/main.scss | 1 + sass/viewer.scss | 6 +- svelte/src/field-adder.svelte | 204 ++++++++++++++++++ svelte/src/field-adder.webc.svelte | 129 ----------- svelte/src/field-details.svelte | 7 + svelte/src/field-header.svelte | 15 +- svelte/src/presentation.svelte.ts | 30 ++- svelte/src/table-viewer.webc.svelte | 10 +- 15 files changed, 458 insertions(+), 146 deletions(-) create mode 100644 interim-server/src/routes/relations_single/update_field_handler.rs create mode 100644 svelte/src/field-adder.svelte delete mode 100644 svelte/src/field-adder.webc.svelte diff --git a/interim-models/src/field.rs b/interim-models/src/field.rs index 83a9cc6..c61c54a 100644 --- a/interim-models/src/field.rs +++ b/interim-models/src/field.rs @@ -39,6 +39,11 @@ impl Field { InsertableFieldBuilder::default() } + /// Construct an update to an existing field. + pub fn update() -> UpdateBuilder { + UpdateBuilder::default() + } + /// Generate a default field config based on an existing column's name and /// type. pub fn default_from_attr(attr: &PgAttribute) -> Option { @@ -148,6 +153,24 @@ impl InsertableFieldBuilder { } } +#[derive(Builder, Clone, Debug)] +pub struct Update { + id: Uuid, + #[builder(default, setter(strip_option))] + table_label: Option>, + #[builder(default, setter(strip_option))] + presentation: Option, + #[builder(default, setter(strip_option))] + table_width_px: i32, +} + +impl Update { + pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> { + // TODO: consolidate + todo!(); + } +} + /// Error when parsing a sqlx value to JSON #[derive(Debug, Error)] pub enum ParseError { diff --git a/interim-models/src/presentation.rs b/interim-models/src/presentation.rs index 62868a3..870be48 100644 --- a/interim-models/src/presentation.rs +++ b/interim-models/src/presentation.rs @@ -8,7 +8,6 @@ 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 { - Array { inner: Box }, Dropdown { allow_custom: bool }, Text { input_mode: TextInputMode }, Timestamp { format: String }, @@ -20,7 +19,6 @@ impl Presentation { /// altering a backing column, such as "integer", or "timestamptz". pub fn attr_data_type_fragment(&self) -> String { match self { - Self::Array { inner } => format!("{0}[]", inner.attr_data_type_fragment()), Self::Dropdown { .. } | Self::Text { .. } => "text".to_owned(), Self::Timestamp { .. } => "timestamptz".to_owned(), Self::Uuid { .. } => "uuid".to_owned(), @@ -45,7 +43,6 @@ impl Presentation { /// Bet the web component tag name to use for rendering a UI cell. pub fn cell_webc_tag(&self) -> String { match self { - Self::Array { .. } => todo!(), Self::Dropdown { .. } => "cell-dropdown".to_owned(), Self::Text { .. } => "cell-text".to_owned(), Self::Timestamp { .. } => "cell-timestamp".to_owned(), 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 b2be28b..fc15e27 100644 --- a/interim-server/src/routes/relations_single/add_field_handler.rs +++ b/interim-server/src/routes/relations_single/add_field_handler.rs @@ -33,6 +33,7 @@ pub(super) struct PathParams { workspace_id: Uuid, } +// FIXME: validate name, prevent leading underscore #[derive(Debug, Deserialize)] pub(super) struct FormBody { name: String, @@ -126,7 +127,6 @@ fn try_presentation_from_form(form: &FormBody) -> Result // `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::Array { .. } => todo!(), Presentation::Dropdown { .. } => Presentation::Dropdown { allow_custom: true }, Presentation::Text { .. } => Presentation::Text { input_mode: form diff --git a/interim-server/src/routes/relations_single/mod.rs b/interim-server/src/routes/relations_single/mod.rs index e71c324..6e93166 100644 --- a/interim-server/src/routes/relations_single/mod.rs +++ b/interim-server/src/routes/relations_single/mod.rs @@ -16,6 +16,7 @@ mod portal_settings_handler; mod set_filter_handler; mod settings_handler; mod settings_invite_handler; +mod update_field_handler; mod update_form_transitions_handler; mod update_portal_name_handler; mod update_prompts_handler; @@ -39,6 +40,10 @@ pub(super) fn new_router() -> Router { post(update_portal_name_handler::post), ) .route("/p/{portal_id}/add-field", post(add_field_handler::post)) + .route( + "/p/{portal_id}/update-field", + post(update_field_handler::post), + ) .route("/p/{portal_id}/insert", post(insert_handler::post)) .route( "/p/{portal_id}/update-value", diff --git a/interim-server/src/routes/relations_single/portal_handler.rs b/interim-server/src/routes/relations_single/portal_handler.rs index 17651d0..0ab6af9 100644 --- a/interim-server/src/routes/relations_single/portal_handler.rs +++ b/interim-server/src/routes/relations_single/portal_handler.rs @@ -5,7 +5,7 @@ use axum::{ }; use interim_models::{expression::PgExpressionAny, portal::Portal, workspace::Workspace}; use interim_pgtypes::pg_attribute::PgAttribute; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sqlx::postgres::types::Oid; use uuid::Uuid; @@ -57,10 +57,23 @@ pub(super) async fn get( .fetch_all(&mut workspace_client) .await?; let attr_names: Vec = attrs.iter().map(|attr| attr.attname.clone()).collect(); + #[derive(Clone, Debug, Serialize)] + struct ColumnInfo { + name: String, + regtype: String, + } + let columns: Vec = attrs + .iter() + .map(|attr| ColumnInfo { + name: attr.attname.clone(), + regtype: attr.regtype.clone(), + }) + .collect(); #[derive(Template)] #[template(path = "portal_table.html")] struct ResponseTemplate { + columns: Vec, attr_names: Vec, filter: Option, settings: Settings, @@ -68,6 +81,7 @@ pub(super) async fn get( } Ok(Html( ResponseTemplate { + columns, attr_names, filter: portal.table_filter.0, navbar: WorkspaceNav::builder() diff --git a/interim-server/src/routes/relations_single/update_field_handler.rs b/interim-server/src/routes/relations_single/update_field_handler.rs new file mode 100644 index 0000000..9bedce1 --- /dev/null +++ b/interim-server/src/routes/relations_single/update_field_handler.rs @@ -0,0 +1,146 @@ +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 interim_models::{ + field::Field, + portal::Portal, + presentation::{Presentation, RFC_3339_S, TextInputMode}, + workspace::Workspace, + workspace_user_perm::{self, WorkspaceUserPerm}, +}; +use interim_pgtypes::{escape_identifier, pg_class::PgClass}; +use serde::Deserialize; +use sqlx::{postgres::types::Oid, query}; +use uuid::Uuid; + +use crate::{ + app::{App, AppDbConn}, + errors::{AppError, forbidden}, + navigator::{Navigator, NavigatorPage}, + user::CurrentUser, + workspace_pooler::{RoleAssignment, WorkspacePooler}, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct PathParams { + portal_id: Uuid, + rel_oid: u32, + workspace_id: Uuid, +} + +#[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, +} + +/// HTTP POST handler for adding a [`Field`] to a [`Portal`]. If the field name +/// does not match a column in the backing database, a new column is created +/// with a compatible type. +/// +/// This handler expects 3 path parameters with the structure described by +/// [`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, + Path(PathParams { + portal_id, + rel_oid, + workspace_id, + }): Path, + Form(form): Form, +) -> Result { + // Check workspace authorization. + let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id) + .fetch_all(&mut app_db) + .await?; + if workspace_perms.iter().all(|p| { + p.workspace_id != workspace_id || p.perm != workspace_user_perm::PermissionValue::Connect + }) { + return Err(forbidden!("access denied to workspace")); + } + // 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) + .fetch_one(&mut app_db) + .await?; + + let mut workspace_client = workspace_pooler + .acquire_for(workspace.id, RoleAssignment::User(user.id)) + .await?; + + let class = PgClass::with_oid(portal.class_oid) + .fetch_one(&mut workspace_client) + .await?; + + let presentation = try_presentation_from_form(&form)?; + + query(&format!( + "alter table {ident} add column if not exists {col} {typ}", + ident = class.get_identifier(), + col = escape_identifier(&form.name), + typ = presentation.attr_data_type_fragment(), + )) + .execute(workspace_client.get_conn()) + .await?; + + Field::insert() + .portal_id(portal.id) + .name(form.name) + .table_label(if form.label.is_empty() { + None + } else { + Some(form.label) + }) + .presentation(presentation) + .build()? + .insert(&mut app_db) + .await?; + + Ok(navigator + .portal_page() + .workspace_id(workspace_id) + .rel_oid(Oid(rel_oid)) + .portal_id(portal_id) + .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/templates/portal_table.html b/interim-server/templates/portal_table.html index bcaa8aa..1b72765 100644 --- a/interim-server/templates/portal_table.html +++ b/interim-server/templates/portal_table.html @@ -10,7 +10,10 @@ Portal Settings - +
@@ -19,7 +22,7 @@
- +
diff --git a/sass/main.scss b/sass/main.scss index 48f82a9..5cf014b 100644 --- a/sass/main.scss +++ b/sass/main.scss @@ -149,6 +149,7 @@ button, input[type="submit"] { } } +// TODO: can this be removed? .button-menu { &__toggle-button { @include globals.button-outline; diff --git a/sass/viewer.scss b/sass/viewer.scss index 336d5c0..63b3a58 100644 --- a/sass/viewer.scss +++ b/sass/viewer.scss @@ -161,7 +161,10 @@ $table-border-color: #ccc; &__popover { &:popover-open { @include globals.popover; + left: anchor(left); + top: anchor(bottom); padding: 1rem; + position: absolute; } } } @@ -200,11 +203,12 @@ $table-border-color: #ccc; } } -.lens-editor { +.datum-editor { align-items: stretch; border-top: globals.$default-border; display: flex; grid-area: editor; + height: 12rem; &__input { @include globals.reset_input; diff --git a/svelte/src/field-adder.svelte b/svelte/src/field-adder.svelte new file mode 100644 index 0000000..f52945d --- /dev/null +++ b/svelte/src/field-adder.svelte @@ -0,0 +1,204 @@ + + + + + + + +
+
+
+ name) + .filter((name) => + name + .toLocaleLowerCase("en-US") + .includes(label_value.toLocaleLowerCase("en-US")), + )} + search_input_class="field-adder__label-input" + /> +
+ +
+ + +
+
+ +
+ + {#if presentation_value} + + {/if} + +
+
diff --git a/svelte/src/field-adder.webc.svelte b/svelte/src/field-adder.webc.svelte deleted file mode 100644 index 485ca72..0000000 --- a/svelte/src/field-adder.webc.svelte +++ /dev/null @@ -1,129 +0,0 @@ - - - - - - - - -
-
-
- - col - .toLocaleLowerCase("en-US") - .includes(label_value.toLocaleLowerCase("en-US")), - )} - search_input_class="field-adder__label-input" - /> -
- -
- - -
-
- -
- - - -
-
diff --git a/svelte/src/field-details.svelte b/svelte/src/field-details.svelte index 936d9e3..6f94078 100644 --- a/svelte/src/field-details.svelte +++ b/svelte/src/field-details.svelte @@ -21,6 +21,7 @@ field. This is typically rendered within a popover component, and within an HTML on_name_input?( ev: Event & { currentTarget: EventTarget & HTMLInputElement }, ): void; + on_presentation_input?(presentation: Presentation): void; presentation?: Presentation; }; @@ -30,6 +31,7 @@ field. This is typically rendered within a popover component, and within an HTML name_value = $bindable(), label_value = $bindable(), on_name_input, + on_presentation_input, }: Props = $props(); function handle_presentation_tag_change( @@ -38,11 +40,15 @@ field. This is typically rendered within a popover component, and within an HTML const tag = ev.currentTarget .value as (typeof all_presentation_tags)[number]; presentation = get_empty_presentation(tag); + on_presentation_input?.(presentation); } function get_empty_presentation( tag: (typeof all_presentation_tags)[number], ): Presentation { + if (tag === "Dropdown") { + return { t: "Dropdown", c: { allow_custom: true } }; + } if (tag === "Text") { return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } }; } @@ -76,6 +82,7 @@ field. This is typically rendered within a popover component, and within an HTML type _ = Assert; throw new Error("this should be unreachable"); } + on_presentation_input?.(presentation); } } diff --git a/svelte/src/field-header.svelte b/svelte/src/field-header.svelte index 2576bb2..a2c3fa2 100644 --- a/svelte/src/field-header.svelte +++ b/svelte/src/field-header.svelte @@ -1,5 +1,6 @@