Refactor DashboardCollectionList: Simplify search input layout and improve tag selection logic for better user experience.

This commit is contained in:
Peter Meier
2026-03-12 16:36:20 +01:00
parent 22b4367c47
commit 7754d800f5
17 changed files with 759 additions and 151 deletions

View File

@@ -22,10 +22,11 @@ use super::auth;
use super::cache::{self, ContentCache, TransformCache};
use super::error::ApiError;
use super::response::{collapse_asset_urls, expand_asset_urls, format_references, parse_resolve};
use super::webhooks;
/// Shared application state. Registry and OpenAPI spec are behind RwLock for hot-reload.
/// Store is selected via RUSTYCMS_STORE=file|sqlite.
/// If api_key is set (RUSTYCMS_API_KEY), POST/PUT/DELETE require it (Bearer or X-API-Key).
/// If api_keys is set (RUSTYCMS_API_KEY or RUSTYCMS_API_KEYS), write operations require a matching key with the right role.
/// When locales is set (RUSTYCMS_LOCALES e.g. "de,en"), API accepts _locale query param.
pub struct AppState {
pub registry: Arc<RwLock<SchemaRegistry>>,
@@ -33,7 +34,8 @@ pub struct AppState {
pub openapi_spec: Arc<RwLock<serde_json::Value>>,
/// Path to types directory (e.g. ./types) for schema file writes.
pub types_dir: PathBuf,
pub api_key: Option<String>,
/// API keys with roles (from RUSTYCMS_API_KEYS or RUSTYCMS_API_KEY).
pub api_keys: Option<super::auth::ApiKeys>,
pub cache: Arc<ContentCache>,
pub transform_cache: Arc<TransformCache>,
pub http_client: reqwest::Client,
@@ -43,6 +45,52 @@ pub struct AppState {
pub assets_dir: PathBuf,
/// Public base URL (e.g. https://api.example.com). Used to expand relative /api/assets/ paths.
pub base_url: String,
/// Webhook URLs to POST on content/asset/schema changes (from RUSTYCMS_WEBHOOKS).
pub webhook_urls: Vec<String>,
/// When set (RUSTYCMS_ENVIRONMENTS), content/assets are per-environment (e.g. production, staging).
pub environments: Option<Vec<String>>,
/// Store per environment when environments is set. Key = env name.
pub stores: Option<HashMap<String, Arc<dyn ContentStore>>>,
/// Assets dir per environment when environments is set. Key = env name.
pub assets_dirs: Option<HashMap<String, PathBuf>>,
}
impl AppState {
/// Resolve environment from query _environment. Default = first in list.
pub fn effective_environment(&self, params: &HashMap<String, String>) -> String {
let requested = params.get("_environment").map(|s| s.trim()).filter(|s| !s.is_empty());
self.effective_environment_from_param(requested)
}
/// Resolve environment from optional _environment value (e.g. from asset query params).
pub fn effective_environment_from_param(&self, env_param: Option<&str>) -> String {
let Some(ref list) = self.environments else {
return "default".to_string();
};
if list.is_empty() {
return "default".to_string();
}
match env_param {
Some(env) if list.iter().any(|e| e == env) => env.to_string(),
_ => list[0].clone(),
}
}
/// Store for the given environment. When environments is off, returns the single store.
pub fn store_for(&self, env: &str) -> Arc<dyn ContentStore> {
self.stores
.as_ref()
.and_then(|m| m.get(env).cloned())
.unwrap_or_else(|| Arc::clone(&self.store))
}
/// Assets directory for the given environment.
pub fn assets_dir_for(&self, env: &str) -> PathBuf {
self.assets_dirs
.as_ref()
.and_then(|m| m.get(env).cloned())
.unwrap_or_else(|| self.assets_dir.clone())
}
}
/// Resolve effective locale from query _locale and state.locales. Returns None when i18n is off.
@@ -114,7 +162,9 @@ pub async fn create_schema(
headers: HeaderMap,
Json(schema): Json<SchemaDefinition>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
if let Some(ref keys) = state.api_keys {
keys.require(&headers, auth::Permission::SchemasWrite)?;
}
if !is_valid_schema_name(&schema.name) {
return Err(ApiError::BadRequest(
@@ -156,6 +206,15 @@ pub async fn create_schema(
tracing::info!("Schema created: {} ({})", schema.name, path.display());
webhooks::fire(
state.http_client.clone(),
&state.webhook_urls,
json!({
"event": webhooks::EVENT_SCHEMA_CREATED,
"name": schema.name,
}),
);
Ok((
StatusCode::CREATED,
Json(serde_json::to_value(&schema).unwrap()),
@@ -172,7 +231,9 @@ pub async fn update_schema(
headers: HeaderMap,
Json(schema): Json<SchemaDefinition>,
) -> Result<Json<Value>, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
if let Some(ref keys) = state.api_keys {
keys.require(&headers, auth::Permission::SchemasWrite)?;
}
if name != schema.name {
return Err(ApiError::BadRequest(
@@ -215,6 +276,15 @@ pub async fn update_schema(
tracing::info!("Schema updated: {} ({})", name, path.display());
webhooks::fire(
state.http_client.clone(),
&state.webhook_urls,
json!({
"event": webhooks::EVENT_SCHEMA_UPDATED,
"name": name,
}),
);
Ok(Json(serde_json::to_value(&schema).unwrap()))
}
@@ -227,7 +297,9 @@ pub async fn delete_schema(
Path(name): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
if let Some(ref keys) = state.api_keys {
keys.require(&headers, auth::Permission::SchemasWrite)?;
}
if !is_valid_schema_name(&name) {
return Err(ApiError::BadRequest("Invalid schema name".to_string()));
@@ -254,6 +326,15 @@ pub async fn delete_schema(
return Err(ApiError::NotFound(format!("Schema '{}' not found", name)));
}
webhooks::fire(
state.http_client.clone(),
&state.webhook_urls,
json!({
"event": webhooks::EVENT_SCHEMA_DELETED,
"name": name,
}),
);
Ok(StatusCode::NO_CONTENT)
}
@@ -342,8 +423,9 @@ pub async fn slug_check(
if exclude.as_deref() == Some(normalized.as_str()) {
true
} else {
let exists = state
.store
let env = state.effective_environment(&params);
let store = state.store_for(&env);
let exists = store
.get(&collection, &normalized, locale_ref)
.await
.map_err(ApiError::from)?
@@ -381,6 +463,8 @@ pub async fn list_entries(
)));
}
let env = state.effective_environment(&params);
let store = state.store_for(&env);
let locale = effective_locale(&params, state.locales.as_deref());
let locale_ref = locale.as_deref();
@@ -404,17 +488,20 @@ pub async fn list_entries(
list_params.get(k.as_str()).unwrap().hash(&mut hasher);
}
let query_hash = hasher.finish();
let cache_key = cache::list_cache_key(&collection, query_hash, locale_ref);
let cache_key = cache::list_cache_key(&env, &collection, query_hash, locale_ref);
if let Some(cached) = state.cache.get(&cache_key).await {
return Ok(Json(cached));
}
let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
let entries = store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
let resolve = parse_resolve(list_params.get("_resolve").map(|s| s.as_str()));
// When API key is required but not sent, show only published entries. Otherwise use _status param.
let status_override = if state.api_key.as_ref().is_some()
&& auth::token_from_headers(&headers).as_deref() != state.api_key.as_ref().map(String::as_str)
// When API keys are enabled but request has no valid key, show only published entries.
let status_override = if state
.api_keys
.as_ref()
.map(|k| k.is_enabled() && !k.is_authenticated(&headers))
.unwrap_or(false)
{
Some(StatusFilter::Published)
} else {
@@ -427,7 +514,7 @@ pub async fn list_entries(
*item = format_references(
std::mem::take(item),
schema,
state.store.as_ref(),
store.as_ref(),
resolve.as_ref(),
locale_ref,
Some(&*registry),
@@ -464,15 +551,20 @@ pub async fn get_entry(
)));
}
let env = state.effective_environment(&params);
let store = state.store_for(&env);
let locale = effective_locale(&params, state.locales.as_deref());
let locale_ref = locale.as_deref();
let resolve_key = params.get("_resolve").map(|s| s.as_str()).unwrap_or("");
let cache_key = cache::entry_cache_key(&collection, &slug, resolve_key, locale_ref);
let cache_key = cache::entry_cache_key(&env, &collection, &slug, resolve_key, locale_ref);
if let Some(ref cached) = state.cache.get(&cache_key).await {
// Don't serve cached draft to unauthenticated requests.
let is_authenticated = state.api_key.as_ref().is_none()
|| auth::token_from_headers(&headers).as_deref() == state.api_key.as_ref().map(String::as_str);
let is_authenticated = state
.api_keys
.as_ref()
.map(|k| k.is_authenticated(&headers))
.unwrap_or(true);
if !is_authenticated && entry_is_draft(cached) {
// Fall through to load from store (will 404 if draft).
} else {
@@ -502,8 +594,7 @@ pub async fn get_entry(
}
}
let entry = state
.store
let entry = store
.get(&collection, &slug, locale_ref)
.await
.map_err(ApiError::from)?
@@ -511,9 +602,12 @@ pub async fn get_entry(
ApiError::NotFound(format!("Entry '{}' not found in '{}'", slug, collection))
})?;
// When no API key is sent, hide draft entries (return 404).
if state.api_key.as_ref().is_some()
&& auth::token_from_headers(&headers).as_deref() != state.api_key.as_ref().map(String::as_str)
// When API keys are enabled and request has no valid key, hide draft entries (return 404).
if state
.api_keys
.as_ref()
.map(|k| k.is_enabled() && !k.is_authenticated(&headers))
.unwrap_or(false)
&& entry_is_draft(&entry)
{
return Err(ApiError::NotFound(format!(
@@ -526,7 +620,7 @@ pub async fn get_entry(
let mut formatted = format_references(
entry,
schema,
state.store.as_ref(),
store.as_ref(),
resolve.as_ref(),
locale_ref,
Some(&*registry),
@@ -538,7 +632,7 @@ pub async fn get_entry(
// Only cache published entries so unauthenticated requests never see cached drafts.
if !entry_is_draft(&formatted) {
state.cache.set(cache_key, formatted.clone()).await;
state.cache.set(cache_key.clone(), formatted.clone()).await;
}
let json_str = serde_json::to_string(&formatted).unwrap_or_default();
@@ -579,8 +673,12 @@ pub async fn create_entry(
headers: HeaderMap,
Json(mut body): Json<Value>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
if let Some(ref keys) = state.api_keys {
keys.require(&headers, auth::Permission::ContentWrite)?;
}
let env = state.effective_environment(&params);
let store = state.store_for(&env);
let locale = effective_locale(&params, state.locales.as_deref());
let locale_ref = locale.as_deref();
@@ -637,7 +735,7 @@ pub async fn create_entry(
}
// Unique constraint check (within same locale)
let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
let entries = store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
let unique_errors = validator::validate_unique(&schema, &body, None, &entries);
if !unique_errors.is_empty() {
let messages: Vec<String> = unique_errors.iter().map(|e| e.to_string()).collect();
@@ -645,11 +743,11 @@ pub async fn create_entry(
}
// Reference validation (blocking: we need sync closure; use tokio::task::block_in_place or spawn)
let store = &state.store;
let ref_errors = validator::validate_references(&schema, &body, &|coll, s| {
let store_ref = Arc::clone(&store);
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async move {
store.get(coll, s, locale_ref).await.ok().flatten().is_some()
store_ref.get(coll, s, locale_ref).await.ok().flatten().is_some()
})
})
});
@@ -662,16 +760,14 @@ pub async fn create_entry(
collapse_asset_urls(&mut body, &state.base_url);
// Persist to filesystem
state
.store
store
.create(&collection, &slug, &body, locale_ref)
.await
.map_err(ApiError::from)?;
state.cache.invalidate_collection(&collection).await;
state.cache.invalidate_collection(&env, &collection).await;
// Return created entry (with reference format)
let entry = state
.store
let entry = store
.get(&collection, &slug, locale_ref)
.await
.map_err(ApiError::from)?
@@ -679,13 +775,25 @@ pub async fn create_entry(
let formatted = format_references(
entry,
&schema,
state.store.as_ref(),
store.as_ref(),
None,
locale_ref,
None,
)
.await;
webhooks::fire(
state.http_client.clone(),
&state.webhook_urls,
json!({
"event": webhooks::EVENT_CONTENT_CREATED,
"collection": collection,
"slug": slug,
"locale": locale_ref,
"environment": env,
}),
);
Ok((StatusCode::CREATED, Json(formatted)))
}
@@ -700,8 +808,12 @@ pub async fn update_entry(
headers: HeaderMap,
Json(mut body): Json<Value>,
) -> Result<Json<Value>, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
if let Some(ref keys) = state.api_keys {
keys.require(&headers, auth::Permission::ContentWrite)?;
}
let env = state.effective_environment(&params);
let store = state.store_for(&env);
let locale = effective_locale(&params, state.locales.as_deref());
let locale_ref = locale.as_deref();
@@ -736,8 +848,7 @@ pub async fn update_entry(
validator::normalize_reference_arrays(&schema, &mut body);
// Load existing content for readonly check
let existing = state
.store
let existing = store
.get(&collection, &slug, locale_ref)
.await
.map_err(ApiError::from)?
@@ -760,7 +871,7 @@ pub async fn update_entry(
}
// Unique constraint check (exclude self, within same locale)
let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
let entries = store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
let unique_errors = validator::validate_unique(&schema, &body, Some(&slug), &entries);
if !unique_errors.is_empty() {
let messages: Vec<String> = unique_errors.iter().map(|e| e.to_string()).collect();
@@ -768,11 +879,11 @@ pub async fn update_entry(
}
// Reference validation
let store = &state.store;
let ref_errors = validator::validate_references(&schema, &body, &|coll, s| {
let store_ref = Arc::clone(&store);
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async move {
store.get(coll, s, locale_ref).await.ok().flatten().is_some()
store_ref.get(coll, s, locale_ref).await.ok().flatten().is_some()
})
})
});
@@ -785,16 +896,14 @@ pub async fn update_entry(
collapse_asset_urls(&mut body, &state.base_url);
// Persist to filesystem
state
.store
store
.update(&collection, &slug, &body, locale_ref)
.await
.map_err(ApiError::from)?;
state.cache.invalidate_collection(&collection).await;
state.cache.invalidate_collection(&env, &collection).await;
// Return updated entry (with reference format)
let entry = state
.store
let entry = store
.get(&collection, &slug, locale_ref)
.await
.map_err(ApiError::from)?
@@ -802,13 +911,25 @@ pub async fn update_entry(
let formatted = format_references(
entry,
&schema,
state.store.as_ref(),
store.as_ref(),
None,
locale_ref,
None,
)
.await;
webhooks::fire(
state.http_client.clone(),
&state.webhook_urls,
json!({
"event": webhooks::EVENT_CONTENT_UPDATED,
"collection": collection,
"slug": slug,
"locale": locale_ref,
"environment": env,
}),
);
Ok(Json(formatted))
}
@@ -838,8 +959,12 @@ pub async fn delete_entry(
Query(params): Query<HashMap<String, String>>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
auth::require_api_key(state.api_key.as_ref(), &headers)?;
if let Some(ref keys) = state.api_keys {
keys.require(&headers, auth::Permission::ContentWrite)?;
}
let env = state.effective_environment(&params);
let store = state.store_for(&env);
let locale = effective_locale(&params, state.locales.as_deref());
let locale_ref = locale.as_deref();
@@ -857,12 +982,23 @@ pub async fn delete_entry(
)));
}
state
.store
store
.delete(&collection, &slug, locale_ref)
.await
.map_err(ApiError::from)?;
state.cache.invalidate_collection(&collection).await;
state.cache.invalidate_collection(&env, &collection).await;
webhooks::fire(
state.http_client.clone(),
&state.webhook_urls,
json!({
"event": webhooks::EVENT_CONTENT_DELETED,
"collection": collection,
"slug": slug,
"locale": locale_ref,
"environment": env,
}),
);
Ok(StatusCode::NO_CONTENT)
}