Skip to content

Commit a763187

Browse files
committed
Added forms-signup.rs
Also renamed login.rs to forms-login.rs
1 parent e16ef0f commit a763187

File tree

2 files changed

+354
-1
lines changed

2 files changed

+354
-1
lines changed

examples/login.rs renamed to examples/forms-login.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,6 @@ fn main() -> cushy::Result {
7777
.contain()
7878
.width(Lp::inches(3)..Lp::inches(6))
7979
.pad()
80-
.scroll()
8180
.centered();
8281

8382
ui.and(tooltips).into_layers().run()

examples/forms-signup.rs

Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
use std::process::exit;
2+
use std::thread;
3+
use std::time::Duration;
4+
5+
use cushy::figures::units::Lp;
6+
use cushy::reactive::channel;
7+
use cushy::reactive::value::{Destination, Dynamic, MapEach, Source, Switchable, Validations};
8+
use cushy::widget::MakeWidget;
9+
use cushy::widgets::input::{InputValue, MaskedString};
10+
use cushy::widgets::layers::{Modal, OverlayLayer};
11+
use cushy::widgets::{Expand, ProgressBar, Validated};
12+
use cushy::Run;
13+
use kempt::Map;
14+
15+
#[derive(Default, PartialEq)]
16+
enum AppState {
17+
#[default]
18+
NewUser,
19+
LoggedIn {
20+
username: String,
21+
},
22+
}
23+
24+
fn main() -> cushy::Result {
25+
let app_state = Dynamic::<AppState>::default();
26+
let tooltips = OverlayLayer::default();
27+
let modals = Modal::new();
28+
29+
// This example switches between a new user form and a screen once a user
30+
// has signed up successfully. The api service is simulated using a
31+
// background task.
32+
let api = channel::build().on_receive(fake_service).finish();
33+
34+
let ui = app_state.switcher({
35+
let tooltips = tooltips.clone();
36+
let modals = modals.clone();
37+
move |current_state, app_state| match current_state {
38+
AppState::NewUser => signup_form(&tooltips, &modals, app_state, &api).make_widget(),
39+
AppState::LoggedIn { username } => logged_in(username, app_state).make_widget(),
40+
}
41+
});
42+
43+
ui.and(tooltips).and(modals).into_layers().run()
44+
}
45+
46+
#[derive(Default, PartialEq)]
47+
enum NewUserState {
48+
#[default]
49+
FormEntry,
50+
SigningUp,
51+
Done,
52+
}
53+
54+
fn signup_form(
55+
tooltips: &OverlayLayer,
56+
modals: &Modal,
57+
app_state: &Dynamic<AppState>,
58+
api: &channel::Sender<FakeApiRequest>,
59+
) -> impl MakeWidget {
60+
let form_state = Dynamic::<NewUserState>::default();
61+
let username = Dynamic::<String>::default();
62+
let password = Dynamic::<MaskedString>::default();
63+
let password_confirmation = Dynamic::<MaskedString>::default();
64+
let validations = Validations::default();
65+
66+
// A network request can take time, so rather than waiting on the API call
67+
// once we are ready to submit the form, we delegate the login process to a
68+
// background task using a channel.
69+
let api_errors = Dynamic::default();
70+
let login_handler = channel::build()
71+
.on_receive({
72+
let form_state = form_state.clone();
73+
let app_state = app_state.clone();
74+
let api = api.clone();
75+
let api_errors = api_errors.clone();
76+
move |(username, password)| {
77+
handle_login(
78+
username,
79+
password,
80+
&api,
81+
&app_state,
82+
&form_state,
83+
&api_errors,
84+
);
85+
}
86+
})
87+
.finish();
88+
89+
// When we are processing a signup request, we should display a modal with a
90+
// spinner so that the user can't edit the form or click the sign in button
91+
// again.
92+
let signup_modal = modals.new_handle();
93+
form_state
94+
.for_each(move |state| match state {
95+
NewUserState::FormEntry { .. } | NewUserState::Done => signup_modal.dismiss(),
96+
NewUserState::SigningUp => {
97+
signup_modal.present(
98+
"SIgning Up"
99+
.and(ProgressBar::indeterminant().spinner().centered())
100+
.into_rows()
101+
.pad()
102+
.centered()
103+
.contain(),
104+
);
105+
}
106+
})
107+
.persist();
108+
109+
// We use a helper in this file `validated_field` to combine our validation
110+
// callback and any error returned from the API for this field.
111+
let username_field = "Username"
112+
.and(
113+
validated_field(SignupField::Username, username
114+
.to_input()
115+
.placeholder("Username"), &username, &validations, &api_errors, |username| {
116+
if username.is_empty() {
117+
Err(String::from(
118+
"usernames must contain at least one character",
119+
))
120+
} else if username.chars().any(|ch| !ch.is_ascii_alphanumeric()) {
121+
Err(String::from("usernames must contain only a-z or 0-9"))
122+
} else {
123+
Ok(())
124+
}
125+
})
126+
.hint("* required")
127+
.tooltip(
128+
tooltips,
129+
"Your username uniquely identifies your account. It must only contain ascii letters and digits.",
130+
),
131+
)
132+
.into_rows();
133+
134+
let password_field = "Password"
135+
.and(
136+
validated_field(
137+
SignupField::Password,
138+
password.to_input().placeholder("Password"),
139+
&password,
140+
&validations,
141+
&api_errors,
142+
|password| {
143+
if password.len() < 8 {
144+
Err(String::from("passwords must be at least 8 characters long"))
145+
} else {
146+
Ok(())
147+
}
148+
},
149+
)
150+
.hint("* required, 8 characters min")
151+
.tooltip(tooltips, "Passwords are always at least 8 bytes long."),
152+
)
153+
.into_rows();
154+
155+
// The password confirmation validation simply checks that the password and
156+
// confirm password match.
157+
let password_confirmation_result =
158+
(&password, &password_confirmation).map_each(|(password, confirmation)| {
159+
if password == confirmation {
160+
Ok(())
161+
} else {
162+
Err("Passwords must match")
163+
}
164+
});
165+
166+
let password_confirmation_field = "Confirm Password"
167+
.and(
168+
password_confirmation
169+
.to_input()
170+
.placeholder("Password")
171+
.validation(validations.validate_result(password_confirmation_result)),
172+
)
173+
.into_rows();
174+
175+
let buttons = "Cancel"
176+
.into_button()
177+
.on_click(|_| {
178+
eprintln!("Sign Up cancelled");
179+
exit(0)
180+
})
181+
.into_escape()
182+
.tooltip(tooltips, "This button quits the program")
183+
.and(Expand::empty_horizontally())
184+
.and(
185+
"Sign Up"
186+
.into_button()
187+
.on_click(validations.when_valid(move |_| {
188+
// The form is valid and the sign up button was clickeed.
189+
// Send the request to our login handler background task
190+
// after setting the state to show the indeterminant
191+
// progress modal.
192+
form_state.set(NewUserState::SigningUp);
193+
login_handler
194+
.send((username.get(), password.get()))
195+
.unwrap();
196+
}))
197+
.into_default(),
198+
)
199+
.into_columns();
200+
201+
username_field
202+
.and(password_field)
203+
.and(password_confirmation_field)
204+
.and(buttons)
205+
.into_rows()
206+
.contain()
207+
.width(Lp::inches(3)..Lp::inches(6))
208+
.pad()
209+
.scroll()
210+
.centered()
211+
}
212+
213+
/// Returns `widget` that is validated using `validate` and `api_errors`.
214+
fn validated_field<T>(
215+
field: SignupField,
216+
widget: impl MakeWidget,
217+
value: &Dynamic<T>,
218+
validations: &Validations,
219+
api_errors: &Dynamic<Map<SignupField, String>>,
220+
mut validate: impl FnMut(&T) -> Result<(), String> + Send + 'static,
221+
) -> Validated
222+
where
223+
T: Send + 'static,
224+
{
225+
// Create a dynamic that contains the error for this field, or None.
226+
let api_error = api_errors.map_each(move |errors| errors.get(&field).cloned());
227+
// When the underlying value has been changed, we should invalidate the API
228+
// error since the edited value needs to be re-checked by the API.
229+
value
230+
.on_change({
231+
let api_error = api_error.clone();
232+
move || {
233+
api_error.set(None);
234+
}
235+
})
236+
.persist();
237+
238+
// Each time either the value or the api error is updated, we produce a new
239+
// validation.
240+
let validation = (value, &api_error).map_each(move |(value, api_error)| {
241+
validate(value)?;
242+
243+
if let Some(error) = api_error {
244+
Err(error.clone())
245+
} else {
246+
Ok(())
247+
}
248+
});
249+
// Finally we return the widget with the merged validation.
250+
widget.validation(validations.validate_result(validation))
251+
}
252+
253+
fn logged_in(username: &str, app_state: &Dynamic<AppState>) -> impl MakeWidget {
254+
let app_state = app_state.clone();
255+
format!("Welcome {username}!")
256+
.and("Log Out".into_button().on_click(move |_| {
257+
app_state.set(AppState::NewUser);
258+
}))
259+
.into_rows()
260+
.centered()
261+
}
262+
263+
fn handle_login(
264+
username: String,
265+
password: MaskedString,
266+
api: &channel::Sender<FakeApiRequest>,
267+
app_state: &Dynamic<AppState>,
268+
form_state: &Dynamic<NewUserState>,
269+
api_errors: &Dynamic<Map<SignupField, String>>,
270+
) {
271+
let response = FakeApiRequestKind::SignUp {
272+
username: username.clone(),
273+
password,
274+
}
275+
.send_to(api);
276+
match response {
277+
FakeApiResponse::SignUpSuccess => {
278+
app_state.set(AppState::LoggedIn { username });
279+
form_state.set(NewUserState::Done);
280+
}
281+
FakeApiResponse::SignUpFailure(errors) => {
282+
form_state.set(NewUserState::FormEntry);
283+
api_errors.set(errors);
284+
}
285+
}
286+
}
287+
288+
#[derive(Debug)]
289+
enum FakeApiRequestKind {
290+
SignUp {
291+
username: String,
292+
password: MaskedString,
293+
},
294+
}
295+
296+
impl FakeApiRequestKind {
297+
fn send_to(self, api: &channel::Sender<FakeApiRequest>) -> FakeApiResponse {
298+
let (response_sender, response_receiver) = channel::bounded(1);
299+
api.send(FakeApiRequest {
300+
kind: self,
301+
response: response_sender,
302+
})
303+
.expect("service running");
304+
response_receiver.receive().expect("service to respond")
305+
}
306+
}
307+
308+
#[derive(Debug)]
309+
struct FakeApiRequest {
310+
kind: FakeApiRequestKind,
311+
response: channel::Sender<FakeApiResponse>,
312+
}
313+
314+
#[derive(Debug)]
315+
enum FakeApiResponse {
316+
SignUpFailure(Map<SignupField, String>),
317+
SignUpSuccess,
318+
}
319+
320+
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
321+
enum SignupField {
322+
Username,
323+
Password,
324+
}
325+
326+
fn fake_service(request: FakeApiRequest) {
327+
let response = match request.kind {
328+
FakeApiRequestKind::SignUp { username, password } => {
329+
// Simulate this api taking a while
330+
thread::sleep(Duration::from_secs(1));
331+
332+
let mut errors = Map::new();
333+
if username == "admin" {
334+
errors.insert(
335+
SignupField::Username,
336+
String::from("admin is a reserved username"),
337+
);
338+
}
339+
if *password == "password" {
340+
errors.insert(
341+
SignupField::Password,
342+
String::from("'password' is not a strong password"),
343+
);
344+
}
345+
346+
if errors.is_empty() {
347+
FakeApiResponse::SignUpSuccess
348+
} else {
349+
FakeApiResponse::SignUpFailure(errors)
350+
}
351+
}
352+
};
353+
let _ = request.response.send(response);
354+
}

0 commit comments

Comments
 (0)