vte.cxとReactで検索フォームの実装

#概要
**[以前作成したアプリ](https://qiita.com/kako-2748/items/55a67a24ac29ef657045)**に検索フォームを実装しました。
**[vte.cx](https://blog.vte.cx/)**について詳しく知りたい方は**[vte.cxのドキュメント](https://vte.cx/documentation.html)**をご覧ください。

今回実装した検索フォームの仕様は以下になります。

- **検索した結果を一覧に表示する。**
- **検索した結果でページネーションを行う。**
- **入力した値を全文検索するフォームを作る**
- **複数（最大すべて）の項目を入力し検索できるフォームを作る**

#実装
今回編集したファイルは一覧画面(ListProf)と新しく作った検索フォームファイル（SearchList）になります。
まずは検索フォームファイルの方から見ていきます。

##検索フォーム(SearchList)

```tsx:SearchList
import * as React from 'react'
import { useState } from 'react'

type SearchProps = Partial<{
  getFeedLength: any,
  putIndex: any,
  getPage: any
}>
const SearchList:React.FC<SearchProps> = ({getFeedLength, putIndex, getPage}) => {
    const [searchText, setSearchText] = useState({})
    const [searchFullText, setSearchFullText] = useState('')

    const searchFeed = () => {
      getFeedLength(searchText, searchFullText)
      putIndex(1,searchText, searchFullText)
      getPage(1,0, searchText, searchFullText)

    }

    return (
        <form>
          <input type="text" placeholder="全文検索" value={searchFullText} onChange={(e:any) => setSearchFullText(e.target.value)} />
          <button type="button" onClick={() => searchFeed()}>検索</button><br />
          
          <input type="text"  placeholder="名前" onChange={(e:any) => setSearchText({...searchText, name:e.target.value})} />
          <input type="text"  placeholder="メール" onChange={(e:any) => setSearchText({...searchText, email:e.target.value})} />
          <input type="text"  placeholder="職業" onChange={(e:any) => setSearchText({...searchText, job:e.target.value})}  />   
          <input type="text"  placeholder="住所" onChange={(e:any) => setSearchText({...searchText, address:e.target.value})} />
          <input type="text"  placeholder="身長" onChange={(e:any) => setSearchText({...searchText, height:e.target.value})} /><br />

          <label htmlFor="calendar">生年月日:</label>
          <input type="date"  onChange={(e:any) => setSearchText({...searchText, birthday:e.target.value})} />
          
          <input type="radio" name="check" onChange={(e:any) => setSearchText({...searchText, gender:e.target.value})} /><label htmlFor="gender">男</label>
          <input type="radio" name="check" onChange={(e:any) => setSearchText({...searchText, gender:e.target.value})} /><label htmlFor="gender">女</label>

          <input type="checkbox" name='チェックボックス' onChange={() => setSearchText({...searchText, check: 'チェック1'})} checked={Object.values(searchText).indexOf('チェック1') !== -1  && true} />チェック１
          <input type="checkbox" name='チェックボックス' onChange={() => setSearchText({...searchText, check: 'チェック2'})} checked={Object.values(searchText).indexOf('チェック2') !== -1 && true}/>チェック2
          <input type="checkbox" name='チェックボックス' onChange={() => setSearchText({...searchText, check: 'チェック3'})} checked={Object.values(searchText).indexOf('チェック3') !== -1  && true}/>チェック3<br />

          <select onChange={(e:any) => setSearchText({...searchText, select:e.target.value})}>
             <option disabled selected value="">選択してください</option>
             <option value="サンプル１">サンプル１</option>
             <option value="サンプル２">サンプル２</option>
             <option value="サンプル３">サンプル３</option>
          </select><br />

          <textarea placeholder="メモ" onChange={(e:any) => setSearchText({...searchText, memo:e.target.value})}></textarea><br /> 

          <button type="button" onClick={() => searchFeed()}>検索</button>
        </form>
    )
}

export default SearchList
```

このSearchListコンポーネントでは、検索フォームに入力された値を``searchText``か``searchFullText``に格納して、検索ボタンが押されたら、``searchFeed``関数を実行し画面描画する。という処理を行っています。複数（最大すべて）の項目を検索できるフォームは,スキーマ名と入力された値を格納したいので、オブジェクトに``key:value``の形で格納するようにしました。
<br />
次に``searchFeed``関数内で実行している関数を見ていきます。
##一覧画面(ListProf)

```tsx:ListProf

import * as React from 'react'
import { useState, useRef } from 'react'
import axios from 'axios'
import { withRouter } from 'react-router-dom'

import Pagination from './Pagination'
import SearchList from './SearchList'

//一覧画面
const ListProf:React.FC = (props:any) => {
  const [deletedPage, setDeletedPage] = useState(1)
  const [feed, setFeed] = useState<VtecxApp.Entry[]>([])
  const [firstIndexPage, setFirstIndexPage] = useState(1)
  const [lastIndexPage, setLastIndexPage] = useState(50)
  const [fullText, setFullText] = useState('')
  const [text, setText] = useState({})

  const feedLength = useRef(0)
  const lastPage = useRef(0)
  const search_url = useRef('')

  const getFeedLength = async(searchText:{}, searchFullText:string) => {
    try {
        search_url.current = '&|user.name-rg-.*'+searchFullText+'.*'
        +'&|user.email-rg-.*'+searchFullText+'.*&|user.gender-rg-.*'+searchFullText+'.*'
        +'&|user.memo-rg-.*'+searchFullText+'.*&|user.birthday-rg-.*'+searchFullText+'.*'
        +'&|user.check-rg-.*'+searchFullText+'.*&|user.select-rg-.*'+searchFullText+'.*'
        +'&|user.job-rg-.*'+searchFullText+'.*&|user.address-rg-.*'+searchFullText+'.*'
        +'&|user.height-rg-.*'+searchFullText+'.*'

        axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
        //データの総件数取得
        if(Object.keys(searchText).length === 0 && searchFullText === '') {
          const res = await axios.get('/d/foo?f&c&l=*')
          feedLength.current = (Number(res.data.feed.title))
          lastPage.current = (Math.ceil(res.data.feed.title / 5))

        } else if(searchFullText !== '') {
          const res = await axios.get('/d/foo?f'+search_url.current+'&c&l=*')
          feedLength.current = (Number(res.data.feed.title))
          lastPage.current = (Math.ceil(res.data.feed.title / 5))

        } else if(Object.keys(searchText).length > 0) {
          const mapText =  Object.entries(searchText).map(([key, value]) => {
             return key+'-rg-.*'+value+'.*'
          })
          const str = mapText.join('&|')
          const res = await axios.get(`/d/foo?f&|user.${str}&c&l=*`)
          feedLength.current = (Number(res.data.feed.title))
          lastPage.current = (Math.ceil(res.data.feed.title / 5))

        }

    } catch {
      alert('総件数の取得に失敗しました。')

    }
 }

  const putIndex = async(currentPage:number, searchText:{}, searchFullText:string) => {
    try {
      let firstIndex= firstIndexPage
      let lastIndex= lastIndexPage

      if(currentPage > firstIndexPage && currentPage > lastIndexPage){
        firstIndex = firstIndex + 50
        lastIndex = lastIndex + 50

      }

      axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
  
      if(Object.keys(searchText).length === 0 && searchFullText === '') {
        await axios.get('/d/foo?f&_pagination='+firstIndex+','+lastIndex+'&l=5')

      } else if(searchFullText !== '') {
        await axios.get('/d/foo?f&_pagination='+firstIndex+','+lastIndex+search_url.current+'&l=5')

      } else if(Object.keys(searchText).length > 0) {
        const mapText =  Object.entries(searchText).map(([key, value]) => {
          return key+'-rg-.*'+value+'.*'
        })
        const str = mapText.join('&|')
        await axios.get('/d/foo?f&|user.'+str+'&_pagination='+firstIndex+','+lastIndex+'&l=5')

      }
      setFirstIndexPage(firstIndex)
      setLastIndexPage(lastIndex)

    } catch {
      alert('indexが貼れませんでした。')

    } 
  }
 
  const getPage = async(currentPage:number, retry_count:number, searchText:{},searchFullText:string)  => {
    try {
      axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
      
      //ページの取得
      if(Object.keys(searchText).length === 0 && searchFullText === '') {
        const res = await axios.get('/d/foo?f&n='+ currentPage + '&l=5')
        setFeed(res.data)
        
      } else if(searchFullText !== '') {
        const res = await axios.get('/d/foo?f'+search_url.current+'&n='+ currentPage + '&l=5')
        setFeed(res.data)
        setFullText(searchFullText)
       
      } else if(Object.keys(searchText).length > 0) {
          const mapText =  Object.entries(searchText).map(([key, value]) => {
            return key+'-rg-.*'+value+'.*'
          })
          const str = mapText.join('&|')
          const res = await axios.get('/d/foo?f&|user.'+ str+'&n='+ currentPage + '&l=5')
          setFeed(res.data)
          setText(searchText)

        }

    } catch(e) {
      const retry = () => {
        retry_count++

        const retryIndex = ():void => { 
          getPage(currentPage, retry_count, searchText,searchFullText)

        }
        if(retry_count > 9) {
          alert('ページが取得できませんでした')
          return false
 
        }else{
          setTimeout(() => retryIndex(),1000) 

        }
      }
      
      //indexが貼れていなかった場合10回までリトライ
      if(e.response.data.feed.title === "Please make a pagination index in advance.") {
       retry()
      //ページのリクエストが最終番号以降のリクエストが来た時にindexを追加で貼り直す
      } else if(e.response.data.feed.title === "Please set a positive number for Page number.") {
        putIndex(currentPage, searchText,searchFullText)
        retry()

      }

    }  
  }

  //削除関数
  const deleteEntry = async (entry:VtecxApp.Entry, index:number) => {
    try {
      axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
 
      if(entry && entry.link) {
         const key = entry.link[0].___href
         await axios.delete('/d'+ key)
         getFeedLength(text, fullText)
         putIndex(deletedPage,text, fullText)

         if(index === 0 && deletedPage !== 1){
           getPage(deletedPage -1, 0, text, fullText)
         } else {
           getPage(deletedPage, 0, text, fullText)
         }

      }
      alert('削除しました')

    } catch (e) {
      alert('削除できませんでした')

    }
  }
  
       return ( 
        <div>
        { feed.length > 0 ? 
        <div>
          <SearchList getFeedLength={getFeedLength}
          putIndex={putIndex} getPage={getPage} />
          <table>
              <tr>
                  <th>名前</th>
                  <th>メール</th>
                  <th>職業</th>
                  <th>住所</th>
                  <th>身長</th>
                  <th>誕生日</th>
                  <th>性別</th>
                  <th>チェック</th>
                  <th>セレクト</th>
                  <th>メモ</th>
              </tr>

                {feed.map((entry, index) => (
              <tr>   
                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.name}, data: entry, title: 'name', type: 'text'})}>
                     {entry.user!.name}</a></td>
                
                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                    state: {text:entry.user!.email},data: entry, title: 'email', type: 'text'})}>
                    {entry.user!.email}</a></td>

                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.job},data: entry, title:'job', type: 'text'})}>
                     {entry.user!.job}</a></td>
                
                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.address},data: entry, title: 'address', type: 'text'})}>
                     {entry.user!.address}</a></td>

                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.height},data: entry, title: 'height', type: 'text'})}>
                     {entry.user!.height}</a></td>

                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.birthday},data: entry, title: 'birthday'})}>
                     {entry.user!.birthday}</a></td>

                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.gender},data: entry, title: 'gender'})}>
                     {entry.user!.gender}</a></td>

                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.check},data: entry, title: 'check'})}>
                     {entry.user!.check}</a></td>
                
                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.select},data: entry, title: 'select'})}>
                     {entry.user!.select}</a></td>

                    <td><a onClick={() => props.history.push({pathname: '/EditProf',
                     state: {text:entry.user!.memo},data: entry, title: 'memo'})}>
                     {entry.user!.memo}</a></td>
                     <td><button onClick={() => deleteEntry(entry, index)}>削除</button></td>  
              </tr>
              ))} 
            </table>
          </div>
        :
         <p>登録されていません</p>
        }
        <Pagination
         getPage={(e:number) => getPage(e, 0, text, fullText)} 
         putIndex={(e:number) => putIndex(e, text,fullText)}
         setDeletedPage={setDeletedPage} getFeedLength={getFeedLength}
         feedLength={feedLength.current} lastPage={lastPage.current}
         lastIndexPage={lastIndexPage} text={text} fullText={fullText} />
        <button onClick={() => props.history.push('/RegisterProf')}>新規登録</button>
       </div> 
    )
  }

  export default withRouter(ListProf)
```

``getFeedLength``,``putIndex``,``getPage``の中身がだいぶ変わっていると思います。しかし、これはURLの中身が少し変わっているだけで、ページネーションの基本的な考え方は同じです。

下記は全文検索用のURLになります。

```tsx
 search_url.current = '&|user.name-rg-.*'+searchFullText+'.*'
        +'&|user.email-rg-.*'+searchFullText+'.*&|user.gender-rg-.*'+searchFullText+'.*'
        +'&|user.memo-rg-.*'+searchFullText+'.*&|user.birthday-rg-.*'+searchFullText+'.*'
        +'&|user.check-rg-.*'+searchFullText+'.*&|user.select-rg-.*'+searchFullText+'.*'
        +'&|user.job-rg-.*'+searchFullText+'.*&|user.address-rg-.*'+searchFullText+'.*'
        +'&|user.height-rg-.*'+searchFullText+'.*'

```
``user.name-rg-.*'+searchFullText+'.*'``のところで、指定したスキーマ名から入力された値を検索しています。``rg``を指定すると正規表現になるのですが、``-rg-.*{テキスト}.*``とすることで、 指定した値が含まれる検索をすることができます。他の検索方法で検索したい場合は以下をご覧ください。

eq : = (等しい)
lt : < (未満)
le : <= (以下)
gt : > (より大きい)
ge : >= (以上)
ne : != (等しくない)
rg : regex (正規表現に合致する)
fm : 前方一致
bm : 後方一致
ft : 全文検索
asc : 昇順ソート
desc : 降順ソート

URLをよく見ると、``&``のところが``&|``になっていると思います。これはOR検索といって、``&|``を使うことでいずれかの検索条件を満たしたものを表示してくれます。
今回の場合だと、全スキーマ名の中から入力された値を検索して、一致したものを表示してくれます。
このURLを``search_url``に格納して、それぞれの関数内で実行しています。

```tsx
 if(Object.keys(searchText).length === 0 && searchFullText === '') {
        const res = await axios.get('/d/foo?f&n='+ currentPage + '&l=5')
        setFeed(res.data)

      } else if(searchFullText !== '') {
        const res = await axios.get('/d/foo?f'+search_url.current+'&n='+ currentPage + '&l=5')
        setFeed(res.data)
        setFullText(searchFullText)

      } else if(Object.keys(searchText).length > 0) {
          const mapText =  Object.entries(searchText).map(([key, value]) => {
            return key+'-rg-.*'+value+'.*'
          })
          const str = mapText.join('&|')
          const res = await axios.get('/d/foo?f&|user.'+ str+'&n='+ currentPage + '&l=5')
          setFeed(res.data)
          setText(searchText)

        }
```
また、関数内で実行する処理は、検索フォームに入力されていない時の処理、全文検索フォームに入力されていた時の処理、複数の項目を検索するフォームに入力されていた時の処理の３種類あるため、if文で条件分岐させています。

次に複数の項目を検索するフォームに入力されていた時の処理を見ていきます。

```tsx
 } else if(Object.keys(searchText).length > 0) {
          const mapText =  Object.entries(searchText).map(([key, value]) => {
             return key+'-rg-.*'+value+'.*'
          })
          const str = mapText.join('&|')
          const res = await axios.get(`/d/foo?f&|user.${str}&c&l=*`)
```
``searchText``はオブジェクトになっているため、``map``でループさせて``mapText``に格納しています。ループさせたら、``mapText.join``でカンマ(,)で区切られいてる部分を``&|``に変えてつなぎ合わせます。

以上が検索フォームの実装になります。

#終わりに
今回はvte.cxを使った検索フォームの実装を行いました。他にも色々な検索方法があるので、状況によって使い分けられるように、また理解を深めていきたいと思います。
また、画面描画用の関数を実行する際に引数(``searchText``,``searchFullText``)が増えたのを忘れて指定するのを忘れてデバッグに結構時間を使ってしまいました。
``join``や``map``なども使ってReactだけではなく、JavaScriptの復習にもなりました。

以上。