Replace the input! macro with an Input builder (#646)

* Replace the input! macro with an Input builder

* Use a BTreeMap instead of an HashMap

Followinf @fdb-hiroshima's advice

* Rename Input::to_html to Input::html

To make clippy happy

* Wrap error messages in red paragraphs
This commit is contained in:
Ana Gelez 2019-08-27 16:50:24 +02:00 committed by Igor Galić
parent 935d331e97
commit 8ab690001d
14 changed files with 297 additions and 146 deletions

View File

@ -5,7 +5,7 @@ use rocket::http::{Method, Status};
use rocket::request::Request;
use rocket::response::{self, content::Html as HtmlCt, Responder, Response};
use rocket_i18n::Catalog;
use std::collections::hash_map::DefaultHasher;
use std::collections::{btree_map::BTreeMap, hash_map::DefaultHasher};
use std::hash::Hasher;
use templates::Html;
@ -231,11 +231,108 @@ macro_rules! icon {
};
}
macro_rules! input {
($catalog:expr, $name:tt ($kind:tt), $label:expr, $optional:expr, $details:expr, $form:expr, $err:expr, $props:expr) => {{
use std::borrow::Cow;
use validator::ValidationErrorsKind;
let cat = $catalog;
/// A builder type to generate `<input>` tags in a type-safe way.
///
/// # Example
///
/// This example uses all options, but you don't have to specify everything.
///
/// ```rust
/// # let current_email = "foo@bar.baz";
/// # let catalog = gettext::Catalog::parse("").unwrap();
/// Input::new("mail", "Your email address")
/// .input_type("email")
/// .default(current_email)
/// .optional()
/// .details("We won't use it for advertising.")
/// .set_prop("class", "email-input")
/// .to_html(catalog);
/// ```
pub struct Input {
/// The name of the input (`name` and `id` in HTML).
name: String,
/// The description of this field.
label: String,
/// The `type` of the input (`text`, `email`, `password`, etc).
input_type: String,
/// The default value for this input field.
default: Option<String>,
/// `true` if this field is not required (will add a little badge next to the label).
optional: bool,
/// A small message to display next to the label.
details: Option<String>,
/// Additional HTML properties.
props: BTreeMap<String, String>,
/// The error message to show next to this field.
error: Option<String>,
}
impl Input {
/// Creates a new input with a given name.
pub fn new(name: impl ToString, label: impl ToString) -> Input {
Input {
name: name.to_string(),
label: label.to_string(),
input_type: "text".into(),
default: None,
optional: false,
details: None,
props: BTreeMap::new(),
error: None,
}
}
/// Set the `type` of this input.
pub fn input_type(mut self, t: impl ToString) -> Input {
self.input_type = t.to_string();
self
}
/// Marks this field as optional.
pub fn optional(mut self) -> Input {
self.optional = true;
self
}
/// Fills the input with a default value (useful for edition form, to show the current values).
pub fn default(mut self, val: impl ToString) -> Input {
self.default = Some(val.to_string());
self
}
/// Adds additional information next to the label.
pub fn details(mut self, text: impl ToString) -> Input {
self.details = Some(text.to_string());
self
}
/// Defines an additional HTML property.
///
/// This method can be called multiple times for the same input.
pub fn set_prop(mut self, key: impl ToString, val: impl ToString) -> Input {
self.props.insert(key.to_string(), val.to_string());
self
}
/// Shows an error message
pub fn error(mut self, errs: &validator::ValidationErrors) -> Input {
if let Some(field_errs) = errs.clone().field_errors().get(self.name.as_str()) {
self.error = Some(
field_errs[0]
.message
.clone()
.unwrap_or_default()
.to_string(),
);
}
self
}
/// Returns the HTML markup for this field.
pub fn html(mut self, cat: &Catalog) -> Html<String> {
if !self.optional {
self = self.set_prop("required", true);
}
Html(format!(
r#"
@ -247,98 +344,30 @@ macro_rules! input {
{error}
<input type="{kind}" id="{name}" name="{name}" value="{val}" {props} dir="auto"/>
"#,
name = stringify!($name),
label = i18n!(cat, $label),
kind = stringify!($kind),
optional = if $optional {
name = self.name,
label = self.label,
kind = self.input_type,
optional = if self.optional {
format!("<small>{}</small>", i18n!(cat, "Optional"))
} else {
String::new()
},
details = if $details.len() > 0 {
format!("<small>{}</small>", i18n!(cat, $details))
} else {
String::new()
},
error = if let Some(ValidationErrorsKind::Field(errs)) =
$err.errors().get(stringify!($name))
{
format!(
r#"<p class="error" dir="auto">{}</p>"#,
errs[0]
.message
.clone()
.unwrap_or(Cow::from("Unknown error"))
)
} else {
String::new()
},
val = escape(&$form.$name),
props = $props
details = self
.details
.map(|d| format!("<small>{}</small>", d))
.unwrap_or_default(),
error = self
.error
.map(|e| format!(r#"<p class="error" dir="auto">{}</p>"#, e))
.unwrap_or_default(),
val = escape(&self.default.unwrap_or_default()),
props = self
.props
.into_iter()
.fold(String::new(), |mut res, (key, val)| {
res.push_str(&format!("{}=\"{}\" ", key, val));
res
})
))
}};
($catalog:expr, $name:tt (optional $kind:tt), $label:expr, $details:expr, $form:expr, $err:expr, $props:expr) => {
input!(
$catalog,
$name($kind),
$label,
true,
$details,
$form,
$err,
$props
)
};
($catalog:expr, $name:tt (optional $kind:tt), $label:expr, $form:expr, $err:expr, $props:expr) => {
input!(
$catalog,
$name($kind),
$label,
true,
"",
$form,
$err,
$props
)
};
($catalog:expr, $name:tt ($kind:tt), $label:expr, $details:expr, $form:expr, $err:expr, $props:expr) => {
input!(
$catalog,
$name($kind),
$label,
false,
$details,
$form,
$err,
$props
)
};
($catalog:expr, $name:tt ($kind:tt), $label:expr, $form:expr, $err:expr, $props:expr) => {
input!(
$catalog,
$name($kind),
$label,
false,
"",
$form,
$err,
$props
)
};
($catalog:expr, $name:tt ($kind:tt), $label:expr, $form:expr, $err:expr) => {
input!($catalog, $name($kind), $label, false, "", $form, $err, "")
};
($catalog:expr, $name:tt ($kind:tt), $label:expr, $props:expr) => {{
let cat = $catalog;
Html(format!(
r#"
<label for="{name}" dir="auto">{label}</label>
<input type="{kind}" id="{name}" name="{name}" {props} dir="auto"/>
"#,
name = stringify!($name),
label = i18n!(cat, $label),
kind = stringify!($kind),
props = $props
))
}};
}
}

View File

@ -19,7 +19,11 @@
<!-- Rocket hack to use various HTTP methods -->
<input type=hidden name="_method" value="put">
@input!(ctx.1, title (text), "Title", form, errors.clone(), "minlenght=\"1\"")
@(Input::new("title", i18n!(ctx.1, "Title"))
.default(&form.title)
.error(&errors)
.set_prop("minlenght", 1)
.html(ctx.1))
<label for="summary">@i18n!(ctx.1, "Description")<small>@i18n!(ctx.1, "Markdown syntax is supported")</small></label>
<textarea id="summary" name="summary" rows="20">@form.summary</textarea>

View File

@ -9,7 +9,11 @@
@:base(ctx, i18n!(ctx.1, "New Blog"), {}, {}, {
<h1 dir="auto">@i18n!(ctx.1, "Create a blog")</h1>
<form method="post" action="@uri!(blogs::create)">
@input!(ctx.1, title (text), "Title", form, errors, "required minlength=\"1\"")
@(Input::new("title", i18n!(ctx.1, "Title"))
.default(&form.title)
.error(&errors)
.set_prop("minlength", 1)
.html(ctx.1))
<input type="submit" value="@i18n!(ctx.1, "Create blog")" dir="auto"/>
</form>
})

View File

@ -17,7 +17,11 @@
])
<form method="post" action="@uri!(instance::update_settings)">
@input!(ctx.1, name (text), "Name", form, errors.clone(), "props")
@(Input::new("name", i18n!(ctx.1, "Name"))
.default(&form.name)
.error(&errors)
.set_prop("minlength", 1)
.html(ctx.1))
<label for="open_registrations">
<input type="checkbox" name="open_registrations" id="open_registrations" @if instance.open_registrations { checked }>
@ -30,7 +34,11 @@
<label for="long_description">@i18n!(ctx.1, "Long description")<small>@i18n!(ctx.1, "Markdown syntax is supported")</small></label>
<textarea id="long_description" name="long_description">@Html(form.long_description)</textarea>
@input!(ctx.1, default_license (text), "Default article license", form, errors, "minlenght=\"1\"")
@(Input::new("default_license", i18n!(ctx.1, "Default article license"))
.default(&form.default_license)
.error(&errors)
.set_prop("minlength", 1)
.html(ctx.1))
<input type="submit" value="@i18n!(ctx.1, "Save these settings")"/>
</form>

View File

@ -7,22 +7,19 @@
@:base(ctx, i18n!(ctx.1, "Media upload"), {}, {}, {
<h1>@i18n!(ctx.1, "Media upload")</h1>
<form method="post" enctype="multipart/form-data" action="@uri!(medias::upload)">
<label for="alt">
@i18n!(ctx.1, "Description")
<small>@i18n!(ctx.1, "Useful for visually impaired people, as well as licensing information")</small>
</label>
<input type="text" id="alt" name="alt" required minlenght="1"/>
@(Input::new("alt", i18n!(ctx.1, "Description"))
.details(i18n!(ctx.1, "Useful for visually impaired people, as well as licensing information"))
.set_prop("minlenght", 1)
.html(ctx.1))
<label for="cw">
@i18n!(ctx.1, "Content warning")
<small>@i18n!(ctx.1, "Leave it empty, if none is needed")</small>
</label>
<input type="txt" id="cw" name="cw"/>
@(Input::new("cw", i18n!(ctx.1, "Content warning"))
.details(i18n!(ctx.1, "Leave it empty, if none is needed"))
.optional()
.html(ctx.1))
<label for="file">
@i18n!(ctx.1, "File")
</label>
<input type="file" id="file" name="file" required/>
@(Input::new("file", i18n!(ctx.1, "File"))
.input_type("file")
.html(ctx.1))
<input type="submit" value="@i18n!(ctx.1, "Send")"/>
</form>

View File

@ -144,7 +144,11 @@
@if ctx.2.is_some() {
<form method="post" action="@uri!(comments::create: blog_name = &blog.fqn, slug = &article.slug)">
@input!(ctx.1, warning (optional text), "Content warning", comment_form, comment_errors, "")
@(Input::new("warning", i18n!(ctx.1, "Content warning"))
.default(&comment_form.warning)
.error(&comment_errors)
.optional()
.html(ctx.1))
<label for="plume-editor">@i18n!(ctx.1, "Your comment")</label>
@if let Some(ref prev) = previous_comment {

View File

@ -25,8 +25,15 @@
} else {
<form id="plume-fallback-editor" class="new-post" method="post" action="@uri!(posts::new: blog = blog.actor_id)" content-size="@content_len">
}
@input!(ctx.1, title (text), "Title", form, errors.clone(), "required")
@input!(ctx.1, subtitle (optional text), "Subtitle", form, errors.clone(), "")
@(Input::new("title", i18n!(ctx.1, "Title"))
.default(&form.title)
.error(&errors)
.html(ctx.1))
@(Input::new("subtitle", i18n!(ctx.1, "Subtitle"))
.default(&form.subtitle)
.error(&errors)
.optional()
.html(ctx.1))
@if let Some(ValidationErrorsKind::Field(errs)) = errors.clone().errors().get("content") {
@format!(r#"<p class="error">{}</p>"#, errs[0].message.clone().unwrap_or_else(|| Cow::from("Unknown error")))
@ -40,9 +47,17 @@
<a href="@uri!(medias::new)">@i18n!(ctx.1, "Upload media")</a>
</p>
@input!(ctx.1, tags (optional text), "Tags, separated by commas", form, errors.clone(), "")
@input!(ctx.1, license (optional text), "License", "Leave it empty to reserve all rights", form, errors, "")
@(Input::new("tags", i18n!(ctx.1, "Tags, separated by commas"))
.default(&form.tags)
.error(&errors)
.optional()
.html(ctx.1))
@(Input::new("license", i18n!(ctx.1, "License"))
.default(&form.license)
.error(&errors)
.optional()
.details("Leave it empty to reserve all rights")
.html(ctx.1))
@:image_select(ctx, "cover", i18n!(ctx.1, "Illustration"), true, medias, form.cover)

View File

@ -16,15 +16,28 @@
<form method="post" action="/login">
<h2>@i18n!(ctx.1, "I'm from this instance")</h2>
<p>@login_msg</p>
@input!(ctx.1, email_or_name (text), "Username, or email", login_form, login_errs.clone(), "minlenght=\"1\"")
@input!(ctx.1, password (password), "Password", login_form, login_errs, "minlenght=\"1\"")
@(Input::new("email_or_name", i18n!(ctx.1, "Username, or email"))
.default(&login_form.email_or_name)
.error(&login_errs)
.set_prop("minlenght", 1)
.html(ctx.1))
@(Input::new("password", i18n!(ctx.1, "Password"))
.default(login_form.password)
.error(&login_errs)
.set_prop("minlength", 1)
.html(ctx.1))
<input type="submit" value="@i18n!(ctx.1, "Log in")" />
</form>
<form method="post">
<h2>@i18n!(ctx.1, "I'm from another instance")</h2>
<p>@remote_msg</p>
@input!(ctx.1, remote (text), "Username", "Example: user@plu.me", remote_form, remote_errs.clone(), "minlenght=\"1\"")
@(Input::new("remote", i18n!(ctx.1, "Username"))
.details("Example: user@plu.me")
.default(&remote_form.remote)
.error(&remote_errs)
.set_prop("minlenght", 1)
.html(ctx.1))
<input type="submit" value="@i18n!(ctx.1, "Continue to your instance")"/>
</form>
</div>

View File

@ -6,21 +6,48 @@
@:base(ctx, i18n!(ctx.1, "Search"), {}, {}, {
<h1>@i18n!(ctx.1, "Search")</h1>
<form method="get" id="form">
<input id="q" name="q" placeholder="@i18n!(ctx.1, "Your query")" type="search" style="-webkit-appearance: none;">
@(Input::new("q", "Your query")
.input_type("search")
.set_prop("style", "-webkit-appearance: none;")
.html(ctx.1))
<details>
<summary>@i18n!(ctx.1, "Advanced search")</summary>
@input!(ctx.1, title (text), "Article title matching these words", &format!("placeholder=\"{}\"", i18n!(ctx.1, "Title")))
@input!(ctx.1, subtitle (text), "Subtitle matching these words", &format!("placeholder=\"{}\"", i18n!(ctx.1, "Subtitle - byline")))
@input!(ctx.1, content (text), "Content matching these words", &format!("placeholder=\"{}\"", i18n!(ctx.1, "Body content")))
@input!(ctx.1, after (date), "From this date", &format!("max={}", now))
@input!(ctx.1, before (date), "To this date", &format!("max={}", now))
@input!(ctx.1, tag (text), "Containing these tags", &format!("placeholder=\"{}\"", i18n!(ctx.1, "Tags")))
@input!(ctx.1, instance (text), "Posted on one of these instances", &format!("placeholder=\"{}\"", i18n!(ctx.1, "Instance domain")))
@input!(ctx.1, author (text), "Posted by one of these authors", &format!("placeholder=\"{}\"", i18n!(ctx.1, "Author(s)")))
@input!(ctx.1, blog (text), "Posted on one of these blogs", &format!("placeholder=\"{}\"", i18n!(ctx.1, "Blog title")))
@input!(ctx.1, lang (text), "Written in this language", &format!("placeholder=\"{}\"", i18n!(ctx.1, "Language")))
@input!(ctx.1, license (text), "Published under this license", &format!("placeholder=\"{}\"", i18n!(ctx.1, "Article license")))
@(Input::new("title", i18n!(ctx.1, "Article title matching these words"))
.set_prop("placeholder", i18n!(ctx.1, "Title"))
.html(ctx.1))
@(Input::new("subtitle", i18n!(ctx.1, "Subtitle matching these words"))
.set_prop("placeholder", i18n!(ctx.1, "Subtitle"))
.html(ctx.1))
@(Input::new("content", i18n!(ctx.1, "Content macthing these words"))
.set_prop("placeholder", i18n!(ctx.1, "Body content"))
.html(ctx.1))
@(Input::new("after", i18n!(ctx.1, "From this date"))
.input_type("date")
.set_prop("max", now)
.html(ctx.1))
@(Input::new("before", i18n!(ctx.1, ""))
.input_type("date")
.set_prop("max", now)
.html(ctx.1))
@(Input::new("tag", i18n!(ctx.1, "Containing these tags"))
.set_prop("placeholder", i18n!(ctx.1, "Tags"))
.html(ctx.1))
@(Input::new("instance", i18n!(ctx.1, "Posted on one of these instances"))
.set_prop("placeholder", i18n!(ctx.1, "Instance domain"))
.html(ctx.1))
@(Input::new("author", i18n!(ctx.1, "Posted by one of these authors"))
.set_prop("placeholder", i18n!(ctx.1, "Author(s)"))
.html(ctx.1))
@(Input::new("blog", i18n!(ctx.1, "Posted on one of these blogs"))
.set_prop("placeholder", i18n!(ctx.1, "Blog title"))
.html(ctx.1))
@(Input::new("lang", i18n!(ctx.1, "Written in this language"))
.set_prop("placeholder", i18n!(ctx.1, "Language"))
.html(ctx.1))
@(Input::new("license", i18n!(ctx.1, "Published under this license"))
.set_prop("placeholder", i18n!(ctx.1, "Article license"))
.html(ctx.1))
</details>
<input type="submit" value="@i18n!(ctx.1, "Search")"/>
</form>

View File

@ -12,8 +12,17 @@
<p>@message</p>
}
<form method="post" action="@uri!(session::create)">
@input!(ctx.1, email_or_name (text), "Username, or email", form, errors.clone(), "minlenght=\"1\"")
@input!(ctx.1, password (password), "Password", form, errors, "minlenght=\"1\"")
@(Input::new("email_or_name", i18n!(ctx.1, "Username, or email"))
.default(&form.email_or_name)
.error(&errors)
.set_prop("minlenght", 1)
.html(ctx.1))
@(Input::new("password", i18n!(ctx.1, "Password"))
.default(&form.password)
.error(&errors)
.set_prop("minlenght", 8)
.input_type("password")
.html(ctx.1))
<input type="submit" value="@i18n!(ctx.1, "Log in")" />
</form>
<a href="@uri!(session::password_reset_request_form)">Forgot your password?</a>

View File

@ -9,8 +9,18 @@
<h1>@i18n!(ctx.1, "Reset your password")</h1>
<form method="POST">
@input!(ctx.1, password (password), "New password", form, errors.clone(), "minlenght=\"8\"")
@input!(ctx.1, password_confirmation (password), "Confirmation", form, errors.clone(), "minlenght=\"8\"")
@(Input::new("password", i18n!(ctx.1, "New password"))
.default(&form.password)
.error(&errors)
.set_prop("minlenght", 8)
.input_type("password")
.html(ctx.1))
@(Input::new("password_confirmation", i18n!(ctx.1, "Confirmation"))
.default(&form.password_confirmation)
.error(&errors)
.set_prop("minlenght", 8)
.input_type("password")
.html(ctx.1))
<input type="submit" value="@i18n!(ctx.1, "Update password")" />
</form>
})

View File

@ -9,7 +9,12 @@
<h1>@i18n!(ctx.1, "Reset your password")</h1>
<form method="POST">
@input!(ctx.1, email (email), "E-mail", form, errors.clone(), "minlenght=\"1\"")
@(Input::new("email", i18n!(ctx.1, "Email"))
.default(&form.email)
.error(&errors)
.set_prop("minlenght", 1)
.input_type("email")
.html(ctx.1))
<input type="submit" value="@i18n!(ctx.1, "Send password reset link")" />
</form>
})

View File

@ -18,8 +18,15 @@
<!-- Rocket hack to use various HTTP methods -->
<input type=hidden name="_method" value="put">
@input!(ctx.1, display_name (text), "Display name", form, errors.clone())
@input!(ctx.1, email (text), "Email", form, errors.clone())
@(Input::new("display_name", i18n!(ctx.1, ""))
.default(&form.display_name)
.error(&errors)
.html(ctx.1))
@(Input::new("email", i18n!(ctx.1, ""))
.default(&form.email)
.error(&errors)
.input_type("email")
.html(ctx.1))
<label for="summary">@i18n!(ctx.1, "Summary")</label>
<textarea id="summary" name="summary">@form.summary</textarea>

View File

@ -11,13 +11,32 @@
@if enabled {
<h1>@i18n!(ctx.1, "Create an account")</h1>
<form method="post" action="@uri!(user::create)">
@input!(ctx.1, username (text), "Username", form, errors.clone(), "minlenght=\"1\"")
@input!(ctx.1, email (text), "Email", form, errors.clone())
@input!(ctx.1, password (password), "Password", form, errors.clone(), "minlenght=\"8\"")
@if let Some(ValidationErrorsKind::Field(errs)) = errors.clone().errors().get("__all__") {
<p class="error">@errs[0].message.as_ref().unwrap_or(&Cow::from("Unknown error"))</p>
<p class="error">@errs[0].message.as_ref().unwrap_or(&Cow::from("Unknown error"))</p>
}
@input!(ctx.1, password_confirmation (password), "Password confirmation", form, errors, "minlenght=\"8\"")
@(Input::new("username", i18n!(ctx.1, ""))
.default(&form.username)
.error(&errors)
.set_prop("minlength", 1)
.html(ctx.1))
@(Input::new("email", i18n!(ctx.1, ""))
.default(&form.email)
.error(&errors)
.set_prop("minlength", 1)
.html(ctx.1))
@(Input::new("password", i18n!(ctx.1, ""))
.default(&form.password)
.error(&errors)
.set_prop("minlength", 8)
.input_type("password")
.html(ctx.1))
@(Input::new("password_confirmation", i18n!(ctx.1, ""))
.default(&form.password_confirmation)
.error(&errors)
.set_prop("minlength", 8)
.input_type("password")
.html(ctx.1))
<input type="submit" value="@i18n!(ctx.1, "Create your account")" />
</form>