graphs renamed to web

This commit is contained in:
Priec
2026-08-03 12:23:50 +02:00
parent f77e23a986
commit 35d85de556
47 changed files with 19 additions and 19 deletions

View File

@@ -0,0 +1,62 @@
use axum::http::HeaderMap;
use crate::{
AppState,
auth::GetAuthorizationRequest,
definitions::common::Empty,
services::authenticated_request,
};
use super::state::{ValidationForm, ValidationPageState, ValidationSetForm};
pub(crate) async fn load_page(
state: AppState,
headers: &HeaderMap,
form: ValidationForm,
reusable_rule: bool,
error: Option<String>,
) -> Result<ValidationPageState, LoadError> {
let request = authenticated_request(headers, GetAuthorizationRequest {})
.map_err(|_| LoadError::Unauthenticated)?;
let mut auth = state.auth;
let authorization = auth
.get_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();
let tables = tree
.profiles
.iter()
.flat_map(|profile| profile.tables.iter().map(|table| table.name.clone()))
.collect();
Ok(ValidationPageState { profiles, tables, form, reusable_rule, error })
}
pub(crate) async fn load_set_page(
state: AppState,
headers: &HeaderMap,
form: ValidationSetForm,
error: Option<String>,
) -> Result<(ValidationSetForm, Option<String>), LoadError> {
let _ = load_page(state, headers, ValidationForm::default(), true, None).await?;
Ok((form, error))
}
pub(crate) enum LoadError {
Unauthenticated,
Forbidden,
Backend(String),
}

View File

@@ -0,0 +1,142 @@
use axum::{
Form,
extract::State,
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
};
use crate::{AppState, services::authenticated_request};
use super::{
loader::{LoadError, load_page, load_set_page},
state::{ValidationForm, ValidationSetForm},
ui,
};
pub(crate) async fn new_field_validation_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
render_loaded(load_page(state, &headers, ValidationForm::default(), false, None).await)
}
pub(crate) async fn new_validation_rule_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
render_loaded(load_page(state, &headers, ValidationForm::default(), true, None).await)
}
pub(crate) async fn new_validation_set_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
render_set_loaded(load_set_page(state, &headers, ValidationSetForm::default(), None).await)
}
pub(crate) async fn save_field_validation(
State(state): State<AppState>, headers: HeaderMap, Form(form): Form<ValidationForm>,
) -> Response {
let submitted = form.clone();
if let Err(error) = load_page(state.clone(), &headers, submitted.clone(), false, None).await {
return render_loaded(Err(error));
}
if cross_site(&headers) {
return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response();
}
if !form.validation_set_name.trim().is_empty() {
let request = match form.apply_set_request() {
Ok(request) => request,
Err(message) => return render_loaded(load_page(state, &headers, submitted, false, Some(message)).await),
};
let mut validations = state.validations;
let request = match authenticated_request(&headers, request) {
Ok(request) => request,
Err(_) => return Redirect::to("/login").into_response(),
};
return match validations.apply_validation_set(request).await {
Ok(response) if response.get_ref().success => Html(ui::render_success(&response.get_ref().message)).into_response(),
Ok(response) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(&response.get_ref().message))).into_response(),
Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(error.message()))).into_response(),
};
}
let request = match form.field_request() {
Ok(request) => request,
Err(message) => return render_loaded(load_page(state, &headers, submitted, false, Some(message)).await),
};
let mut validations = state.validations;
let request = match authenticated_request(&headers, request) {
Ok(request) => request,
Err(_) => return Redirect::to("/login").into_response(),
};
match validations.update_field_validation(request).await {
Ok(response) if response.get_ref().success => Html(ui::render_success(&response.get_ref().message)).into_response(),
Ok(response) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(&response.get_ref().message))).into_response(),
Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(error.message()))).into_response(),
}
}
pub(crate) async fn save_validation_rule(
State(state): State<AppState>, headers: HeaderMap, Form(form): Form<ValidationForm>,
) -> Response {
let submitted = form.clone();
if let Err(error) = load_page(state.clone(), &headers, submitted.clone(), true, None).await {
return render_loaded(Err(error));
}
if cross_site(&headers) {
return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response();
}
let request = match form.rule_request() {
Ok(request) => request,
Err(message) => return render_loaded(load_page(state, &headers, submitted, true, Some(message)).await),
};
let mut validations = state.validations;
let request = match authenticated_request(&headers, request) {
Ok(request) => request,
Err(_) => return Redirect::to("/login").into_response(),
};
match validations.upsert_validation_rule(request).await {
Ok(response) if response.get_ref().success => Html(ui::render_success(&response.get_ref().message)).into_response(),
Ok(response) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(&response.get_ref().message))).into_response(),
Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(error.message()))).into_response(),
}
}
pub(crate) async fn save_validation_set(
State(state): State<AppState>, headers: HeaderMap, Form(form): Form<ValidationSetForm>,
) -> Response {
let submitted = form.clone();
if let Err(error) = load_set_page(state.clone(), &headers, submitted.clone(), None).await {
return render_set_loaded(Err(error));
}
if cross_site(&headers) {
return (StatusCode::FORBIDDEN, "Cross-site form submission rejected").into_response();
}
let request = match form.request() {
Ok(request) => request,
Err(message) => return render_set_loaded(load_set_page(state, &headers, submitted, Some(message)).await),
};
let mut validations = state.validations;
let request = match authenticated_request(&headers, request) {
Ok(request) => request,
Err(_) => return Redirect::to("/login").into_response(),
};
match validations.upsert_validation_set(request).await {
Ok(response) if response.get_ref().success => Html(ui::render_success(&response.get_ref().message)).into_response(),
Ok(response) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(&response.get_ref().message))).into_response(),
Err(error) => (StatusCode::UNPROCESSABLE_ENTITY, Html(ui::render_error(error.message()))).into_response(),
}
}
fn render_set_loaded(result: Result<(ValidationSetForm, Option<String>), LoadError>) -> Response {
match result {
Ok((form, error)) => Html(ui::render_set_page(&form, error.as_deref())).into_response(),
Err(LoadError::Unauthenticated) => Redirect::to("/login").into_response(),
Err(LoadError::Forbidden) => (StatusCode::FORBIDDEN, Html(ui::render_error("Administrator access is required."))).into_response(),
Err(LoadError::Backend(message)) => (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response(),
}
}
fn render_loaded(result: Result<super::state::ValidationPageState, 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_error("Administrator access is required."))).into_response(),
Err(LoadError::Backend(message)) => (StatusCode::BAD_GATEWAY, Html(ui::render_error(&message))).into_response(),
}
}
fn cross_site(headers: &HeaderMap) -> bool {
headers.get("sec-fetch-site").is_some_and(|value| value == "cross-site")
}

View File

@@ -0,0 +1,27 @@
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/validation/new", get(logic::new_field_validation_page))
.route(
"/admin/validation/rules/new",
get(logic::new_validation_rule_page),
)
.route(
"/admin/validation/sets/new",
get(logic::new_validation_set_page),
)
.route("/admin/validation", post(logic::save_field_validation))
.route("/admin/validation/rules", post(logic::save_validation_rule))
.route("/admin/validation/sets", post(logic::save_validation_set))
}

View File

@@ -0,0 +1,273 @@
use crate::definitions::table_validation::{
AllowedValues, ApplyValidationSetRequest, CharacterConstraint, CharacterLimits, DisplayMask,
FieldValidation, PatternPosition, PatternRule, PatternRules, UpdateFieldValidationRequest,
UpsertValidationRuleRequest, UpsertValidationSetRequest, ValidationRuleDefinition,
ValidationSetDefinition, ValidationSetRuleItem, validation_set_rule_item,
};
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct ValidationForm {
#[serde(default)]
pub profile_name: String,
#[serde(default)]
pub table_name: String,
#[serde(default)]
pub data_key: String,
#[serde(default)]
pub rule_name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub minimum: String,
#[serde(default)]
pub maximum: String,
#[serde(default)]
pub warn_at: String,
#[serde(default)]
pub count_mode: String,
#[serde(default)]
pub allowed_values: String,
#[serde(default)]
pub allow_empty: bool,
#[serde(default)]
pub case_insensitive: bool,
#[serde(default)]
pub mask_pattern: String,
#[serde(default)]
pub mask_input_char: String,
#[serde(default)]
pub mask_template_char: String,
#[serde(default)]
pub external_validation_enabled: bool,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub locked: bool,
#[serde(default)]
pub pattern_rules: String,
#[serde(default)]
pub pattern_description: String,
#[serde(default)]
pub validation_set_name: String,
}
pub(crate) struct ValidationPageState {
pub profiles: Vec<String>,
pub tables: Vec<String>,
pub form: ValidationForm,
pub reusable_rule: bool,
pub error: Option<String>,
}
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub(crate) struct ValidationSetForm {
#[serde(default)]
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub global_rules: String,
}
impl ValidationSetForm {
pub(crate) fn request(&self) -> Result<UpsertValidationSetRequest, String> {
let name = required(&self.name, "set name")?;
let rules = comma_separated(&self.global_rules);
if rules.is_empty() {
return Err("Enter at least one reusable rule name.".to_string());
}
let rule_items = rules.into_iter().enumerate().map(|(position, rule_name)| ValidationSetRuleItem {
position: position as i32,
name: Some(rule_name.clone()),
description: None,
source: Some(validation_set_rule_item::Source::GlobalRuleName(rule_name)),
}).collect();
Ok(UpsertValidationSetRequest {
profile_name: String::new(),
set: Some(ValidationSetDefinition {
name,
description: nonempty(&self.description),
rule_items,
resolved_validation: None,
}),
})
}
}
impl ValidationForm {
pub(crate) fn field_request(&self) -> Result<UpdateFieldValidationRequest, String> {
let profile_name = required(&self.profile_name, "profile")?;
let table_name = required(&self.table_name, "table")?;
let data_key = required(&self.data_key, "column")?;
Ok(UpdateFieldValidationRequest {
profile_name,
table_name,
data_key: data_key.clone(),
validation: Some(self.validation(data_key)?),
})
}
pub(crate) fn rule_request(&self) -> Result<UpsertValidationRuleRequest, String> {
let name = required(&self.rule_name, "rule name")?;
Ok(UpsertValidationRuleRequest {
profile_name: String::new(),
rule: Some(ValidationRuleDefinition {
id: None,
name,
description: nonempty(&self.description),
validation: Some(self.validation(String::new())?),
}),
})
}
pub(crate) fn apply_set_request(&self) -> Result<ApplyValidationSetRequest, String> {
Ok(ApplyValidationSetRequest {
profile_name: required(&self.profile_name, "profile")?,
table_name: required(&self.table_name, "table")?,
data_key: required(&self.data_key, "column")?,
set_name: required(&self.validation_set_name, "validation set name")?,
})
}
fn validation(&self, data_key: String) -> Result<FieldValidation, String> {
let minimum = parse_u32(&self.minimum, "Minimum")?.unwrap_or(0);
let maximum = parse_u32(&self.maximum, "Maximum")?.unwrap_or(0);
if maximum > 0 && minimum > maximum {
return Err("Minimum cannot be greater than maximum.".to_string());
}
let warn_at = parse_u32(&self.warn_at, "Warning threshold")?;
let count_mode = match self.count_mode.as_str() {
"bytes" => 2,
"display-width" => 3,
_ => 1,
};
let limits = (minimum > 0 || maximum > 0 || warn_at.is_some()).then_some(CharacterLimits {
min: minimum,
max: maximum,
warn_at,
count_mode,
});
let allowed = comma_separated(&self.allowed_values);
let allowed_values = (!allowed.is_empty()).then_some(AllowedValues {
values: allowed,
allow_empty: self.allow_empty,
case_insensitive: self.case_insensitive,
});
let mask_pattern = self.mask_pattern.trim();
let mask = (!mask_pattern.is_empty()).then_some(DisplayMask {
pattern: mask_pattern.to_string(),
input_char: if self.mask_input_char.is_empty() {
"#".to_string()
} else {
self.mask_input_char.clone()
},
template_char: nonempty(&self.mask_template_char),
});
let pattern = parse_pattern_rules(&self.pattern_rules, &self.pattern_description)?;
Ok(FieldValidation {
data_key,
limits,
pattern,
allowed_values,
external_validation_enabled: self.external_validation_enabled,
mask,
required: self.required,
locked: self.locked,
})
}
}
fn required(value: &str, label: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() {
Err(format!("Enter a {label}."))
} else {
Ok(value.to_string())
}
}
fn parse_u32(value: &str, label: &str) -> Result<Option<u32>, String> {
let value = value.trim();
if value.is_empty() {
Ok(None)
} else {
value.parse().map(Some).map_err(|_| format!("{label} must be a non-negative integer."))
}
}
fn comma_separated(value: &str) -> Vec<String> {
value.split(',').map(str::trim).filter(|v| !v.is_empty()).map(str::to_string).collect()
}
fn nonempty(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
fn parse_pattern_rules(value: &str, description: &str) -> Result<Option<PatternRules>, String> {
let mut rules = Vec::new();
for (index, line) in value.lines().enumerate() {
let line = line.trim();
if line.is_empty() { continue; }
let Some((position, constraint)) = line.split_once('|') else {
return Err(format!("Pattern line {} must use `position | constraint`.", index + 1));
};
rules.push(PatternRule {
position: Some(parse_position(position.trim())?),
constraint: Some(parse_constraint(constraint.trim())?),
});
}
Ok((!rules.is_empty()).then_some(PatternRules { rules, description: nonempty(description) }))
}
fn parse_position(value: &str) -> Result<PatternPosition, String> {
if let Some(start) = value.strip_suffix('+') {
return Ok(PatternPosition { kind: 3, start: parse_position_number(start)?, ..Default::default() });
}
if value.contains(',') {
let positions = value.split(',').map(parse_position_number).collect::<Result<Vec<_>, _>>()?;
return Ok(PatternPosition { kind: 4, positions, ..Default::default() });
}
if let Some((start, end)) = value.split_once('-') {
let start = parse_position_number(start)?;
let end = parse_position_number(end)?;
if start > end { return Err("Pattern range start cannot exceed its end.".to_string()); }
return Ok(PatternPosition { kind: 2, start, end, ..Default::default() });
}
Ok(PatternPosition { kind: 1, single: parse_position_number(value)?, ..Default::default() })
}
fn parse_position_number(value: &str) -> Result<u32, String> {
value.trim().parse().map_err(|_| format!("Invalid pattern position '{}'.", value.trim()))
}
fn parse_constraint(value: &str) -> Result<CharacterConstraint, String> {
let normalized = value.to_ascii_lowercase();
match normalized.as_str() {
"alphabetic" => Ok(CharacterConstraint { kind: 1, ..Default::default() }),
"numeric" => Ok(CharacterConstraint { kind: 2, ..Default::default() }),
"alphanumeric" => Ok(CharacterConstraint { kind: 3, ..Default::default() }),
_ if normalized.starts_with("exact=") => Ok(CharacterConstraint { kind: 4, exact: Some(value[6..].to_string()), ..Default::default() }),
_ if normalized.starts_with("one-of=") => Ok(CharacterConstraint {
kind: 5,
one_of: value[7..].split(',').map(str::trim).filter(|item| !item.is_empty()).map(str::to_string).collect(),
..Default::default()
}),
_ if normalized.starts_with("regex=") => Ok(CharacterConstraint { kind: 6, regex: Some(value[6..].to_string()), ..Default::default() }),
_ => Err(format!("Unknown character constraint '{value}'.")),
}
}
#[cfg(test)]
mod pattern_tests {
use super::*;
#[test]
fn parses_position_and_constraint_rules() {
let rules = parse_pattern_rules("0-3 | alphabetic\n4,5 | one-of=-,+\n6+ | regex=[0-9]", "code").unwrap().unwrap();
assert_eq!(rules.rules.len(), 3);
assert_eq!(rules.rules[0].position.as_ref().unwrap().kind, 2);
assert_eq!(rules.rules[1].constraint.as_ref().unwrap().kind, 5);
assert_eq!(rules.rules[2].position.as_ref().unwrap().kind, 3);
}
}

View File

@@ -0,0 +1,79 @@
use super::state::{ValidationPageState, ValidationSetForm};
const ADMIN_CSS: &str = include_str!("../../../static/admin.css");
pub(crate) fn render_page(page: &ValidationPageState) -> String {
let profiles = datalist("profiles", &page.profiles);
let tables = datalist("tables", &page.tables);
let error = page.error.as_deref().map(render_error).unwrap_or_default();
let (title, action, mut identity) = if page.reusable_rule {
(
"Add reusable validation rule",
"/admin/validation/rules",
format!("<label>Rule name<input name=\"rule_name\" value=\"{}\" required></label><label>Description<input name=\"description\" value=\"{}\"></label>", crate::escape_html(&page.form.rule_name), crate::escape_html(&page.form.description)),
)
} else {
(
"Add field validation",
"/admin/validation",
format!("<label>Table<input name=\"table_name\" list=\"tables\" value=\"{}\" required></label><label>Column<input name=\"data_key\" value=\"{}\" required></label>", crate::escape_html(&page.form.table_name), crate::escape_html(&page.form.data_key)),
)
};
let profile_field = if page.reusable_rule {
let set_field = if page.reusable_rule {
String::new()
} else {
format!("<label class=\"wide\">Apply named validation set instead<input name=\"validation_set_name\" value=\"{}\" placeholder=\"Leave empty to save the rules below\"></label>", crate::escape_html(&page.form.validation_set_name))
};
identity.push_str(&format!(
"{set_field}<label class=\"wide\">Position rules<textarea name=\"pattern_rules\" rows=\"6\" placeholder=\"0-3 | alphabetic&#10;4,5 | one-of=-,+&#10;6+ | regex=[0-9]\">{}</textarea><small>One per line: position | constraint. Positions: 0, 0-3, 4+, or 1,3,5. Constraints: alphabetic, numeric, alphanumeric, exact=x, one-of=a,b, regex=...</small></label><label class=\"wide\">Pattern description<input name=\"pattern_description\" value=\"{}\"></label>",
crate::escape_html(&page.form.pattern_rules),
crate::escape_html(&page.form.pattern_description),
));
String::new()
} else {
format!("<label>Profile<input name=\"profile_name\" list=\"profiles\" value=\"{}\" required></label>", crate::escape_html(&page.form.profile_name))
};
format!(
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>{title}</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\">Validation</p><h1>{title}</h1><form hx-post=\"{action}\" hx-target=\"#submission-status\" hx-swap=\"innerHTML\"><div class=\"form-grid\">{profile_field}{identity}<label>Minimum length<input name=\"minimum\" type=\"number\" min=\"0\" value=\"{}\"></label><label>Maximum length<input name=\"maximum\" type=\"number\" min=\"0\" value=\"{}\"></label><label>Warning threshold<input name=\"warn_at\" type=\"number\" min=\"0\" value=\"{}\"></label><label>Count mode<select name=\"count_mode\"><option value=\"chars\">Characters</option><option value=\"bytes\">Bytes</option><option value=\"display-width\">Display width</option></select></label><label class=\"wide\">Allowed values<input name=\"allowed_values\" value=\"{}\" placeholder=\"draft, issued, paid\"></label><label>Display mask<input name=\"mask_pattern\" value=\"{}\" placeholder=\"####-##-##\"></label><label>Mask input character<input name=\"mask_input_char\" value=\"{}\" placeholder=\"#\"></label><label>Mask template character<input name=\"mask_template_char\" value=\"{}\" placeholder=\"_\"></label><div class=\"check-group\">{}{}{}{}{} </div></div>{profiles}{tables}<div id=\"submission-status\" aria-live=\"polite\">{error}</div><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Save validation</button></div></form></section></main></body></html>",
crate::escape_html(&page.form.minimum),
crate::escape_html(&page.form.maximum),
crate::escape_html(&page.form.warn_at),
crate::escape_html(&page.form.allowed_values),
crate::escape_html(&page.form.mask_pattern),
crate::escape_html(&page.form.mask_input_char),
crate::escape_html(&page.form.mask_template_char),
checkbox("required", "Required", page.form.required),
checkbox("allow_empty", "Allow empty", page.form.allow_empty),
checkbox("case_insensitive", "Case insensitive", page.form.case_insensitive),
checkbox("external_validation_enabled", "External validation", page.form.external_validation_enabled),
checkbox("locked", "Lock configuration", page.form.locked),
)
}
pub(crate) fn render_set_page(form: &ValidationSetForm, error: Option<&str>) -> String {
let error = error.map(render_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 validation set</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=\"/admin/validation/rules/new\">New reusable rule</a></nav></header><main class=\"form-main\"><a class=\"back-link\" href=\"/admin\">← Admin panel</a><section class=\"form-card\"><p class=\"eyebrow\">Global validation library</p><h1>Add validation set</h1><p>Compose an ordered set from existing reusable rule names.</p><form hx-post=\"/admin/validation/sets\" hx-target=\"#submission-status\" hx-swap=\"innerHTML\"><div class=\"form-grid\"><label>Set name<input name=\"name\" value=\"{}\" required></label><label>Description<input name=\"description\" value=\"{}\"></label><label class=\"wide\">Reusable rules<input name=\"global_rules\" value=\"{}\" required placeholder=\"required, invoice-number, max-length\"><small>Comma-separated and applied in this order.</small></label></div><div id=\"submission-status\">{error}</div><div class=\"form-actions\"><a href=\"/admin\">Cancel</a><button type=\"submit\">Save set</button></div></form></section></main></body></html>",
crate::escape_html(&form.name),
crate::escape_html(&form.description),
crate::escape_html(&form.global_rules),
)
}
fn datalist(id: &str, values: &[String]) -> String {
let options = values.iter().map(|value| format!("<option value=\"{}\"></option>", crate::escape_html(value))).collect::<String>();
format!("<datalist id=\"{id}\">{options}</datalist>")
}
fn checkbox(name: &str, label: &str, checked: bool) -> String {
format!("<label class=\"check\"><input type=\"checkbox\" name=\"{name}\" value=\"true\" {}>{label}</label>", if checked { "checked" } else { "" })
}
pub(crate) fn render_error(message: &str) -> String {
format!("<div class=\"form-error\"><strong>Could not save validation</strong><p>{}</p></div>", crate::escape_html(message))
}
pub(crate) fn render_success(message: &str) -> String {
format!("<div class=\"form-success\"><strong>Validation saved</strong><p>{}</p></div>", crate::escape_html(message))
}