diff --git a/client/Cargo.toml b/client/Cargo.toml index 7196dd6..428b140 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -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" diff --git a/client/src/pages/forms/state.rs b/client/src/pages/forms/state.rs index 50498d5..e15676c 100644 --- a/client/src/pages/forms/state.rs +++ b/client/src/pages/forms/state.rs @@ -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, 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>, +} + +#[cfg(feature = "validation")] +#[derive(Debug, Clone)] +pub struct CharLimitsRule { + pub min: Option, + pub max: Option, + pub warn_at: Option, + pub count_mode: CountMode, } impl FormState { @@ -56,6 +72,7 @@ impl FormState { fields: Vec, ) -> 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>, + ) { + 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 { + 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(), + ) + } } diff --git a/client/src/services/grpc_client.rs b/client/src/services/grpc_client.rs index 21c823e..5d35e9a 100644 --- a/client/src/services/grpc_client.rs +++ b/client/src/services/grpc_client.rs @@ -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, tables_data_client: TablesDataClient, search_client: SearchGrpc, + table_validation_client: TableValidationServiceClient, } 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 { + 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, diff --git a/client/src/services/ui_service.rs b/client/src/services/ui_service.rs index 01127f8..582925c 100644 --- a/client/src/services/ui_service.rs +++ b/client/src/services/ui_service.rs @@ -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> = + 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(()) + } } diff --git a/client/src/ui/handlers/ui.rs b/client/src/ui/handlers/ui.rs index 99c115d..bdedbbc 100644 --- a/client/src/ui/handlers/ui.rs +++ b/client/src/ui/handlers/ui.rs @@ -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( diff --git a/common/build.rs b/common/build.rs index f65f5e1..e390ebf 100644 --- a/common/build.rs +++ b/common/build.rs @@ -16,6 +16,7 @@ fn main() -> Result<(), Box> { "proto/table_script.proto", "proto/search.proto", "proto/search2.proto", + "proto/table_validation.proto", ], &["proto"], )?; diff --git a/common/proto/table_validation.proto b/common/proto/table_validation.proto new file mode 100644 index 0000000..b4b6026 --- /dev/null +++ b/common/proto/table_validation.proto @@ -0,0 +1,56 @@ +// common/proto/table_validation.proto +syntax = "proto3"; +package komp_ac.table_validation; + +// Request validation rules for a table +message GetTableValidationRequest { + string profile_name = 1; + string table_name = 2; +} + +// Response with field-level validations; if a field is omitted, +// no validation is applied (default unspecified). +message TableValidationResponse { + repeated FieldValidation fields = 1; +} + +// Field-level validation (extensible for future kinds) +message FieldValidation { + // MUST match your frontend FormState.data_key for the column + string data_key = 1; + + // Current: only CharacterLimits. More rules can be added later. + CharacterLimits limits = 10; + // Future expansion: + // PatternRules pattern = 11; + // DisplayMask mask = 12; + // ExternalValidation external = 13; + // CustomFormatter formatter = 14; +} + +// Character length counting mode +enum CountMode { + COUNT_MODE_UNSPECIFIED = 0; // default: same as CHARS + CHARS = 1; + BYTES = 2; + DISPLAY_WIDTH = 3; +} + +// Character limit validation (Validation 1) +message CharacterLimits { + // When zero, the field is considered "not set". If both min/max are zero, + // the server should avoid sending this FieldValidation (no validation). + uint32 min = 1; + uint32 max = 2; + + // Optional warning threshold; when unset, no warning threshold is applied. + optional uint32 warn_at = 3; + + CountMode count_mode = 4; // defaults to CHARS if unspecified +} + +// Service to fetch validations for a table +service TableValidationService { + rpc GetTableValidation(GetTableValidationRequest) + returns (TableValidationResponse); +} diff --git a/common/src/lib.rs b/common/src/lib.rs index 8767dda..938155f 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -34,6 +34,9 @@ pub mod proto { pub mod search2 { include!("proto/komp_ac.search2.rs"); } + pub mod table_validation { + include!("proto/komp_ac.table_validation.rs"); + } pub const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!("proto/descriptor.bin"); } diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 4e0c537..23e9a23 100644 Binary files a/common/src/proto/descriptor.bin and b/common/src/proto/descriptor.bin differ diff --git a/common/src/proto/komp_ac.table_validation.rs b/common/src/proto/komp_ac.table_validation.rs new file mode 100644 index 0000000..c8a7a2c --- /dev/null +++ b/common/src/proto/komp_ac.table_validation.rs @@ -0,0 +1,395 @@ +// This file is @generated by prost-build. +/// Request validation rules for a table +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetTableValidationRequest { + #[prost(string, tag = "1")] + pub profile_name: ::prost::alloc::string::String, + #[prost(string, tag = "2")] + pub table_name: ::prost::alloc::string::String, +} +/// Response with field-level validations; if a field is omitted, +/// no validation is applied (default unspecified). +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct TableValidationResponse { + #[prost(message, repeated, tag = "1")] + pub fields: ::prost::alloc::vec::Vec, +} +/// Field-level validation (extensible for future kinds) +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct FieldValidation { + /// MUST match your frontend FormState.data_key for the column + #[prost(string, tag = "1")] + pub data_key: ::prost::alloc::string::String, + /// Current: only CharacterLimits. More rules can be added later. + /// + /// Future expansion: + /// PatternRules pattern = 11; + /// DisplayMask mask = 12; + /// ExternalValidation external = 13; + /// CustomFormatter formatter = 14; + #[prost(message, optional, tag = "10")] + pub limits: ::core::option::Option, +} +/// Character limit validation (Validation 1) +#[derive(Clone, Copy, PartialEq, ::prost::Message)] +pub struct CharacterLimits { + /// When zero, the field is considered "not set". If both min/max are zero, + /// the server should avoid sending this FieldValidation (no validation). + #[prost(uint32, tag = "1")] + pub min: u32, + #[prost(uint32, tag = "2")] + pub max: u32, + /// Optional warning threshold; when unset, no warning threshold is applied. + #[prost(uint32, optional, tag = "3")] + pub warn_at: ::core::option::Option, + /// defaults to CHARS if unspecified + #[prost(enumeration = "CountMode", tag = "4")] + pub count_mode: i32, +} +/// Character length counting mode +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum CountMode { + /// default: same as CHARS + Unspecified = 0, + Chars = 1, + Bytes = 2, + DisplayWidth = 3, +} +impl CountMode { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "COUNT_MODE_UNSPECIFIED", + Self::Chars => "CHARS", + Self::Bytes => "BYTES", + Self::DisplayWidth => "DISPLAY_WIDTH", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "COUNT_MODE_UNSPECIFIED" => Some(Self::Unspecified), + "CHARS" => Some(Self::Chars), + "BYTES" => Some(Self::Bytes), + "DISPLAY_WIDTH" => Some(Self::DisplayWidth), + _ => None, + } + } +} +/// Generated client implementations. +pub mod table_validation_service_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + /// Service to fetch validations for a table + #[derive(Debug, Clone)] + pub struct TableValidationServiceClient { + inner: tonic::client::Grpc, + } + impl TableValidationServiceClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl TableValidationServiceClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> TableValidationServiceClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + TableValidationServiceClient::new( + InterceptedService::new(inner, interceptor), + ) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + pub async fn get_table_validation( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic::codec::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/komp_ac.table_validation.TableValidationService/GetTableValidation", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "komp_ac.table_validation.TableValidationService", + "GetTableValidation", + ), + ); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod table_validation_service_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with TableValidationServiceServer. + #[async_trait] + pub trait TableValidationService: std::marker::Send + std::marker::Sync + 'static { + async fn get_table_validation( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + } + /// Service to fetch validations for a table + #[derive(Debug)] + pub struct TableValidationServiceServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl TableValidationServiceServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> + for TableValidationServiceServer + where + T: TableValidationService, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/komp_ac.table_validation.TableValidationService/GetTableValidation" => { + #[allow(non_camel_case_types)] + struct GetTableValidationSvc(pub Arc); + impl< + T: TableValidationService, + > tonic::server::UnaryService + for GetTableValidationSvc { + type Response = super::TableValidationResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_table_validation( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetTableValidationSvc(inner); + let codec = tonic::codec::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => { + Box::pin(async move { + let mut response = http::Response::new( + tonic::body::Body::default(), + ); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers + .insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }) + } + } + } + } + impl Clone for TableValidationServiceServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "komp_ac.table_validation.TableValidationService"; + impl tonic::server::NamedService for TableValidationServiceServer { + const NAME: &'static str = SERVICE_NAME; + } +}