validation1 for the form

This commit is contained in:
Priec
2025-09-12 21:25:49 +02:00
parent 9672b9949c
commit cec2361b00
10 changed files with 623 additions and 2 deletions

View File

@@ -8,7 +8,7 @@ license.workspace = true
anyhow = { workspace = true }
async-trait = "0.1.88"
common = { path = "../common" }
canvas = { path = "../canvas", features = ["gui", "suggestions", "cursor-style", "keymap"] }
canvas = { path = "../canvas", features = ["gui", "suggestions", "cursor-style", "keymap", "validation"] }
ratatui = { workspace = true }
crossterm = { workspace = true }
@@ -30,8 +30,9 @@ unicode-segmentation = "1.12.0"
unicode-width.workspace = true
[features]
default = []
default = ["validation"]
ui-debug = []
validation = []
[dev-dependencies]
rstest = "0.25.0"

View File

@@ -1,6 +1,10 @@
// src/pages/forms/state.rs
use canvas::{DataProvider, AppMode};
#[cfg(feature = "validation")]
use canvas::{CharacterLimits, ValidationConfig, ValidationConfigBuilder};
#[cfg(feature = "validation")]
use canvas::validation::limits::CountMode;
use common::proto::komp_ac::search::search_response::Hit;
use std::collections::HashMap;
@@ -39,6 +43,18 @@ pub struct FormState {
pub autocomplete_loading: bool,
pub link_display_map: HashMap<usize, String>,
pub app_mode: AppMode,
// Validation 1 (character limits) per field. None = no validation for that field.
// Leave room for future rules (patterns, masks, etc.).
pub char_limits: Vec<Option<CharLimitsRule>>,
}
#[cfg(feature = "validation")]
#[derive(Debug, Clone)]
pub struct CharLimitsRule {
pub min: Option<usize>,
pub max: Option<usize>,
pub warn_at: Option<usize>,
pub count_mode: CountMode,
}
impl FormState {
@@ -56,6 +72,7 @@ impl FormState {
fields: Vec<FieldDefinition>,
) -> Self {
let values = vec![String::new(); fields.len()];
let len = values.len();
FormState {
id: 0,
profile_name,
@@ -73,6 +90,7 @@ impl FormState {
autocomplete_loading: false,
link_display_map: HashMap::new(),
app_mode: canvas::AppMode::Edit,
char_limits: vec![None; len],
}
}
@@ -256,6 +274,24 @@ impl FormState {
pub fn set_current_cursor_pos(&mut self, pos: usize) {
self.current_cursor_pos = pos;
}
#[cfg(feature = "validation")]
pub fn set_character_limits_rules(
&mut self,
rules: Vec<Option<CharLimitsRule>>,
) {
if rules.len() == self.fields.len() {
self.char_limits = rules;
} else {
tracing::warn!(
"Character limits count {} != field count {} for {}.{}",
rules.len(),
self.fields.len(),
self.profile_name,
self.table_name
);
}
}
}
// Step 2: Implement DataProvider for FormState
@@ -282,4 +318,26 @@ impl DataProvider for FormState {
fn supports_suggestions(&self, field_index: usize) -> bool {
self.fields.get(field_index).map(|f| f.is_link).unwrap_or(false)
}
// Validation 1: Provide character-limit-based validation to canvas
// Only compiled when the "validation" feature is enabled on canvas.
#[cfg(feature = "validation")]
fn validation_config(&self, index: usize) -> Option<ValidationConfig> {
let rule = self.char_limits.get(index)?.as_ref()?;
let mut limits = match (rule.min, rule.max) {
(Some(min), Some(max)) => CharacterLimits::new_range(min, max),
(None, Some(max)) => CharacterLimits::new(max),
(Some(min), None) => CharacterLimits::new_range(min, usize::MAX),
(None, None) => CharacterLimits::new(usize::MAX),
};
limits = limits.with_count_mode(rule.count_mode);
if let Some(warn) = rule.warn_at {
limits = limits.with_warning_threshold(warn);
}
Some(
ValidationConfigBuilder::new()
.with_character_limits(limits)
.build(),
)
}
}

View File

@@ -26,6 +26,14 @@ use common::proto::komp_ac::tables_data::{
};
use crate::search::SearchGrpc;
use common::proto::komp_ac::search::SearchResponse;
use common::proto::komp_ac::table_validation::{
table_validation_service_client::TableValidationServiceClient,
GetTableValidationRequest,
TableValidationResponse,
CountMode as PbCountMode,
FieldValidation as PbFieldValidation,
CharacterLimits as PbCharacterLimits,
};
use anyhow::{Context, Result};
use std::collections::HashMap;
use tonic::transport::{Channel, Endpoint};
@@ -40,6 +48,7 @@ pub struct GrpcClient {
table_script_client: TableScriptClient<Channel>,
tables_data_client: TablesDataClient<Channel>,
search_client: SearchGrpc,
table_validation_client: TableValidationServiceClient<Channel>,
}
impl GrpcClient {
@@ -63,6 +72,8 @@ impl GrpcClient {
let table_script_client = TableScriptClient::new(channel.clone());
let tables_data_client = TablesDataClient::new(channel.clone());
let search_client = SearchGrpc::new(channel.clone());
let table_validation_client =
TableValidationServiceClient::new(channel.clone());
Ok(Self {
channel,
@@ -71,6 +82,7 @@ impl GrpcClient {
table_script_client,
tables_data_client,
search_client,
table_validation_client,
})
}
@@ -79,6 +91,24 @@ impl GrpcClient {
self.channel.clone()
}
// Fetch validation rules for a table. Absence of a field in response = no validation.
pub async fn get_table_validation(
&mut self,
profile_name: String,
table_name: String,
) -> Result<TableValidationResponse> {
let req = GetTableValidationRequest {
profile_name,
table_name,
};
let resp = self
.table_validation_client
.get_table_validation(tonic::Request::new(req))
.await
.context("gRPC GetTableValidation call failed")?;
Ok(resp.into_inner())
}
pub async fn get_table_structure(
&mut self,
profile_name: String,

View File

@@ -6,6 +6,8 @@ use crate::pages::admin_panel::add_logic::state::AddLogicState;
use crate::pages::forms::logic::SaveOutcome;
use crate::utils::columns::filter_user_columns;
use crate::pages::forms::{FieldDefinition, FormState};
use common::proto::komp_ac::table_validation::CountMode as PbCountMode;
use canvas::validation::limits::CountMode;
use anyhow::{anyhow, Context, Result};
use std::sync::Arc;
@@ -314,4 +316,60 @@ impl UiService {
}
Ok(())
}
/// Fetch and apply "Validation 1" (character limits) rules for this form.
pub async fn apply_validation1_for_form(
grpc_client: &mut GrpcClient,
app_state: &mut AppState,
path: &str,
) -> Result<()> {
let (profile, table) = path
.split_once('/')
.context("Invalid form path for validation")?;
let resp = grpc_client
.get_table_validation(profile.to_string(), table.to_string())
.await
.context("Failed to fetch table validation")?;
if let Some(fs) = app_state.form_state_for_path(path) {
let mut rules: Vec<Option<crate::pages::forms::state::CharLimitsRule>> =
vec![None; fs.fields.len()];
for f in resp.fields {
if let Some(idx) = fs.fields.iter().position(|fd| fd.data_key == f.data_key) {
if let Some(limits) = f.limits {
let has_any =
limits.min != 0 || limits.max != 0 || limits.warn_at.is_some();
if has_any {
let cm = match PbCountMode::from_i32(limits.count_mode) {
Some(PbCountMode::Unspecified) | None => CountMode::Characters, // protobuf default → fallback
Some(PbCountMode::Chars) => CountMode::Characters,
Some(PbCountMode::Bytes) => CountMode::Bytes,
Some(PbCountMode::DisplayWidth) => CountMode::DisplayWidth,
};
let min = if limits.min == 0 { None } else { Some(limits.min as usize) };
let max = if limits.max == 0 { None } else { Some(limits.max as usize) };
let warn_at = limits.warn_at.map(|w| w as usize);
rules[idx] = Some(crate::pages::forms::state::CharLimitsRule {
min,
max,
warn_at,
count_mode: cm,
});
}
}
}
}
fs.set_character_limits_rules(rules);
}
if let Some(editor) = app_state.editor_for_path(path) {
editor.set_validation_enabled(true);
}
Ok(())
}
}

View File

@@ -123,6 +123,10 @@ pub async fn run_ui() -> Result<()> {
app_state.ensure_form_editor(&path, &config, || {
FormState::new(initial_profile.clone(), initial_table.clone(), initial_field_defs)
});
#[cfg(feature = "validation")]
UiService::apply_validation1_for_form(&mut grpc_client, &mut app_state, &path)
.await
.ok();
buffer_state.update_history(AppView::Form(path.clone()));
router.navigate(Page::Form(path.clone()));
@@ -516,6 +520,21 @@ pub async fn run_ui() -> Result<()> {
prev_view_profile_name = current_view_profile;
prev_view_table_name = current_view_table;
table_just_switched = true;
// Apply character-limit validation for the new form
#[cfg(feature = "validation")]
if let (Some(prof), Some(tbl)) = (
app_state.current_view_profile_name.as_ref(),
app_state.current_view_table_name.as_ref(),
) {
let p = format!("{}/{}", prof, tbl);
UiService::apply_validation1_for_form(
&mut grpc_client,
&mut app_state,
&p,
)
.await
.ok();
}
}
Err(e) => {
app_state.update_dialog_content(