graphs renamed to web
This commit is contained in:
52
web/src/pages/add_table/loader.rs
Normal file
52
web/src/pages/add_table/loader.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use axum::http::HeaderMap;
|
||||
|
||||
use crate::{
|
||||
AppState,
|
||||
auth::GetAuthorizationRequest,
|
||||
definitions::common::Empty,
|
||||
services::authenticated_request,
|
||||
};
|
||||
|
||||
use super::state::{AddTablePageState, CreateTableForm};
|
||||
|
||||
pub(crate) async fn load_page(
|
||||
state: AppState,
|
||||
headers: &HeaderMap,
|
||||
form: CreateTableForm,
|
||||
error: Option<String>,
|
||||
) -> Result<AddTablePageState, 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();
|
||||
Ok(AddTablePageState {
|
||||
profiles: tree.profiles.into_iter().map(|profile| profile.name).collect(),
|
||||
form,
|
||||
error,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) enum LoadError {
|
||||
Unauthenticated,
|
||||
Forbidden,
|
||||
Backend(String),
|
||||
}
|
||||
98
web/src/pages/add_table/logic.rs
Normal file
98
web/src/pages/add_table/logic.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use axum::{
|
||||
Form,
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
};
|
||||
|
||||
use crate::{AppState, services::authenticated_request};
|
||||
|
||||
use super::{
|
||||
loader::{LoadError, load_page},
|
||||
state::CreateTableForm,
|
||||
ui,
|
||||
};
|
||||
|
||||
pub(crate) async fn new_table_page(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
render_loaded(load_page(state, &headers, CreateTableForm::default(), None).await)
|
||||
}
|
||||
|
||||
pub(crate) async fn create_table(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<CreateTableForm>,
|
||||
) -> Response {
|
||||
let submitted_form = form.clone();
|
||||
if let Err(error) = load_page(state.clone(), &headers, form.clone(), None).await {
|
||||
return render_loaded(Err(error));
|
||||
}
|
||||
if headers
|
||||
.get("sec-fetch-site")
|
||||
.is_some_and(|value| value == "cross-site")
|
||||
{
|
||||
return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response();
|
||||
}
|
||||
let request = match form.into_request() {
|
||||
Ok(request) => request,
|
||||
Err(message) => {
|
||||
return render_loaded(
|
||||
load_page(state, &headers, submitted_form, Some(message)).await,
|
||||
);
|
||||
}
|
||||
};
|
||||
let profile_name = request.profile_name.clone();
|
||||
let mut definitions = state.definitions;
|
||||
let request = match authenticated_request(&headers, request) {
|
||||
Ok(request) => request,
|
||||
Err(_) => return Redirect::to("/login").into_response(),
|
||||
};
|
||||
match definitions.post_table_definition(request).await {
|
||||
Ok(response) if response.get_ref().success => {
|
||||
let location = format!("/admin?profile={profile_name}");
|
||||
let Ok(location) = HeaderValue::try_from(location) else {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid redirect").into_response();
|
||||
};
|
||||
let mut response = StatusCode::SEE_OTHER.into_response();
|
||||
response.headers_mut().insert(header::LOCATION, location.clone());
|
||||
response.headers_mut().insert("hx-redirect", location);
|
||||
response
|
||||
}
|
||||
Ok(response) => {
|
||||
let detail = if response.get_ref().sql.is_empty() {
|
||||
"The backend did not create the table.".to_string()
|
||||
} else {
|
||||
response.get_ref().sql.clone()
|
||||
};
|
||||
(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Html(ui::render_submission_error(&detail)),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
Err(error) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
Html(ui::render_submission_error(error.message())),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_loaded(result: Result<super::state::AddTablePageState, LoadError>) -> Response {
|
||||
match result {
|
||||
Ok(page) => Html(ui::render_page(&page)).into_response(),
|
||||
Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(),
|
||||
Err(LoadError::Forbidden) => (
|
||||
StatusCode::FORBIDDEN,
|
||||
Html(ui::render_submission_error("Administrator access is required.")),
|
||||
)
|
||||
.into_response(),
|
||||
Err(LoadError::Backend(message)) => (
|
||||
StatusCode::BAD_GATEWAY,
|
||||
Html(ui::render_submission_error(&message)),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
17
web/src/pages/add_table/mod.rs
Normal file
17
web/src/pages/add_table/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
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/tables/new", get(logic::new_table_page))
|
||||
.route("/admin/tables", post(logic::create_table))
|
||||
}
|
||||
163
web/src/pages/add_table/state.rs
Normal file
163
web/src/pages/add_table/state.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use crate::definitions::table_definition::{
|
||||
ColumnDefinition, MoneyRounding, PostTableDefinitionRequest, TableLink,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, serde::Deserialize)]
|
||||
pub(crate) struct CreateTableForm {
|
||||
#[serde(default)]
|
||||
pub profile_name: String,
|
||||
#[serde(default)]
|
||||
pub table_name: String,
|
||||
#[serde(default)]
|
||||
pub columns: String,
|
||||
#[serde(default)]
|
||||
pub indexed_columns: String,
|
||||
#[serde(default)]
|
||||
pub required_links: String,
|
||||
#[serde(default)]
|
||||
pub optional_links: String,
|
||||
#[serde(default)]
|
||||
pub base_currency: String,
|
||||
#[serde(default)]
|
||||
pub row_display_columns: String,
|
||||
}
|
||||
|
||||
pub(crate) struct AddTablePageState {
|
||||
pub profiles: Vec<String>,
|
||||
pub form: CreateTableForm,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl CreateTableForm {
|
||||
pub(crate) fn into_request(self) -> Result<PostTableDefinitionRequest, String> {
|
||||
let profile_name = self.profile_name.trim().to_string();
|
||||
let table_name = self.table_name.trim().to_string();
|
||||
if profile_name.is_empty() {
|
||||
return Err("Select a profile.".to_string());
|
||||
}
|
||||
if table_name.is_empty() {
|
||||
return Err("Enter a table name.".to_string());
|
||||
}
|
||||
|
||||
let indexed_columns = comma_separated(&self.indexed_columns);
|
||||
let mut columns = Vec::new();
|
||||
let mut inline_indexes = Vec::new();
|
||||
for (index, line) in self.columns.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut parts = line.splitn(3, ':');
|
||||
let name = parts.next().unwrap_or_default().trim();
|
||||
let field_type = parts.next().unwrap_or_default().trim();
|
||||
let flags = parts.next().unwrap_or_default();
|
||||
if name.is_empty() || field_type.is_empty() {
|
||||
return Err(format!(
|
||||
"Column line {} must use `name: type`.",
|
||||
index + 1
|
||||
));
|
||||
}
|
||||
let flags = flags
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|flag| !flag.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
if flags.contains(&"indexed") {
|
||||
inline_indexes.push(name.to_string());
|
||||
}
|
||||
let rounding = if flags.contains(&"half-up") {
|
||||
MoneyRounding::HalfUp
|
||||
} else {
|
||||
MoneyRounding::None
|
||||
};
|
||||
columns.push(ColumnDefinition {
|
||||
name: name.to_string(),
|
||||
field_type: field_type.to_string(),
|
||||
rounding: rounding.into(),
|
||||
quantity_ledger: flags.contains(&"quantity-ledger"),
|
||||
});
|
||||
}
|
||||
if columns.is_empty() {
|
||||
return Err("Add at least one column.".to_string());
|
||||
}
|
||||
|
||||
let mut indexes = indexed_columns;
|
||||
for name in inline_indexes {
|
||||
if !indexes.contains(&name) {
|
||||
indexes.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
let mut links = comma_separated(&self.required_links)
|
||||
.into_iter()
|
||||
.map(|linked_table_name| TableLink {
|
||||
linked_table_name,
|
||||
required: true,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
links.extend(
|
||||
comma_separated(&self.optional_links)
|
||||
.into_iter()
|
||||
.map(|linked_table_name| TableLink {
|
||||
linked_table_name,
|
||||
required: false,
|
||||
}),
|
||||
);
|
||||
|
||||
let has_money = columns.iter().any(|column| {
|
||||
column.field_type.eq_ignore_ascii_case("money")
|
||||
|| column.field_type.eq_ignore_ascii_case("accounting")
|
||||
});
|
||||
let base_currency = self.base_currency.trim().to_ascii_uppercase();
|
||||
if has_money && base_currency.is_empty() {
|
||||
return Err("A base currency is required when a MONEY column is used.".to_string());
|
||||
}
|
||||
|
||||
Ok(PostTableDefinitionRequest {
|
||||
accounting_currency: String::new(),
|
||||
table_name,
|
||||
links,
|
||||
columns,
|
||||
indexes,
|
||||
profile_name,
|
||||
base_currency: if has_money { base_currency } else { String::new() },
|
||||
row_display_columns: comma_separated(&self.row_display_columns),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn comma_separated(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_columns_indexes_links_and_money_options() {
|
||||
let request = CreateTableForm {
|
||||
profile_name: "accounting".into(),
|
||||
table_name: "invoice".into(),
|
||||
columns: "number: text:indexed\namount: money:half-up,quantity-ledger".into(),
|
||||
required_links: "customer".into(),
|
||||
base_currency: "eur".into(),
|
||||
row_display_columns: "number, amount".into(),
|
||||
..Default::default()
|
||||
}
|
||||
.into_request()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(request.indexes, vec!["number"]);
|
||||
assert_eq!(request.links[0].linked_table_name, "customer");
|
||||
assert!(request.links[0].required);
|
||||
assert_eq!(request.base_currency, "EUR");
|
||||
assert_eq!(request.row_display_columns, vec!["number", "amount"]);
|
||||
assert!(request.columns[1].quantity_ledger);
|
||||
}
|
||||
}
|
||||
40
web/src/pages/add_table/ui.rs
Normal file
40
web/src/pages/add_table/ui.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use super::state::AddTablePageState;
|
||||
|
||||
const ADMIN_CSS: &str = include_str!("../../../static/admin.css");
|
||||
|
||||
pub(crate) fn render_page(page: &AddTablePageState) -> String {
|
||||
let options = page
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|profile| {
|
||||
format!(
|
||||
"<option value=\"{}\" {}>{}</option>",
|
||||
crate::escape_html(profile),
|
||||
if page.form.profile_name == *profile { "selected" } else { "" },
|
||||
crate::escape_html(profile),
|
||||
)
|
||||
})
|
||||
.collect::<String>();
|
||||
let error = page
|
||||
.error
|
||||
.as_deref()
|
||||
.map(render_submission_error)
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>Add table</title><script src=\"https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js\"></script><style>{ADMIN_CSS}</style></head><body><header class=\"topbar\"><div><strong>Komp Accounting</strong></div><nav><a href=\"/admin\">Admin</a><a href=\"/\">Analytics</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Table definition</p><h1>Add table</h1><p>Create a table through the existing gRPC table-definition service.</p><form hx-post=\"/admin/tables\" hx-target=\"#submission-status\" hx-swap=\"innerHTML\" hx-disabled-elt=\"button[type=submit]\"><div class=\"form-grid\"><label>Profile<select name=\"profile_name\" required><option value=\"\">Choose a profile</option>{options}</select></label><label>Table name<input name=\"table_name\" value=\"{}\" required placeholder=\"invoices\"></label><label class=\"wide\">Columns<textarea name=\"columns\" rows=\"9\" required placeholder=\"number: text:indexed issued_on: date stock: int:quantity-ledger\">{}</textarea><small>One per line: <code>name: type: optional flags</code>. Flags: indexed, half-up, quantity-ledger.</small></label><label>Additional indexed columns<input name=\"indexed_columns\" value=\"{}\" placeholder=\"number, issued_on\"></label><label>Base currency<input name=\"base_currency\" value=\"{}\" maxlength=\"3\" placeholder=\"EUR\"></label><label>Required links<input name=\"required_links\" value=\"{}\" placeholder=\"customer, address\"></label><label>Optional links<input name=\"optional_links\" value=\"{}\" placeholder=\"project\"></label><label>Row display columns<input name=\"row_display_columns\" value=\"{}\" placeholder=\"name, ico\"></label></div><div id=\"submission-status\" aria-live=\"polite\">{error}</div><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Create table</button></div></form></section></main></body></html>",
|
||||
crate::escape_html(&page.form.table_name),
|
||||
crate::escape_html(&page.form.columns),
|
||||
crate::escape_html(&page.form.indexed_columns),
|
||||
crate::escape_html(&page.form.base_currency),
|
||||
crate::escape_html(&page.form.required_links),
|
||||
crate::escape_html(&page.form.optional_links),
|
||||
crate::escape_html(&page.form.row_display_columns),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_submission_error(message: &str) -> String {
|
||||
format!(
|
||||
"<div class=\"form-error\"><strong>Could not create the table</strong><p>{}</p></div>",
|
||||
crate::escape_html(message),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user