WordPress+Vue.JSで作るジョブボード(2) データの更新

さて、前回の記事ではVue.JSの基本的な動作を管理画面で実装する例をお見せしたが、今回は次のようなことにチャレンジしてみよう。

  • 投稿を更新する画面の作成
  • 強い制約のある編集画面の設計
  • カスタムエンドポイントの実装

「強い制約のある編集」とは?

さて、いきなり新ワードを導入してしまったが、「強い制約のある編集」とは筆者が勝手に考えた言葉で、一般的な用語ではない。まずはソクラテスにならい、言葉の定義から始めよう。勘の言い方は「制約のある/なし」「制約の強い/弱い」といった対立概念がパッと思いつくだろう。

そもそもであるが、WordPressはすべての投稿を編集できるわけではない。ユーザーには「役割」と「権限」という概念があり、それによってできることとできないことがある。「そこから?」と驚かれる読者もいるかもしれないが、何事も基本は重要、千里の道も一歩からだ。

CodexのRoles and Capabilitiesに記載された権限。

さて、こうしたそれぞれの役割があることでWordPressはCMSとして利用できているのだが、実はユーザーごとの権限のパターンは少ない。投稿者Aが投稿者Bの投稿を編集できないという「投稿の所有権」という概念は存在し、実際にそれはデータベース上の wp_posts.post_author という項目として存在するのだが、たとえば「特定の投稿者に対して編集権を与える」「一定の期間が過ぎると誰でも投稿できるようになる」といった機能は拡張機能なしに実現しない。

議論が拡散しないよう、いったん具体的な例に落としこもう。いま筆者が作りたいのは「ジョブボード」なのだが、以下の要件を実現する機能がWordPressにあるかどうか、3秒ほど考えてみてほしい。

  1. 一度公開した投稿の本文およびタイトルを募集者が編集・削除することはできないが、追記は許可する。これは「求人」という業務に伴う社会的責任の実装である。たとえば公開したのちに募集があったとして、勝手に給与や待遇などを書き換えられてしまってはCapital Pの沽券に関わるからである。
  2. 募集者は任意の項目を変更できるが、その変更の差分についてはCapital Pの管理者が確認することなく反映可能である。なぜかというと、Capital Pは人事のスペシャリストではないので、変更の妥当性を検証する能力はない(がんばればできるが、ここでは「ない」とする)。したがって、クリティカル(=Capital Pの沽券に関わる)な変更でない限り、募集者はその変更を許可を得ずに行うことができる。
  3. いくつかの項目の変更権限は提供されないか、または特定の条件を満たすと付与/剥奪される。たとえば、WordPressでは公開日時を過去に設定できるが、そういうことは許さないし、最初の公開日以外は設定することができない。また、「 追加の費用を払った募集者は自サイトへの導線を貼ることができる」など、特定条件に応じて発生する権限もありうる。

3、2、1……どうだろう、できそうだろうか? こうした機能を実現するプラグインもあるのかもしれないが、条件が複雑であるほど、既存機能ではまかないきれない部分が出てくるはずだ。

上記の要件で総じて言えるのは、コンディショナル、つまり、条件に応じて場当たり的に発生する制約だということである。これらの制約は、ユーザーの役割や権限といったグルーピングでは発生しない。ユーザーが起こしたアクションに応じて発生するのである。これは人間が肌の色や生まれた場所、話す言語で差別されてはならない一方、好きな音楽やいま持っているお金、さっきまでタバコを吸っていたかどうかで容赦無く峻別されるのと似ている。

こうした制約を筆者は「強い制約」と呼ぶ。実際にそれが強力であるかどうかではなく、WordPressが本来備えている制約(ex. 投稿者は他の投稿者の投稿を編集できない)の範囲外にあるから「強い」のだ。

強い制約に耐える編集画面

では、そうした強い制約があっても入力しやすい編集画面とはどのようなものだろうか。たとえば、ジョブ形態と募集金額について、次のような条件があると仮定しよう。

  • 雇用形態には「雇用」と「スポット」があると仮定する。これはタクソノミーで実装される。
  • 募集金額は「雇用」の場合は上限と下限(ex. 年収300万〜600万)、スポットの場合は希望金額(ex. 5万円)となる。スポットでは応募者が金額を提示するコンペスタイルとする。

この場合、WordPressの既存編集画面だとこのような形になるだろう。

タクソノミーを実装すると、こんな感じになる。もちろん、デフォルトだとチェックボックスなので、ラジオボタンに変更する必要がある。

しかし、タクソノミーの変更に伴い、金額の入力欄を変更することは難しいので、次のような入力になることが想定される。

似たような項目が複数並ぶことで分かりづらさが増す。

これはあまりにアグリーだし、「いまどの項目に何を入れたらいいのか」が明瞭ではない。できれば次のようにタクソノミーを切り替えると金額入力欄が切り替わるUIが望ましい。

スポットの場合はズバリ単価のみ。
雇用形態のときは上限と下限のみ。

「雇用形態を変更すると金額がハイライト表示される」などのマイクロインタラクションがあれば尚更よい。

こうした例を挙げると「それ○○でこうすればできるよ!」という無粋なツッコミが入る可能性があるのだが、あまり具体例にはフォーカスせず、「強い制約をもった編集画面をいかにしてコントロール下に置くか」という点を重要視していただければ幸いである。

実装

それでは、実装に入ろう。前回のおさらいでは、投稿(求人票)のリストを作成するところまで紹介した。では、この求人票の一つ一つを編集する機能を実装しよう。

編集モードの実装

いまのところ、「編集」リンクをクリックしてもアラートが表示されるだけである。では、ここで編集モードに突入する機能を作成しよう。

まず、すでに存在する投稿のリストは取得済みなので、これを利用する。まず、dataプロパティにpostという項目を追加し、これまではアラートを出すだけだった editPostメソッドに「現在のpostを選ばれたIDのものにする」という機能を実装する。

data: {
      recruitment: [],
      newTitle: '',
      post: null //追加
    },
methods: {
    editPost: function(id){
        var post = null;
        for(var i = 0; i < this.recruitment.length; i++){
          if(id == this.recruitment[i].ID){
            post = this.recruitment[i];
            break;
          }
        }
        this.post = post;
   },
   finishEdit: function(){
        this.post = null;
   },
}

また、Vue.JSには条件分岐タグv-ifあるので、postが設定されているかいないかで編集中か否かを判断する。「編集中なら編集画面を表示、編集を抜けたらリストを表示」という単純な機能を実装しよう。v-ifの条件として渡すのはtrue/falseではなく、編集対象である投稿のオブジェクトpostにする。また、キャンセルするメソッドfinishEditも同時に実装しておく。戻るボタンをカジュアルに押せてしまうと、未変更の状態が保存されないままになってしまうのだが、本来であれば押す前に確認すべきである。今回は時間の節約のために割愛する。

<div v-if="!post">
  <p>
    <input type="text" v-model="newTitle"/>
    <button type="button" v-on:click="addNew">新規追加</button>
  </p>
  <div v-if="!recruitment.length">
    データがありません。
  </div>
  <div v-if="recruitment.length">
    <ul>
      <li v-for="item in recruitment">
        #{{item.ID}} <strong>{{item.post_title}}</strong>
        <p>
          <button type="button" v-on:click="editPost(item.ID)">編集</button>
          <button type="button" v-on:click="removePost(item.ID)">削除</button>
        </p>
      </li>
    </ul>
  </div>
</div>

<div v-if="post">
  <button type="button" v-on:click="finishEdit">編集終了</button>
  {{post.post_title}}
</div>

ついでにアニメーションも実装しよう。Vue.JSにはtransitionというタグがあり、これを使うとアニメーション用のCSSクラスが適用される。こうすることで、リストモードと編集モードが切り替わったことをユーザーに知らせることができる。

<transition name="toggle">
    <div v-if="!post">
        <!-- リストの表示 -->
    </div>
</transition>
<transition name="toggle">
    <div v-if="post">
        <!-- 編集モードの表示 -->
    </div>
</transition>

transitionの内部にあるタグにはCSSアニメーションのクラス(nameプロパティに設定された文字列から始まるもの)が適用されるので、その通り実装しよう。

.toggle{
  &-enter-active,
  &-leave-active{
    transition: opacity .3s linear,
                transform .3s linear;
  }
  &-enter-active{
    transition-delay: .3s;
  }
  &-enter,
  &-leave-to{
    opacity: 0;
    transform: translateX(100%);
  }
  &-enter-to,
  &-leave{
    opacity: 1;
    transform: translateX(0);
  }
}

これで切り替わるリストが作成できたはずである。

編集モードの実装

さて、エディター(=編集モードに表示されるもの)の実装に移ろう。ここではエディターをコンポーネントとして追加する。このコンポーネント内では常に投稿オブジェクトが存在し、フォーム上のオブジェクトが変更されたら、REST APIへリクエストを投げる。まず、何よりも先に、APIを実装しなくてはならない。前回実装したエンドポイントでPUTメソッドを受け付けるようにし、これを「投稿の変更」とする。

[
  'methods' => 'PUT',
  'args' => [
    'id' => [
      'validate_callback' => function( $var ) {
        if ( ! is_numeric( $var ) ) {
          return false;
        }
        $post = get_post( $var );
        if ( ! $post || 'recruitment' !== $post->post_type || get_current_user_id() != $post->post_author ) {
          return new WP_Error( 'invalid_request', '求人票へのリクエストは許可されていません。', [
            'status' => 403,
          ] );
        }
        // ここまできたらオーケー。
        return true;
      },
      'required' => true,
    ],
    'title' => [
      'required' => false,
    ],
    'content' => [
      'required' => false,
    ],
    'status' => [
      'required' => false,
      'validate_callback' => function( $var ) {
        $status = get_post_status_object( $var );
        return $status;
      }
    ],
  ],
  'callback' => function( WP_REST_Request $request ) {
    $post_id = $request->get_param( 'id' );
    $post_arr = [];
    // Titleを設定
    $title = $request->get_param( 'title' );
    if ( $title ) {
      $post_arr['post_title'] = $title;
    }
    $content = $request->get_param( 'content' );
    if ( $content ) {
      $post_arr['post_content'] = $content;
    }
    $status = $request->get_param( 'status' );
    if ( $status ) {
      // なんかちぇっく
      $checked = true;
      if (! $checked ) {
        return new WP_Error( 'invalid_job', '無効な求人票です。', [
          'status' => 400,
        ] );
      }
      $post_arr['post_status'] = $status;
    }
    if ( $post_arr ) {
      $post_arr['ID'] = $post_id;
      wp_update_post( $post_arr );
    }
    return new WP_REST_Response( capitalp_job_mapper( get_post( $post_id ) ) );
  },
  'permission_callback' => function( WP_REST_Request $request ) {
    return current_user_can( 'contributor' );
  }
],

では、なにがこのAPIを叩くのだろうか?

ポイントとしては、まず、v-modelでpostオブジェクトをまるごとバインドしてしないこと。というのは、一回で編集できる値にはそれぞれに制約があるので、たとえば、「すでに公開された求人票の本文は変更できない」という要件を満たすためには、投稿本文とステータスを同時に変更できてはいけない。このため、各項目をうけつけるエンドポイントは一つでもいいのだが、それは要件を厳密に反映したAPIになっている必要がある。

それでは、これを反映したUIを作成しよう。こうした複雑な要素はコンポーネントに切り出すことにする。コンポーネントとは、「独自タグ」のようなものだと考えてもらっていい。コンポーネントが受け取るプロパティはただ一つ、postだけである。

<transition name="toggle">
  <div v-if="post">
    <button type="button" v-on:click="finishEdit">編集終了</button>
    <job-board-editor :post="post"></job-board-editor>
  </div>
</transition>

:post="post"は省略記法で、v-bind:post="post"の略である。続いて、このコンポーネントをJSで実装する。

Vue.component('job-board-editor', {
  props: {
    post: {
      type: Object,
      required: true
    },
    newTitle: {
      type: String,
      default: ""
    },
    newContent: {
      type: String,
      default: ""
    },
  },
  methods: {
    publish: function() {
      var self = this;
      restApi( 'PUT', 'job-board/v1/recruitment/', {
        id: this.post.ID,
        status: 'publish'
      } ).done(function(response){
        self.post = response;
      }).fail(function(){
        alert('エラー!');
      });

    },
    saveTitle: function(){
      var self = this;
      restApi( 'PUT', 'job-board/v1/recruitment/', {
        id: this.post.ID,
        title: this.newTitle,
        content: this.newContent
      } ).done(function(response){
        self.newTitle = '';
        self.newContent = '';
        self.post = response;
      }).fail(function(){
        alert('エラー!');
      });
    },
  },
  template: '<div class="job-board-editor">' +
  '<strong>{{post.status}}</strong>' +
  '<p>{{post.post_title}}</p>' +
  '<p v-if="post.editable"><label>' +
    '<input type="text" v-model="newTitle" /><button type="button" v-on:click="saveTitle">保存</buttont>' +
  '</label></p>' +
  '<p>{{post.post_content}}</p>' +
  '<p v-if="post.editable"><label>' +
  '<textarea v-model="newContent"></textarea>' +
  '</label></p>' +
  '<button type="button" v-if="post.editable" v-on:click="publish">申請する</button>' +
  '</div>'
});

いくつか細く説明をしよう。

コンポーネントは独自のプロパティを持つことができる。Vueインスタンスと同じくそれぞれのプロパティ(new Vueで設定したdata)は型を指定することができるので、typescriptと組み合わせるとかなり強力になるだろう。

templateは文字列としてタグを定義する。改行がかなりめんどくさいことになるので、PhpStormなどのIDEを使わない限り、すぐにES2015のバックティック記法JSXに移行したくなるだろう。

さて、ここで一つ問題が発生する。postを編集したにも関わらず、リストにそれが反映されないのだ。

これはなぜかというと、コンポーネント内のpostはリストの一つを反映したものではあるのだが、オブジェクトのコピーであるため、リストの内部のpostは変更されない。

したがって、コンポーネントからこの変更を伝える必要がある。これにはイベントを利用する。コンポーネントからイベントの発生をつたえるのは$emitで、その変更を検知するのはv-onである。

publish: function() {
  var self = this;
  restApi( 'PUT', 'job-board/v1/recruitment/', {
    id: this.post.ID,
    status: 'publish'
  } ).done(function(response){
    self.post = response;
    self.$emit('post-changed', response); // イベント発生
  }).fail(function(){
    alert('エラー!');
  });
},
<job-board-editor :post="post" v-on:post-changed="postChangeHandler"></job-board-editor>

これで投稿の変更を検知できるようになったので、それをリストに反映するメソッドpostChangeHandlerを実装する。

postChangeHandler: function(post){
    for(var i = 0; i < this.recruitment.length; i++){
      if(this.recruitment[i].ID == post.ID){
        this.recruitment[i] = post;
        break;
      }
    }
},

無事、リストが反映されるようになった。

以上、駆け足ではあるが、投稿の編集画面の実装を紹介した。正直、ここまで詳細にコードを説明するのは疲れるので、「なんかよくわかんなかった」という方は、有料会員に申し込み、実装のすべてを収めた1:47の動画とソースコードの全体をご覧いただきたい。

続きの 2% を読み、添付されたファイルにアクセスするには、Gumroadでライセンスキーを取得してください!

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください