From d934d4ad90a561bab21c3ac21ad2fb2272ffca8d Mon Sep 17 00:00:00 2001 From: Brent Schroeter Date: Wed, 5 Nov 2025 22:48:55 +0000 Subject: [PATCH] reordering fields --- .../migrations/20250918060948_init.up.sql | 3 +- interim-models/src/field.rs | 45 +++++++++-- .../src/routes/relations_single/mod.rs | 5 ++ .../update_field_ordinality_handler.rs | 77 +++++++++++++++++++ svelte/src/field-header.svelte | 22 +++++- svelte/src/field.svelte.ts | 1 + svelte/src/table-viewer.webc.svelte | 73 ++++++++++++++++++ 7 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 interim-server/src/routes/relations_single/update_field_ordinality_handler.rs diff --git a/interim-models/migrations/20250918060948_init.up.sql b/interim-models/migrations/20250918060948_init.up.sql index 61fcd74..697594f 100644 --- a/interim-models/migrations/20250918060948_init.up.sql +++ b/interim-models/migrations/20250918060948_init.up.sql @@ -82,7 +82,8 @@ create table if not exists fields ( name text not null, presentation jsonb not null, table_label text, - table_width_px int not null default 200 + table_width_px int not null default 200, + ordinality float not null ); -- Service Credentials -- diff --git a/interim-models/src/field.rs b/interim-models/src/field.rs index 179dd0b..ef7e463 100644 --- a/interim-models/src/field.rs +++ b/interim-models/src/field.rs @@ -35,6 +35,9 @@ pub struct Field { /// Width of UI table column in pixels. pub table_width_px: i32, + + /// Position of the field relative to others. Smaller values appear earlier. + pub ordinality: f64, } impl Field { @@ -57,6 +60,7 @@ impl Field { table_label: None, presentation: sqlx::types::Json(presentation), table_width_px: 200, + ordinality: 1.0, }) } @@ -106,9 +110,11 @@ select name, table_label, presentation as "presentation: Json", - table_width_px + table_width_px, + ordinality from fields where portal_id = $1 +order by ordinality "#, self.portal_id ) @@ -133,7 +139,8 @@ select name, table_label, presentation as "presentation: Json", - table_width_px + table_width_px, + ordinality from fields where portal_id = $1 and id = $2 "#, @@ -162,14 +169,31 @@ impl InsertableField { Field, r#" insert into fields -(portal_id, name, table_label, presentation, table_width_px) -values ($1, $2, $3, $4, $5) +( + portal_id, + name, + table_label, + presentation, + table_width_px, + ordinality +) ( + select + $1 as portal_id, + $2 as name, + $3 as table_label, + $4 as presentation, + $5 as table_width_px, + coalesce(max(prev.ordinality), 0) + 1 as ordinality + from fields as prev + where prev.portal_id = $1 +) returning id, name, table_label, presentation as "presentation: Json", - table_width_px + table_width_px, + ordinality "#, self.portal_id, self.name, @@ -201,6 +225,8 @@ pub struct Update { presentation: Option, #[builder(default, setter(strip_option))] table_width_px: Option, + #[builder(default, setter(strip_option))] + ordinality: Option, } impl Update { @@ -235,6 +261,15 @@ impl Update { .execute(&mut *tx) .await?; } + if let Some(ordinality) = self.ordinality { + query!( + "update fields set ordinality = $1 where id = $2", + ordinality, + self.id + ) + .execute(&mut *tx) + .await?; + } tx.commit().await?; diff --git a/interim-server/src/routes/relations_single/mod.rs b/interim-server/src/routes/relations_single/mod.rs index bce33fb..0dbcfff 100644 --- a/interim-server/src/routes/relations_single/mod.rs +++ b/interim-server/src/routes/relations_single/mod.rs @@ -17,6 +17,7 @@ mod set_filter_handler; mod settings_handler; mod settings_invite_handler; mod update_field_handler; +mod update_field_ordinality_handler; mod update_form_transitions_handler; mod update_portal_name_handler; mod update_prompts_handler; @@ -44,6 +45,10 @@ pub(super) fn new_router() -> Router { "/p/{portal_id}/update-field", post(update_field_handler::post), ) + .route( + "/p/{portal_id}/update-field-ordinality", + post(update_field_ordinality_handler::post), + ) .route("/p/{portal_id}/insert", post(insert_handler::post)) .route( "/p/{portal_id}/update-values", diff --git a/interim-server/src/routes/relations_single/update_field_ordinality_handler.rs b/interim-server/src/routes/relations_single/update_field_ordinality_handler.rs new file mode 100644 index 0000000..8527a18 --- /dev/null +++ b/interim-server/src/routes/relations_single/update_field_ordinality_handler.rs @@ -0,0 +1,77 @@ +use axum::{debug_handler, extract::Path, response::Response}; +use interim_models::field::Field; +use serde::Deserialize; +use sqlx::postgres::types::Oid; +use uuid::Uuid; +use validator::Validate; + +use crate::{ + app::{App, AppDbConn}, + errors::AppError, + extractors::ValidatedForm, + navigator::{Navigator, NavigatorPage}, + user::CurrentUser, +}; + +#[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 = 0.0))] + ordinality: f64, +} + +/// HTTP POST handler for updating the ordinal position of an existing +/// [`Field`]. 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`]. +#[debug_handler(state = App)] +pub(super) async fn post( + AppDbConn(mut app_db): AppDbConn, + CurrentUser(_user): CurrentUser, + navigator: Navigator, + Path(PathParams { + portal_id, + rel_oid, + workspace_id, + }): Path, + ValidatedForm(FormBody { + field_id, + ordinality, + }): ValidatedForm, +) -> Result { + // 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. + Field::belonging_to_portal(portal_id) + .with_id(field_id) + .fetch_one(&mut app_db) + .await?; + + Field::update() + .id(field_id) + .ordinality(ordinality) + .build()? + .execute(&mut app_db) + .await?; + + Ok(navigator + .portal_page() + .workspace_id(workspace_id) + .rel_oid(Oid(rel_oid)) + .portal_id(portal_id) + .build()? + .redirect_to()) +} diff --git a/svelte/src/field-header.svelte b/svelte/src/field-header.svelte index b9c269a..26eeb69 100644 --- a/svelte/src/field-header.svelte +++ b/svelte/src/field-header.svelte @@ -6,9 +6,22 @@ type Props = { field: FieldInfo; index: number; + ondragenter?(ev: DragEvent): unknown; + ondragleave?(ev: DragEvent): unknown; + ondragover?(ev: DragEvent): unknown; + ondragstart?(ev: DragEvent): unknown; + ondrop?(ev: DragEvent): unknown; }; - let { field = $bindable(), index }: Props = $props(); + let { + field = $bindable(), + index, + ondragenter, + ondragleave, + ondragover, + ondragstart, + ondrop, + }: Props = $props(); const original_label_value = field.field.table_label; @@ -24,8 +37,15 @@
{field.field.table_label ?? field.field.name} diff --git a/svelte/src/field.svelte.ts b/svelte/src/field.svelte.ts index 6972f53..47e124e 100644 --- a/svelte/src/field.svelte.ts +++ b/svelte/src/field.svelte.ts @@ -9,6 +9,7 @@ export const field_schema = z.object({ table_label: z.string().nullish().transform((x) => x ?? undefined), presentation: presentation_schema, table_width_px: z.number(), + ordinality: z.number(), }); export type Field = z.infer; diff --git a/svelte/src/table-viewer.webc.svelte b/svelte/src/table-viewer.webc.svelte index 894bad3..d1d4920 100644 --- a/svelte/src/table-viewer.webc.svelte +++ b/svelte/src/table-viewer.webc.svelte @@ -81,6 +81,7 @@ let focus_cursor = $state<(() => unknown) | undefined>(); let inserter_rows = $state([]); let lazy_data = $state(); + let dragged_header = $state(); // -------- Helper Functions -------- // @@ -100,6 +101,62 @@ } } + function update_field_ordinality({ + field_index, + beyond_index, + }: { + field_index: number; + beyond_index: number; + }) { + if (!lazy_data) { + console.warn("preconditions for update_field_ordinality() not met"); + return; + } + let target_ordinality: number | undefined; + const ordinality_near = lazy_data.fields[beyond_index].field.ordinality; + if (beyond_index > field_index) { + // Field is moving towards the end. + const ordinality_far = + lazy_data.fields[beyond_index + 1]?.field.ordinality; + if (ordinality_far) { + target_ordinality = (ordinality_near + ordinality_far) / 2; + } else { + target_ordinality = ordinality_near + 1; + } + } else if (beyond_index < field_index) { + // Field is moving towards the start. + const ordinality_far = + lazy_data.fields[beyond_index - 1]?.field.ordinality; + if (ordinality_far) { + target_ordinality = (ordinality_near + ordinality_far) / 2; + } else { + // Avoid setting ordinality <= 0. + target_ordinality = ordinality_near / 2; + } + } else { + // No movement. + return; + } + + // Imperatively submit HTML form. + const form = document.createElement("form"); + form.setAttribute("action", "update-field-ordinality"); + form.setAttribute("method", "post"); + form.style.display = "none"; + const field_id_input = document.createElement("input"); + field_id_input.type = "hidden"; + field_id_input.name = "field_id"; + field_id_input.value = lazy_data.fields[field_index].field.id; + const ordinality_input = document.createElement("input"); + ordinality_input.name = "ordinality"; + ordinality_input.type = "hidden"; + ordinality_input.value = `${target_ordinality}`; + form.appendChild(field_id_input); + form.appendChild(ordinality_input); + document.body.appendChild(form); + form.submit(); + } + // -------- Updates and Effects -------- // function set_selections(arr: Omit[]) { @@ -450,6 +507,22 @@ { + dragged_header = field_index; + }} + ondragover={(ev) => { + // Enable element as drop target + ev.preventDefault(); + }} + ondrop={(ev) => { + ev.preventDefault(); + if (dragged_header !== undefined) { + update_field_ordinality({ + field_index: dragged_header, + beyond_index: field_index, + }); + } + }} /> {/each}