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

@@ -133,7 +133,7 @@ admin-no-profile-tables = Tento profil nemá vlastní tabulky.
admin-no-dependencies = Bez závislostí admin-no-dependencies = Bez závislostí
admin-depends-on-prefix = Závisí na admin-depends-on-prefix = Závisí na
admin-row-display-prefix = zobrazení: admin-row-display-prefix = zobrazení:
admin-add-rename-columns = Přidat / přejmenovat sloupce admin-column-presentation = Aliasy sloupců
admin-delete-table = Smazat tabulku admin-delete-table = Smazat tabulku
admin-who-may-use-it = Kdo ji může používat admin-who-may-use-it = Kdo ji může používat
admin-pick-table-hint = Vyberte tabulku, abyste do ní přidali sloupce, smazali ji nebo určili, kdo ji může používat. admin-pick-table-hint = Vyberte tabulku, abyste do ní přidali sloupce, smazali ji nebo určili, kdo ji může používat.
@@ -156,16 +156,17 @@ admin-select-table-inspect = Vyberte tabulku, abyste zobrazili její sloupce.
admin-no-visible-columns = Tato tabulka nemá žádné viditelné sloupce. admin-no-visible-columns = Tato tabulka nemá žádné viditelné sloupce.
admin-system-columns-label = Systémové sloupce · skryté před uživateli admin-system-columns-label = Systémové sloupce · skryté před uživateli
# --- Stránky definice tabulky (sloupce / smazat / kopírovat / šablona / historie) # --- Stránky definice tabulky (přidat sloupce / zobrazení / smazat / kopírovat / šablona / historie)
td-columns-title = Sloupce td-add-columns-title = Přidat sloupce
td-presentation-title = Zobrazení sloupců
td-add-columns-to = Přidat sloupce do td-add-columns-to = Přidat sloupce do
td-append-hint = Sloupce se přidávají na konec. Nic, co už existuje, se nemění a nové sloupce lze indexovat hned při přidání. td-append-hint = Sloupce se přidávají na konec. Nic, co už existuje, se nemění a nové sloupce lze indexovat hned při přidání.
td-rename-column = Přejmenovat sloupec td-rename-column = Aliasy a pořadí sloupců
td-rename-hint = Přejmenovává se jen zobrazený název, ne fyzický sloupec pod ním, takže uložená data a skripty zůstávají nedotčené. Možné jen dokud tabulka nemá žádné řádky. td-rename-hint = Přejmenovává se jen zobrazený název, ne fyzický sloupec pod ním, takže uložená data a skripty zůstávají nedotčené. Možné jen dokud tabulka nemá žádné řádky.
td-column-label = Sloupec td-column-label = Sloupec
td-choose-column = Vyberte sloupec td-choose-column = Vyberte sloupec
td-new-name = Nový název td-new-name = Nový název
td-rename-button = Přejmenovat sloupec td-rename-button = Uložit zobrazení
td-columns-now = Současné sloupce td-columns-now = Současné sloupce
td-col-column = Sloupec td-col-column = Sloupec
td-col-type = Typ td-col-type = Typ
@@ -234,8 +235,10 @@ td-depends-on-prefix = závisí na
td-acts-on-profile = Vše na této stránce se týká tohoto profilu. td-acts-on-profile = Vše na této stránce se týká tohoto profilu.
td-global-label = Globální — všechny profily td-global-label = Globální — všechny profily
td-tab-aria = akce s tabulkou td-tab-aria = akce s tabulkou
td-tab-columns = Sloupce td-tab-add-columns = Přidat sloupce
td-tab-columns-sub = Přidat sloupce a přejmenovat je td-tab-add-columns-sub = Připojit nové sloupce
td-tab-presentation = Zobrazení
td-tab-presentation-sub = Změnit aliasy a pořadí
td-tab-delete = Smazat td-tab-delete = Smazat
td-tab-delete-sub = Odebrat tuto tabulku td-tab-delete-sub = Odebrat tuto tabulku
td-tab-access = Přístup td-tab-access = Přístup

View File

@@ -137,7 +137,7 @@ admin-no-profile-tables = This profile has no tables of its own.
admin-no-dependencies = No dependencies admin-no-dependencies = No dependencies
admin-depends-on-prefix = Depends on admin-depends-on-prefix = Depends on
admin-row-display-prefix = display: admin-row-display-prefix = display:
admin-add-rename-columns = Add / rename columns admin-column-presentation = Column aliases
admin-delete-table = Delete table admin-delete-table = Delete table
admin-who-may-use-it = Who may use it admin-who-may-use-it = Who may use it
admin-pick-table-hint = Pick a table to add columns to it, delete it, or say who may use it. admin-pick-table-hint = Pick a table to add columns to it, delete it, or say who may use it.
@@ -158,16 +158,17 @@ admin-select-table-inspect = Select a table to inspect its columns.
admin-no-visible-columns = This table has no visible columns. admin-no-visible-columns = This table has no visible columns.
admin-system-columns-label = System columns · hidden from users admin-system-columns-label = System columns · hidden from users
# --- Table definition pages (columns / delete / copy / template / history) - # --- Table definition pages (add columns / presentation / delete / copy / template / history) -
td-columns-title = Columns td-add-columns-title = Add columns
td-presentation-title = Column presentation
td-add-columns-to = Add columns to td-add-columns-to = Add columns to
td-append-hint = Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added. td-append-hint = Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added.
td-rename-column = Rename a column td-rename-column = Column aliases and order
td-rename-hint = Renames what the column is called, not the physical column underneath, so stored data and scripts are untouched. Only possible while the table has no rows. td-rename-hint = Renames what the column is called, not the physical column underneath, so stored data and scripts are untouched. Only possible while the table has no rows.
td-column-label = Column td-column-label = Column
td-choose-column = Choose a column td-choose-column = Choose a column
td-new-name = New name td-new-name = New name
td-rename-button = Rename column td-rename-button = Save presentation
td-columns-now = Columns it has now td-columns-now = Columns it has now
td-col-column = Column td-col-column = Column
td-col-type = Type td-col-type = Type
@@ -234,8 +235,10 @@ td-depends-on-prefix = depends on
td-acts-on-profile = Everything on this page acts on this profile. td-acts-on-profile = Everything on this page acts on this profile.
td-global-label = Global — all profiles td-global-label = Global — all profiles
td-tab-aria = table actions td-tab-aria = table actions
td-tab-columns = Columns td-tab-add-columns = Add columns
td-tab-columns-sub = Append columns and rename them td-tab-add-columns-sub = Append new columns
td-tab-presentation = Presentation
td-tab-presentation-sub = Change aliases and order
td-tab-delete = Delete td-tab-delete = Delete
td-tab-delete-sub = Drop this table td-tab-delete-sub = Drop this table
td-tab-access = Access td-tab-access = Access

View File

@@ -133,7 +133,7 @@ admin-no-profile-tables = Tento profil nemá vlastné tabuľky.
admin-no-dependencies = Bez závislostí admin-no-dependencies = Bez závislostí
admin-depends-on-prefix = Závisí od admin-depends-on-prefix = Závisí od
admin-row-display-prefix = zobrazenie: admin-row-display-prefix = zobrazenie:
admin-add-rename-columns = Pridať / premenovať stĺpce admin-column-presentation = Aliasy stĺpcov
admin-delete-table = Vymazať tabuľku admin-delete-table = Vymazať tabuľku
admin-who-may-use-it = Kto ju môže používať admin-who-may-use-it = Kto ju môže používať
admin-pick-table-hint = Vyberte tabuľku, aby ste do nej pridali stĺpce, vymazali ju alebo určili, kto ju môže používať. admin-pick-table-hint = Vyberte tabuľku, aby ste do nej pridali stĺpce, vymazali ju alebo určili, kto ju môže používať.
@@ -156,16 +156,17 @@ admin-select-table-inspect = Vyberte tabuľku, aby ste zobrazili jej stĺpce.
admin-no-visible-columns = Táto tabuľka nemá žiadne viditeľné stĺpce. admin-no-visible-columns = Táto tabuľka nemá žiadne viditeľné stĺpce.
admin-system-columns-label = Systémové stĺpce · skryté pred používateľmi admin-system-columns-label = Systémové stĺpce · skryté pred používateľmi
# --- Stránky definície tabuľky (stĺpce / vymazať / kopírovať / šablóna / história) # --- Stránky definície tabuľky (pridať stĺpce / zobrazenie / vymazať / kopírovať / šablóna / história)
td-columns-title = Stĺpce td-add-columns-title = Pridať stĺpce
td-presentation-title = Zobrazenie stĺpcov
td-add-columns-to = Pridať stĺpce do td-add-columns-to = Pridať stĺpce do
td-append-hint = Stĺpce sa pridávajú na koniec. Nič, čo už existuje, sa nemení a nové stĺpce možno indexovať hneď pri pridaní. td-append-hint = Stĺpce sa pridávajú na koniec. Nič, čo už existuje, sa nemení a nové stĺpce možno indexovať hneď pri pridaní.
td-rename-column = Premenovať stĺpec td-rename-column = Aliasy a poradie stĺpcov
td-rename-hint = Premenúva sa len zobrazený názov, nie fyzický stĺpec pod ním, takže uložené údaje a skripty zostávajú nedotknuté. Možné len kým tabuľka nemá žiadne riadky. td-rename-hint = Premenúva sa len zobrazený názov, nie fyzický stĺpec pod ním, takže uložené údaje a skripty zostávajú nedotknuté. Možné len kým tabuľka nemá žiadne riadky.
td-column-label = Stĺpec td-column-label = Stĺpec
td-choose-column = Vyberte stĺpec td-choose-column = Vyberte stĺpec
td-new-name = Nový názov td-new-name = Nový názov
td-rename-button = Premenovať stĺpec td-rename-button = Uložiť zobrazenie
td-columns-now = Súčasné stĺpce td-columns-now = Súčasné stĺpce
td-col-column = Stĺpec td-col-column = Stĺpec
td-col-type = Typ td-col-type = Typ
@@ -234,8 +235,10 @@ td-depends-on-prefix = závisí od
td-acts-on-profile = Všetko na tejto stránke sa týka tohto profilu. td-acts-on-profile = Všetko na tejto stránke sa týka tohto profilu.
td-global-label = Globálne — všetky profily td-global-label = Globálne — všetky profily
td-tab-aria = akcie s tabuľkou td-tab-aria = akcie s tabuľkou
td-tab-columns = Stĺpce td-tab-add-columns = Pridať stĺpce
td-tab-columns-sub = Pridať stĺpce a premenovať ich td-tab-add-columns-sub = Pripojiť nové stĺpce
td-tab-presentation = Zobrazenie
td-tab-presentation-sub = Zmeniť aliasy a poradie
td-tab-delete = Vymazať td-tab-delete = Vymazať
td-tab-delete-sub = Odstrániť túto tabuľku td-tab-delete-sub = Odstrániť túto tabuľku
td-tab-access = Prístup td-tab-access = Prístup

View File

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

View File

@@ -156,7 +156,7 @@ mod tests {
let html = render_workspace(&page); let html = render_workspace(&page);
for route in [ 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/delete?profile=books&table=invoice",
"/admin/tables/new?profile=books", "/admin/tables/new?profile=books",
"/admin/profiles/copy?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 //! 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 //! 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, //! 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, //! 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. /// which fragment a write on it swaps back.
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
enum Page { enum Page {
Columns, AddColumns,
Presentation,
Delete, Delete,
Copy, Copy,
Template, Template,
@@ -53,7 +54,8 @@ enum Page {
impl Page { impl Page {
fn name(self) -> &'static str { fn name(self) -> &'static str {
match self { match self {
Self::Columns => "columns", Self::AddColumns => "add-columns",
Self::Presentation => "presentation",
Self::Delete => "delete", Self::Delete => "delete",
Self::Copy => "copy", Self::Copy => "copy",
Self::Template => "template", Self::Template => "template",
@@ -63,7 +65,8 @@ impl Page {
fn render_page(self, page: &TableDefinitionPageState) -> String { fn render_page(self, page: &TableDefinitionPageState) -> String {
match self { 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::Delete => ui::render_delete_page(page),
Self::Copy => ui::render_copy_page(page), Self::Copy => ui::render_copy_page(page),
Self::Template => ui::render_template_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. /// The `#table-panel` swap. History has no writes, so it never asks.
fn render_fragment(self, page: &TableDefinitionPageState) -> String { fn render_fragment(self, page: &TableDefinitionPageState) -> String {
match self { 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::Delete => ui::render_delete_fragment(page),
Self::Copy => ui::render_copy_fragment(page), Self::Copy => ui::render_copy_fragment(page),
Self::Template => ui::render_template_fragment(page), Self::Template => ui::render_template_fragment(page),
@@ -85,13 +89,22 @@ impl Page {
// ── The pages ─────────────────────────────────────────────────────────────── // ── The pages ───────────────────────────────────────────────────────────────
/// GET /admin/tables/columns /// GET /admin/tables/columns/add
pub(crate) async fn columns_page( pub(crate) async fn add_columns_page(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Query(selection): Query<Selection>, Query(selection): Query<Selection>,
) -> Response { ) -> 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 /// GET /admin/tables/delete
@@ -151,7 +164,7 @@ async fn show(state: AppState, headers: HeaderMap, inputs: PageInputs, page: Pag
// ── The writes ────────────────────────────────────────────────────────────── // ── 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, /// 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. /// 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 { match load_page(state, &headers, inputs).await {
Ok(mut loaded) => { Ok(mut loaded) => {
loaded.active = Page::Columns.name(); loaded.active = Page::AddColumns.name();
Html(ui::render_column_panel(&loaded)).into_response() Html(ui::render_column_panel(&loaded)).into_response()
} }
Err(error) => load_error_response(&headers, error), Err(error) => load_error_response(&headers, error),
} }
} }
/// POST /admin/tables/columns — AddTableColumns. /// POST /admin/tables/columns/add — AddTableColumns.
pub(crate) async fn add_columns( pub(crate) async fn add_columns(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -232,7 +245,7 @@ pub(crate) async fn add_columns(
state, state,
headers, headers,
inputs, inputs,
Page::Columns, Page::AddColumns,
message, message,
) )
.await; .await;
@@ -246,7 +259,7 @@ pub(crate) async fn add_columns(
state, state,
headers, headers,
inputs, inputs,
Page::Columns, Page::AddColumns,
message, message,
) )
.await; .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 // The same checks the server runs, applied to a draft that may have been
// rebuilt from a posted form rather than through the panel. // rebuilt from a posted form rather than through the panel.
if let Err(message) = inputs.columns.validate(Locale::from_headers(&headers)) { 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 { 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. // The columns are the table's now, so the panel starts empty.
inputs.columns = crate::schema::ColumnDraft::for_append(catalog); 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) => { Ok(response) => {
let message = response.into_inner().sql; let message = response.into_inner().sql;
@@ -295,10 +308,10 @@ pub(crate) async fn add_columns(
} else { } else {
message message
}; };
refuse(state, headers, inputs, Page::Columns, message).await refuse(state, headers, inputs, Page::AddColumns, message).await
} }
Err(error) => { 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, state,
headers, headers,
inputs, inputs,
Page::Columns, Page::Presentation,
message, message,
) )
.await; .await;
@@ -378,7 +391,7 @@ pub(crate) async fn set_column_presentation(
table: form.table, table: form.table,
..Default::default() ..Default::default()
}; };
respond(state, headers, inputs, Page::Columns, StatusCode::OK).await respond(state, headers, inputs, Page::Presentation, StatusCode::OK).await
} }
Ok(response) => { Ok(response) => {
let message = response.into_inner().message; let message = response.into_inner().message;
@@ -390,10 +403,10 @@ pub(crate) async fn set_column_presentation(
} else { } else {
message message
}; };
refuse(state, headers, inputs, Page::Columns, message).await refuse(state, headers, inputs, Page::Presentation, message).await
} }
Err(error) => { 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. //! 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 //! Adding columns, presenting columns, dropping the table, copying its profile,
//! from a template and reading the rename history are five different jobs, and //! generating tables from a template and reading the rename history are six
//! they used to be five panels stacked on one `/admin/table-definition` //! 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 //! workspace — which meant that after creating a table you landed on a screen
//! where finding the delete form meant scrolling past five other forms. //! where finding the delete form meant scrolling past five other forms.
//! //!
@@ -30,13 +30,18 @@ use crate::AppState;
pub(crate) fn router() -> Router<AppState> { pub(crate) fn router() -> Router<AppState> {
Router::new() Router::new()
// Table-scoped. // Table-scoped.
.route("/admin/tables/columns", get(logic::columns_page))
.route("/admin/tables/columns", post(logic::add_columns))
.route( .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), 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", get(logic::delete_page))
.route("/admin/tables/delete", post(logic::delete_table)) .route("/admin/tables/delete", post(logic::delete_table))
// Profile-scoped. // Profile-scoped.

View File

@@ -1,6 +1,6 @@
//! What the table-definition pages render, and the wire formats they post. //! 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 //! 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` //! currently is. What differs is which panel the page renders, and `active`
//! is what says so. //! is what says so.

View File

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

View File

@@ -58,7 +58,7 @@
{% if page.selected_table.as_deref() == Some(table.name.as_str()) %} {% if page.selected_table.as_deref() == Some(table.name.as_str()) %}
<div class="browser-actions"> <div class="browser-actions">
{% if page.can_manage_tables && !table.is_system() %} {% if page.can_manage_tables && !table.is_system() %}
<a href="/admin/tables/columns?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}&table={{ table.name }}">{{ nav.tr("admin-add-rename-columns") }}</a> <a href="/admin/tables/presentation?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}&table={{ table.name }}">{{ nav.tr("admin-column-presentation") }}</a>
<a class="danger-action" href="/admin/tables/delete?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}&table={{ table.name }}">{{ nav.tr("admin-delete-table") }}</a> <a class="danger-action" href="/admin/tables/delete?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}&table={{ table.name }}">{{ nav.tr("admin-delete-table") }}</a>
{% endif %} {% endif %}
<a href="/permissions/grants">{{ nav.tr("admin-who-may-use-it") }}</a> <a href="/permissions/grants">{{ nav.tr("admin-who-may-use-it") }}</a>

View File

@@ -0,0 +1,18 @@
{# GET /admin/tables/columns/add — crate::pages::admin::table_definition::ui::AddColumnsPage #}
{% extends "ui/base.html" %}
{% block title %}{{ nav.tr("td-add-columns-title") }}{% endblock %}
{% block content %}
<main>
{% include "pages/admin/table_definition/context.html" %}
<div id="table-panel" aria-live="polite">
{% include "pages/admin/table_definition/add_columns_panel.html" %}
</div>
</main>
<datalist id="currency-codes">
{% for code in currency_codes %}<option value="{{ code }}"></option>{% endfor %}
</datalist>
{% endblock %}

View File

@@ -0,0 +1,17 @@
{# The add-columns page body — crate::pages::admin::table_definition::ui::AddColumnsFragment. #}
{% include "pages/admin/table_definition/feedback.html" %}
{% if page.table_is_writable() %}
<section class="panel">
<h2>{{ nav.tr("td-add-columns-to") }} <code>{{ page.selection.table }}</code></h2>
<p class="hint">{{ nav.tr("td-append-hint") }}</p>
<form id="column-form"
hx-post="/admin/tables/columns/add{{ page.selection.query() }}"
hx-target="#table-panel" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
<div id="column-panel">
{% include "pages/admin/table_definition/column_panel.html" %}
</div>
</form>
</section>
{% endif %}

View File

@@ -29,7 +29,7 @@
</label> </label>
<label>{{ nav.tr("td-column-type") }} <label>{{ nav.tr("td-column-type") }}
<select name="column_type_input" <select name="column_type_input"
hx-post="/admin/tables/columns/builder{{ page.selection.query() }}" hx-post="/admin/tables/columns/add/builder{{ page.selection.query() }}"
hx-trigger="change" hx-include="#column-form" hx-trigger="change" hx-include="#column-form"
hx-target="#column-panel" hx-swap="innerHTML" hx-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "refresh"}'> hx-vals='{"action": "refresh"}'>
@@ -157,7 +157,7 @@
</div> </div>
<button type="button" class="secondary" <button type="button" class="secondary"
hx-post="/admin/tables/columns/builder{{ page.selection.query() }}" hx-post="/admin/tables/columns/add/builder{{ page.selection.query() }}"
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML" hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "add-column"}'>{{ nav.tr("td-stage-column") }}</button> hx-vals='{"action": "add-column"}'>{{ nav.tr("td-stage-column") }}</button>
@@ -176,7 +176,7 @@
there is no column of its own name to index. #} there is no column of its own name to index. #}
{% if page.columns.is_indexable(*loop.index0) %} {% if page.columns.is_indexable(*loop.index0) %}
<button type="button" class="toggle" <button type="button" class="toggle"
hx-post="/admin/tables/columns/builder{{ page.selection.query() }}" hx-post="/admin/tables/columns/add/builder{{ page.selection.query() }}"
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML" hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "toggle-index", "index": "{{ loop.index0 }}"}'> hx-vals='{"action": "toggle-index", "index": "{{ loop.index0 }}"}'>
{% if column.indexed %}[x]{% else %}[ ]{% endif %} {% if column.indexed %}[x]{% else %}[ ]{% endif %}
@@ -194,7 +194,7 @@
</td> </td>
<td> <td>
<button type="button" class="danger" <button type="button" class="danger"
hx-post="/admin/tables/columns/builder{{ page.selection.query() }}" hx-post="/admin/tables/columns/add/builder{{ page.selection.query() }}"
hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML" hx-include="#column-form" hx-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "remove-column", "index": "{{ loop.index0 }}"}'>{{ nav.tr("common-remove") }}</button> hx-vals='{"action": "remove-column", "index": "{{ loop.index0 }}"}'>{{ nav.tr("common-remove") }}</button>
</td> </td>

View File

@@ -1,23 +0,0 @@
{# GET /admin/tables/columns — crate::pages::admin::table_definition::ui::ColumnsPage #}
{% extends "ui/base.html" %}
{% block title %}{{ nav.tr("td-columns-title") }}{% endblock %}
{% block content %}
<main>
{% include "pages/admin/table_definition/context.html" %}
{#
One swap target for the page's body. Both writes answer with this markup
re-read from the backend, so the screen after a change is the definition
as it now is, not the form that was submitted.
#}
<div id="table-panel" aria-live="polite">
{% include "pages/admin/table_definition/columns_panel.html" %}
</div>
</main>
<datalist id="currency-codes">
{% for code in currency_codes %}<option value="{{ code }}"></option>{% endfor %}
</datalist>
{% endblock %}

View File

@@ -1,14 +1,13 @@
{# {#
The heading and action switcher shared by the five table-definition pages. The heading and action switcher shared by the table-definition pages.
Every one of them carries a `page` (crate::pages::admin::table_definition:: Every one of them carries a `page` (crate::pages::admin::table_definition::
state::TableDefinitionPageState), whose `active` says which is open. state::TableDefinitionPageState), whose `active` says which is open.
These are deliberately separate pages, for the same reason the permission These are deliberately separate pages, for the same reason the permission
sections are: adding a column, dropping a table, copying a profile and sections are: adding columns, presenting them, dropping a table, copying a
reading its rename history are four different decisions, and stacking them profile and reading its rename history are different decisions. Which links
on one screen was what made the old workspace impossible to find anything appear follows how much has been selected — the table-wide ones need a
in. Which links appear follows how much has been selected — the table-wide table, the profile-wide ones only a profile.
ones need a table, the profile-wide ones only a profile.
#} #}
<section class="heading"> <section class="heading">
<div> <div>
@@ -40,8 +39,11 @@
<nav class="tabs" aria-label="{{ nav.tr("td-tab-aria") }}"> <nav class="tabs" aria-label="{{ nav.tr("td-tab-aria") }}">
{% if page.selection.has_table() %} {% if page.selection.has_table() %}
{% if page.table_is_writable() %} {% if page.table_is_writable() %}
<a href="/admin/tables/columns{{ page.selection.query() }}" class="tab {% if page.is("columns") %}selected{% endif %}" {% if page.is("columns") %}aria-current="page"{% endif %}> <a href="/admin/tables/columns/add{{ page.selection.query() }}" class="tab {% if page.is("add-columns") %}selected{% endif %}" {% if page.is("add-columns") %}aria-current="page"{% endif %}>
<span>{{ nav.tr("td-tab-columns") }}</span><small>{{ nav.tr("td-tab-columns-sub") }}</small> <span>{{ nav.tr("td-tab-add-columns") }}</span><small>{{ nav.tr("td-tab-add-columns-sub") }}</small>
</a>
<a href="/admin/tables/presentation{{ page.selection.query() }}" class="tab {% if page.is("presentation") %}selected{% endif %}" {% if page.is("presentation") %}aria-current="page"{% endif %}>
<span>{{ nav.tr("td-tab-presentation") }}</span><small>{{ nav.tr("td-tab-presentation-sub") }}</small>
</a> </a>
<a href="/admin/tables/delete{{ page.selection.query() }}" class="tab {% if page.is("delete") %}selected{% endif %}" {% if page.is("delete") %}aria-current="page"{% endif %}> <a href="/admin/tables/delete{{ page.selection.query() }}" class="tab {% if page.is("delete") %}selected{% endif %}" {% if page.is("delete") %}aria-current="page"{% endif %}>
<span>{{ nav.tr("td-tab-delete") }}</span><small>{{ nav.tr("td-tab-delete-sub") }}</small> <span>{{ nav.tr("td-tab-delete") }}</span><small>{{ nav.tr("td-tab-delete-sub") }}</small>

View File

@@ -0,0 +1,14 @@
{# GET /admin/tables/presentation — crate::pages::admin::table_definition::ui::PresentationPage #}
{% extends "ui/base.html" %}
{% block title %}{{ nav.tr("td-presentation-title") }}{% endblock %}
{% block content %}
<main>
{% include "pages/admin/table_definition/context.html" %}
<div id="table-panel" aria-live="polite">
{% include "pages/admin/table_definition/presentation_panel.html" %}
</div>
</main>
{% endblock %}

View File

@@ -1,29 +1,7 @@
{# {# The column-presentation page body — crate::pages::admin::table_definition::ui::PresentationFragment. #}
The columns page's body — crate::pages::admin::table_definition::ui::ColumnsFragment.
Both writes on this page swap it, so it carries the outcome as well as the
forms.
#}
{% include "pages/admin/table_definition/feedback.html" %} {% include "pages/admin/table_definition/feedback.html" %}
{% if page.table_is_writable() %} {% if page.table_is_writable() %}
{#
Append columns. The panel stages columns without writing anything; the
selection travels in the URL so the posted fields are exactly the ones the
Add-table builder posts, and both are decoded by the same code.
#}
<section class="panel">
<h2>{{ nav.tr("td-add-columns-to") }} <code>{{ page.selection.table }}</code></h2>
<p class="hint">{{ nav.tr("td-append-hint") }}</p>
<form id="column-form"
hx-post="/admin/tables/columns{{ page.selection.query() }}"
hx-target="#table-panel" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
<div id="column-panel">
{% include "pages/admin/table_definition/column_panel.html" %}
</div>
</form>
</section>
{% if let Some(detail) = page.detail %} {% if let Some(detail) = page.detail %}
<section class="panel"> <section class="panel">
<h2>{{ nav.tr("td-rename-column") }}</h2> <h2>{{ nav.tr("td-rename-column") }}</h2>