tangled
alpha
login
or
join now
nonbinary.computer
/
weaver
atproto blogging
24
fork
atom
overview
issues
2
pulls
pipelines
schema validation
Orual
2 months ago
da465d03
9538a97b
+655
-38
7 changed files
expand all
collapse all
unified
split
crates
weaver-app
Cargo.toml
assets
styling
record-view.css
src
components
accordion
component.rs
mod.rs
style.css
mod.rs
views
record.rs
+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
33
-
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
33
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
87
+
.schema-valid {
88
88
+
color: var(--color-success);
89
89
+
}
90
90
+
.schema-invalid {
91
91
+
color: var(--color-warning);
92
92
+
}
93
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
216
+
}
217
217
+
218
218
+
.record-field.field-error {
219
219
+
border-left-color: var(--color-error);
220
220
+
background-color: rgba(255, 107, 107, 0.05);
221
221
+
}
222
222
+
223
223
+
.field-error-message {
224
224
+
font-family: var(--font-mono);
225
225
+
font-size: 0.85rem;
226
226
+
color: var(--color-error);
227
227
+
padding: 0.25rem 0;
228
228
+
border-left: 2px solid var(--color-error);
229
229
+
border-bottom: 1px dashed var(--color-error);
230
230
+
padding-left: 0.5rem;
231
231
+
word-wrap: break-word;
232
232
+
margin-left: -1px;
233
233
+
word-break: break-word;
234
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
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
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
515
+
overflow-x: hidden;
516
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
543
+
word-wrap: break-word;
544
544
+
word-break: break-word;
545
545
+
overflow-wrap: break-word;
546
546
+
}
547
547
+
548
548
+
.validation-warning {
549
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
1
+
use dioxus::prelude::*;
2
2
+
use dioxus_primitives::accordion::{
3
3
+
self, AccordionContentProps, AccordionItemProps, AccordionProps, AccordionTriggerProps,
4
4
+
};
5
5
+
6
6
+
#[component]
7
7
+
pub fn Accordion(props: AccordionProps) -> Element {
8
8
+
rsx! {
9
9
+
document::Link { rel: "stylesheet", href: asset!("./style.css") }
10
10
+
accordion::Accordion {
11
11
+
class: "accordion",
12
12
+
width: "15rem",
13
13
+
id: props.id,
14
14
+
allow_multiple_open: props.allow_multiple_open,
15
15
+
disabled: props.disabled,
16
16
+
collapsible: props.collapsible,
17
17
+
horizontal: props.horizontal,
18
18
+
attributes: props.attributes,
19
19
+
{props.children}
20
20
+
}
21
21
+
}
22
22
+
}
23
23
+
24
24
+
#[component]
25
25
+
pub fn AccordionItem(props: AccordionItemProps) -> Element {
26
26
+
rsx! {
27
27
+
accordion::AccordionItem {
28
28
+
class: "accordion-item",
29
29
+
disabled: props.disabled,
30
30
+
default_open: props.default_open,
31
31
+
on_change: props.on_change,
32
32
+
on_trigger_click: props.on_trigger_click,
33
33
+
index: props.index,
34
34
+
attributes: props.attributes,
35
35
+
{props.children}
36
36
+
}
37
37
+
}
38
38
+
}
39
39
+
40
40
+
#[component]
41
41
+
pub fn AccordionTrigger(props: AccordionTriggerProps) -> Element {
42
42
+
rsx! {
43
43
+
accordion::AccordionTrigger {
44
44
+
class: "accordion-trigger",
45
45
+
id: props.id,
46
46
+
attributes: props.attributes,
47
47
+
{props.children}
48
48
+
svg {
49
49
+
class: "accordion-expand-icon",
50
50
+
view_box: "0 0 24 24",
51
51
+
xmlns: "http://www.w3.org/2000/svg",
52
52
+
polyline { points: "6 9 12 15 18 9" }
53
53
+
}
54
54
+
}
55
55
+
}
56
56
+
}
57
57
+
58
58
+
#[component]
59
59
+
pub fn AccordionContent(props: AccordionContentProps) -> Element {
60
60
+
rsx! {
61
61
+
accordion::AccordionContent {
62
62
+
class: "accordion-content",
63
63
+
style: "--collapsible-content-width: 140px",
64
64
+
id: props.id,
65
65
+
attributes: props.attributes,
66
66
+
{props.children}
67
67
+
}
68
68
+
}
69
69
+
}
+2
crates/weaver-app/src/components/accordion/mod.rs
···
1
1
+
mod component;
2
2
+
pub use component::*;
+89
crates/weaver-app/src/components/accordion/style.css
···
1
1
+
.accordion-trigger {
2
2
+
display: flex;
3
3
+
width: 100%;
4
4
+
box-sizing: border-box;
5
5
+
flex-direction: row;
6
6
+
align-items: center;
7
7
+
justify-content: space-between;
8
8
+
padding: 0;
9
9
+
padding-top: 1rem;
10
10
+
padding-bottom: 1rem;
11
11
+
border: none;
12
12
+
background-color: transparent;
13
13
+
color: var(--secondary-color-4);
14
14
+
outline: none;
15
15
+
text-align: left;
16
16
+
}
17
17
+
18
18
+
.accordion-trigger:focus-visible {
19
19
+
border: none;
20
20
+
box-shadow: inset 0 0 0 2px var(--focused-border-color);
21
21
+
}
22
22
+
23
23
+
.accordion-trigger:hover {
24
24
+
cursor: pointer;
25
25
+
text-decoration-line: underline;
26
26
+
}
27
27
+
28
28
+
.accordion-content {
29
29
+
display: grid;
30
30
+
height: 0;
31
31
+
}
32
32
+
33
33
+
.accordion-content[data-open="false"] {
34
34
+
animation: accordion-slide-down 300ms cubic-bezier(0.87, 0, 0.13, 1) forwards;
35
35
+
}
36
36
+
37
37
+
.accordion-content[data-open="true"] {
38
38
+
animation: accordion-slide-up 300ms cubic-bezier(0.87, 0, 0.13, 1) forwards;
39
39
+
}
40
40
+
41
41
+
@keyframes accordion-slide-down {
42
42
+
from {
43
43
+
height: var(--collapsible-content-width);
44
44
+
}
45
45
+
46
46
+
to {
47
47
+
height: 0;
48
48
+
}
49
49
+
}
50
50
+
51
51
+
@keyframes accordion-slide-up {
52
52
+
from {
53
53
+
height: 0;
54
54
+
}
55
55
+
56
56
+
to {
57
57
+
height: var(--collapsible-content-width);
58
58
+
}
59
59
+
}
60
60
+
61
61
+
.accordion-item {
62
62
+
overflow: hidden;
63
63
+
box-sizing: border-box;
64
64
+
border-bottom: 1px solid var(--primary-color-6);
65
65
+
margin-top: 1px;
66
66
+
}
67
67
+
68
68
+
.accordion-item:first-child {
69
69
+
margin-top: 0;
70
70
+
}
71
71
+
72
72
+
.accordion-item:last-child {
73
73
+
border-bottom: none;
74
74
+
}
75
75
+
76
76
+
.accordion-expand-icon {
77
77
+
width: 20px;
78
78
+
height: 20px;
79
79
+
fill: none;
80
80
+
stroke: var(--secondary-color-4);
81
81
+
stroke-linecap: round;
82
82
+
stroke-linejoin: round;
83
83
+
stroke-width: 2;
84
84
+
transition: rotate 150ms cubic-bezier(0.4, 0, 0.2, 1);
85
85
+
}
86
86
+
87
87
+
.accordion-item[data-open="true"] .accordion-expand-icon {
88
88
+
rotate: 180deg;
89
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
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
19
+
use jacquard_lexicon::validation::{
20
20
+
ConstraintError, StructuralError, ValidationError, ValidationPath, ValidationResult,
21
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
103
+
let mut resolved = std::collections::HashSet::new();
104
104
+
105
105
+
// Helper to recursively resolve a schema and its refs
106
106
+
fn resolve_schema_with_refs<'a>(
107
107
+
fetcher: &'a CachedFetcher,
108
108
+
type_str: &'a str,
109
109
+
validator: &'a jacquard_lexicon::validation::SchemaValidator,
110
110
+
resolved: &'a mut std::collections::HashSet<String>,
111
111
+
) -> std::pin::Pin<
112
112
+
Box<dyn std::future::Future<Output = Option<LexiconDoc<'static>>> + 'a>,
113
113
+
> {
114
114
+
Box::pin(async move {
115
115
+
if resolved.contains(type_str) {
116
116
+
return None;
117
117
+
}
118
118
+
resolved.insert(type_str.to_string());
119
119
+
120
120
+
let mut split = type_str.split('#');
121
121
+
let nsid_str = split.next().unwrap_or_default();
122
122
+
let nsid = Nsid::new(nsid_str).ok()?;
123
123
+
124
124
+
let schema = fetcher.resolve_lexicon_schema(&nsid).await.ok()?;
125
125
+
126
126
+
// Register by base NSID only (validator handles fragment lookup)
127
127
+
validator
128
128
+
.registry()
129
129
+
.insert(nsid_str.to_smolstr(), schema.doc.clone());
130
130
+
131
131
+
// Find refs in the schema and resolve them
132
132
+
if let Ok(schema_data) = to_data(&schema.doc) {
133
133
+
for ref_val in schema_data.query("..ref").values() {
134
134
+
if let Some(ref_str) = ref_val.as_str() {
135
135
+
if ref_str.contains('.') {
136
136
+
resolve_schema_with_refs(fetcher, ref_str, validator, resolved)
137
137
+
.await;
138
138
+
}
139
139
+
}
140
140
+
}
141
141
+
}
142
142
+
143
143
+
Some(schema.doc)
144
144
+
})
145
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
109
-
if let Ok(nsid) = Nsid::new(type_str) {
110
110
-
// Fetch and register schema
111
111
-
if let Ok(schema) = fetcher.resolve_lexicon_schema(&nsid).await {
112
112
-
validator
113
113
-
.registry()
114
114
-
.insert(nsid.to_smolstr(), schema.doc.clone());
115
115
-
116
116
-
// Keep the main record schema
117
117
-
if Some(type_str) == main_type {
118
118
-
main_schema = Some(schema.doc);
119
119
-
}
155
155
+
if let Some(schema) =
156
156
+
resolve_schema_with_refs(&fetcher, type_str, &validator, &mut resolved)
157
157
+
.await
158
158
+
{
159
159
+
// Keep the main record schema
160
160
+
if Some(type_str) == main_type {
161
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
199
+
schema: schema_signal,
200
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
197
-
PrettyRecordView { record: record_value, uri: uri().clone() }
241
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
226
-
fn PrettyRecordView(record: Data<'static>, uri: AtUri<'static>) -> Element {
270
270
+
fn PrettyRecordView(
271
271
+
record: Data<'static>,
272
272
+
uri: AtUri<'static>,
273
273
+
schema: ReadSignal<Option<LexiconDoc<'static>>>,
274
274
+
) -> Element {
227
275
let did = uri.authority().to_string();
276
276
+
let root_data = use_signal(|| record.clone());
277
277
+
278
278
+
// Validate the record and provide via context - only after schema is loaded
279
279
+
let mut validation_result = use_signal(|| None);
280
280
+
use_effect(move || {
281
281
+
// Wait for schema to be loaded
282
282
+
if schema().is_some() {
283
283
+
if let Some(nsid_str) = root_data.read().type_discriminator() {
284
284
+
let validator = jacquard_lexicon::validation::SchemaValidator::global();
285
285
+
let result = validator.validate_by_nsid(nsid_str, &*root_data.read());
286
286
+
validation_result.set(Some(result));
287
287
+
}
288
288
+
}
289
289
+
});
290
290
+
use_context_provider(|| validation_result);
291
291
+
228
292
rsx! {
229
293
div {
230
294
class: "pretty-record",
231
231
-
DataView { data: record, path: String::new(), did }
295
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
307
+
let root_data = use_signal(|| data.clone());
243
308
rsx! {
244
309
div {
245
310
class: "pretty-record",
246
246
-
DataView { data: data, path: String::new(), did: String::new() }
311
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
372
+
/// Get expected string format from schema by navigating path
373
373
+
fn get_expected_string_format(
374
374
+
root_data: &Data<'_>,
375
375
+
current_path: &str,
376
376
+
) -> Option<jacquard_lexicon::lexicon::LexStringFormat> {
377
377
+
use jacquard_lexicon::lexicon::*;
378
378
+
379
379
+
// Get root type discriminator
380
380
+
let root_nsid = root_data.type_discriminator()?;
381
381
+
382
382
+
// Look up schema in global registry
383
383
+
let validator = jacquard_lexicon::validation::SchemaValidator::global();
384
384
+
let registry = validator.registry();
385
385
+
let schema = registry.get(root_nsid)?;
386
386
+
387
387
+
// Navigate to the property at this path
388
388
+
let segments: Vec<&str> = if current_path.is_empty() {
389
389
+
vec![]
390
390
+
} else {
391
391
+
current_path.split('.').collect()
392
392
+
};
393
393
+
394
394
+
// Start with the record's main object definition
395
395
+
let main_obj = match schema.defs.get("main")? {
396
396
+
LexUserType::Record(rec) => match &rec.record {
397
397
+
LexRecordRecord::Object(obj) => obj,
398
398
+
},
399
399
+
_ => return None,
400
400
+
};
401
401
+
402
402
+
// Track current position in schema
403
403
+
enum SchemaType<'a> {
404
404
+
ObjectProp(&'a LexObjectProperty<'a>),
405
405
+
ArrayItem(&'a LexArrayItem<'a>),
406
406
+
}
407
407
+
408
408
+
let mut current_type: Option<SchemaType> = None;
409
409
+
let mut current_obj = Some(main_obj);
410
410
+
411
411
+
for segment in segments {
412
412
+
// Handle array indices - strip them to get field name
413
413
+
let field_name = segment.trim_end_matches(|c: char| c.is_numeric() || c == '[' || c == ']');
414
414
+
415
415
+
if field_name.is_empty() {
416
416
+
continue; // Pure array index like [0], skip
417
417
+
}
418
418
+
419
419
+
if let Some(obj) = current_obj.take() {
420
420
+
if let Some(prop) = obj.properties.get(field_name) {
421
421
+
current_type = Some(SchemaType::ObjectProp(prop));
422
422
+
}
423
423
+
}
424
424
+
425
425
+
// Process current type
426
426
+
match current_type {
427
427
+
Some(SchemaType::ObjectProp(LexObjectProperty::Array(arr))) => {
428
428
+
// Array - unwrap to item type
429
429
+
current_type = Some(SchemaType::ArrayItem(&arr.items));
430
430
+
}
431
431
+
Some(SchemaType::ObjectProp(LexObjectProperty::Object(obj))) => {
432
432
+
// Nested object - descend into it
433
433
+
current_obj = Some(obj);
434
434
+
current_type = None;
435
435
+
}
436
436
+
Some(SchemaType::ArrayItem(LexArrayItem::Object(obj))) => {
437
437
+
// Array of objects - descend into object
438
438
+
current_obj = Some(obj);
439
439
+
current_type = None;
440
440
+
}
441
441
+
_ => {}
442
442
+
}
443
443
+
}
444
444
+
445
445
+
// Check if final type is a string with format
446
446
+
match current_type? {
447
447
+
SchemaType::ObjectProp(LexObjectProperty::String(lex_string)) => lex_string.format,
448
448
+
SchemaType::ArrayItem(LexArrayItem::String(lex_string)) => lex_string.format,
449
449
+
_ => None,
450
450
+
}
451
451
+
}
452
452
+
307
453
#[component]
308
308
-
fn DataView(data: Data<'static>, path: String, did: String) -> Element {
454
454
+
fn DataView(
455
455
+
data: Data<'static>,
456
456
+
root_data: ReadSignal<Data<'static>>,
457
457
+
path: String,
458
458
+
did: String,
459
459
+
) -> Element {
460
460
+
// Try to get validation result from context and get errors exactly at this path
461
461
+
let validation_result = try_use_context::<Signal<Option<ValidationResult>>>();
462
462
+
463
463
+
let errors = if let Some(vr_signal) = validation_result {
464
464
+
get_errors_at_exact_path(&*vr_signal.read(), &path)
465
465
+
} else {
466
466
+
Vec::new()
467
467
+
};
468
468
+
469
469
+
let has_errors = !errors.is_empty();
470
470
+
309
471
match &data {
310
472
Data::Null => rsx! {
311
311
-
div { class: "record-field",
473
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
476
+
if has_errors {
477
477
+
for error in &errors {
478
478
+
div { class: "field-error-message", "{error}" }
479
479
+
}
480
480
+
}
314
481
}
315
482
},
316
483
Data::Boolean(b) => rsx! {
317
317
-
div { class: "record-field",
484
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
487
+
if has_errors {
488
488
+
for error in &errors {
489
489
+
div { class: "field-error-message", "{error}" }
490
490
+
}
491
491
+
}
320
492
}
321
493
},
322
494
Data::Integer(i) => rsx! {
323
323
-
div { class: "record-field",
495
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
498
+
if has_errors {
499
499
+
for error in &errors {
500
500
+
div { class: "field-error-message", "{error}" }
501
501
+
}
502
502
+
}
326
503
}
327
504
},
328
505
Data::String(s) => {
329
506
use jacquard::types::string::AtprotoStr;
507
507
+
use jacquard_lexicon::lexicon::LexStringFormat;
330
508
331
331
-
let type_label = match s {
509
509
+
// Get expected format from schema
510
510
+
let expected_format = get_expected_string_format(&*root_data.read(), &path);
511
511
+
512
512
+
// Get actual type from data
513
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
528
+
// Prefer schema format if available, otherwise use actual type
529
529
+
let type_label = if let Some(fmt) = expected_format {
530
530
+
match fmt {
531
531
+
LexStringFormat::Datetime => "datetime",
532
532
+
LexStringFormat::Uri => "uri",
533
533
+
LexStringFormat::AtUri => "at-uri",
534
534
+
LexStringFormat::Did => "did",
535
535
+
LexStringFormat::Handle => "handle",
536
536
+
LexStringFormat::AtIdentifier => "at-identifier",
537
537
+
LexStringFormat::Nsid => "nsid",
538
538
+
LexStringFormat::Cid => "cid",
539
539
+
LexStringFormat::Language => "language",
540
540
+
LexStringFormat::Tid => "tid",
541
541
+
LexStringFormat::RecordKey => "record-key",
542
542
+
}
543
543
+
} else {
544
544
+
actual_type_label
545
545
+
};
546
546
+
346
547
rsx! {
347
347
-
div { class: "record-field",
548
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
555
+
}
556
556
+
}
557
557
+
if has_errors {
558
558
+
for error in &errors {
559
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
367
-
div { class: "record-field",
573
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
576
+
if has_errors {
577
577
+
for error in &errors {
578
578
+
div { class: "field-error-message", "{error}" }
579
579
+
}
580
580
+
}
370
581
}
371
582
}
372
583
}
373
584
Data::CidLink(cid) => rsx! {
374
374
-
div { class: "record-field",
585
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
588
+
if has_errors {
589
589
+
for error in &errors {
590
590
+
div { class: "field-error-message", "{error}" }
591
591
+
}
592
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
384
-
600
600
+
if has_errors {
601
601
+
for error in &errors {
602
602
+
div { class: "field-error-message", "{error}" }
603
603
+
}
604
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
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
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
658
+
if has_errors {
659
659
+
for error in &errors {
660
660
+
div { class: "field-error-message", "{error}" }
661
661
+
}
662
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
445
-
DataView { data: value.clone(), path: new_path, did: did_clone }
672
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
684
+
if has_errors {
685
685
+
for error in &errors {
686
686
+
div { class: "field-error-message", "{error}" }
687
687
+
}
688
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
464
-
DataView { data: value.clone(), path: new_path, did: did_clone }
696
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
813
-
validation: Resource<
814
814
-
Option<(
815
815
-
Option<jacquard_lexicon::validation::ValidationResult>,
816
816
-
Option<String>,
817
817
-
)>,
818
818
-
>,
1045
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
827
-
} else {
828
828
-
div { class: "parse-success", "✓ Valid JSON syntax" }
829
1054
}
830
1055
831
1056
if let Some(result) = result_opt {
1057
1057
+
// Structural validity
1058
1058
+
if result.is_structurally_valid() {
1059
1059
+
div { class: "validation-success", "✓ Structurally valid" }
1060
1060
+
} else {
1061
1061
+
div { class: "parse-error", "❌ Structurally invalid" }
1062
1062
+
}
1063
1063
+
1064
1064
+
// Overall validity
832
1065
if result.is_valid() {
833
833
-
div { class: "validation-success", "✓ Record is valid" }
1066
1066
+
div { class: "validation-success", "✓ Fully valid" }
834
1067
} else {
1068
1068
+
div { class: "validation-warning", "⚠ Has errors" }
1069
1069
+
}
1070
1070
+
1071
1071
+
// Show errors if any
1072
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
838
-
div { class: "error", "❌ {error}" }
1076
1076
+
div { class: "error", "{error}" }
839
1077
}
840
1078
}
841
1079
}
···
844
1082
div { "Validating..." }
845
1083
}
846
1084
}
1085
1085
+
}
1086
1086
+
}
1087
1087
+
1088
1088
+
// ============================================================================
1089
1089
+
// Validation Helper Functions
1090
1090
+
// ============================================================================
1091
1091
+
1092
1092
+
/// Parse UI path into segments (fields and indices only)
1093
1093
+
fn parse_ui_path(ui_path: &str) -> Vec<UiPathSegment> {
1094
1094
+
if ui_path.is_empty() {
1095
1095
+
return vec![];
1096
1096
+
}
1097
1097
+
1098
1098
+
let mut segments = Vec::new();
1099
1099
+
let mut current = String::new();
1100
1100
+
1101
1101
+
for ch in ui_path.chars() {
1102
1102
+
match ch {
1103
1103
+
'.' => {
1104
1104
+
if !current.is_empty() {
1105
1105
+
segments.push(UiPathSegment::Field(current.clone()));
1106
1106
+
current.clear();
1107
1107
+
}
1108
1108
+
}
1109
1109
+
'[' => {
1110
1110
+
if !current.is_empty() {
1111
1111
+
segments.push(UiPathSegment::Field(current.clone()));
1112
1112
+
current.clear();
1113
1113
+
}
1114
1114
+
}
1115
1115
+
']' => {
1116
1116
+
if !current.is_empty() {
1117
1117
+
if let Ok(idx) = current.parse::<usize>() {
1118
1118
+
segments.push(UiPathSegment::Index(idx));
1119
1119
+
}
1120
1120
+
current.clear();
1121
1121
+
}
1122
1122
+
}
1123
1123
+
c => current.push(c),
1124
1124
+
}
1125
1125
+
}
1126
1126
+
1127
1127
+
if !current.is_empty() {
1128
1128
+
segments.push(UiPathSegment::Field(current));
1129
1129
+
}
1130
1130
+
1131
1131
+
segments
1132
1132
+
}
1133
1133
+
1134
1134
+
#[derive(Debug, PartialEq)]
1135
1135
+
enum UiPathSegment {
1136
1136
+
Field(String),
1137
1137
+
Index(usize),
1138
1138
+
}
1139
1139
+
1140
1140
+
/// Check if a validation path matches or is a child of the given UI path
1141
1141
+
/// Filters out UnionVariant segments from validation path for comparison
1142
1142
+
fn validation_path_matches_ui(validation_path: &ValidationPath, ui_path: &str) -> bool {
1143
1143
+
use jacquard_lexicon::validation::PathSegment;
1144
1144
+
1145
1145
+
let ui_segments = parse_ui_path(ui_path);
1146
1146
+
1147
1147
+
// Convert validation path to UI segments by filtering out UnionVariant
1148
1148
+
let validation_ui_segments: Vec<_> = validation_path
1149
1149
+
.segments()
1150
1150
+
.iter()
1151
1151
+
.filter_map(|seg| match seg {
1152
1152
+
PathSegment::Field(name) => Some(UiPathSegment::Field(name.to_string())),
1153
1153
+
PathSegment::Index(idx) => Some(UiPathSegment::Index(*idx)),
1154
1154
+
PathSegment::UnionVariant(_) => None, // Skip union discriminators
1155
1155
+
})
1156
1156
+
.collect();
1157
1157
+
1158
1158
+
// Check if validation path matches or is a child of UI path
1159
1159
+
if validation_ui_segments.len() < ui_segments.len() {
1160
1160
+
return false; // Validation path can't be shorter than UI path
1161
1161
+
}
1162
1162
+
1163
1163
+
// Check if all UI segments match the start of validation segments
1164
1164
+
ui_segments
1165
1165
+
.iter()
1166
1166
+
.zip(validation_ui_segments.iter())
1167
1167
+
.all(|(a, b)| a == b)
1168
1168
+
}
1169
1169
+
1170
1170
+
/// Get all validation errors at exactly this path (not children)
1171
1171
+
fn get_errors_at_exact_path(
1172
1172
+
validation_result: &Option<ValidationResult>,
1173
1173
+
ui_path: &str,
1174
1174
+
) -> Vec<String> {
1175
1175
+
use jacquard_lexicon::validation::PathSegment;
1176
1176
+
1177
1177
+
if let Some(result) = validation_result {
1178
1178
+
let ui_segments = parse_ui_path(ui_path);
1179
1179
+
1180
1180
+
result
1181
1181
+
.all_errors()
1182
1182
+
.filter_map(|err| {
1183
1183
+
let validation_path = match &err {
1184
1184
+
ValidationError::Structural(s) => match s {
1185
1185
+
StructuralError::TypeMismatch { path, .. } => Some(path),
1186
1186
+
StructuralError::MissingRequiredField { path, .. } => Some(path),
1187
1187
+
StructuralError::MissingUnionDiscriminator { path } => Some(path),
1188
1188
+
StructuralError::UnionNoMatch { path, .. } => Some(path),
1189
1189
+
StructuralError::UnresolvedRef { path, .. } => Some(path),
1190
1190
+
StructuralError::RefCycle { path, .. } => Some(path),
1191
1191
+
StructuralError::MaxDepthExceeded { path, .. } => Some(path),
1192
1192
+
},
1193
1193
+
ValidationError::Constraint(c) => match c {
1194
1194
+
ConstraintError::MaxLength { path, .. } => Some(path),
1195
1195
+
ConstraintError::MaxGraphemes { path, .. } => Some(path),
1196
1196
+
ConstraintError::MinLength { path, .. } => Some(path),
1197
1197
+
ConstraintError::MinGraphemes { path, .. } => Some(path),
1198
1198
+
ConstraintError::Maximum { path, .. } => Some(path),
1199
1199
+
ConstraintError::Minimum { path, .. } => Some(path),
1200
1200
+
},
1201
1201
+
};
1202
1202
+
1203
1203
+
if let Some(path) = validation_path {
1204
1204
+
// Convert validation path to UI segments
1205
1205
+
let validation_ui_segments: Vec<_> = path
1206
1206
+
.segments()
1207
1207
+
.iter()
1208
1208
+
.filter_map(|seg| match seg {
1209
1209
+
PathSegment::Field(name) => {
1210
1210
+
Some(UiPathSegment::Field(name.to_string()))
1211
1211
+
}
1212
1212
+
PathSegment::Index(idx) => Some(UiPathSegment::Index(*idx)),
1213
1213
+
PathSegment::UnionVariant(_) => None,
1214
1214
+
})
1215
1215
+
.collect();
1216
1216
+
1217
1217
+
// Exact match only
1218
1218
+
if validation_ui_segments == ui_segments {
1219
1219
+
return Some(err.to_string());
1220
1220
+
}
1221
1221
+
}
1222
1222
+
None
1223
1223
+
})
1224
1224
+
.collect()
1225
1225
+
} else {
1226
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
2174
-
fn RecordViewLayout(uri: AtUri<'static>, cid: Option<Cid<'static>>, children: Element) -> Element {
2554
2554
+
fn RecordViewLayout(
2555
2555
+
uri: AtUri<'static>,
2556
2556
+
cid: Option<Cid<'static>>,
2557
2557
+
schema: ReadSignal<Option<LexiconDoc<'static>>>,
2558
2558
+
record_value: Data<'static>,
2559
2559
+
children: Element,
2560
2560
+
) -> Element {
2561
2561
+
// Validate the record if schema is available
2562
2562
+
let validation_status = use_memo(move || {
2563
2563
+
let _schema_doc = schema()?;
2564
2564
+
let nsid_str = record_value.type_discriminator()?;
2565
2565
+
2566
2566
+
let validator = jacquard_lexicon::validation::SchemaValidator::global();
2567
2567
+
let result = validator.validate_by_nsid(nsid_str, &record_value);
2568
2568
+
2569
2569
+
Some(result.is_valid())
2570
2570
+
});
2571
2571
+
2175
2572
rsx! {
2176
2573
div {
2177
2574
class: "record-metadata",
···
2187
2584
code { class: "metadata-value", "{cid}" }
2188
2585
}
2189
2586
}
2587
2587
+
if let Some(is_valid) = validation_status() {
2588
2588
+
div { class: "metadata-row",
2589
2589
+
span { class: "metadata-label", "Schema" }
2590
2590
+
span {
2591
2591
+
class: if is_valid { "metadata-value schema-valid" } else { "metadata-value schema-invalid" },
2592
2592
+
if is_valid { "Valid" } else { "Invalid" }
2593
2593
+
}
2594
2594
+
}
2595
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
2617
+
2618
2618
+
// Validate edit_data whenever it changes and provide via context
2619
2619
+
let mut validation_result = use_signal(|| None);
2620
2620
+
use_effect(move || {
2621
2621
+
let _ = schema(); // Track schema changes
2622
2622
+
if let Some(nsid_str) = nsid() {
2623
2623
+
let data = edit_data();
2624
2624
+
let validator = jacquard_lexicon::validation::SchemaValidator::global();
2625
2625
+
let result = validator.validate_by_nsid(&nsid_str, &data);
2626
2626
+
validation_result.set(Some(result));
2627
2627
+
}
2628
2628
+
});
2629
2629
+
use_context_provider(|| validation_result);
2211
2630
2212
2631
let update_fetcher = fetcher.clone();
2213
2632
let create_fetcher = fetcher.clone();