SnowLeaf(スノーリーフ) https://snow-leaf.com Tue, 28 Jan 2025 04:38:08 +0000 ja hourly 1 https://snow-leaf.com/wp-content/uploads/2023/10/cropped-ded5093380b263341ac107303cad9c06-32x32.png SnowLeaf(スノーリーフ) https://snow-leaf.com 32 32 Vue3における親子間のデータ渡しとイベントの仕組み https://snow-leaf.com/programming/dontforget/defineemits_vue3_memo/ Tue, 28 Jan 2025 04:36:34 +0000 https://snow-leaf.com/?p=1536

親から子へのデータ渡し

v-model を使用したデータのバインディング

Vue 3 では、v-model を使用して親コンポーネントから子コンポーネントにデータを渡し、双方向データバインディングを実現できます。デフォルトでは v-modelmodelValue を渡しますが、カスタムプロパティ名を使用することも可能です。

親コンポーネントの例

<template>
  <ConfirmDeleteDialog v-model:visible="dialog" @confirm="goToCustomerDelete" />
</template>

<script setup>
import { ref } from 'vue';

const dialog = ref(false);

const goToCustomerDelete = () => {
  console.log("削除を実行しました。");
};
</script>

上記では、dialog という親コンポーネントのデータが子コンポーネントに渡され、さらに子コンポーネントがデータを更新することで親の状態も変更されます。

子コンポーネントの例

<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>

解説

  1. props によるデータ受け取り
    • 親コンポーネントから渡された dialog は、props.visible として受け取ります。
  2. emit によるデータ更新
    • 子コンポーネントで emit("update:visible", 値) を発火することで、親コンポーネントのデータを更新します。
  3. ローカル状態の管理
    • localVisible を使用してローカルの状態を管理し、watchprops.visible の変更を反映しています。

子から親へのデータ送信

update:<プロパティ名> のイベント

v-model を使用する場合、子コンポーネントは update:<プロパティ名> イベントを発火させる必要があります。これにより、親コンポーネントのデータが更新されます。

親コンポーネント

<ConfirmDeleteDialog v-model:visible="dialog" @confirm="goToCustomerDelete" />

子コンポーネント

const emit = defineEmits<{
  (event: "update:visible", value: boolean): void;
  (event: "confirm"): void;
}>();

const closeDialog = () => {
  emit("update:visible", false);
};

defineEmits とイベントの型定義

defineEmits は、子コンポーネントから親コンポーネントへイベントを通知するために使用します。TypeScript を使用する場合、イベント名や引数の型を明示的に定義できます。

型定義の例

const emit = defineEmits<{
  (event: "update:visible", value: boolean): void;
  (event: "confirm"): void;
}>();

解説

  1. (event: "update:visible", value: boolean): void;
    • update:visible イベントには、boolean 型の引数 value を渡す必要があることを示しています。
  2. (event: "confirm"): void;
    • confirm イベントは引数を必要としないことを示しています。

イベントの発火例

emit("update:visible", true);  // `update:visible` を発火し、true を渡す
emit("confirm");               // `confirm` を発火する

まとめ

  • 親から子へデータを渡す際は、props を使用します。
  • 親から子への双方向バインディングには、v-modelupdate:<プロパティ名> を組み合わせます。
  • 子から親への通知は、emit を使用します。
  • defineEmits を用いることで、イベントの型を明示的に定義し、コードの可読性と安全性を向上させます。
]]>
開発メモ③ 編集・削除機能実装(Laravel11 + Vue.js3 + TypeScript + Inertia.js + Vuetify) https://snow-leaf.com/programming/dontforget/laravel11_dev_memo3/ Mon, 27 Jan 2025 20:42:31 +0000 https://snow-leaf.com/?p=1512

③では編集・削除機能の実装を行う。①、②は以下からどうぞ。 編集画面に顧客情報(customer)を渡す。この処理においては、CustomerController の edit メソッド内で以下のように実装されている。 ... ]]>

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

編集機能

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 の条件が自動的に適用される。

選択肢メリットデメリット
グローバルスコープ・コードの重複を減らせる
・安全性が向上する
・一貫した挙動を実現
・柔軟性が低下する
・必要に応じてスコープを無効化する手間
ローカルスコープ・必要なときだけ適用可能
・柔軟性が高い
・コードの記述量が増える
クエリに直接記述・完全に制御可能・冗長になりやすい
・誤りが起きやすい
]]>
開発メモ② 登録・詳細機能実装(Laravel11 + Vue.js3 + TypeScript + Inertia.js + Vuetify) https://snow-leaf.com/programming/dontforget/laravel11_dev_memo2/ Fri, 17 Jan 2025 21:09:41 +0000 https://snow-leaf.com/?p=1406

②では登録・詳細機能の実装を行う。①は以下からどうぞ。 View側から作成する。Customers/Create.vueを作成し、投稿画面を以下の様に作成。 開発メモ①に引き続き、vuetifyにてUIは構築し、form ... ]]>

②では登録・詳細機能の実装を行う。①は以下からどうぞ。

開発メモ① モデル作成〜一覧表示〜検索・ソート機能(Laravel11 + Vue.js3 + TypeScript + Inertia.js + Vuetify)

投稿機能

Viewを作成

View側から作成する。
Customers/Create.vueを作成し、投稿画面を以下の様に作成。

<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 = {
    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 customerForm = useForm<Customer>({
    last_name: "",
    first_name: "",
    last_name_kana: "",
    first_name_kana: "",
    postcode: "",
    address: "",
    tel: "",
    birth: "",
    gender: 0,
    memo: "",
  })

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

  const storeCustomer = () => {
    customerForm.post(route('customers.store'))
  }

</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="storeCustomer">
                  <InputError :message="customerForm.errors.last_name" />
                  <InputError :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
                    />
                    <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
                    />
                  </div>
                  <InputError :message="customerForm.errors.last_name_kana" />
                  <InputError :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
                    />
                    <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
                    />
                  </div>
                  <InputError :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
                  @change="fetchAddress"
                  />
                  <InputError :message="customerForm.errors.address" />
                  <TextInput
                  label="住所"
                  id="address"
                  v-model="customerForm.address"
                  type="text"
                  icon="mdi-home"
                  class="block w-full"
                  required
                  />
                  <InputError :message="customerForm.errors.tel" />
                  <TextInput
                  label="電話番号"
                  placeholder="例:09876543210"
                  id="tel"
                  v-model="customerForm.tel"
                  type="number"
                  icon="mdi-phone"
                  class="block w-full"
                  required
                  />
                  <InputError :message="customerForm.errors.birth" />
                  <TextInput
                  label="誕生日"
                  id="birth"
                  v-model="customerForm.birth"
                  type="date"
                  icon="mdi-cake"
                  class="block w-full"
                  required
                  />
                  <InputError :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 :message="customerForm.errors.memo" />
                  <TextArea
                  label="メモ"
                  id="memo"
                  v-model="customerForm.memo"
                  icon="mdi-book-open-blank-variant-outline"
                  class="block w-full"
                  />
                  <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>

開発メモ①に引き続き、vuetifyにてUIは構築し、formはInertiaのuseFormを用いる。
form.errors.****とすることで、バリデーションエラーをpropsから取得できる。

郵便番号入力はyubinbango-core2というライブラリを用いた。

TextInput.vueInputErrors.vueはデフォルトをものをvuetifyに書き換えて使用。TextArea.vueは新規に作成。
v-radioはコンポーネント化検討中。これらのコンポーネントは以下のgithubを参照

Routing確認

CustomersモデルはRestful設計に基づいて作成しているので新たにルーティング設定は必要ない。
確認する場合は、php artisan route:listにて一覧表示する。

php artisan route:list

~略~
POST customers …………………customers.store  CustomerController@store
~略~

この表示となるので、投稿ボタン押下時にはcustomers.storeが走るようにし、CustomerController.phpstore()メソッドに処理を記述する。

CustomerController.php編集

以下の様にstore()メソッドに追記する。
最初にdd()を用いてCustomers/Create.vueから投稿をテストすると送られてくるデータを見れる。

確認できたらdd()メソッドは消しておく。

<?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
{
  /**
   * Store a newly created resource in storage.
   */
  public function store(StoreCustomerRequest $request)
  {
    dd($request->all()); //確認後、コメントアウトする
    
    Customer::create([
      'last_name' => $request->last_name, 
      'first_name' => $request->first_name,
      'last_name_kana' => $request->last_name_kana, 
      'first_name_kana' => $request->first_name_kana, 
      'postcode' => $request->postcode, 
      'address' => $request->address, 
      'tel' => $request->tel, 
      'birth' => $request->birth, 
      'gender' => $request->gender, 
      'memo' => $request->memo, 
    ]);

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

create()メソッドを用いる場合は、モデルで$fillableを設定する(開発メモ①参照・または$guraded)。
$fillableに記述されている属性だけが、create()update()で代入・保存される。

メソッド説明使い方特徴
create()新しいレコードを一度に挿入する。配列を使って一括代入。$customer = Customer::create([ ‘name’ => ‘John Doe’, ‘email’ => ‘john@example.com’ ]);一度の呼び出しで新規レコードを挿入。 $fillable が必要。
save()モデルインスタンスを保存(新規挿入または既存のレコードを更新)。$customer = new Customer(); $customer->name = ‘John Doe’; $customer->save();既存のインスタンスに対して挿入または更新。
update()既存のレコードを更新。配列で一括更新。$customer = Customer::find(1); $customer->update([ ‘phone’ => ‘987-654-3210’ ]);一括更新専用。既存レコードの更新のみ。
firstOrCreate()条件に一致するレコードがあれば返し、一致しなければ新規作成。$customer = Customer::firstOrCreate([ ‘email’ => ‘john@example.com’ ], [‘name’ => ‘John Doe’]);条件が一致すれば既存レコードを、なければ新規作成。
updateOrCreate()条件に一致するレコードがあれば更新し、一致しなければ新規作成。$customer = Customer::updateOrCreate([ ‘email’ => ‘john@example.com’ ], [‘phone’ => ‘987-654-3210’]);条件一致で更新、なければ新規作成。
find()主キー(通常はID)を指定してレコードを取得。$customer = Customer::find(1);主キーでレコードを検索、見つからなければnullを返す。
findOrFail()主キーでレコードを取得。見つからない場合は例外をスロー。$customer = Customer::findOrFail(1);見つからなければ例外が発生。
get()条件に一致する複数のレコードを取得。$customers = Customer::where(‘status’, ‘active’)->get();複数レコードを取得、コレクションを返す。

投稿後は顧客一覧ページに戻る。Inertia::renderはページ描画用なので基本的にredirect()か、to_route()を用いる(to_route()はLaravel9以降)。
更に、with()を用いてフラッシュメッセージを渡している(フラッシュメッセージはHandleInertiaRequests.phpに追記が必要なので後述)。

StoreCustomerRequest.php編集

CustomerController.phpに達する前にformRequestクラスでバリデーションを行う。
以下の記事がとてもわかりやすいので参考に。

authorizeはデフォルトでfalseになっているので、trueに変更し、rulesに記述するバリデーションチェックをできるようにする。

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreCustomerRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'last_name' => ['required', 'max:50'],
            'first_name' => ['required', 'max:50'],
            'last_name_kana' => ['required', 'regex:/^[ァ-ヾ]+$/u', 'max:50'],
            'first_name_kana' => ['required', 'regex:/^[ァ-ヾ]+$/u', 'max:50'],
            'postcode' => ['required', 'max:7'],
            'address' => ['required', 'max:100'],
            'tel' => ['required', 'max:20'],
            'birth' => ['required', 'date'],
            'gender' => ['required'],
            'memo' => ['max:1000'],
        ];
    }
}

言語ファイル編集

lang/ja/validation.phpは、バリデーションエラー時に生成されるエラーメッセージの中で、フィールド名を分かりやすく表示するために使う。

lang/ja/validation.phpattributes配列に、必要な入力項目名を日本語で追記する。
たとえば、新しいフォームでcompanyという項目がある場合、‘company’ => ‘会社名’と記述する。

	'attributes' => [
		'password' => 'パスワード',
		'name' => '名',
		'title' => '件名',
		'gender' => '性別',
		'age' => '年齢',
		'contact' => 'お問い合わせ内容',
		'caution' => '注意事項',
		'content' => '本文',
		'memo' => 'メモ',
		'price' => '料金',
		'kana' => 'カナ',
		'tel' => '電話番号',
		'email' => 'メールアドレス',
		'postcode' => '郵便番号',
		'address' => '住所',
		'birth' => '誕生日',
		'gender' => '性別',
		'last_name' => '姓',
		'first_name' => '名前',
		'last_name_kana' => '姓カナ',
		'first_name_kana' => '名カナ',
	],

HandleInertiaRequests.php編集

app/Http/Middleware/HandleInertiaRequests.php は、Inertia.jsを使用するLaravelプロジェクトにおいて、リクエストとレスポンスの間でデータを操作するためのミドルウェアのこと。
このミドルウェアは、サーバーサイドのデータをフロントエンド側(Viewファイル)に渡す際の「共通データの管理」を行う。

Laravelでは、すべてのInertiaリクエストがこのミドルウェアを通過し、share() メソッドを使用して共有データを設定する。この共有データはInertiaのpropsとしてVueやReactなどで参照できるようになる。

コントローラーからの流れ:フラッシュメッセージを設定

フラッシュメッセージは、コントローラーでwith()を使用してセッションに保存される。with()はセッションに一時的なデータ(フラッシュデータ)を保存し、次のリクエスト時に自動的に破棄される仕組み。

public function store(StoreCustomerRequest $request)
{
    // データの保存処理
    // ...

    // フラッシュメッセージをセッションに保存
    return to_route('customers.index')->with([
      'message' => '登録が完了しました!',
      'status' => 'success',
    ]);
}

ここでwith('message', 'メッセージ')を使うと、セッションにmessageというキーでメッセージが保存される。

HandleInertiaRequests.phpでセッションデータを共有

セッションに保存されたデータ(フラッシュメッセージなど)をフロントエンドに渡すために、HandleInertiaRequests.phpshareメソッドを使用する。

以下では、セッションのmessagestatusflashというキーにまとめてInertiaのpropsに追加している。

namespace App\Http\Middleware;

use Illuminate\Http\Request;
use Inertia\Middleware;

class HandleInertiaRequests extends Middleware
{
	public function share(Request $request): array
	{
		return [
			...parent::share($request),
			'auth' => [
				'user' => $request->user(),
			],
			'flash' => [
				'message' => fn() => $request->session()->get('message'),
				'status' => fn() => $request->session()->get('status'),
			],
		];
	}
}

このコードでは、

  1. session('message') を呼び出し、セッションに保存されたフラッシュデータを取得。
  2. propsflash キーとして追加。
  3. VueやReactなどのフロントエンドから、props.flash.message としてアクセスできるようになる。

なぜこの仕組みが必要か?

Inertia.jsはLaravelのセッションやBladeテンプレートを直接使わず、フロントエンドにすべてのデータをpropsとして渡す。そのため、セッションデータ(フラッシュメッセージ)もpropsに追加し、クライアント側で動的に表示する必要がある。

Vue.jsでのフラッシュメッセージの取得

フロントエンドでフラッシュメッセージを表示するには、Inertia.jsのusePage()を使用する。

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

resources/js/Components/FlashMessage.vueを作成する。

vuetifyのv-snackbarを用いる。v-ifでは動かなかったため、v-modelを用いて、statussuccessであれば表示するようにしている。

<script setup lang="ts">
  import { usePage } from '@inertiajs/vue3';
  import { ref } from 'vue';

  const flash = usePage().props.flash;

  // 初期状態でSnackbarを表示するかどうかを設定
  const isSnackbarVisible = ref(flash?.status === 'success');

</script>
<template>
  <v-snackbar v-model="isSnackbarVisible" color="success" location="top center" timeout="2500">
    {{ flash?.message }}
  </v-snackbar>
</template>

global.d.ts型定義追記

flashプロパティの型が存在しないと怒られるので、resources/js/types/global.d.tsに追記。

import { PageProps as InertiaPageProps } from '@inertiajs/core';
import { AxiosInstance } from 'axios';
import { route as ziggyRoute } from 'ziggy-js';
import { PageProps as AppPageProps } from './';

declare global {
	interface Window {
		axios: AxiosInstance;
	}

	/* eslint-disable no-var */
	var route: typeof ziggyRoute;
}

declare module 'vue' {
	interface ComponentCustomProperties {
		route: typeof ziggyRoute;
	}
}

declare module '@inertiajs/core' {
	interface PageProps extends InertiaPageProps, AppPageProps {
		flash?: {                                                    //=========ここを追記=========
			message?: string;                                          //=========ここを追記=========
			status?: string;                                           //=========ここを追記=========
		}
	}
}
型について

usePage()で取得するpropsの型は、デフォルトでは以下のようになっている(@inertiajs/core における定義):

interface PageProps extends Record<string, unknown> {}

つまり、TypeScriptはpropsに格納されるオブジェクトが 「どのようなプロパティを持っているか」 わからない状態になっている。
たとえば以下のコードでは、flash プロパティの存在が型情報に含まれていないため、TypeScriptがエラーを出す:

const page = usePage();
console.log(page.props.flash.status); // Error: プロパティ 'flash' が型 'PageProps' に存在しません

フラッシュメッセージはプロジェクト全体で使うので、global.d.tsに型定義を行った。

顧客一覧画面編集

顧客登録画面への遷移ボタンと、FlashMessage.vueコンポーネントを表示するためCustomers/Index.vueを編集する。

FlashMessage.vueコンポーネントを追記

importし、適当な場所に配置する。
全ページで表示する場合は、共通レイアウトのファイルで配置すれば良い。

<script setup lang="ts"> 
  import FlashMessage from '@/Components/FlashMessage.vue';
</script>
<template>
  <FlashMessage />
</template>

顧客登録ボタン作成

InertiaのLinkコンポーネントを用いてroute指定する。

<template>
  <Link :href="route('customers.create')"><v-btn color="blue" class="w-full">顧客登録</v-btn></Link>
</template>

実際に登録してみる

全てできたら、実際に登録画面へ移動し、入力して登録ボタンを押してみる。
一覧画面に戻り、フラッシュメッセージが表示されたら完了。

詳細画面

LaravelでデータをViewに渡す

CustomerController.phpstore()メソッドにて顧客情報を取得し、View側へ渡す。

<?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
{
  /**
   * Show the form for editing the specified resource.
   */
  public function edit(Customer $customer)
  {
      return Inertia::render('Customers/Edit', [
          'customer' => $customer
      ]);
  }
}

動作の流れ(内部ロジック)

  1. ルートが/customers/{customer}のように定義されていると、{customer}に渡されるパラメータがモデルのidとみなされる。
  2. Laravelは自動的にCustomerモデルに対応するEloquentクエリを発行する。
    • Customer::where('id', $id)->firstOrFail()と同じ処理が裏で行われる。
  3. 見つからない場合は404 Not Foundエラーを自動で返す。

Vueコンポーネントで顧客詳細画面を作成

顧客詳細画面を構成するVueコンポーネントを作成する。resources/js/Pages/Customers/Show.vueを新規作成(長いので一部削除)。
編集する・削除するボタンは配置のみ。実装は後述。

<script setup lang="ts">
  import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
  import { Head, useForm, router } from '@inertiajs/vue3';
import DisplayTextField from '@/Components/DisplayTextField.vue';

  // 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 goToCustomerEdit = () => {
  //  router.get(route('customers.edit', {
  //    'customer': customerForm.id
  //  }))
  //}
  
  // const deleteCustomer = () => {
  //   customerForm.delete(route('customers.destroy', {
  //     '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">
                <div class="d-flex">
                  <DisplayTextField
                  label="姓"
                  id="last_name"
                  v-model="customerForm.last_name"
                  type="text"
                  icon="mdi-card-bulleted-outline"
                  class="block w-full"
                  readonly
                  />
                  <DisplayTextField
                  label="名"
                  id="first_name"
                  v-model="customerForm.first_name"
                  type="text"
                  icon="mdi-card-bulleted-outline"
                  class="block w-full ml-2"
                  readonly
                  />
                </div>
                
                <!-- 〜〜一部略〜〜 -->
                <!-- 〜〜一部略〜〜 -->
                <!-- 〜〜一部略〜〜 -->
                
                <DisplayTextField
                label="郵便番号"
                id="postcode"
                v-model="customerForm.postcode"
                type="number"
                icon="mdi-post-lamp"
                class="block w-full"
                />
                
                <!-- 〜〜一部略〜〜 -->
                <!-- 〜〜一部略〜〜 -->
                <!-- 〜〜一部略〜〜 -->
                
                <div class="mb-5">
                  <v-list-item-subtitle>性別</v-list-item-subtitle>
                  <v-list-item-title v-if="customerForm.gender === 0">男性</v-list-item-title>
                  <v-list-item-title v-if="customerForm.gender === 1">女性</v-list-item-title>
                  <v-list-item-title v-if="customerForm.gender === 2">その他</v-list-item-title>
                </div>
                <v-textarea
                label="メモ"
                id="memo"
                v-model="customerForm.memo"
                icon="mdi-book-open-blank-variant-outline"
                class="block w-full"
                variant="plain"
                readonly
                />
              </v-col>
            </v-row>
            <v-row>
              <v-col lg="3"></v-col>
              <v-col md="12" lg="6" cols="12">
                <v-btn color="blue-darken-1" class="text-none w-full" rounded="xs" size="x-large" variant="flat">編集する</v-btn>
              </v-col>
            </v-row>
            <v-row>
              <v-col lg="3"></v-col>
              <v-col md="12" lg="6" cols="12">
                <v-btn color="red-darken-1 w-full" class="text-none" rounded="xs" size="x-large" variant="flat">削除する</v-btn>
              </v-col>
            </v-row>
          </v-container>
        </div>
      </div>
    </div>
  </AuthenticatedLayout>
</template>

resources/js/Components/DisplayTextField.vueを作成する(ラジオボタンとテキストエリアはそのまま使うのでコンポーネント化はしていない)。
v-modelを用いて表示しつつ、readonlyを用いて、表示のみとしている。

<script setup lang="ts">

const props = withDefaults(
  defineProps<{
		label?: string;
    type?: string;
    icon?: string;
  }>(),
  {
    type: 'text', // type のデフォルト値
    icon: 'mdi-home',    // icon のデフォルト値
  },
);

const model = defineModel<string>({ required: true });
</script>

<template>
	<v-text-field :label="label" v-model="model"  :type="type" variant="plain" class="text-field-display" readonly/>
</template>

<style scoped>
.text-field-display .v-input__control {
  border: none; /* 囲いをなくす */
  background: none; /* 背景を透明に */
  color: black; /* 文字色を黒に */
  cursor: default; /* クリック可能でない見た目 */
  pointer-events: none; /* 完全な表示専用 */
}
</style>

顧客一覧画面から遷移設定

顧客一覧画面(Customers/Index.vue)に詳細画面へのリンクを追加する。

goToCustomerShow()と、tbodytrタグへ@clickclassを追記。更にstyleも追記する。

<script setup lang="ts">
  import { router } from '@inertiajs/vue3';

  // 詳細画面へ遷移
  const goToCustomerShow = (id: number) => {
    router.get(route('customers.show', { customer: id }))
  }
</script>
<template>
  <tbody>
    <tr
    v-for="item in customers.data" :key="item.id" @click="goToCustomerShow(item.id)" class="hoverable-row">
      <td>{{ item.id }}</td>
      <td class="text-right">{{ item.full_name }}</td>
      <td class="text-right">{{ item.full_name_kana }}</td>
      <td class="text-right">{{ dayjs(item.created_at).format("YYYY-MM-DD") }}</td>
    </tr>
  </tbody>
</template>
<style scoped>
  .custom-table .hoverable-row:hover {
    background-color: #e6f7ff;
    cursor: pointer;
  }
</style>

Inertiaのrouterを用いてページ遷移を行う。


参考にさせて頂いたサイト様

]]>
開発メモ① モデル作成〜一覧表示〜検索・ソート機能(Laravel11 + Vue.js3 + TypeScript + Inertia.js + Vuetify) https://snow-leaf.com/programming/dontforget/laravel11_dev_memo/ Thu, 02 Jan 2025 22:42:17 +0000 https://snow-leaf.com/?p=1222

久しぶりにやると忘れてしまうので、個人用備忘録。 参考11.x TOC Laravelnull モデルの生成と、-aオプションはファクトリー・マイグレーション・シーダー・リクエスト・リソースコントローラー・ポリシーなどを ... ]]>

久しぶりにやると忘れてしまうので、個人用備忘録。

モデル作成・DB関連

モデルを作る

モデルの生成と、-aオプションはファクトリー・マイグレーション・シーダー・リクエスト・リソースコントローラー・ポリシーなどをまとめて生成

php artisan make:model Customer -a

マイグレーションファイルを編集し、以下を入力する

未処理のマイグレーションを全て実行

php artisan migrate

モデルのfillableに追記する

fillableプロパティは、マスアサインメント(Mass Assignment) という、複数のカラムに一度に値を設定する際に、どの属性を一括で変更できるかを指定する。このプロパティに指定したカラムのみが、ユーザーからの入力を受け取ることができる。

例えば、以下のようにfillableを設定した場合、nameemailのみがマスアサインメントに対応し、それ以外のカラムには直接値を設定できない。

class User extends Model
{
  protected $fillable = ['name', 'email'];
}

なぜfillableが必要か?

Laravel では、リクエストからのデータを直接モデルにインサートや更新する際に マスアサインメント を行うことができる。例えば、以下のようにユーザーが送信したデータを使って、Userモデルを更新する場合。

User::create($request->all());

このとき、$request->all()に含まれるすべてのデータがUserモデルのカラムにマッピングされるが、fillableを設定していないと、意図しないカラム(例えばパスワードや管理者権限など)にも値が割り当てられてしまうリスクがある。

ダミーデータを作る

Factoryを使う

Factoryファイルに生成したいデータを記述する。
参考: https://www.wantedly.com/companies/logical-studio/post_articles/916638

Laravel9以降では、fake()メソッドを使う。

特徴fake()$this->faker
導入バージョンLaravel9以降Laravel8以前
使用範囲グローバルFactoryクラス内部
簡潔さシンプル$thisが必要でやや冗長
インスタンス指定必要無し$this->fakerを参照

Factoryを書いた後に、DatabaseSeeder.phpにて呼び出す。

public function run(): void
{
  $this->call([
    CustomerSeeder::class
  ]);
}

CustomerSeeder.phprunメソッドに以下を追記する。

Customer::factory(1000)->create();

状況にあわせて以下のコマンドを入力する。

特定ファイルだけシーディングしたい場合

php artisan db:seed --class=UserSeeder

全テーブル削除してmigrateして、その後特定ファイルのシーディングする場合

php artisan migrate:fresh --seed --seeder=UserSeeder

全テーブル削除してmigrateして、その後シーディングする場合

php artisan migrate:fresh —seed

UserFactory.phpを作成した上で、DatabaseSeeder.phpに以下のように記述することで、ダミーデータを作成できる。

  • UserFactorydefinition() に記述されたデフォルトデータを基に、create() メソッドでユーザーを作成。
  • nameemail の値を引数として渡すことで、ファクトリーのデフォルト設定をカスタマイズ。
User::factory()->create([
	'name' => 'Test User',
	'email' => 'test@test.com',
]);

Factoryを使わない

DB::table('users')->insert() を使用して直接データベースにレコードを挿入する方法。
UserSeeder.phpを作成し、runメソッド内に以下を記述するか、DatabaseSeeder.phprunメソッドに以下を直接記述する。

複数insertしたい場合は、配列の配列([]内に更に[])を渡す。

DB::table('users')->insert([
    'name' => Str::random(10), // ランダムな10文字の名前
    'email' => Str::random(10).'@example.com', // ランダムなメールアドレス
    'password' => Hash::make('password'), // ハッシュ化されたパスワード
]);

どちらを使うか

項目Factoryを使う方法Factoryを使わない方法(直接挿入)
データ生成の自動化Factory で定義したルールに基づいて生成すべてを手動で指定する必要がある
カスタマイズの柔軟性create() の引数で一部の値を上書き可能挿入するすべての値を自分で記述する
読みやすさ簡潔で読みやすいフィールドごとに手動記述が必要でやや冗長
テストデータ向き主にテストやシーディング用に設計されている小規模で一時的な挿入に適している
  • 少数のレコードを手動で挿入する場合
    DB::table('users')->insert() がシンプルで素早く挿入できる。
  • テストや開発で大量のランダムデータが必要な場合
    Factoryを使うのがおすすめ。

ルーティング設定

モデルやダミーデータを作ったら、それをビューに渡すためにルーティングを設定する。アクセスがあると、ルーティングでリクエストが解決されて、コントローラーからビューにデータが渡される。

Restful

routes/web.phpに以下を追記する。

RESTfulなルーティングを実現するためにRoute::resourceメソッドを使用する。
また、middlewareで設定するauthはログイン認証を、verifiedはメールアドレス認証を意味する。

メールアドレス認証の仕組みでは、ユーザー登録時に登録メールアドレス宛てに確認メールを送信し、リンクをクリックすることで認証が完了する。この認証状況は、usersテーブルのemail_verified_atカラムで管理される。
ただし、初期設定では実装されていない。

Route::resource('customers', CustomerController::class)->middleware(['auth', 'verified']);

以下のコマンドでルーティングの確認を行う。

php artisan route:list

一覧表示

CustomerController.phpのindex()メソッド

一覧表示などの基本的なアクションは、indexメソッドで処理するのがLaravelのRESTful設計の標準。

Eloquent ORMを使用することで、データベース操作を簡潔なコードで行うことができる。例えば、Customerモデルを使ってデータを取得する場合、Customer::select('id', 'name')->get()Customer::paginate(10)を使って、データベースから必要な情報を取得し、それをビューに渡す。

public function index()
{
  $customers = Customer::select(
        'id',
        DB::raw("CONCAT(last_name, ' ', first_name) AS full_name"),
        DB::raw("CONCAT(last_name_kana, ' ', first_name_kana) AS full_name_kana"),
        'created_at'
    )->get();
  
  return Inertia::render('Customers/Index', [
    'customers' => $customers
  ]);
  
  /* Inertiaを使わないなら */
  return view('customers.index', compact('customers'));
  /* Inertiaを使わないなら */
}

Customers/Index.vue

コントローラーのindex()メソッドから渡された値を受け取り、Customer/Index.vueで表示する。
一度、console.logで出力してみる。

<script setup lang="ts">
  import { onMounted } from 'vue';

  const props = defineProps<{
    customers: {id: number, name: string }[]
  }>()
  onMounted(() => {
    console.log(props.customers);
  })
</script>

<template>

</template>

VuetifyのDataTableを使うと簡単にソートやページネーション機能もつけれるが、学習にならないので今回はVuetifyのTableコンポーネントを使って行う。ちなみに、DataTableを使ったコードとレイアウト例は以下。

<script setup lang="ts">
  import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
  import { Head } from '@inertiajs/vue3';
  import { DataTableHeader } from '@/types/vuetify'; //型ファイル呼び出し

  const props = defineProps<{
    customers: {id: number, full_name: string, full_name_kana: string, created_at:string}[]
  }>()

  const headers:DataTableHeader[] = [
    { title: 'ID', key: 'id' },
    { title: '名前', key: 'full_name' },
    { title: 'かな', key: 'full_name_kana' },
    { title: '最終来院日', key: 'created_at' },
  ];
</script>

<template>
	<Head title="Dashboard" />

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

		<div>
			<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
				<div
					class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
				>
          <v-container>
            <v-row>
              <v-col cols="12">
                <v-data-table
                  :items="customers"
                  :headers="headers"
                  item-key="id"
                >
                </v-data-table>
              </v-col>
            </v-row>
          </v-container>
				</div>
			</div>
		</div>
	</AuthenticatedLayout>
</template>

一覧表示する

Controllerのget()paginate()メソッドに変更する。括弧内は1ページに表示する件数。

  public function index()
  {
    $customers = Customer::select(
        'id',
        DB::raw("CONCAT(last_name, ' ', first_name) AS full_name"),
        DB::raw("CONCAT(last_name_kana, ' ', first_name_kana) AS full_name_kana"),
        'created_at'
    )->paginate(10);
    
    return Inertia::render('Customers/Index', [
      'customers' => $customers,
    ]);
  }

ページネーション作成

Inertia.jsを使用したページネーションを実装する。

resources/js/ComponentsディレクトリにPagination.vueを作成し、以下を入力する。

<script setup lang="ts">
import { Link } from '@inertiajs/vue3';

const props = defineProps<{
  links: {
    url: string | null;
    label: string;
    active: boolean;
  }[];
}>()

</script>
<template>
  <div v-if="links.length > 3">
      <div class="flex flex-wrap -mb-1">
        <template v-for="(link, p) in links" :key="p">
          <div v-if="link.url === null"
            class="mr-1 mb-1 px-4 py-3 text-sm leading-4 text-gray-400 border rounded"
            v-html="link.label"
          />
          <Link v-else
            class="mr-1 mb-1 px-4 py-3 text-sm leading-4 border rounded hover:bg-blue-300 focus:border-indigo-500"
            :class="{ 'bg-blue-700 text-white': link.active }"
            :href="link.url"
            v-html="link.label"
          />
        </template>
      </div>
  </div>
</template>

Customers/Index.vueを以下のように編集。

<script setup lang="ts">
  import { onMounted } from 'vue';
  import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
  import { Head } from '@inertiajs/vue3';
  import dayjs from 'dayjs';
  import { LaravelPagination } from '@/types/laravel';
  import Pagination from '@/Components/Pagination.vue'; //Paginationをimport

  type Customer = {
    id: number;
    full_name: string;
    full_name_kana: string;
    created_at: string;
  };
  
  const props = defineProps<{
    customers: LaravelPagination<Customer>;
  }>();
</script>

<template>
	<Head title="Dashboard" />

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

		<div class="">
			<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
				<div
					class="overflow-hidden bg-white shadow-lg sm:rounded-lg dark:bg-gray-800"
				>
          <v-container>
            <v-row class="justify-center">
              <v-col cols="10">
                <v-table density="compact">
                  <thead>
                    <tr>
                      <th class="text-left">ID</th>
                      <th class="text-right">名前</th>
                      <th class="text-right">かな</th>
                      <th class="text-right">最終来院日</th>
                    </tr>
                  </thead>
                  <tbody>
                    <tr
                      v-for="item in customers.data" :key="item.id">
                      <td>{{ item.id }}</td>
                      <td class="text-right">{{ item.full_name }}</td>
                      <td class="text-right">{{ item.full_name_kana }}</td>
                      <td class="text-right">{{ dayjs(item.created_at).format("YYYY-MM-DD") }}</td>
                    </tr>
                  </tbody>
                </v-table>
              </v-col>
              <Pagination class="my-6" :links="customers.links" /> <!-- Paginationを追記 -->
            </v-row>
          </v-container>
				</div>
			</div>
		</div>
	</AuthenticatedLayout>
</template>

ページネーションをもう少しカスタマイズしたい方は以下の記事が参考になる。

顧客検索機能

名前とカナで顧客検索ができる機能の実装を行う。

Customerモデルにローカルスコープを記述する。
以下はscopeSearchByNameというローカルスコープを記述し、これをコントローラーで呼び出す。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Customer extends Model
{
    /** @use HasFactory<\Database\Factories\CustomerFactory> */
    use HasFactory;

    protected $fillable = ['last_name', 'first_name', 'last_name_kana', 'first_name_kana', 'postcode', 'address', 'tel', 'birth', 'gender', 'memo', 'delete_flg'];

    public function scopeSearchByName($query, $keyword)
    {
        if ($keyword) {
            return $query->where(function ($query) use ($keyword) {
                $query->where('last_name', 'LIKE', '%' . $keyword . '%')
                    ->orWhere('first_name', 'LIKE', '%' . $keyword . '%')
                    ->orWhere('last_name_kana', 'LIKE', '%' . $keyword . '%')
                    ->orWhere('first_name_kana', 'LIKE', '%' . $keyword . '%');
            });
        }

        return $query;
    }
}

ローカルスコープを使用することで、特定の条件を簡単にクエリに適用することができ、コードの可読性や再利用性が向上する。
スコープメソッド名はscopeで始め、その後にキャメルケースで続ける。

CustomerController.phpを以下の様に、scopeという文字を除いたメソッド名でローカルスコープを記述し、引数を指定する。例えば以下のコードでは、山田というワードで検索をかけ、dd()でデバッグしている。実際に山田の検索結果が得られれば成功。

class CustomerController extends Controller
{
  public function index(Request $request)
  {
    $customers = Customer::searchByName('山田')
    ->select(
        'id',
        DB::raw("CONCAT(last_name, ' ', first_name) AS full_name"),
        DB::raw("CONCAT(last_name_kana, ' ', first_name_kana) AS full_name_kana"),
        'created_at')
    ->paginate(10);
    
    dd($customers)
    
    return Inertia::render('Customers/Index', [
      'customers' => $customers,
    ]);
  }
}

実際には入力された値が渡るようにしたいので、以下のように修正する。

class CustomerController extends Controller
{
  public function index(Request $request)
  {
    $keyword = $request->searchKeyword ?? '';     //=====ここを追記======
    $customers = Customer::searchByName($keyword) //=====引数を$keywordに======
    ->select(
        'id',
        DB::raw("CONCAT(last_name, ' ', first_name) AS full_name"),
        DB::raw("CONCAT(last_name_kana, ' ', first_name_kana) AS full_name_kana"),
        'created_at')
    ->paginate(10);
    
    //dd($customers)
    
    return Inertia::render('Customers/Index', [
      'customers' => $customers,
    ]);
  }
}

次にView側、Customers/Index.vueに以下を記述する。
Enterキーでも検索を実行させたいので、InertiaのuseFormを用いることにした。

検索用のTextFieldにはVuetifyを用いる。buttonかEnterキーを押下すると、searchByName()が走り、getメソッドにてフォーム送信される。パラメータはsearchKeywordとして送られる(コントローラー側で$request->searchKeywordで取得する)。

<script setup lang="ts">
  import { useForm } from '@inertiajs/vue3';
  
  // 型定義
  type SearchForm = {
    searchKeyword?: string;
  };
  
  // searchKeywordをリアクティブにする
  const searchForm = useForm<SearchForm>({
    searchKeyword: ""
  })
  
  // 動的にクエリパラメータを生成
  const queryParams = computed(() => {
    const params:Record<string, string> = {}
    if (searchForm.searchKeyword) {
      params.searchKeyword = searchForm.searchKeyword;
    }
    return params;
  });

  const searchByName = () => {
    searchForm.get(route('customers.index', queryParams.value))
  }
</script>
<template>
  <form @submit.prevent class="w-100">
    <v-text-field density="compact" label="顧客検索" variant="solo" hide-details single-line v-model="searchForm.searchKeyword">
      <template #append-inner>
        <button @click="searchByName" type="submit" class="icon-button"> 
          <v-icon>mdi-magnify</v-icon>
        </button>
      </template> 
    </v-text-field>
  </form>
</template>

次に、ページネーションでページ移動すると検索結果が消えてしまうのでその修正と、検索結果画面において、検索ワードも消えないように保持を行う。
CustomerController.phpを以下の様に編集する。

class CustomerController extends Controller
{
  public function index(Request $request)
  {
    $keyword = $request->searchKeyword ?? '';
    $customers = Customer::searchByName($keyword)
    ->select(
        'id',
        DB::raw("CONCAT(last_name, ' ', first_name) AS full_name"),
        DB::raw("CONCAT(last_name_kana, ' ', first_name_kana) AS full_name_kana"),
        'created_at')
    ->paginate(10);
    ->withQueryString();           //=====ここを追記======
    
    //dd($customers)
    
    return Inertia::render('Customers/Index', [
      'customers' => $customers,
      'keyword' => $keyword       //=====ここを追記======
    ]);
  }
}

withQueryString()をつけることによって、ページネーションでページ移動した際もクエリが保持される。

$keywordをView側に渡したので、Customer/Index.vueに以下を追記する。これで検索ワードを保持してくれる。

<script setup lang="ts">
import { onMounted } from 'vue';

const props = defineProps<{
  customers: LaravelPagination<Customer>;
  keyword: string;                           //=====ここを追記======
}>();

onMounted(() => {
  searchForm.searchKeyword = props.keyword   //=====ここを追記======
})
</script>

ソート機能

次に、最終来院日でのソート機能を実装する。CustomerController.phpindex()に以下を追記する。
クエリパラメータでsortValが送られてきて、それを取得する。ascかdescかに応じてorderBy()でソートをかける。
最終的に$sortを返却する。

  public function index(Request $request)
  {
    $keyword = $request->searchKeyword ?? '';
    $sort = $request->sortVal ?? '';                  //=====ここを追記======

    $customers = Customer::searchByName($keyword)
    ->select(
        'id',
        DB::raw("CONCAT(last_name, ' ', first_name) AS full_name"),
        DB::raw("CONCAT(last_name_kana, ' ', first_name_kana) AS full_name_kana"),
        'created_at')
    ->when($sort === 'asc', function ($query) {                         //=====ここを追記======
        $query->orderBy('created_at', 'asc')->orderBy('id', 'asc');       //=====ここを追記======
    }, function ($query) {                                                //=====ここを追記======
        $query->orderBy('created_at', 'desc')->orderBy('id', 'desc');     //=====ここを追記======
    })                                                                    //=====ここを追記======
    ->paginate(10)
    ->withQueryString();

    return Inertia::render('Customers/Index', [
      'customers' => $customers,
      'keyword' => $keyword,
      'sort' => $sort                                 //=====ここを追記======
    ]);
  }

View側での処理を行う。Customers/Index.vueに以下を追記する。queryParams()は既存のものを使い、追記する。
queryParams()では顧客検索のクエリパラメータも同時に送れるようにし、保持できるようにしている。

<script setup lang="ts">
  // 型定義
  type SortForm = {
    sortVal: 'asc' | 'desc';
  };
  
  // sortValをリアクティブにする
  const sort = useForm<SortForm>({
    sortVal: "desc"
  })
  
  // ソートアイコンの切り替え
  const getSortIcon = () => {
    return sort.sortVal === 'asc' ? "mdi-menu-up" : "mdi-menu-down";
  }
  
  // 動的にクエリパラメータを生成
  const queryParams = computed(() => {
    const params:Record<string, string> = {}
    if (searchForm.searchKeyword) {
      params.searchKeyword = searchForm.searchKeyword;
    }
    if (sort.sortVal) {                                // =======既存のqueryParamsに追記する=======
      params.sortVal = sort.sortVal;                   // =======既存のqueryParamsに追記する=======
    }                                                  // =======既存のqueryParamsに追記する=======
    return params;
  });

  // ソート押下時処理
  const sortHandler = () => {
    sort.sortVal = sort.sortVal === "asc" ? "desc" : "asc";

    sort.get(route('customers.index', queryParams.value))
  }
</script>
<template>
<!-- 省略 -->
  <thead>
    <tr>
      <th class="text-left">ID</th>
      <th class="text-right">名前</th>
      <th class="text-right">かな</th>
      <th class="text-right">
        <button @click="sortHandler">                  <!-- =======buttonを追加======= -->
          最終来院日
          <v-icon>{{ getSortIcon() }}</v-icon>         <!-- =======buttonを追加======= -->
        </button>                                      <!-- =======buttonを追加======= -->
      </th>
    </tr>
  </thead>
<!-- 省略 -->
</template>

完成系のコードは以下。

<script setup lang="ts">
  import { onMounted, computed } from 'vue';
  import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
  import { Head, useForm, Link } from '@inertiajs/vue3';
  import dayjs from 'dayjs';
  import { LaravelPagination } from '@/types/laravel';
  import Pagination from '@/Components/Pagination.vue';

  // customers のデータ構造を定義
  type Customer = {
    id: number;
    full_name: string;
    full_name_kana: string;
    created_at: string;
  };
  // フォームの型を定義
  type SearchForm = {
    searchKeyword?: string;
  };
  type SortForm = {
    sortVal: 'asc' | 'desc';
  };

  // props管理
  // LaravelPagination<Customer> を使用
  const props = defineProps<{
    customers: LaravelPagination<Customer>;
    keyword: string;
    sort: 'asc' | 'desc';
  }>();

  // フォームデータ管理
  const searchForm = useForm<SearchForm>({
    searchKeyword: ""
  })
  const sort = useForm<SortForm>({
    sortVal: "desc"
  })

  // ソートアイコンの切り替え
  const getSortIcon = () => {
    return sort.sortVal === 'asc' ? "mdi-menu-up" : "mdi-menu-down";
  }

  // 動的にクエリパラメータを生成
const queryParams = computed(() => {
  const params:Record<string, string> = {}
  if (searchForm.searchKeyword) {
    params.searchKeyword = searchForm.searchKeyword;
  }
  if (sort.sortVal) {
    params.sortVal = sort.sortVal;
  }
  return params;
});

  // GET送信用関数
  const searchByName = () => {
    searchForm.get(route('customers.index', queryParams.value))
  }
  const sortHandler = () => {
    sort.sortVal = sort.sortVal === "asc" ? "desc" : "asc";

    sort.get(route('customers.index', queryParams.value))
  }

  // 画面マウント後処理
  onMounted(() => {
    //検索ワードを保持する
    searchForm.searchKeyword = props.keyword
    sort.sortVal = props.sort
  })
</script>

<template>
	<Head title="Dashboard" />

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

		<div class="">
			<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
				<div
					class="overflow-hidden bg-white shadow-lg sm:rounded-lg dark:bg-gray-800"
				>
          <v-container>
            <v-row>
              <v-col cols="1"></v-col>
              <v-col cols="3" class="d-flex">
                <form @submit.prevent class="w-100">
                  <v-text-field density="compact" label="顧客検索" variant="solo" hide-details single-line v-model="searchForm.searchKeyword">
                    <template #append-inner>
                      <button @click="searchByName" type="submit" class="icon-button"> 
                        <v-icon>mdi-magnify</v-icon>
                      </button>
                    </template> 
                  </v-text-field>
                </form>
              </v-col>
            </v-row>
            <v-row class="justify-center">
              <v-col cols="12" md="10" lg="10" xl="10">
                <v-table density="compact">
                  <thead>
                    <tr>
                      <th class="text-left">ID</th>
                      <th class="text-right">名前</th>
                      <th class="text-right">かな</th>
                      <th class="text-right">
                        <button @click="sortHandler">
                          最終来院日
                          <v-icon>{{ getSortIcon() }}</v-icon>
                        </button>
                      </th>
                    </tr>
                  </thead>
                  <tbody>
                    <tr
                      v-for="item in customers.data" :key="item.id">
                      <td>{{ item.id }}</td>
                      <td class="text-right">{{ item.full_name }}</td>
                      <td class="text-right">{{ item.full_name_kana }}</td>
                      <td class="text-right">{{ dayjs(item.created_at).format("YYYY-MM-DD") }}</td>
                    </tr>
                  </tbody>
                </v-table>
              </v-col>
              <Pagination class="my-6" :links="customers.links" />
            </v-row>
          </v-container>
				</div>
			</div>
		</div>
	</AuthenticatedLayout>
</template>

続きは②で。

開発メモ② 登録・詳細機能実装(Laravel11 + Vue.js3 + TypeScript + Inertia.js + Vuetify) ]]>
接骨院・治療院向けCRM設計書(学習用・進行中) https://snow-leaf.com/programming/sekkotuin_crm_document/ Tue, 31 Dec 2024 08:10:01 +0000 https://snow-leaf.com/?p=1160

メモとして残しております。設計書です。 Laravelデフォルトのものを使用。使わないカラムもありますが、そのままいきます。 カラム名 型 PK / FK UNIQUE NOT NULL id bigInt 🔑 ✅ ✅ ... ]]>

メモとして残しております。設計書です。

要件定義

目的と目標

システムの導入目的

  • 接骨院・治療院の顧客管理と、分析

解決したい課題

  • 利用年齢層に応じたニーズへの対応

対象範囲

システムの利用者(ユーザー層)

  • 接骨院経営者

サービスの対象範囲

  • PC・スマホ・タブレット
  • 顧客管理・支払い金額・施術管理

機能要件

必要な機能

  • 顧客管理、施術履歴管理、メニュー管理、施術メニュー管理など

非機能要件

セキュリティ要件(認証、権限管理)

  • 管理者のみ登録可能、一般ユーザーは閲覧のみとする

運用要件

保守運用の方法

  • さくらレンタルサーバー上にて運用予定

制約事項

技術的制約

  • Laravel11
  • Vue.js3
  • Vuetify
  • TypeScript
  • chart.js
  • Inertia.js
  • MySQL
  • 開発はDockerとVSCode
  • デプロイはさくらのレンタルサーバー

予算・スケジュールの制約

  • 特に無し

基本設計

システム構成

システム全体のアーキテクチャ

  • Web三層アーキテクチャ
  • SPAを基本とする

使用するプラットフォームや技術

  • 要件定義制約事項に同じ

画面設計

各画面の構成やUI/UXの概要(ワイヤーフレーム、遷移図)

  • 作成中

入出力項目

  • 作成中

データ設計

データベースの概要

  • 顧客(cusomers)
  • スタッフ(staffs)
  • 施術メニュー(menus)
  • 施術履歴(顧客・施術メニュー・担当スタッフ・日にち)(histrories)

機能概要設計

各機能の処理フロー

  • 作成中

外部連携仕様

  • 作成中

詳細設計

プログラム設計

各モジュールやクラスの設計

  • 作成中

メソッドや関数の仕様(引数、戻り値、処理内容)

  • 作成中

画面詳細設計

具体的な画面レイアウト(UI設計書)

  • 作成中

入力チェックやバリデーション仕様

  • 作成中

データベース設計

usersテーブル

Laravelデフォルトのものを使用。使わないカラムもありますが、そのままいきます。

カラム名PK / FKUNIQUENOT NULL
idbigInt🔑✅✅
namestring✅
emailstring✅
email_verified_attimestamp
passwordstring✅
remember_tokenstring
created_attimestamp
updated_attimestamp

customersテーブル

顧客

カラム名PK / FKUNIQUENOT NULL
idbigInt🔑✅✅
last_namestring✅
first_namestring✅
last_name_kanastring✅
first_name_kanastring✅
postcodestring✅
addressstring✅
telstring unique✅✅
birthdate✅
gendertinyInteger✅
memotext
delete_flgboolean✅
created_attimestamp
updated_attimestamp

staffsテーブル

従業員

カラム名PK / FKUNIQUENOT NULL
idbigInt🔑✅✅
last_namestring✅
first_namestring✅
last_name_kanastring✅
first_name_kanastring✅
licensestring
delete_flgboolean✅
created_attimestamp
updated_attimestamp

menusテーブル

施術メニュー

カラム名PK / FKUNIQUENOT NULL
idbigInt🔑✅✅
namestring✅
priceinteger✅
timeinteger✅
genre_idbigInt➡ genres(id)✅
own_pay_flgboolean✅
hidden_flgboolean✅
created_attimestamp
updated_attimestamp

genresテーブル

施術メニューがマッサージか、鍼灸か機械かなど

カラム名PK / FKUNIQUENOT NULL
idbigInt🔑✅✅
namestring✅

menu_historyテーブル

メニューごと(1部位や1メニューごと1レコード)

カラム名PK / FKUNIQUENOT NULL
idbigInt🔑✅✅
history_idbigInt➡ histories(id)✅
menu_idbigInt➡ menus(id)✅
body_partstring✅
memotext
created_attimestamp
updated_attimestamp

historiesテーブル

施術全体情報(誰が誰に対して行なった 詳細はmenu_historyテーブルで)

カラム名PK / FKUNIQUENOT NULL
idbigInt🔑✅✅
customer_idbigInt➡ customers(id)✅
staff_idbigInt➡ staffs(id)✅
datedatetime✅
memotext
created_attimestamp
updated_attimestamp

ER図

URL設計

No.名前URLmethodroute-nameVueController
1顧客一覧/customersgetcustomers.indexCustomers/IndexCustomerController@index
2顧客登録画面/customers/creategetcustomers.createCustomers/CreateCustomerController@create
3顧客登録/customerspostcustomers.storeCustomerController@store
4顧客詳細画面/customers/{customer}getcustomers.showCustomers/ShowCustomerController@show
5顧客編集画面/customers/{customer}/editgetcustomers.editCustomers/EditCustomerController@edit
6顧客更新/customers/{customer}putcustomers.updateCustomerController@update
7顧客削除/customers/{customer}deletecustomers.destroyCustomerController@destroy
8顧客検索結果/customersgetapi.searchCustomersApi\searchCustomerController@index
9スタッフ一覧/staffsgetstaff.indexStaff/IndexStaffController@index
10スタッフ登録画面/staffs/creategetstaff.createStaff/CreateStaffController@create
11スタッフ登録/staffspoststaff.storeStaffController@store
12スタッフ詳細画面/staffs/{staff}getstaff.showStaff/ShowStaffController@show
13スタッフ編集画面/staffs/{staff}/editgetstaff.editStaff/EditStaffController@edit
14スタッフ更新/staffs/{staff}putstaff.updateStaffController@update
15スタッフ削除/staffs/{staff}deletestaff.destroyStaffController@destroy
16メニュー一覧/menusgetmenu.indexMenu/IndexMenuController@index
17メニュー登録画面/menus/creategetmenu.createMenu/CreateMenuController@create
18メニュー登録/menuspostmenu.storeMenuController@store
19メニュー詳細画面/menus/{menu}getmenu.showMenu/ShowMenuController@show
20メニュー編集画面/menus/{menu}/editgetmenu.editMenu/EditMenuController@edit
21メニュー更新/menus/{menu}putmenu.updateMenuController@update
22メニュー削除/menus/{menu}deletemenu.destroyMenuController@destroy
23ジャンル一覧/genregetgenre.indexGenre/IndexGenreController@Index
24ジャンル登録画面/genres/creategetgenre.createGenre/CreateGenreController@create
25ジャンル登録/genres/createpostgenre.storeGenreController@store
26ジャンル編集画面/genres/{genre}/editgetgenre.editGenre/EditGenreController@edit
27ジャンル更新/genres/{genre}putgenre.updateGenreController@update
28ジャンル削除/genres/{genre}deletegenre.destroyGenreController@destroy
29施術情報登録画面/histories/creategethistory.createHistory/CreateHistoryController@create
30施術情報登録/historiesposthistory.storeHistoryController@store
31施術履歴一覧画面/historiesgethistory.indexHistory/IndexHistoryController@index
32施術履歴詳細画面/histories/{histories}gethistory.showHistory/ShowHistoryController@show
33施術履歴編集画面/histories/{histories}/editgethistory.editHisotry/EditHistoryController@edit
34施術履歴更新/historiesputhistory.updateHistoryController@update
35施術情報削除/historiesdeletehistory.destroyHistoryController@destroy
36分析画面/analysisgetanalysisAnalysisAnalysisController@index
37分析結果取得/api/analysisgetapi.analysisApi\AnalysisController@index

メモ

施術登録

日付・顧客名・メニューを一覧表示(ジャンル・金額)・施術部位(入力・追加できるようにする)・小計

]]>
Next.js(SSG)とPHPで作るお問い合わせフォーム:さくらレンタルサーバ https://snow-leaf.com/programming/nextjs_php_contactform/ Sun, 29 Dec 2024 20:33:28 +0000 https://snow-leaf.com/?p=1126

ちょうどNext.jsを触り出した頃、友人から事業用のホームページ制作を依頼されました。現在注目を集めているNext.jsを使って制作すれば、学習にもつながり非常に魅力的だと感じましたが、予算が限られていたため、できるだ ... ]]>

はじめに

ちょうどNext.jsを触り出した頃、友人から事業用のホームページ制作を依頼されました。
現在注目を集めているNext.jsを使って制作すれば、学習にもつながり非常に魅力的だと感じましたが、予算が限られていたため、できるだけコストを抑えて制作する必要がありました。
Next.jsを使用する場合、可能な限りサーバーサイドレンダリング(SSR)を活用したいところですが、SSRを実現するにはVPSなどのNode.jsを動作させる環境が必要となります。
しかし、コスト面を考慮すると、レンタルサーバー上で静的サイト生成(SSG)を用いる方法が適していると判断しました。

さらに、お問い合わせフォームも無料で利用できるサービスが見当たらなかったため、以下の記事を参考に、レンタルサーバー上で動作するPHPを用いて一からお問い合わせフォームを構築することにしました。

Next.js + React Hook Form + バニラPHPでお問い合わせメールを送る

上記の記事を、より実践的な形に落とし込んだのが当記事です。参考にさせていただいた記事に敬意を表します🙏

必要なもの

  1. Next.jsプロジェクト: 既にセットアップ済み、もしくは新規に作成します。
  2. PHPをローカルで動かせる環境:今回は簡単なのでMAMPを使います。Docker等でもいいですね。さくらのレンタルサーバとバージョンをあわせてください。
  3. さくらレンタルサーバのアカウント
    • ドメイン名
      下の画像で確認してください。SPF、DKIM、DMARCの設定をしてください。
    • メールアドレス
      「メール」→「メール一覧」→「新規作成」で作成します。その際にパスワード等は忘れないでください。
    • SMTPサーバー情報
      ・SMTPサーバー:〜.sakura.ne.jp(初期ドメイン)
      ・SMTPユーザー:上記設定メールアドレス@ドメイン名
      ・SMTPパスワード:メール新規作成で作ったパスワード
      ・ポート:587

環境

  • Next.js 14
  • React 18
  • React Hook Form 7
  • axios 1.7
  • PHP 8.3
  • Composer 2.7
  • PHPMailer 6.9
  • Dotenv 5.6

    さくらレンタルサーバ側
  • FreeBSD 13
  • PHP 8.3
  • Composer 2.7
  • Apache 2.4

フロントエンドの実装(Next.js)

Next.jsを使用してお問い合わせフォームを作成します。フォームにはreact-hook-formを使用し、入力バリデーションや送信処理を簡潔に行います。
今回のプロジェクトでは、以下のようなフォルダ構成を採用しました。

フォルダ構成

.env.development
.env.production
src/
└── app/
    ├── components/
    │   ├── Modal.tsx
    │   └── Contact.tsx
    ├── complete/
    │   └── page.tsx
    ├── error/
    │   └── page.tsx
    └── page.tsx

必要最低限の構成となっております。プロジェクトでは各々のルールで作成してください🙇‍♀️

必要なパッケージのインストール

まず、必要なパッケージをインストールします。

npm install react-hook-form axios
  • react-hook-form: フォームの管理とバリデーションを行います。
  • axios: フォームデータをバックエンドに送信するために使用します。

コンポーネントの作成

以下は、Contact.tsxに記述するお問い合わせフォームのコンポーネントです。

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm, SubmitHandler } from "react-hook-form";
import axios from "axios";
import Modal from "./Modal";

type Inputs = {
  name: string;
  email: string;
  message: string;
  submit: any;
};

export default function Contact() {
  const router = useRouter();

  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<Inputs>();

  const [isModalOpen, setIsModalOpen] = useState(false);
  const [formData, setFormData] = useState<Inputs | null>(null);

  const handleConfirmSubmit = (data: Inputs) => {
    setFormData(data);
    setIsModalOpen(true);
  };

  const onSubmit: SubmitHandler<Inputs> = async () => {
    if (!formData) return;

    try {
      await axios.post(
        process.env.NEXT_PUBLIC_FETCH_URL!,
        JSON.stringify(formData),
        {
          headers: {
            "Content-Type": "application/json",
          },
        }
      );
      router.push("/contact/complete");
    } catch (error) {
      router.push("/contact/error");
    } finally {
      setIsModalOpen(false);
    }
  };

  return (
    <div className="container">
      <div className="inquiry">
        <form id="mailForm" onSubmit={handleSubmit(handleConfirmSubmit)}>
          <div className="column2">
            <label htmlFor="name">
              <span className="required">必須</span>
              お名前
            </label>
            <div className="inputWrap">
              <input
                type="text"
                id="name"
                placeholder="お名前"
                {...register("name", {
                  required: "お名前を入力してください。",
                  maxLength: {
                    value: 20,
                    message: "20文字以下で入力してください。",
                  },
                })}
              />
              {errors.name?.message && (
                <p className="errorMessage">{errors.name?.message}</p>
              )}
            </div>
          </div>
          <div className="column2">
            <label htmlFor="email">
              <span className="required">必須</span>
              メール
            </label>
            <div className="inputWrap">
              <input
                type="email"
                id="email"
                placeholder="メールアドレス"
                {...register("email", {
                  required: "メールアドレスを入力してください。",
                  maxLength: {
                    value: 50,
                    message: "50文字以下で入力してください。",
                  },
                  pattern: {
                    value:
                      /^[a-zA-Z0-9_.+-]+@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/,
                    message: "正しいメールアドレスを入力してください。",
                  },
                })}
              />
              {errors.email?.message && (
                <p className="errorMessage">{errors.email?.message}</p>
              )}
            </div>
          </div>
          <div className="column2">
            <label htmlFor="message">
              <span className="required">必須</span>
              メッセージ
            </label>
            <div className="inputWrap">
              <textarea
                id="message"
                rows={10}
                placeholder="お問い合わせ内容"
                {...register("message", {
                  required: "メッセージを入力してください。",
                  maxLength: {
                    value: 1000,
                    message: "1000文字以下で入力してください。",
                  },
                })}
              ></textarea>
              {errors.message?.message && (
                <p className="errorMessage">{errors.message.message}</p>
              )}
            </div>
          </div>
          <div className="column2">
            <input
              type="submit"
              value="内容を確認する"
              id="sendBtn"
              name="sendBtn"
            />
          </div>
          <Modal
            isOpen={isModalOpen}
            onClose={() => setIsModalOpen(false)}
            onConfirm={handleSubmit(onSubmit)}
            formData={formData}
            isLoading={isSubmitting}
          />
        </form>
      </div>
      <style jsx>{`
        .container {
          display: flex;
          flex-direction: column;
          justify-content: center;
          align-items: center;
          min-height: 100vh;
          padding: 20px;
          margin: 0 auto;
        }

        .required {
          color: red;
        }

        .inputWrap {
          margin-top: 8px;
        }

        .errorMessage {
          color: red;
          font-size: 12px;
        }

        input[type="text"],
        input[type="email"],
        input[type="phone"],
        textarea {
          width: 100%;
          font-size: 1rem;
          padding: 0.5rem 1rem;
          border-radius: 0.5rem;
          border: none;
          box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
          background-color: #f6eddb;
          box-sizing: border-box;
          flex: 2;
        }

        input[type="submit"] {
          display: inline-block;
          padding: 10px 20px;
          margin: 0 auto;
          font-size: 16px;
          font-weight: bold;
          color: #ffffff;
          background-color: #e9a320;
          border: none;
          border-radius: 4px;
          box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
          cursor: pointer;
          transition: all 0.2s ease-in-out;
          width: 50%;
          box-sizing: border-box;
        }

        input[type="submit"]:hover {
          background-color: #a33400;
          box-shadow: 0px 8px 8px rgba(0, 0, 0, 0.25);
        }

        .column2 {
          display: flex;
          justify-content: center;
          margin-bottom: 16px;
        }
        .inquiry {
          background-color: #fff;
          border-radius: 20px;
          box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
          padding: 20px;
          width: 700px;
          box-sizing: border-box;
          margin: 0 auto;
        }

        .inquiry .inner {
          max-width: 700px;
          margin: 0 auto;
        }

        .inquiry label {
          display: flex;
          flex: 0.5;
          align-items: center;
        }

        .inputWrap {
          flex: 2;
          margin-bottom: 15px;
        }

        .required {
          display: inline-block;
          padding: 2px 8px;
          background-color: red;
          color: #fff;
          border-radius: 8px;
          margin-right: 4px;
          font-size: 12px;
        }
      `}</style>
    </div>
  );
}

以下は、Modal.tsxに記述するお問い合わせフォームのモーダルコンポーネントです。

import { useEffect } from "react";

type ModalProps = {
  isOpen: boolean;
  onClose: () => void;
  onConfirm: () => void;
  formData: {
    name: string;
    email: string;
    message: string;
  } | null;
  isLoading: boolean;
};

const Modal: React.FC<ModalProps> = ({
  isOpen,
  onClose,
  onConfirm,
  formData,
  isLoading,
}) => {
  useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = "hidden";
    } else {
      document.body.style.overflow = "";
    }

    return () => {
      document.body.style.overflow = "";
    };
  }, [isOpen]);

  if (!isOpen || !formData) return null;

  return (
    <div className="modalBackdrop">
      <div className="modal">
        <h2 className="modalTitle">この内容で送信します</h2>
        <div className="inquiry">
          <div className="column2">
            <label>お名前</label>
            <div className="inputWrap">
              <input type="text" id="name" value={formData.name} disabled />
            </div>
          </div>
          <div className="column2">
            <label>メール</label>
            <div className="inputWrap">
              <input type="email" id="email" value={formData.email} disabled />
            </div>
          </div>
          <div className="column2">
            <label>メッセージ</label>
            <div className="inputWrap">
              <textarea
                id="message"
                rows={5}
                value={formData.message}
                disabled
              ></textarea>
            </div>
          </div>
          <div className="modalActions">
            <button
              className="modalButton"
              onClick={onConfirm}
              disabled={isLoading}
            >
              {isLoading ? "送信中..." : "送信する"}
            </button>
            <button
              className="modalButton cancel"
              onClick={onClose}
              disabled={isLoading}
            >
              キャンセル
            </button>
          </div>
        </div>
      </div>
      <style jsx>{`
        .modalBackdrop {
          position: fixed;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          background-color: rgba(0, 0, 0, 0.5);
          display: flex;
          justify-content: center;
          align-items: center;
          z-index: 1000;
        }

        .modal {
          position: fixed;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          background-color: white;
          padding: 20px;
          border-radius: 10px;
          width: 700px;
          box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
          z-index: 1001;
        }

        .modalTitle {
          font-size: 20px;
          margin-bottom: 16px;
          text-align: center;
        }

        .modalActions {
          display: flex;
          margin-top: 20px;
        }

        .modalButton {
          display: inline-block;
          padding: 10px 20px;
          margin: 0 5px 20px 5px;
          font-size: 16px;
          font-weight: bold;
          color: #ffffff;
          background-color: #e9a320;
          border: none;
          border-radius: 4px;
          box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
          cursor: pointer;
          transition: all 0.2s ease-in-out;
          width: 100%;
          box-sizing: border-box;
        }

        .modalButton:disabled {
          background-color: #ccc;
          cursor: not-allowed;
          box-shadow: none;
          opacity: 0.6;
          pointer-events: none;
          transition: none;
        }

        .column2 {
          display: flex;
          justify-content: center;
          margin-bottom: 16px;
        }

        .inquiry label {
          display: block;
          flex: 0.5;
        }

        .inquiry input[type="text"],
        .inquiry input[type="email"],
        .inquiry textarea {
          width: 100%;
          font-size: 1rem;
          padding: 0.5rem 1rem;
          border-radius: 0.5rem;
          border: none;
          box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
          box-sizing: border-box;
          color: gray;
          background-color: rgb(233, 233, 233);
          flex: 2;
        }

        .inputWrap {
          flex: 2;
          margin-bottom: 15px;
        }

        .modalButton:hover {
          background-color: #a33400;
          box-shadow: 0px 8px 8px rgba(0, 0, 0, 0.25);
        }

        .modalButton.cancel {
          background-color: #e74c3c;
          color: white;
        }

        .modalButton.cancel:hover {
          background-color: #a33400;
          box-shadow: 0px 8px 8px rgba(0, 0, 0, 0.25);
        }
      `}</style>
    </div>
  );
};

export default Modal;

送信完了後に表示されるcomplete/page.tsx、エラー時のerror/page.tsxは適当に作っておきます。

export default function CompletePage(){
  return (
  <>
    送信完了しました
  </>
  )
}
export default function ErrorPage() {
  return (
    <>
      エラーが発生しました
    </>
  );
}

コード解説

Contact.tsx

インポートと依存関係

  • Reactおよびフック
    • useState: ローカル状態(モーダルの開閉状態やフォームデータ)を管理。
  • react-hook-form
    • フォームの状態管理とバリデーションを効率的に行うためのライブラリ。
  • axios
    • HTTPリクエストを行うためのライブラリ。ここではフォームデータの送信に使用。
  • next/navigation
    • Next.jsのルーターを使用してページ遷移を制御。
  • Modalコンポーネント
    • 確認モーダルを表示するためにカスタムコンポーネントをインポート。

型定義

type Inputs = {
  name: string;
  email: string;
  message: string;
  submit: any;
};

フォームで扱う入力データの型を定義しています。

フォーム管理

const {
  register,
  handleSubmit,
  formState: { errors, isSubmitting },
} = useForm<Inputs>();
  • useFormフックを使用してフォームの状態を管理。registerでフォームフィールドを登録し、handleSubmitで送信時の処理をラップします。
  • errorsにはバリデーションエラーが格納され、isSubmittingはフォーム送信中の状態を示します。

ローカル状態の管理

const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState<Inputs | null>(null);
  • isModalOpen: モーダルの開閉状態を管理。
  • formData: ユーザーが入力したフォームデータを一時的に保存。

送信前の確認処理

const handleConfirmSubmit = (data: Inputs) => {
  setFormData(data);
  setIsModalOpen(true);
};

ユーザーがフォームを送信しようとした際に呼び出され、入力データをformDataに保存し、確認モーダルを開きます。

最終送信処理

const onSubmit: SubmitHandler<Inputs> = async () => {
  if (!formData) return;

  try {
    await axios.post(
      process.env.NEXT_PUBLIC_FETCH_URL!,
      JSON.stringify(formData),
      {
        headers: {
          "Content-Type": "application/json",
        },
      }
    );
    router.push("/contact/complete");
  } catch (error) {
    router.push("/contact/error");
  } finally {
    setIsModalOpen(false);
  }
};
  • onSubmit関数は、実際にデータをサーバーに送信する処理を担当。
  • 環境変数
    • process.env.NEXT_PUBLIC_FETCH_URL: データ送信先のURLを環境変数として設定。NEXT_PUBLIC_で始まる環境変数はクライアントサイドでもアクセス可能。
  • エラーハンドリング
    • データ送信が成功した場合は/contact/completeページへ遷移。
    • 失敗した場合は/contact/errorページへ遷移。
  • 最終的にモーダルを閉じます。

レンダリング部分

  • フォームフィールド(名前、メール、メッセージ)の入力欄とバリデーションエラーメッセージを表示。
  • 送信ボタンを押すとhandleConfirmSubmitが呼び出され、モーダルが表示されます。
  • Modalコンポーネントをレンダリングし、モーダルの開閉や送信確認を制御。

Modal.tsx

インポートと依存関係

  • Reactおよびフック
    • useEffect: モーダルの開閉に応じて、背景のスクロールを制御。

型定義

type ModalProps = {
  isOpen: boolean;
  onClose: () => void;
  onConfirm: () => void;
  formData: {
    name: string;
    email: string;
    message: string;
  } | null;
  isLoading: boolean;
};
  • isOpen: モーダルが開いているかどうかの状態。
  • onClose: モーダルを閉じるための関数。
  • onConfirm: 確認後に実行される送信関数。
  • formData: 表示するフォームデータ。
  • isLoading: 送信中の状態を示すフラグ。

スクロール制御

useEffect(() => {
  if (isOpen) {
    document.body.style.overflow = "hidden";
  } else {
    document.body.style.overflow = "";
  }

  return () => {
    document.body.style.overflow = "";
  };
}, [isOpen]);

モーダルが開いているときに背景のスクロールを無効化し、閉じると元に戻します。クリーンアップ関数で確実にスクロールを元に戻すようにしています。

レンダリング条件

if (!isOpen || !formData) return null;

モーダルが開いていない、またはフォームデータが存在しない場合は何もレンダリングしません。

モーダルの内容

  • フォームデータ(名前、メール、メッセージ)を表示する入力欄をdisabled状態で表示。
  • 送信ボタンとキャンセルボタンを提供。
    • 送信ボタン
      • クリックするとonConfirmが呼ばれ、最終送信処理が実行されます。
      • isLoadingtrueの場合、ボタンは無効化され「送信中…」と表示されます。
    • キャンセルボタン
      • クリックするとonCloseが呼ばれ、モーダルが閉じられます。
      • 送信中はボタンが無効化されます。

環境変数について

Contactコンポーネント内で使用されている環境変数NEXT_PUBLIC_FETCH_URLについて説明します。

process.env.NEXT_PUBLIC_FETCH_URL!
  • 役割
    • NEXT_PUBLIC_FETCH_URL は、フォームデータを送信するAPIのエンドポイントURLを指定する環境変数です。Contact コンポーネント内で使用されており、.envに記述されているURLに対してフォームデータが送信されます。この環境変数は、プロジェクトのルートにある .env ファイルで定義されますが、今回は開発環境と本番環境で異なるAPIエンドポイントを使用するため、.env.production と .env.development の2つのファイルに分けて設定します。
  • 開発環境(ローカル環境)の設定
    • ローカル環境では、MAMPを使用し、APIエンドポイントをローカルサーバーに設定します。このため、.env.development ファイルで設定を行います。
      project_nameは各自書き換えて下さい。
NEXT_PUBLIC_FETCH_URL=http://localhost:8888/project_name/api/mail
  • 本番環境の設定
    • 本番環境では、公開されているエンドポイントを指定します。このため、.env.production ファイルを作成し、以下のように設定します。

      ここでは、https://example.com/ をベースにしています。実際には、本番用のURLを指定します。
NEXT_PUBLIC_FETCH_URL=https://example.com/api/mail
  • プレフィックスの意味
    • NEXT_PUBLIC_で始まる環境変数は、クライアントサイド(ブラウザ)でもアクセス可能です。これにより、フロントエンドコードから直接参照できます。
  • セキュリティ
    • 公開される環境変数には機密情報を含めないように注意が必要です。APIキーや秘密情報はNEXT_PUBLIC_プレフィックスを付けずにサーバーサイドでのみ使用します。

これにより、以下のバックエンド実装後、ローカル開発環境でメールが送れるようになります。

バックエンドの実装

バックエンドでは、さくらレンタルサーバー上で動作するPHPスクリプトを用いて、受け取ったフォームデータをメール送信します。
PHPMailerを利用してSMTP経由でメールを送信することで、信頼性の高いメール配信を実現します。また、ローカル開発環境ではMAMPを使用します。

MAMPのファイル構成

ローカル環境での開発のMAMP内ディレクトリ構成は次のようにしました。

MAMP/
└── htdocs/
    ├── project_name/
        └── api/
            ├── .env
            └── mail/
                └── index.php

MAMPのデフォルトドキュメントルートはhtdocsディレクトリです。開発中のPHPプロジェクトをhtdocs内に配置することで、ローカルホストからアクセス可能になります。

Composerのインストール

Composer公式サイトのコードを参考にしてグローバルにインストールします。

php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('sha384', 'composer-setup.php') === 'dac665fdc30fdd8ec78b38b9800061b4150413ff2e3b6f88543c636f7cd84f6db9189d43a81e5503cda447da73c7e5b6') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
php composer-setup.php
php -r "unlink('composer-setup.php');"
sudo mv composer.phar /usr/local/bin/composer
composer --version

正常にインストールされていれば、Composerのバージョン情報が表示されます。

プロジェクトへのComposerの設定

apiディレクトリへ移動

プロジェクトのapiディレクトリに移動します。

cd /Applications/MAMP/htdocs/project_name/api

/Applications/project_nameは実際のパスに置き換えてください。

composer.jsonの初期化

Composerの初期設定を行います。以下のコマンドを実行してcomposer.jsonを作成します。

composer init

プロンプトに従ってプロジェクト情報を入力します。必要に応じてデフォルトの設定を使用できます。

PHPMailerとdotenvのインストール

PHPMailerのインストール

以下のコマンドを実行してPHPMailerをインストールします。

composer require phpmailer/phpmailer

dotenvのインストール

以下のコマンドを実行してdotenvライブラリをインストールします。

composer require vlucas/phpdotenv

vendorディレクトリの配置確認

以下のコマンドでvendorディレクトリが正しく作成されていることを確認してください。

ls vendor

autoload.phpなどのファイルが表示されれば成功です。

phpファイルの作成

以下は、mail/index.phpです。

コード解説

依存関係の読み込みと名前空間の設定

<?php
require '/Applications/MAMP/htdocs/project_name/api/vendor/autoload.php';

use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
  • require: Composerで管理されている依存関係を読み込みます。autoload.phpは、プロジェクト内のすべての依存ライブラリを自動的に読み込む役割を果たします。
  • use: PHPMailerのクラスを名前空間からインポートし、コード内で簡単に利用できるようにします。

ヘッダーの設定とCORS対応

header('Content-Type: application/json; charset=UTF-8');

// プリフライトリクエストへの対応
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
    header("Access-Control-Allow-Origin: *");
    header("Access-Control-Allow-Methods: POST, OPTIONS");
    header("Access-Control-Allow-Headers: Content-Type");
    http_response_code(200);
    exit;
}

// CORSヘッダーを設定
header("Access-Control-Allow-Origin: *");
  • Content-Type: レスポンスの内容タイプをJSONに設定します。
  • プリフライトリクエストの対応: ブラウザがCORSリクエストを送信する際、まずOPTIONSメソッドでプリフライトリクエストが行われます。これに対して、許可するオリジン、メソッド、ヘッダーを指定し、200ステータスコードで応答します。
  • Access-Control-Allow-Origin: すべてのオリジンからのリクエストを許可します。セキュリティ要件に応じて、特定のオリジンに制限することも検討してください。

CORSの設定は、必要最低限のオリジンに限定することが推奨されます。*を使用するとすべてのオリジンからのリクエストを許可するため、セキュリティリスクが高まる可能性があります。

環境変数の読み込み

$dotenv = Dotenv\Dotenv::createImmutable("/Applications/MAMP/htdocs/project_name/");
$dotenv->load();
  • Dotenv.envファイルから環境変数を読み込みます。これにより、データベースの資格情報やSMTPの設定などをコード内にハードコーディングする必要がなくなります。
  • createImmutable: 環境変数を変更不可として読み込みます。

POSTリクエストの処理

1.リファラの検証

if ($_SERVER['REQUEST_METHOD'] == 'POST') {

    $subject = "ホームページよりお問合せがありました";
    $to = 'exmaple@example.com';
    $url1 = 'http://localhost:3000';
    $url2 = 'http://exmaple.com';

    $allowedUrls = array($url1, $url2);
    $isAllowed = false;
    foreach ($allowedUrls as $allowedUrl) {
        if (strncmp($_SERVER['HTTP_REFERER'], $allowedUrl, strlen($allowedUrl)) === 0) {
            $isAllowed = true;
            break;
        }
    }

    if (!$isAllowed) {
        header("HTTP/1.1 404 Not Found");
        exit;
    }

    // 以下略...
}
  • リファラの検証: リクエストの発信元(HTTP_REFERER)が許可されたURL($allowedUrls)に含まれているかを確認します。これにより、指定されたオリジンからのリクエストのみを受け付けます。
  • 注意点: リファラヘッダーはクライアント側で簡単に偽装できるため、セキュリティ対策として完全ではありません。必要に応じて、追加の認証手段を導入してください。

リファラの検証は基本的なセキュリティ対策ですが、完全ではありません。可能であれば、トークンベースの認証やその他のセキュリティメカニズムを併用することを検討してください。

2.入力データの取得とバリデーション

$json = file_get_contents("php://input");
$contents = json_decode($json, true);
error_log(print_r($contents, true));

// バリデーションの初期化
$errors = array();

// 必須項目のチェック
if (!isset($contents["name"]) || empty(trim($contents["name"]))) {
    $errors[] = "名前が必要です。";
}

if (!isset($contents["email"]) || empty(trim($contents["email"]))) {
    $errors[] = "メールアドレスが必要です。";
} elseif (!filter_var($contents["email"], FILTER_VALIDATE_EMAIL)) {
    $errors[] = "有効なメールアドレスを入力してください。";
}

if (!isset($contents["message"]) || empty(trim($contents["message"]))) {
    $errors[] = "メッセージが必要です。";
}

if (!empty($errors)) {
    // バリデーションエラーがある場合
    http_response_code(400);
    echo json_encode([
        "status" => "validationError",
        "errors" => $errors
    ], JSON_PRETTY_PRINT);
    exit;
}
  • データの取得php://inputストリームから生のPOSTデータを取得し、JSON形式としてデコードします。
  • バリデーション:
    • 必須項目のチェックnameemailmessageの各フィールドが存在し、空でないことを確認します。
    • メールアドレスの形式チェック: PHPのfilter_var関数を用いて、メールアドレスの形式が有効かを検証します。
  • エラーハンドリング: バリデーションエラーが存在する場合、400ステータスコードとエラーメッセージをJSON形式で返します。

入力データのバリデーションは基本的なセキュリティ対策ですが、さらに詳細な検証やサニタイズを行うことで、セキュリティを強化できます。

3.データのエスケープとJSON形式での保存

// htmlspecialchars() を使用して入力データをエスケープ
$escapedName = htmlspecialchars(trim($contents["name"]), ENT_QUOTES, 'UTF-8');
$escapedEmail = htmlspecialchars(trim($contents["email"]), ENT_QUOTES, 'UTF-8');
$escapedMessage = htmlspecialchars(trim($contents["message"]), ENT_QUOTES, 'UTF-8');

// エスケープ済みデータを使用して新しい配列を作成
$safeContents = array(
    "name" => $escapedName,
    "email" => $escapedEmail,
    "message" => $escapedMessage
);

// エスケープ済みデータをJSON形式で保存
file_put_contents("contact.json", json_encode($safeContents, JSON_UNESCAPED_UNICODE) . "\n", FILE_APPEND);
  • データのエスケープhtmlspecialchars関数を使用して、入力データ内の特殊文字をエスケープし、XSS(クロスサイトスクリプティング)攻撃を防止します。

XSS対策htmlspecialcharsによるエスケープは基本的な対策ですが、出力時にも適切なエスケープ処理を行うことで、セキュリティを強化できます。
reCAPTCHAの導入: スパムや自動化された悪意のあるリクエストを防ぐために、GoogleのreCAPTCHAなどのCAPTCHAソリューションを導入することを強く推奨します。これにより、フォームのセキュリティが大幅に向上します。

  • JSON形式での保存:メール送信エラー等でメール紛失を防ぐためにJSONに保存します。
    • 保存方法: エスケープ済みデータをcontact.jsonファイルに追記します。

この方法は簡易的な実装であり、実際の運用環境ではデータベースの使用や適切なファイル管理を検討してください。JSONファイルへの直接保存は、スケーラビリティやセキュリティの面で制約があります。
ブラウザからJSONへのアクセスを禁じて下さい。以下はapi/mail/.htaccessの記述例です。

<Files "contact.json">
    Order allow,deny
    Deny from all
</Files>

4.PHPMailerを用いたメール送信

// 管理者へのメール送信処理
$mail = new PHPMailer(true);
try {
    // SMTP設定
    $mail->isSMTP();
    $mail->Host       = $_ENV['SMTP_HOST'];
    $mail->SMTPAuth   = true;
    $mail->Username   = $_ENV['SMTP_USERNAME'];
    $mail->Password   = $_ENV['SMTP_PASSWORD'];
    $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
    $mail->Port       = $_ENV['SMTP_PORT'];
    $mail->CharSet = 'UTF-8';

    // 送信者情報
    $mail->setFrom('exmaple@example.com', 'UserName');

    // 受信者情報
    $mail->addAddress($to);

    // メール内容
    $mail->isHTML(false);
    $mail->Subject = $subject;
    $mail->Body    = "ホームページより以下のお問い合わせがありました" . "\n" . "\n" .
                     "お名前:" . $safeContents["name"] . "\n" .
                     "メールアドレス:" . $safeContents["email"] . "\n" . "\n" .
                     $safeContents["message"] . "\n";

    // 管理者へのメール送信
    $mail->send();
    $arr["status"] = "sendOk";

    // お問い合わせした人にも確認メールを送る
    $mail->clearAddresses();
    $mail->addAddress($safeContents["email"]);
    $mail->Subject = "お問い合わせありがとうございました";
    $mail->Body    = $safeContents["name"] . " 様" . "\n" .
                     "お問い合わせいただきありがとうございました。" . "\n" .
                     "以下の内容でお問い合わせを受け付けました。" . "\n" . "\n" .
                     "お名前:" . $safeContents["name"] . "\n" .
                     "メールアドレス:" . $safeContents["email"] . "\n" . "\n" .
                     $safeContents["message"] . "\n" .
                     "追ってご連絡いたします。";

    // 送信者への確認メール送信
    $mail->send();

} catch (Exception $e) {
    error_log("Message could not be sent. Mailer Error: {$mail->ErrorInfo}");
    $arr["status"] = "sendError";
}

print json_encode($arr, JSON_PRETTY_PRINT);
  • PHPMailerの設定:
    • SMTP設定.envファイルからSMTPサーバーの設定を読み込み、SMTPを使用してメールを送信します。セキュリティを考慮し、パスワードなどの機密情報は環境変数で管理します。
    • 送信者情報: メールの送信元アドレスと名前を設定します。
    • 受信者情報: 管理者のメールアドレスにメールを送信します。
  • メール内容:
    • 管理者向けメール: お問い合わせ内容を含むメールを管理者に送信します。
    • ユーザー向け確認メール: お問い合わせを行ったユーザーに対して、確認メールを送信します。
  • エラーハンドリング: メール送信に失敗した場合、エラーログに記録し、ステータスをsendErrorとしてクライアントに返します。
  • 注意点: 同一のPHPMailerインスタンスを再利用していますが、複数のメールを送信する場合は、適切にアドレスや内容をクリアする必要があります。

メール送信のセキュリティ: SMTP認証情報は厳重に管理し、コード内にハードコーディングしないようにしましょう。
reCAPTCHAの検証後にメール送信: reCAPTCHAを導入した場合、ユーザーがCAPTCHAを正しく完了した後にのみメール送信処理を実行するようにします。

5.不正なリクエストへの対応

  • リクエストメソッドの確認: POST以外のリクエストが送信された場合、400 Bad Requestステータスコードとともにエラーメッセージを表示します。
  • ユーザー向けエラーページ: 見やすいエラーページを提供し、ユーザーに正しいリクエスト方法を案内します。

エラーメッセージはユーザーにとって分かりやすいものであると同時に、詳細な内部情報を漏らさないように注意します。

注意事項

  • JSONファイルへのデータ保存: JSONファイルへの直接保存は簡易的な実装であり、実際の運用環境ではデータベースの使用や適切なファイル管理を検討することを推奨します。JSONファイルはスケーラビリティやセキュリティの面で制約があるため、用途に応じて最適なデータストレージ方法を選択してください。
  • XSS対策の強化htmlspecialcharsによるエスケープは基本的な対策ですが、出力時にも適切なエスケープ処理を行うことや、コンテンツセキュリティポリシー(CSP)の導入など、さらなるXSS対策を講じることが重要です。
  • reCAPTCHAの導入: フォームへのスパムや自動化された攻撃を防ぐために、GoogleのreCAPTCHAなどのCAPTCHAソリューションを導入することを強く推奨します。これにより、フォームのセキュリティが大幅に向上します。
  • リファラの検証: リファラの検証は基本的なセキュリティ対策ですが、完全ではありません。可能であれば、トークンベースの認証やその他のセキュリティメカニズムを併用することで、セキュリティを強化できます。

環境変数の設定

セキュリティ向上のため、SMTPの認証情報などの機密情報は環境変数として管理します。

SMTP_HOST=******.sakura.ne.jp
SMTP_USERNAME=********@*********
SMTP_PASSWORD=*******
SMTP_PORT=587

PHPスクリプト内では、Dotenvを使用して.envファイルから環境変数を読み込みます。

メール送信テスト

Next.jsの開発ディレクトリで、npm run devを実行して開発サーバーを立ち上げます。また、MAMPを起動してサーバーを動作させます。

次に、作成したメールフォームから実際にメール送信を試みます。このとき、フォームが正しく機能していれば、送信後に complete/page.tsx が表示されるはずです。このページが表示されれば、メール送信処理が成功していることを示しています。

送信後、メールが届いていない場合は、迷惑メールフォルダも必ず確認してください。場合によっては、そちらに格納されていることもあります。

さくらのレンタルサーバへのデプロイ

準備1 : Next.jsのプロジェクトをビルド

Next.jsでプロジェクトを静的サイト生成(SSG)用にビルドするためには、まずnext.config.jsファイルを適切に設定する必要があります。
特に、重要な設定項目が output: 'export' です。この設定により、プロジェクトは静的なHTMLファイルとしてビルドされ、/out ディレクトリに書き出されます。

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "export",
  trailingSlash: true,
};

export default nextConfig;

output: “export”とは?

output: "export"は、Next.jsのプロジェクトを完全な静的サイトとしてビルドするための設定です。
この設定により、サーバーサイドレンダリング(SSR)を行わずに、HTMLファイルとしてすべてのページが生成されます。
生成された静的なファイルは、ホスティングサービスやレンタルサーバーにそのままアップロードすることができます。

trailingSlash: true の意味

trailingSlash: trueを設定すると、生成されたURLの末尾にスラッシュ(/)が自動的に追加されます。
例えば、/about というページがあった場合、/about/ というURLになります。
この設定をすることで、特定のサーバー環境やSEO上の理由で推奨されるスラッシュ付きURLに統一できます。

準備2 : レンタルサーバにComposerをインストールする

SSH接続する

まず、SSHクライアント(例: ターミナル、PuTTY)を使用してさくらレンタルサーバに接続します。

ssh ユーザー名@初期ドメイン

Composerをインストールする

ユーザーのホームディレクトリに composer をインストールする手順は以下の通りです。

1.ユーザーのホームディレクトリに移動

cd ~

2.「bin」というディレクトリを作成

mkdir bin

3.Composerをインストール(ダウンロード)

curl -sS https://getcomposer.org/installer | php

4.ダウンロードしたファイルをリネームして「bin」ディレクトリに移動

mv composer.phar bin/composer

5.確認

composer -V

さくらインターネットのレンタルサーバーでは、~/bin が既に PATH に含まれているため、上記の手順で composer コマンドをどこからでも実行できるようになります。追加で PATH を設定する必要はありません。

デプロイ

Next.jsの静的ファイルをアップロード

生成された静的ファイルをレンタルサーバにアップロードします。SFTPまたはSCPを使用して、outディレクトリ内を/home/username/www/project_name/ にアップロードします。
私はGUIクライアント(Cyberduck)を用いましたが、なんでも構いません。

PHPファイルのアップロード

次に、PHPファイルをアップロードします。vendorディレクトリは含めずにアップロードを行い、後でサーバー上でComposerを使って必要な依存パッケージをインストールします。
ローカルのMAMP/htdocs/project_name/api//home/username/www/project_name/api/へアップロードします。

Composerの依存パッケージをインストール

次に、PHPプロジェクト内でcomposer installコマンドを実行し、依存パッケージをインストールします。

cd /home/username/www/project_name/api/
composer install --no-dev --optimize-autoloader

オプションの説明

  • --no-dev
    開発用パッケージをインストールしないオプションです。本番環境では不要な開発依存関係を除外し、軽量化を図ります。
  • --optimize-autoloader
    オートローダーを最適化するオプションです。パフォーマンスを向上させ、ロード時間を短縮するために使用します。特に本番環境でのパフォーマンスが重要な場合に推奨されます。

メール送信テスト

実際にホームページにアクセスして、メールを送信してみましょう。

まとめ

この記事では、Next.jsを用いたフロントエンドとPHPを用いたバックエンドで構成されたお問い合わせフォームの作成方法を解説しました。
react-hook-formによるフォーム管理、PHPMailerを用いたメール送信、環境変数の管理など、セキュアで効率的な実装手法を取り入れることで、信頼性の高いお問い合わせフォームを構築できます。
さくらレンタルサーバーへのデプロイ手順も網羅したので、実際の運用に向けてぜひ参考にしてください。

]]>
Laravel9 + Vue.js3 + Inertia.js + TypeScript (+ Vuetify)をDockerで環境構築 https://snow-leaf.com/programming/laravel9-vue-js3-vite-inertia-js-typescript-vuetify/ Sat, 28 Dec 2024 23:01:07 +0000 https://snow-leaf.com/?p=1116

Laravel9だとBreezeを使ってTypeScriptが選択できません。手動で行う必要があります。初期設定でTypeScriptを選択したければ、Laravel10とBreezeで開発を進めましょう。 今回はLar ... ]]>

Laravel9だとBreezeを使ってTypeScriptが選択できません。手動で行う必要があります。
初期設定でTypeScriptを選択したければ、Laravel10とBreezeで開発を進めましょう。

今回はLaravel9でのTypeScript環境を作るための備忘録です。

Docker設定

project-root/
├── docker/
│   ├── nginx/
│   │   └── default.conf
│   └── php/
│       ├── Dockerfile
│       └── php.ini
├── src/
│   └── laravel
├── docker-compose.yml

以上のディレクトリ構成で。
src配下にLaravelプロジェクトがインストールされるので、わかりやすくディレクトリ名を変えても可。
変えた場合はdocker-compose.ymlのvolumesも変える。

server {
    listen 80;
    index index.php index.html index.htm;

    root /var/www/html/public;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        # FastCGIの設定をここに直接記述
        fastcgi_pass app:9000;  # PHPサービスへのリンク
        fastcgi_index index.php;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

        # 必要なFastCGIパラメータをここに定義
        include fastcgi_params;  # 他のFastCGIパラメータを読み込む
    }

    location ~ /\.ht {
        deny all;
    }
}

1. default.confを作成

FROM php:8.3-fpm

# 必要なPHP拡張機能をインストール
RUN apt-get update && apt-get install -y \
    libpng-dev \
    libjpeg-dev \
    libfreetype6-dev \
    libzip-dev \
    unzip \
    git \
    curl && \
    docker-php-ext-configure gd --with-freetype --with-jpeg && \
    docker-php-ext-install gd zip pdo pdo_mysql

# 作業ディレクトリを設定
WORKDIR /var/www/html

# Composerをインストール
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# php.ini設定ファイルをコピー
COPY php.ini /usr/local/etc/php/conf.d/custom.ini

# Node.jsとnpmをインストール(Node.js 20.xを使用)
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
    apt-get install -y nodejs && \
    npm install -g npm@latest

# 権限の設定(必要に応じて)
RUN chown -R www-data:www-data /var/www/html

# Laravel用エントリーポイント
CMD ["php-fpm"]

2. Dockerfileを作成

; 日付設定
[Date]
date.timezone = "Asia/Tokyo"
; 文字&言語設定
[mbstring]
mbstring.language = "Japanese"

3. php.iniを作成

services:
  app:
    image: php:8.2-fpm
    ports:
      - "5173:5173"
    build:
      context: ./docker/php
    container_name: app
    volumes:
      - ./src/laravel:/var/www/html
    depends_on:
      - db
    networks:
      - app-network

  web:
    image: nginx:alpine
    container_name: nginx
    volumes:
      - ./src/laravel:/var/www/html
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
    ports:
      - "8080:80"
    networks:
      - app-network
    depends_on:
      - db


  db:
    image: mysql:8
    container_name: mysql_db
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: laravel
      MYSQL_USER: user
      MYSQL_PASSWORD: password
    volumes:
      - db_data:/var/lib/mysql
    ports:
      - "3306:3306"
    networks:
      - app-network

  phpmyadmin:
    image: phpmyadmin/phpmyadmin
    container_name: phpmyadmin
    environment:
      PMA_HOST: db
      MYSQL_ROOT_PASSWORD: root
    ports:
      - "8081:80"
    networks:
      - app-network

networks:
  app-network:
    driver: bridge

volumes:
  db_data:
    driver: local

4. docker-compose.ymlを作成
portsやcontainer_nameは競合しないように環境によって都度変える

docker compose up -d —build

5. dockerを立ち上げる

docker compose exec app bash
composer create-project --prefer-dist laravel/laravel . "9.*"

6. コンテナ内に入り、Laravelセットアップ
以降、インストール関連は全てコンテナ内で行う

Laravel関連

1.localhost:8080localhost:8081へアクセスし、確認する

APP_NAME=Laravel
APP_ENV=local
APP_KEY=base64:fn0GiuZN/2UTrhg40/dTsg8gHO/GuU+jIJxDAJ3S5MI=
APP_DEBUG=true
APP_URL=http://localhost:8080

LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug

DB_CONNECTION=mysql
DB_HOST=db
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=user
DB_PASSWORD=password

2. .envを修正
APP_URLと、DB関連。docker-compose.ymlのdbで設定した内容に変更する

php artisan migrate

3. マイグレーションを行う

4. phpMyAdminへアクセスし、テーブル確認

'timezone' => 'Asia/Tokyo', 

'locale' => 'ja',

5. config/app.phpのタイムゾーンを変更する

6. langファイル作成
https://github.com/snowleaf-com/sekkotuin_crm/tree/main/src/sekkotuin/lang/ja
上記より4つのファイルをコピーし、lang/jaディレクトリを作成し追加

composer require barryvdh/laravel-debugbar --dev

7. デバッグバーインストール

composer require laravel/breeze:^1 --dev

php artisan breeze:install vue

8. LaravelBreeze(Laravel9はBreeze2をインストールできない)インストール

server: {
    host: '0.0.0.0',
    watch: {
        usePolling: true,
    },
    port:5173,
}

9. vite.config.jsに以上を追記

composer require inertiajs/inertia-laravel

10. Inertia.jsインストール

php artisan inertia:middleware 

11. Inertiaのミドルウェア作成
Laravel側でInertia.jsが正しく動作し、クライアントサイド(Vue.js)とのやりとりをスムーズにするための準備

\App\Http\Middleware\HandleInertiaRequests::class,

12. /app/Http/Kernel.phpへ以上を追記(webの中)

“@inertiajs/inertia”: “^0.11.0”,
“@inertiajs/inertia-vue3”: “^0.6.0”,

13. package.jsonのdevDependensicesに以上を追記(inertia1の書き方)

npm install

14. 追記したら以上のコマンド

npm install vuetify@next

16. vuetify3インストール

TypeScript関連

npm install vue-tsc --dev

1. vue-tscインストール(型チェック)

npx tsc --init

2. tsconfig.jsonを作成する

{
    "compilerOptions": {
        "allowJs": true,
        "module": "ESNext",
        "moduleResolution": "bundler",
        "jsx": "preserve",
        "strict": true,
        "isolatedModules": true,
        "target": "ESNext",
        "esModuleInterop": true,
        "forceConsistentCasingInFileNames": true,
        "noEmit": true,
        "skipLibCheck": true,
        "paths": {
            "@/*": ["./resources/js/*"],
            "ziggy-js": ["./vendor/tightenco/ziggy"]
        },
    },
    "include": ["resources/js/**/*.ts", "resources/js/**/*.d.ts", "resources/js/**/*.vue"]
}

3. tsconfig.jsonを以上のように修正する

"scripts": {
    "dev": "vite",
    "build": "vite build",
    "type:check": "vue-tsc --noEmit"
},

4. package.jsonに型チェック用のコマンド追記
—noEmitでトランスパイルせずに型チェックのみ行う

npm run type:check

5. 以上のコマンドで型チェックできる

import './bootstrap';
import '../css/app.css';

import { createApp, h, DefineComponent } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { ZiggyVue } from '../../vendor/tightenco/ziggy/dist/vue.m';

import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import 'vuetify/styles';

const appName = import.meta.env.VITE_APP_NAME || 'Laravel';

createInertiaApp({
    title: (title) => `${title} - ${appName}`,
    resolve: (name) => resolvePageComponent(`./Pages/${name}.vue`, import.meta.glob<DefineComponent>('./Pages/**/*.vue')),
    setup({ el, App, props, plugin }) {
        const vuetify = createVuetify({ components, directives });
        createApp({ render: () => h(App, props) })
            .use(plugin)
            .use(ZiggyVue)
            .use(vuetify)
            .mount(el);
    },
    progress: {
        color: '#4B5563',
    },
});

6. resources/js/app.jsをapp.tsに変え、以上のように修正する
vuetifyが不要な場合は9行目〜12行目、21行目、24行目を消す

@vite(['resources/js/app.ts', "resources/js/Pages/{$page['component']}.vue"])

7. resources/views/app.blade.phpの@viteのapp.jsをapp.tsへ修正

input: 'resources/js/app.ts',

8. vite.config.jsもvite.config.tsへ変更し、inputのapp.jsをapp.tsへ修正

import _ from 'lodash';
window._ = _;

9. bootstrap.jsもbootstrap.tsへ変更し、上記の1行目、2行目を削除する

import { PageProps as InertiaPageProps } from '@inertiajs/core';
import { AxiosInstance } from 'axios';
import { route as ziggyRoute } from 'ziggy-js';
import { PageProps as AppPageProps } from './';

declare global {
    interface Window {
        axios: AxiosInstance;
    }

    var route: typeof ziggyRoute;
}

declare module 'vue' {
    interface ComponentCustomProperties {
        route: typeof ziggyRoute;
    }
}

declare module '@inertiajs/core' {
    interface PageProps extends InertiaPageProps, AppPageProps {}
}

10. resources/js/にtypesディレクトリを作り、その中にglobal.d.tsを作成し以上を記述

export interface User {
    id: number;
    name: string;
    email: string;
    email_verified_at: string;
}

export type PageProps<T extends Record<string, unknown> = Record<string, unknown>> = T & {
    auth: {
        user: User;
    };
};

11. resources/js/typesディレクトリの中にindex.d.tsを作成し以上を記述

/// <reference types="vite/client" />

12. resources/js/typesディレクトリの中にvite-env.d.tsを作成し以上を記述

確認作業

<?php

namespace Database\Seeders;

// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    /**
     * Seed the application's database.
     *
     * @return void
     */
    public function run()
    {
        \App\Models\User::factory(10)->create();

        \App\Models\User::factory()->create([
            'name' => 'Test User',
            'email' => 'test@test.com',
        ]);
    }
}

1.database/seeders/DatabaseSeeder.phpを上記のように編集
nameとemailは好きなように

php artisan db:seed

2.シーディングコマンドでテストユーザー作成

npm run dev

3.viteを起動させ、実際にログインして、Profile画面などに遷移してみて異常がなければOK

]]>
【SANGO】シェアボタンのTwitterをXに変更する方法【WordPress】 https://snow-leaf.com/programming/dontforget/twitter_to_x_sharebutton/ Sun, 22 Oct 2023 22:35:33 +0000 https://snow-leaf.com/?p=1015

SANGOv3.7.8、2023.10.21時点でシェアボタンはTwitterのままです。これはFontAwesomeバージョンの都合かと思われます。FontAwesomeバージョン6.4.2以降でXが対応していますので ... ]]>

SANGOv3.7.82023.10.21時点でシェアボタンはTwitterのままです。
これはFontAwesomeバージョンの都合かと思われます。FontAwesomeバージョン6.4.2以降でXが対応していますので、4系5系では無いのが現状です。

ゆくゆくは対応されると思うので、対応されたらこの記事は意味がなくなりますが、書き残しておきます。

今回の記事は、WordPressのファイル内をいじります。
以下の記事から、編集できる環境を整えてください。これらのツールで説明を行いますが、他のツールを用いてお構いません。

環境構築 【WordPress】環境を構築する【VSCode, Cyberduck】

after

before

子テーマを変更する

親テーマに変更を加える事はしません。
はじめて編集する方は以下のページから子テーマのカスタマイズについて、一読する事をおすすめします。

今回変更するのは以下です。

/wp-content/themes/sango-theme-child/functions.php

/wp-content/themes/sango-theme-child/style.css

更に追加するファイルやフォルダは以下です。

/wp-content/themes/sango-theme-child/library/

/wp-content/themes/sango-theme-child/library/functions/

/wp-content/themes/sango-theme-child/library/functions/share-buttons.php

library、functions、share-buttons.phpを追加・編集する

Cyberduckにてサーバーと接続します。

libraryフォルダを追加

~/wp-content/themes/sango-theme-child/に移動した事を確認してください。
右クリック新規フォルダlibraryと入力し、作成

functionsフォルダを追加

~/wp-content/themes/sango-theme-child/library/に移動した事を確認してください。
右クリック新規フォルダfunctionsと入力し、作成

functionではなく、functionsです。sをお忘れなく。

share-buttons.phpを追加

親テーマからファイルをまるっとコピーして、子テーマで変更を加えます。
なので、親テーマへ移動します。

~/wp-content/themes/sango-theme/library/functions/に移動した事を確認してください。
※親テーマなので、sango-theme内へ移動です。子テーマではありません。

share-buttons.phpをクリックし、選択してください。
選択したら、Winの方はCtrl+C、Macの方はcommand+Cでコピーします。

再び子テーマへ戻ります。

~/wp-content/themes/sango-theme-child/library/functions/に移動した事を確認してください。

移動したら、Winの方はCtrl+V、Macの方はcommand+Vで貼り付けします。

貼り付けが完了したら再び右クリック→エディタで編集→CodeでVSCodeを開きます。

        <!-- twitter -->
        <li class="tw sns-btn__item">
          <a href="<?php echo sng_get_tweet_url($encoded_url, $encoded_title); ?>" target="_blank" rel="nofollow noopener noreferrer" aria-label="Xでシェアする">
            <?php fa_tag("twitter", "twitter", true) ?>
            <span class="share_txt">ポスト</span>
          </a>
          <?php if (function_exists('scc_get_share_twitter')) {
              echo '<span class="scc dfont">' . scc_get_share_twitter() . '</span>';
            } 
          ?>
        </li>

108行目からがTwitterの表示となっています。
108行目〜118行目までを削除し、新たに上記のコードをコピーして、貼り付けてください。

// ツイートURLを取得する
if (!function_exists('sng_get_tweet_url')) {
  function sng_get_tweet_url($url, $title) {
    $via = (get_option('include_tweet_via')) ? '&via=' . get_option('include_tweet_via') : '';
    return 'https://x.com/intent/tweet?url='.$url.'&text='.$title.$via;
  }
}

また、33行目もURLがtwitter.comになっているので、上記コードのようにx.comに変えるのも良いと思います。
twitter.comで動くので気にしない方は変更しなくていいです。

最後に、Winの方はCtrl+S、Macの方はcommand+Sで保存します。

style.cssを編集する

~/wp-content/themes/sango-theme-child/style.cssVSCodeで開きます。

/* Twitter→Xへ */
.fa-twitter:before {
  content: "𝕏";
  font-family: unset;
  font-weight: bold;
}

.tw a {
  color: #000000;
}

一番下に以上のコードを追加します。

Winの方はCtrl+S、Macの方はcommand+Sで保存します。

functions.phpを編集する

~/wp-content/themes/sango-theme-child/functions.phpVSCodeで開きます。

<?php
//子テーマのCSSの読み込み
add_action( 'wp_enqueue_scripts', 'enqueue_my_child_styles' );
function enqueue_my_child_styles() {
  wp_enqueue_style( 'child-style', 
  	get_stylesheet_directory_uri() . '/style.css', 
  	array('sng-stylesheet','sng-option')
	);
} 
/************************
 *functions.phpへの追記は以下に
 *************************/
// function sng_is_sidebar_shown() { if( !is_active_sidebar( 'sidebar1' ) ) return false; if( is_singular() && wp_is_mobile() && get_option('no_sidebar_mobile') ) return false; // 記事ページと投稿ページで非表示に if( is_single() || is_page() ) { return false; } // それ以外では表示 return true;
// }
require_once 'library/functions/share-buttons.php';


/************************
 *functions.phpへの追記はこの上に
 *************************/

15行目にrequire_once 'library/functions/share-buttons.php';を追記します。

Winの方はCtrl+S、Macの方はcommand+Sで保存します。

完成

今回はシェアボタンだけでしたが、プロフィールなどのTwitterにも応用できるかと思います。
要望があれば記事出しますので、お問い合わせください。

また、Xアイコン表示CSSは以下のサイトを参考にさせてもらいました。参考にしてみてください。

]]>
素人が簡単なスマホアプリを作った話 https://snow-leaf.com/programming/smartphone_app/ Mon, 16 Oct 2023 02:27:14 +0000 https://snow-leaf.com/?p=947

こんにちは、snowleaf slowlifeの雪葉です。スマホアプリ制作なんてした事がないスマホアプリに関してはズブの素人の私ですが、ChatGPTを駆使し、なんとか作れました。やる気さえあればという感じです。その過程 ... ]]>

こんにちは、snowleaf slowlifeの雪葉です。
スマホアプリ制作なんてした事がないスマホアプリに関してはズブの素人の私ですが、ChatGPTを駆使し、なんとか作れました。
やる気さえあればという感じです。その過程をここに記しておきますので、誰かの参考になれば幸いです。

制作アプリ芝生用薬剤希釈計算アプリ
使用言語React Native
実行・テストExpo Go / Xcode / Android Studio

前置き

スマホアプリを作りはしましたが、リリースはしておりません😅
その理由としてリリースしても、ある程度利用者が見込めないと大赤字になってしまいます。
1年間にiOSとAndroidで約15,000円の利用料💰がかかります。月々1,000円くらいの広告収入か、有料アプリにするしかないのです。

今回スマホアプリを作った理由は、スマホアプリはどのようにして作られるのかを体感したかったからです。
簡単なスマホアプリ制作を体感した上で、更に難易度の高いスマホアプリの制作も視野に入れています。
その際には、満を持してリリースしますので、よろしくお願いします🙇

設計編

機能

現在、私が運営している「お庭用お役立ち計算ソフト集」から、希釈倍率計算ソフト除草剤計算ソフトの2つをアプリに導入します。Web→スマホの移植なので計算フローはそのまま使えて楽です。
しかし移植だけだと、Web上にあるものを使えば良い事になってしまいます。スマホアプリを使う意味がないです。
そのため、付加価値をつけます。スマホアプリでは、それらの計算結果を保存できるようにします。

ターゲット

これらのソフトは何かと言いますと、自宅の庭で芝生を生育している人向けのソフトです。
つまり自宅があり、芝生を管理する時間や余裕がある方達です。30代後半〜50代が多いかと思います。
画面レイアウトやロゴのデザインはターゲット年齢層によって変わります。若者向けでは無いので、画面もわかりやすくする必要がありますし、ロゴはポップよりもシックさが欲しいです。

開発言語

iOSはSwift、AndroidはJavaかKotlinがネイティブ言語として扱われてます。しかし両方の言語を習得している時間的余裕はありません。
そこで、マルチプラットフォーム開発のフレームワークを用います。Flutterか、ReactNativeの2択です。
1つのコードで、iOSやAndroid、ひいてはMacOSやWindowsでも動かせるアプリを開発可能です。優秀ですね。

FlutterとReactNative、両方とも試してみましたが、JavaScriptを触っていた私はReactNativeで開発する事にしました。
Reactも触った事はありませんが、同じJavaScriptフレームワークであるVue.jsは開発経験がありますので戸惑いはありませんでした。

データベース

Web開発ですとMySQLやPostgreSQL、MongoDBが一般的かと思います。
スマホアプリではSQLiteやFirebase、Realmが主に使われるようです。

今回のアプリでは、保存機能は少ないため、React NativeのAsync Storageというものを使う事にしました。
これはセンシティブではない小規模データの永続保存に向いている機能です。
大規模なアプリ開発となると先述したSQLiteやFirebase等か、LaravelAPIなどを介してMySQLを使う方法等があります。

様々な方法がありますので、制作するアプリにあわせて決める必要がありますね。

今回は以下のような関数でデータを保存しています。

  const handleDialogOK = async () => {
    const key = uuidv4(); // 一意のUUIDを生成
    const timestamp = new Date().toLocaleString("ja-JP");

    // ダイアログ保存
    const newSaveDatas = {
      id: key,
      name: storageMemo,
      pesticideL: result.pesticideL,
      pesticideML: result.pesticideML,
      waterL: result.waterL,
      waterML: result.waterML,
      totalSa: result.totalSa,
      divisionSprayerWater: result.divisionSprayerWater,
      divisionSprayerPesticide: result.divisionSprayerPesticide,
      divisionSprayerTenchaku: result.divisionSprayerTenchaku,
      divisionSprayerCount: result.divisionSprayerCount,
      divisionSprayerWaterRemaining: result.divisionSprayerWaterRemaining,
      divisionSprayerPesticideRemaining:
        result.divisionSprayerPesticideRemaining,
      divisitonSprayerTenchakuRemaining:
        result.divisitonSprayerTenchakuRemaining,
      sprayerTotalCount: result.sprayerTotalCount,
      toggle1: toggle1,
      toggle2: toggle2,
      timestamp: timestamp,
    };
    try {
      const existingResults = await AsyncStorage.getItem("calculationResults");
      let parsedResults = existingResults ? JSON.parse(existingResults) : [];
      const resultCount = Object.keys(parsedResults).length;
      if (resultCount >= 15) {
        Alert.alert("最大保存数は15です。一覧からいずれかを削除して下さい。");
        setDialogVisible(false);
        return;
      }
      parsedResults.push(newSaveDatas);
      await AsyncStorage.setItem(
        "calculationResults",
        JSON.stringify(parsedResults)
      );
      Alert.alert("保存されました");
    } catch (error) {
      Alert.alert("エラー", "管理者に問い合わせて下さい。");
      console.log("データの保存中にエラーが発生しました", error);
    }
    // 最終処理
    setStorageMemo("");
    setDialogVisible(false);
  };

これをダイアログの保存ボタンの処理に書きます。

<Dialog.Button label="保存" onPress={handleDialogOK} />

画面

設計の段階では、どのようにして画面を作るのか?どんな事ができるのか?全くわからなかったため、開発しながら画面は作りました。
わかりやすい画面でありながらも、洗練されたシンプルなものを目指します。

実装編

ReactNativeには非常に便利なExpoGoというツールがあります。
iPhoneやAndroid端末で、ExpoGoというアプリをインストールし、コマンド$ expo startで表示されるQRコードをスマホで読み取ると、開発中の画面が表示されます。コードを編集すると、ライブプレビューもできます。非常に有用です。

これを駆使して開発を進めていきます。

今回のアプリは設計図はありません。既存のものを移植するのがメインだからです。
また先述の通り、画面もどのようなものができるのか、全くわからないのでコードを打ちながら試行錯誤して画面を作っていきます。
以上から、今回のアプリ制作の流れは以下です。

簡単な画面を作る(テキストフィールドとボタンを適当に配置)

処理を書く

処理ができたら画面をCSSやパーツを用いて完成させる

画面はReact Nativeにて用意されている様々なパーツを組み合わせていきます。
例えばボタンや、テキストフィールド、リストなどをimportして使っていきます。思い通りのものがない場合は、ライブラリも豊富に揃っていますので、そちらを使うといいでしょう。

また、CSSもほぼWeb開発と同じような形で使えます。多少書き方が違いますが、これはWeb開発者にとって知識をそのまま活かせるので非常に有難いです。

画面がある程度できたら、処理をJavaScriptで書いていきます。
Web上で動いているものをスマホアプリ用に移植するだけなので、処理の移植はスムーズに行えました。

処理を書いたら、CSSやパーツを用いて、未完成だった画面を完成させていきます。

つまづいた箇所

テキストフィールド隠れる問題

テキストフィールドに文字を入力しようと、テキストフィールドをタップします。
すると、下からキーボードが出てくると思います。しかし、画面の下にあるテキストフィールドだと出てきたキーボードによって隠れてしまいます。
そんな問題がありました。これはスマホアプリ開発初心者のあるあるかと思います。

解決にはReactNativeで用意されているScrollViewというコンポーネントでラップし、画面をスクロールできるようにし、テキストフィールドをタップされた時に画面の一番下にマージンを追加する処理をつけると、画面下のテキストフィールドでも画面上部に移動することができます。これはあくまでも一例です。他にもやり方はあると思います。

タブ移動できない問題

HTMLではtabindexというものがあり、PCだとタブで、スマホだとキーボードに表示される🔼🔽でテキストフィールド等を行き来できるように設定ができます。
しかし、ReactNativeのTextInputコンポーネントにはそのようなオプションが無く、仕方なくInputAccessoryViewコンポーネントというキーボードをアレンジできるコンポーネントを使用しました。

完成画面

トップ画面

ロゴは仮😅

希釈計算画面

除草剤計算画面

保存フローは希釈剤計算と同様なので割愛。

保存データ画面

その他画面

ダークモード導入検討中。

実際の動き

感想

画面の作成、データ保存、画面遷移、タブ移動などなど勉強になるアプリ開発でした。
基本はChatGPTに投げて、それでも不明な箇所は自分で調べて書いていくという方法をとりました。しかし、ReactNativeも変化が早いのですぐに使えなくなるコンポーネントやライブラリとかありそうです。

自分のスマホとExpoGo、エディタさえあれば開発はすぐできます。興味ある方はぜひチャレンジしてみてください。

]]>
無限大マークの作り方【Illustrator Infinity Logo Design 】 https://snow-leaf.com/design/illustrator/infinity_logo_design/ Fri, 06 Oct 2023 19:10:17 +0000 https://snow-leaf.com/?p=944

]]>

]]>