diff --git a/TASKS.md b/TASKS.md index 9253d33..24b65c0 100644 --- a/TASKS.md +++ b/TASKS.md @@ -406,3 +406,4 @@ Per-component lines live in `tasks/`. Tags there: | T36a | 2 | PASS | | | T36b | 1 | FAIL | a long key name hid its ends at 280px; the secret and ends skipped typography::literal; this device's place first and the name clearing after Create untested | | T36b | 2 | PASS | | +| T36c | 1 | FAIL | a long quota name pushed its count past 280px; a month past 12 read as passed; the Amex shape, the address clearing, the clamped share and the paywall's benefits untested | diff --git a/examples/gallery/pages/account/billing.rs b/examples/gallery/pages/account/billing.rs index a215b77..e6efdae 100644 --- a/examples/gallery/pages/account/billing.rs +++ b/examples/gallery/pages/account/billing.rs @@ -59,7 +59,7 @@ pub fn plan(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { UsageQuota::new( "account-quota", [ - quota("seats", "Seats", 9.0, 12.0, "seats"), + quota("seats", "Seats across every workspace you own", 9.0, 12.0, "seats"), quota("storage", "Storage", 96.5, 100.0, "GB"), quota("runs", "Automation runs", 1_240.0, 2_000.0, "runs"), ], diff --git a/src/account/mod.rs b/src/account/mod.rs index b06e316..058db5a 100644 --- a/src/account/mod.rs +++ b/src/account/mod.rs @@ -24,7 +24,7 @@ pub use keys::{ApiKey, ApiKeyManager}; pub use login::{Login, LoginForm}; pub use mail::{ForgotPassword, MagicLinkForm}; pub use oauth::OAuthButtons; -pub use payment::{CardBrand, CardDetails, PaymentMethodForm, expiry, luhn}; +pub use payment::{CardBrand, CardDetails, ExpiryError, PaymentMethodForm, expiry, luhn}; pub use profile::{Profile, ProfileCard, ProfileEditor}; pub use sessions::{Session, SessionList}; pub use signup::{Signup, SignupForm, password_rules}; diff --git a/src/account/payment.rs b/src/account/payment.rs index 796131b..1dec309 100644 --- a/src/account/payment.rs +++ b/src/account/payment.rs @@ -81,15 +81,46 @@ pub fn luhn(digits: &str) -> bool { !digits.is_empty() && sum % 10 == 0 } +/// Why an expiry cannot be used. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExpiryError { + /// Not written "MM/YY". + Unread, + NoMonth(i8), + Passed, +} + /// The month and year of an expiry written "MM/YY", when it reads and has not passed `now`, a year and month. -pub fn expiry(text: &str, now: (i32, i8)) -> Option<(i8, i32)> { - let (month, year) = text.split_once('/')?; - let (month, year) = ( - month.trim().parse::().ok()?, - year.trim().parse::().ok()?, - ); - let year = 2000 + year; - ((1..=12).contains(&month) && (year, month) >= now).then_some((month, year)) +pub fn expiry(text: &str, now: (i32, i8)) -> Result<(i8, i32), ExpiryError> { + let (month, year) = text.split_once('/').ok_or(ExpiryError::Unread)?; + let month = month + .trim() + .parse::() + .map_err(|_| ExpiryError::Unread)?; + let year = 2000 + + year + .trim() + .parse::() + .map_err(|_| ExpiryError::Unread)?; + if !(1..=12).contains(&month) { + return Err(ExpiryError::NoMonth(month)); + } + if (year, month) < now { + return Err(ExpiryError::Passed); + } + Ok((month, year)) +} + +/// What Expires says under a whole date it refuses. +fn expiry_fault(text: &str, now: (i32, i8)) -> Option { + if text.len() != "MM/YY".len() { + return None; + } + match expiry(text, now) { + Err(ExpiryError::NoMonth(month)) => Some(format!("There is no month {month:02}.")), + Err(ExpiryError::Passed) => Some("This date has passed.".into()), + Err(ExpiryError::Unread) | Ok(_) => None, + } } /// A card as its form hands it over. @@ -165,10 +196,12 @@ impl RenderOnce for PaymentMethodForm { let complete = typed.len() == length; let checks = complete && luhn(&typed); let now = Timestamp::now().to_zoned(system_zone("payment method form")); - let expires = expiry(until.read(cx).text(), (now.year() as i32, now.month())); + let today = (now.year() as i32, now.month()); + let expires = expiry(until.read(cx).text(), today); let cvc = digits(code.read(cx).text()); let holder: SharedString = name.read(cx).text().trim().to_string().into(); let card = expires + .ok() .filter(|_| checks && cvc.len() == code_length && !holder.is_empty()) .map(|(month, year)| CardDetails { brand, @@ -192,10 +225,9 @@ impl RenderOnce for PaymentMethodForm { } else if brand != CardBrand::Other { number_field = number_field.description(brand.name()); } - let late = until.read(cx).text().len() == "MM/YY".len() && expires.is_none(); let mut until_field = FormField::new((id.clone(), "expiry-field"), "Expires"); - if late { - until_field = until_field.error("This date has passed."); + if let Some(fault) = expiry_fault(until.read(cx).text(), today) { + until_field = until_field.error(fault); } div() .flex() @@ -258,7 +290,7 @@ impl RenderOnce for PaymentMethodForm { #[cfg(test)] mod tests { - use super::{CardBrand, expiry, luhn}; + use super::{CardBrand, ExpiryError, expiry, expiry_fault, luhn}; #[test] fn a_number_tells_its_network_and_checks_out() { @@ -279,12 +311,35 @@ mod tests { let now = (2026, 9); assert_eq!( expiry("09/26", now), - Some((9, 2026)), + Ok((9, 2026)), "this month still counts" ); - assert_eq!(expiry("12/31", now), Some((12, 2031))); - assert_eq!(expiry("08/26", now), None, "last month has passed"); - assert_eq!(expiry("13/30", now), None, "there is no thirteenth month"); - assert_eq!(expiry("1230", now), None); + assert_eq!(expiry("12/31", now), Ok((12, 2031))); + 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)); + } + + #[test] + fn a_whole_expiry_says_why_it_is_refused() { + let now = (2026, 9); + assert_eq!( + expiry_fault("13/30", now).as_deref(), + Some("There is no month 13.") + ); + assert_eq!( + expiry_fault("00/30", now).as_deref(), + Some("There is no month 00.") + ); + assert_eq!( + expiry_fault("08/26", now).as_deref(), + Some("This date has passed.") + ); + assert_eq!(expiry_fault("09/26", now), None); + assert_eq!( + expiry_fault("13/3", now), + None, + "a date still being typed says nothing" + ); } } diff --git a/src/account/tests/billing.rs b/src/account/tests/billing.rs index 69e6d1a..fc346a7 100644 --- a/src/account/tests/billing.rs +++ b/src/account/tests/billing.rs @@ -1,9 +1,10 @@ -use gpui::{AnyElement, Entity, IntoElement, TestAppContext}; +use gpui::{AnyElement, Entity, IntoElement, ParentElement, Styled, TestAppContext, div, px}; use super::{Bench, bench, said, say, settle, tab, tap, write}; use crate::account::{ BillingHistory, Invitation, InvitationList, Invoice, InvoiceState, Member, PaymentMethodForm, - Role, Standing, Subscription, SubscriptionCard, TeamMemberTable, UpgradePrompt, + Quota, Role, Standing, Subscription, SubscriptionCard, TeamMemberTable, UpgradePrompt, + UsageQuota, }; fn plan(bench: &Bench, owner: Entity) -> AnyElement { @@ -55,11 +56,12 @@ fn card(_: &Bench, owner: Entity) -> AnyElement { say( &owner, format!( - "{} {} {}/{} {}", + "{} {} {}/{} {} {}", card.brand.name(), card.number, card.month, card.year, + card.cvc, card.name ), cx, @@ -92,7 +94,7 @@ fn a_card_saves_once_its_number_checks_out(cx: &mut TestAppContext) { tap("enter", cx); assert_eq!( said(&host, cx), - ["Visa 4242424242424242 12/2040 Ada Lovelace"] + ["Visa 4242424242424242 12/2040 123 Ada Lovelace"] ); } @@ -273,5 +275,100 @@ fn a_card_waits_for_a_whole_code_and_a_name(cx: &mut TestAppContext) { assert!(said(&host, cx).is_empty(), "no name on the card"); write("Ada", cx); tap("enter", cx); - assert_eq!(said(&host, cx), ["Visa 4242424242424242 12/2040 Ada"]); + assert_eq!(said(&host, cx), ["Visa 4242424242424242 12/2040 123 Ada"]); +} + +/// Stops: number, expiry, code, name; an American Express number takes fifteen digits and a code of four. +#[gpui::test] +fn an_american_express_card_takes_its_own_shape(cx: &mut TestAppContext) { + let (host, cx) = bench(card, cx); + tab(1, cx); + write("378282246310005", cx); + tab(2, cx); + write("1240", cx); + tab(3, cx); + write("1234", cx); + tab(4, cx); + write("Ada", cx); + tap("enter", cx); + assert_eq!( + said(&host, cx), + ["American Express 378282246310005 12/2040 1234 Ada"] + ); +} + +/// Stops: the address, the role, then Invite once the address reads; after it goes, Invite rests and the third stop is Resend. +#[gpui::test] +fn an_invitation_empties_the_address_once_it_goes(cx: &mut TestAppContext) { + let (host, cx) = bench(invites, cx); + tab(1, cx); + write("cy@example.com", cx); + tap("enter", cx); + tab(3, cx); + tap("space", cx); + assert_eq!( + said(&host, cx), + ["invite cy@example.com member", "resend bo"] + ); +} + +/// One quota in a 280px box. +fn usage(name: &'static str, used: f64, limit: f64) -> AnyElement { + let quota = Quota { + key: "seats".into(), + name: name.into(), + used, + limit, + unit: "seats".into(), + }; + div() + .w(px(280.0)) + .child(UsageQuota::new("usage", [quota])) + .into_any_element() +} + +fn long_name(_: &Bench, _: Entity) -> AnyElement { + usage("Seats across every workspace you own", 9.0, 12.0) +} + +/// A long name gives way, so the count stays whole inside a 280px box. +#[gpui::test] +fn a_long_quota_name_leaves_its_count_inside_the_box(cx: &mut TestAppContext) { + let (_, cx) = bench(long_name, cx); + let count = cx.debug_bounds("meter-detail").expect("the count"); + assert!( + count.right() <= px(280.0), + "the count ends inside the box: {count:?}" + ); +} + +fn past_limit(_: &Bench, _: Entity) -> AnyElement { + usage("Seats", 12.4, 10.0) +} + +/// A quota past its limit draws a full bar and says how far past. +#[gpui::test] +fn a_quota_past_its_limit_draws_full(cx: &mut TestAppContext) { + let (_, cx) = bench(past_limit, cx); + assert!(cx.debug_bounds("meter-detail").is_some(), "the quota drew"); +} + +fn line(_: &Bench, owner: Entity) -> AnyElement { + UpgradePrompt::new("line", "Unlock history") + .on_upgrade(move |_, cx| say(&owner, "upgrade".into(), cx)) + .into_any_element() +} + +/// With benefits the prompt is a paywall that lists them. +#[gpui::test] +fn a_paywall_lists_its_benefits(cx: &mut TestAppContext) { + let (_, cx) = bench(wall, cx); + assert!(cx.debug_bounds("paywall-benefits").is_some()); +} + +/// Without benefits the prompt stands as one line, with no list. +#[gpui::test] +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()); } diff --git a/src/account/upgrade.rs b/src/account/upgrade.rs index df1bf23..e1c95da 100644 --- a/src/account/upgrade.rs +++ b/src/account/upgrade.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use gpui::{ - App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, - Window, div, + App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Window, div, }; use super::login::Run; @@ -121,7 +121,14 @@ impl RenderOnce for UpgradePrompt { ) .child(words), ) - .child(div().flex().flex_col().gap_1p5().children(benefits)) + .child( + div() + .debug_selector(|| "paywall-benefits".into()) + .flex() + .flex_col() + .gap_1p5() + .children(benefits), + ) .child(actions), false => div() .flex() diff --git a/src/data_display/measures.rs b/src/data_display/measures.rs index 2ee9283..b39dbc8 100644 --- a/src/data_display/measures.rs +++ b/src/data_display/measures.rs @@ -4,14 +4,14 @@ use std::{ }; use gpui::{ - Animation, AnimationExt, App, ElementId, FontWeight, Hsla, IntoElement, ParentElement, - RenderOnce, SharedString, Styled, Window, canvas, div, prelude::*, relative, + Animation, AnimationExt, App, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, + ParentElement, RenderOnce, SharedString, Styled, Window, canvas, div, prelude::*, relative, }; use crate::{ motion, theme::{ActiveTheme, Palette, TextSize}, - typography::{AnimatedNumber, format, tabular}, + typography::{AnimatedNumber, Ellipsis, format, tabular}, }; /// Where a filled share stands against a meter's thresholds; it indexes `tones`. @@ -135,8 +135,20 @@ impl RenderOnce for Meter { .justify_between() .gap_3() .text_size(theme.text_size(TextSize::Sm)) - .child(div().text_color(colors.fg).child(self.label)) - .child(tabular(div()).text_color(colors.fg_muted).child(detail)); + .child( + div() + .flex_1() + .min_w_0() + .text_color(colors.fg) + .child(Ellipsis::new(self.label)), + ) + .child( + tabular(div()) + .debug_selector(|| "meter-detail".into()) + .flex_none() + .text_color(colors.fg_muted) + .child(detail), + ); let limits = self.thresholds; let bar = gliding(self.id, self.share, window, cx, move |at| { div().relative().h(track).rounded_full().bg(lane).child(