Compare commits
2 commits
292ebd470f
...
341e02a41b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
341e02a41b | ||
|
|
9bee5fdaef |
42 changed files with 449 additions and 1685 deletions
34
phono-models/migrations/20260119172658_remove_forms.down.sql
Normal file
34
phono-models/migrations/20260119172658_remove_forms.down.sql
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
create table if not exists form_transitions (
|
||||||
|
id uuid not null primary key default uuidv7(),
|
||||||
|
source_id uuid not null references portals(id) on delete cascade,
|
||||||
|
dest_id uuid not null references portals(id) on delete restrict,
|
||||||
|
condition jsonb not null default 'null'
|
||||||
|
);
|
||||||
|
create index on form_transitions (source_id);
|
||||||
|
|
||||||
|
create table if not exists field_form_prompts (
|
||||||
|
id uuid not null primary key default uuidv7(),
|
||||||
|
field_id uuid not null references fields(id) on delete cascade,
|
||||||
|
language text not null,
|
||||||
|
content text not null default '',
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
drop table if exists form_touch_points;
|
||||||
|
drop table if exists field_form_prompts;
|
||||||
|
drop table if exists form_transitions;
|
||||||
|
|
@ -1,131 +0,0 @@
|
||||||
use derive_builder::Builder;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::query_as;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{client::AppDbClient, language::Language};
|
|
||||||
|
|
||||||
/// A localized prompt to display above or alongside the form input for the
|
|
||||||
/// given field.
|
|
||||||
///
|
|
||||||
/// There may be zero or one `field_form_prompt` entries for each
|
|
||||||
/// `(field_id, language)` pair. (This uniqueness should be enforced by the
|
|
||||||
/// database.)
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
|
||||||
pub struct FieldFormPrompt {
|
|
||||||
/// Primary key (defaults to UUIDv7).
|
|
||||||
pub id: Uuid,
|
|
||||||
|
|
||||||
/// ID of the field to which this prompt belongs.
|
|
||||||
pub field_id: Uuid,
|
|
||||||
|
|
||||||
/// [ISO 639-3](https://en.wikipedia.org/wiki/List_of_ISO_639-3_codes)
|
|
||||||
/// language code.
|
|
||||||
pub language: Language,
|
|
||||||
|
|
||||||
/// Prompt content for this field, in this language.
|
|
||||||
pub content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FieldFormPrompt {
|
|
||||||
/// Build an insert statement to create a new prompt.
|
|
||||||
pub fn upsert() -> UpsertBuilder {
|
|
||||||
UpsertBuilder::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build an update statement to alter the content of an existing prompt.
|
|
||||||
pub fn update() -> UpdateBuilder {
|
|
||||||
UpdateBuilder::default()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a single-field query by field ID.
|
|
||||||
pub fn belonging_to_field(id: Uuid) -> BelongingToFieldQuery {
|
|
||||||
BelongingToFieldQuery { id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Builder, Clone, Debug)]
|
|
||||||
pub struct Upsert {
|
|
||||||
field_id: Uuid,
|
|
||||||
language: Language,
|
|
||||||
content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
language as "language: Language",
|
|
||||||
content
|
|
||||||
"#,
|
|
||||||
self.field_id,
|
|
||||||
self.language.to_string(),
|
|
||||||
self.content,
|
|
||||||
)
|
|
||||||
.fetch_one(app_db.get_conn())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Builder, Clone, Debug, Default)]
|
|
||||||
pub struct Update {
|
|
||||||
id: Uuid,
|
|
||||||
content: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Update {
|
|
||||||
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<FieldFormPrompt, sqlx::Error> {
|
|
||||||
query_as!(
|
|
||||||
FieldFormPrompt,
|
|
||||||
r#"
|
|
||||||
update field_form_prompts
|
|
||||||
set content = $1
|
|
||||||
where id = $2
|
|
||||||
returning
|
|
||||||
id,
|
|
||||||
field_id,
|
|
||||||
language as "language: Language",
|
|
||||||
content
|
|
||||||
"#,
|
|
||||||
self.content,
|
|
||||||
self.id,
|
|
||||||
)
|
|
||||||
.fetch_one(app_db.get_conn())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct BelongingToFieldQuery {
|
|
||||||
id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BelongingToFieldQuery {
|
|
||||||
pub async fn fetch_all(
|
|
||||||
self,
|
|
||||||
app_db: &mut AppDbClient,
|
|
||||||
) -> Result<Vec<FieldFormPrompt>, sqlx::Error> {
|
|
||||||
query_as!(
|
|
||||||
FieldFormPrompt,
|
|
||||||
r#"
|
|
||||||
select
|
|
||||||
id,
|
|
||||||
field_id,
|
|
||||||
language as "language: Language",
|
|
||||||
content
|
|
||||||
from field_form_prompts
|
|
||||||
where field_id = $1
|
|
||||||
"#,
|
|
||||||
self.id,
|
|
||||||
)
|
|
||||||
.fetch_all(app_db.get_conn())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
use derive_builder::Builder;
|
|
||||||
use serde::Serialize;
|
|
||||||
use sqlx::{Row as _, postgres::PgRow, query, query_as, types::Json};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{client::AppDbClient, expression::PgExpressionAny};
|
|
||||||
|
|
||||||
/// A form transition directionally connects two portals within the same
|
|
||||||
/// workspace, representing a potential navigation of a user between two forms.
|
|
||||||
/// If the user submits a form, form transitions with `source_id` corresponding
|
|
||||||
/// 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, Serialize)]
|
|
||||||
pub struct FormTransition {
|
|
||||||
/// Primary key (defaults to UUIDv7).
|
|
||||||
pub id: Uuid,
|
|
||||||
|
|
||||||
/// When a user is filling out a sequence of forms, this is the ID of the
|
|
||||||
/// portal for which they have just submitted a form for.
|
|
||||||
///
|
|
||||||
/// **Source portal is expected to belong to the same workspace as the
|
|
||||||
/// destination portal.**
|
|
||||||
pub source_id: Uuid,
|
|
||||||
|
|
||||||
/// When a user is filling out a sequence of forms, this is the ID of the
|
|
||||||
/// portal for which they will be directed to if the condition evaluates to
|
|
||||||
/// true.
|
|
||||||
///
|
|
||||||
/// **Destination portal is expected to belong to the same workspace as the
|
|
||||||
/// source portal.**
|
|
||||||
pub dest_id: Uuid,
|
|
||||||
|
|
||||||
/// Represents a semi-arbitrary Postgres expression which will permit this
|
|
||||||
/// transition to be followed, only if the expression evaluates to true at
|
|
||||||
/// the time of the source form's submission.
|
|
||||||
pub condition: Json<Option<PgExpressionAny>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl FormTransition {
|
|
||||||
/// 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.
|
|
||||||
pub fn with_source(id: Uuid) -> WithSourceQuery {
|
|
||||||
WithSourceQuery { id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
|
||||||
pub struct WithSourceQuery {
|
|
||||||
id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WithSourceQuery {
|
|
||||||
pub async fn fetch_all(
|
|
||||||
self,
|
|
||||||
app_db: &mut AppDbClient,
|
|
||||||
) -> Result<Vec<FormTransition>, sqlx::Error> {
|
|
||||||
query_as!(
|
|
||||||
FormTransition,
|
|
||||||
r#"
|
|
||||||
select
|
|
||||||
id,
|
|
||||||
source_id,
|
|
||||||
dest_id,
|
|
||||||
condition as "condition: Json<Option<PgExpressionAny>>"
|
|
||||||
from form_transitions
|
|
||||||
where source_id = $1
|
|
||||||
"#,
|
|
||||||
self.id,
|
|
||||||
)
|
|
||||||
.fetch_all(app_db.get_conn())
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Builder, Clone, Debug)]
|
|
||||||
pub struct Replacement {
|
|
||||||
pub dest_id: Uuid,
|
|
||||||
pub condition: Option<PgExpressionAny>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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)
|
|
||||||
select * from unnest($1, $2, $3)
|
|
||||||
returning id
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.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(),
|
|
||||||
)
|
|
||||||
.execute(app_db.get_conn())
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -23,8 +23,6 @@ pub mod datum;
|
||||||
pub mod errors;
|
pub mod errors;
|
||||||
pub mod expression;
|
pub mod expression;
|
||||||
pub mod field;
|
pub mod field;
|
||||||
pub mod field_form_prompt;
|
|
||||||
pub mod form_transition;
|
|
||||||
pub mod language;
|
pub mod language;
|
||||||
mod macros;
|
mod macros;
|
||||||
pub mod portal;
|
pub mod portal;
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ pub enum RelPermissionKind {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, Hash, PartialEq, Template)]
|
#[derive(Clone, Debug, Eq, Hash, PartialEq, Template)]
|
||||||
#[template(path = "rel_permission.html")]
|
#[template(path = "includes/rel_permission.html")]
|
||||||
pub(crate) struct RelPermission {
|
pub(crate) struct RelPermission {
|
||||||
pub(crate) kind: RelPermissionKind,
|
pub(crate) kind: RelPermissionKind,
|
||||||
pub(crate) rel_oid: Oid,
|
pub(crate) rel_oid: Oid,
|
||||||
|
|
@ -150,7 +150,7 @@ impl TryFrom<Grantee> for User {
|
||||||
/// an "Edit" button which opens a dialog with a form allowing permissions to be
|
/// an "Edit" button which opens a dialog with a form allowing permissions to be
|
||||||
/// assigned across multiple tables within a workspace.
|
/// assigned across multiple tables within a workspace.
|
||||||
#[derive(Clone, Debug, Template)]
|
#[derive(Clone, Debug, Template)]
|
||||||
#[template(path = "workspaces_single/permissions_editor.html")]
|
#[template(path = "includes/permissions_editor.html")]
|
||||||
pub(crate) struct PermissionsEditor {
|
pub(crate) struct PermissionsEditor {
|
||||||
/// User, invite, or service credential being granted permissions.
|
/// User, invite, or service credential being granted permissions.
|
||||||
pub(crate) target: Grantee,
|
pub(crate) target: Grantee,
|
||||||
|
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use askama::Template;
|
|
||||||
use axum::{
|
|
||||||
extract::{Path, State},
|
|
||||||
response::{Html, IntoResponse as _, Response},
|
|
||||||
};
|
|
||||||
use phono_backends::pg_attribute::PgAttribute;
|
|
||||||
use phono_models::{
|
|
||||||
field::Field,
|
|
||||||
field_form_prompt::FieldFormPrompt,
|
|
||||||
language::Language,
|
|
||||||
portal::Portal,
|
|
||||||
presentation::{Presentation, TextInputMode},
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
Settings,
|
|
||||||
app::AppDbConn,
|
|
||||||
errors::{AppError, not_found},
|
|
||||||
field_info::FormFieldInfo,
|
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub(super) struct PathParams {
|
|
||||||
portal_id: Uuid,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// HTTP GET handler for public-facing survey interface. This allows form
|
|
||||||
/// responses to be collected as rows directly into a Phonograph table.
|
|
||||||
pub(super) async fn get(
|
|
||||||
State(settings): State<Settings>,
|
|
||||||
State(mut pooler): State<WorkspacePooler>,
|
|
||||||
AppDbConn(mut app_db): AppDbConn,
|
|
||||||
Path(PathParams { portal_id }): Path<PathParams>,
|
|
||||||
) -> Result<Response, AppError> {
|
|
||||||
// FIXME: Disallow access unless form has been explicitly marked as public.
|
|
||||||
|
|
||||||
// WARNING: Form handler bypasses standard auth checks.
|
|
||||||
let portal = Portal::with_id(portal_id)
|
|
||||||
.fetch_optional(&mut app_db)
|
|
||||||
.await?
|
|
||||||
.ok_or(not_found!("form not found"))?;
|
|
||||||
|
|
||||||
// WARNING: This client is connected with full workspace privileges. Even
|
|
||||||
// more so than usual, the Phonograph server is responsible for ensuring all
|
|
||||||
// auth checks are performed properly.
|
|
||||||
//
|
|
||||||
// TODO: Can this be delegated to a dedicated and less privileged role
|
|
||||||
// instead?
|
|
||||||
let mut workspace_client = pooler
|
|
||||||
.acquire_for(portal.workspace_id, RoleAssignment::Root)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let attrs: HashMap<String, PgAttribute> = PgAttribute::all_for_rel(portal.class_oid)
|
|
||||||
.fetch_all(&mut workspace_client)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.map(|value| (value.attname.clone(), value))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// TODO: implement with sql join
|
|
||||||
let mut fields: Vec<FormFieldInfo> = vec![];
|
|
||||||
for field in Field::belonging_to_portal(portal_id)
|
|
||||||
.fetch_all(&mut app_db)
|
|
||||||
.await?
|
|
||||||
{
|
|
||||||
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(|prompt| (prompt.language, prompt.content))
|
|
||||||
.collect();
|
|
||||||
fields.push(FormFieldInfo {
|
|
||||||
field,
|
|
||||||
column_present: attr.is_some(),
|
|
||||||
has_default: attr.is_some_and(|value| value.atthasdef),
|
|
||||||
not_null: attr.is_some_and(|value| value.attnotnull.is_some_and(|notnull| notnull)),
|
|
||||||
prompts,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut prompts_html: HashMap<String, String> = HashMap::new();
|
|
||||||
for field in fields.iter() {
|
|
||||||
// TODO: i18n
|
|
||||||
let prompt = field
|
|
||||||
.prompts
|
|
||||||
.get(&Language::Eng)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let prompt_md = markdown::to_html(&prompt);
|
|
||||||
// TODO: a11y (input labels)
|
|
||||||
prompts_html.insert(field.field.name.clone(), prompt_md);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
|
||||||
#[template(path = "forms/form_index.html")]
|
|
||||||
struct ResponseTemplate {
|
|
||||||
fields: Vec<FormFieldInfo>,
|
|
||||||
language: Language,
|
|
||||||
portal: Portal,
|
|
||||||
prompts_html: HashMap<String, String>,
|
|
||||||
settings: Settings,
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Html(
|
|
||||||
ResponseTemplate {
|
|
||||||
fields,
|
|
||||||
language: Language::Eng,
|
|
||||||
portal,
|
|
||||||
prompts_html,
|
|
||||||
settings,
|
|
||||||
}
|
|
||||||
.render()?,
|
|
||||||
)
|
|
||||||
.into_response())
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
use axum::{Router, routing::get};
|
|
||||||
use axum_extra::routing::RouterExt as _;
|
|
||||||
|
|
||||||
use crate::app::App;
|
|
||||||
|
|
||||||
mod form_handler;
|
|
||||||
|
|
||||||
pub(super) fn new_router() -> Router<App> {
|
|
||||||
Router::new().route_with_tsr("/{portal_id}/", get(form_handler::get))
|
|
||||||
}
|
|
||||||
|
|
@ -21,7 +21,6 @@ use tower_http::{
|
||||||
|
|
||||||
use crate::{app::App, auth, settings::Settings};
|
use crate::{app::App, auth, settings::Settings};
|
||||||
|
|
||||||
mod forms;
|
|
||||||
mod relations_single;
|
mod relations_single;
|
||||||
mod workspaces_multi;
|
mod workspaces_multi;
|
||||||
mod workspaces_single;
|
mod workspaces_single;
|
||||||
|
|
@ -42,7 +41,6 @@ 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("/f", forms::new_router())
|
|
||||||
.nest("/auth", auth::new_router())
|
.nest("/auth", auth::new_router())
|
||||||
.route("/__dev-healthz", get(|| async move { "ok" }))
|
.route("/__dev-healthz", get(|| async move { "ok" }))
|
||||||
.layer(SetResponseHeaderLayer::if_not_present(
|
.layer(SetResponseHeaderLayer::if_not_present(
|
||||||
|
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
use std::collections::HashMap;
|
|
||||||
|
|
||||||
use askama::Template;
|
|
||||||
use axum::{
|
|
||||||
debug_handler,
|
|
||||||
extract::{Path, State},
|
|
||||||
response::{Html, IntoResponse},
|
|
||||||
};
|
|
||||||
use phono_backends::pg_attribute::PgAttribute;
|
|
||||||
use phono_models::{
|
|
||||||
accessors::{Accessor as _, Actor, portal::PortalAccessor},
|
|
||||||
field::Field,
|
|
||||||
field_form_prompt::FieldFormPrompt,
|
|
||||||
form_transition::FormTransition,
|
|
||||||
language::Language,
|
|
||||||
portal::Portal,
|
|
||||||
workspace::Workspace,
|
|
||||||
};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sqlx::postgres::types::Oid;
|
|
||||||
use strum::IntoEnumIterator as _;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
app::AppDbConn,
|
|
||||||
errors::AppError,
|
|
||||||
field_info::FormFieldInfo,
|
|
||||||
navigator::{Navigator, NavigatorPage as _},
|
|
||||||
settings::Settings,
|
|
||||||
user::CurrentUser,
|
|
||||||
workspace_nav::{NavLocation, RelLocation, WorkspaceNav},
|
|
||||||
workspace_pooler::{RoleAssignment, WorkspacePooler},
|
|
||||||
workspaces::{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 = crate::app::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> {
|
|
||||||
let mut workspace_client = pooler
|
|
||||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let portal = PortalAccessor::new()
|
|
||||||
.id(portal_id)
|
|
||||||
.as_actor(Actor::User(user.id))
|
|
||||||
.verify_workspace_id(workspace_id)
|
|
||||||
.verify_rel_oid(Oid(rel_oid))
|
|
||||||
.verify_rel_ownership()
|
|
||||||
.using_app_db(&mut app_db)
|
|
||||||
.using_workspace_client(&mut workspace_client)
|
|
||||||
.fetch_one()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let workspace = Workspace::with_id(portal.workspace_id)
|
|
||||||
.fetch_one(&mut app_db)
|
|
||||||
.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>,
|
|
||||||
navigator: Navigator,
|
|
||||||
portal: Portal,
|
|
||||||
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(),
|
|
||||||
portal,
|
|
||||||
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.clone())
|
|
||||||
.workspace(workspace)
|
|
||||||
.populate_rels(&mut app_db, &mut workspace_client)
|
|
||||||
.await?
|
|
||||||
.current(NavLocation::Rel(
|
|
||||||
Oid(rel_oid),
|
|
||||||
Some(RelLocation::Portal(portal_id)),
|
|
||||||
))
|
|
||||||
.build()?,
|
|
||||||
navigator,
|
|
||||||
settings,
|
|
||||||
}
|
|
||||||
.render()?,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ 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;
|
||||||
|
|
@ -18,9 +17,7 @@ mod set_filter_handler;
|
||||||
mod settings_handler;
|
mod settings_handler;
|
||||||
mod update_field_handler;
|
mod update_field_handler;
|
||||||
mod update_field_ordinality_handler;
|
mod update_field_ordinality_handler;
|
||||||
mod update_form_transitions_handler;
|
|
||||||
mod update_portal_name_handler;
|
mod update_portal_name_handler;
|
||||||
mod update_prompts_handler;
|
|
||||||
mod update_rel_name_handler;
|
mod update_rel_name_handler;
|
||||||
mod update_values_handler;
|
mod update_values_handler;
|
||||||
|
|
||||||
|
|
@ -58,13 +55,4 @@ pub(super) fn new_router() -> Router<App> {
|
||||||
post(update_values_handler::post),
|
post(update_values_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),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ pub(super) async fn get(
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "portal_table.html")]
|
#[template(path = "relations_single/portal_table.html")]
|
||||||
struct ResponseTemplate {
|
struct ResponseTemplate {
|
||||||
columns: Vec<ColumnInfo>,
|
columns: Vec<ColumnInfo>,
|
||||||
attr_names: Vec<String>,
|
attr_names: Vec<String>,
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
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 phono_models::form_transition::{self, FormTransition};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use sqlx::postgres::types::Oid;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
app::AppDbConn,
|
|
||||||
errors::{AppError, bad_request},
|
|
||||||
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 = crate::app::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> {
|
|
||||||
// FIXME: Check workspace authorization.
|
|
||||||
// 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/")
|
|
||||||
.build()?
|
|
||||||
.redirect_to())
|
|
||||||
}
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
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 phono_models::{
|
|
||||||
accessors::{Accessor, Actor, portal::PortalAccessor},
|
|
||||||
field::Field,
|
|
||||||
field_form_prompt::FieldFormPrompt,
|
|
||||||
language::Language,
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use sqlx::postgres::types::Oid;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
app::AppDbConn,
|
|
||||||
errors::{AppError, bad_request},
|
|
||||||
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 = crate::app::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> {
|
|
||||||
// FIXME CSRF
|
|
||||||
|
|
||||||
let mut workspace_client = workspace_pooler
|
|
||||||
.acquire_for(workspace_id, RoleAssignment::User(user.id))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let portal = PortalAccessor::new()
|
|
||||||
.id(portal_id)
|
|
||||||
.as_actor(Actor::User(user.id))
|
|
||||||
.verify_workspace_id(workspace_id)
|
|
||||||
.verify_rel_oid(Oid(rel_oid))
|
|
||||||
.verify_rel_ownership()
|
|
||||||
.using_app_db(&mut app_db)
|
|
||||||
.using_workspace_client(&mut workspace_client)
|
|
||||||
.fetch_one()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// TODO: This can be sped up somewhat with streams, because queries using
|
|
||||||
// `app_db` can run at the same time as others using `app_db` or
|
|
||||||
// `workspace_client`.
|
|
||||||
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>"))?;
|
|
||||||
|
|
||||||
// For authorization.
|
|
||||||
let _field = Field::belonging_to_portal(portal.id)
|
|
||||||
.with_id(field_id)
|
|
||||||
.fetch_one(&mut app_db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
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)
|
|
||||||
.suffix("form/")
|
|
||||||
.build()?
|
|
||||||
.redirect_to())
|
|
||||||
}
|
|
||||||
|
|
@ -12,7 +12,7 @@ use crate::{
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Builder, Clone, Debug, Template)]
|
#[derive(Builder, Clone, Debug, Template)]
|
||||||
#[template(path = "workspace_nav.html")]
|
#[template(path = "includes/workspace_nav.html")]
|
||||||
pub(crate) struct WorkspaceNav {
|
pub(crate) struct WorkspaceNav {
|
||||||
workspace: Workspace,
|
workspace: Workspace,
|
||||||
relations: Vec<RelationItem>,
|
relations: Vec<RelationItem>,
|
||||||
|
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ portal.name }}{% endblock %}
|
|
||||||
|
|
||||||
{% block head_extras %}
|
|
||||||
<link rel="stylesheet" href="{{ settings.root_path }}/css_dist/form.css">
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<main class="phono-form__container">
|
|
||||||
<form action="submit" method="post">
|
|
||||||
<section>
|
|
||||||
{% for field in fields %}
|
|
||||||
<div class="phono-form__field">
|
|
||||||
{% if field.column_present %}
|
|
||||||
{{ prompts_html.get(field.field.name).cloned().unwrap_or_default() | safe }}
|
|
||||||
{% match field.field.presentation.0 %}
|
|
||||||
{% when Presentation::Text { input_mode } %}
|
|
||||||
{% match input_mode %}
|
|
||||||
{% when TextInputMode::SingleLine %}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
class="form-section__input form-section__input--text"
|
|
||||||
name="{{ field.field.name }}"
|
|
||||||
>
|
|
||||||
{% when TextInputMode::MultiLine %}
|
|
||||||
<textarea name="{{ field.field.name }}"></textarea>
|
|
||||||
{% else %}
|
|
||||||
{% endmatch %}
|
|
||||||
{% else %}
|
|
||||||
{% endmatch %}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</section>
|
|
||||||
<section>
|
|
||||||
<button class="button--primary" type="submit">
|
|
||||||
Continue
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
</form>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
12
phono-server/templates/includes/toolbar_user.html
Normal file
12
phono-server/templates/includes/toolbar_user.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<basic-dropdown alignment="right">
|
||||||
|
<span slot="button-contents" aria-label="Account menu" title="Account menu">
|
||||||
|
<i aria-hidden="true" class="ti ti-user"></i>
|
||||||
|
</span>
|
||||||
|
<menu class="basic-dropdown__menu" slot="popover">
|
||||||
|
<li>
|
||||||
|
<a href="{{ settings.root_path }}/auth/logout" role="button">
|
||||||
|
Log out
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</menu>
|
||||||
|
</basic-dropdown>
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="robots" content="noindex,nofollow">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="icon" type="image/svg" href="{{ settings.root_path }}/phono.svg">
|
||||||
<title>{% block title %}Phonograph{% endblock %}</title>
|
<title>{% block title %}Phonograph{% endblock %}</title>
|
||||||
{% include "meta_tags.html" %}
|
|
||||||
<link rel="stylesheet" href="{{ settings.root_path }}/modern-normalize.min.css">
|
<link rel="stylesheet" href="{{ settings.root_path }}/modern-normalize.min.css">
|
||||||
<link rel="stylesheet" href="{{ settings.root_path }}/main.css">
|
<link rel="stylesheet" href="{{ settings.root_path }}/main.css">
|
||||||
<link rel="stylesheet" href="{{ settings.root_path }}/tabler-icons/webfont/tabler-icons.min.css">
|
<link rel="stylesheet" href="{{ settings.root_path }}/tabler-icons/webfont/tabler-icons.min.css">
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/basic-dropdown.webc.mjs"></script>
|
{%- block head %}{% endblock -%}
|
||||||
{%- block head_extras %}{% endblock -%}
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{% block main %}{% endblock %}
|
{%- block body %}{% endblock -%}
|
||||||
|
<script type="module" src="{{ settings.root_path }}/js_dist/basic-dropdown.webc.mjs"></script>
|
||||||
|
{%- block scripts %}{% endblock -%}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
29
phono-server/templates/layouts/with_sidebar.html
Normal file
29
phono-server/templates/layouts/with_sidebar.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="layout-with-sidebar">
|
||||||
|
<header class="banner" role="note">
|
||||||
|
{%- block banner %}
|
||||||
|
<i class="ti ti-info-square-rounded"></i>
|
||||||
|
This is a technical demo.
|
||||||
|
<a href="https://www.phono.dev/about#demo">
|
||||||
|
Learn more <i class="ti ti-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
{% endblock -%}
|
||||||
|
</header>
|
||||||
|
<header class="toolbar" role="banner">
|
||||||
|
<div class="toolbar__page-actions">
|
||||||
|
{% block toolbar_page_actions %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<div class="toolbar__user">
|
||||||
|
{% include "includes/toolbar_user.html" %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="sidebar">
|
||||||
|
{%- block sidebar %}{% endblock -%}
|
||||||
|
</div>
|
||||||
|
<main>
|
||||||
|
{%- block main %}{% endblock -%}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
26
phono-server/templates/layouts/with_toolbar.html
Normal file
26
phono-server/templates/layouts/with_toolbar.html
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{% extends "layouts/base.html" %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="layout-with-toolbar">
|
||||||
|
<header class="banner" role="note">
|
||||||
|
{%- block banner %}
|
||||||
|
<i class="ti ti-info-square-rounded"></i>
|
||||||
|
This is a technical demo.
|
||||||
|
<a href="https://www.phono.dev/about#demo">
|
||||||
|
Learn more <i class="ti ti-chevron-right"></i>
|
||||||
|
</a>
|
||||||
|
{% endblock -%}
|
||||||
|
</header>
|
||||||
|
<header class="toolbar" role="banner">
|
||||||
|
<div class="toolbar__page-actions">
|
||||||
|
{% block toolbar_page_actions %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
<div class="toolbar__user">
|
||||||
|
{% include "includes/toolbar_user.html" %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{%- block main %}{% endblock -%}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="robots" content="noindex,nofollow">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<link rel="icon" type="image/svg" href="{{ settings.root_path }}/phono.svg">
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block head_extras %}
|
|
||||||
<link rel="stylesheet" href="{{ settings.root_path }}/portal-table.css">
|
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/table-viewer.webc.mjs"></script>
|
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/filter-menu.webc.mjs"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<div class="page-grid">
|
|
||||||
<div class="page-grid__toolbar">
|
|
||||||
<div class="page-grid__toolbar-utilities">
|
|
||||||
<a class="button button--secondary" href="settings" role="button">
|
|
||||||
Portal Settings
|
|
||||||
</a>
|
|
||||||
<filter-menu
|
|
||||||
identifier-hints="{{ attr_names | json }}"
|
|
||||||
initial-value="{{ filter | json }}"
|
|
||||||
></filter-menu>
|
|
||||||
</div>
|
|
||||||
{% include "toolbar_user.html" %}
|
|
||||||
</div>
|
|
||||||
<div class="page-grid__sidebar">
|
|
||||||
<div style="padding: 1rem;">
|
|
||||||
{{ navbar | safe }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<main class="page-grid__main">
|
|
||||||
<table-viewer
|
|
||||||
columns="{{ columns | json }}"
|
|
||||||
{%- if subfilter_str != "" %}
|
|
||||||
subfilter="{{ subfilter_str }}"
|
|
||||||
{% endif -%}
|
|
||||||
></table-viewer>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Email</th>
|
|
||||||
<th>User ID</th>
|
|
||||||
<th>Roles</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for user in users %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ user.user.email }}</td>
|
|
||||||
<td>{{ role_prefix }}{{ user.user.id.simple() }}</td>
|
|
||||||
<td></td>
|
|
||||||
<td>...</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<form method="post" action="">
|
|
||||||
<div>
|
|
||||||
<label for="email">
|
|
||||||
Email Address
|
|
||||||
</label>
|
|
||||||
<input type="text" name="email" inputmode="email">
|
|
||||||
<button type="submit">Invite</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<div class="page-grid">
|
|
||||||
<div class="page-grid__toolbar"></div>
|
|
||||||
<div class="page-grid__sidebar">
|
|
||||||
{{ navbar | safe }}
|
|
||||||
</div>
|
|
||||||
<main class="page-grid__main">
|
|
||||||
<section class="section">
|
|
||||||
<h1>Sharing</h1>
|
|
||||||
</section>
|
|
||||||
<section class="section">
|
|
||||||
<h2>Table Owners</h2>
|
|
||||||
<p class="notice notice--info">
|
|
||||||
Owners are able to edit table structure, including configuring columns,
|
|
||||||
adding, updating, and deleting record data, and dropping the table
|
|
||||||
entirely from the database.
|
|
||||||
</p>
|
|
||||||
<p class="notice notice--info">
|
|
||||||
Each table in Postgres has exactly one owner role, so it's typically
|
|
||||||
best practice to create a dedicated role for this purpose and then grant
|
|
||||||
membership of that role to one or more users.
|
|
||||||
</p>
|
|
||||||
{{ owners | safe }}
|
|
||||||
</section>
|
|
||||||
<section class="section">
|
|
||||||
<h2>Invitations</h2>
|
|
||||||
<a href="{{ settings.root_path }}/d/{{ base.id.simple() }}/r/{{ pg_class.oid.0 }}/rbac/invite">
|
|
||||||
Invite Collaborators
|
|
||||||
</a>
|
|
||||||
<table class="users-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th class="users-table__th">Email</th>
|
|
||||||
{# rolname is intentionally hidden in a submenu (todo), as it is
|
|
||||||
likely to confuse new users #}
|
|
||||||
<th class="users-table__th">Privileges</th>
|
|
||||||
<th class="users-table__th"><span class="sr-only">Actions</span></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{# place invitations at beginning of list as they're liable to cause
|
|
||||||
unpleasant surprises if forgotten #}
|
|
||||||
{% for (email, invites) in invites_by_email %}
|
|
||||||
<tr>
|
|
||||||
<td class="users-table__td">{{ email }}</td>
|
|
||||||
<td class="users-table__td">
|
|
||||||
<code>{% for invite in invites %}{{ invite.privilege }}{% endfor %}</code>
|
|
||||||
</td>
|
|
||||||
<td class="users-table__td"></td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
<section class="section">
|
|
||||||
<h2>Permissions</h2>
|
|
||||||
<ul>
|
|
||||||
{% for acl_tree in acl_trees %}
|
|
||||||
<li>
|
|
||||||
<div>
|
|
||||||
{% for privilege in acl_tree.acl_item.privileges %}{{ privilege.privilege.to_abbrev() }}{% endfor %}
|
|
||||||
</div>
|
|
||||||
{{ acl_tree.grantees | safe }}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<div class="page-grid">
|
|
||||||
<div class="page-grid__toolbar">
|
|
||||||
<div class="page-grid__toolbar-utilities">
|
|
||||||
<a href="{{ navigator.form_page(*portal.id).build()?.get_path() }}">
|
|
||||||
<button class="button--secondary" style="margin-left: 0.5rem;" type="button">View Form</button>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</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 %}
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "layouts/with_sidebar.html" %}
|
||||||
|
|
||||||
{% block main %}
|
{% block toolbar_page_actions %}
|
||||||
<div class="page-grid">
|
|
||||||
<div class="page-grid__toolbar">
|
|
||||||
<div class="page-grid__toolbar-utilities">
|
|
||||||
<a
|
<a
|
||||||
class="button button--secondary"
|
class="button button--secondary"
|
||||||
href="{{ navigator.portal_page()
|
href="{{ navigator.portal_page()
|
||||||
|
|
@ -16,15 +13,14 @@
|
||||||
>
|
>
|
||||||
Back
|
Back
|
||||||
</a>
|
</a>
|
||||||
</div>
|
{% endblock %}
|
||||||
{% include "toolbar_user.html" %}
|
|
||||||
</div>
|
{% block sidebar %}
|
||||||
<div class="page-grid__sidebar">
|
|
||||||
<div style="padding: 1rem;">
|
|
||||||
{{ workspace_nav | safe }}
|
{{ workspace_nav | safe }}
|
||||||
</div>
|
{% endblock %}
|
||||||
</div>
|
|
||||||
<main class="page-grid__main padded padded--lg">
|
{% block main %}
|
||||||
|
<div class="padded padded--lg">
|
||||||
<form method="post" action="update-name">
|
<form method="post" action="update-name">
|
||||||
<section>
|
<section>
|
||||||
<h1>Portal Name</h1>
|
<h1>Portal Name</h1>
|
||||||
|
|
@ -44,6 +40,5 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
33
phono-server/templates/relations_single/portal_table.html
Normal file
33
phono-server/templates/relations_single/portal_table.html
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{% extends "layouts/with_sidebar.html" %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="{{ settings.root_path }}/portal-table.css">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block toolbar_page_actions %}
|
||||||
|
<a class="button button--secondary" href="settings" role="button">
|
||||||
|
Portal Settings
|
||||||
|
</a>
|
||||||
|
<filter-menu
|
||||||
|
identifier-hints="{{ attr_names | json }}"
|
||||||
|
initial-value="{{ filter | json }}"
|
||||||
|
></filter-menu>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block sidebar %}
|
||||||
|
{{ navbar | safe }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main %}
|
||||||
|
<table-viewer
|
||||||
|
columns="{{ columns | json }}"
|
||||||
|
{%- if subfilter_str != "" %}
|
||||||
|
subfilter="{{ subfilter_str }}"
|
||||||
|
{% endif -%}
|
||||||
|
></table-viewer>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script type="module" src="{{ settings.root_path }}/js_dist/table-viewer.webc.mjs"></script>
|
||||||
|
<script type="module" src="{{ settings.root_path }}/js_dist/filter-menu.webc.mjs"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -1,16 +1,11 @@
|
||||||
{% extends "base.html" %}
|
{% extends "layouts/with_sidebar.html" %}
|
||||||
|
|
||||||
|
{% block sidebar %}
|
||||||
|
{{ workspace_nav | safe }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<div class="page-grid">
|
<div class="padded padded--lg">
|
||||||
<div class="page-grid__toolbar">
|
|
||||||
{% include "toolbar_user.html" %}
|
|
||||||
</div>
|
|
||||||
<div class="page-grid__sidebar">
|
|
||||||
<div style="padding: 1rem;">
|
|
||||||
{{ workspace_nav | safe }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<main class="page-grid__main padded padded--lg">
|
|
||||||
<form method="post" action="update-name">
|
<form method="post" action="update-name">
|
||||||
<section>
|
<section>
|
||||||
<h1>Table Name</h1>
|
<h1>Table Name</h1>
|
||||||
|
|
@ -30,6 +25,5 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
<div class="page-grid__toolbar-user">
|
|
||||||
<basic-dropdown alignment="right">
|
|
||||||
<span slot="button-contents" aria-label="Account menu" title="Account menu">
|
|
||||||
<i aria-hidden="true" class="ti ti-user"></i>
|
|
||||||
</span>
|
|
||||||
<menu class="basic-dropdown__menu" slot="popover">
|
|
||||||
<li>
|
|
||||||
<a href="{{ settings.root_path }}/auth/logout" role="button">
|
|
||||||
Log out
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</menu>
|
|
||||||
</basic-dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "layouts/with_toolbar.html" %}
|
||||||
|
|
||||||
{% block head_extras %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<div class="page-grid">
|
|
||||||
<div class="page-grid__toolbar">
|
|
||||||
{% include "toolbar_user.html" %}
|
|
||||||
</div>
|
|
||||||
<main class="page-grid__main">
|
|
||||||
<nav class="workspace-nav" style="position: relative; margin: 0 auto; max-width: 540px;">
|
<nav class="workspace-nav" style="position: relative; margin: 0 auto; max-width: 540px;">
|
||||||
<section class="workspace-nav__section">
|
<section class="workspace-nav__section">
|
||||||
<div class="workspace-nav__heading">
|
<div class="workspace-nav__heading">
|
||||||
|
|
@ -47,6 +39,4 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</nav>
|
</nav>
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,5 @@
|
||||||
{% extends "base.html" %}
|
{% extends "layouts/with_sidebar.html" %}
|
||||||
|
|
||||||
{% block main %}
|
{% block sidebar %}
|
||||||
<div class="page-grid">
|
|
||||||
<div class="page-grid__toolbar">
|
|
||||||
{% include "toolbar_user.html" %}
|
|
||||||
</div>
|
|
||||||
<div class="page-grid__sidebar">
|
|
||||||
<div style="padding: 1rem;">
|
|
||||||
{{ workspace_nav | safe }}
|
{{ workspace_nav | safe }}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<main class="page-grid__main">
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
{% extends "base.html" %}
|
{% extends "layouts/with_sidebar.html" %}
|
||||||
|
|
||||||
{% block head_extras %}
|
{% block toolbar_page_actions %}
|
||||||
<script type="module" src="{{ settings.root_path }}/js_dist/copy-source.webc.mjs"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block main %}
|
|
||||||
<div class="page-grid">
|
|
||||||
<div class="page-grid__toolbar">
|
|
||||||
<div class="page-grid__toolbar-utilities">
|
|
||||||
<basic-dropdown button-class="button--secondary" button-label="New Credential">
|
<basic-dropdown button-class="button--secondary" button-label="New Credential">
|
||||||
<span slot="button-contents">New Credential</span>
|
<span slot="button-contents">New Credential</span>
|
||||||
<div class="padded" slot="popover">
|
<div class="padded" slot="popover">
|
||||||
|
|
@ -16,15 +9,14 @@
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</basic-dropdown>
|
</basic-dropdown>
|
||||||
</div>
|
{% endblock %}
|
||||||
{% include "toolbar_user.html" %}
|
|
||||||
</div>
|
{% block sidebar %}
|
||||||
<div class="page-grid__sidebar">
|
|
||||||
<div style="padding: 1rem;">
|
|
||||||
{{ workspace_nav | safe }}
|
{{ workspace_nav | safe }}
|
||||||
</div>
|
{% endblock %}
|
||||||
</div>
|
|
||||||
<main class="page-grid__main padded padded--lg">
|
{% block main %}
|
||||||
|
<div class="page-grid__main padded padded--lg">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -58,6 +50,9 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
<table>
|
<table>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script type="module" src="{{ settings.root_path }}/js_dist/copy-source.webc.mjs"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,7 @@
|
||||||
{% extends "base.html" %}
|
{% extends "layouts/with_sidebar.html" %}
|
||||||
|
|
||||||
{% block main %}
|
{% block main %}
|
||||||
<div class="page-grid">
|
<div class="padded padded--lg">
|
||||||
<div class="page-grid__toolbar">
|
|
||||||
{% include "toolbar_user.html" %}
|
|
||||||
</div>
|
|
||||||
<div class="page-grid__sidebar">
|
|
||||||
<div style="padding: 1rem;">
|
|
||||||
{{ workspace_nav | safe }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<main class="page-grid__main padded padded--lg">
|
|
||||||
<form method="post" action="update-name">
|
<form method="post" action="update-name">
|
||||||
<section>
|
<section>
|
||||||
<h1>Workspace Name</h1>
|
<h1>Workspace Name</h1>
|
||||||
|
|
@ -98,6 +89,5 @@
|
||||||
<button class="button button--primary" type="submit">Save</button>
|
<button class="button button--primary" type="submit">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@
|
||||||
--default-padding--sm: 8px;
|
--default-padding--sm: 8px;
|
||||||
--default-padding--lg: 32px;
|
--default-padding--lg: 32px;
|
||||||
|
|
||||||
--a-color: #069;
|
--a-color: oklch(from var(--accent-color) 0.5 0.15 calc(h + 135));
|
||||||
|
|
||||||
--button-background--primary: var(--accent-color);
|
--button-background--primary: var(--accent-color);
|
||||||
--button-background--secondary: #fff;
|
--button-background--secondary: #fff;
|
||||||
|
|
@ -51,8 +51,6 @@
|
||||||
--button-padding: var(--button-padding--default);
|
--button-padding: var(--button-padding--default);
|
||||||
--button-shadow: 0 2px 2px #3331;
|
--button-shadow: 0 2px 2px #3331;
|
||||||
|
|
||||||
--notice-color--info: #39d;
|
|
||||||
|
|
||||||
--popover-border-color: var(--default-border-color);
|
--popover-border-color: var(--default-border-color);
|
||||||
--popover-shadow: 0 8px 8px #3333;
|
--popover-shadow: 0 8px 8px #3333;
|
||||||
}
|
}
|
||||||
|
|
@ -251,19 +249,6 @@ a {
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice {
|
|
||||||
border-radius: var(--default-border-radius--rounded);
|
|
||||||
margin: 1rem 0rem;
|
|
||||||
padding: 1rem;
|
|
||||||
max-width: 40rem;
|
|
||||||
|
|
||||||
&.notice--info {
|
|
||||||
border: solid 1px globals.$notice-color-info;
|
|
||||||
background: oklch(from var(--notice-color--info) calc(l * 0.9) c h);
|
|
||||||
color: oklch(from var(--notice-color--info) calc(l * 0.2) c h);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.permissions-list {
|
.permissions-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -343,27 +328,63 @@ a {
|
||||||
|
|
||||||
/* ======== Layout ======== */
|
/* ======== Layout ======== */
|
||||||
|
|
||||||
.page-grid {
|
.layout-with-sidebar, .layout-with-toolbar {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template:
|
grid-template:
|
||||||
'sidebar toolbar' 4rem
|
'banner banner' max-content
|
||||||
|
'sidebar toolbar' 64px
|
||||||
'sidebar main' 1fr / max-content 1fr;
|
'sidebar main' 1fr / max-content 1fr;
|
||||||
|
|
||||||
.page-grid__toolbar {
|
.banner {
|
||||||
|
grid-area: banner;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
grid-area: toolbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
grid-area: sidebar;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
grid-area: main;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.banner {
|
||||||
|
background: oklch(from var(--accent-color) 0.5 0.08 calc(h + 135));
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: oklch(from var(--a-color) 0.9 calc(c * 0.3) h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ti {
|
||||||
|
position: relative;
|
||||||
|
top: 0.05rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 15rem;
|
||||||
|
max-height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
border-right: solid 1px var(--default-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: solid 1px var(--default-border-color);
|
border-bottom: solid 1px var(--default-border-color);
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-area: toolbar;
|
|
||||||
grid-template: 'utilities user' 1fr / 1fr max-content;
|
grid-template: 'utilities user' 1fr / 1fr max-content;
|
||||||
|
|
||||||
.toolbar-item {
|
.toolbar__page-actions {
|
||||||
flex: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-grid__toolbar-utilities {
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
@ -372,7 +393,7 @@ a {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-grid__toolbar-user {
|
.toolbar__user {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
@ -386,24 +407,13 @@ a {
|
||||||
--button-color: var(--button-color--secondary);
|
--button-color: var(--button-color--secondary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-grid__sidebar {
|
|
||||||
grid-area: sidebar;
|
|
||||||
width: 15rem;
|
|
||||||
max-height: 100vh;
|
|
||||||
overflow: auto;
|
|
||||||
border-right: solid 1px var(--default-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-grid__main {
|
|
||||||
grid-area: main;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ======== Workspace Nav ======== */
|
/* ======== Workspace Nav ======== */
|
||||||
|
|
||||||
.workspace-nav {
|
.workspace-nav {
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
:root {
|
:root {
|
||||||
--table-header-border-color: var(--default-border-color);
|
--table-header-border-color: var(--default-border-color);
|
||||||
--table-cell-border-color: oklch(from var(--default-border-color) calc(l * 1.15) c h);
|
--table-cell-border-color: oklch(from var(--default-border-color) calc(l * 1.15) c h);
|
||||||
|
|
||||||
|
--cursor-outline-color: oklch(from var(--accent-color) 0.7 0.45 calc(h + 165));
|
||||||
|
--selection-background-color: oklch(from var(--accent-color) 0.94 0.05 calc(h + 165));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ======== Toolbar ======== */
|
/* ======== Toolbar ======== */
|
||||||
|
|
@ -205,11 +208,11 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&.table-viewer__cell-container--selected {
|
&.table-viewer__cell-container--selected {
|
||||||
background: #07f3;
|
background: var(--selection-background-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.table-viewer__cell-container--cursor {
|
&.table-viewer__cell-container--cursor {
|
||||||
outline: 3px solid #37f;
|
outline: 3px solid var(--cursor-outline-color);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -314,10 +317,10 @@
|
||||||
|
|
||||||
.table-viewer__inserter-submit {
|
.table-viewer__inserter-submit {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: dashed 1px var(--button-background--primary);
|
border: dashed 1px var(--a-color);
|
||||||
border-bottom-right-radius: var(--default-border-radius--rounded-sm);
|
border-bottom-right-radius: var(--default-border-radius--rounded-sm);
|
||||||
border-top-right-radius: var(--default-border-radius--rounded-sm);
|
border-top-right-radius: var(--default-border-radius--rounded-sm);
|
||||||
color: var(--button-background--primary);
|
color: var(--a-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0 var(--default-padding);
|
padding: 0 var(--default-padding);
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
<!--
|
|
||||||
@component
|
|
||||||
|
|
||||||
TODO: Can this component be removed?
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
type CssClass = string | (string | false | null | undefined)[];
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
completions: string[];
|
|
||||||
popover_class?: CssClass;
|
|
||||||
search_input_class?: CssClass;
|
|
||||||
search_input_element?: HTMLInputElement;
|
|
||||||
search_value: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
let {
|
|
||||||
completions,
|
|
||||||
popover_class,
|
|
||||||
search_input_class,
|
|
||||||
search_input_element = $bindable(),
|
|
||||||
search_value = $bindable(),
|
|
||||||
value = $bindable(),
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let focused = $state(false);
|
|
||||||
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)}`);
|
|
||||||
|
|
||||||
function handle_component_focusin() {
|
|
||||||
focused = true;
|
|
||||||
popover_element?.showPopover();
|
|
||||||
}
|
|
||||||
|
|
||||||
function handle_component_focusout() {
|
|
||||||
focused = false;
|
|
||||||
setTimeout(() => {
|
|
||||||
// TODO: There's still an edge case, where a click with a
|
|
||||||
// mousedown-to-mouseup duration greater than the delay here will cause
|
|
||||||
// the popover to hide.
|
|
||||||
if (!focused) {
|
|
||||||
popover_element?.hidePopover();
|
|
||||||
}
|
|
||||||
}, 250);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Wrapping both the search input and the popover in a container element allows us
|
|
||||||
to capture
|
|
||||||
-->
|
|
||||||
<div
|
|
||||||
class="combobox__container"
|
|
||||||
onfocusin={handle_component_focusin}
|
|
||||||
onfocusout={handle_component_focusout}
|
|
||||||
style:anchor-name={anchor_name}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
bind:this={search_input_element}
|
|
||||||
bind:value={search_value}
|
|
||||||
class={search_input_class}
|
|
||||||
oninput={() => {
|
|
||||||
popover_element?.showPopover();
|
|
||||||
}}
|
|
||||||
onkeydown={(ev) => {
|
|
||||||
if (ev.key === "Escape") {
|
|
||||||
popover_element?.hidePopover();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="text"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
bind:this={popover_element}
|
|
||||||
class={popover_class ?? "combobox__popover"}
|
|
||||||
popover="manual"
|
|
||||||
role="listbox"
|
|
||||||
style:position-anchor={anchor_name}
|
|
||||||
>
|
|
||||||
{#each completions as completion}
|
|
||||||
<button
|
|
||||||
aria-selected={value === completion}
|
|
||||||
class="combobox__completion"
|
|
||||||
onclick={() => {
|
|
||||||
value = completion;
|
|
||||||
search_input_element?.focus();
|
|
||||||
popover_element?.hidePopover();
|
|
||||||
}}
|
|
||||||
role="option"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{completion}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.combobox {
|
|
||||||
&__popover {
|
|
||||||
&:popover-open {
|
|
||||||
// @include globals.popover;
|
|
||||||
|
|
||||||
left: anchor(left);
|
|
||||||
position: absolute;
|
|
||||||
top: anchor(bottom);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&__completion {
|
|
||||||
// @include globals.reset-button;
|
|
||||||
|
|
||||||
display: block;
|
|
||||||
padding: 0.5rem;
|
|
||||||
font-weight: normal;
|
|
||||||
text-align: left;
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:focus {
|
|
||||||
background: #0000001f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
<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