laravel12の starter kit (react)を使った開発 - ユーザーのCRUD
完成イメージ
この記事のゴール
ちょっと日本語になっているのはさておいて、このガワにユーザーのCRUDを作っていく。sidebarレイアウトを用いる。
ユーザーのseed
@@ -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
<?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をいじくっていく。まずメニューに関しては
<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
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
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は
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... ということでユーザーの属性が増えたら追加を推奨している。
新規ユーザーの作成リンクを作ってみよう
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">
このようにリンクが出来るのだが、リンクなのかどうかわからないのでボタンにする
ボタンにする
--- 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
@@ -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されているがあんま気にしないで欲しいという事で。
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
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 のコードをほとんどそのまま流用している
@@ -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
を使った
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>
))}
ユーザー表示
/**
* Display the specified resource.
*/
- public function show(User $user)
+ public function show(User $user): Response
{
- //
+ return Inertia::render('users/show', [
+ 'user' => $user,
+ ]);
}
viewはAIの作ってきたものをそのまま貼りつけますよ
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 これを参考にコードを改変する
@@ -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>
こんな感じになる
削除処理
/**
* 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.'));
}
}
編集処理
もうあとは惰性。フォームはコピペしてあるので最終的にまとめた方がよいかと思いますが
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>
編集フォーム
@@ -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
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ってことで
実際の編集
@@ -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にまとめられるといいけどなあ...
Discussion