improve field header controls

This commit is contained in:
Brent Schroeter 2025-11-12 23:00:30 +00:00
parent 7791282e91
commit cf4c07f5b8
11 changed files with 434 additions and 215 deletions

View file

@ -87,6 +87,13 @@ impl Field {
}) })
} }
pub async fn delete(&self, app_db: &mut AppDbClient) -> sqlx::Result<()> {
query!("delete from fields where id = $1", self.id)
.execute(app_db.get_conn())
.await?;
Ok(())
}
pub fn belonging_to_portal(portal_id: Uuid) -> BelongingToPortalQuery { pub fn belonging_to_portal(portal_id: Uuid) -> BelongingToPortalQuery {
BelongingToPortalQuery { portal_id } BelongingToPortalQuery { portal_id }
} }

View file

@ -13,6 +13,7 @@ mod get_data_handler;
mod insert_handler; mod insert_handler;
mod portal_handler; mod portal_handler;
mod portal_settings_handler; mod portal_settings_handler;
mod remove_field_handler;
mod set_filter_handler; mod set_filter_handler;
mod settings_handler; mod settings_handler;
mod settings_invite_handler; mod settings_invite_handler;
@ -45,6 +46,10 @@ pub(super) fn new_router() -> Router<App> {
"/p/{portal_id}/update-field", "/p/{portal_id}/update-field",
post(update_field_handler::post), post(update_field_handler::post),
) )
.route(
"/p/{portal_id}/remove-field",
post(remove_field_handler::post),
)
.route( .route(
"/p/{portal_id}/update-field-ordinality", "/p/{portal_id}/update-field-ordinality",
post(update_field_ordinality_handler::post), post(update_field_ordinality_handler::post),

View file

@ -0,0 +1,98 @@
use axum::{
debug_handler,
extract::{Path, State},
response::Response,
};
use interim_models::{field::Field, portal::Portal};
use interim_pgtypes::{escape_identifier, pg_class::PgClass};
use serde::Deserialize;
use sqlx::{postgres::types::Oid, query};
use uuid::Uuid;
use validator::Validate;
use crate::{
app::{App, AppDbConn},
errors::{AppError, bad_request},
extractors::ValidatedForm,
navigator::{Navigator, NavigatorPage},
user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler},
};
#[derive(Debug, Deserialize)]
pub(super) struct PathParams {
portal_id: Uuid,
rel_oid: u32,
workspace_id: Uuid,
}
#[derive(Debug, Deserialize, Validate)]
pub(super) struct FormBody {
field_id: Uuid,
/// Expects "true" for truthy, else falsy.
delete_data: String,
}
/// HTTP POST handler for removing an existing [`Field`].
///
/// This handler expects 3 path parameters with the structure described by
/// [`PathParams`].
#[debug_handler(state = App)]
pub(super) async fn post(
AppDbConn(mut app_db): AppDbConn,
State(mut pooler): State<WorkspacePooler>,
CurrentUser(user): CurrentUser,
navigator: Navigator,
Path(PathParams {
portal_id,
rel_oid,
workspace_id,
}): Path<PathParams>,
ValidatedForm(FormBody {
delete_data,
field_id,
}): ValidatedForm<FormBody>,
) -> Result<Response, AppError> {
// FIXME CSRF
// FIXME ensure workspace corresponds to rel/portal, and that user has
// permission to access/alter both as needed.
// Ensure field exists and belongs to portal.
let field = Field::belonging_to_portal(portal_id)
.with_id(field_id)
.fetch_one(&mut app_db)
.await?;
if delete_data == "true" && field.name.starts_with('_') {
return Err(bad_request!("cannot delete data for a system column"));
}
field.delete(&mut app_db).await?;
if delete_data == "true" {
let mut workspace_client = pooler
.acquire_for(workspace_id, RoleAssignment::User(user.id))
.await?;
let portal = Portal::with_id(portal_id).fetch_one(&mut app_db).await?;
let rel = PgClass::with_oid(portal.class_oid)
.fetch_one(&mut workspace_client)
.await?;
query(&format!(
"alter table {ident} drop column if exists {col_esc}",
ident = rel.get_identifier(),
col_esc = escape_identifier(&field.name),
))
.execute(workspace_client.get_conn())
.await?;
}
Ok(navigator
.portal_page()
.workspace_id(workspace_id)
.rel_oid(Oid(rel_oid))
.portal_id(portal_id)
.build()?
.redirect_to())
}

View file

@ -174,62 +174,6 @@ button, input[type="submit"] {
} }
} }
// TODO: can this be removed?
.button-menu {
&__toggle-button {
@include globals.button-outline;
align-items: center;
display: flex;
&-icon {
display: flex;
svg path {
stroke: currentColor;
}
}
}
&__popover {
&:popover-open {
@include globals.popover;
width: 16rem;
// FIXME: This makes button border radius work correctly, but also hides
// the outline that appears when each button is focused, particularly
// when there is only one button present.
overflow: hidden;
}
}
// Palindrome humor! Anyone? No? Okay nvm.
&__unem-nottub {
@include globals.button-clear;
border-radius: 0;
padding: 1rem;
text-align: left;
}
}
.combobox {
&__popover:popover-open {
@include globals.popover;
padding: 0;
}
&__completion {
@include globals.reset-button;
display: block;
padding: 0.5rem;
font-weight: normal;
text-align: left;
width: 100%;
&:hover, &:focus {
background: #0000001f;
}
}
}
.table { .table {
border-collapse: collapse; border-collapse: collapse;
@ -248,7 +192,11 @@ button, input[type="submit"] {
} }
} }
.dialog:popover-open { .phono-popover:popover-open {
@include globals.popover;
}
.dialog:popover-open, .dialog:open {
@include globals.rounded; @include globals.rounded;
background: #fff; background: #fff;

View file

@ -27,9 +27,12 @@ $table-border-color: #ccc;
width: 100%; width: 100%;
&__headers { &__headers {
align-items: stretch;
display: flex; display: flex;
grid-area: headers; grid-area: headers;
align-items: stretch; // Ensure that there will be enough space on the right for popovers to
// render without overflowing the container.
padding-right: 480px;
} }
&__main { &__main {
@ -73,7 +76,6 @@ $table-border-color: #ccc;
} }
&--cursor { &--cursor {
background: transparent;
outline: 3px solid #37f; outline: 3px solid #37f;
outline-offset: -2px; outline-offset: -2px;
} }

View file

@ -1,5 +1,6 @@
<svelte:options <svelte:options
customElement={{ customElement={{
// `shadowRoot` field must remain as the default, else named slots break.
props: { props: {
button_aria_label: { attribute: "button-aria-label" }, button_aria_label: { attribute: "button-aria-label" },
button_class: { attribute: "button-class" }, button_class: { attribute: "button-class" },
@ -9,12 +10,28 @@
}} }}
/> />
<!--
@component
A button with an associated popover, which can be styled for a variety of
purposes, such as menus, confirmation popups, and so on.
When used as a web component, this component is rendered with a shadow root in
order to correctly support named slots. As a result, it bundles its own
stylesheet, which is merely a copy of the "main" application stylesheet.
## Props
- `alignment`: When set to "right", the popover aligns with the right edge of
the trigger. Otherwise, it aligns with the left.
-->
<script lang="ts"> <script lang="ts">
type Props = { type Props = {
alignment?: string; alignment?: string;
button_aria_label?: string; button_aria_label?: string;
button_class?: string; button_class?: string;
on_toggle?(ev: ToggleEvent): void; on_toggle?(ev: ToggleEvent): unknown;
}; };
let { let {
@ -23,6 +40,7 @@
button_class = "button--secondary", button_class = "button--secondary",
on_toggle, on_toggle,
}: Props = $props(); }: Props = $props();
let popover_element: HTMLElement | undefined = $state(); let popover_element: HTMLElement | undefined = $state();
// Hacky workaround because as of September 2025 implicit anchor association // Hacky workaround because as of September 2025 implicit anchor association
// is still pretty broken, at least in Firefox. // is still pretty broken, at least in Firefox.

View file

@ -1,48 +0,0 @@
<script lang="ts">
type Props = {
label: string;
on_click(value: string): void;
options: {
label: string;
value: string;
}[];
};
let { label, on_click, options }: Props = $props();
let toggle_button_element = $state<HTMLButtonElement | undefined>();
let popover_element = $state<HTMLDivElement | undefined>();
function handle_toggle_button_click() {
popover_element?.togglePopover();
}
</script>
<div style:display="inline-block">
<button
bind:this={toggle_button_element}
class="button-menu__toggle-button"
onclick={handle_toggle_button_click}
type="button"
>
<div>{label}</div>
<div class="button-menu__toggle-button-icon" aria-hidden="true">
<i class="ti ti-chevron-down"></i>
</div>
</button>
<div bind:this={popover_element} class="button-menu__popover" popover="auto">
{#each options as option}
<button
class="button-menu__unem-nottub"
onclick={() => {
popover_element?.hidePopover();
toggle_button_element?.focus();
on_click(option.value);
}}
type="button"
>
{option.label}
</button>
{/each}
</div>
</div>

View file

@ -1,3 +1,8 @@
<!--
@component
TODO: Can this component be removed?
-->
<script lang="ts"> <script lang="ts">
type CssClass = string | (string | false | null | undefined)[]; type CssClass = string | (string | false | null | undefined)[];
@ -21,6 +26,9 @@
let focused = $state(false); let focused = $state(false);
let popover_element = $state<HTMLDivElement | undefined>(); let popover_element = $state<HTMLDivElement | undefined>();
// Hacky workaround because as of September 2025 implicit anchor association
// is still pretty broken, at least in Firefox.
let anchor_name = $state(`--anchor-${Math.floor(Math.random() * 1000000)}`);
function handle_component_focusin() { function handle_component_focusin() {
focused = true; focused = true;
@ -38,35 +46,30 @@
} }
}, 250); }, 250);
} }
function handle_completion_click(completion: string) {
value = completion;
search_input_element?.focus();
popover_element?.hidePopover();
}
function handle_search_keydown(ev: KeyboardEvent) {
if (ev.key === "Escape") {
popover_element?.hidePopover();
}
}
function handle_search_input() {
popover_element?.showPopover();
}
</script> </script>
<!--
Wrapping both the search input and the popover in a container element allows us
to capture
-->
<div <div
class="combobox__container" class="combobox__container"
onfocusin={handle_component_focusin} onfocusin={handle_component_focusin}
onfocusout={handle_component_focusout} onfocusout={handle_component_focusout}
style:anchor-name={anchor_name}
> >
<input <input
bind:this={search_input_element} bind:this={search_input_element}
bind:value={search_value} bind:value={search_value}
class={search_input_class} class={search_input_class}
oninput={handle_search_input} oninput={() => {
onkeydown={handle_search_keydown} popover_element?.showPopover();
}}
onkeydown={(ev) => {
if (ev.key === "Escape") {
popover_element?.hidePopover();
}
}}
type="text" type="text"
/> />
<div <div
@ -74,12 +77,17 @@
class={popover_class ?? "combobox__popover"} class={popover_class ?? "combobox__popover"}
popover="manual" popover="manual"
role="listbox" role="listbox"
style:position-anchor={anchor_name}
> >
{#each completions as completion} {#each completions as completion}
<button <button
aria-selected={value === completion} aria-selected={value === completion}
class="combobox__completion" class="combobox__completion"
onclick={() => handle_completion_click(completion)} onclick={() => {
value = completion;
search_input_element?.focus();
popover_element?.hidePopover();
}}
role="option" role="option"
type="button" type="button"
> >
@ -88,3 +96,32 @@
{/each} {/each}
</div> </div>
</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>

View file

@ -1,5 +1,6 @@
<!-- <!--
@component @component
An interactive UI that sits in the header row of a table and allows the user to An interactive UI that sits in the header row of a table and allows the user to
quickly add a new field based on an existing column or configure a field backed quickly add a new field based on an existing column or configure a field backed
by a new column to be added to the relation in the database. by a new column to be added to the relation in the database.
@ -15,7 +16,7 @@ submission.
incompatible with the current presentation configuration.--> incompatible with the current presentation configuration.-->
<script lang="ts"> <script lang="ts">
import Combobox from "./combobox.svelte"; import { toLowerCase } from "zod";
import FieldDetails from "./field-details.svelte"; import FieldDetails from "./field-details.svelte";
import { import {
all_presentation_tags, all_presentation_tags,
@ -98,11 +99,14 @@ incompatible with the current presentation configuration.-->
if (tag === "Dropdown") { if (tag === "Dropdown") {
return { t: "Dropdown", c: { allow_custom: true, options: [] } }; return { t: "Dropdown", c: { allow_custom: true, options: [] } };
} }
if (tag === "Numeric") {
return { t: "Numeric", c: {} };
}
if (tag === "Text") { if (tag === "Text") {
return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } }; return { t: "Text", c: { input_mode: { t: "SingleLine", c: {} } } };
} }
if (tag === "Timestamp") { if (tag === "Timestamp") {
return { t: "Timestamp", c: {} }; return { t: "Timestamp", c: { format: "yyyy-MM-dd'T'HH:mm:ssXXX" } };
} }
if (tag === "Uuid") { if (tag === "Uuid") {
return { t: "Uuid", c: {} }; return { t: "Uuid", c: {} };
@ -111,64 +115,98 @@ incompatible with the current presentation configuration.-->
throw new Error("this should be unreachable"); throw new Error("this should be unreachable");
} }
function handle_presentation_input() { function toggle_expanded() {
presentation_customized = true;
}
function handle_summary_toggle_button_click() {
expanded = !expanded; expanded = !expanded;
if (expanded) { if (expanded) {
// Delay focus call until next frame so that the element has an
// opportunity to render.
setTimeout(() => { setTimeout(() => {
search_input_element?.focus(); search_input_element?.focus();
}, 0); }, 0);
} }
} }
function handle_field_options_button_click() {
popover_element?.showPopover();
}
function handle_name_input() { function handle_name_input() {
name_customized = true; name_customized = true;
} }
</script> </script>
<form method="post" action="add-field"> <div class="container">
<div class="field-adder__container">
<div <div
class="field-adder__header-lookalike" class={["header-lookalike", expanded && "visible"]}
style:display={expanded ? "block" : "none"} style:anchor-name={anchor_name}
> >
<Combobox <input
bind:search_value={label_value} bind:this={search_input_element}
bind:search_input_element
bind:value={label_value} bind:value={label_value}
completions={columns class="field-adder__label-input"
.map(({ name }) => name) onclick={() => {
.filter((name) => popover_element?.showPopover();
name }}
.toLocaleLowerCase("en-US") oninput={() => {
.includes(label_value.toLocaleLowerCase("en-US")), popover_element?.showPopover();
)} }}
search_input_class="field-adder__label-input" onkeydown={(ev) => {
if (ev.key === "Escape") {
toggle_expanded();
}
}}
type="text"
/> />
<form method="post" action="add-field">
<div
bind:this={popover_element}
class="phono-popover popover"
popover="auto"
style:position-anchor={anchor_name}
>
<div class="completions" role="listbox">
{#each columns
.map(({ name }) => name)
.filter((name) => name
.toLocaleLowerCase("en-US")
.includes(label_value.toLocaleLowerCase("en-US")) || name
.toLocaleLowerCase("en-US")
.includes(name_value.toLocaleLowerCase("en-US"))) as completion}
<button
aria-selected={name_value === completion}
onclick={() => {
if (!name_customized) {
label_value = completion;
}
name_value = completion;
search_input_element?.focus();
}}
role="option"
type="button"
>
{completion}
</button>
{/each}
</div>
<div class="configs">
{#if presentation_value}
<FieldDetails
bind:name_value
bind:label_value
bind:presentation={presentation_value}
on_name_input={handle_name_input}
on_presentation_input={() => {
presentation_customized = true;
}}
/>
{/if}
<button class="button--primary" type="submit">Create</button>
</div>
</div>
</form>
</div> </div>
<div class="field-adder__summary-buttons"> <div class="field-adder__summary-buttons">
<button
aria-label="more field options"
class="button--clear"
onclick={handle_field_options_button_click}
style:anchor-name={anchor_name}
style:display={expanded ? "block" : "none"}
type="button"
>
<i class="ti ti-dots-vertical"></i>
</button>
<button <button
aria-label="toggle field adder" aria-label="toggle field adder"
class="button--clear" class="button--clear"
onclick={handle_summary_toggle_button_click} onclick={toggle_expanded}
type="button" type="button"
> >
<i class="ti ti-{expanded ? 'x' : 'plus'}"></i> <i class="ti ti-{expanded ? 'x' : 'plus'}"></i>
@ -176,26 +214,84 @@ incompatible with the current presentation configuration.-->
</div> </div>
</div> </div>
<div <style lang="css">
bind:this={popover_element} /*
class="field-adder__popover" I've been annoyed by some of the rough edges around global SCSS in web
popover="auto" components, and independently curious about simplifying the build process by
style:position-anchor={anchor_name} replacing SCSS with modern vanilla CSS entirely, so trying something new here.
> TBD whether it gets adopted more widely, or reverted.
<!-- */
The "advanced" details for creating a new column or customizing the behavior
of a field backed by an existing column overlap with the controls exposed when @import "../../css_dist/main.css";
editing the configuration of an existing field.
--> .container {
{#if presentation_value} --default-border-color: #ccc;
<FieldDetails --viewer-th-background: #0001;
bind:name_value --viewer-th-border: solid 1px #ccc;
bind:label_value --viewer-th-font-family: "Funnel Sans";
bind:presentation={presentation_value} --viewer-th-font-weight: bolder;
on_name_input={handle_name_input} --viewer-th-padding-x: 8px;
on_presentation_input={handle_presentation_input} --viewer-th-padding-y: 4px;
/>
{/if} align-items: stretch;
<button class="button--primary" type="submit">Create</button> display: flex;
</div> height: 100%;
</form> }
.header-lookalike {
align-items: stretch;
background: var(--viewer-th-background);
border: var(--viewer-th-border);
border-top: none;
&:first-child {
border-left: none;
}
display: none;
&.visible {
display: flex;
}
flex-direction: column;
font-family: var(--viewer-th-font-family);
font-weight: var(--viewer-th-font-weight);
height: 100%; /* css hack to make percentage based cell heights work */
justify-content: center;
padding: var(--viewer-th-padding-y) var(--viewer-th-padding-x);
}
.popover:popover-open {
display: grid;
grid-template: "completions configs" 1fr / 1fr 2fr;
left: anchor(left);
position: absolute;
top: anchor(bottom);
width: 480px;
}
.completions {
border-right: solid 1px var(--default-border-color);
grid-area: completions;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: start;
& > button {
display: block;
padding: 0.5rem;
font-weight: normal;
text-align: left;
width: 100%;
&:hover,
&:focus,
&[aria-selected="true"] {
background: #0000001f;
}
}
}
.configs {
grid-area: configs;
padding: 16px;
}
</style>

View file

@ -26,6 +26,8 @@
const original_label_value = field.field.table_label; const original_label_value = field.field.table_label;
let type_indicator_element = $state<HTMLButtonElement | undefined>(); let type_indicator_element = $state<HTMLButtonElement | undefined>();
let remove_field_dialog_element = $state<HTMLDialogElement | undefined>();
let field_config_dialog_element = $state<HTMLDialogElement | undefined>();
let name_value = $state(field.field.name); let name_value = $state(field.field.name);
let label_value = $state(field.field.table_label ?? ""); let label_value = $state(field.field.table_label ?? "");
@ -53,6 +55,7 @@
<div class="field-header__menu-container"> <div class="field-header__menu-container">
<BasicDropdown <BasicDropdown
alignment="right" alignment="right"
button_aria_label="Field options"
button_class="field-header__type-indicator" button_class="field-header__type-indicator"
on_toggle={(ev) => { on_toggle={(ev) => {
if (ev.newState === "closed") { if (ev.newState === "closed") {
@ -64,7 +67,7 @@
> >
<span slot="button-contents"> <span slot="button-contents">
{#if field.field.presentation.t === "Dropdown"} {#if field.field.presentation.t === "Dropdown"}
<i class="ti ti-pointer"></i> <i class="ti ti-select"></i>
{:else if field.field.presentation.t === "Numeric"} {:else if field.field.presentation.t === "Numeric"}
<i class="ti ti-decimal"></i> <i class="ti ti-decimal"></i>
{:else if field.field.presentation.t === "Text"} {:else if field.field.presentation.t === "Text"}
@ -75,18 +78,71 @@
<i class="ti ti-id"></i> <i class="ti ti-id"></i>
{/if} {/if}
</span> </span>
<div slot="popover" style:padding="16px"> <menu slot="popover" class="basic-dropdown__menu">
<li>
<button
onclick={() => {
field_config_dialog_element?.showModal();
}}
type="button"
>
{field.field.presentation.t} settings
</button>
</li>
<li>
<button
onclick={() => {
remove_field_dialog_element?.showModal();
}}
type="button"
>
Remove field
</button>
</li>
</menu>
</BasicDropdown>
</div>
</div>
<dialog bind:this={remove_field_dialog_element} class="dialog">
<form method="post" action="remove-field">
<div class="padded">
<label style:display="block">
<input type="radio" name="delete_data" value="false" checked />
Hide field without deleting data.
</label>
<label style:display="block">
<input type="radio" name="delete_data" value="true" />
Remove field and underlying data. This cannot be undone.
</label>
</div>
<div class="padded">
<input type="hidden" name="field_id" value={field.field.id} />
<button class="button--primary" type="submit">Remove</button>
<button
class="button--secondary"
onclick={() => {
remove_field_dialog_element?.close();
}}
type="button">Cancel</button
>
</div>
</form>
</dialog>
<dialog bind:this={field_config_dialog_element} class="dialog">
<form method="post" action="update-field"> <form method="post" action="update-field">
<div class="padded">
<FieldDetails <FieldDetails
bind:name_value bind:name_value
bind:label_value bind:label_value
name_input_disabled name_input_disabled
presentation={field.field.presentation} presentation={field.field.presentation}
/> />
</div>
<input type="hidden" name="field_id" value={field.field.id} /> <input type="hidden" name="field_id" value={field.field.id} />
<div class="padded">
<button class="button--primary" type="submit">Save</button> <button class="button--primary" type="submit">Save</button>
</div>
</form> </form>
</div> </dialog>
</BasicDropdown>
</div>
</div>

View file

@ -601,7 +601,7 @@
<div class="lens-grid"> <div class="lens-grid">
{#if lazy_data} {#if lazy_data}
<div class="lens-table" role="grid"> <div class="lens-table" role="grid">
<div class={["lens-table__headers"]}> <div class="lens-table__headers">
{#each lazy_data.fields as _, field_index} {#each lazy_data.fields as _, field_index}
<FieldHeader <FieldHeader
bind:field={lazy_data.fields[field_index]} bind:field={lazy_data.fields[field_index]}