diff --git a/phono-server/src/routes/relations_single/mod.rs b/phono-server/src/routes/relations_single/mod.rs index d88178f..b32fb83 100644 --- a/phono-server/src/routes/relations_single/mod.rs +++ b/phono-server/src/routes/relations_single/mod.rs @@ -17,6 +17,7 @@ mod set_filter_handler; mod settings_handler; mod update_field_handler; mod update_field_ordinality_handler; +mod update_field_table_width_px_handler; mod update_portal_name_handler; mod update_rel_name_handler; mod update_values_handler; @@ -49,6 +50,10 @@ pub(super) fn new_router() -> Router { "/p/{portal_id}/update-field-ordinality", 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}/update-values", diff --git a/phono-server/src/routes/relations_single/update_field_table_width_px_handler.rs b/phono-server/src/routes/relations_single/update_field_table_width_px_handler.rs new file mode 100644 index 0000000..90f0261 --- /dev/null +++ b/phono-server/src/routes/relations_single/update_field_table_width_px_handler.rs @@ -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, + AppDbConn(mut app_db): AppDbConn, + CurrentUser(user): CurrentUser, + Path(PathParams { + portal_id, + rel_oid, + workspace_id, + }): Path, + ValidatedForm(FormBody { + field_id, + table_width_px, + }): ValidatedForm, +) -> Result { + // 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()) +} diff --git a/static/portal-table.css b/static/portal-table.css index e551a4f..b86db6a 100644 --- a/static/portal-table.css +++ b/static/portal-table.css @@ -57,16 +57,27 @@ .field-adder__header-lookalike { align-items: center; background: #0001; - border: solid 1px var(--table-header-border-color); - border-top: none; - border-left: none; + border-bottom: solid 1px var(--table-header-border-color); + cursor: grab; display: flex; flex: none; font-family: var(--default-font-family--data); height: 100%; justify-content: space-between; - padding: 0.25rem; + padding: 4px; + padding-left: 8px; + padding-right: 0; 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 { @@ -78,7 +89,7 @@ .field-header__label { overflow: hidden; - padding: 4px; + padding: 4px 0; text-overflow: ellipsis; white-space: nowrap; } @@ -104,6 +115,25 @@ 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 ======== */ .field-adder { diff --git a/svelte/src/field-header.svelte b/svelte/src/field-header.svelte index 57ca5d7..61a5a7d 100644 --- a/svelte/src/field-header.svelte +++ b/svelte/src/field-header.svelte @@ -3,6 +3,9 @@ import { type FieldInfo } from "./field.svelte"; import FieldDetails from "./field-details.svelte"; + const MAX_COL_WIDTH_PX = 800; + const MIN_COL_WIDTH_PX = 160; + type Props = { field: FieldInfo; index: number; @@ -11,6 +14,7 @@ ondragover?(ev: DragEvent): unknown; ondragstart?(ev: DragEvent): unknown; ondrop?(ev: DragEvent): unknown; + onresize?(width_px: number): unknown; }; let { @@ -21,6 +25,7 @@ ondragover, ondragstart, ondrop, + onresize, }: Props = $props(); const original_label_value = field.field.table_label; @@ -30,6 +35,7 @@ let field_config_dialog_element = $state(); let name_value = $state(field.field.name); let label_value = $state(field.field.table_label ?? ""); + let resize_drag_x_start = $state(); $effect(() => { field.field.table_label = label_value === "" ? undefined : label_value; @@ -46,7 +52,7 @@ {ondragstart} {ondrop} role="columnheader" - style:width={`${field.field.table_width_px}px`} + style:--column-width={`${field.field.table_width_px}px`} tabindex={0} >
@@ -100,6 +106,33 @@
+
{ + 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} +> +
+
diff --git a/svelte/src/table-viewer.webc.svelte b/svelte/src/table-viewer.webc.svelte index 162543d..aa8825d 100644 --- a/svelte/src/table-viewer.webc.svelte +++ b/svelte/src/table-viewer.webc.svelte @@ -171,6 +171,22 @@ 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 -------- // function set_selections(arr: Coords[]) { @@ -635,6 +651,9 @@ }); } }} + onresize={(new_width_px) => { + update_field_table_width_px(field_index, new_width_px); + }} /> {/each}