SnowLeaf(スノーリーフ) https://snow-leaf.com Thu, 24 Apr 2025 00:46:45 +0000 ja hourly 1 https://snow-leaf.com/wp-content/uploads/2023/10/cropped-ded5093380b263341ac107303cad9c06-32x32.png SnowLeaf(スノーリーフ) https://snow-leaf.com 32 32 react-router-domとreact-router v7 https://snow-leaf.com/programming/dontforget/react-router-dom-react-router-v7/ Wed, 23 Apr 2025 22:46:00 +0000 https://snow-leaf.com/?p=1559 概要

viteでreactのプロジェクトを作成すると、react-router-domの7系がセットアップされます。

これでも問題なく使えますが、公式ドキュメントではreact-router-domを削除し、react-routerをインストールすることを推奨しています。

https://react-router-docs-ja.techtalk.jp/upgrading/v6#v7-%E3%81%AB%E3%82%A2%E3%83%83%E3%83%97%E3%82%B0%E3%83%AC%E3%83%BC%E3%83%89%E3%81%99%E3%82%8B

The react-router-dom, @remix-run/react, @remix-run/server-runtime, and @remix-run/router have been collapsed into the react-router package

react-router-domなどはreact-routerに内包されたようで、react-router-domでも今まで通りの表記が可能となりますが、import元を変更する必要があります。

react-router-dom

react-router/dom

To ease migration, react-router-dom is still published in v7 as a re-export of everything from react-router

なぜreact-router-dom v7があるのかというと移行を容易にするためとのことです。

https://github.com/remix-run/react-router/blob/main/CHANGELOG.md#package-restructuring

結論として、react-routerの7系を使う場合は、react-router-domより順次移行する必要があります。

ルーティング記述方法3種類

react-routerには記述方法が3種類あります。Declarative, Data, Frameworkです。

DeclarativeからData、そしてFrameworkへと移行すると、アーキテクチャの制御と引き換えに、より多くの機能が追加されます。したがって、React Routerからどれだけの制御または支援が必要かに基づいてモードを選択してください。

https://react-router-docs-ja.techtalk.jp/start/modes#%E9%81%B8%E6%8A%9E%E3%81%AE%E3%82%A2%E3%83%89%E3%83%90%E3%82%A4%E3%82%B9

以下はDeclarativeモードの一例です。
DeclarativeモードではBrowserRouterを使います。

import './App.css'
import { BrowserRouter } from 'react-router'
import { Routes, Route } from 'react-router'
import { AuthProvider } from './contexts/AuthContext'
import MainLayout from './layouts/MainLayout'
import Dashboard from './pages/Dashboard'
import Users from './pages/Users'
import Settings from './pages/Settings'
import Login from './pages/Login'
import PublicRoute from './components/PublicRoute'
import ProtectedRoute from './components/ProtectedRoute'

export default function App() {
  return (
    <AuthProvider>
      <BrowserRouter>
        <Routes>
          <Route element={<PublicRoute />}>
            <Route path="/login" element={<Login />} />
          </Route>
          <Route element={<ProtectedRoute />}>
            <Route path="/" element={<MainLayout />}>
              <Route index element={<Dashboard />} />
              <Route path="users" element={<Users />} />
              <Route path="settings" element={<Settings />} />
            </Route>
          </Route>
        </Routes>
      </BrowserRouter>
    </AuthProvider>
  );
}

以下はDataモードの一例です。
DataモードはcreateBrowserRouterを使います。

import './App.css';
import { createBrowserRouter, RouterProvider, redirect } from 'react-router';
import { AuthProvider, useAuth } from './contexts/AuthContext';
import MainLayout from './layouts/MainLayout';
import Dashboard from './pages/Dashboard';
import Users from './pages/Users';
import Settings from './pages/Settings';
import Login from './pages/Login';

// 認証チェックのためのLoader関数
const protectedLoader = () => {
  // loader関数では useAuth が使えないのでここではダミーの認証チェック
  // 実際の実装では getAuthStatus などの非Hook関数を使う必要があります
  const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true';
  
  if (!isAuthenticated) {
    return redirect('/login');
  }
  return null;
};

const publicLoader = () => {
  const isAuthenticated = localStorage.getItem('isAuthenticated') === 'true';
  
  if (isAuthenticated) {
    return redirect('/');
  }
  return null;
};

// ルーターの設定
const router = createBrowserRouter([
  {
    path: "/login",
    element: <Login />,
    loader: publicLoader,
  },
  {
    path: "/",
    element: <MainLayout />,
    loader: protectedLoader,
    children: [
      {
        index: true,
        element: <Dashboard />,
      },
      {
        path: "users",
        element: <Users />,
        loader: async () => {
          // ここでユーザーデータをフェッチする例
          // const response = await fetch('/api/users');
          // return response.json();
          return { users: [] }; // サンプルデータ
        },
      },
      {
        path: "settings",
        element: <Settings />,
      },
    ],
  },
]);

export default function App() {
  return (
    <AuthProvider>
      <RouterProvider router={router} />
    </AuthProvider>
  );
}

]]>
ProviderとContextの違い https://snow-leaf.com/programming/dontforget/provider_context/ Fri, 18 Apr 2025 00:27:16 +0000 https://snow-leaf.com/?p=1554 Reactで認証機能を実装するときによく出てくるContextProvider

この記事では、ContextProvider の役割の違いをわかりやすく解説します!


🧠 ざっくり結論

名前役割たとえ話
Context認証情報を共有するための「箱」「電源コンセント本体」
Providerその箱に中身を詰めて提供する「供給元」「コンセントに電気を流す人」

👀 AuthContextとは?

export const AuthContext = createContext<AuthContextType | undefined>(undefined)
  • ReactのContextオブジェクトです。
  • 認証情報(ログインユーザーなど)をグローバルに共有するための”箱”を作ります。
  • この時点では中身は空っぽ。

つまり、「みんなで共有するための箱を用意しただけ」の状態です。


⚙ Providerとは?

export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const [user, setUser] = useState<User | null>(null)
  const [isAuthenticated, setIsAuthenticated] = useState(false)

  // ...ログイン・ログアウト処理など...

  return (
    <AuthContext.Provider value={{ user, login, logout, isAuthenticated }}>
      {children}
    </AuthContext.Provider>
  )
}
  • Context に値をセットしてアプリ全体に渡すコンポーネントです。
  • 実際の認証状態やユーザー情報を持っていて、それを 〜〜Context.Provider を通して子コンポーネントに供給しています。

要するに、「箱に実際のデータを入れて使えるようにする」のがこの Provider です。


🧩 関係性まとめ

// AuthContext:状態を共有する「箱」
export const AuthContext = createContext<AuthContextType | undefined>(undefined)

// AuthProvider:その箱にデータを詰めて、子コンポーネントに提供する
<AuthContext.Provider value={...}>
  {children}
</AuthContext.Provider>

// useAuth:その箱からデータを取り出すフック(カスタムフックで分離して使うと便利!)

🔌 たとえ話でイメージすると…

  • Context:電源コンセントの”差込口”
  • Provider:そこに電気(データ)を流す人
  • use〜〜():その電気を使うためのプラグ

📝 実際にどう使う?

// App.tsx
import { AuthProvider } from './contexts/AuthContext'

const App = () => (
  <AuthProvider>
    <YourAppRoutes />
  </AuthProvider>
)

こうすることで、<YourAppRoutes />以下のすべてのコンポーネントが useContext(AuthContext)useAuth() を使ってログイン状態にアクセスできるようになります!


✅ まとめ

  • Context:共有のための「状態の箱」
  • Provider:その箱に実際の値を入れてアプリ全体に届ける
  • use〜〜():その箱から値を取り出すカスタムフック(分けておくと便利)

Reactで認証状態を管理する際には、この3点セットの関係をしっかり理解しておくと、よりスムーズに開発できるようになります!

]]>
emitはイベントなので、v-on(@)で親側で受け取る https://snow-leaf.com/others/emit_event_v_on/ Thu, 20 Mar 2025 09:38:28 +0000 https://snow-leaf.com/?p=1550 emit は子コンポーネントから親コンポーネントへ イベントを発行する(発信する)ためのものです。

  • 子コンポーネントthis.$emit() を使ってイベントを発行します。このイベントは親コンポーネントに通知され、親はそのイベントを リスン(購読) することができます。
  • 親コンポーネントは、子コンポーネントから発行されたイベントを @(または v-on)を使ってリスン(購読)し、そのイベントが発生したときに指定したメソッドを実行します。

具体例

子コンポーネント(Child.vue

子コンポーネントは、何らかのアクション(例えばボタンのクリック)でイベントを発行します。

<template>
  <button @click="sendMessage">Send Message</button>
</template>

<script>
export default {
  methods: {
    sendMessage() {
      // 'messageSent' イベントを親に送信する
      this.$emit('messageSent', 'Hello from child');
    }
  }
}
</script>

親コンポーネント(Parent.vue

親コンポーネントは、子コンポーネントが発行した messageSent イベントを購読し、そのデータを受け取って処理します。

<template>
  <Child @messageSent="handleMessage" />
</template>

<script>
import Child from './Child.vue';

export default {
  components: {
    Child
  },
  methods: {
    handleMessage(message) {
      console.log('Received from child:', message);  // 'Hello from child' と表示される
    }
  }
}
</script>

ポイント

  • 子コンポーネントが this.$emit('messageSent', 'Hello from child') を実行すると、親コンポーネントは @messageSent="handleMessage" を使ってこのイベントを購読しているので、handleMessage メソッドが呼ばれ、message'Hello from child')を受け取ります。

結論

  • emit子コンポーネント から 親コンポーネント へイベントを発行するために使います。
  • 親コンポーネントでは、そのイベントを @(または v-on)でリスン(購読)して、指定したメソッドを実行するという流れです。
]]>
SPA(SinglePageApplication)のライフサイクルについて(Vue.js) https://snow-leaf.com/others/spa_lifecycle_hooks/ Thu, 20 Mar 2025 08:56:55 +0000 https://snow-leaf.com/?p=1548 SPA(Single Page Application) では、ページ遷移がなく、コンテンツの一部だけを動的に更新するため、Vueのコンポーネントライフサイクルは通常のページ遷移の際のものとは少し異なります。しかし、基本的なライフサイクルメソッドは同様に動作します。

1. 通常のライフサイクルメソッドとSPAでの違い

通常のページ遷移(複数ページアプリケーション):

  • ページがロードされるたびに、ページのリロードが発生し、その都度Vueコンポーネントがマウントされます。

SPA(Single Page Application):

  • ページ遷移があっても、ブラウザのページ全体のリロードは発生しません
  • Vue Routerを使ってビューが変更されるため、コンポーネントのライフサイクルは、ページ遷移ごとに完全に再度実行されるわけではありません。
  • 新しいコンポーネントがロードされると、ライフサイクルメソッドが実行されますが、既存のコンポーネントはアンマウントされずに再利用される場合もあります。

2. SPAでの主なライフサイクル

  • created: コンポーネントがインスタンス化され、データのセットアップが完了した時点で呼ばれます。ページ遷移時にも再実行されますが、現在表示されているコンポーネントがすでに表示されていれば、再実行されない場合があります
  • mounted: コンポーネントがDOMにマウントされ、画面に描画された後に呼ばれます。SPAでは、ルート遷移によって新しいコンポーネントがマウントされる際に実行されますが、ページ全体のリロードがないため、前のコンポーネントがアンマウントされることなく、新しいコンポーネントがマウントされます
  • beforeRouteEnter: Vue Routerを使用している場合、ページ遷移前に実行されるライフサイクルメソッドです。このメソッドはコンポーネントがマウントされる前に呼ばれるため、コンポーネントのDOMにアクセスすることはできませんが、ページ遷移前に処理を挟みたい場合に使用します。
  • beforeRouteLeave: このメソッドは、ルートを離れるときに呼ばれ、次のルートに遷移する前に実行されます。コンポーネントのアンマウント前に処理をしたい場合に使います。
  • beforeDestroy / destroyed:
    • **beforeDestroy**はコンポーネントが破棄される直前に呼ばれますが、SPAの場合、ルートが遷移する前に表示されていたコンポーネントが破棄されます。
    • **destroyed**はコンポーネントが完全に破棄された後に呼ばれます。
    • これらは、ルートの切り替え時にコンポーネントがアンマウントされる際に実行されます。

3. Vue Routerとの関連

VueのライフサイクルメソッドVue Routerの組み合わせにより、コンポーネントのライフサイクルはルート遷移に密接に関連します。例えば、Vue Routerの<router-view>を使用して、ルートが変更されるたびにコンポーネントが動的に切り替わるので、その際にコンポーネントのライフサイクルメソッドが呼ばれます。

  • ルート遷移時に新しいコンポーネントがマウントされると、createdmounted が呼ばれます。
  • 古いコンポーネントは、必要に応じてbeforeDestroydestroyedで破棄されます。
  • beforeRouteEnterbeforeRouteLeaveはルート遷移時に処理を加えたい場合に有用です。

4. ライフサイクルの例:SPAにおける動作

<template>
  <div>
    <h1>{{ message }}</h1>
    <button @click="changeMessage">Change Message</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: 'Initial message'
    };
  },
  created() {
    console.log('Component created');
  },
  mounted() {
    console.log('Component mounted');
  },
  methods: {
    changeMessage() {
      this.message = 'Message updated';
    }
  },
  beforeRouteEnter(to, from, next) {
    console.log('Before route enter');
    next();
  },
  beforeRouteLeave(to, from, next) {
    console.log('Before route leave');
    next();
  }
};
</script>

5. SPAのライフサイクルの流れ

  • 初回表示:
    • ページが最初に表示されたとき、コンポーネントがマウントされるときに、createdmounted が呼ばれます。
  • ルート遷移:
    • Vue Routerで新しいルートに遷移すると、beforeRouteEntercreatedmounted が順番に呼ばれます。
    • 古いコンポーネントは、beforeRouteLeavebeforeDestroydestroyed の順で破棄されます。

6. まとめ

SPAでは、コンポーネントのライフサイクルが通常のページ遷移とは異なります。
ページ遷移時にページ全体がリロードされないため、コンポーネントの再マウントやアンマウントはルート遷移に基づいて行われ、createdmountedなどのライフサイクルメソッドが再実行されます。
Vue Routerと組み合わせて、コンポーネントのライフサイクルを細かく制御できるため、SPAでも効率的に状態管理や処理を行うことができます。

]]>
親から子のデータやメソッドを参照する場合($refs) https://snow-leaf.com/programming/dontforget/parent_child_refs/ Thu, 20 Mar 2025 08:50:40 +0000 https://snow-leaf.com/?p=1546 子コンポーネントにrefを設定しておけば、親コンポーネントから子コンポーネントのメソッドやデータにアクセスできます。

詳細な流れ

  1. 子コンポーネント側にrefを設定
    • 子コンポーネントでrefを設定することで、親コンポーネントからその子コンポーネントを参照できるようになります。
  2. 親コンポーネントで子コンポーネントを参照
    • 親コンポーネントからthis.$refsを使って、子コンポーネントのインスタンスにアクセスできます。
    • これにより、子コンポーネントのメソッドやデータにアクセスできます。

具体例

1) 子コンポーネント (ChildComponent.vue)

<template>
  <div>
    <p>{{ message }}</p>
    <button @click="changeMessage">Change Message</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: 'Hello from child!'
    }
  },
  methods: {
    changeMessage() {
      this.message = 'Message changed by parent!';
    }
  }
}
</script>

2) 親コンポーネント (ParentComponent.vue)

<template>
  <div>
    <ChildComponent ref="childComp" />
    <button @click="accessChild">Change Child Message</button>
  </div>
</template>

<script>
import ChildComponent from './ChildComponent.vue';

export default {
  components: {
    ChildComponent
  },
  methods: {
    accessChild() {
      // 子コンポーネントのメソッドとデータにアクセス
      console.log(this.$refs.childComp.message);  // 'Hello from child!'
      this.$refs.childComp.changeMessage();      // 子コンポーネントのメソッド呼び出し
      console.log(this.$refs.childComp.message);  // 'Message changed by parent!'
    }
  }
}
</script>

ポイント

  • 親コンポーネントのthis.$refs.childCompで、子コンポーネントのインスタンスにアクセスできます。
  • this.$refs.childComp.messageで、子コンポーネントのdata(ここではmessage)にアクセスできます。
  • this.$refs.childComp.changeMessage()で、子コンポーネントのmethods(ここではchangeMessage)を呼び出すことができます。

注意点

  • $refsは親コンポーネントがマウントされた後でアクセス可能です。つまり、親コンポーネントがレンダリングされる前にアクセスしようとするとundefinedになることがあります。
  • $refsでアクセスできるのは、子コンポーネントのインスタンスやDOM要素ですが、一般的に子コンポーネントのインスタンスが返されます。

]]>
composerの備忘録 https://snow-leaf.com/others/composer%e3%81%ae%e5%82%99%e5%bf%98%e9%8c%b2/ Fri, 07 Mar 2025 21:51:31 +0000 https://snow-leaf.com/?p=1542

Composerの基本的な使い方とコマンド

ComposerはPHPの依存関係管理ツールです。これを使うことで、プロジェクトに必要なライブラリやパッケージを簡単に管理できます。以下では、composer installcomposer updateなどの基本的なコマンドの使い方を説明します。

composer installの使用方法

composer installは、composer.jsonファイルに記述されたパッケージ情報を基に、必要な依存関係をインストールします。
初めてプロジェクトをセットアップする際に使用します。このコマンドが実行されると、依存関係がインストールされるとともに、composer.lockファイルが生成されます。
composer.lockファイルは、インストールされたパッケージのバージョン情報を正確に記録し、他の開発者が同じ環境を再現できるようにします。

二回目以降の使用

プロジェクトに変更がなければ、二回目以降にcomposer installを実行しても、composer.jsonのパッケージ情報は変更されません。
代わりに、composer.lockに記載されたバージョンのパッケージがインストールされます。
これにより、同じプロジェクトを複数人で共有する際に、全員が同じバージョンのパッケージを使用することが保証されます。

パッケージの追加

新しいパッケージをプロジェクトに追加したい場合は、composer requireコマンドを使用します。例えば、guzzlehttp/guzzleというパッケージを追加したい場合、以下のコマンドを実行します。

composer require guzzlehttp/guzzle

このコマンドを実行すると、composer.jsonにそのパッケージが追加されるとともに、composer.lockにもそのパッケージの情報が追加されます。これにより、他の開発者がcomposer installを実行する際に、追加したパッケージがインストールされます。

パッケージのアップデート

パッケージを最新のバージョンに更新したい場合は、composer updateコマンドを使用します。例えば、guzzlehttp/guzzleを最新バージョンにアップデートしたい場合は、以下のコマンドを実行します。

composer update guzzlehttp/guzzle

このコマンドを実行すると、composer.jsonのパッケージ情報が最新のものに更新され、composer.lockにも最新の情報が記載されます。このコマンドを使用することで、依存関係を最新に保つことができます。

まとめ

  • composer installcomposer.jsonに基づいてパッケージをインストールし、composer.lockが生成されます。
  • 以降、composer installを実行すると、composer.lockに基づいてパッケージがインストールされ、composer.jsonの情報は変更されません。
  • 新しいパッケージを追加するにはcomposer requireを使用し、composer.jsoncomposer.lockが更新されます。
  • 既存のパッケージを最新バージョンにアップデートするにはcomposer updateを使用し、composer.jsoncomposer.lockが更新されます

Composerを使用した依存関係管理の慎重な取り扱い

依存関係を慎重に扱いたい場合、Composerには便利なコマンドや技術があります。これらを活用することで、依存関係の変更がプロジェクトに与える影響を最小限に抑えることができます。以下では、dry-runwhyコマンドをはじめとする、慎重に依存関係を管理するためのコマンドやバックアップ方法について紹介します。

composer update –dry-run — 実行前に変更内容を確認

composer updateコマンドを実行する前に、変更内容を確認するための安全策として--dry-runオプションを使用できます。このオプションを使うことで、実際にパッケージを更新せずに、どのパッケージがアップデートされるか、どのような変更が行われるかを確認できます。

composer update --dry-run

このコマンドを実行すると、依存関係の変更内容が表示されるので、問題が発生する前に確認できます。

composer why — 依存関係の原因を調査

composer whyコマンドは、特定のパッケージがどの依存関係によって要求されているかを調査するための便利なツールです。これを使うことで、特定のパッケージがプロジェクトにどのように影響を与えているかを確認できます。

composer why <package-name>

例えば、guzzlehttp/guzzleが依存関係に含まれている場合、以下のコマンドでその詳細を確認できます。

composer why guzzlehttp/guzzle

これにより、依存関係がどのパッケージから要求されているのかを調べることができます。

composer show — インストール済みパッケージの確認

composer showコマンドを使用すると、現在インストールされているすべてのパッケージとそのバージョンを確認できます。このコマンドは、プロジェクトにどのパッケージがインストールされているかを把握するために役立ちます。

composer show

パッケージ名を指定して、そのパッケージの詳細情報を確認することもできます。

composer show <package-name>

これにより、インストールされているパッケージのバージョンや依存関係を詳細に確認できます。

バックアップ方法 — 重要なファイルのバックアップ

依存関係を更新する前に、composer.jsonおよびcomposer.lockファイルをバックアップすることを強くお勧めします。これにより、万が一の問題が発生した場合に元の状態に戻すことができます。

  1. composer.jsonのバックアップ
cp composer.json composer.json.bak
  1. composer.lockのバックアップ
cp composer.lock composer.lock.bak

これで、composer.jsoncomposer.lockに問題が発生した場合に、バックアップファイルを元に戻すことができます。

まとめ

依存関係を慎重に扱いたい場合、以下のコマンドや技術が役立ちます。

  • composer update --dry-runを使用して、更新前に変更内容を確認する。
  • composer whyを使って、特定のパッケージがどの依存関係から要求されているかを調べる。
  • composer showを使って、インストールされているパッケージとそのバージョンを確認する。
  • composer.jsoncomposer.lockのバックアップを取り、必要に応じて元に戻せるようにする。
  • バージョン管理ツール(Gitなど)を使用して、変更履歴を管理し、必要に応じて元に戻す。
]]>
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) ]]>