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
weaver-renderer = { path = "../weaver-renderer" }
31
mini-moka = { git = "https://github.com/moka-rs/mini-moka", rev = "da864e849f5d034f32e02197fee9bb5d5af36d3d" }
32
n0-future = { workspace = true }
33
-
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
34
axum = {version = "0.8.6", optional = true}
35
mime-sniffer = {version = "^0.1"}
36
chrono = { version = "0.4" }
···
30
weaver-renderer = { path = "../weaver-renderer" }
31
mini-moka = { git = "https://github.com/moka-rs/mini-moka", rev = "da864e849f5d034f32e02197fee9bb5d5af36d3d" }
32
n0-future = { workspace = true }
33
+
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false, features = ["router"] }
34
axum = {version = "0.8.6", optional = true}
35
mime-sniffer = {version = "^0.1"}
36
chrono = { version = "0.4" }
+37
crates/weaver-app/assets/styling/record-view.css
···
84
word-break: break-all;
85
}
86
0
0
0
0
0
0
0
87
.uri-link {
88
text-decoration: none;
89
}
···
206
border-left: 2px solid var(--color-secondary);
207
border-bottom: 1px dashed var(--color-subtle);
208
z-index: 1;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
209
}
210
211
.field-label {
···
464
font-family: var(--font-mono);
465
font-size: 0.9rem;
466
padding: 1rem;
0
467
background: var(--color-surface, rgba(0, 0, 0, 0.2));
468
border: 1px solid var(--color-border);
469
color: var(--color-text);
···
478
479
.validation-panel {
480
flex: 0 0 300px;
0
481
font-family: var(--font-mono);
482
font-size: 0.85rem;
483
padding: 1rem;
484
background: var(--color-surface, rgba(0, 0, 0, 0.2));
485
border: 1px solid var(--color-border);
486
overflow-y: auto;
0
0
487
align-self: flex-start;
488
}
489
···
510
border-left: 2px solid var(--color-error);
511
padding-left: 0.5rem;
512
margin: 0.25rem 0;
0
0
0
0
0
0
0
513
}
514
515
/* Array Section Styling */
···
84
word-break: break-all;
85
}
86
87
+
.schema-valid {
88
+
color: var(--color-success);
89
+
}
90
+
.schema-invalid {
91
+
color: var(--color-warning);
92
+
}
93
+
94
.uri-link {
95
text-decoration: none;
96
}
···
213
border-left: 2px solid var(--color-secondary);
214
border-bottom: 1px dashed var(--color-subtle);
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;
235
}
236
237
.field-label {
···
490
font-family: var(--font-mono);
491
font-size: 0.9rem;
492
padding: 1rem;
493
+
margin-top: 1.5rem;
494
background: var(--color-surface, rgba(0, 0, 0, 0.2));
495
border: 1px solid var(--color-border);
496
color: var(--color-text);
···
505
506
.validation-panel {
507
flex: 0 0 300px;
508
+
max-width: 400px;
509
font-family: var(--font-mono);
510
font-size: 0.85rem;
511
padding: 1rem;
512
background: var(--color-surface, rgba(0, 0, 0, 0.2));
513
border: 1px solid var(--color-border);
514
overflow-y: auto;
515
+
overflow-x: hidden;
516
+
margin-top: 1.5rem;
517
align-self: flex-start;
518
}
519
···
540
border-left: 2px solid var(--color-error);
541
padding-left: 0.5rem;
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);
550
}
551
552
/* Array Section Styling */
+69
crates/weaver-app/src/components/accordion/component.rs
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
···
0
0
···
1
+
mod component;
2
+
pub use component::*;
+89
crates/weaver-app/src/components/accordion/style.css
···
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
···
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
pub mod input;
125
pub mod dialog;
126
pub mod button;
0
···
124
pub mod input;
125
pub mod dialog;
126
pub mod button;
127
+
pub mod accordion;
+456
-37
crates/weaver-app/src/views/record.rs
···
16
types::{aturi::AtUri, cid::Cid, ident::AtIdentifier, string::Nsid},
17
};
18
use jacquard_lexicon::lexicon::LexiconDoc;
0
0
0
19
use mime_sniffer::MimeTypeSniffer;
20
use weaver_api::com_atproto::repo::{
21
create_record::CreateRecord, delete_record::DeleteRecord, put_record::PutRecord,
···
97
let validator = jacquard_lexicon::validation::SchemaValidator::global();
98
let main_type = record.value.type_discriminator();
99
let mut main_schema = None;
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
100
101
// Find and resolve all schemas (including main and nested)
102
for type_val in record.value.query("...$type").values() {
···
106
continue;
107
}
108
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
-
}
120
}
121
}
122
}
···
154
RecordViewLayout {
155
uri: uri().clone(),
156
cid: record.cid.clone(),
0
0
157
if edit_mode() {
158
159
EditableRecordContent {
···
194
class: "tab-content",
195
match view_mode() {
196
ViewMode::Pretty => rsx! {
197
-
PrettyRecordView { record: record_value, uri: uri().clone() }
198
},
199
ViewMode::Json => {
200
let json = use_memo(use_reactive!(|record| serde_json::to_string_pretty(
···
223
}
224
225
#[component]
226
-
fn PrettyRecordView(record: Data<'static>, uri: AtUri<'static>) -> Element {
0
0
0
0
227
let did = uri.authority().to_string();
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
228
rsx! {
229
div {
230
class: "pretty-record",
231
-
DataView { data: record, path: String::new(), did }
232
}
233
}
234
}
···
240
let schema_data = use_memo(move || to_data(&schema_doc).ok().map(|d| d.into_static()));
241
242
if let Some(data) = schema_data() {
0
243
rsx! {
244
div {
245
class: "pretty-record",
246
-
DataView { data: data, path: String::new(), did: String::new() }
247
}
248
}
249
} else {
···
304
}
305
}
306
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
307
#[component]
308
-
fn DataView(data: Data<'static>, path: String, did: String) -> Element {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
309
match &data {
310
Data::Null => rsx! {
311
-
div { class: "record-field",
312
PathLabel { path: path.clone() }
313
span { class: "field-value muted", "null" }
0
0
0
0
0
314
}
315
},
316
Data::Boolean(b) => rsx! {
317
-
div { class: "record-field",
318
PathLabel { path: path.clone() }
319
span { class: "field-value", "{b}" }
0
0
0
0
0
320
}
321
},
322
Data::Integer(i) => rsx! {
323
-
div { class: "record-field",
324
PathLabel { path: path.clone() }
325
span { class: "field-value", "{i}" }
0
0
0
0
0
326
}
327
},
328
Data::String(s) => {
329
use jacquard::types::string::AtprotoStr;
0
330
331
-
let type_label = match s {
0
0
0
0
332
AtprotoStr::Datetime(_) => "datetime",
333
AtprotoStr::Language(_) => "language",
334
AtprotoStr::Tid(_) => "tid",
···
343
AtprotoStr::String(_) => "string",
344
};
345
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
346
rsx! {
347
-
div { class: "record-field",
348
PathLabel { path: path.clone() }
349
span { class: "field-value",
350
351
HighlightedString { string_type: s.clone() }
352
if type_label != "string" {
353
span { class: "string-type-tag", " [{type_label}]" }
0
0
0
0
0
354
}
355
}
356
}
···
364
format!("{} bytes", b.len())
365
};
366
rsx! {
367
-
div { class: "record-field",
368
PathLabel { path: path.clone() }
369
pre { class: "field-value bytes", "{hex_string} [{byte_size}]" }
0
0
0
0
0
370
}
371
}
372
}
373
Data::CidLink(cid) => rsx! {
374
-
div { class: "record-field",
375
span { class: "field-label", "{path}" }
376
span { class: "field-value", "{cid}" }
0
0
0
0
0
377
}
378
},
379
Data::Array(arr) => {
···
381
rsx! {
382
div { class: "record-section",
383
div { class: "section-label", "{label}" span { class: "array-len", "[{arr.len()}] " } }
384
-
0
0
0
0
385
div { class: "section-content",
386
for (idx, item) in arr.iter().enumerate() {
387
{
···
398
div { class: "section-content",
399
DataView {
400
data: item.clone(),
0
401
path: item_path.clone(),
402
did: did.clone()
403
}
···
413
class: "array-item",
414
DataView {
415
data: item.clone(),
0
416
path: item_path,
417
did: did.clone()
418
}
···
433
// Root object or array item: just render children (array items already wrapped)
434
rsx! {
435
div { class: if !is_root { "record-section" } else {""},
0
0
0
0
0
436
for (key, value) in obj.iter() {
437
{
438
let new_path = if is_root {
···
442
};
443
let did_clone = did.clone();
444
rsx! {
445
-
DataView { data: value.clone(), path: new_path, did: did_clone }
446
}
447
}
448
}
···
454
rsx! {
455
456
div { class: "section-label", "{label}" }
0
0
0
0
0
457
div { class: "record-section",
458
div { class: "section-content",
459
for (key, value) in obj.iter() {
···
461
let new_path = format!("{}.{}", path, key);
462
let did_clone = did.clone();
463
rsx! {
464
-
DataView { data: value.clone(), path: new_path, did: did_clone }
465
}
466
}
467
}
···
810
811
#[component]
812
fn ValidationPanel(
813
-
validation: Resource<
814
-
Option<(
815
-
Option<jacquard_lexicon::validation::ValidationResult>,
816
-
Option<String>,
817
-
)>,
818
-
>,
819
) -> Element {
820
rsx! {
821
div { class: "validation-panel",
···
824
div { class: "parse-error",
825
"❌ Invalid JSON: {parse_err}"
826
}
827
-
} else {
828
-
div { class: "parse-success", "✓ Valid JSON syntax" }
829
}
830
831
if let Some(result) = result_opt {
0
0
0
0
0
0
0
0
832
if result.is_valid() {
833
-
div { class: "validation-success", "✓ Record is valid" }
834
} else {
0
0
0
0
0
835
div { class: "validation-errors",
836
h4 { "Validation Errors:" }
837
for error in result.all_errors() {
838
-
div { class: "error", "❌ {error}" }
839
}
840
}
841
}
···
844
div { "Validating..." }
845
}
846
}
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
847
}
848
}
849
···
2171
2172
/// Layout component for record view - handles header, metadata, and wraps children
2173
#[component]
2174
-
fn RecordViewLayout(uri: AtUri<'static>, cid: Option<Cid<'static>>, children: Element) -> Element {
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
0
2175
rsx! {
2176
div {
2177
class: "record-metadata",
···
2187
code { class: "metadata-value", "{cid}" }
2188
}
2189
}
0
0
0
0
0
0
0
0
0
2190
}
2191
2192
{children}
···
2208
let nsid = use_memo(move || edit_data().type_discriminator().map(|s| s.to_string()));
2209
let navigator = use_navigator();
2210
let fetcher = use_context::<CachedFetcher>();
0
0
0
0
0
0
0
0
0
0
0
0
0
2211
2212
let update_fetcher = fetcher.clone();
2213
let create_fetcher = fetcher.clone();
···
16
types::{aturi::AtUri, cid::Cid, ident::AtIdentifier, string::Nsid},
17
};
18
use jacquard_lexicon::lexicon::LexiconDoc;
19
+
use jacquard_lexicon::validation::{
20
+
ConstraintError, StructuralError, ValidationError, ValidationPath, ValidationResult,
21
+
};
22
use mime_sniffer::MimeTypeSniffer;
23
use weaver_api::com_atproto::repo::{
24
create_record::CreateRecord, delete_record::DeleteRecord, put_record::PutRecord,
···
100
let validator = jacquard_lexicon::validation::SchemaValidator::global();
101
let main_type = record.value.type_discriminator();
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
+
}
146
147
// Find and resolve all schemas (including main and nested)
148
for type_val in record.value.query("...$type").values() {
···
152
continue;
153
}
154
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);
0
0
0
0
162
}
163
}
164
}
···
196
RecordViewLayout {
197
uri: uri().clone(),
198
cid: record.cid.clone(),
199
+
schema: schema_signal,
200
+
record_value: record_value.clone(),
201
if edit_mode() {
202
203
EditableRecordContent {
···
238
class: "tab-content",
239
match view_mode() {
240
ViewMode::Pretty => rsx! {
241
+
PrettyRecordView { record: record_value, uri: uri().clone(), schema: schema_signal }
242
},
243
ViewMode::Json => {
244
let json = use_memo(use_reactive!(|record| serde_json::to_string_pretty(
···
267
}
268
269
#[component]
270
+
fn PrettyRecordView(
271
+
record: Data<'static>,
272
+
uri: AtUri<'static>,
273
+
schema: ReadSignal<Option<LexiconDoc<'static>>>,
274
+
) -> Element {
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
+
292
rsx! {
293
div {
294
class: "pretty-record",
295
+
DataView { data: record, root_data, path: String::new(), did }
296
}
297
}
298
}
···
304
let schema_data = use_memo(move || to_data(&schema_doc).ok().map(|d| d.into_static()));
305
306
if let Some(data) = schema_data() {
307
+
let root_data = use_signal(|| data.clone());
308
rsx! {
309
div {
310
class: "pretty-record",
311
+
DataView { data: data, root_data, path: String::new(), did: String::new() }
312
}
313
}
314
} else {
···
369
}
370
}
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
+
453
#[component]
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
+
471
match &data {
472
Data::Null => rsx! {
473
+
div { class: if has_errors { "record-field field-error" } else { "record-field" },
474
PathLabel { path: path.clone() }
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
+
}
481
}
482
},
483
Data::Boolean(b) => rsx! {
484
+
div { class: if has_errors { "record-field field-error" } else { "record-field" },
485
PathLabel { path: path.clone() }
486
span { class: "field-value", "{b}" }
487
+
if has_errors {
488
+
for error in &errors {
489
+
div { class: "field-error-message", "{error}" }
490
+
}
491
+
}
492
}
493
},
494
Data::Integer(i) => rsx! {
495
+
div { class: if has_errors { "record-field field-error" } else { "record-field" },
496
PathLabel { path: path.clone() }
497
span { class: "field-value", "{i}" }
498
+
if has_errors {
499
+
for error in &errors {
500
+
div { class: "field-error-message", "{error}" }
501
+
}
502
+
}
503
}
504
},
505
Data::String(s) => {
506
use jacquard::types::string::AtprotoStr;
507
+
use jacquard_lexicon::lexicon::LexStringFormat;
508
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 {
514
AtprotoStr::Datetime(_) => "datetime",
515
AtprotoStr::Language(_) => "language",
516
AtprotoStr::Tid(_) => "tid",
···
525
AtprotoStr::String(_) => "string",
526
};
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
+
547
rsx! {
548
+
div { class: if has_errors { "record-field field-error" } else { "record-field" },
549
PathLabel { path: path.clone() }
550
span { class: "field-value",
551
552
HighlightedString { string_type: s.clone() }
553
if type_label != "string" {
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}" }
560
}
561
}
562
}
···
570
format!("{} bytes", b.len())
571
};
572
rsx! {
573
+
div { class: if has_errors { "record-field field-error" } else { "record-field" },
574
PathLabel { path: path.clone() }
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
+
}
581
}
582
}
583
}
584
Data::CidLink(cid) => rsx! {
585
+
div { class: if has_errors { "record-field field-error" } else { "record-field" },
586
span { class: "field-label", "{path}" }
587
span { class: "field-value", "{cid}" }
588
+
if has_errors {
589
+
for error in &errors {
590
+
div { class: "field-error-message", "{error}" }
591
+
}
592
+
}
593
}
594
},
595
Data::Array(arr) => {
···
597
rsx! {
598
div { class: "record-section",
599
div { class: "section-label", "{label}" span { class: "array-len", "[{arr.len()}] " } }
600
+
if has_errors {
601
+
for error in &errors {
602
+
div { class: "field-error-message", "{error}" }
603
+
}
604
+
}
605
div { class: "section-content",
606
for (idx, item) in arr.iter().enumerate() {
607
{
···
618
div { class: "section-content",
619
DataView {
620
data: item.clone(),
621
+
root_data,
622
path: item_path.clone(),
623
did: did.clone()
624
}
···
634
class: "array-item",
635
DataView {
636
data: item.clone(),
637
+
root_data,
638
path: item_path,
639
did: did.clone()
640
}
···
655
// Root object or array item: just render children (array items already wrapped)
656
rsx! {
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
+
}
663
for (key, value) in obj.iter() {
664
{
665
let new_path = if is_root {
···
669
};
670
let did_clone = did.clone();
671
rsx! {
672
+
DataView { data: value.clone(), root_data, path: new_path, did: did_clone }
673
}
674
}
675
}
···
681
rsx! {
682
683
div { class: "section-label", "{label}" }
684
+
if has_errors {
685
+
for error in &errors {
686
+
div { class: "field-error-message", "{error}" }
687
+
}
688
+
}
689
div { class: "record-section",
690
div { class: "section-content",
691
for (key, value) in obj.iter() {
···
693
let new_path = format!("{}.{}", path, key);
694
let did_clone = did.clone();
695
rsx! {
696
+
DataView { data: value.clone(), root_data, path: new_path, did: did_clone }
697
}
698
}
699
}
···
1042
1043
#[component]
1044
fn ValidationPanel(
1045
+
validation: Resource<Option<(Option<ValidationResult>, Option<String>)>>,
0
0
0
0
0
1046
) -> Element {
1047
rsx! {
1048
div { class: "validation-panel",
···
1051
div { class: "parse-error",
1052
"❌ Invalid JSON: {parse_err}"
1053
}
0
0
1054
}
1055
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
1065
if result.is_valid() {
1066
+
div { class: "validation-success", "✓ Fully valid" }
1067
} else {
1068
+
div { class: "validation-warning", "⚠ Has errors" }
1069
+
}
1070
+
1071
+
// Show errors if any
1072
+
if !result.is_valid() {
1073
div { class: "validation-errors",
1074
h4 { "Validation Errors:" }
1075
for error in result.all_errors() {
1076
+
div { class: "error", "{error}" }
1077
}
1078
}
1079
}
···
1082
div { "Validating..." }
1083
}
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()
1227
}
1228
}
1229
···
2551
2552
/// Layout component for record view - handles header, metadata, and wraps children
2553
#[component]
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
+
2572
rsx! {
2573
div {
2574
class: "record-metadata",
···
2584
code { class: "metadata-value", "{cid}" }
2585
}
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
+
}
2596
}
2597
2598
{children}
···
2614
let nsid = use_memo(move || edit_data().type_discriminator().map(|s| s.to_string()));
2615
let navigator = use_navigator();
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);
2630
2631
let update_fetcher = fetcher.clone();
2632
let create_fetcher = fetcher.clone();