10日目: 続・フォーム

昨日はフォームの基本を学習し、求人フォームの残りの部分(プレビュー・投稿)の機能を宿題としました。 今日はその答え合わせをしましょう。

コントローラとのつなぎこみ

昨日はフォームを表示するとこまでしかやりませんでした。 フォームの内容を POST したときにコントローラではどのように処理をするのでしょうか。 Job コントローラをこのようにしてみます:

sub create :Local :Form('Jobeet::Form::Job') {
    my ($self, $c) = @_;

    if ($c->req->method eq 'POST' and $self->form->submitted_and_valid) {
        # バリデーション成功、求人を生成する
    }
}

リクエストが POST メソッドでかつ、フォームのバリデーションが通っていたら、if の中が実行されます。 ここで求人を生成し、そのページにリダイレクトする処理を行います。

二日目のストーリーによれば求人は token によって識別されるとしました。 token は求人作成時に自動的に発行されるようにしましょう。 Jobeet::Schema::Result::Job の 冒頭部に

use Digest::SHA1 qw/sha1_hex/;
use Data::UUID;

とし、insert メソッドに

$self->token( sha1_hex(Data::UUID->new->create) );

と言う処理を加えましょう。これで token は自動的にユニークが id がつけられます。コントローラに処理を追加しましょう:

sub create :Local :Form('Jobeet::Form::Job') {
    my ($self, $c) = @_;

    if ($c->req->method eq 'POST' and $self->form->submitted_and_valid) {
        my $job = models('Schema::Job')->create_from_form($self->form);
        $c->redirect( $c->uri_for('/job', $job->token) );
    }
}

リクエストメソッドが POST でかつフォームの内容が正しい場合にモデルの create_from_form に処理を渡しています。 このコードを動作させるために ResultSet::Job に create_from_form メソッドを作成します:

use Jobeet::Models;

sub create_from_form {
    my ($self, $form) = @_;

    my $txn_guard = models('Schema')->txn_scope_guard;

    my $category_id = delete $form->params->{category};
    my $category = models('Schema::Category')->find({ slug => $category_id })
        or die 'no such category_id: ', $category_id;

    my $job = $self->create({
        category_id => $category->id,
        %{ $form->params },
    });

    $txn_guard->commit;

    $job;
}

これで、フォームのバリデーションが成功し、求人の作成が完了すると redirect メソッドをつかって /job/{token} と言う求人情報のページへリダイレクトされるようになりました。

このページでは Job コントローラの show アクションが呼ばれます、このように定義してみましょう:

sub show :Path :Args(1) {
    my ($self, $c, $job_token) = @_;

    $c->stash->{job} = models('Schema::Job')->find({ token => $job_token })
        or $c->detach('/default');
}

URLでうけとった token をもとに求人をさがし、みつかったら stash へ格納し、そうでなければ 404 ページへ処理を飛ばしています。 求人情報のページのテンプレート job/show.mt も作成しておきましょう:

? my $job = $c->stash->{job};

? extends 'common/base';

? block stylesheets => sub {
<link rel="stylesheet" type="text/css" href="<?= $c->uri_for('/css/main.css') ?>" />
<link rel="stylesheet" type="text/css" href="<?= $c->uri_for('/css/job.css') ?>" />
? }

? block content => sub {

<div id="job">
  <h1><?= $job->company ?></h1>
  <h2><?= $job->location ?></h2>
  <h3>
    <?= $job->category->name ?>    <small> - <?= $job->type ?></small>
  </h3>

  <div class="description">
    <?= $job->description ?>
  </div>

  <h4>How to apply?</h4>

  <p class="how_to_apply"><?= $job->how_to_apply ?></p>

  <div class="meta">
    <small>posted on <?= $job->created_at->ymd ?></small>
  </div>
</div>

? } # endblock content

この状態でフォームにアクセスし、なにか求人を作成してみてください。 ただしくプレビュー画面が表示されましたか?

この状態ではプレビューからさきへ進むことができません。編集や削除へのリンクを追加していきます。 ここで、編集画面ではまた同じようなフォームをつくるでしょう。フォーム部分は job/_partial_form.mt としてパーシャル化しておきましょう:

? my $form = shift;

<form method="post">
<table id="job_form">
  <tfoot>
    <tr>
      <td colspan="2">
        <input type="submit" value="Preview your job" />
      </td>
    </tr>
  </tfoot>
  <tbody>
? for my $field (qw/category type company url position location description how_to_apply email/) {
    <tr>
      <th><?= raw_string $form->label($field) ?></th>
      <td>
? if ($form->is_error($field)) {
        <ul class="error_list">
? for my $err (@{ $form->error_messages($field) }) {
          <li><?= raw_string $err ?></li>
? } # endfor $err
        </ul>
? } # endif
        <?= raw_string $form->input($field) ?>
      </td>
    </tr>
? } # endfor $field
  </tbody>
</table>
</form>

これを使用すると create.mt はこのようになるでしょう:

? my $form = $c->stash->{form};

? extends 'common/jobs_base';

? block content => sub {

<h1>New Job</h1>

?= include('job/_partial_form', $form);

? } # endblock content

同様に編集用のテンプレート job/edit.mt はこのようになるでしょう:

? my $form = $c->stash->{form};

? extends 'common/jobs_base';

? block content => sub {

<h1>Edit Job</h1>

?= include('job/_partial_form', $form);

? } # endblock content

それではつぎに編集用のコントローラを作成していきましょう:

sub job :Chained('/') :PathPart :CaptureArgs(1) {
    my ($self, $c, $job_token) = @_;

    $c->stash->{job} = models('Schema::Job')->find({ token => $job_token })
        or $c->detach('/default');
}

sub edit :Chained('job') :PathPart :Form('Jobeet::Form::Job') {
    my ($self, $c) = @_;

    my $job = $c->stash->{job};

    if ($c->req->method eq 'POST') {
        if ($self->form->submitted_and_valid) {
            $job->update_from_form($self->form);
            $c->redirect( $c->uri_for('/job', $job->token) );
        }
    }
    else {
        $self->form->fill({
            $job->get_columns,
            category => $job->category->name,
        });
    }
}

はじめて Chained アクションを使用しています。ルーティングの日の内容を思い出してください。 job アクションが URL の token パートをうけとり、それをもとに job が存在するかをチェックし、 存在していれば stash 格納しています。見つからなかった場合 404 ページへ飛ばします。

job が存在した場合処理は引き続き、edit アクションへと続きます。 ここでは POST でかつフォームのエラーがない場合、Job を更新しています。 POST メソッド以外のリクエストの場合はフォームをDBの内容で埋めています。これで編集画面を開いたときにはすでにフォームは現在の値で埋まっていると言うことになります。

削除のアクションは

sub delete :Chained('job') :PathPart {
    my ($self, $c) = @_;

    $c->stash->{job}->delete;
    $c->redirect( $c->uri_for('/job') );
}

のようになります。このコントローラは求人を削除し求人トップへリダイレクトするという内容なのでテンプレートを持ちません。

それではプレビューページにこれら編集や削除へのリンクをつけましょう

<div id="job_actions">
  <h3>Admin</h3>
  <ul>
? if (!$job->is_activated) {
      <li><a href="<?= $c->uri_for('/job', $job->token, 'edit') ?>">Edit</a></li>
      <li><a href="<?= $c->uri_for('/job', $job->token, 'publish') ?>">Publish</a></li>
? }
    <li><a href="<?= $c->uri_for('/job', $job->token, 'delete') ?>" onclick="return confirm('Are you sure?')">Delete</a></li>

? if ($job->is_activated) {
    <li<?= $job->expires_soon ? ' class="expires_soon"' : '' ?>>
? if ($job->is_expired) {
          Expired
? } else {
          Expires in <strong><?= $job->days_before_expired ?></strong> days
? }

? if ($job->expires_soon) {
         - <a href="">Extend</a> for another <?= Jobeet::Models->get('conf')->{active_days} ?> days
? }
      </li>
? } else {
      <li>
        [Bookmark this <a href="<?= $c->req->uri ?>">URL</a> to manage this job in the future.]
      </li>
? }
  </ul>
</div>

たくさんのコードがありますが、たいていのコードは簡単に理解できます。

テンプレートを読みやすくするために、Jobeet::Schema::Result::Job クラスに次のような一連のショートカットメソッドを追加しました:

sub is_expired {
    my ($self) = @_;
    $self->days_before_expired < 0;
}

sub days_before_expired {
    my ($self) = @_;
    ($self->expires_at - models('Schema')->now)->days;
}

sub expires_soon {
    my ($self) = @_;
    $self->days_before_expired < 5;
}

管理バーには、求人のステータスに合わせて異なるアクションが表示されます:

not activated

activated

求人の有効化と公開

前のセクションで、求人を公開するリンクがありましたがその機能はまだ作っていませんでした。Job コントローラに次のような publish アクションを作成しましょう。

sub publish :Chained('job') :PathPart {
    my ($self, $c) = @_;

    my $job = $c->stash->{job};

    $job->publish;
    $c->redirect( $c->uri_for('/job', $job->token) );
}

そして Jobeet::Schema::Result::Job に publish メソッドを定義します

sub publish {
    my ($self) = @_;
    $self->update({ is_activated => 1 });
}

これで、新しい公開機能をブラウザーでテストできます。

しかし修正するものがまだあります。グローバルテンプレートの POST A JOB リンクが間違っています。ただしくリンクが機能するよう書き直しておきましょう。

            <a href="<?= $c->uri_for('/job/create') ?>">Post a Job</a>

また、有効化されていない求人情報はアクセスされてはなりません。つまり、Jobeet ホームページにこれらの求人情報は表示されてはならず、URL からアクセス可能であってはならないことです。 Criteria を有効な求人情報のみに制限する get_active_jobs メソッドをすでに作成したので、最後にこのメソッドを編集して新しい要件を追加します:

sub get_active_jobs {
    my $self = shift;
    my $attr = shift || {};

    $self->jobs(
        { expires_at => { '>=', models('Schema')->now }, is_activated => 1 },
        {   order_by => { -desc => 'created_at' },
            defined $attr->{rows} ? ( rows => $attr->{rows} ) : (),
            defined $attr->{page} ? ( page => $attr->{page} ) : (),
        }
    );
}

これでお終いです。ブラウザーでテストしてみましょう。すべての有効ではない求人はホームページから消えました; それらのURLを知っていても、もはやアクセスできません。しかしながら、これらは求人のトークンURLを知っていればアクセスできます。この場合、求人のプレビューは管理バーつきで表示されます。

これまでのところ、これがMVCパターンとリファクタリングの大きな利点の1つです。新しい要件を追加するために1つのメソッドで1つの変更だけが必要でした。

また明日

今日のチュートリアルにはたくさんの新しい情報が詰め込まれていましたが、Ark のフォームフレームワークをより理解していただけることを願っています。

明日からはプログラミングで最も重要なテストについて学びはじめます。