app

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

commit eb11f38ffd9929e4252c9fad646c844cfd4c405c
parent dbbbdd063afd60a9f13efdff0096dd36e7b25a34
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 08:44:08 +0000

products: add typed screen and editor contracts

Diffstat:
Mcrates/shared/models/src/lib.rs | 409++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/shared/state/src/lib.rs | 351+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 750 insertions(+), 10 deletions(-)

diff --git a/crates/shared/models/src/lib.rs b/crates/shared/models/src/lib.rs @@ -502,12 +502,297 @@ pub enum FarmReadiness { Ready, } -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ProductStatus { + #[default] Draft, Published, Paused, + Archived, +} + +impl ProductStatus { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Draft => "draft", + Self::Published => "published", + Self::Paused => "paused", + Self::Archived => "archived", + } + } + + pub const fn is_live(self) -> bool { + matches!(self, Self::Published) + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductsFilter { + #[default] + All, + Live, + Drafts, + NeedAttention, + Paused, + Archived, +} + +impl ProductsFilter { + pub const fn storage_key(self) -> &'static str { + match self { + Self::All => "all", + Self::Live => "live", + Self::Drafts => "drafts", + Self::NeedAttention => "need_attention", + Self::Paused => "paused", + Self::Archived => "archived", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductsSort { + #[default] + Updated, + Name, + Availability, + Stock, + Price, +} + +impl ProductsSort { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Updated => "updated", + Self::Name => "name", + Self::Availability => "availability", + Self::Stock => "stock", + Self::Price => "price", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductAttentionState { + #[default] + Healthy, + LowStock, + SoldOut, + MissingAvailability, + NoFutureAvailability, + MissingDetails, +} + +impl ProductAttentionState { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Healthy => "healthy", + Self::LowStock => "low_stock", + Self::SoldOut => "sold_out", + Self::MissingAvailability => "missing_availability", + Self::NoFutureAvailability => "no_future_availability", + Self::MissingDetails => "missing_details", + } + } + + pub const fn requires_attention(self) -> bool { + !matches!(self, Self::Healthy) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductAvailabilityState { + Scheduled, + Open, + MissingWindow, + NoFutureWindow, +} + +impl ProductAvailabilityState { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Scheduled => "scheduled", + Self::Open => "open", + Self::MissingWindow => "missing_window", + Self::NoFutureWindow => "no_future_window", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductAvailabilitySummary { + pub state: ProductAvailabilityState, + pub label: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductStockState { + Unset, + InStock, + LowStock, + SoldOut, +} + +impl ProductStockState { + pub const fn storage_key(self) -> &'static str { + match self { + Self::Unset => "unset", + Self::InStock => "in_stock", + Self::LowStock => "low_stock", + Self::SoldOut => "sold_out", + } + } + + pub const fn requires_attention(self) -> bool { + matches!(self, Self::LowStock | Self::SoldOut) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductStockSummary { + pub quantity: Option<u32>, + pub unit_label: Option<String>, + pub state: ProductStockState, +} + +impl ProductStockSummary { + pub const fn requires_attention(&self) -> bool { + self.state.requires_attention() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductPricePresentation { + pub amount_minor_units: u32, + pub currency_code: String, + pub unit_label: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductsListSummary { + pub total_products: u32, + pub live_products: u32, + pub draft_products: u32, + pub need_attention_products: u32, +} + +impl ProductsListSummary { + pub const fn has_products(&self) -> bool { + self.total_products > 0 + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductsListRow { + pub product_id: ProductId, + pub farm_id: FarmId, + pub title: String, + pub subtitle: Option<String>, + pub status: ProductStatus, + pub attention_state: ProductAttentionState, + pub availability: ProductAvailabilitySummary, + pub stock: ProductStockSummary, + pub price: Option<ProductPricePresentation>, + pub updated_at: String, +} + +impl ProductsListRow { + pub const fn requires_attention(&self) -> bool { + self.attention_state.requires_attention() || self.stock.requires_attention() + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductsListProjection { + pub summary: ProductsListSummary, + pub rows: Vec<ProductsListRow>, +} + +impl ProductsListProjection { + pub fn is_empty(&self) -> bool { + self.rows.is_empty() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductPublishBlocker { + AddProductName, + ChooseUnit, + SetPrice, + AttachAvailability, +} + +impl ProductPublishBlocker { + pub const fn storage_key(self) -> &'static str { + match self { + Self::AddProductName => "add_product_name", + Self::ChooseUnit => "choose_unit", + Self::SetPrice => "set_price", + Self::AttachAvailability => "attach_availability", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ProductEditorDraft { + pub title: String, + pub subtitle: String, + pub unit_label: String, + pub price_minor_units: Option<u32>, + pub price_currency: String, + pub stock_quantity: Option<u32>, + pub availability_window_id: Option<FulfillmentWindowId>, + pub status: ProductStatus, +} + +impl Default for ProductEditorDraft { + fn default() -> Self { + Self { + title: String::new(), + subtitle: String::new(), + unit_label: String::new(), + price_minor_units: None, + price_currency: "USD".to_owned(), + stock_quantity: None, + availability_window_id: None, + status: ProductStatus::Draft, + } + } +} + +impl ProductEditorDraft { + pub fn publish_blockers(&self) -> Vec<ProductPublishBlocker> { + let mut blockers = Vec::new(); + + if self.title.trim().is_empty() { + blockers.push(ProductPublishBlocker::AddProductName); + } + + if self.unit_label.trim().is_empty() { + blockers.push(ProductPublishBlocker::ChooseUnit); + } + + if self.price_minor_units.is_none_or(|value| value == 0) { + blockers.push(ProductPublishBlocker::SetPrice); + } + + if self.availability_window_id.is_none() { + blockers.push(ProductPublishBlocker::AttachAvailability); + } + + blockers + } + + pub fn is_publish_ready(&self) -> bool { + self.publish_blockers().is_empty() + } } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -849,9 +1134,12 @@ mod tests { AppIdentityProjection, AppStartupGate, FarmId, FarmOrderMethod, FarmSetupBlocker, FarmSetupDraft, FarmSetupProjection, FarmSetupReadiness, FarmSetupSection, FarmerActivationProjection, FarmerSection, IdentityBlockedReason, IdentityReadiness, - OrderListRow, ProductListRow, SelectedAccountProjection, SelectedSurfaceProjection, - SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, - TodaySetupTaskKind, TodaySummary, + OrderListRow, ProductAttentionState, ProductAvailabilityState, ProductAvailabilitySummary, + ProductEditorDraft, ProductListRow, ProductPricePresentation, ProductPublishBlocker, + ProductStatus, ProductStockState, ProductStockSummary, ProductsFilter, + ProductsListProjection, ProductsListRow, ProductsListSummary, ProductsSort, + SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, + ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; use std::{collections::BTreeSet, str::FromStr}; use uuid::Uuid; @@ -1084,6 +1372,119 @@ mod tests { } #[test] + fn product_status_filter_and_sort_storage_keys_are_stable() { + assert_eq!(ProductStatus::Draft.storage_key(), "draft"); + assert_eq!(ProductStatus::Published.storage_key(), "published"); + assert_eq!(ProductStatus::Paused.storage_key(), "paused"); + assert_eq!(ProductStatus::Archived.storage_key(), "archived"); + assert!(ProductStatus::Published.is_live()); + assert!(!ProductStatus::Draft.is_live()); + + assert_eq!(ProductsFilter::default(), ProductsFilter::All); + assert_eq!(ProductsFilter::All.storage_key(), "all"); + assert_eq!(ProductsFilter::Live.storage_key(), "live"); + assert_eq!(ProductsFilter::Drafts.storage_key(), "drafts"); + assert_eq!( + ProductsFilter::NeedAttention.storage_key(), + "need_attention" + ); + assert_eq!(ProductsFilter::Paused.storage_key(), "paused"); + assert_eq!(ProductsFilter::Archived.storage_key(), "archived"); + + assert_eq!(ProductsSort::default(), ProductsSort::Updated); + assert_eq!(ProductsSort::Updated.storage_key(), "updated"); + assert_eq!(ProductsSort::Name.storage_key(), "name"); + assert_eq!(ProductsSort::Availability.storage_key(), "availability"); + assert_eq!(ProductsSort::Stock.storage_key(), "stock"); + assert_eq!(ProductsSort::Price.storage_key(), "price"); + } + + #[test] + fn product_attention_stock_and_projection_states_are_explicit() { + let row = ProductsListRow { + product_id: super::ProductId::new(), + farm_id: FarmId::new(), + title: "Pea shoots".to_owned(), + subtitle: Some("Tray-grown".to_owned()), + status: ProductStatus::Draft, + attention_state: ProductAttentionState::MissingAvailability, + availability: ProductAvailabilitySummary { + state: ProductAvailabilityState::MissingWindow, + label: "Missing window".to_owned(), + }, + stock: ProductStockSummary { + quantity: None, + unit_label: None, + state: ProductStockState::Unset, + }, + price: Some(ProductPricePresentation { + amount_minor_units: 300, + currency_code: "USD".to_owned(), + unit_label: "bag".to_owned(), + }), + updated_at: "2026-04-18T10:00:00Z".to_owned(), + }; + let summary = ProductsListSummary { + total_products: 1, + live_products: 0, + draft_products: 1, + need_attention_products: 1, + }; + let projection = ProductsListProjection { + summary: summary.clone(), + rows: vec![row.clone()], + }; + + assert_eq!(ProductAttentionState::LowStock.storage_key(), "low_stock"); + assert!(ProductAttentionState::LowStock.requires_attention()); + assert!(!ProductAttentionState::Healthy.requires_attention()); + assert_eq!( + ProductAvailabilityState::MissingWindow.storage_key(), + "missing_window" + ); + assert_eq!(ProductStockState::SoldOut.storage_key(), "sold_out"); + assert!(ProductStockState::SoldOut.requires_attention()); + assert!(!ProductStockState::InStock.requires_attention()); + assert!(row.requires_attention()); + assert!(summary.has_products()); + assert!(!projection.is_empty()); + assert_eq!(projection.rows[0].availability.label, "Missing window"); + } + + #[test] + fn product_editor_publish_blockers_are_explicit_and_minimal() { + let empty_draft = ProductEditorDraft::default(); + let ready_draft = ProductEditorDraft { + title: "Heirloom tomatoes".to_owned(), + subtitle: "Brandywine".to_owned(), + unit_label: "lb".to_owned(), + price_minor_units: Some(450), + price_currency: "USD".to_owned(), + stock_quantity: Some(12), + availability_window_id: Some(super::FulfillmentWindowId::new()), + status: ProductStatus::Draft, + }; + + assert_eq!( + empty_draft.publish_blockers(), + vec![ + ProductPublishBlocker::AddProductName, + ProductPublishBlocker::ChooseUnit, + ProductPublishBlocker::SetPrice, + ProductPublishBlocker::AttachAvailability, + ] + ); + assert_eq!( + ProductPublishBlocker::AttachAvailability.storage_key(), + "attach_availability" + ); + assert_eq!(empty_draft.price_currency, "USD"); + assert!(!empty_draft.is_publish_ready()); + assert!(ready_draft.is_publish_ready()); + assert!(ready_draft.publish_blockers().is_empty()); + } + + #[test] fn today_summary_attention_state_is_explicit() { let quiet = TodaySummary { farm_id: FarmId::new(), diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -2,8 +2,9 @@ use radroots_app_models::{ ActiveSurface, AppIdentityProjection, AppStartupGate, FarmSetupProjection, FarmSetupReadiness, - SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, SettingsSection, - ShellSection, TodayAgendaProjection, + ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, + ProductsSort, SelectedSurfaceProjection, SettingsAccountProjection, SettingsPreference, + SettingsSection, ShellSection, TodayAgendaProjection, }; use thiserror::Error; @@ -66,6 +67,120 @@ impl SettingsShellProjection { } } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProductsScreenQueryState { + pub search_query: String, + pub filter: ProductsFilter, + pub sort: ProductsSort, +} + +impl Default for ProductsScreenQueryState { + fn default() -> Self { + Self { + search_query: String::new(), + filter: ProductsFilter::default(), + sort: ProductsSort::default(), + } + } +} + +impl ProductsScreenQueryState { + pub fn new( + search_query: impl Into<String>, + filter: ProductsFilter, + sort: ProductsSort, + ) -> Self { + Self { + search_query: search_query.into(), + filter, + sort, + } + } + + fn set_search_query(&mut self, search_query: impl Into<String>) { + self.search_query = search_query.into(); + } + + fn select_filter(&mut self, filter: ProductsFilter) { + self.filter = filter; + } + + fn select_sort(&mut self, sort: ProductsSort) { + self.sort = sort; + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProductEditorSession { + pub selected_product_id: Option<ProductId>, + pub draft: ProductEditorDraft, + pub publish_blockers: Vec<ProductPublishBlocker>, +} + +impl ProductEditorSession { + fn new_draft() -> Self { + Self::from_selection(None, ProductEditorDraft::default()) + } + + fn existing(product_id: ProductId, draft: ProductEditorDraft) -> Self { + Self::from_selection(Some(product_id), draft) + } + + fn from_selection(selected_product_id: Option<ProductId>, draft: ProductEditorDraft) -> Self { + let publish_blockers = draft.publish_blockers(); + + Self { + selected_product_id, + draft, + publish_blockers, + } + } + + fn replace_draft(&mut self, draft: ProductEditorDraft) { + self.publish_blockers = draft.publish_blockers(); + self.draft = draft; + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ProductEditorState { + Closed, + Open(ProductEditorSession), +} + +impl Default for ProductEditorState { + fn default() -> Self { + Self::Closed + } +} + +impl ProductEditorState { + fn open_new_draft(&mut self) { + *self = Self::Open(ProductEditorSession::new_draft()); + } + + fn open_existing(&mut self, product_id: ProductId, draft: ProductEditorDraft) { + *self = Self::Open(ProductEditorSession::existing(product_id, draft)); + } + + fn replace_draft(&mut self, draft: ProductEditorDraft) { + if let Self::Open(session) = self { + session.replace_draft(draft); + } + } + + fn close(&mut self) { + *self = Self::Closed; + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProductsScreenProjection { + pub list: ProductsListProjection, + pub query: ProductsScreenQueryState, + pub editor: ProductEditorState, +} + #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum FarmSetupFlowStage { #[default] @@ -167,6 +282,7 @@ pub struct AppProjection { pub identity: AppIdentityProjection, pub startup_gate: AppStartupGate, pub today: TodayAgendaProjection, + pub products: ProductsScreenProjection, pub farm_setup: FarmSetupProjection, pub farm_setup_flow_stage: FarmSetupFlowStage, } @@ -191,6 +307,7 @@ impl AppProjection { identity, startup_gate: AppStartupGate::default(), today, + products: ProductsScreenProjection::default(), farm_setup, farm_setup_flow_stage: FarmSetupFlowStage::default(), }; @@ -239,6 +356,17 @@ pub enum AppStateCommand { enabled: bool, }, ReplaceTodayAgenda(TodayAgendaProjection), + SetProductsSearchQuery(String), + SelectProductsFilter(ProductsFilter), + SelectProductsSort(ProductsSort), + ReplaceProductsList(ProductsListProjection), + OpenNewProductEditor, + OpenExistingProductEditor { + product_id: ProductId, + draft: ProductEditorDraft, + }, + ReplaceProductEditorDraft(ProductEditorDraft), + CloseProductEditor, } impl AppStateCommand { @@ -265,6 +393,38 @@ impl AppStateCommand { pub fn replace_today_agenda(projection: TodayAgendaProjection) -> Self { Self::ReplaceTodayAgenda(projection) } + + pub fn set_products_search_query(search_query: impl Into<String>) -> Self { + Self::SetProductsSearchQuery(search_query.into()) + } + + pub const fn select_products_filter(filter: ProductsFilter) -> Self { + Self::SelectProductsFilter(filter) + } + + pub const fn select_products_sort(sort: ProductsSort) -> Self { + Self::SelectProductsSort(sort) + } + + pub fn replace_products_list(projection: ProductsListProjection) -> Self { + Self::ReplaceProductsList(projection) + } + + pub const fn open_new_product_editor() -> Self { + Self::OpenNewProductEditor + } + + pub fn open_existing_product_editor(product_id: ProductId, draft: ProductEditorDraft) -> Self { + Self::OpenExistingProductEditor { product_id, draft } + } + + pub fn replace_product_editor_draft(draft: ProductEditorDraft) -> Self { + Self::ReplaceProductEditorDraft(draft) + } + + pub const fn close_product_editor() -> Self { + Self::CloseProductEditor + } } #[derive(Clone, Debug, Eq, Error, PartialEq)] @@ -383,6 +543,10 @@ impl<R: AppStateRepository> AppStateStore<R> { &self.projection.farm_setup } + pub fn products_projection(&self) -> &ProductsScreenProjection { + &self.projection.products + } + pub fn home_route(&self) -> HomeRoute { self.projection.home_route() } @@ -421,6 +585,11 @@ impl<R: AppStateRepository> AppStateStore<R> { Ok(true) } + AppStateMutation::ProductsChanged => { + self.projection = next_projection; + + Ok(true) + } } } } @@ -458,6 +627,11 @@ impl AppStateStore<InMemoryAppStateRepository> { true } + AppStateMutation::ProductsChanged => { + self.projection = next_projection; + + true + } } } } @@ -468,6 +642,7 @@ enum AppStateMutation { ShellChanged, FarmSetupChanged, TodayChanged, + ProductsChanged, } fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> AppStateMutation { @@ -514,6 +689,30 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap AppStateCommand::ReplaceTodayAgenda(today_projection) => { projection.today = today_projection; } + AppStateCommand::SetProductsSearchQuery(search_query) => { + projection.products.query.set_search_query(search_query); + } + AppStateCommand::SelectProductsFilter(filter) => { + projection.products.query.select_filter(filter); + } + AppStateCommand::SelectProductsSort(sort) => { + projection.products.query.select_sort(sort); + } + AppStateCommand::ReplaceProductsList(products_projection) => { + projection.products.list = products_projection; + } + AppStateCommand::OpenNewProductEditor => { + projection.products.editor.open_new_draft(); + } + AppStateCommand::OpenExistingProductEditor { product_id, draft } => { + projection.products.editor.open_existing(product_id, draft); + } + AppStateCommand::ReplaceProductEditorDraft(draft) => { + projection.products.editor.replace_draft(draft); + } + AppStateCommand::CloseProductEditor => { + projection.products.editor.close(); + } } sync_projection(projection); @@ -526,6 +725,8 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap || projection.farm_setup_flow_stage != before.farm_setup_flow_stage { AppStateMutation::FarmSetupChanged + } else if projection.products != before.products { + AppStateMutation::ProductsChanged } else { AppStateMutation::TodayChanged } @@ -582,14 +783,16 @@ mod tests { use super::{ AppProjection, AppShellProjection, AppStateCommand, AppStateRepository, AppStateRepositoryError, AppStateStore, AppStateStoreError, FarmSetupFlowStage, HomeRoute, - InMemoryAppStateRepository, SettingsPreference, + InMemoryAppStateRepository, ProductEditorState, ProductsScreenProjection, + ProductsScreenQueryState, SettingsPreference, }; use radroots_app_models::{ AccountCustody, AccountSummary, ActiveSurface, AppIdentityProjection, AppStartupGate, FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, - FarmerActivationProjection, FarmerSection, SelectedAccountProjection, - SelectedSurfaceProjection, SettingsSection, ShellSection, TodayAgendaProjection, - TodaySetupTask, TodaySetupTaskKind, + FarmerActivationProjection, FarmerSection, FulfillmentWindowId, ProductEditorDraft, + ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, + SelectedAccountProjection, SelectedSurfaceProjection, SettingsSection, ShellSection, + TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; struct FailingRepository; @@ -640,6 +843,7 @@ mod tests { assert!(projection.shell.settings.general.use_nip05); assert!(!projection.shell.settings.general.launch_at_login); assert_eq!(projection.today, TodayAgendaProjection::default()); + assert_eq!(projection.products, ProductsScreenProjection::default()); assert_eq!(projection.farm_setup, FarmSetupProjection::default()); assert_eq!( projection.farm_setup_flow_stage, @@ -670,10 +874,145 @@ mod tests { ); assert_eq!(store.startup_gate(), AppStartupGate::SetupRequired); assert_eq!(store.projection().today, TodayAgendaProjection::default()); + assert_eq!( + store.projection().products, + ProductsScreenProjection::default() + ); assert_eq!(store.home_route(), HomeRoute::SetupRequired); } #[test] + fn products_query_defaults_and_refreshes_are_local_app_state() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + let products_list = ProductsListProjection { + summary: radroots_app_models::ProductsListSummary { + total_products: 2, + live_products: 1, + draft_products: 1, + need_attention_products: 1, + }, + rows: Vec::new(), + }; + + assert_eq!( + store.projection().products.query, + ProductsScreenQueryState::default() + ); + + assert_eq!( + store.apply(AppStateCommand::set_products_search_query("pea")), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::select_products_filter( + ProductsFilter::NeedAttention, + )), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::select_products_sort(ProductsSort::Name)), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::replace_products_list( + products_list.clone() + )), + Ok(true) + ); + assert_eq!( + store.projection().products.query, + ProductsScreenQueryState::new("pea", ProductsFilter::NeedAttention, ProductsSort::Name) + ); + assert_eq!(store.projection().products.list, products_list); + assert_eq!( + store.repository().projection(), + &AppShellProjection::default() + ); + } + + #[test] + fn product_editor_state_transitions_are_explicit() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + let product_id = ProductId::new(); + let ready_draft = ProductEditorDraft { + title: "Heirloom tomatoes".to_owned(), + subtitle: "Brandywine".to_owned(), + unit_label: "lb".to_owned(), + price_minor_units: Some(450), + price_currency: "USD".to_owned(), + stock_quantity: Some(12), + availability_window_id: Some(FulfillmentWindowId::new()), + status: radroots_app_models::ProductStatus::Draft, + }; + + assert_eq!( + store.apply(AppStateCommand::open_new_product_editor()), + Ok(true) + ); + assert_eq!( + store.projection().products.editor, + ProductEditorState::Open(super::ProductEditorSession { + selected_product_id: None, + draft: ProductEditorDraft::default(), + publish_blockers: vec![ + ProductPublishBlocker::AddProductName, + ProductPublishBlocker::ChooseUnit, + ProductPublishBlocker::SetPrice, + ProductPublishBlocker::AttachAvailability, + ], + }) + ); + + assert_eq!( + store.apply(AppStateCommand::replace_product_editor_draft( + ready_draft.clone(), + )), + Ok(true) + ); + assert_eq!( + store.projection().products.editor, + ProductEditorState::Open(super::ProductEditorSession { + selected_product_id: None, + draft: ready_draft.clone(), + publish_blockers: Vec::new(), + }) + ); + + assert_eq!( + store.apply(AppStateCommand::open_existing_product_editor( + product_id, + ready_draft.clone(), + )), + Ok(true) + ); + assert_eq!( + store.projection().products.editor, + ProductEditorState::Open(super::ProductEditorSession { + selected_product_id: Some(product_id), + draft: ready_draft, + publish_blockers: Vec::new(), + }) + ); + + assert_eq!( + store.apply(AppStateCommand::close_product_editor()), + Ok(true) + ); + assert_eq!( + store.projection().products.editor, + ProductEditorState::Closed + ); + assert_eq!( + store.apply(AppStateCommand::replace_product_editor_draft( + ProductEditorDraft::default(), + )), + Ok(false) + ); + } + + #[test] fn select_settings_section_updates_shared_settings_without_clobbering_home() { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load");