implement basic dropdown presentation ui

This commit is contained in:
Brent Schroeter 2025-10-21 18:58:09 +00:00
parent 5a24454787
commit cc285aaaaa
13 changed files with 461 additions and 96 deletions

View file

@ -2,7 +2,11 @@ use chrono::{DateTime, Utc};
use derive_builder::Builder;
use interim_pgtypes::pg_attribute::PgAttribute;
use serde::{Deserialize, Serialize};
use sqlx::{Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _, postgres::PgRow, query_as};
use sqlx::Acquire as _;
use sqlx::{
Decode, Postgres, Row as _, TypeInfo as _, ValueRef as _, postgres::PgRow, query, query_as,
types::Json,
};
use thiserror::Error;
use uuid::Uuid;
@ -80,12 +84,19 @@ impl Field {
}
}
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub struct BelongingToPortalQuery {
portal_id: Uuid,
}
impl BelongingToPortalQuery {
pub fn with_id(self, id: Uuid) -> WithIdQuery {
WithIdQuery {
id,
portal_id: self.portal_id,
}
}
pub async fn fetch_all(self, app_db: &mut AppDbClient) -> Result<Vec<Field>, sqlx::Error> {
query_as!(
Field,
@ -94,7 +105,7 @@ select
id,
name,
table_label,
presentation as "presentation: sqlx::types::Json<Presentation>",
presentation as "presentation: Json<Presentation>",
table_width_px
from fields
where portal_id = $1
@ -106,6 +117,34 @@ where portal_id = $1
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct WithIdQuery {
id: Uuid,
portal_id: Uuid,
}
impl WithIdQuery {
pub async fn fetch_one(self, app_db: &mut AppDbClient) -> Result<Field, sqlx::Error> {
query_as!(
Field,
r#"
select
id,
name,
table_label,
presentation as "presentation: Json<Presentation>",
table_width_px
from fields
where portal_id = $1 and id = $2
"#,
self.portal_id,
self.id,
)
.fetch_one(&mut *app_db.conn)
.await
}
}
#[derive(Builder, Clone, Debug)]
pub struct InsertableField {
portal_id: Uuid,
@ -129,13 +168,13 @@ returning
id,
name,
table_label,
presentation as "presentation: sqlx::types::Json<Presentation>",
presentation as "presentation: Json<Presentation>",
table_width_px
"#,
self.portal_id,
self.name,
self.table_label,
sqlx::types::Json::<_>(self.presentation) as sqlx::types::Json<Presentation>,
Json::<_>(self.presentation) as Json<Presentation>,
self.table_width_px,
)
.fetch_one(&mut *app_db.conn)
@ -161,13 +200,45 @@ pub struct Update {
#[builder(default, setter(strip_option))]
presentation: Option<Presentation>,
#[builder(default, setter(strip_option))]
table_width_px: i32,
table_width_px: Option<i32>,
}
impl Update {
pub async fn execute(self, app_db: &mut AppDbClient) -> Result<(), sqlx::Error> {
// TODO: consolidate
todo!();
// TODO: consolidate statements instead of using transaction
let mut tx = app_db.get_conn().begin().await?;
if let Some(table_label) = self.table_label {
query!(
"update fields set table_label = $1 where id = $2",
table_label,
self.id
)
.execute(&mut *tx)
.await?;
}
if let Some(presentation) = self.presentation {
query!(
"update fields set presentation = $1 where id = $2",
Json::<_>(presentation) as Json<Presentation>,
self.id
)
.execute(&mut *tx)
.await?;
}
if let Some(table_width_px) = self.table_width_px {
query!(
"update fields set table_width_px = $1 where id = $2",
table_width_px,
self.id
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
}

View file

@ -8,12 +8,25 @@ pub const RFC_3339_S: &str = "%Y-%m-%dT%H:%M:%S";
#[derive(Clone, Debug, Deserialize, EnumIter, EnumString, PartialEq, Serialize, strum::Display)]
#[serde(tag = "t", content = "c")]
pub enum Presentation {
Dropdown { allow_custom: bool },
Text { input_mode: TextInputMode },
Timestamp { format: String },
Dropdown {
allow_custom: bool,
options: Vec<DropdownOption>,
},
Text {
input_mode: TextInputMode,
},
Timestamp {
format: String,
},
Uuid {},
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct DropdownOption {
pub color: String,
pub value: String,
}
impl Presentation {
/// Returns a SQL fragment for the default data type for creating or
/// altering a backing column, such as "integer", or "timestamptz".

View file

@ -18,6 +18,7 @@ mod extractors;
mod field_info;
mod middleware;
mod navigator;
mod presentation_form;
mod renderable_role_tree;
mod routes;
mod sessions;

View file

@ -0,0 +1,54 @@
use std::iter::zip;
use interim_models::presentation::{DropdownOption, Presentation, RFC_3339_S, TextInputMode};
use serde::Deserialize;
use crate::errors::AppError;
/// A subset of an HTTP form that represents a [`Presentation`] in its component
/// parts. It may be merged into a larger HTTP form using `serde(flatten)`.
#[derive(Clone, Debug, Deserialize)]
pub(crate) struct PresentationForm {
pub(crate) presentation_tag: String,
pub(crate) dropdown_option_colors: Vec<String>,
pub(crate) dropdown_option_values: Vec<String>,
pub(crate) text_input_mode: Option<String>,
pub(crate) timestamp_format: Option<String>,
}
impl TryFrom<PresentationForm> for Presentation {
type Error = AppError;
fn try_from(form_value: PresentationForm) -> Result<Self, Self::Error> {
// Parses the presentation tag into the correct enum variant, but without
// meaningful inner value(s). Match arms should all use the
// `MyVariant { .. }` pattern to pay attention to only the tag.
let presentation_default = Presentation::try_from(form_value.presentation_tag.as_str())?;
Ok(match presentation_default {
Presentation::Dropdown { .. } => Presentation::Dropdown {
allow_custom: false,
options: zip(
form_value.dropdown_option_colors,
form_value.dropdown_option_values,
)
.map(|(color, value)| DropdownOption { color, value })
.collect(),
},
Presentation::Text { .. } => Presentation::Text {
input_mode: form_value
.text_input_mode
.clone()
.map(|value| TextInputMode::try_from(value.as_str()))
.transpose()?
.unwrap_or_default(),
},
Presentation::Timestamp { .. } => Presentation::Timestamp {
format: form_value
.timestamp_format
.clone()
.unwrap_or(RFC_3339_S.to_owned()),
},
Presentation::Uuid { .. } => Presentation::Uuid {},
})
}
}

View file

@ -9,7 +9,7 @@ use axum_extra::extract::Form;
use interim_models::{
field::Field,
portal::Portal,
presentation::{Presentation, RFC_3339_S, TextInputMode},
presentation::Presentation,
workspace::Workspace,
workspace_user_perm::{self, WorkspaceUserPerm},
};
@ -22,6 +22,7 @@ use crate::{
app::{App, AppDbConn},
errors::{AppError, forbidden},
navigator::{Navigator, NavigatorPage},
presentation_form::PresentationForm,
user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler},
};
@ -37,11 +38,10 @@ pub(super) struct PathParams {
#[derive(Debug, Deserialize)]
pub(super) struct FormBody {
name: String,
label: String,
presentation_tag: String,
dropdown_allow_custom: Option<bool>,
text_input_mode: Option<String>,
timestamp_format: Option<String>,
table_label: String,
#[serde(flatten)]
presentation_form: PresentationForm,
}
/// HTTP POST handler for adding a [`Field`] to a [`Portal`]. If the field name
@ -88,7 +88,7 @@ pub(super) async fn post(
.fetch_one(&mut workspace_client)
.await?;
let presentation = try_presentation_from_form(&form)?;
let presentation = Presentation::try_from(form.presentation_form)?;
query(&format!(
"alter table {ident} add column if not exists {col} {typ}",
@ -102,10 +102,10 @@ pub(super) async fn post(
Field::insert()
.portal_id(portal.id)
.name(form.name)
.table_label(if form.label.is_empty() {
.table_label(if form.table_label.is_empty() {
None
} else {
Some(form.label)
Some(form.table_label)
})
.presentation(presentation)
.build()?
@ -120,28 +120,3 @@ pub(super) async fn post(
.build()?
.redirect_to())
}
fn try_presentation_from_form(form: &FormBody) -> Result<Presentation, AppError> {
// Parses the presentation tag into the correct enum variant, but without
// meaningful inner value(s). Match arms should all use the
// `MyVariant { .. }` pattern to pay attention to only the tag.
let presentation_default = Presentation::try_from(form.presentation_tag.as_str())?;
Ok(match presentation_default {
Presentation::Dropdown { .. } => Presentation::Dropdown { allow_custom: true },
Presentation::Text { .. } => Presentation::Text {
input_mode: form
.text_input_mode
.clone()
.map(|value| TextInputMode::try_from(value.as_str()))
.transpose()?
.unwrap_or_default(),
},
Presentation::Timestamp { .. } => Presentation::Timestamp {
format: form
.timestamp_format
.clone()
.unwrap_or(RFC_3339_S.to_owned()),
},
Presentation::Uuid { .. } => Presentation::Uuid {},
})
}

View file

@ -1,29 +1,21 @@
use axum::{
debug_handler,
extract::{Path, State},
response::Response,
};
// [`axum_extra`]'s form extractor is preferred:
// https://docs.rs/axum-extra/0.10.1/axum_extra/extract/struct.Form.html#differences-from-axumextractform
use axum_extra::extract::Form;
use axum::{debug_handler, extract::Path, response::Response};
use interim_models::{
field::Field,
portal::Portal,
presentation::{Presentation, RFC_3339_S, TextInputMode},
workspace::Workspace,
presentation::Presentation,
workspace_user_perm::{self, WorkspaceUserPerm},
};
use interim_pgtypes::{escape_identifier, pg_class::PgClass};
use serde::Deserialize;
use sqlx::{postgres::types::Oid, query};
use sqlx::postgres::types::Oid;
use uuid::Uuid;
use validator::Validate;
use crate::{
app::{App, AppDbConn},
errors::{AppError, forbidden},
extractors::ValidatedForm,
navigator::{Navigator, NavigatorPage},
presentation_form::PresentationForm,
user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler},
};
#[derive(Debug, Deserialize)]
@ -33,14 +25,13 @@ pub(super) struct PathParams {
workspace_id: Uuid,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Validate)]
pub(super) struct FormBody {
field_id: Uuid,
label: String,
presentation_tag: String,
dropdown_allow_custom: Option<bool>,
text_input_mode: Option<String>,
timestamp_format: Option<String>,
table_label: String,
#[serde(flatten)]
presentation_form: PresentationForm,
}
/// HTTP POST handler for updating an existing [`Field`].
@ -49,7 +40,6 @@ pub(super) struct FormBody {
/// [`PathParams`].
#[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,
@ -58,8 +48,14 @@ pub(super) async fn post(
rel_oid,
workspace_id,
}): Path<PathParams>,
Form(form): Form<FormBody>,
ValidatedForm(FormBody {
field_id,
table_label,
presentation_form,
}): ValidatedForm<FormBody>,
) -> Result<Response, AppError> {
// FIXME CSRF
// Check workspace authorization.
let workspace_perms = WorkspaceUserPerm::belonging_to_user(user.id)
.fetch_all(&mut app_db)
@ -72,14 +68,29 @@ pub(super) async fn post(
// 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)
// Ensure field exists and belongs to portal.
Field::belonging_to_portal(portal_id)
.with_id(field_id)
.fetch_one(&mut app_db)
.await?;
let mut workspace_client = workspace_pooler
.acquire_for(workspace.id, RoleAssignment::User(user.id))
Field::update()
.id(field_id)
.table_label(if table_label.is_empty() {
None
} else {
Some(table_label)
})
.presentation(Presentation::try_from(presentation_form)?)
.build()?
.execute(&mut app_db)
.await?;
todo!();
Ok(navigator
.portal_page()
.workspace_id(workspace_id)
.rel_oid(Oid(rel_oid))
.portal_id(portal_id)
.build()?
.redirect_to())
}

View file

@ -37,6 +37,10 @@ button, input[type="submit"] {
src: url("../funnel_sans/funnel_sans_variable.ttf");
}
@view-transitions {
navigation: auto;
}
// https://css-tricks.com/inclusively-hidden/
.sr-only:not(:focus):not(:active) {
clip: rect(0 0 0 0);

View file

@ -84,11 +84,16 @@ $table-border-color: #ccc;
flex: 1;
font-family: globals.$font-family-data;
&--dropdown {
overflow: hidden;
padding: 0 8px;
}
&--text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 0.5rem;
padding: 0 8px;
}
&--uuid {
@ -211,6 +216,59 @@ $table-border-color: #ccc;
height: 6rem;
}
.dropdown-option-badge {
background: #ccc;
border-radius: 999px;
display: block;
width: max-content;
&:not(:has(button)) {
padding: 6px 8px;
}
&--red {
background: #f99;
color: color.scale(#f99, $lightness: -66%, $space: oklch);
}
&--orange {
background: color.adjust(#f99, $hue: 50deg, $space: oklch);
color: color.scale(color.adjust(#f99, $hue: 50deg, $space: oklch), $lightness: -66%, $space: oklch);
}
&--yellow {
background: color.adjust(#f99, $hue: 100deg, $space: oklch);
color: color.scale(color.adjust(#f99, $hue: 100deg, $space: oklch), $lightness: -66%, $space: oklch);
}
&--green {
background: color.adjust(#f99, $hue: 150deg, $space: oklch);
color: color.scale(color.adjust(#f99, $hue: 150deg, $space: oklch), $lightness: -66%, $space: oklch);
}
&--blue {
background: color.adjust(#f99, $hue: 200deg, $space: oklch);
color: color.scale(color.adjust(#f99, $hue: 200deg, $space: oklch), $lightness: -66%, $space: oklch);
}
&--indigo {
background: color.adjust(#f99, $hue: 250deg, $space: oklch);
color: color.scale(color.adjust(#f99, $hue: 250deg, $space: oklch), $lightness: -66%, $space: oklch);
}
&--violet {
background: color.adjust(#f99, $hue: 300deg, $space: oklch);
color: color.scale(color.adjust(#f99, $hue: 300deg, $space: oklch), $lightness: -66%, $space: oklch);
}
button {
@include globals.button-clear;
color: inherit;
padding: 8px;
}
}
.datum-editor {
&__container {
border-left: solid 4px transparent;
@ -250,6 +308,21 @@ $table-border-color: #ccc;
font-family: globals.$font-family-data;
padding: 0.75rem 0.5rem;
}
&__helpers {
grid-area: helpers;
overflow: auto;
}
&__dropdown-options {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-start;
margin: 0;
padding: 0 8px;
}
}
.toolbar-item {

View file

@ -57,6 +57,7 @@
editor_state,
field_info.field.presentation,
);
console.log(value);
on_change?.(value);
}
@ -124,15 +125,20 @@
{@html icon_cube}
{/if}
</button>
{#if field_info.field.presentation.t === "Text" || field_info.field.presentation.t === "Uuid"}
{#if field_info.field.presentation.t === "Dropdown" || field_info.field.presentation.t === "Text" || field_info.field.presentation.t === "Uuid"}
<input
bind:this={text_input_element}
value={editor_state.text_value}
oninput={({ currentTarget: { value } }) => {
if (editor_state) {
editor_state.text_value = value;
handle_input();
oninput={({ currentTarget }) => {
if (!editor_state) {
console.warn("text input oninput() preconditions not met");
return;
}
editor_state.text_value = currentTarget.value;
if (currentTarget.value !== "") {
editor_state.is_null = false;
}
handle_input();
}}
class="datum-editor__text-input"
type="text"
@ -141,6 +147,42 @@
<input value={editor_state.date_value} type="date" />
<input value={editor_state.time_value} type="time" />
{/if}
<div class="datum-editor__helpers"></div>
<div class="datum-editor__helpers" tabindex="-1">
{#if field_info.field.presentation.t === "Dropdown"}
<!-- TODO: This is an awkward way to implement a keyboard-navigable listbox. -->
<menu class="datum-editor__dropdown-options">
{#each field_info.field.presentation.c.options as dropdown_option}
<!-- FIXME: validate or escape dropdown_option.color -->
<li
class={[
"dropdown-option-badge",
`dropdown-option-badge--${dropdown_option.color.toLocaleLowerCase("en-US")}`,
]}
role="option"
aria-selected={dropdown_option.value === value?.c}
>
<button
class="datum-editor__dropdown-option-button"
onclick={() => {
if (!editor_state || !text_input_element) {
console.warn(
"dropdown option onclick() preconditions not met",
);
return;
}
editor_state.text_value = dropdown_option.value;
editor_state.is_null = false;
text_input_element.focus();
handle_input();
}}
type="button"
>
{dropdown_option.value}
</button>
</li>
{/each}
</menu>
{/if}
</div>
{/if}
</div>

View file

@ -58,7 +58,21 @@ export function datum_from_editor_state(
presentation: Presentation,
): Datum | undefined {
if (presentation.t === "Dropdown") {
return { t: "Text", c: value.is_null ? undefined : value.text_value };
if (value.is_null) {
return { t: "Text", c: undefined };
}
if (
!presentation.c.allow_custom &&
presentation.c.options.every((option) =>
option.value !== value.text_value
)
) {
return undefined;
}
return {
t: "Text",
c: value.text_value,
};
}
if (presentation.t === "Text") {
return { t: "Text", c: value.is_null ? undefined : value.text_value };

View file

@ -6,12 +6,23 @@ field. This is typically rendered within a popover component, and within an HTML
-->
<script lang="ts">
import icon_trash from "../assets/heroicons/20/solid/trash.svg?raw";
import {
type Presentation,
all_presentation_tags,
all_text_input_modes,
} from "./presentation.svelte";
const COLORS: string[] = [
"Red",
"Orange",
"Yellow",
"Green",
"Blue",
"Indigo",
"Violet",
] as const;
type Assert<_T extends true> = void;
type Props = {
@ -47,7 +58,7 @@ field. This is typically rendered within a popover component, and within an HTML
tag: (typeof all_presentation_tags)[number],
): Presentation {
if (tag === "Dropdown") {
return { t: "Dropdown", c: { allow_custom: true } };
return { t: "Dropdown", c: { allow_custom: false, options: [] } };
}
if (tag === "Text") {
return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } };
@ -103,7 +114,7 @@ field. This is typically rendered within a popover component, and within an HTML
<input
bind:value={label_value}
class="form-section__input form-section__input--text"
name="label"
name="table_label"
oninput={on_name_input}
type="text"
/>
@ -123,7 +134,65 @@ field. This is typically rendered within a popover component, and within an HTML
{/each}
</select>
</label>
{#if presentation?.t === "Text"}
{#if presentation?.t === "Dropdown"}
<div class="form-section">
<ul class="field-details__dropdown-options-list">
{#each presentation.c.options as option, i}
<li class="field-details__dropdown-option">
<select
class="field-details__dropdown-option-color"
name="dropdown_option_colors"
>
{#each COLORS as color}
<option value={color}>{color}</option>
{/each}
</select>
<input
type="text"
name="dropdown_option_values"
class="form-section__input--text"
value={option.value}
/>
<button
class="button--clear"
onclick={() => {
if (presentation?.t !== "Dropdown") {
console.warn(
"remove dropdown option onclick() preconditions not met",
);
return;
}
presentation.c.options = presentation.c.options.filter(
(_, j) => j !== i,
);
}}
type="button"
>
{@html icon_trash}
</button>
</li>
{/each}
</ul>
<button
class="button--secondary"
onclick={() => {
if (presentation?.t !== "Dropdown") {
console.warn(
"remove dropdown option onclick() preconditions not met",
);
return;
}
presentation.c.options = [
...presentation.c.options,
{ color: COLORS[0], value: "" },
];
}}
type="button"
>
Add option
</button>
</div>
{:else if presentation?.t === "Text"}
<label class="form-section">
<div class="form-section__label">Input Mode</div>
<select

View file

@ -49,10 +49,16 @@ const text_input_mode_schema = z.union([
export type TextInputMode = z.infer<typeof text_input_mode_schema>;
const dropdown_option_schema = z.object({
color: z.string(),
value: z.string(),
});
const presentation_dropdown_schema = z.object({
t: z.literal("Dropdown"),
c: z.object({
allow_custom: z.boolean(),
options: z.array(dropdown_option_schema),
}),
});

View file

@ -218,9 +218,14 @@
}
function try_sync_edit_to_cells() {
if (lazy_data && selections.length === 1) {
if (!lazy_data || selections.length !== 1) {
console.warn("preconditions for try_sync_edit_to_cells() not met");
return;
}
if (editor_value === undefined) {
return;
}
const [sel] = selections;
if (editor_value !== undefined) {
if (sel.region === "main") {
lazy_data.rows[sel.coords[0]].data[sel.coords[1]] = editor_value;
} else if (sel.region === "inserter") {
@ -229,13 +234,12 @@
throw new Error("Unknown region");
}
}
}
}
function try_queue_delta() {
// Copy `editor_value` so that it can be used intuitively within closures.
const editor_value_scoped = editor_value;
if (editor_value_scoped === undefined) {
console.debug("not a valid cell value");
cancel_edit();
} else {
if (selections.length > 0) {
@ -251,6 +255,7 @@
})),
},
];
console.debug("Commit queue:", deltas.commit_queued);
selections = selections.map((sel) => ({
...sel,
original_value: editor_value_scoped,
@ -449,7 +454,34 @@
class="lens-cell__container"
class:lens-cell__container--selected={cell_selected}
>
{#if field.field.presentation.t === "Dropdown"}
{#if cell_data.t === "Text"}
<div
class="lens-cell__content lens-cell__content--dropdown"
class:lens-cell__content--null={cell_data.c === undefined}
>
{#if cell_data.c === undefined}
{@html null_value_html}
{:else}
<!-- FIXME: validate or escape dropdown_option.color -->
<div
class={[
"dropdown-option-badge",
`dropdown-option-badge--${
field.field.presentation.c.options
.find((option) => option.value === cell_data.c)
?.color.toLocaleLowerCase("en-US") ?? "grey"
}`,
]}
>
{cell_data.c}
</div>
{/if}
</div>
{:else}
UNKNOWN
{/if}
{:else if field.field.presentation.t === "Text"}
<div
class="lens-cell__content lens-cell__content--text"
class:lens-cell__content--null={cell_data.c === undefined}
@ -460,7 +492,7 @@
{cell_data.c}
{/if}
</div>
{:else if cell_data.t === "Uuid"}
{:else if field.field.presentation.t === "Uuid"}
<div
class="lens-cell__content lens-cell__content--uuid"
class:lens-cell__content--null={cell_data.c === undefined}