reordering fields

This commit is contained in:
Brent Schroeter 2025-11-05 22:48:55 +00:00
parent 55c58158cc
commit d934d4ad90
7 changed files with 219 additions and 7 deletions

View file

@ -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 --

View file

@ -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<Presentation>",
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<Presentation>",
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<Presentation>",
table_width_px
table_width_px,
ordinality
"#,
self.portal_id,
self.name,
@ -201,6 +225,8 @@ pub struct Update {
presentation: Option<Presentation>,
#[builder(default, setter(strip_option))]
table_width_px: Option<i32>,
#[builder(default, setter(strip_option))]
ordinality: Option<f64>,
}
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?;

View file

@ -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<App> {
"/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",

View file

@ -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<PathParams>,
ValidatedForm(FormBody {
field_id,
ordinality,
}): 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.
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())
}

View file

@ -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 @@
<div
aria-colindex={index}
class="field-header__container"
draggable={true}
{ondragenter}
{ondragleave}
{ondragover}
{ondragstart}
{ondrop}
role="columnheader"
style:width={`${field.field.table_width_px}px`}
tabindex={0}
>
<div class="field-header__label">
{field.field.table_label ?? field.field.name}

View file

@ -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<typeof field_schema>;

View file

@ -81,6 +81,7 @@
let focus_cursor = $state<(() => unknown) | undefined>();
let inserter_rows = $state<Row[]>([]);
let lazy_data = $state<LazyData | undefined>();
let dragged_header = $state<number | undefined>();
// -------- 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<Selection, "original_value">[]) {
@ -450,6 +507,22 @@
<FieldHeader
bind:field={lazy_data.fields[field_index]}
index={field_index}
ondragstart={() => {
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}
<div class="lens-table__header-actions">