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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user