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,91 +115,183 @@ 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
class="field-adder__header-lookalike"
style:display={expanded ? "block" : "none"}
>
<Combobox
bind:search_value={label_value}
bind:search_input_element
bind:value={label_value}
completions={columns
.map(({ name }) => name)
.filter((name) =>
name
.toLocaleLowerCase("en-US")
.includes(label_value.toLocaleLowerCase("en-US")),
)}
search_input_class="field-adder__label-input"
/>
</div>
<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
aria-label="toggle field adder"
class="button--clear"
onclick={handle_summary_toggle_button_click}
type="button"
>
<i class="ti ti-{expanded ? 'x' : 'plus'}"></i>
</button>
</div>
</div>
<div <div
bind:this={popover_element} class={["header-lookalike", expanded && "visible"]}
class="field-adder__popover" style:anchor-name={anchor_name}
popover="auto"
style:position-anchor={anchor_name}
> >
<!-- <input
The "advanced" details for creating a new column or customizing the behavior bind:this={search_input_element}
of a field backed by an existing column overlap with the controls exposed when bind:value={label_value}
editing the configuration of an existing field. class="field-adder__label-input"
--> onclick={() => {
{#if presentation_value} popover_element?.showPopover();
<FieldDetails }}
bind:name_value oninput={() => {
bind:label_value popover_element?.showPopover();
bind:presentation={presentation_value} }}
on_name_input={handle_name_input} onkeydown={(ev) => {
on_presentation_input={handle_presentation_input} if (ev.key === "Escape") {
/> toggle_expanded();
{/if} }
<button class="button--primary" type="submit">Create</button> }}
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>
</form>
<div class="field-adder__summary-buttons">
<button
aria-label="toggle field adder"
class="button--clear"
onclick={toggle_expanded}
type="button"
>
<i class="ti ti-{expanded ? 'x' : 'plus'}"></i>
</button>
</div>
</div>
<style lang="css">
/*
I've been annoyed by some of the rough edges around global SCSS in web
components, and independently curious about simplifying the build process by
replacing SCSS with modern vanilla CSS entirely, so trying something new here.
TBD whether it gets adopted more widely, or reverted.
*/
@import "../../css_dist/main.css";
.container {
--default-border-color: #ccc;
--viewer-th-background: #0001;
--viewer-th-border: solid 1px #ccc;
--viewer-th-font-family: "Funnel Sans";
--viewer-th-font-weight: bolder;
--viewer-th-padding-x: 8px;
--viewer-th-padding-y: 4px;
align-items: stretch;
display: flex;
height: 100%;
}
.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">
<form method="post" action="update-field"> <li>
<FieldDetails <button
bind:name_value onclick={() => {
bind:label_value field_config_dialog_element?.showModal();
name_input_disabled }}
presentation={field.field.presentation} type="button"
/> >
<input type="hidden" name="field_id" value={field.field.id} /> {field.field.presentation.t} settings
<button class="button--primary" type="submit">Save</button> </button>
</form> </li>
</div> <li>
<button
onclick={() => {
remove_field_dialog_element?.showModal();
}}
type="button"
>
Remove field
</button>
</li>
</menu>
</BasicDropdown> </BasicDropdown>
</div> </div>
</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">
<div class="padded">
<FieldDetails
bind:name_value
bind:label_value
name_input_disabled
presentation={field.field.presentation}
/>
</div>
<input type="hidden" name="field_id" value={field.field.id} />
<div class="padded">
<button class="button--primary" type="submit">Save</button>
</div>
</form>
</dialog>

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]}