add column and alias are separate

This commit is contained in:
Priec
2026-08-15 14:58:36 +02:00
parent f3ba8e73c5
commit 7b190e3d2f
18 changed files with 222 additions and 142 deletions

View File

@@ -301,7 +301,8 @@ mod tests {
#[tokio::test]
async fn the_table_definition_pages_are_mounted_and_need_a_session() {
for path in [
"/admin/tables/columns",
"/admin/tables/columns/add",
"/admin/tables/presentation",
"/admin/tables/delete",
"/admin/profiles/copy",
"/admin/profiles/history",
@@ -321,8 +322,8 @@ mod tests {
assert_eq!(status, axum::http::StatusCode::PERMANENT_REDIRECT);
for path in [
"/admin/tables/columns",
"/admin/tables/columns/builder",
"/admin/tables/columns/add",
"/admin/tables/columns/add/builder",
"/admin/tables/presentation",
"/admin/tables/delete",
"/admin/profiles/copy",
@@ -452,7 +453,7 @@ mod tests {
("/admin/tables/delete", "profile=billing&table=invoice"),
("/admin/profiles/copy", "profile=billing&table=invoice"),
("/admin/tables/presentation", "profile=billing&table=invoice"),
("/admin/tables/columns", "profile=billing&table=invoice"),
("/admin/tables/columns/add", "profile=billing&table=invoice"),
("/admin/tables/builder", ""),
("/admin/tables", ""),
("/admin/logic", ""),

View File

@@ -156,7 +156,7 @@ mod tests {
let html = render_workspace(&page);
for route in [
"/admin/tables/columns?profile=books&table=invoice",
"/admin/tables/presentation?profile=books&table=invoice",
"/admin/tables/delete?profile=books&table=invoice",
"/admin/tables/new?profile=books",
"/admin/profiles/copy?profile=books",

View File

@@ -1,4 +1,4 @@
//! Reads the context every one of the five pages shows.
//! Reads the context every table-definition page shows.
//!
//! One loader serves them all, because they all need the same thing: which
//! table is being worked on, and what it currently is. Four calls, in this

View File

@@ -1,4 +1,4 @@
//! The request handlers behind the five table-definition pages.
//! The request handlers behind the six table-definition pages.
//!
//! 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,
@@ -43,7 +43,8 @@ use super::{
/// which fragment a write on it swaps back.
#[derive(Clone, Copy)]
enum Page {
Columns,
AddColumns,
Presentation,
Delete,
Copy,
Template,
@@ -53,7 +54,8 @@ enum Page {
impl Page {
fn name(self) -> &'static str {
match self {
Self::Columns => "columns",
Self::AddColumns => "add-columns",
Self::Presentation => "presentation",
Self::Delete => "delete",
Self::Copy => "copy",
Self::Template => "template",
@@ -63,7 +65,8 @@ impl Page {
fn render_page(self, page: &TableDefinitionPageState) -> String {
match self {
Self::Columns => ui::render_columns_page(page),
Self::AddColumns => ui::render_add_columns_page(page),
Self::Presentation => ui::render_presentation_page(page),
Self::Delete => ui::render_delete_page(page),
Self::Copy => ui::render_copy_page(page),
Self::Template => ui::render_template_page(page),
@@ -74,7 +77,8 @@ impl 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::AddColumns => ui::render_add_columns_fragment(page),
Self::Presentation => ui::render_presentation_fragment(page),
Self::Delete => ui::render_delete_fragment(page),
Self::Copy => ui::render_copy_fragment(page),
Self::Template => ui::render_template_fragment(page),
@@ -85,13 +89,22 @@ impl Page {
// ── The pages ───────────────────────────────────────────────────────────────
/// GET /admin/tables/columns
pub(crate) async fn columns_page(
/// GET /admin/tables/columns/add
pub(crate) async fn add_columns_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
show(state, headers, PageInputs::for_selection(selection), Page::Columns).await
show(state, headers, PageInputs::for_selection(selection), Page::AddColumns).await
}
/// GET /admin/tables/presentation
pub(crate) async fn presentation_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
show(state, headers, PageInputs::for_selection(selection), Page::Presentation).await
}
/// GET /admin/tables/delete
@@ -151,7 +164,7 @@ async fn show(state: AppState, headers: HeaderMap, inputs: PageInputs, page: Pag
// ── The writes ──────────────────────────────────────────────────────────────
/// POST /admin/tables/columns/builder — staging a column to append.
/// POST /admin/tables/columns/add/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.
@@ -194,14 +207,14 @@ pub(crate) async fn update_columns(
match load_page(state, &headers, inputs).await {
Ok(mut loaded) => {
loaded.active = Page::Columns.name();
loaded.active = Page::AddColumns.name();
Html(ui::render_column_panel(&loaded)).into_response()
}
Err(error) => load_error_response(&headers, error),
}
}
/// POST /admin/tables/columns — AddTableColumns.
/// POST /admin/tables/columns/add — AddTableColumns.
pub(crate) async fn add_columns(
State(state): State<AppState>,
headers: HeaderMap,
@@ -232,7 +245,7 @@ pub(crate) async fn add_columns(
state,
headers,
inputs,
Page::Columns,
Page::AddColumns,
message,
)
.await;
@@ -246,7 +259,7 @@ pub(crate) async fn add_columns(
state,
headers,
inputs,
Page::Columns,
Page::AddColumns,
message,
)
.await;
@@ -254,7 +267,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(Locale::from_headers(&headers)) {
return refuse(state, headers, inputs, Page::Columns, message).await;
return refuse(state, headers, inputs, Page::AddColumns, message).await;
}
let request = AddTableColumnsRequest {
@@ -283,7 +296,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, Page::Columns, StatusCode::OK).await
respond(state, headers, inputs, Page::AddColumns, StatusCode::OK).await
}
Ok(response) => {
let message = response.into_inner().sql;
@@ -295,10 +308,10 @@ pub(crate) async fn add_columns(
} else {
message
};
refuse(state, headers, inputs, Page::Columns, message).await
refuse(state, headers, inputs, Page::AddColumns, message).await
}
Err(error) => {
refuse(state, headers, inputs, Page::Columns, error.message().to_string()).await
refuse(state, headers, inputs, Page::AddColumns, error.message().to_string()).await
}
}
}
@@ -331,7 +344,7 @@ pub(crate) async fn set_column_presentation(
state,
headers,
inputs,
Page::Columns,
Page::Presentation,
message,
)
.await;
@@ -378,7 +391,7 @@ pub(crate) async fn set_column_presentation(
table: form.table,
..Default::default()
};
respond(state, headers, inputs, Page::Columns, StatusCode::OK).await
respond(state, headers, inputs, Page::Presentation, StatusCode::OK).await
}
Ok(response) => {
let message = response.into_inner().message;
@@ -390,10 +403,10 @@ pub(crate) async fn set_column_presentation(
} else {
message
};
refuse(state, headers, inputs, Page::Columns, message).await
refuse(state, headers, inputs, Page::Presentation, message).await
}
Err(error) => {
refuse(state, headers, inputs, Page::Columns, error.message().to_string()).await
refuse(state, headers, inputs, Page::Presentation, error.message().to_string()).await
}
}
}

View File

@@ -1,8 +1,8 @@
//! What can be done to a table once it exists, as one page per decision.
//!
//! Adding columns, dropping the table, copying its profile, generating tables
//! from a template and reading the rename history are five different jobs, and
//! they used to be five panels stacked on one `/admin/table-definition`
//! Adding columns, presenting columns, dropping the table, copying its profile,
//! generating tables from a template and reading the rename history are six
//! different jobs, and they used to be panels stacked on one `/admin/table-definition`
//! workspace — which meant that after creating a table you landed on a screen
//! where finding the delete form meant scrolling past five other forms.
//!
@@ -30,13 +30,18 @@ use crate::AppState;
pub(crate) fn router() -> Router<AppState> {
Router::new()
// Table-scoped.
.route("/admin/tables/columns", get(logic::columns_page))
.route("/admin/tables/columns", post(logic::add_columns))
.route(
"/admin/tables/columns/builder",
"/admin/tables/columns/add",
get(logic::add_columns_page).post(logic::add_columns),
)
.route(
"/admin/tables/columns/add/builder",
post(logic::update_columns),
)
.route("/admin/tables/presentation", post(logic::set_column_presentation))
.route(
"/admin/tables/presentation",
get(logic::presentation_page).post(logic::set_column_presentation),
)
.route("/admin/tables/delete", get(logic::delete_page))
.route("/admin/tables/delete", post(logic::delete_table))
// Profile-scoped.

View File

@@ -1,6 +1,6 @@
//! What the table-definition pages render, and the wire formats they post.
//!
//! One state type serves all five pages, because each of them needs the same
//! One state type serves all table-definition pages, because each of them needs the same
//! context: which profile and table is being worked on, and what that table
//! currently is. What differs is which panel the page renders, and `active`
//! is what says so.

View File

@@ -2,8 +2,8 @@
//!
//! The pages share a state type and an action switcher, so what differs
//! between them here is only which panel they render. The column vocabulary
//! is threaded through the two that need it, because askama resolves those
//! fields on the struct rather than on `page`.
//! is threaded through the add-column templates, because askama resolves
//! those fields on the struct rather than on `page`.
use askama::Template;
@@ -15,25 +15,25 @@ use crate::{
use super::state::TableDefinitionPageState;
/// GET /admin/tables/columns
/// GET /admin/tables/columns/add
#[derive(Template)]
#[template(path = "pages/admin/table_definition/columns.html")]
struct ColumnsPage<'a> {
#[template(path = "pages/admin/table_definition/add_columns.html")]
struct AddColumnsPage<'a> {
nav: Nav,
page: &'a TableDefinitionPageState,
column_types: Vec<String>,
temporal_types: Vec<String>,
gtin_types: Vec<String>,
currency_codes: &'static [&'static str],
/// False, as on the fragment: the page embeds both, and the outcome is
/// reported once, at the top.
/// False, as on the fragment: the page reports the outcome once, at the
/// top of the panel containing this fragment.
standalone_column_panel: bool,
}
/// The `#table-panel` swap on the columns page.
/// The `#table-panel` swap on the add-columns page.
#[derive(Template)]
#[template(path = "pages/admin/table_definition/columns_panel.html")]
struct ColumnsFragment<'a> {
#[template(path = "pages/admin/table_definition/add_columns_panel.html")]
struct AddColumnsFragment<'a> {
nav: Nav,
page: &'a TableDefinitionPageState,
column_types: Vec<String>,
@@ -58,6 +58,22 @@ struct ColumnPanelFragment<'a> {
standalone_column_panel: bool,
}
/// GET /admin/tables/presentation
#[derive(Template)]
#[template(path = "pages/admin/table_definition/presentation.html")]
struct PresentationPage<'a> {
nav: Nav,
page: &'a TableDefinitionPageState,
}
/// The `#table-panel` swap on the column-presentation page.
#[derive(Template)]
#[template(path = "pages/admin/table_definition/presentation_panel.html")]
struct PresentationFragment<'a> {
nav: Nav,
page: &'a TableDefinitionPageState,
}
/// GET /admin/tables/delete
#[derive(Template)]
#[template(path = "pages/admin/table_definition/delete.html")]
@@ -111,8 +127,8 @@ struct HistoryPage<'a> {
page: &'a TableDefinitionPageState,
}
pub(crate) fn render_columns_page(page: &TableDefinitionPageState) -> String {
render(&ColumnsPage {
pub(crate) fn render_add_columns_page(page: &TableDefinitionPageState) -> String {
render(&AddColumnsPage {
nav: page.nav.clone(),
page,
// Asking the draft, so the picker can only ever offer what the draft
@@ -125,8 +141,8 @@ pub(crate) fn render_columns_page(page: &TableDefinitionPageState) -> String {
})
}
pub(crate) fn render_columns_fragment(page: &TableDefinitionPageState) -> String {
render(&ColumnsFragment {
pub(crate) fn render_add_columns_fragment(page: &TableDefinitionPageState) -> String {
render(&AddColumnsFragment {
nav: page.nav.clone(),
page,
column_types: page.columns.offered_types(),
@@ -147,6 +163,20 @@ pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String {
})
}
pub(crate) fn render_presentation_page(page: &TableDefinitionPageState) -> String {
render(&PresentationPage {
nav: page.nav.clone(),
page,
})
}
pub(crate) fn render_presentation_fragment(page: &TableDefinitionPageState) -> String {
render(&PresentationFragment {
nav: page.nav.clone(),
page,
})
}
pub(crate) fn render_delete_page(page: &TableDefinitionPageState) -> String {
render(&DeletePage {
nav: page.nav.clone(),
@@ -262,7 +292,7 @@ mod tests {
error: None,
sql: None,
generated: Vec::new(),
active: "columns",
active: "presentation",
}
}
@@ -273,16 +303,18 @@ mod tests {
let mut state = page();
for (active, html) in [
("columns", render_columns_page(&state)),
("add-columns", render_add_columns_page(&state)),
("presentation", render_presentation_page(&state)),
("delete", render_delete_page(&state)),
] {
state.active = active;
assert!(!html.contains("Template error"), "{html}");
}
let html = render_columns_page(&state);
let html = render_presentation_page(&state);
for route in [
"/admin/tables/columns?profile=billing",
"/admin/tables/columns/add?profile=billing",
"/admin/tables/presentation?profile=billing",
"/admin/tables/delete?profile=billing",
"/admin/profiles/copy?profile=billing",
"/admin/tables/from-template?profile=billing",
@@ -308,7 +340,7 @@ mod tests {
assert!(html.contains("/admin/tables/delete"));
assert!(html.contains("Type <code>invoice</code> to confirm"));
// The other writes are links in the switcher, not forms on the page.
assert!(!html.contains("/admin/tables/presentation"));
assert!(!html.contains(r#"hx-post="/admin/tables/presentation""#));
assert!(!html.contains("/admin/profiles/copy?profile=billing\" method"));
}
@@ -368,13 +400,16 @@ mod tests {
assert!(html.contains("backend's own"));
assert!(!html.contains(r#"name="confirm_table_name""#));
let html = render_columns_page(&state);
assert!(!html.contains("/admin/tables/presentation"));
let html = render_add_columns_page(&state);
assert!(!html.contains(r#"id="column-form""#));
let html = render_presentation_page(&state);
assert!(!html.contains(r#"name="aliases""#));
}
#[test]
fn column_presentation_posts_stable_ids_aliases_and_order_controls() {
let html = render_columns_page(&page());
let html = render_presentation_page(&page());
assert!(html.contains(r#"hx-post="/admin/tables/presentation""#), "{html}");
assert!(html.contains(r#"name="column_ids" value="1""#), "{html}");
@@ -383,6 +418,17 @@ mod tests {
assert!(html.contains(r#"name="action" value="save""#), "{html}");
}
#[test]
fn adding_columns_and_presentation_are_separate_pages() {
let add_html = render_add_columns_page(&page());
assert!(add_html.contains(r#"id="column-form""#), "{add_html}");
assert!(!add_html.contains(r#"name="aliases""#), "{add_html}");
let presentation_html = render_presentation_page(&page());
assert!(presentation_html.contains(r#"name="aliases""#), "{presentation_html}");
assert!(!presentation_html.contains(r#"id="column-form""#), "{presentation_html}");
}
/// The profile-wide pages need only a profile, and say so by still
/// rendering with no table selected.
#[test]
@@ -429,7 +475,7 @@ mod tests {
let html = render_column_panel(&state);
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains("/admin/tables/columns/builder"));
assert!(html.contains("/admin/tables/columns/add/builder"));
// Escaped, because it is an attribute: `&#38;` is what a browser reads
// back as the `&` separating the two parameters.
assert!(html.contains("?profile=billing&#38;table=invoice"));
@@ -548,10 +594,10 @@ mod tests {
#[test]
fn a_failure_is_shown_as_a_dialog_as_well_as_an_alert() {
let mut state = page();
assert!(!render_columns_fragment(&state).contains(r#"role="dialog""#));
assert!(!render_add_columns_fragment(&state).contains(r#"role="dialog""#));
state.error = Some("That column already exists.".to_string());
let html = render_columns_fragment(&state);
let html = render_add_columns_fragment(&state);
assert!(html.contains(r#"role="dialog""#));
// Once in the inline alert, once in the dialog — and not a third time
// from the column panel the fragment embeds.
@@ -580,7 +626,7 @@ mod tests {
state.status = Some("1 column added to `invoice`.".to_string());
state.sql = Some("ALTER TABLE \"billing\".\"invoice\" ADD COLUMN …".to_string());
let html = render_columns_fragment(&state);
let html = render_add_columns_fragment(&state);
assert!(html.contains("ALTER TABLE"));
assert!(html.contains("1 column added"));
@@ -591,7 +637,7 @@ mod tests {
let mut state = page();
state.selection.profile = "__global".to_string();
let html = render_columns_page(&state);
let html = render_presentation_page(&state);
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains("Global — all profiles"));