Compare commits

...

2 commits

Author SHA1 Message Date
Brent Schroeter
fc8e3d6b99 implement drag-and-drop column resizing 2026-01-20 04:47:14 +00:00
Brent Schroeter
610b902ac1 fix auth checks for update_field_ordinality_handler 2026-01-20 04:46:45 +00:00
6 changed files with 217 additions and 13 deletions

View file

@ -17,6 +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_field_table_width_px_handler;
mod update_portal_name_handler; mod update_portal_name_handler;
mod update_rel_name_handler; mod update_rel_name_handler;
mod update_values_handler; mod update_values_handler;
@ -49,6 +50,10 @@ pub(super) fn new_router() -> Router<App> {
"/p/{portal_id}/update-field-ordinality", "/p/{portal_id}/update-field-ordinality",
post(update_field_ordinality_handler::post), post(update_field_ordinality_handler::post),
) )
.route(
"/p/{portal_id}/update-field-table-width-px",
post(update_field_table_width_px_handler::post),
)
.route("/p/{portal_id}/insert", post(insert_handler::post)) .route("/p/{portal_id}/insert", post(insert_handler::post))
.route( .route(
"/p/{portal_id}/update-values", "/p/{portal_id}/update-values",

View file

@ -1,5 +1,12 @@
use axum::{debug_handler, extract::Path, response::Response}; use axum::{
use phono_models::field::Field; debug_handler,
extract::{Path, State},
response::Response,
};
use phono_models::{
accessors::{Accessor as _, Actor, portal::PortalAccessor},
field::Field,
};
use serde::Deserialize; use serde::Deserialize;
use sqlx::postgres::types::Oid; use sqlx::postgres::types::Oid;
use uuid::Uuid; use uuid::Uuid;
@ -11,6 +18,7 @@ use crate::{
extractors::ValidatedForm, extractors::ValidatedForm,
navigator::{Navigator, NavigatorPage}, navigator::{Navigator, NavigatorPage},
user::CurrentUser, user::CurrentUser,
workspace_pooler::{RoleAssignment, WorkspacePooler},
}; };
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -36,8 +44,9 @@ pub(super) struct FormBody {
/// [`PathParams`]. /// [`PathParams`].
#[debug_handler(state = crate::app::App)] #[debug_handler(state = crate::app::App)]
pub(super) async fn post( pub(super) async fn post(
State(mut pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn, AppDbConn(mut app_db): AppDbConn,
CurrentUser(_user): CurrentUser, CurrentUser(user): CurrentUser,
navigator: Navigator, navigator: Navigator,
Path(PathParams { Path(PathParams {
portal_id, portal_id,
@ -51,11 +60,23 @@ pub(super) async fn post(
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
// FIXME CSRF // FIXME CSRF
// FIXME ensure workspace corresponds to rel/portal, and that user has let mut workspace_client = pooler
// permission to access/alter both as needed. .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?;
// Ensure field exists and belongs to portal. // Ensure field exists and belongs to portal.
Field::belonging_to_portal(portal_id) Field::belonging_to_portal(portal.id)
.with_id(field_id) .with_id(field_id)
.fetch_one(&mut app_db) .fetch_one(&mut app_db)
.await?; .await?;
@ -67,11 +88,12 @@ pub(super) async fn post(
.execute(&mut app_db) .execute(&mut app_db)
.await?; .await?;
// TODO: Redirect with subfilter query intact.
Ok(navigator Ok(navigator
.portal_page() .portal_page()
.workspace_id(workspace_id) .workspace_id(workspace_id)
.rel_oid(Oid(rel_oid)) .rel_oid(Oid(rel_oid))
.portal_id(portal_id) .portal_id(portal.id)
.build()? .build()?
.redirect_to()) .redirect_to())
} }

View file

@ -0,0 +1,95 @@
use axum::{
Json, debug_handler,
extract::{Path, State},
response::{IntoResponse as _, Response},
};
use phono_models::{
accessors::{Accessor as _, Actor, portal::PortalAccessor},
field::Field,
};
use serde::Deserialize;
use serde_json::json;
use sqlx::postgres::types::Oid;
use uuid::Uuid;
use validator::Validate;
use crate::{
app::AppDbConn,
errors::AppError,
extractors::ValidatedForm,
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,
#[validate(range(min = 160, max = 800))]
table_width_px: u16,
}
/// HTTP POST handler for updating the display width of a [`Field`] in the table
/// view. This is distinct from `./update-field`, because it is initiated in the
/// UI by a "drag" interaction. It may make sense to fold this into the
/// aforementioned endpoint later, if it is modified to handle partial updates.
///
/// This handler expects 3 path parameters with the structure described by
/// [`PathParams`].
///
/// Note that unlike [`super::update_field_ordinality_handler::post`], this
/// endpoint expects to be called asynchronously and thus returns an HTTP 200
/// response instead of a redirect.
#[debug_handler(state = crate::app::App)]
pub(super) async fn post(
State(mut pooler): State<WorkspacePooler>,
AppDbConn(mut app_db): AppDbConn,
CurrentUser(user): CurrentUser,
Path(PathParams {
portal_id,
rel_oid,
workspace_id,
}): Path<PathParams>,
ValidatedForm(FormBody {
field_id,
table_width_px,
}): ValidatedForm<FormBody>,
) -> Result<Response, AppError> {
// FIXME CSRF
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?;
// Ensure field exists and belongs to portal.
Field::belonging_to_portal(portal.id)
.with_id(field_id)
.fetch_one(&mut app_db)
.await?;
Field::update()
.id(field_id)
.table_width_px(table_width_px.into())
.build()?
.execute(&mut app_db)
.await?;
Ok(Json(json!({ "ok": true })).into_response())
}

View file

@ -57,16 +57,27 @@
.field-adder__header-lookalike { .field-adder__header-lookalike {
align-items: center; align-items: center;
background: #0001; background: #0001;
border: solid 1px var(--table-header-border-color); border-bottom: solid 1px var(--table-header-border-color);
border-top: none; cursor: grab;
border-left: none;
display: flex; display: flex;
flex: none; flex: none;
font-family: var(--default-font-family--data); font-family: var(--default-font-family--data);
height: 100%; height: 100%;
justify-content: space-between; justify-content: space-between;
padding: 0.25rem; padding: 4px;
padding-left: 8px;
padding-right: 0;
text-align: left; text-align: left;
width: calc(var(--column-width) - 8px);
&:first-child {
padding-left: 12px;
width: calc(var(--column-width) - 4px);
}
}
.field-adder__header-lookalike {
border-right: solid 1px var(--table-header-border-color);
} }
.field-header .basic-dropdown__button { .field-header .basic-dropdown__button {
@ -78,7 +89,7 @@
.field-header__label { .field-header__label {
overflow: hidden; overflow: hidden;
padding: 4px; padding: 4px 0;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@ -104,6 +115,25 @@
position: absolute; position: absolute;
} }
.field-resize-handle {
background: #0001;
border-bottom: solid 1px var(--table-header-border-color);
cursor: col-resize;
height: 100%;
width: 8px;
.field-resize-handle__v-border {
border-left: solid 1px var(--table-header-border-color);
height: 100%;
margin-left: 3px;
width: 1px;
}
&:nth-last-child(1 of .field-resize-handle) {
width: 4px;
}
}
/* ======== Component: Field Adder ======== */ /* ======== Component: Field Adder ======== */
.field-adder { .field-adder {

View file

@ -3,6 +3,9 @@
import { type FieldInfo } from "./field.svelte"; import { type FieldInfo } from "./field.svelte";
import FieldDetails from "./field-details.svelte"; import FieldDetails from "./field-details.svelte";
const MAX_COL_WIDTH_PX = 800;
const MIN_COL_WIDTH_PX = 160;
type Props = { type Props = {
field: FieldInfo; field: FieldInfo;
index: number; index: number;
@ -11,6 +14,7 @@
ondragover?(ev: DragEvent): unknown; ondragover?(ev: DragEvent): unknown;
ondragstart?(ev: DragEvent): unknown; ondragstart?(ev: DragEvent): unknown;
ondrop?(ev: DragEvent): unknown; ondrop?(ev: DragEvent): unknown;
onresize?(width_px: number): unknown;
}; };
let { let {
@ -21,6 +25,7 @@
ondragover, ondragover,
ondragstart, ondragstart,
ondrop, ondrop,
onresize,
}: Props = $props(); }: Props = $props();
const original_label_value = field.field.table_label; const original_label_value = field.field.table_label;
@ -30,6 +35,7 @@
let field_config_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 ?? "");
let resize_drag_x_start = $state<number | undefined>();
$effect(() => { $effect(() => {
field.field.table_label = label_value === "" ? undefined : label_value; field.field.table_label = label_value === "" ? undefined : label_value;
@ -46,7 +52,7 @@
{ondragstart} {ondragstart}
{ondrop} {ondrop}
role="columnheader" role="columnheader"
style:width={`${field.field.table_width_px}px`} style:--column-width={`${field.field.table_width_px}px`}
tabindex={0} tabindex={0}
> >
<div class="field-header__label"> <div class="field-header__label">
@ -100,6 +106,33 @@
</menu> </menu>
</BasicDropdown> </BasicDropdown>
</div> </div>
<div
class="field-resize-handle"
draggable={true}
ondragstart={(ev) => {
resize_drag_x_start = ev.layerX;
}}
ondragend={(ev) => {
if (resize_drag_x_start !== undefined) {
onresize?.(
Math.min(
MAX_COL_WIDTH_PX,
Math.max(
MIN_COL_WIDTH_PX,
field.field.table_width_px + ev.layerX - resize_drag_x_start,
),
),
);
resize_drag_x_start = undefined;
}
}}
role="separator"
aria-valuenow={field.field.table_width_px}
aria-valuemin={MIN_COL_WIDTH_PX}
aria-valuemax={MAX_COL_WIDTH_PX}
>
<div class="field-resize-handle__v-border"></div>
</div>
<dialog bind:this={remove_field_dialog_element} class="dialog"> <dialog bind:this={remove_field_dialog_element} class="dialog">
<form method="post" action="remove-field"> <form method="post" action="remove-field">

View file

@ -171,6 +171,22 @@
form.submit(); form.submit();
} }
function update_field_table_width_px(
field_index: number,
new_width_px: number,
) {
if (lazy_data?.fields[field_index]) {
lazy_data.fields[field_index].field.table_width_px = new_width_px;
fetch("update-field-table-width-px", {
method: "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: `field_id=${encodeURIComponent(lazy_data.fields[field_index].field.id)}&table_width_px=${new_width_px}`,
}).catch(console.error);
}
}
// -------- Updates and Effects -------- // // -------- Updates and Effects -------- //
function set_selections(arr: Coords[]) { function set_selections(arr: Coords[]) {
@ -635,6 +651,9 @@
}); });
} }
}} }}
onresize={(new_width_px) => {
update_field_table_width_px(field_index, new_width_px);
}}
/> />
{/each} {/each}
<div class="table-viewer__header-actions"> <div class="table-viewer__header-actions">