🚀

laravel12の starter kit (react)を使った開発 - ユーザーのCRUD

2025/03/01に公開
1


完成イメージ


この記事のゴール

ちょっと日本語になっているのはさておいて、このガワにユーザーのCRUDを作っていく。sidebarレイアウトを用いる。

ユーザーのseed

database/seeders/DatabaseSeeder.php
@@ -13,7 +13,7 @@ class DatabaseSeeder extends Seeder
      */
     public function run(): void
     {
-        // User::factory(10)->create();
+        User::factory(10)->create();

         User::factory()->create([
             'name' => 'Test User',

10人 + 1 作る

UserControllerの作成とrouteのupdate

artisan make:controller UserController -m User -r
routes/web.php
<?php

use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use App\Http\Controllers\UserController; // <---- 追加

Route::get('/', function () {
    return Inertia::render('welcome');
})->name('home');

Route::middleware(['auth'])->group(function () {
    Route::get('dashboard', function () {
        return Inertia::render('dashboard');
    })->name('dashboard');

    Route::resource('users', UserController::class); // <---- 追加
});

require __DIR__.'/settings.php';
require __DIR__.'/auth.php';

ここではシンプルにusersにUserControllerリソースを与えた。ユーザー管理っぽいルートであるがとりあえずアクセス制限はかけていない。

メニューの追加(とか)

ここからstarter kitをいじくっていく。まずメニューに関しては

resources/js/components/app-sidebar.tsx
    <Sidebar collapsible="icon" variant="inset">
      <SidebarHeader>
        <SidebarMenu>
          <SidebarMenuItem>
            <SidebarMenuButton size="lg" asChild>
              <Link href="/dashboard" prefetch>
                <AppLogo />
              </Link>
            </SidebarMenuButton>
          </SidebarMenuItem>
        </SidebarMenu>
      </SidebarHeader>

      <SidebarContent>
        <NavMain items={mainNavItems} />
      </SidebarContent>

      <SidebarFooter>
        <NavFooter items={footerNavItems} className="mt-auto" />
        <NavUser />
      </SidebarFooter>

こんな感じになっており、基本的にはmainNavItemsをいじればよさそうだ。ここでは実は日本語にするにあたってちょっと弄っちゃったんだけど

  const { t } = useLaravelReactI18n();

  const mainNavItems: NavItem[] = [
    {
      title: t('Dashboard'),
      url: '/dashboard',
      icon: LayoutGrid,
    },
  ];

いずれにせよこのmainNavItemsを増やしていく。

  const mainNavItems: NavItem[] = [
    {
      title: t('Dashboard'),
      url: '/dashboard',
      icon: LayoutGrid,
    },
    {
      title: t('Users'),
      url: '/users',
      icon: LayoutGrid,
    },
  ];

アイコンに関して

これは

import { BookOpen, Folder, LayoutGrid } from 'lucide-react';

でわかるように https://lucide.dev/icons/ を使っている。もちろんreact-iconsとかで差し替えてもよいはずだが、とりあえずはこのアイコンセットから選択する

@@ -13,7 +13,7 @@ import {
 import { type NavItem } from '@/types';
 import { Link } from '@inertiajs/react';
 import { useLaravelReactI18n } from 'laravel-react-i18n';
-import { BookOpen, Folder, LayoutGrid } from 'lucide-react';
+import { BookOpen, Folder, LayoutGrid, Users } from 'lucide-react';
 import AppLogo from './app-logo';

 export function AppSidebar() {
@@ -28,7 +28,7 @@ export function AppSidebar() {
     {
       title: t('Users'),
       url: '/users',
-      icon: LayoutGrid,
+      icon: Users,
     },
   ];


Userアイコンが追加された

とすればこのようにはめてくれる。

ただまあ個人的には

@@ -13,7 +13,7 @@ import {
 import { type NavItem } from '@/types';
 import { Link } from '@inertiajs/react';
 import { useLaravelReactI18n } from 'laravel-react-i18n';
-import { BookOpen, Folder, LayoutGrid } from 'lucide-react';
+import { BookOpen, Folder, LayoutGrid, Users } from 'lucide-react';
 import AppLogo from './app-logo';

 export function AppSidebar() {
@@ -22,13 +22,13 @@ export function AppSidebar() {
   const mainNavItems: NavItem[] = [
     {
       title: t('Dashboard'),
-      url: '/dashboard',
+      url: route('dashboard'),
       icon: LayoutGrid,
     },
     {
       title: t('Users'),
-      url: '/users',
-      icon: LayoutGrid,
+      url: route('users.index'),
+      icon: Users,
     },
   ];

こんな感じでroute()しておきたい、かな。

Users.index

app/Http/Controllers/UserController.php
 use App\Models\User;
 use Illuminate\Http\Request;
+use Inertia\Inertia;
+use Inertia\Response;

 class UserController extends Controller
 {
     /**
      * Display a listing of the resource.
      */
-    public function index()
+    public function index(): Response
     {
-        //
+        $users = User::all();
+        return Inertia::render('users/index', [
+            'users' => $users,
+        ]);
     }

こんな感じにして、 index.tsx を作成する。app/Http/Controllers/Auth/ 以下に従いrenderは全て小文字に統一してある。

users/index.tsc

resources/js/pages/users/index.tsx
import AppLayout from '@/layouts/app-layout';
import { type BreadcrumbItem, type User } from '@/types';
import { Head } from '@inertiajs/react';

export default function UserIndex({ users }: { users: User[] }) {

  const breadcrumbs: BreadcrumbItem[] = [
    {
      title: 'Users',
      href: route('users.index'),
    },
  ];

  return (
    <AppLayout breadcrumbs={breadcrumbs}>
      <Head title="Users" />
      <div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
        <h1 className="text-xl font-bold">Users</h1>

        <div className="overflow-x-auto rounded-lg shadow-lg">
          <table className="min-w-full divide-y divide-gray-200 rounded-md bg-white shadow-md">
            <thead className="bg-gray-100">
              <tr>
                <th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-700 uppercase">
                  ID
                </th>
                <th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-700 uppercase">
                  Name
                </th>
                <th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-700 uppercase">
                  Email
                </th>
              </tr>
            </thead>
            <tbody className="divide-y divide-gray-200">
              {users.map(user => (
                <tr key={user.id} className="hover:bg-gray-50">
                  <td className="px-6 py-4 text-sm whitespace-nowrap text-gray-900">{user.id}</td>
                  <td className="px-6 py-4 text-sm font-semibold whitespace-nowrap text-gray-700">
                    {user.name}
                  </td>
                  <td className="px-6 py-4 text-sm whitespace-nowrap text-gray-600">
                    {user.email}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </AppLayout>
  );
}

ざっくり作る。こんな感じのtableになる

typeは

resources/js/types/index.ts
import { LucideIcon } from 'lucide-react';

export interface Auth {
  user: User;
}

export interface BreadcrumbItem {
  title: string;
  href: string;
}

export interface NavGroup {
  title: string;
  items: NavItem[];
}

export interface NavItem {
  title: string;
  url: string;
  icon?: LucideIcon | null;
  isActive?: boolean;
}

export interface SharedData {
  name: string;
  quote: { message: string; author: string };
  auth: Auth;
  [key: string]: unknown;
}

export interface User {
  id: number;
  name: string;
  email: string;
  avatar?: string;
  email_verified_at: string | null;
  created_at: string;
  updated_at: string;
  [key: string]: unknown; // This allows for additional properties...
}

ここに置いてあるようで、 // This allows for additional properties... ということでユーザーの属性が増えたら追加を推奨している。

新規ユーザーの作成リンクを作ってみよう

resources/js/pages/users/index.tsx
 import AppLayout from '@/layouts/app-layout';
 import { type BreadcrumbItem, type User } from '@/types';
-import { Head } from '@inertiajs/react';
+import { Head, Link } from '@inertiajs/react';

 export default function UserIndex({ users }: { users: User[] }) {

@@ -15,7 +15,14 @@ export default function UserIndex({ users }: { users: User[] }) {
     <AppLayout breadcrumbs={breadcrumbs}>
       <Head title="Users" />
       <div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
-        <h1 className="text-xl font-bold">Users</h1>
+        <div className="flex justify-between items-center">
+          <h1 className="text-xl font-bold">Users</h1>
+          <Link href={route('users.create')}>
+            Create User
+          </Link>
+        </div>

         <div className="overflow-x-auto rounded-lg shadow-lg">
           <table className="min-w-full divide-y divide-gray-200 rounded-md bg-white shadow-md">

このようにリンクが出来るのだが、リンクなのかどうかわからないのでボタンにする

ボタンにする

resources/js/pages/users/index.tsx
--- a/resources/js/pages/users/index.tsx
+++ b/resources/js/pages/users/index.tsx
@@ -1,6 +1,7 @@
 import AppLayout from '@/layouts/app-layout';
 import { type BreadcrumbItem, type User } from '@/types';
-import { Head } from '@inertiajs/react';
+import { Head, Link } from '@inertiajs/react';
+import { Button } from '@/components/ui/button';

 export default function UserIndex({ users }: { users: User[] }) {

@@ -15,7 +16,12 @@ export default function UserIndex({ users }: { users: User[] }) {
     <AppLayout breadcrumbs={breadcrumbs}>
       <Head title="Users" />
       <div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
-        <h1 className="text-xl font-bold">Users</h1>
+        <div className="flex items-center justify-between">
+          <h1 className="text-xl font-bold">Users</h1>
+          <Link href={route('users.create')}>
+            <Button asChild><span>Create User</span></Button>
+          </Link>
+        </div>

とすると

となる。asChildを付けるとLinkの要素として存在できるのでリンクボタンを使う時はこれを使うといいようである。

ためしにいろいろなvariantで作成してみよう

        <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
          <Button variant="default">Button (default)</Button>
          <Button variant="destructive">Button (destructive)</Button>
          <Button variant="outline">Button (outline)</Button>
          <Button variant="secondary">Button (secondary)</Button>
          <Button variant="ghost">Button (ghost)</Button>
          <Button variant="link">Button (link)</Button>
        </div>

        <h2 className="text-lg font-semibold mt-6">Size Variations</h2>
        <div className="flex flex-wrap gap-4">
          <Button size="sm">Button (small)</Button>
          <Button size="default">Button (default)</Button>
          <Button size="lg">Button (large)</Button>
          <Button size="icon">🔍</Button>
        </div>

これはダークモードでも機能する

まあ、つっこんだ話はさておいて、なるべく備えつけのcomponentを使っておいた方が何だかんだメンテナンス性がよいかもしれない。

ユーザー作成UI

app/Http/Controllers/UserController.php
@@ -24,9 +24,9 @@ public function index(): Response
     /**
      * Show the form for creating a new resource.
      */
-    public function create()
+    public function create(): Response
     {
-        //
+        return Inertia::render('users/create');
     }

とする

フォーム

これは編集と新規作成で共有した方がよさそうではあるが、とりあえずregisterのformを大部分コピペした。ここではi18nされているがあんま気にしないで欲しいという事で。

resources/js/pages/users/create.tsx
import { Head, useForm } from '@inertiajs/react';
import { LoaderCircle } from 'lucide-react';
import { FormEventHandler } from 'react';
import { type BreadcrumbItem } from '@/types';

import InputError from '@/components/input-error';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/app-layout';

import { useLaravelReactI18n } from 'laravel-react-i18n';

interface RegisterForm {
  [key: string]: string;
  name: string;
  email: string;
  password: string;
  password_confirmation: string;
}

export default function UserCreate() {
  const { t } = useLaravelReactI18n();

  const breadcrumbs: BreadcrumbItem[] = [
    {
      title: 'Users',
      href: route('users.index'),
    },
    {
      title: 'Create',
      href: route('users.create'),
    },
  ];

  const { data, setData, post, processing, errors, reset } = useForm<RegisterForm>({
    name: '',
    email: '',
    password: '',
    password_confirmation: '',
  });

  const submit: FormEventHandler = e => {
    e.preventDefault();
    post(route('users.store'), {
      onFinish: () => reset('password', 'password_confirmation'),
    });
  };

  return (
    <AppLayout breadcrumbs={breadcrumbs}>
      <Head title="New User" />
      <div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
        <form className="flex flex-col gap-6" onSubmit={submit}>
          <div className="grid gap-6">
            <div className="grid gap-2">
              <Label htmlFor="name">{t('Name')}</Label>
              <Input
                id="name"
                type="text"
                required
                autoFocus
                tabIndex={1}
                autoComplete="name"
                value={data.name}
                onChange={e => setData('name', e.target.value)}
                disabled={processing}
                placeholder={t('Full name')}
              />
              <InputError message={errors.name} className="mt-2" />
            </div>

            <div className="grid gap-2">
              <Label htmlFor="email">{t('Email address')}</Label>
              <Input
                id="email"
                type="email"
                required
                tabIndex={2}
                autoComplete="email"
                value={data.email}
                onChange={e => setData('email', e.target.value)}
                disabled={processing}
                placeholder="email@example.com"
              />
              <InputError message={errors.email} />
            </div>

            <div className="grid gap-2">
              <Label htmlFor="password">{t('Password')}</Label>
              <Input
                id="password"
                type="password"
                required
                tabIndex={3}
                autoComplete="new-password"
                value={data.password}
                onChange={e => setData('password', e.target.value)}
                disabled={processing}
                placeholder={t('Password')}
              />
              <InputError message={errors.password} />
            </div>

            <div className="grid gap-2">
              <Label htmlFor="password_confirmation">{t('Confirm password')}</Label>
              <Input
                id="password_confirmation"
                type="password"
                required
                tabIndex={4}
                autoComplete="new-password"
                value={data.password_confirmation}
                onChange={e => setData('password_confirmation', e.target.value)}
                disabled={processing}
                placeholder={t('Confirm password')}
              />
              <InputError message={errors.password_confirmation} />
            </div>

            <Button type="submit" className="mt-2 w-full" tabIndex={5} disabled={processing}>
              {processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
              {t('Create user')}
            </Button>
          </div>
        </form>
      </div>
    </AppLayout>
  );
}

デザインの微妙さはさておいて

こんな感じのformになり、breadcrumbが機能しているのがわかる。

ユーザー保存backend

app/Http/Controllers/UserController.php
 use Illuminate\Http\Request;
 use Inertia\Inertia;
 use Inertia\Response;
+use Illuminate\Http\RedirectResponse;

 class UserController extends Controller
 {
@@ -32,7 +33,7 @@ public function create(): Response
     /**
      * Store a newly created resource in storage.
      */
-    public function store(Request $request)
+    public function store(Request $request): RedirectResponse
     {
         dd($request->all());
     }

こんな感じであり、ここは基本的にphpの領域なのでいつものように保存していく。これは app/Http/Controllers/Auth/RegisteredUserController.php のコードをほとんどそのまま流用している

app/Http/Controllers/UserController.php
@@ -6,6 +6,9 @@
 use Illuminate\Http\Request;
 use Inertia\Inertia;
 use Inertia\Response;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Validation\Rules;
+use Illuminate\Support\Facades\Hash;

 class UserController extends Controller
 {
@@ -32,9 +35,21 @@ public function create(): Response
     /**
      * Store a newly created resource in storage.
      */
-    public function store(Request $request)
+    public function store(Request $request): RedirectResponse
     {
-        dd($request->all());
+        $request->validate([
+            'name' => 'required|string|max:255',
+            'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
+            'password' => ['required', 'confirmed', Rules\Password::defaults()],
+        ]);
+
+        User::create([
+            'name' => $request->name,
+            'email' => $request->email,
+            'password' => Hash::make($request->password),
+        ]);
+
+        return to_route('users.index')->with('success', __('User created successfully.'));
     }

これはもちろんリクエストを分離するべきだし、Registerと処理を共有したいなら(すなわちユーザーの新規登録を許可するのであれば)その辺も考えて作らないといけない。そしてパスワードのルールもアプリごとで結構違うだろうから Rules\Password::defaults()で括っているのもあんまり使えないだろうという気はする。

さらに

return to_route('users.index')->with('success', __('User created successfully.'));

users.indexルートにリダイレクトしている。これは

return redirect()->route('users.index')->with('success', __('User created successfully.'));

の短縮形であり、 with('success', __('User created successfully.')でメッセージを送信している、が、受信の仕組みが作られてないので何も表示されない。これはTODOってことになるだろう(書くかどうかはさておいて)

ユーザーのshowへの動線

今ユーザーが作成できるようになっている


作成されたユーザー

もちろんusers.showへアクセスしたいので、これにおいてはresources/js/pages/users/index.tsx をさらに弄っていく事になる。ここではnameにリンクを貼りたいところであるが、名前はnullを許容しているので、認証キーになっているemailにリンクを貼ってみた。ここではTextLinkを使った

resources/js/pages/users/index.tsx

 import AppLayout from '@/layouts/app-layout';
 import { type BreadcrumbItem, type User } from '@/types';
 import { Head, Link } from '@inertiajs/react';
+import TextLink from '@/components/text-link';

 export default function UserIndex({ users }: { users: User[] }) {
   const breadcrumbs: BreadcrumbItem[] = [
@@ -45,7 +46,9 @@ export default function UserIndex({ users }: { users: User[] }) {
                     {user.name}
                   </td>
                   <td className="px-6 py-4 text-sm whitespace-nowrap text-gray-600">
-                    {user.email}
+                    <TextLink href={route('users.show', user.id)}>
+                      {user.email}
+                    </TextLink>
                   </td>
                 </tr>
               ))}

ユーザー表示

app/Http/Controllers/UserController.php
     /**
      * Display the specified resource.
      */
-    public function show(User $user)
+    public function show(User $user): Response
     {
-        //
+        return Inertia::render('users/show', [
+            'user' => $user,
+        ]);
     }

viewはAIの作ってきたものをそのまま貼りつけますよ

resources/js/pages/users/show.tsx
import AppLayout from '@/layouts/app-layout';
import { type BreadcrumbItem, type User } from '@/types';
import { Head } from '@inertiajs/react';
import { useLaravelReactI18n } from 'laravel-react-i18n';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';

export default function UserShow({ user }: { user: User }) {
  const { t } = useLaravelReactI18n();

  const breadcrumbs: BreadcrumbItem[] = [
    {
      title: 'Users',
      href: route('users.index'),
    },
    {
      title: user.name,
      href: route('users.show', user.id),
    },
  ];

  return (
    <AppLayout breadcrumbs={breadcrumbs}>
      <Head title={user.name} />
      <div className="flex flex-col items-center space-y-6 p-6">
        <Avatar className="w-32 h-32 border-4 border-gray-200 dark:border-gray-700 shadow-lg">
          <AvatarImage src={user.avatar || `https://ui-avatars.com/api/?name=${user.name}`} alt={user.name} />
          <AvatarFallback>{user.name.charAt(0)}</AvatarFallback>
        </Avatar>

        <div className="text-center">
          <h1 className="text-2xl font-bold">{user.name}</h1>
          <p className="text-gray-500">{user.email}</p>
          <p className="text-sm text-gray-400">ID: {user.id}</p>
        </div>

        <div className="flex space-x-4">
          <Button variant="default">Edit Profile</Button>
          <Button variant="destructive">Delete Account</Button>
        </div>

        <div className="w-full max-w-2xl bg-white dark:bg-gray-800 shadow-md rounded-lg p-6 space-y-4">
          <h2 className="text-lg font-semibold">User Details</h2>
          <div className="grid grid-cols-2 gap-4 text-sm">
            <div className="font-medium text-gray-600 dark:text-gray-300">Email Verified</div>
            <div className="text-gray-900 dark:text-white">{user.email_verified_at ? 'Yes' : 'No'}</div>

            <div className="font-medium text-gray-600 dark:text-gray-300">Created At</div>
            <div className="text-gray-900 dark:text-white">{new Date(user.created_at).toLocaleDateString()}</div>

            <div className="font-medium text-gray-600 dark:text-gray-300">Updated At</div>
            <div className="text-gray-900 dark:text-white">{new Date(user.updated_at).toLocaleDateString()}</div>
          </div>
        </div>
      </div>
    </AppLayout>
  );
}


AIが書いたユーザーprofile

アバターとかもちゃんと考えるならちゃんと考えた方がよいとは思うがとりあえず

ユーザーの削除

これに関してはprofileの設定のところにヒントがあってresources/js/components/delete-user.tsx これを参考にコードを改変する

resources/js/pages/users/show.tsx
@@ -1,9 +1,19 @@
 import AppLayout from '@/layouts/app-layout';
 import { type BreadcrumbItem, type User } from '@/types';
-import { Head } from '@inertiajs/react';
+import { Head, useForm } from '@inertiajs/react';
 import { useLaravelReactI18n } from 'laravel-react-i18n';
 import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
 import { Button } from '@/components/ui/button';
+import { FormEventHandler } from 'react';
+import {
+  Dialog,
+  DialogClose,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogTitle,
+  DialogTrigger,
+} from '@/components/ui/dialog';

 export default function UserShow({ user }: { user: User }) {
   const { t } = useLaravelReactI18n();
@@ -19,6 +29,13 @@ export default function UserShow({ user }: { user: User }) {
     },
   ];

+  const { delete: destroy, processing } = useForm();
+
+  const deleteUser: FormEventHandler = e => {
+    e.preventDefault();
+    destroy(route('users.destroy', user.id));
+  };
+
   return (
     <AppLayout breadcrumbs={breadcrumbs}>
       <Head title={user.name} />
@@ -36,7 +53,27 @@ export default function UserShow({ user }: { user: User }) {

         <div className="flex space-x-4">
           <Button variant="default">Edit Profile</Button>
-          <Button variant="destructive">Delete Account</Button>
+
+          <Dialog>
+            <DialogTrigger asChild>
+              <Button variant="destructive">Delete Account</Button>
+            </DialogTrigger>
+            <DialogContent>
+              <DialogTitle>Are you sure you want to delete this account?</DialogTitle>
+              <DialogDescription>
+                Once deleted, this user’s data will be permanently removed. This action cannot be undone.
+              </DialogDescription>
+              <DialogFooter>
+                <DialogClose asChild>
+                  <Button variant="secondary">Cancel</Button>
+                </DialogClose>
+
+                <Button variant="destructive" disabled={processing} onClick={deleteUser}>
+                  {processing ? 'Deleting...' : 'Delete Account'}
+                </Button>
+              </DialogFooter>
+            </DialogContent>
+          </Dialog>
         </div>

こんな感じになる

削除処理

app/Http/Controllers/UserController.php
     /**
      * Remove the specified resource from storage.
      */
-    public function destroy(User $user)
+    public function destroy(User $user): RedirectResponse
     {
-        //
+        $user->delete();
+        return to_route('users.index')->with('success', __('User deleted successfully.'));
     }
 }

編集処理

もうあとは惰性。フォームはコピペしてあるので最終的にまとめた方がよいかと思いますが

resources/js/pages/users/show.tsx
 import AppLayout from '@/layouts/app-layout';
 import { type BreadcrumbItem, type User } from '@/types';
-import { Head, useForm } from '@inertiajs/react';
+import { Head, useForm, Link } from '@inertiajs/react';
 import { useLaravelReactI18n } from 'laravel-react-i18n';
 import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
 import { Button } from '@/components/ui/button';
@@ -52,7 +52,11 @@ export default function UserShow({ user }: { user: User }) {
         </div>

         <div className="flex space-x-4">
-          <Button variant="default">Edit Profile</Button>
+          <Link href={route('users.edit', user.id)}>
+            <Button variant="default" asChild>
+              <span>Edit Profile</span>
+            </Button>
+          </Link>

           <Dialog>
             <DialogTrigger asChild>

編集フォーム

app/Http/Controllers/UserController.php
@@ -64,9 +64,11 @@ public function show(User $user): Response
     /**
      * Show the form for editing the specified resource.
      */
-    public function edit(User $user)
+    public function edit(User $user): Response
     {
-        //
+        return Inertia::render('users/edit', [
+            'user' => $user,
+        ]);
     }

編集フォームview

ほぼコピペ対応

cp resources/js/pages/users/create.tsx resources/js/pages/users/edit.tsx
resources/js/pages/users/edit.tsx
 import { Head, useForm } from '@inertiajs/react';
 import { LoaderCircle } from 'lucide-react';
 import { FormEventHandler } from 'react';
-import { type BreadcrumbItem } from '@/types';
+import { type BreadcrumbItem, type User } from '@/types';

 import InputError from '@/components/input-error';
 import { Button } from '@/components/ui/button';
@@ -11,15 +11,14 @@ import AppLayout from '@/layouts/app-layout';

 import { useLaravelReactI18n } from 'laravel-react-i18n';

-interface RegisterForm {
-  [key: string]: string;
+interface EditUserForm {
+  [key: string]: string | undefined;
   name: string;
   email: string;
-  password: string;
-  password_confirmation: string;
+  password?: string;
+  password_confirmation?: string;
 }

-export default function UserCreate() {
+export default function UserEdit({ user }: { user: User }) {
   const { t } = useLaravelReactI18n();

   const breadcrumbs: BreadcrumbItem[] = [
@@ -28,28 +27,32 @@ export default function UserCreate() {
       href: route('users.index'),
     },
     {
-      title: 'Create',
-      href: route('users.create'),
+      title: user.name,
+      href: route('users.show', user.id),
+    },
+    {
+      title: 'Edit',
+      href: route('users.edit', user.id),
     },
   ];

-  const { data, setData, post, processing, errors, reset } = useForm<RegisterForm>({
-    name: '',
-    email: '',
+  const { data, setData, patch, processing, errors, reset } = useForm<EditUserForm>({
+    name: user.name,
+    email: user.email,
     password: '',
     password_confirmation: '',
   });

   const submit: FormEventHandler = e => {
     e.preventDefault();
-    post(route('users.store'), {
+    patch(route('users.update', user.id), {
       onFinish: () => reset('password', 'password_confirmation'),
     });
   };

   return (
     <AppLayout breadcrumbs={breadcrumbs}>
-      <Head title="New User" />
+      <Head title="Edit User" />
       <div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4">
         <form className="flex flex-col gap-6" onSubmit={submit}>
           <div className="grid gap-6">
@@ -91,13 +94,12 @@ export default function UserCreate() {
               <Input
                 id="password"
                 type="password"
-                required
                 tabIndex={3}
                 autoComplete="new-password"
                 value={data.password}
                 onChange={e => setData('password', e.target.value)}
                 disabled={processing}
-                placeholder={t('Password')}
+                placeholder={t('Leave blank to keep current password')}
               />
               <InputError message={errors.password} />
             </div>
@@ -107,20 +109,19 @@ export default function UserCreate() {
               <Input
                 id="password_confirmation"
                 type="password"
-                required
                 tabIndex={4}
                 autoComplete="new-password"
                 value={data.password_confirmation}
                 onChange={e => setData('password_confirmation', e.target.value)}
                 disabled={processing}
-                placeholder={t('Confirm password')}
+                placeholder={t('Confirm new password')}
               />
               <InputError message={errors.password_confirmation} />
             </div>

             <Button type="submit" className="mt-2 w-full" tabIndex={5} disabled={processing}>
               {processing && <LoaderCircle className="h-4 w-4 animate-spin" />}
-              {t('Create user')}
+              {t('Update User')}
             </Button>
           </div>
         </form>

いずれにせよ共通部分がcreateと多数あるので、これは後に考える必要がある。まあTODOってことで

実際の編集

app/Http/Controllers/UserController.php
@@ -76,9 +76,25 @@ public function edit(User $user): Response
      */
     public function update(Request $request, User $user)
     {
-        //
+        $validated = $request->validate([
+            'name' => 'required|string|max:255',
+            'email' => 'required|string|email|max:255|unique:users,email,' . $user->id,
+            'password' => 'nullable|string|min:8|confirmed',
+        ]);
+
+        $user->name = $validated['name'];
+        $user->email = $validated['email'];
+
+        if (!empty($validated['password'])) {
+            $user->password = bcrypt($validated['password']);
+        }
+
+        $user->save();
+
+        return to_route('users.show', $user)->with('success', __('User updated successfully.'));
     }

最後に

./vendor/bin/sail test

   PASS  Tests\Unit\ExampleTest
  ✓ that true is true                                                                                            0.01s

   PASS  Tests\Feature\Auth\AuthenticationTest
  ✓ login screen can be rendered                                                                                 1.06s
  ✓ users can authenticate using the login screen                                                                0.06s
  ✓ users can not authenticate with invalid password                                                             0.23s
  ✓ users can logout                                                                                             0.02s

   PASS  Tests\Feature\Auth\EmailVerificationTest
  ✓ email verification screen can be rendered                                                                    0.03s
  ✓ email can be verified                                                                                        0.04s
  ✓ email is not verified with invalid hash                                                                      0.03s

   PASS  Tests\Feature\Auth\PasswordConfirmationTest
  ✓ confirm password screen can be rendered                                                                      0.03s
  ✓ password can be confirmed                                                                                    0.03s
  ✓ password is not confirmed with invalid password                                                              0.23s

   PASS  Tests\Feature\Auth\PasswordResetTest
  ✓ reset password link screen can be rendered                                                                   0.03s
  ✓ reset password link can be requested                                                                         0.04s
  ✓ reset password screen can be rendered                                                                        0.03s
  ✓ password can be reset with valid token                                                                       0.05s

   PASS  Tests\Feature\Auth\RegistrationTest
  ✓ registration screen can be rendered                                                                          0.02s
  ✓ new users can register                                                                                       0.03s

   PASS  Tests\Feature\DashboardTest
  ✓ guests are redirected to the login page                                                                      0.02s
  ✓ authenticated users can visit the dashboard                                                                  0.03s

   PASS  Tests\Feature\ExampleTest
  ✓ it returns a successful response                                                                             0.02s

   PASS  Tests\Feature\Http\Controllers\UserControllerTest
  ✓ example                                                                                                      0.02s

   PASS  Tests\Feature\Settings\PasswordUpdateTest
  ✓ password can be updated                                                                                      0.04s
  ✓ correct password must be provided to update password                                                         0.03s

   PASS  Tests\Feature\Settings\ProfileUpdateTest
  ✓ profile page is displayed                                                                                    0.03s
  ✓ profile information can be updated                                                                           0.03s
  ✓ email verification status is unchanged when the email address is unchanged                                   0.03s
  ✓ user can delete their account                                                                                0.03s
  ✓ correct password must be provided to delete account                                                          0.03s

  Tests:    28 passed (65 assertions)
  Duration: 2.42s

./vendor/bin/sail npm run lint

> lint
> eslint . --fix

./vendor/bin/sail npm run format
...

./vendor/bin/sail npx tsc --noEmit

まとめ

コピペの応酬でもauth系からいろいろ持ってこれるのがユーザーのCRUDであるので、一度作成してみると理解が深まるんじゃないかなあと思う。五月雨式にメモっぽくざっと書いちゃったのでbookにまとめられるといいけどなあ...

1

Discussion