T10b feedback: error views with 404, 403, maintenance and offline, error boundary, drawn results, confirmation card, saving, sync and connection lines, countdown and timer with rolling digits
This commit is contained in:
24 files changed
+1806
-173
No files matched your search
@@ -0,0 +1,255 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use gpui::{
|
||||
AppContext as _, Context, Entity, FocusHandle, IntoElement, KeyBinding, KeyUpEvent, Keystroke,
|
||||
Modifiers, ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, point, px,
|
||||
};
|
||||
|
||||
mod states;
|
||||
|
||||
use super::{Alert, Notification, NotificationCenter, Toast, ToastViewport, Toaster};
|
||||
use crate::{
|
||||
primitives::{FocusNext, FocusScope, IconName, Severity},
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
/// A toast stack, an alert and a notification center, recording what they do.
|
||||
struct Desk {
|
||||
root: FocusHandle,
|
||||
toaster: Entity<Toaster>,
|
||||
unread: bool,
|
||||
log: Vec<String>,
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
fn note(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
what: &'static str,
|
||||
) -> impl Fn(&mut Window, &mut gpui::App) + 'static + use<> {
|
||||
let view = cx.entity();
|
||||
move |_, cx| view.update(cx, |desk, _| desk.log.push(what.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Desk {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
FocusScope::new(&self.root)
|
||||
.root()
|
||||
.size_full()
|
||||
.child(ToastViewport::new("toasts", &self.toaster))
|
||||
.child(
|
||||
Alert::new("alert", Severity::Warning, "Heads up")
|
||||
.on_dismiss(self.note(cx, "alert")),
|
||||
)
|
||||
.child(
|
||||
NotificationCenter::new("center")
|
||||
.group(
|
||||
"Today",
|
||||
[Notification::new("n", IconName::Bell, "Hello").unread(self.unread)],
|
||||
)
|
||||
.on_read_all(self.note(cx, "read all"))
|
||||
.on_clear(self.note(cx, "clear")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme with reduced motion, text keys and Tab.
|
||||
fn setup(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
crate::forms::bind_keys(cx);
|
||||
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
}
|
||||
|
||||
fn desk(cx: &mut TestAppContext) -> (Entity<Desk>, &mut VisualTestContext) {
|
||||
setup(cx);
|
||||
let (view, cx) = cx.add_window_view(|_, cx| Desk {
|
||||
root: cx.focus_handle(),
|
||||
toaster: cx.new(|_| Toaster::default()),
|
||||
unread: true,
|
||||
log: Vec::new(),
|
||||
});
|
||||
settle(cx);
|
||||
let root = view.read_with(cx, |desk, _| desk.root.clone());
|
||||
cx.update(|window, _| window.focus(&root));
|
||||
(view, cx)
|
||||
}
|
||||
|
||||
fn settle(cx: &mut VisualTestContext) {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
settle(cx);
|
||||
cx.simulate_event(KeyUpEvent {
|
||||
keystroke: Keystroke::parse(key).expect("a key"),
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
/// Toasts paint last, so their buttons follow the alert's and the center's two in Tab order.
|
||||
fn tab_to_toast(cx: &mut VisualTestContext) {
|
||||
for _ in 0..4 {
|
||||
press("tab", cx);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait(time: Duration, cx: &mut VisualTestContext) {
|
||||
cx.executor().advance_clock(time);
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn push(view: &Entity<Desk>, toast: Toast, cx: &mut VisualTestContext) -> u64 {
|
||||
let toaster = view.read_with(cx, |desk, _| desk.toaster.clone());
|
||||
let id = toaster.update(cx, |toaster, cx| toaster.push(toast, cx));
|
||||
settle(cx);
|
||||
id
|
||||
}
|
||||
|
||||
/// Each toast's id, and whether it is on its way out.
|
||||
fn toasts(view: &Entity<Desk>, cx: &mut VisualTestContext) -> Vec<(u64, bool)> {
|
||||
let toaster = view.read_with(cx, |desk, _| desk.toaster.clone());
|
||||
toaster.read_with(cx, |toaster, _| {
|
||||
toaster
|
||||
.live
|
||||
.iter()
|
||||
.map(|live| (live.id, live.leaving))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn log(view: &Entity<Desk>, cx: &mut VisualTestContext) -> Vec<String> {
|
||||
view.read_with(cx, |desk, _| desk.log.clone())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_toast_folds_away_after_its_time(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
let id = push(&view, Toast::new("Saved"), cx);
|
||||
wait(Duration::from_millis(4900), cx);
|
||||
assert_eq!(toasts(&view, cx), [(id, false)]);
|
||||
wait(Duration::from_millis(200), cx);
|
||||
assert!(
|
||||
toasts(&view, cx).is_empty(),
|
||||
"reduced motion folds it at once"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_pointer_on_the_stack_holds_the_clock(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
let id = push(&view, Toast::new("Saved"), cx);
|
||||
wait(Duration::from_secs(3), cx);
|
||||
let screen = cx.update(|window, _| window.viewport_size());
|
||||
let on_toast = point(screen.width - px(120.0), screen.height - px(48.0));
|
||||
cx.simulate_mouse_move(on_toast, None, Modifiers::none());
|
||||
settle(cx);
|
||||
wait(Duration::from_secs(20), cx);
|
||||
assert_eq!(toasts(&view, cx), [(id, false)], "held while pointed at");
|
||||
cx.simulate_mouse_move(point(px(4.0), px(4.0)), None, Modifiers::none());
|
||||
settle(cx);
|
||||
wait(Duration::from_millis(1900), cx);
|
||||
assert_eq!(toasts(&view, cx), [(id, false)], "two seconds were left");
|
||||
wait(Duration::from_millis(200), cx);
|
||||
assert!(toasts(&view, cx).is_empty());
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_fifth_toast_sends_the_oldest_away(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
let ids: Vec<u64> = (0..5).map(|_| push(&view, Toast::new("Hi"), cx)).collect();
|
||||
let leaving: Vec<u64> = toasts(&view, cx)
|
||||
.into_iter()
|
||||
.filter(|(_, out)| *out)
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
assert_eq!(leaving, [ids[0]]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_toast_without_a_time_stays(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
let id = push(&view, Toast::new("Offline").stay(None), cx);
|
||||
wait(Duration::from_secs(120), cx);
|
||||
assert_eq!(toasts(&view, cx), [(id, false)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn undo_runs_then_sends_its_toast_away(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
let undo = view.update(cx, |desk, cx| desk.note(cx, "undo"));
|
||||
let id = push(&view, Toast::new("Deleted").undo(undo), cx);
|
||||
tab_to_toast(cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(log(&view, cx), ["undo"]);
|
||||
assert_eq!(toasts(&view, cx), [(id, true)]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_alert_closes_from_its_button(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(log(&view, cx), ["alert"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn mark_all_read_shows_only_with_something_unread(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
press("tab", cx);
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(log(&view, cx), ["read all"]);
|
||||
view.update(cx, |desk, cx| {
|
||||
desk.unread = false;
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
press("tab", cx);
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
log(&view, cx),
|
||||
["read all", "clear"],
|
||||
"the root took focus back, Clear is second"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_toast_gone_under_focus_hands_it_to_the_root(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
let undo = view.update(cx, |desk, cx| desk.note(cx, "undo"));
|
||||
push(&view, Toast::new("Deleted").undo(undo), cx);
|
||||
tab_to_toast(cx);
|
||||
press("enter", cx);
|
||||
wait(Duration::from_millis(300), cx);
|
||||
assert!(toasts(&view, cx).is_empty());
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(log(&view, cx), ["undo", "alert"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_leaving_toast_runs_its_undo_once(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
cx.update(|_, cx| Theme::update(cx, |theme| theme.reduced_motion = false));
|
||||
let undo = view.update(cx, |desk, cx| desk.note(cx, "undo"));
|
||||
push(&view, Toast::new("Deleted").undo(undo), cx);
|
||||
// Animations run on the wall clock; let the toast finish rising.
|
||||
std::thread::sleep(Duration::from_millis(400));
|
||||
settle(cx);
|
||||
let screen = cx.update(|window, _| window.viewport_size());
|
||||
let on_undo = point(screen.width - px(78.0), screen.height - px(49.0));
|
||||
for _ in 0..2 {
|
||||
cx.simulate_mouse_move(on_undo, None, Modifiers::none());
|
||||
cx.simulate_click(on_undo, Modifiers::none());
|
||||
settle(cx);
|
||||
}
|
||||
assert_eq!(log(&view, cx), ["undo"]);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::anyhow;
|
||||
use gpui::{
|
||||
Context, Entity, FocusHandle, IntoElement, ParentElement, Pixels, Render, Styled,
|
||||
TestAppContext, VisualTestContext, Window, div, px,
|
||||
};
|
||||
|
||||
use super::{press, settle, setup, wait};
|
||||
use crate::{
|
||||
buttons::Button,
|
||||
feedback::{
|
||||
ConfirmationCard, Countdown, EmptyState, ErrorBoundary, SaveState, SavingIndicator,
|
||||
},
|
||||
primitives::{FocusScope, IconName, Measure},
|
||||
};
|
||||
|
||||
/// A boundary, a confirmation card, a saving line and a countdown, recording what they do.
|
||||
struct Shelf {
|
||||
root: FocusHandle,
|
||||
broken: bool,
|
||||
save: SaveState,
|
||||
until: Instant,
|
||||
log: Vec<String>,
|
||||
}
|
||||
|
||||
impl Shelf {
|
||||
fn note(
|
||||
&self,
|
||||
cx: &mut Context<Self>,
|
||||
what: &'static str,
|
||||
) -> impl Fn(&mut Window, &mut gpui::App) + 'static + use<> {
|
||||
let view = cx.entity();
|
||||
move |_, cx| view.update(cx, |shelf, _| shelf.log.push(what.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Shelf {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let content = if self.broken {
|
||||
Err(anyhow!("the service answered 503"))
|
||||
} else {
|
||||
Ok(div().child("fine"))
|
||||
};
|
||||
FocusScope::new(&self.root)
|
||||
.root()
|
||||
.size_full()
|
||||
.child(ErrorBoundary::new("boundary", content).on_retry(self.note(cx, "retry")))
|
||||
.child(
|
||||
ConfirmationCard::new("card", "Sure?")
|
||||
.on_cancel(self.note(cx, "cancel"))
|
||||
.on_confirm(self.note(cx, "confirm")),
|
||||
)
|
||||
.child(SavingIndicator::new("saving", self.save).on_retry(self.note(cx, "save again")))
|
||||
.child(Countdown::new("countdown", self.until).on_done(self.note(cx, "done")))
|
||||
}
|
||||
}
|
||||
|
||||
fn shelf(
|
||||
broken: bool,
|
||||
save: SaveState,
|
||||
cx: &mut TestAppContext,
|
||||
) -> (Entity<Shelf>, &mut VisualTestContext) {
|
||||
setup(cx);
|
||||
let (view, cx) = cx.add_window_view(|_, cx| Shelf {
|
||||
root: cx.focus_handle(),
|
||||
broken,
|
||||
save,
|
||||
until: cx.background_executor().now() + Duration::from_secs(3),
|
||||
log: Vec::new(),
|
||||
});
|
||||
settle(cx);
|
||||
let root = view.read_with(cx, |shelf, _| shelf.root.clone());
|
||||
cx.update(|window, _| window.focus(&root));
|
||||
(view, cx)
|
||||
}
|
||||
|
||||
fn log(view: &Entity<Shelf>, cx: &mut VisualTestContext) -> Vec<String> {
|
||||
view.read_with(cx, |shelf, _| shelf.log.clone())
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_broken_boundary_offers_to_try_again(cx: &mut TestAppContext) {
|
||||
let (view, cx) = shelf(true, SaveState::Saved, cx);
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(log(&view, cx), ["retry"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_card_cancels_and_confirms(cx: &mut TestAppContext) {
|
||||
let (view, cx) = shelf(false, SaveState::Saved, cx);
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
press("tab", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(log(&view, cx), ["cancel", "confirm"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn retry_shows_only_once_saving_failed(cx: &mut TestAppContext) {
|
||||
let (view, cx) = shelf(false, SaveState::Failed, cx);
|
||||
for _ in 0..3 {
|
||||
press("tab", cx);
|
||||
}
|
||||
press("enter", cx);
|
||||
assert_eq!(log(&view, cx), ["save again"]);
|
||||
view.update(cx, |shelf, cx| {
|
||||
shelf.save = SaveState::Saved;
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
let root = view.read_with(cx, |shelf, _| shelf.root.clone());
|
||||
cx.update(|window, _| window.focus(&root));
|
||||
for _ in 0..3 {
|
||||
press("tab", cx);
|
||||
}
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
log(&view, cx),
|
||||
["save again", "cancel"],
|
||||
"Tab wrapped past a saved line"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_countdown_runs_on_done_once_at_zero(cx: &mut TestAppContext) {
|
||||
let (view, cx) = shelf(false, SaveState::Saved, cx);
|
||||
wait(Duration::from_millis(2_900), cx);
|
||||
assert!(log(&view, cx).is_empty());
|
||||
wait(Duration::from_millis(200), cx);
|
||||
assert_eq!(log(&view, cx), ["done"]);
|
||||
wait(Duration::from_secs(5), cx);
|
||||
assert_eq!(log(&view, cx), ["done"]);
|
||||
}
|
||||
|
||||
/// An empty state in a narrow column, and the height it took.
|
||||
struct Column {
|
||||
body: &'static str,
|
||||
height: Pixels,
|
||||
}
|
||||
|
||||
impl Render for Column {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let view = cx.entity();
|
||||
div().w(px(420.0)).child(
|
||||
Measure::new("column", move |bounds, _, cx| {
|
||||
view.update(cx, |column, _| column.height = bounds.size.height)
|
||||
})
|
||||
.child(
|
||||
EmptyState::new("empty", IconName::Inbox, "Nothing yet")
|
||||
.body(self.body)
|
||||
.action(Button::new("act", "Act")),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn column_height(body: &'static str, cx: &mut TestAppContext) -> Pixels {
|
||||
setup(cx);
|
||||
let (view, cx) = cx.add_window_view(|_, _| Column {
|
||||
body,
|
||||
height: Pixels::ZERO,
|
||||
});
|
||||
settle(cx);
|
||||
view.read_with(cx, |column, _| column.height)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_wrapped_body_pushes_the_actions_down(cx: &mut TestAppContext) {
|
||||
let short = column_height("One line.", cx);
|
||||
let long = column_height(
|
||||
"Many words that cannot fit on one line of a narrow column, so they wrap onto a second, a third and a fourth line.",
|
||||
cx,
|
||||
);
|
||||
assert!(long > short + px(24.0), "{short:?} then {long:?}");
|
||||
}
|
||||
Reference in new issue
Block a user