Account · Billing and team: a meter's name gives way, a month past 12 says so

A Meter's name takes the free width and ends in an ellipsis while its
count stays whole, so a long quota name keeps "9 of 12 seats" inside a
280px box. `expiry` says why it refuses a date, and Expires reads
"There is no month 13." apart from "This date has passed.". Tests now
cover an American Express card's fifteen digits and four-digit code,
the address emptying once an invitation goes, a quota past its limit,
and a prompt with benefits standing as a paywall and one without as a
line.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 14:05:34 -04:00
1 parent 431aab594f
commit a2f654044e
7 files changed
+205 -33

No files matched your search

+1
View File
@@ -406,3 +406,4 @@ Per-component lines live in `tasks/`. Tags there:
| T36a | 2 | PASS | | | 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 | 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 | | | 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 |
+1 -1
View File
@@ -59,7 +59,7 @@ pub fn plan(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
UsageQuota::new( UsageQuota::new(
"account-quota", "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("storage", "Storage", 96.5, 100.0, "GB"),
quota("runs", "Automation runs", 1_240.0, 2_000.0, "runs"), quota("runs", "Automation runs", 1_240.0, 2_000.0, "runs"),
], ],
+1 -1
View File
@@ -24,7 +24,7 @@ pub use keys::{ApiKey, ApiKeyManager};
pub use login::{Login, LoginForm}; pub use login::{Login, LoginForm};
pub use mail::{ForgotPassword, MagicLinkForm}; pub use mail::{ForgotPassword, MagicLinkForm};
pub use oauth::OAuthButtons; 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 profile::{Profile, ProfileCard, ProfileEditor};
pub use sessions::{Session, SessionList}; pub use sessions::{Session, SessionList};
pub use signup::{Signup, SignupForm, password_rules}; pub use signup::{Signup, SignupForm, password_rules};
+73 -18
View File
@@ -81,15 +81,46 @@ pub fn luhn(digits: &str) -> bool {
!digits.is_empty() && sum % 10 == 0 !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. /// 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)> { pub fn expiry(text: &str, now: (i32, i8)) -> Result<(i8, i32), ExpiryError> {
let (month, year) = text.split_once('/')?; let (month, year) = text.split_once('/').ok_or(ExpiryError::Unread)?;
let (month, year) = ( let month = month
month.trim().parse::<i8>().ok()?, .trim()
year.trim().parse::<i32>().ok()?, .parse::<i8>()
); .map_err(|_| ExpiryError::Unread)?;
let year = 2000 + year; let year = 2000
((1..=12).contains(&month) && (year, month) >= now).then_some((month, year)) + year
.trim()
.parse::<i32>()
.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<String> {
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. /// A card as its form hands it over.
@@ -165,10 +196,12 @@ impl RenderOnce for PaymentMethodForm {
let complete = typed.len() == length; let complete = typed.len() == length;
let checks = complete && luhn(&typed); let checks = complete && luhn(&typed);
let now = Timestamp::now().to_zoned(system_zone("payment method form")); 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 cvc = digits(code.read(cx).text());
let holder: SharedString = name.read(cx).text().trim().to_string().into(); let holder: SharedString = name.read(cx).text().trim().to_string().into();
let card = expires let card = expires
.ok()
.filter(|_| checks && cvc.len() == code_length && !holder.is_empty()) .filter(|_| checks && cvc.len() == code_length && !holder.is_empty())
.map(|(month, year)| CardDetails { .map(|(month, year)| CardDetails {
brand, brand,
@@ -192,10 +225,9 @@ impl RenderOnce for PaymentMethodForm {
} else if brand != CardBrand::Other { } else if brand != CardBrand::Other {
number_field = number_field.description(brand.name()); 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"); let mut until_field = FormField::new((id.clone(), "expiry-field"), "Expires");
if late { if let Some(fault) = expiry_fault(until.read(cx).text(), today) {
until_field = until_field.error("This date has passed."); until_field = until_field.error(fault);
} }
div() div()
.flex() .flex()
@@ -258,7 +290,7 @@ impl RenderOnce for PaymentMethodForm {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{CardBrand, expiry, luhn}; use super::{CardBrand, ExpiryError, expiry, expiry_fault, luhn};
#[test] #[test]
fn a_number_tells_its_network_and_checks_out() { fn a_number_tells_its_network_and_checks_out() {
@@ -279,12 +311,35 @@ mod tests {
let now = (2026, 9); let now = (2026, 9);
assert_eq!( assert_eq!(
expiry("09/26", now), expiry("09/26", now),
Some((9, 2026)), Ok((9, 2026)),
"this month still counts" "this month still counts"
); );
assert_eq!(expiry("12/31", now), Some((12, 2031))); assert_eq!(expiry("12/31", now), Ok((12, 2031)));
assert_eq!(expiry("08/26", now), None, "last month has passed"); assert_eq!(expiry("08/26", now), Err(ExpiryError::Passed));
assert_eq!(expiry("13/30", now), None, "there is no thirteenth month"); assert_eq!(expiry("13/30", now), Err(ExpiryError::NoMonth(13)));
assert_eq!(expiry("1230", now), None); 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"
);
} }
} }
+102 -5
View File
@@ -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 super::{Bench, bench, said, say, settle, tab, tap, write};
use crate::account::{ use crate::account::{
BillingHistory, Invitation, InvitationList, Invoice, InvoiceState, Member, PaymentMethodForm, 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<Bench>) -> AnyElement { fn plan(bench: &Bench, owner: Entity<Bench>) -> AnyElement {
@@ -55,11 +56,12 @@ fn card(_: &Bench, owner: Entity<Bench>) -> AnyElement {
say( say(
&owner, &owner,
format!( format!(
"{} {} {}/{} {}", "{} {} {}/{} {} {}",
card.brand.name(), card.brand.name(),
card.number, card.number,
card.month, card.month,
card.year, card.year,
card.cvc,
card.name card.name
), ),
cx, cx,
@@ -92,7 +94,7 @@ fn a_card_saves_once_its_number_checks_out(cx: &mut TestAppContext) {
tap("enter", cx); tap("enter", cx);
assert_eq!( assert_eq!(
said(&host, cx), 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"); assert!(said(&host, cx).is_empty(), "no name on the card");
write("Ada", cx); write("Ada", cx);
tap("enter", 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<Bench>) -> 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<Bench>) -> 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<Bench>) -> 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());
} }
+10 -3
View File
@@ -1,8 +1,8 @@
use std::rc::Rc; use std::rc::Rc;
use gpui::{ use gpui::{
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, RenderOnce,
Window, div, SharedString, Styled, Window, div,
}; };
use super::login::Run; use super::login::Run;
@@ -121,7 +121,14 @@ impl RenderOnce for UpgradePrompt {
) )
.child(words), .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), .child(actions),
false => div() false => div()
.flex() .flex()
+17 -5
View File
@@ -4,14 +4,14 @@ use std::{
}; };
use gpui::{ use gpui::{
Animation, AnimationExt, App, ElementId, FontWeight, Hsla, IntoElement, ParentElement, Animation, AnimationExt, App, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement,
RenderOnce, SharedString, Styled, Window, canvas, div, prelude::*, relative, ParentElement, RenderOnce, SharedString, Styled, Window, canvas, div, prelude::*, relative,
}; };
use crate::{ use crate::{
motion, motion,
theme::{ActiveTheme, Palette, TextSize}, 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`. /// Where a filled share stands against a meter's thresholds; it indexes `tones`.
@@ -135,8 +135,20 @@ impl RenderOnce for Meter {
.justify_between() .justify_between()
.gap_3() .gap_3()
.text_size(theme.text_size(TextSize::Sm)) .text_size(theme.text_size(TextSize::Sm))
.child(div().text_color(colors.fg).child(self.label)) .child(
.child(tabular(div()).text_color(colors.fg_muted).child(detail)); 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 limits = self.thresholds;
let bar = gliding(self.id, self.share, window, cx, move |at| { let bar = gliding(self.id, self.share, window, cx, move |at| {
div().relative().h(track).rounded_full().bg(lane).child( div().relative().h(track).rounded_full().bg(lane).child(