graphs renamed to web
This commit is contained in:
62
web/src/pages/add_validation/loader.rs
Normal file
62
web/src/pages/add_validation/loader.rs
Normal 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),
|
||||
}
|
||||
142
web/src/pages/add_validation/logic.rs
Normal file
142
web/src/pages/add_validation/logic.rs
Normal 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")
|
||||
}
|
||||
27
web/src/pages/add_validation/mod.rs
Normal file
27
web/src/pages/add_validation/mod.rs
Normal 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))
|
||||
}
|
||||
273
web/src/pages/add_validation/state.rs
Normal file
273
web/src/pages/add_validation/state.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
79
web/src/pages/add_validation/ui.rs
Normal file
79
web/src/pages/add_validation/ui.rs
Normal 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 4,5 | one-of=-,+ 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))
|
||||
}
|
||||
Reference in New Issue
Block a user