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-depends-on-prefix = Závisí na
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-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.
@@ -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-system-columns-label = Systémové sloupce · skryté před uživateli
# --- Stránky definice tabulky (sloupce / smazat / kopírovat / šablona / historie)
td-columns-title = Sloupce
# --- Stránky definice tabulky (přidat sloupce / zobrazení / smazat / kopírovat / šablona / historie)
td-add-columns-title = Přidat sloupce
td-presentation-title = Zobrazení sloupců
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-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-column-label = Sloupec
td-choose-column = Vyberte sloupec
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-col-column = Sloupec
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-global-label = Globální — všechny profily
td-tab-aria = akce s tabulkou
td-tab-columns = Sloupce
td-tab-columns-sub = Přidat sloupce a přejmenovat je
td-tab-add-columns = Přidat sloupce
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-sub = Odebrat tuto tabulku
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-depends-on-prefix = Depends on
admin-row-display-prefix = display:
admin-add-rename-columns = Add / rename columns
admin-column-presentation = Column aliases
admin-delete-table = Delete table
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.
@@ -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-system-columns-label = System columns · hidden from users
# --- Table definition pages (columns / delete / copy / template / history) -
td-columns-title = Columns
# --- Table definition pages (add columns / presentation / delete / copy / template / history) -
td-add-columns-title = Add columns
td-presentation-title = Column presentation
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-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-column-label = Column
td-choose-column = Choose a column
td-new-name = New name
td-rename-button = Rename column
td-rename-button = Save presentation
td-columns-now = Columns it has now
td-col-column = Column
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-global-label = Global — all profiles
td-tab-aria = table actions
td-tab-columns = Columns
td-tab-columns-sub = Append columns and rename them
td-tab-add-columns = Add columns
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-sub = Drop this table
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-depends-on-prefix = Závisí od
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-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ť.
@@ -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-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)
td-columns-title = Stĺpce
# --- Stránky definície tabuľky (pridať stĺpce / zobrazenie / vymazať / kopírovať / šablóna / história)
td-add-columns-title = Pridať stĺpce
td-presentation-title = Zobrazenie stĺpcov
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-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-column-label = Stĺpec
td-choose-column = Vyberte stĺpec
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-col-column = Stĺpec
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-global-label = Globálne — všetky profily
td-tab-aria = akcie s tabuľkou
td-tab-columns = Stĺpce
td-tab-columns-sub = Pridať stĺpce a premenovať ich
td-tab-add-columns = Pridať stĺpce
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-sub = Odstrániť túto tabuľku
td-tab-access = Prístup

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"));

View File

@@ -58,7 +58,7 @@
{% if page.selected_table.as_deref() == Some(table.name.as_str()) %}
<div class="browser-actions">
{% 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>
{% endif %}
<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>{{ nav.tr("td-column-type") }}
<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-target="#column-panel" hx-swap="innerHTML"
hx-vals='{"action": "refresh"}'>
@@ -157,7 +157,7 @@
</div>
<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-vals='{"action": "add-column"}'>{{ nav.tr("td-stage-column") }}</button>
@@ -176,7 +176,7 @@
there is no column of its own name to index. #}
{% if page.columns.is_indexable(*loop.index0) %}
<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-vals='{"action": "toggle-index", "index": "{{ loop.index0 }}"}'>
{% if column.indexed %}[x]{% else %}[ ]{% endif %}
@@ -194,7 +194,7 @@
</td>
<td>
<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-vals='{"action": "remove-column", "index": "{{ loop.index0 }}"}'>{{ nav.tr("common-remove") }}</button>
</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::
state::TableDefinitionPageState), whose `active` says which is open.
These are deliberately separate pages, for the same reason the permission
sections are: adding a column, dropping a table, copying a profile and
reading its rename history are four different decisions, and stacking them
on one screen was what made the old workspace impossible to find anything
in. Which links appear follows how much has been selected — the table-wide
ones need a table, the profile-wide ones only a profile.
sections are: adding columns, presenting them, dropping a table, copying a
profile and reading its rename history are different decisions. Which links
appear follows how much has been selected — the table-wide ones need a
table, the profile-wide ones only a profile.
#}
<section class="heading">
<div>
@@ -40,8 +39,11 @@
<nav class="tabs" aria-label="{{ nav.tr("td-tab-aria") }}">
{% if page.selection.has_table() %}
{% 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 %}>
<span>{{ nav.tr("td-tab-columns") }}</span><small>{{ nav.tr("td-tab-columns-sub") }}</small>
<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-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 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>

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 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.
#}
{# The column-presentation page body — crate::pages::admin::table_definition::ui::PresentationFragment. #}
{% include "pages/admin/table_definition/feedback.html" %}
{% 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 %}
<section class="panel">
<h2>{{ nav.tr("td-rename-column") }}</h2>