Vue.js と本気で戦ってみた ~ 第1章 : Laravel の RESTful API を使って管理画面を実装

Vue.js で 管理画面を実装

Cities History Project を勢いに任せて Laravel 化してます。

「SEO 的には JavaScript による動的ページより、静的 HTML のほうがいい」って噂を耳にしたので、フロントは Laravel で普通に HTML を出力し、管理画面ではがっつり Vue.js で動的にしようと企み、まんまとハマりました。

やりたいことは、固定ページの管理画面
フロントで /page/{slug} でアクセスすると固定ページが表示され、ユーザ認証すると /admin/pageページの管理画面が開きます。
管理画面内では RESTful API で

  • GET /api/admin/page : ページ一覧
  • GET /api/admin/page/{id} : ページ詳細
  • PUT /api/admin/page/{id} : ページ更新

という定義をしました。(なお API はトークン認証をしないと使えないようにしています)
これを Vue.jsAxios で組んだのですが、まぁ Vue のコンポーネントとかイベントの伝播とかで苦労した……
結局、どうしても分からなかった箇所は jQuery に逃げました。(特にモーダルダイアログの表示/非表示を切り替えるところ)

そんなこんなで、ひとまず完成しました。
動作イメージはこんな感じ。

とりあえず /admin/page で表示されるビューをまるごと貼ります。親テンプレートの admin_layout.blade.php は大事なところだけ。

<!DOCTYPE html>
<html lang="ja">

{{-- authorized user --}}
@set( $user, \Illuminate\Support\Facades\Auth::user() )

{{-- generate title --}}
@set( $global_title,
    isset($title) && $title
        ? $title . ' - ' . Config::get('chp.title')
        : Config::get('chp.title') . '(' . Config::get('chp.title_ja') . ')'
)

    <head>
        <meta charset="utf-8" />
        <title>{{$global_title}}</title>

<-- 中略:いろんな meta タグ -->

        <link rel="stylesheet" href="{{ route('top') }}/css/app.css" />
    </head>
    <body>
        <header class="navbar fixed-top navbar-expand-md navbar-light">
            <h1>
                <a class="navbar-brand" href="{{ route('top') }}">
                    Cities History Project
                </a>
            </h1>

<-- 中略:グローバルナビ -->

        </header>

        <div class="main-container">

<-- 中略:パンくず -->

            <section class="under-breadcrumb">

                <main role="main" class="container">
@yield('child')

                </main>

            </section>
        </div><!-- .main-container -->

<-- 中略:フッタ -->

        <script src="{{ route('top') }}/js/admin.js"></script>
        <script src="{{ route('top') }}/js/geoshape.js"></script>
@yield('script')
    </body>
</html>

@extends('admin_layout')

{{-- authorized user --}}
@set( $user, \Illuminate\Support\Facades\Auth::user() )

@section('child')
            <head>
                <h2>Page manage</h2>
            </head>
<example-component></example-component>
            <section id="admin-pages">
                <div class="row">
                    <page-summary
                        class="col-md-6"
                        v-for="page in pages"
                        v-bind:key="page.id"
                        v-bind:page="page"
                        ref="summary"
                        @edit="modal"
                    ></page-summary>
                </div>

                <edit-page-modal
                        ref="modal"
                        @redraw="init"
                ></edit-page-modal>
            </section>
@endsection

@section('script')
            <script>
var vuePages;

Vue.component("page-summary", {
    props: ["page"],
    template: `
        <article>
            <div class="card" @click="edit(page.id)">
                <header class="card-header">
                    <h3 class="card-title" v-html="page.title"></h3>
                    <div>slug : <span v-html="page.slug"></span></div>
                </header>
                <div class="card-body">
                    <div v-html="page.content"></div>
                </div>
            </div>
        </article>
    `,
    methods: {
        edit: function (id) {
            this.$emit("edit", id);
        }
    }
});

Vue.component("edit-page-modal", {
    data: function () {
        return {
            id: 0,
            title: "",
            slug: "",
            content: "",
            errors: {
                title: "",
                slug: "",
                content: "",
            }
        }
    },
    template: `
        <div id="edit-page"
                class="modal fade"
                tabindex="-1"
                role="dialog"
                aria-labelledby="edit-page-label"
                aria-hidden="true">
            <div class="modal-dialog" role="document">
                <div class="modal-content">
                    <div class="modal-header">
                        <input type="hidden"
                                name="id"
                                id="page-id"
                                v-bind:value="id" />
                        <h5 class="modal-title" id="edit-page-label">Edit page</h5>
                        <button type="button"
                                class="close"
                                data-dismiss="modal"
                                aria-label="Close">
                            <span aria-hidden="true">×</span>
                        </button>
                    </div>
                    <form v-on:submit.prevent="save(id)">
                        <div class="modal-body">
                            <div class="form-group" v-bind:class="{ 'is-invalid': errors.slug }">
                                <label for="page-title">Title</label>
                                <input type="text"
                                        class="form-control"
                                        id="page-title"
                                        v-model="title"
                                        v-bind:class="{ 'is-invalid': errors.title }"
                                    />
                                <label class="invalid-feedback"
                                        role="alert"
                                        for="page-title"
                                        v-if="errors.title"
                                        v-html="errors.title"></label>
                            </div>
                            <div class="form-group" v-bind:class="{ 'is-invalid': errors.slug }">
                                <label for="page-slug">Slug</label>
                                <input type="text"
                                        class="form-control"
                                        id="page-slug"
                                        v-model="slug"
                                        v-bind:class="{ 'is-invalid': errors.slug }"
                                    />
                                <label class="invalid-feedback"
                                        role="alert"
                                        for="page-slug"
                                        v-if="errors.slug"
                                        v-html="errors.slug"></label>
                            </div>
                            <div class="form-group" v-bind:class="{ 'is-invalid': errors.slug }">
                                <label for="page-content">Content</label>
                                <textarea
                                        class="form-control"
                                        id="page-content"
                                        v-model="content"
                                        v-bind:class="{ 'is-invalid': errors.content }"
                                        rows="10"
                                    ></textarea>
                                <label class="invalid-feedback"
                                        role="alert"
                                        for="page-content"
                                        v-if="errors.content"
                                        v-html="errors.content"></label>
                            </div>
                        </div>
                        <div class="modal-footer">
                            <button type="button"
                                    class="btn btn-secondary"
                                    data-dismiss="modal"
                                >Cancel</button>
                            <button type="submit"
                                    class="btn btn-primary"
                                >Save</button>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    `,
    methods: {
        init: function (id) {
            console.log("modal::init");

            // initialize
            this.id = 0;
            this.title = "";
            this.slug = "";
            this.content = "";
            this.errors.title = "";
            this.errors.slug = "";
            this.errors.content = "";

            // get detail
            var url = "{{ route('page.show', ['page' => ':id']) }}".replace(":id", id);
            console.log("get", url);
            url += "?api_token=" + "{{ $user->api_token }}";
            axios.get(url)
            .then(response => {
                this.id = response.data.id;
                this.title = response.data.title;
                this.slug = response.data.slug;
                this.content = response.data.content;
            })
            .catch(response => {
                alert(response);
            });

            // show modal
            var el = "#" + this.$el.id;
            $(el).modal();
        },
        save: function() {
            console.log("modal::save", this.id);

            // update
            var url = "{{ route('page.update', ['page' => ':id']) }}".replace(":id", this.id);
            console.log("put", url);
            url += "?api_token=" + "{{ $user->api_token }}";
            axios.put(url, {
                id: this.id,
                title: this.title,
                slug: this.slug,
                content: this.content
            })
            .then(response => {
                // hide modal
                var el = "#" + this.$el.id;
                $(el).modal('hide');

                // redraw list
                this.$emit("redraw");
            })
            .catch(response => {
                var status = response.response.status;
                if (status == 400) {
                    var errors = response.response.data.errors;
                    this.errors.title = ( errors.title ? errors.title[0] : "" );
                    this.errors.slug = ( errors.slug ? errors.slug[0] : "" );
                    this.errors.content = ( errors.content ? errors.content[0] : "" );
                }
            });
            return false;
        },
        redraw: function (id) {
            this.$emit("init");
        }
    }
});

window.onload = function() {
    // list view
    vuePages = new Vue({
        el: "#admin-pages",
        data: {
            pages: []
        },
        component: [
            "page-summary"
        ],
        methods: {
            init: function() {
                console.log("vuePages::init");

                // get list
                this.pages = [];
                var url = "{{ route('page.index') }}";
                console.log("get", url);
                url += "?api_token=" + "{{ $user->api_token }}";
                axios.get(url)
                .then(response => {
                    for (idx in response.data) {
                        this.pages.push(response.data[idx]);
                    }
                })
                .catch(response => {
                    alert(response);
                });
            },
            modal: function(id) {
                console.log("vuePages::modal", id);
                this.$refs.modal.init(id);
            }
        }
    });
    vuePages.init();
};
            </script>
@endsection

なげえ。
とりあえずキモとしては、ページ全体を総括する Vue オブジェクトと、一覧表示の各ページごとのコンポーネント編集フォームのモーダルダイアログコンポーネントを実装。レイアウトは Bootstrap に任せました。

  1. ページが表示されたら vuePages::init() が走って一覧取得の API を叩き、記事ごとに page-summary コンポーネントを表示
  2. page-summary がクリックされると page-summary::edit() から vuePages::modal() へとイベントが伝播し、edit-page-modal::init() でモーダルダイアログコンポーネントを表示
  3. edit-page-modal::init() 内でページ詳細取得の API を叩き、入力フォームの各項目にデフォルト値をセット
  4. edit-page-modal::save() を実行するとページ更新の API を実行し、エラーがなければモーダルを非表示にして vuePages::init() で再度一覧取得

こんな構造になっています。

ソースがあまりに長すぎるので、次回は Laravel Mix でファイルを分割しようと思います。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です