web interface for table_definition improved
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub(crate) mod admin;
|
||||
pub(crate) mod table_definition;
|
||||
|
||||
196
web/src/pages/admin/table_definition/loader.rs
Normal file
196
web/src/pages/admin/table_definition/loader.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
460
web/src/pages/admin/table_definition/logic.rs
Normal file
460
web/src/pages/admin/table_definition/logic.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
36
web/src/pages/admin/table_definition/mod.rs
Normal file
36
web/src/pages/admin/table_definition/mod.rs
Normal 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),
|
||||
)
|
||||
}
|
||||
346
web/src/pages/admin/table_definition/state.rs
Normal file
346
web/src/pages/admin/table_definition/state.rs
Normal 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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
306
web/src/pages/admin/table_definition/ui.rs
Normal file
306
web/src/pages/admin/table_definition/ui.rs
Normal 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: `&` is what a browser reads
|
||||
// back as the `&` separating the two parameters.
|
||||
// Escaped, because it is an attribute: `&` is what a browser reads
|
||||
// back as the `&` separating the two parameters.
|
||||
assert!(html.contains("?profile=billing&table=invoice"));
|
||||
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."));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user