app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 3e539da77ae575e8562ac99a9da65447194b7698
parent 95d6afd692a2e468ed10514d334d4c0bf041790b
Author: triesap <tyson@radroots.org>
Date:   Fri,  6 Feb 2026 16:39:35 +0000

app: persist configuration flow

- add config draft to data mapping helpers and tests
- save configuration records with create or update fallback
- log config store errors and update config status on failures
- add settings action to reset and re-run configuration

Diffstat:
Mapp/src/app.rs | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mapp/src/config_flow.rs | 137++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mapp/src/configuration.rs | 15+++++++++++++++
Mapp/src/lib.rs | 1+
Mapp/src/settings.rs | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 287 insertions(+), 4 deletions(-)

diff --git a/app/src/app.rs b/app/src/app.rs @@ -45,6 +45,7 @@ use crate::{ app_init_stage_set, app_init_total_add, app_init_total_unknown, + app_config_flow_build_config, app_config_flow_validate, app_config_step_default, app_context, @@ -56,6 +57,8 @@ use crate::{ app_config_gate_from_status, app_config_default, app_config_status, + app_datastore_create_config, + app_datastore_update_config, app_datastore_clear_setup_draft, app_datastore_write_profile_seed, app_datastore_write_setup_draft, @@ -75,6 +78,8 @@ use crate::{ RadrootsAppConfigFlowDraft, RadrootsAppConfigStep, RadrootsAppConfigStatus, + RadrootsAppConfigRecordError, + RadrootsAppConfigStoreError, RadrootsAppInitError, RadrootsAppInitStage, RadrootsAppNotifications, @@ -1526,7 +1531,12 @@ fn RecoveryPage() -> impl IntoView { #[component] fn ConfigPage() -> impl IntoView { let context = app_context(); + let fallback_backends = RwSignal::new_local(None::<RadrootsAppBackends>); let fallback_config_status = RwSignal::new_local(RadrootsAppConfigStatus::Unknown); + let backends = context + .as_ref() + .map(|value| value.backends) + .unwrap_or(fallback_backends); let config_status = context .as_ref() .map(|value| value.config_status) @@ -1550,6 +1560,7 @@ fn ConfigPage() -> impl IntoView { let notifications_orders = RwSignal::new_local(true); let notifications_messages = RwSignal::new_local(true); let payment_method = RwSignal::new_local(String::new()); + let config_saving = RwSignal::new_local(false); let config_flow = move || RadrootsAppConfigFlowDraft { step: config_step.get(), profile_name: profile_name.get(), @@ -1570,16 +1581,65 @@ fn ConfigPage() -> impl IntoView { }; let config_validation = move || app_config_flow_validate(&config_flow()); let advance_step = { + let backends = backends.clone(); let config_status = config_status.clone(); + let config_saving = config_saving.clone(); let navigate = navigate.clone(); Callback::new(move |_| { let validation = config_validation(); - if !validation.can_continue { + if !validation.can_continue || config_saving.get() { return; } if matches!(config_step.get(), RadrootsAppConfigStep::Preferences) { - config_status.set(RadrootsAppConfigStatus::Configured); - navigate("/", Default::default()); + let Some(config_data) = app_config_flow_build_config(&config_flow()) else { + return; + }; + let Some((datastore, key_maps)) = backends.with(|value| { + value.as_ref().map(|backends| { + ( + backends.datastore.clone(), + backends.config.datastore.key_maps.clone(), + ) + }) + }) else { + return; + }; + let config_status = config_status.clone(); + let config_saving = config_saving.clone(); + let navigate = navigate.clone(); + config_saving.set(true); + spawn_local(async move { + let result = match app_datastore_create_config( + datastore.as_ref(), + &key_maps, + &config_data, + ) + .await + { + Ok(_) => Ok(()), + Err(RadrootsAppConfigStoreError::Record( + RadrootsAppConfigRecordError::AlreadyExists, + )) => app_datastore_update_config( + datastore.as_ref(), + &key_maps, + &config_data, + ) + .await + .map(|_| ()), + Err(err) => Err(err), + }; + match result { + Ok(()) => { + config_status.set(RadrootsAppConfigStatus::Configured); + navigate("/", Default::default()); + } + Err(err) => { + let _ = app_log_error_emit(&err); + config_status.set(RadrootsAppConfigStatus::Corrupt); + } + } + config_saving.set(false); + }); return; } config_step.set(validation.next_step); diff --git a/app/src/config_flow.rs b/app/src/config_flow.rs @@ -1,6 +1,14 @@ #![forbid(unsafe_code)] -use crate::RadrootsAppRole; +use crate::{ + RadrootsAppConfigBusiness, + RadrootsAppConfigData, + RadrootsAppConfigFarmer, + RadrootsAppConfigIndividual, + RadrootsAppConfigPreferences, + RadrootsAppConfigProfile, + RadrootsAppRole, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RadrootsAppConfigStep { @@ -88,6 +96,30 @@ fn has_items(values: &[String]) -> bool { values.iter().any(|value| !value.trim().is_empty()) } +fn normalize_text(value: &str) -> Option<String> { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn normalize_items(values: &[String]) -> Vec<String> { + let mut out: Vec<String> = Vec::new(); + for value in values { + let trimmed = value.trim(); + if trimmed.is_empty() { + continue; + } + if out.iter().any(|item| item.eq_ignore_ascii_case(trimmed)) { + continue; + } + out.push(trimmed.to_string()); + } + out +} + fn role_step_valid(draft: &RadrootsAppConfigFlowDraft) -> bool { match draft.role { Some(RadrootsAppRole::Farm) => { @@ -126,9 +158,86 @@ pub fn app_config_flow_validate(draft: &RadrootsAppConfigFlowDraft) -> RadrootsA } } +pub fn app_config_flow_build_config( + draft: &RadrootsAppConfigFlowDraft, +) -> Option<RadrootsAppConfigData> { + let profile_name = normalize_text(&draft.profile_name)?; + let profile_location = normalize_text(&draft.profile_location)?; + let role = draft.role?; + let profile = RadrootsAppConfigProfile { + name: profile_name, + location: profile_location, + }; + let preferences = RadrootsAppConfigPreferences { + notifications_orders: draft.notifications_orders, + notifications_messages: draft.notifications_messages, + payment_method: normalize_text(&draft.payment_method), + }; + match role { + RadrootsAppRole::Farm => { + let farm_name = normalize_text(&draft.farmer_farm_name)?; + let farm_location = normalize_text(&draft.farmer_location)?; + let products = normalize_items(&draft.farmer_products); + if products.is_empty() { + return None; + } + Some(RadrootsAppConfigData { + profile, + role, + farmer: Some(RadrootsAppConfigFarmer { + farm_name, + farm_location, + products_growing: products, + }), + business: None, + individual: None, + preferences, + }) + } + RadrootsAppRole::Individual => { + let name = normalize_text(&draft.individual_name)?; + let location = normalize_text(&draft.individual_location)?; + let products = normalize_items(&draft.individual_products); + if products.is_empty() { + return None; + } + Some(RadrootsAppConfigData { + profile, + role, + farmer: None, + business: None, + individual: Some(RadrootsAppConfigIndividual { + name, + location, + products_interested: products, + }), + preferences, + }) + } + RadrootsAppRole::Business => { + let name = normalize_text(&draft.business_name)?; + let location = normalize_text(&draft.business_location)?; + let operations = normalize_text(&draft.business_operations)?; + Some(RadrootsAppConfigData { + profile, + role, + farmer: None, + business: Some(RadrootsAppConfigBusiness { + name, + location, + operations, + }), + individual: None, + preferences, + }) + } + } +} + #[cfg(test)] mod tests { use super::{ + app_config_flow_build_config, app_config_flow_next_step, app_config_flow_prev_step, app_config_flow_validate, @@ -176,4 +285,30 @@ mod tests { let validation = app_config_flow_validate(&draft); assert!(validation.can_continue); } + + #[test] + fn flow_build_config_requires_role() { + let draft = RadrootsAppConfigFlowDraft::default(); + assert!(app_config_flow_build_config(&draft).is_none()); + } + + #[test] + fn flow_build_config_maps_farm_values() { + let mut draft = RadrootsAppConfigFlowDraft::default(); + draft.profile_name = String::from("Radroots"); + draft.profile_location = String::from("Valley"); + draft.role = Some(RadrootsAppRole::Farm); + draft.farmer_farm_name = String::from("Willow Farm"); + draft.farmer_location = String::from("Valley"); + draft.farmer_products = vec![ + String::from("tomatoes"), + String::from(" Tomatoes "), + String::from(" "), + ]; + let config = app_config_flow_build_config(&draft).expect("config"); + let farmer = config.farmer.expect("farmer"); + assert_eq!(config.profile.name, "Radroots"); + assert_eq!(config.profile.location, "Valley"); + assert_eq!(farmer.products_growing, vec![String::from("tomatoes")]); + } } diff --git a/app/src/configuration.rs b/app/src/configuration.rs @@ -10,6 +10,7 @@ use crate::{ app_state_timestamp_ms, RadrootsAppConfigError, RadrootsAppKeyMapConfig, + RadrootsAppLoggableError, RadrootsAppRole, }; @@ -263,6 +264,20 @@ impl std::fmt::Display for RadrootsAppConfigStoreError { impl std::error::Error for RadrootsAppConfigStoreError {} +impl RadrootsAppLoggableError for RadrootsAppConfigStoreError { + fn log_code(&self) -> &'static str { + match self { + RadrootsAppConfigStoreError::Datastore(_) => "error.app.config.datastore", + RadrootsAppConfigStoreError::Config(err) => err.message(), + RadrootsAppConfigStoreError::Record(err) => err.message(), + } + } + + fn log_context(&self) -> Option<String> { + Some(self.to_string()) + } +} + pub type RadrootsAppConfigStoreResult<T> = Result<T, RadrootsAppConfigStoreError>; pub async fn app_config_status<T: RadrootsClientDatastore>( diff --git a/app/src/lib.rs b/app/src/lib.rs @@ -254,6 +254,7 @@ pub use config::{ app_datastore_key_setup_lock, }; pub use config_flow::{ + app_config_flow_build_config, app_config_flow_next_step, app_config_flow_prev_step, app_config_flow_validate, diff --git a/app/src/settings.rs b/app/src/settings.rs @@ -2,15 +2,21 @@ use leptos::ev::MouseEvent; use leptos::prelude::*; +use leptos::task::spawn_local; use leptos_router::hooks::use_navigate; use crate::{ app::AppPageChrome, + app_context, + app_datastore_clear_config, + app_log_error_emit, app_theme_apply_mode, app_theme_mode_from_value, app_theme_read_mode, app_theme_store_mode, t, + RadrootsAppBackends, + RadrootsAppConfigStatus, RadrootsAppThemeMode, }; use radroots_app_ui_components::{ @@ -71,6 +77,17 @@ fn settings_label(value: String, classes: Option<&str>) -> RadrootsAppUiListLabe #[component] pub fn RadrootsAppSettingsPage() -> impl IntoView { + let context = app_context(); + let fallback_backends = RwSignal::new_local(None::<RadrootsAppBackends>); + let fallback_config_status = RwSignal::new_local(RadrootsAppConfigStatus::Unknown); + let backends = context + .as_ref() + .map(|value| value.backends) + .unwrap_or(fallback_backends); + let config_status = context + .as_ref() + .map(|value| value.config_status) + .unwrap_or(fallback_config_status); let navigate = use_navigate(); let initial_mode = app_theme_read_mode().unwrap_or(RadrootsAppThemeMode::System); let color_mode_value = initial_mode.as_str().to_string(); @@ -154,6 +171,36 @@ pub fn RadrootsAppSettingsPage() -> impl IntoView { styles: None, }; let logs_navigate = navigate.clone(); + let reconfigure_action = { + let navigate = navigate.clone(); + let backends = backends.clone(); + let config_status = config_status.clone(); + Callback::new(move |_| { + let Some((datastore, key_maps)) = backends.with(|value| { + value.as_ref().map(|backends| { + ( + backends.datastore.clone(), + backends.config.datastore.key_maps.clone(), + ) + }) + }) else { + return; + }; + let navigate = navigate.clone(); + let config_status = config_status.clone(); + spawn_local(async move { + match app_datastore_clear_config(datastore.as_ref(), &key_maps).await { + Ok(()) => { + config_status.set(RadrootsAppConfigStatus::Required); + navigate("/setup/config", Default::default()); + } + Err(err) => { + let _ = app_log_error_emit(&err); + } + } + }); + }) + }; let actions_list = RadrootsAppUiList { id: Some("settings-actions".to_string()), view: Some("settings".to_string()), @@ -189,6 +236,31 @@ pub fn RadrootsAppSettingsPage() -> impl IntoView { Some(RadrootsAppUiListItem { kind: RadrootsAppUiListItemKind::Touch(RadrootsAppUiListTouch { label: RadrootsAppUiListLabel { + left: vec![settings_label( + "update configuration".to_string(), + Some("capitalize"), + )], + right: Vec::new(), + }, + display: None, + end: Some(RadrootsAppUiListTouchEnd { + icon: RadrootsAppUiListIcon { + key: "caret-right".to_string(), + class: None, + }, + on_click: None, + }), + on_click: Some(reconfigure_action), + }), + loading: false, + hide_active: true, + hide_field: false, + full_rounded: false, + offset: None, + }), + Some(RadrootsAppUiListItem { + kind: RadrootsAppUiListItemKind::Touch(RadrootsAppUiListTouch { + label: RadrootsAppUiListLabel { left: vec![settings_label(t!("app.nav.logs"), Some("capitalize"))], right: Vec::new(), },