178 lines
6.1 KiB
Rust
178 lines
6.1 KiB
Rust
//! The builder's request handlers.
|
|
//!
|
|
//! Every interaction posts the whole draft and swaps the whole builder back,
|
|
//! so the server stays the single owner of the draft's rules — the same ones
|
|
//! the TUI client applies in-process between keystrokes.
|
|
|
|
use axum::{
|
|
extract::{Query, State},
|
|
http::{HeaderMap, HeaderValue, StatusCode},
|
|
response::{Html, IntoResponse, Redirect, Response},
|
|
};
|
|
use axum_extra::extract::Form;
|
|
|
|
use crate::{
|
|
AppState,
|
|
services::{authenticated_request, reject_cross_site},
|
|
};
|
|
|
|
use super::{
|
|
draft::TableDraft,
|
|
loader::{LoadError, load_page},
|
|
state::{AddTablePageState, BuilderForm},
|
|
ui,
|
|
};
|
|
|
|
/// The profile the table-definition workspace hands over when it sends the
|
|
/// user here to create a table, so the picker opens on the right one.
|
|
#[derive(Debug, Default, serde::Deserialize)]
|
|
pub(crate) struct NewTableQuery {
|
|
#[serde(default)]
|
|
profile: String,
|
|
#[serde(default)]
|
|
global: bool,
|
|
}
|
|
|
|
/// GET /admin/tables/new
|
|
pub(crate) async fn new_table_page(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<NewTableQuery>,
|
|
) -> Response {
|
|
let mut draft = TableDraft::new();
|
|
draft.profile_name = query.profile.trim().to_string();
|
|
draft.global = query.global;
|
|
|
|
match load_page(state, &headers, draft, None, None).await {
|
|
Ok(page) => Html(ui::render_page(&page)).into_response(),
|
|
Err(error) => load_error_response(error),
|
|
}
|
|
}
|
|
|
|
/// POST /admin/tables/builder — every button and every `change` in the builder.
|
|
pub(crate) async fn update_builder(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Form(form): Form<BuilderForm>,
|
|
) -> Response {
|
|
if let Some(rejection) = reject_cross_site(&headers) {
|
|
return rejection;
|
|
}
|
|
|
|
let mut page = match load_page(state, &headers, form.to_draft(), None, None).await {
|
|
Ok(page) => page,
|
|
Err(error) => return load_error_response(error),
|
|
};
|
|
|
|
apply_action(&mut page, &form);
|
|
Html(ui::render_builder(&page)).into_response()
|
|
}
|
|
|
|
/// POST /admin/tables — create the table.
|
|
pub(crate) async fn create_table(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
Form(form): Form<BuilderForm>,
|
|
) -> Response {
|
|
if let Some(rejection) = reject_cross_site(&headers) {
|
|
return rejection;
|
|
}
|
|
|
|
let mut page = match load_page(state.clone(), &headers, form.to_draft(), None, None).await {
|
|
Ok(page) => page,
|
|
Err(error) => return load_error_response(error),
|
|
};
|
|
|
|
// The draft is validated here with exactly the checks the client runs
|
|
// before it will save; the server re-validates authoritatively.
|
|
let request = match page.draft.clone().into_request() {
|
|
Ok(request) => request,
|
|
Err(message) => {
|
|
page.error = Some(message);
|
|
return (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page)))
|
|
.into_response();
|
|
}
|
|
};
|
|
|
|
let profile_name = if request.global {
|
|
"__global".to_string()
|
|
} else {
|
|
request.profile_name.clone()
|
|
};
|
|
let request = match authenticated_request(&headers, request) {
|
|
Ok(request) => request,
|
|
Err(_) => return Redirect::to("/login").into_response(),
|
|
};
|
|
|
|
let mut definitions = state.definitions;
|
|
let result = definitions.post_table_definition(request).await;
|
|
match result {
|
|
Ok(response) if response.get_ref().success => {
|
|
let location = format!(
|
|
"/admin/table-definition?profile={profile_name}&table={}",
|
|
page.draft.table_name
|
|
);
|
|
let Ok(location) = HeaderValue::try_from(location) else {
|
|
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
|
|
};
|
|
// `hx-redirect` alone, with no body and no 3xx: the form is posted
|
|
// over XHR, and the browser would follow a `Location` itself,
|
|
// leaving htmx to swap the whole redirected page into `#builder`.
|
|
let mut response = Html(String::new()).into_response();
|
|
response.headers_mut().insert("hx-redirect", location);
|
|
response
|
|
}
|
|
Ok(response) => {
|
|
page.error = Some(if response.get_ref().sql.is_empty() {
|
|
"The backend did not create the table.".to_string()
|
|
} else {
|
|
response.get_ref().sql.clone()
|
|
});
|
|
(StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page))).into_response()
|
|
}
|
|
Err(error) => {
|
|
page.error = Some(error.message().to_string());
|
|
(StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_builder(&page))).into_response()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Runs the pressed button against the draft.
|
|
///
|
|
/// `refresh` covers the plain re-renders — picking a profile or a column type
|
|
/// changes which fields apply, which is the client's field-visibility rule.
|
|
fn apply_action(page: &mut AddTablePageState, form: &BuilderForm) {
|
|
let index = form.index.unwrap_or(0);
|
|
match form.action.as_str() {
|
|
"add-column" => match page.draft.columns.add_from_inputs() {
|
|
Ok(status) => page.status = Some(status),
|
|
Err(message) => page.error = Some(message),
|
|
},
|
|
"remove-column" => match page.draft.remove_column(index) {
|
|
Ok(status) => page.status = Some(status),
|
|
Err(message) => page.error = Some(message),
|
|
},
|
|
"toggle-index" => page.draft.columns.toggle_indexed(index),
|
|
"toggle-display" => page.draft.toggle_row_display_candidate(index),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn load_error_response(error: LoadError) -> Response {
|
|
match error {
|
|
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
|
|
LoadError::Forbidden => (
|
|
StatusCode::FORBIDDEN,
|
|
Html(ui::render_submission_error(
|
|
"Table-management permission is required.",
|
|
)),
|
|
)
|
|
.into_response(),
|
|
LoadError::Backend(message) => (
|
|
StatusCode::BAD_GATEWAY,
|
|
Html(ui::render_submission_error(&message)),
|
|
)
|
|
.into_response(),
|
|
}
|
|
}
|