commit 478962e802cddf487f49d7ec92f8b821e60dbe02
parent 880fd367a4e82a0e20e0ebdbd6085c225ce44637
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Apr 2026 17:32:13 +0000
desktop: add stock updates and today products follow-ons
Diffstat:
5 files changed, 881 insertions(+), 327 deletions(-)
diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs
@@ -5,7 +5,7 @@ use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedA
use radroots_app_models::{
ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate,
FarmId, FarmReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection,
- ProductsFilter, ProductsListProjection, ProductsSort, SettingsAccountProjection,
+ ProductId, ProductsFilter, ProductsListProjection, ProductsSort, SettingsAccountProjection,
SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection,
};
use radroots_app_sqlite::{
@@ -117,6 +117,19 @@ impl DesktopAppRuntime {
self.lock_state_mut().select_products_sort(sort)
}
+ pub fn open_products_filter(&self, filter: ProductsFilter) -> Result<bool, AppSqliteError> {
+ self.lock_state_mut().open_products_filter(filter)
+ }
+
+ pub fn update_product_stock(
+ &self,
+ product_id: ProductId,
+ stock_quantity: u32,
+ ) -> Result<bool, AppSqliteError> {
+ self.lock_state_mut()
+ .update_product_stock(product_id, stock_quantity)
+ }
+
pub fn set_settings_preference(&self, preference: SettingsPreference, enabled: bool) -> bool {
let changed = self.lock_state_mut().state_store.apply_in_memory(
AppStateCommand::SetSettingsPreference {
@@ -499,6 +512,49 @@ impl DesktopAppRuntimeState {
))
}
+ fn open_products_filter(&mut self, filter: ProductsFilter) -> Result<bool, AppSqliteError> {
+ if !self.state_store.farm_setup_projection().has_saved_farm() {
+ return Ok(false);
+ }
+
+ let filter_changed = self.select_products_filter(filter)?;
+ let section_changed = self.select_farmer_section(FarmerSection::Products);
+
+ Ok(filter_changed || section_changed)
+ }
+
+ fn update_product_stock(
+ &mut self,
+ product_id: ProductId,
+ stock_quantity: u32,
+ ) -> Result<bool, AppSqliteError> {
+ let Some(sqlite_store) = self.sqlite_store.as_ref() else {
+ return Ok(false);
+ };
+ if self
+ .state_store
+ .identity_projection()
+ .selected_account
+ .is_none()
+ {
+ return Ok(false);
+ }
+
+ let updated = sqlite_store.update_product_stock(product_id, stock_quantity)?;
+ if !updated {
+ 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);
+
+ Ok(updated || context_changed)
+ }
+
fn save_farm_setup_draft(
&mut self,
draft: FarmSetupDraft,
@@ -1309,6 +1365,157 @@ mod tests {
}
#[test]
+ fn runtime_open_products_filter_routes_today_follow_ons_into_products() {
+ 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().shell_projection.selected_section,
+ ShellSection::Farmer(FarmerSection::Today)
+ );
+
+ assert!(
+ runtime
+ .open_products_filter(ProductsFilter::Drafts)
+ .expect("products follow-on should route")
+ );
+ let summary = runtime.summary();
+
+ assert_eq!(
+ summary.shell_projection.selected_section,
+ ShellSection::Farmer(FarmerSection::Products)
+ );
+ assert_eq!(
+ summary.products_projection.query.filter,
+ ProductsFilter::Drafts
+ );
+ }
+
+ #[test]
+ fn runtime_stock_updates_refresh_today_and_products_projections() {
+ 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");
+ seed_product(
+ &runtime,
+ farm_id,
+ "Salad mix",
+ "Spring blend",
+ "published",
+ Some(2),
+ "2026-04-18T10:00:00Z",
+ );
+
+ assert!(
+ runtime
+ .select_local_account(account_id.as_str())
+ .expect("account should select")
+ );
+ let product_id = runtime.summary().products_projection.list.rows[0].product_id;
+
+ assert_eq!(
+ runtime.summary().today_projection.low_stock_products.len(),
+ 1
+ );
+ assert!(
+ runtime
+ .update_product_stock(product_id, 12)
+ .expect("stock update should succeed")
+ );
+
+ let summary = runtime.summary();
+ assert_eq!(
+ summary.products_projection.list.rows[0].stock.quantity,
+ Some(12)
+ );
+ assert!(summary.today_projection.low_stock_products.is_empty());
+ }
+
+ #[test]
fn runtime_account_commands_refresh_identity_projection() {
let runtime = memory_runtime();
diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs
@@ -5,11 +5,14 @@ const ALLOWED_MENU_LITERALS: &[&str] = &["cmd-q", "settings window should open"]
const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"${dollars}.{cents:02} / {}",
", ",
+ "0",
"account-add",
"account-open-workspace",
"account-log-out",
"account-more",
"failed to add relay `{relay_url}`: {error}",
+ "failed to route into products view",
+ "failed to update product stock",
"failed to update products filter",
"failed to update products search query",
"failed to update products sort",
@@ -22,6 +25,8 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"home-farm-setup-start",
"home-nav-products",
"home-nav-today",
+ "home-today-open-products-drafts",
+ "home-today-open-products-low-stock",
"home-products-scroll",
"home-today-scroll",
"products",
@@ -31,13 +36,19 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"products-filter-live",
"products-filter-need-attention",
"products-filter-paused",
+ "products-row-stock-action",
"products-sort-availability",
"products-sort-name",
"products-sort-price",
"products-sort-stock",
"products-sort-updated",
+ "products-stock-editor-cancel",
+ "products-stock-editor-close",
+ "products-stock-editor-save",
"products.filter_update_failed",
+ "products.route_failed",
"products.search_query_update_failed",
+ "products.stock_update_failed",
"products.sort_update_failed",
"settings-allow-relay-connections",
"settings-launch-at-login",
@@ -74,6 +85,7 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[
"AppTextKey::HomeFarmSetupSaveFailedLocally",
"AppTextKey::HomeFarmSetupFinishAction",
"AppTextKey::HomeFarmSetupContinueAction",
+ "AppTextKey::HomeTodayOpenInProductsAction",
"AppTextKey::HomeNavToday",
"AppTextKey::HomeNavProducts",
"AppTextKey::ProductsTitle",
@@ -102,6 +114,14 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[
"AppTextKey::ProductsColumnStock",
"AppTextKey::ProductsColumnPrice",
"AppTextKey::ProductsColumnUpdated",
+ "AppTextKey::ProductsColumnAction",
+ "AppTextKey::ProductsUpdateStockAction",
+ "AppTextKey::ProductsStockEditorTitle",
+ "AppTextKey::ProductsStockEditorFieldLabel",
+ "AppTextKey::ProductsStockEditorSaveAction",
+ "AppTextKey::ProductsStockEditorCancelAction",
+ "AppTextKey::ProductsStockEditorInvalidQuantity",
+ "AppTextKey::ProductsStockEditorSaveFailed",
"AppTextKey::ProductsStatusDraft",
"AppTextKey::ProductsStatusLive",
"AppTextKey::ProductsStatusPaused",
diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs
@@ -3,17 +3,19 @@ use gpui::{
InteractiveElement, IntoElement, ParentElement, Render, SharedString,
StatefulInteractiveElement, Styled, Subscription, Timer, Window, WindowBackgroundAppearance,
WindowBounds, WindowOptions, div, prelude::FluentBuilder, px, relative, rgb, size,
+ transparent_black,
};
use gpui_component::{
IconName, Root, Sizable, Size as ComponentSize,
+ button::{Button, ButtonCustomVariant, ButtonRounded, ButtonVariants},
input::{Input, InputEvent, InputState},
};
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, ProductListRow,
- ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, ShellSection,
+ FarmerSection, FulfillmentWindowSummary, OrderListRow, ProductAttentionState, ProductId,
+ ProductListRow, ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, ShellSection,
TodayAgendaProjection, TodaySetupTaskKind,
};
use radroots_app_state::{FarmSetupFlowStage, HomeRoute};
@@ -155,6 +157,7 @@ pub struct HomeView {
logged_in_view: LoggedInHomeView,
farm_setup_form: Option<FarmSetupFormState>,
products_search: Option<ProductsSearchState>,
+ products_stock_editor: Option<ProductsStockEditorState>,
relay_client: Option<RadrootsNostrClient>,
}
@@ -166,6 +169,7 @@ impl HomeView {
logged_in_view: LoggedInHomeView::new(),
farm_setup_form: None,
products_search: None,
+ products_stock_editor: None,
relay_client: None,
}
}
@@ -328,8 +332,38 @@ impl HomeView {
}
}
+ fn sync_products_stock_editor(&mut self, runtime_summary: &DesktopAppRuntimeSummary) {
+ let Some(editor) = self.products_stock_editor.as_ref() else {
+ return;
+ };
+ let Some(account_id) = runtime_summary
+ .settings_account_projection
+ .selected_account
+ .as_ref()
+ .map(|account| account.account.account_id.as_str())
+ else {
+ self.products_stock_editor = None;
+ return;
+ };
+
+ let should_clear = editor.account_id != account_id
+ || selected_farmer_section(runtime_summary) != FarmerSection::Products
+ || !runtime_summary.farm_setup_projection.has_saved_farm()
+ || !runtime_summary
+ .products_projection
+ .list
+ .rows
+ .iter()
+ .any(|row| row.product_id == editor.product_id);
+
+ if should_clear {
+ self.products_stock_editor = None;
+ }
+ }
+
fn select_farmer_section(&mut self, section: FarmerSection, cx: &mut Context<Self>) {
if self.runtime.select_farmer_section(section) {
+ self.products_stock_editor = None;
cx.notify();
}
}
@@ -362,7 +396,10 @@ impl HomeView {
fn select_products_filter(&mut self, filter: ProductsFilter, cx: &mut Context<Self>) {
match self.runtime.select_products_filter(filter) {
- Ok(true) => cx.notify(),
+ Ok(true) => {
+ self.products_stock_editor = None;
+ cx.notify();
+ }
Ok(false) => {}
Err(runtime_error) => {
error!(
@@ -378,7 +415,10 @@ impl HomeView {
fn select_products_sort(&mut self, sort: ProductsSort, cx: &mut Context<Self>) {
match self.runtime.select_products_sort(sort) {
- Ok(true) => cx.notify(),
+ Ok(true) => {
+ self.products_stock_editor = None;
+ cx.notify();
+ }
Ok(false) => {}
Err(runtime_error) => {
error!(
@@ -392,6 +432,131 @@ impl HomeView {
}
}
+ fn open_products_filter(&mut self, filter: ProductsFilter, cx: &mut Context<Self>) {
+ match self.runtime.open_products_filter(filter) {
+ Ok(true) => {
+ self.products_stock_editor = None;
+ cx.notify();
+ }
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "products",
+ event = "products.route_failed",
+ error = %runtime_error,
+ filter = filter.storage_key(),
+ "failed to route into products view"
+ );
+ }
+ }
+ }
+
+ fn open_products_stock_editor(
+ &mut self,
+ product_id: ProductId,
+ stock_quantity: Option<u32>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(account_id) = self
+ .runtime
+ .summary()
+ .settings_account_projection
+ .selected_account
+ .as_ref()
+ .map(|account| account.account.account_id.clone())
+ else {
+ return;
+ };
+
+ if self
+ .products_stock_editor
+ .as_ref()
+ .map(|editor| editor.product_id == product_id)
+ .unwrap_or(false)
+ {
+ self.products_stock_editor = None;
+ cx.notify();
+ return;
+ }
+
+ self.products_stock_editor = Some(ProductsStockEditorState::new(
+ account_id,
+ product_id,
+ stock_quantity,
+ window,
+ cx,
+ ));
+ cx.notify();
+ }
+
+ fn close_products_stock_editor(&mut self, cx: &mut Context<Self>) {
+ if self.products_stock_editor.take().is_some() {
+ cx.notify();
+ }
+ }
+
+ fn handle_products_stock_input_event(
+ &mut self,
+ state: &Entity<InputState>,
+ event: &InputEvent,
+ _: &mut Window,
+ cx: &mut Context<Self>,
+ ) {
+ if !matches!(event, InputEvent::Change) {
+ return;
+ }
+
+ let Some(editor) = self.products_stock_editor.as_mut() else {
+ return;
+ };
+
+ if editor.input != *state || !editor.save_failed {
+ return;
+ }
+
+ editor.save_failed = false;
+ cx.notify();
+ }
+
+ fn save_products_stock_editor(&mut self, cx: &mut Context<Self>) {
+ let Some((product_id, stock_quantity)) =
+ self.products_stock_editor.as_ref().and_then(|editor| {
+ editor
+ .parsed_stock_quantity(cx)
+ .map(|stock_quantity| (editor.product_id, stock_quantity))
+ })
+ else {
+ return;
+ };
+
+ match self
+ .runtime
+ .update_product_stock(product_id, stock_quantity)
+ {
+ Ok(true) => {
+ self.products_stock_editor = None;
+ cx.notify();
+ }
+ Ok(false) => {}
+ Err(runtime_error) => {
+ error!(
+ target: "products",
+ event = "products.stock_update_failed",
+ error = %runtime_error,
+ product_id = %product_id,
+ stock_quantity,
+ "failed to update product stock"
+ );
+
+ if let Some(editor) = self.products_stock_editor.as_mut() {
+ editor.save_failed = true;
+ }
+ cx.notify();
+ }
+ }
+ }
+
fn handle_farm_name_input_event(
&mut self,
state: &Entity<InputState>,
@@ -478,6 +643,224 @@ impl HomeView {
cx.notify();
}
+
+ fn render_farmer_workspace(
+ &mut self,
+ runtime: &DesktopAppRuntimeSummary,
+ cx: &mut Context<Self>,
+ ) -> AnyElement {
+ let selected_farmer_section = selected_farmer_section(runtime);
+ let main_content = match selected_farmer_section {
+ FarmerSection::Products if farmer_products_available(runtime) => {
+ self.render_products_content(runtime, cx)
+ }
+ FarmerSection::Today
+ | FarmerSection::Products
+ | FarmerSection::Orders
+ | FarmerSection::PackDay
+ | FarmerSection::Farm => home_today_content(
+ runtime,
+ self.farm_setup_form.as_ref().map(|form| {
+ home_farm_setup_form_card(
+ form,
+ cx.listener(|this, checked: &bool, _, cx| {
+ this.toggle_farm_order_method(FarmOrderMethod::Pickup, *checked, cx)
+ }),
+ cx.listener(|this, checked: &bool, _, cx| {
+ this.toggle_farm_order_method(FarmOrderMethod::Delivery, *checked, cx)
+ }),
+ cx.listener(|this, checked: &bool, _, cx| {
+ this.toggle_farm_order_method(FarmOrderMethod::Shipping, *checked, cx)
+ }),
+ cx.listener(|this, _, _, cx| this.finish_farm_setup(cx)),
+ cx,
+ )
+ .into_any_element()
+ }),
+ cx.listener(|this, _, window, cx| this.open_farm_setup(window, cx)),
+ cx.listener(|this, _, window, cx| this.open_farm_setup(window, cx)),
+ cx.listener(|this, _, _, cx| {
+ this.open_products_filter(ProductsFilter::NeedAttention, cx)
+ }),
+ cx.listener(|this, _, _, cx| this.open_products_filter(ProductsFilter::Drafts, cx)),
+ cx,
+ )
+ .into_any_element(),
+ };
+
+ home_shell_frame(
+ home_sidebar(
+ runtime,
+ cx.listener(|this, _, _, cx| this.select_farmer_section(FarmerSection::Today, cx)),
+ cx.listener(|this, _, _, cx| {
+ this.select_farmer_section(FarmerSection::Products, cx)
+ }),
+ cx,
+ )
+ .into_any_element(),
+ div()
+ .id(home_content_scroll_id(selected_farmer_section))
+ .size_full()
+ .overflow_y_scroll()
+ .child(main_content)
+ .into_any_element(),
+ )
+ .into_any_element()
+ }
+
+ fn render_products_content(
+ &mut self,
+ runtime: &DesktopAppRuntimeSummary,
+ cx: &mut Context<Self>,
+ ) -> AnyElement {
+ let projection = &runtime.products_projection;
+ let summary = &projection.list.summary;
+
+ div()
+ .w_full()
+ .max_w(px(APP_UI_THEME.layout.home_card_max_width_px))
+ .mx_auto()
+ .flex()
+ .flex_col()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .child(products_title_row(runtime))
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .child(home_summary_metric(
+ AppTextKey::ProductsSummaryTotal,
+ summary.total_products,
+ ))
+ .child(home_summary_metric(
+ AppTextKey::ProductsSummaryLive,
+ summary.live_products,
+ ))
+ .child(home_summary_metric(
+ AppTextKey::ProductsSummaryNeedAttention,
+ summary.need_attention_products,
+ ))
+ .child(home_summary_metric(
+ AppTextKey::ProductsSummaryDrafts,
+ summary.draft_products,
+ )),
+ )
+ .child(products_controls_card(
+ runtime,
+ self.products_search.as_ref(),
+ cx.listener(|this, _, _, cx| this.select_products_filter(ProductsFilter::All, cx)),
+ cx.listener(|this, _, _, cx| this.select_products_filter(ProductsFilter::Live, cx)),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_filter(ProductsFilter::Drafts, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_filter(ProductsFilter::NeedAttention, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_filter(ProductsFilter::Paused, cx)
+ }),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_filter(ProductsFilter::Archived, cx)
+ }),
+ cx.listener(|this, _, _, cx| this.select_products_sort(ProductsSort::Updated, cx)),
+ cx.listener(|this, _, _, cx| this.select_products_sort(ProductsSort::Name, cx)),
+ cx.listener(|this, _, _, cx| {
+ this.select_products_sort(ProductsSort::Availability, cx)
+ }),
+ cx.listener(|this, _, _, cx| this.select_products_sort(ProductsSort::Stock, cx)),
+ cx.listener(|this, _, _, cx| this.select_products_sort(ProductsSort::Price, cx)),
+ cx,
+ ))
+ .child(if projection.list.is_empty() {
+ products_empty_state_card(projection.query.filter).into_any_element()
+ } else {
+ self.render_products_table_card(&projection.list.rows, cx)
+ })
+ .into_any_element()
+ }
+
+ fn render_products_table_card(
+ &mut self,
+ rows: &[ProductsListRow],
+ cx: &mut Context<Self>,
+ ) -> AnyElement {
+ let mut items = Vec::with_capacity(rows.len().saturating_mul(2));
+ for (index, row) in rows.iter().enumerate() {
+ items.push(self.render_products_table_entry(index, row, cx));
+ if index + 1 < rows.len() {
+ items.push(section_divider().into_any_element());
+ }
+ }
+
+ home_card(
+ app_shared_text(AppTextKey::ProductsTableTitle),
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(12.0))
+ .child(products_table_header())
+ .child(section_divider())
+ .children(items),
+ )
+ .into_any_element()
+ }
+
+ fn render_products_table_entry(
+ &mut self,
+ index: usize,
+ row: &ProductsListRow,
+ cx: &mut Context<Self>,
+ ) -> AnyElement {
+ let is_editing = self
+ .products_stock_editor
+ .as_ref()
+ .map(|editor| editor.product_id == row.product_id)
+ .unwrap_or(false);
+ let action = if is_editing {
+ action_button_compact(
+ "products-stock-editor-cancel",
+ app_shared_text(AppTextKey::ProductsStockEditorCancelAction),
+ cx.listener(|this, _, _, cx| this.close_products_stock_editor(cx)),
+ cx,
+ )
+ .into_any_element()
+ } else {
+ products_row_action_button(
+ ("products-row-stock-action", index),
+ app_shared_text(AppTextKey::ProductsUpdateStockAction),
+ cx.listener({
+ let product_id = row.product_id;
+ let stock_quantity = row.stock.quantity;
+ move |this, _, window, cx| {
+ this.open_products_stock_editor(product_id, stock_quantity, window, cx)
+ }
+ }),
+ cx,
+ )
+ .into_any_element()
+ };
+
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .child(products_table_row(row, action))
+ .when(is_editing, |this| {
+ this.when_some(self.products_stock_editor.as_ref(), |this, editor| {
+ this.child(products_stock_editor_card(
+ row,
+ editor,
+ cx.listener(|this, _, _, cx| this.save_products_stock_editor(cx)),
+ cx.listener(|this, _, _, cx| this.close_products_stock_editor(cx)),
+ cx,
+ ))
+ })
+ })
+ .into_any_element()
+ }
}
impl Render for HomeView {
@@ -485,6 +868,7 @@ impl Render for HomeView {
let runtime_summary = self.runtime.summary();
self.sync_farm_setup_form(&runtime_summary, window, cx);
self.sync_products_search(&runtime_summary, window, cx);
+ self.sync_products_stock_editor(&runtime_summary);
match home_stage(&runtime_summary) {
HomeStage::Setup => self
.startup_view
@@ -500,78 +884,7 @@ impl Render for HomeView {
.logged_in_view
.render_holding(&runtime_summary)
.into_any_element(),
- HomeStage::FarmerWorkspace => self
- .logged_in_view
- .render_farmer(
- &runtime_summary,
- self.farm_setup_form.as_ref().map(|form| {
- home_farm_setup_form_card(
- form,
- cx.listener(|this, checked: &bool, _, cx| {
- this.toggle_farm_order_method(FarmOrderMethod::Pickup, *checked, cx)
- }),
- cx.listener(|this, checked: &bool, _, cx| {
- this.toggle_farm_order_method(
- FarmOrderMethod::Delivery,
- *checked,
- cx,
- )
- }),
- cx.listener(|this, checked: &bool, _, cx| {
- this.toggle_farm_order_method(
- FarmOrderMethod::Shipping,
- *checked,
- cx,
- )
- }),
- cx.listener(|this, _, _, cx| this.finish_farm_setup(cx)),
- cx,
- )
- .into_any_element()
- }),
- self.products_search.as_ref(),
- cx.listener(|this, _, _, cx| {
- this.select_farmer_section(FarmerSection::Today, cx)
- }),
- cx.listener(|this, _, _, cx| {
- this.select_farmer_section(FarmerSection::Products, cx)
- }),
- cx.listener(|this, _, window, cx| this.open_farm_setup(window, cx)),
- cx.listener(|this, _, window, cx| this.open_farm_setup(window, cx)),
- cx.listener(|this, _, _, cx| {
- this.select_products_filter(ProductsFilter::All, cx)
- }),
- cx.listener(|this, _, _, cx| {
- this.select_products_filter(ProductsFilter::Live, cx)
- }),
- cx.listener(|this, _, _, cx| {
- this.select_products_filter(ProductsFilter::Drafts, cx)
- }),
- cx.listener(|this, _, _, cx| {
- this.select_products_filter(ProductsFilter::NeedAttention, cx)
- }),
- cx.listener(|this, _, _, cx| {
- this.select_products_filter(ProductsFilter::Paused, cx)
- }),
- cx.listener(|this, _, _, cx| {
- this.select_products_filter(ProductsFilter::Archived, cx)
- }),
- cx.listener(|this, _, _, cx| {
- this.select_products_sort(ProductsSort::Updated, cx)
- }),
- cx.listener(|this, _, _, cx| this.select_products_sort(ProductsSort::Name, cx)),
- cx.listener(|this, _, _, cx| {
- this.select_products_sort(ProductsSort::Availability, cx)
- }),
- cx.listener(|this, _, _, cx| {
- this.select_products_sort(ProductsSort::Stock, cx)
- }),
- cx.listener(|this, _, _, cx| {
- this.select_products_sort(ProductsSort::Price, cx)
- }),
- cx,
- )
- .into_any_element(),
+ HomeStage::FarmerWorkspace => self.render_farmer_workspace(&runtime_summary, cx),
}
}
}
@@ -672,6 +985,56 @@ impl ProductsSearchState {
}
}
+struct ProductsStockEditorState {
+ account_id: String,
+ product_id: ProductId,
+ initial_stock_quantity: Option<u32>,
+ input: Entity<InputState>,
+ _input_subscription: Subscription,
+ save_failed: bool,
+}
+
+impl ProductsStockEditorState {
+ fn new(
+ account_id: String,
+ product_id: ProductId,
+ stock_quantity: Option<u32>,
+ window: &mut Window,
+ cx: &mut Context<HomeView>,
+ ) -> Self {
+ let input = cx.new(|cx| {
+ InputState::new(window, cx)
+ .placeholder(app_shared_text(AppTextKey::ProductsStockEditorFieldLabel))
+ .default_value(
+ stock_quantity
+ .map(|quantity| quantity.to_string())
+ .unwrap_or_else(|| "0".to_owned()),
+ )
+ });
+ let input_subscription =
+ cx.subscribe_in(&input, window, HomeView::handle_products_stock_input_event);
+
+ Self {
+ account_id,
+ product_id,
+ initial_stock_quantity: stock_quantity,
+ input,
+ _input_subscription: input_subscription,
+ save_failed: false,
+ }
+ }
+
+ fn parsed_stock_quantity(&self, cx: &App) -> Option<u32> {
+ parse_products_stock_quantity(self.input.read(cx).value().as_ref())
+ }
+
+ fn has_changes(&self, cx: &App) -> bool {
+ self.parsed_stock_quantity(cx)
+ .map(|stock_quantity| Some(stock_quantity) != self.initial_stock_quantity)
+ .unwrap_or(false)
+ }
+}
+
#[derive(Clone, Debug, Eq, PartialEq)]
enum StartupPhase {
Idle,
@@ -742,52 +1105,6 @@ impl LoggedInHomeView {
fn render_holding(&self, runtime: &DesktopAppRuntimeSummary) -> AnyElement {
holding_home_shell(runtime).into_any_element()
}
-
- fn render_farmer(
- &self,
- runtime: &DesktopAppRuntimeSummary,
- farm_setup_form: Option<AnyElement>,
- products_search: Option<&ProductsSearchState>,
- on_select_today: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_start_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_continue_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_all_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_live_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_draft_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_products_needing_attention: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_paused_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_archived_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_updated: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_name: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_availability: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_stock: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_price: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- cx: &App,
- ) -> AnyElement {
- farmer_home_shell(
- runtime,
- farm_setup_form,
- products_search,
- on_select_today,
- on_select_products,
- on_start_farm_setup,
- on_continue_farm_setup,
- on_select_all_products,
- on_select_live_products,
- on_select_draft_products,
- on_select_products_needing_attention,
- on_select_paused_products,
- on_select_archived_products,
- on_sort_products_by_updated,
- on_sort_products_by_name,
- on_sort_products_by_availability,
- on_sort_products_by_stock,
- on_sort_products_by_price,
- cx,
- )
- .into_any_element()
- }
}
pub struct SettingsWindowView {
@@ -1364,58 +1681,6 @@ struct FarmSetupOnboardingCardSpec {
action_key: Option<AppTextKey>,
}
-fn farmer_home_shell(
- runtime: &DesktopAppRuntimeSummary,
- farm_setup_form: Option<AnyElement>,
- products_search: Option<&ProductsSearchState>,
- on_select_today: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_start_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_continue_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_all_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_live_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_draft_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_products_needing_attention: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_paused_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_archived_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_updated: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_name: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_availability: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_stock: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_price: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- cx: &App,
-) -> impl IntoElement {
- let selected_farmer_section = selected_farmer_section(runtime);
-
- home_shell_frame(
- home_sidebar(runtime, on_select_today, on_select_products, cx).into_any_element(),
- div()
- .id(home_content_scroll_id(selected_farmer_section))
- .size_full()
- .overflow_y_scroll()
- .child(home_view_content(
- runtime,
- farm_setup_form,
- products_search,
- on_start_farm_setup,
- on_continue_farm_setup,
- on_select_all_products,
- on_select_live_products,
- on_select_draft_products,
- on_select_products_needing_attention,
- on_select_paused_products,
- on_select_archived_products,
- on_sort_products_by_updated,
- on_sort_products_by_name,
- on_sort_products_by_availability,
- on_sort_products_by_stock,
- on_sort_products_by_price,
- cx,
- ))
- .into_any_element(),
- )
-}
-
fn holding_home_shell(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement {
let home_status = home_status_presentation(runtime);
let (title_key, body_key) = match home_stage(runtime) {
@@ -1780,63 +2045,13 @@ fn holding_home_sidebar(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement
)
}
-fn home_view_content(
- runtime: &DesktopAppRuntimeSummary,
- farm_setup_form: Option<AnyElement>,
- products_search: Option<&ProductsSearchState>,
- on_start_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_continue_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_all_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_live_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_draft_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_products_needing_attention: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_paused_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_archived_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_updated: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_name: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_availability: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_stock: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_price: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- cx: &App,
-) -> impl IntoElement {
- match selected_farmer_section(runtime) {
- FarmerSection::Products if farmer_products_available(runtime) => home_products_content(
- runtime,
- products_search,
- on_select_all_products,
- on_select_live_products,
- on_select_draft_products,
- on_select_products_needing_attention,
- on_select_paused_products,
- on_select_archived_products,
- on_sort_products_by_updated,
- on_sort_products_by_name,
- on_sort_products_by_availability,
- on_sort_products_by_stock,
- on_sort_products_by_price,
- cx,
- )
- .into_any_element(),
- FarmerSection::Today
- | FarmerSection::Products
- | FarmerSection::Orders
- | FarmerSection::PackDay
- | FarmerSection::Farm => home_today_content(
- runtime,
- farm_setup_form,
- on_start_farm_setup,
- on_continue_farm_setup,
- cx,
- )
- .into_any_element(),
- }
-}
-
fn home_today_content(
runtime: &DesktopAppRuntimeSummary,
farm_setup_form: Option<AnyElement>,
on_start_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
on_continue_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_open_low_stock_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_open_draft_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
cx: &App,
) -> impl IntoElement {
let projection = &runtime.today_projection;
@@ -1917,7 +2132,15 @@ fn home_today_content(
.iter()
.map(home_low_stock_row)
.collect::<Vec<_>>(),
- None,
+ Some(
+ action_button_compact(
+ "home-today-open-products-low-stock",
+ app_shared_text(AppTextKey::HomeTodayOpenInProductsAction),
+ on_open_low_stock_products,
+ cx,
+ )
+ .into_any_element(),
+ ),
)
.into_any_element(),
);
@@ -1932,7 +2155,15 @@ fn home_today_content(
.iter()
.map(home_draft_row)
.collect::<Vec<_>>(),
- None,
+ Some(
+ action_button_compact(
+ "home-today-open-products-drafts",
+ app_shared_text(AppTextKey::HomeTodayOpenInProductsAction),
+ on_open_draft_products,
+ cx,
+ )
+ .into_any_element(),
+ ),
)
.into_any_element(),
);
@@ -2010,78 +2241,6 @@ fn home_today_content(
.children(sections)
}
-fn home_products_content(
- runtime: &DesktopAppRuntimeSummary,
- products_search: Option<&ProductsSearchState>,
- on_select_all_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_live_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_draft_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_products_needing_attention: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_paused_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_select_archived_products: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_updated: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_name: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_availability: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_stock: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- on_sort_products_by_price: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
- cx: &App,
-) -> impl IntoElement {
- let projection = &runtime.products_projection;
- let summary = &projection.list.summary;
-
- div()
- .w_full()
- .max_w(px(APP_UI_THEME.layout.home_card_max_width_px))
- .mx_auto()
- .flex()
- .flex_col()
- .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
- .child(products_title_row(runtime))
- .child(
- div()
- .w_full()
- .flex()
- .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
- .child(home_summary_metric(
- AppTextKey::ProductsSummaryTotal,
- summary.total_products,
- ))
- .child(home_summary_metric(
- AppTextKey::ProductsSummaryLive,
- summary.live_products,
- ))
- .child(home_summary_metric(
- AppTextKey::ProductsSummaryNeedAttention,
- summary.need_attention_products,
- ))
- .child(home_summary_metric(
- AppTextKey::ProductsSummaryDrafts,
- summary.draft_products,
- )),
- )
- .child(products_controls_card(
- runtime,
- products_search,
- on_select_all_products,
- on_select_live_products,
- on_select_draft_products,
- on_select_products_needing_attention,
- on_select_paused_products,
- on_select_archived_products,
- on_sort_products_by_updated,
- on_sort_products_by_name,
- on_sort_products_by_availability,
- on_sort_products_by_stock,
- on_sort_products_by_price,
- cx,
- ))
- .child(if projection.list.is_empty() {
- products_empty_state_card(projection.query.filter).into_any_element()
- } else {
- products_table_card(&projection.list.rows).into_any_element()
- })
-}
-
fn selected_farmer_section(runtime: &DesktopAppRuntimeSummary) -> FarmerSection {
match runtime.shell_projection.selected_section {
ShellSection::Farmer(section) => section,
@@ -2298,31 +2457,6 @@ fn products_filter_button(
}
}
-fn products_table_card(rows: &[ProductsListRow]) -> impl IntoElement {
- home_card(
- app_shared_text(AppTextKey::ProductsTableTitle),
- div()
- .w_full()
- .flex()
- .flex_col()
- .gap(px(12.0))
- .child(products_table_header())
- .child(section_divider())
- .children(
- rows.iter()
- .enumerate()
- .flat_map(|(index, row)| {
- let mut items = vec![products_table_row(row).into_any_element()];
- if index + 1 < rows.len() {
- items.push(section_divider().into_any_element());
- }
- items
- })
- .collect::<Vec<_>>(),
- ),
- )
-}
-
fn products_table_header() -> impl IntoElement {
div()
.w_full()
@@ -2359,6 +2493,11 @@ fn products_table_header() -> impl IntoElement {
Some(164.0),
false,
))
+ .child(products_table_header_column(
+ AppTextKey::ProductsColumnAction,
+ Some(120.0),
+ false,
+ ))
}
fn products_table_header_column(
@@ -2375,7 +2514,7 @@ fn products_table_header_column(
.child(app_shared_text(key))
}
-fn products_table_row(row: &ProductsListRow) -> impl IntoElement {
+fn products_table_row(row: &ProductsListRow, action: AnyElement) -> impl IntoElement {
div()
.w_full()
.flex()
@@ -2451,6 +2590,7 @@ fn products_table_row(row: &ProductsListRow) -> impl IntoElement {
.text_color(rgb(APP_UI_THEME.text.secondary))
.child(row.updated_at.clone()),
)
+ .child(div().w(px(120.0)).flex().justify_end().child(action))
}
fn products_empty_state_card(filter: ProductsFilter) -> impl IntoElement {
@@ -2511,6 +2651,175 @@ fn products_price_text(row: &ProductsListRow) -> String {
format!("${dollars}.{cents:02} / {}", price.unit_label)
}
+fn products_row_action_button(
+ id: (&'static str, usize),
+ label: impl Into<SharedString>,
+ on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ let sizing = APP_UI_THEME.controls.action_button.sizing;
+ let colors = APP_UI_THEME.controls.action_button.colors;
+
+ Button::new(id)
+ .custom(
+ ButtonCustomVariant::new(cx)
+ .color(rgb(colors.background).into())
+ .foreground(rgb(colors.foreground).into())
+ .border(transparent_black())
+ .hover(rgb(colors.background).into())
+ .active(rgb(colors.active_background).into()),
+ )
+ .rounded(ButtonRounded::Size(px(sizing.corner_radius_px)))
+ .h(px(sizing.height_px))
+ .on_click(on_click)
+ .child(
+ div()
+ .h_full()
+ .flex()
+ .items_center()
+ .justify_center()
+ .px(px(sizing.compact_horizontal_padding_px))
+ .text_size(px(sizing.label_size_px))
+ .text_color(rgb(colors.foreground))
+ .child(label.into()),
+ )
+}
+
+fn products_stock_editor_card(
+ row: &ProductsListRow,
+ editor: &ProductsStockEditorState,
+ on_save: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ on_cancel: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ let validation_key = products_stock_editor_validation_key(editor, cx);
+ let save_ready = editor.has_changes(cx) && editor.parsed_stock_quantity(cx).is_some();
+
+ div()
+ .w_full()
+ .bg(rgb(APP_UI_THEME.surfaces.window_background))
+ .rounded(px(APP_UI_THEME
+ .controls
+ .action_button
+ .sizing
+ .corner_radius_px))
+ .p(px(16.0))
+ .flex()
+ .flex_col()
+ .gap(px(8.0))
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(2.0))
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.body_text_px))
+ .font_weight(gpui::FontWeight::SEMIBOLD)
+ .text_color(rgb(APP_UI_THEME.text.primary))
+ .child(app_shared_text(AppTextKey::ProductsStockEditorTitle)),
+ )
+ .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(row.title.clone()),
+ ),
+ )
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .items_end()
+ .gap(px(APP_UI_THEME.layout.home_stack_gap_px))
+ .child(
+ div()
+ .flex_1()
+ .min_w_0()
+ .flex()
+ .flex_col()
+ .gap(px(6.0))
+ .child(
+ div()
+ .text_size(px(APP_UI_THEME.typography.utility_title_text_px))
+ .font_weight(gpui::FontWeight::SEMIBOLD)
+ .text_color(rgb(APP_UI_THEME.text.secondary))
+ .child(app_shared_text(AppTextKey::ProductsStockEditorFieldLabel)),
+ )
+ .child(
+ Input::new(&editor.input)
+ .with_size(ComponentSize::Large)
+ .w_full(),
+ )
+ .when_some(validation_key, |this, key| {
+ 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(app_shared_text(key)),
+ )
+ })
+ .when(editor.save_failed, |this| {
+ 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(app_shared_text(
+ AppTextKey::ProductsStockEditorSaveFailed,
+ )),
+ )
+ }),
+ )
+ .child(
+ div()
+ .flex()
+ .items_center()
+ .gap(px(8.0))
+ .child(action_button_compact(
+ "products-stock-editor-close",
+ app_shared_text(AppTextKey::ProductsStockEditorCancelAction),
+ on_cancel,
+ cx,
+ ))
+ .child(if save_ready {
+ action_button_primary(
+ "products-stock-editor-save",
+ app_shared_text(AppTextKey::ProductsStockEditorSaveAction),
+ on_save,
+ cx,
+ )
+ .into_any_element()
+ } else {
+ action_button_primary_disabled(
+ "products-stock-editor-save",
+ app_shared_text(AppTextKey::ProductsStockEditorSaveAction),
+ cx,
+ )
+ .into_any_element()
+ }),
+ ),
+ )
+}
+
+fn products_stock_editor_validation_key(
+ editor: &ProductsStockEditorState,
+ cx: &App,
+) -> Option<AppTextKey> {
+ if editor.parsed_stock_quantity(cx).is_some() {
+ return None;
+ }
+
+ Some(AppTextKey::ProductsStockEditorInvalidQuantity)
+}
+
+fn parse_products_stock_quantity(input: &str) -> Option<u32> {
+ input.trim().parse().ok()
+}
+
fn home_farm_setup_onboarding_card(
spec: FarmSetupOnboardingCardSpec,
on_open_farm_setup: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs
@@ -33,6 +33,7 @@ define_app_text_keys! {
HomeTodayOrdersNeedingAction => "home.today.orders_needing_action",
HomeTodayLowStock => "home.today.low_stock",
HomeTodayDraftProducts => "home.today.draft_products",
+ HomeTodayOpenInProductsAction => "home.today.open_in_products.action",
HomeTodaySetupChecklist => "home.today.setup_checklist",
HomeTodayNextFulfillmentWindow => "home.today.next_fulfillment_window",
HomeTodayWindowStartsLabel => "home.today.window.starts",
@@ -95,6 +96,14 @@ define_app_text_keys! {
ProductsColumnStock => "products.column.stock",
ProductsColumnPrice => "products.column.price",
ProductsColumnUpdated => "products.column.updated",
+ ProductsColumnAction => "products.column.action",
+ ProductsUpdateStockAction => "products.action.update_stock",
+ ProductsStockEditorTitle => "products.stock_editor.title",
+ ProductsStockEditorFieldLabel => "products.stock_editor.field.label",
+ ProductsStockEditorSaveAction => "products.stock_editor.action.save",
+ ProductsStockEditorCancelAction => "products.stock_editor.action.cancel",
+ ProductsStockEditorInvalidQuantity => "products.stock_editor.invalid_quantity",
+ ProductsStockEditorSaveFailed => "products.stock_editor.save_failed",
ProductsStatusDraft => "products.status.draft",
ProductsStatusLive => "products.status.live",
ProductsStatusPaused => "products.status.paused",
diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json
@@ -12,6 +12,7 @@
"home.today.orders_needing_action": "Orders needing action",
"home.today.low_stock": "Low stock",
"home.today.draft_products": "Draft products",
+ "home.today.open_in_products.action": "Open in Products",
"home.today.setup_checklist": "Setup checklist",
"home.today.next_fulfillment_window": "Next fulfillment window",
"home.today.window.starts": "Starts",
@@ -74,6 +75,14 @@
"products.column.stock": "Stock",
"products.column.price": "Price",
"products.column.updated": "Updated",
+ "products.column.action": "Action",
+ "products.action.update_stock": "Update stock",
+ "products.stock_editor.title": "Update stock",
+ "products.stock_editor.field.label": "Stock",
+ "products.stock_editor.action.save": "Save stock",
+ "products.stock_editor.action.cancel": "Cancel",
+ "products.stock_editor.invalid_quantity": "Enter a whole number.",
+ "products.stock_editor.save_failed": "Couldn't save stock. Try again.",
"products.status.draft": "Draft",
"products.status.live": "Live",
"products.status.paused": "Paused",