Help us understand the problem. What is going on with this article?

CakePHP3で自作アプリを作ってみた part4 〜登録・編集機能の実装〜

More than 1 year has passed since last update.

7回に渡って記載していきます。
CakePHP3で自作アプリを作ってみた シリーズ
ちょうど折り返し地点にきました。

今回part4では

  • エンティティ説明 ←追加
  • 登録機能
  • 編集機能

らへんを解説したいと思います。

ブツはこちら公開済み。
https://cocktail-com.herokuapp.com/

データ登録が間に合わなかったので悪しからず。。。
少しづつ登録します。

今までの記事
CakePHP3で自作アプリを作ってみた part1 〜イントロ〜
https://qiita.com/m-hatano/items/61392c33fdbd49376747

CakePHP3で自作アプリを作ってみた part2 〜herokuでHello World!!まで〜
https://qiita.com/m-hatano/items/79480fa380ebc49c0209

CakePHP3で自作アプリを作ってみた part3 〜検索機能の実装〜
https://qiita.com/m-hatano/items/e85b8aa8fcaa0c3410f0


ここからは管理機能として現在実装されています。
そのため、セキュリティなども関わるので全部公開している訳ではないので悪しからず。

前回までは設定方法やルーティングとかCakePHP3チュートリアル的な説明もしましたが、今回は省きます。

今回からは、実装方法や技術的に苦労した点などに絞って記載したいと思います。

エンティティ説明

今更ながらテーブル構成を説明すると下記のようになります。

  • cocktails
    カクテルの情報が入っています
    画像URLもここ

  • cocktails_elements
    カクテルに紐づく材料(ウォッカとかソーダとか...)
    cocktailsとは1:nになります。
    elementsのidと必要な量を持っています。

  • cocktails_tags
    カクテルに紐づくタグ
    cocktailsとは1:nになります。
    tagsのidを持っています。

  • elements
    材料マスタ
    名前とカテゴリを持っています。

  • tags
    材料タグ
    タグ名を持っています。

登録機能

状態変化

ログイン画面(ログインする)→カクテル検索画面(サイドナビバーからカクテルを作るクリック)→カクテル作成画面(保存)→カクテル詳細

カクテル作成画面やその他管理画面はログイン中のみ表示されるように制御してあります。

機能説明していく

スクリーンショット 2018-03-07 21.43.10.png

スクショのみの公開ということで。
上から

画像のアップロードエリア

ここで画像を選択するとNo Imageの部分にJsでプレビューがかかります。

  • ビュー
    // 画像アップロード時のイベント
    $('.img').on('change', function(){
        var strFileInfo = $('.img')[0].files[0];
        $('.preview').remove();

        if(strFileInfo && strFileInfo.type.match('image.*')){
            $('.preview-area').append('<img class="preview"/>');

            fileReader = new FileReader();
            fileReader.onload = function(event){
                $('.preview').attr('src', event.target.result);
            }
            fileReader.readAsDataURL(strFileInfo);
        } else {
            $('.preview-area').append('<label class="error preview">プレビューできません。不正な画像ファイルがアップロードされました</label>');
        }
    });

~~ 中略 ~~

    <h2>画像をアップロード</h2>
    <?= $this->element('input_errors', ['name' => 'img']); ?>
    <?= $this->Form->file('img', ['class' => 'img']); ?>
    <span class="preview-area"><img class="preview" width="225" src="<?=$no_img_url ?>"/></span><!-- プレビューエリア -->

エレメントを当て込んでいるのはエラーをここに表示するために汎用的にエラー出力を作って、それを呼んでいます。
jQueryで.imgをonイベントで監視して変更がかかったらpreview-areaにプレビューとして読み込みます。

情報入力エリア

特になし、一般的な入力フォーム。サーバサイドでバリデーションをかけています。

材料選択エリア

一番時間取った部分かもしれません。

  • ビュー
   // ①セレクトボックスを未選択状態にする
    $('#category').prop('selectedIndex', -1);

    // ②カテゴリのセレクトボックス変更時の材料セレクトボックスの変更
    $('#category').on('change', function() {
        $('#elements').children().remove();
        var category = $('#category').val();
        <?php foreach ($elements_master as $elements):?>
        if("<?= $elements['category_kbn'] ?>" == category){
            $option = $("<option>").val("<?= $elements['id'] ?>").text("<?= $elements['name'] ?>");
            $("#elements").append($option);
        }
        <?php endforeach;?>
    });

    // ③材料追加ボタン押下イベント
    $('.submit-elements').on('click', function() {
        validate();
        if(!$('.cocktail-form').valid()){
            return;
        };

        var obj = new Object();
        // 選択済み材料を取得
        obj = makeSelectedList(obj);
        // 新しく追加する材料を追加
        obj['element_id'] = $('#elements').val();
        obj['amount'] = $('.amount-input').val();

        var csrf = $('input[name=_csrfToken]').val();
        $.ajax({
            url: '/cocktails/mergeElementsTable/',
            type: "POST",
            data: obj,
            beforeSend: function(xhr){
                xhr.setRequestHeader('X-CSRF-Token', csrf);
            }
        }).done(function(data){
            $('#elements-table').html(data);
        });
    });

    // ④材料削除ボタン押下イベント
    // #elements-table自体を監視対象にしておいて、第二引数で指定した要素にhitしたら関数が呼ばれる仕組み。
    $('#elements-table').on('click', '.delete-elements', function(){
        var obj = new Object();
        // 選択済み材料を取得
        obj = makeSelectedList(obj);
        // 削除する材料
        obj['del_index'] = $(this).closest('tr').find('.index').val();

        var csrf = $('input[name=_csrfToken]').val();
        $.ajax({
            url: '/cocktails/deleteElementsTable/',
            type: "POST",
            data: obj,
            beforeSend: function(xhr){
                xhr.setRequestHeader('X-CSRF-Token', csrf);
            }
        }).done(function(data){
            $('#elements-table').html(data);
        });
    });

~~ 中略 ~~

<h2 class="elements-title">材料を選択する</h2>
    <div class="elements-select__inner">
        <span>
            <select class="category" id="category" name="category" size="5">
            <?php foreach ($category_list as $key => $value):?>
                <option value="<?=$key?>" <?php if (($params['category']??'') == $key):?>selected<?php endif;?> ><?=$value?></option>
            <?php endforeach;?>
            </select>
        </span>
        <span>
            <select class="elements" id="elements" name="elements" size="5">
            <?php foreach ($elements_master as $elements):?>
                <option value="<?=$elements['id']?>" <?php if ($params['elements']??'' == $elements['id']):?>selected<?php endif;?> ><?=$elements['name']?></option>
            <?php endforeach;?>
            </select>
        </span>
        <div class="display-flex">
            <input type="text" class="amount-input" name="amount" value="<?= $params['amount'][0]??'' ?>" placeholder="量を入力..." />
            <button type="button" class="btn btn-default btn-sm submit-elements" >材料を追加</button>
        </div>
    </div>
    <h3>材料一覧</h3>
    <?= $this->element('input_errors', ['name' => 'element_id_selected']); ?>
    <table id="elements-table"><!-- Ajaxで生成 -->
        <?= $this->element('Cocktails/ajax_elements_table'); ?>
    </table>

ちょっと長いので小分けにします。

①セレクトボックスの初期値設定

そのままのため省略

②セレクトボックスの連動

    // ②カテゴリのセレクトボックス変更時の材料セレクトボックスの変更
    $('#category').on('change', function() {
        $('#elements').children().remove();
        var category = $('#category').val();
        <?php foreach ($elements_master as $elements):?>
        if("<?= $elements['category_kbn'] ?>" == category){
            $option = $("<option>").val("<?= $elements['id'] ?>").text("<?= $elements['name'] ?>");
            $("#elements").append($option);
        }
        <?php endforeach;?>
    });

~~  ~~

               <span><!-- プルダウン1 -->
            <select class="category" id="category" name="category" size="5">
            <?php foreach ($category_list as $key => $value):?>
                <option value="<?=$key?>" <?php if (($params['category']??'') == $key):?>selected<?php endif;?> ><?=$value?></option>
            <?php endforeach;?>
            </select>
        </span>
        <span><!-- プルダウン2 -->
            <select class="elements" id="elements" name="elements" size="5">
            <?php foreach ($elements_master as $elements):?>
                <option value="<?=$elements['id']?>" <?php if ($params['elements']??'' == $elements['id']):?>selected<?php endif;?> ><?=$elements['name']?></option>
            <?php endforeach;?>
            </select>
        </span>

一番めのプルダウンの選択に連動して次のプルダウンが動的に生成されます。
プルダウン連動のやり方はいくつか試してシックリくる 3. の実装にしました。
プルダウンの中身はDBに保存してあるので下記のパターンがありました。

  1. 連動元の変更イベントからajaxを着火させてDBまたはキャッシュから検索して返却する
  2. 画面表示でDBから全取得して、optionを生成しておく。連動元の変更イベントでoptionを一つづつ表示、非表示にする
  3. 画面表示でDBから全取得して、画面に返却だけしておく。連動元の変更イベントでoptionを一つづつ生成する

size="5"のセレクトボックスの中身(option)を生成するのですが、流し込むoptionの数が5個より少なくなるとoptionはhtmlに流し込めるのですが、表示されない現象が起きました。。。。(未解決)
chromeの幅を変更したりすると現れるのですが、原因不明です。
safariではattrやappend,htmlがうまく動作しない現象があるようですが、本件は不明。
とりあえずマスタの想定レコード的にそうなることはないためそのままにしておきました。

③材料追加イベント

    // ③材料追加ボタン押下イベント
    $('.submit-elements').on('click', function() {
        validate();
        if(!$('.cocktail-form').valid()){
            return;
        };

        var obj = new Object();
        // 選択済み材料を取得
        obj = makeSelectedList(obj);
        // 新しく追加する材料を追加
        obj['element_id'] = $('#elements').val();
        obj['amount'] = $('.amount-input').val();

        var csrf = $('input[name=_csrfToken]').val();
        $.ajax({
            url: '/cocktails/mergeElementsTable/',
            type: "POST",
            data: obj,
            beforeSend: function(xhr){
                xhr.setRequestHeader('X-CSRF-Token', csrf);
            }
        }).done(function(data){
            $('#elements-table').html(data);
        });
    });

~~  ~~

//材料追加ボタンのバリデーション
function validate(){
    $('.cocktail-form').validate({
        rules:  {
            elements: {required: true},
            amount: {required: true}
        },
        messages: {
            elements: {
                required: "材料を選択してください"
            },
            amount: {
                required: "量を入力してください"
            },
            onsubmit: false
        },
        //エラーメッセージ出力箇所調整
        errorPlacement: function(error){
            error.appendTo('.elements-title');
        },
    });
}

~~  ~~

            <button type="button" class="btn btn-default btn-sm submit-elements" >材料を追加</button>
        </div>
    </div>
    <h3>材料一覧</h3>
    <?= $this->element('input_errors', ['name' => 'element_id_selected']); ?>
    <table id="elements-table"><!-- $params['cocktails_elements']はAjaxで生成 -->
        <?php if(isset($params['cocktails_elements'])):?>
                        <?php foreach ($params['cocktails_elements'] as $key => $value):?>
                        <tr>
                                <input type="hidden" class="index" name="index" value="<?=$key?>" />
                              <input type="hidden" class="saved_id" name="saved_id[]" value="<?=                                                 $value['saved_id']??'' ?>" />
                              <input type="hidden" class="element_id_selected"                         name="element_id_selected[]" value="<?= $value['id'] ?>" />
                              <input type="hidden" class="amount_selected" name="amount_selected[]" value="<?=$value['amount']?>" />
                              <th class="table-header-md"><?=$category_list[$value['category_kbn']]?></th>
                              <td class="table-data-md"><?=$value['name']?></td>
                              <td class="table-data-sm"><?=$value['amount']?></td>
                              <td class="table-data-md"><button type="button" class="btn btn-default btn-sm delete-elements" >削除</button></td>
                        </tr>
                        <?php endforeach;?>
                <?php endif;?>
    </table>

「材料を追加ボタン」を押下するとjsで入力内容をバリデーションかけ、OKなら、ajaxで「選択済材料テーブル」と「削除ボタン」を生成してhtmlを流し込みます。
jsのバリデーションには下記の読み込みが必要です。

<script type="text/javascript" src="/js/jquery.validate.min.js"></script>

が必要です。

「選択済材料テーブル」を生成する部分はコントローラもあります。

  • CocktailsController#mergeElementsTable()
    /**
     * (Ajax用)材料追加用
     * POST /cocktails/mergeElementsTable
     */
    public function mergeElementsTable()
    {
        if (!$this->request->is('ajax')) {
            return $this->redirect('/');
        }
        $params = $this->request->getData();
        // 材料リストに、追加される材料を追加
        $params['element_id_selected'][] = $params['element_id'];
        $params['amount_selected'][] = $params['amount'];

        $cocktails = new Cocktails($params);
        $params['cocktails_elements'] = $cocktails->makeElementsTableList();

        $this->set(compact('params'));
        $this->render('/Element/Cocktails/ajax_elements_table','');
    }

選択済みの材料と量はhiddenに入れてあり、getData()でelement_id_selectedとamount_selectedとして取得できます。
そして、getData()した追加する材料のidと量をelement_id_selectedとamount_selectedに追加します。

$cocktails->makeElementsTableList();ではelement_id_selectedからelementsを取得します(材料名とカテゴリを表示するために必要)。

renderの第二引数を空にすることでレイアウトをなしにすることができます。

$this->render('/Element/Cocktails/ajax_elements_table','');

htmlが取得できますので、$('#elements-table').html(data);で流し込みます。

④材料削除イベント

// ④材料削除ボタン押下イベント
    // #elements-table自体を監視対象にしておいて、第二引数で指定した要素にhitしたら関数が呼ばれる仕組み。
    $('#elements-table').on('click', '.delete-elements', function(){
        var obj = new Object();
        // 選択済み材料を取得
        obj = makeSelectedList(obj);
        // 削除する材料
        obj['del_index'] = $(this).closest('tr').find('.index').val();

        var csrf = $('input[name=_csrfToken]').val();
        $.ajax({
            url: '/cocktails/deleteElementsTable/',
            type: "POST",
            data: obj,
            beforeSend: function(xhr){
                xhr.setRequestHeader('X-CSRF-Token', csrf);
            }
        }).done(function(data){
            $('#elements-table').html(data);
        });
    });

「削除ボタン」を押下するとajaxで「選択済材料テーブル」から削除された材料を除外したテーブルを生成してhtmlを流し込みます。

  • CocktailsController#deleteElementsTable()
    /**
     * (Ajax用)材料削除用
     * POST /cocktails/deleteElementsTable
     */
    public function deleteElementsTable(){

        if (!$this->request->is('ajax')) {
            return $this->redirect('/');
        }
        $params = $this->request->getData();
        // 材料リストから、削除される材料を削除
        array_splice($params['saved_id'], $params['del_index'], 1);
        array_splice($params['element_id_selected'], $params['del_index'], 1);
        array_splice($params['amount_selected'], $params['del_index'], 1);

        $cocktails = new Cocktails($params);
        $params['cocktails_elements'] = $cocktails->makeElementsTableList();

        $this->set(compact('params'));
        $this->render('/Element/Cocktails/ajax_elements_table','');
    }

del_indexに削除するidを入れて、選択済みidリストもろもろからarray_spliceで除外します。
その後の流れはmargeElemenstable()と同様です。

作成手順エリア

手書きのテキストエリアですが、上のボタンを押下すると定型文を追記できます。

    $('.processes').on('click', function(){
        var text = String($('#processes').val());
        switch($(this).val()){
            case 'soda':
                $('#processes').val(text + 'ソーダ以外の');
                break;
            case 'shake':
                $('#processes').val(text + '材料をシェークして、');
                break;

~~  ~~ 

<h2>作成手順</h2>
        <button type="button" class="btn btn-default btn-sm processes" value="soda" >ソーダ以外の</button>
        <button type="button" class="btn btn-default btn-sm processes" value="shake" >シェーク</button>

~~  ~~ 

        <div>
            <textarea name="processes" id="processes" cols="70" rows="5" ><?= $params['processes']??'' ?></textarea>
        </div>

特に難しいことはしていなく、textareaの値を取得して追記しています。
手打ちが大変だったのでついてに頻用する文言を乱立させただけなので特に面白味はないです。

タグづけエリア

キャッシュからマスタを並べています。
特に問題ないと思います。

保存ボタン

こいつを押下するとここで初めてフォームがサブミットされて登録処理が走ります。

    <?= $this->Form->create(null, ['type' => 'file', 'url' => "/cocktails/add", 'class' => 'cocktail-form']); ?>
        <?= $this->element('Cocktails/cocktail_input_area');?>
        <button type="submit" class="btn btn-default btn-full cancel" >保存する</button>
    <?= $this->Form->end() ?>

cocktail_input_areaは上記で解説していたhtmlで、編集画面でも使い回すのでエレメント化しています。

  • CocktailsController#add()
public function add()
    {
        $params = [];
        $errors = [];

        if($this->request->is('POST')){

            $params = $this->request->getData();
            // 登録時処理
            $cocktails = new Cocktails($params);
            $errors = $cocktails->valudateForCreate();
            // バリデエラーがない場合、登録を行う
            if (! $errors) {
                try {
                    $results = $cocktails->saveCocktail();
                    $this->Flash->success(MessageUtil::getMsg(MessageUtil::SAVE_SUCCESS));
                    // 画像を送ったのに、DBのURLが空ならエラー表示
                    if(!empty($this->params['img']['name']) && empty($results['img_url'])){
                        $this->Flash->error('画像のアップロードができませんでした。画像以外の保存は問題ありません。');
                    }
                    // 登録完了した場合、詳細画面を表示する
                    return $this->redirect('cocktails/view/' . $results['id']);

                } catch (\Exception $e) {
                    $this->logger->log($e->getMessage(), LOG_ERR);
                    $this->Flash->error(MessageUtil::getMsg(MessageUtil::SAVE_ERROR));
                }
            } else {
                $this->Flash->error(MessageUtil::getMsg(MessageUtil::VALIDATE_ERROR));
            }
            // バリデエラー、登録エラーがある場合、かつ材料リストがある場合、入力保持のため材料テーブルを作成する
            if (isset($params['element_id_selected'])) {
                $params['cocktails_elements'] = $cocktails->makeElementsTableList();
            }
        }
        // バリデエラー、Exception、画面表示からの遷移の場合は登録画面を表示する
        $this->set(compact('params', 'errors'));
    }

やっていることは、post以外(get)の場合は画面の表示します。
postの場合は、入力内容のバリデーションをかけ、パラメータ保存ロジックを実行します。

$results = $cocktails->saveCocktail();
  • Model/Cocktails/Cocktails#saveCocktail()
public function saveCocktail(){
        // S3にアップロードしてアップロード先のURLをセットする
        if(!empty($this->params['img']['name'])){
            // imgが送られてきていれば登録
        }
        // cocktailsの配列作成
        $data = [
            ~~  ~~
        ];
        // cocktails_elementsの配列作成
        for ($i = 0; $i < count($this->params['element_id_selected']); $i++){
            $data['cocktails_elements'][] = [
                'id' => $this->params['saved_id'][$i]??'',
                'cocktail_id' => $this->params['id'],
                'element_id' => $this->params['element_id_selected'][$i],
                'amount' => $this->params['amount_selected'][$i],
            ];
        }
        // cocktails_tagsの配列作成
        if(isset($this->params['tag_id'])){
            foreach ($this->params['tag_id'] as $tag_id){
                $data['cocktails_tags'][] = [
                    ~~  ~~
                ];
            }
        }
        // エンティティとアソシエーションを作成
        $cocktailsTable = TableRegistry::get('Cocktails');
        $cocktailsElementsTable = TableRegistry::get('CocktailsElements');
        $cocktailsTagsTable = TableRegistry::get('CocktailsTags');

        $connection = ConnectionManager::get('default');
        $connection->begin();
        try{
            // patchEntityのみではアソシエーション削除の場合、削除されない
            // そのためCocktailsElements, CocktailsTagsを全削除して入れ直す
            $cocktailsElementsTable->deleteAll(['cocktail_id' => $this->params['id']]);
            $cocktailsTagsTable->deleteAll(['cocktail_id' => $this->params['id']]);

            $cocktail = $cocktailsTable->newEntity();
            $cocktail = $cocktailsTable->patchEntity($cocktail, $data, [
                'associated' => ['CocktailsElements', 'CocktailsTags'],
            ]);
            $result = $cocktailsTable->save($cocktail);
            $connection->commit();

        } catch (\Exception $e){

            $connection->rollback();
            throw new \Exception($e->getMessage());
        }

        return $result;
    }

保存ロジックではS3へのアップロードを実行し、Cocktails, CocktailsElements, CocktailTags用の配列を作成しています。

            // patchEntityのみではアソシエーション削除の場合、削除されない
            // そのためCocktailsElements, CocktailsTagsを全削除して入れ直す
            $cocktailsElementsTable->deleteAll(['cocktail_id' => $this->params['id']]);
            $cocktailsTagsTable->deleteAll(['cocktail_id' => $this->params['id']]);

$cocktail = $cocktailsTable->newEntity();
            $cocktail = $cocktailsTable->patchEntity($cocktail, $data, [
                'associated' => ['CocktailsElements', 'CocktailsTags'],
            ]);

これでinsertもupdateも対応しています。
配列をアソシエーションと対応する状態に生成しておかなければいけないのが少し面倒です。

注意しなければならないのは、patchEntityはアソシエーションも例外なくinsertかupdateのみです。
1:多の「多」のレコードは削除してくれません。あくまでもpatchなんです。

そのおかげでupdate時に問題点があり、「多」のレコードが保存のたびに倍に増えていく現象になりましたww
Cocktailsはidを配列に設定する場合はupdate、しない場合はinsertと切り替えれます。
しかし、Cocktailsにアソシエーションで紐づくテーブルCocktailsElements, CocktailsTagsはそれぞれのidが設定されてない場合にはinsertされちゃいます。

なので「多」のやつらは全消し→insertにしました。。。。
せめてもidがわかるものは配列に設定しておきました。

'id' => $this->params['saved_id'][$i]??'',

ひとつづつテーブルごとのEntityを生成しCocktailsのEntityにsetしていく方法もありましたが配列に慣れているのでこっちにしました。

編集機能

うまく作れたため登録と同じものを使いまわせましたw
ビューのフォームの部分とコントローラのget部分が追加になったくらいです

  • CocktailsController#edit($id)
~~  ~~

        if($this->request->is('GET')){
            // 画面表示。idから検索して登録画面を表示する
            $cocktails = new Cocktails();
            $results = $cocktails->fetchCocktailDetail($id);

            $params = $results['cocktail'];
            $params['cocktails_elements'] = $results['cocktails_elements'];
            // 付いているタグIDは配列にして返却
            $params['tag_id'] = [];
            foreach ($results['cocktail']['cocktails_tags'] as $cocktails_tag){
                $params['tag_id'][] = $cocktails_tag['tag_id'];
            }

        }
~~  ~~


以上です!
オリジナルで作成したので結構手間がかかりました。
もう少しCakePHP3っぽく作れば削減できるような気がします。
管理機能なので正直ここまでこだわらなくてもよかった感ありますが、完成したしいいでしょうw

part4はここまでで次回は

  • 画像アップロード

を解説します。
最後までお付き合いありがとうございました!

参考にさせていただいた記事はこちら
https://book.cakephp.org/3.0/ja/orm/associations.html
https://qiita.com/sainome_7/items/370da7b4964569ce786a
https://qiita.com/uedatakeshi/items/fd3de8e2b770798aacfc

m-hatano
スタートアップwebエンジニア。大手SIerから3人スタートアップまで幅広経験。 自称フルスタックエンジニア(ビジネス、技術両方面)
https://note.com/hatamasa
anyinc
any株式会社のエンジニアチームです
http://anyinc.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away