schema validation

Orual da465d03 9538a97b

+655 -38
+1 -1
crates/weaver-app/Cargo.toml
··· 30 30 weaver-renderer = { path = "../weaver-renderer" } 31 31 mini-moka = { git = "https://github.com/moka-rs/mini-moka", rev = "da864e849f5d034f32e02197fee9bb5d5af36d3d" } 32 32 n0-future = { workspace = true } 33 - dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false } 33 + dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false, features = ["router"] } 34 34 axum = {version = "0.8.6", optional = true} 35 35 mime-sniffer = {version = "^0.1"} 36 36 chrono = { version = "0.4" }
+37
crates/weaver-app/assets/styling/record-view.css
··· 84 84 word-break: break-all; 85 85 } 86 86 87 + .schema-valid { 88 + color: var(--color-success); 89 + } 90 + .schema-invalid { 91 + color: var(--color-warning); 92 + } 93 + 87 94 .uri-link { 88 95 text-decoration: none; 89 96 } ··· 206 213 border-left: 2px solid var(--color-secondary); 207 214 border-bottom: 1px dashed var(--color-subtle); 208 215 z-index: 1; 216 + } 217 + 218 + .record-field.field-error { 219 + border-left-color: var(--color-error); 220 + background-color: rgba(255, 107, 107, 0.05); 221 + } 222 + 223 + .field-error-message { 224 + font-family: var(--font-mono); 225 + font-size: 0.85rem; 226 + color: var(--color-error); 227 + padding: 0.25rem 0; 228 + border-left: 2px solid var(--color-error); 229 + border-bottom: 1px dashed var(--color-error); 230 + padding-left: 0.5rem; 231 + word-wrap: break-word; 232 + margin-left: -1px; 233 + word-break: break-word; 234 + overflow-wrap: break-word; 209 235 } 210 236 211 237 .field-label { ··· 464 490 font-family: var(--font-mono); 465 491 font-size: 0.9rem; 466 492 padding: 1rem; 493 + margin-top: 1.5rem; 467 494 background: var(--color-surface, rgba(0, 0, 0, 0.2)); 468 495 border: 1px solid var(--color-border); 469 496 color: var(--color-text); ··· 478 505 479 506 .validation-panel { 480 507 flex: 0 0 300px; 508 + max-width: 400px; 481 509 font-family: var(--font-mono); 482 510 font-size: 0.85rem; 483 511 padding: 1rem; 484 512 background: var(--color-surface, rgba(0, 0, 0, 0.2)); 485 513 border: 1px solid var(--color-border); 486 514 overflow-y: auto; 515 + overflow-x: hidden; 516 + margin-top: 1.5rem; 487 517 align-self: flex-start; 488 518 } 489 519 ··· 510 540 border-left: 2px solid var(--color-error); 511 541 padding-left: 0.5rem; 512 542 margin: 0.25rem 0; 543 + word-wrap: break-word; 544 + word-break: break-word; 545 + overflow-wrap: break-word; 546 + } 547 + 548 + .validation-warning { 549 + color: var(--color-warning, #ffaa00); 513 550 } 514 551 515 552 /* Array Section Styling */
+69
crates/weaver-app/src/components/accordion/component.rs
··· 1 + use dioxus::prelude::*; 2 + use dioxus_primitives::accordion::{ 3 + self, AccordionContentProps, AccordionItemProps, AccordionProps, AccordionTriggerProps, 4 + }; 5 + 6 + #[component] 7 + pub fn Accordion(props: AccordionProps) -> Element { 8 + rsx! { 9 + document::Link { rel: "stylesheet", href: asset!("./style.css") } 10 + accordion::Accordion { 11 + class: "accordion", 12 + width: "15rem", 13 + id: props.id, 14 + allow_multiple_open: props.allow_multiple_open, 15 + disabled: props.disabled, 16 + collapsible: props.collapsible, 17 + horizontal: props.horizontal, 18 + attributes: props.attributes, 19 + {props.children} 20 + } 21 + } 22 + } 23 + 24 + #[component] 25 + pub fn AccordionItem(props: AccordionItemProps) -> Element { 26 + rsx! { 27 + accordion::AccordionItem { 28 + class: "accordion-item", 29 + disabled: props.disabled, 30 + default_open: props.default_open, 31 + on_change: props.on_change, 32 + on_trigger_click: props.on_trigger_click, 33 + index: props.index, 34 + attributes: props.attributes, 35 + {props.children} 36 + } 37 + } 38 + } 39 + 40 + #[component] 41 + pub fn AccordionTrigger(props: AccordionTriggerProps) -> Element { 42 + rsx! { 43 + accordion::AccordionTrigger { 44 + class: "accordion-trigger", 45 + id: props.id, 46 + attributes: props.attributes, 47 + {props.children} 48 + svg { 49 + class: "accordion-expand-icon", 50 + view_box: "0 0 24 24", 51 + xmlns: "http://www.w3.org/2000/svg", 52 + polyline { points: "6 9 12 15 18 9" } 53 + } 54 + } 55 + } 56 + } 57 + 58 + #[component] 59 + pub fn AccordionContent(props: AccordionContentProps) -> Element { 60 + rsx! { 61 + accordion::AccordionContent { 62 + class: "accordion-content", 63 + style: "--collapsible-content-width: 140px", 64 + id: props.id, 65 + attributes: props.attributes, 66 + {props.children} 67 + } 68 + } 69 + }
+2
crates/weaver-app/src/components/accordion/mod.rs
··· 1 + mod component; 2 + pub use component::*;
+89
crates/weaver-app/src/components/accordion/style.css
··· 1 + .accordion-trigger { 2 + display: flex; 3 + width: 100%; 4 + box-sizing: border-box; 5 + flex-direction: row; 6 + align-items: center; 7 + justify-content: space-between; 8 + padding: 0; 9 + padding-top: 1rem; 10 + padding-bottom: 1rem; 11 + border: none; 12 + background-color: transparent; 13 + color: var(--secondary-color-4); 14 + outline: none; 15 + text-align: left; 16 + } 17 + 18 + .accordion-trigger:focus-visible { 19 + border: none; 20 + box-shadow: inset 0 0 0 2px var(--focused-border-color); 21 + } 22 + 23 + .accordion-trigger:hover { 24 + cursor: pointer; 25 + text-decoration-line: underline; 26 + } 27 + 28 + .accordion-content { 29 + display: grid; 30 + height: 0; 31 + } 32 + 33 + .accordion-content[data-open="false"] { 34 + animation: accordion-slide-down 300ms cubic-bezier(0.87, 0, 0.13, 1) forwards; 35 + } 36 + 37 + .accordion-content[data-open="true"] { 38 + animation: accordion-slide-up 300ms cubic-bezier(0.87, 0, 0.13, 1) forwards; 39 + } 40 + 41 + @keyframes accordion-slide-down { 42 + from { 43 + height: var(--collapsible-content-width); 44 + } 45 + 46 + to { 47 + height: 0; 48 + } 49 + } 50 + 51 + @keyframes accordion-slide-up { 52 + from { 53 + height: 0; 54 + } 55 + 56 + to { 57 + height: var(--collapsible-content-width); 58 + } 59 + } 60 + 61 + .accordion-item { 62 + overflow: hidden; 63 + box-sizing: border-box; 64 + border-bottom: 1px solid var(--primary-color-6); 65 + margin-top: 1px; 66 + } 67 + 68 + .accordion-item:first-child { 69 + margin-top: 0; 70 + } 71 + 72 + .accordion-item:last-child { 73 + border-bottom: none; 74 + } 75 + 76 + .accordion-expand-icon { 77 + width: 20px; 78 + height: 20px; 79 + fill: none; 80 + stroke: var(--secondary-color-4); 81 + stroke-linecap: round; 82 + stroke-linejoin: round; 83 + stroke-width: 2; 84 + transition: rotate 150ms cubic-bezier(0.4, 0, 0.2, 1); 85 + } 86 + 87 + .accordion-item[data-open="true"] .accordion-expand-icon { 88 + rotate: 180deg; 89 + }
+1
crates/weaver-app/src/components/mod.rs
··· 124 124 pub mod input; 125 125 pub mod dialog; 126 126 pub mod button; 127 + pub mod accordion;
+456 -37
crates/weaver-app/src/views/record.rs
··· 16 16 types::{aturi::AtUri, cid::Cid, ident::AtIdentifier, string::Nsid}, 17 17 }; 18 18 use jacquard_lexicon::lexicon::LexiconDoc; 19 + use jacquard_lexicon::validation::{ 20 + ConstraintError, StructuralError, ValidationError, ValidationPath, ValidationResult, 21 + }; 19 22 use mime_sniffer::MimeTypeSniffer; 20 23 use weaver_api::com_atproto::repo::{ 21 24 create_record::CreateRecord, delete_record::DeleteRecord, put_record::PutRecord, ··· 97 100 let validator = jacquard_lexicon::validation::SchemaValidator::global(); 98 101 let main_type = record.value.type_discriminator(); 99 102 let mut main_schema = None; 103 + let mut resolved = std::collections::HashSet::new(); 104 + 105 + // Helper to recursively resolve a schema and its refs 106 + fn resolve_schema_with_refs<'a>( 107 + fetcher: &'a CachedFetcher, 108 + type_str: &'a str, 109 + validator: &'a jacquard_lexicon::validation::SchemaValidator, 110 + resolved: &'a mut std::collections::HashSet<String>, 111 + ) -> std::pin::Pin< 112 + Box<dyn std::future::Future<Output = Option<LexiconDoc<'static>>> + 'a>, 113 + > { 114 + Box::pin(async move { 115 + if resolved.contains(type_str) { 116 + return None; 117 + } 118 + resolved.insert(type_str.to_string()); 119 + 120 + let mut split = type_str.split('#'); 121 + let nsid_str = split.next().unwrap_or_default(); 122 + let nsid = Nsid::new(nsid_str).ok()?; 123 + 124 + let schema = fetcher.resolve_lexicon_schema(&nsid).await.ok()?; 125 + 126 + // Register by base NSID only (validator handles fragment lookup) 127 + validator 128 + .registry() 129 + .insert(nsid_str.to_smolstr(), schema.doc.clone()); 130 + 131 + // Find refs in the schema and resolve them 132 + if let Ok(schema_data) = to_data(&schema.doc) { 133 + for ref_val in schema_data.query("..ref").values() { 134 + if let Some(ref_str) = ref_val.as_str() { 135 + if ref_str.contains('.') { 136 + resolve_schema_with_refs(fetcher, ref_str, validator, resolved) 137 + .await; 138 + } 139 + } 140 + } 141 + } 142 + 143 + Some(schema.doc) 144 + }) 145 + } 100 146 101 147 // Find and resolve all schemas (including main and nested) 102 148 for type_val in record.value.query("...$type").values() { ··· 106 152 continue; 107 153 } 108 154 109 - if let Ok(nsid) = Nsid::new(type_str) { 110 - // Fetch and register schema 111 - if let Ok(schema) = fetcher.resolve_lexicon_schema(&nsid).await { 112 - validator 113 - .registry() 114 - .insert(nsid.to_smolstr(), schema.doc.clone()); 115 - 116 - // Keep the main record schema 117 - if Some(type_str) == main_type { 118 - main_schema = Some(schema.doc); 119 - } 155 + if let Some(schema) = 156 + resolve_schema_with_refs(&fetcher, type_str, &validator, &mut resolved) 157 + .await 158 + { 159 + // Keep the main record schema 160 + if Some(type_str) == main_type { 161 + main_schema = Some(schema); 120 162 } 121 163 } 122 164 } ··· 154 196 RecordViewLayout { 155 197 uri: uri().clone(), 156 198 cid: record.cid.clone(), 199 + schema: schema_signal, 200 + record_value: record_value.clone(), 157 201 if edit_mode() { 158 202 159 203 EditableRecordContent { ··· 194 238 class: "tab-content", 195 239 match view_mode() { 196 240 ViewMode::Pretty => rsx! { 197 - PrettyRecordView { record: record_value, uri: uri().clone() } 241 + PrettyRecordView { record: record_value, uri: uri().clone(), schema: schema_signal } 198 242 }, 199 243 ViewMode::Json => { 200 244 let json = use_memo(use_reactive!(|record| serde_json::to_string_pretty( ··· 223 267 } 224 268 225 269 #[component] 226 - fn PrettyRecordView(record: Data<'static>, uri: AtUri<'static>) -> Element { 270 + fn PrettyRecordView( 271 + record: Data<'static>, 272 + uri: AtUri<'static>, 273 + schema: ReadSignal<Option<LexiconDoc<'static>>>, 274 + ) -> Element { 227 275 let did = uri.authority().to_string(); 276 + let root_data = use_signal(|| record.clone()); 277 + 278 + // Validate the record and provide via context - only after schema is loaded 279 + let mut validation_result = use_signal(|| None); 280 + use_effect(move || { 281 + // Wait for schema to be loaded 282 + if schema().is_some() { 283 + if let Some(nsid_str) = root_data.read().type_discriminator() { 284 + let validator = jacquard_lexicon::validation::SchemaValidator::global(); 285 + let result = validator.validate_by_nsid(nsid_str, &*root_data.read()); 286 + validation_result.set(Some(result)); 287 + } 288 + } 289 + }); 290 + use_context_provider(|| validation_result); 291 + 228 292 rsx! { 229 293 div { 230 294 class: "pretty-record", 231 - DataView { data: record, path: String::new(), did } 295 + DataView { data: record, root_data, path: String::new(), did } 232 296 } 233 297 } 234 298 } ··· 240 304 let schema_data = use_memo(move || to_data(&schema_doc).ok().map(|d| d.into_static())); 241 305 242 306 if let Some(data) = schema_data() { 307 + let root_data = use_signal(|| data.clone()); 243 308 rsx! { 244 309 div { 245 310 class: "pretty-record", 246 - DataView { data: data, path: String::new(), did: String::new() } 311 + DataView { data: data, root_data, path: String::new(), did: String::new() } 247 312 } 248 313 } 249 314 } else { ··· 304 369 } 305 370 } 306 371 372 + /// Get expected string format from schema by navigating path 373 + fn get_expected_string_format( 374 + root_data: &Data<'_>, 375 + current_path: &str, 376 + ) -> Option<jacquard_lexicon::lexicon::LexStringFormat> { 377 + use jacquard_lexicon::lexicon::*; 378 + 379 + // Get root type discriminator 380 + let root_nsid = root_data.type_discriminator()?; 381 + 382 + // Look up schema in global registry 383 + let validator = jacquard_lexicon::validation::SchemaValidator::global(); 384 + let registry = validator.registry(); 385 + let schema = registry.get(root_nsid)?; 386 + 387 + // Navigate to the property at this path 388 + let segments: Vec<&str> = if current_path.is_empty() { 389 + vec![] 390 + } else { 391 + current_path.split('.').collect() 392 + }; 393 + 394 + // Start with the record's main object definition 395 + let main_obj = match schema.defs.get("main")? { 396 + LexUserType::Record(rec) => match &rec.record { 397 + LexRecordRecord::Object(obj) => obj, 398 + }, 399 + _ => return None, 400 + }; 401 + 402 + // Track current position in schema 403 + enum SchemaType<'a> { 404 + ObjectProp(&'a LexObjectProperty<'a>), 405 + ArrayItem(&'a LexArrayItem<'a>), 406 + } 407 + 408 + let mut current_type: Option<SchemaType> = None; 409 + let mut current_obj = Some(main_obj); 410 + 411 + for segment in segments { 412 + // Handle array indices - strip them to get field name 413 + let field_name = segment.trim_end_matches(|c: char| c.is_numeric() || c == '[' || c == ']'); 414 + 415 + if field_name.is_empty() { 416 + continue; // Pure array index like [0], skip 417 + } 418 + 419 + if let Some(obj) = current_obj.take() { 420 + if let Some(prop) = obj.properties.get(field_name) { 421 + current_type = Some(SchemaType::ObjectProp(prop)); 422 + } 423 + } 424 + 425 + // Process current type 426 + match current_type { 427 + Some(SchemaType::ObjectProp(LexObjectProperty::Array(arr))) => { 428 + // Array - unwrap to item type 429 + current_type = Some(SchemaType::ArrayItem(&arr.items)); 430 + } 431 + Some(SchemaType::ObjectProp(LexObjectProperty::Object(obj))) => { 432 + // Nested object - descend into it 433 + current_obj = Some(obj); 434 + current_type = None; 435 + } 436 + Some(SchemaType::ArrayItem(LexArrayItem::Object(obj))) => { 437 + // Array of objects - descend into object 438 + current_obj = Some(obj); 439 + current_type = None; 440 + } 441 + _ => {} 442 + } 443 + } 444 + 445 + // Check if final type is a string with format 446 + match current_type? { 447 + SchemaType::ObjectProp(LexObjectProperty::String(lex_string)) => lex_string.format, 448 + SchemaType::ArrayItem(LexArrayItem::String(lex_string)) => lex_string.format, 449 + _ => None, 450 + } 451 + } 452 + 307 453 #[component] 308 - fn DataView(data: Data<'static>, path: String, did: String) -> Element { 454 + fn DataView( 455 + data: Data<'static>, 456 + root_data: ReadSignal<Data<'static>>, 457 + path: String, 458 + did: String, 459 + ) -> Element { 460 + // Try to get validation result from context and get errors exactly at this path 461 + let validation_result = try_use_context::<Signal<Option<ValidationResult>>>(); 462 + 463 + let errors = if let Some(vr_signal) = validation_result { 464 + get_errors_at_exact_path(&*vr_signal.read(), &path) 465 + } else { 466 + Vec::new() 467 + }; 468 + 469 + let has_errors = !errors.is_empty(); 470 + 309 471 match &data { 310 472 Data::Null => rsx! { 311 - div { class: "record-field", 473 + div { class: if has_errors { "record-field field-error" } else { "record-field" }, 312 474 PathLabel { path: path.clone() } 313 475 span { class: "field-value muted", "null" } 476 + if has_errors { 477 + for error in &errors { 478 + div { class: "field-error-message", "{error}" } 479 + } 480 + } 314 481 } 315 482 }, 316 483 Data::Boolean(b) => rsx! { 317 - div { class: "record-field", 484 + div { class: if has_errors { "record-field field-error" } else { "record-field" }, 318 485 PathLabel { path: path.clone() } 319 486 span { class: "field-value", "{b}" } 487 + if has_errors { 488 + for error in &errors { 489 + div { class: "field-error-message", "{error}" } 490 + } 491 + } 320 492 } 321 493 }, 322 494 Data::Integer(i) => rsx! { 323 - div { class: "record-field", 495 + div { class: if has_errors { "record-field field-error" } else { "record-field" }, 324 496 PathLabel { path: path.clone() } 325 497 span { class: "field-value", "{i}" } 498 + if has_errors { 499 + for error in &errors { 500 + div { class: "field-error-message", "{error}" } 501 + } 502 + } 326 503 } 327 504 }, 328 505 Data::String(s) => { 329 506 use jacquard::types::string::AtprotoStr; 507 + use jacquard_lexicon::lexicon::LexStringFormat; 330 508 331 - let type_label = match s { 509 + // Get expected format from schema 510 + let expected_format = get_expected_string_format(&*root_data.read(), &path); 511 + 512 + // Get actual type from data 513 + let actual_type_label = match s { 332 514 AtprotoStr::Datetime(_) => "datetime", 333 515 AtprotoStr::Language(_) => "language", 334 516 AtprotoStr::Tid(_) => "tid", ··· 343 525 AtprotoStr::String(_) => "string", 344 526 }; 345 527 528 + // Prefer schema format if available, otherwise use actual type 529 + let type_label = if let Some(fmt) = expected_format { 530 + match fmt { 531 + LexStringFormat::Datetime => "datetime", 532 + LexStringFormat::Uri => "uri", 533 + LexStringFormat::AtUri => "at-uri", 534 + LexStringFormat::Did => "did", 535 + LexStringFormat::Handle => "handle", 536 + LexStringFormat::AtIdentifier => "at-identifier", 537 + LexStringFormat::Nsid => "nsid", 538 + LexStringFormat::Cid => "cid", 539 + LexStringFormat::Language => "language", 540 + LexStringFormat::Tid => "tid", 541 + LexStringFormat::RecordKey => "record-key", 542 + } 543 + } else { 544 + actual_type_label 545 + }; 546 + 346 547 rsx! { 347 - div { class: "record-field", 548 + div { class: if has_errors { "record-field field-error" } else { "record-field" }, 348 549 PathLabel { path: path.clone() } 349 550 span { class: "field-value", 350 551 351 552 HighlightedString { string_type: s.clone() } 352 553 if type_label != "string" { 353 554 span { class: "string-type-tag", " [{type_label}]" } 555 + } 556 + } 557 + if has_errors { 558 + for error in &errors { 559 + div { class: "field-error-message", "{error}" } 354 560 } 355 561 } 356 562 } ··· 364 570 format!("{} bytes", b.len()) 365 571 }; 366 572 rsx! { 367 - div { class: "record-field", 573 + div { class: if has_errors { "record-field field-error" } else { "record-field" }, 368 574 PathLabel { path: path.clone() } 369 575 pre { class: "field-value bytes", "{hex_string} [{byte_size}]" } 576 + if has_errors { 577 + for error in &errors { 578 + div { class: "field-error-message", "{error}" } 579 + } 580 + } 370 581 } 371 582 } 372 583 } 373 584 Data::CidLink(cid) => rsx! { 374 - div { class: "record-field", 585 + div { class: if has_errors { "record-field field-error" } else { "record-field" }, 375 586 span { class: "field-label", "{path}" } 376 587 span { class: "field-value", "{cid}" } 588 + if has_errors { 589 + for error in &errors { 590 + div { class: "field-error-message", "{error}" } 591 + } 592 + } 377 593 } 378 594 }, 379 595 Data::Array(arr) => { ··· 381 597 rsx! { 382 598 div { class: "record-section", 383 599 div { class: "section-label", "{label}" span { class: "array-len", "[{arr.len()}] " } } 384 - 600 + if has_errors { 601 + for error in &errors { 602 + div { class: "field-error-message", "{error}" } 603 + } 604 + } 385 605 div { class: "section-content", 386 606 for (idx, item) in arr.iter().enumerate() { 387 607 { ··· 398 618 div { class: "section-content", 399 619 DataView { 400 620 data: item.clone(), 621 + root_data, 401 622 path: item_path.clone(), 402 623 did: did.clone() 403 624 } ··· 413 634 class: "array-item", 414 635 DataView { 415 636 data: item.clone(), 637 + root_data, 416 638 path: item_path, 417 639 did: did.clone() 418 640 } ··· 433 655 // Root object or array item: just render children (array items already wrapped) 434 656 rsx! { 435 657 div { class: if !is_root { "record-section" } else {""}, 658 + if has_errors { 659 + for error in &errors { 660 + div { class: "field-error-message", "{error}" } 661 + } 662 + } 436 663 for (key, value) in obj.iter() { 437 664 { 438 665 let new_path = if is_root { ··· 442 669 }; 443 670 let did_clone = did.clone(); 444 671 rsx! { 445 - DataView { data: value.clone(), path: new_path, did: did_clone } 672 + DataView { data: value.clone(), root_data, path: new_path, did: did_clone } 446 673 } 447 674 } 448 675 } ··· 454 681 rsx! { 455 682 456 683 div { class: "section-label", "{label}" } 684 + if has_errors { 685 + for error in &errors { 686 + div { class: "field-error-message", "{error}" } 687 + } 688 + } 457 689 div { class: "record-section", 458 690 div { class: "section-content", 459 691 for (key, value) in obj.iter() { ··· 461 693 let new_path = format!("{}.{}", path, key); 462 694 let did_clone = did.clone(); 463 695 rsx! { 464 - DataView { data: value.clone(), path: new_path, did: did_clone } 696 + DataView { data: value.clone(), root_data, path: new_path, did: did_clone } 465 697 } 466 698 } 467 699 } ··· 810 1042 811 1043 #[component] 812 1044 fn ValidationPanel( 813 - validation: Resource< 814 - Option<( 815 - Option<jacquard_lexicon::validation::ValidationResult>, 816 - Option<String>, 817 - )>, 818 - >, 1045 + validation: Resource<Option<(Option<ValidationResult>, Option<String>)>>, 819 1046 ) -> Element { 820 1047 rsx! { 821 1048 div { class: "validation-panel", ··· 824 1051 div { class: "parse-error", 825 1052 "❌ Invalid JSON: {parse_err}" 826 1053 } 827 - } else { 828 - div { class: "parse-success", "✓ Valid JSON syntax" } 829 1054 } 830 1055 831 1056 if let Some(result) = result_opt { 1057 + // Structural validity 1058 + if result.is_structurally_valid() { 1059 + div { class: "validation-success", "✓ Structurally valid" } 1060 + } else { 1061 + div { class: "parse-error", "❌ Structurally invalid" } 1062 + } 1063 + 1064 + // Overall validity 832 1065 if result.is_valid() { 833 - div { class: "validation-success", "✓ Record is valid" } 1066 + div { class: "validation-success", "✓ Fully valid" } 834 1067 } else { 1068 + div { class: "validation-warning", "⚠ Has errors" } 1069 + } 1070 + 1071 + // Show errors if any 1072 + if !result.is_valid() { 835 1073 div { class: "validation-errors", 836 1074 h4 { "Validation Errors:" } 837 1075 for error in result.all_errors() { 838 - div { class: "error", "❌ {error}" } 1076 + div { class: "error", "{error}" } 839 1077 } 840 1078 } 841 1079 } ··· 844 1082 div { "Validating..." } 845 1083 } 846 1084 } 1085 + } 1086 + } 1087 + 1088 + // ============================================================================ 1089 + // Validation Helper Functions 1090 + // ============================================================================ 1091 + 1092 + /// Parse UI path into segments (fields and indices only) 1093 + fn parse_ui_path(ui_path: &str) -> Vec<UiPathSegment> { 1094 + if ui_path.is_empty() { 1095 + return vec![]; 1096 + } 1097 + 1098 + let mut segments = Vec::new(); 1099 + let mut current = String::new(); 1100 + 1101 + for ch in ui_path.chars() { 1102 + match ch { 1103 + '.' => { 1104 + if !current.is_empty() { 1105 + segments.push(UiPathSegment::Field(current.clone())); 1106 + current.clear(); 1107 + } 1108 + } 1109 + '[' => { 1110 + if !current.is_empty() { 1111 + segments.push(UiPathSegment::Field(current.clone())); 1112 + current.clear(); 1113 + } 1114 + } 1115 + ']' => { 1116 + if !current.is_empty() { 1117 + if let Ok(idx) = current.parse::<usize>() { 1118 + segments.push(UiPathSegment::Index(idx)); 1119 + } 1120 + current.clear(); 1121 + } 1122 + } 1123 + c => current.push(c), 1124 + } 1125 + } 1126 + 1127 + if !current.is_empty() { 1128 + segments.push(UiPathSegment::Field(current)); 1129 + } 1130 + 1131 + segments 1132 + } 1133 + 1134 + #[derive(Debug, PartialEq)] 1135 + enum UiPathSegment { 1136 + Field(String), 1137 + Index(usize), 1138 + } 1139 + 1140 + /// Check if a validation path matches or is a child of the given UI path 1141 + /// Filters out UnionVariant segments from validation path for comparison 1142 + fn validation_path_matches_ui(validation_path: &ValidationPath, ui_path: &str) -> bool { 1143 + use jacquard_lexicon::validation::PathSegment; 1144 + 1145 + let ui_segments = parse_ui_path(ui_path); 1146 + 1147 + // Convert validation path to UI segments by filtering out UnionVariant 1148 + let validation_ui_segments: Vec<_> = validation_path 1149 + .segments() 1150 + .iter() 1151 + .filter_map(|seg| match seg { 1152 + PathSegment::Field(name) => Some(UiPathSegment::Field(name.to_string())), 1153 + PathSegment::Index(idx) => Some(UiPathSegment::Index(*idx)), 1154 + PathSegment::UnionVariant(_) => None, // Skip union discriminators 1155 + }) 1156 + .collect(); 1157 + 1158 + // Check if validation path matches or is a child of UI path 1159 + if validation_ui_segments.len() < ui_segments.len() { 1160 + return false; // Validation path can't be shorter than UI path 1161 + } 1162 + 1163 + // Check if all UI segments match the start of validation segments 1164 + ui_segments 1165 + .iter() 1166 + .zip(validation_ui_segments.iter()) 1167 + .all(|(a, b)| a == b) 1168 + } 1169 + 1170 + /// Get all validation errors at exactly this path (not children) 1171 + fn get_errors_at_exact_path( 1172 + validation_result: &Option<ValidationResult>, 1173 + ui_path: &str, 1174 + ) -> Vec<String> { 1175 + use jacquard_lexicon::validation::PathSegment; 1176 + 1177 + if let Some(result) = validation_result { 1178 + let ui_segments = parse_ui_path(ui_path); 1179 + 1180 + result 1181 + .all_errors() 1182 + .filter_map(|err| { 1183 + let validation_path = match &err { 1184 + ValidationError::Structural(s) => match s { 1185 + StructuralError::TypeMismatch { path, .. } => Some(path), 1186 + StructuralError::MissingRequiredField { path, .. } => Some(path), 1187 + StructuralError::MissingUnionDiscriminator { path } => Some(path), 1188 + StructuralError::UnionNoMatch { path, .. } => Some(path), 1189 + StructuralError::UnresolvedRef { path, .. } => Some(path), 1190 + StructuralError::RefCycle { path, .. } => Some(path), 1191 + StructuralError::MaxDepthExceeded { path, .. } => Some(path), 1192 + }, 1193 + ValidationError::Constraint(c) => match c { 1194 + ConstraintError::MaxLength { path, .. } => Some(path), 1195 + ConstraintError::MaxGraphemes { path, .. } => Some(path), 1196 + ConstraintError::MinLength { path, .. } => Some(path), 1197 + ConstraintError::MinGraphemes { path, .. } => Some(path), 1198 + ConstraintError::Maximum { path, .. } => Some(path), 1199 + ConstraintError::Minimum { path, .. } => Some(path), 1200 + }, 1201 + }; 1202 + 1203 + if let Some(path) = validation_path { 1204 + // Convert validation path to UI segments 1205 + let validation_ui_segments: Vec<_> = path 1206 + .segments() 1207 + .iter() 1208 + .filter_map(|seg| match seg { 1209 + PathSegment::Field(name) => { 1210 + Some(UiPathSegment::Field(name.to_string())) 1211 + } 1212 + PathSegment::Index(idx) => Some(UiPathSegment::Index(*idx)), 1213 + PathSegment::UnionVariant(_) => None, 1214 + }) 1215 + .collect(); 1216 + 1217 + // Exact match only 1218 + if validation_ui_segments == ui_segments { 1219 + return Some(err.to_string()); 1220 + } 1221 + } 1222 + None 1223 + }) 1224 + .collect() 1225 + } else { 1226 + Vec::new() 847 1227 } 848 1228 } 849 1229 ··· 2171 2551 2172 2552 /// Layout component for record view - handles header, metadata, and wraps children 2173 2553 #[component] 2174 - fn RecordViewLayout(uri: AtUri<'static>, cid: Option<Cid<'static>>, children: Element) -> Element { 2554 + fn RecordViewLayout( 2555 + uri: AtUri<'static>, 2556 + cid: Option<Cid<'static>>, 2557 + schema: ReadSignal<Option<LexiconDoc<'static>>>, 2558 + record_value: Data<'static>, 2559 + children: Element, 2560 + ) -> Element { 2561 + // Validate the record if schema is available 2562 + let validation_status = use_memo(move || { 2563 + let _schema_doc = schema()?; 2564 + let nsid_str = record_value.type_discriminator()?; 2565 + 2566 + let validator = jacquard_lexicon::validation::SchemaValidator::global(); 2567 + let result = validator.validate_by_nsid(nsid_str, &record_value); 2568 + 2569 + Some(result.is_valid()) 2570 + }); 2571 + 2175 2572 rsx! { 2176 2573 div { 2177 2574 class: "record-metadata", ··· 2187 2584 code { class: "metadata-value", "{cid}" } 2188 2585 } 2189 2586 } 2587 + if let Some(is_valid) = validation_status() { 2588 + div { class: "metadata-row", 2589 + span { class: "metadata-label", "Schema" } 2590 + span { 2591 + class: if is_valid { "metadata-value schema-valid" } else { "metadata-value schema-invalid" }, 2592 + if is_valid { "Valid" } else { "Invalid" } 2593 + } 2594 + } 2595 + } 2190 2596 } 2191 2597 2192 2598 {children} ··· 2208 2614 let nsid = use_memo(move || edit_data().type_discriminator().map(|s| s.to_string())); 2209 2615 let navigator = use_navigator(); 2210 2616 let fetcher = use_context::<CachedFetcher>(); 2617 + 2618 + // Validate edit_data whenever it changes and provide via context 2619 + let mut validation_result = use_signal(|| None); 2620 + use_effect(move || { 2621 + let _ = schema(); // Track schema changes 2622 + if let Some(nsid_str) = nsid() { 2623 + let data = edit_data(); 2624 + let validator = jacquard_lexicon::validation::SchemaValidator::global(); 2625 + let result = validator.validate_by_nsid(&nsid_str, &data); 2626 + validation_result.set(Some(result)); 2627 + } 2628 + }); 2629 + use_context_provider(|| validation_result); 2211 2630 2212 2631 let update_fetcher = fetcher.clone(); 2213 2632 let create_fetcher = fetcher.clone();