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

2
server

Submodule server updated: 1a3c049329...0b0cb105be

View File

@@ -286,8 +286,14 @@ mod tests {
/// answers with the redirect to the login page rather than a 404 or a call /// answers with the redirect to the login page rather than a 404 or a call
/// to the backend. /// to the backend.
#[tokio::test] #[tokio::test]
async fn the_table_definition_workspace_is_mounted_and_needs_a_session() { async fn the_table_definition_pages_are_mounted_and_need_a_session() {
for path in ["/admin/table-definition", "/admin/table-definition/workspace"] { for path in [
"/admin/tables/columns",
"/admin/tables/delete",
"/admin/profiles/copy",
"/admin/profiles/history",
"/admin/tables/from-template",
] {
let (status, _) = get(path).await; let (status, _) = get(path).await;
assert_eq!( assert_eq!(
status, status,
@@ -296,13 +302,18 @@ mod tests {
); );
} }
// The workspace these came out of is gone, and anything still pointing
// at it lands on the browser rather than on a 404.
let (status, _) = get("/admin/table-definition").await;
assert_eq!(status, axum::http::StatusCode::PERMANENT_REDIRECT);
for path in [ for path in [
"/admin/table-definition/columns", "/admin/tables/columns",
"/admin/table-definition/columns/builder", "/admin/tables/columns/builder",
"/admin/table-definition/rename", "/admin/tables/rename",
"/admin/table-definition/delete", "/admin/tables/delete",
"/admin/table-definition/copy", "/admin/profiles/copy",
"/admin/table-definition/invoice-template", "/admin/tables/from-template",
] { ] {
let response = test_router() let response = test_router()
.oneshot( .oneshot(

View File

@@ -108,8 +108,11 @@ pub(crate) async fn create_table(
let result = definitions.post_table_definition(request).await; let result = definitions.post_table_definition(request).await;
match result { match result {
Ok(response) if response.get_ref().success => { Ok(response) if response.get_ref().success => {
// The admin panel, with the new table selected: it is the browser
// the actions on a table are reached from, so landing there is
// landing next to everything that can be done to it.
let location = format!( let location = format!(
"/admin/table-definition?profile={profile_name}&table={}", "/admin?profile={profile_name}&table={}",
page.draft.table_name page.draft.table_name
); );
let Ok(location) = HeaderValue::try_from(location) else { let Ok(location) = HeaderValue::try_from(location) else {

View File

@@ -102,6 +102,7 @@ pub(crate) async fn load_admin_page(
}) })
.collect(), .collect(),
row_display_columns: table.row_display_columns.clone(), row_display_columns: table.row_display_columns.clone(),
table_kind: table.table_kind.clone(),
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })

View File

@@ -56,6 +56,15 @@ pub(crate) struct TableView {
pub name: String, pub name: String,
pub depends_on: Vec<String>, pub depends_on: Vec<String>,
pub row_display_columns: Vec<String>, pub row_display_columns: Vec<String>,
pub table_kind: String,
}
impl TableView {
/// System tables are backend-managed: the server refuses every structural
/// write on them, so the pane offers none of the action links for one.
pub(crate) fn is_system(&self) -> bool {
self.table_kind == "system"
}
} }
#[derive(Debug)] #[derive(Debug)]

View File

@@ -69,7 +69,6 @@ mod tests {
}; };
let html = render_page(&page); let html = render_page(&page);
for route in [ for route in [
"/admin/table-definition",
"/admin/tables/new", "/admin/tables/new",
"/admin/logic/new", "/admin/logic/new",
"/admin/validation/new", "/admin/validation/new",
@@ -120,6 +119,42 @@ mod tests {
assert!(html.contains("There are no global tables.")); assert!(html.contains("There are no global tables."));
} }
/// Selecting a table has to put what can be done to it on the screen. It
/// is the only way in: these pages take their selection from the URL, and
/// nobody is going to type one.
#[test]
fn selecting_a_table_shows_what_can_be_done_to_it() {
use crate::pages::admin::admin::state::TableView;
let page = AdminPageState {
nav: Nav::default(),
profiles: Vec::new(),
selected_profile: Some("books".to_string()),
tables: vec![TableView {
name: "invoice".to_string(),
depends_on: Vec::new(),
row_display_columns: vec!["number".to_string()],
table_kind: "dynamic".to_string(),
}],
selected_table: Some("invoice".to_string()),
columns: Vec::new(),
can_manage_tables: true,
can_manage_scripts: true,
can_manage_validations: true,
can_export: true,
};
let html = render_workspace(&page);
for route in [
"/admin/tables/columns?profile=books&table=invoice",
"/admin/tables/delete?profile=books&table=invoice",
"/admin/tables/new?profile=books",
"/admin/profiles/copy?profile=books",
] {
assert!(html.contains(route), "missing {route}\n{html}");
}
}
#[test] #[test]
fn system_columns_render_after_the_user_defined_ones() { fn system_columns_render_after_the_user_defined_ones() {
let column = |name: &str, system: bool| crate::pages::admin::admin::state::ColumnView { let column = |name: &str, system: bool| crate::pages::admin::admin::state::ColumnView {

View File

@@ -1,9 +1,12 @@
//! Reads everything the workspace shows. //! Reads the context every one of the five pages 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
//! order — the column-type catalog is the vocabulary the append panel offers,
//! the profile tree names the tables, the profile details describe the
//! selected table's columns and scripts, and the rename history explains how
//! those columns got their names.
//! //!
//! Four calls, in this order: the column-type catalog is the vocabulary the
//! append panel offers, the profile tree names the profiles and their tables,
//! the profile details describe the selected table's columns and scripts, and
//! the rename history explains how those columns got their names.
//! Nothing here trusts the posted selection — a profile or table that is gone //! Nothing here trusts the posted selection — a profile or table that is gone
//! is dropped from the selection rather than reported as an error, because the //! is dropped from the selection rather than reported as an error, because the
//! commonest way to get here with a stale one is having just deleted it. //! commonest way to get here with a stale one is having just deleted it.
@@ -13,7 +16,7 @@ use tonic::transport::Channel;
use crate::{ use crate::{
AppState, AppState,
auth::{GetAuthorizationRequest, ListGrantableObjectsRequest, ListRolePermissionsRequest, ListRolesRequest}, auth::GetAuthorizationRequest,
definitions::{ definitions::{
common::Empty, common::Empty,
table_definition::{ table_definition::{
@@ -26,8 +29,8 @@ use crate::{
}; };
use super::state::{ use super::state::{
DetailColumn, GLOBAL_SCOPE, LoadError, PageInputs, RenameEntry, ScriptView, TableDefinitionPageState, DetailColumn, GLOBAL_SCOPE, LoadError, PageInputs, RenameEntry, ScriptView,
TableDetailView, TablePermissionAction, TableRolePermissions, TableSummary, TableDefinitionPageState, TableDetailView, TableSummary,
}; };
/// Reads the column-type vocabulary on its own. /// Reads the column-type vocabulary on its own.
@@ -92,11 +95,13 @@ pub(crate) async fn load_page(
let profiles = tree let profiles = tree
.profiles .profiles
.iter() .iter()
.map(|profile| profile.name.clone()) .map(|profile| profile.name.as_str())
.collect::<Vec<_>>(); .collect::<Vec<_>>();
// A profile that no longer exists takes the table selection with it. // A profile that no longer exists takes the table selection with it.
if inputs.selection.profile != GLOBAL_SCOPE && !profiles.contains(&inputs.selection.profile) { if inputs.selection.profile != GLOBAL_SCOPE
&& !profiles.contains(&inputs.selection.profile.as_str())
{
inputs.selection.profile.clear(); inputs.selection.profile.clear();
inputs.selection.table.clear(); inputs.selection.table.clear();
} }
@@ -127,7 +132,6 @@ pub(crate) async fn load_page(
format!("{} ({})", dependency.table_name, dependency.column_name) format!("{} ({})", dependency.table_name, dependency.column_name)
}) })
.collect(), .collect(),
row_display_columns: table.row_display_columns.clone(),
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
@@ -195,8 +199,6 @@ pub(crate) async fn load_page(
}) })
.collect(), .collect(),
row_display_columns: table.row_display_columns, row_display_columns: table.row_display_columns,
table_kind: table.table_kind,
name: table.name,
}) })
} }
false => None, false => None,
@@ -230,84 +232,8 @@ pub(crate) async fn load_page(
false => Vec::new(), false => Vec::new(),
}; };
let mut permission_object = String::new();
let mut role_permissions = Vec::new();
if inputs.selection.has_table()
&& crate::authz::can_manage(&authorization, crate::authz::STRUCT_ROLE)
{
let expected_object = crate::authz::table_object(
&inputs.selection.profile,
&inputs.selection.table,
);
let roles = auth
.list_roles(
authenticated_request(headers, ListRolesRequest {})
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner()
.roles;
for role in roles.into_iter().filter(|role| role.kind == "data") {
let grantable = auth
.list_grantable_objects(
authenticated_request(
headers,
ListGrantableObjectsRequest {
target_role: role.name.clone(),
},
)
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner()
.objects
.into_iter()
.find(|object| object.object == expected_object);
let Some(grantable) = grantable else {
continue;
};
permission_object = expected_object.clone();
let permissions = auth
.list_role_permissions(
authenticated_request(
headers,
ListRolePermissionsRequest {
role: role.name.clone(),
},
)
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner();
role_permissions.push(TableRolePermissions {
role: role.name,
actions: grantable
.allowed_actions
.into_iter()
.map(|action| TablePermissionAction {
direct: crate::authz::is_direct_permission(
&permissions.permissions,
&expected_object,
&action,
),
effective: crate::authz::permissions_permit(
&permissions.effective_permissions,
&expected_object,
&action,
),
action,
})
.collect(),
});
}
}
Ok(TableDefinitionPageState { Ok(TableDefinitionPageState {
nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization), nav: crate::ui::Nav::new(headers, "admin").with_authorization(&authorization),
profiles,
tables, tables,
detail, detail,
history, history,
@@ -320,7 +246,8 @@ pub(crate) async fn load_page(
error: inputs.error, error: inputs.error,
sql: inputs.sql, sql: inputs.sql,
generated: inputs.generated, generated: inputs.generated,
permission_object, // Overwritten by the handler, which is the only thing that knows
role_permissions, // which of the pages it is answering for.
active: "",
}) })
} }

View File

@@ -1,17 +1,20 @@
//! The workspace's request handlers — one per `TableDefinition` write. //! The request handlers behind the five table-definition pages.
//! //!
//! Every write answers with the whole workspace, re-read from the backend, so //! Each page has a GET that renders it and, except for the read-only history,
//! what the user sees after a change is the definition as it now is rather //! a POST per write it offers. A write answers with its own page's body,
//! than the form they submitted. A refused write answers the same way but with //! re-read from the backend, so what the user sees after a change is the
//! 422 and the backend's own message, which `ui/base.html` swaps in because a //! definition as it now is rather than the form they submitted. A refused
//! 4xx still carries the explanation. //! write answers the same way but with 422 and the backend's own message,
//! which `ui/base.html` swaps in because a 4xx still carries the explanation.
//! //!
//! The column panel is the exception: staging a column changes nothing on the //! Two exceptions. Staging a column changes nothing on the server, so those
//! server, so those interactions swap only the panel. //! interactions swap only the column panel. And a successful delete leaves no
//! page to return to — the table it was about is gone — so it redirects to the
//! admin panel instead.
use axum::{ use axum::{
extract::{Query, State}, extract::{Query, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, HeaderValue, StatusCode},
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
}; };
use axum_extra::extract::Form; use axum_extra::extract::Form;
@@ -30,36 +33,124 @@ use super::{
loader::{self, load_page}, loader::{self, load_page},
state::{ state::{
CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs, CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs,
RenameForm, Selection, RenameForm, Selection, TableDefinitionPageState,
}, },
ui, ui,
}; };
/// GET /admin/table-definition /// Which page a response belongs to: what the switcher marks as current, and
pub(crate) async fn page( /// which fragment a write on it swaps back.
#[derive(Clone, Copy)]
enum Page {
Columns,
Delete,
Copy,
Template,
History,
}
impl Page {
fn name(self) -> &'static str {
match self {
Self::Columns => "columns",
Self::Delete => "delete",
Self::Copy => "copy",
Self::Template => "template",
Self::History => "history",
}
}
fn render_page(self, page: &TableDefinitionPageState) -> String {
match self {
Self::Columns => ui::render_columns_page(page),
Self::Delete => ui::render_delete_page(page),
Self::Copy => ui::render_copy_page(page),
Self::Template => ui::render_template_page(page),
Self::History => ui::render_history_page(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::Delete => ui::render_delete_fragment(page),
Self::Copy => ui::render_copy_fragment(page),
Self::Template => ui::render_template_fragment(page),
Self::History => ui::render_history_page(page),
}
}
}
// ── The pages ───────────────────────────────────────────────────────────────
/// GET /admin/tables/columns
pub(crate) async fn columns_page(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Query(selection): Query<Selection>, Query(selection): Query<Selection>,
) -> Response { ) -> Response {
match load_page(state, &headers, PageInputs::for_selection(selection)).await { show(state, headers, PageInputs::for_selection(selection), Page::Columns).await
Ok(page) => Html(ui::render_page(&page)).into_response(),
Err(error) => load_error_response(error),
}
} }
/// GET /admin/table-definition/workspace — the swap when the selection changes. /// GET /admin/tables/delete
pub(crate) async fn workspace( pub(crate) async fn delete_page(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
Query(selection): Query<Selection>, Query(selection): Query<Selection>,
) -> Response { ) -> Response {
match load_page(state, &headers, PageInputs::for_selection(selection)).await { show(state, headers, PageInputs::for_selection(selection), Page::Delete).await
Ok(page) => Html(ui::render_workspace(&page)).into_response(), }
/// GET /admin/profiles/copy
pub(crate) async fn copy_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
show(state, headers, profile_inputs(selection), Page::Copy).await
}
/// GET /admin/tables/from-template
pub(crate) async fn template_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
show(state, headers, profile_inputs(selection), Page::Template).await
}
/// GET /admin/profiles/history
pub(crate) async fn history_page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
show(state, headers, PageInputs::for_selection(selection), Page::History).await
}
/// The profile-wide pages act on a profile, so a table in the query is
/// dropped rather than carried into a form that has no use for it.
fn profile_inputs(selection: Selection) -> PageInputs {
PageInputs::for_selection(Selection {
profile: selection.profile,
table: String::new(),
})
}
async fn show(state: AppState, headers: HeaderMap, inputs: PageInputs, page: Page) -> Response {
match load_page(state, &headers, inputs).await {
Ok(mut loaded) => {
loaded.active = page.name();
Html(page.render_page(&loaded)).into_response()
}
Err(error) => load_error_response(error), Err(error) => load_error_response(error),
} }
} }
/// POST /admin/table-definition/columns/builder — staging a column to append. // ── The writes ──────────────────────────────────────────────────────────────
/// POST /admin/tables/columns/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.
@@ -100,12 +191,15 @@ pub(crate) async fn update_columns(
} }
match load_page(state, &headers, inputs).await { match load_page(state, &headers, inputs).await {
Ok(page) => Html(ui::render_column_panel(&page)).into_response(), Ok(mut loaded) => {
loaded.active = Page::Columns.name();
Html(ui::render_column_panel(&loaded)).into_response()
}
Err(error) => load_error_response(error), Err(error) => load_error_response(error),
} }
} }
/// POST /admin/table-definition/columns — AddTableColumns. /// POST /admin/tables/columns — 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,
@@ -128,13 +222,15 @@ pub(crate) async fn add_columns(
inputs.columns = form.to_draft(catalog.clone(), false); inputs.columns = form.to_draft(catalog.clone(), false);
if !inputs.selection.has_table() { if !inputs.selection.has_table() {
return refuse(state, headers, inputs, "Select a table first.".to_string()).await; return refuse(state, headers, inputs, Page::Columns, "Select a table first.".to_string())
.await;
} }
if inputs.columns.is_empty() { if inputs.columns.is_empty() {
return refuse( return refuse(
state, state,
headers, headers,
inputs, inputs,
Page::Columns,
"Describe at least one column before adding.".to_string(), "Describe at least one column before adding.".to_string(),
) )
.await; .await;
@@ -142,7 +238,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() { if let Err(message) = inputs.columns.validate() {
return refuse(state, headers, inputs, message).await; return refuse(state, headers, inputs, Page::Columns, message).await;
} }
let request = AddTableColumnsRequest { let request = AddTableColumnsRequest {
@@ -166,7 +262,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, StatusCode::OK).await respond(state, headers, inputs, Page::Columns, StatusCode::OK).await
} }
Ok(response) => { Ok(response) => {
let message = response.into_inner().sql; let message = response.into_inner().sql;
@@ -175,13 +271,15 @@ pub(crate) async fn add_columns(
} else { } else {
message message
}; };
refuse(state, headers, inputs, message).await refuse(state, headers, inputs, Page::Columns, message).await
}
Err(error) => {
refuse(state, headers, inputs, Page::Columns, error.message().to_string()).await
} }
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
} }
} }
/// POST /admin/table-definition/rename — RenameColumnAlias. /// POST /admin/tables/rename — RenameColumnAlias.
pub(crate) async fn rename_column( pub(crate) async fn rename_column(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -202,6 +300,7 @@ pub(crate) async fn rename_column(
state, state,
headers, headers,
inputs, inputs,
Page::Columns,
"Choose a column and type its new name.".to_string(), "Choose a column and type its new name.".to_string(),
) )
.await; .await;
@@ -226,7 +325,7 @@ pub(crate) async fn rename_column(
table: form.table, table: form.table,
..Default::default() ..Default::default()
}; };
respond(state, headers, inputs, StatusCode::OK).await respond(state, headers, inputs, Page::Columns, StatusCode::OK).await
} }
Ok(response) => { Ok(response) => {
let message = response.into_inner().message; let message = response.into_inner().message;
@@ -235,13 +334,18 @@ pub(crate) async fn rename_column(
} else { } else {
message message
}; };
refuse(state, headers, inputs, message).await refuse(state, headers, inputs, Page::Columns, message).await
}
Err(error) => {
refuse(state, headers, inputs, Page::Columns, error.message().to_string()).await
} }
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
} }
} }
/// POST /admin/table-definition/delete — DeleteTable. /// POST /admin/tables/delete — DeleteTable.
///
/// On success there is nothing left for this page to show, so it hands the
/// browser to the admin panel with the profile still selected.
pub(crate) async fn delete_table( pub(crate) async fn delete_table(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -251,7 +355,7 @@ pub(crate) async fn delete_table(
return rejection; return rejection;
} }
let mut inputs = PageInputs::for_selection(Selection { let inputs = PageInputs::for_selection(Selection {
profile: form.profile.clone(), profile: form.profile.clone(),
table: form.table.clone(), table: form.table.clone(),
}); });
@@ -263,6 +367,7 @@ pub(crate) async fn delete_table(
state, state,
headers, headers,
inputs, inputs,
Page::Delete,
"Type the table's name exactly to confirm the deletion.".to_string(), "Type the table's name exactly to confirm the deletion.".to_string(),
) )
.await; .await;
@@ -279,11 +384,18 @@ pub(crate) async fn delete_table(
let mut definitions = state.definitions.clone(); let mut definitions = state.definitions.clone();
match definitions.delete_table(request).await { match definitions.delete_table(request).await {
Ok(response) if response.get_ref().success => { Ok(response) if response.get_ref().success => {
inputs.status = Some(response.into_inner().message); // The profile may have gone with it, in which case the admin panel
// Whatever was selected is gone; the loader drops it, and this // refuses the selection; landing on the bare panel is what the
// keeps the workspace from asking for it again. // caller wants then anyway.
inputs.selection.table.clear(); let location = format!("/admin?profile={}", form.profile);
respond(state, headers, inputs, StatusCode::OK).await match HeaderValue::try_from(location) {
Ok(location) => {
let mut response = Html(String::new()).into_response();
response.headers_mut().insert("hx-redirect", location);
response
}
Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response(),
}
} }
Ok(response) => { Ok(response) => {
let message = response.into_inner().message; let message = response.into_inner().message;
@@ -292,13 +404,15 @@ pub(crate) async fn delete_table(
} else { } else {
message message
}; };
refuse(state, headers, inputs, message).await refuse(state, headers, inputs, Page::Delete, message).await
}
Err(error) => {
refuse(state, headers, inputs, Page::Delete, error.message().to_string()).await
} }
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
} }
} }
/// POST /admin/table-definition/copy — CopyProfile. /// POST /admin/profiles/copy — CopyProfile.
pub(crate) async fn copy_profile( pub(crate) async fn copy_profile(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -319,6 +433,7 @@ pub(crate) async fn copy_profile(
state, state,
headers, headers,
inputs, inputs,
Page::Copy,
"Name the profile to copy into.".to_string(), "Name the profile to copy into.".to_string(),
) )
.await; .await;
@@ -345,7 +460,7 @@ pub(crate) async fn copy_profile(
profile: form.profile, profile: form.profile,
..Default::default() ..Default::default()
}; };
respond(state, headers, inputs, StatusCode::OK).await respond(state, headers, inputs, Page::Copy, StatusCode::OK).await
} }
Ok(response) => { Ok(response) => {
let message = response.into_inner().message; let message = response.into_inner().message;
@@ -354,13 +469,13 @@ pub(crate) async fn copy_profile(
} else { } else {
message message
}; };
refuse(state, headers, inputs, message).await refuse(state, headers, inputs, Page::Copy, message).await
} }
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await, Err(error) => refuse(state, headers, inputs, Page::Copy, error.message().to_string()).await,
} }
} }
/// POST /admin/table-definition/invoice-template — CreateInvoiceTemplateTable. /// POST /admin/tables/from-template — CreateInvoiceTemplateTable.
pub(crate) async fn create_from_invoice_template( pub(crate) async fn create_from_invoice_template(
State(state): State<AppState>, State(state): State<AppState>,
headers: HeaderMap, headers: HeaderMap,
@@ -381,6 +496,7 @@ pub(crate) async fn create_from_invoice_template(
state, state,
headers, headers,
inputs, inputs,
Page::Template,
"A table name and the template's source are both required.".to_string(), "A table name and the template's source are both required.".to_string(),
) )
.await; .await;
@@ -417,43 +533,51 @@ pub(crate) async fn create_from_invoice_template(
profile: form.profile, profile: form.profile,
..Default::default() ..Default::default()
}; };
respond(state, headers, inputs, StatusCode::OK).await respond(state, headers, inputs, Page::Template, StatusCode::OK).await
} }
Ok(_) => { Ok(_) => {
refuse( refuse(
state, state,
headers, headers,
inputs, inputs,
Page::Template,
"The backend did not create the template's tables.".to_string(), "The backend did not create the template's tables.".to_string(),
) )
.await .await
} }
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await, Err(error) => {
refuse(state, headers, inputs, Page::Template, error.message().to_string()).await
}
} }
} }
/// Re-reads the workspace and answers with it. /// Re-reads the page's body and answers with it.
async fn respond( async fn respond(
state: AppState, state: AppState,
headers: HeaderMap, headers: HeaderMap,
inputs: PageInputs, inputs: PageInputs,
page: Page,
status: StatusCode, status: StatusCode,
) -> Response { ) -> Response {
match load_page(state, &headers, inputs).await { match load_page(state, &headers, inputs).await {
Ok(page) => (status, Html(ui::render_workspace(&page))).into_response(), Ok(mut loaded) => {
loaded.active = page.name();
(status, Html(page.render_fragment(&loaded))).into_response()
}
Err(error) => load_error_response(error), Err(error) => load_error_response(error),
} }
} }
/// Answers a refused write: the workspace as it still is, plus the reason. /// Answers a refused write: the page as it still is, plus the reason.
async fn refuse( async fn refuse(
state: AppState, state: AppState,
headers: HeaderMap, headers: HeaderMap,
mut inputs: PageInputs, mut inputs: PageInputs,
page: Page,
message: String, message: String,
) -> Response { ) -> Response {
inputs.error = Some(message); inputs.error = Some(message);
respond(state, headers, inputs, StatusCode::UNPROCESSABLE_ENTITY).await respond(state, headers, inputs, page, StatusCode::UNPROCESSABLE_ENTITY).await
} }
fn load_error_response(error: LoadError) -> Response { fn load_error_response(error: LoadError) -> Response {

View File

@@ -1,9 +1,18 @@
//! The table-definition workspace: pick a profile and a table, then do //! What can be done to a table once it exists, as one page per decision.
//! anything the `TableDefinition` service offers to it.
//! //!
//! Creating a table is the one operation that lives elsewhere — it is a form //! Adding columns, dropping the table, copying its profile, generating tables
//! long enough to want its own page, `pages/add_table` — and the workspace //! from a template and reading the rename history are five different jobs, and
//! links to it with the chosen profile already filled in. //! they used to be five 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.
//!
//! They are now five routes, and the browsing they all needed — which profile,
//! which table — is done in the admin panel, which was already a three-pane
//! browser for exactly that. Every page here is entered with its selection in
//! the query string, and links back to the panel it came from.
//!
//! Creating a table is still elsewhere: it is a form long enough to want its
//! own page, `pages/add_table`.
mod loader; mod loader;
mod logic; mod logic;
@@ -12,6 +21,7 @@ mod ui;
use axum::{ use axum::{
Router, Router,
response::Redirect,
routing::{get, post}, routing::{get, post},
}; };
@@ -19,18 +29,28 @@ use crate::AppState;
pub(crate) fn router() -> Router<AppState> { pub(crate) fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/admin/table-definition", get(logic::page)) // Table-scoped.
.route("/admin/table-definition/workspace", get(logic::workspace)) .route("/admin/tables/columns", get(logic::columns_page))
.route("/admin/tables/columns", post(logic::add_columns))
.route( .route(
"/admin/table-definition/columns/builder", "/admin/tables/columns/builder",
post(logic::update_columns), post(logic::update_columns),
) )
.route("/admin/table-definition/columns", post(logic::add_columns)) .route("/admin/tables/rename", post(logic::rename_column))
.route("/admin/table-definition/rename", post(logic::rename_column)) .route("/admin/tables/delete", get(logic::delete_page))
.route("/admin/table-definition/delete", post(logic::delete_table)) .route("/admin/tables/delete", post(logic::delete_table))
.route("/admin/table-definition/copy", post(logic::copy_profile)) // Profile-scoped.
.route("/admin/profiles/copy", get(logic::copy_page))
.route("/admin/profiles/copy", post(logic::copy_profile))
.route("/admin/profiles/history", get(logic::history_page))
.route( .route(
"/admin/table-definition/invoice-template", "/admin/tables/from-template",
post(logic::create_from_invoice_template), get(logic::template_page).post(logic::create_from_invoice_template),
)
// The workspace these came out of. Anything still pointing at it —
// a bookmark, an old link — lands on the browser instead.
.route(
"/admin/table-definition",
get(|| async { Redirect::permanent("/admin") }),
) )
} }

View File

@@ -1,8 +1,12 @@
//! What the workspace renders, and the wire formats its panels post. //! What the table-definition pages render, and the wire formats they post.
//! //!
//! Every panel is a form of its own, and each one carries the selection it //! One state type serves all five pages, because each of them needs the same
//! acts on in hidden fields, because the workspace is swapped whole on every //! context: which profile and table is being worked on, and what that table
//! write: the response is rebuilt from the live profile tree rather than from //! currently is. What differs is which panel the page renders, and `active`
//! is what says so.
//!
//! Every panel carries the selection it acts on in hidden fields, because a
//! write answers with its page re-read from the backend rather than with
//! whatever the browser still had on screen. //! whatever the browser still had on screen.
use crate::schema::{ColumnCatalog, ColumnDraft}; use crate::schema::{ColumnCatalog, ColumnDraft};
@@ -38,6 +42,21 @@ impl Selection {
pub(crate) fn query(&self) -> String { pub(crate) fn query(&self) -> String {
format!("?profile={}&table={}", self.profile, self.table) format!("?profile={}&table={}", self.profile, self.table)
} }
/// The same, for the profile-wide pages, which have no table to name.
pub(crate) fn profile_query(&self) -> String {
format!("?profile={}", self.profile)
}
/// What the heading calls the scope, so the global one is not shown by its
/// sentinel name.
pub(crate) fn scope_label(&self) -> String {
if self.is_global() {
"Global — all profiles".to_string()
} else {
self.profile.clone()
}
}
} }
/// One table in the selected profile, from the profile tree. /// One table in the selected profile, from the profile tree.
@@ -47,7 +66,6 @@ pub(crate) struct TableSummary {
pub table_kind: String, pub table_kind: String,
pub global: bool, pub global: bool,
pub depends_on: Vec<String>, pub depends_on: Vec<String>,
pub row_display_columns: Vec<String>,
} }
impl TableSummary { impl TableSummary {
@@ -62,18 +80,12 @@ impl TableSummary {
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub(crate) struct TableDetailView { pub(crate) struct TableDetailView {
pub id: i64, pub id: i64,
pub name: String,
pub table_kind: String,
pub row_display_columns: Vec<String>, pub row_display_columns: Vec<String>,
pub columns: Vec<DetailColumn>, pub columns: Vec<DetailColumn>,
pub scripts: Vec<ScriptView>, pub scripts: Vec<ScriptView>,
} }
impl TableDetailView { impl TableDetailView {
pub(crate) fn is_system(&self) -> bool {
self.table_kind == "system"
}
/// Columns a rename may target. Provenance and renameability are separate: /// Columns a rename may target. Provenance and renameability are separate:
/// accounting companions remain renameable while protected generated /// accounting companions remain renameable while protected generated
/// columns do not. /// columns do not.
@@ -248,7 +260,6 @@ impl PageInputs {
/// What the templates read. /// What the templates read.
pub(crate) struct TableDefinitionPageState { pub(crate) struct TableDefinitionPageState {
pub nav: crate::ui::Nav, pub nav: crate::ui::Nav,
pub profiles: Vec<String>,
pub selection: Selection, pub selection: Selection,
pub tables: Vec<TableSummary>, pub tables: Vec<TableSummary>,
pub detail: Option<TableDetailView>, pub detail: Option<TableDetailView>,
@@ -261,22 +272,17 @@ pub(crate) struct TableDefinitionPageState {
pub error: Option<String>, pub error: Option<String>,
pub sql: Option<String>, pub sql: Option<String>,
pub generated: Vec<GeneratedTableView>, pub generated: Vec<GeneratedTableView>,
pub permission_object: String, /// Which of the pages this is, for the action switcher they share. Set by
pub role_permissions: Vec<TableRolePermissions>, /// the handler, because it is the one thing the loader cannot know.
} pub active: &'static str,
pub(crate) struct TableRolePermissions {
pub role: String,
pub actions: Vec<TablePermissionAction>,
}
pub(crate) struct TablePermissionAction {
pub action: String,
pub direct: bool,
pub effective: bool,
} }
impl TableDefinitionPageState { impl TableDefinitionPageState {
/// Whether `name` is the page being rendered. Read by context.html.
pub(crate) fn is(&self, name: &str) -> bool {
self.active == name
}
fn eligible_link_target(&self, table: &TableSummary) -> bool { fn eligible_link_target(&self, table: &TableSummary) -> bool {
table.name != self.selection.table && table.name != "accounts" table.name != self.selection.table && table.name != "accounts"
} }
@@ -363,8 +369,6 @@ mod tests {
fn provenance_and_renameability_are_independent() { fn provenance_and_renameability_are_independent() {
let detail = TableDetailView { let detail = TableDetailView {
id: 1, id: 1,
name: "contact".to_string(),
table_kind: "dynamic".to_string(),
row_display_columns: Vec::new(), row_display_columns: Vec::new(),
scripts: Vec::new(), scripts: Vec::new(),
columns: vec![ columns: vec![

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 askama::Template;
use crate::{ use crate::{
@@ -7,32 +14,31 @@ use crate::{
use super::state::TableDefinitionPageState; use super::state::TableDefinitionPageState;
/// GET /admin/table-definition — the shell around the workspace. /// GET /admin/tables/columns
#[derive(Template)] #[derive(Template)]
#[template(path = "pages/admin/table_definition/table_definition.html")] #[template(path = "pages/admin/table_definition/columns.html")]
struct TableDefinitionPage<'a> { struct ColumnsPage<'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 workspace fragment: the page embeds both, and the /// False, as on the fragment: the page embeds both, and the outcome is
/// outcome is reported once, at the top. /// reported once, at the top.
standalone_column_panel: bool, standalone_column_panel: bool,
} }
/// The `#table-definition-workspace` swap, which is the same markup the page /// The `#table-panel` swap on the columns page.
/// embeds, so one template serves both.
#[derive(Template)] #[derive(Template)]
#[template(path = "pages/admin/table_definition/workspace.html")] #[template(path = "pages/admin/table_definition/columns_panel.html")]
struct WorkspaceFragment<'a> { struct ColumnsFragment<'a> {
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>,
/// False: the workspace shows the outcome of the last action itself, at /// False: the fragment shows the outcome of the last action itself, at the
/// the top, so the panel it embeds must not repeat it. /// top, so the panel it embeds must not repeat it.
standalone_column_panel: bool, standalone_column_panel: bool,
} }
@@ -49,8 +55,58 @@ struct ColumnPanelFragment<'a> {
standalone_column_panel: bool, standalone_column_panel: bool,
} }
pub(crate) fn render_page(page: &TableDefinitionPageState) -> String { /// GET /admin/tables/delete
render(&TableDefinitionPage { #[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(), 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
@@ -63,11 +119,9 @@ pub(crate) fn render_page(page: &TableDefinitionPageState) -> String {
}) })
} }
pub(crate) fn render_workspace(page: &TableDefinitionPageState) -> String { pub(crate) fn render_columns_fragment(page: &TableDefinitionPageState) -> String {
render(&WorkspaceFragment { render(&ColumnsFragment {
page, 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(), column_types: page.columns.offered_types(),
temporal_types: page.columns.temporal_types(), temporal_types: page.columns.temporal_types(),
gtin_types: page.columns.gtin_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 { pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String {
render(&ColumnPanelFragment { render(&ColumnPanelFragment {
page, 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(), column_types: page.columns.offered_types(),
temporal_types: page.columns.temporal_types(), temporal_types: page.columns.temporal_types(),
gtin_types: page.columns.gtin_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 pub(crate) fn render_delete_page(page: &TableDefinitionPageState) -> String {
/// render, so the dialog is what tells the user why. 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 { pub(crate) fn render_load_error(message: &str) -> String {
render(&Alert::error("Table definition unavailable", message)) render(&Alert::error("Table definition unavailable", message))
} }
@@ -99,7 +191,7 @@ mod tests {
use crate::{ use crate::{
pages::admin::table_definition::state::{ pages::admin::table_definition::state::{
CopyForm, DetailColumn, InvoiceTemplateForm, RenameForm, Selection, TableDetailView, CopyForm, DetailColumn, InvoiceTemplateForm, RenameForm, Selection, TableDetailView,
TablePermissionAction, TableRolePermissions, TableSummary, TableSummary,
}, },
schema::ColumnDraft, schema::ColumnDraft,
}; };
@@ -110,28 +202,12 @@ mod tests {
table_kind: kind.to_string(), table_kind: kind.to_string(),
global: false, global: false,
depends_on: Vec::new(), 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 { fn page() -> TableDefinitionPageState {
TableDefinitionPageState { TableDefinitionPageState {
nav: Nav::default(), nav: Nav::default(),
profiles: vec!["billing".to_string(), "payroll".to_string()],
selection: Selection { selection: Selection {
profile: "billing".to_string(), profile: "billing".to_string(),
table: "invoice".to_string(), table: "invoice".to_string(),
@@ -139,8 +215,6 @@ mod tests {
tables: vec![table("invoice", "dynamic"), table("accounts", "system")], tables: vec![table("invoice", "dynamic"), table("accounts", "system")],
detail: Some(TableDetailView { detail: Some(TableDetailView {
id: 7, id: 7,
name: "invoice".to_string(),
table_kind: "dynamic".to_string(),
row_display_columns: vec!["number".to_string()], row_display_columns: vec!["number".to_string()],
scripts: Vec::new(), scripts: Vec::new(),
columns: vec![DetailColumn { columns: vec![DetailColumn {
@@ -165,29 +239,96 @@ mod tests {
error: None, error: None,
sql: None, sql: None,
generated: Vec::new(), generated: Vec::new(),
permission_object: String::new(), active: "columns",
role_permissions: Vec::new(),
} }
} }
/// The point of the page: every write the service offers is reachable /// The point of the split: each write is its own page, and the switcher
/// from the one screen, for the one selection. /// they share is how you get from one to the next.
#[test] #[test]
fn the_workspace_offers_every_table_definition_write() { fn every_page_links_to_the_others() {
let html = render_workspace(&page()); 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}"); assert!(!html.contains("Template error"), "{html}");
for route in [ assert!(html.contains("/admin/tables/delete"));
"/admin/table-definition/columns", assert!(html.contains("Type <code>invoice</code> to confirm"));
"/admin/table-definition/rename", // The other writes are links in the switcher, not forms on the page.
"/admin/table-definition/delete", assert!(!html.contains("/admin/tables/rename"));
"/admin/table-definition/copy", assert!(!html.contains("/admin/profiles/copy?profile=billing\" method"));
"/admin/table-definition/invoice-template", }
] {
assert!(html.contains(route), "missing the {route} panel"); /// A system table is the backend's own; every write is refused for it, so
} /// none of the write pages offer their form.
// And creating a table, which is the one write that has its own page. #[test]
assert!(html.contains("/admin/tables/new?profile=billing")); 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 /// 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); let html = render_column_panel(&state);
assert!(!html.contains("Template error"), "{html}"); assert!(!html.contains("Template error"), "{html}");
// Escaped, because it is an attribute: `&amp;` is what a browser reads assert!(html.contains("/admin/tables/columns/builder"));
// back as the `&` separating the two parameters.
// 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"));
@@ -254,58 +394,16 @@ mod tests {
assert!(!html.contains(r#"<option value="invoice""#)); 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] #[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_workspace(&state).contains(r#"role="dialog""#)); assert!(!render_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_workspace(&state); let html = render_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 workspace embeds. // from the column panel the fragment embeds.
assert_eq!(html.matches("That column already exists.").count(), 2); 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.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_workspace(&state); let html = render_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"));
} }
#[test] #[test]
fn selected_table_exposes_its_data_permission_actions() { fn global_tables_have_their_own_scope() {
let mut page = page(); let mut state = page();
page.permission_object = "data:billing/invoice".to_string(); state.selection.profile = "__global".to_string();
page.role_permissions = vec![TableRolePermissions {
role: "bookkeeper".to_string(), let html = render_columns_page(&state);
actions: vec![TablePermissionAction {
action: "read".to_string(),
direct: false,
effective: false,
}],
}];
let html = render_workspace(&page);
assert!(!html.contains("Template error"), "{html}"); assert!(!html.contains("Template error"), "{html}");
assert!(html.contains("Data permissions for")); assert!(html.contains("Global — all profiles"));
assert!(html.contains("data:billing/invoice")); // Copying and the invoice template are per profile, so the global
assert!(html.contains("Grant read")); // scope offers neither.
assert!(!html.contains("/admin/profiles/copy"));
assert!(!html.contains("/admin/tables/from-template"));
} }
#[test] #[test]

View File

@@ -58,6 +58,18 @@
.column.system { color: #6b7686; background: #fafbfc; } .column.system { color: #6b7686; background: #fafbfc; }
.column.system code { color: #67788e; } .column.system code { color: #67788e; }
.group-label { margin: 14px 0 4px; padding: 0 11px; color: #7c8796; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; } .group-label { margin: 14px 0 4px; padding: 0 11px; color: #7c8796; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; }
/* What can be done to whatever the pane is pointed at, rendered directly
under it: under the selected table, and under the tables list for the
profile-wide actions. Each link is a page of its own. */
.browser-actions { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 12px 12px; }
.browser-actions a { padding: 6px 12px; border: 1px solid #a9c7f6; border-radius: 7px; color: #1d4ed8; background: #eaf2ff; font-size: 13px; font-weight: 600; text-decoration: none; }
.browser-actions a:hover { background: #d8e8ff; }
.browser-actions a.danger-action { border-color: #eccfcf; color: #a12b2b; background: #fdf3f3; }
.browser-actions a.danger-action:hover { background: #fbe6e6; }
.pane-footer { margin: 0 10px 10px; border-top: 1px solid #e5e8ed; padding-top: 12px; }
.pane-footer a { border-color: #d9dfe7; color: #33415c; background: white; font-weight: 500; }
.pane-footer a:hover { background: #f2f6fc; }
.empty { margin: 8px; color: #7c8796; } .empty { margin: 8px; color: #7c8796; }
/* ---------- Forms (components/form_card.html) ---------- */ /* ---------- Forms (components/form_card.html) ---------- */
@@ -199,8 +211,11 @@
/* ---------- Permissions (pages/permissions) ---------- */ /* ---------- Permissions (pages/permissions) ---------- */
/* The three sections — roles, people, access — are separate pages, and this /* The switcher between pages that belong together but are deliberately
is the switcher between them, rendered by pages/permissions/tabs.html. */ apart: the three permission sections (pages/permissions/tabs.html), and
the table-definition actions (pages/admin/table_definition/context.html).
Both split one crowded screen into a page per decision, so both need the
same thing — a way back to the siblings. */
.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; } .tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
.tab { display: grid; gap: 2px; padding: 10px 16px; border: 1px solid #d9dfe7; border-radius: 9px; color: #33415c; background: white; text-decoration: none; } .tab { display: grid; gap: 2px; padding: 10px 16px; border: 1px solid #d9dfe7; border-radius: 9px; color: #33415c; background: white; text-decoration: none; }
.tab small { color: #7c8796; font-size: 11px; } .tab small { color: #7c8796; font-size: 11px; }

View File

@@ -9,10 +9,9 @@
<div> <div>
<p class="eyebrow">Workspace</p> <p class="eyebrow">Workspace</p>
<h1>Admin panel</h1> <h1>Admin panel</h1>
<p>Browse profiles, tables, and their physical columns.</p> <p>Browse profiles, tables, and their physical columns. Pick a table to reach what can be done to it.</p>
</div> </div>
<div class="actions"> <div class="actions">
{% if page.can_manage_tables %}<a href="/admin/table-definition">Table definition</a>{% endif %}
{% if page.can_manage_tables %}<a href="/admin/tables/new">Add table</a>{% endif %} {% if page.can_manage_tables %}<a href="/admin/tables/new">Add table</a>{% endif %}
{% if page.can_manage_scripts %}<a href="/admin/logic/new">Add logic</a>{% endif %} {% if page.can_manage_scripts %}<a href="/admin/logic/new">Add logic</a>{% endif %}
{% if page.can_manage_validations %}<a href="/admin/validation/new">Add validation</a>{% endif %} {% if page.can_manage_validations %}<a href="/admin/validation/new">Add validation</a>{% endif %}

View File

@@ -50,9 +50,38 @@
</small> </small>
</button> </button>
</form> </form>
{#
The actions for the table you are pointed at, next to the table
itself. Each one is a page of its own; this pane is where you
choose what to act on, so it is also where you say what to do.
#}
{% 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 }}">Add / rename columns</a>
<a class="danger-action" href="/admin/tables/delete?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}&table={{ table.name }}">Delete table</a>
{% endif %}
<a href="/permissions/grants">Who may use it</a>
</div>
{% endif %}
{% endfor %} {% endfor %}
{% if page.selected_table.is_none() %}
<p class="empty">Pick a table to add columns to it, delete it, or say who may use it.</p>
{% endif %}
{% endif %} {% endif %}
</div> </div>
{% if page.can_manage_tables && page.selected_profile.is_some() %}
<div class="browser-actions pane-footer">
{% if page.is_global() %}
<a href="/admin/tables/new?global=true">+ New global table</a>
{% else %}
<a href="/admin/tables/new?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}">+ New table</a>
<a href="/admin/tables/from-template?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}">From template</a>
<a href="/admin/profiles/copy?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}">Copy profile</a>
<a href="/admin/profiles/history?profile={{ page.selected_profile.as_deref().unwrap_or_default() }}">Rename history</a>
{% endif %}
</div>
{% endif %}
</section> </section>
<section class="pane"> <section class="pane">

View File

@@ -29,7 +29,7 @@
</label> </label>
<label>Column type <label>Column type
<select name="column_type_input" <select name="column_type_input"
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}" hx-post="/admin/tables/columns/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"}'>
@@ -134,7 +134,7 @@
</div> </div>
<button type="button" class="secondary" <button type="button" class="secondary"
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}" hx-post="/admin/tables/columns/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"}'>Stage column</button> hx-vals='{"action": "add-column"}'>Stage column</button>
@@ -153,7 +153,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/table-definition/columns/builder{{ page.selection.query() }}" hx-post="/admin/tables/columns/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 %}
@@ -168,7 +168,7 @@
</td> </td>
<td> <td>
<button type="button" class="danger" <button type="button" class="danger"
hx-post="/admin/table-definition/columns/builder{{ page.selection.query() }}" hx-post="/admin/tables/columns/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 }}"}'>Remove</button> hx-vals='{"action": "remove-column", "index": "{{ loop.index0 }}"}'>Remove</button>
</td> </td>

View File

@@ -0,0 +1,23 @@
{# GET /admin/tables/columns — crate::pages::admin::table_definition::ui::ColumnsPage #}
{% extends "ui/base.html" %}
{% block title %}Columns{% 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

@@ -0,0 +1,93 @@
{#
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" %}
{% 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>Add columns to <code>{{ page.selection.table }}</code></h2>
<p class="hint">Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added.</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>Rename a column</h2>
<p class="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.</p>
<form hx-post="/admin/tables/rename"
hx-target="#table-panel" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<input type="hidden" name="table" value="{{ page.selection.table }}">
<div class="form-grid">
<label>Column
<select name="old_column_name">
<option value="">Choose a column</option>
{% for column in detail.renameable_columns() %}
<option value="{{ column.name }}" {% if page.rename.old_column_name == column.name %}selected{% endif %}>{{ column.name }}</option>
{% endfor %}
</select>
</label>
<label>New name
<input name="new_column_name" value="{{ page.rename.new_column_name }}" placeholder="invoice_number">
</label>
</div>
<div class="form-actions">
<button type="submit">Rename column</button>
</div>
</form>
</section>
{% endif %}
{% endif %}
{% if let Some(detail) = page.detail %}
<section class="panel">
<h2>Columns it has now <span class="count">{{ detail.columns.len() }}</span></h2>
<table class="builder-table">
<thead><tr><th>Column</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
{% for column in detail.columns %}
<tr>
<td><code>{{ column.name }}</code></td>
<td>{{ column.field_type }}{% if !column.sql_type.is_empty() %} <small class="hint">{{ column.sql_type }}</small>{% endif %}</td>
<td>
{%- for flag in column.flags() %}<span class="tag">{{ flag }}</span>{% endfor -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if !detail.scripts.is_empty() %}
<h3 class="panel-subhead">Scripts</h3>
<table class="builder-table">
<thead><tr><th>Target column</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
{% for script in detail.scripts %}
<tr>
<td><code>{{ script.target_column }}</code></td>
<td>{{ script.target_column_type }}</td>
<td>
{{ script.description }}
<details><summary>source</summary><pre class="sql-preview">{{ script.script }}</pre></details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
{% endif %}

View File

@@ -0,0 +1,76 @@
{#
The heading and action switcher shared by the five 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.
#}
<section class="heading">
<div>
<p class="eyebrow">
{%- if page.selection.has_table() %}{{ page.selection.scope_label() }}{% else %}Table definition{% endif -%}
</p>
<h1>
{%- if page.selection.has_table() %}{{ page.selection.table }}{% else %}{{ page.selection.scope_label() }}{% endif -%}
</h1>
<p>
{%- if let Some(detail) = page.detail -%}
{{ detail.columns.len() }} columns · identified by
{%- if detail.row_display_columns.is_empty() %} its id
{%- else %} {{ detail.row_display_columns|join(", ") }}{% endif -%}
{%- if let Some(summary) = page.selected_table() -%}
{%- if !summary.depends_on.is_empty() %} · depends on {{ summary.depends_on|join(", ") }}{% endif -%}
{%- endif -%}
.
{%- else -%}
Everything on this page acts on this profile.
{%- endif -%}
</p>
</div>
<div class="actions">
<a href="/admin{% if page.selection.has_table() %}{{ page.selection.query() }}{% else %}{{ page.selection.profile_query() }}{% endif %}">← Admin panel</a>
</div>
</section>
<nav class="tabs" aria-label="table actions">
{% 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>Columns</span><small>Append columns and rename them</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>Delete</span><small>Drop this table</small>
</a>
{% endif %}
<a href="/permissions/grants" class="tab">
<span>Access</span><small>Which roles may read and write it</small>
</a>
{% endif %}
{% if page.selection.has_profile() && !page.selection.is_global() %}
<a href="/admin/profiles/copy{{ page.selection.profile_query() }}" class="tab {% if page.is("copy") %}selected{% endif %}" {% if page.is("copy") %}aria-current="page"{% endif %}>
<span>Copy profile</span><small>Duplicate its structure</small>
</a>
<a href="/admin/tables/from-template{{ page.selection.profile_query() }}" class="tab {% if page.is("template") %}selected{% endif %}" {% if page.is("template") %}aria-current="page"{% endif %}>
<span>From template</span><small>Create tables from a Typst invoice</small>
</a>
<a href="/admin/profiles/history{{ page.selection.profile_query() }}" class="tab {% if page.is("history") %}selected{% endif %}" {% if page.is("history") %}aria-current="page"{% endif %}>
<span>Rename history</span><small>How columns got their names</small>
</a>
{% endif %}
</nav>
{% if page.selection.has_table() && !page.table_is_writable() %}
<section class="panel">
<h2>Backend-managed</h2>
<p class="hint">
This table is the backend's own. Its definition is visible in the
<a href="/admin{{ page.selection.query() }}">admin panel</a>, but it can only be
changed through the backend's own APIs, so none of the write actions apply to it.
</p>
</section>
{% endif %}

View File

@@ -0,0 +1,14 @@
{# GET /admin/profiles/copy — crate::pages::admin::table_definition::ui::CopyPage #}
{% extends "ui/base.html" %}
{% block title %}Copy profile{% endblock %}
{% block content %}
<main>
{% include "pages/admin/table_definition/context.html" %}
<div id="table-panel" aria-live="polite">
{% include "pages/admin/table_definition/copy_panel.html" %}
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,33 @@
{#
The copy-profile page's body —
crate::pages::admin::table_definition::ui::CopyFragment.
#}
{% include "pages/admin/table_definition/feedback.html" %}
<section class="panel">
<h2>Copy <code>{{ page.selection.profile }}</code> into a new profile</h2>
<p class="hint">Copies structure — tables, links and scripts — and no rows. Leave every table unticked to copy the whole profile.</p>
<form hx-post="/admin/profiles/copy"
hx-target="#table-panel" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<div class="form-grid">
<label>New profile name
<input name="target_profile_name" value="{{ page.copy.target_profile_name }}" placeholder="billing_2027">
</label>
</div>
{% if !page.copy_candidates().is_empty() %}
<div class="check-group">
{% for table in page.copy_candidates() %}
<label class="check">
<input type="checkbox" name="table_names" value="{{ table.name }}"
{% if page.copy_selected(table.name.as_str()) %}checked{% endif %}>
{{ table.name }}
</label>
{% endfor %}
</div>
{% endif %}
<div class="form-actions">
<button type="submit">Copy profile</button>
</div>
</form>
</section>

View File

@@ -0,0 +1,14 @@
{# GET /admin/tables/delete — crate::pages::admin::table_definition::ui::DeletePage #}
{% extends "ui/base.html" %}
{% block title %}Delete table{% endblock %}
{% block content %}
<main>
{% include "pages/admin/table_definition/context.html" %}
<div id="table-panel" aria-live="polite">
{% include "pages/admin/table_definition/delete_panel.html" %}
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{#
The delete page's body — crate::pages::admin::table_definition::ui::DeleteFragment.
Only a refused delete swaps this: a successful one redirects to the admin
panel, because the table this page was about no longer exists.
#}
{% include "pages/admin/table_definition/feedback.html" %}
{% if page.table_is_writable() %}
<section class="panel danger-panel">
<h2>Delete <code>{{ page.selection.table }}</code></h2>
<p class="hint">
Drops the table and its definition, and the profile too when this was its
last table. The backend refuses to delete a table that still has rows.
</p>
{% if let Some(summary) = page.selected_table() %}
{% if !summary.depends_on.is_empty() %}
<p class="hint">It links to {{ summary.depends_on|join(", ") }}.</p>
{% endif %}
{% endif %}
<form hx-post="/admin/tables/delete"
hx-target="#table-panel" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<input type="hidden" name="table" value="{{ page.selection.table }}">
<div class="form-grid">
<label class="wide">Type <code>{{ page.selection.table }}</code> to confirm
<input name="confirm_table_name" value="" autocomplete="off" placeholder="{{ page.selection.table }}">
</label>
</div>
<div class="form-actions">
<button type="submit" class="danger-submit">Delete table</button>
</div>
</form>
</section>
{% else if !page.selection.has_table() %}
<section class="panel">
<h2>No table chosen</h2>
<p class="hint">Pick the table to delete in the <a href="/admin{{ page.selection.profile_query() }}">admin panel</a> first.</p>
</section>
{% endif %}

View File

@@ -0,0 +1,24 @@
{#
The outcome of the last write, reported the way every page here reports it:
inline as an alert, as a modal that has to be dismissed, and — on success —
as a toast that outlives the swap.
Included at the top of each page's panel fragment, because the fragment is
what a write swaps, so this is the markup that has to carry the reason.
#}
{% import "ui/alert.html" as feedback_alert %}
{% import "ui/dialog.html" as feedback_dialog %}
{% import "ui/toast.html" as feedback_toast %}
{%- if let Some(message) = page.error %}{% call feedback_alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.status %}{% call feedback_toast::success("Done", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.error %}{% call feedback_dialog::error("Could not continue", message) %}{% endcall %}{% endif -%}
{% if let Some(sql) = page.sql %}
{% if !sql.is_empty() %}
<section class="panel">
<h2>SQL the backend ran</h2>
<pre class="sql-preview">{{ sql }}</pre>
</section>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,14 @@
{# GET /admin/tables/from-template — crate::pages::admin::table_definition::ui::TemplatePage #}
{% extends "ui/base.html" %}
{% block title %}Create from template{% endblock %}
{% block content %}
<main>
{% include "pages/admin/table_definition/context.html" %}
<div id="table-panel" aria-live="polite">
{% include "pages/admin/table_definition/from_template_panel.html" %}
</div>
</main>
{% endblock %}

View File

@@ -0,0 +1,53 @@
{#
The invoice-template page's body —
crate::pages::admin::table_definition::ui::TemplateFragment.
#}
{% include "pages/admin/table_definition/feedback.html" %}
{% if !page.generated.is_empty() %}
<section class="panel">
<h2>Tables created from the template</h2>
<table class="builder-table">
<thead><tr><th>Table</th><th>Collection</th><th>Parent</th></tr></thead>
<tbody>
{% for generated in page.generated %}
<tr>
<td><code>{{ generated.table_name }}</code></td>
<td>{% if generated.collection_path.is_empty() %}<span class="hint">root</span>{% else %}<code>{{ generated.collection_path }}</code>{% endif %}</td>
<td>{% if generated.parent_table_name.is_empty() %}<span class="hint"></span>{% else %}<code>{{ generated.parent_table_name }}</code>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
<section class="panel">
<h2>Create tables from an invoice template</h2>
<p class="hint">
Reads the <code>#let komp_ac_fields = (…)</code> contract out of a Typst
template. A path that matches an existing table and column becomes a
reference to it; anything else becomes a TEXT column to refine later, and
every <code>[]</code> becomes a child table.
</p>
<form hx-post="/admin/tables/from-template"
hx-target="#table-panel" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<div class="form-grid">
<label>Table name
<input name="table_name" value="{{ page.invoice.table_name }}" placeholder="invoice">
</label>
<label>Row display columns
<input name="row_display_columns" value="{{ page.invoice.row_display_columns }}" placeholder="number, issued_on">
<small>Comma-separated, and only for the root table.</small>
</label>
<label class="wide">Template source
<textarea name="typst_source" rows="10" placeholder="#let komp_ac_fields = (&#10; &quot;sidlo.nazov&quot;,&#10; &quot;people[].name&quot;,&#10;)">{{ page.invoice.typst_source }}</textarea>
</label>
</div>
<div class="form-actions">
<button type="submit">Create from template</button>
</div>
</form>
</section>

View File

@@ -0,0 +1,39 @@
{#
GET /admin/profiles/history — crate::pages::admin::table_definition::ui::HistoryPage
Read-only, so there is nothing here to swap and no panel fragment beside it.
A table in the selection narrows the history to that table; the loader is
what applies that, by passing its id to the backend.
#}
{% extends "ui/base.html" %}
{% block title %}Rename history{% endblock %}
{% block content %}
<main>
{% include "pages/admin/table_definition/context.html" %}
<section class="panel">
<h2>Column rename history</h2>
{% if page.history.is_empty() %}
<p class="hint">
No column in {% if page.selection.has_table() %}<code>{{ page.selection.table }}</code>{% else %}this profile{% endif %} has been renamed.
</p>
{% else %}
<table class="builder-table">
<thead><tr><th>Table</th><th>Was</th><th>Is</th><th>When</th></tr></thead>
<tbody>
{% for entry in page.history %}
<tr>
<td><code>{{ entry.table_name }}</code></td>
<td><code>{{ entry.old_column_name }}</code></td>
<td><code>{{ entry.new_column_name }}</code></td>
<td>{{ entry.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
</main>
{% endblock %}

View File

@@ -1,32 +0,0 @@
{# GET /admin/table-definition — crate::pages::admin::table_definition::ui::TableDefinitionPage #}
{% extends "ui/base.html" %}
{% block title %}Table definition{% endblock %}
{% block content %}
<main>
<section class="heading">
<div>
<p class="eyebrow">Table definition</p>
<h1>Table definition</h1>
<p>Choose Global to manage tables shared by every profile, or choose a profile to manage only its own tables.</p>
</div>
<div class="actions">
<a href="/admin">← Admin panel</a>
</div>
</section>
{#
One swap target for the whole workspace. Every write answers 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-definition-workspace" aria-live="polite">
{% include "pages/admin/table_definition/workspace.html" %}
</div>
</main>
<datalist id="currency-codes">
{% for code in currency_codes %}<option value="{{ code }}"></option>{% endfor %}
</datalist>
{% endblock %}

View File

@@ -1,342 +0,0 @@
{#
The whole workspace — crate::pages::admin::table_definition::ui::WorkspaceFragment,
and what table_definition.html embeds on first load.
Which panels appear follows how much has been selected: the copy and invoice
template panels need a profile, everything else needs a table, and a
backend-managed ("system") table gets none of the write panels at all,
because the server refuses every one of them for it.
#}
{% import "ui/alert.html" as alert %}
{% import "ui/dialog.html" as dialog %}
{% import "ui/toast.html" as toast %}
{%- if let Some(message) = page.error %}{% call alert::error("Could not continue", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.status %}{% call toast::success("Done", message) %}{% endcall %}{% endif -%}
{%- if let Some(message) = page.error %}{% call dialog::error("Could not continue", message) %}{% endcall %}{% endif -%}
{% if let Some(sql) = page.sql %}
{% if !sql.is_empty() %}
<section class="panel">
<h2>SQL the backend ran</h2>
<pre class="sql-preview">{{ sql }}</pre>
</section>
{% endif %}
{% endif %}
{% if !page.generated.is_empty() %}
<section class="panel">
<h2>Tables created from the template</h2>
<table class="builder-table">
<thead><tr><th>Table</th><th>Collection</th><th>Parent</th></tr></thead>
<tbody>
{% for generated in page.generated %}
<tr>
<td><code>{{ generated.table_name }}</code></td>
<td>{% if generated.collection_path.is_empty() %}<span class="hint">root</span>{% else %}<code>{{ generated.collection_path }}</code>{% endif %}</td>
<td>{% if generated.parent_table_name.is_empty() %}<span class="hint"></span>{% else %}<code>{{ generated.parent_table_name }}</code>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
<section class="panel">
<h2>Selection</h2>
<div class="form-grid">
<label>Scope
<select name="profile" hx-get="/admin/table-definition/workspace"
hx-target="#table-definition-workspace" hx-swap="innerHTML">
<option value="">Choose a scope</option>
<option value="__global" {% if page.selection.is_global() %}selected{% endif %}>Global — all profiles</option>
{% for profile in page.profiles %}
<option value="{{ profile }}" {% if page.selection.profile == *profile %}selected{% endif %}>{{ profile }}</option>
{% endfor %}
</select>
</label>
{% if page.selection.has_profile() %}
<label>Table
<select name="table" hx-get="/admin/table-definition/workspace"
hx-target="#table-definition-workspace" hx-swap="innerHTML"
hx-include="[name='profile']">
<option value="">Choose a table</option>
{% for table in page.tables %}
<option value="{{ table.name }}" {% if page.selection.table == table.name %}selected{% endif %}>
{{ table.name }}{% if table.is_system() %} (system){% endif %}
</option>
{% endfor %}
</select>
</label>
{% endif %}
</div>
{% if page.selection.has_profile() %}
<div class="actions panel-actions">
{% if page.selection.is_global() %}
<a href="/admin/tables/new?global=true">+ Create a global table</a>
{% else %}
<a href="/admin/tables/new?profile={{ page.selection.profile }}">+ Create a table in this profile</a>
{% endif %}
</div>
{% if page.tables.is_empty() %}
<p class="hint">This profile has no tables yet.</p>
{% else %}
<table class="builder-table">
<thead><tr><th>Table</th><th>Kind</th><th>Depends on</th><th>Row display</th></tr></thead>
<tbody>
{% for table in page.tables %}
<tr>
<td><code>{{ table.name }}</code></td>
<td>{{ table.table_kind }}</td>
<td>{% if table.depends_on.is_empty() %}<span class="hint"></span>{% else %}{{ table.depends_on|join(", ") }}{% endif %}</td>
<td>{% if table.row_display_columns.is_empty() %}<span class="hint">id</span>{% else %}{{ table.row_display_columns|join(", ") }}{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% else %}
<p class="hint">Nothing is selected yet. Every panel below acts on one profile, and most of them on one table inside it.</p>
{% endif %}
</section>
{% if let Some(detail) = page.detail %}
<section class="panel">
<h2>{{ detail.name }} <span class="count">{{ detail.columns.len() }} columns</span></h2>
<p class="hint">
Identified by
{%- if detail.row_display_columns.is_empty() %} its id
{%- else %} {{ detail.row_display_columns|join(", ") }}{% endif -%}
{%- if let Some(summary) = page.selected_table() -%}
{%- if !summary.depends_on.is_empty() %} · depends on {{ summary.depends_on|join(", ") }}{% endif -%}
{%- endif -%}
.
</p>
{% if detail.is_system() %}
<p class="hint">This table is backend-managed. Its definition is shown here, but it can only be changed through the backend's own APIs.</p>
{% endif %}
<table class="builder-table">
<thead><tr><th>Column</th><th>Type</th><th>Notes</th></tr></thead>
<tbody>
{% for column in detail.columns %}
<tr>
<td><code>{{ column.name }}</code></td>
<td>{{ column.field_type }}{% if !column.sql_type.is_empty() %} <small class="hint">{{ column.sql_type }}</small>{% endif %}</td>
<td>
{%- for flag in column.flags() %}<span class="tag">{{ flag }}</span>{% endfor -%}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if !detail.scripts.is_empty() %}
<h3 class="panel-subhead">Scripts</h3>
<table class="builder-table">
<thead><tr><th>Target column</th><th>Type</th><th>Description</th></tr></thead>
<tbody>
{% for script in detail.scripts %}
<tr>
<td><code>{{ script.target_column }}</code></td>
<td>{{ script.target_column_type }}</td>
<td>
{{ script.description }}
<details><summary>source</summary><pre class="sql-preview">{{ script.script }}</pre></details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
{% endif %}
{% if !page.permission_object.is_empty() %}
<section class="panel">
<h2>Data permissions for <code>{{ page.selection.table }}</code></h2>
<p class="hint">
These grants cover this table family and take effect on subsequent requests without replacing the
current session. Wider access — a whole profile, or every table at once — is on the
<a href="/permissions/grants">Permissions page</a>.
</p>
<table class="builder-table">
<thead><tr><th>Role</th><th>Actions</th></tr></thead>
<tbody>{% for role in page.role_permissions %}<tr>
<td>{{ role.role }}</td>
<td><div class="actions">{% for permission in role.actions %}
{% if permission.direct %}
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="mode" value="revoke"><input type="hidden" name="pair" value="{{ page.permission_object }}|{{ permission.action }}"><input type="hidden" name="return_to" value="/admin/table-definition{{ page.selection.query() }}">
<button type="submit" class="danger">{{ permission.action }} ✓</button>
</form>
{% else if permission.effective %}
<span class="tag">{{ permission.action }} · inherited</span>
{% else %}
<form hx-post="/permissions/grants/apply" hx-target="#permission-status">
<input type="hidden" name="role" value="{{ role.role }}"><input type="hidden" name="mode" value="grant"><input type="hidden" name="pair" value="{{ page.permission_object }}|{{ permission.action }}"><input type="hidden" name="return_to" value="/admin/table-definition{{ page.selection.query() }}">
<button type="submit" class="secondary">Grant {{ permission.action }}</button>
</form>
{% endif %}
{% endfor %}</div></td>
</tr>{% endfor %}</tbody>
</table>
<div id="permission-status" aria-live="polite"></div>
</section>
{% endif %}
{% 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>Add columns to <code>{{ page.selection.table }}</code></h2>
<p class="hint">Columns are appended. Nothing that already exists is changed, and the new columns can be indexed as they are added.</p>
<form id="column-form"
hx-post="/admin/table-definition/columns{{ page.selection.query() }}"
hx-target="#table-definition-workspace" 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>Rename a column</h2>
<p class="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.</p>
<form hx-post="/admin/table-definition/rename"
hx-target="#table-definition-workspace" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<input type="hidden" name="table" value="{{ page.selection.table }}">
<div class="form-grid">
<label>Column
<select name="old_column_name">
<option value="">Choose a column</option>
{% for column in detail.renameable_columns() %}
<option value="{{ column.name }}" {% if page.rename.old_column_name == column.name %}selected{% endif %}>{{ column.name }}</option>
{% endfor %}
</select>
</label>
<label>New name
<input name="new_column_name" value="{{ page.rename.new_column_name }}" placeholder="invoice_number">
</label>
</div>
<div class="form-actions">
<button type="submit">Rename column</button>
</div>
</form>
</section>
{% endif %}
<section class="panel danger-panel">
<h2>Delete <code>{{ page.selection.table }}</code></h2>
<p class="hint">
Drops the table and its definition, and the profile too when this was its
last table. The backend refuses to delete a table that still has rows.
</p>
<form hx-post="/admin/table-definition/delete"
hx-target="#table-definition-workspace" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<input type="hidden" name="table" value="{{ page.selection.table }}">
<div class="form-grid">
<label class="wide">Type <code>{{ page.selection.table }}</code> to confirm
<input name="confirm_table_name" value="" autocomplete="off" placeholder="{{ page.selection.table }}">
</label>
</div>
<div class="form-actions">
<button type="submit" class="danger-submit">Delete table</button>
</div>
</form>
</section>
{% endif %}
{% if page.selection.has_profile() && !page.selection.is_global() %}
<section class="panel">
<h2>Copy <code>{{ page.selection.profile }}</code> into a new profile</h2>
<p class="hint">Copies structure — tables, links and scripts — and no rows. Leave every table unticked to copy the whole profile.</p>
<form hx-post="/admin/table-definition/copy"
hx-target="#table-definition-workspace" hx-swap="innerHTML">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<div class="form-grid">
<label>New profile name
<input name="target_profile_name" value="{{ page.copy.target_profile_name }}" placeholder="billing_2027">
</label>
</div>
{% if !page.copy_candidates().is_empty() %}
<div class="check-group">
{% for table in page.copy_candidates() %}
<label class="check">
<input type="checkbox" name="table_names" value="{{ table.name }}"
{% if page.copy_selected(table.name.as_str()) %}checked{% endif %}>
{{ table.name }}
</label>
{% endfor %}
</div>
{% endif %}
<div class="form-actions">
<button type="submit">Copy profile</button>
</div>
</form>
</section>
<section class="panel">
<h2>Create tables from an invoice template</h2>
<p class="hint">
Reads the <code>#let komp_ac_fields = (…)</code> contract out of a Typst
template. A path that matches an existing table and column becomes a
reference to it; anything else becomes a TEXT column to refine later, and
every <code>[]</code> becomes a child table.
</p>
<form hx-post="/admin/table-definition/invoice-template"
hx-target="#table-definition-workspace" hx-swap="innerHTML"
hx-disabled-elt="button[type=submit]">
<input type="hidden" name="profile" value="{{ page.selection.profile }}">
<div class="form-grid">
<label>Table name
<input name="table_name" value="{{ page.invoice.table_name }}" placeholder="invoice">
</label>
<label>Row display columns
<input name="row_display_columns" value="{{ page.invoice.row_display_columns }}" placeholder="number, issued_on">
<small>Comma-separated, and only for the root table.</small>
</label>
<label class="wide">Template source
<textarea name="typst_source" rows="10" placeholder="#let komp_ac_fields = (&#10; &quot;sidlo.nazov&quot;,&#10; &quot;people[].name&quot;,&#10;)">{{ page.invoice.typst_source }}</textarea>
</label>
</div>
<div class="form-actions">
<button type="submit">Create from template</button>
</div>
</form>
</section>
<section class="panel">
<h2>Column rename history</h2>
{% if page.history.is_empty() %}
<p class="hint">
No column in {% if page.selection.has_table() %}<code>{{ page.selection.table }}</code>{% else %}this profile{% endif %} has been renamed.
</p>
{% else %}
<table class="builder-table">
<thead><tr><th>Table</th><th>Was</th><th>Is</th><th>When</th></tr></thead>
<tbody>
{% for entry in page.history %}
<tr>
<td><code>{{ entry.table_name }}</code></td>
<td><code>{{ entry.old_column_name }}</code></td>
<td><code>{{ entry.new_column_name }}</code></td>
<td>{{ entry.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</section>
{% endif %}