laravelの認証機構を見てみよう(10) : userのcrudを仕上げる(2) - ユーザーの編集

いやー、本当は編集からやりたかったんだけどな〜みたいな


ユーザー一覧画面の見直し


うーん、どうなんすかねえ。一覧から編集したい場合「詳細」ボタンじゃなくてここはドロップダウンメニューとかになってるといいんじゃないか的な。まあ詳細なんてのはメールなりID列なりにリンクしとけばいいんじゃないのっていう話もあり。まあここではキーであるメールにリンクを貼り変えるか。

resources/js/Pages/Users/Index.jsx

<td className="px-6 py-4 whitespace-nowrap flex items-center">
    <Link
    href={route('users.show', user.id)}
    className="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 flex items-center"
>
        {user.email_verified_at !== null ? (
            <VscVerifiedFilled className="text-green-500 mr-2 text-xl flex-shrink-0" />
        ) : (
            <VscUnverified className="text-red-500 mr-2 text-lg flex-shrink-0" />
        )}
            <span>{user.email}</span>
        </Link>
</td>


リンクが追加されたが、テーブルが今外のグレー一杯一杯なので、ちょっと開けとくかと。

<table className="min-w-full divide-y divide-gray-200 m-3">
隙間が空いた

こういうのは気になるっちゃ気になるね

ドロップダウンメニュー

                                        <td className="px-6 py-4 whitespace-nowrap">
                                            <Link href={route('users.show', user.id)} className="text-blue-600 hover:text-blue-900">
                                                <button className="bg-blue-500 hover:bg-blue-700 text-white py-1 px-2 rounded flex items-center">
                                                    <VscInfo className="mr-2"/>{t("Details")}
                                                </button>
                                            </Link>
                                        </td>

この部分であるがドロップダウンに変更する。これはbreezeがDropdownというコンポーネントを供えているのでそれを使ったらいいだろう

import Dropdown from '@/Components/Dropdown';
<Dropdown>
    <Dropdown.Trigger>
        <button>
            <svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
                <path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z" />
            </svg>
        </button>
    </Dropdown.Trigger>
    <Dropdown.Content>
        <button className="block w-full px-4 py-2 text-left text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:bg-gray-100 transition duration-150 ease-in-out" >
            Edit
        </button>
    </Dropdown.Content>
</Dropdown>

これはlaravel bootcampからパクってきたソースである。余談ではあるが、ソースをパクってくるのも技術のうち。というか広くソースコードを見聞しておかないとそもそもすぐパクれないよねって話

ドロップダウンメニューができた

これをちょいちょい手直ししていこう。今VSCのアイコンを使ってるから統一感があった方がいいかな?

import {
    VscVerifiedFilled,
    VscUnverified,
    VscInfo,
    VscPersonAdd,
    VscEllipsis // これ
} from "react-icons/vsc";

長くなってきたし、改行したよ。

でまあもろもろ改造

<Dropdown>
    <Dropdown.Trigger>
        <button>
            <VscEllipsis />
        </button>
    </Dropdown.Trigger>
    <Dropdown.Content>
        <Link href={route('users.create')} className="block w-full px-4 py-2 text-left text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:bg-gray-100 transition duration-150 ease-in-out">
            {t("Edit")}
        </Link>
    </Dropdown.Content>
</Dropdown>

ja.jsonにもエントリを

    "Edit": "編集",

追加すれば普通に動作するだろう

アイコンは微妙かもw

ただ、これはご覧の通りというかusers.editへは向いていない。createにいってるので、これを直す

<Link href={route('users.edit', user.id)} className="block w-full px-4 py-2 text-left text-sm leading-5 text-gray-700 hover:bg-gray-100 focus:bg-gray-100 transition duration-150 ease-in-out">
    public function edit(User $user)
    {
        dd($user);
    }

じゃあこのEditを作っていきますか(イソイソ)

の前にちょい手直し

めりこんどるやないかーい

テーブルの一番下がこんな感じでうまってるのでベタな方法としてtableのボトムをもうちょい取っときました。

<table className="min-w-full divide-y divide-gray-200 m-3 mb-16">

ドロップダウンがめりこまないように考える方針もあるんだろうけど、面倒なのでボトムを沢山取ったなお最大値はmb-32である

編集機能

これはとりあえずコピペの応酬で行うことにしよう。

    public function edit(User $user): Response
    {
        return Inertia::render('Users/Edit', ['user' => $user]);
    }

このようにして、Create.jsxをガバっとEdit.jsxにコピーする

当然これを手直してゆく

Edit.jsxの手直し

まず、storeではなくupdateにしよう。ここからはじめる

    const submit = (e) => {
        e.preventDefault();

        patch(route('users.update', user.id));
    };

そうすると

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

これもpostではなくpatchの呼び出しが必要になるであろう。例によってddしておく

    public function update(Request $request, User $user)
    {
        dd($request->all());
    }

でまあ編集っぽい文言に変更していく

        <AuthenticatedLayout
            user={auth.user}
            header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">{t("Update User")}</h2>}
        >

ここではUpdate Userにした翻訳のjsonも変更してある

    "Update User": "ユーザー情報の更新",

ボタンも

                            <PrimaryButton className="ml-4" disabled={processing}>
                                {t("Update User")}
                            </PrimaryButton>

そして、breadcrumbも
routes/breadcrumbs.php

Breadcrumbs::for('users.edit', function (BreadcrumbTrail $trail, User $user) {
    $trail->parent('users.show', $user);
    $trail->push(__('Update User'), route('users.edit', $user));
});



この辺は一々作るのが面倒かもしれない。そのうちUpdateとかSaveとか適当なワードでまるめたくもなるかもだけどまあ一応ね。

で、現在のデーターを挿入する

    const { data, setData, patch, processing, errors, reset } = useForm({
        name: user.name,
        email: user.email,
        password: '',
        password_confirmation: '',
    });

基本的にはこれだけである、が、まあちょいとまだ荒すぎって感じもありますな。

実際のupdate

    public function update(UserRequest $request, User $user): RedirectResponse
    {
        $data = $request->validated();
        $data['email_verified_at'] = now();
        $data['password'] = bcrypt($data['password']);

        User::update($data);

        return redirect(route('users.show'))
            ->with('success', __('User Updated'))
        ;
    }

これでokだぜー!なわけないやろ

このようにvalidationが通らなくなる。

validationの変更

これはpatchかputのときに条件を変更してやればいい、ってことはreturnしてるのはマズいってことね

    public function rules(): array
    {
        $rules = [
            'name'  => 'required|string|max:255',
            'email' => [
                'required',
                'string',
                'email',
                'max:255'
            ],
        ];
        if ($this->isMethod('patch') || $this->isMethod('put')) {
            // If update
        } else {
            // If store
            $rules['email'][] = 'unique:users';
            $rules['password'] = ['required', 'confirmed', Rules\Password::defaults()];
        }

        return $rules;
    }

これはstoreのときはいいんだけどupdateをかなり修正しないといけない。まずパスワードの空を許可する必要がある。ってことはdefault nullableにしといた方がよさそうだね

    public function rules(): array
    {
        $rules = [
            'name'  => 'required|string|max:255',
            'email' => [
                'required',
                'string',
                'email',
                'max:255'
            ],
            'password' => [
                'nullable',
                'confirmed',
                Rules\Password::defaults()
            ],
        ];
        if ($this->isMethod('patch') || $this->isMethod('put')) {
            // If update
        } else {
            // If store
            $rules['email'][] = 'unique:users';
            $rules['password'][] = 'required';
        }

        return $rules;
    }

これで空のときは空だし、そうでないときはチェックが走るだろう。この際backend

    public function update(UserRequest $request, User $user): RedirectResponse
    {
        $data = $request->validated();
        $data['email_verified_at'] = now();
        $data['password'] = bcrypt($data['password']);

        User::update($data);

        return redirect(route('users.show'))
            ->with('success', __('User Updated'))
        ;
    }

このbcryptはいかんでしょ。nullもbcryptしたら変なhashが入るから

    public function update(UserRequest $request, User $user): RedirectResponse
    {
        $data = $request->validated();
        if (array_key_exists('password', $data) && $data['password']) { // パスワードがあるときだけ
            $data['password'] = bcrypt($data['password']);
        } else {
            unset($data['password']);
        }

        $user->update($data);

        return redirect(route('users.show', $user))
            ->with('success', __('User Updated'))
        ;

    }

あとemail_verified_atもコピペすぎて残っていたが、これも除外したのと、User::update()が意味不明すぎたので修正したw。あとredirectもひっそり修正。

ユニークチェック

こんなのがあったとして、これは通していいんだけど

とか存在するユーザーのメアドをブチこむと

となるので、これも修正するというか、まあこれは何っていうかなー、あるあるですね、あるある。

        if ($this->isMethod('patch') || $this->isMethod('put')) {
            // If update
            $userId = $this->route('user')->id;
            // あるいはtype hintより
            // $userId = $this->user->id;
            $rules['email'][] = Rule::unique('users')->ignore($userId);
        } else {
            // If store
            $rules['email'][] = 'unique:users';
            $rules['password'][] = 'required';
        }

つわけで

Rule::unique('users')->ignore($userId)

を足しといてくださいな

テスト

とりあえず簡単に

% ./vendor/bin/sail artisan make:test UserRequestTest


   INFO  Test [tests/Feature/UserRequestTest.php] created successfully.
<?php

namespace Tests\Feature;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;

use App\Models\User;
class UserRequestTest extends TestCase
{
    use RefreshDatabase;
    protected function setUp(): void
    {
        parent::setUp();
        $this->artisan('db:seed');
    }


    public function test_store_empty_value_not_accepted(): void
    {
        // Adminユーザーを作成
        $admin = User::factory()->create();
        $admin->assignRole('admin');
        $this->actingAs($admin);

        $response = $this->post(route('users.index'), []);

        // バリデーションエラーがあるため、302ステータスコードが返されることを確認
        $response->assertStatus(302);

        // セッションにエラーが存在することを確認
        $response->assertSessionHasErrors(['name', 'email', 'password']);
    }

}

テストケースを考えるのもしんどいんでrulesに基いてchat gptに作らせたらいいと思いますよ。ものは使いようですわね。

ちなみに、Inertia自体のテストは

use Inertia\Testing\AssertableInertia as Assert;
        $createdUser = User::where('email', 'john.doe@example.com')->first();
        $this->assertNotNull($createdUser);

        $response = $this->get(route('users.index'));
        $response->assertInertia(fn (Assert $page) => $page
             ->component('Users/Index')
             ->has('users')
        );

みたいに書けて、そのhasの先も書けるんだけど、コレクションになってる場合は茨の路なのでやめといた方がいいかも。showとかなら多分もうちょいうまくいくんだけどなー

Updateのテストも書いといてくださいねw

partilized (分割)

ここでCreate.jsxとEdit.jsxは同じようなコピペで派生しているので共通部分を切り出したいとなるわけだ。

いきなり全部分割するのは慣れてないとアレなのでとりあえず

                            <div>
                                <InputLabel htmlFor="name" value={t("Name")} />
                                <TextInput
                                    id="name"
                                    name="name"
                                    value={data.name}
                                    className="mt-1 block w-full"
                                    onChange={(e) => setData('name', e.target.value)}
                                />

                                <InputError message={errors.name} className="mt-2" />
                            </div>

この部分を別の部分に移してみよう

Partials/UserForm.jsx

% mkdir resources/js/Pages/Users/Partials
import InputLabel from '@/Components/InputLabel';
import TextInput from '@/Components/TextInput';
import InputError from '@/Components/InputError';
import { useLaravelReactI18n } from 'laravel-react-i18n';

export default function UserForm({ data, setData, onSubmit, buttonLabel, errors, processing }) {
    const { t } = useLaravelReactI18n();

    return (
        <div>
            <InputLabel htmlFor="name" value={t("Name")} />
            <TextInput
                id="name"
                name="name"
                value={data.name}
                className="mt-1 block w-full"
                onChange={(e) => setData('name', e.target.value)}
            />
            <InputError message={errors.name} className="mt-2" />
        </div>
    );
}

ここに移動していく

                            <UserForm
                                data={data}
                                setData={setData}
                                onSubmit={submit}
                                buttonLabel={t("Create New User")}
                                errors={errors}
                                processing={processing}
                            />

これでcreateとedit両方とも確認できたら、順々に動かしていくこと。

    return (
        <div>
            <InputLabel htmlFor="name" value={t("Name")} />
            <TextInput
                id="name"
                name="name"
                value={data.name}
                className="mt-1 block w-full"
                onChange={(e) => setData('name', e.target.value)}
            />
            <InputError message={errors.name} className="mt-2" />
        </div>

        <div className="mt-4">
            <InputLabel htmlFor="email" value={t("Email")} />

            <TextInput
                id="email"
                type="text"
                name="email"
                value={data.email}
                className="mt-1 block w-full"
                onChange={(e) => setData('email', e.target.value)}
            />

            <InputError message={errors.email} className="mt-2" />
        </div>
    );

こうするとさっそくエラーになるだろう。これはjsxの特性でちゃんとしたdomで返さないとエラーになるので、このような空タグ<> </> を入れといてやる

    return (
        <>
        <div>
            <InputLabel htmlFor="name" value={t("Name")} />
            <TextInput
                id="name"
                name="name"
                value={data.name}
                className="mt-1 block w-full"
                onChange={(e) => setData('name', e.target.value)}
            />
            <InputError message={errors.name} className="mt-2" />
        </div>

        <div className="mt-4">
            <InputLabel htmlFor="email" value={t("Email")} />

            <TextInput
                id="email"
                type="text"
                name="email"
                value={data.email}
                className="mt-1 block w-full"
                onChange={(e) => setData('email', e.target.value)}
            />

            <InputError message={errors.email} className="mt-2" />
        </div>
        </>
    );

ただまあ最終的にformも共通なのでformごとおさめちゃっていいと思う
最終的なコード
resources/js/Pages/Users/Partials/UserForm.jsx

import InputLabel from '@/Components/InputLabel';
import TextInput from '@/Components/TextInput';
import InputError from '@/Components/InputError';
import PrimaryButton from '@/Components/PrimaryButton';
import { useLaravelReactI18n } from 'laravel-react-i18n';

export default function UserForm({ data, setData, onSubmit, buttonLabel, errors, processing }) {
    const { t } = useLaravelReactI18n();

    return (
        <form onSubmit={onSubmit} className="space-y-4">
            <div>
                <InputLabel htmlFor="name" value={t("Name")} />
                <TextInput
                    id="name"
                    name="name"
                    value={data.name}
                    className="mt-1 block w-full"
                    onChange={(e) => setData('name', e.target.value)}
                />
                <InputError message={errors.name} className="mt-2" />
            </div>

            <div className="mt-4">
                <InputLabel htmlFor="email" value={t("Email")} />

                <TextInput
                    id="email"
                    type="text"
                    name="email"
                    value={data.email}
                    className="mt-1 block w-full"
                    onChange={(e) => setData('email', e.target.value)}
                />

                <InputError message={errors.email} className="mt-2" />
            </div>
            <div className="mt-4">
                <InputLabel htmlFor="password" value={t("Password")} />

                <TextInput
                    id="password"
                    type="password"
                    name="password"
                    value={data.password}
                    className="mt-1 block w-full"
                    autoComplete="new-password"
                    onChange={(e) => setData('password', e.target.value)}
                />

                <InputError message={errors.password} className="mt-2" />
            </div>
            <div className="mt-4">
                <InputLabel htmlFor="password_confirmation" value={t("Confirm Password")} />

                <TextInput
                    id="password_confirmation"
                    type="password"
                    name="password_confirmation"
                    value={data.password_confirmation}
                    className="mt-1 block w-full"
                    autoComplete="new-password"
                    onChange={(e) => setData('password_confirmation', e.target.value)}
                />

                <InputError message={errors.password_confirmation} className="mt-2" />
            </div>
            <PrimaryButton className="ml-4" disabled={processing}>
                {buttonLabel}
            </PrimaryButton>
        </form>
    );
}

随分動いたんで resources/js/Pages/Users/Create.jsx とかもスッキリしている

    return (
        <AuthenticatedLayout
            user={auth.user}
            header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">{t("Create New User")}</h2>}
        >
            <Head title="Users" />

            <div className="py-12">
                <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
                        <UserForm
                            data={data}
                            setData={setData}
                            onSubmit={submit}
                            buttonLabel={t("Create New User")}
                            errors={errors}
                            processing={processing}
                        />
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );

formの描画はpartialでやらせているので余計なimportも消しといたらいいだろう

resources/js/Pages/Users/Create.jsx

import { useEffect } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';

import { Head, Link, useForm } from '@inertiajs/react';
import { useLaravelReactI18n } from 'laravel-react-i18n';
import UserForm from './Partials/UserForm';

export default function UserCreate({ auth }) {
    const { t } = useLaravelReactI18n();
    const { data, setData, post, processing, errors, reset } = useForm({
        name: '',
        email: '',
        password: '',
        password_confirmation: '',
    });

    useEffect(() => {
        return () => {
            reset('password', 'password_confirmation');
        };
    }, []);

    const submit = (e) => {
        e.preventDefault();

        post(route('users.store'));
    };

    return (
        <AuthenticatedLayout
            user={auth.user}
            header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">{t("Create New User")}</h2>}
        >
            <Head title="Users" />

            <div className="py-12">
                <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
                        <UserForm
                            data={data}
                            setData={setData}
                            onSubmit={submit}
                            buttonLabel={t("Create New User")}
                            errors={errors}
                            processing={processing}
                        />
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}
% cat resources/js/Pages/Users/Edit.jsx
import { useEffect } from 'react';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout';

import { Head, Link, useForm } from '@inertiajs/react';
import { useLaravelReactI18n } from 'laravel-react-i18n';
import UserForm from './Partials/UserForm';

export default function UserEdit({ auth, user }) {
    const { t } = useLaravelReactI18n();
    const { data, setData, patch, processing, errors, reset } = useForm({
        name: user.name,
        email: user.email,
        password: '',
        password_confirmation: '',
    });

    useEffect(() => {
        return () => {
            reset('password', 'password_confirmation');
        };
    }, []);

    const submit = (e) => {
        e.preventDefault();

        patch(route('users.update', user.id));
    };

    return (
        <AuthenticatedLayout
            user={auth.user}
            header={<h2 className="font-semibold text-xl text-gray-800 leading-tight">{t("Update User")}</h2>}
        >
            <Head title="Users" />

            <div className="py-12">
                <div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
                    <div className="bg-white overflow-hidden shadow-sm sm:rounded-lg p-6">
                        <UserForm
                            data={data}
                            setData={setData}
                            onSubmit={submit}
                            buttonLabel={t("Update User")}
                            errors={errors}
                            processing={processing}
                        />
                    </div>
                </div>
            </div>
        </AuthenticatedLayout>
    );
}

微調整


backendで落としているとはいえ、このように空が送信できたりemailにemail以外が送信できたりと忙しい。これを制御する。のと、フィールドサイズもちょっと調整しよう。

                <TextInput
                    id="name"
                    name="name"
                    value={data.name}
                    className="mt-1 block w-full"
                    onChange={(e) => setData('name', e.target.value)}
                    required
                />
                <InputError message={errors.name} className="mt-2" />
            </div>

            <div className="mt-4">
                <InputLabel htmlFor="email" value={t("Email")} />

                <TextInput
                    id="email"
                    type="email"
                    name="email"
                    value={data.email}
                    className="mt-1 block w-full"
                    onChange={(e) => setData('email', e.target.value)}
                    required
                />

requiredをつけたりemailにはtype emailを与えたりと、htmlの属性でできる事は全部やる。

この場合passwordは新規と更新で処理が違っていて新規はrequiredでいいが更新はrequiredではない。これも分岐しておこう。

                        <UserForm
                            data={data}
                            setData={setData}
                            onSubmit={submit}
                            buttonLabel={t("Update User")}
                            errors={errors}
                            processing={processing}
                            isPasswordRequired={false}
                        />

みたいにisPasswordRequiredを生やして

export default function UserForm({ data, setData, onSubmit, buttonLabel, errors, processing, isPasswordRequired }) {
                <TextInput
                    id="password"
                    type="password"
                    name="password"
                    value={data.password}
                    className="mt-1 block w-full"
                    autoComplete="new-password"
                    onChange={(e) => setData('password', e.target.value)}
                    required={isPasswordRequired}
                />

                <InputError message={errors.password} className="mt-2" />
            </div>
            <div className="mt-4">
                <InputLabel htmlFor="password_confirmation" value={t("Confirm Password")} />

                <TextInput
                    id="password_confirmation"
                    type="password"
                    name="password_confirmation"
                    value={data.password_confirmation}
                    className="mt-1 block w-full"
                    autoComplete="new-password"
                    onChange={(e) => setData('password_confirmation', e.target.value)}
                    required={isPasswordRequired}
                />

みたいな感じやね。

フォームサイズの調整


横に長すぎる?

どうですかねえ、、まあ半分くらいにしときますか?

            <div>
                <InputLabel htmlFor="name" value={t("Name")} />
                <TextInput
                    id="name"
                    name="name"
                    value={data.name}
                    className="mt-1 block w-1/2"
                    onChange={(e) => setData('name', e.target.value)}
                    required
                />
                <InputError message={errors.name} className="mt-2" />
            </div>


なるほどね。

これは小さい画面でも当然半分になるんだけど、これはやだなーと思うときは

                <TextInput
                    id="name"
                    name="name"
                    value={data.name}
                    className="mt-1 block md:w-1/2 w-full"
                    onChange={(e) => setData('name', e.target.value)}
                    required
                />

のようにすればよい。ちなみにsmall deviceでのチェックは全体的にぼんやりしているので最終的に置いこんでやる。いろいろ不備しかないですね。

参考までにパスワードは

                <TextInput
                    id="password"
                    type="password"
                    name="password"
                    value={data.password}
                    className="mt-1 block sm:w-1/4 w-full"
                    autoComplete="new-password"
                    onChange={(e) => setData('password', e.target.value)}
                    required={isPasswordRequired}
                />

にしといたよ。

flashメッセージのトースト

今リクエスト送信後に

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


とか

        return redirect(route('users.show', $user))
            ->with('success', __('User Updated'))
        ;

とかやってんだけど何もしてないから捨てられてしまっているね。これを何とかするってこれはここでやってましたナ

まあこの手の使いそうなのはどんどん組み入れていく。

ロールの割り当て

は、作ると面倒くさいんで、とりあえずDBで操作しといてください。DBじゃないな、tinkerでやってね。余裕があればmodifyします。

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

とか

        return redirect(route('users.show', $user))
            ->with('success', __('User Updated'))
        ;

とかしているが、これは

次回は

userの削除とか

まあ、あるいはavatarとか

あるいは…

いいなと思ったら応援しよう!