今回は,Yewを用いて以下のように入力をバリデーションしてメッセージを表示させてみます.change
イベントごとにバリデーションする方法(上部分)と送信ボタンのclick
イベントでバリデーションする方法(下半分)の二つの方法でinput要素に対応するコンポーネントを作成してみます.コード全体はこちら
Rustで入力データなどのバリデーションを行う方法として,プリミティブな型からドメイン固有の型へのTryFrom
トレイトを実装しResult
で返す方法があります.例えば郵便番号を文字列から作成する場合に以下のようにTryFrom<String>
を実装するとします(必ずしもFromStr
を噛ませる必要はありません).これによってString
からtry_into
メソッドによってPostalCode
に変換でき,失敗した場合DomainError
が返ります.
/// 郵便番号
#[derive(PartialEq, Eq, Debug, Clone)]
pub struct PostalCode(String);
impl FromStr for PostalCode {
type Err = DomainError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// - で分ける
let (first, second) = s.split_once('-').ok_or(DomainError::DomainParseError)?;
if first.chars().count() == 3_usize
&& first.chars().all(|c| c.is_ascii_digit())
&& second.chars().count() == 4_usize
&& second.chars().all(|c| c.is_ascii_digit())
{
Ok(PostalCode(s.to_string()))
} else {
Err(DomainError::DomainParseError)
}
}
}
impl TryFrom<String> for PostalCode {
type Error = DomainError;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.parse()
}
}
ここでDomainError
は以下とします.今回は表示するメッセージをコンポーネントの引数で渡すため全てDomainParseError
ですが,より詳細なメッセージを表示したい場合はDomainParseError
により細かいバリアントを定義してDisplay
トレイトや文字列を返す独自のトレイトを利用するのが良いと思います.
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum DomainError {
/// TryFromの実装時に利用
#[error("DomainParseError")]
DomainParseError,
/// フォーム型の変換時に利用
#[error("ValidateFormError")]
ValidateFormError,
}
以上のようなTryFrom<String>
を用いて入力をバリデーションするようなInput要素を持つコンポーネントを作っていきます.
changeイベントごとにバリデーションする方法
ValidationInputコンポーネント
change
イベントごとにバリデーションするコンポーネントは以下です.ジェネリックコンポーネントになっています.T
は文字列から変換する任意のドメイン固有型でありプロパティとして渡すためPartialEq
が必要です.E
はtry_into
で返るエラー型で同じくPartialEq
が必要です.親コンポーネント(フォーム全体)から渡されたコールバックにchange
イベントごとにOption<T>
をemit
します(成功:Some()
, 失敗:None
).失敗した際にエラーメッセージ用のstate
に値をset
します(成功:None
, 失敗:Some()
).
use wasm_bindgen::JsCast;
use web_sys::HtmlInputElement;
use yew::prelude::*;
#[derive(Properties, PartialEq, Clone)]
pub struct ValidationInputPropsV1<T, E>
where
T: TryFrom<String, Error = E> + PartialEq + 'static,
E: PartialEq,
{
// 値の更新時に実行するコールバック
pub onchange: Callback<Option<T>>,
// バリデーションのエラーメッセージ
pub error_message: String,
// 必須のフィールドであるかどうか
#[prop_or(false)]
pub required: bool,
}
#[function_component(ValidationInputV1)]
pub fn validation_input_v1<T, E>(
ValidationInputPropsV1 {
onchange,
error_message,
required,
}: &ValidationInputPropsV1<T, E>,
) -> Html
where
T: TryFrom<String, Error = E> + PartialEq + 'static,
E: PartialEq,
{
let error_message = error_message.clone();
let required = *required;
let error_message_handle: UseStateHandle<Option<String>> =
use_state(|| Some("必須のフィールドです".to_string()));
let text_onchange = {
let parent_onchange = onchange.clone();
let error_message_handle = error_message_handle.clone();
let try_into_func = move |s: String| -> Result<T, String> {
// required
if required {
let _required_s: RequiredString = s
.clone()
.try_into()
.map_err(|_| "必須のフィールドです".to_string())?;
}
let domain_value: T = s.try_into().map_err(|_| error_message.clone())?;
Ok(domain_value)
};
Callback::from(move |e: Event| {
let input_element = e
.target()
.expect("Event should have a target when dispatched")
.dyn_into::<HtmlInputElement>()
.expect("This EventTarget should cast into HtmlInputElement");
let input_text = input_element.value();
let try_into_res = try_into_func(input_text);
match try_into_res {
Ok(domain_value) => { // 成功時
error_message_handle.set(None);
parent_onchange.emit(Some(domain_value));
}
Err(error_message) => { // 失敗時
error_message_handle.set(Some(error_message));
parent_onchange.emit(None);
}
}
})
};
html! {
<>
<input type="text" onchange={text_onchange}/>
if let Some(s) = (*error_message_handle).clone() {
<span>{s}</span>
}
</>
}
}
change
イベントに結び付けるコールバックのうち,バリデーションを行う関数は以下です.メッセージを取得しやすいようにResult<T, String>
で返しています.RequiredString
は入力を保証する文字列であり後で説明します.
let try_into_func = move |s: String| -> Result<T, String> {
// required
if required {
let _required_s: RequiredString = s
.clone()
.try_into()
.map_err(|_| "必須のフィールドです".to_string())?;
}
let domain_value: T = s.try_into().map_err(|_| error_message.clone())?;
Ok(domain_value)
};
RequiredString
は以下のようにシンプルに空白文字列でないことを保証するだけの型です.
/// 何か入力が必要なString
pub struct RequiredString(String);
pub struct RequiredError;
impl TryFrom<String> for RequiredString {
type Error = RequiredError;
fn try_from(value: String) -> Result<Self, Self::Error> {
if value != String::default() {
Ok(RequiredString(value))
} else {
Err(RequiredError)
}
}
}
コールバックの中身は以下です.HtmlInputElement
のvalue
を先ほどのtry_into_func
関数でResult<T, String>
に変換しバリデーションが成功・失敗したときの処理を行っています.成功した場合はエラーメッセージにNone
,親のコールバックにSome
を渡し,失敗した場合はエラーメッセージにSome
,親のコールバックにNone
を渡します.
let input_element = e
.target()
.expect("Event should have a target when dispatched")
.dyn_into::<HtmlInputElement>()
.expect("This EventTarget should cast into HtmlInputElement");
let input_text = input_element.value();
let try_into_res = try_into_func(input_text);
match try_into_res {
Ok(domain_value) => { // 成功時
error_message_handle.set(None);
parent_onchange.emit(Some(domain_value));
}
Err(error_message) => { // 失敗時
error_message_handle.set(Some(error_message));
parent_onchange.emit(None);
}
}
Formコンポーネント
親コンポーネントであるFormコンポーネントの説明の前に,バリデーションが成功したことをチェックする部分を説明します.この部分は一つめの方法と二つ目の方法どちらからも利用します.ドメイン固有型をフィールドに持つDomainForm
とそれぞれのフィールドがOption
のDomainFormOpt
を用意します.DomainFormOpt
のdefault
メソッドは全てのフィールドがNone
となります.
#[derive(Default, Clone)]
pub struct DomainFormOpt {
pub date: Option<Date>,
pub postal_code: Option<PostalCode>,
}
#[derive(Debug)]
pub struct DomainForm {
pub date: Date,
pub postal_code: PostalCode,
}
そして以下のTryForm<DomainFormOpt>
が成功すれば,Date
,PostalCode
のバリデーションが成功した(全てでそれぞれSome()
が得られた)ことになります.
impl TryFrom<DomainFormOpt> for DomainForm {
type Error = DomainError;
fn try_from(opt: DomainFormOpt) -> Result<Self, Self::Error> {
Ok(Self {
date: opt.date.ok_or(DomainError::ValidateFormError)?,
postal_code: opt.postal_code.ok_or(DomainError::ValidateFormError)?,
})
}
}
これとValidationInputV1
を用いたFormコンポーネントは以下です.実際には送信時にFetch APIなどを利用することになると思いますが,今回はバリデーションが成功して得られたDomainForm
をログとしてコンソールに表示しています.
use log::info;
use yew::prelude::*;
#[function_component(FormV1)]
pub fn form_v1() -> Html {
let domain_form_opt_handle = use_mut_ref(DomainFormOpt::default);
let onclick = {
let domain_form_opt_handle = domain_form_opt_handle.clone();
Callback::from(move |_: MouseEvent| {
let res: Result<DomainForm, _> = domain_form_opt_handle
.replace_with(|domain_form_opt| domain_form_opt.clone())
.try_into();
if let Ok(domain_form) = res {
// 送信の処理
info!("form v1:{:?}", domain_form);
}
})
};
html! {
<>
<div>
<label>
{"郵便番号を入力"}
<ValidationInputV1<PostalCode, _>
onchange={
let domain_form_opt_handle = domain_form_opt_handle.clone();
move |opt|{domain_form_opt_handle.borrow_mut().postal_code = opt;}
}
error_message={"有効な郵便番号ではありません"}
required={true}
/>
</label>
</div>
<div>
<label>
{"年月日を入力"}
<ValidationInputV1<Date, _>
onchange={
let domain_form_opt_handle = domain_form_opt_handle.clone();
move |opt|{domain_form_opt_handle.borrow_mut().date = opt;}
}
error_message={"有効な年月日ではありません"}
required={true}
/>
</label>
</div>
<div>
<button {onclick}>{"送信"}</button>
</div>
</>
}
}
送信ボタンのclickイベントでバリデーションする方法
ValidationInputコンポーネント
親コンポーネントの送信ボタンのclick
イベントでバリデーションするコンポーネントは以下です.yew::prelude::NodeRef
とgloo_events::EventListener
を用いて,プロパティとして与えられたボタンのclick
イベントにクロージャ―を結び付けいてる点と,<input type="text"/>
からのvalue
の取得にもNodeRef
を用いている点が異なります.submit_ref
について,このコンポーネント内でhtml要素にref
で渡していないにも関わらずNodeRef::cast
が成功するのは少し奇妙に思えます.しかしuse_effect_with_deps
で与えたコールバックは最初のレンダリングの後に行われるため(そしてdeps
に()
を渡すことによって一回だけ実行される),親コンポーネントで適切なhtml要素にref
で渡していればこのタイミングでもNodeRef::cast
が成功します.
use gloo_events::EventListener;
use web_sys::{HtmlElement, HtmlInputElement};
use yew::prelude::*;
#[derive(Properties, PartialEq, Clone)]
pub struct ValidationInputPropsV2<T, E>
where
T: TryFrom<String, Error = E> + PartialEq + 'static,
E: PartialEq,
{
// 送信時に実行するコールバック
pub onsubmit: Callback<Option<T>>,
// 送信イベントを結びつけるボタンのNodeRef
pub submit_ref: NodeRef,
// バリデーションのエラーメッセージ
pub error_message: String,
// 必須のフィールドであるかどうか
#[prop_or(false)]
pub required: bool,
}
#[function_component(ValidationInputV2)]
pub fn validation_input_v2<T, E>(
ValidationInputPropsV2 {
onsubmit,
submit_ref,
error_message,
required,
}: &ValidationInputPropsV2<T, E>,
) -> Html
where
T: TryFrom<String, Error = E> + PartialEq + 'static,
E: PartialEq,
{
let error_message = error_message.clone();
let required = *required;
let error_message_handle: UseStateHandle<Option<String>> = use_state(|| None);
let text_ref = use_node_ref();
use_effect_with_deps(
{
let parent_onsubmit = onsubmit.clone();
let error_message_handle = error_message_handle.clone();
let text_ref = text_ref.clone();
let submit_ref = submit_ref.clone();
let try_into_func = move |s: String| -> Result<T, String> {
if required {
let _required_s: RequiredString = s
.clone()
.try_into()
.map_err(|_| "必須のフィールドです".to_string())?;
}
let domain_value: T = s.try_into().map_err(|_| error_message.clone())?;
Ok(domain_value)
};
move |_| {
let submit_node = submit_ref
.cast::<HtmlElement>()
.expect("This NodeRef should cast into HtmlElement");
let listener = EventListener::new(&submit_node, "click", move |_| {
let input_text = text_ref
.cast::<HtmlInputElement>()
.expect("This NodeRef should cast into HtmlInputElement")
.value();
let try_into_res = try_into_func(input_text);
match try_into_res {
Ok(domain_value) => {
error_message_handle.set(None);
parent_onsubmit.emit(Some(domain_value));
}
Err(error_message) => {
error_message_handle.set(Some(error_message));
parent_onsubmit.emit(None);
}
}
});
move || drop(listener)
}
},
(), //最初のレンダリング時に実行
);
html! {
<>
<input type="text" ref={text_ref}/>
if let Some(s) = (*error_message_handle).clone() {
<span>{s}</span>
}
</>
}
}
Formコンポーネント
ValidationInputV2
を用いたFormコンポーネントは以下です.バリデーションの終了チェックと送信の関数がValidationInputV2
に渡すコールバック内で実行されているのが異なる点です.しかし重複して送信しないように送信の処理内でdomain_form_opt_handle
を初期化しています.またValidationInputV2
に渡すsubmit_ref
をボタン要素にref
で結びつけています.
use log::info;
use std::cell::RefCell;
use std::rc::Rc;
use yew::prelude::*;
fn check_and_send(domain_form_opt_handle: Rc<RefCell<DomainFormOpt>>) {
let res: Result<DomainForm, _> = domain_form_opt_handle
.replace_with(|opt| opt.clone())
.try_into();
if let Ok(domain_form) = res {
// 送信の処理
info!("form v2:{:?}", domain_form);
*domain_form_opt_handle.borrow_mut() = DomainFormOpt::default();
}
}
#[function_component(FormV2)]
pub fn form_v2() -> Html {
let submit_ref = use_node_ref();
let domain_form_opt_handle = use_mut_ref(DomainFormOpt::default);
html! {
<>
<div>
<label>{"郵便番号を入力"}
<ValidationInputV2<PostalCode, _>
onsubmit={
let domain_form_opt_handle = domain_form_opt_handle.clone();
move |opt|{
domain_form_opt_handle.borrow_mut().postal_code = opt;
check_and_send(domain_form_opt_handle.clone());
}
}
submit_ref={submit_ref.clone()}
error_message={"有効な郵便番号ではありません"}
required={true}
/>
</label>
</div>
<div>
<label>{"年月日を入力"}
<ValidationInputV2<Date, _>
onsubmit={
let domain_form_opt_handle = domain_form_opt_handle.clone();
move |opt|{
domain_form_opt_handle.borrow_mut().date = opt;
check_and_send(domain_form_opt_handle.clone());
}
}
submit_ref={submit_ref.clone()}
error_message={"有効な年月日ではありません"}
required={true}
/>
</label>
</div>
<div>
<button ref={submit_ref}>{"送信"}</button>
</div>
</>
}
}
実際には上記のような例に当てはまらず複数のinput要素からドメイン固有型を作成する場合や,ドメイン固有型を設けるのが冗長な場合があると思いますが,その場合もrustの強力なエラーハンドリングツールであるResult
は役に立つと思います.