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:
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(),
},