web routing is POG now
This commit is contained in:
@@ -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: `&` is what a browser
|
||||
// reads back as the `&` separating the two parameters.
|
||||
let route = route.replace('&', "&");
|
||||
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&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: `&` 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: `&` is what a browser reads
|
||||
// back as the `&` separating the two parameters.
|
||||
assert!(html.contains("?profile=billing&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]
|
||||
|
||||
Reference in New Issue
Block a user