web routing is POG now
This commit is contained in:
@@ -1,17 +1,20 @@
|
||||
//! The workspace's request handlers — one per `TableDefinition` write.
|
||||
//! The request handlers behind the five table-definition pages.
|
||||
//!
|
||||
//! 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.
|
||||
//! Each page has a GET that renders it and, except for the read-only history,
|
||||
//! a POST per write it offers. A write answers with its own page's body,
|
||||
//! 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.
|
||||
//! Two exceptions. Staging a column changes nothing on the server, so those
|
||||
//! interactions swap only the column panel. And a successful delete leaves no
|
||||
//! page to return to — the table it was about is gone — so it redirects to the
|
||||
//! admin panel instead.
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::Form;
|
||||
@@ -30,36 +33,124 @@ use super::{
|
||||
loader::{self, load_page},
|
||||
state::{
|
||||
CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs,
|
||||
RenameForm, Selection,
|
||||
RenameForm, Selection, TableDefinitionPageState,
|
||||
},
|
||||
ui,
|
||||
};
|
||||
|
||||
/// GET /admin/table-definition
|
||||
pub(crate) async fn page(
|
||||
/// Which page a response belongs to: what the switcher marks as current, and
|
||||
/// which fragment a write on it swaps back.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Page {
|
||||
Columns,
|
||||
Delete,
|
||||
Copy,
|
||||
Template,
|
||||
History,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Columns => "columns",
|
||||
Self::Delete => "delete",
|
||||
Self::Copy => "copy",
|
||||
Self::Template => "template",
|
||||
Self::History => "history",
|
||||
}
|
||||
}
|
||||
|
||||
fn render_page(self, page: &TableDefinitionPageState) -> String {
|
||||
match self {
|
||||
Self::Columns => ui::render_columns_page(page),
|
||||
Self::Delete => ui::render_delete_page(page),
|
||||
Self::Copy => ui::render_copy_page(page),
|
||||
Self::Template => ui::render_template_page(page),
|
||||
Self::History => ui::render_history_page(page),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `#table-panel` swap. History has no writes, so it never asks.
|
||||
fn render_fragment(self, page: &TableDefinitionPageState) -> String {
|
||||
match self {
|
||||
Self::Columns => ui::render_columns_fragment(page),
|
||||
Self::Delete => ui::render_delete_fragment(page),
|
||||
Self::Copy => ui::render_copy_fragment(page),
|
||||
Self::Template => ui::render_template_fragment(page),
|
||||
Self::History => ui::render_history_page(page),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── The pages ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// GET /admin/tables/columns
|
||||
pub(crate) async fn columns_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),
|
||||
}
|
||||
show(state, headers, PageInputs::for_selection(selection), Page::Columns).await
|
||||
}
|
||||
|
||||
/// GET /admin/table-definition/workspace — the swap when the selection changes.
|
||||
pub(crate) async fn workspace(
|
||||
/// GET /admin/tables/delete
|
||||
pub(crate) async fn delete_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_workspace(&page)).into_response(),
|
||||
show(state, headers, PageInputs::for_selection(selection), Page::Delete).await
|
||||
}
|
||||
|
||||
/// GET /admin/profiles/copy
|
||||
pub(crate) async fn copy_page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<Selection>,
|
||||
) -> Response {
|
||||
show(state, headers, profile_inputs(selection), Page::Copy).await
|
||||
}
|
||||
|
||||
/// GET /admin/tables/from-template
|
||||
pub(crate) async fn template_page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<Selection>,
|
||||
) -> Response {
|
||||
show(state, headers, profile_inputs(selection), Page::Template).await
|
||||
}
|
||||
|
||||
/// GET /admin/profiles/history
|
||||
pub(crate) async fn history_page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Query(selection): Query<Selection>,
|
||||
) -> Response {
|
||||
show(state, headers, PageInputs::for_selection(selection), Page::History).await
|
||||
}
|
||||
|
||||
/// The profile-wide pages act on a profile, so a table in the query is
|
||||
/// dropped rather than carried into a form that has no use for it.
|
||||
fn profile_inputs(selection: Selection) -> PageInputs {
|
||||
PageInputs::for_selection(Selection {
|
||||
profile: selection.profile,
|
||||
table: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn show(state: AppState, headers: HeaderMap, inputs: PageInputs, page: Page) -> Response {
|
||||
match load_page(state, &headers, inputs).await {
|
||||
Ok(mut loaded) => {
|
||||
loaded.active = page.name();
|
||||
Html(page.render_page(&loaded)).into_response()
|
||||
}
|
||||
Err(error) => load_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/columns/builder — staging a column to append.
|
||||
// ── The writes ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// POST /admin/tables/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.
|
||||
@@ -100,12 +191,15 @@ pub(crate) async fn update_columns(
|
||||
}
|
||||
|
||||
match load_page(state, &headers, inputs).await {
|
||||
Ok(page) => Html(ui::render_column_panel(&page)).into_response(),
|
||||
Ok(mut loaded) => {
|
||||
loaded.active = Page::Columns.name();
|
||||
Html(ui::render_column_panel(&loaded)).into_response()
|
||||
}
|
||||
Err(error) => load_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/columns — AddTableColumns.
|
||||
/// POST /admin/tables/columns — AddTableColumns.
|
||||
pub(crate) async fn add_columns(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -128,13 +222,15 @@ pub(crate) async fn add_columns(
|
||||
inputs.columns = form.to_draft(catalog.clone(), false);
|
||||
|
||||
if !inputs.selection.has_table() {
|
||||
return refuse(state, headers, inputs, "Select a table first.".to_string()).await;
|
||||
return refuse(state, headers, inputs, Page::Columns, "Select a table first.".to_string())
|
||||
.await;
|
||||
}
|
||||
if inputs.columns.is_empty() {
|
||||
return refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
Page::Columns,
|
||||
"Describe at least one column before adding.".to_string(),
|
||||
)
|
||||
.await;
|
||||
@@ -142,7 +238,7 @@ pub(crate) async fn add_columns(
|
||||
// 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;
|
||||
return refuse(state, headers, inputs, Page::Columns, message).await;
|
||||
}
|
||||
|
||||
let request = AddTableColumnsRequest {
|
||||
@@ -166,7 +262,7 @@ pub(crate) async fn add_columns(
|
||||
));
|
||||
// The columns are the table's now, so the panel starts empty.
|
||||
inputs.columns = crate::schema::ColumnDraft::for_append(catalog);
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
respond(state, headers, inputs, Page::Columns, StatusCode::OK).await
|
||||
}
|
||||
Ok(response) => {
|
||||
let message = response.into_inner().sql;
|
||||
@@ -175,13 +271,15 @@ pub(crate) async fn add_columns(
|
||||
} else {
|
||||
message
|
||||
};
|
||||
refuse(state, headers, inputs, message).await
|
||||
refuse(state, headers, inputs, Page::Columns, message).await
|
||||
}
|
||||
Err(error) => {
|
||||
refuse(state, headers, inputs, Page::Columns, error.message().to_string()).await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/rename — RenameColumnAlias.
|
||||
/// POST /admin/tables/rename — RenameColumnAlias.
|
||||
pub(crate) async fn rename_column(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -202,6 +300,7 @@ pub(crate) async fn rename_column(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
Page::Columns,
|
||||
"Choose a column and type its new name.".to_string(),
|
||||
)
|
||||
.await;
|
||||
@@ -226,7 +325,7 @@ pub(crate) async fn rename_column(
|
||||
table: form.table,
|
||||
..Default::default()
|
||||
};
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
respond(state, headers, inputs, Page::Columns, StatusCode::OK).await
|
||||
}
|
||||
Ok(response) => {
|
||||
let message = response.into_inner().message;
|
||||
@@ -235,13 +334,18 @@ pub(crate) async fn rename_column(
|
||||
} else {
|
||||
message
|
||||
};
|
||||
refuse(state, headers, inputs, message).await
|
||||
refuse(state, headers, inputs, Page::Columns, message).await
|
||||
}
|
||||
Err(error) => {
|
||||
refuse(state, headers, inputs, Page::Columns, error.message().to_string()).await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/delete — DeleteTable.
|
||||
/// POST /admin/tables/delete — DeleteTable.
|
||||
///
|
||||
/// On success there is nothing left for this page to show, so it hands the
|
||||
/// browser to the admin panel with the profile still selected.
|
||||
pub(crate) async fn delete_table(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -251,7 +355,7 @@ pub(crate) async fn delete_table(
|
||||
return rejection;
|
||||
}
|
||||
|
||||
let mut inputs = PageInputs::for_selection(Selection {
|
||||
let inputs = PageInputs::for_selection(Selection {
|
||||
profile: form.profile.clone(),
|
||||
table: form.table.clone(),
|
||||
});
|
||||
@@ -263,6 +367,7 @@ pub(crate) async fn delete_table(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
Page::Delete,
|
||||
"Type the table's name exactly to confirm the deletion.".to_string(),
|
||||
)
|
||||
.await;
|
||||
@@ -279,11 +384,18 @@ pub(crate) async fn delete_table(
|
||||
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
|
||||
// The profile may have gone with it, in which case the admin panel
|
||||
// refuses the selection; landing on the bare panel is what the
|
||||
// caller wants then anyway.
|
||||
let location = format!("/admin?profile={}", form.profile);
|
||||
match HeaderValue::try_from(location) {
|
||||
Ok(location) => {
|
||||
let mut response = Html(String::new()).into_response();
|
||||
response.headers_mut().insert("hx-redirect", location);
|
||||
response
|
||||
}
|
||||
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response(),
|
||||
}
|
||||
}
|
||||
Ok(response) => {
|
||||
let message = response.into_inner().message;
|
||||
@@ -292,13 +404,15 @@ pub(crate) async fn delete_table(
|
||||
} else {
|
||||
message
|
||||
};
|
||||
refuse(state, headers, inputs, message).await
|
||||
refuse(state, headers, inputs, Page::Delete, message).await
|
||||
}
|
||||
Err(error) => {
|
||||
refuse(state, headers, inputs, Page::Delete, error.message().to_string()).await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/copy — CopyProfile.
|
||||
/// POST /admin/profiles/copy — CopyProfile.
|
||||
pub(crate) async fn copy_profile(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -319,6 +433,7 @@ pub(crate) async fn copy_profile(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
Page::Copy,
|
||||
"Name the profile to copy into.".to_string(),
|
||||
)
|
||||
.await;
|
||||
@@ -345,7 +460,7 @@ pub(crate) async fn copy_profile(
|
||||
profile: form.profile,
|
||||
..Default::default()
|
||||
};
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
respond(state, headers, inputs, Page::Copy, StatusCode::OK).await
|
||||
}
|
||||
Ok(response) => {
|
||||
let message = response.into_inner().message;
|
||||
@@ -354,13 +469,13 @@ pub(crate) async fn copy_profile(
|
||||
} else {
|
||||
message
|
||||
};
|
||||
refuse(state, headers, inputs, message).await
|
||||
refuse(state, headers, inputs, Page::Copy, message).await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
Err(error) => refuse(state, headers, inputs, Page::Copy, error.message().to_string()).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// POST /admin/table-definition/invoice-template — CreateInvoiceTemplateTable.
|
||||
/// POST /admin/tables/from-template — CreateInvoiceTemplateTable.
|
||||
pub(crate) async fn create_from_invoice_template(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
@@ -381,6 +496,7 @@ pub(crate) async fn create_from_invoice_template(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
Page::Template,
|
||||
"A table name and the template's source are both required.".to_string(),
|
||||
)
|
||||
.await;
|
||||
@@ -417,43 +533,51 @@ pub(crate) async fn create_from_invoice_template(
|
||||
profile: form.profile,
|
||||
..Default::default()
|
||||
};
|
||||
respond(state, headers, inputs, StatusCode::OK).await
|
||||
respond(state, headers, inputs, Page::Template, StatusCode::OK).await
|
||||
}
|
||||
Ok(_) => {
|
||||
refuse(
|
||||
state,
|
||||
headers,
|
||||
inputs,
|
||||
Page::Template,
|
||||
"The backend did not create the template's tables.".to_string(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
|
||||
Err(error) => {
|
||||
refuse(state, headers, inputs, Page::Template, error.message().to_string()).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-reads the workspace and answers with it.
|
||||
/// Re-reads the page's body and answers with it.
|
||||
async fn respond(
|
||||
state: AppState,
|
||||
headers: HeaderMap,
|
||||
inputs: PageInputs,
|
||||
page: Page,
|
||||
status: StatusCode,
|
||||
) -> Response {
|
||||
match load_page(state, &headers, inputs).await {
|
||||
Ok(page) => (status, Html(ui::render_workspace(&page))).into_response(),
|
||||
Ok(mut loaded) => {
|
||||
loaded.active = page.name();
|
||||
(status, Html(page.render_fragment(&loaded))).into_response()
|
||||
}
|
||||
Err(error) => load_error_response(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers a refused write: the workspace as it still is, plus the reason.
|
||||
/// Answers a refused write: the page as it still is, plus the reason.
|
||||
async fn refuse(
|
||||
state: AppState,
|
||||
headers: HeaderMap,
|
||||
mut inputs: PageInputs,
|
||||
page: Page,
|
||||
message: String,
|
||||
) -> Response {
|
||||
inputs.error = Some(message);
|
||||
respond(state, headers, inputs, StatusCode::UNPROCESSABLE_ENTITY).await
|
||||
respond(state, headers, inputs, page, StatusCode::UNPROCESSABLE_ENTITY).await
|
||||
}
|
||||
|
||||
fn load_error_response(error: LoadError) -> Response {
|
||||
|
||||
Reference in New Issue
Block a user