implement basic form editor
This commit is contained in:
parent
f2d5f9fd01
commit
c1dd95c06d
47 changed files with 1504 additions and 163 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
21
interim-models/src/errors.rs
Normal file
21
interim-models/src/errors.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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<FieldFormPrompt, sqlx::Error> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<PgExpressionAny>,
|
||||
pub struct Replacement {
|
||||
pub dest_id: Uuid,
|
||||
pub condition: Option<PgExpressionAny>,
|
||||
}
|
||||
|
||||
impl Insertable {
|
||||
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<FormTransition, sqlx::Error> {
|
||||
query_as!(
|
||||
FormTransition,
|
||||
r#"
|
||||
#[derive(Builder, Clone, Debug)]
|
||||
pub struct Replace {
|
||||
#[builder(setter(custom))]
|
||||
portal_id: Uuid,
|
||||
|
||||
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)
|
||||
values ($1, $2, $3)
|
||||
returning
|
||||
id,
|
||||
source_id,
|
||||
dest_id,
|
||||
condition as "condition: Json<Option<PgExpressionAny>>"
|
||||
select * from unnest($1, $2, $3)
|
||||
returning id
|
||||
"#,
|
||||
self.source_id,
|
||||
self.dest_id,
|
||||
Json(self.condition) as Json<Option<PgExpressionAny>>,
|
||||
)
|
||||
.bind(
|
||||
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())
|
||||
.await
|
||||
.execute(app_db.get_conn())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
pub mod client;
|
||||
pub mod datum;
|
||||
pub mod errors;
|
||||
pub mod expression;
|
||||
pub mod field;
|
||||
pub mod field_form_prompt;
|
||||
|
|
|
|||
|
|
@ -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<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.
|
||||
#[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<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 {
|
||||
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<Portal, sqlx::Error> {
|
||||
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<Option<PgExpressionAny>>,
|
||||
|
||||
#[builder(default, setter(strip_option = true))]
|
||||
#[validate(regex(path = *RE_PORTAL_NAME))]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
29
interim-server/src/extractors.rs
Normal file
29
interim-server/src/extractors.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Language, String>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
rel_oid: Oid,
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PathParams>,
|
||||
Form(form): Form<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
|
|
@ -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<Presentation, AppError> {
|
||||
|
|
|
|||
174
interim-server/src/routes/relations_single/form_handler.rs
Normal file
174
interim-server/src/routes/relations_single/form_handler.rs
Normal 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()?,
|
||||
))
|
||||
}
|
||||
|
|
@ -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<FieldInfo> = {
|
||||
let fields: Vec<TableFieldInfo> = {
|
||||
let fields: Vec<Field> = Field::belonging_to_portal(portal.id)
|
||||
.fetch_all(&mut app_db)
|
||||
.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 {
|
||||
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<DataRow>,
|
||||
fields: Vec<FieldInfo>,
|
||||
fields: Vec<TableFieldInfo>,
|
||||
pkeys: Vec<String>,
|
||||
}
|
||||
Ok(Json(ResponseBody {
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
.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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<App> {
|
||||
Router::<App>::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<App> {
|
|||
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),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()?,
|
||||
))
|
||||
}
|
||||
|
|
@ -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<PathParams>,
|
||||
Form(form): Form<FormBody>,
|
||||
) -> Result<Response, AppError> {
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()?,
|
||||
))
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Settings>,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
interim-server/src/workspace_utils.rs
Normal file
47
interim-server/src/workspace_utils.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
{% if settings.dev != 0 %}
|
||||
<script type="module">
|
||||
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>
|
||||
{% endif %}
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,14 @@
|
|||
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/viewer.css">
|
||||
<div class="page-grid">
|
||||
<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 class="page-grid__sidebar">
|
||||
<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/filter-menu.webc.mjs"></script>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
|||
52
interim-server/templates/relations_single/form_index.html
Normal file
52
interim-server/templates/relations_single/form_index.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
28
interim-server/templates/relations_single/settings.html
Normal file
28
interim-server/templates/relations_single/settings.html
Normal 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 %}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 5.3 KiB |
|
|
@ -11,6 +11,9 @@
|
|||
|
||||
let menu_button_element = $state<HTMLButtonElement | 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<{
|
||||
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}
|
||||
<ul class="expression-selector__section">
|
||||
|
|
|
|||
87
svelte/src/form-transitions-editor.webc.svelte
Normal file
87
svelte/src/form-transitions-editor.webc.svelte
Normal 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>
|
||||
55
svelte/src/i18n-textarea.webc.svelte
Normal file
55
svelte/src/i18n-textarea.webc.svelte
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue