diff --git a/docs/user-stories.md b/docs/user-stories.md index 02cca9635..4391fb2db 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -536,6 +536,7 @@ As a user, I want to register a human-readable username on DPNS so that others c - Choose identity, enter desired name. - Cost estimate displayed before confirmation. +- While registration runs, a full-window blocking overlay (UX-001) is shown so the same name cannot be submitted twice; it lowers automatically on success or error. ### DPN-002: View owned usernames [Implemented] **Persona:** Alex, Priya diff --git a/src/ui/identities/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index 232481aa8..71ddc1e7a 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -12,7 +12,9 @@ use crate::ui::components::top_panel::add_top_panel; use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ResultBannerExt}; +use crate::ui::components::{ + MessageBanner, OptionOverlayExt, OverlayConfig, OverlayHandle, ResultBannerExt, +}; use crate::ui::helpers::{TransactionType, add_key_chooser_with_doc_type}; use crate::ui::theme::{DashColors, ResponseExt}; use crate::ui::{MessageType, ScreenLike}; @@ -61,7 +63,11 @@ pub struct RegisterDpnsNameScreen { completed_fee_result: Option, // Source of navigation to this screen pub source: RegisterDpnsNameSource, - refresh_banner: Option, + /// Bucket A overlay-adoption pattern: a button-less full-window block raised + /// when the bounded registration is dispatched and torn down on every + /// terminal result. It replaces the old progress banner and, by blocking the + /// whole window, closes the double-submit hole the banner left open. + op_overlay: Option, } impl RegisterDpnsNameScreen { @@ -124,7 +130,7 @@ impl RegisterDpnsNameScreen { show_advanced_options: false, completed_fee_result: None, source, - refresh_banner: None, + op_overlay: None, } } @@ -270,6 +276,37 @@ impl RegisterDpnsNameScreen { ))) } + /// Dispatch the registration and raise the Bucket A blocking overlay. + /// + /// The overlay is raised only when a real task is produced (an identity and a + /// signing key are selected), so a no-op click never strands a block. The + /// full-window block is the in-progress feedback that replaces the old banner + /// and prevents a second submit while the first is in flight. + fn begin_registration(&mut self, ctx: &Context) -> AppAction { + let action = self.register_dpns_name_clicked(); + if matches!(action, AppAction::BackendTask(_)) { + self.register_dpns_name_status = RegisterDpnsNameStatus::WaitingForResult; + self.raise_progress_overlay(ctx); + } + action + } + + fn raise_progress_overlay(&mut self, ctx: &Context) { + self.op_overlay.raise( + ctx, + "Registering your username on the network.", + OverlayConfig::default(), + ); + } + + /// Test seam: run the exact production overlay-raise the Register button uses, + /// so the Bucket A adoption (raise + guaranteed teardown) is exercisable in + /// kittests without funding an identity. Mirrors `force_input_for_test`. + #[doc(hidden)] + pub fn raise_progress_overlay_for_test(&mut self, ctx: &Context) { + self.raise_progress_overlay(ctx); + } + pub fn show_success(&mut self, ui: &mut Ui) -> AppAction { let action = crate::ui::helpers::show_success_screen_with_info( ui, @@ -301,17 +338,20 @@ impl RegisterDpnsNameScreen { impl ScreenLike for RegisterDpnsNameScreen { fn display_message(&mut self, _message: &str, message_type: MessageType) { // Banner display is handled globally by AppState; this is only for side-effects. + // SEC-001: tear down the blocking overlay on the error terminal path so a + // failed registration can never hard-lock the window. if matches!(message_type, MessageType::Error | MessageType::Warning) { - self.refresh_banner.take_and_clear(); + self.op_overlay.take_and_clear(); self.register_dpns_name_status = RegisterDpnsNameStatus::Error; } } fn display_task_result(&mut self, backend_task_success_result: BackendTaskSuccessResult) { + // SEC-001: tear down the blocking overlay on the success terminal path. if let BackendTaskSuccessResult::RegisteredDpnsName(fee_result) = backend_task_success_result { - self.refresh_banner.take_and_clear(); + self.op_overlay.take_and_clear(); self.completed_fee_result = Some(fee_result); self.register_dpns_name_status = RegisterDpnsNameStatus::Complete; } @@ -551,11 +591,7 @@ impl ScreenLike for RegisterDpnsNameScreen { .disabled_tooltip(&hover_text) .clicked() { - self.register_dpns_name_status = RegisterDpnsNameStatus::WaitingForResult; - let handle = MessageBanner::set_global(ui.ctx(), "Registering DPNS name...", MessageType::Info); - handle.with_elapsed(); - self.refresh_banner = Some(handle); - inner_action = self.register_dpns_name_clicked(); + inner_action = self.begin_registration(ui.ctx()); } ui.add_space(10.0); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d0a3f6149..0a548db76 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -85,7 +85,7 @@ pub mod contracts_documents; pub mod dashpay; pub mod dpns; pub mod helpers; -pub(crate) mod identities; +pub mod identities; pub mod network_chooser_screen; pub mod state; pub mod theme; diff --git a/tests/kittest/main.rs b/tests/kittest/main.rs index 690071059..bb6a2356a 100644 --- a/tests/kittest/main.rs +++ b/tests/kittest/main.rs @@ -8,6 +8,7 @@ mod message_banner; mod migration_banner; mod network_chooser; mod progress_overlay; +mod register_dpns_name_screen; mod restore_single_key; mod secret_prompt; mod startup; diff --git a/tests/kittest/register_dpns_name_screen.rs b/tests/kittest/register_dpns_name_screen.rs new file mode 100644 index 000000000..3ba52936c --- /dev/null +++ b/tests/kittest/register_dpns_name_screen.rs @@ -0,0 +1,93 @@ +//! Kittest coverage for the Bucket A overlay adoption on the DPNS registration +//! screen (`RegisterDpnsNameScreen`). +//! +//! Proves the canonical contract the remaining transaction screens will copy: +//! dispatching the registration raises the global blocking overlay, and every +//! terminal result — success (`display_task_result`) or error +//! (`display_message`) — tears it down (the SEC-001 no-hard-lock guarantee). +//! +//! The screen needs an `Arc`, so each test borrows one from a +//! throwaway `AppState` built in an isolated data dir. The overlay raise/teardown +//! runs against an independent `egui::Context` the AppState never renders, so the +//! app's own SPV block can't perturb the `has_global` assertions. + +use crate::support::with_isolated_data_dir; +use dash_evo_tool::app::AppState; +use dash_evo_tool::backend_task::{BackendTaskSuccessResult, FeeResult}; +use dash_evo_tool::ui::MessageType; +use dash_evo_tool::ui::ScreenLike; +use dash_evo_tool::ui::components::ProgressOverlay; +use dash_evo_tool::ui::identities::register_dpns_name_screen::{ + RegisterDpnsNameScreen, RegisterDpnsNameSource, +}; + +/// Build a `RegisterDpnsNameScreen` over a fresh, isolated `AppContext`. +fn screen_with_context() -> RegisterDpnsNameScreen { + let app_state = AppState::new(egui::Context::default()).expect("AppState builds"); + let app_context = app_state.current_app_context().clone(); + RegisterDpnsNameScreen::new(&app_context, RegisterDpnsNameSource::Dpns) +} + +/// Dispatching the registration raises the global blocking overlay. +#[test] +fn dpns_dispatch_raises_blocking_overlay() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let mut screen = screen_with_context(); + let ctx = egui::Context::default(); + + assert!(!ProgressOverlay::has_global(&ctx)); + screen.raise_progress_overlay_for_test(&ctx); + assert!( + ProgressOverlay::has_global(&ctx), + "dispatching the registration must raise the blocking overlay" + ); + }); +} + +/// A successful registration result tears the overlay down (success terminal path). +#[test] +fn dpns_success_result_clears_overlay() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let mut screen = screen_with_context(); + let ctx = egui::Context::default(); + + screen.raise_progress_overlay_for_test(&ctx); + assert!(ProgressOverlay::has_global(&ctx)); + + screen.display_task_result(BackendTaskSuccessResult::RegisteredDpnsName( + FeeResult::new(0, 0), + )); + assert!( + !ProgressOverlay::has_global(&ctx), + "a successful result must tear down the blocking overlay" + ); + }); +} + +/// An error message tears the overlay down (error terminal path) — SEC-001: a +/// failed registration can never leave the window hard-locked. +#[test] +fn dpns_error_message_clears_overlay() { + with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); + let _guard = rt.enter(); + + let mut screen = screen_with_context(); + let ctx = egui::Context::default(); + + screen.raise_progress_overlay_for_test(&ctx); + assert!(ProgressOverlay::has_global(&ctx)); + + screen.display_message("Registration failed. Try again.", MessageType::Error); + assert!( + !ProgressOverlay::has_global(&ctx), + "an error result must tear down the blocking overlay" + ); + }); +}