Enhance documentation and admin UI: Add detailed implementation guidelines in CLAUDE.md, introduce a referrer index in README.md, and update admin UI translations for improved user experience. Update package dependencies for better functionality and performance.

This commit is contained in:
Peter Meier
2026-03-13 10:55:33 +01:00
parent 7754d800f5
commit 606455c59b
42 changed files with 3814 additions and 421 deletions

View File

@@ -96,11 +96,12 @@ fn validate_field(
errors: &mut Vec<ValidationError>,
) {
// ── Null handling ────────────────────────────────────────────────────
// Optional fields (not required) may be null; required fields may not.
if value.is_null() {
if !fd.nullable {
if fd.required {
errors.push(ValidationError {
field: field_name.to_string(),
message: "Field does not allow null (set nullable: true to permit)".to_string(),
message: "Field is required".to_string(),
});
}
return;
@@ -112,7 +113,7 @@ fn validate_field(
"number" => value.is_number(),
"integer" => value.is_i64() || value.is_u64() || value.as_f64().map(|f| f.fract() == 0.0 && f.is_finite()).unwrap_or(false),
"boolean" => value.is_boolean(),
"array" => value.is_array(),
"array" | "multiSelect" => value.is_array(),
"object" => value.is_object(),
"reference" => value.is_string(),
"referenceOrInline" => value.is_string() || value.is_object(),
@@ -131,13 +132,39 @@ fn validate_field(
return; // no point checking constraints if type is wrong
}
// ── Enum constraint ─────────────────────────────────────────────────
if let Some(ref allowed) = fd.enum_values {
if !allowed.contains(value) {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("Value must be one of: {:?}", allowed),
});
// ── Enum constraint (single value) ───────────────────────────────────
// Optional string+enum: empty string = no selection, allowed
if fd.field_type != "multiSelect" {
if let Some(ref allowed) = fd.enum_values {
if !allowed.contains(value) {
let empty_ok = !fd.required && value.as_str().map_or(false, |s| s.is_empty());
if !empty_ok {
errors.push(ValidationError {
field: field_name.to_string(),
message: format!("Value must be one of: {:?}", allowed),
});
}
}
}
}
// ── multiSelect: array of strings, each must be in enum ──────────────
if fd.field_type == "multiSelect" {
if let Some(arr) = value.as_array() {
let allowed: Vec<&Value> = fd.enum_values.as_ref().map(|e| e.iter().collect()).unwrap_or_default();
for (i, item) in arr.iter().enumerate() {
if !item.is_string() {
errors.push(ValidationError {
field: format!("{}[{}]", field_name, i),
message: "Each item must be a string".to_string(),
});
} else if !allowed.is_empty() && !allowed.iter().any(|v| v.as_str() == item.as_str()) {
errors.push(ValidationError {
field: format!("{}[{}]", field_name, i),
message: format!("Item must be one of: {:?}", fd.enum_values),
});
}
}
}
}
@@ -270,6 +297,11 @@ pub fn apply_defaults(schema: &SchemaDefinition, content: &mut Value) {
continue;
}
if fd.field_type == "multiSelect" {
obj.insert(name.clone(), Value::Array(vec![]));
continue;
}
if let Some(ref default_value) = fd.default {
obj.insert(name.clone(), default_value.clone());
continue;
@@ -285,6 +317,27 @@ pub fn apply_defaults(schema: &SchemaDefinition, content: &mut Value) {
}
}
// ---------------------------------------------------------------------------
// Normalize multiSelect: string (e.g. "") → [] before validation
// ---------------------------------------------------------------------------
/// Coerce multiSelect fields that have a string value (e.g. from old data or form) to [].
pub fn normalize_multi_select(schema: &SchemaDefinition, content: &mut Value) {
let obj = match content.as_object_mut() {
Some(o) => o,
None => return,
};
for (field_name, fd) in &schema.fields {
if fd.field_type == "multiSelect" {
if let Some(v) = obj.get_mut(field_name) {
if v.is_string() {
*v = Value::Array(vec![]);
}
}
}
}
}
// ---------------------------------------------------------------------------
// Normalize reference arrays (used before validation on create/update)
// ---------------------------------------------------------------------------
@@ -458,6 +511,9 @@ pub fn validate_references(
continue;
}
if let Some(Value::String(slug)) = obj.get(field_name) {
if slug.trim().is_empty() {
continue; // empty = no selection, valid for optional reference
}
let found = colls.iter().any(|c| entry_exists(c, slug));
if !found {
errors.push(ValidationError {
@@ -467,6 +523,16 @@ pub fn validate_references(
slug, colls
),
});
} else if let Some(ref allowed) = fd.allowed_slugs {
if !allowed.is_empty() && !allowed.iter().any(|s| s.as_str() == slug) {
errors.push(ValidationError {
field: field_name.clone(),
message: format!(
"Slug '{}' is not in the allowed list for this reference",
slug
),
});
}
}
}
continue;
@@ -485,6 +551,9 @@ pub fn validate_references(
if let Some(Value::Array(arr)) = obj.get(field_name) {
for (i, item) in arr.iter().enumerate() {
if let Some(slug) = item.as_str() {
if slug.trim().is_empty() {
continue; // empty = no selection in list
}
let found = colls.iter().any(|c| entry_exists(c, slug));
if !found {
errors.push(ValidationError {
@@ -494,6 +563,16 @@ pub fn validate_references(
slug, colls
),
});
} else if let Some(ref allowed) = items.allowed_slugs {
if !allowed.is_empty() && !allowed.iter().any(|s| s.as_str() == slug) {
errors.push(ValidationError {
field: format!("{}[{}]", field_name, i),
message: format!(
"Slug '{}' is not in the allowed list for this reference",
slug
),
});
}
}
}
}
@@ -505,3 +584,77 @@ pub fn validate_references(
errors
}
// ---------------------------------------------------------------------------
// Extract references (for reverse index / referrers)
// ---------------------------------------------------------------------------
/// One referenced entry: (collection, slug). Used to update the referrer index.
fn parse_ref_value(value: &str, collections: &[&str]) -> Option<(String, String)> {
let value = value.trim();
if value.is_empty() {
return None;
}
if let Some((coll, slug)) = value.split_once(':') {
let slug = slug.trim();
if !slug.is_empty() && collections.contains(&coll) {
return Some((coll.to_string(), slug.to_string()));
}
}
if let Some(&coll) = collections.first() {
return Some((coll.to_string(), value.to_string()));
}
None
}
/// One reference: (referenced collection, referenced slug, field name).
pub type ExtractedRef = (String, String, String);
/// Extract all (referenced collection, referenced slug, field name) from content for referrer index updates.
/// Only considers reference and referenceOrInline (string) fields; referenceOrInline as object is inline, not a ref.
pub fn extract_references(schema: &SchemaDefinition, content: &Value) -> Vec<ExtractedRef> {
let mut out = Vec::new();
let obj = match content.as_object() {
Some(o) => o,
None => return out,
};
for (field_name, fd) in &schema.fields {
let is_ref = fd.field_type == "reference"
|| (fd.field_type == "referenceOrInline" && obj.get(field_name).map_or(false, |v| v.is_string()));
if is_ref {
let colls = fd.reference_collections();
if colls.is_empty() {
continue;
}
if let Some(Value::String(s)) = obj.get(field_name) {
if let Some((c, slug)) = parse_ref_value(s, &colls) {
out.push((c, slug, field_name.clone()));
}
}
continue;
}
if fd.field_type == "array" {
if let Some(ref items) = fd.items {
let is_ref_item = items.field_type == "reference"
|| items.field_type == "referenceOrInline";
if !is_ref_item {
continue;
}
let colls = items.reference_collections();
if colls.is_empty() {
continue;
}
if let Some(Value::Array(arr)) = obj.get(field_name) {
for item in arr.iter() {
if let Some(s) = item.as_str() {
if let Some((c, slug)) = parse_ref_value(s, &colls) {
out.push((c, slug, field_name.clone()));
}
}
}
}
}
}
}
out
}