commit c7c8e272a54891d9812494158ee81064f318423b
parent f2e1a0b3ebc5f7fb0a7fdad63d617c206d13be89
Author: triesap <tyson@radroots.org>
Date: Sun, 19 Apr 2026 00:48:35 +0000
settings: add farm rules scheduling editors
Diffstat:
7 files changed, 1538 insertions(+), 120 deletions(-)
diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs
@@ -1355,6 +1355,28 @@ fn normalize_farm_rules_projection(
.filter(|directions| !directions.is_empty());
}
+ if let Some(operating_rules) = projection.operating_rules.as_mut() {
+ operating_rules.farm_id = fallback_profile.farm_id;
+ operating_rules.substitution_policy = operating_rules.substitution_policy.trim().to_owned();
+ operating_rules.missed_pickup_policy =
+ operating_rules.missed_pickup_policy.trim().to_owned();
+ }
+
+ for fulfillment_window in &mut projection.fulfillment_windows {
+ fulfillment_window.farm_id = fallback_profile.farm_id;
+ fulfillment_window.label = fulfillment_window.label.trim().to_owned();
+ fulfillment_window.starts_at = fulfillment_window.starts_at.trim().to_owned();
+ fulfillment_window.ends_at = fulfillment_window.ends_at.trim().to_owned();
+ fulfillment_window.order_cutoff_at = fulfillment_window.order_cutoff_at.trim().to_owned();
+ }
+
+ for blackout_period in &mut projection.blackout_periods {
+ blackout_period.farm_id = fallback_profile.farm_id;
+ blackout_period.label = blackout_period.label.trim().to_owned();
+ blackout_period.starts_at = blackout_period.starts_at.trim().to_owned();
+ blackout_period.ends_at = blackout_period.ends_at.trim().to_owned();
+ }
+
normalize_pickup_location_defaults(&mut projection.pickup_locations);
projection.readiness = derive_farm_rules_readiness(&projection);
projection
@@ -1385,13 +1407,14 @@ mod tests {
AppSharedAccountsPaths, SHARED_ACCOUNTS_STORE_FILE_NAME, SHARED_IDENTITY_FILE_NAME,
};
use radroots_app_models::{
- AccountSurfaceActivationProjection, ActiveSurface, AppActivityKind, AppStartupGate, FarmId,
- FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmReadinessBlocker, FarmSetupDraft,
+ AccountSurfaceActivationProjection, ActiveSurface, AppActivityKind, AppStartupGate,
+ BlackoutPeriodId, BlackoutPeriodRecord, FarmId, FarmOperatingRulesRecord, FarmOrderMethod,
+ FarmProfileRecord, FarmReadiness, FarmReadinessBlocker, FarmSetupDraft,
FarmSetupProjection, FarmSummary, FarmerActivationProjection, FarmerSection,
- LoggedOutStartupProjection, PickupLocationId, PickupLocationRecord, ProductEditorDraft,
- ProductStatus, ProductsFilter, ProductsSort, SelectedSurfaceProjection, SettingsPreference,
- SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind,
- TodaySummary,
+ FulfillmentWindowId, FulfillmentWindowRecord, LoggedOutStartupProjection, PickupLocationId,
+ PickupLocationRecord, ProductEditorDraft, ProductStatus, ProductsFilter, ProductsSort,
+ SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection,
+ TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary,
};
use radroots_app_remote_signer::{
RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord,
@@ -2859,6 +2882,11 @@ mod tests {
.expect("account should select")
);
+ let default_pickup_location_id = PickupLocationId::new();
+ let market_pickup_location_id = PickupLocationId::new();
+ let fulfillment_window_id = FulfillmentWindowId::new();
+ let blackout_period_id = BlackoutPeriodId::new();
+
let saved_projection = runtime
.save_farm_rules_projection(radroots_app_models::FarmRulesProjection {
farm_profile: Some(FarmProfileRecord {
@@ -2869,7 +2897,7 @@ mod tests {
}),
pickup_locations: vec![
PickupLocationRecord {
- pickup_location_id: PickupLocationId::new(),
+ pickup_location_id: default_pickup_location_id,
farm_id,
label: " Barn pickup ".to_owned(),
address_line: " 14 Orchard Lane ".to_owned(),
@@ -2877,7 +2905,7 @@ mod tests {
is_default: false,
},
PickupLocationRecord {
- pickup_location_id: PickupLocationId::new(),
+ pickup_location_id: market_pickup_location_id,
farm_id,
label: "Market stall".to_owned(),
address_line: "2 Harbor Road".to_owned(),
@@ -2885,6 +2913,28 @@ mod tests {
is_default: false,
},
],
+ operating_rules: Some(FarmOperatingRulesRecord {
+ farm_id,
+ promise_lead_hours: 24,
+ substitution_policy: " ask_customer ".to_owned(),
+ missed_pickup_policy: " hold_next_window ".to_owned(),
+ }),
+ fulfillment_windows: vec![FulfillmentWindowRecord {
+ fulfillment_window_id,
+ farm_id,
+ pickup_location_id: default_pickup_location_id,
+ label: " Friday pickup ".to_owned(),
+ starts_at: " 2026-04-25T14:00:00Z ".to_owned(),
+ ends_at: " 2026-04-25T18:00:00Z ".to_owned(),
+ order_cutoff_at: " 2026-04-24T18:00:00Z ".to_owned(),
+ }],
+ blackout_periods: vec![BlackoutPeriodRecord {
+ blackout_period_id,
+ farm_id,
+ label: " Spring break ".to_owned(),
+ starts_at: " 2026-05-01T00:00:00Z ".to_owned(),
+ ends_at: " 2026-05-03T23:59:59Z ".to_owned(),
+ }],
..runtime
.load_farm_rules_projection()
.expect("farm rules projection should load")
@@ -2911,6 +2961,37 @@ mod tests {
saved_projection.pickup_locations[0].directions.as_deref(),
Some("Drive to the red barn.")
);
+ assert_eq!(
+ saved_projection.operating_rules,
+ Some(FarmOperatingRulesRecord {
+ farm_id,
+ promise_lead_hours: 24,
+ substitution_policy: "ask_customer".to_owned(),
+ missed_pickup_policy: "hold_next_window".to_owned(),
+ })
+ );
+ assert_eq!(
+ saved_projection.fulfillment_windows,
+ vec![FulfillmentWindowRecord {
+ fulfillment_window_id,
+ farm_id,
+ pickup_location_id: default_pickup_location_id,
+ label: "Friday pickup".to_owned(),
+ starts_at: "2026-04-25T14:00:00Z".to_owned(),
+ ends_at: "2026-04-25T18:00:00Z".to_owned(),
+ order_cutoff_at: "2026-04-24T18:00:00Z".to_owned(),
+ }]
+ );
+ assert_eq!(
+ saved_projection.blackout_periods,
+ vec![BlackoutPeriodRecord {
+ blackout_period_id,
+ farm_id,
+ label: "Spring break".to_owned(),
+ starts_at: "2026-05-01T00:00:00Z".to_owned(),
+ ends_at: "2026-05-03T23:59:59Z".to_owned(),
+ }]
+ );
let summary = runtime.summary();
assert_eq!(
@@ -2918,7 +2999,7 @@ mod tests {
Some(FarmSummary {
farm_id,
display_name: "Harbor farm".to_owned(),
- readiness: FarmReadiness::Incomplete,
+ readiness: FarmReadiness::Ready,
})
);
assert_eq!(summary.farm_setup_projection.draft.farm_name, "Harbor farm");
diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs
@@ -99,11 +99,14 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"remote signer did not respond yet",
"runtime unavailable",
"settings",
+ "settings-add-blackout-period",
+ "settings-add-fulfillment-window",
"settings-allow-relay-connections",
"settings-farm-add-pickup",
"settings-farm-default-pickup",
"settings-farm-remove-pickup",
"settings-farm-save",
+ "settings-fulfillment-window-pickup-location",
"settings-launch-at-login",
"settings-manage-media-servers",
"settings-nav-about",
@@ -111,6 +114,8 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[
"settings-nav-farm",
"settings-nav-settings",
"settings-panel-scroll",
+ "settings-remove-blackout-period",
+ "settings-remove-fulfillment-window",
"settings-use-media-servers",
"settings-use-nip05",
"settings.farm.load_failed",
@@ -237,6 +242,7 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[
"AppTextKey::SettingsFarmSaveAction",
"AppTextKey::SettingsFarmSaveSaved",
"AppTextKey::SettingsFarmSavePending",
+ "AppTextKey::SettingsFarmSaveBlocked",
"AppTextKey::SettingsFarmSaveFailed",
"AppTextKey::SettingsFarmFieldTimezone",
"AppTextKey::SettingsFarmFieldCurrency",
@@ -255,22 +261,39 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[
"AppTextKey::SettingsOperatingRulesFieldPromiseLeadTime",
"AppTextKey::SettingsOperatingRulesFieldSubstitutionPolicy",
"AppTextKey::SettingsOperatingRulesFieldMissedPickupPolicy",
+ "AppTextKey::SettingsOperatingRulesInvalidPromiseLeadTime",
"AppTextKey::SettingsFulfillmentWindowsSectionLabel",
+ "AppTextKey::SettingsFulfillmentWindowsEmptyBody",
+ "AppTextKey::SettingsFulfillmentWindowsPickupLocationsBody",
+ "AppTextKey::SettingsFulfillmentWindowsAddAction",
+ "AppTextKey::SettingsFulfillmentWindowsRemoveAction",
+ "AppTextKey::SettingsFulfillmentWindowsItemLabel",
"AppTextKey::SettingsFulfillmentWindowsFieldLabel",
"AppTextKey::SettingsFulfillmentWindowsFieldPickupLocation",
"AppTextKey::SettingsFulfillmentWindowsFieldStartsAt",
"AppTextKey::SettingsFulfillmentWindowsFieldEndsAt",
"AppTextKey::SettingsFulfillmentWindowsFieldOrderCutoff",
+ "AppTextKey::SettingsFulfillmentWindowsValidationCompleteBeforeSave",
+ "AppTextKey::SettingsFulfillmentWindowsValidationChoosePickupLocation",
"AppTextKey::SettingsBlackoutPeriodsSectionLabel",
+ "AppTextKey::SettingsBlackoutPeriodsEmptyBody",
+ "AppTextKey::SettingsBlackoutPeriodsAddAction",
+ "AppTextKey::SettingsBlackoutPeriodsRemoveAction",
+ "AppTextKey::SettingsBlackoutPeriodsItemLabel",
"AppTextKey::SettingsBlackoutPeriodsFieldLabel",
"AppTextKey::SettingsBlackoutPeriodsFieldStartsAt",
"AppTextKey::SettingsBlackoutPeriodsFieldEndsAt",
+ "AppTextKey::SettingsBlackoutPeriodsValidationCompleteBeforeSave",
"AppTextKey::SettingsReadinessSectionLabel",
"AppTextKey::SettingsReadinessFieldMissingProfileBasics",
"AppTextKey::SettingsReadinessFieldMissingPickupLocation",
"AppTextKey::SettingsReadinessFieldMissingFulfillmentWindow",
"AppTextKey::SettingsReadinessFieldMissingOperatingRules",
"AppTextKey::SettingsReadinessFieldInvalidTimingConflicts",
+ "AppTextKey::SettingsReadinessFieldFulfillmentWindowEndsBeforeStart",
+ "AppTextKey::SettingsReadinessFieldFulfillmentWindowCutoffAfterStart",
+ "AppTextKey::SettingsReadinessFieldBlackoutPeriodEndsBeforeStart",
+ "AppTextKey::SettingsReadinessFieldBlackoutOverlapsFulfillmentWindow",
"AppTextKey::SettingsReadinessReady",
];
diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs
@@ -13,12 +13,14 @@ use gpui_component::{
use radroots_app_i18n::AppTextKey;
pub use radroots_app_models::SettingsSection as SettingsPanelViewKey;
use radroots_app_models::{
- AppStartupGate, FarmId, FarmOrderMethod, FarmProfileRecord, FarmReadiness,
- FarmReadinessBlocker, FarmRulesProjection, FarmSetupBlocker, FarmSetupDraft, FarmSummary,
- FarmerSection, FulfillmentWindowSummary, LoggedOutStartupPhase, OrderListRow, PickupLocationId,
- PickupLocationRecord, ProductAttentionState, ProductEditorDraft, ProductId, ProductListRow,
- ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsListRow, ProductsSort,
- ShellSection, TodayAgendaProjection, TodaySetupTaskKind,
+ AppStartupGate, BlackoutPeriodId, BlackoutPeriodRecord, FarmId, FarmOperatingRulesRecord,
+ FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmReadinessBlocker, FarmRulesProjection,
+ FarmRulesReadiness, FarmSetupBlocker, FarmSetupDraft, FarmSummary, FarmTimingConflictKind,
+ FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, FulfillmentWindowSummary,
+ LoggedOutStartupPhase, OrderListRow, PickupLocationId, PickupLocationRecord,
+ ProductAttentionState, ProductEditorDraft, ProductId, ProductListRow, ProductPublishBlocker,
+ ProductStatus, ProductsFilter, ProductsListRow, ProductsSort, ShellSection,
+ TodayAgendaProjection, TodaySetupTaskKind,
};
use radroots_app_remote_signer::{
RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome,
@@ -1934,28 +1936,404 @@ impl SettingsPickupLocationFormState {
}
}
- fn current_record(&self, farm_id: FarmId, cx: &App) -> PickupLocationRecord {
- let directions = self.directions_input.read(cx).value().trim().to_owned();
+ fn current_draft(&self, cx: &App) -> SettingsPickupLocationDraft {
+ SettingsPickupLocationDraft {
+ pickup_location_id: self.pickup_location_id,
+ label: self.label_input.read(cx).value().to_string(),
+ address_line: self.address_input.read(cx).value().to_string(),
+ directions: self.directions_input.read(cx).value().to_string(),
+ is_default: self.is_default,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct SettingsPickupLocationDraft {
+ pickup_location_id: PickupLocationId,
+ label: String,
+ address_line: String,
+ directions: String,
+ is_default: bool,
+}
+
+impl SettingsPickupLocationDraft {
+ fn from_record(record: &PickupLocationRecord) -> Self {
+ Self {
+ pickup_location_id: record.pickup_location_id,
+ label: record.label.clone(),
+ address_line: record.address_line.clone(),
+ directions: record.directions.clone().unwrap_or_default(),
+ is_default: record.is_default,
+ }
+ }
+
+ fn into_record(self, farm_id: FarmId) -> PickupLocationRecord {
+ let directions = self.directions.trim().to_owned();
PickupLocationRecord {
pickup_location_id: self.pickup_location_id,
farm_id,
- label: self.label_input.read(cx).value().to_string(),
- address_line: self.address_input.read(cx).value().to_string(),
+ label: self.label.trim().to_owned(),
+ address_line: self.address_line.trim().to_owned(),
directions: (!directions.is_empty()).then_some(directions),
is_default: self.is_default,
}
}
}
+struct SettingsOperatingRulesFormState {
+ promise_lead_hours_input: Entity<InputState>,
+ substitution_policy_input: Entity<InputState>,
+ missed_pickup_policy_input: Entity<InputState>,
+ _promise_lead_hours_subscription: Subscription,
+ _substitution_policy_subscription: Subscription,
+ _missed_pickup_policy_subscription: Subscription,
+}
+
+impl SettingsOperatingRulesFormState {
+ fn new(
+ record: Option<&FarmOperatingRulesRecord>,
+ window: &mut Window,
+ cx: &mut Context<SettingsWindowView>,
+ ) -> Self {
+ let promise_lead_hours_input = cx.new(|cx| {
+ InputState::new(window, cx).default_value(
+ record
+ .map(|record| record.promise_lead_hours.to_string())
+ .unwrap_or_default(),
+ )
+ });
+ let substitution_policy_input = cx.new(|cx| {
+ InputState::new(window, cx).default_value(
+ record
+ .map(|record| record.substitution_policy.clone())
+ .unwrap_or_default(),
+ )
+ });
+ let missed_pickup_policy_input = cx.new(|cx| {
+ InputState::new(window, cx).default_value(
+ record
+ .map(|record| record.missed_pickup_policy.clone())
+ .unwrap_or_default(),
+ )
+ });
+ let promise_lead_hours_subscription = cx.subscribe_in(
+ &promise_lead_hours_input,
+ window,
+ SettingsWindowView::handle_farm_rules_input_event,
+ );
+ let substitution_policy_subscription = cx.subscribe_in(
+ &substitution_policy_input,
+ window,
+ SettingsWindowView::handle_farm_rules_input_event,
+ );
+ let missed_pickup_policy_subscription = cx.subscribe_in(
+ &missed_pickup_policy_input,
+ window,
+ SettingsWindowView::handle_farm_rules_input_event,
+ );
+
+ Self {
+ promise_lead_hours_input,
+ substitution_policy_input,
+ missed_pickup_policy_input,
+ _promise_lead_hours_subscription: promise_lead_hours_subscription,
+ _substitution_policy_subscription: substitution_policy_subscription,
+ _missed_pickup_policy_subscription: missed_pickup_policy_subscription,
+ }
+ }
+
+ fn current_draft(&self, cx: &App) -> SettingsOperatingRulesDraft {
+ SettingsOperatingRulesDraft {
+ promise_lead_hours: self.promise_lead_hours_input.read(cx).value().to_string(),
+ substitution_policy: self.substitution_policy_input.read(cx).value().to_string(),
+ missed_pickup_policy: self.missed_pickup_policy_input.read(cx).value().to_string(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct SettingsOperatingRulesDraft {
+ promise_lead_hours: String,
+ substitution_policy: String,
+ missed_pickup_policy: String,
+}
+
+impl SettingsOperatingRulesDraft {
+ fn from_record(record: Option<&FarmOperatingRulesRecord>) -> Self {
+ Self {
+ promise_lead_hours: record
+ .map(|record| record.promise_lead_hours.to_string())
+ .unwrap_or_default(),
+ substitution_policy: record
+ .map(|record| record.substitution_policy.clone())
+ .unwrap_or_default(),
+ missed_pickup_policy: record
+ .map(|record| record.missed_pickup_policy.clone())
+ .unwrap_or_default(),
+ }
+ }
+
+ fn is_empty(&self) -> bool {
+ self.promise_lead_hours.trim().is_empty()
+ && self.substitution_policy.trim().is_empty()
+ && self.missed_pickup_policy.trim().is_empty()
+ }
+}
+
+struct SettingsFulfillmentWindowFormState {
+ fulfillment_window_id: FulfillmentWindowId,
+ selected_pickup_location_id: Option<PickupLocationId>,
+ label_input: Entity<InputState>,
+ starts_at_input: Entity<InputState>,
+ ends_at_input: Entity<InputState>,
+ order_cutoff_input: Entity<InputState>,
+ _label_subscription: Subscription,
+ _starts_at_subscription: Subscription,
+ _ends_at_subscription: Subscription,
+ _order_cutoff_subscription: Subscription,
+}
+
+impl SettingsFulfillmentWindowFormState {
+ fn new(
+ draft: &SettingsFulfillmentWindowDraft,
+ window: &mut Window,
+ cx: &mut Context<SettingsWindowView>,
+ ) -> Self {
+ let label_input =
+ cx.new(|cx| InputState::new(window, cx).default_value(draft.label.clone()));
+ let starts_at_input =
+ cx.new(|cx| InputState::new(window, cx).default_value(draft.starts_at.clone()));
+ let ends_at_input =
+ cx.new(|cx| InputState::new(window, cx).default_value(draft.ends_at.clone()));
+ let order_cutoff_input =
+ cx.new(|cx| InputState::new(window, cx).default_value(draft.order_cutoff_at.clone()));
+ let label_subscription = cx.subscribe_in(
+ &label_input,
+ window,
+ SettingsWindowView::handle_farm_rules_input_event,
+ );
+ let starts_at_subscription = cx.subscribe_in(
+ &starts_at_input,
+ window,
+ SettingsWindowView::handle_farm_rules_input_event,
+ );
+ let ends_at_subscription = cx.subscribe_in(
+ &ends_at_input,
+ window,
+ SettingsWindowView::handle_farm_rules_input_event,
+ );
+ let order_cutoff_subscription = cx.subscribe_in(
+ &order_cutoff_input,
+ window,
+ SettingsWindowView::handle_farm_rules_input_event,
+ );
+
+ Self {
+ fulfillment_window_id: draft.fulfillment_window_id,
+ selected_pickup_location_id: draft.selected_pickup_location_id,
+ label_input,
+ starts_at_input,
+ ends_at_input,
+ order_cutoff_input,
+ _label_subscription: label_subscription,
+ _starts_at_subscription: starts_at_subscription,
+ _ends_at_subscription: ends_at_subscription,
+ _order_cutoff_subscription: order_cutoff_subscription,
+ }
+ }
+
+ fn current_draft(&self, cx: &App) -> SettingsFulfillmentWindowDraft {
+ SettingsFulfillmentWindowDraft {
+ fulfillment_window_id: self.fulfillment_window_id,
+ selected_pickup_location_id: self.selected_pickup_location_id,
+ label: self.label_input.read(cx).value().to_string(),
+ starts_at: self.starts_at_input.read(cx).value().to_string(),
+ ends_at: self.ends_at_input.read(cx).value().to_string(),
+ order_cutoff_at: self.order_cutoff_input.read(cx).value().to_string(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct SettingsFulfillmentWindowDraft {
+ fulfillment_window_id: FulfillmentWindowId,
+ selected_pickup_location_id: Option<PickupLocationId>,
+ label: String,
+ starts_at: String,
+ ends_at: String,
+ order_cutoff_at: String,
+}
+
+impl SettingsFulfillmentWindowDraft {
+ fn from_record(record: &FulfillmentWindowRecord) -> Self {
+ Self {
+ fulfillment_window_id: record.fulfillment_window_id,
+ selected_pickup_location_id: Some(record.pickup_location_id),
+ label: record.label.clone(),
+ starts_at: record.starts_at.clone(),
+ ends_at: record.ends_at.clone(),
+ order_cutoff_at: record.order_cutoff_at.clone(),
+ }
+ }
+}
+
+struct SettingsBlackoutPeriodFormState {
+ blackout_period_id: BlackoutPeriodId,
+ label_input: Entity<InputState>,
+ starts_at_input: Entity<InputState>,
+ ends_at_input: Entity<InputState>,
+ _label_subscription: Subscription,
+ _starts_at_subscription: Subscription,
+ _ends_at_subscription: Subscription,
+}
+
+impl SettingsBlackoutPeriodFormState {
+ fn new(
+ draft: &SettingsBlackoutPeriodDraft,
+ window: &mut Window,
+ cx: &mut Context<SettingsWindowView>,
+ ) -> Self {
+ let label_input =
+ cx.new(|cx| InputState::new(window, cx).default_value(draft.label.clone()));
+ let starts_at_input =
+ cx.new(|cx| InputState::new(window, cx).default_value(draft.starts_at.clone()));
+ let ends_at_input =
+ cx.new(|cx| InputState::new(window, cx).default_value(draft.ends_at.clone()));
+ let label_subscription = cx.subscribe_in(
+ &label_input,
+ window,
+ SettingsWindowView::handle_farm_rules_input_event,
+ );
+ let starts_at_subscription = cx.subscribe_in(
+ &starts_at_input,
+ window,
+ SettingsWindowView::handle_farm_rules_input_event,
+ );
+ let ends_at_subscription = cx.subscribe_in(
+ &ends_at_input,
+ window,
+ SettingsWindowView::handle_farm_rules_input_event,
+ );
+
+ Self {
+ blackout_period_id: draft.blackout_period_id,
+ label_input,
+ starts_at_input,
+ ends_at_input,
+ _label_subscription: label_subscription,
+ _starts_at_subscription: starts_at_subscription,
+ _ends_at_subscription: ends_at_subscription,
+ }
+ }
+
+ fn current_draft(&self, cx: &App) -> SettingsBlackoutPeriodDraft {
+ SettingsBlackoutPeriodDraft {
+ blackout_period_id: self.blackout_period_id,
+ label: self.label_input.read(cx).value().to_string(),
+ starts_at: self.starts_at_input.read(cx).value().to_string(),
+ ends_at: self.ends_at_input.read(cx).value().to_string(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct SettingsBlackoutPeriodDraft {
+ blackout_period_id: BlackoutPeriodId,
+ label: String,
+ starts_at: String,
+ ends_at: String,
+}
+
+impl SettingsBlackoutPeriodDraft {
+ fn from_record(record: &BlackoutPeriodRecord) -> Self {
+ Self {
+ blackout_period_id: record.blackout_period_id,
+ label: record.label.clone(),
+ starts_at: record.starts_at.clone(),
+ ends_at: record.ends_at.clone(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct SettingsFarmRulesDraft {
+ farm_profile: FarmProfileRecord,
+ pickup_locations: Vec<SettingsPickupLocationDraft>,
+ operating_rules: SettingsOperatingRulesDraft,
+ fulfillment_windows: Vec<SettingsFulfillmentWindowDraft>,
+ blackout_periods: Vec<SettingsBlackoutPeriodDraft>,
+}
+
+impl SettingsFarmRulesDraft {
+ fn from_projection(farm_id: FarmId, projection: &FarmRulesProjection) -> Self {
+ let farm_profile = projection
+ .farm_profile
+ .as_ref()
+ .cloned()
+ .unwrap_or(FarmProfileRecord {
+ farm_id,
+ display_name: String::new(),
+ timezone: String::new(),
+ currency_code: String::new(),
+ });
+
+ Self {
+ farm_profile,
+ pickup_locations: projection
+ .pickup_locations
+ .iter()
+ .map(SettingsPickupLocationDraft::from_record)
+ .collect(),
+ operating_rules: SettingsOperatingRulesDraft::from_record(
+ projection.operating_rules.as_ref(),
+ ),
+ fulfillment_windows: projection
+ .fulfillment_windows
+ .iter()
+ .map(SettingsFulfillmentWindowDraft::from_record)
+ .collect(),
+ blackout_periods: projection
+ .blackout_periods
+ .iter()
+ .map(SettingsBlackoutPeriodDraft::from_record)
+ .collect(),
+ }
+ }
+}
+
+struct SettingsFarmRulesEvaluation {
+ projection: FarmRulesProjection,
+ operating_rules_validation_keys: Vec<AppTextKey>,
+ fulfillment_window_validation_keys: Vec<Vec<AppTextKey>>,
+ blackout_period_validation_keys: Vec<Vec<AppTextKey>>,
+ blocking_keys: Vec<AppTextKey>,
+ readiness_keys: Vec<AppTextKey>,
+}
+
+impl SettingsFarmRulesEvaluation {
+ fn has_blocking_errors(&self) -> bool {
+ !self.blocking_keys.is_empty()
+ }
+}
+
+fn push_unique_text_key(keys: &mut Vec<AppTextKey>, key: AppTextKey) {
+ if !keys.contains(&key) {
+ keys.push(key);
+ }
+}
+
struct SettingsFarmPanelState {
account_id: String,
farm_id: FarmId,
- initial_projection: FarmRulesProjection,
+ initial_draft: SettingsFarmRulesDraft,
farm_name_input: Entity<InputState>,
timezone_input: Entity<InputState>,
currency_input: Entity<InputState>,
pickup_locations: Vec<SettingsPickupLocationFormState>,
+ operating_rules: SettingsOperatingRulesFormState,
+ fulfillment_windows: Vec<SettingsFulfillmentWindowFormState>,
+ blackout_periods: Vec<SettingsBlackoutPeriodFormState>,
_farm_name_subscription: Subscription,
_timezone_subscription: Subscription,
_currency_subscription: Subscription,
@@ -1969,22 +2347,23 @@ impl SettingsFarmPanelState {
window: &mut Window,
cx: &mut Context<SettingsWindowView>,
) -> Self {
- let farm_profile = projection
+ let farm_id = projection
.farm_profile
.as_ref()
- .cloned()
- .unwrap_or(FarmProfileRecord {
- farm_id: FarmId::new(),
- display_name: String::new(),
- timezone: String::new(),
- currency_code: String::new(),
- });
- let farm_name_input =
- cx.new(|cx| InputState::new(window, cx).default_value(farm_profile.display_name));
- let timezone_input =
- cx.new(|cx| InputState::new(window, cx).default_value(farm_profile.timezone));
- let currency_input =
- cx.new(|cx| InputState::new(window, cx).default_value(farm_profile.currency_code));
+ .map(|farm_profile| farm_profile.farm_id)
+ .unwrap_or_else(FarmId::new);
+ let initial_draft = SettingsFarmRulesDraft::from_projection(farm_id, &projection);
+ let farm_name_input = cx.new(|cx| {
+ InputState::new(window, cx)
+ .default_value(initial_draft.farm_profile.display_name.clone())
+ });
+ let timezone_input = cx.new(|cx| {
+ InputState::new(window, cx).default_value(initial_draft.farm_profile.timezone.clone())
+ });
+ let currency_input = cx.new(|cx| {
+ InputState::new(window, cx)
+ .default_value(initial_draft.farm_profile.currency_code.clone())
+ });
let farm_name_subscription = cx.subscribe_in(
&farm_name_input,
window,
@@ -2010,25 +2389,48 @@ impl SettingsFarmPanelState {
SettingsPickupLocationFormState::new(record, can_remove, window, cx)
})
.collect();
- let farm_id = projection
- .farm_profile
- .as_ref()
- .map(|farm_profile| farm_profile.farm_id)
- .unwrap_or_else(FarmId::new);
-
- Self {
+ let operating_rules =
+ SettingsOperatingRulesFormState::new(projection.operating_rules.as_ref(), window, cx);
+ let fulfillment_windows = projection
+ .fulfillment_windows
+ .iter()
+ .map(|record| {
+ SettingsFulfillmentWindowFormState::new(
+ &SettingsFulfillmentWindowDraft::from_record(record),
+ window,
+ cx,
+ )
+ })
+ .collect();
+ let blackout_periods = projection
+ .blackout_periods
+ .iter()
+ .map(|record| {
+ SettingsBlackoutPeriodFormState::new(
+ &SettingsBlackoutPeriodDraft::from_record(record),
+ window,
+ cx,
+ )
+ })
+ .collect();
+ let mut state = Self {
account_id,
farm_id,
- initial_projection: projection,
+ initial_draft,
farm_name_input,
timezone_input,
currency_input,
pickup_locations,
+ operating_rules,
+ fulfillment_windows,
+ blackout_periods,
_farm_name_subscription: farm_name_subscription,
_timezone_subscription: timezone_subscription,
_currency_subscription: currency_subscription,
save_failed: false,
- }
+ };
+ state.sync_pickup_location_removability();
+ state
}
fn add_pickup_location(&mut self, window: &mut Window, cx: &mut Context<SettingsWindowView>) {
@@ -2043,6 +2445,7 @@ impl SettingsFarmPanelState {
let pickup_location = SettingsPickupLocationFormState::new(&record, true, window, cx);
self.pickup_locations.push(pickup_location);
+ self.sync_pickup_location_removability();
self.save_failed = false;
}
@@ -2065,39 +2468,331 @@ impl SettingsFarmPanelState {
first_pickup_location.is_default = true;
}
}
+ self.sync_pickup_location_removability();
self.save_failed = false;
}
- fn current_projection(&self, cx: &App) -> FarmRulesProjection {
- let mut projection = self.initial_projection.clone();
- projection.farm_profile = Some(FarmProfileRecord {
- farm_id: self.farm_id,
- display_name: self.farm_name_input.read(cx).value().to_string(),
- timezone: self.timezone_input.read(cx).value().to_string(),
- currency_code: self.currency_input.read(cx).value().to_string(),
- });
- projection.pickup_locations = self
+ fn add_fulfillment_window(
+ &mut self,
+ window: &mut Window,
+ cx: &mut Context<SettingsWindowView>,
+ ) {
+ let selected_pickup_location_id = self
.pickup_locations
.iter()
- .map(|pickup_location| pickup_location.current_record(self.farm_id, cx))
+ .find(|pickup_location| pickup_location.is_default)
+ .or_else(|| self.pickup_locations.first())
+ .map(|pickup_location| pickup_location.pickup_location_id);
+ let fulfillment_window = SettingsFulfillmentWindowFormState::new(
+ &SettingsFulfillmentWindowDraft {
+ fulfillment_window_id: FulfillmentWindowId::new(),
+ selected_pickup_location_id,
+ label: String::new(),
+ starts_at: String::new(),
+ ends_at: String::new(),
+ order_cutoff_at: String::new(),
+ },
+ window,
+ cx,
+ );
+
+ self.fulfillment_windows.push(fulfillment_window);
+ self.sync_pickup_location_removability();
+ self.save_failed = false;
+ }
+
+ fn select_fulfillment_window_pickup_location(
+ &mut self,
+ fulfillment_window_id: FulfillmentWindowId,
+ pickup_location_id: PickupLocationId,
+ ) {
+ if let Some(fulfillment_window) =
+ self.fulfillment_windows
+ .iter_mut()
+ .find(|fulfillment_window| {
+ fulfillment_window.fulfillment_window_id == fulfillment_window_id
+ })
+ {
+ fulfillment_window.selected_pickup_location_id = Some(pickup_location_id);
+ self.sync_pickup_location_removability();
+ self.save_failed = false;
+ }
+ }
+
+ fn remove_fulfillment_window(&mut self, fulfillment_window_id: FulfillmentWindowId) {
+ self.fulfillment_windows.retain(|fulfillment_window| {
+ fulfillment_window.fulfillment_window_id != fulfillment_window_id
+ });
+ self.sync_pickup_location_removability();
+ self.save_failed = false;
+ }
+
+ fn add_blackout_period(&mut self, window: &mut Window, cx: &mut Context<SettingsWindowView>) {
+ let blackout_period = SettingsBlackoutPeriodFormState::new(
+ &SettingsBlackoutPeriodDraft {
+ blackout_period_id: BlackoutPeriodId::new(),
+ label: String::new(),
+ starts_at: String::new(),
+ ends_at: String::new(),
+ },
+ window,
+ cx,
+ );
+
+ self.blackout_periods.push(blackout_period);
+ self.save_failed = false;
+ }
+
+ fn remove_blackout_period(&mut self, blackout_period_id: BlackoutPeriodId) {
+ self.blackout_periods
+ .retain(|blackout_period| blackout_period.blackout_period_id != blackout_period_id);
+ self.save_failed = false;
+ }
+
+ fn current_draft(&self, cx: &App) -> SettingsFarmRulesDraft {
+ SettingsFarmRulesDraft {
+ farm_profile: FarmProfileRecord {
+ farm_id: self.farm_id,
+ display_name: self.farm_name_input.read(cx).value().to_string(),
+ timezone: self.timezone_input.read(cx).value().to_string(),
+ currency_code: self.currency_input.read(cx).value().to_string(),
+ },
+ pickup_locations: self
+ .pickup_locations
+ .iter()
+ .map(|pickup_location| pickup_location.current_draft(cx))
+ .collect(),
+ operating_rules: self.operating_rules.current_draft(cx),
+ fulfillment_windows: self
+ .fulfillment_windows
+ .iter()
+ .map(|fulfillment_window| fulfillment_window.current_draft(cx))
+ .collect(),
+ blackout_periods: self
+ .blackout_periods
+ .iter()
+ .map(|blackout_period| blackout_period.current_draft(cx))
+ .collect(),
+ }
+ }
+
+ fn evaluate(&self, cx: &App) -> SettingsFarmRulesEvaluation {
+ let draft = self.current_draft(cx);
+ let farm_profile = FarmProfileRecord {
+ farm_id: self.farm_id,
+ display_name: draft.farm_profile.display_name.trim().to_owned(),
+ timezone: draft.farm_profile.timezone.trim().to_owned(),
+ currency_code: draft.farm_profile.currency_code.trim().to_owned(),
+ };
+ let pickup_locations = draft
+ .pickup_locations
+ .clone()
+ .into_iter()
+ .map(|pickup_location| pickup_location.into_record(self.farm_id))
.collect();
+ let mut operating_rules_validation_keys = Vec::new();
+ let operating_rules = if draft.operating_rules.is_empty() {
+ None
+ } else {
+ let promise_lead_hours = match draft
+ .operating_rules
+ .promise_lead_hours
+ .trim()
+ .parse::<u16>()
+ {
+ Ok(promise_lead_hours) => promise_lead_hours,
+ Err(_) if draft.operating_rules.promise_lead_hours.trim().is_empty() => 0,
+ Err(_) => {
+ push_unique_text_key(
+ &mut operating_rules_validation_keys,
+ AppTextKey::SettingsOperatingRulesInvalidPromiseLeadTime,
+ );
+ 0
+ }
+ };
+
+ Some(FarmOperatingRulesRecord {
+ farm_id: self.farm_id,
+ promise_lead_hours,
+ substitution_policy: draft.operating_rules.substitution_policy.trim().to_owned(),
+ missed_pickup_policy: draft.operating_rules.missed_pickup_policy.trim().to_owned(),
+ })
+ };
+ let mut fulfillment_windows = Vec::new();
+ let mut fulfillment_window_validation_keys =
+ Vec::with_capacity(draft.fulfillment_windows.len());
+ for fulfillment_window in &draft.fulfillment_windows {
+ let label = fulfillment_window.label.trim().to_owned();
+ let starts_at = fulfillment_window.starts_at.trim().to_owned();
+ let ends_at = fulfillment_window.ends_at.trim().to_owned();
+ let order_cutoff_at = fulfillment_window.order_cutoff_at.trim().to_owned();
+ let mut row_validation_keys = Vec::new();
+ let missing_required_fields = label.is_empty()
+ || starts_at.is_empty()
+ || ends_at.is_empty()
+ || order_cutoff_at.is_empty();
+
+ if missing_required_fields {
+ push_unique_text_key(
+ &mut row_validation_keys,
+ AppTextKey::SettingsFulfillmentWindowsValidationCompleteBeforeSave,
+ );
+ } else if fulfillment_window.selected_pickup_location_id.is_none() {
+ push_unique_text_key(
+ &mut row_validation_keys,
+ AppTextKey::SettingsFulfillmentWindowsValidationChoosePickupLocation,
+ );
+ }
+
+ if let Some(pickup_location_id) = fulfillment_window.selected_pickup_location_id {
+ if !missing_required_fields {
+ if ends_at <= starts_at {
+ push_unique_text_key(
+ &mut row_validation_keys,
+ AppTextKey::SettingsReadinessFieldFulfillmentWindowEndsBeforeStart,
+ );
+ }
+ if order_cutoff_at >= starts_at {
+ push_unique_text_key(
+ &mut row_validation_keys,
+ AppTextKey::SettingsReadinessFieldFulfillmentWindowCutoffAfterStart,
+ );
+ }
+ fulfillment_windows.push(FulfillmentWindowRecord {
+ fulfillment_window_id: fulfillment_window.fulfillment_window_id,
+ farm_id: self.farm_id,
+ pickup_location_id,
+ label,
+ starts_at,
+ ends_at,
+ order_cutoff_at,
+ });
+ }
+ }
+
+ fulfillment_window_validation_keys.push(row_validation_keys);
+ }
+ let mut blackout_periods = Vec::new();
+ let mut blackout_period_validation_keys = Vec::with_capacity(draft.blackout_periods.len());
+ for blackout_period in &draft.blackout_periods {
+ let label = blackout_period.label.trim().to_owned();
+ let starts_at = blackout_period.starts_at.trim().to_owned();
+ let ends_at = blackout_period.ends_at.trim().to_owned();
+ let mut row_validation_keys = Vec::new();
+
+ if label.is_empty() || starts_at.is_empty() || ends_at.is_empty() {
+ push_unique_text_key(
+ &mut row_validation_keys,
+ AppTextKey::SettingsBlackoutPeriodsValidationCompleteBeforeSave,
+ );
+ } else {
+ if ends_at <= starts_at {
+ push_unique_text_key(
+ &mut row_validation_keys,
+ AppTextKey::SettingsReadinessFieldBlackoutPeriodEndsBeforeStart,
+ );
+ }
+ blackout_periods.push(BlackoutPeriodRecord {
+ blackout_period_id: blackout_period.blackout_period_id,
+ farm_id: self.farm_id,
+ label,
+ starts_at,
+ ends_at,
+ });
+ }
+
+ blackout_period_validation_keys.push(row_validation_keys);
+ }
+
+ let mut projection = FarmRulesProjection {
+ farm_profile: Some(farm_profile),
+ pickup_locations,
+ operating_rules,
+ fulfillment_windows,
+ blackout_periods,
+ readiness: FarmRulesReadiness::ready(),
+ };
projection.readiness = derive_farm_rules_readiness(&projection);
- projection
+
+ let mut blocking_keys = operating_rules_validation_keys.clone();
+ for row_validation_keys in &fulfillment_window_validation_keys {
+ for validation_key in row_validation_keys {
+ push_unique_text_key(&mut blocking_keys, *validation_key);
+ }
+ }
+ for row_validation_keys in &blackout_period_validation_keys {
+ for validation_key in row_validation_keys {
+ push_unique_text_key(&mut blocking_keys, *validation_key);
+ }
+ }
+ for timing_conflict in &projection.readiness.timing_conflicts {
+ push_unique_text_key(
+ &mut blocking_keys,
+ settings_timing_conflict_key(timing_conflict.kind),
+ );
+ }
+
+ let mut readiness_keys = projection
+ .readiness
+ .blockers
+ .iter()
+ .copied()
+ .map(settings_readiness_key)
+ .collect::<Vec<_>>();
+ for blocking_key in &blocking_keys {
+ push_unique_text_key(&mut readiness_keys, *blocking_key);
+ }
+
+ SettingsFarmRulesEvaluation {
+ projection,
+ operating_rules_validation_keys,
+ fulfillment_window_validation_keys,
+ blackout_period_validation_keys,
+ blocking_keys,
+ readiness_keys,
+ }
+ }
+
+ fn current_projection(&self, cx: &App) -> FarmRulesProjection {
+ self.evaluate(cx).projection
}
fn has_changes(&self, cx: &App) -> bool {
- self.current_projection(cx) != self.initial_projection
+ self.current_draft(cx) != self.initial_draft
+ }
+
+ fn save_ready(&self, cx: &App) -> bool {
+ let evaluation = self.evaluate(cx);
+ self.has_changes(cx) && !evaluation.has_blocking_errors()
}
fn save_status_key(&self, cx: &App) -> AppTextKey {
if self.save_failed {
AppTextKey::SettingsFarmSaveFailed
} else if self.has_changes(cx) {
- AppTextKey::SettingsFarmSavePending
+ let evaluation = self.evaluate(cx);
+ if evaluation.has_blocking_errors() {
+ AppTextKey::SettingsFarmSaveBlocked
+ } else {
+ AppTextKey::SettingsFarmSavePending
+ }
} else {
AppTextKey::SettingsFarmSaveSaved
}
}
+
+ fn sync_pickup_location_removability(&mut self) {
+ let selected_pickup_location_ids = self
+ .fulfillment_windows
+ .iter()
+ .filter_map(|fulfillment_window| fulfillment_window.selected_pickup_location_id)
+ .collect::<Vec<_>>();
+
+ for pickup_location in &mut self.pickup_locations {
+ pickup_location.can_remove =
+ !selected_pickup_location_ids.contains(&pickup_location.pickup_location_id);
+ }
+ }
}
pub struct SettingsWindowView {
@@ -2215,14 +2910,75 @@ impl SettingsWindowView {
cx.notify();
}
+ fn add_fulfillment_window(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let Some(form) = self.farm_panel_state.as_mut() else {
+ return;
+ };
+
+ form.add_fulfillment_window(window, cx);
+ cx.notify();
+ }
+
+ fn select_fulfillment_window_pickup_location(
+ &mut self,
+ fulfillment_window_id: FulfillmentWindowId,
+ pickup_location_id: PickupLocationId,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(form) = self.farm_panel_state.as_mut() else {
+ return;
+ };
+
+ form.select_fulfillment_window_pickup_location(fulfillment_window_id, pickup_location_id);
+ cx.notify();
+ }
+
+ fn remove_fulfillment_window(
+ &mut self,
+ fulfillment_window_id: FulfillmentWindowId,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(form) = self.farm_panel_state.as_mut() else {
+ return;
+ };
+
+ form.remove_fulfillment_window(fulfillment_window_id);
+ cx.notify();
+ }
+
+ fn add_blackout_period(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+ let Some(form) = self.farm_panel_state.as_mut() else {
+ return;
+ };
+
+ form.add_blackout_period(window, cx);
+ cx.notify();
+ }
+
+ fn remove_blackout_period(
+ &mut self,
+ blackout_period_id: BlackoutPeriodId,
+ cx: &mut Context<Self>,
+ ) {
+ let Some(form) = self.farm_panel_state.as_mut() else {
+ return;
+ };
+
+ form.remove_blackout_period(blackout_period_id);
+ cx.notify();
+ }
+
fn save_farm_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) {
- let Some(current_projection) = self
+ let Some((current_projection, save_ready)) = self
.farm_panel_state
.as_ref()
- .map(|form| form.current_projection(cx))
+ .map(|form| (form.current_projection(cx), form.save_ready(cx)))
else {
return;
};
+ if !save_ready {
+ return;
+ }
match self.runtime.save_farm_rules_projection(current_projection) {
Ok(saved_projection) => {
@@ -2590,7 +3346,9 @@ impl SettingsWindowView {
)
}
- fn settings_panel(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
+ fn settings_panel(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+ self.sync_farm_panel_state(window, cx);
+
let runtime_summary = self.runtime.summary();
let general_settings = runtime_summary.shell_projection.settings.general;
let general_allow_relay_connections = general_settings.allow_relay_connections;
@@ -2598,12 +3356,238 @@ impl SettingsWindowView {
let general_use_nip05 = general_settings.use_nip05;
let general_launch_at_login = general_settings.launch_at_login;
- let mut cards = SETTINGS_OPERATIONS_PANEL_SECTIONS
- .iter()
- .copied()
- .map(settings_inventory_card)
- .map(IntoElement::into_any_element)
- .collect::<Vec<_>>();
+ let mut cards = Vec::new();
+
+ if let Some(error) = self.farm_panel_error.as_ref() {
+ cards.push(
+ home_card(
+ app_shared_text(AppTextKey::SettingsNavSettings),
+ home_body_text(error.clone()),
+ )
+ .into_any_element(),
+ );
+ } else if let Some(form) = self.farm_panel_state.as_ref() {
+ let evaluation = form.evaluate(cx);
+ let save_ready = form.has_changes(cx) && !evaluation.has_blocking_errors();
+ let save_action = if save_ready {
+ action_button_primary(
+ "settings-farm-save",
+ app_shared_text(AppTextKey::SettingsFarmSaveAction),
+ cx.listener(|this, _, window, cx| this.save_farm_panel(window, cx)),
+ cx,
+ )
+ .into_any_element()
+ } else {
+ action_button_primary_disabled(
+ "settings-farm-save",
+ app_shared_text(AppTextKey::SettingsFarmSaveAction),
+ cx,
+ )
+ .into_any_element()
+ };
+
+ cards.push(
+ home_card(
+ app_shared_text(AppTextKey::SettingsOperatingRulesSectionLabel),
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(12.0))
+ .child(settings_text_field(
+ AppTextKey::SettingsOperatingRulesFieldPromiseLeadTime,
+ &form.operating_rules.promise_lead_hours_input,
+ ))
+ .child(settings_text_field(
+ AppTextKey::SettingsOperatingRulesFieldSubstitutionPolicy,
+ &form.operating_rules.substitution_policy_input,
+ ))
+ .child(settings_text_field(
+ AppTextKey::SettingsOperatingRulesFieldMissedPickupPolicy,
+ &form.operating_rules.missed_pickup_policy_input,
+ ))
+ .children(settings_validation_rows(
+ &evaluation.operating_rules_validation_keys,
+ )),
+ )
+ .into_any_element(),
+ );
+ cards.push(
+ home_card(
+ app_shared_text(AppTextKey::SettingsFulfillmentWindowsSectionLabel),
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(12.0))
+ .when(form.fulfillment_windows.is_empty(), |this| {
+ this.child(home_body_text(app_shared_text(
+ AppTextKey::SettingsFulfillmentWindowsEmptyBody,
+ )))
+ })
+ .when(form.pickup_locations.is_empty(), |this| {
+ this.child(home_body_text(app_shared_text(
+ AppTextKey::SettingsFulfillmentWindowsPickupLocationsBody,
+ )))
+ })
+ .children(
+ form.fulfillment_windows
+ .iter()
+ .enumerate()
+ .map(|(index, fulfillment_window)| {
+ let fulfillment_window_id =
+ fulfillment_window.fulfillment_window_id;
+ let pickup_location_options = form
+ .pickup_locations
+ .iter()
+ .enumerate()
+ .map(|(pickup_index, pickup_location)| {
+ let pickup_location_id =
+ pickup_location.pickup_location_id;
+ let is_selected = fulfillment_window
+ .selected_pickup_location_id
+ .is_some_and(|selected_pickup_location_id| {
+ selected_pickup_location_id
+ == pickup_location_id
+ });
+ settings_dynamic_action_button(
+ (
+ "settings-fulfillment-window-pickup-location",
+ index * 100 + pickup_index,
+ ),
+ settings_pickup_location_title(
+ pickup_index,
+ pickup_location,
+ cx,
+ ),
+ is_selected,
+ cx.listener(move |this, _, _, cx| {
+ this.select_fulfillment_window_pickup_location(
+ fulfillment_window_id,
+ pickup_location_id,
+ cx,
+ )
+ }),
+ cx,
+ )
+ .into_any_element()
+ })
+ .collect::<Vec<_>>();
+ let validation_keys = evaluation
+ .fulfillment_window_validation_keys
+ .get(index)
+ .cloned()
+ .unwrap_or_default();
+
+ settings_fulfillment_window_card(
+ index,
+ fulfillment_window,
+ pickup_location_options,
+ &validation_keys,
+ cx.listener(move |this, _, _, cx| {
+ this.remove_fulfillment_window(
+ fulfillment_window_id,
+ cx,
+ )
+ }),
+ cx,
+ )
+ .into_any_element()
+ })
+ .collect::<Vec<_>>(),
+ )
+ .child(
+ settings_dynamic_action_button(
+ "settings-add-fulfillment-window",
+ app_shared_text(AppTextKey::SettingsFulfillmentWindowsAddAction),
+ false,
+ cx.listener(|this, _, window, cx| {
+ this.add_fulfillment_window(window, cx)
+ }),
+ cx,
+ )
+ .into_any_element(),
+ ),
+ )
+ .into_any_element(),
+ );
+ cards.push(
+ home_card(
+ app_shared_text(AppTextKey::SettingsBlackoutPeriodsSectionLabel),
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(12.0))
+ .when(form.blackout_periods.is_empty(), |this| {
+ this.child(home_body_text(app_shared_text(
+ AppTextKey::SettingsBlackoutPeriodsEmptyBody,
+ )))
+ })
+ .children(
+ form.blackout_periods
+ .iter()
+ .enumerate()
+ .map(|(index, blackout_period)| {
+ let blackout_period_id = blackout_period.blackout_period_id;
+ let validation_keys = evaluation
+ .blackout_period_validation_keys
+ .get(index)
+ .cloned()
+ .unwrap_or_default();
+
+ settings_blackout_period_card(
+ index,
+ blackout_period,
+ &validation_keys,
+ cx.listener(move |this, _, _, cx| {
+ this.remove_blackout_period(blackout_period_id, cx)
+ }),
+ cx,
+ )
+ .into_any_element()
+ })
+ .collect::<Vec<_>>(),
+ )
+ .child(
+ settings_dynamic_action_button(
+ "settings-add-blackout-period",
+ app_shared_text(AppTextKey::SettingsBlackoutPeriodsAddAction),
+ false,
+ cx.listener(|this, _, window, cx| {
+ this.add_blackout_period(window, cx)
+ }),
+ cx,
+ )
+ .into_any_element(),
+ ),
+ )
+ .into_any_element(),
+ );
+ cards.push(
+ home_card(
+ app_shared_text(AppTextKey::SettingsReadinessSectionLabel),
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(12.0))
+ .children(settings_farm_readiness_rows(&evaluation))
+ .child(section_divider())
+ .child(home_body_text(app_shared_text(form.save_status_key(cx))))
+ .child(div().child(save_action)),
+ )
+ .into_any_element(),
+ );
+ } else {
+ cards.push(
+ home_card(
+ app_shared_text(AppTextKey::SettingsNavSettings),
+ home_body_text(app_shared_text(AppTextKey::SettingsFarmUnavailableBody)),
+ )
+ .into_any_element(),
+ );
+ }
cards.push(
home_card(
@@ -2687,8 +3671,8 @@ impl SettingsWindowView {
return settings_inventory_panel(AppTextKey::SettingsFarmPanelBody, cards);
};
- let current_projection = form.current_projection(cx);
- let save_action = if form.has_changes(cx) {
+ let evaluation = form.evaluate(cx);
+ let save_action = if form.has_changes(cx) && !evaluation.has_blocking_errors() {
action_button_primary(
"settings-farm-save",
app_shared_text(AppTextKey::SettingsFarmSaveAction),
@@ -2771,19 +3755,7 @@ impl SettingsWindowView {
cx,
)
.into_any_element(),
- ),
- )
- .into_any_element(),
- );
- cards.push(
- home_card(
- app_shared_text(AppTextKey::SettingsReadinessSectionLabel),
- div()
- .w_full()
- .flex()
- .flex_col()
- .gap(px(12.0))
- .children(settings_farm_readiness_rows(¤t_projection.readiness))
+ )
.child(section_divider())
.child(home_body_text(app_shared_text(form.save_status_key(cx))))
.child(div().child(save_action)),
@@ -2854,7 +3826,7 @@ impl SettingsWindowView {
match self.selected_view() {
SettingsPanelViewKey::Account => self.account_panel(cx).into_any_element(),
SettingsPanelViewKey::Farm => self.farm_panel(window, cx).into_any_element(),
- SettingsPanelViewKey::Settings => self.settings_panel(cx).into_any_element(),
+ SettingsPanelViewKey::Settings => self.settings_panel(window, cx).into_any_element(),
SettingsPanelViewKey::About => self.about_panel().into_any_element(),
}
}
@@ -2939,6 +3911,7 @@ struct FarmSetupOnboardingCardSpec {
action_key: Option<AppTextKey>,
}
+#[cfg(test)]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct SettingsInventorySectionSpec {
title_key: AppTextKey,
@@ -2952,12 +3925,14 @@ const SETTINGS_NAVIGATION_ORDER: &[SettingsPanelViewKey] = &[
SettingsPanelViewKey::About,
];
+#[cfg(test)]
const SETTINGS_FARM_SECTION_FIELDS: &[AppTextKey] = &[
AppTextKey::HomeFarmSetupFieldFarmName,
AppTextKey::SettingsFarmFieldTimezone,
AppTextKey::SettingsFarmFieldCurrency,
];
+#[cfg(test)]
const SETTINGS_PICKUP_LOCATIONS_SECTION_FIELDS: &[AppTextKey] = &[
AppTextKey::SettingsPickupLocationsFieldLabel,
AppTextKey::SettingsPickupLocationsFieldAddress,
@@ -2965,12 +3940,14 @@ const SETTINGS_PICKUP_LOCATIONS_SECTION_FIELDS: &[AppTextKey] = &[
AppTextKey::SettingsPickupLocationsFieldDefault,
];
+#[cfg(test)]
const SETTINGS_OPERATING_RULES_SECTION_FIELDS: &[AppTextKey] = &[
AppTextKey::SettingsOperatingRulesFieldPromiseLeadTime,
AppTextKey::SettingsOperatingRulesFieldSubstitutionPolicy,
AppTextKey::SettingsOperatingRulesFieldMissedPickupPolicy,
];
+#[cfg(test)]
const SETTINGS_FULFILLMENT_WINDOWS_SECTION_FIELDS: &[AppTextKey] = &[
AppTextKey::SettingsFulfillmentWindowsFieldLabel,
AppTextKey::SettingsFulfillmentWindowsFieldPickupLocation,
@@ -2979,12 +3956,14 @@ const SETTINGS_FULFILLMENT_WINDOWS_SECTION_FIELDS: &[AppTextKey] = &[
AppTextKey::SettingsFulfillmentWindowsFieldOrderCutoff,
];
+#[cfg(test)]
const SETTINGS_BLACKOUT_PERIODS_SECTION_FIELDS: &[AppTextKey] = &[
AppTextKey::SettingsBlackoutPeriodsFieldLabel,
AppTextKey::SettingsBlackoutPeriodsFieldStartsAt,
AppTextKey::SettingsBlackoutPeriodsFieldEndsAt,
];
+#[cfg(test)]
const SETTINGS_READINESS_SECTION_FIELDS: &[AppTextKey] = &[
AppTextKey::SettingsReadinessFieldMissingProfileBasics,
AppTextKey::SettingsReadinessFieldMissingPickupLocation,
@@ -2993,6 +3972,7 @@ const SETTINGS_READINESS_SECTION_FIELDS: &[AppTextKey] = &[
AppTextKey::SettingsReadinessFieldInvalidTimingConflicts,
];
+#[cfg(test)]
const SETTINGS_FARM_PANEL_SECTIONS: &[SettingsInventorySectionSpec] = &[
SettingsInventorySectionSpec {
title_key: AppTextKey::HomeFarmSetupSectionFarm,
@@ -3004,6 +3984,7 @@ const SETTINGS_FARM_PANEL_SECTIONS: &[SettingsInventorySectionSpec] = &[
},
];
+#[cfg(test)]
const SETTINGS_OPERATIONS_PANEL_SECTIONS: &[SettingsInventorySectionSpec] = &[
SettingsInventorySectionSpec {
title_key: AppTextKey::SettingsOperatingRulesSectionLabel,
@@ -5152,6 +6133,28 @@ fn settings_text_field(label_key: AppTextKey, input: &Entity<InputState>) -> imp
)
}
+fn settings_pickup_location_title(
+ index: usize,
+ pickup_location: &SettingsPickupLocationFormState,
+ cx: &App,
+) -> String {
+ let label = pickup_location
+ .label_input
+ .read(cx)
+ .value()
+ .trim()
+ .to_owned();
+ if label.is_empty() {
+ format!(
+ "{} {}",
+ app_shared_text(AppTextKey::SettingsPickupLocationsSectionLabel),
+ index + 1
+ )
+ } else {
+ label
+ }
+}
+
fn settings_pickup_location_card(
index: usize,
pickup_location: &SettingsPickupLocationFormState,
@@ -5159,23 +6162,7 @@ fn settings_pickup_location_card(
on_remove: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
cx: &App,
) -> impl IntoElement {
- let title = {
- let label = pickup_location
- .label_input
- .read(cx)
- .value()
- .trim()
- .to_owned();
- if label.is_empty() {
- format!(
- "{} {}",
- app_shared_text(AppTextKey::SettingsPickupLocationsSectionLabel),
- index + 1
- )
- } else {
- label
- }
- };
+ let title = settings_pickup_location_title(index, pickup_location, cx);
let action_row = div()
.flex()
.items_center()
@@ -5247,32 +6234,215 @@ fn settings_pickup_location_card(
))
}
-fn settings_farm_readiness_rows(
- readiness: &radroots_app_models::FarmRulesReadiness,
-) -> Vec<AnyElement> {
- let mut rows = readiness
- .blockers
- .iter()
+fn settings_fulfillment_window_title(
+ index: usize,
+ fulfillment_window: &SettingsFulfillmentWindowFormState,
+ cx: &App,
+) -> String {
+ let label = fulfillment_window
+ .label_input
+ .read(cx)
+ .value()
+ .trim()
+ .to_owned();
+ if label.is_empty() {
+ format!(
+ "{} {}",
+ app_shared_text(AppTextKey::SettingsFulfillmentWindowsItemLabel),
+ index + 1
+ )
+ } else {
+ label
+ }
+}
+
+fn settings_blackout_period_title(
+ index: usize,
+ blackout_period: &SettingsBlackoutPeriodFormState,
+ cx: &App,
+) -> String {
+ let label = blackout_period
+ .label_input
+ .read(cx)
+ .value()
+ .trim()
+ .to_owned();
+ if label.is_empty() {
+ format!(
+ "{} {}",
+ app_shared_text(AppTextKey::SettingsBlackoutPeriodsItemLabel),
+ index + 1
+ )
+ } else {
+ label
+ }
+}
+
+fn settings_validation_rows(keys: &[AppTextKey]) -> Vec<AnyElement> {
+ keys.iter()
.copied()
- .map(settings_readiness_key)
- .map(settings_inventory_field_row)
+ .map(home_farm_setup_blocker)
.map(IntoElement::into_any_element)
- .collect::<Vec<_>>();
+ .collect()
+}
- if !readiness.timing_conflicts.is_empty() {
- rows.push(
- settings_inventory_field_row(AppTextKey::SettingsReadinessFieldInvalidTimingConflicts)
- .into_any_element(),
- );
- }
+fn settings_fulfillment_window_card(
+ index: usize,
+ fulfillment_window: &SettingsFulfillmentWindowFormState,
+ pickup_location_options: Vec<AnyElement>,
+ validation_keys: &[AppTextKey],
+ on_remove: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ div()
+ .w_full()
+ .bg(rgb(APP_UI_THEME.surfaces.chrome_background))
+ .rounded(px(APP_UI_THEME
+ .controls
+ .action_button
+ .sizing
+ .corner_radius_px))
+ .p(px(12.0))
+ .flex()
+ .flex_col()
+ .gap(px(10.0))
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .items_start()
+ .justify_between()
+ .gap(px(8.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(settings_fulfillment_window_title(
+ index,
+ fulfillment_window,
+ cx,
+ )),
+ )
+ .child(
+ settings_dynamic_action_button(
+ ("settings-remove-fulfillment-window", index),
+ app_shared_text(AppTextKey::SettingsFulfillmentWindowsRemoveAction),
+ false,
+ on_remove,
+ cx,
+ )
+ .into_any_element(),
+ ),
+ )
+ .child(settings_text_field(
+ AppTextKey::SettingsFulfillmentWindowsFieldLabel,
+ &fulfillment_window.label_input,
+ ))
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .flex_col()
+ .gap(px(6.0))
+ .child(home_farm_setup_field_label(
+ AppTextKey::SettingsFulfillmentWindowsFieldPickupLocation,
+ ))
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .flex_wrap()
+ .gap(px(8.0))
+ .children(pickup_location_options),
+ ),
+ )
+ .child(settings_text_field(
+ AppTextKey::SettingsFulfillmentWindowsFieldStartsAt,
+ &fulfillment_window.starts_at_input,
+ ))
+ .child(settings_text_field(
+ AppTextKey::SettingsFulfillmentWindowsFieldEndsAt,
+ &fulfillment_window.ends_at_input,
+ ))
+ .child(settings_text_field(
+ AppTextKey::SettingsFulfillmentWindowsFieldOrderCutoff,
+ &fulfillment_window.order_cutoff_input,
+ ))
+ .children(settings_validation_rows(validation_keys))
+}
- if rows.is_empty() {
- rows.push(
- settings_inventory_field_row(AppTextKey::SettingsReadinessReady).into_any_element(),
- );
- }
+fn settings_blackout_period_card(
+ index: usize,
+ blackout_period: &SettingsBlackoutPeriodFormState,
+ validation_keys: &[AppTextKey],
+ on_remove: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
+ cx: &App,
+) -> impl IntoElement {
+ div()
+ .w_full()
+ .bg(rgb(APP_UI_THEME.surfaces.chrome_background))
+ .rounded(px(APP_UI_THEME
+ .controls
+ .action_button
+ .sizing
+ .corner_radius_px))
+ .p(px(12.0))
+ .flex()
+ .flex_col()
+ .gap(px(10.0))
+ .child(
+ div()
+ .w_full()
+ .flex()
+ .items_start()
+ .justify_between()
+ .gap(px(8.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(settings_blackout_period_title(index, blackout_period, cx)),
+ )
+ .child(
+ settings_dynamic_action_button(
+ ("settings-remove-blackout-period", index),
+ app_shared_text(AppTextKey::SettingsBlackoutPeriodsRemoveAction),
+ false,
+ on_remove,
+ cx,
+ )
+ .into_any_element(),
+ ),
+ )
+ .child(settings_text_field(
+ AppTextKey::SettingsBlackoutPeriodsFieldLabel,
+ &blackout_period.label_input,
+ ))
+ .child(settings_text_field(
+ AppTextKey::SettingsBlackoutPeriodsFieldStartsAt,
+ &blackout_period.starts_at_input,
+ ))
+ .child(settings_text_field(
+ AppTextKey::SettingsBlackoutPeriodsFieldEndsAt,
+ &blackout_period.ends_at_input,
+ ))
+ .children(settings_validation_rows(validation_keys))
+}
+
+fn settings_farm_readiness_rows(evaluation: &SettingsFarmRulesEvaluation) -> Vec<AnyElement> {
+ let readiness_keys = if evaluation.readiness_keys.is_empty() {
+ vec![AppTextKey::SettingsReadinessReady]
+ } else {
+ evaluation.readiness_keys.clone()
+ };
- rows
+ readiness_keys
+ .into_iter()
+ .map(settings_inventory_field_row)
+ .map(IntoElement::into_any_element)
+ .collect()
}
fn settings_readiness_key(blocker: FarmReadinessBlocker) -> AppTextKey {
@@ -5292,6 +6462,23 @@ fn settings_readiness_key(blocker: FarmReadinessBlocker) -> AppTextKey {
}
}
+fn settings_timing_conflict_key(kind: FarmTimingConflictKind) -> AppTextKey {
+ match kind {
+ FarmTimingConflictKind::FulfillmentWindowEndsBeforeStart => {
+ AppTextKey::SettingsReadinessFieldFulfillmentWindowEndsBeforeStart
+ }
+ FarmTimingConflictKind::FulfillmentWindowCutoffAfterStart => {
+ AppTextKey::SettingsReadinessFieldFulfillmentWindowCutoffAfterStart
+ }
+ FarmTimingConflictKind::BlackoutPeriodEndsBeforeStart => {
+ AppTextKey::SettingsReadinessFieldBlackoutPeriodEndsBeforeStart
+ }
+ FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow => {
+ AppTextKey::SettingsReadinessFieldBlackoutOverlapsFulfillmentWindow
+ }
+ }
+}
+
fn settings_badge_text(key: AppTextKey) -> impl IntoElement {
div()
.text_size(px(APP_UI_THEME.typography.utility_title_text_px))
@@ -5372,6 +6559,7 @@ fn settings_inventory_panel(intro_key: AppTextKey, cards: Vec<AnyElement>) -> im
)
}
+#[cfg(test)]
fn settings_inventory_card(spec: SettingsInventorySectionSpec) -> impl IntoElement {
home_card(
app_shared_text(spec.title_key),
diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs
@@ -184,6 +184,7 @@ define_app_text_keys! {
SettingsFarmSaveAction => "settings.farm.save.action",
SettingsFarmSaveSaved => "settings.farm.save.saved",
SettingsFarmSavePending => "settings.farm.save.pending",
+ SettingsFarmSaveBlocked => "settings.farm.save.blocked",
SettingsFarmSaveFailed => "settings.farm.save.failed",
SettingsFarmFieldTimezone => "settings.farm.field.timezone",
SettingsFarmFieldCurrency => "settings.farm.field.currency",
@@ -202,22 +203,39 @@ define_app_text_keys! {
SettingsOperatingRulesFieldPromiseLeadTime => "settings.operating_rules.field.promise_lead_time",
SettingsOperatingRulesFieldSubstitutionPolicy => "settings.operating_rules.field.substitution_policy",
SettingsOperatingRulesFieldMissedPickupPolicy => "settings.operating_rules.field.missed_pickup_policy",
+ SettingsOperatingRulesInvalidPromiseLeadTime => "settings.operating_rules.invalid_promise_lead_time",
SettingsFulfillmentWindowsSectionLabel => "settings.fulfillment_windows.section.label",
+ SettingsFulfillmentWindowsEmptyBody => "settings.fulfillment_windows.empty.body",
+ SettingsFulfillmentWindowsPickupLocationsBody => "settings.fulfillment_windows.pickup_locations.body",
+ SettingsFulfillmentWindowsAddAction => "settings.fulfillment_windows.add.action",
+ SettingsFulfillmentWindowsRemoveAction => "settings.fulfillment_windows.remove.action",
+ SettingsFulfillmentWindowsItemLabel => "settings.fulfillment_windows.item.label",
SettingsFulfillmentWindowsFieldLabel => "settings.fulfillment_windows.field.label",
SettingsFulfillmentWindowsFieldPickupLocation => "settings.fulfillment_windows.field.pickup_location",
SettingsFulfillmentWindowsFieldStartsAt => "settings.fulfillment_windows.field.starts_at",
SettingsFulfillmentWindowsFieldEndsAt => "settings.fulfillment_windows.field.ends_at",
SettingsFulfillmentWindowsFieldOrderCutoff => "settings.fulfillment_windows.field.order_cutoff",
+ SettingsFulfillmentWindowsValidationCompleteBeforeSave => "settings.fulfillment_windows.validation.complete_before_save",
+ SettingsFulfillmentWindowsValidationChoosePickupLocation => "settings.fulfillment_windows.validation.choose_pickup_location",
SettingsBlackoutPeriodsSectionLabel => "settings.blackout_periods.section.label",
+ SettingsBlackoutPeriodsEmptyBody => "settings.blackout_periods.empty.body",
+ SettingsBlackoutPeriodsAddAction => "settings.blackout_periods.add.action",
+ SettingsBlackoutPeriodsRemoveAction => "settings.blackout_periods.remove.action",
+ SettingsBlackoutPeriodsItemLabel => "settings.blackout_periods.item.label",
SettingsBlackoutPeriodsFieldLabel => "settings.blackout_periods.field.label",
SettingsBlackoutPeriodsFieldStartsAt => "settings.blackout_periods.field.starts_at",
SettingsBlackoutPeriodsFieldEndsAt => "settings.blackout_periods.field.ends_at",
+ SettingsBlackoutPeriodsValidationCompleteBeforeSave => "settings.blackout_periods.validation.complete_before_save",
SettingsReadinessSectionLabel => "settings.readiness.section.label",
SettingsReadinessFieldMissingProfileBasics => "settings.readiness.field.missing_profile_basics",
SettingsReadinessFieldMissingPickupLocation => "settings.readiness.field.missing_pickup_location",
SettingsReadinessFieldMissingFulfillmentWindow => "settings.readiness.field.missing_fulfillment_window",
SettingsReadinessFieldMissingOperatingRules => "settings.readiness.field.missing_operating_rules",
SettingsReadinessFieldInvalidTimingConflicts => "settings.readiness.field.invalid_timing_conflicts",
+ SettingsReadinessFieldFulfillmentWindowEndsBeforeStart => "settings.readiness.field.fulfillment_window_ends_before_start",
+ SettingsReadinessFieldFulfillmentWindowCutoffAfterStart => "settings.readiness.field.fulfillment_window_cutoff_after_start",
+ SettingsReadinessFieldBlackoutPeriodEndsBeforeStart => "settings.readiness.field.blackout_period_ends_before_start",
+ SettingsReadinessFieldBlackoutOverlapsFulfillmentWindow => "settings.readiness.field.blackout_overlaps_fulfillment_window",
SettingsReadinessReady => "settings.readiness.ready",
SettingsGeneralSectionLabel => "settings.general.section.label",
SettingsGeneralAllowRelayConnections => "settings.general.allow_relay_connections",
diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs
@@ -188,6 +188,10 @@ mod tests {
"Save changes to keep this on this device."
);
assert_eq!(
+ app_text(AppTextKey::SettingsFarmSaveBlocked),
+ "Complete the highlighted fields before saving."
+ );
+ assert_eq!(
app_text(AppTextKey::SettingsFarmSaveFailed),
"Could not save farm settings on this device."
);
@@ -220,14 +224,46 @@ mod tests {
"Operating rules"
);
assert_eq!(
+ app_text(AppTextKey::SettingsOperatingRulesInvalidPromiseLeadTime),
+ "Enter whole hours, for example 24."
+ );
+ assert_eq!(
app_text(AppTextKey::SettingsFulfillmentWindowsSectionLabel),
"Fulfillment windows"
);
assert_eq!(
+ app_text(AppTextKey::SettingsFulfillmentWindowsEmptyBody),
+ "Add a fulfillment window so customers know when orders are ready."
+ );
+ assert_eq!(
+ app_text(AppTextKey::SettingsFulfillmentWindowsPickupLocationsBody),
+ "Add a pickup location before saving a fulfillment window."
+ );
+ assert_eq!(
+ app_text(AppTextKey::SettingsFulfillmentWindowsAddAction),
+ "Add fulfillment window"
+ );
+ assert_eq!(
+ app_text(AppTextKey::SettingsFulfillmentWindowsItemLabel),
+ "Fulfillment window"
+ );
+ assert_eq!(
app_text(AppTextKey::SettingsBlackoutPeriodsSectionLabel),
"Blackout periods"
);
assert_eq!(
+ app_text(AppTextKey::SettingsBlackoutPeriodsEmptyBody),
+ "Add a blackout period for days when this farm is unavailable."
+ );
+ assert_eq!(
+ app_text(AppTextKey::SettingsBlackoutPeriodsAddAction),
+ "Add blackout period"
+ );
+ assert_eq!(
+ app_text(AppTextKey::SettingsBlackoutPeriodsItemLabel),
+ "Blackout period"
+ );
+ assert_eq!(
app_text(AppTextKey::SettingsReadinessSectionLabel),
"Readiness"
);
@@ -235,6 +271,14 @@ mod tests {
app_text(AppTextKey::SettingsReadinessFieldInvalidTimingConflicts),
"Invalid timing conflicts"
);
+ assert_eq!(
+ app_text(AppTextKey::SettingsReadinessFieldFulfillmentWindowEndsBeforeStart),
+ "A fulfillment window ends before it starts."
+ );
+ assert_eq!(
+ app_text(AppTextKey::SettingsReadinessFieldBlackoutOverlapsFulfillmentWindow),
+ "A blackout period overlaps a fulfillment window."
+ );
assert_eq!(app_text(AppTextKey::SettingsReadinessReady), "Ready");
}
diff --git a/crates/shared/sqlite/src/farm_rules.rs b/crates/shared/sqlite/src/farm_rules.rs
@@ -776,7 +776,8 @@ fn derive_farm_rules_readiness_parts(
}
if operating_rules.is_none_or(|operating_rules| {
- operating_rules.substitution_policy.trim().is_empty()
+ operating_rules.promise_lead_hours == 0
+ || operating_rules.substitution_policy.trim().is_empty()
|| operating_rules.missed_pickup_policy.trim().is_empty()
}) {
blockers.push(FarmReadinessBlocker::MissingOperatingRules);
@@ -1140,6 +1141,51 @@ mod tests {
}
#[test]
+ fn zero_promise_lead_hours_keep_operating_rules_incomplete() {
+ let farm_id = FarmId::new();
+ let pickup_location_id = PickupLocationId::new();
+ let readiness = derive_farm_rules_readiness(&FarmRulesProjection {
+ farm_profile: Some(FarmProfileRecord {
+ farm_id,
+ display_name: "North field farm".to_owned(),
+ timezone: "UTC".to_owned(),
+ currency_code: "USD".to_owned(),
+ }),
+ pickup_locations: vec![PickupLocationRecord {
+ pickup_location_id,
+ farm_id,
+ label: "Barn pickup".to_owned(),
+ address_line: "14 Orchard Lane".to_owned(),
+ directions: None,
+ is_default: true,
+ }],
+ operating_rules: Some(FarmOperatingRulesRecord {
+ farm_id,
+ promise_lead_hours: 0,
+ substitution_policy: "ask_customer".to_owned(),
+ missed_pickup_policy: "hold_next_window".to_owned(),
+ }),
+ fulfillment_windows: vec![FulfillmentWindowRecord {
+ fulfillment_window_id: FulfillmentWindowId::new(),
+ farm_id,
+ pickup_location_id,
+ label: "Friday pickup".to_owned(),
+ starts_at: "2026-04-25T14:00:00Z".to_owned(),
+ ends_at: "2026-04-25T18:00:00Z".to_owned(),
+ order_cutoff_at: "2026-04-24T18:00:00Z".to_owned(),
+ }],
+ blackout_periods: Vec::new(),
+ readiness: FarmRulesReadiness::ready(),
+ });
+
+ assert!(
+ readiness
+ .blockers
+ .contains(&FarmReadinessBlocker::MissingOperatingRules)
+ );
+ }
+
+ #[test]
fn complete_pickup_location_row_counts_as_present_for_readiness() {
let farm_id = FarmId::new();
let pickup_location_id = PickupLocationId::new();
diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json
@@ -163,6 +163,7 @@
"settings.farm.save.action": "Save changes",
"settings.farm.save.saved": "Saved locally on this device.",
"settings.farm.save.pending": "Save changes to keep this on this device.",
+ "settings.farm.save.blocked": "Complete the highlighted fields before saving.",
"settings.farm.save.failed": "Could not save farm settings on this device.",
"settings.farm.field.timezone": "Timezone",
"settings.farm.field.currency": "Currency",
@@ -181,22 +182,39 @@
"settings.operating_rules.field.promise_lead_time": "Promise lead time",
"settings.operating_rules.field.substitution_policy": "Substitution policy",
"settings.operating_rules.field.missed_pickup_policy": "Missed pickup policy",
+ "settings.operating_rules.invalid_promise_lead_time": "Enter whole hours, for example 24.",
"settings.fulfillment_windows.section.label": "Fulfillment windows",
+ "settings.fulfillment_windows.empty.body": "Add a fulfillment window so customers know when orders are ready.",
+ "settings.fulfillment_windows.pickup_locations.body": "Add a pickup location before saving a fulfillment window.",
+ "settings.fulfillment_windows.add.action": "Add fulfillment window",
+ "settings.fulfillment_windows.remove.action": "Remove",
+ "settings.fulfillment_windows.item.label": "Fulfillment window",
"settings.fulfillment_windows.field.label": "Label",
"settings.fulfillment_windows.field.pickup_location": "Pickup location",
"settings.fulfillment_windows.field.starts_at": "Starts at",
"settings.fulfillment_windows.field.ends_at": "Ends at",
"settings.fulfillment_windows.field.order_cutoff": "Order cutoff",
+ "settings.fulfillment_windows.validation.complete_before_save": "Complete this fulfillment window before saving.",
+ "settings.fulfillment_windows.validation.choose_pickup_location": "Choose a pickup location.",
"settings.blackout_periods.section.label": "Blackout periods",
+ "settings.blackout_periods.empty.body": "Add a blackout period for days when this farm is unavailable.",
+ "settings.blackout_periods.add.action": "Add blackout period",
+ "settings.blackout_periods.remove.action": "Remove",
+ "settings.blackout_periods.item.label": "Blackout period",
"settings.blackout_periods.field.label": "Label",
"settings.blackout_periods.field.starts_at": "Starts at",
"settings.blackout_periods.field.ends_at": "Ends at",
+ "settings.blackout_periods.validation.complete_before_save": "Complete this blackout period before saving.",
"settings.readiness.section.label": "Readiness",
"settings.readiness.field.missing_profile_basics": "Missing profile basics",
"settings.readiness.field.missing_pickup_location": "Missing pickup location",
"settings.readiness.field.missing_fulfillment_window": "Missing fulfillment window",
"settings.readiness.field.missing_operating_rules": "Missing operating rules",
"settings.readiness.field.invalid_timing_conflicts": "Invalid timing conflicts",
+ "settings.readiness.field.fulfillment_window_ends_before_start": "A fulfillment window ends before it starts.",
+ "settings.readiness.field.fulfillment_window_cutoff_after_start": "A fulfillment window cutoff must be before the start time.",
+ "settings.readiness.field.blackout_period_ends_before_start": "A blackout period ends before it starts.",
+ "settings.readiness.field.blackout_overlaps_fulfillment_window": "A blackout period overlaps a fulfillment window.",
"settings.readiness.ready": "Ready",
"settings.general.section.label": "General",
"settings.general.allow_relay_connections": "Allow relay connections",