Ikeda's Blog

Laravelでブログサイトを作る 17. フロントページ

はじめに

今回の、フロント側作成によって、ブログは一応の完成となります。
カテゴリを管理し、記事を作成編集でき、それを表示する。ごくごくシンプルな構成ですが、ブログとしての最低限が備わった状態になります。

そしてその作業ですが、実はほとんどありません。

Controller

app/Http/Controllers/ArticleController.php

一覧

ルーティングの際に設定した、一覧表示画面を用意します。
新着順、親子カテゴリの絞り込み、検索。前々回前回で、管理画面で用意したものとほとんど同じです。
違う点は、以下の2つ。

  • 削除済み記事を出さない→getList()の第2引数をfalseにしてあげればOKです
  • フロント用のテンプレートを使う→listView()の第2引数をfrontに変更
    /**
     * 記事一覧
     *
     * @return mixed
     */
    public function list() {
        // ブログ記事一覧を取得
        $articleData = $this->article->getList($this->ADMIN_LIST_NUM, false);

        return $this->listView($articleData, 'front');
    }

    /**
     * 親カテゴリ別記事一覧
     *
     * @param string $parent
     * @return mixed
     */
    public function parentList(string $parent) {
        // ブログ記事一覧を取得
        $articleData = $this->article->getList($this->ADMIN_LIST_NUM, false, (int)$parent);

        return $this->listView($articleData, 'front');
    }

    /**
     * 子カテゴリ別記事一覧
     *
     * @param string $child
     * @return mixed
     */
    public function childList(string $child) {
        // ブログ記事一覧を取得
        $articleData = $this->article->getList($this->ADMIN_LIST_NUM, false, 0, $child);

        return $this->listView($articleData, 'front');
    }

    /**
     * 記事一覧(ワード検索)
     *
     * @param Request $request
     * @return mixed
     */
    public function searchList(Request $request) {
        // ブログ記事一覧を取得
        $articleData = $this->article->getList($this->ADMIN_LIST_NUM, false, 0, 0, $request['search']);

        return $this->listView($articleData, 'front');
    }

記事詳細

① 記事取得
記事テーブルと親カテゴリテーブルのJoinは、カテゴリ名を取得するための連結です。後述の子カテゴリ同様に、記事の終わりにカテゴリ別一覧へのリンク生成に使います。
一覧のようにget()を使うのではなく、first()を使っているのは、主キーで検索しているからです。

② 記事と関連付けられている子カテゴリの情報を取得
記事のIDが固定なので、そこに関連する子カテゴリを取得します。関連付けテーブルと子カテゴリテーブルをJoinして、カテゴリ名も取得します。

    /**
     * 記事詳細
     *
     * @param string $id
     * @return mixed
     */
    public function content(string $id) {
        // ① 記事取得
        $articleData = $this->article
            // 親カテゴリについても取得
            ->leftJoin(
                'parent_categories',
                function ($join) {
				    $join->on('articles.parent', '=', 'parent_categories.id');
                }
            )
            // ID指定して1件分のデータを取得
            ->where('articles.id', $id)->first();

        if ($articleData === null) {
            // 記事データが見つからない場合はエラー出力する
            return $this->error('warning', '記事が見つかりません。');
        }

        // ② 記事と関連付けられている子カテゴリの情報を取得
        $articleChildrenData = $this->articleChild
            ->leftJoin('child_categories', function ($join) {
                $join->on('article_children.children', '=', 'child_categories.id');
            })->where('article_children.article', $id)->get();

        // 親カテゴリを取得
        $parentCategoryList = $this->parentCategory->orderBy('number', 'asc')->orderBy('id', 'asc')->get();

        // 子カテゴリは、キーを親カテゴリIDにした連想配列にする
        $data = $this->childCategory->orderBy('number', 'asc')->orderBy('id', 'asc')->get();
        $childCategoryList = [];
        foreach ($data as $item) {
            $childCategoryList[$item->parent][] = $item;
        }

        return view('front.article', [
            'articleData' => $articleData,
            'articleChildrenData' => $articleChildrenData,
            'parentCategoryList' => $parentCategoryList,
            'childCategoryList' => $childCategoryList,
        ]);
    }

    /**
     * エラー出力
     *
     * @param string $type
     * @param string $message
     * @return mixed
     */
    public function error(string $type, string $message) {
        // $typeに特定文字列以外を指定させない
        if (!in_array($type, ['information', 'caution', 'warning'])) {
            $type = 'information';
        }

        return view('front.error', [
            'type' => $type,
            'message' => $message,
        ]);
    }

テンプレート

記事詳細

resources/views/front/article.blade.php
あまり、特筆することはありません。記事のタイトルと本文を表示し、属するカテゴリの表示と、それぞれの絞り込み一覧へのリンクを生成しています。

@extends('front.app')

@section('title')
    {{ $articleData->title }}
@endsection

@section('main')
    <article>
        <section class="article">
            {{-- 記事タイトル --}}
            <h2>{{ $articleData->title }}</h2>

            {{-- 本文 --}}
            {!! $articleData->body !!}

            {{-- 親子カテゴリ --}}
            <div class="articleCategory">
                <ul>
                    <li>
                        <a href="{{ route('list_parent', ['id' => $articleData->parent]) }}">
                            {{ $articleData->name }}
                        </a>
                    </li>
                    @foreach ($articleChildrenData as $articleChildren)
                        <li>
                            <a href="{{ route('list_child', ['id' => $articleChildren->children]) }}">
                                {{ $articleChildren->name }}
                            </a>
                        </li>
                    @endforeach
                </ul>
            </div>

            {{-- 公開日時 --}}
            <p class="articleDate">{{ $articleData->created_at }}</p>
        </section>
    </article>
@endsection

エラー

resources/views/front/error.blade.php
$typeはエラーレベルを指定するイメージです。ひとまず「通知(information)」「注意(caution)」「警告(warning)」を想定しています。

@section('title', 'エラー')

@section('main')
    <article>
        <section class="article">
            <div class="{{ $type }}"><p>{{ $message }}</p></div>
        </section>
    </article>
@endsection

Laravelでブログサイトを作る 16. 管理機能・記事一覧の絞り込みと検索

はじめに

前回で記事一覧を作成した続きになります。

Model

app/Models/Article.php
前回作成した一覧取得メソッドを拡張します。
引数に、今回追加分が未指定の場合の初期値を設定しているので、既存の呼び出し箇所に変更は不要となります。

// ...省略...

    /**
     * 一覧取得
     *
     * @param int    $num      件数
     * @param bool   $delete   削除済みを含めるか(true => 含める)
     * @param int    $parent   親カテゴリID 【今回追加】
     * @param int    $children 子カテゴリID 【今回追加】
     * @param string $search   検索ワード   【今回追加】
     * @return mixed
     */
    public function getList(
        int $num,
        bool $delete = false,
        int $parent = 0,
        int $children = 0,
        string $search = '')
    {
        $article = $this;

        // 【今回追加】親カテゴリが指定されている場合、記事テーブルのカラムと一致するものを抽出する
        if ($parent !== 0) {
            $article = $article->where('articles.parent', '=', $parent);
        }

        // 【今回追加】子カテゴリが指定されている場合、テーブルをJOINする
        if ($children !== 0) {
            $article = $article
                // 記事テーブルと、記事-子カテゴリ関連付けテーブルをJOIN
                ->leftJoin('article_children', function ($join) {
                    $join->on('articles.id', '=', 'article_children.article')
                        ->where('article_children.deleted_at', '=', NULL);
                })
                // 子カテゴリテーブルと、記事-子カテゴリ関連付けテーブルをJOIN
                ->leftJoin('child_categories', function ($join) {
                    $join->on('child_categories.id', '=', 'article_children.children');
                })
                // 子カテゴリの一致するものを抽出する
                ->where('child_categories.id', '=', $children);
        }

        // 【今回追加】検索ワードが指定されている場合
        if ($search !== '') {
            $article = $article
                // タイトルか本文のどこかに検索ワードが含まれていれる記事を抽出する
                ->where(function($query) use ($search) {
                    $query->where('articles.title', 'like', '%'.$search.'%')
                        ->orWhere('articles.body', 'like', '%'.$search.'%')
                    ;
                });
        }

        // 削除済みを含む場合は`withTrashed()`
        if ($delete !== true) {
            $article = $article->withTrashed();
        }

        // ソートは、新しい記事を上位表示
        $article = $article->orderBy('open', 'desc');

        // 取得
        return $article->paginate($num);
    }

// ...省略...

Controller

ルーティングで設定したメソッドを用意し、それぞれのパラメータを変更して一覧取得します。
その後は、表示用メソッドlistView()を実行するだけです。

// ...省略...


    /**
     * 管理画面・親カテゴリ別記事一覧
     *
     * @param string $parent
     * @return mixed
     */
    public function adminParentList(string $parent) {
        // ブログ記事一覧を取得
        $articleData = $this->article->getList($this->ADMIN_LIST_NUM, true, (int)$parent);

        return $this->listView($articleData, 'admin');
    }

    /**
     * 管理画面・子カテゴリ別記事一覧
     *
     * @param string $child
     * @return mixed
     */
    public function adminChildList(string $child) {
        // ブログ記事一覧を取得
        $articleData = $this->article->getList($this->ADMIN_LIST_NUM, true, 0, $child);

        return $this->listView($articleData, 'admin');
    }

    /**
     * 管理画面・記事一覧(ワード検索)
     *
     * @param Request $request
     * @return mixed
     */
    public function adminSearchList(Request $request) {
        // ブログ記事一覧を取得
        $articleData = $this->article->getList($this->ADMIN_LIST_NUM, true, 0, 0, $request['search']);

        return $this->listView($articleData, 'admin');
    }

// ...省略...

テンプレート

resources/views/admin/app.blade.php
サイドメニューに、検索ボックスとカテゴリの絞り込みリンクを設置します。

// ...省略...

    <section class="aside">
        <h3>検索</h3>
        <div id="searchWrapper">
            <form action="{{ route('admin_article_list_search') }}" method="post" name="searchForm">
                {{ csrf_field() }}
                <input type="text" id="searchTxt" name="search" placeholder="サイト内検索" />
                    <a href="javascript:searchForm.submit()" id="searchBtn">
                        <i class="fas fa-search"></i>
                    </a>
            </form>
        </div>
    </section>

    @if (isset($parentCategoryList))
    <section class="aside">
        <h3>カテゴリ</h3>
        <ul>
            @foreach ($parentCategoryList as $parent)
                <li>
                    <a href="{{ route('admin_article_list_parent', ['parent' => $parent->id]) }}">
                        {{ $parent->name }}
                    </a>
                    @if (isset($childCategoryList[$parent->id]))
                        <ul>
                            @foreach ($childCategoryList[$parent->id] as $child)
                                <li>
                                    <a href="{{ route('admin_article_list_child', ['child' => $child->id]) }}">
                                        {{ $child->name }}
                                    </a>
                                </li>
                            @endforeach
                        </ul>
                    @endif
                </li>
            @endforeach
        </ul>
    </section>
    @endif

// ...省略...

Laravelでブログサイトを作る 15. 管理機能・記事一覧画面

はじめに

記事の一覧表示は、管理側とフロント側でほとんど同じものを用意することになります。
相違点は以下の2点ですので、それを踏まえて作成します。

  • 削除済みの内容を表示するかどうか
  • 使用するテンプレート

Model

app/Models/Article.php
一覧取得のためのメソッドをModelに記載します。
今回は「削除済みを含むか」だけですが、今後は「絞り込み(親カテゴリ、子カテゴリ)」「検索」についてもコチラを編集する予定です

<?php

// ...省略...

class Article extends Model
{
    // ...省略...

    /**
     * 一覧取得
     *
     * @param int    $num    件数
     * @param bool   $delete 削除済みを含めるか(true => 含める)
     * @return mixed
     */
    public function getList(int $num, bool $delete = false) {
        $article = $this;

        // 削除済みを含む場合は`withTrashed()`
        if ($delete !== true) {
            $article = $article->withTrashed();
        }

        // ソートは、新しい順に表示
        $article = $article->orderBy('open', 'desc');

        // 指定件数を取得
        return $article->paginate($num);
    }
}

Controller

app/Http/Controllers/ArticleController.php

「一覧取得する」「表示する」という工程に分けて考えます。後に、絞り込みや検索した際に、「表示する」アルゴリズムは共通なので、listView()を作成し、表示処理を行っています。

<?php

// ...省略...

class ArticleController extends Controller
{
    // ...省略...

    private $ADMIN_LIST_NUM = 30; // 管理画面での記事一覧表示件数

    // ...省略...

    /**
     * 管理画面・記事一覧
     *
     * @return mixed
     */
    public function adminList() {
        // ブログ記事一覧を取得
        $articleData = $this->article->getList($this->ADMIN_LIST_NUM, true);

        return $this->listView($articleData, 'admin');
    }

    /**
     * 記事の一覧表示
     *
     * @param object $articleData
     * @param string $type
     * @return mixed
     */
    private function listView(object $articleData, string $type = 'front') {
        // 親カテゴリを取得
        $parentCategoryList = $this->parentCategory->orderBy('number', 'asc')->orderBy('id', 'asc')->get();

        // 子カテゴリは、キーを親カテゴリIDにした連想配列にする
        $data = $this->childCategory->orderBy('number', 'asc')->orderBy('id', 'asc')->get();
        $childCategoryList = [];
        foreach ($data as $item) {
            $childCategoryList[$item->parent][] = $item;
        }

        return view($type . '.articleList', [
            'articleData' => $articleData,
            'parentCategoryList' => $parentCategoryList,
            'childCategoryList' => $childCategoryList,
        ]);
    }
}

テンプレート

resources/views/admin/articleList.blade.php
ページの移動には、独自に作成したテンプレートを使用しています。これについては、後述します。

@extends('admin.app')

@section('adminTitle', '記事一覧')

@section('paging')
    <nav>
        {{ $articleData->links('pagination') }}
    </nav>
@endsection

@section('adminMain')
    <article>
        <section class="article">
            <h2>記事一覧</h2>

            <table>
                <tr>
                    <th style="width:200px;">サムネイル</th>
                    <th>タイトル</th>
                </tr>
                @foreach ($articleData as $item)
                    {{-- 本文中の最初の画像をサムネイルとして扱う --}}
                    <?php preg_match('/<img.+src=[\'"](?P<src>.+?)[\'"].*>/i', $item->body, $image); ?>
                    <tr {!! ($item->private === 1) ? 'style="background-color: #cccccc;"' : '' !!}>
                        <td><img src="{{ $image['src'] ?? '/img/no_image.jpg' }}" /></td>
                        <td>
                            {{-- タイトル(クリックしたら編集画面へ) --}}
                            <a href="{{ route('admin_article_edit', ['id' => $item->id]) }}">{{ $item->title }}</a>
                            {{-- タイトルと本文を水平線で区切る --}}
                            <hr />
                            {{-- 本文は、タグを除いた先頭200文字だけを表示させる --}}
                            {!! mb_substr(strip_tags($item->body), 0, 200) !!}
                        </td>
                    </tr>
                @endforeach
            </table>
        </section>
    </article>
@endsection

独自定義のページネーション

resources/views/pagination.blade.php

先述のテンプレートに出てきた、{{ $articleData->links('pagination') }}で、ページネーションのHTMLを作成しています。

$articleDataは、Modelで検索条件を付けた後に$article->paginate($num);の戻り値が入っています。

このpaginate()の返り値に対して、->links()で、基本的なページネーションができます。テンプレート名を渡してやれば、対象のファイルを使ってくれます。

@if ($paginator->hasPages())
    <ul class="pagination">
        {{-- 現在が1ページ目でなければ、最初へ遷移するリンクを設置 --}}
        @if (!$paginator->onFirstPage())
            <li>
                <a href="{{ $paginator->url(1) }}">最初</a>
            </li>
        @endif

        {{-- ページ番号を使用したリンクを設置 --}}
        @foreach ($elements as $element)
            @if (is_array($element))
                @foreach ($element as $page => $url)
                    @if ($page === $paginator->currentPage())
                        {{-- 現在のページ番号には、リンクを設置しない --}}
                        <li class="active" aria-current="page"><span>{{ $page }}</span></li>
                    @elseif (($page - 2) <= $paginator->currentPage() && ($page + 2) >= $paginator->currentPage())
                        {{-- 2ページ前~2ページ次までのページ番号を表示 --}}
                        <li><a href="{{ $url }}">{{ $page }}</a></li>
                    @endif
                @endforeach
            @endif
        @endforeach

        {{-- Next Page Link --}}
        @if ($paginator->hasMorePages())
            <li>
                <a href="{{ $paginator->url($paginator->lastPage()) }}">最後</a>
            </li>
        @endif
    </ul>
@endif

Laravelでブログサイトを作る 14. 管理機能・記事編集画面から新規登録まで

今回のゴール

記事を作成し、保存するところまで。
編集することも考慮した作りにすること。

Controller

インスタンスの生成等

useに記載した、Illuminate\Support\Facades\DBは、トランザクション処理のためです。
(親子カテゴリでは実装漏れです。後で対応しました)

<?php

namespace App\Http\Controllers;

use App\Models\Article;
use App\Models\ArticleChild;
use App\Models\ChildCategory;
use App\Models\ParentCategory;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class ArticleController extends Controller
{
    protected $article;
    protected $articleChild;
    protected $childCategory;
    protected $parentCategory;

    /**
     * 新しいコントローラインスタンスの生成
     *
     * @param Article $article
     * @param ArticleChild $articleChild
     * @param ChildCategory $childCategory
     * @param ParentCategory $parentCategory
     * @return void
     */
    function __construct(
Article $article,
ArticleChild $articleChild,
ChildCategory $childCategory,
ParentCategory $parentCategory)
{ $this->article = $article; $this->articleChild = $articleChild; $this->childCategory = $childCategory; $this->parentCategory = $parentCategory; }

入力画面

editメソッドの最後、画面表示はeditView()を作成して行っています。
これは、"更新に失敗した時"の場合に、変更した内容をそのままに再表示させたいので、ArticleとArticleChildだけが違う入力画面表示として用意しました。

  • 通常:DBのArticleとArticleChildを取得して、画面表示
  • 更新失敗:入力内容をそのままに、画面表示

としたいので、「画面表示」の部分だけ共通化した形です。

    /**
     * 記事内容入力画面
     *
     * @param string|null $id
     * @return mixed
     */
    public function edit(string $id = null) {
        // 記事情報を取得(存在しない場合はnull)
        $articleData = $this->article->withTrashed()->where('id', $id)->first();

        // 記事と子カテゴリの連携データは、テンプレートで使いやすいように子カテゴリIDをキーにしておく
        $list = $this->articleChild->where('article', $id)->get();
        $articleChildData = [];
        foreach ($list as $item) {
            $articleChildData[$item->children] = 'on';
        }

        // 編集画面表示
        return $this->editView($articleData, $articleChildData);
    }

    /**
     * 記事内容入力画面の生成
     *
     * @param $articleData
     * @param $articleChildData
     * @param $message
     * @return mixed
     */
    private function editView($articleData, $articleChildData, $message = '') {
        // 親子カテゴリを取得(削除状態のカテゴリを設定しないように、withTrashed()はつけない)
        $parentCategoryData = $this->parentCategory->orderBy('number', 'asc')->orderBy('id', 'asc')->get();
        $childCategoryData = $this->childCategory->orderBy('number', 'asc')->orderBy('id', 'asc')->get();

        return view('admin.articleEdit', [
            'articleData' => $articleData,
            'articleChildData' => $articleChildData,
            'parentCategoryData' => $parentCategoryData,
            'childCategoryData' => $childCategoryData,
            'message' => $message,
        ]);
    }

更新処理

Articleの登録更新は、カテゴリと同様に対応できます。
ArticleChildについては、一旦すべての子カテゴリを取得し、それぞれにチェックが入っているかを確認しています。
チェックボックスで子カテゴリを選択する予定なので、チェックしない(関連付けない)場合、情報がPOSTされません。なので、全部の子カテゴリのうち、チェックされているものを有効に、チェック情報が存在しないものを無効に、という考えです。

    /**
     * 記事情報更新
     *
     * @param Request $request
     * @return mixed
     */
    public function update(Request $request)
    {
        // 入力値取得
        $id = $request['id'];
        $tiitle = $request['title'];
        $body = $request['body'];
        $parent = $request['parent'];
        $childList = $request['child'];
        $open = $request['open'];
        $private = $request['private'];

        // トランザクション開始
        DB::beginTransaction();

        try {
            // 記事データ保存
            $articleData = $this->article->withTrashed()->where('id', $id)->first();
            if ($articleData === null) {
                $articleData = new Article();
            }
            $articleData->title = $tiitle;
            $articleData->body = $body;
            $articleData->parent = $parent;
            $articleData->open = $open;
            $articleData->private = $private;
            $articleData->save();

            // 記事-子カテゴリの連携(削除検出のため、子カテゴリ全件取得)
            $childCategoryData = $this->childCategory->withTrashed()->get();

            foreach ($childCategoryData as $item) {
                // 連携データを取得
                $articleChildData = $this->articleChild->withTrashed()
                    ->where('article', $articleData->id)->where('children', $item->id)->first();

                if (isset($childList[$item->id]) && $childList[$item->id] === 'on') {
                    // チェックが入っている => insert or restore
                    if ($articleChildData === null) {
                        $articleChildData = new ArticleChild();
                        $articleChildData->article = $articleData->id;
                        $articleChildData->children = $item->id;
                        $articleChildData->save();
                    } else {
                        $articleChildData->restore();
                    }
                } else if ($articleChildData !== null) {
                    // チェックが入っていない => 以前に作られていたら削除
                    $articleChildData->delete();
                }
            }

            // コミット
            DB::commit();

            // 完了表示
            //return redirect()->route('admin_article_list');
            return redirect()->route('admin_article_edit', ['id' => $articleData->id]);
        } catch (\Exception $e) {
            // 処理失敗時はロールバック
            DB::rollback();

            // 入力内容をそのままに、編集画面再表示
            return $this->editView(
$articleData,
$childList,
'DBの書き込みに失敗しました。[' . $e->getMessage() . ']'
); } } }

テンプレート

resources/views/admin/articleEdit.blade.php

{{ $hoge ?? 'fuga' }}は、「変数$hogeが存在するなら$hogeの内容を表示し、なければfugaを表示」します。
子カテゴリのlabelタグに記載しているdata-parentは、JavaScript側での制御に利用します。属する親カテゴリIDを持たせ、表示/非表示を切り替えます。

@extends('admin.app')

@section('adminTitle', '記事作成編集')

@section('adminMain')
    <article>
        @if ($message !== '')
            <section class="article">
                <p>{{ $message }}</p>
            </section>
        @endif

        <section class="article">
            <h2>記事作成編集</h2>

            <form method="post">
                <input type="hidden" name="id" value="{{ $articleData->id ?? '' }}" />
                <h3>記事タイトル</h3>
                <input type="text" name="title" value="{{ $articleData->title ?? '' }}" />
                <h3>親カテゴリ</h3>
                <select name="parent" id="articleEditParent">
                    @foreach ($parentCategoryData as $parent)
                        <option value="{{ $parent->id }}" {{ ($articleData !== null && $articleData->parent == $parent->id) ? 'selected' : '' }}>
                            {{ $parent->name }}
                        </option>
                    @endforeach
                </select>
                <h3>子カテゴリ</h3>
                <div id="childArea">
                    @foreach ($childCategoryData as $child)
                        <label class="articleEditChild" data-parent="{{ $child->parent }}">
                            <input type="checkbox" name="child[{{ $child->id }}]" value="on"
                                {{ isset($articleChildData[$child->id]) ? 'checked' : '' }} />{{ $child->name }}
                        </label>
                    @endforeach
                </div>
                <h3>記事本文</h3>
                <textarea name="body">{{ $articleData->body ?? '' }}</textarea>
                <h3>公開日時</h3>
                <input type="text" name="open" value="{{ $articleData->open ?? date('Y/m/d H:i:s') }}" />
                <h3>状態</h3>
                <div style="margin-bottom: 30px;">
                    <label><input type="radio" name="private" value="0" {{ ($articleData === null || $articleData->private !== 1) ? 'checked' : '' }} />公開/公開待ち</label>
                    <label><input type="radio" name="private" value="1" {{ ($articleData !== null && $articleData->private === 1) ? 'checked' : '' }} />非公開</label>
                </div>

                {{-- CSRFトークン --}}
                {{ csrf_field() }}

                <input type="button" id="update" value="{{ isset($articleData->id) ? '更新' : '登録' }}" />
            </form>
        </section>
    </article>

    <script>
        $(document)
            .on('click', '#update', function(){
                $(this).parents('form').attr('action', '{{ route('admin_article_update') }}').attr('target', '');
                $(this).parents('form').submit();
            })
        ;
    </script>
@endsection

JavaScript

public/js/admin.js
ページ読み込み時と、親カテゴリを変更した時に、changeArticleEditSubArea()を実行します。
この関数では、子カテゴリ全てを確認し、data-parentの値が親カテゴリIDと一致したら表示するようにしています。
また、非表示にする時にはチェックを外しています。「見えてないけどチェックされている」を防ぐためです。

// ページ読み込み時の処理
$(document).ready(function(){
    // 親カテゴリの初期表示にあわせて、子カテゴリの表示を切り替える(記事作成画面のみ)
    if ($('#articleEditParent').length > 0) {
        changeArticleEditSubArea($('#articleEditParent').val());
    }
});

$(document)
    .on('change', '#articleEditParent', function() {
        // 親カテゴリが変更された際、子カテゴリの表示を切り替える
        changeArticleEditSubArea($(this).val());
    })
;

/**
 * 記事作成画面の時、子カテゴリの表示を親カテゴリにあわせたものにする
 *
 * @param parentID
 */
function changeArticleEditSubArea(parentID) {
    $('.articleEditChild').each(function(){
        if ($(this).attr('data-parent') === parentID) {
            $(this).css('display', 'inline-block');
        } else {
            $(this).css('display', 'none');
            $('input', this).prop('checked', false).change(); // 非表示にするチェックボックスはチェックを外す
        }
    });
}

Laravelでブログサイトを作る 13. 管理機能・子カテゴリの管理

はじめに

親カテゴリを基にして作成します。
一覧登録更新を作成した前提で進めますので、ご留意ください。

Controller

app/Http/Controllers/ChildCategoryController.phpの内容は、まずParentCategoryController.phpをコピーします。
その後、親カテゴリには無かった、カラム「parent」について追加します。
変更箇所はコード内にコメントを入れていますが、目的は以下の2点です。

  1. 画面表示する際に、セレクトメニューで使用するための「全親カテゴリ」データを取得しテンプレートへ渡す
  2. 登録更新する情報に、関連する親カテゴリのIDを追加
<?php

namespace App\Http\Controllers;

use App\Models\ChildCategory; // ※今回追加
use App\Models\ParentCategory;
use Illuminate\Http\Request;

class ChildCategoryController extends Controller
{
    /**
     * @var ChildCategory
     */
    protected $childCategory; // ※今回追加

    /**
     * @var ParentCategory
     */
    protected $parentCategory;

    /**
     * 新しいコントローラインスタンスの生成 ※ChildCategoryを追加
     *
     * @param ChildCategory $childCategory
     * @param ParentCategory $parentCategory
     * @return void
     */
    function __construct(ChildCategory $childCategory, ParentCategory $parentCategory) {
        $this->childCategory = $childCategory;
        $this->parentCategory = $parentCategory;
    }

    /**
     * 一覧画面表示
     *
     * @return mixed
     */
    public function list() {
        // 子カテゴリデータの一覧 ※今回追加
        $childCategoryData = $this->childCategory->withTrashed()->orderBy('id', 'asc')->get();

        // 親カテゴリデータの一覧 ※説明用のコメントを削除して一行にまとめ
        $parentCategoryData = $this->parentCategory->withTrashed()->orderBy('id', 'asc')->get();

        return view('admin.childCategory', [
            'childCategoryData' => $childCategoryData, // ※今回追加
            'parentCategory' => $parentCategoryData,
        ]);
    }

    /**
     * 子カテゴリ レコード総更新
     *
     * @param Request $request
     * @return mixed
     */
    public function update(Request $request) {
        $flg = true; // $flgがfalseになるまでループします
        for ($i = 1;$flg;$i++) {
            // ...省略...

            // 入力値を取得
            $id = $request['id_' . $i];
            $name = $request['name_' . $i];
            $number = $request['number_' . $i];
            $delete = $request['delete_' . $i];
            $parent = $request['parent_' . $i]; // ※今回追加

            // ...省略...

            // idがあるなら上書き、なければ新規作成
            $childCategoryData = $this->childCategory->withTrashed()->where('id', $id)->first();
            if ($childCategoryData === null) {
                $childCategoryData = new ChildCategory();
            }
            $childCategoryData->name = $name;
            $childCategoryData->number = $number;
            $childCategoryData->parent = $parent; // ※今回追加
            $childCategoryData->save();

            // ...省略...
        }

        return redirect()->route('admin_child');
    }

テンプレート

resources/views/admin/childCategory.blade.php
コチラも、ベースとしてparentCategory.blade.phpをコピーして使います。

  • テキストの「親」を「子」に変更
  • formのaction属性を、parentからchildに変更
  • 一覧表示でforeachに使用していた変数を$childCategoryData
  • 表に親カテゴリの項目を追加
@extends('admin.app')

@section('adminTitle', '子カテゴリ管理') {{-- 親→子 --}}

@section('adminMain')
    <article>
        <section class="article">
            <h2>子カテゴリ管理</h2> {{-- 親→子 --}}

            <form action="{{ route('admin_parent_update') }}" method="post"> {{-- parent→child --}}
                <table>
                    <tr>
                        <th>カテゴリ名</th>
                        <th>親カテゴリ</th> {{-- 追加 --}}
                        <th>表示順</th>
                        <th>削除</th>
                    </tr>
                    <tr id="model">
                        <td>
                            <input type="hidden" id="id" value="" />
                            <input type="text" id="name" value="" />
                        </td>
                        <td> {{-- 追加 --}}
                            <select id="parent">
                                @foreach ($parentCategory as $parentItem)
                                    <option value="{{ $parentItem->id }}">{{ $parentItem->name }}</option>
                                @endforeach
                            </select>
                        </td>
                        <td><input type="number" id="number" value="" /></td>
                        <td>
                            <label>
                                <input type="checkbox" id="delete" value="on" />削除
                            </label>
                        </td>
                    </tr>
                    <?php $cnt = 1; ?>
                    @foreach ($childCategoryData as $item) {{-- parent→child --}}
                        <tr>
                            <td>
                                <input type="hidden" name="id_{{ $cnt }}" value="{{ $item->id }}" />
                                <input type="text" name="name_{{ $cnt }}" value="{{ $item->name }}" />
                            </td>
                            <td> {{-- 追加 --}}
                                <select name="parent_{{ $cnt }}">
                                    @foreach ($parentCategory as $parentItem)
                                        <option value="{{ $parentItem->id }}"
                                            {{ ($item->parent === $parentItem->id) ? 'selected' : '' }}>
                                            {{ $parentItem->name }}
                                        </option>
                                    @endforeach
                                </select>
                            </td>
                            <td>
                                <input type="number" name="number_{{ $cnt }}"
                                    value="{{ $item->number }}" /></td>
                            <td>
                                <label>
                                    <input type="checkbox" name="delete_{{ $cnt }}" value="on"
                                        {{ ($item->deleted_at !== null) ? 'checked' : '' }} />削除
                                </label>
                            </td>
                        </tr>
                        <?php $cnt++; ?>
                    @endforeach
                    <tr>
                        <td>
                            <input type="hidden" name="id_{{ $cnt }}" value="" />
                            <input type="text" name="name_{{ $cnt }}" value="" />
                        </td>
                        <td> {{-- 追加 --}}
                            <select name="parent_{{ $cnt }}">
                                @foreach ($parentCategory as $parentItem)
                                    <option value="{{ $parentItem->id }}">{{ $parentItem->name }}</option>
                                @endforeach
                            </select>
                        </td>
                        <td><input type="number" name="number_{{ $cnt }}" value="{{ $cnt }}" /></td>
                        <td>
                            <label>
                                <input type="checkbox" name="delete_{{ $cnt }}" value="on" />削除
                            </label>
                        </td>
                    </tr>
                </table>

                <input type="hidden" id="max" value="{{ $cnt }}" />

                {{-- CSRFトークン --}}
                {{ csrf_field() }}

                <input type="button" value="+ 入力枠追加" id="addInput" />
                <input type="submit" value="更新" />
            </form>
        </section>
    </article>
@endsection

JavaScript

public/js/admin.js
入力枠追加の際に実行されるJavaScriptにも、親カテゴリについてを追記します。
lengthを使用して、「要素がある時」としているので、親子カテゴリの処理をできるだけ共通化しています。

$(document)
    // 親カテゴリの入力枠追加
    .on('click', '#addInput', function(){
        // 次の番号を算出
        const max = $('#max');
        const nextNum = parseInt(max.val()) + 1;
        max.val(nextNum);

        // id=modelになっているtr要素を、idを削除した上でcloneする
        const clone = $('#model').clone(true).removeAttr('id');

        // cloneしたtr内の各name属性を変更し、識別用のidを削除しておく
        $('#name', clone).attr('name', 'name_' + nextNum).removeAttr('id');
        $('#number', clone).attr('name', 'number_' + nextNum).val(nextNum).removeAttr('id');
        $('#id', clone).attr('name', 'id_' + nextNum).removeAttr('id');
        $('#delete', clone).attr('name', 'delete_' + nextNum).removeAttr('id');
        if ($('#parent', clone).length > 0) { // ※追加
            $('#parent', clone).attr('name', 'parent_' + nextNum).removeAttr('id');
        }

        // テーブルに追加
        clone.appendTo('table');
    })
;