commit aa104f446918b4027708157290c04b30268fe9cd
parent 478962e802cddf487f49d7ec92f8b821e60dbe02
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Apr 2026 17:54:21 +0000
desktop: add product editor flow
Diffstat:
5 files changed, 1273 insertions(+), 80 deletions(-)
diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs
@@ -5,8 +5,9 @@ use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedA
use radroots_app_models::{
ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate,
FarmId, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection,
- ProductId, ProductsFilter, ProductsListProjection, ProductsSort, SettingsAccountProjection,
- SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection,
+ ProductEditorDraft, ProductId, ProductsFilter, ProductsListProjection, ProductsSort,
+ SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection,
+ TodayAgendaProjection,
};
use radroots_app_sqlite::{
APP_ACTIVITY_CONTEXT_LIMIT, AppSqliteError, AppSqliteStore, DatabaseTarget,
@@ -95,9 +96,12 @@ impl DesktopAppRuntime {
}
};
- state
+ let section_changed = state
.state_store
- .apply_in_memory(AppStateCommand::SelectSection(selected_section))
+ .apply_in_memory(AppStateCommand::SelectSection(selected_section));
+ let editor_changed = state.close_product_editor();
+
+ section_changed || editor_changed
}
pub fn select_farmer_section(&self, section: FarmerSection) -> bool {
@@ -130,6 +134,29 @@ impl DesktopAppRuntime {
.update_product_stock(product_id, stock_quantity)
}
+ pub fn open_new_product_editor(&self) -> Result<bool, AppSqliteError> {
+ self.lock_state_mut().open_new_product_editor()
+ }
+
+ pub fn open_existing_product_editor(
+ &self,
+ product_id: ProductId,
+ ) -> Result<bool, AppSqliteError> {
+ self.lock_state_mut()
+ .open_existing_product_editor(product_id)
+ }
+
+ pub fn save_product_editor_draft(
+ &self,
+ draft: ProductEditorDraft,
+ ) -> Result<bool, AppSqliteError> {
+ self.lock_state_mut().save_product_editor_draft(draft)
+ }
+
+ pub fn close_product_editor(&self) -> bool {
+ self.lock_state_mut().close_product_editor()
+ }
+
pub fn set_settings_preference(&self, preference: SettingsPreference, enabled: bool) -> bool {
let changed = self.lock_state_mut().state_store.apply_in_memory(
AppStateCommand::SetSettingsPreference {
@@ -453,10 +480,14 @@ impl DesktopAppRuntimeState {
fn select_farmer_section(&mut self, section: FarmerSection) -> bool {
match section {
FarmerSection::Today => {
- self.state_store
- .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer(
- FarmerSection::Today,
- )))
+ let section_changed =
+ self.state_store
+ .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer(
+ FarmerSection::Today,
+ )));
+ let editor_changed = self.close_product_editor();
+
+ section_changed || editor_changed
}
FarmerSection::Products
if self.state_store.farm_setup_projection().has_saved_farm() =>
@@ -555,6 +586,105 @@ impl DesktopAppRuntimeState {
Ok(updated || context_changed)
}
+ fn open_new_product_editor(&mut self) -> Result<bool, AppSqliteError> {
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Ok(false);
+ };
+ let Some(farm_id) = self.selected_farm_id() else {
+ return Ok(false);
+ };
+
+ let product_id = sqlite_store.create_product_draft(farm_id)?;
+ let Some(draft) = sqlite_store.load_product_editor_draft(product_id)? else {
+ return Ok(false);
+ };
+ let selected_account_context = load_selected_account_context(
+ sqlite_store,
+ self.state_store.identity_projection(),
+ self.state_store.products_projection().query.clone(),
+ )?;
+ let context_changed = self.apply_selected_account_context(&selected_account_context);
+ let section_changed = self.select_farmer_section(FarmerSection::Products);
+ let editor_changed =
+ self.state_store
+ .apply_in_memory(AppStateCommand::open_existing_product_editor(
+ product_id, draft,
+ ));
+
+ Ok(context_changed || section_changed || editor_changed)
+ }
+
+ fn open_existing_product_editor(
+ &mut self,
+ product_id: ProductId,
+ ) -> Result<bool, AppSqliteError> {
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Ok(false);
+ };
+ let Some(draft) = sqlite_store.load_product_editor_draft(product_id)? else {
+ return Ok(false);
+ };
+ let section_changed = self.select_farmer_section(FarmerSection::Products);
+ let editor_changed =
+ self.state_store
+ .apply_in_memory(AppStateCommand::open_existing_product_editor(
+ product_id, draft,
+ ));
+
+ Ok(section_changed || editor_changed)
+ }
+
+ fn save_product_editor_draft(
+ &mut self,
+ draft: ProductEditorDraft,
+ ) -> Result<bool, AppSqliteError> {
+ let Some(product_id) = self.selected_product_editor_id() else {
+ return Ok(false);
+ };
+
+ let saved = {
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Ok(false);
+ };
+ sqlite_store.save_product_editor_draft(product_id, &draft)?
+ };
+ if !saved {
+ return Ok(false);
+ }
+
+ let selected_account_context = {
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Ok(false);
+ };
+ load_selected_account_context(
+ sqlite_store,
+ self.state_store.identity_projection(),
+ self.state_store.products_projection().query.clone(),
+ )?
+ };
+ let reloaded_draft = {
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Ok(false);
+ };
+ sqlite_store
+ .load_product_editor_draft(product_id)?
+ .unwrap_or(draft)
+ };
+ let context_changed = self.apply_selected_account_context(&selected_account_context);
+ let editor_changed =
+ self.state_store
+ .apply_in_memory(AppStateCommand::replace_product_editor_draft(
+ reloaded_draft,
+ ));
+
+ Ok(saved || context_changed || editor_changed)
+ }
+
+ fn close_product_editor(&mut self) -> bool {
+ self.state_store
+ .apply_in_memory(AppStateCommand::close_product_editor())
+ }
+
fn save_farm_setup_draft(
&mut self,
draft: FarmSetupDraft,
@@ -613,8 +743,9 @@ impl DesktopAppRuntimeState {
.state_store
.apply_in_memory(AppStateCommand::replace_identity_projection(projection));
let context_changed = self.apply_selected_account_context(&selected_account_context);
+ let editor_changed = self.close_product_editor();
- Ok(identity_changed || context_changed)
+ Ok(identity_changed || context_changed || editor_changed)
}
fn refresh_selected_account_context(
@@ -643,9 +774,14 @@ impl DesktopAppRuntimeState {
.apply_in_memory(AppStateCommand::replace_products_list(
context.products_list.clone(),
));
+ let editor_changed = if context.farm_setup_projection.has_saved_farm() {
+ false
+ } else {
+ self.close_product_editor()
+ };
let shell_changed = self.sync_truthful_farmer_section();
- farm_setup_changed || today_changed || products_changed || shell_changed
+ farm_setup_changed || today_changed || products_changed || editor_changed || shell_changed
}
fn selected_account_id(&self) -> Result<String, DesktopAppRuntimeFarmSetupError> {
@@ -709,6 +845,27 @@ impl DesktopAppRuntimeState {
Ok(query_changed || filter_changed || sort_changed || list_changed)
}
+ fn selected_farm_id(&self) -> Option<FarmId> {
+ self.state_store
+ .identity_projection()
+ .selected_account
+ .as_ref()
+ .and_then(|account| account.farmer_activation.farm_id)
+ .or(self
+ .state_store
+ .farm_setup_projection()
+ .saved_farm
+ .as_ref()
+ .map(|farm| farm.farm_id))
+ }
+
+ fn selected_product_editor_id(&self) -> Option<ProductId> {
+ match &self.state_store.products_projection().editor {
+ radroots_app_state::ProductEditorState::Open(session) => session.selected_product_id,
+ radroots_app_state::ProductEditorState::Closed => None,
+ }
+ }
+
fn load_products_list_for_query(
&self,
query: &ProductsScreenQueryState,
@@ -716,21 +873,7 @@ impl DesktopAppRuntimeState {
let Some(sqlite_store) = self.sqlite_store.as_ref() else {
return Ok(ProductsListProjection::default());
};
- let Some(selected_account) = self
- .state_store
- .identity_projection()
- .selected_account
- .as_ref()
- else {
- return Ok(ProductsListProjection::default());
- };
- let farm_id = selected_account.farmer_activation.farm_id.or(self
- .state_store
- .farm_setup_projection()
- .saved_farm
- .as_ref()
- .map(|farm| farm.farm_id));
- let Some(farm_id) = farm_id else {
+ let Some(farm_id) = self.selected_farm_id() else {
return Ok(ProductsListProjection::default());
};
@@ -860,9 +1003,10 @@ mod tests {
use radroots_app_models::{
AccountSurfaceActivationProjection, ActiveSurface, AppActivityKind, AppStartupGate, FarmId,
FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary,
- FarmerActivationProjection, FarmerSection, ProductsFilter, ProductsSort,
- SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection,
- TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary,
+ FarmerActivationProjection, FarmerSection, ProductEditorDraft, ProductStatus,
+ ProductsFilter, ProductsSort, SelectedSurfaceProjection, SettingsPreference,
+ SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind,
+ TodaySummary,
};
use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget};
use radroots_app_state::{
@@ -1516,6 +1660,197 @@ mod tests {
}
#[test]
+ fn runtime_open_new_product_editor_creates_a_local_draft_and_opens_it() {
+ let runtime = memory_runtime();
+
+ assert!(
+ runtime
+ .generate_local_account(Some("Farmer".to_owned()))
+ .expect("account should generate")
+ );
+ let account_id = runtime
+ .summary()
+ .settings_account_projection
+ .selected_account
+ .as_ref()
+ .expect("selected account")
+ .account
+ .account_id
+ .clone();
+ let farm_id =
+ save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer);
+ let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary {
+ farm_id,
+ display_name: "North field farm".to_owned(),
+ readiness: FarmReadiness::Ready,
+ });
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .save_farm_summary(
+ farm_setup_projection
+ .saved_farm
+ .as_ref()
+ .expect("saved farm should exist"),
+ )
+ .expect("farm summary should save");
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .save_farm_setup(account_id.as_str(), &farm_setup_projection)
+ .expect("farm setup should save");
+
+ assert!(
+ runtime
+ .select_local_account(account_id.as_str())
+ .expect("account should select")
+ );
+ assert_eq!(
+ runtime
+ .summary()
+ .products_projection
+ .list
+ .summary
+ .total_products,
+ 0
+ );
+
+ assert!(
+ runtime
+ .open_new_product_editor()
+ .expect("new product editor should open")
+ );
+
+ let summary = runtime.summary();
+ assert_eq!(summary.products_projection.list.summary.total_products, 1);
+ assert!(matches!(
+ summary.products_projection.editor,
+ radroots_app_state::ProductEditorState::Open(_)
+ ));
+ assert_eq!(
+ summary.products_projection.list.rows[0].status,
+ ProductStatus::Draft
+ );
+ }
+
+ #[test]
+ fn runtime_open_existing_and_save_product_editor_refreshes_products_projection() {
+ let runtime = memory_runtime();
+
+ assert!(
+ runtime
+ .generate_local_account(Some("Farmer".to_owned()))
+ .expect("account should generate")
+ );
+ let account_id = runtime
+ .summary()
+ .settings_account_projection
+ .selected_account
+ .as_ref()
+ .expect("selected account")
+ .account
+ .account_id
+ .clone();
+ let farm_id =
+ save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer);
+ let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary {
+ farm_id,
+ display_name: "North field farm".to_owned(),
+ readiness: FarmReadiness::Ready,
+ });
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .save_farm_summary(
+ farm_setup_projection
+ .saved_farm
+ .as_ref()
+ .expect("saved farm should exist"),
+ )
+ .expect("farm summary should save");
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .save_farm_setup(account_id.as_str(), &farm_setup_projection)
+ .expect("farm setup should save");
+ let product_id = seed_product(
+ &runtime,
+ farm_id,
+ "Salad mix",
+ "Spring blend",
+ "draft",
+ Some(2),
+ "2026-04-18T10:00:00Z",
+ );
+
+ assert!(
+ runtime
+ .select_local_account(account_id.as_str())
+ .expect("account should select")
+ );
+ assert!(
+ runtime
+ .open_existing_product_editor(product_id)
+ .expect("existing product editor should open")
+ );
+
+ let saved_draft = ProductEditorDraft {
+ title: "Salad mix".to_owned(),
+ subtitle: "Washed and boxed".to_owned(),
+ unit_label: "box".to_owned(),
+ price_minor_units: Some(900),
+ price_currency: "usd".to_owned(),
+ stock_quantity: Some(14),
+ availability_window_id: None,
+ status: radroots_app_models::ProductStatus::Published,
+ };
+
+ assert!(
+ runtime
+ .save_product_editor_draft(saved_draft.clone())
+ .expect("product editor draft should save")
+ );
+
+ let summary = runtime.summary();
+ assert_eq!(
+ summary.products_projection.list.rows[0].subtitle.as_deref(),
+ Some("Washed and boxed")
+ );
+ assert_eq!(
+ summary.products_projection.list.rows[0]
+ .price
+ .as_ref()
+ .map(|price| price.amount_minor_units),
+ Some(900)
+ );
+ assert_eq!(
+ summary.products_projection.list.rows[0].stock.quantity,
+ Some(14)
+ );
+ assert_eq!(
+ runtime
+ .lock_state()
+ .sqlite_store
+ .as_ref()
+ .expect("sqlite store")
+ .load_product_editor_draft(product_id)
+ .expect("saved draft should load"),
+ Some(ProductEditorDraft {
+ price_currency: "USD".to_owned(),
+ ..saved_draft
+ })
+ );
+ }
+
+ #[test]
fn runtime_account_commands_refresh_identity_projection() {
let runtime = memory_runtime();
@@ -2067,7 +2402,8 @@ mod tests {
status: &str,
stock_count: Option<u32>,
updated_at: &str,
- ) {
+ ) -> radroots_app_models::ProductId {
+ let product_id = radroots_app_models::ProductId::new();
let stock_count = stock_count
.map(|value| value.to_string())
.unwrap_or_else(|| "null".to_owned());
@@ -2097,7 +2433,7 @@ mod tests {
null,
'{updated_at}'
)",
- product_id = radroots_app_models::ProductId::new(),
+ product_id = product_id,
farm_id = farm_id,
title = title,
subtitle = subtitle,
@@ -2113,6 +2449,8 @@ mod tests {
.connection()
.execute_batch(&sql)
.expect("product should seed");
+
+ product_id
}
fn cleanup_paths(paths: &AppSharedAccountsPaths) {
diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs
@@ -3,14 +3,31 @@ use std::collections::BTreeSet;
const ALLOWED_MENU_LITERALS: &[&str] = &["cmd-q", "settings window should open"];
const ALLOWED_WINDOW_LITERALS: &[&str] = &[
+ "",
+ " ",
"${dollars}.{cents:02} / {}",
", ",
"0",
+ "14",
+ "14.5",
+ "6",
+ "6.",
+ "6.5",
+ "6.50",
+ "6.500",
+ "Salad mix",
+ "USD",
+ "Untitled draft",
+ "{}.{:02}",
+ "abc",
"account-add",
"account-open-workspace",
"account-log-out",
"account-more",
"failed to add relay `{relay_url}`: {error}",
+ "failed to open existing product editor",
+ "failed to open new product editor",
+ "failed to save product editor draft",
"failed to route into products view",
"failed to update product stock",
"failed to update products filter",
@@ -37,11 +54,22 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"products-filter-need-attention",
"products-filter-paused",
"products-row-stock-action",
+ "products-row-open",
"products-sort-availability",
"products-sort-name",
"products-sort-price",
"products-sort-stock",
"products-sort-updated",
+ "products-add-product",
+ "products-editor-close",
+ "products-editor-save",
+ "products-editor-status-archived",
+ "products-editor-status-draft",
+ "products-editor-status-live",
+ "products-editor-status-paused",
+ "products.editor_open_failed",
+ "products.editor_save_failed",
+ "products.new_editor_open_failed",
"products-stock-editor-cancel",
"products-stock-editor-close",
"products-stock-editor-save",
@@ -115,7 +143,28 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[
"AppTextKey::ProductsColumnPrice",
"AppTextKey::ProductsColumnUpdated",
"AppTextKey::ProductsColumnAction",
+ "AppTextKey::ProductsAddAction",
"AppTextKey::ProductsUpdateStockAction",
+ "AppTextKey::ProductsEditorTitle",
+ "AppTextKey::ProductsEditorBody",
+ "AppTextKey::ProductsEditorFieldTitle",
+ "AppTextKey::ProductsEditorFieldSubtitle",
+ "AppTextKey::ProductsEditorFieldUnit",
+ "AppTextKey::ProductsEditorFieldPrice",
+ "AppTextKey::ProductsEditorFieldStock",
+ "AppTextKey::ProductsEditorFieldStatus",
+ "AppTextKey::ProductsEditorCloseAction",
+ "AppTextKey::ProductsEditorSaveAction",
+ "AppTextKey::ProductsEditorSaveFailed",
+ "AppTextKey::ProductsEditorInvalidPrice",
+ "AppTextKey::ProductsEditorInvalidStock",
+ "AppTextKey::ProductsEditorPublishReadinessTitle",
+ "AppTextKey::ProductsEditorReady",
+ "AppTextKey::ProductsEditorBlockerAddProductName",
+ "AppTextKey::ProductsEditorBlockerChooseUnit",
+ "AppTextKey::ProductsEditorBlockerSetPrice",
+ "AppTextKey::ProductsEditorBlockerAttachAvailability",
+ "AppTextKey::ProductsUntitledDraft",
"AppTextKey::ProductsStockEditorTitle",
"AppTextKey::ProductsStockEditorFieldLabel",
"AppTextKey::ProductsStockEditorSaveAction",
diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs
@@ -14,9 +14,10 @@ use radroots_app_i18n::AppTextKey;
pub use radroots_app_models::SettingsSection as SettingsPanelViewKey;
use radroots_app_models::{
AppStartupGate, FarmOrderMethod, FarmReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSummary,
- FarmerSection, FulfillmentWindowSummary, OrderListRow, ProductAttentionState, ProductId,
- ProductListRow, ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, ShellSection,
- TodayAgendaProjection, TodaySetupTaskKind,
+ FarmerSection, FulfillmentWindowSummary, OrderListRow, ProductAttentionState,
+ ProductEditorDraft, ProductId, ProductListRow, ProductPublishBlocker, ProductStatus,
+ ProductsFilter, ProductsListRow, ProductsSort, ShellSection, TodayAgendaProjection,
+ TodaySetupTaskKind,
};
use radroots_app_state::{FarmSetupFlowStage, HomeRoute};
use radroots_app_ui::{
@@ -158,6 +159,7 @@ pub struct HomeView {
farm_setup_form: Option<FarmSetupFormState>,
products_search: Option<ProductsSearchState>,
products_stock_editor: Option<ProductsStockEditorState>,
+ product_editor_form: Option<ProductEditorFormState>,
relay_client: Option<RadrootsNostrClient>,
}
@@ -170,6 +172,7 @@ impl HomeView {
farm_setup_form: None,
products_search: None,
products_stock_editor: None,
+ product_editor_form: None,
relay_client: None,
}
}
@@ -361,9 +364,62 @@ impl HomeView {
}
}
+ fn sync_product_editor_form(
+ &mut self,
+ runtime_summary: &DesktopAppRuntimeSummary,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(account_id) = runtime_summary
+ .settings_account_projection
+ .selected_account
+ .as_ref()
+ .map(|account| account.account.account_id.clone())
+ else {
+ self.product_editor_form = None;
+ return;
+ };
+
+ if selected_farmer_section(runtime_summary) != FarmerSection::Products
+ || !runtime_summary.farm_setup_projection.has_saved_farm()
+ {
+ self.product_editor_form = None;
+ return;
+ }
+
+ let radroots_app_state::ProductEditorState::Open(session) =
+ &runtime_summary.products_projection.editor
+ else {
+ self.product_editor_form = None;
+ return;
+ };
+ let Some(product_id) = session.selected_product_id else {
+ self.product_editor_form = None;
+ return;
+ };
+ let should_reset = self
+ .product_editor_form
+ .as_ref()
+ .map(|form| form.account_id != account_id || form.product_id != product_id)
+ .unwrap_or(true);
+
+ if should_reset {
+ self.product_editor_form = Some(ProductEditorFormState::new(
+ account_id,
+ product_id,
+ session.draft.clone(),
+ window,
+ cx,
+ ));
+ }
+ }
+
fn select_farmer_section(&mut self, section: FarmerSection, cx: &mut Context<Self>) {
if self.runtime.select_farmer_section(section) {
self.products_stock_editor = None;
+ if section != FarmerSection::Products {
+ self.product_editor_form = None;
+ }
cx.notify();
}
}
@@ -458,6 +514,7 @@ impl HomeView {
window: &mut Window,
cx: &mut Context<Self>,
) {
+ let _ = self.runtime.close_product_editor();
let Some(account_id) = self
.runtime
.summary()
@@ -487,6 +544,7 @@ impl HomeView {
window,
cx,
));
+ self.product_editor_form = None;
cx.notify();
}
@@ -557,6 +615,126 @@ impl HomeView {
}
}
+ fn open_new_product_editor(&mut self, cx: &mut Context<Self>) {
+ match self.runtime.open_new_product_editor() {
+ Ok(true) => {
+ self.products_stock_editor = None;
+ cx.notify();
+ }
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "products",
+ event = "products.new_editor_open_failed",
+ error = %runtime_error,
+ "failed to open new product editor"
+ );
+ }
+ }
+ }
+
+ fn open_existing_product_editor(&mut self, product_id: ProductId, cx: &mut Context<Self>) {
+ match self.runtime.open_existing_product_editor(product_id) {
+ Ok(true) => {
+ self.products_stock_editor = None;
+ cx.notify();
+ }
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "products",
+ event = "products.editor_open_failed",
+ error = %runtime_error,
+ product_id = %product_id,
+ "failed to open existing product editor"
+ );
+ }
+ }
+ }
+
+ fn close_product_editor(&mut self, cx: &mut Context<Self>) {
+ let changed = self.runtime.close_product_editor();
+ let cleared = self.product_editor_form.take().is_some();
+
+ if changed || cleared {
+ cx.notify();
+ }
+ }
+
+ fn handle_product_editor_input_event(
+ &mut self,
+ state: &Entity<InputState>,
+ event: &InputEvent,
+ _: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if !matches!(event, InputEvent::Change) {
+ return;
+ }
+
+ let Some(form) = self.product_editor_form.as_mut() else {
+ return;
+ };
+ let matches_input = form.title_input == *state
+ || form.subtitle_input == *state
+ || form.unit_input == *state
+ || form.price_input == *state
+ || form.stock_input == *state;
+
+ if !matches_input {
+ return;
+ }
+
+ if form.save_failed {
+ form.save_failed = false;
+ }
+
+ cx.notify();
+ }
+
+ fn select_product_editor_status(&mut self, status: ProductStatus, cx: &mut Context<Self>) {
+ let Some(form) = self.product_editor_form.as_mut() else {
+ return;
+ };
+
+ if form.status == status {
+ return;
+ }
+
+ form.status = status;
+ form.save_failed = false;
+ cx.notify();
+ }
+
+ fn save_product_editor(&mut self, cx: &mut Context<Self>) {
+ let Some(form) = self.product_editor_form.as_mut() else {
+ return;
+ };
+ let Some(draft) = form.current_draft(cx) else {
+ return;
+ };
+
+ match self.runtime.save_product_editor_draft(draft.clone()) {
+ Ok(true) => {
+ form.initial_draft = draft;
+ form.save_failed = false;
+ cx.notify();
+ }
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "products",
+ event = "products.editor_save_failed",
+ error = %runtime_error,
+ product_id = %form.product_id,
+ "failed to save product editor draft"
+ );
+ form.save_failed = true;
+ cx.notify();
+ }
+ }
+ }
+
fn handle_farm_name_input_event(
&mut self,
state: &Entity<InputState>,
@@ -723,7 +901,16 @@ impl HomeView {
.flex()
.flex_col()
.gap(px(APP_UI_THEME.layout.home_stack_gap_px))
- .child(products_title_row(runtime))
+ .child(products_title_row(
+ runtime,
+ action_button_primary(
+ "products-add-product",
+ app_shared_text(AppTextKey::ProductsAddAction),
+ cx.listener(|this, _, _, cx| this.open_new_product_editor(cx)),
+ cx,
+ )
+ .into_any_element(),
+ ))
.child(
div()
.w_full()
@@ -772,6 +959,26 @@ impl HomeView {
cx.listener(|this, _, _, cx| this.select_products_sort(ProductsSort::Price, cx)),
cx,
))
+ .when_some(self.product_editor_form.as_ref(), |this, form| {
+ this.child(products_editor_surface(
+ form,
+ cx.listener(|this, _, _, cx| {
+ this.select_product_editor_status(ProductStatus::Draft, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_product_editor_status(ProductStatus::Published, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_product_editor_status(ProductStatus::Paused, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_product_editor_status(ProductStatus::Archived, cx)
+ }),
+ cx.listener(|this, _, _, cx| this.close_product_editor(cx)),
+ cx.listener(|this, _, _, cx| this.save_product_editor(cx)),
+ cx,
+ ))
+ })
.child(if projection.list.is_empty() {
products_empty_state_card(projection.query.filter).into_any_element()
} else {
@@ -813,11 +1020,27 @@ impl HomeView {
row: &ProductsListRow,
cx: &mut Context<Self>,
) -> AnyElement {
+ let is_open = self
+ .product_editor_form
+ .as_ref()
+ .map(|form| form.product_id == row.product_id)
+ .unwrap_or(false);
let is_editing = self
.products_stock_editor
.as_ref()
.map(|editor| editor.product_id == row.product_id)
.unwrap_or(false);
+ let product = products_row_open_button(
+ ("products-row-open", index),
+ row,
+ is_open,
+ cx.listener({
+ let product_id = row.product_id;
+ move |this, _, _, cx| this.open_existing_product_editor(product_id, cx)
+ }),
+ cx,
+ )
+ .into_any_element();
let action = if is_editing {
action_button_compact(
"products-stock-editor-cancel",
@@ -847,7 +1070,7 @@ impl HomeView {
.flex()
.flex_col()
.gap(px(APP_UI_THEME.layout.home_stack_gap_px))
- .child(products_table_row(row, action))
+ .child(products_table_row(product, row, action))
.when(is_editing, |this| {
this.when_some(self.products_stock_editor.as_ref(), |this, editor| {
this.child(products_stock_editor_card(
@@ -869,6 +1092,7 @@ impl Render for HomeView {
self.sync_farm_setup_form(&runtime_summary, window, cx);
self.sync_products_search(&runtime_summary, window, cx);
self.sync_products_stock_editor(&runtime_summary);
+ self.sync_product_editor_form(&runtime_summary, window, cx);
match home_stage(&runtime_summary) {
HomeStage::Setup => self
.startup_view
@@ -1035,6 +1259,125 @@ impl ProductsStockEditorState {
}
}
+struct ProductEditorFormState {
+ account_id: String,
+ product_id: ProductId,
+ initial_draft: ProductEditorDraft,
+ status: ProductStatus,
+ title_input: Entity<InputState>,
+ subtitle_input: Entity<InputState>,
+ unit_input: Entity<InputState>,
+ price_input: Entity<InputState>,
+ stock_input: Entity<InputState>,
+ _title_subscription: Subscription,
+ _subtitle_subscription: Subscription,
+ _unit_subscription: Subscription,
+ _price_subscription: Subscription,
+ _stock_subscription: Subscription,
+ save_failed: bool,
+}
+
+impl ProductEditorFormState {
+ fn new(
+ account_id: String,
+ product_id: ProductId,
+ draft: ProductEditorDraft,
+ window: &mut Window,
+ cx: &mut Context<HomeView>,
+ ) -> Self {
+ let title_input =
+ cx.new(|cx| InputState::new(window, cx).default_value(draft.title.clone()));
+ let subtitle_input =
+ cx.new(|cx| InputState::new(window, cx).default_value(draft.subtitle.clone()));
+ let unit_input =
+ cx.new(|cx| InputState::new(window, cx).default_value(draft.unit_label.clone()));
+ let price_input = cx.new(|cx| {
+ InputState::new(window, cx)
+ .default_value(product_editor_price_input_value(draft.price_minor_units))
+ });
+ let stock_input = cx.new(|cx| {
+ InputState::new(window, cx).default_value(
+ draft
+ .stock_quantity
+ .map(|quantity| quantity.to_string())
+ .unwrap_or_default(),
+ )
+ });
+ let title_subscription = cx.subscribe_in(
+ &title_input,
+ window,
+ HomeView::handle_product_editor_input_event,
+ );
+ let subtitle_subscription = cx.subscribe_in(
+ &subtitle_input,
+ window,
+ HomeView::handle_product_editor_input_event,
+ );
+ let unit_subscription = cx.subscribe_in(
+ &unit_input,
+ window,
+ HomeView::handle_product_editor_input_event,
+ );
+ let price_subscription = cx.subscribe_in(
+ &price_input,
+ window,
+ HomeView::handle_product_editor_input_event,
+ );
+ let stock_subscription = cx.subscribe_in(
+ &stock_input,
+ window,
+ HomeView::handle_product_editor_input_event,
+ );
+
+ Self {
+ account_id,
+ product_id,
+ status: draft.status,
+ initial_draft: draft,
+ title_input,
+ subtitle_input,
+ unit_input,
+ price_input,
+ stock_input,
+ _title_subscription: title_subscription,
+ _subtitle_subscription: subtitle_subscription,
+ _unit_subscription: unit_subscription,
+ _price_subscription: price_subscription,
+ _stock_subscription: stock_subscription,
+ save_failed: false,
+ }
+ }
+
+ fn current_draft(&self, cx: &App) -> Option<ProductEditorDraft> {
+ Some(ProductEditorDraft {
+ title: self.title_input.read(cx).value().to_string(),
+ subtitle: self.subtitle_input.read(cx).value().to_string(),
+ unit_label: self.unit_input.read(cx).value().to_string(),
+ price_minor_units: parse_product_editor_price_input(
+ self.price_input.read(cx).value().as_ref(),
+ )?,
+ price_currency: "USD".to_owned(),
+ stock_quantity: parse_optional_product_editor_stock_input(
+ self.stock_input.read(cx).value().as_ref(),
+ )?,
+ availability_window_id: self.initial_draft.availability_window_id,
+ status: self.status,
+ })
+ }
+
+ fn has_changes(&self, cx: &App) -> bool {
+ self.current_draft(cx)
+ .map(|draft| draft != self.initial_draft)
+ .unwrap_or(false)
+ }
+
+ fn publish_blockers(&self, cx: &App) -> Vec<ProductPublishBlocker> {
+ self.current_draft(cx)
+ .map(|draft| draft.publish_blockers())
+ .unwrap_or_default()
+ }
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
enum StartupPhase {
Idle,
@@ -2276,29 +2619,40 @@ fn home_sidebar_nav_button(
}
}
-fn products_title_row(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement {
+fn products_title_row(
+ runtime: &DesktopAppRuntimeSummary,
+ add_product_action: AnyElement,
+) -> impl IntoElement {
div()
.w_full()
.flex()
- .flex_col()
- .gap(px(4.0))
- .child(
- div()
- .text_size(px(APP_UI_THEME.typography.body_text_px * 2.0))
- .font_weight(gpui::FontWeight::BOLD)
- .text_color(rgb(APP_UI_THEME.text.primary))
- .child(app_shared_text(AppTextKey::ProductsTitle)),
- )
+ .items_end()
+ .justify_between()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
.child(
div()
- .text_size(px(APP_UI_THEME.typography.body_text_px))
- .font_weight(gpui::FontWeight::MEDIUM)
- .line_height(relative(1.2))
- .text_color(rgb(APP_UI_THEME.text.primary))
- .when_some(home_saved_farm(runtime), |this, farm| {
- this.child(farm.display_name.clone())
- }),
+ .flex()
+ .flex_col()
+ .gap(px(4.0))
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.body_text_px * 2.0))
+ .font_weight(gpui::FontWeight::BOLD)
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(app_shared_text(AppTextKey::ProductsTitle)),
+ )
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.body_text_px))
+ .font_weight(gpui::FontWeight::MEDIUM)
+ .line_height(relative(1.2))
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .when_some(home_saved_farm(runtime), |this, farm| {
+ this.child(farm.display_name.clone())
+ }),
+ ),
)
+ .child(add_product_action)
}
fn products_controls_card(
@@ -2514,36 +2868,17 @@ fn products_table_header_column(
.child(app_shared_text(key))
}
-fn products_table_row(row: &ProductsListRow, action: AnyElement) -> impl IntoElement {
+fn products_table_row(
+ product: AnyElement,
+ row: &ProductsListRow,
+ action: AnyElement,
+) -> impl IntoElement {
div()
.w_full()
.flex()
.items_center()
.gap(px(APP_UI_THEME.layout.home_stack_gap_px))
- .child(
- div()
- .flex_1()
- .min_w_0()
- .flex()
- .flex_col()
- .gap(px(4.0))
- .child(
- div()
- .text_size(px(APP_UI_THEME.typography.body_text_px))
- .font_weight(gpui::FontWeight::MEDIUM)
- .text_color(rgb(APP_UI_THEME.text.primary))
- .child(row.title.clone()),
- )
- .when_some(row.subtitle.as_ref(), |this, subtitle| {
- this.child(
- div()
- .text_size(px(APP_UI_THEME.typography.utility_title_text_px))
- .line_height(relative(1.2))
- .text_color(rgb(APP_UI_THEME.text.secondary))
- .child(subtitle.clone()),
- )
- }),
- )
+ .child(product)
.child(
div()
.w(px(112.0))
@@ -2593,6 +2928,64 @@ fn products_table_row(row: &ProductsListRow, action: AnyElement) -> impl IntoEle
.child(div().w(px(120.0)).flex().justify_end().child(action))
}
+fn products_row_open_button(
+ id: (&'static str, usize),
+ row: &ProductsListRow,
+ is_open: bool,
+ on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ let selected_background = rgb(APP_UI_THEME.surfaces.window_background);
+
+ Button::new(id)
+ .custom(
+ ButtonCustomVariant::new(cx)
+ .color(if is_open {
+ selected_background.into()
+ } else {
+ transparent_black().into()
+ })
+ .foreground(rgb(APP_UI_THEME.text.primary).into())
+ .border(transparent_black())
+ .hover(selected_background.into())
+ .active(selected_background.into()),
+ )
+ .rounded(ButtonRounded::Size(px(APP_UI_THEME
+ .controls
+ .action_button
+ .sizing
+ .corner_radius_px)))
+ .flex_1()
+ .min_w_0()
+ .on_click(on_click)
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .items_start()
+ .gap(px(4.0))
+ .px(px(8.0))
+ .py(px(6.0))
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.body_text_px))
+ .font_weight(gpui::FontWeight::MEDIUM)
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(product_display_title(row.title.as_str())),
+ )
+ .when_some(row.subtitle.as_ref(), |this, subtitle| {
+ this.child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.utility_title_text_px))
+ .line_height(relative(1.2))
+ .text_color(rgb(APP_UI_THEME.text.secondary))
+ .child(subtitle.clone()),
+ )
+ }),
+ )
+}
+
fn products_empty_state_card(filter: ProductsFilter) -> impl IntoElement {
let (title_key, body_key) = if filter == ProductsFilter::NeedAttention {
(
@@ -2725,7 +3118,7 @@ fn products_stock_editor_card(
.text_size(px(APP_UI_THEME.typography.utility_title_text_px))
.line_height(relative(1.2))
.text_color(rgb(APP_UI_THEME.text.secondary))
- .child(row.title.clone()),
+ .child(product_display_title(row.title.as_str())),
),
)
.child(
@@ -2816,10 +3209,350 @@ fn products_stock_editor_validation_key(
Some(AppTextKey::ProductsStockEditorInvalidQuantity)
}
+fn products_editor_surface(
+ form: &ProductEditorFormState,
+ on_select_draft: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_live: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_paused: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_archived: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_close: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_save: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ let validation_keys = products_editor_validation_keys(form, cx);
+ let save_ready = form.has_changes(cx) && validation_keys.is_empty();
+
+ div().w_full().flex().justify_center().child(
+ div().w_full().max_w(px(520.0)).child(home_card(
+ app_shared_text(AppTextKey::ProductsEditorTitle),
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .child(home_body_text(app_shared_text(
+ AppTextKey::ProductsEditorBody,
+ )))
+ .child(products_editor_text_field(
+ AppTextKey::ProductsEditorFieldTitle,
+ &form.title_input,
+ None,
+ ))
+ .child(products_editor_text_field(
+ AppTextKey::ProductsEditorFieldSubtitle,
+ &form.subtitle_input,
+ None,
+ ))
+ .child(products_editor_text_field(
+ AppTextKey::ProductsEditorFieldUnit,
+ &form.unit_input,
+ None,
+ ))
+ .child(products_editor_text_field(
+ AppTextKey::ProductsEditorFieldPrice,
+ &form.price_input,
+ products_editor_invalid_price_key(form, cx),
+ ))
+ .child(products_editor_text_field(
+ AppTextKey::ProductsEditorFieldStock,
+ &form.stock_input,
+ products_editor_invalid_stock_key(form, cx),
+ ))
+ .child(products_editor_status_section(
+ form.status,
+ on_select_draft,
+ on_select_live,
+ on_select_paused,
+ on_select_archived,
+ cx,
+ ))
+ .child(products_editor_publish_readiness_section(form, cx))
+ .when(form.save_failed, |this| {
+ this.child(home_body_text(app_shared_text(
+ AppTextKey::ProductsEditorSaveFailed,
+ )))
+ })
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .items_center()
+ .justify_between()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.utility_title_text_px))
+ .text_color(rgb(APP_UI_THEME.text.secondary))
+ .child(product_display_title(
+ form.title_input.read(cx).value().as_ref(),
+ )),
+ )
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .gap(px(8.0))
+ .child(action_button_compact(
+ "products-editor-close",
+ app_shared_text(AppTextKey::ProductsEditorCloseAction),
+ on_close,
+ cx,
+ ))
+ .child(if save_ready {
+ action_button_primary(
+ "products-editor-save",
+ app_shared_text(AppTextKey::ProductsEditorSaveAction),
+ on_save,
+ cx,
+ )
+ .into_any_element()
+ } else {
+ action_button_primary_disabled(
+ "products-editor-save",
+ app_shared_text(AppTextKey::ProductsEditorSaveAction),
+ cx,
+ )
+ .into_any_element()
+ }),
+ ),
+ ),
+ )),
+ )
+}
+
+fn products_editor_text_field(
+ field_label_key: AppTextKey,
+ input: &Entity<InputState>,
+ validation_key: Option<AppTextKey>,
+) -> impl IntoElement {
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .items_start()
+ .gap(px(8.0))
+ .child(home_farm_setup_field_label(field_label_key))
+ .child(Input::new(input).with_size(ComponentSize::Large).w_full())
+ .when_some(validation_key, |this, validation_key| {
+ this.child(home_body_text(app_shared_text(validation_key)))
+ })
+}
+
+fn products_editor_status_section(
+ selected_status: ProductStatus,
+ on_select_draft: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_live: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_paused: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_select_archived: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .items_start()
+ .gap(px(8.0))
+ .child(home_farm_setup_field_label(
+ AppTextKey::ProductsEditorFieldStatus,
+ ))
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .items_center()
+ .gap(px(8.0))
+ .child(products_filter_button(
+ "products-editor-status-draft",
+ AppTextKey::ProductsStatusDraft,
+ selected_status == ProductStatus::Draft,
+ on_select_draft,
+ cx,
+ ))
+ .child(products_filter_button(
+ "products-editor-status-live",
+ AppTextKey::ProductsStatusLive,
+ selected_status == ProductStatus::Published,
+ on_select_live,
+ cx,
+ ))
+ .child(products_filter_button(
+ "products-editor-status-paused",
+ AppTextKey::ProductsStatusPaused,
+ selected_status == ProductStatus::Paused,
+ on_select_paused,
+ cx,
+ ))
+ .child(products_filter_button(
+ "products-editor-status-archived",
+ AppTextKey::ProductsStatusArchived,
+ selected_status == ProductStatus::Archived,
+ on_select_archived,
+ cx,
+ )),
+ )
+}
+
+fn products_editor_publish_readiness_section(
+ form: &ProductEditorFormState,
+ cx: &App,
+) -> impl IntoElement {
+ let blockers = form.publish_blockers(cx);
+
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .items_start()
+ .gap(px(8.0))
+ .child(home_farm_setup_field_label(
+ AppTextKey::ProductsEditorPublishReadinessTitle,
+ ))
+ .child(if blockers.is_empty() {
+ home_body_text(app_shared_text(AppTextKey::ProductsEditorReady)).into_any_element()
+ } else {
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .items_start()
+ .gap(px(8.0))
+ .children(
+ blockers
+ .into_iter()
+ .map(products_editor_publish_blocker_row)
+ .collect::<Vec<_>>(),
+ )
+ .into_any_element()
+ })
+}
+
+fn products_editor_publish_blocker_row(blocker: ProductPublishBlocker) -> AnyElement {
+ div()
+ .w_full()
+ .flex()
+ .items_start()
+ .gap(px(APP_UI_THEME.layout.settings_account_status_gap_px))
+ .child(status_indicator(
+ APP_UI_THEME.controls.status_indicator.attention,
+ ))
+ .child(home_body_text(app_shared_text(
+ products_editor_publish_blocker_key(blocker),
+ )))
+ .into_any_element()
+}
+
+fn products_editor_publish_blocker_key(blocker: ProductPublishBlocker) -> AppTextKey {
+ match blocker {
+ ProductPublishBlocker::AddProductName => AppTextKey::ProductsEditorBlockerAddProductName,
+ ProductPublishBlocker::ChooseUnit => AppTextKey::ProductsEditorBlockerChooseUnit,
+ ProductPublishBlocker::SetPrice => AppTextKey::ProductsEditorBlockerSetPrice,
+ ProductPublishBlocker::AttachAvailability => {
+ AppTextKey::ProductsEditorBlockerAttachAvailability
+ }
+ }
+}
+
+fn products_editor_validation_keys(form: &ProductEditorFormState, cx: &App) -> Vec<AppTextKey> {
+ let mut keys = Vec::new();
+
+ if let Some(key) = products_editor_invalid_price_key(form, cx) {
+ keys.push(key);
+ }
+
+ if let Some(key) = products_editor_invalid_stock_key(form, cx) {
+ keys.push(key);
+ }
+
+ keys
+}
+
+fn products_editor_invalid_price_key(
+ form: &ProductEditorFormState,
+ cx: &App,
+) -> Option<AppTextKey> {
+ parse_product_editor_price_input(form.price_input.read(cx).value().as_ref())
+ .is_none()
+ .then_some(AppTextKey::ProductsEditorInvalidPrice)
+}
+
+fn products_editor_invalid_stock_key(
+ form: &ProductEditorFormState,
+ cx: &App,
+) -> Option<AppTextKey> {
+ parse_optional_product_editor_stock_input(form.stock_input.read(cx).value().as_ref())
+ .is_none()
+ .then_some(AppTextKey::ProductsEditorInvalidStock)
+}
+
fn parse_products_stock_quantity(input: &str) -> Option<u32> {
input.trim().parse().ok()
}
+fn parse_product_editor_price_input(input: &str) -> Option<Option<u32>> {
+ let trimmed = input.trim();
+ if trimmed.is_empty() {
+ return Some(None);
+ }
+
+ let parse_whole_dollars = |value: &str| -> Option<u32> { value.parse::<u32>().ok() };
+
+ if let Some((dollars, cents)) = trimmed.split_once('.') {
+ if trimmed.matches('.').count() != 1 || cents.is_empty() || cents.len() > 2 {
+ return None;
+ }
+
+ let dollars = if dollars.is_empty() {
+ 0
+ } else {
+ parse_whole_dollars(dollars)?
+ };
+ let cents = match cents.len() {
+ 1 => cents.parse::<u32>().ok()?.checked_mul(10)?,
+ 2 => cents.parse::<u32>().ok()?,
+ _ => return None,
+ };
+
+ return dollars
+ .checked_mul(100)
+ .and_then(|amount| amount.checked_add(cents))
+ .map(Some);
+ }
+
+ parse_whole_dollars(trimmed)
+ .and_then(|dollars| dollars.checked_mul(100))
+ .map(Some)
+}
+
+fn parse_optional_product_editor_stock_input(input: &str) -> Option<Option<u32>> {
+ let trimmed = input.trim();
+ if trimmed.is_empty() {
+ return Some(None);
+ }
+
+ trimmed.parse::<u32>().ok().map(Some)
+}
+
+fn product_editor_price_input_value(price_minor_units: Option<u32>) -> String {
+ price_minor_units
+ .map(|amount_minor_units| {
+ format!(
+ "{}.{:02}",
+ amount_minor_units / 100,
+ amount_minor_units % 100
+ )
+ })
+ .unwrap_or_default()
+}
+
+fn product_display_title(title: &str) -> String {
+ let trimmed = title.trim();
+ if trimmed.is_empty() {
+ app_shared_text(AppTextKey::ProductsUntitledDraft).to_string()
+ } else {
+ trimmed.to_owned()
+ }
+}
+
fn home_farm_setup_onboarding_card(
spec: FarmSetupOnboardingCardSpec,
on_open_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
@@ -3278,7 +4011,7 @@ fn home_low_stock_row(product: &ProductListRow) -> AnyElement {
.text_size(px(APP_UI_THEME.typography.body_text_px))
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(rgb(APP_UI_THEME.text.primary))
- .child(product.title.clone()),
+ .child(product_display_title(product.title.as_str())),
)
.child(
div()
@@ -3325,7 +4058,7 @@ fn home_draft_row(product: &ProductListRow) -> AnyElement {
.text_size(px(APP_UI_THEME.typography.body_text_px))
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(rgb(APP_UI_THEME.text.primary))
- .child(product.title.clone()),
+ .child(product_display_title(product.title.as_str())),
)
.child(status_indicator(
APP_UI_THEME.controls.status_indicator.offline,
@@ -3501,6 +4234,8 @@ mod tests {
use super::{
AppTextKey, FarmerHomeFarmState, farm_setup_onboarding_card_spec, farmer_home_farm_state,
home_saved_farm, home_window_launch_size_px, home_window_minimum_size_px,
+ parse_optional_product_editor_stock_input, parse_product_editor_price_input,
+ product_display_title,
};
use crate::runtime::DesktopAppRuntimeSummary;
use radroots_app_models::SettingsAccountProjection;
@@ -3623,6 +4358,35 @@ mod tests {
assert_eq!(home_saved_farm(&runtime), Some(&saved_farm));
}
+ #[test]
+ fn product_editor_price_parser_handles_blank_whole_and_decimal_inputs() {
+ assert_eq!(parse_product_editor_price_input(""), Some(None));
+ assert_eq!(parse_product_editor_price_input("6"), Some(Some(600)));
+ assert_eq!(parse_product_editor_price_input("6.5"), Some(Some(650)));
+ assert_eq!(parse_product_editor_price_input("6.50"), Some(Some(650)));
+ assert_eq!(parse_product_editor_price_input("6."), None);
+ assert_eq!(parse_product_editor_price_input("6.500"), None);
+ assert_eq!(parse_product_editor_price_input("abc"), None);
+ }
+
+ #[test]
+ fn product_editor_stock_parser_accepts_blank_or_whole_numbers_only() {
+ assert_eq!(parse_optional_product_editor_stock_input(""), Some(None));
+ assert_eq!(
+ parse_optional_product_editor_stock_input("14"),
+ Some(Some(14))
+ );
+ assert_eq!(parse_optional_product_editor_stock_input("14.5"), None);
+ assert_eq!(parse_optional_product_editor_stock_input("abc"), None);
+ }
+
+ #[test]
+ fn blank_product_titles_fall_back_to_the_untitled_copy() {
+ assert_eq!(product_display_title(""), "Untitled draft");
+ assert_eq!(product_display_title(" "), "Untitled draft");
+ assert_eq!(product_display_title("Salad mix"), "Salad mix");
+ }
+
fn summary(
home_route: HomeRoute,
today_projection: TodayAgendaProjection,
diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs
@@ -97,7 +97,28 @@ define_app_text_keys! {
ProductsColumnPrice => "products.column.price",
ProductsColumnUpdated => "products.column.updated",
ProductsColumnAction => "products.column.action",
+ ProductsAddAction => "products.action.add",
ProductsUpdateStockAction => "products.action.update_stock",
+ ProductsEditorTitle => "products.editor.title",
+ ProductsEditorBody => "products.editor.body",
+ ProductsEditorFieldTitle => "products.editor.field.title",
+ ProductsEditorFieldSubtitle => "products.editor.field.subtitle",
+ ProductsEditorFieldUnit => "products.editor.field.unit",
+ ProductsEditorFieldPrice => "products.editor.field.price",
+ ProductsEditorFieldStock => "products.editor.field.stock",
+ ProductsEditorFieldStatus => "products.editor.field.status",
+ ProductsEditorCloseAction => "products.editor.action.close",
+ ProductsEditorSaveAction => "products.editor.action.save",
+ ProductsEditorSaveFailed => "products.editor.save_failed",
+ ProductsEditorInvalidPrice => "products.editor.invalid_price",
+ ProductsEditorInvalidStock => "products.editor.invalid_stock",
+ ProductsEditorPublishReadinessTitle => "products.editor.publish_readiness.title",
+ ProductsEditorReady => "products.editor.publish_readiness.ready",
+ ProductsEditorBlockerAddProductName => "products.editor.blocker.add_product_name",
+ ProductsEditorBlockerChooseUnit => "products.editor.blocker.choose_unit",
+ ProductsEditorBlockerSetPrice => "products.editor.blocker.set_price",
+ ProductsEditorBlockerAttachAvailability => "products.editor.blocker.attach_availability",
+ ProductsUntitledDraft => "products.untitled_draft",
ProductsStockEditorTitle => "products.stock_editor.title",
ProductsStockEditorFieldLabel => "products.stock_editor.field.label",
ProductsStockEditorSaveAction => "products.stock_editor.action.save",
diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json
@@ -76,7 +76,28 @@
"products.column.price": "Price",
"products.column.updated": "Updated",
"products.column.action": "Action",
+ "products.action.add": "Add product",
"products.action.update_stock": "Update stock",
+ "products.editor.title": "Product details",
+ "products.editor.body": "Saved locally on this device.",
+ "products.editor.field.title": "Name",
+ "products.editor.field.subtitle": "Details",
+ "products.editor.field.unit": "Unit",
+ "products.editor.field.price": "Price (USD)",
+ "products.editor.field.stock": "Stock",
+ "products.editor.field.status": "Status",
+ "products.editor.action.close": "Close",
+ "products.editor.action.save": "Save changes",
+ "products.editor.save_failed": "Couldn't save product details. Try again.",
+ "products.editor.invalid_price": "Enter dollars and cents, for example 6.50.",
+ "products.editor.invalid_stock": "Enter a whole number or leave blank.",
+ "products.editor.publish_readiness.title": "Publish readiness",
+ "products.editor.publish_readiness.ready": "This product is ready to publish.",
+ "products.editor.blocker.add_product_name": "Add a product name.",
+ "products.editor.blocker.choose_unit": "Choose a unit.",
+ "products.editor.blocker.set_price": "Set a price.",
+ "products.editor.blocker.attach_availability": "Attach an availability window.",
+ "products.untitled_draft": "Untitled draft",
"products.stock_editor.title": "Update stock",
"products.stock_editor.field.label": "Stock",
"products.stock_editor.action.save": "Save stock",