web interface for table_definition improved
This commit is contained in:
460
web/src/pages/admin/table_definition/logic.rs
Normal file
460
web/src/pages/admin/table_definition/logic.rs
Normal file
@@ -0,0 +1,460 @@
|
||||
//! The workspace's request handlers — one per `TableDefinition` write.
|
||||
//!
|
||||
//! Every write answers with the whole workspace, re-read from the backend, so
|
||||
//! what the user sees after a change is the definition as it now is rather
|
||||
//! than the form they submitted. A refused write answers the same way but with
|
||||
//! 422 and the backend's own message, which `ui/base.html` swaps in because a
|
||||
//! 4xx still carries the explanation.
|
||||
//!
|
||||
//! The column panel is the exception: staging a column changes nothing on the
|
||||
//! server, so those interactions swap only the panel.
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
definitions::table_definition::{
|
||||
AddTableColumnsRequest, CopyProfileRequest, CreateInvoiceTemplateTableRequest,
|
||||
DeleteTableRequest, RenameColumnAliasRequest,
|
||||
},
|
||||
schema::{ColumnForm, proto_columns},
|
||||
services::{authenticated_request, reject_cross_site},
|
||||
};
|
||||
|
||||
use super::{
|
||||
loader::load_page,
|
||||
state::{
|
||||
CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs,
|
||||
RenameForm, Selection,
|
||||
},
|
||||
ui,
|
||||
};
|
||||
|
||||
/// GET /admin/table-definition
|
||||
pub(crate) async fn page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<Selection>,
|
||||
) -> Response {
|
||||
match load_page(state, &headers, PageInputs::for_selection(selection)).await {
|
||||
Ok(page) => Html(ui::render_page(&page)).into_response(),
|
||||
Err(error) => load_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /admin/table-definition/workspace — the swap when the selection changes.
|
||||
pub(crate) async fn workspace(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<Selection>,
|
||||
) -> Response {
|
||||
match load_page(state, &headers, PageInputs::for_selection(selection)).await {
|
||||
Ok(page) => Html(ui::render_workspace(&page)).into_response(),
|
||||
Err(error) => load_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/columns/builder — staging a column to append.
|
||||
///
|
||||
/// Nothing is written here; the panel is swapped back with the column added,
|
||||
/// removed, or its index toggled, exactly as the Add-table builder works.
|
||||
pub(crate) async fn update_columns(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<Selection>,
|
||||
Form(form): Form<ColumnForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut inputs = PageInputs::for_selection(selection);
|
||||
inputs.columns = form.to_draft(false);
|
||||
|
||||
let index = form.index.unwrap_or(0);
|
||||
match form.action.as_str() {
|
||||
"add-column" => match inputs.columns.add_from_inputs() {
|
||||
Ok(status) => inputs.status = Some(status),
|
||||
Err(message) => inputs.error = Some(message),
|
||||
},
|
||||
"remove-column" => {
|
||||
if let Err(message) = inputs.columns.remove(index) {
|
||||
inputs.error = Some(message);
|
||||
}
|
||||
}
|
||||
"toggle-index" => inputs.columns.toggle_indexed(index),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match load_page(state, &headers, inputs).await {
|
||||
Ok(page) => Html(ui::render_column_panel(&page)).into_response(),
|
||||
Err(error) => load_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/columns — AddTableColumns.
|
||||
pub(crate) async fn add_columns(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<Selection>,
|
||||
Form(form): Form<ColumnForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut inputs = PageInputs::for_selection(selection);
|
||||
inputs.columns = form.to_draft(false);
|
||||
|
||||
if !inputs.selection.has_table() {
|
||||
return refuse(state, headers, inputs, "Select a table first.".to_string()).await;
|
||||
}
|
||||
if inputs.columns.is_empty() {
|
||||
return refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
"Describe at least one column before adding.".to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// The same checks the server runs, applied to a draft that may have been
|
||||
// rebuilt from a posted form rather than through the panel.
|
||||
if let Err(message) = inputs.columns.validate() {
|
||||
return refuse(state, headers, inputs, message).await;
|
||||
}
|
||||
|
||||
let request = AddTableColumnsRequest {
|
||||
profile_name: inputs.selection.profile.clone(),
|
||||
table_name: inputs.selection.table.clone(),
|
||||
columns: proto_columns(&inputs.columns.added),
|
||||
indexes: inputs.columns.selected_index_names(),
|
||||
};
|
||||
let Ok(request) = authenticated_request(&headers, request) else {
|
||||
return Redirect::to("/login").into_response();
|
||||
};
|
||||
|
||||
let mut definitions = state.definitions.clone();
|
||||
match definitions.add_table_columns(request).await {
|
||||
Ok(response) if response.get_ref().success => {
|
||||
let added = inputs.columns.added.len();
|
||||
inputs.sql = Some(response.into_inner().sql);
|
||||
inputs.status = Some(format!(
|
||||
"{added} column{} added to `{}`.",
|
||||
if added == 1 { "" } else { "s" },
|
||||
inputs.selection.table
|
||||
));
|
||||
// The columns are the table's now, so the panel starts empty.
|
||||
inputs.columns = crate::schema::ColumnDraft::for_append();
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
}
|
||||
Ok(response) => {
|
||||
let message = response.into_inner().sql;
|
||||
let message = if message.is_empty() {
|
||||
"The backend did not add the columns.".to_string()
|
||||
} else {
|
||||
message
|
||||
};
|
||||
refuse(state, headers, inputs, message).await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/rename — RenameColumnAlias.
|
||||
pub(crate) async fn rename_column(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<RenameForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut inputs = PageInputs::for_selection(Selection {
|
||||
profile: form.profile.clone(),
|
||||
table: form.table.clone(),
|
||||
});
|
||||
inputs.rename = form.clone();
|
||||
|
||||
if form.old_column_name.is_empty() || form.new_column_name.trim().is_empty() {
|
||||
return refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
"Choose a column and type its new name.".to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let request = RenameColumnAliasRequest {
|
||||
profile_name: form.profile.clone(),
|
||||
table_name: form.table.clone(),
|
||||
old_column_name: form.old_column_name.clone(),
|
||||
new_column_name: form.new_column_name.trim().to_string(),
|
||||
};
|
||||
let Ok(request) = authenticated_request(&headers, request) else {
|
||||
return Redirect::to("/login").into_response();
|
||||
};
|
||||
|
||||
let mut definitions = state.definitions.clone();
|
||||
match definitions.rename_column_alias(request).await {
|
||||
Ok(response) if response.get_ref().success => {
|
||||
inputs.status = Some(response.into_inner().message);
|
||||
inputs.rename = RenameForm {
|
||||
profile: form.profile,
|
||||
table: form.table,
|
||||
..Default::default()
|
||||
};
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
}
|
||||
Ok(response) => {
|
||||
let message = response.into_inner().message;
|
||||
let message = if message.is_empty() {
|
||||
"The backend did not rename the column.".to_string()
|
||||
} else {
|
||||
message
|
||||
};
|
||||
refuse(state, headers, inputs, message).await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/delete — DeleteTable.
|
||||
pub(crate) async fn delete_table(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<DeleteForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut inputs = PageInputs::for_selection(Selection {
|
||||
profile: form.profile.clone(),
|
||||
table: form.table.clone(),
|
||||
});
|
||||
|
||||
// The typed name is the whole guard: the drop is CASCADE, and it takes the
|
||||
// profile with it when this was its last table.
|
||||
if form.confirm_table_name.trim() != form.table || form.table.is_empty() {
|
||||
return refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
"Type the table's name exactly to confirm the deletion.".to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let request = DeleteTableRequest {
|
||||
profile_name: form.profile.clone(),
|
||||
table_name: form.table.clone(),
|
||||
};
|
||||
let Ok(request) = authenticated_request(&headers, request) else {
|
||||
return Redirect::to("/login").into_response();
|
||||
};
|
||||
|
||||
let mut definitions = state.definitions.clone();
|
||||
match definitions.delete_table(request).await {
|
||||
Ok(response) if response.get_ref().success => {
|
||||
inputs.status = Some(response.into_inner().message);
|
||||
// Whatever was selected is gone; the loader drops it, and this
|
||||
// keeps the workspace from asking for it again.
|
||||
inputs.selection.table.clear();
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
}
|
||||
Ok(response) => {
|
||||
let message = response.into_inner().message;
|
||||
let message = if message.is_empty() {
|
||||
"The backend did not delete the table.".to_string()
|
||||
} else {
|
||||
message
|
||||
};
|
||||
refuse(state, headers, inputs, message).await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/copy — CopyProfile.
|
||||
pub(crate) async fn copy_profile(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<CopyForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut inputs = PageInputs::for_selection(Selection {
|
||||
profile: form.profile.clone(),
|
||||
table: String::new(),
|
||||
});
|
||||
inputs.copy = form.clone();
|
||||
|
||||
if form.target_profile_name.trim().is_empty() {
|
||||
return refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
"Name the profile to copy into.".to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let request = CopyProfileRequest {
|
||||
source_profile_name: form.profile.clone(),
|
||||
target_profile_name: form.target_profile_name.trim().to_string(),
|
||||
table_names: form.table_names.clone(),
|
||||
};
|
||||
let Ok(request) = authenticated_request(&headers, request) else {
|
||||
return Redirect::to("/login").into_response();
|
||||
};
|
||||
|
||||
let mut definitions = state.definitions.clone();
|
||||
match definitions.copy_profile(request).await {
|
||||
Ok(response) if response.get_ref().success => {
|
||||
let response = response.into_inner();
|
||||
inputs.status = Some(format!(
|
||||
"{} — {} table(s) and {} script(s) copied.",
|
||||
response.message, response.tables_copied, response.scripts_copied
|
||||
));
|
||||
inputs.copy = CopyForm {
|
||||
profile: form.profile,
|
||||
..Default::default()
|
||||
};
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
}
|
||||
Ok(response) => {
|
||||
let message = response.into_inner().message;
|
||||
let message = if message.is_empty() {
|
||||
"The backend did not copy the profile.".to_string()
|
||||
} else {
|
||||
message
|
||||
};
|
||||
refuse(state, headers, inputs, message).await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/invoice-template — CreateInvoiceTemplateTable.
|
||||
pub(crate) async fn create_from_invoice_template(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<InvoiceTemplateForm>,
|
||||
) -> Response {
|
||||
if let Some(rejection) = reject_cross_site(&headers) {
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut inputs = PageInputs::for_selection(Selection {
|
||||
profile: form.profile.clone(),
|
||||
table: String::new(),
|
||||
});
|
||||
inputs.invoice = form.clone();
|
||||
|
||||
if form.table_name.trim().is_empty() || form.typst_source.trim().is_empty() {
|
||||
return refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
"A table name and the template's source are both required.".to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let request = CreateInvoiceTemplateTableRequest {
|
||||
profile_name: form.profile.clone(),
|
||||
table_name: form.table_name.trim().to_string(),
|
||||
typst_source: form.typst_source.clone(),
|
||||
row_display_columns: form.display_columns(),
|
||||
};
|
||||
let Ok(request) = authenticated_request(&headers, request) else {
|
||||
return Redirect::to("/login").into_response();
|
||||
};
|
||||
|
||||
let mut definitions = state.definitions.clone();
|
||||
match definitions.create_invoice_template_table(request).await {
|
||||
Ok(response) if response.get_ref().success => {
|
||||
let response = response.into_inner();
|
||||
inputs.status = Some(format!(
|
||||
"{} table(s) created from the template.",
|
||||
response.tables.len()
|
||||
));
|
||||
inputs.generated = response
|
||||
.tables
|
||||
.into_iter()
|
||||
.map(|table| GeneratedTableView {
|
||||
table_name: table.table_name,
|
||||
collection_path: table.collection_path,
|
||||
parent_table_name: table.parent_table_name,
|
||||
})
|
||||
.collect();
|
||||
inputs.invoice = InvoiceTemplateForm {
|
||||
profile: form.profile,
|
||||
..Default::default()
|
||||
};
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
}
|
||||
Ok(_) => {
|
||||
refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
"The backend did not create the template's tables.".to_string(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-reads the workspace and answers with it.
|
||||
async fn respond(
|
||||
state: AppState,
|
||||
headers: HeaderMap,
|
||||
inputs: PageInputs,
|
||||
status: StatusCode,
|
||||
) -> Response {
|
||||
match load_page(state, &headers, inputs).await {
|
||||
Ok(page) => (status, Html(ui::render_workspace(&page))).into_response(),
|
||||
Err(error) => load_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers a refused write: the workspace as it still is, plus the reason.
|
||||
async fn refuse(
|
||||
state: AppState,
|
||||
headers: HeaderMap,
|
||||
mut inputs: PageInputs,
|
||||
message: String,
|
||||
) -> Response {
|
||||
inputs.error = Some(message);
|
||||
respond(state, headers, inputs, StatusCode::UNPROCESSABLE_ENTITY).await
|
||||
}
|
||||
|
||||
fn load_error_response(error: LoadError) -> Response {
|
||||
match error {
|
||||
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
|
||||
LoadError::Forbidden => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Html(ui::render_load_error(
|
||||
"Administrator access is required.",
|
||||
)),
|
||||
)
|
||||
.into_response(),
|
||||
LoadError::Backend(message) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Html(ui::render_load_error(&message)),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user