LoginSignup

This article is a Private article. Only a writer and users who know the URL can access it.
Please change open range to public in publish setting if you want to share this article with other users.

More than 3 years have passed since last update.

React + Firestoreで普通の?Paginationをしてみる(応用編)

Last updated at Posted at 2019-11-22

基礎編のつづき。
検索機能やCSVダウンロード機能をつけてみたいと思います。

検索機能

検索機能をつけてみます。もちろんPagination機能も正常に動くようにします。

完成イメージ

下記のような感じ。

スクリーンショット 2019-11-22 10.24.24.png

上記画面にはCSV Downloadも付いてますが。。。後でやります。

実装方針(手順)

検索機能は下記のように実現したいと思います。

  • HTMLに検索フォームを追加(検索条件リセットボタンも追加)
  • 検索keywordをstateに保存
  • keywordが設定されていれば、where付きでクエリを実行

実装

なんか冗長でいやですが、下記のようにしました。

App.js
import React from 'react';
import './App.css';
import { db } from './Firebase';
import moment from 'moment';

import { Table, Form, FormGroup, Label, Input, Button, Pagination, PaginationItem, PaginationLink } from 'reactstrap';

class App extends React.Component {

    state = {
        items: [], //検索結果
        limit: 10,
        lastVisible: null,
        history: [],
+        keyword: '',
    }

    componentDidMount = () => {
        this.getData();
    }

    //初期データ取得用
    getData = async () => {

+        //keywordの有無でクエリを分岐
+        let initialQuery = null;
+        if (this.state.keyword === '') {
+            initialQuery = db.collection('members')
+                .orderBy('createdAt', 'desc')
+                .limit(this.state.limit);
+        } else {
+            initialQuery = db.collection('members')
+                .where('keywords', 'array-contains-any', [this.state.keyword]) //keywordで検索
+                .orderBy('createdAt', 'desc')
+                .limit(this.state.limit);
        }

        //取得
        const snapshot = await initialQuery.get();

        //各行を取得
        const docs = snapshot.docs.map(doc => doc.data());

        //最後の行(オブジェクトを記憶しておく)
        const lastVisible = snapshot.docs[docs.length - 1];

        //Prevでの戻り先をhistoryに記憶
        const startVisible = snapshot.docs[0];
        let history = [...this.state.history];
        history.push(startVisible);

        //state更新
        this.setState({
            items: docs,
            lastVisible: lastVisible,
            history: history,
        });

    }

    //次のデータを取得
    getNextData = async () => {

+        let nextQuery = null;
+        if (this.state.keyword === '') {
+            nextQuery = db.collection('members')
+                .orderBy('createdAt', 'desc')
+                .startAfter(this.state.lastVisible) //記録してある前回の最後以降から取得する
+                .limit(this.state.limit);
+        } else {
+            nextQuery = db.collection('members')
+                .where('keywords', 'array-contains-any', [this.state.keyword]) //keywordで検索
+                .orderBy('createdAt', 'desc')
+                .startAfter(this.state.lastVisible) //記録してある前回の最後以降から取得する
+                .limit(this.state.limit);
+        }

        const snapshot = await nextQuery.get();

        //データが1つ以上なければ何もしない(最後のページ対策)
        if (snapshot.size < 1) {
            return null;
        }

        const docs = snapshot.docs.map(doc => doc.data());

        const lastVisible = snapshot.docs[docs.length - 1];

        //Prevでの戻り先をhistoryに記憶
        const startVisible = snapshot.docs[0];
        let history = [...this.state.history];
        history.push(startVisible);

        this.setState({
            items: docs,
            lastVisible: lastVisible,
            history: history,
        });
    }

    getPrevData = async () => {

        if (this.state.history.length <= 1) {
            return null;
        }

+        let prevQuery = null;
+        if (this.state.keyword === '') {
+            prevQuery = db.collection('members')
+                .orderBy('createdAt', 'desc')
+                .startAt(this.state.history[this.state.history.length - 2]) //最後から2つ目のページに戻る
+                .limit(this.state.limit);
+        } else {
+            prevQuery = db.collection('members')
+                .where('keywords', 'array-contains-any', [this.state.keyword]) //keywordで検索
+                .orderBy('createdAt', 'desc')
+                .startAt(this.state.history[this.state.history.length - 2]) //最後から2つ目のページに戻る
+                .limit(this.state.limit);
+        }

        const snapshot = await prevQuery.get();

        if (snapshot.size < 1) {
            return null;
        }

        const docs = snapshot.docs.map(doc => doc.data());

        const lastVisible = snapshot.docs[docs.length - 1];

        //戻る際に最後のhistoryを削除
        const history = [...this.state.history];
        history.pop();

        this.setState({
            items: docs,
            lastVisible: lastVisible,
            history: history,
        });
    }

    //nextがクリックされたとき
    handleNext = () => {
        this.getNextData();
    }

    //prevがクリックされたとき
    handlePrev = () => {
        this.getPrevData();
    }

+    //text change対応
+    haneleChangeText = (e) => {
+        this.setState({ [e.target.name]: e.target.value });
+    }

+    //search
+    handleSearch = () => {
+        this.getData();
+    }

+    //reset
+    handleReset = async () => {
+        await this.setState({ keyword: '' }); //setが終わらないままgetData()が走るのを防止
+        await this.getData();
+    }

    render() {
        return (
            <div className="container">
                <h3 className="my-4">Pagination sample.</h3>

+                <Form inline className="mb-4">
+                    <FormGroup className="mb-2 mr-sm-2 mb-sm-0">
+                        <Label for="keyword" className="mr-sm-2">Keyword</Label>
+                        <Input type="text" name="keyword" id="keyword" value={this.state.keyword} onChange={this.haneleChangeText} ></Input>
+                    </FormGroup>
+                    <Button onClick={this.handleSearch} color="primary">検索</Button>
+                    <Button onClick={this.handleReset} className="ml-sm-2">リセット</Button>
+                </Form>

                <Table striped>
                    <thead>
                        <tr>
                            <th>ID</th>
                            <th>UID</th>
                            <th>Name</th>
                            <th>Age</th>
                            <th>Address</th>
                            <th>CreatedAt</th>
                        </tr>
                    </thead>
                    <tbody>
                        {
                            this.state.items.map(doc => (
                                <tr key={doc.docId}>
                                    <td>{doc.id}</td>
                                    <td>{doc.docId}</td>
                                    <td>{doc.name}</td>
                                    <td>{doc.age}</td>
                                    <td>{doc.address}</td>
                                    <td>{moment(doc.createdAt.seconds * 1000).format('YYYY-MM-DD HH:mm:ss')}</td>
                                </tr>
                            ))
                        }
                    </tbody>
                </Table>

                <Pagination>
                    <PaginationItem>
                        <PaginationLink previous className="mr-3" onClick={this.handlePrev} />
                    </PaginationItem>
                    <PaginationItem>
                        <PaginationLink next onClick={this.handleNext} />
                    </PaginationItem>
                </Pagination>

            </div>
        );
    }
}

export default App;

CSV Dowload機能の追加

管理画面では必須となるCSVのダウンロード機能をつけてみたいと思います。簡単にCSVダウンロード機能を実現できるreact-csvというモジュールがあるので利用してみたいと思います。

実装方針(手順)

CsvLinkは設置してクリックするだけでCSVダウンロード機能が付けられる便利な機能なのですが、ボタンクリックでダウンロードさせたり、ダンロードする値をカスタマイズする際には不便な時もあります。今回は参照を設定し、クリックを別の関数内で制御してみます。

また、CSVにはPaginationは必要ないため、とりあえず全てのデータをダウンロードするようにしてみます。

  • Formに[CSV Download]ボタンを設置し、onClick()でgetDownloadData()をキック。
  • ダウンロード用のデータは別途staetにてdownloadItemsとして管理。
  • getDownloadData()内でlimitをかけてないCSV用のデータを取得してsteteに設定。
  • csvLinkのclick()をキックし、ダウンロードを開始させる
App.js
import React from 'react';
import './App.css';
import { db } from './Firebase';
import moment from 'moment';

import { Table, Form, FormGroup, Label, Input, Button, Pagination, PaginationItem, PaginationLink } from 'reactstrap';

import { CSVLink } from 'react-csv';

class App extends React.Component {

    state = {
        items: [], //検索結果
        limit: 10,
        lastVisible: null,
        history: [],
        keyword: '',
+        downloadItems: [], //ダウンロード対象データ
    }

+    csvLink = React.createRef(); //CsvLinkをプログラムで制御するためのrefを設定

    componentDidMount = () => {
        this.getData();
    }

    //初期データ取得用
    getData = async () => {

        //クエリ
        let initialQuery = null;
        if (this.state.keyword === '') {
            initialQuery = db.collection('members')
                .orderBy('createdAt', 'desc')
                .limit(this.state.limit);
        } else {
            initialQuery = db.collection('members')
                .where('keywords', 'array-contains-any', [this.state.keyword]) //keywordで検索
                .orderBy('createdAt', 'desc')
                .limit(this.state.limit);
        }

        //取得
        const snapshot = await initialQuery.get();

        //各行を取得
        const docs = snapshot.docs.map(doc => doc.data());

        //最後の行(オブジェクトを記憶しておく)
        const lastVisible = snapshot.docs[docs.length - 1];

        //Prevでの戻り先をhistoryに記憶
        const startVisible = snapshot.docs[0];
        let history = [...this.state.history];
        history.push(startVisible);

        //state更新
        this.setState({
            items: docs,
            lastVisible: lastVisible,
            history: history,
        });

    }

    //次のデータを取得
    getNextData = async () => {

        let nextQuery = null;
        if (this.state.keyword === '') {
            nextQuery = db.collection('members')
                .orderBy('createdAt', 'desc')
                .startAfter(this.state.lastVisible) //記録してある前回の最後以降から取得する
                .limit(this.state.limit);
        } else {
            nextQuery = db.collection('members')
                .where('keywords', 'array-contains-any', [this.state.keyword]) //keywordで検索
                .orderBy('createdAt', 'desc')
                .startAfter(this.state.lastVisible) //記録してある前回の最後以降から取得する
                .limit(this.state.limit);
        }

        const snapshot = await nextQuery.get();

        //データが1つ以上なければ何もしない(最後のページ対策)
        if (snapshot.size < 1) {
            return null;
        }

        const docs = snapshot.docs.map(doc => doc.data());

        const lastVisible = snapshot.docs[docs.length - 1];

        //Prevでの戻り先をhistoryに記憶
        const startVisible = snapshot.docs[0];
        let history = [...this.state.history];
        history.push(startVisible);

        this.setState({
            items: docs,
            lastVisible: lastVisible,
            history: history,
        });
    }

    getPrevData = async () => {

        if (this.state.history.length <= 1) {
            return null;
        }

        let prevQuery = null;
        if (this.state.keyword === '') {
            prevQuery = db.collection('members')
                .orderBy('createdAt', 'desc')
                .startAt(this.state.history[this.state.history.length - 2]) //最後から2つ目のページに戻る
                .limit(this.state.limit);
        } else {
            prevQuery = db.collection('members')
                .where('keywords', 'array-contains-any', [this.state.keyword]) //keywordで検索
                .orderBy('createdAt', 'desc')
                .startAt(this.state.history[this.state.history.length - 2]) //最後から2つ目のページに戻る
                .limit(this.state.limit);
        }

        const snapshot = await prevQuery.get();

        if (snapshot.size < 1) {
            return null;
        }

        const docs = snapshot.docs.map(doc => doc.data());

        const lastVisible = snapshot.docs[docs.length - 1];

        //戻る際に最後のhistoryを削除
        const history = [...this.state.history];
        history.pop();

        this.setState({
            items: docs,
            lastVisible: lastVisible,
            history: history,
        });
    }

    //nextがクリックされたとき
    handleNext = () => {
        this.getNextData();
    }

    //prevがクリックされたとき
    handlePrev = () => {
        this.getPrevData();
    }

    //text change対応
    haneleChangeText = (e) => {
        this.setState({ [e.target.name]: e.target.value });
    }

    //search
    handleSearch = () => {
        this.getData();
    }

    //reset
    handleReset = async () => {
        await this.setState({ keyword: '' }); //setが終わらないままgetData()が走るのを防止
        await this.getData();
    }

+    //csv download(関数全体を追加)
    getDownloadData = async () => {

        let donwloadQuery = null;
        //keywordの有無で分岐(limit無し)
        if (this.state.keyword === '') {
            donwloadQuery = db.collection("members")
                .orderBy('createdAt', 'desc');
        } else {
            donwloadQuery = db.collection("members")
                .where('keywords', 'array-contains-any', [this.state.keyword])
                .orderBy('createdAt', 'desc');
        }

        const snapshot = await donwloadQuery.get();

        //ダウロード用のデータ生成
        let docs = [];
        snapshot.docs.map(doc => {
            docs.push({
                docId: doc.data().docId,
                name: doc.data().name,
                address: doc.data().address,
                datetime: moment(doc.data().createdAt.seconds * 1000).format('YYYY-MM-DD hh:mm:ss') //フォーマット変換
            });
        })

        //値をセットし、callbackでcsvLinkのClickを実行
        await this.setState({ downloadItems: docs }, () => {
            this.csvLink.current.link.click();
        });
    }

    render() {
        return (
            <div className="container">
                <h3 className="my-4">Pagination sample.</h3>

                <Form inline className="mb-4">
                    <FormGroup className="mb-2 mr-sm-2 mb-sm-0">
                        <Label for="keyword" className="mr-sm-2">Keyword</Label>
                        <Input type="text" name="keyword" id="keyword" value={this.state.keyword} onChange={this.haneleChangeText} ></Input>
                    </FormGroup>
                    <Button onClick={this.handleSearch} color="primary">検索</Button>
                    <Button onClick={this.handleReset} className="ml-sm-2">リセット</Button>
+                    <Button onClick={this.getDownloadData} className="ml-sm-5" size="sm" color="info">CSV Download</Button>
                </Form>

                {/* 表示はせず機能だけ利用 */}
+                <CSVLink
+                    data={this.state.downloadItems}
+                    filename="data.csv"
+                    ref={this.csvLink}
+                    target="_blank"
+                />

                <Table striped>
                    <thead>
                        <tr>
                            <th>ID</th>
                            <th>UID</th>
                            <th>Name</th>
                            <th>Age</th>
                            <th>Address</th>
                            <th>CreatedAt</th>
                        </tr>
                    </thead>
                    <tbody>
                        {
                            this.state.items.map(doc => (
                                <tr key={doc.docId}>
                                    <td>{doc.id}</td>
                                    <td>{doc.docId}</td>
                                    <td>{doc.name}</td>
                                    <td>{doc.age}</td>
                                    <td>{doc.address}</td>
                                    <td>{moment(doc.createdAt.seconds * 1000).format('YYYY-MM-DD HH:mm:ss')}</td>
                                </tr>
                            ))
                        }
                    </tbody>
                </Table>

                <Pagination>
                    <PaginationItem>
                        <PaginationLink previous className="mr-3" onClick={this.handlePrev} />
                    </PaginationItem>
                    <PaginationItem>
                        <PaginationLink next onClick={this.handleNext} />
                    </PaginationItem>
                </Pagination>

            </div>
        );
    }
}

export default App;

その他(落ち穂拾い)

戻り先が内場合は[<]ボタンはdisableにする

初期画面等に置いては戻るボタンは不要か機能させるべきではありません。disabledにしてみます。
やりかたはいくつかありますが、historyに履歴の数が1以下であることを条件にcssを適用するようにします。

                <Pagination>
+                    <PaginationItem className={this.state.history.length <= 1 ? 'disabled' : null}>
                        <PaginationLink previous className="mr-3" onClick={this.handlePrev} />
                    </PaginationItem>
                    <PaginationItem>
                        <PaginationLink next onClick={this.handleNext} />
                    </PaginationItem>
                </Pagination>

次のページがない場合はalertで知らせる

<ボタン同様、disabledにしたいところですが、そのためには「最後のメージであること」を最後のページにきるときに認識しなくてはなりません。このためには「全件数」を事前に数える必要があり、避けたいところです。ですので、次のページに値が無いことがわかった時点でalertを表示するようにしてみます。

getNextData()中のsnapshotの中身判断の箇所にalert()を入れてみます。

        //データが1つ以上なければ何もしない(最後のページ対策)
        if (snapshot.size < 1) {
+            alert("これ以上データが無いようです。");
            return null;
        }

備考

ソースはgithubにもあげてます。

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