web interface for table_definition improved

This commit is contained in:
Priec
2026-08-04 17:20:58 +02:00
parent c2002c7da3
commit 803207b1af
20 changed files with 2989 additions and 612 deletions

View File

@@ -61,6 +61,7 @@ mod tests {
};
let html = render_page(&page);
for route in [
"/admin/table-definition",
"/admin/tables/new",
"/admin/logic/new",
"/admin/validation/new",

View File

@@ -1 +1,2 @@
pub(crate) mod admin;
pub(crate) mod table_definition;

View File

@@ -0,0 +1,196 @@
//! Reads everything the workspace shows.
//!
//! Three calls, in this order: 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
//! 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.
use axum::http::HeaderMap;
use crate::{
AppState,
auth::GetAuthorizationRequest,
definitions::{
common::Empty,
table_definition::{
GetColumnAliasRenameHistoryRequest, GetProfileDetailsRequest, MoneyRounding,
},
},
services::authenticated_request,
};
use super::state::{
DetailColumn, LoadError, PageInputs, RenameEntry, ScriptView, TableDefinitionPageState,
TableDetailView, TableSummary,
};
pub(crate) async fn load_page(
state: AppState,
headers: &HeaderMap,
mut inputs: PageInputs,
) -> Result<TableDefinitionPageState, LoadError> {
let authorization_request = authenticated_request(headers, GetAuthorizationRequest {})
.map_err(|_| LoadError::Unauthenticated)?;
let mut auth = state.auth;
let authorization = auth
.get_authorization(authorization_request)
.await
.map_err(|error| match error.code() {
tonic::Code::Unauthenticated => LoadError::Unauthenticated,
_ => LoadError::Backend(error.message().to_string()),
})?
.into_inner();
if authorization.role != "admin" {
return Err(LoadError::Forbidden);
}
let mut definitions = state.definitions;
let tree = definitions
.get_profile_tree(
authenticated_request(headers, Empty {}).map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner();
let profiles = tree
.profiles
.iter()
.map(|profile| profile.name.clone())
.collect::<Vec<_>>();
// A profile that no longer exists takes the table selection with it.
if !profiles.contains(&inputs.selection.profile) {
inputs.selection.profile.clear();
inputs.selection.table.clear();
}
let tables = tree
.profiles
.iter()
.find(|profile| profile.name == inputs.selection.profile)
.map(|profile| {
profile
.tables
.iter()
.map(|table| TableSummary {
name: table.name.clone(),
table_kind: table.table_kind.clone(),
depends_on: table.depends_on.clone(),
row_display_columns: table.row_display_columns.clone(),
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !tables
.iter()
.any(|table| table.name == inputs.selection.table)
{
inputs.selection.table.clear();
}
let detail = match inputs.selection.has_table() {
true => {
let request = GetProfileDetailsRequest {
profile_name: inputs.selection.profile.clone(),
};
let details = definitions
.get_profile_details(
authenticated_request(headers, request)
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner();
details
.tables
.into_iter()
.find(|table| table.name == inputs.selection.table)
.map(|table| TableDetailView {
id: table.id,
columns: table
.columns
.iter()
.map(|column| {
let behavior = table.column_behaviors.get(&column.name);
DetailColumn {
name: column.name.clone(),
field_type: column.field_type.clone(),
currency: column.currency.clone(),
quantity_ledger: column.quantity_ledger,
rounded: column.rounding == i32::from(MoneyRounding::HalfUp),
generated: behavior.is_some_and(|behavior| behavior.generated),
read_only: behavior.is_some_and(|behavior| behavior.read_only),
generated_from: behavior
.map(|behavior| behavior.generated_from.clone())
.unwrap_or_default(),
}
})
.collect(),
scripts: table
.scripts
.into_iter()
.map(|script| ScriptView {
target_column: script.target_column,
target_column_type: script.target_column_type,
description: script.description,
script: script.script,
})
.collect(),
row_display_columns: table.row_display_columns,
table_kind: table.table_kind,
name: table.name,
})
}
false => None,
};
// The history is per profile; a selected table narrows it to that table.
let history = match inputs.selection.has_profile() {
true => {
let request = GetColumnAliasRenameHistoryRequest {
profile_name: inputs.selection.profile.clone(),
table_definition_id: detail.as_ref().map(|detail| detail.id),
};
definitions
.get_column_alias_rename_history(
authenticated_request(headers, request)
.map_err(|_| LoadError::Unauthenticated)?,
)
.await
.map_err(|error| LoadError::Backend(error.message().to_string()))?
.into_inner()
.entries
.into_iter()
.map(|entry| RenameEntry {
table_name: entry.table_name,
old_column_name: entry.old_column_name,
new_column_name: entry.new_column_name,
created_at: entry.created_at,
})
.collect()
}
false => Vec::new(),
};
Ok(TableDefinitionPageState {
nav: crate::ui::Nav::new(headers, "admin").with_role(authorization.role),
profiles,
tables,
detail,
history,
selection: inputs.selection,
columns: inputs.columns,
rename: inputs.rename,
copy: inputs.copy,
invoice: inputs.invoice,
status: inputs.status,
error: inputs.error,
sql: inputs.sql,
generated: inputs.generated,
})
}

View File

@@ -0,0 +1,460 @@
//! The workspace's request handlers — one per `TableDefinition` write.
//!
//! Every write answers with the whole workspace, re-read from the backend, so
//! what the user sees after a change is the definition as it now is rather
//! than the form they submitted. A refused 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
//! server, so those interactions swap only the panel.
use axum::{
extract::{Query, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
};
use axum_extra::extract::Form;
use crate::{
AppState,
definitions::table_definition::{
AddTableColumnsRequest, CopyProfileRequest, CreateInvoiceTemplateTableRequest,
DeleteTableRequest, RenameColumnAliasRequest,
},
schema::{ColumnForm, proto_columns},
services::{authenticated_request, reject_cross_site},
};
use super::{
loader::load_page,
state::{
CopyForm, DeleteForm, GeneratedTableView, InvoiceTemplateForm, LoadError, PageInputs,
RenameForm, Selection,
},
ui,
};
/// GET /admin/table-definition
pub(crate) async fn page(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
match load_page(state, &headers, PageInputs::for_selection(selection)).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.
pub(crate) async fn workspace(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
) -> Response {
match load_page(state, &headers, PageInputs::for_selection(selection)).await {
Ok(page) => Html(ui::render_workspace(&page)).into_response(),
Err(error) => load_error_response(error),
}
}
/// POST /admin/table-definition/columns/builder — staging a column to append.
///
/// Nothing is written here; the panel is swapped back with the column added,
/// removed, or its index toggled, exactly as the Add-table builder works.
pub(crate) async fn update_columns(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
Form(form): Form<ColumnForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let mut inputs = PageInputs::for_selection(selection);
inputs.columns = form.to_draft(false);
let index = form.index.unwrap_or(0);
match form.action.as_str() {
"add-column" => match inputs.columns.add_from_inputs() {
Ok(status) => inputs.status = Some(status),
Err(message) => inputs.error = Some(message),
},
"remove-column" => {
if let Err(message) = inputs.columns.remove(index) {
inputs.error = Some(message);
}
}
"toggle-index" => inputs.columns.toggle_indexed(index),
_ => {}
}
match load_page(state, &headers, inputs).await {
Ok(page) => Html(ui::render_column_panel(&page)).into_response(),
Err(error) => load_error_response(error),
}
}
/// POST /admin/table-definition/columns — AddTableColumns.
pub(crate) async fn add_columns(
State(state): State<AppState>,
headers: HeaderMap,
Query(selection): Query<Selection>,
Form(form): Form<ColumnForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let mut inputs = PageInputs::for_selection(selection);
inputs.columns = form.to_draft(false);
if !inputs.selection.has_table() {
return refuse(state, headers, inputs, "Select a table first.".to_string()).await;
}
if inputs.columns.is_empty() {
return refuse(
state,
headers,
inputs,
"Describe at least one column before adding.".to_string(),
)
.await;
}
// The same checks the server runs, applied to a draft that may have been
// rebuilt from a posted form rather than through the panel.
if let Err(message) = inputs.columns.validate() {
return refuse(state, headers, inputs, message).await;
}
let request = AddTableColumnsRequest {
profile_name: inputs.selection.profile.clone(),
table_name: inputs.selection.table.clone(),
columns: proto_columns(&inputs.columns.added),
indexes: inputs.columns.selected_index_names(),
};
let Ok(request) = authenticated_request(&headers, request) else {
return Redirect::to("/login").into_response();
};
let mut definitions = state.definitions.clone();
match definitions.add_table_columns(request).await {
Ok(response) if response.get_ref().success => {
let added = inputs.columns.added.len();
inputs.sql = Some(response.into_inner().sql);
inputs.status = Some(format!(
"{added} column{} added to `{}`.",
if added == 1 { "" } else { "s" },
inputs.selection.table
));
// The columns are the table's now, so the panel starts empty.
inputs.columns = crate::schema::ColumnDraft::for_append();
respond(state, headers, inputs, StatusCode::OK).await
}
Ok(response) => {
let message = response.into_inner().sql;
let message = if message.is_empty() {
"The backend did not add the columns.".to_string()
} else {
message
};
refuse(state, headers, inputs, message).await
}
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
}
}
/// POST /admin/table-definition/rename — RenameColumnAlias.
pub(crate) async fn rename_column(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<RenameForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let mut inputs = PageInputs::for_selection(Selection {
profile: form.profile.clone(),
table: form.table.clone(),
});
inputs.rename = form.clone();
if form.old_column_name.is_empty() || form.new_column_name.trim().is_empty() {
return refuse(
state,
headers,
inputs,
"Choose a column and type its new name.".to_string(),
)
.await;
}
let request = RenameColumnAliasRequest {
profile_name: form.profile.clone(),
table_name: form.table.clone(),
old_column_name: form.old_column_name.clone(),
new_column_name: form.new_column_name.trim().to_string(),
};
let Ok(request) = authenticated_request(&headers, request) else {
return Redirect::to("/login").into_response();
};
let mut definitions = state.definitions.clone();
match definitions.rename_column_alias(request).await {
Ok(response) if response.get_ref().success => {
inputs.status = Some(response.into_inner().message);
inputs.rename = RenameForm {
profile: form.profile,
table: form.table,
..Default::default()
};
respond(state, headers, inputs, StatusCode::OK).await
}
Ok(response) => {
let message = response.into_inner().message;
let message = if message.is_empty() {
"The backend did not rename the column.".to_string()
} else {
message
};
refuse(state, headers, inputs, message).await
}
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
}
}
/// POST /admin/table-definition/delete — DeleteTable.
pub(crate) async fn delete_table(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<DeleteForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let mut inputs = PageInputs::for_selection(Selection {
profile: form.profile.clone(),
table: form.table.clone(),
});
// The typed name is the whole guard: the drop is CASCADE, and it takes the
// profile with it when this was its last table.
if form.confirm_table_name.trim() != form.table || form.table.is_empty() {
return refuse(
state,
headers,
inputs,
"Type the table's name exactly to confirm the deletion.".to_string(),
)
.await;
}
let request = DeleteTableRequest {
profile_name: form.profile.clone(),
table_name: form.table.clone(),
};
let Ok(request) = authenticated_request(&headers, request) else {
return Redirect::to("/login").into_response();
};
let mut definitions = state.definitions.clone();
match definitions.delete_table(request).await {
Ok(response) if response.get_ref().success => {
inputs.status = Some(response.into_inner().message);
// Whatever was selected is gone; the loader drops it, and this
// keeps the workspace from asking for it again.
inputs.selection.table.clear();
respond(state, headers, inputs, StatusCode::OK).await
}
Ok(response) => {
let message = response.into_inner().message;
let message = if message.is_empty() {
"The backend did not delete the table.".to_string()
} else {
message
};
refuse(state, headers, inputs, message).await
}
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
}
}
/// POST /admin/table-definition/copy — CopyProfile.
pub(crate) async fn copy_profile(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<CopyForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let mut inputs = PageInputs::for_selection(Selection {
profile: form.profile.clone(),
table: String::new(),
});
inputs.copy = form.clone();
if form.target_profile_name.trim().is_empty() {
return refuse(
state,
headers,
inputs,
"Name the profile to copy into.".to_string(),
)
.await;
}
let request = CopyProfileRequest {
source_profile_name: form.profile.clone(),
target_profile_name: form.target_profile_name.trim().to_string(),
table_names: form.table_names.clone(),
};
let Ok(request) = authenticated_request(&headers, request) else {
return Redirect::to("/login").into_response();
};
let mut definitions = state.definitions.clone();
match definitions.copy_profile(request).await {
Ok(response) if response.get_ref().success => {
let response = response.into_inner();
inputs.status = Some(format!(
"{}{} table(s) and {} script(s) copied.",
response.message, response.tables_copied, response.scripts_copied
));
inputs.copy = CopyForm {
profile: form.profile,
..Default::default()
};
respond(state, headers, inputs, StatusCode::OK).await
}
Ok(response) => {
let message = response.into_inner().message;
let message = if message.is_empty() {
"The backend did not copy the profile.".to_string()
} else {
message
};
refuse(state, headers, inputs, message).await
}
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
}
}
/// POST /admin/table-definition/invoice-template — CreateInvoiceTemplateTable.
pub(crate) async fn create_from_invoice_template(
State(state): State<AppState>,
headers: HeaderMap,
Form(form): Form<InvoiceTemplateForm>,
) -> Response {
if let Some(rejection) = reject_cross_site(&headers) {
return rejection;
}
let mut inputs = PageInputs::for_selection(Selection {
profile: form.profile.clone(),
table: String::new(),
});
inputs.invoice = form.clone();
if form.table_name.trim().is_empty() || form.typst_source.trim().is_empty() {
return refuse(
state,
headers,
inputs,
"A table name and the template's source are both required.".to_string(),
)
.await;
}
let request = CreateInvoiceTemplateTableRequest {
profile_name: form.profile.clone(),
table_name: form.table_name.trim().to_string(),
typst_source: form.typst_source.clone(),
row_display_columns: form.display_columns(),
};
let Ok(request) = authenticated_request(&headers, request) else {
return Redirect::to("/login").into_response();
};
let mut definitions = state.definitions.clone();
match definitions.create_invoice_template_table(request).await {
Ok(response) if response.get_ref().success => {
let response = response.into_inner();
inputs.status = Some(format!(
"{} table(s) created from the template.",
response.tables.len()
));
inputs.generated = response
.tables
.into_iter()
.map(|table| GeneratedTableView {
table_name: table.table_name,
collection_path: table.collection_path,
parent_table_name: table.parent_table_name,
})
.collect();
inputs.invoice = InvoiceTemplateForm {
profile: form.profile,
..Default::default()
};
respond(state, headers, inputs, StatusCode::OK).await
}
Ok(_) => {
refuse(
state,
headers,
inputs,
"The backend did not create the template's tables.".to_string(),
)
.await
}
Err(error) => refuse(state, headers, inputs, error.message().to_string()).await,
}
}
/// Re-reads the workspace and answers with it.
async fn respond(
state: AppState,
headers: HeaderMap,
inputs: PageInputs,
status: StatusCode,
) -> Response {
match load_page(state, &headers, inputs).await {
Ok(page) => (status, Html(ui::render_workspace(&page))).into_response(),
Err(error) => load_error_response(error),
}
}
/// Answers a refused write: the workspace as it still is, plus the reason.
async fn refuse(
state: AppState,
headers: HeaderMap,
mut inputs: PageInputs,
message: String,
) -> Response {
inputs.error = Some(message);
respond(state, headers, inputs, StatusCode::UNPROCESSABLE_ENTITY).await
}
fn load_error_response(error: LoadError) -> Response {
match error {
LoadError::Unauthenticated => Redirect::to("/login").into_response(),
LoadError::Forbidden => (
StatusCode::FORBIDDEN,
Html(ui::render_load_error(
"Administrator access is required.",
)),
)
.into_response(),
LoadError::Backend(message) => (
StatusCode::BAD_GATEWAY,
Html(ui::render_load_error(&message)),
)
.into_response(),
}
}

View File

@@ -0,0 +1,36 @@
//! The table-definition workspace: pick a profile and a table, then do
//! anything the `TableDefinition` service offers to it.
//!
//! Creating a table is the one operation that lives elsewhere — it is a form
//! long enough to want its own page, `pages/add_table` — and the workspace
//! links to it with the chosen profile already filled in.
mod loader;
mod logic;
mod state;
mod ui;
use axum::{
Router,
routing::{get, post},
};
use crate::AppState;
pub(crate) fn router() -> Router<AppState> {
Router::new()
.route("/admin/table-definition", get(logic::page))
.route("/admin/table-definition/workspace", get(logic::workspace))
.route(
"/admin/table-definition/columns/builder",
post(logic::update_columns),
)
.route("/admin/table-definition/columns", post(logic::add_columns))
.route("/admin/table-definition/rename", post(logic::rename_column))
.route("/admin/table-definition/delete", post(logic::delete_table))
.route("/admin/table-definition/copy", post(logic::copy_profile))
.route(
"/admin/table-definition/invoice-template",
post(logic::create_from_invoice_template),
)
}

View File

@@ -0,0 +1,346 @@
//! What the workspace renders, and the wire formats its panels post.
//!
//! Every panel is a form of its own, and each one carries the selection it
//! acts on in hidden fields, because the workspace is swapped whole on every
//! write: the response is rebuilt from the live profile tree rather than from
//! whatever the browser still had on screen.
use crate::schema::ColumnDraft;
/// The profile and table the workspace is pointed at. Arrives as a query
/// string on the selector and on the column-panel endpoints, and as hidden
/// fields on the panels that write.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct Selection {
#[serde(default)]
pub profile: String,
#[serde(default)]
pub table: String,
}
impl Selection {
pub(crate) fn has_profile(&self) -> bool {
!self.profile.is_empty()
}
pub(crate) fn has_table(&self) -> bool {
!self.table.is_empty()
}
/// The query string the column panel posts back to, so the panel's own
/// form does not have to carry the selection among its column fields.
pub(crate) fn query(&self) -> String {
format!("?profile={}&table={}", self.profile, self.table)
}
}
/// One table in the selected profile, from the profile tree.
#[derive(Clone, Debug)]
pub(crate) struct TableSummary {
pub name: String,
pub table_kind: String,
pub depends_on: Vec<String>,
pub row_display_columns: Vec<String>,
}
impl TableSummary {
/// System tables are backend-managed: the server refuses every write below
/// on them, so the workspace does not offer the panels either.
pub(crate) fn is_system(&self) -> bool {
self.table_kind == "system"
}
}
/// The selected table as the backend describes it, from `GetProfileDetails`.
#[derive(Clone, Debug)]
pub(crate) struct TableDetailView {
pub id: i64,
pub name: String,
pub table_kind: String,
pub row_display_columns: Vec<String>,
pub columns: Vec<DetailColumn>,
pub scripts: Vec<ScriptView>,
}
impl TableDetailView {
pub(crate) fn is_system(&self) -> bool {
self.table_kind == "system"
}
/// Columns a rename may target. A generated companion (`phone_country`,
/// the accounting fields) belongs to the column it was derived from, and
/// the server refuses to rename one.
pub(crate) fn renameable_columns(&self) -> Vec<&DetailColumn> {
self.columns
.iter()
.filter(|column| !column.generated)
.collect()
}
}
#[derive(Clone, Debug)]
pub(crate) struct DetailColumn {
pub name: String,
pub field_type: String,
pub currency: String,
pub quantity_ledger: bool,
pub rounded: bool,
pub generated: bool,
pub read_only: bool,
pub generated_from: String,
}
impl DetailColumn {
/// The badge list rendered under each column name.
pub(crate) fn flags(&self) -> Vec<String> {
let mut flags = Vec::new();
if !self.currency.is_empty() {
flags.push(self.currency.clone());
}
if self.rounded {
flags.push("half-up".to_string());
}
if self.quantity_ledger {
flags.push("quantity ledger".to_string());
}
if self.read_only {
flags.push("read only".to_string());
}
if self.generated {
flags.push(if self.generated_from.is_empty() {
"generated".to_string()
} else {
format!("generated from {}", self.generated_from)
});
}
flags
}
}
#[derive(Clone, Debug)]
pub(crate) struct ScriptView {
pub target_column: String,
pub target_column_type: String,
pub description: String,
pub script: String,
}
/// One row of the stored rename history.
#[derive(Clone, Debug)]
pub(crate) struct RenameEntry {
pub table_name: String,
pub old_column_name: String,
pub new_column_name: String,
pub created_at: String,
}
/// One physical table created from an invoice template.
#[derive(Clone, Debug)]
pub(crate) struct GeneratedTableView {
pub table_name: String,
pub collection_path: String,
pub parent_table_name: String,
}
/// The rename panel's inputs, kept across a failed submit so the user does not
/// retype them.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct RenameForm {
#[serde(default)]
pub profile: String,
#[serde(default)]
pub table: String,
#[serde(default)]
pub old_column_name: String,
#[serde(default)]
pub new_column_name: String,
}
/// The copy-profile panel. An empty `table_names` copies the whole profile,
/// which is what the backend takes an empty list to mean.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct CopyForm {
#[serde(default)]
pub profile: String,
#[serde(default)]
pub target_profile_name: String,
#[serde(default)]
pub table_names: Vec<String>,
}
/// The invoice-template panel. `row_display_columns` is typed as a
/// comma-separated list, because the columns do not exist yet — they are
/// whatever the template's contract turns out to declare.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct InvoiceTemplateForm {
#[serde(default)]
pub profile: String,
#[serde(default)]
pub table_name: String,
#[serde(default)]
pub typst_source: String,
#[serde(default)]
pub row_display_columns: String,
}
impl InvoiceTemplateForm {
pub(crate) fn display_columns(&self) -> Vec<String> {
self.row_display_columns
.split(',')
.map(str::trim)
.filter(|column| !column.is_empty())
.map(str::to_string)
.collect()
}
}
/// The delete panel. The table name has to be typed back: `DeleteTable` drops
/// the physical table with CASCADE and takes the profile with it when it was
/// the last one, so a mis-click must not be enough.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct DeleteForm {
#[serde(default)]
pub profile: String,
#[serde(default)]
pub table: String,
#[serde(default)]
pub confirm_table_name: String,
}
/// Everything the caller decides before the loader fills in the live data.
#[derive(Clone, Debug, Default)]
pub(crate) struct PageInputs {
pub selection: Selection,
/// The columns staged for `AddTableColumns`.
pub columns: ColumnDraft,
pub rename: RenameForm,
pub copy: CopyForm,
pub invoice: InvoiceTemplateForm,
pub status: Option<String>,
pub error: Option<String>,
/// The DDL a successful write reported.
pub sql: Option<String>,
/// The bundle a successful invoice-template creation reported.
pub generated: Vec<GeneratedTableView>,
}
impl PageInputs {
pub(crate) fn for_selection(selection: Selection) -> Self {
Self {
selection,
columns: ColumnDraft::for_append(),
..Self::default()
}
}
}
/// What the templates read.
pub(crate) struct TableDefinitionPageState {
pub nav: crate::ui::Nav,
pub profiles: Vec<String>,
pub selection: Selection,
pub tables: Vec<TableSummary>,
pub detail: Option<TableDetailView>,
pub history: Vec<RenameEntry>,
pub columns: ColumnDraft,
pub rename: RenameForm,
pub copy: CopyForm,
pub invoice: InvoiceTemplateForm,
pub status: Option<String>,
pub error: Option<String>,
pub sql: Option<String>,
pub generated: Vec<GeneratedTableView>,
}
impl TableDefinitionPageState {
/// The selected table's summary, which is where its kind and dependencies
/// come from.
pub(crate) fn selected_table(&self) -> Option<&TableSummary> {
self.tables
.iter()
.find(|table| table.name == self.selection.table)
}
/// Whether the write panels apply. They need a table, and that table has
/// to be one the server will let anyone but itself modify.
pub(crate) fn table_is_writable(&self) -> bool {
self.selected_table()
.is_some_and(|table| !table.is_system())
}
/// Tables offered as copy sources — all of them, since `CopyProfile`
/// copies structure and a system table is structure too.
pub(crate) fn copy_candidates(&self) -> &[TableSummary] {
&self.tables
}
pub(crate) fn copy_selected(&self, table_name: &str) -> bool {
self.copy
.table_names
.iter()
.any(|selected| selected == table_name)
}
}
#[derive(Debug)]
pub(crate) enum LoadError {
Unauthenticated,
Forbidden,
Backend(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_columns_are_split_and_trimmed() {
let form = InvoiceTemplateForm {
row_display_columns: " number , , issued_on ".to_string(),
..Default::default()
};
assert_eq!(form.display_columns(), vec!["number", "issued_on"]);
}
#[test]
fn a_generated_column_says_what_it_came_from_and_cannot_be_renamed() {
let detail = TableDetailView {
id: 1,
name: "contact".to_string(),
table_kind: "dynamic".to_string(),
row_display_columns: Vec::new(),
scripts: Vec::new(),
columns: vec![
DetailColumn {
name: "work_phone".to_string(),
field_type: "phone".to_string(),
currency: String::new(),
quantity_ledger: false,
rounded: false,
generated: false,
read_only: false,
generated_from: String::new(),
},
DetailColumn {
name: "work_phone_country".to_string(),
field_type: "phone_country".to_string(),
currency: String::new(),
quantity_ledger: false,
rounded: false,
generated: true,
read_only: true,
generated_from: "work_phone".to_string(),
},
],
};
let renameable = detail.renameable_columns();
assert_eq!(renameable.len(), 1);
assert_eq!(renameable[0].name, "work_phone");
assert_eq!(
detail.columns[1].flags(),
vec!["read only", "generated from work_phone"]
);
}
}

View File

@@ -0,0 +1,306 @@
use askama::Template;
use crate::{
schema::{CURRENCY_CODES, GTIN_TYPES, TEMPORAL_TYPES},
ui::{Alert, Nav, render},
};
use super::state::TableDefinitionPageState;
/// GET /admin/table-definition — the shell around the workspace.
#[derive(Template)]
#[template(path = "pages/admin/table_definition/table_definition.html")]
struct TableDefinitionPage<'a> {
nav: Nav,
page: &'a TableDefinitionPageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
currency_codes: &'static [&'static str],
/// False, as on the workspace fragment: the page embeds both, and the
/// outcome is reported once, at the top.
standalone_column_panel: bool,
}
/// The `#table-definition-workspace` swap, which is the same markup the page
/// embeds, so one template serves both.
#[derive(Template)]
#[template(path = "pages/admin/table_definition/workspace.html")]
struct WorkspaceFragment<'a> {
page: &'a TableDefinitionPageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
/// False: the workspace shows the outcome of the last action itself, at
/// the top, so the panel it embeds must not repeat it.
standalone_column_panel: bool,
}
/// The `#column-panel` swap, for staging a column without writing anything.
#[derive(Template)]
#[template(path = "pages/admin/table_definition/column_panel.html")]
struct ColumnPanelFragment<'a> {
page: &'a TableDefinitionPageState,
column_types: &'static [&'static str],
temporal_types: &'static [&'static str],
gtin_types: &'static [&'static str],
/// True: this is the whole response, so a refused column has nowhere else
/// to be reported.
standalone_column_panel: bool,
}
pub(crate) fn render_page(page: &TableDefinitionPageState) -> String {
render(&TableDefinitionPage {
nav: page.nav.clone(),
page,
// Asking the draft, so the picker can only ever offer what the draft
// would accept — the accounting rule is stated once.
column_types: page.columns.offered_types(),
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
currency_codes: CURRENCY_CODES,
standalone_column_panel: false,
})
}
pub(crate) fn render_workspace(page: &TableDefinitionPageState) -> String {
render(&WorkspaceFragment {
page,
// Asking the draft, so the picker can only ever offer what the draft
// would accept — the accounting rule is stated once.
column_types: page.columns.offered_types(),
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
standalone_column_panel: false,
})
}
pub(crate) fn render_column_panel(page: &TableDefinitionPageState) -> String {
render(&ColumnPanelFragment {
page,
// Asking the draft, so the picker can only ever offer what the draft
// would accept — the accounting rule is stated once.
column_types: page.columns.offered_types(),
temporal_types: TEMPORAL_TYPES,
gtin_types: GTIN_TYPES,
standalone_column_panel: true,
})
}
/// Used when the workspace itself cannot be loaded. There is nothing left to
/// render, so the dialog is what tells the user why.
pub(crate) fn render_load_error(message: &str) -> String {
render(&Alert::error("Table definition unavailable", message))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
pages::admin::table_definition::state::{
CopyForm, DetailColumn, InvoiceTemplateForm, RenameForm, Selection, TableDetailView,
TableSummary,
},
schema::ColumnDraft,
};
fn table(name: &str, kind: &str) -> TableSummary {
TableSummary {
name: name.to_string(),
table_kind: kind.to_string(),
depends_on: Vec::new(),
row_display_columns: vec!["number".to_string()],
}
}
fn page() -> TableDefinitionPageState {
TableDefinitionPageState {
nav: Nav::default(),
profiles: vec!["billing".to_string(), "payroll".to_string()],
selection: Selection {
profile: "billing".to_string(),
table: "invoice".to_string(),
},
tables: vec![table("invoice", "dynamic"), table("accounts", "system")],
detail: Some(TableDetailView {
id: 7,
name: "invoice".to_string(),
table_kind: "dynamic".to_string(),
row_display_columns: vec!["number".to_string()],
scripts: Vec::new(),
columns: vec![DetailColumn {
name: "number".to_string(),
field_type: "text".to_string(),
currency: String::new(),
quantity_ledger: false,
rounded: false,
generated: false,
read_only: false,
generated_from: String::new(),
}],
}),
history: Vec::new(),
columns: ColumnDraft::for_append(),
rename: RenameForm::default(),
copy: CopyForm::default(),
invoice: InvoiceTemplateForm::default(),
status: None,
error: None,
sql: None,
generated: Vec::new(),
}
}
/// The point of the page: every write the service offers is reachable
/// from the one screen, for the one selection.
#[test]
fn the_workspace_offers_every_table_definition_write() {
let html = render_workspace(&page());
assert!(!html.contains("Template error"), "{html}");
for route in [
"/admin/table-definition/columns",
"/admin/table-definition/rename",
"/admin/table-definition/delete",
"/admin/table-definition/copy",
"/admin/table-definition/invoice-template",
] {
assert!(html.contains(route), "missing the {route} panel");
}
// And creating a table, which is the one write that has its own page.
assert!(html.contains("/admin/tables/new?profile=billing"));
}
/// The append panel posts the selection in its URL, so the column fields
/// themselves stay exactly the ones the Add-table builder posts.
#[test]
fn the_column_panel_carries_the_selection_and_the_staged_columns() {
let mut state = page();
state.columns.name_input = "issued_on".to_string();
state.columns.type_input = "temporal".to_string();
state.columns.added.push(crate::schema::ColumnDefinition {
name: "total".to_string(),
data_type: "money".to_string(),
indexed: true,
quantity_ledger: false,
money_mode: crate::schema::MoneyMode::Rounded,
currency: "EUR".to_string(),
});
let html = render_column_panel(&state);
assert!(!html.contains("Template error"), "{html}");
// Escaped, because it is an attribute: `&amp;` is what a browser reads
// back as the `&` separating the two parameters.
// Escaped, because it is an attribute: `&#38;` is what a browser reads
// back as the `&` separating the two parameters.
assert!(html.contains("?profile=billing&#38;table=invoice"));
assert!(html.contains(r#"name="column_names" value="total""#));
assert!(html.contains(r#"name="column_indexed" value="yes""#));
assert!(html.contains(r#"name="column_currencies" value="EUR""#));
// The pending type is temporal, so its subtype picker is showing.
assert!(html.contains(r#"name="temporal_type_input""#));
}
/// ACCOUNTING brings schema-managed companions with it, so the server only
/// accepts it while the table is created. The panel must not offer it.
#[test]
fn the_append_panel_never_offers_an_accounting_column() {
let html = render_column_panel(&page());
assert!(html.contains(r#"<option value="money""#));
assert!(!html.contains(r#"<option value="accounting""#));
}
/// 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 profile"));
}
#[test]
fn a_failure_is_shown_as_a_dialog_as_well_as_an_alert() {
let mut state = page();
assert!(!render_workspace(&state).contains(r#"role="dialog""#));
state.error = Some("That column already exists.".to_string());
let html = render_workspace(&state);
assert!(html.contains(r#"role="dialog""#));
// Once in the inline alert, once in the dialog — and not a third time
// from the column panel the workspace embeds.
assert_eq!(html.matches("That column already exists.").count(), 2);
}
/// Staging a column swaps the panel alone, so a refused column has to be
/// reported inside it or it is reported nowhere.
#[test]
fn a_refused_column_is_reported_in_the_panel_that_swapped() {
let mut state = page();
state.error = Some("Column name uses a reserved name.".to_string());
let html = render_column_panel(&state);
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains(r#"role="dialog""#));
assert_eq!(html.matches("Column name uses a reserved name.").count(), 2);
}
/// A successful write reports the DDL the backend ran, which is the only
/// place the user gets to see it.
#[test]
fn a_successful_write_shows_its_ddl() {
let mut state = page();
state.status = Some("1 column added to `invoice`.".to_string());
state.sql = Some("ALTER TABLE \"billing\".\"invoice\" ADD COLUMN …".to_string());
let html = render_workspace(&state);
assert!(html.contains("ALTER TABLE"));
assert!(html.contains("1 column added"));
}
#[test]
fn a_load_failure_answers_with_the_dialog() {
let html = render_load_error("The backend is unreachable.");
assert!(!html.contains("Template error"), "{html}");
assert!(html.contains(r#"role="dialog""#));
assert!(html.contains("The backend is unreachable."));
}
}