0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[Yew] ジェネリックコンポーネントでフォームのバリデーション

Last updated at Posted at 2022-12-22

今回は,Yewを用いて以下のように入力をバリデーションしてメッセージを表示させてみます.changeイベントごとにバリデーションする方法(上部分)と送信ボタンのclickイベントでバリデーションする方法(下半分)の二つの方法でinput要素に対応するコンポーネントを作成してみます.コード全体はこちら
validation_input.gif
Rustで入力データなどのバリデーションを行う方法として,プリミティブな型からドメイン固有の型へのTryFromトレイトを実装しResultで返す方法があります.例えば郵便番号を文字列から作成する場合に以下のようにTryFrom<String>を実装するとします(必ずしもFromStrを噛ませる必要はありません).これによってStringからtry_intoメソッドによってPostalCodeに変換でき,失敗した場合DomainErrorが返ります.

domain.rs
/// 郵便番号
#[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トレイトや文字列を返す独自のトレイトを利用するのが良いと思います.

domain_error.rs
#[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が必要です.Etry_intoで返るエラー型で同じくPartialEqが必要です.親コンポーネント(フォーム全体)から渡されたコールバックにchangeイベントごとにOption<T>emitします(成功:Some(), 失敗:None).失敗した際にエラーメッセージ用のstateに値をsetします(成功:None, 失敗:Some()).

validation_input_v1.rs
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は以下のようにシンプルに空白文字列でないことを保証するだけの型です.

required.rs
/// 何か入力が必要な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)
        }
    }
}

コールバックの中身は以下です.HtmlInputElementvalueを先ほどの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とそれぞれのフィールドがOptionDomainFormOptを用意します.DomainFormOptdefaultメソッドは全てのフィールドがNoneとなります.

domain_form.rs
#[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()が得られた)ことになります.

domain_form.rs
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をログとしてコンソールに表示しています.

form_v1.rs
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::NodeRefgloo_events::EventListenerを用いて,プロパティとして与えられたボタンのclickイベントにクロージャ―を結び付けいてる点と,<input type="text"/>からのvalueの取得にもNodeRefを用いている点が異なります.submit_refについて,このコンポーネント内でhtml要素にrefで渡していないにも関わらずNodeRef::castが成功するのは少し奇妙に思えます.しかしuse_effect_with_depsで与えたコールバックは最初のレンダリングの後に行われるため(そしてdeps()を渡すことによって一回だけ実行される),親コンポーネントで適切なhtml要素にrefで渡していればこのタイミングでもNodeRef::castが成功します.

validation_input_v2.rs
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は役に立つと思います.

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?