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:
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");