開発メモ③ 編集・削除機能実装(Laravel11 + Vue.js3 + TypeScript + Inertia.js + Vuetify)

③では編集・削除機能の実装を行う。①、②は以下からどうぞ。

編集機能

CustomerController.phpのedit()メソッド

編集画面に顧客情報(customer)を渡す。この処理においては、CustomerControlleredit メソッド内で以下のように実装されている。

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreCustomerRequest;
use App\Http\Requests\UpdateCustomerRequest;
use App\Models\Customer;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;

class CustomerController extends Controller
{
    public function edit(Customer $customer)
    {
        return Inertia::render('Customers/Edit', [
            'customer' => $customer
        ]);
    }
}

このコードでは、編集対象の顧客データ($customer)を Vue コンポーネントである Customers/Edit に渡している。
なお、edit() メソッドの処理に関しては、「開発メモ2」で解説した内容と同様。モデルのインジェクションを使用して、指定した customer を自動的に取得し、編集画面に渡している。

Edit.vue作成

Edit.vue を以下のように作成する。基本的な構造は Create.vue と同じだが、編集時に必要な処理を追加している。

📝ポイント

  1. definePropscustomer を受け取る
    編集対象の顧客データを親コンポーネントやコントローラーから受け取り、それをローカルで使用する。
  2. customerForm の初期値を props から受け取る
    フォームデータの初期値として、customer から受け取った情報を設定する。
  3. HTTPメソッドの put を用いて送信する
    更新処理には PUT メソッドを使用し、指定したルートにリクエストを送信する。
<script setup lang="ts">
  import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
  import { Head, useForm } from '@inertiajs/vue3';
  import TextInput from '@/Components/TextInput.vue';
  import TextArea from '@/Components/TextArea.vue';
  import InputError from '@/Components/InputError.vue';
  import { Core as YubinBangoCore } from "yubinbango-core2";

  // customers のデータ構造を定義
  type Customer = {
    id: number;
    last_name: string;
    first_name: string;
    last_name_kana: string;
    first_name_kana: string;
    postcode: string;
    address: string;
    tel: string;
    birth: string;
    gender: number;
    memo: string;
  };

  const props = defineProps<{ customer: Customer }>();

  const customerForm = useForm<Customer>({
    id: props.customer.id,
    last_name: props.customer.last_name,
    first_name: props.customer.first_name,
    last_name_kana: props.customer.last_name_kana,
    first_name_kana: props.customer.first_name_kana,
    postcode: props.customer.postcode,
    address: props.customer.address,
    tel: props.customer.tel,
    birth: props.customer.birth,
    gender: props.customer.gender,
    memo: props.customer.memo,
  })

  const fetchAddress = () => {
    new YubinBangoCore(String(customerForm.postcode), (value: any) => {
      customerForm.address = value.region + value.locality + value.street
    })
  }

  const updateCustomer = () => {
    customerForm.put(route('customers.update', {
      'customer' : customerForm.id
    }))
  }
</script>
<template>
	<Head title="顧客編集" />

	<AuthenticatedLayout>
		<template #header>
			<h2
				class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200"
			>
				顧客編集
			</h2>
		</template>

		<div class="mb-10">
			<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
				<div
					class="py-5 overflow-hidden bg-white shadow-lg sm:rounded-lg dark:bg-gray-800"
				>
          <v-container>
            <v-row>
              <v-col lg="3"></v-col>
              <v-col md="12" lg="6" cols="12">
                <form @submit.prevent="updateCustomer">
                  <InputError class="" :message="customerForm.errors.last_name" />
                  <InputError class="" :message="customerForm.errors.first_name" />
                  <div class="d-flex">
                    <TextInput
                    label="姓"
                    placeholder="例:山田"
                    id="last_name"
                    v-model="customerForm.last_name"
                    type="text"
                    icon="mdi-card-bulleted-outline"
                    class="block w-full"
                    required
                    autofocus
                    autocomplete="姓"
                    />
                    <TextInput
                    label="名"
                    placeholder="例:太郎"
                    id="first_name"
                    v-model="customerForm.first_name"
                    type="text"
                    icon="mdi-card-bulleted-outline"
                    class="block w-full ml-2"
                    required
                    autocomplete="名"
                    />
                  </div>
                  <InputError class="" :message="customerForm.errors.last_name_kana" />
                  <InputError class="" :message="customerForm.errors.first_name_kana" />
                  <div class="d-flex">
                    <TextInput
                    label="姓カナ"
                    placeholder="例:ヤマダ"
                    id="last_name_kana"
                    v-model="customerForm.last_name_kana"
                    type="text"
                    icon="mdi-card-bulleted-outline"
                    class="block w-full"
                    required
                    autocomplete="姓カナ"
                    />
                    <TextInput
                    label="名カナ"
                    placeholder="例:タロウ"
                    id="first_name_kana"
                    v-model="customerForm.first_name_kana"
                    type="text"
                    icon="mdi-card-bulleted-outline"
                    class="block w-full ml-2"
                    required
                    autocomplete="名カナ"
                    />
                  </div>
                  <InputError class="" :message="customerForm.errors.postcode" />
                  <TextInput
                  label="郵便番号"
                  placeholder="例:1234567"
                  id="postcode"
                  v-model="customerForm.postcode"
                  type="number"
                  icon="mdi-post-lamp"
                  class="block w-full"
                  required
                  autocomplete="郵便番号"
                  @change="fetchAddress"
                  />
                  <InputError class="" :message="customerForm.errors.address" />
                  <TextInput
                  label="住所"
                  id="address"
                  v-model="customerForm.address"
                  type="text"
                  icon="mdi-home"
                  class="block w-full"
                  required
                  autocomplete="住所"
                  />
                  <InputError class="" :message="customerForm.errors.tel" />
                  <TextInput
                  label="電話番号"
                  placeholder="例:09876543210"
                  id="tel"
                  v-model="customerForm.tel"
                  type="number"
                  icon="mdi-phone"
                  class="block w-full"
                  required
                  autocomplete="電話番号"
                  />
                  <InputError class="" :message="customerForm.errors.birth" />
                  <TextInput
                  label="誕生日"
                  id="birth"
                  v-model="customerForm.birth"
                  type="date"
                  icon="mdi-cake"
                  class="block w-full"
                  required
                  autocomplete="誕生日"
                  />
                  <InputError class="" :message="customerForm.errors.gender" />
                  <v-radio-group v-model="customerForm.gender" inline>
                    <template v-slot:label>
                        <div>性別</div>
                    </template>
                    <v-radio label="男性" :value="0"></v-radio>
                    <v-radio label="女性" :value="1" class="ml-2"></v-radio>
                    <v-radio label="不明" :value="2" class="ml-2"></v-radio>
                  </v-radio-group>
                  <InputError class="" :message="customerForm.errors.memo" />
                  <TextArea
                  label="メモ"
                  id="memo"
                  v-model="customerForm.memo"
                  icon="mdi-book-open-blank-variant-outline"
                  class="block w-full"
                  autocomplete="メモ"
                  />
                  <v-btn :disabled="customerForm.processing" color="blue-darken-1" type="submit" class="text-none" rounded="xs" size="x-large" variant="flat" block>変更する</v-btn>
                </form>
              </v-col>
            </v-row>
          </v-container>
        </div>
      </div>
    </div>
  </AuthenticatedLayout>
</template>

CustomerController.phpのupdate()メソッド

$request->validated()とすることで、UpdateCustomerRequestクラスに定義されたバリデーションを通過したデータの配列を取得できる。その配列を update() メソッドに渡すことで、対応するモデルのデータベースレコードを更新する。

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreCustomerRequest;
use App\Http\Requests\UpdateCustomerRequest;
use App\Models\Customer;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;

class CustomerController extends Controller
{
    public function update(UpdateCustomerRequest $request, Customer $customer)
    {
        $customer->update($request->validated());

        return to_route('customers.index')->with([
            'message' => '変更が完了しました!',
            'status' => 'success',
        ]);
    }
}

UpdateCustomerRequest.php編集

storeCustomerRequest.phpと同じなので割愛。以下を参照。

Show.vue編集(編集ボタン)

詳細画面で作成しておいた『編集する』ボタンに、click イベントを追加する。
以下のテンプレートでは、@click 属性に goToCustomerEdit メソッドを紐付けている。

<template>
  <v-btn color="blue-darken-1" class="text-none w-full" rounded="xs" size="x-large" variant="flat" 
  @click="goToCustomerEdit">編集する</v-btn>
</template>

goToCustomerEdit() メソッドを作成し、指定された顧客ID(customerForm.id)を用いて編集画面に遷移させる処理を実装する。

<script setup lang="ts">
  const goToCustomerEdit = () => {
    router.get(route('customers.edit', {
      'customer': customerForm.id
    }))
  }
</script>

このコードにより、ボタンをクリックすると該当顧客の編集画面(customers.edit ルート)に遷移するようになる。

削除機能

Show.vue編集(削除ボタン)

詳細画面で作成しておいた『削除する』ボタンに、clickイベントを追加する。また、削除確認用のダイアログとして ConfirmDeleteDialogコンポーネントを記述する。
以下のテンプレートでは、@click イベントが発火すると dialog = true となり、その状態が ConfirmDeleteDialog コンポーネントに渡される。

<template>
  <v-btn color="red-darken-1 w-full" class="text-none" rounded="xs" size="x-large" variant="flat" @click="dialog = true">     
  削除する</v-btn>
  <ConfirmDeleteDialog v-model:visible="dialog" @confirm="goToCustomerDelete" />
</template>

script setup 内で以下の実装を行う。

  1. dialogref で定義して、ダイアログの開閉状態を管理する。
  2. 削除処理を行う goToCustomerDelete() メソッドを作成し、削除対象の顧客ID(customerForm.id)を利用して削除リクエストを送信する。
  3. 削除後にダイアログを閉じる処理を追加。
<script setup lang="ts">
  const dialog = ref<boolean>(false);
  
  const goToCustomerDelete = () => {
    customerForm.delete(route('customers.destroy', {
      'customer' : customerForm.id
    }))
    dialog.value = false
  }
</script>

ConfirmDeleteDialog.vueコンポーネント作成

削除操作を行う際に確認ダイアログを表示するコンポーネント ConfirmDeleteDialog.vue を作成する。このコンポーネントは以下の機能を提供する。

  1. 削除確認ダイアログの表示と非表示
    親コンポーネントから受け取った visible プロパティを基に、ダイアログを制御する。
  2. 削除確定時のイベント発火
    ユーザーが削除を確定した際、confirm イベントを親コンポーネントに通知する。
<script setup lang="ts">
  import { ref, watch } from 'vue';
  
  interface Props {
    visible: boolean
  }
  
  const props = defineProps<Props>()
  
  const emit = defineEmits<{
    (event: "update:visible", value: boolean) :void
    (event: "confirm") :void
  }>()
  
  const localVisible = ref(props.visible)
  
  watch(() => props.visible,
    (newVal) => {
      localVisible.value = newVal
  })
  
  const closeDialog = () => {
    emit("update:visible", false)
  }
  
  const confirmDelete = () => {
    emit("confirm")
    closeDialog()
  }
</script>
<template>
  <v-dialog v-model="localVisible" max-width="400px">
    <v-card>
      <v-card-title class="text-h6">本当に削除しますか?</v-card-title>
      <v-card-text>この操作は元に戻せません。</v-card-text>
      <v-card-actions>
        <v-spacer></v-spacer>
        <!-- キャンセルボタン -->
        <v-btn @click="closeDialog">キャンセル</v-btn>
        <!-- 削除確認ボタン -->
        <v-btn color="red-darken-1" class="text-none" variant="flat" @click="confirmDelete">削除</v-btn>
      </v-card-actions>
    </v-card>
</v-dialog>
</template>

CustomerController.php編集

顧客情報を管理する際、削除フラグ(delete_flg)を用いた論理削除を採用することで、データの物理削除を防ぎ、安全性を確保する方法をとる。

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreCustomerRequest;
use App\Http\Requests\UpdateCustomerRequest;
use App\Models\Customer;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;

class CustomerController extends Controller
{
    public function destroy(Customer $customer)
    {
        $customer->delete_flg = 1;
        $customer->save();

        return to_route('customers.index')->with([
            'message' => '削除が完了しました!',
            'status' => 'success',
        ]);
    }
}

グローバルスコープ作成

論理削除の仕組みを実装したが、現在、顧客一覧には delete_flg に関係なく全てのデータが取得されている。
これを改善し、削除フラグが 0(未削除)のデータのみを取得するようにする。

一時的に Controllerindex メソッドに条件を記述する方法もあるが、削除済みデータを基本的に扱わないので、Customer モデルに グローバルスコープ を追加する。
これにより、モデルを通じたデータ操作全般で、自動的に delete_flg = 0 の条件が適用されるようになる。

以下のコマンドを実行してスコープクラスを作成する。

php artisan make:scope CustomerActiveScope

作成されたクラス内の apply() メソッドに、削除済みデータを除外する条件を記述する。

<?php

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class CustomerActiveScope implements Scope
{
    /**
     * Apply the scope to a given Eloquent query builder.
     */
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('delete_flg', 0);
    }
}

スコープをモデルに登録する。ここでは、ScopedByアトリビュート を使用する。

<?php

namespace App\Models;

use App\Models\Scopes\CustomerActiveScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;

#[ScopedBy([CustomerActiveScope::class])]
class Customer extends Model
{
  //略
}

補足: booted() メソッドを使用する場合
ScopedByアトリビュートを使用せず、booted() メソッドをオーバーライドして登録する方法もある。

<?php

namespace App\Models;

use App\Models\Scopes\CustomerActiveScope;
use Illuminate\Database\Eloquent\Model;

class Customer extends Model
{
    /**
     * モデルの起動時にグローバルスコープを追加
     */
    protected static function booted(): void
    {
        static::addGlobalScope(new CustomerActiveScope);
    }
}

これで、Customer モデルを使用する全てのクエリで、delete_flg = 0 の条件が自動的に適用される。

選択肢メリットデメリット
グローバルスコープ・コードの重複を減らせる
・安全性が向上する
・一貫した挙動を実現
・柔軟性が低下する
・必要に応じてスコープを無効化する手間
ローカルスコープ・必要なときだけ適用可能
・柔軟性が高い
・コードの記述量が増える
クエリに直接記述・完全に制御可能・冗長になりやすい
・誤りが起きやすい