implement basic form editor

This commit is contained in:
Brent Schroeter 2025-10-01 22:36:19 -07:00
parent f2d5f9fd01
commit c1dd95c06d
47 changed files with 1504 additions and 163 deletions

2
Cargo.lock generated
View file

@ -1648,6 +1648,7 @@ dependencies = [
"thiserror 2.0.12", "thiserror 2.0.12",
"url", "url",
"uuid", "uuid",
"validator",
] ]
[[package]] [[package]]
@ -1690,6 +1691,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sqlx", "sqlx",
"strum",
"thiserror 2.0.12", "thiserror 2.0.12",
"tokio", "tokio",
"tower", "tower",

View file

@ -20,6 +20,7 @@ reqwest = { version = "0.12.8", features = ["json"] }
serde = { version = "1.0.213", features = ["derive"] } serde = { version = "1.0.213", features = ["derive"] }
serde_json = "1.0.132" serde_json = "1.0.132"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls-ring-native-roots", "postgres", "derive", "uuid", "chrono", "json", "macros"] } 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" thiserror = "2.0.12"
tokio = { version = "1.42.0", features = ["full"] } tokio = { version = "1.42.0", features = ["full"] }
tracing = "0.1.40" tracing = "0.1.40"

View file

@ -12,7 +12,8 @@ regex = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sqlx = { workspace = true } sqlx = { workspace = true }
strum = { version = "0.27.2", features = ["derive"] } strum = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
url = { workspace = true } url = { workspace = true }
uuid = { workspace = true } uuid = { workspace = true }
validator = { workspace = true }

View file

@ -92,3 +92,21 @@ create table if not exists field_form_prompts (
unique (field_id, language) unique (field_id, language)
); );
create index on field_form_prompts (field_id); 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
);

View file

@ -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<validator::ValidationErrors> for QueryError {
fn from(value: validator::ValidationErrors) -> Self {
Self::ValidationErrors(value)
}
}
impl From<sqlx::Error> for QueryError {
fn from(value: sqlx::Error) -> Self {
Self::SqlxError(value)
}
}

View file

@ -29,8 +29,8 @@ pub struct FieldFormPrompt {
impl FieldFormPrompt { impl FieldFormPrompt {
/// Build an insert statement to create a new prompt. /// Build an insert statement to create a new prompt.
pub fn insert() -> InsertableBuilder { pub fn upsert() -> UpsertBuilder {
InsertableBuilder::default() UpsertBuilder::default()
} }
/// Build an update statement to alter the content of an existing prompt. /// Build an update statement to alter the content of an existing prompt.
@ -45,18 +45,20 @@ impl FieldFormPrompt {
} }
#[derive(Builder, Clone, Debug)] #[derive(Builder, Clone, Debug)]
pub struct Insertable { pub struct Upsert {
field_id: Uuid, field_id: Uuid,
language: Language, language: Language,
content: String, content: String,
} }
impl Insertable { impl Upsert {
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<FieldFormPrompt, sqlx::Error> { pub async fn execute(self, app_db: &mut AppDbClient) -> Result<FieldFormPrompt, sqlx::Error> {
query_as!( query_as!(
FieldFormPrompt, FieldFormPrompt,
r#" r#"
insert into field_form_prompts (field_id, language, content) values ($1, $2, $3) 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 returning
id, id,
field_id, field_id,

View file

@ -1,5 +1,6 @@
use derive_builder::Builder; 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 uuid::Uuid;
use crate::{client::AppDbClient, expression::PgExpressionAny}; 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 /// 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 /// 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`. /// used to direct the user to the form corresponding to portal `dest_id`.
#[derive(Clone, Debug)] #[derive(Clone, Debug, Serialize)]
pub struct FormTransition { pub struct FormTransition {
/// Primary key (defaults to UUIDv7). /// Primary key (defaults to UUIDv7).
pub id: Uuid, pub id: Uuid,
@ -37,9 +38,13 @@ pub struct FormTransition {
} }
impl FormTransition { impl FormTransition {
/// Build an insert statement to create a new transtition. /// Build a multi-row update statement to replace all transtitions for a
pub fn insert() -> InsertableBuilder { /// given source portal.
InsertableBuilder::default() 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. /// Build a single-field query by source portal ID.
@ -77,30 +82,81 @@ where source_id = $1
} }
#[derive(Builder, Clone, Debug)] #[derive(Builder, Clone, Debug)]
pub struct Insertable { pub struct Replacement {
source_id: Uuid, pub dest_id: Uuid,
dest_id: Uuid, pub condition: Option<PgExpressionAny>,
condition: Option<PgExpressionAny>,
} }
impl Insertable { #[derive(Builder, Clone, Debug)]
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<FormTransition, sqlx::Error> { pub struct Replace {
query_as!( #[builder(setter(custom))]
FormTransition, portal_id: Uuid,
r#"
replacements: Vec<Replacement>,
}
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<Uuid> = 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) insert into form_transitions (source_id, dest_id, condition)
values ($1, $2, $3) select * from unnest($1, $2, $3)
returning returning id
id,
source_id,
dest_id,
condition as "condition: Json<Option<PgExpressionAny>>"
"#, "#,
self.source_id, )
self.dest_id, .bind(
Json(self.condition) as Json<Option<PgExpressionAny>>, self.replacements
.iter()
.map(|_| self.portal_id)
.collect::<Vec<_>>(),
)
.bind(
self.replacements
.iter()
.map(|value| value.dest_id)
.collect::<Vec<_>>(),
)
.bind(
self.replacements
.iter()
.map(|value| Json(value.condition.clone()))
.collect::<Vec<_>>() as Vec<Json<Option<PgExpressionAny>>>,
)
.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()) .execute(app_db.get_conn())
.await .await?;
Ok(())
} }
} }

View file

@ -6,7 +6,9 @@ use strum::{EnumIter, EnumString};
/// Languages represented as /// Languages represented as
/// [ISO 639-3 codes](https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes). /// [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")] #[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")] #[strum(serialize_all = "lowercase")]
// NOTE: The [`sqlx::Encode`] and [`sqlx::Decode`] derive macros do not seem to // NOTE: The [`sqlx::Encode`] and [`sqlx::Decode`] derive macros do not seem to

View file

@ -1,5 +1,6 @@
pub mod client; pub mod client;
pub mod datum; pub mod datum;
pub mod errors;
pub mod expression; pub mod expression;
pub mod field; pub mod field;
pub mod field_form_prompt; pub mod field_form_prompt;

View file

@ -1,9 +1,16 @@
use std::sync::LazyLock;
use derive_builder::Builder; use derive_builder::Builder;
use regex::Regex;
use serde::Serialize; use serde::Serialize;
use sqlx::{postgres::types::Oid, query, query_as, types::Json}; use sqlx::{postgres::types::Oid, query, query_as, types::Json};
use uuid::Uuid; 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<Regex> =
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. /// A portal is a derivative representation of a Postgres relation.
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
@ -28,13 +35,13 @@ pub struct Portal {
impl Portal { impl Portal {
/// Build an insert statement to create a new portal. /// Build an insert statement to create a new portal.
pub fn insert() -> InsertablePortalBuilder { pub fn insert() -> InsertBuilder {
InsertablePortalBuilder::default() InsertBuilder::default()
} }
/// Build an update statement to alter an existing portal. /// Build an update statement to alter an existing portal.
pub fn update() -> PortalUpdateBuilder { pub fn update() -> UpdateBuilder {
PortalUpdateBuilder::default() UpdateBuilder::default()
} }
/// Build a single-field query by portal ID. /// Build a single-field query by portal ID.
@ -102,6 +109,25 @@ pub struct BelongingToWorkspaceQuery {
} }
impl BelongingToWorkspaceQuery { impl BelongingToWorkspaceQuery {
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result<Vec<Portal>, sqlx::Error> {
query_as!(
Portal,
r#"
select
id,
name,
workspace_id,
class_oid,
table_filter as "table_filter: Json<Option<PgExpressionAny>>"
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 { pub fn belonging_to_rel(self, rel_oid: Oid) -> BelongingToRelQuery {
BelongingToRelQuery { BelongingToRelQuery {
workspace_id: self.workspace_id, workspace_id: self.workspace_id,
@ -145,13 +171,13 @@ pub enum LensDisplayType {
} }
#[derive(Builder, Clone, Debug)] #[derive(Builder, Clone, Debug)]
pub struct InsertablePortal { pub struct Insert {
name: String, name: String,
workspace_id: Uuid, workspace_id: Uuid,
class_oid: Oid, class_oid: Oid,
} }
impl InsertablePortal { impl Insert {
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<Portal, sqlx::Error> { pub async fn execute(self, app_db: &mut AppDbClient) -> Result<Portal, sqlx::Error> {
query_as!( query_as!(
Portal, Portal,
@ -175,15 +201,22 @@ returning
} }
} }
#[derive(Builder, Clone, Debug)] #[derive(Builder, Clone, Debug, Validate)]
pub struct PortalUpdate { pub struct Update {
id: Uuid, id: Uuid,
#[builder(setter(strip_option = true))] #[builder(default, setter(strip_option = true))]
filter: Option<Option<PgExpressionAny>>, filter: Option<Option<PgExpressionAny>>,
#[builder(default, setter(strip_option = true))]
#[validate(regex(path = *RE_PORTAL_NAME))]
name: Option<String>,
} }
impl PortalUpdate { impl Update {
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> { pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), QueryError> {
self.validate()?;
// TODO: consolidate queries
if let Some(filter) = self.filter { if let Some(filter) = self.filter {
query!( query!(
"update portals set table_filter = $1 where id = $2", "update portals set table_filter = $1 where id = $2",
@ -193,6 +226,11 @@ impl PortalUpdate {
.execute(&mut *app_db.conn) .execute(&mut *app_db.conn)
.await?; .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(()) Ok(())
} }
} }

View file

@ -26,6 +26,7 @@ reqwest = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true} serde_json = { workspace = true}
sqlx = { workspace = true } sqlx = { workspace = true }
strum = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tower = "0.5.2" tower = "0.5.2"

View file

@ -2,7 +2,6 @@ use std::fmt::{self, Display};
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use validator::ValidationErrors;
macro_rules! forbidden { macro_rules! forbidden {
($message:literal) => { ($message:literal) => {
@ -26,7 +25,7 @@ macro_rules! not_found {
macro_rules! bad_request { macro_rules! bad_request {
($message:literal) => { ($message:literal) => {
AppError::BadRequest($message.to_owned()) AppError::BadRequest(format!($message))
}; };
($message:literal, $($param:expr),+) => { ($message:literal, $($param:expr),+) => {
@ -48,13 +47,6 @@ pub enum AppError {
TooManyRequests(String), 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 { impl IntoResponse for AppError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self { match self {
@ -99,16 +91,16 @@ impl Display for AppError {
match self { match self {
AppError::InternalServerError(inner) => inner.fmt(f), AppError::InternalServerError(inner) => inner.fmt(f),
AppError::Forbidden(client_message) => { AppError::Forbidden(client_message) => {
write!(f, "ForbiddenError: {}", client_message) write!(f, "ForbiddenError: {client_message}")
} }
AppError::NotFound(client_message) => { AppError::NotFound(client_message) => {
write!(f, "NotFoundError: {}", client_message) write!(f, "NotFoundError: {client_message}")
} }
AppError::BadRequest(client_message) => { AppError::BadRequest(client_message) => {
write!(f, "BadRequestError: {}", client_message) write!(f, "BadRequestError: {client_message}")
} }
AppError::TooManyRequests(client_message) => { AppError::TooManyRequests(client_message) => {
write!(f, "TooManyRequestsError: {}", client_message) write!(f, "TooManyRequestsError: {client_message}")
} }
} }
} }

View file

@ -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<T: Validate>(pub(crate) T);
impl<T, S> FromRequest<S> for ValidatedForm<T>
where
T: DeserializeOwned + Validate,
S: Send + Sync,
{
type Rejection = AppError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
let Form(form) = Form::<T>::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))
}
}

View file

@ -1,9 +1,21 @@
use interim_models::field::Field; use std::collections::HashMap;
use interim_models::{field::Field, language::Language};
use serde::Serialize; use serde::Serialize;
#[derive(Clone, Debug, Serialize)] #[derive(Clone, Debug, Serialize)]
pub struct FieldInfo { pub(crate) struct TableFieldInfo {
pub field: Field, pub(crate) field: Field,
pub has_default: bool, pub(crate) column_present: bool,
pub not_null: 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<Language, String>,
} }

View file

@ -14,6 +14,7 @@ mod app;
mod auth; mod auth;
mod cli; mod cli;
mod errors; mod errors;
mod extractors;
mod field_info; mod field_info;
mod middleware; mod middleware;
mod navigator; mod navigator;
@ -26,6 +27,7 @@ mod worker;
mod workspace_nav; mod workspace_nav;
mod workspace_pooler; mod workspace_pooler;
mod workspace_user_perms; mod workspace_user_perms;
mod workspace_utils;
/// Run CLI /// Run CLI
#[tokio::main] #[tokio::main]

View file

@ -4,7 +4,6 @@ use axum::{
response::{IntoResponse as _, Redirect, Response}, response::{IntoResponse as _, Redirect, Response},
}; };
use derive_builder::Builder; use derive_builder::Builder;
use interim_models::portal::Portal;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
@ -38,15 +37,10 @@ impl Navigator {
} }
} }
pub(crate) fn portal_page(&self, portal: &Portal) -> Self { pub(crate) fn portal_page(&self) -> PortalPageBuilder {
Self { PortalPageBuilder {
sub_path: format!( root_path: Some(self.get_root_path()),
"/w/{0}/r/{1}/p/{2}/", ..Default::default()
portal.workspace_id.simple(),
portal.class_oid.0,
portal.id.simple()
),
..self.clone()
} }
} }
@ -83,7 +77,38 @@ impl FromRequestParts<App> 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<String>,
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 { pub(crate) struct RelSettingsPage {
rel_oid: Oid, rel_oid: Oid,

View file

@ -6,14 +6,12 @@
//! file paths grow exceedingly long. Deeply nested routers may still be //! file paths grow exceedingly long. Deeply nested routers may still be
//! implemented, by use of the `super` keyword. //! implemented, by use of the `super` keyword.
use std::net::SocketAddr;
use axum::{ use axum::{
Router, Router,
extract::{ConnectInfo, State, WebSocketUpgrade, ws::WebSocket}, extract::State,
http::{HeaderValue, header::CACHE_CONTROL}, http::{HeaderValue, header::CACHE_CONTROL},
response::{Redirect, Response}, response::Redirect,
routing::{any, get}, routing::get,
}; };
use tower::ServiceBuilder; use tower::ServiceBuilder;
use tower_http::{ use tower_http::{
@ -45,7 +43,7 @@ pub(crate) fn new_router(app: App) -> Router<()> {
.nest("/workspaces", workspaces_multi::new_router()) .nest("/workspaces", workspaces_multi::new_router())
.nest("/w", workspaces_single::new_router()) .nest("/w", workspaces_single::new_router())
.nest("/auth", auth::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( .layer(SetResponseHeaderLayer::if_not_present(
CACHE_CONTROL, CACHE_CONTROL,
HeaderValue::from_static("no-cache"), HeaderValue::from_static("no-cache"),
@ -116,17 +114,3 @@ pub(crate) fn new_router(app: App) -> Router<()> {
.fallback(|| async move { Redirect::to(&root_path) }) .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<SocketAddr>,
) -> 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 {}
}

View file

@ -15,13 +15,13 @@ use interim_models::{
}; };
use interim_pgtypes::{escape_identifier, pg_class::PgClass}; use interim_pgtypes::{escape_identifier, pg_class::PgClass};
use serde::Deserialize; use serde::Deserialize;
use sqlx::query; use sqlx::{postgres::types::Oid, query};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, forbidden}, errors::{AppError, forbidden},
navigator::Navigator, navigator::{Navigator, NavigatorPage},
user::CurrentUser, user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler}, workspace_pooler::{RoleAssignment, WorkspacePooler},
}; };
@ -57,8 +57,8 @@ pub(super) async fn post(
navigator: Navigator, navigator: Navigator,
Path(PathParams { Path(PathParams {
portal_id, portal_id,
rel_oid,
workspace_id, workspace_id,
..
}): Path<PathParams>, }): Path<PathParams>,
Form(form): Form<FormBody>, Form(form): Form<FormBody>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
@ -111,7 +111,13 @@ pub(super) async fn post(
.insert(&mut app_db) .insert(&mut app_db)
.await?; .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<Presentation, AppError> { fn try_presentation_from_form(form: &FormBody) -> Result<Presentation, AppError> {

View file

@ -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<Settings>,
CurrentUser(user): CurrentUser,
AppDbConn(mut app_db): AppDbConn,
Path(PathParams {
portal_id,
rel_oid,
workspace_id,
}): Path<PathParams>,
navigator: Navigator,
State(mut pooler): State<WorkspacePooler>,
) -> Result<impl IntoResponse, AppError> {
// 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<String, PgAttribute> = 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<FormFieldInfo> = {
let fields: Vec<Field> = Field::belonging_to_portal(portal.id)
.fetch_all(&mut app_db)
.await?;
let mut field_info: Vec<FormFieldInfo> = Vec::with_capacity(fields.len());
for field in fields {
let attr = attrs.get(&field.name);
let prompts: HashMap<Language, String> = 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<FormFieldInfo>,
identifier_hints: Vec<String>,
languages: Vec<LanguageInfo>,
portals: Vec<PortalDisplay>,
settings: Settings,
transitions: Vec<FormTransition>,
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()?,
))
}

View file

@ -14,7 +14,7 @@ use uuid::Uuid;
use crate::{ use crate::{
app::AppDbConn, app::AppDbConn,
errors::AppError, errors::AppError,
field_info::FieldInfo, field_info::TableFieldInfo,
user::CurrentUser, user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler}, workspace_pooler::{RoleAssignment, WorkspacePooler},
}; };
@ -53,15 +53,16 @@ pub(super) async fn get(
.fetch_all(&mut workspace_client) .fetch_all(&mut workspace_client)
.await?; .await?;
let fields: Vec<FieldInfo> = { let fields: Vec<TableFieldInfo> = {
let fields: Vec<Field> = Field::belonging_to_portal(portal.id) let fields: Vec<Field> = Field::belonging_to_portal(portal.id)
.fetch_all(&mut app_db) .fetch_all(&mut app_db)
.await?; .await?;
let mut field_info: Vec<FieldInfo> = Vec::with_capacity(fields.len()); let mut field_info: Vec<TableFieldInfo> = Vec::with_capacity(fields.len());
for field in fields { for field in fields {
if let Some(attr) = attrs.iter().find(|attr| attr.attname == field.name) { if let Some(attr) = attrs.iter().find(|attr| attr.attname == field.name) {
field_info.push(FieldInfo { field_info.push(TableFieldInfo {
field, field,
column_present: true,
has_default: attr.atthasdef, has_default: attr.atthasdef,
not_null: attr.attnotnull.unwrap_or_default(), not_null: attr.attnotnull.unwrap_or_default(),
}); });
@ -133,7 +134,7 @@ pub(super) async fn get(
#[derive(Serialize)] #[derive(Serialize)]
struct ResponseBody { struct ResponseBody {
rows: Vec<DataRow>, rows: Vec<DataRow>,
fields: Vec<FieldInfo>, fields: Vec<TableFieldInfo>,
pkeys: Vec<String>, pkeys: Vec<String>,
} }
Ok(Json(ResponseBody { Ok(Json(ResponseBody {

View file

@ -22,7 +22,7 @@ use uuid::Uuid;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, forbidden}, errors::{AppError, forbidden},
navigator::Navigator, navigator::{Navigator, NavigatorPage as _},
user::CurrentUser, user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler}, workspace_pooler::{RoleAssignment, WorkspacePooler},
}; };
@ -63,6 +63,7 @@ pub(super) async fn post(
} }
// FIXME ensure workspace corresponds to rel/portal, and that user has // FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed. // permission to access/alter both as needed.
// FIXME CSRF
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?; let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
let workspace = Workspace::with_id(portal.workspace_id) let workspace = Workspace::with_id(portal.workspace_id)
@ -78,6 +79,12 @@ pub(super) async fn post(
.await?; .await?;
let col_names: Vec<String> = form.keys().cloned().collect(); let col_names: Vec<String> = 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 let col_list_sql = col_names
.iter() .iter()
.map(|value| escape_identifier(value)) .map(|value| escape_identifier(value))
@ -120,5 +127,11 @@ pub(super) async fn post(
q.execute(workspace_client.get_conn()).await?; 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())
} }

View file

@ -8,19 +8,36 @@ use crate::app::App;
mod add_field_handler; mod add_field_handler;
mod add_portal_handler; mod add_portal_handler;
mod form_handler;
mod get_data_handler; mod get_data_handler;
mod insert_handler; mod insert_handler;
mod portal_handler; mod portal_handler;
mod portal_settings_handler;
mod set_filter_handler; mod set_filter_handler;
mod settings_handler;
mod settings_invite_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; mod update_value_handler;
pub(super) fn new_router() -> Router<App> { pub(super) fn new_router() -> Router<App> {
Router::<App>::new() Router::<App>::new()
.route_with_tsr("/settings/", get(settings_handler::get))
.route("/settings/invite", post(settings_invite_handler::post)) .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("/add-portal", post(add_portal_handler::post))
.route_with_tsr("/p/{portal_id}/", get(portal_handler::get)) .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}/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}/add-field", post(add_field_handler::post))
.route("/p/{portal_id}/insert", post(insert_handler::post)) .route("/p/{portal_id}/insert", post(insert_handler::post))
.route( .route(
@ -28,4 +45,13 @@ pub(super) fn new_router() -> Router<App> {
post(update_value_handler::post), post(update_value_handler::post),
) )
.route("/p/{portal_id}/set-filter", post(set_filter_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),
)
} }

View file

@ -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<Settings>,
CurrentUser(user): CurrentUser,
AppDbConn(mut app_db): AppDbConn,
Path(PathParams {
portal_id,
rel_oid,
workspace_id,
}): Path<PathParams>,
navigator: Navigator,
State(mut pooler): State<WorkspacePooler>,
) -> Result<impl IntoResponse, AppError> {
// 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()?,
))
}

View file

@ -8,12 +8,13 @@ use interim_models::{
workspace_user_perm::{self, WorkspaceUserPerm}, workspace_user_perm::{self, WorkspaceUserPerm},
}; };
use serde::Deserialize; use serde::Deserialize;
use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
app::{App, AppDbConn}, app::{App, AppDbConn},
errors::{AppError, forbidden}, errors::{AppError, forbidden},
navigator::Navigator, navigator::{Navigator, NavigatorPage as _},
user::CurrentUser, user::CurrentUser,
}; };
@ -41,8 +42,8 @@ pub(super) async fn post(
navigator: Navigator, navigator: Navigator,
Path(PathParams { Path(PathParams {
portal_id, portal_id,
rel_oid,
workspace_id, workspace_id,
..
}): Path<PathParams>, }): Path<PathParams>,
Form(form): Form<FormBody>, Form(form): Form<FormBody>,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
@ -68,5 +69,11 @@ pub(super) async fn post(
.execute(&mut app_db) .execute(&mut app_db)
.await?; .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())
} }

View file

@ -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<Settings>,
CurrentUser(user): CurrentUser,
AppDbConn(mut app_db): AppDbConn,
Path(PathParams {
rel_oid,
workspace_id,
}): Path<PathParams>,
navigator: Navigator,
State(mut pooler): State<WorkspacePooler>,
) -> Result<impl IntoResponse, AppError> {
// 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()?,
))
}

View file

@ -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<Uuid>,
condition: Vec<String>,
}
/// 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<PathParams>,
Form(form): Form<FormBody>,
) -> Result<Response, AppError> {
// 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::<Result<Vec<_>, 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())
}

View file

@ -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<WorkspacePooler>,
CurrentUser(user): CurrentUser,
navigator: Navigator,
Path(PathParams {
portal_id,
rel_oid,
workspace_id,
}): Path<PathParams>,
ValidatedForm(FormBody { name }): ValidatedForm<FormBody>,
) -> Result<Response, AppError> {
// 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())
}

View file

@ -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 `<FIELD_ID>.<LANGUAGE_CODE>` 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<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(user): CurrentUser,
navigator: Navigator,
Path(PathParams {
portal_id,
rel_oid,
workspace_id,
}): Path<PathParams>,
Form(form): Form<HashMap<String, String>>,
) -> Result<Response, AppError> {
// 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 <FIELD_ID>"))?;
let language = name_split
.next()
.and_then(|value| Language::from_str(value).ok())
.ok_or(bad_request!(
"expected input name to be <FIELD_ID>.<LANGUAGE_CODE>"
))?;
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())
}

View file

@ -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<Regex> = 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<WorkspacePooler>,
CurrentUser(user): CurrentUser,
navigator: Navigator,
Path(PathParams {
rel_oid,
workspace_id,
}): Path<PathParams>,
ValidatedForm(FormBody { name }): ValidatedForm<FormBody>,
) -> Result<Response, AppError> {
// 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())
}

View file

@ -69,6 +69,11 @@ pub(super) async fn post(
// FIXME ensure workspace corresponds to rel/portal, and that user has // FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed. // permission to access/alter both as needed.
// 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 portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
let workspace = Workspace::with_id(portal.workspace_id) let workspace = Workspace::with_id(portal.workspace_id)
.fetch_one(&mut app_db) .fetch_one(&mut app_db)

View file

@ -83,7 +83,7 @@ pub(super) async fn post(
r#" r#"
create table {0}.{1} ( create table {0}.{1} (
_id uuid primary key not null default uuidv7(), _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(), _created_at timestamptz not null default now(),
_form_session uuid, _form_session uuid,
_form_backlink_portal uuid, _form_backlink_portal uuid,

View file

@ -26,6 +26,9 @@ pub(super) struct PathParams {
workspace_id: Uuid, 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)] #[debug_handler(state = App)]
pub(super) async fn get( pub(super) async fn get(
State(settings): State<Settings>, State(settings): State<Settings>,

View file

@ -9,7 +9,10 @@ use interim_pgtypes::{
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
use crate::navigator::Navigator; use crate::{
navigator::Navigator,
workspace_utils::{RelationPortalSet, fetch_all_accessible_portals},
};
#[derive(Builder, Clone, Debug, Template)] #[derive(Builder, Clone, Debug, Template)]
#[template(path = "workspace_nav.html")] #[template(path = "workspace_nav.html")]
@ -55,41 +58,35 @@ impl WorkspaceNavBuilder {
/// Helper function to populate relations and lenses automatically. /// Helper function to populate relations and lenses automatically.
/// [`WorkspaceNavBuilder::workspace()`] must be called first, or else this /// [`WorkspaceNavBuilder::workspace()`] must be called first, or else this
/// method will return an error. /// 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( pub async fn populate_rels(
&mut self, &mut self,
app_db: &mut AppDbClient, app_db: &mut AppDbClient,
workspace_client: &mut WorkspaceClient, workspace_client: &mut WorkspaceClient,
) -> Result<&mut Self> { ) -> Result<&mut Self> {
let rels = PgClass::with_kind_in([PgRelKind::OrdinaryTable]) let workspace_id = self
.fetch_all(workspace_client) .workspace
.await?; .clone()
let mut rel_items = Vec::with_capacity(rels.len()); .ok_or(WorkspaceNavBuilderError::UninitializedField("workspace"))?
for rel in rels { .id;
if rel.regnamespace.as_str() != "pg_catalog" Ok(self.relations(
&& rel.regnamespace.as_str() != "information_schema" fetch_all_accessible_portals(workspace_id, app_db, workspace_client)
{ .await?
let portals = Portal::belonging_to_workspace( .into_iter()
self.workspace .map(|RelationPortalSet { rel, portals }| RelationItem {
.as_ref()
.ok_or(WorkspaceNavBuilderError::UninitializedField("workspace"))?
.id,
)
.belonging_to_rel(rel.oid)
.fetch_all(app_db)
.await?;
rel_items.push(RelationItem {
name: rel.relname, name: rel.relname,
oid: rel.oid, oid: rel.oid,
portals: portals portals: portals
.into_iter() .into_iter()
.map(|portal| PortalItem { .map(|portal| PortalItem {
name: portal.name,
id: portal.id, id: portal.id,
name: portal.name,
}) })
.collect(), .collect(),
}); })
} .collect(),
} ))
Ok(self.relations(rel_items))
} }
} }

View file

@ -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<Portal>,
}
/// 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<Vec<RelationPortalSet>, sqlx::Error> {
let rels = PgClass::with_kind_in([PgRelKind::OrdinaryTable])
.fetch_all(workspace_client)
.await?;
let mut portal_sets: Vec<RelationPortalSet> = 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)
}

View file

@ -10,7 +10,7 @@
{% if settings.dev != 0 %} {% if settings.dev != 0 %}
<script type="module"> <script type="module">
import { initDevReloader } from "{{ settings.root_path }}/dev_reloader.mjs"; import { initDevReloader } from "{{ settings.root_path }}/dev_reloader.mjs";
initDevReloader("ws://127.0.0.1:8080{{ settings.root_path }}/__dev-healthz"); initDevReloader("http://127.0.0.1:8080{{ settings.root_path }}/__dev-healthz");
</script> </script>
{% endif %} {% endif %}
</body> </body>

View file

@ -4,7 +4,14 @@
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/viewer.css"> <link rel="stylesheet" href="{{ settings.root_path }}/css_dist/viewer.css">
<div class="page-grid"> <div class="page-grid">
<div class="page-grid__toolbar"> <div class="page-grid__toolbar">
<filter-menu identifier-hints="{{ attr_names | json }}" initial-value="{{ filter | json }}"></filter-menu> <div class="page-grid__toolbar-utilities">
<a href="settings">
<button class="button--secondary" style="margin-left: 0.5rem;" type="button">
Portal Settings
</button>
</a>
<filter-menu identifier-hints="{{ attr_names | json }}" initial-value="{{ filter | json }}"></filter-menu>
</div>
</div> </div>
<div class="page-grid__sidebar"> <div class="page-grid__sidebar">
<div style="padding: 1rem;"> <div style="padding: 1rem;">
@ -19,4 +26,3 @@
<script type="module" src="{{ settings.root_path }}/js_dist/field-adder.webc.mjs"></script> <script type="module" src="{{ settings.root_path }}/js_dist/field-adder.webc.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/filter-menu.webc.mjs"></script> <script type="module" src="{{ settings.root_path }}/js_dist/filter-menu.webc.mjs"></script>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block main %}
<div class="page-grid">
<div class="page-grid__toolbar">
</div>
<div class="page-grid__sidebar">
<div style="padding: 1rem;">
{{ workspace_nav | safe }}
</div>
</div>
<main class="page-grid__main">
<form method="post" action="update-prompts">
<section>
<h1>Prompts</h1>
<div>
{% for field_info in fields %}
<div class="form-editor__field">
<div class="form-editor__field-label">
{{ field_info.field.table_label.clone().unwrap_or(field_info.field.name.clone()) }}
</div>
<div class="form-editor__field-prompt">
<i18n-textarea
field-id="{{ field_info.field.id }}"
languages="{{ languages | json }}"
value="{{ field_info.prompts | json }}"
>
</i18n-textarea>
</div>
</div>
{% endfor %}
</div>
<button class="button--primary" type="submit">Save Prompts</button>
</section>
</form>
<form method="post" action="update-form-transitions">
<section>
<h1>Destinations</h1>
<form-transitions-editor
identifier-hints="{{ identifier_hints | json }}"
portals="{{ portals | json }}"
value="{{ transitions | json }}"
>
</form-transitions-editor>
<button class="button--primary" type="submit">Save Destinations</button>
</section>
</form>
</main>
</div>
<script type="module" src="{{ settings.root_path }}/js_dist/i18n-textarea.webc.mjs"></script>
<script type="module" src="{{ settings.root_path }}/js_dist/form-transitions-editor.webc.mjs"></script>
{% endblock %}

View file

@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block main %}
<div class="page-grid">
<div class="page-grid__toolbar">
<a href="{{ navigator.portal_page()
.workspace_id(*portal.workspace_id)
.rel_oid(*portal.class_oid)
.portal_id(*portal.id)
.build()?
.get_path() }}">
<button class="button--secondary" style="margin-left: 0.5rem;" type="button">
Back
</button>
</a>
</div>
<div class="page-grid__sidebar">
<div style="padding: 1rem;">
{{ workspace_nav | safe }}
</div>
</div>
<main class="page-grid__main">
<form method="post" action="update-name">
<section>
<h1>Name</h1>
<input type="text" name="name" value="{{ portal.name }}">
<button class="button--primary" type="submit">Save</button>
</section>
</form>
</main>
</div>
{% endblock %}

View file

@ -0,0 +1,28 @@
{% extends "base.html" %}
{% block main %}
<div class="page-grid">
<div class="page-grid__toolbar">
</div>
<div class="page-grid__sidebar">
<div style="padding: 1rem;">
{{ workspace_nav | safe }}
</div>
</div>
<main class="page-grid__main">
<form method="post" action="update-name">
<section>
<h1>Name</h1>
<input type="text" name="name" value="{{ rel.relname }}">
<button class="button--primary" type="submit">Save</button>
</section>
</form>
<form method="post" action="">
<section>
<h1>Sharing</h1>
<button class="button--primary" type="submit">Save</button>
</section>
</form>
</main>
</div>
{% endblock %}

View file

@ -1,7 +1,7 @@
@use 'sass:color'; @use 'sass:color';
$button-primary-background: #07f; $button-primary-background: #fc0;
$button-primary-color: #fff; $button-primary-color: #000;
$button-shadow: 0 0.15rem 0.15rem #3331; $button-shadow: 0 0.15rem 0.15rem #3331;
$default-border-color: #ccc; $default-border-color: #ccc;
$default-border: solid 1px $default-border-color; $default-border: solid 1px $default-border-color;
@ -14,7 +14,7 @@ $border-radius-rounded-sm: 0.25rem;
$border-radius-rounded: 0.5rem; $border-radius-rounded: 0.5rem;
$link-color: #069; $link-color: #069;
$notice-color-info: #39d; $notice-color-info: #39d;
$hover-lightness-scale-factor: -10%; $hover-lightness-scale-factor: -5%;
@mixin reset-button { @mixin reset-button {
appearance: none; appearance: none;
@ -33,7 +33,7 @@ $hover-lightness-scale-factor: -10%;
@include rounded; @include rounded;
box-shadow: $button-shadow; box-shadow: $button-shadow;
font-family: $font-family-default; font-family: $font-family-mono;
font-weight: 500; font-weight: 500;
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
transition: background 0.2s ease; transition: background 0.2s ease;
@ -43,6 +43,11 @@ $hover-lightness-scale-factor: -10%;
@include button-base; @include button-base;
background: $button-primary-background; background: $button-primary-background;
border: solid 1px color.scale(
$button-primary-background,
$lightness: -5%,
$space: oklch
);
color: $button-primary-color; color: $button-primary-color;
&:hover { &:hover {
@ -51,6 +56,11 @@ $hover-lightness-scale-factor: -10%;
$lightness: $hover-lightness-scale-factor, $lightness: $hover-lightness-scale-factor,
$space: oklch $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; @include button-base;
background: $button-primary-color; 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; color: $button-primary-background;
&:hover { &:hover {
border-color: color.scale( border-color: color.scale(
$button-primary-background, $button-primary-background,
$lightness: $hover-lightness-scale-factor, $lightness: -10%,
$space: oklch $space: oklch
); );
} }

View file

@ -62,10 +62,11 @@
&:popover-open { &:popover-open {
@include globals.rounded; @include globals.rounded;
inset: unset; inset: unset;
top: anchor(bottom);
border: globals.$popover-border; border: globals.$popover-border;
margin: 0; margin: 0;
margin-top: 0.25rem; margin-top: 0.25rem;
position: fixed; position: absolute;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0; padding: 0;

View file

@ -4,6 +4,7 @@
@use 'modern-normalize'; @use 'modern-normalize';
@use 'forms'; @use 'forms';
@use 'collapsible_menu'; @use 'collapsible_menu';
@use 'condition-editor';
@use 'workspace-nav'; @use 'workspace-nav';
html { html {
@ -78,6 +79,14 @@ button, input[type="submit"] {
'utilities user' 1fr / 1fr max-content; '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 { &__sidebar {
grid-area: sidebar; grid-area: sidebar;
width: 15rem; width: 15rem;
@ -88,7 +97,7 @@ button, input[type="submit"] {
&__main { &__main {
grid-area: main; grid-area: main;
overflow: hidden; overflow: auto;
} }
} }

View file

@ -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. // State model is implemented with variables and closures.
let auto = true; let auto = true;
let connected = false; let connected = false;
let socket = undefined;
let initialized = false; let initialized = false;
let interval;
const button = document.createElement("button"); const button = document.createElement("button");
const indicator = document.createElement("div"); const indicator = document.createElement("div");
@ -39,33 +41,39 @@ export function initDevReloader(wsAddr, pollIntervalMs = 500) {
function toggleAuto() { function toggleAuto() {
auto = !auto; auto = !auto;
if (auto && !interval) {
startInterval();
} else if (!auto && interval) {
clearInterval(interval);
interval = undefined;
connected = false;
initialized = false;
}
render(); render();
} }
function handleDisconnect() { function startInterval() {
if (connected || !initialized) { interval = setInterval(function () {
console.log("dev-reloader: disconnected"); fetch(healthzAddr)
connected = false; .then(function () {
socket = undefined; if (!connected) {
render();
const intvl = setInterval(function () {
try {
socket = new WebSocket(wsAddr);
socket.addEventListener("open", function () {
console.log("dev-reloader: connected"); console.log("dev-reloader: connected");
clearInterval(intvl);
if (auto && initialized) { if (auto && initialized) {
globalThis.location.reload(); globalThis.location.reload();
} }
connected = true; connected = true;
initialized = true; initialized = true;
render(); render();
}); }
socket.addEventListener("close", handleDisconnect); })
socket.addEventListener("error", handleDisconnect); .catch(function () {
} catch { /* no-op */ } if (connected) {
}, pollIntervalMs); console.log("dev-reloader: disconnected");
} connected = false;
render();
}
});
}, pollIntervalMs);
} }
render(); render();
@ -76,6 +84,5 @@ export function initDevReloader(wsAddr, pollIntervalMs = 500) {
button.appendChild(label); button.appendChild(label);
document.body.appendChild(button); document.body.appendChild(button);
// Simulate disconnect event to initialize. startInterval();
handleDisconnect();
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -11,6 +11,9 @@
let menu_button_element = $state<HTMLButtonElement | undefined>(); let menu_button_element = $state<HTMLButtonElement | undefined>();
let popover_element = $state<HTMLDivElement | undefined>(); let popover_element = $state<HTMLDivElement | undefined>();
// 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<{ const expressions: ReadonlyArray<{
section_label: string; section_label: string;
@ -140,6 +143,7 @@
bind:this={menu_button_element} bind:this={menu_button_element}
class="expression-selector__expression-button" class="expression-selector__expression-button"
onclick={handle_menu_button_click} onclick={handle_menu_button_click}
style:anchor-name={anchor_name}
title={iconography_current?.label} title={iconography_current?.label}
type="button" type="button"
> >
@ -153,6 +157,7 @@
bind:this={popover_element} bind:this={popover_element}
class="expression-selector__popover" class="expression-selector__popover"
popover="auto" popover="auto"
style:position-anchor={anchor_name}
> >
{#each expressions as section} {#each expressions as section}
<ul class="expression-selector__section"> <ul class="expression-selector__section">

View file

@ -0,0 +1,87 @@
<svelte:options
customElement={{
props: {
identifier_hints: { attribute: "identifier-hints", type: "Array" },
portals: { type: "Array" },
value: { type: "Array" },
},
shadow: "none",
tag: "form-transitions-editor",
}}
/>
<!--
@component
Interactive island for configuring the form navigation that occurs when a user
submits a portal's form.
-->
<script lang="ts">
import { type PgExpressionAny } from "./expression.svelte";
import ExpressionEditor from "./expression-editor.webc.svelte";
type Props = {
identifier_hints: string[];
portals: {
id: string;
display_name: string;
}[];
value: {
condition?: PgExpressionAny;
dest?: string;
}[];
};
let {
identifier_hints = [],
portals = [],
value: initial_value = [],
}: Props = $props();
// Prop of this webc component cannot be bound to `<ExpressionEditor value>`
// without freezing up the child component.
let value = $state(initial_value);
function handle_add_transition_button_click() {
value = [...value, {}];
}
</script>
<div class="form-transitions-editor">
<ul class="form-transitions-editor__transitions-list">
{#each value as _, i}
<li class="form-transitions-editor__transition-item">
<div>
Continue to form:
<select name="dest" bind:value={value[i].dest}>
{#each portals as portal}
<option value={portal.id}>{portal.display_name}</option>
{/each}
</select>
</div>
<div>
if:
<ExpressionEditor
{identifier_hints}
bind:value={value[i].condition}
/>
<input
type="hidden"
name="condition"
value={JSON.stringify(value[i].condition)}
/>
</div>
</li>
{/each}
</ul>
<button
class="button--secondary"
onclick={handle_add_transition_button_click}
type="button"
>
Add Destination
</button>
<div>
If no destinations match, the user will be redirected to a success page.
</div>
</div>

View file

@ -0,0 +1,55 @@
<svelte:options
customElement={{
props: {
field_id: { attribute: "field-id" },
languages: { type: "Array" },
value: { type: "Object" },
},
shadow: "none",
tag: "i18n-textarea",
}}
/>
<!--
@component
A textbox allowing for input in multiple alternative languages, used in the
form editor. The `value` attribute is expected to be a JSON record mapping
language codes to their initial text contents. The `languages` attribute is
expected to be a JSON array containing objects with keys `"code"` and
`"locale_str"`.
Form values are exposed as textarea inputs named as:
`<FIELD_ID>.<LANGUAGE_CODE>`.
-->
<script lang="ts">
const DEFAULT_LANGUAGE = "eng";
type Props = {
field_id: string;
languages: {
code: string;
locale_str: string;
}[];
value: Record<string, string>;
};
let { field_id = "", languages = [], value = {} }: Props = $props();
let visible_language = $state(DEFAULT_LANGUAGE);
</script>
<div class="i18n-textarea">
<select bind:value={visible_language}>
{#each languages as { code, locale_str }}
<option value={code}>{locale_str}</option>
{/each}
</select>
{#each languages as { code }}
<textarea
name={`${field_id}.${code}`}
style:display={visible_language === code ? "block" : "none"}
>{value[code]}</textarea
>
{/each}
</div>