web routing is POG now

This commit is contained in:
Priec
2026-08-12 23:40:14 +02:00
parent 10c8235420
commit f8e483efa8
28 changed files with 1015 additions and 697 deletions

View File

@@ -1,3 +1,10 @@
//! One template struct per page, and one per swap target inside it.
//!
//! 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`.
use askama::Template;
use crate::{
@@ -7,32 +14,31 @@ use crate::{
use super::state::TableDefinitionPageState;
/// GET /admin/table-definition — the shell around the workspace.
/// GET /admin/tables/columns
#[derive(Template)]
#[template(path = "pages/admin/table_definition/table_definition.html")]
struct TableDefinitionPage<'a> {
#[template(path = "pages/admin/table_definition/columns.html")]
struct ColumnsPage<'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 workspace fragment: the page embeds both, and the
/// outcome is reported once, at the top.
/// False, as on the fragment: the page embeds both, and the outcome is
/// reported once, at the top.
standalone_column_panel: bool,
}
/// The `#table-definition-workspace` swap, which is the same markup the page
/// embeds, so one template serves both.
/// The `#table-panel` swap on the columns page.
#[derive(Template)]
#[template(path = "pages/admin/table_definition/workspace.html")]
struct WorkspaceFragment<'a> {
#[template(path = "pages/admin/table_definition/columns_panel.html")]
struct ColumnsFragment<'a> {
page: &'a TableDefinitionPageState,
column_types: Vec<String>,
temporal_types: Vec<String>,
gtin_types: Vec<String>,
/// False: the workspace shows the outcome of the last action itself, at
/// the top, so the panel it embeds must not repeat it.
/// False: the fragment shows the outcome of the last action itself, at the
/// top, so the panel it embeds must not repeat it.
standalone_column_panel: bool,
}
@@ -49,8 +55,58 @@ struct ColumnPanelFragment<'a> {
standalone_column_panel: bool,
}
pub(crate) fn render_page(page: &TableDefinitionPageState) -> String {
render(&TableDefinitionPage {
/// GET /admin/tables/delete
#[derive(Template)]
#[template(path = "pages/admin/table_definition/delete.html")]
struct DeletePage<'a> {
nav: Nav,
page: &'a TableDefinitionPageState,
}
#[derive(Template)]
#[template(path = "pages/admin/table_definition/delete_panel.html")]
struct DeleteFragment<'a> {
page: &'a TableDefinitionPageState,
}
/// GET /admin/profiles/copy
#[derive(Template)]
#[template(path = "pages/admin/table_definition/copy.html")]
struct CopyPage<'a> {
nav: Nav,
page: &'a TableDefinitionPageState,
}
#[derive(Template)]
#[template(path = "pages/admin/table_definition/copy_panel.html")]
struct CopyFragment<'a> {
page: &'a TableDefinitionPageState,
}
/// GET /admin/tables/from-template
#[derive(Template)]
#[template(path = "pages/admin/table_definition/from_template.html")]
struct TemplatePage<'a> {
nav: Nav,
page: &'a TableDefinitionPageState,
}
#[derive(Template)]
#[template(path = "pages/admin/table_definition/from_template_panel.html")]
struct TemplateFragment<'a> {
page: &'a TableDefinitionPageState,
}
/// GET /admin/profiles/history
#[derive(Template)]
#[template(path = "pages/admin/table_definition/history.html")]
struct HistoryPage<'a> {
nav: Nav,
page: &'a TableDefinitionPageState,
}
pub(crate) fn render_columns_page(page: &TableDefinitionPageState) -> String {
render(&ColumnsPage {
nav: page.nav.clone(),
page,
// Asking the draft, so the picker can only ever offer what the draft
@@ -63,11 +119,9 @@ pub(crate) fn render_page(page: &TableDefinitionPageState) -> String {
})
}
pub(crate) fn render_workspace(page: &TableDefinitionPageState) -> String {
render(&WorkspaceFragment {
pub(crate) fn render_columns_fragment(page: &TableDefinitionPageState) -> String {
render(&ColumnsFragment {
page,
// Asking the draft, so the picker can only ever offer what the draft
// would accept — the accounting rule is stated once.
column_types: page.columns.offered_types(),
temporal_types: page.columns.temporal_types(),
gtin_types: page.columns.gtin_types(),
@@ -78,8 +132,6 @@ pub(crate) fn render_workspace(page: &TableDefinitionPageState) -> String {
pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String {
render(&ColumnPanelFragment {
page,
// Asking the draft, so the picker can only ever offer what the draft
// would accept — the accounting rule is stated once.
column_types: page.columns.offered_types(),
temporal_types: page.columns.temporal_types(),
gtin_types: page.columns.gtin_types(),
@@ -87,8 +139,48 @@ pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String {
})
}
/// Used when the workspace itself cannot be loaded. There is nothing left to
/// render, so the dialog is what tells the user why.
pub(crate) fn render_delete_page(page: &TableDefinitionPageState) -> String {
render(&DeletePage {
nav: page.nav.clone(),
page,
})
}
pub(crate) fn render_delete_fragment(page: &TableDefinitionPageState) -> String {
render(&DeleteFragment { page })
}
pub(crate) fn render_copy_page(page: &TableDefinitionPageState) -> String {
render(&CopyPage {
nav: page.nav.clone(),
page,
})
}
pub(crate) fn render_copy_fragment(page: &TableDefinitionPageState) -> String {
render(&CopyFragment { page })
}
pub(crate) fn render_template_page(page: &TableDefinitionPageState) -> String {
render(&TemplatePage {
nav: page.nav.clone(),
page,
})
}
pub(crate) fn render_template_fragment(page: &TableDefinitionPageState) -> String {
render(&TemplateFragment { page })
}
pub(crate) fn render_history_page(page: &TableDefinitionPageState) -> String {
render(&HistoryPage {
nav: page.nav.clone(),
page,
})
}
/// Used when a page itself cannot be loaded. There is nothing left to render,
/// so the dialog is what tells the user why.
pub(crate) fn render_load_error(message: &str) -> String {
render(&Alert::error("Table definition unavailable", message))
}
@@ -99,7 +191,7 @@ mod tests {
use crate::{
pages::admin::table_definition::state::{
CopyForm, DetailColumn, InvoiceTemplateForm, RenameForm, Selection, TableDetailView,
TablePermissionAction, TableRolePermissions, TableSummary,
TableSummary,
},
schema::ColumnDraft,
};
@@ -110,28 +202,12 @@ mod tests {
table_kind: kind.to_string(),
global: false,
depends_on: Vec::new(),
row_display_columns: vec!["number".to_string()],
}
}
#[test]
fn global_tables_have_their_own_scope() {
let mut state = page();
state.selection.profile = "__global".to_string();
let html = render_workspace(&state);
assert!(html.contains(r#"value="__global" selected"#));
assert!(html.contains("Global — all profiles"));
assert!(html.contains("/admin/tables/new?global=true"));
assert!(!html.contains("Copy <code>__global</code>"));
assert!(!html.contains("Create tables from an invoice template"));
}
fn page() -> TableDefinitionPageState {
TableDefinitionPageState {
nav: Nav::default(),
profiles: vec!["billing".to_string(), "payroll".to_string()],
selection: Selection {
profile: "billing".to_string(),
table: "invoice".to_string(),
@@ -139,8 +215,6 @@ mod tests {
tables: vec![table("invoice", "dynamic"), table("accounts", "system")],
detail: Some(TableDetailView {
id: 7,
name: "invoice".to_string(),
table_kind: "dynamic".to_string(),
row_display_columns: vec!["number".to_string()],
scripts: Vec::new(),
columns: vec![DetailColumn {
@@ -165,29 +239,96 @@ mod tests {
error: None,
sql: None,
generated: Vec::new(),
permission_object: String::new(),
role_permissions: Vec::new(),
active: "columns",
}
}
/// The point of the page: every write the service offers is reachable
/// from the one screen, for the one selection.
/// The point of the split: each write is its own page, and the switcher
/// they share is how you get from one to the next.
#[test]
fn the_workspace_offers_every_table_definition_write() {
let html = render_workspace(&page());
fn every_page_links_to_the_others() {
let mut state = page();
for (active, html) in [
("columns", render_columns_page(&state)),
("delete", render_delete_page(&state)),
] {
state.active = active;
assert!(!html.contains("Template error"), "{html}");
}
let html = render_columns_page(&state);
for route in [
"/admin/tables/columns?profile=billing",
"/admin/tables/delete?profile=billing",
"/admin/profiles/copy?profile=billing",
"/admin/tables/from-template?profile=billing",
"/admin/profiles/history?profile=billing",
"/permissions/grants",
] {
// Escaped, because it is an attribute: `&#38;` is what a browser
// reads back as the `&` separating the two parameters.
let route = route.replace('&', "&#38;");
assert!(html.contains(&route), "missing the {route} link\n{html}");
}
// And back to where the table was picked.
assert!(html.contains(r#"href="/admin?profile=billing&#38;table=invoice""#));
}
/// Deleting is its own page, so the form is the only thing on it — no
/// scrolling past five other panels to reach it.
#[test]
fn the_delete_page_is_only_the_delete_form() {
let html = render_delete_page(&page());
assert!(!html.contains("Template error"), "{html}");
for route in [
"/admin/table-definition/columns",
"/admin/table-definition/rename",
"/admin/table-definition/delete",
"/admin/table-definition/copy",
"/admin/table-definition/invoice-template",
] {
assert!(html.contains(route), "missing the {route} panel");
}
// And creating a table, which is the one write that has its own page.
assert!(html.contains("/admin/tables/new?profile=billing"));
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/rename"));
assert!(!html.contains("/admin/profiles/copy?profile=billing\" method"));
}
/// A system table is the backend's own; every write is refused for it, so
/// none of the write pages offer their form.
#[test]
fn a_system_table_gets_no_write_form() {
let mut state = page();
state.selection.table = "accounts".to_string();
let html = render_delete_page(&state);
assert!(!html.contains("Template error"), "{html}");
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/rename"));
}
/// The profile-wide pages need only a profile, and say so by still
/// rendering with no table selected.
#[test]
fn the_profile_pages_do_not_need_a_table() {
let mut state = page();
state.selection.table = String::new();
state.detail = None;
state.active = "copy";
let html = render_copy_page(&state);
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains("/admin/profiles/copy"));
// Table-scoped links are absent, because there is no table.
assert!(!html.contains("/admin/tables/delete"));
state.active = "template";
let html = render_template_page(&state);
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains("/admin/tables/from-template"));
state.active = "history";
let html = render_history_page(&state);
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains("has been renamed"));
}
/// The append panel posts the selection in its URL, so the column fields
@@ -209,8 +350,7 @@ mod tests {
let html = render_column_panel(&state);
assert!(!html.contains("Template error"), "{html}");
// Escaped, because it is an attribute: `&amp;` is what a browser reads
// back as the `&` separating the two parameters.
assert!(html.contains("/admin/tables/columns/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"));
@@ -254,58 +394,16 @@ mod tests {
assert!(!html.contains(r#"<option value="invoice""#));
}
/// A system table is the backend's own; every write below is refused for
/// it, so the workspace shows the definition and stops there.
#[test]
fn a_system_table_is_readable_but_not_writable() {
let mut state = page();
state.selection.table = "accounts".to_string();
state.detail = state.detail.map(|mut detail| {
detail.name = "accounts".to_string();
detail.table_kind = "system".to_string();
detail
});
let html = render_workspace(&state);
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains("backend-managed"));
assert!(!html.contains("/admin/table-definition/delete"));
assert!(!html.contains("/admin/table-definition/rename"));
}
/// With only a profile chosen, the profile-wide panels are there and the
/// table-wide ones are not.
#[test]
fn the_panels_follow_how_much_has_been_selected() {
let mut state = page();
state.selection.table = String::new();
state.detail = None;
let html = render_workspace(&state);
assert!(html.contains("/admin/table-definition/copy"));
assert!(html.contains("/admin/table-definition/invoice-template"));
assert!(!html.contains("/admin/table-definition/delete"));
// With nothing chosen at all, only the profile picker is.
state.selection.profile = String::new();
state.tables.clear();
let html = render_workspace(&state);
assert!(!html.contains("/admin/table-definition/copy"));
assert!(html.contains("Choose a scope"));
}
#[test]
fn a_failure_is_shown_as_a_dialog_as_well_as_an_alert() {
let mut state = page();
assert!(!render_workspace(&state).contains(r#"role="dialog""#));
assert!(!render_columns_fragment(&state).contains(r#"role="dialog""#));
state.error = Some("That column already exists.".to_string());
let html = render_workspace(&state);
let html = render_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 workspace embeds.
// from the column panel the fragment embeds.
assert_eq!(html.matches("That column already exists.").count(), 2);
}
@@ -331,30 +429,25 @@ 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_workspace(&state);
let html = render_columns_fragment(&state);
assert!(html.contains("ALTER TABLE"));
assert!(html.contains("1 column added"));
}
#[test]
fn selected_table_exposes_its_data_permission_actions() {
let mut page = page();
page.permission_object = "data:billing/invoice".to_string();
page.role_permissions = vec![TableRolePermissions {
role: "bookkeeper".to_string(),
actions: vec![TablePermissionAction {
action: "read".to_string(),
direct: false,
effective: false,
}],
}];
fn global_tables_have_their_own_scope() {
let mut state = page();
state.selection.profile = "__global".to_string();
let html = render_columns_page(&state);
let html = render_workspace(&page);
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains("Data permissions for"));
assert!(html.contains("data:billing/invoice"));
assert!(html.contains("Grant read"));
assert!(html.contains("Global — all profiles"));
// Copying and the invoice template are per profile, so the global
// scope offers neither.
assert!(!html.contains("/admin/profiles/copy"));
assert!(!html.contains("/admin/tables/from-template"));
}
#[test]