From e7aec1651e440be4b5b9753968dfb6dcae4cb8fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 7 Oct 2026 05:59:27 -0400 Subject: [PATCH] =?UTF-8?q?Panics=20=C2=B7=20P36=20Account:=20handlers=20i?= =?UTF-8?q?n=20new;=20gone=20accounts,=20workspaces,=20providers=20and=20r?= =?UTF-8?q?oles=20mark=20none;=20expiries=20refuse=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/gallery/pages/account/access.rs | 31 ++-- examples/gallery/pages/account/billing.rs | 21 +-- examples/gallery/pages/account/sign_in.rs | 41 ++--- src/account/factor.rs | 21 +-- src/account/keys.rs | 55 ++----- src/account/login.rs | 18 +- src/account/mail.rs | 39 ++--- src/account/oauth.rs | 26 +-- src/account/payment.rs | 37 ++--- src/account/profile.rs | 19 +-- src/account/sessions.rs | 22 +-- src/account/signup.rs | 18 +- src/account/switcher.rs | 103 +++++------- src/account/team.rs | 158 ++++++------------ src/account/tests/access.rs | 63 ++++--- src/account/tests/billing.rs | 190 +++++++++++++++++----- src/account/tests/sign_in.rs | 110 +++++++------ src/account/upgrade.rs | 19 +-- tasks/panics.md | 3 +- 19 files changed, 476 insertions(+), 518 deletions(-) diff --git a/examples/gallery/pages/account/access.rs b/examples/gallery/pages/account/access.rs index adaf485..3b807dc 100644 --- a/examples/gallery/pages/account/access.rs +++ b/examples/gallery/pages/account/access.rs @@ -73,23 +73,20 @@ pub fn switchers(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> .gap_6() .child( div().w(px(260.)).flex().flex_col().gap_2().child( - WorkspaceSwitcher::new("account-workspaces", workspaces, workspace) - .on_select(move |key, _, cx| change(&to_workspace, cx, |places| places.workspace = key.clone())) + WorkspaceSwitcher::new("account-workspaces", workspaces, workspace, move |key, _, cx| change(&to_workspace, cx, |places| places.workspace = key.clone())) .on_create(|_, _| log::info!("gallery: create workspace")), ) .child( - AccountSwitcher::new("account-accounts", accounts, account) - .on_select(move |key, _, cx| change(&to_account, cx, |places| places.account = key.clone())) + AccountSwitcher::new("account-accounts", accounts, account, move |key, _, cx| change(&to_account, cx, |places| places.account = key.clone())) .on_add(|_, _| log::info!("gallery: add account")), ), ) .child(probe( "account-user", - UserMenu::new("account-user", "Ada Lovelace", "ada@atlas.dev") + UserMenu::new("account-user", "Ada Lovelace", "ada@atlas.dev", |_, _| log::info!("gallery: sign out")) .presence(Presence::Online) .item(MenuItem::new("Profile").icon(IconName::User).on_click(|_, _| log::info!("gallery: profile"))) - .item(MenuItem::new("Settings").icon(IconName::Settings).on_click(|_, _| log::info!("gallery: settings"))) - .on_sign_out(|_, _| log::info!("gallery: sign out")), + .item(MenuItem::new("Settings").icon(IconName::Settings).on_click(|_, _| log::info!("gallery: settings"))), )), ) } @@ -133,7 +130,7 @@ pub fn profile(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { .action(Button::new("account-card-profile", "View profile")), ), ) - .child(div().w(px(400.)).child(ProfileEditor::new("account-editor", now).on_save(move |profile, _, cx| { + .child(div().w(px(400.)).child(ProfileEditor::new("account-editor", now, move |profile, _, cx| { let profile = profile.clone(); change(&saved, cx, move |now| *now = profile) }))), @@ -215,8 +212,10 @@ pub fn security_page(window: &mut Window, cx: &mut App) -> impl IntoElement + us let now = state.read(cx); let (sessions, keys, secret) = (now.sessions.clone(), now.keys.clone(), now.secret.clone()); let [out, others, made, revoked, done] = [(); 5].map(|_| state.clone()); - let mut manager = ApiKeyManager::new("account-keys", keys) - .on_create(move |name, _, cx| { + let mut manager = ApiKeyManager::new( + "account-keys", + keys, + move |name, _, cx| { let name = name.clone(); change(&made, cx, move |security| { security.made += 1; @@ -233,14 +232,15 @@ pub fn security_page(window: &mut Window, cx: &mut App) -> impl IntoElement + us ); security.secret = Some("ely_live_5f1c0a9e2b7d4c8a3e6f7d3e".into()); }) - }) - .on_revoke(move |key, _, cx| { + }, + move |key, _, cx| { let key = key.clone(); change(&revoked, cx, move |security| { security.keys.retain(|each| each.key != key) }) - }) - .on_dismiss(move |_, cx| change(&done, cx, |security| security.secret = None)); + }, + move |_, cx| change(&done, cx, |security| security.secret = None), + ); if let Some(secret) = secret { manager = manager.secret(secret); } @@ -256,8 +256,7 @@ pub fn security_page(window: &mut Window, cx: &mut App) -> impl IntoElement + us .items_start() .gap_8() .child(div().w(px(380.)).child( - SessionList::new("account-sessions", sessions) - .on_sign_out(move |key, _, cx| { + SessionList::new("account-sessions", sessions, move |key, _, cx| { let key = key.clone(); change(&out, cx, move |security| security.sessions.retain(|each| each.key != key)) }) diff --git a/examples/gallery/pages/account/billing.rs b/examples/gallery/pages/account/billing.rs index e6efdae..504be3d 100644 --- a/examples/gallery/pages/account/billing.rs +++ b/examples/gallery/pages/account/billing.rs @@ -68,7 +68,7 @@ pub fn plan(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { ), ) .child(div().w(px(360.)).flex().flex_col().gap_3().child( - PaymentMethodForm::new("account-card-form").on_submit(move |card, _, cx| { + PaymentMethodForm::new("account-card-form", move |card, _, cx| { let saved_as: SharedString = format!("{} ending {}", card.brand.name(), &card.number[card.number.len() - 4..]).into(); change(&saved, cx, move |plan| plan.card = Some(saved_as)) }), @@ -116,18 +116,16 @@ pub fn history(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> { .flex_col() .gap_4() .child( - UpgradePrompt::new("account-nudge", "9 of 10 projects used") + UpgradePrompt::new("account-nudge", "9 of 10 projects used", |_, _| log::info!("gallery: upgrade")) .body("Pro has no limit on projects.") - .on_upgrade(|_, _| log::info!("gallery: upgrade")) .on_dismiss(|_, _| log::info!("gallery: not now")), ) .child( - UpgradePrompt::new("account-wall", "Version history is on Pro") + UpgradePrompt::new("account-wall", "Version history is on Pro", |_, _| log::info!("gallery: upgrade")) .body("See and restore every version of a document.") .benefit("Every version, kept a year") .benefit("Restore with one press") .benefit("Compare any two") - .on_upgrade(|_, _| log::info!("gallery: upgrade")) .on_dismiss(|_, _| log::info!("gallery: not now")), ), ), @@ -207,27 +205,22 @@ pub fn members(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { .gap_6() .w(px(640.)) .child( - TeamMemberTable::new("account-members", members, roles()) - .on_role(move |key, next, _, cx| { + TeamMemberTable::new("account-members", members, roles(), move |key, next, _, cx| { let (key, next) = (key.clone(), next.clone()); change(&role, cx, move |team| team.members.iter_mut().filter(|member| member.key == key).for_each(|member| member.role = next.clone())) - }) - .on_remove(move |key, _, cx| { + }, move |key, _, cx| { let key = key.clone(); change(&removed, cx, move |team| team.members.retain(|member| member.key != key)) }), ) .child( - InvitationList::new("account-invites", invitations, roles()) - .on_invite(move |email, role, _, cx| { + InvitationList::new("account-invites", invitations, roles(), move |email, role, _, cx| { let (email, role) = (email.clone(), role.clone()); change(&invited, cx, move |team| { team.sent += 1; team.invitations.insert(0, Invitation { key: format!("new-{}", team.sent).into(), email, role, sent: "Sent just now".into(), expired: false }); }) - }) - .on_resend(|key, _, _| log::info!("gallery: resend {key}")) - .on_revoke(move |key, _, cx| { + }, |key, _, _| log::info!("gallery: resend {key}"), move |key, _, cx| { let key = key.clone(); change(&revoked, cx, move |team| team.invitations.retain(|each| each.key != key)) }), diff --git a/examples/gallery/pages/account/sign_in.rs b/examples/gallery/pages/account/sign_in.rs index 655bf3d..fe0e2b4 100644 --- a/examples/gallery/pages/account/sign_in.rs +++ b/examples/gallery/pages/account/sign_in.rs @@ -30,19 +30,18 @@ pub fn sign_in(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { ("google", "Google"), ("apple", "Apple"), ], - ) - .on_pick(move |key, _, cx| change(&picked, cx, |state| state.provider = Some(key.clone()))); + move |key, _, cx| change(&picked, cx, |state| state.provider = Some(key.clone())), + ); if let Some(provider) = provider { providers = providers.busy(provider); } - let mut form = LoginForm::new("account-login") - .on_submit(move |_, _, cx| { - change(&tried, cx, |state| { - state.error = Some("No account uses that email and password.".into()) - }) + let mut form = LoginForm::new("account-login", move |_, _, cx| { + change(&tried, cx, |state| { + state.error = Some("No account uses that email and password.".into()) }) - .on_forgot(|_, _| log::info!("gallery: forgot password")) - .on_sign_up(|_, _| log::info!("gallery: sign up")); + }) + .on_forgot(|_, _| log::info!("gallery: forgot password")) + .on_sign_up(|_, _| log::info!("gallery: sign up")); if let Some(error) = error { form = form.error(error); } @@ -71,13 +70,12 @@ pub fn sign_up(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { let state = keep("account-sign-up", SignUp::default, window, cx); let error = state.read(cx).error.clone(); let tried = state.clone(); - let mut form = SignupForm::new("account-signup") - .terms("I agree to the terms of service") - .on_submit(move |signup, _, cx| { - let taken = format!("An account already uses {}.", signup.email); - change(&tried, cx, |state| state.error = Some(taken.into())) - }) - .on_sign_in(|_, _| log::info!("gallery: sign in")); + let mut form = SignupForm::new("account-signup", move |signup, _, cx| { + let taken = format!("An account already uses {}.", signup.email); + change(&tried, cx, |state| state.error = Some(taken.into())) + }) + .terms("I agree to the terms of service") + .on_sign_in(|_, _| log::info!("gallery: sign in")); if let Some(error) = error { form = form.error(error); } @@ -115,9 +113,8 @@ pub fn by_email(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { "account-magic", div().w(px(360.)).child( Card::new().header(CardHeader::new("Sign in by email")).child( - MagicLinkForm::new("account-magic") + MagicLinkForm::new("account-magic", move |_, _, cx| change(&sent, cx, |state| state.magic = true)) .sent(magic) - .on_send(move |_, _, cx| change(&sent, cx, |state| state.magic = true)) .on_other(move |_, cx| change(&other, cx, |state| state.magic = false)), ), ), @@ -125,9 +122,8 @@ pub fn by_email(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { .child( div().w(px(360.)).child( Card::new().header(CardHeader::new("Reset your password")).child( - ForgotPassword::new("account-forgot") + ForgotPassword::new("account-forgot", move |_, _, cx| change(&asked, cx, |state| state.reset = true)) .sent(reset) - .on_send(move |_, _, cx| change(&asked, cx, |state| state.reset = true)) .on_back(move |_, cx| change(&back, cx, |state| state.reset = false)), ), ), @@ -161,14 +157,13 @@ pub fn second_factor(window: &mut Window, cx: &mut App) -> impl IntoElement + us Card::new() .header(CardHeader::new("Two-step sign-in")) .child( - TwoFactorInput::new("account-factor") - .failed(failed) - .on_code(move |code, _, cx| { + TwoFactorInput::new("account-factor", move |code, _, cx| { change(&checked, cx, |state| match code.as_ref() == CODE { true => state.passed = true, false => state.failed += 1, }) }) + .failed(failed) .on_recovery(|_, _| log::info!("gallery: recovery code")), ) .children(passed.then(|| InlineMessage::new(Severity::Success, "Signed in."))), diff --git a/src/account/factor.rs b/src/account/factor.rs index ff3d509..d676c35 100644 --- a/src/account/factor.rs +++ b/src/account/factor.rs @@ -24,17 +24,20 @@ pub struct TwoFactorInput { id: ElementId, failed: usize, busy: bool, - on_code: Option, + on_code: OnText, on_recovery: Option, } impl TwoFactorInput { - pub fn new(id: impl Into) -> Self { + pub fn new( + id: impl Into, + on_code: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { Self { id: id.into(), failed: 0, busy: false, - on_code: None, + on_code: Rc::new(on_code), on_recovery: None, } } @@ -51,14 +54,6 @@ impl TwoFactorInput { self } - pub fn on_code( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_code = Some(Rc::new(handler)); - self - } - /// Shows "Use a recovery code". pub fn on_recovery(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { self.on_recovery = Some(Rc::new(handler)); @@ -69,9 +64,7 @@ impl TwoFactorInput { impl RenderOnce for TwoFactorInput { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let on_code = self - .on_code - .unwrap_or_else(|| panic!("two-factor input {id:?} has no on_code")); + let on_code = self.on_code; let theme = cx.theme(); let note = match (self.busy, self.failed) { (true, _) => Some( diff --git a/src/account/keys.rs b/src/account/keys.rs index 0a33a58..f64eac5 100644 --- a/src/account/keys.rs +++ b/src/account/keys.rs @@ -33,20 +33,26 @@ pub struct ApiKeyManager { id: ElementId, keys: Vec, secret: Option, - on_create: Option, - on_revoke: Option, - on_dismiss: Option, + on_create: OnKey, + on_revoke: OnKey, + on_dismiss: Run, } impl ApiKeyManager { - pub fn new(id: impl Into, keys: impl IntoIterator) -> Self { + pub fn new( + id: impl Into, + keys: impl IntoIterator, + on_create: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + on_revoke: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + on_dismiss: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { Self { id: id.into(), keys: keys.into_iter().collect(), secret: None, - on_create: None, - on_revoke: None, - on_dismiss: None, + on_create: Rc::new(on_create), + on_revoke: Rc::new(on_revoke), + on_dismiss: Rc::new(on_dismiss), } } @@ -55,40 +61,13 @@ impl ApiKeyManager { self.secret = Some(secret.into()); self } - - /// Runs with the new key's name. - pub fn on_create( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_create = Some(Rc::new(handler)); - self - } - - pub fn on_revoke( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_revoke = Some(Rc::new(handler)); - self - } - - /// Runs on Done under a secret; the owner clears it. - pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { - self.on_dismiss = Some(Rc::new(handler)); - self - } } impl RenderOnce for ApiKeyManager { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let on_create = self - .on_create - .unwrap_or_else(|| panic!("api key manager {id:?} has no on_create")); - let on_revoke = self - .on_revoke - .unwrap_or_else(|| panic!("api key manager {id:?} has no on_revoke")); + let on_create = self.on_create; + let on_revoke = self.on_revoke; let name = field(&id, "name", "A name, such as Deploys", false, window, cx); let named: SharedString = name.read(cx).text().trim().to_string().into(); let ready = !named.is_empty(); @@ -102,9 +81,7 @@ impl RenderOnce for ApiKeyManager { let theme = cx.theme(); let mono = theme.mono_family.clone(); let secret = self.secret.map(|secret| { - let dismiss = self.on_dismiss.clone().unwrap_or_else(|| { - panic!("api key manager {id:?} shows a secret with no on_dismiss") - }); + let dismiss = self.on_dismiss.clone(); Callout::new(Severity::Warning) .title("Copy this key now. It will not show again.") .child( diff --git a/src/account/login.rs b/src/account/login.rs index f707fbf..6ec6af2 100644 --- a/src/account/login.rs +++ b/src/account/login.rs @@ -67,18 +67,21 @@ pub struct LoginForm { id: ElementId, error: Option, busy: bool, - on_submit: Option, + on_submit: OnLogin, on_forgot: Option, on_sign_up: Option, } impl LoginForm { - pub fn new(id: impl Into) -> Self { + pub fn new( + id: impl Into, + on_submit: impl Fn(&Login, &mut Window, &mut App) + 'static, + ) -> Self { Self { id: id.into(), error: None, busy: false, - on_submit: None, + on_submit: Rc::new(on_submit), on_forgot: None, on_sign_up: None, } @@ -96,11 +99,6 @@ impl LoginForm { self } - pub fn on_submit(mut self, handler: impl Fn(&Login, &mut Window, &mut App) + 'static) -> Self { - self.on_submit = Some(Rc::new(handler)); - self - } - /// Shows "Forgot password?". pub fn on_forgot(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { self.on_forgot = Some(Rc::new(handler)); @@ -127,9 +125,7 @@ impl RenderOnce for LoginForm { }; let remembered = login.remember; let ready = is_email(&login.email) && !login.password.is_empty() && !self.busy; - let on_submit = self - .on_submit - .unwrap_or_else(|| panic!("login form {id:?} has no on_submit")); + let on_submit = self.on_submit; let submit: Run = Rc::new(move |window, cx| { log::info!("login form: sign in"); on_submit(&login, window, cx); diff --git a/src/account/mail.rs b/src/account/mail.rs index 37078f4..0782dd5 100644 --- a/src/account/mail.rs +++ b/src/account/mail.rs @@ -33,7 +33,7 @@ struct Step { words: Words, sent: bool, busy: bool, - on_send: Option, + on_send: OnText, back: Way, other: Way, } @@ -52,7 +52,6 @@ fn email_step(step: Step, window: &mut Window, cx: &mut App) -> AnyElement { let email = field(&id, "email", "name@example.com", false, window, cx); let address: SharedString = email.read(cx).text().trim().to_string().into(); let ready = is_email(&address) && !busy; - let on_send = on_send.unwrap_or_else(|| panic!("email step {id:?} has no on_send")); let send: Run = { let address = address.clone(); Rc::new(move |window, cx| { @@ -151,17 +150,20 @@ pub struct MagicLinkForm { id: ElementId, sent: bool, busy: bool, - on_send: Option, + on_send: OnText, on_other: Option, } impl MagicLinkForm { - pub fn new(id: impl Into) -> Self { + pub fn new( + id: impl Into, + on_send: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { Self { id: id.into(), sent: false, busy: false, - on_send: None, + on_send: Rc::new(on_send), on_other: None, } } @@ -178,15 +180,6 @@ impl MagicLinkForm { self } - /// Runs with the address, for the first link and each one after. - pub fn on_send( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_send = Some(Rc::new(handler)); - self - } - /// Shows "Use another email" once sent; the owner clears `sent`. pub fn on_other(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { self.on_other = Some(Rc::new(handler)); @@ -224,17 +217,20 @@ pub struct ForgotPassword { id: ElementId, sent: bool, busy: bool, - on_send: Option, + on_send: OnText, on_back: Option, } impl ForgotPassword { - pub fn new(id: impl Into) -> Self { + pub fn new( + id: impl Into, + on_send: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { Self { id: id.into(), sent: false, busy: false, - on_send: None, + on_send: Rc::new(on_send), on_back: None, } } @@ -251,15 +247,6 @@ impl ForgotPassword { self } - /// Runs with the address, for the first link and each one after. - pub fn on_send( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_send = Some(Rc::new(handler)); - self - } - /// Shows "Back to sign in". pub fn on_back(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { self.on_back = Some(Rc::new(handler)); diff --git a/src/account/oauth.rs b/src/account/oauth.rs index 89edfdc..603a700 100644 --- a/src/account/oauth.rs +++ b/src/account/oauth.rs @@ -14,7 +14,7 @@ pub struct OAuthButtons { id: ElementId, providers: Vec<(SharedString, SharedString)>, busy: Option, - on_pick: Option, + on_pick: OnKey, } impl OAuthButtons { @@ -22,6 +22,7 @@ impl OAuthButtons { pub fn new( id: impl Into, providers: impl IntoIterator, impl Into)>, + on_pick: impl Fn(&SharedString, &mut Window, &mut App) + 'static, ) -> Self { let id = id.into(); let providers: Vec<_> = providers @@ -33,36 +34,27 @@ impl OAuthButtons { id, providers, busy: None, - on_pick: None, + on_pick: Rc::new(on_pick), } } /// The provider on its way. pub fn busy(mut self, key: impl Into) -> Self { let key = key.into(); - assert!( - self.providers.iter().any(|(each, _)| *each == key), - "oauth buttons: no provider {key}" - ); + if !self.providers.iter().any(|(each, _)| *each == key) { + log::error!("oauth buttons: no provider {key}; none busy"); + self.busy = None; + return self; + } self.busy = Some(key); self } - - pub fn on_pick( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_pick = Some(Rc::new(handler)); - self - } } impl RenderOnce for OAuthButtons { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let id = self.id; - let on_pick = self - .on_pick - .unwrap_or_else(|| panic!("oauth buttons {id:?} have no on_pick")); + let on_pick = self.on_pick; let busy = self.busy; div() .flex() diff --git a/src/account/payment.rs b/src/account/payment.rs index 1dec309..d8d4a53 100644 --- a/src/account/payment.rs +++ b/src/account/payment.rs @@ -97,11 +97,12 @@ pub fn expiry(text: &str, now: (i32, i8)) -> Result<(i8, i32), ExpiryError> { .trim() .parse::() .map_err(|_| ExpiryError::Unread)?; - let year = 2000 - + year - .trim() - .parse::() - .map_err(|_| ExpiryError::Unread)?; + let year = year + .trim() + .parse::() + .ok() + .and_then(|year| year.checked_add(2000)) + .ok_or(ExpiryError::Unread)?; if !(1..=12).contains(&month) { return Err(ExpiryError::NoMonth(month)); } @@ -146,16 +147,19 @@ pub struct PaymentMethodForm { id: ElementId, error: Option, busy: bool, - on_submit: Option, + on_submit: OnCard, } impl PaymentMethodForm { - pub fn new(id: impl Into) -> Self { + pub fn new( + id: impl Into, + on_submit: impl Fn(&CardDetails, &mut Window, &mut App) + 'static, + ) -> Self { Self { id: id.into(), error: None, busy: false, - on_submit: None, + on_submit: Rc::new(on_submit), } } @@ -170,22 +174,12 @@ impl PaymentMethodForm { self.busy = busy; self } - - pub fn on_submit( - mut self, - handler: impl Fn(&CardDetails, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_submit = Some(Rc::new(handler)); - self - } } impl RenderOnce for PaymentMethodForm { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let on_submit = self - .on_submit - .unwrap_or_else(|| panic!("payment method form {id:?} has no on_submit")); + let on_submit = self.on_submit; let number = field(&id, "number", "", false, window, cx); let until = field(&id, "expiry", "", false, window, cx); let code = field(&id, "cvc", "", false, window, cx); @@ -315,6 +309,11 @@ mod tests { "this month still counts" ); assert_eq!(expiry("12/31", now), Ok((12, 2031))); + assert_eq!( + expiry("12/2147483647", now), + Err(ExpiryError::Unread), + "a year past any clock" + ); assert_eq!(expiry("08/26", now), Err(ExpiryError::Passed)); assert_eq!(expiry("13/30", now), Err(ExpiryError::NoMonth(13))); assert_eq!(expiry("1230", now), Err(ExpiryError::Unread)); diff --git a/src/account/profile.rs b/src/account/profile.rs index 4ea6348..8adc641 100644 --- a/src/account/profile.rs +++ b/src/account/profile.rs @@ -212,30 +212,27 @@ fn savable(edited: &Profile, seed: &Profile) -> bool { pub struct ProfileEditor { id: ElementId, profile: Profile, - on_save: Option, + on_save: OnProfile, } impl ProfileEditor { - pub fn new(id: impl Into, profile: Profile) -> Self { + pub fn new( + id: impl Into, + profile: Profile, + on_save: impl Fn(&Profile, &mut Window, &mut App) + 'static, + ) -> Self { Self { id: id.into(), profile, - on_save: None, + on_save: Rc::new(on_save), } } - - pub fn on_save(mut self, handler: impl Fn(&Profile, &mut Window, &mut App) + 'static) -> Self { - self.on_save = Some(Rc::new(handler)); - self - } } impl RenderOnce for ProfileEditor { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let (id, profile) = (self.id, self.profile); - let on_save = self - .on_save - .unwrap_or_else(|| panic!("profile editor {id:?} has no on_save")); + let on_save = self.on_save; let fields = window.use_keyed_state((id.clone(), "fields"), cx, |window, cx| { Fields::filled(&profile, window, cx) }); diff --git a/src/account/sessions.rs b/src/account/sessions.rs index ccf935a..ea77e83 100644 --- a/src/account/sessions.rs +++ b/src/account/sessions.rs @@ -33,12 +33,16 @@ pub struct Session { pub struct SessionList { id: ElementId, sessions: Vec, - on_sign_out: Option, + on_sign_out: OnKey, on_sign_out_others: Option, } impl SessionList { - pub fn new(id: impl Into, sessions: impl IntoIterator) -> Self { + pub fn new( + id: impl Into, + sessions: impl IntoIterator, + on_sign_out: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { let mut sessions: Vec<_> = sessions.into_iter().collect(); assert!( sessions.iter().filter(|session| session.current).count() <= 1, @@ -48,19 +52,11 @@ impl SessionList { Self { id: id.into(), sessions, - on_sign_out: None, + on_sign_out: Rc::new(on_sign_out), on_sign_out_others: None, } } - pub fn on_sign_out( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_sign_out = Some(Rc::new(handler)); - self - } - /// Shows "Sign out of all other devices" while there are others. pub fn on_sign_out_others(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { self.on_sign_out_others = Some(Rc::new(handler)); @@ -71,9 +67,7 @@ impl SessionList { impl RenderOnce for SessionList { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let on_sign_out = self - .on_sign_out - .unwrap_or_else(|| panic!("session list {id:?} has no on_sign_out")); + let on_sign_out = self.on_sign_out; let others = self.sessions.iter().any(|session| !session.current); let theme = cx.theme(); let rows = self.sessions.into_iter().enumerate().map(|(ix, session)| { diff --git a/src/account/signup.rs b/src/account/signup.rs index f4f8227..c9de818 100644 --- a/src/account/signup.rs +++ b/src/account/signup.rs @@ -40,18 +40,21 @@ pub struct SignupForm { terms: Option, error: Option, busy: bool, - on_submit: Option, + on_submit: OnSignup, on_sign_in: Option, } impl SignupForm { - pub fn new(id: impl Into) -> Self { + pub fn new( + id: impl Into, + on_submit: impl Fn(&Signup, &mut Window, &mut App) + 'static, + ) -> Self { Self { id: id.into(), terms: None, error: None, busy: false, - on_submit: None, + on_submit: Rc::new(on_submit), on_sign_in: None, } } @@ -74,11 +77,6 @@ impl SignupForm { self } - pub fn on_submit(mut self, handler: impl Fn(&Signup, &mut Window, &mut App) + 'static) -> Self { - self.on_submit = Some(Rc::new(handler)); - self - } - /// Shows "Have an account? Sign in". pub fn on_sign_in(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { self.on_sign_in = Some(Rc::new(handler)); @@ -105,9 +103,7 @@ impl RenderOnce for SignupForm { && rules.iter().all(|(_, kept)| *kept) && (self.terms.is_none() || agreed) && !self.busy; - let on_submit = self - .on_submit - .unwrap_or_else(|| panic!("sign-up form {id:?} has no on_submit")); + let on_submit = self.on_submit; let submit: Run = Rc::new(move |window, cx| { log::info!("sign-up form: create account"); on_submit(&signup, window, cx); diff --git a/src/account/switcher.rs b/src/account/switcher.rs index 37eafba..a6be138 100644 --- a/src/account/switcher.rs +++ b/src/account/switcher.rs @@ -109,7 +109,7 @@ pub struct AccountSwitcher { id: ElementId, accounts: Vec, current: SharedString, - on_select: Option, + on_select: OnKey, on_add: Option, } @@ -118,30 +118,19 @@ impl AccountSwitcher { id: impl Into, accounts: impl IntoIterator, current: impl Into, + on_select: impl Fn(&SharedString, &mut Window, &mut App) + 'static, ) -> Self { let (accounts, current): (Vec<_>, SharedString) = (accounts.into_iter().collect(), current.into()); - assert!( - accounts.iter().any(|account| account.key == current), - "account switcher: no account {current}" - ); Self { id: id.into(), accounts, current, - on_select: None, + on_select: Rc::new(on_select), on_add: None, } } - pub fn on_select( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_select = Some(Rc::new(handler)); - self - } - /// Shows "Add account". pub fn on_add(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { self.on_add = Some(Rc::new(handler)); @@ -152,9 +141,7 @@ impl AccountSwitcher { impl RenderOnce for AccountSwitcher { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let on_select = self - .on_select - .unwrap_or_else(|| panic!("account switcher {id:?} has no on_select")); + let on_select = self.on_select; let items = self.accounts.iter().map(|account| { let (key, pick) = (account.key.clone(), on_select.clone()); MenuItem::radio(account.email.clone(), account.key == self.current).on_click( @@ -175,18 +162,22 @@ impl RenderOnce for AccountSwitcher { let now = self .accounts .into_iter() - .find(|account| account.key == self.current) - .expect("the current account"); - let mut avatar = Avatar::new((id.clone(), "avatar"), now.name.clone()).size(AvatarSize::Sm); - if let Some(image) = now.image { + .find(|account| account.key == self.current); + if now.is_none() { + log::error!( + "account switcher {id:?}: no account {}; none current", + self.current + ); + } + let name = now + .as_ref() + .map_or_else(|| SharedString::from("No account"), |now| now.name.clone()); + let mut avatar = Avatar::new((id.clone(), "avatar"), name.clone()).size(AvatarSize::Sm); + if let Some(image) = now.as_ref().and_then(|now| now.image.clone()) { avatar = avatar.image(image); } - let trigger = row( - (id.clone(), "trigger"), - avatar, - named(now.name, now.email, cx), - cx, - ); + let email = now.map(|now| now.email).unwrap_or_default(); + let trigger = row((id.clone(), "trigger"), avatar, named(name, email, cx), cx); opening(id, &menu, trigger, window, cx) } } @@ -197,7 +188,7 @@ pub struct WorkspaceSwitcher { id: ElementId, workspaces: Vec, current: SharedString, - on_select: Option, + on_select: OnKey, on_create: Option, } @@ -206,30 +197,19 @@ impl WorkspaceSwitcher { id: impl Into, workspaces: impl IntoIterator, current: impl Into, + on_select: impl Fn(&SharedString, &mut Window, &mut App) + 'static, ) -> Self { let (workspaces, current): (Vec<_>, SharedString) = (workspaces.into_iter().collect(), current.into()); - assert!( - workspaces.iter().any(|each| each.key == current), - "workspace switcher: no workspace {current}" - ); Self { id: id.into(), workspaces, current, - on_select: None, + on_select: Rc::new(on_select), on_create: None, } } - pub fn on_select( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_select = Some(Rc::new(handler)); - self - } - /// Shows "Create workspace". pub fn on_create(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { self.on_create = Some(Rc::new(handler)); @@ -240,9 +220,7 @@ impl WorkspaceSwitcher { impl RenderOnce for WorkspaceSwitcher { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let on_select = self - .on_select - .unwrap_or_else(|| panic!("workspace switcher {id:?} has no on_select")); + let on_select = self.on_select; let items: Vec<_> = self .workspaces .iter() @@ -267,17 +245,22 @@ impl RenderOnce for WorkspaceSwitcher { let now = self .workspaces .into_iter() - .find(|each| each.key == self.current) - .expect("the current workspace"); - let mark = Avatar::new((id.clone(), "mark"), now.name.clone()) + .find(|each| each.key == self.current); + if now.is_none() { + log::error!( + "workspace switcher {id:?}: no workspace {}; none current", + self.current + ); + } + let name = now.as_ref().map_or_else( + || SharedString::from("No workspace"), + |now| now.name.clone(), + ); + let mark = Avatar::new((id.clone(), "mark"), name.clone()) .square() .size(AvatarSize::Sm); - let trigger = row( - (id.clone(), "trigger"), - mark, - named(now.name, now.detail, cx), - cx, - ); + let detail = now.map(|now| now.detail).unwrap_or_default(); + let trigger = row((id.clone(), "trigger"), mark, named(name, detail, cx), cx); opening(id, &menu, trigger, window, cx) } } @@ -291,7 +274,7 @@ pub struct UserMenu { image: Option, presence: Option, items: Vec, - on_sign_out: Option, + on_sign_out: Run, } impl UserMenu { @@ -299,6 +282,7 @@ impl UserMenu { id: impl Into, name: impl Into, email: impl Into, + on_sign_out: impl Fn(&mut Window, &mut App) + 'static, ) -> Self { Self { id: id.into(), @@ -307,7 +291,7 @@ impl UserMenu { image: None, presence: None, items: Vec::new(), - on_sign_out: None, + on_sign_out: Rc::new(on_sign_out), } } @@ -326,19 +310,12 @@ impl UserMenu { self.items.push(item); self } - - pub fn on_sign_out(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { - self.on_sign_out = Some(Rc::new(handler)); - self - } } impl RenderOnce for UserMenu { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let sign_out = self - .on_sign_out - .unwrap_or_else(|| panic!("user menu {id:?} has no on_sign_out")); + let sign_out = self.on_sign_out; let menu = Menu::new() .group(format!("Signed in as {}", self.email), self.items) diff --git a/src/account/team.rs b/src/account/team.rs index 1646f33..6687de7 100644 --- a/src/account/team.rs +++ b/src/account/team.rs @@ -25,11 +25,13 @@ pub struct Role { pub description: SharedString, } -fn role_named<'a>(roles: &'a [Role], key: &SharedString, owner: &str) -> &'a Role { - roles - .iter() - .find(|role| role.key == *key) - .unwrap_or_else(|| panic!("{owner}: no role {key}")) +/// The role of `key`; none, logged for `owner`, when the roles lost it. +fn role_named<'a>(roles: &'a [Role], key: &SharedString, owner: &str) -> Option<&'a Role> { + let role = roles.iter().find(|role| role.key == *key); + if role.is_none() { + log::error!("{owner}: no role {key}; none shown"); + } + role } /// A role to pick, each with what it may do beneath its name. @@ -39,7 +41,7 @@ pub struct RoleSelector { roles: Vec, selected: SharedString, disabled: bool, - on_change: Option, + on_change: OnKey, } impl RoleSelector { @@ -47,6 +49,7 @@ impl RoleSelector { id: impl Into, roles: impl IntoIterator, selected: impl Into, + on_change: impl Fn(&SharedString, &mut Window, &mut App) + 'static, ) -> Self { let (roles, selected): (Vec<_>, SharedString) = (roles.into_iter().collect(), selected.into()); @@ -56,7 +59,7 @@ impl RoleSelector { roles, selected, disabled: false, - on_change: None, + on_change: Rc::new(on_change), } } @@ -64,22 +67,12 @@ impl RoleSelector { self.disabled = disabled; self } - - pub fn on_change( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_change = Some(Rc::new(handler)); - self - } } impl RenderOnce for RoleSelector { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let id = self.id; - let on_change = self - .on_change - .unwrap_or_else(|| panic!("role selector {id:?} has no on_change")); + let on_change = self.on_change; let choices = self .roles .into_iter() @@ -113,8 +106,8 @@ pub struct TeamMemberTable { id: ElementId, members: Vec, roles: Vec, - on_role: Option, - on_remove: Option, + on_role: OnPair, + on_remove: OnKey, } impl TeamMemberTable { @@ -122,48 +115,26 @@ impl TeamMemberTable { id: impl Into, members: impl IntoIterator, roles: impl IntoIterator, + on_role: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static, + on_remove: impl Fn(&SharedString, &mut Window, &mut App) + 'static, ) -> Self { let (members, roles): (Vec, Vec) = (members.into_iter().collect(), roles.into_iter().collect()); - for member in members.iter().filter(|member| !member.owner) { - role_named(&roles, &member.role, "team member table"); - } Self { id: id.into(), members, roles, - on_role: None, - on_remove: None, + on_role: Rc::new(on_role), + on_remove: Rc::new(on_remove), } } - - /// Runs with a member's key and their new role's. - pub fn on_role( - mut self, - handler: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_role = Some(Rc::new(handler)); - self - } - - pub fn on_remove( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_remove = Some(Rc::new(handler)); - self - } } impl RenderOnce for TeamMemberTable { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let on_role = self - .on_role - .unwrap_or_else(|| panic!("team member table {id:?} has no on_role")); - let on_remove = self - .on_remove - .unwrap_or_else(|| panic!("team member table {id:?} has no on_remove")); + let on_role = self.on_role; + let on_remove = self.on_remove; let theme = cx.theme(); let rows = self.members.into_iter().enumerate().map(|(ix, member)| { let mut avatar = Avatar::new( @@ -189,8 +160,8 @@ impl RenderOnce for TeamMemberTable { (id.clone(), format!("role-{}", member.key)), self.roles.clone(), member.role.clone(), - ) - .on_change(move |role, window, cx| set(&changed, role, window, cx)); + move |role, window, cx| set(&changed, role, window, cx), + ); let button = ConfirmButton::new( (id.clone(), format!("remove-{}", member.key)), "Remove", @@ -272,9 +243,9 @@ pub struct InvitationList { id: ElementId, invitations: Vec, roles: Vec, - on_invite: Option, - on_resend: Option, - on_revoke: Option, + on_invite: OnPair, + on_resend: OnKey, + on_revoke: OnKey, } impl InvitationList { @@ -282,63 +253,32 @@ impl InvitationList { id: impl Into, invitations: impl IntoIterator, roles: impl IntoIterator, + on_invite: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static, + on_resend: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + on_revoke: impl Fn(&SharedString, &mut Window, &mut App) + 'static, ) -> Self { let (invitations, roles): (Vec, Vec) = ( invitations.into_iter().collect(), roles.into_iter().collect(), ); assert!(!roles.is_empty(), "invitation list: no role to invite as"); - for invitation in &invitations { - role_named(&roles, &invitation.role, "invitation list"); - } Self { id: id.into(), invitations, roles, - on_invite: None, - on_resend: None, - on_revoke: None, + on_invite: Rc::new(on_invite), + on_resend: Rc::new(on_resend), + on_revoke: Rc::new(on_revoke), } } - - /// Runs with the address and the role's key. - pub fn on_invite( - mut self, - handler: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_invite = Some(Rc::new(handler)); - self - } - - pub fn on_resend( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_resend = Some(Rc::new(handler)); - self - } - - pub fn on_revoke( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_revoke = Some(Rc::new(handler)); - self - } } impl RenderOnce for InvitationList { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let on_invite = self - .on_invite - .unwrap_or_else(|| panic!("invitation list {id:?} has no on_invite")); - let on_resend = self - .on_resend - .unwrap_or_else(|| panic!("invitation list {id:?} has no on_resend")); - let on_revoke = self - .on_revoke - .unwrap_or_else(|| panic!("invitation list {id:?} has no on_revoke")); + let on_invite = self.on_invite; + let on_resend = self.on_resend; + let on_revoke = self.on_revoke; let email = field(&id, "email", "name@example.com", false, window, cx); let first = self.roles[0].key.clone(); let role = window.use_keyed_state((id.clone(), "role"), cx, move |_, _| first); @@ -346,7 +286,8 @@ impl RenderOnce for InvitationList { email.read(cx).text().trim().to_string().into(), role.read(cx).clone(), ); - let ready = is_email(&address); + let offered = self.roles.iter().any(|role| role.key == chosen); + let ready = is_email(&address) && offered; let clear = email.clone(); let invite = Rc::new(move |window: &mut Window, cx: &mut App| { log::info!("invitation list: invite as {chosen}"); @@ -358,8 +299,7 @@ impl RenderOnce for InvitationList { let rows = self.invitations.iter().map(|invitation| { let named = role_named(&self.roles, &invitation.role, "invitation list") - .name - .clone(); + .map_or_else(|| SharedString::from("No role"), |role| role.name.clone()); let [again, gone] = [(); 2].map(|_| invitation.key.clone()); let (resend, revoke) = (on_resend.clone(), on_revoke.clone()); div() @@ -448,21 +388,17 @@ impl RenderOnce for InvitationList { .min_w(theme.label_width()) .child(EmailInput::new(&email)), ) - .child( - div().w(theme.label_width()).child( - RoleSelector::new( - (id.clone(), "invite-role"), - self.roles.clone(), - role.read(cx).clone(), - ) - .on_change(move |key, _, cx| { - picked.update(cx, |role, cx| { - *role = key.clone(); - cx.notify(); - }) - }), - ), - ) + .child(div().w(theme.label_width()).child(RoleSelector::new( + (id.clone(), "invite-role"), + self.roles.clone(), + role.read(cx).clone(), + move |key, _, cx| { + picked.update(cx, |role, cx| { + *role = key.clone(); + cx.notify(); + }) + }, + ))) .child( Button::new((id.clone(), "invite"), "Invite") .variant(ButtonVariant::Primary) diff --git a/src/account/tests/access.rs b/src/account/tests/access.rs index 63b3b6c..4727d86 100644 --- a/src/account/tests/access.rs +++ b/src/account/tests/access.rs @@ -21,8 +21,8 @@ fn accounts(_: &Bench, owner: Entity) -> AnyElement { account("home", "Ada", "ada@home.com"), ], "ada", + move |key, _, cx| say(&owner, key.to_string(), cx), ) - .on_select(move |key, _, cx| say(&owner, key.to_string(), cx)) .into_any_element() } @@ -48,10 +48,13 @@ fn sessions(_: &Bench, owner: Entity) -> AnyElement { current, }; let everyone = owner.clone(); - SessionList::new("sessions", [session("phone", false), session("mac", true)]) - .on_sign_out(move |key, _, cx| say(&owner, format!("out {key}"), cx)) - .on_sign_out_others(move |_, cx| say(&everyone, "others".into(), cx)) - .into_any_element() + SessionList::new( + "sessions", + [session("phone", false), session("mac", true)], + move |key, _, cx| say(&owner, format!("out {key}"), cx), + ) + .on_sign_out_others(move |_, cx| say(&everyone, "others".into(), cx)) + .into_any_element() } /// Stops: the phone's Sign out, then Sign out of all other devices; this device has none. @@ -81,10 +84,13 @@ fn keys(bench: &Bench, owner: Entity) -> AnyElement { used: "Never used".into(), }; let [made, revoked, done] = [(); 3].map(|_| owner.clone()); - let manager = ApiKeyManager::new("keys", [key]) - .on_create(move |name, _, cx| say(&made, format!("make {name}"), cx)) - .on_revoke(move |key, _, cx| say(&revoked, format!("revoke {key}"), cx)) - .on_dismiss(move |_, cx| say(&done, "done".into(), cx)); + let manager = ApiKeyManager::new( + "keys", + [key], + move |name, _, cx| say(&made, format!("make {name}"), cx), + move |key, _, cx| say(&revoked, format!("revoke {key}"), cx), + move |_, cx| say(&done, "done".into(), cx), + ); match bench.sent { true => manager.secret("sk_live_0123456789").into_any_element(), false => manager.into_any_element(), @@ -138,11 +144,10 @@ fn profile() -> Profile { } fn editor(_: &Bench, owner: Entity) -> AnyElement { - ProfileEditor::new("profile", profile()) - .on_save(move |profile, _, cx| { - say(&owner, format!("{} {}", profile.name, profile.email), cx) - }) - .into_any_element() + ProfileEditor::new("profile", profile(), move |profile, _, cx| { + say(&owner, format!("{} {}", profile.name, profile.email), cx) + }) + .into_any_element() } /// Stops: the picture, name, username, email, about, location; Discard and Save once changed. @@ -167,10 +172,11 @@ fn a_profile_saves_only_what_changed_and_discard_puts_it_back(cx: &mut TestAppCo fn menu(_: &Bench, owner: Entity) -> AnyElement { let [profile, out] = [(); 2].map(|_| owner.clone()); - UserMenu::new("user", "Ada", "ada@example.com") - .item(MenuItem::new("Profile").on_click(move |_, cx| say(&profile, "profile".into(), cx))) - .on_sign_out(move |_, cx| say(&out, "out".into(), cx)) - .into_any_element() + UserMenu::new("user", "Ada", "ada@example.com", move |_, cx| { + say(&out, "out".into(), cx) + }) + .item(MenuItem::new("Profile").on_click(move |_, cx| say(&profile, "profile".into(), cx))) + .into_any_element() } /// Stops: the avatar; the menu's rows are Profile, then Sign out. @@ -191,10 +197,14 @@ fn workspaces(_: &Bench, owner: Entity) -> AnyElement { detail: "Pro".into(), }; let made = owner.clone(); - WorkspaceSwitcher::new("spaces", [space("north"), space("south")], "south") - .on_select(move |key, _, cx| say(&owner, key.to_string(), cx)) - .on_create(move |_, cx| say(&made, "create".into(), cx)) - .into_any_element() + WorkspaceSwitcher::new( + "spaces", + [space("north"), space("south")], + "south", + move |key, _, cx| say(&owner, key.to_string(), cx), + ) + .on_create(move |_, cx| say(&made, "create".into(), cx)) + .into_any_element() } /// Stops: the switcher; its rows are north, south, then Create workspace. @@ -222,10 +232,11 @@ fn alone(_: &Bench, owner: Entity) -> AnyElement { current: true, }; let everyone = owner.clone(); - SessionList::new("sessions", [here]) - .on_sign_out(move |key, _, cx| say(&owner, format!("out {key}"), cx)) - .on_sign_out_others(move |_, cx| say(&everyone, "others".into(), cx)) - .into_any_element() + SessionList::new("sessions", [here], move |key, _, cx| { + say(&owner, format!("out {key}"), cx) + }) + .on_sign_out_others(move |_, cx| say(&everyone, "others".into(), cx)) + .into_any_element() } /// With this device alone there is nothing to sign out, so the list has no stop. diff --git a/src/account/tests/billing.rs b/src/account/tests/billing.rs index 996fe95..abcf7e5 100644 --- a/src/account/tests/billing.rs +++ b/src/account/tests/billing.rs @@ -51,23 +51,22 @@ fn a_plan_cancels_on_the_second_press_and_comes_back(cx: &mut TestAppContext) { } fn card(_: &Bench, owner: Entity) -> AnyElement { - PaymentMethodForm::new("card") - .on_submit(move |card, _, cx| { - say( - &owner, - format!( - "{} {} {}/{} {} {}", - card.brand.name(), - card.number, - card.month, - card.year, - card.cvc, - card.name - ), - cx, - ) - }) - .into_any_element() + PaymentMethodForm::new("card", move |card, _, cx| { + say( + &owner, + format!( + "{} {} {}/{} {} {}", + card.brand.name(), + card.number, + card.month, + card.year, + card.cvc, + card.name + ), + cx, + ) + }) + .into_any_element() } /// Stops: number, expiry, code, name, then Save once the card reads. @@ -116,11 +115,15 @@ fn invites(_: &Bench, owner: Entity) -> AnyElement { expired: false, }; let [invited, again, gone] = [(); 3].map(|_| owner.clone()); - InvitationList::new("invites", [sent], roles()) - .on_invite(move |email, role, _, cx| say(&invited, format!("invite {email} {role}"), cx)) - .on_resend(move |key, _, cx| say(&again, format!("resend {key}"), cx)) - .on_revoke(move |key, _, cx| say(&gone, format!("revoke {key}"), cx)) - .into_any_element() + InvitationList::new( + "invites", + [sent], + roles(), + move |email, role, _, cx| say(&invited, format!("invite {email} {role}"), cx), + move |key, _, cx| say(&again, format!("resend {key}"), cx), + move |key, _, cx| say(&gone, format!("revoke {key}"), cx), + ) + .into_any_element() } /// Stops: the address, the role, then Resend and Revoke while Invite rests. @@ -149,10 +152,14 @@ fn team(_: &Bench, owner: Entity) -> AnyElement { owner, }; let removed = owner.clone(); - TeamMemberTable::new("team", [member("ada", true), member("bo", false)], roles()) - .on_role(move |key, role, _, cx| say(&owner, format!("{key} {role}"), cx)) - .on_remove(move |key, _, cx| say(&removed, format!("remove {key}"), cx)) - .into_any_element() + TeamMemberTable::new( + "team", + [member("ada", true), member("bo", false)], + roles(), + move |key, role, _, cx| say(&owner, format!("{key} {role}"), cx), + move |key, _, cx| say(&removed, format!("remove {key}"), cx), + ) + .into_any_element() } /// Stops: bo's role, then bo's Remove; the owner has neither. @@ -167,11 +174,12 @@ fn a_member_leaves_on_the_second_press_and_the_owner_stays(cx: &mut TestAppConte fn wall(_: &Bench, owner: Entity) -> AnyElement { let later = owner.clone(); - UpgradePrompt::new("wall", "Unlock history") - .benefit("Every message, kept") - .on_upgrade(move |_, cx| say(&owner, "upgrade".into(), cx)) - .on_dismiss(move |_, cx| say(&later, "later".into(), cx)) - .into_any_element() + UpgradePrompt::new("wall", "Unlock history", move |_, cx| { + say(&owner, "upgrade".into(), cx) + }) + .benefit("Every message, kept") + .on_dismiss(move |_, cx| say(&later, "later".into(), cx)) + .into_any_element() } /// Stops: Upgrade, then Not now. @@ -186,10 +194,11 @@ fn a_paywall_upgrades_or_waits(cx: &mut TestAppContext) { } fn busy_card(_: &Bench, owner: Entity) -> AnyElement { - PaymentMethodForm::new("card") - .busy(true) - .on_submit(move |card, _, cx| say(&owner, card.number.to_string(), cx)) - .into_any_element() + PaymentMethodForm::new("card", move |card, _, cx| { + say(&owner, card.number.to_string(), cx) + }) + .busy(true) + .into_any_element() } /// Stops: number, expiry, code, name; Save rests while the owner checks the card. @@ -354,9 +363,10 @@ fn a_quota_past_its_limit_draws_full(cx: &mut TestAppContext) { } fn line(_: &Bench, owner: Entity) -> AnyElement { - UpgradePrompt::new("line", "Unlock history") - .on_upgrade(move |_, cx| say(&owner, "upgrade".into(), cx)) - .into_any_element() + UpgradePrompt::new("line", "Unlock history", move |_, cx| { + say(&owner, "upgrade".into(), cx) + }) + .into_any_element() } /// With benefits the prompt is a paywall that lists them. @@ -372,3 +382,107 @@ fn a_prompt_without_benefits_stands_as_a_line(cx: &mut TestAppContext) { let (_, cx) = bench(line, cx); assert!(cx.debug_bounds("paywall-benefits").is_none()); } + +/// Accounts, workspaces, a provider and roles that left their lists. +fn departed(_: &Bench, _: Entity) -> AnyElement { + let account = crate::account::Account { + key: "ada".into(), + name: "Ada".into(), + email: "ada@work.com".into(), + image: None, + }; + let space = crate::account::Workspace { + key: "north".into(), + name: "North".into(), + detail: "Pro".into(), + }; + let invitation = Invitation { + key: "bo".into(), + email: "bo@example.com".into(), + role: "gone".into(), + sent: "Sent 2 days ago".into(), + expired: false, + }; + let member = Member { + key: "bo".into(), + name: "Bo".into(), + email: "bo@example.com".into(), + role: "gone".into(), + seen: "Active today".into(), + image: None, + owner: false, + }; + div() + .w(px(360.0)) + .child(crate::account::AccountSwitcher::new( + "accounts", + [account], + "gone", + |_, _, _| {}, + )) + .child(crate::account::WorkspaceSwitcher::new( + "spaces", + [space], + "gone", + |_, _, _| {}, + )) + .child( + crate::account::OAuthButtons::new("oauth", [("github", "GitHub")], |_, _, _| {}) + .busy("gone"), + ) + .child(InvitationList::new( + "invites", + [invitation], + roles(), + |_, _, _, _| {}, + |_, _, _| {}, + |_, _, _| {}, + )) + .child(TeamMemberTable::new( + "team", + [member], + roles(), + |_, _, _, _| {}, + |_, _, _| {}, + )) + .into_any_element() +} + +#[gpui::test] +fn accounts_workspaces_and_roles_that_left_draw(cx: &mut TestAppContext) { + let (_, cx) = bench(departed, cx); + settle(cx); +} + +/// Invitations whose first role leaves once the bench says sent. +fn departing_role(state: &Bench, owner: Entity) -> AnyElement { + let offered: Vec = roles() + .into_iter() + .filter(|role| !state.sent || role.key != "member") + .collect(); + InvitationList::new( + "invites", + [], + offered, + move |email, role, _, cx| say(&owner, format!("invite {email} {role}"), cx), + |_, _, _| {}, + |_, _, _| {}, + ) + .into_any_element() +} + +#[gpui::test] +fn an_invitation_as_a_role_that_left_sends_nothing(cx: &mut TestAppContext) { + let (host, cx) = bench(departing_role, cx); + tab(1, cx); + write("cy@example.com", cx); + host.update(cx, |state, cx| { + state.sent = true; + cx.notify(); + }); + settle(cx); + tap("enter", cx); + tab(3, cx); + tap("space", cx); + assert_eq!(said(&host, cx), Vec::::new()); +} diff --git a/src/account/tests/sign_in.rs b/src/account/tests/sign_in.rs index 966bb55..1c79c84 100644 --- a/src/account/tests/sign_in.rs +++ b/src/account/tests/sign_in.rs @@ -6,16 +6,15 @@ use crate::account::{ }; fn login(_: &Bench, owner: Entity) -> AnyElement { - LoginForm::new("login") - .on_submit(move |login, _, cx| { - say( - &owner, - format!("{} {} {}", login.email, login.password, login.remember), - cx, - ) - }) - .on_forgot(|_, _| {}) - .into_any_element() + LoginForm::new("login", move |login, _, cx| { + say( + &owner, + format!("{} {} {}", login.email, login.password, login.remember), + cx, + ) + }) + .on_forgot(|_, _| {}) + .into_any_element() } /// Stops: email, password, its eye, the box, the forgotten password, Sign in. @@ -41,16 +40,15 @@ fn sign_in_waits_for_an_address_and_a_password_and_enter_sends(cx: &mut TestAppC } fn signup(_: &Bench, owner: Entity) -> AnyElement { - SignupForm::new("signup") - .terms("I agree to the terms") - .on_submit(move |signup, _, cx| { - say( - &owner, - format!("{} {} {}", signup.name, signup.email, signup.password), - cx, - ) - }) - .into_any_element() + SignupForm::new("signup", move |signup, _, cx| { + say( + &owner, + format!("{} {} {}", signup.name, signup.email, signup.password), + cx, + ) + }) + .terms("I agree to the terms") + .into_any_element() } /// Stops: name, email, password, its eye, the terms, then Create account once it holds. @@ -80,8 +78,11 @@ fn sign_up_waits_for_every_rule_and_the_terms(cx: &mut TestAppContext) { } fn providers(bench: &Bench, owner: Entity) -> AnyElement { - let buttons = OAuthButtons::new("oauth", [("github", "GitHub"), ("google", "Google")]) - .on_pick(move |key, _, cx| say(&owner, key.to_string(), cx)); + let buttons = OAuthButtons::new( + "oauth", + [("github", "GitHub"), ("google", "Google")], + move |key, _, cx| say(&owner, key.to_string(), cx), + ); match bench.sent { true => buttons.busy("github").into_any_element(), false => buttons.into_any_element(), @@ -113,19 +114,18 @@ fn a_provider_hands_its_key_and_rests_while_one_is_on_its_way(cx: &mut TestAppCo fn magic(bench: &Bench, owner: Entity) -> AnyElement { let other = owner.clone(); - MagicLinkForm::new("magic") - .sent(bench.sent) - .on_send(move |address, _, cx| { - say(&owner, format!("send {address}"), cx); - owner.update(cx, |bench, _| bench.sent = true); + MagicLinkForm::new("magic", move |address, _, cx| { + say(&owner, format!("send {address}"), cx); + owner.update(cx, |bench, _| bench.sent = true); + }) + .sent(bench.sent) + .on_other(move |_, cx| { + other.update(cx, |bench, cx| { + bench.sent = false; + cx.notify(); }) - .on_other(move |_, cx| { - other.update(cx, |bench, cx| { - bench.sent = false; - cx.notify(); - }) - }) - .into_any_element() + }) + .into_any_element() } /// Stops: email, then the link button; once sent, Send again and Use another email. @@ -151,11 +151,12 @@ fn a_link_goes_to_the_address_and_again_on_request(cx: &mut TestAppContext) { fn forgot(bench: &Bench, owner: Entity) -> AnyElement { let back = owner.clone(); - ForgotPassword::new("forgot") - .sent(bench.sent) - .on_send(move |address, _, cx| say(&owner, format!("reset {address}"), cx)) - .on_back(move |_, cx| say(&back, "back".into(), cx)) - .into_any_element() + ForgotPassword::new("forgot", move |address, _, cx| { + say(&owner, format!("reset {address}"), cx) + }) + .sent(bench.sent) + .on_back(move |_, cx| say(&back, "back".into(), cx)) + .into_any_element() } /// Stops: email, then Back to sign in while the send rests. @@ -172,10 +173,11 @@ fn a_reset_waits_for_an_address_and_offers_the_way_back(cx: &mut TestAppContext) } fn factor(bench: &Bench, owner: Entity) -> AnyElement { - TwoFactorInput::new("factor") - .failed(bench.failed) - .on_code(move |code, _, cx| say(&owner, code.to_string(), cx)) - .into_any_element() + TwoFactorInput::new("factor", move |code, _, cx| { + say(&owner, code.to_string(), cx) + }) + .failed(bench.failed) + .into_any_element() } /// Stops: the code. @@ -207,10 +209,11 @@ fn sign_in_waits_for_a_password(cx: &mut TestAppContext) { } fn busy_login(_: &Bench, owner: Entity) -> AnyElement { - LoginForm::new("login") - .busy(true) - .on_submit(move |login, _, cx| say(&owner, login.email.to_string(), cx)) - .into_any_element() + LoginForm::new("login", move |login, _, cx| { + say(&owner, login.email.to_string(), cx) + }) + .busy(true) + .into_any_element() } /// Stops: email, password, its eye, the box; Sign in rests while busy, so a fifth Tab wraps to the email. @@ -248,12 +251,13 @@ fn sign_up_waits_for_a_name(cx: &mut TestAppContext) { fn busy_sent(_: &Bench, owner: Entity) -> AnyElement { let other = owner.clone(); - MagicLinkForm::new("magic") - .sent(true) - .busy(true) - .on_send(move |address, _, cx| say(&owner, format!("send {address}"), cx)) - .on_other(move |_, cx| say(&other, "other".into(), cx)) - .into_any_element() + MagicLinkForm::new("magic", move |address, _, cx| { + say(&owner, format!("send {address}"), cx) + }) + .sent(true) + .busy(true) + .on_other(move |_, cx| say(&other, "other".into(), cx)) + .into_any_element() } /// While a link is on its way Send again rests, so the first stop is Use another email. diff --git a/src/account/upgrade.rs b/src/account/upgrade.rs index e1c95da..2ec239b 100644 --- a/src/account/upgrade.rs +++ b/src/account/upgrade.rs @@ -20,18 +20,22 @@ pub struct UpgradePrompt { title: SharedString, body: Option, benefits: Vec, - on_upgrade: Option, + on_upgrade: Run, on_dismiss: Option, } impl UpgradePrompt { - pub fn new(id: impl Into, title: impl Into) -> Self { + pub fn new( + id: impl Into, + title: impl Into, + on_upgrade: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { Self { id: id.into(), title: title.into(), body: None, benefits: Vec::new(), - on_upgrade: None, + on_upgrade: Rc::new(on_upgrade), on_dismiss: None, } } @@ -47,11 +51,6 @@ impl UpgradePrompt { self } - pub fn on_upgrade(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { - self.on_upgrade = Some(Rc::new(handler)); - self - } - /// Shows "Not now". pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { self.on_dismiss = Some(Rc::new(handler)); @@ -62,9 +61,7 @@ impl UpgradePrompt { impl RenderOnce for UpgradePrompt { fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let upgrade = self - .on_upgrade - .unwrap_or_else(|| panic!("upgrade prompt {id:?} has no on_upgrade")); + let upgrade = self.on_upgrade; let theme = cx.theme(); let wall = !self.benefits.is_empty(); let actions = div() diff --git a/tasks/panics.md b/tasks/panics.md index b700d91..a29fd4f 100644 --- a/tasks/panics.md +++ b/tasks/panics.md @@ -50,7 +50,7 @@ Each item reads every site in its folders, fixes what the rules name, updates ca - [x] P33 DB & Dev Tools — `src/devtools` - [x] P34 Dashboard — `src/dashboard` - [x] P35 Settings — `src/settings` -- [ ] P36 Account — `src/account` +- [x] P36 Account — `src/account` - [ ] P37 Onboarding — `src/onboarding` - [ ] P38 Interaction — `src/interaction` - [ ] P39 Theme — `src/theme` @@ -101,3 +101,4 @@ One line per item: what changed, what stayed and why, review rounds. - P33: six devtools take their handlers in `new` (contrast checker, environment selector, GraphQL explorer, WebSocket console, structure editor, index manager); `collection::moved` returns `Option` for a gone key or target or a drop into a request; a gone environment shows none chosen; an ER table without a place stays out. Kept: readings, costs, lookups over the components' own tables, button-gated expects, JSON that read writes back. Review: 1 round. - P34: the refresh interval selector, filter bar and dashboard grid take their handlers in `new`; `tiles::arranged` and `stepped` return `Option` for a tile gone from the grid, so a landing or step after the owner removed it moves nothing; tiles without cards and cards without tiles stay out. Kept: columns, cells, intervals offered, services and uptimes, lookups from one render. Review: 1 round. - P35: eighteen settings components take their handlers in `new`; a section or syntax theme not offered marks none; a font size or default outside its range pegs. Kept: options offered by construction, the proxy's Apply behind its read, drops that bring a file. Review: 1 round. +- P36: seventeen account components take their handlers in `new`, the email step's `on_send` with them; a busy provider, a current account or workspace, and a role no longer listed mark none and log; an invitation waits for its role to be offered; an expiry year that overflows reads as unread. Kept: quotas, providers, one current session, roles to invite as, the card behind Save. Review: 2 rounds.