From c1dd95c06dbc5be016a13f869aca6688fed445d3 Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Wed, 1 Oct 2025 22:36:19 -0700 Subject: [PATCH] implement basic form editor --- Cargo.lock | 2 + Cargo.toml | 1 + interim-models/Cargo.toml | 3 +- .../migrations/20250918060948_init.up.sql | 18 ++ interim-models/src/errors.rs | 21 +++ interim-models/src/field_form_prompt.rs | 10 +- interim-models/src/form_transition.rs | 106 ++++++++--- interim-models/src/language.rs | 4 +- interim-models/src/lib.rs | 1 + interim-models/src/portal.rs | 62 +++++-- interim-server/Cargo.toml | 1 + interim-server/src/errors.rs | 18 +- interim-server/src/extractors.rs | 29 +++ interim-server/src/field_info.rs | 22 ++- interim-server/src/main.rs | 2 + interim-server/src/navigator.rs | 47 +++-- interim-server/src/routes/mod.rs | 24 +-- .../relations_single/add_field_handler.rs | 14 +- .../routes/relations_single/form_handler.rs | 174 ++++++++++++++++++ .../relations_single/get_data_handler.rs | 11 +- .../routes/relations_single/insert_handler.rs | 17 +- .../src/routes/relations_single/mod.rs | 26 +++ .../portal_settings_handler.rs | 99 ++++++++++ .../relations_single/set_filter_handler.rs | 13 +- .../relations_single/settings_handler.rs | 91 +++++++++ .../update_form_transitions_handler.rs | 91 +++++++++ .../update_portal_name_handler.rs | 91 +++++++++ .../update_prompts_handler.rs | 112 +++++++++++ .../update_rel_name_handler.rs | 95 ++++++++++ .../relations_single/update_value_handler.rs | 5 + .../workspaces_single/add_table_handler.rs | 2 +- .../routes/workspaces_single/nav_handler.rs | 3 + interim-server/src/workspace_nav.rs | 45 +++-- interim-server/src/workspace_utils.rs | 47 +++++ interim-server/templates/base.html | 2 +- interim-server/templates/portal_table.html | 10 +- .../relations_single/form_index.html | 52 ++++++ .../relations_single/portal_settings.html | 32 ++++ .../templates/relations_single/settings.html | 28 +++ sass/_globals.scss | 26 ++- sass/condition-editor.scss | 3 +- sass/main.scss | 11 +- static/dev_reloader.mjs | 49 ++--- static/favicon.ico | Bin 2965 -> 5380 bytes svelte/src/expression-selector.svelte | 5 + .../src/form-transitions-editor.webc.svelte | 87 +++++++++ svelte/src/i18n-textarea.webc.svelte | 55 ++++++ 47 files changed, 1504 insertions(+), 163 deletions(-) create mode 100644 interim-models/src/errors.rs create mode 100644 interim-server/src/extractors.rs create mode 100644 interim-server/src/routes/relations_single/form_handler.rs create mode 100644 interim-server/src/routes/relations_single/portal_settings_handler.rs create mode 100644 interim-server/src/routes/relations_single/settings_handler.rs create mode 100644 interim-server/src/routes/relations_single/update_form_transitions_handler.rs create mode 100644 interim-server/src/routes/relations_single/update_portal_name_handler.rs create mode 100644 interim-server/src/routes/relations_single/update_prompts_handler.rs create mode 100644 interim-server/src/routes/relations_single/update_rel_name_handler.rs create mode 100644 interim-server/src/workspace_utils.rs create mode 100644 interim-server/templates/relations_single/form_index.html create mode 100644 interim-server/templates/relations_single/portal_settings.html create mode 100644 interim-server/templates/relations_single/settings.html create mode 100644 svelte/src/form-transitions-editor.webc.svelte create mode 100644 svelte/src/i18n-textarea.webc.svelte diff --git a/Cargo.lock b/Cargo.lock index 6bc9d3c..6e48f45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1648,6 +1648,7 @@ dependencies = [ "thiserror 2.0.12", "url", "uuid", + "validator", ] [[package]] @@ -1690,6 +1691,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "strum", "thiserror 2.0.12", "tokio", "tower", diff --git a/Cargo.toml b/Cargo.toml index 0f411e5..82888a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ reqwest = { version = "0.12.8", features = ["json"] } serde = { version = "1.0.213", features = ["derive"] } serde_json = "1.0.132" sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "derive", "uuid", "chrono", "json", "macros"] } +strum = { version = "0.27.2", features = ["derive"] } thiserror = "2.0.12" tokio = { version = "1.42.0", features = ["full"] } tracing = "0.1.40" diff --git a/interim-models/Cargo.toml b/interim-models/Cargo.toml index 70fd399..6dad812 100644 --- a/interim-models/Cargo.toml +++ b/interim-models/Cargo.toml @@ -12,7 +12,8 @@ regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sqlx = { workspace = true } -strum = { version = "0.27.2", features = ["derive"] } +strum = { workspace = true } thiserror = { workspace = true } url = { workspace = true } uuid = { workspace = true } +validator = { workspace = true } diff --git a/interim-models/migrations/20250918060948_init.up.sql b/interim-models/migrations/20250918060948_init.up.sql index 04f59af..80dc9a1 100644 --- a/interim-models/migrations/20250918060948_init.up.sql +++ b/interim-models/migrations/20250918060948_init.up.sql @@ -92,3 +92,21 @@ create table if not exists field_form_prompts ( unique (field_id, language) ); create index on field_form_prompts (field_id); + +create table if not exists form_sessions ( + id uuid not null primary key default uuidv7(), + user_id uuid references users(id) on delete cascade +); + +create table if not exists form_touch_points ( + id uuid not null primary key default uuidv7(), + -- `on delete restrict` errs on the side of conservatism, but is not known + -- to be crucial. + form_session_id uuid not null references form_sessions(id) on delete restrict, + -- `on delete restrict` errs on the side of conservatism, but is not known + -- to be crucial. + portal_id uuid not null references portals(id) on delete restrict, + -- Points to a row in the portal's backing table, so foreign key constraints + -- do not apply here. + row_id uuid not null +); diff --git a/interim-models/src/errors.rs b/interim-models/src/errors.rs new file mode 100644 index 0000000..eeb533a --- /dev/null +++ b/interim-models/src/errors.rs @@ -0,0 +1,21 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum QueryError { + #[error("query validation failed: {0}")] + ValidationErrors(validator::ValidationErrors), + #[error("sqlx error: {0}")] + SqlxError(sqlx::Error), +} + +impl From for QueryError { + fn from(value: validator::ValidationErrors) -> Self { + Self::ValidationErrors(value) + } +} + +impl From for QueryError { + fn from(value: sqlx::Error) -> Self { + Self::SqlxError(value) + } +} diff --git a/interim-models/src/field_form_prompt.rs b/interim-models/src/field_form_prompt.rs index e69b5da..bab92da 100644 --- a/interim-models/src/field_form_prompt.rs +++ b/interim-models/src/field_form_prompt.rs @@ -29,8 +29,8 @@ pub struct FieldFormPrompt { impl FieldFormPrompt { /// Build an insert statement to create a new prompt. - pub fn insert() -> InsertableBuilder { - InsertableBuilder::default() + pub fn upsert() -> UpsertBuilder { + UpsertBuilder::default() } /// Build an update statement to alter the content of an existing prompt. @@ -45,18 +45,20 @@ impl FieldFormPrompt { } #[derive(Builder, Clone, Debug)] -pub struct Insertable { +pub struct Upsert { field_id: Uuid, language: Language, content: String, } -impl Insertable { +impl Upsert { pub async fn execute(self, app_db: &mut AppDbClient) -> Result { query_as!( FieldFormPrompt, r#" insert into field_form_prompts (field_id, language, content) values ($1, $2, $3) +on conflict (field_id, language) do update set + content = excluded.content returning id, field_id, diff --git a/interim-models/src/form_transition.rs b/interim-models/src/form_transition.rs index da682b6..cf4cb46 100644 --- a/interim-models/src/form_transition.rs +++ b/interim-models/src/form_transition.rs @@ -1,5 +1,6 @@ use derive_builder::Builder; -use sqlx::{query_as, types::Json}; +use serde::Serialize; +use sqlx::{Row as _, postgres::PgRow, query, query_as, types::Json}; use uuid::Uuid; use crate::{client::AppDbClient, expression::PgExpressionAny}; @@ -10,7 +11,7 @@ use crate::{client::AppDbClient, expression::PgExpressionAny}; /// to that portal will be evaluated one by one (in order by ID---that is, by /// creation time), and the first with a condition evaluating to true will be /// used to direct the user to the form corresponding to portal `dest_id`. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize)] pub struct FormTransition { /// Primary key (defaults to UUIDv7). pub id: Uuid, @@ -37,9 +38,13 @@ pub struct FormTransition { } impl FormTransition { - /// Build an insert statement to create a new transtition. - pub fn insert() -> InsertableBuilder { - InsertableBuilder::default() + /// Build a multi-row update statement to replace all transtitions for a + /// given source portal. + pub fn replace_for_portal(portal_id: Uuid) -> ReplaceBuilder { + ReplaceBuilder { + portal_id: Some(portal_id), + ..ReplaceBuilder::default() + } } /// Build a single-field query by source portal ID. @@ -77,30 +82,81 @@ where source_id = $1 } #[derive(Builder, Clone, Debug)] -pub struct Insertable { - source_id: Uuid, - dest_id: Uuid, - condition: Option, +pub struct Replacement { + pub dest_id: Uuid, + pub condition: Option, } -impl Insertable { - pub async fn execute(self, app_db: &mut AppDbClient) -> Result { - query_as!( - FormTransition, - r#" +#[derive(Builder, Clone, Debug)] +pub struct Replace { + #[builder(setter(custom))] + portal_id: Uuid, + + replacements: Vec, +} + +impl Replace { + /// Insert zero or more form transitions, and then remove all others + /// associated with the same portal. When they are being used, form + /// transitions are evaluated from first to last by ID (that is, by + /// creation timestamp, because IDs are UUIDv7s), so none of the newly added + /// transitions will supersede their predecessors until the latter are + /// deleted in one fell swoop. However, there will be a (hopefully brief) + /// period during which both the old and the new transitions will be + /// evaluated together in order, so this is not quite equivalent to an + /// atomic update. + /// + /// FIXME: There is a race condition in which executing [`Replace::execute`] + /// for the same portal two or more times simultaneously may remove *all* + /// form transitions for that portal, new and old. This would require + /// impeccable timing, but it should absolutely be fixed... at some point. + pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> { + let ids: Vec = if self.replacements.is_empty() { + vec![] + } else { + // Nice to do this in one query to avoid generating even more + // intermediate database state purgatory. Credit to [@greglearns]( + // https://github.com/launchbadge/sqlx/issues/294#issuecomment-716149160 + // ) for the clever syntax. Too bad it doesn't seem plausible to + // dovetail this with the [`query!`] macro, but that makes sense + // given the circuitous query structure. + query( + r#" insert into form_transitions (source_id, dest_id, condition) -values ($1, $2, $3) -returning - id, - source_id, - dest_id, - condition as "condition: Json>" + select * from unnest($1, $2, $3) +returning id "#, - self.source_id, - self.dest_id, - Json(self.condition) as Json>, + ) + .bind( + self.replacements + .iter() + .map(|_| self.portal_id) + .collect::>(), + ) + .bind( + self.replacements + .iter() + .map(|value| value.dest_id) + .collect::>(), + ) + .bind( + self.replacements + .iter() + .map(|value| Json(value.condition.clone())) + .collect::>() as Vec>>, + ) + .map(|row: PgRow| -> Uuid { row.get(0) }) + .fetch_all(app_db.get_conn()) + .await? + }; + + query!( + "delete from form_transitions where id <> any($1)", + ids.as_slice(), ) - .fetch_one(app_db.get_conn()) - .await + .execute(app_db.get_conn()) + .await?; + + Ok(()) } } diff --git a/interim-models/src/language.rs b/interim-models/src/language.rs index f2914b6..42c102e 100644 --- a/interim-models/src/language.rs +++ b/interim-models/src/language.rs @@ -6,7 +6,9 @@ use strum::{EnumIter, EnumString}; /// Languages represented as /// [ISO 639-3 codes](https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes). -#[derive(Clone, Debug, Deserialize, strum::Display, PartialEq, Serialize, EnumIter, EnumString)] +#[derive( + Clone, Debug, Deserialize, strum::Display, Eq, Hash, PartialEq, Serialize, EnumIter, EnumString, +)] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] // NOTE: The [`sqlx::Encode`] and [`sqlx::Decode`] derive macros do not seem to diff --git a/interim-models/src/lib.rs b/interim-models/src/lib.rs index 4464ec6..9376da4 100644 --- a/interim-models/src/lib.rs +++ b/interim-models/src/lib.rs @@ -1,5 +1,6 @@ pub mod client; pub mod datum; +pub mod errors; pub mod expression; pub mod field; pub mod field_form_prompt; diff --git a/interim-models/src/portal.rs b/interim-models/src/portal.rs index 5f2b6b8..9507e72 100644 --- a/interim-models/src/portal.rs +++ b/interim-models/src/portal.rs @@ -1,9 +1,16 @@ +use std::sync::LazyLock; + use derive_builder::Builder; +use regex::Regex; use serde::Serialize; use sqlx::{postgres::types::Oid, query, query_as, types::Json}; use uuid::Uuid; +use validator::Validate; -use crate::{client::AppDbClient, expression::PgExpressionAny}; +use crate::{client::AppDbClient, errors::QueryError, expression::PgExpressionAny}; + +pub static RE_PORTAL_NAME: LazyLock = + LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9][()a-zA-Z0-9 _-]*[a-zA-Z0-9()_-]$").unwrap()); /// A portal is a derivative representation of a Postgres relation. #[derive(Clone, Debug, Serialize)] @@ -28,13 +35,13 @@ pub struct Portal { impl Portal { /// Build an insert statement to create a new portal. - pub fn insert() -> InsertablePortalBuilder { - InsertablePortalBuilder::default() + pub fn insert() -> InsertBuilder { + InsertBuilder::default() } /// Build an update statement to alter an existing portal. - pub fn update() -> PortalUpdateBuilder { - PortalUpdateBuilder::default() + pub fn update() -> UpdateBuilder { + UpdateBuilder::default() } /// Build a single-field query by portal ID. @@ -102,6 +109,25 @@ pub struct BelongingToWorkspaceQuery { } impl BelongingToWorkspaceQuery { + pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result, sqlx::Error> { + query_as!( + Portal, + r#" +select + id, + name, + workspace_id, + class_oid, + table_filter as "table_filter: Json>" +from portals +where workspace_id = $1 +"#, + self.workspace_id, + ) + .fetch_all(&mut *app_db.conn) + .await + } + pub fn belonging_to_rel(self, rel_oid: Oid) -> BelongingToRelQuery { BelongingToRelQuery { workspace_id: self.workspace_id, @@ -145,13 +171,13 @@ pub enum LensDisplayType { } #[derive(Builder, Clone, Debug)] -pub struct InsertablePortal { +pub struct Insert { name: String, workspace_id: Uuid, class_oid: Oid, } -impl InsertablePortal { +impl Insert { pub async fn execute(self, app_db: &mut AppDbClient) -> Result { query_as!( Portal, @@ -175,15 +201,22 @@ returning } } -#[derive(Builder, Clone, Debug)] -pub struct PortalUpdate { +#[derive(Builder, Clone, Debug, Validate)] +pub struct Update { id: Uuid, - #[builder(setter(strip_option = true))] + #[builder(default, setter(strip_option = true))] filter: Option>, + + #[builder(default, setter(strip_option = true))] + #[validate(regex(path = *RE_PORTAL_NAME))] + name: Option, } -impl PortalUpdate { - pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> { +impl Update { + pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), QueryError> { + self.validate()?; + + // TODO: consolidate queries if let Some(filter) = self.filter { query!( "update portals set table_filter = $1 where id = $2", @@ -193,6 +226,11 @@ impl PortalUpdate { .execute(&mut *app_db.conn) .await?; } + if let Some(name) = self.name { + query!("update portals set name = $1 where id = $2", name, self.id) + .execute(&mut *app_db.conn) + .await?; + } Ok(()) } } diff --git a/interim-server/Cargo.toml b/interim-server/Cargo.toml index 82a047c..4763d02 100644 --- a/interim-server/Cargo.toml +++ b/interim-server/Cargo.toml @@ -26,6 +26,7 @@ reqwest = { workspace = true } serde = { workspace = true } serde_json = { workspace = true} sqlx = { workspace = true } +strum = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } tower = "0.5.2" diff --git a/interim-server/src/errors.rs b/interim-server/src/errors.rs index 9b99fbd..487a939 100644 --- a/interim-server/src/errors.rs +++ b/interim-server/src/errors.rs @@ -2,7 +2,6 @@ use std::fmt::{self, Display}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use validator::ValidationErrors; macro_rules! forbidden { ($message:literal) => { @@ -26,7 +25,7 @@ macro_rules! not_found { macro_rules! bad_request { ($message:literal) => { - AppError::BadRequest($message.to_owned()) + AppError::BadRequest(format!($message)) }; ($message:literal, $($param:expr),+) => { @@ -48,13 +47,6 @@ pub enum AppError { TooManyRequests(String), } -impl AppError { - pub fn from_validation_errors(errs: ValidationErrors) -> Self { - // TODO: customize validation errors formatting - Self::BadRequest(serde_json::to_string(&errs).unwrap_or("validation error".to_string())) - } -} - impl IntoResponse for AppError { fn into_response(self) -> Response { match self { @@ -99,16 +91,16 @@ impl Display for AppError { match self { AppError::InternalServerError(inner) => inner.fmt(f), AppError::Forbidden(client_message) => { - write!(f, "ForbiddenError: {}", client_message) + write!(f, "ForbiddenError: {client_message}") } AppError::NotFound(client_message) => { - write!(f, "NotFoundError: {}", client_message) + write!(f, "NotFoundError: {client_message}") } AppError::BadRequest(client_message) => { - write!(f, "BadRequestError: {}", client_message) + write!(f, "BadRequestError: {client_message}") } AppError::TooManyRequests(client_message) => { - write!(f, "TooManyRequestsError: {}", client_message) + write!(f, "TooManyRequestsError: {client_message}") } } } diff --git a/interim-server/src/extractors.rs b/interim-server/src/extractors.rs new file mode 100644 index 0000000..0ff3bdf --- /dev/null +++ b/interim-server/src/extractors.rs @@ -0,0 +1,29 @@ +use axum::extract::{FromRequest, Request}; +use axum_extra::extract::Form; +use serde::de::DeserializeOwned; +use validator::Validate; + +use crate::errors::{AppError, bad_request}; + +/// Wrapper around [`axum_extra::extract::Form`] which returns an +/// [`AppError::BadRequest`] if [`validator`] checks on the target type do not +/// pass. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct ValidatedForm(pub(crate) T); + +impl FromRequest for ValidatedForm +where + T: DeserializeOwned + Validate, + S: Send + Sync, +{ + type Rejection = AppError; + + async fn from_request(req: Request, state: &S) -> Result { + let Form(form) = Form::::from_request(req, state) + .await + .map_err(|err| bad_request!("couldn't parse form: {err}"))?; + form.validate() + .map_err(|err| bad_request!("couldn't validate form: {err}"))?; + Ok(ValidatedForm(form)) + } +} diff --git a/interim-server/src/field_info.rs b/interim-server/src/field_info.rs index 2631de0..6176734 100644 --- a/interim-server/src/field_info.rs +++ b/interim-server/src/field_info.rs @@ -1,9 +1,21 @@ -use interim_models::field::Field; +use std::collections::HashMap; + +use interim_models::{field::Field, language::Language}; use serde::Serialize; #[derive(Clone, Debug, Serialize)] -pub struct FieldInfo { - pub field: Field, - pub has_default: bool, - pub not_null: bool, +pub(crate) struct TableFieldInfo { + pub(crate) field: Field, + pub(crate) column_present: bool, + pub(crate) has_default: bool, + pub(crate) not_null: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct FormFieldInfo { + pub(crate) field: Field, + pub(crate) column_present: bool, + pub(crate) has_default: bool, + pub(crate) not_null: bool, + pub(crate) prompts: HashMap, } diff --git a/interim-server/src/main.rs b/interim-server/src/main.rs index fb92656..6b5112d 100644 --- a/interim-server/src/main.rs +++ b/interim-server/src/main.rs @@ -14,6 +14,7 @@ mod app; mod auth; mod cli; mod errors; +mod extractors; mod field_info; mod middleware; mod navigator; @@ -26,6 +27,7 @@ mod worker; mod workspace_nav; mod workspace_pooler; mod workspace_user_perms; +mod workspace_utils; /// Run CLI #[tokio::main] diff --git a/interim-server/src/navigator.rs b/interim-server/src/navigator.rs index 4d12c05..1c96c9e 100644 --- a/interim-server/src/navigator.rs +++ b/interim-server/src/navigator.rs @@ -4,7 +4,6 @@ use axum::{ response::{IntoResponse as _, Redirect, Response}, }; use derive_builder::Builder; -use interim_models::portal::Portal; use sqlx::postgres::types::Oid; use uuid::Uuid; @@ -38,15 +37,10 @@ impl Navigator { } } - pub(crate) fn portal_page(&self, portal: &Portal) -> Self { - Self { - sub_path: format!( - "/w/{0}/r/{1}/p/{2}/", - portal.workspace_id.simple(), - portal.class_oid.0, - portal.id.simple() - ), - ..self.clone() + pub(crate) fn portal_page(&self) -> PortalPageBuilder { + PortalPageBuilder { + root_path: Some(self.get_root_path()), + ..Default::default() } } @@ -83,7 +77,38 @@ impl FromRequestParts for Navigator { } } -#[derive(Builder, Clone, Debug, PartialEq)] +#[derive(Builder, Clone, Debug)] +pub(crate) struct PortalPage { + portal_id: Uuid, + + rel_oid: Oid, + + #[builder(setter(custom))] + root_path: String, + + /// Any value provided for `suffix` will be appended (without %-encoding) to + /// the final path value. This may be used for sub-paths and/or search + /// parameters. + #[builder(default, setter(strip_option))] + suffix: Option, + + workspace_id: Uuid, +} + +impl NavigatorPage for PortalPage { + fn get_path(&self) -> String { + format!( + "{root_path}/w/{workspace_id}/r/{rel_oid}/p/{portal_id}/{suffix}", + root_path = self.root_path, + workspace_id = self.workspace_id.simple(), + rel_oid = self.rel_oid.0, + portal_id = self.portal_id.simple(), + suffix = self.suffix.clone().unwrap_or_default() + ) + } +} + +#[derive(Builder, Clone, Debug)] pub(crate) struct RelSettingsPage { rel_oid: Oid, diff --git a/interim-server/src/routes/mod.rs b/interim-server/src/routes/mod.rs index 5b47a7d..62b539f 100644 --- a/interim-server/src/routes/mod.rs +++ b/interim-server/src/routes/mod.rs @@ -6,14 +6,12 @@ //! file paths grow exceedingly long. Deeply nested routers may still be //! implemented, by use of the `super` keyword. -use std::net::SocketAddr; - use axum::{ Router, - extract::{ConnectInfo, State, WebSocketUpgrade, ws::WebSocket}, + extract::State, http::{HeaderValue, header::CACHE_CONTROL}, - response::{Redirect, Response}, - routing::{any, get}, + response::Redirect, + routing::get, }; use tower::ServiceBuilder; use tower_http::{ @@ -45,7 +43,7 @@ pub(crate) fn new_router(app: App) -> Router<()> { .nest("/workspaces", workspaces_multi::new_router()) .nest("/w", workspaces_single::new_router()) .nest("/auth", auth::new_router()) - .route("/__dev-healthz", any(dev_healthz_handler)) + .route("/__dev-healthz", get(|| async move { "ok" })) .layer(SetResponseHeaderLayer::if_not_present( CACHE_CONTROL, HeaderValue::from_static("no-cache"), @@ -116,17 +114,3 @@ pub(crate) fn new_router(app: App) -> Router<()> { .fallback(|| async move { Redirect::to(&root_path) }) } } - -/// Development endpoint helping to implement home-grown "hot" reloads. -async fn dev_healthz_handler( - ws: WebSocketUpgrade, - ConnectInfo(addr): ConnectInfo, -) -> Response { - tracing::info!("{addr} connected"); - ws.on_upgrade(move |socket| handle_dev_healthz_socket(socket, addr)) -} - -async fn handle_dev_healthz_socket(mut socket: WebSocket, _: SocketAddr) { - // Keep socket open indefinitely until the entire server exits - while let Some(Ok(_)) = socket.recv().await {} -} 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 bdf4002..b2be28b 100644 --- a/interim-server/src/routes/relations_single/add_field_handler.rs +++ b/interim-server/src/routes/relations_single/add_field_handler.rs @@ -15,13 +15,13 @@ use interim_models::{ }; use interim_pgtypes::{escape_identifier, pg_class::PgClass}; use serde::Deserialize; -use sqlx::query; +use sqlx::{postgres::types::Oid, query}; use uuid::Uuid; use crate::{ app::{App, AppDbConn}, errors::{AppError, forbidden}, - navigator::Navigator, + navigator::{Navigator, NavigatorPage}, user::CurrentUser, workspace_pooler::{RoleAssignment, WorkspacePooler}, }; @@ -57,8 +57,8 @@ pub(super) async fn post( navigator: Navigator, Path(PathParams { portal_id, + rel_oid, workspace_id, - .. }): Path, Form(form): Form, ) -> Result { @@ -111,7 +111,13 @@ pub(super) async fn post( .insert(&mut app_db) .await?; - Ok(navigator.portal_page(&portal).redirect_to()) + 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 { diff --git a/interim-server/src/routes/relations_single/form_handler.rs b/interim-server/src/routes/relations_single/form_handler.rs new file mode 100644 index 0000000..bf37274 --- /dev/null +++ b/interim-server/src/routes/relations_single/form_handler.rs @@ -0,0 +1,174 @@ +use std::collections::HashMap; + +use askama::Template; +use axum::{ + debug_handler, + extract::{Path, State}, + response::{Html, IntoResponse}, +}; +use interim_models::{ + field::Field, + field_form_prompt::FieldFormPrompt, + form_transition::FormTransition, + language::Language, + portal::Portal, + workspace::Workspace, + workspace_user_perm::{self, WorkspaceUserPerm}, +}; +use interim_pgtypes::{pg_attribute::PgAttribute, pg_class::PgClass}; +use serde::{Deserialize, Serialize}; +use sqlx::postgres::types::Oid; +use strum::IntoEnumIterator as _; +use uuid::Uuid; + +use crate::{ + app::{App, AppDbConn}, + errors::{AppError, forbidden}, + field_info::FormFieldInfo, + navigator::Navigator, + settings::Settings, + user::CurrentUser, + workspace_nav::{NavLocation, RelLocation, WorkspaceNav}, + workspace_pooler::{RoleAssignment, WorkspacePooler}, + workspace_utils::{RelationPortalSet, fetch_all_accessible_portals}, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct PathParams { + portal_id: Uuid, + rel_oid: u32, + workspace_id: Uuid, +} + +/// HTTP GET handler for the top-level portal form builder page. +#[debug_handler(state = App)] +pub(super) async fn get( + State(settings): State, + CurrentUser(user): CurrentUser, + AppDbConn(mut app_db): AppDbConn, + Path(PathParams { + portal_id, + rel_oid, + workspace_id, + }): Path, + navigator: Navigator, + State(mut pooler): State, +) -> 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 = pooler + .acquire_for(workspace.id, RoleAssignment::User(user.id)) + .await?; + + let attrs: HashMap = PgAttribute::all_for_rel(portal.class_oid) + .fetch_all(&mut workspace_client) + .await? + .into_iter() + .map(|attr| (attr.attname.clone(), attr)) + .collect(); + let fields: Vec = { + let fields: Vec = Field::belonging_to_portal(portal.id) + .fetch_all(&mut app_db) + .await?; + let mut field_info: Vec = Vec::with_capacity(fields.len()); + for field in fields { + let attr = attrs.get(&field.name); + let prompts: HashMap = FieldFormPrompt::belonging_to_field(field.id) + .fetch_all(&mut app_db) + .await? + .into_iter() + .map(|value| (value.language, value.content)) + .collect(); + field_info.push(FormFieldInfo { + field, + column_present: attr.is_some(), + has_default: attr.map(|value| value.atthasdef).unwrap_or(false), + not_null: attr.and_then(|value| value.attnotnull).unwrap_or_default(), + prompts, + }); + } + field_info + }; + + // FIXME: exclude portals user does not have access to, as well as + // unnecessary fields + let portal_sets = + fetch_all_accessible_portals(workspace_id, &mut app_db, &mut workspace_client).await?; + + #[derive(Template)] + #[template(path = "relations_single/form_index.html")] + struct ResponseTemplate { + fields: Vec, + identifier_hints: Vec, + languages: Vec, + portals: Vec, + settings: Settings, + transitions: Vec, + workspace_nav: WorkspaceNav, + } + #[derive(Debug, Serialize)] + struct LanguageInfo { + code: String, + locale_str: String, + } + #[derive(Debug, Serialize)] + struct PortalDisplay { + id: Uuid, + display_name: String, + } + Ok(Html( + ResponseTemplate { + fields, + identifier_hints: attrs.keys().cloned().collect(), + languages: Language::iter() + .map(|value| LanguageInfo { + code: value.to_string(), + locale_str: value.as_locale_str().to_owned(), + }) + .collect(), + portals: portal_sets + .iter() + .flat_map(|RelationPortalSet { rel, portals }| { + portals.iter().map(|portal| PortalDisplay { + id: portal.id, + display_name: format!( + "{rel_name}: {portal_name}", + rel_name = rel.relname, + portal_name = portal.name + ), + }) + }) + .collect(), + transitions: FormTransition::with_source(portal_id) + .fetch_all(&mut app_db) + .await?, + workspace_nav: WorkspaceNav::builder() + .navigator(navigator) + .workspace(workspace) + .populate_rels(&mut app_db, &mut workspace_client) + .await? + .current(NavLocation::Rel( + Oid(rel_oid), + Some(RelLocation::Portal(portal_id)), + )) + .build()?, + settings, + } + .render()?, + )) +} diff --git a/interim-server/src/routes/relations_single/get_data_handler.rs b/interim-server/src/routes/relations_single/get_data_handler.rs index abe4502..cf3538b 100644 --- a/interim-server/src/routes/relations_single/get_data_handler.rs +++ b/interim-server/src/routes/relations_single/get_data_handler.rs @@ -14,7 +14,7 @@ use uuid::Uuid; use crate::{ app::AppDbConn, errors::AppError, - field_info::FieldInfo, + field_info::TableFieldInfo, user::CurrentUser, workspace_pooler::{RoleAssignment, WorkspacePooler}, }; @@ -53,15 +53,16 @@ pub(super) async fn get( .fetch_all(&mut workspace_client) .await?; - let fields: Vec = { + let fields: Vec = { let fields: Vec = Field::belonging_to_portal(portal.id) .fetch_all(&mut app_db) .await?; - let mut field_info: Vec = Vec::with_capacity(fields.len()); + let mut field_info: Vec = Vec::with_capacity(fields.len()); for field in fields { if let Some(attr) = attrs.iter().find(|attr| attr.attname == field.name) { - field_info.push(FieldInfo { + field_info.push(TableFieldInfo { field, + column_present: true, has_default: attr.atthasdef, not_null: attr.attnotnull.unwrap_or_default(), }); @@ -133,7 +134,7 @@ pub(super) async fn get( #[derive(Serialize)] struct ResponseBody { rows: Vec, - fields: Vec, + fields: Vec, pkeys: Vec, } Ok(Json(ResponseBody { diff --git a/interim-server/src/routes/relations_single/insert_handler.rs b/interim-server/src/routes/relations_single/insert_handler.rs index 2361a03..9148284 100644 --- a/interim-server/src/routes/relations_single/insert_handler.rs +++ b/interim-server/src/routes/relations_single/insert_handler.rs @@ -22,7 +22,7 @@ use uuid::Uuid; use crate::{ app::{App, AppDbConn}, errors::{AppError, forbidden}, - navigator::Navigator, + navigator::{Navigator, NavigatorPage as _}, user::CurrentUser, workspace_pooler::{RoleAssignment, WorkspacePooler}, }; @@ -63,6 +63,7 @@ pub(super) async fn post( } // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. + // FIXME CSRF let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?; let workspace = Workspace::with_id(portal.workspace_id) @@ -78,6 +79,12 @@ pub(super) async fn post( .await?; let col_names: Vec = form.keys().cloned().collect(); + + // Prevent users from modifying Phonograph metadata columns. + if col_names.iter().any(|col| col.starts_with('_')) { + return Err(forbidden!("access denied to update system metadata column")); + } + let col_list_sql = col_names .iter() .map(|value| escape_identifier(value)) @@ -120,5 +127,11 @@ pub(super) async fn post( q.execute(workspace_client.get_conn()).await?; } - Ok(navigator.portal_page(&portal).redirect_to()) + Ok(navigator + .portal_page() + .workspace_id(workspace_id) + .rel_oid(Oid(rel_oid)) + .portal_id(portal_id) + .build()? + .redirect_to()) } diff --git a/interim-server/src/routes/relations_single/mod.rs b/interim-server/src/routes/relations_single/mod.rs index 23a46a6..e71c324 100644 --- a/interim-server/src/routes/relations_single/mod.rs +++ b/interim-server/src/routes/relations_single/mod.rs @@ -8,19 +8,36 @@ use crate::app::App; mod add_field_handler; mod add_portal_handler; +mod form_handler; mod get_data_handler; mod insert_handler; mod portal_handler; +mod portal_settings_handler; mod set_filter_handler; +mod settings_handler; mod settings_invite_handler; +mod update_form_transitions_handler; +mod update_portal_name_handler; +mod update_prompts_handler; +mod update_rel_name_handler; mod update_value_handler; pub(super) fn new_router() -> Router { Router::::new() + .route_with_tsr("/settings/", get(settings_handler::get)) .route("/settings/invite", post(settings_invite_handler::post)) + .route("/settings/update-name", post(update_rel_name_handler::post)) .route("/add-portal", post(add_portal_handler::post)) .route_with_tsr("/p/{portal_id}/", get(portal_handler::get)) .route_with_tsr("/p/{portal_id}/get-data/", get(get_data_handler::get)) + .route_with_tsr( + "/p/{portal_id}/settings/", + get(portal_settings_handler::get), + ) + .route( + "/p/{portal_id}/settings/update-name", + post(update_portal_name_handler::post), + ) .route("/p/{portal_id}/add-field", post(add_field_handler::post)) .route("/p/{portal_id}/insert", post(insert_handler::post)) .route( @@ -28,4 +45,13 @@ pub(super) fn new_router() -> Router { post(update_value_handler::post), ) .route("/p/{portal_id}/set-filter", post(set_filter_handler::post)) + .route_with_tsr("/p/{portal_id}/form/", get(form_handler::get)) + .route( + "/p/{portal_id}/form/update-prompts", + post(update_prompts_handler::post), + ) + .route( + "/p/{portal_id}/form/update-form-transitions", + post(update_form_transitions_handler::post), + ) } diff --git a/interim-server/src/routes/relations_single/portal_settings_handler.rs b/interim-server/src/routes/relations_single/portal_settings_handler.rs new file mode 100644 index 0000000..fa65efe --- /dev/null +++ b/interim-server/src/routes/relations_single/portal_settings_handler.rs @@ -0,0 +1,99 @@ +use askama::Template; +use axum::{ + debug_handler, + extract::{Path, State}, + response::{Html, IntoResponse}, +}; +use interim_models::{ + portal::Portal, + workspace::Workspace, + workspace_user_perm::{self, WorkspaceUserPerm}, +}; +use interim_pgtypes::pg_class::PgClass; +use serde::Deserialize; +use sqlx::postgres::types::Oid; +use uuid::Uuid; + +use crate::{ + app::{App, AppDbConn}, + errors::{AppError, forbidden}, + navigator::{Navigator, NavigatorPage as _}, + settings::Settings, + user::CurrentUser, + workspace_nav::{NavLocation, RelLocation, WorkspaceNav}, + workspace_pooler::{RoleAssignment, WorkspacePooler}, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct PathParams { + portal_id: Uuid, + rel_oid: u32, + workspace_id: Uuid, +} + +/// HTTP GET handler for portal settings, including renaming and deletion. +#[debug_handler(state = App)] +pub(super) async fn get( + State(settings): State, + CurrentUser(user): CurrentUser, + AppDbConn(mut app_db): AppDbConn, + Path(PathParams { + portal_id, + rel_oid, + workspace_id, + }): Path, + navigator: Navigator, + State(mut pooler): State, +) -> 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 workspace = Workspace::with_id(workspace_id) + .fetch_one(&mut app_db) + .await?; + + let mut workspace_client = pooler + .acquire_for(workspace.id, RoleAssignment::User(user.id)) + .await?; + + let rel = PgClass::with_oid(Oid(rel_oid)) + .fetch_one(&mut workspace_client) + .await?; + + let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?; + + #[derive(Debug, Template)] + #[template(path = "relations_single/portal_settings.html")] + struct ResponseTemplate { + navigator: Navigator, + portal: Portal, + rel: PgClass, + settings: Settings, + workspace_nav: WorkspaceNav, + } + Ok(Html( + ResponseTemplate { + workspace_nav: WorkspaceNav::builder() + .navigator(navigator.clone()) + .workspace(workspace) + .populate_rels(&mut app_db, &mut workspace_client) + .await? + .current(NavLocation::Rel(Oid(rel_oid), Some(RelLocation::Sharing))) + .build()?, + navigator, + portal, + rel, + settings, + } + .render()?, + )) +} diff --git a/interim-server/src/routes/relations_single/set_filter_handler.rs b/interim-server/src/routes/relations_single/set_filter_handler.rs index 9ac7c10..afbe9d0 100644 --- a/interim-server/src/routes/relations_single/set_filter_handler.rs +++ b/interim-server/src/routes/relations_single/set_filter_handler.rs @@ -8,12 +8,13 @@ use interim_models::{ workspace_user_perm::{self, WorkspaceUserPerm}, }; use serde::Deserialize; +use sqlx::postgres::types::Oid; use uuid::Uuid; use crate::{ app::{App, AppDbConn}, errors::{AppError, forbidden}, - navigator::Navigator, + navigator::{Navigator, NavigatorPage as _}, user::CurrentUser, }; @@ -41,8 +42,8 @@ pub(super) async fn post( navigator: Navigator, Path(PathParams { portal_id, + rel_oid, workspace_id, - .. }): Path, Form(form): Form, ) -> Result { @@ -68,5 +69,11 @@ pub(super) async fn post( .execute(&mut app_db) .await?; - Ok(navigator.portal_page(&portal).redirect_to()) + Ok(navigator + .portal_page() + .workspace_id(workspace_id) + .rel_oid(Oid(rel_oid)) + .portal_id(portal_id) + .build()? + .redirect_to()) } diff --git a/interim-server/src/routes/relations_single/settings_handler.rs b/interim-server/src/routes/relations_single/settings_handler.rs new file mode 100644 index 0000000..40d5b72 --- /dev/null +++ b/interim-server/src/routes/relations_single/settings_handler.rs @@ -0,0 +1,91 @@ +use askama::Template; +use axum::{ + debug_handler, + extract::{Path, State}, + response::{Html, IntoResponse}, +}; +use interim_models::{ + workspace::Workspace, + workspace_user_perm::{self, WorkspaceUserPerm}, +}; +use interim_pgtypes::pg_class::PgClass; +use serde::Deserialize; +use sqlx::postgres::types::Oid; +use uuid::Uuid; + +use crate::{ + app::{App, AppDbConn}, + errors::{AppError, forbidden}, + navigator::Navigator, + settings::Settings, + user::CurrentUser, + workspace_nav::{NavLocation, RelLocation, WorkspaceNav}, + workspace_pooler::{RoleAssignment, WorkspacePooler}, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct PathParams { + rel_oid: u32, + workspace_id: Uuid, +} + +/// HTTP GET handler for table settings, including renaming, access control, +/// and deletion. +#[debug_handler(state = App)] +pub(super) async fn get( + State(settings): State, + CurrentUser(user): CurrentUser, + AppDbConn(mut app_db): AppDbConn, + Path(PathParams { + rel_oid, + workspace_id, + }): Path, + navigator: Navigator, + State(mut pooler): State, +) -> 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 workspace = Workspace::with_id(workspace_id) + .fetch_one(&mut app_db) + .await?; + + let mut workspace_client = pooler + .acquire_for(workspace.id, RoleAssignment::User(user.id)) + .await?; + + let rel = PgClass::with_oid(Oid(rel_oid)) + .fetch_one(&mut workspace_client) + .await?; + + #[derive(Debug, Template)] + #[template(path = "relations_single/settings.html")] + struct ResponseTemplate { + rel: PgClass, + settings: Settings, + workspace_nav: WorkspaceNav, + } + Ok(Html( + ResponseTemplate { + workspace_nav: WorkspaceNav::builder() + .navigator(navigator) + .workspace(workspace) + .populate_rels(&mut app_db, &mut workspace_client) + .await? + .current(NavLocation::Rel(Oid(rel_oid), Some(RelLocation::Sharing))) + .build()?, + rel, + settings, + } + .render()?, + )) +} diff --git a/interim-server/src/routes/relations_single/update_form_transitions_handler.rs b/interim-server/src/routes/relations_single/update_form_transitions_handler.rs new file mode 100644 index 0000000..db7fa4c --- /dev/null +++ b/interim-server/src/routes/relations_single/update_form_transitions_handler.rs @@ -0,0 +1,91 @@ +use std::iter::zip; + +use axum::{debug_handler, extract::Path, response::Response}; +// [`axum_extra`]'s form extractor is required to support repeated keys: +// 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::{ + form_transition::{self, FormTransition}, + workspace_user_perm::{self, WorkspaceUserPerm}, +}; +use serde::Deserialize; +use sqlx::postgres::types::Oid; +use uuid::Uuid; + +use crate::{ + app::{App, AppDbConn}, + errors::{AppError, bad_request, forbidden}, + navigator::{Navigator, NavigatorPage as _}, + user::CurrentUser, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct PathParams { + portal_id: Uuid, + rel_oid: u32, + workspace_id: Uuid, +} + +#[derive(Clone, Debug, Deserialize)] +pub(super) struct FormBody { + dest: Vec, + condition: Vec, +} + +/// HTTP POST handler for setting form transitions for a [`Portal`]. The form +/// body is expected to be an HTTP form encoded with a list of inputs named +/// `"dest"` and `"condition"`, where `"dest"` encodes a portal ID and +/// `"condition"` is JSON deserializing to [`PgExpressionAny`]. +/// +/// Upon success, the client is redirected back to the portal's form editor +/// page. +#[debug_handler(state = App)] +pub(super) async fn post( + 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. + // FIXME CSRF + + let replacements = zip(form.dest, form.condition) + .map(|(dest_id, condition)| { + Ok(form_transition::Replacement { + dest_id, + condition: serde_json::from_str(&condition)?, + }) + }) + .collect::, serde_json::Error>>() + .map_err(|err| bad_request!("unable to deserialize condition: {err}"))?; + + FormTransition::replace_for_portal(portal_id) + .replacements(replacements) + .build()? + .execute(&mut app_db) + .await?; + + Ok(navigator + .portal_page() + .workspace_id(workspace_id) + .rel_oid(Oid(rel_oid)) + .portal_id(portal_id) + .suffix("form/".to_owned()) + .build()? + .redirect_to()) +} diff --git a/interim-server/src/routes/relations_single/update_portal_name_handler.rs b/interim-server/src/routes/relations_single/update_portal_name_handler.rs new file mode 100644 index 0000000..f88a22d --- /dev/null +++ b/interim-server/src/routes/relations_single/update_portal_name_handler.rs @@ -0,0 +1,91 @@ +use axum::{ + debug_handler, + extract::{Path, State}, + response::Response, +}; +use interim_models::{ + portal::{Portal, RE_PORTAL_NAME}, + workspace_user_perm::{self, WorkspaceUserPerm}, +}; +use interim_pgtypes::pg_class::PgClass; +use serde::Deserialize; +use sqlx::postgres::types::Oid; +use uuid::Uuid; +use validator::Validate; + +use crate::{ + app::{App, AppDbConn}, + errors::{AppError, forbidden}, + extractors::ValidatedForm, + navigator::{Navigator, NavigatorPage as _}, + user::CurrentUser, + workspace_pooler::WorkspacePooler, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct PathParams { + portal_id: Uuid, + rel_oid: u32, + workspace_id: Uuid, +} + +#[derive(Debug, Deserialize, Validate)] +pub(super) struct FormBody { + #[validate(regex(path = *RE_PORTAL_NAME))] + name: String, +} + +/// HTTP POST handler for updating a portal's name. +#[debug_handler(state = App)] +pub(super) async fn post( + AppDbConn(mut app_db): AppDbConn, + State(mut pooler): State, + CurrentUser(user): CurrentUser, + navigator: Navigator, + Path(PathParams { + portal_id, + rel_oid, + workspace_id, + }): Path, + ValidatedForm(FormBody { name }): ValidatedForm, +) -> 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")); + } + + let mut workspace_client = pooler + .acquire_for( + workspace_id, + crate::workspace_pooler::RoleAssignment::User(user.id), + ) + .await?; + + let rel = PgClass::with_oid(Oid(rel_oid)) + .fetch_one(&mut workspace_client) + .await?; + // FIXME ensure that user has ownership of the table. + + let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?; + + Portal::update() + .id(portal_id) + .name(name) + .build()? + .execute(&mut app_db) + .await?; + + Ok(navigator + .portal_page() + .workspace_id(workspace_id) + .rel_oid(Oid(rel_oid)) + .portal_id(portal_id) + .suffix("settings/".to_owned()) + .build()? + .redirect_to()) +} diff --git a/interim-server/src/routes/relations_single/update_prompts_handler.rs b/interim-server/src/routes/relations_single/update_prompts_handler.rs new file mode 100644 index 0000000..4bb90de --- /dev/null +++ b/interim-server/src/routes/relations_single/update_prompts_handler.rs @@ -0,0 +1,112 @@ +use std::{collections::HashMap, str::FromStr}; + +use axum::{ + debug_handler, + extract::{Path, State}, + response::Response, +}; +// [`axum_extra`]'s form extractor is required to support repeated keys: +// 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_form_prompt::FieldFormPrompt, + language::Language, + portal::Portal, + workspace::Workspace, + workspace_user_perm::{self, WorkspaceUserPerm}, +}; +use serde::Deserialize; +use sqlx::postgres::types::Oid; +use uuid::Uuid; + +use crate::{ + app::{App, AppDbConn}, + errors::{AppError, bad_request, forbidden}, + navigator::{Navigator, NavigatorPage as _}, + user::CurrentUser, + workspace_pooler::{RoleAssignment, WorkspacePooler}, +}; + +#[derive(Debug, Deserialize)] +pub(super) struct PathParams { + portal_id: Uuid, + rel_oid: u32, + workspace_id: Uuid, +} + +/// HTTP POST handler for setting form prompt content on all fields within a +/// single [`Portal`]. The form body is expected to be an HTTP form encoded +/// mapping of `.` to content. +/// +/// Upon success, the client is redirected back to the portal's form editor +/// page. +#[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. + // FIXME CSRF + + 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?; + + // FIXME assert that fields all belong to the authorized portal + + for (name, content) in form { + let mut name_split = name.split('.'); + let field_id = name_split + .next() + .and_then(|value| Uuid::parse_str(value).ok()) + .ok_or(bad_request!("expected input name to start with "))?; + let language = name_split + .next() + .and_then(|value| Language::from_str(value).ok()) + .ok_or(bad_request!( + "expected input name to be ." + ))?; + if name_split.next().is_some() { + return Err(bad_request!("input name longer than expected")); + } + FieldFormPrompt::upsert() + .field_id(field_id) + .language(language) + .content(content) + .build()? + .execute(&mut app_db) + .await?; + } + + // FIXME redirect to the correct page + Ok(navigator + .portal_page() + .workspace_id(workspace_id) + .rel_oid(Oid(rel_oid)) + .portal_id(portal_id) + .build()? + .redirect_to()) +} diff --git a/interim-server/src/routes/relations_single/update_rel_name_handler.rs b/interim-server/src/routes/relations_single/update_rel_name_handler.rs new file mode 100644 index 0000000..d903792 --- /dev/null +++ b/interim-server/src/routes/relations_single/update_rel_name_handler.rs @@ -0,0 +1,95 @@ +use std::sync::LazyLock; + +use axum::{ + debug_handler, + extract::{Path, State}, + response::Response, +}; +use interim_models::workspace_user_perm::{self, WorkspaceUserPerm}; +use interim_pgtypes::{escape_identifier, pg_class::PgClass}; +use regex::Regex; +use serde::Deserialize; +use sqlx::{postgres::types::Oid, query}; +use uuid::Uuid; +use validator::Validate; + +use crate::{ + app::{App, AppDbConn}, + errors::{AppError, forbidden}, + extractors::ValidatedForm, + navigator::{Navigator, NavigatorPage as _}, + user::CurrentUser, + workspace_pooler::WorkspacePooler, +}; + +static RE_REL_NAME: LazyLock = LazyLock::new(|| Regex::new(r"^[a-z][a-z0-9_]*$").unwrap()); + +#[derive(Debug, Deserialize)] +pub(super) struct PathParams { + rel_oid: u32, + workspace_id: Uuid, +} + +#[derive(Debug, Deserialize, Validate)] +pub(super) struct FormBody { + #[validate(regex(path = *RE_REL_NAME))] + name: String, +} + +/// HTTP POST handler for updating a relation's name. +/// +/// Currently, names must begin with a letter and may only contain lowercase +/// alphanumeric characters and underscores. +#[debug_handler(state = App)] +pub(super) async fn post( + AppDbConn(mut app_db): AppDbConn, + State(mut pooler): State, + CurrentUser(user): CurrentUser, + navigator: Navigator, + Path(PathParams { + rel_oid, + workspace_id, + }): Path, + ValidatedForm(FormBody { name }): ValidatedForm, +) -> 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")); + } + + let mut workspace_client = pooler + .acquire_for( + workspace_id, + crate::workspace_pooler::RoleAssignment::User(user.id), + ) + .await?; + + let rel = PgClass::with_oid(Oid(rel_oid)) + .fetch_one(&mut workspace_client) + .await?; + // FIXME ensure that user has ownership of the table. + + // TODO: move this to a function in `interim-pgtypes`. + query(&format!( + "alter table {ident} rename to {name_esc}", + ident = rel.get_identifier(), + // `_esc` suffixes to make sure that the macro won't fall back to + // similarly named variable(s) in scope if anything inadvertently + // changes. + name_esc = escape_identifier(&name) + )) + .execute(workspace_client.get_conn()) + .await?; + + Ok(navigator + .rel_settings_page() + .workspace_id(workspace_id) + .rel_oid(Oid(rel_oid)) + .build()? + .redirect_to()) +} diff --git a/interim-server/src/routes/relations_single/update_value_handler.rs b/interim-server/src/routes/relations_single/update_value_handler.rs index f8be56f..71471f4 100644 --- a/interim-server/src/routes/relations_single/update_value_handler.rs +++ b/interim-server/src/routes/relations_single/update_value_handler.rs @@ -69,6 +69,11 @@ pub(super) async fn post( // FIXME ensure workspace corresponds to rel/portal, and that user has // permission to access/alter both as needed. + // Prevent users from modifying Phonograph metadata columns. + if form.column.starts_with('_') { + return Err(forbidden!("access denied to update system metadata column")); + } + 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) diff --git a/interim-server/src/routes/workspaces_single/add_table_handler.rs b/interim-server/src/routes/workspaces_single/add_table_handler.rs index 4ff37ba..ef7d6c0 100644 --- a/interim-server/src/routes/workspaces_single/add_table_handler.rs +++ b/interim-server/src/routes/workspaces_single/add_table_handler.rs @@ -83,7 +83,7 @@ pub(super) async fn post( r#" create table {0}.{1} ( _id uuid primary key not null default uuidv7(), - _created_by text not null default current_user, + _created_by text default current_user, _created_at timestamptz not null default now(), _form_session uuid, _form_backlink_portal uuid, diff --git a/interim-server/src/routes/workspaces_single/nav_handler.rs b/interim-server/src/routes/workspaces_single/nav_handler.rs index 27dc67f..0dc21c6 100644 --- a/interim-server/src/routes/workspaces_single/nav_handler.rs +++ b/interim-server/src/routes/workspaces_single/nav_handler.rs @@ -26,6 +26,9 @@ pub(super) struct PathParams { workspace_id: Uuid, } +/// HTTP GET handler for a top-level workspace navigation page. At the moment, +/// this is pretty spare---essentially the workspace navigation sidebar blown +/// up to the size of a full page. #[debug_handler(state = App)] pub(super) async fn get( State(settings): State, diff --git a/interim-server/src/workspace_nav.rs b/interim-server/src/workspace_nav.rs index fb656a9..0b3fefe 100644 --- a/interim-server/src/workspace_nav.rs +++ b/interim-server/src/workspace_nav.rs @@ -9,7 +9,10 @@ use interim_pgtypes::{ use sqlx::postgres::types::Oid; use uuid::Uuid; -use crate::navigator::Navigator; +use crate::{ + navigator::Navigator, + workspace_utils::{RelationPortalSet, fetch_all_accessible_portals}, +}; #[derive(Builder, Clone, Debug, Template)] #[template(path = "workspace_nav.html")] @@ -55,41 +58,35 @@ impl WorkspaceNavBuilder { /// Helper function to populate relations and lenses automatically. /// [`WorkspaceNavBuilder::workspace()`] must be called first, or else this /// method will return an error. + /// + /// WARNING: This assumes that `workspace_client` is authenticated with + /// [`RoleAssignment::User`] for the current user. pub async fn populate_rels( &mut self, app_db: &mut AppDbClient, workspace_client: &mut WorkspaceClient, ) -> Result<&mut Self> { - let rels = PgClass::with_kind_in([PgRelKind::OrdinaryTable]) - .fetch_all(workspace_client) - .await?; - let mut rel_items = Vec::with_capacity(rels.len()); - for rel in rels { - if rel.regnamespace.as_str() != "pg_catalog" - && rel.regnamespace.as_str() != "information_schema" - { - let portals = Portal::belonging_to_workspace( - self.workspace - .as_ref() - .ok_or(WorkspaceNavBuilderError::UninitializedField("workspace"))? - .id, - ) - .belonging_to_rel(rel.oid) - .fetch_all(app_db) - .await?; - rel_items.push(RelationItem { + let workspace_id = self + .workspace + .clone() + .ok_or(WorkspaceNavBuilderError::UninitializedField("workspace"))? + .id; + Ok(self.relations( + fetch_all_accessible_portals(workspace_id, app_db, workspace_client) + .await? + .into_iter() + .map(|RelationPortalSet { rel, portals }| RelationItem { name: rel.relname, oid: rel.oid, portals: portals .into_iter() .map(|portal| PortalItem { - name: portal.name, id: portal.id, + name: portal.name, }) .collect(), - }); - } - } - Ok(self.relations(rel_items)) + }) + .collect(), + )) } } diff --git a/interim-server/src/workspace_utils.rs b/interim-server/src/workspace_utils.rs new file mode 100644 index 0000000..014e4b6 --- /dev/null +++ b/interim-server/src/workspace_utils.rs @@ -0,0 +1,47 @@ +//! This module is named with the `_utils` suffix to help differentiate it from +//! the [`interim_models::workspace`] module, which is also used extensively +//! across the server code. + +use interim_models::{client::AppDbClient, portal::Portal}; +use interim_pgtypes::{ + client::WorkspaceClient, + pg_class::{PgClass, PgRelKind}, +}; +use uuid::Uuid; + +#[derive(Clone, Debug)] +pub(crate) struct RelationPortalSet { + pub(crate) rel: PgClass, + pub(crate) portals: Vec, +} + +/// Fetch a [`Vec`] of [`RelationPortalSet`]s containing all relations the given +/// user has access to, within the given workspace. +/// +/// WARNING: This assumes that `workspace_client` is authenticated with +/// [`RoleAssignment::User`] for the current user. +pub(crate) async fn fetch_all_accessible_portals( + workspace_id: Uuid, + app_db: &mut AppDbClient, + workspace_client: &mut WorkspaceClient, +) -> Result, sqlx::Error> { + let rels = PgClass::with_kind_in([PgRelKind::OrdinaryTable]) + .fetch_all(workspace_client) + .await?; + let mut portal_sets: Vec = Vec::with_capacity(rels.len()); + for rel in rels { + if rel.regnamespace.as_str() != "pg_catalog" + && rel.regnamespace.as_str() != "information_schema" + { + let mut portals = Portal::belonging_to_workspace(workspace_id) + .belonging_to_rel(rel.oid) + .fetch_all(app_db) + .await?; + portals.sort_by_key(|value| value.name.clone()); + portal_sets.push(RelationPortalSet { rel, portals }); + } + } + portal_sets.sort_by_key(|value| value.rel.relname.clone()); + + Ok(portal_sets) +} diff --git a/interim-server/templates/base.html b/interim-server/templates/base.html index 237c174..b4adabe 100644 --- a/interim-server/templates/base.html +++ b/interim-server/templates/base.html @@ -10,7 +10,7 @@ {% if settings.dev != 0 %} {% endif %} diff --git a/interim-server/templates/portal_table.html b/interim-server/templates/portal_table.html index 068ce7d..bcaa8aa 100644 --- a/interim-server/templates/portal_table.html +++ b/interim-server/templates/portal_table.html @@ -4,7 +4,14 @@
@@ -19,4 +26,3 @@ {% endblock %} - diff --git a/interim-server/templates/relations_single/form_index.html b/interim-server/templates/relations_single/form_index.html new file mode 100644 index 0000000..379085a --- /dev/null +++ b/interim-server/templates/relations_single/form_index.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} + +{% block main %} +
+
+
+
+
+ {{ workspace_nav | safe }} +
+
+
+
+
+

Prompts

+
+ {% for field_info in fields %} +
+
+ {{ field_info.field.table_label.clone().unwrap_or(field_info.field.name.clone()) }} +
+
+ + +
+
+ {% endfor %} +
+ +
+
+
+
+

Destinations

+ + + +
+
+
+
+ + +{% endblock %} diff --git a/interim-server/templates/relations_single/portal_settings.html b/interim-server/templates/relations_single/portal_settings.html new file mode 100644 index 0000000..b82172c --- /dev/null +++ b/interim-server/templates/relations_single/portal_settings.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block main %} +
+ +
+
+ {{ workspace_nav | safe }} +
+
+
+
+
+

Name

+ + +
+
+
+
+{% endblock %} diff --git a/interim-server/templates/relations_single/settings.html b/interim-server/templates/relations_single/settings.html new file mode 100644 index 0000000..98f9af7 --- /dev/null +++ b/interim-server/templates/relations_single/settings.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block main %} +
+
+
+
+
+ {{ workspace_nav | safe }} +
+
+
+
+
+

Name

+ + +
+
+
+
+

Sharing

+ +
+
+
+
+{% endblock %} diff --git a/sass/_globals.scss b/sass/_globals.scss index 8cb79e8..c65364f 100644 --- a/sass/_globals.scss +++ b/sass/_globals.scss @@ -1,7 +1,7 @@ @use 'sass:color'; -$button-primary-background: #07f; -$button-primary-color: #fff; +$button-primary-background: #fc0; +$button-primary-color: #000; $button-shadow: 0 0.15rem 0.15rem #3331; $default-border-color: #ccc; $default-border: solid 1px $default-border-color; @@ -14,7 +14,7 @@ $border-radius-rounded-sm: 0.25rem; $border-radius-rounded: 0.5rem; $link-color: #069; $notice-color-info: #39d; -$hover-lightness-scale-factor: -10%; +$hover-lightness-scale-factor: -5%; @mixin reset-button { appearance: none; @@ -33,7 +33,7 @@ $hover-lightness-scale-factor: -10%; @include rounded; box-shadow: $button-shadow; - font-family: $font-family-default; + font-family: $font-family-mono; font-weight: 500; padding: 0.5rem 1rem; transition: background 0.2s ease; @@ -43,6 +43,11 @@ $hover-lightness-scale-factor: -10%; @include button-base; background: $button-primary-background; + border: solid 1px color.scale( + $button-primary-background, + $lightness: -5%, + $space: oklch + ); color: $button-primary-color; &:hover { @@ -51,6 +56,11 @@ $hover-lightness-scale-factor: -10%; $lightness: $hover-lightness-scale-factor, $space: oklch ); + border-color: color.scale( + $button-primary-background, + $lightness: -10%, + $space: oklch + ); } } @@ -58,13 +68,17 @@ $hover-lightness-scale-factor: -10%; @include button-base; background: $button-primary-color; - border: solid 1px $button-primary-background; + border: solid 1px color.scale( + $button-primary-background, + $lightness: -5%, + $space: oklch + ); color: $button-primary-background; &:hover { border-color: color.scale( $button-primary-background, - $lightness: $hover-lightness-scale-factor, + $lightness: -10%, $space: oklch ); } diff --git a/sass/condition-editor.scss b/sass/condition-editor.scss index 8f3a95a..c1c5400 100644 --- a/sass/condition-editor.scss +++ b/sass/condition-editor.scss @@ -62,10 +62,11 @@ &:popover-open { @include globals.rounded; inset: unset; + top: anchor(bottom); border: globals.$popover-border; margin: 0; margin-top: 0.25rem; - position: fixed; + position: absolute; display: flex; flex-direction: column; padding: 0; diff --git a/sass/main.scss b/sass/main.scss index f377d5d..4a343ed 100644 --- a/sass/main.scss +++ b/sass/main.scss @@ -4,6 +4,7 @@ @use 'modern-normalize'; @use 'forms'; @use 'collapsible_menu'; +@use 'condition-editor'; @use 'workspace-nav'; html { @@ -78,6 +79,14 @@ button, input[type="submit"] { 'utilities user' 1fr / 1fr max-content; } + &__toolbar-utilities { + align-items: center; + border-bottom: globals.$default-border; + display: flex; + grid-area: utilities; + justify-content: flex-start; + } + &__sidebar { grid-area: sidebar; width: 15rem; @@ -88,7 +97,7 @@ button, input[type="submit"] { &__main { grid-area: main; - overflow: hidden; + overflow: auto; } } diff --git a/static/dev_reloader.mjs b/static/dev_reloader.mjs index 4bbe5d2..ee9b5c2 100644 --- a/static/dev_reloader.mjs +++ b/static/dev_reloader.mjs @@ -1,9 +1,11 @@ -export function initDevReloader(wsAddr, pollIntervalMs = 500) { +// This used to be based on waiting for a websocket to disconnect, but that was +// flaky. Now we simply poll the shit out of the healthcheck endpoint. +export function initDevReloader(healthzAddr, pollIntervalMs = 500) { // State model is implemented with variables and closures. let auto = true; let connected = false; - let socket = undefined; let initialized = false; + let interval; const button = document.createElement("button"); const indicator = document.createElement("div"); @@ -39,33 +41,39 @@ export function initDevReloader(wsAddr, pollIntervalMs = 500) { function toggleAuto() { auto = !auto; + if (auto && !interval) { + startInterval(); + } else if (!auto && interval) { + clearInterval(interval); + interval = undefined; + connected = false; + initialized = false; + } render(); } - function handleDisconnect() { - if (connected || !initialized) { - console.log("dev-reloader: disconnected"); - connected = false; - socket = undefined; - render(); - const intvl = setInterval(function () { - try { - socket = new WebSocket(wsAddr); - socket.addEventListener("open", function () { + function startInterval() { + interval = setInterval(function () { + fetch(healthzAddr) + .then(function () { + if (!connected) { console.log("dev-reloader: connected"); - clearInterval(intvl); if (auto && initialized) { globalThis.location.reload(); } connected = true; initialized = true; render(); - }); - socket.addEventListener("close", handleDisconnect); - socket.addEventListener("error", handleDisconnect); - } catch { /* no-op */ } - }, pollIntervalMs); - } + } + }) + .catch(function () { + if (connected) { + console.log("dev-reloader: disconnected"); + connected = false; + render(); + } + }); + }, pollIntervalMs); } render(); @@ -76,6 +84,5 @@ export function initDevReloader(wsAddr, pollIntervalMs = 500) { button.appendChild(label); document.body.appendChild(button); - // Simulate disconnect event to initialize. - handleDisconnect(); + startInterval(); } diff --git a/static/favicon.ico b/static/favicon.ico index d212a9c85be93aacff371fbc8cf6e9d959f187b8..b754a58622b62c41a04b08a2acc0eef8bf3b790b 100644 GIT binary patch delta 2703 zcmcgt_gj+(7kwEjqd}r#g$jbJtq4V_7Mw@{jTS^Uik1orh@e1$PopGafUpDv8G$gu z2oUxj2Er5wkU$t=j|>Q5B&;ys_oIKn_x;%OJm=o$-XG5S|ZJVPUwpf%eb|2nE8~nb~FGieO2&Fuyp@ zUzp?Zr>9ueRkftt{G4nOo5h`;Vt2H6wzjmjG=FPuYHe(2rqnmpRFm^aMUIZnadAlz z5iv+4ibN_R67%qQLPJAqRTY^+X(p2!YHI2#DryP}N^)}xh(uB)slaBySiwdo!uQBoo#I$t*v`H zEo5^2*RQC$qN0+uwe^*iRiSWsc6Lv0W^!_Be0*Ybbd1Ry>hB-u>+7e}d+O^c`S}Iu z=^1!@a(sLOrIC?KDsO4+W{xbfxT0=aKPDjLBPQ4ng^7+y_#7P{8Wt586gE0GJw3Di z_{q~(um67O=E0p6`2Odeho?_)$R})Ycn~%W6&Ms65Ez092=@ON^ua$6g~q&l{{fen zl7LH&Pr%2;Cw+;<#e7MK`Wzb>^(7)QCM-NEBs8L^xI8bvIG0qIos*xQkxfV^rV+AI z(=t<1Gm=x%@yUcFd|F~s>d@5A`P&@Kec z@OUf^hdDe9LeR$MCVzgO&EfDD_-pHHOM<02{v3#W|0HVXd9wn&n9K_TCnb)CFBXIN{IA#q_*h6eLkc$Lo z1HrjiaDfO4NYxLgN{hM`sfY6BoD0Ck9i?fXEgG)R>LyN^IjeW(F|ngOT@SQZNV z7>wQB-R! z?bb_%iFaJlD0>zMcdu0<_>CVYs;b=Gp-ug z`5U6eRndC#K$>&m#!aA9&cw{#NlHx>Back>ul<5i71!cF^R+Cn*_O?0mqP4zY0>54 z)VkkN7O)LTQR1a0<6K{*!@qrYxg!~mTFH1Y5x%{6!$jAc`&Snuj)g;%*XR7IjP)`f!s6_K)*=Tbxl?^T{LK z6QLj{l1;JyG&@2MTP~@sX<8#bz8aCXU&s2W=22_CD~p?@YJ*~m)W}l-870fdyOou% z@!8JzyR>ea_DfSGRD`}$DuT?Bf&zE|EakF=%Cv94Nrl{LV^3{!*^Uhhdql`_)BV7^ zv{6mzVtNy7Qr#zR!q#r}c|VFW<@HFBlVhZpIt|=I0~9+_R-WqCqca*2VQ?rDP@Keoq~zD!^@>1lvGZ@oEA!dPWx}z8uA%As=kly!XHr8B{NnRc z&iO$pJl+A>_(7~Q!`Ql20aIBjNQ?f>=Gl7Y542Ba`#+yunD7n;Zp5L+V`Vjwg|F6r zx%=c?^mC2TzCOqjQD$XoCzL&lI~g2PEMffV&Mga^A))cd6K+r?S(~-=<>aJU zG~Bl`G~?(sh}+ywn3d#7tLgt#pJZ9iw#l{&(cX@A6|MwEbsocs>)D(V9L*dUyZMRo zr>y=WC$zRqsw%&53?^JfecTsQJEHt+%LRWifj2^hcFpU}>Pf%y2(5br6^X6m&q<`& z$f*Z-$4eMJ89OOa1E-~r>d%Xk=U>qu zG85)fupz0d=WFsca4p;Zb?9?6!&0Um6aD6hG!=81CNt9T1>;JGr4RAqn2w;2A7r!F*+rn+A%=iyASfh&#v z7bB06v&GR{#RKDm+D`>7ele5@!(+x~SFtc$gQk}gYDvz`0a2IQ?;H{;6HUWT13-G7 z(vqOKVWW|HMDHC~UH|9tw?>IRM~+@dJv6zF{roPoYP}EbDYo_|nS z7mO^umk%pP^>r_FaBxA^rc8I0IQ+ol@AUM9B6-Q(gR(&8_+R%^y4xqOpW1qwX5w9E zwnCQy98=t1o`q-VwB8EmQRNA0rqX8&VpjwJJqdf|y+S6ywoI`k7P2g4D0j7k^?nT= zJy;TUQeL7+?e_;rBcRwfU32s5B?sWV3v}8AP%K0N^&@tY2yHvLz0Uz43{4CQ^zHrs E3snYM2mk;8 delta 269 zcmZqCnkuf?8Q|y6%O%Cdz`(%k>ERLtq!mDzgBeJ=Ea^J7QE@U?Jp)gGPlzi}CXxY@)#FuamSgtC`epDekCd$<}aQa5ny#{x(mB7U=-9iX6W}XE76p z#;|SNNwS*iAv}qiriXT&Yv`L(-Fzlcxo3l!_C>x|UOGCVj(@)0^xq`mXS470(u>ay zoS9rBHRbB;`KI&O*OxDrV_aP>p4|6?`NNg+=!Atof7LVWm6Z3rR+zdI(); let popover_element = $state(); + // Hacky workaround because as of September 2025 implicit anchor association + // is still pretty broken, at least in Firefox. + let anchor_name = $state(`--anchor-${Math.floor(Math.random() * 1000000)}`); const expressions: ReadonlyArray<{ section_label: string; @@ -140,6 +143,7 @@ bind:this={menu_button_element} class="expression-selector__expression-button" onclick={handle_menu_button_click} + style:anchor-name={anchor_name} title={iconography_current?.label} type="button" > @@ -153,6 +157,7 @@ bind:this={popover_element} class="expression-selector__popover" popover="auto" + style:position-anchor={anchor_name} > {#each expressions as section}
    diff --git a/svelte/src/form-transitions-editor.webc.svelte b/svelte/src/form-transitions-editor.webc.svelte new file mode 100644 index 0000000..2186770 --- /dev/null +++ b/svelte/src/form-transitions-editor.webc.svelte @@ -0,0 +1,87 @@ + + + + + + +
    +
      + {#each value as _, i} +
    • +
      + Continue to form: + +
      +
      + if: + + +
      +
    • + {/each} +
    + +
    + If no destinations match, the user will be redirected to a success page. +
    +
    diff --git a/svelte/src/i18n-textarea.webc.svelte b/svelte/src/i18n-textarea.webc.svelte new file mode 100644 index 0000000..3c75a50 --- /dev/null +++ b/svelte/src/i18n-textarea.webc.svelte @@ -0,0 +1,55 @@ + + + + + + +
    + + {#each languages as { code }} + + {/each} +