We will start our quiz admin panel by creating questions CRUD, and later we will assign those questions to the quizzes.
Let's start with a Model with Migration.
php artisan make:model Question -m
database/migrations/xxxx_create_questions_table.php:
return new class extends Migration { public function up(): void { Schema::create('questions', function (Blueprint $table) { $table->id(); $table->text('question_text'); $table->text('code_snippet')->nullable(); $table->text('answer_explanation')->nullable(); $table->string('more_info_link')->nullable(); $table->timestamps(); $table->softDeletes(); }); }};
app/Models/Question.php:
class Question extends Model{ use SoftDeletes; protected $fillable = [ 'question_text', 'code_snippet', 'answer_explanation', 'more_info_link', ];}
Next, the Livewire component for showing questions.
php artisan make:livewire Questions/QuestionList
Remember when we created a Middleware earlier? So now we can use it in the routes.
routes/web.php:
use App\Http\Livewire\Questions\QuestionList; // ...Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::middleware('isAdmin')->group(function () { Route::get('questions', QuestionList::class)->name('questions'); }); });// ...
Of course, we need the navigation link.
resources/views/layouts/navigation.blade.php:
// ...<!-- Settings Dropdown --><div class="hidden sm:flex sm:items-center sm:ml-6"> @admin <x-dropdown align="right" width="48"> <x-slot name="trigger"> <button class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none transition ease-in-out duration-150"> <div>Admin</div> <div class="ml-1"> <svg class="fill-current h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"> <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /> </svg> </div> </button> </x-slot> <x-slot name="content"> <x-dropdown-link :href="route('questions')"> Questions </x-dropdown-link> </x-slot> </x-dropdown> @endadmin <x-dropdown align="right" width="48"> <x-slot name="trigger">// ...
Now for the component itself and the Blade file.
app/Livewire/Questions/QuestionList.php:
use App\Models\Question;use Illuminate\Contracts\View\View; class QuestionList extends Component{ public function render(): View { $questions = Question::latest()->paginate(); return view('livewire.questions.question-list', compact('questions')); } public function delete(Question $question): void { abort_if(! auth()->user()->is_admin, Response::HTTP_FORBIDDEN, '403 Forbidden'); $question->delete(); }}
resources/views/livewire/questions/question-list.blade.php:
<div> <x-slot name="header"> <h2 class="text-xl font-semibold leading-tight text-gray-800"> Questions </h2> </x-slot> <div class="py-12"> <div class="mx-auto max-w-7xl sm:px-6 lg:px-8"> <div class="overflow-hidden bg-white shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900"> <div class="mb-4"> <a href="{{ route('questions.create') }}" class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white hover:bg-gray-700"> Create Question </a> </div> <div class="mb-4 min-w-full overflow-hidden overflow-x-auto align-middle sm:rounded-md"> <table class="min-w-full border divide-y divide-gray-200"> <thead> <tr> <th class="w-16 bg-gray-50 px-6 py-3 text-left"> </th> <th class="bg-gray-50 px-6 py-3 text-left"> <span class="text-xs font-medium uppercase leading-4 tracking-wider text-gray-500">Question text</span> </th> <th class="w-40 bg-gray-50 px-6 py-3 text-left"> </th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200 divide-solid"> @forelse($questions as $question) <tr class="bg-white"> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $question->id }} </td> <td class="px-6 py-4 text-sm leading-5 text-gray-900 whitespace-no-wrap"> {{ $question->question_text }} </td> <td> <a href="{{ route('questions.edit', $question->id) }}" class="inline-flex items-center rounded-md border border-transparent bg-gray-800 px-4 py-2 text-xs font-semibold uppercase tracking-widest text-white hover:bg-gray-700"> Edit </a> <button wire:click="delete({{ $question }})" class="rounded-md border border-transparent bg-red-200 px-4 py-2 text-xs uppercase text-red-500 hover:bg-red-300 hover:text-red-700"> Delete </button> </td> </tr> @empty <tr> <td colspan="3" class="px-6 py-4 text-center leading-5 text-gray-900 whitespace-no-wrap"> No questions were found. </td> </tr> @endforelse </tbody> </table> </div> {{ $questions->links() }} </div> </div> </div> </div></div>
Here we do nothing special, just getting all the questions and showing them on the table. Also, we added the delete()
method to which we pass the Question
model, and in it, we just delete the question. The empty questions list should be like below:
Next, the form for creating and editing the question. First, the component and adding into a route.
php artisan make:livewire Questions/QuestionForm
routes/web.php:
use App\Http\Livewire\Questions\QuestionForm;use App\Http\Livewire\Questions\QuestionList; // ...Route::middleware('auth')->group(function () { Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy'); Route::middleware('isAdmin')->group(function () { Route::get('questions', QuestionIndex::class)->name('questions'); Route::get('questions/create', QuestionForm::class)->name('questions.create'); Route::get('questions/{question}', QuestionForm::class)->name('questions.edit'); });});// ...
The component itself:
app/Livewire/Questions/QuestionForm.php:
use App\Models\Question;use Illuminate\Http\RedirectResponse;use Livewire\Features\SupportRedirects\Redirector; class QuestionForm extends Component{ public ?Question $question = null; public string $question_text = ''; public string|null $code_snippet = ''; public string|null $answer_explanation = ''; public string|null $more_info_link = ''; public bool $editing = false; public function mount(Question $question): void { if ($question->exists) { $this->question = $question; $this->editing = true; $this->question_text = $question->question_text; $this->code_snippet = $question->code_snippet; $this->answer_explanation = $question->answer_explanation; $this->more_info_link = $question->more_info_link; } } public function save(): Redirector|RedirectResponse { $this->validate(); if (empty($this->question)) { $this->question = Question::create($this->only(['question_text', 'code_snippet', 'answer_explanation', 'more_info_link'])); } else { $this->question->update($this->only(['question_text', 'code_snippet', 'answer_explanation', 'more_info_link'])); } return to_route('questions'); } public function render(): View { return view('livewire.questions.question-form'); } protected function rules(): array { return [ 'question_text' => [ 'string', 'required', ], 'code_snippet' => [ 'string', 'nullable', ], 'answer_explanation' => [ 'string', 'nullable', ], 'more_info_link' => [ 'url', 'nullable', ], ]; }}
Here we also aren't doing anything special. The only thing, we check in the mount()
if the Model exists in the DB, and if it does, we just set $editing
to true
. This way in the form we show if it's for creating or editing.
Throughout this course, we will use the textarea
field. For this, we will create a blade component to easily reuse it.
php artisan make:component Textarea --view
resources/view/components/textarea.blade.php:
<textarea {!! $attributes->merge(['class' => 'border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm']) !!}>{{ $slot }}</textarea>
And the form for the questions:
resources/views/livewire/questions/form.blade.php:
<div> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ $editing ? 'Edit Question' : 'Create Question' }} </h2> </x-slot> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900"> <form wire:submit="save"> <div> <x-input-label for="question_text" value="Question text" /> <x-textarea wire:model="question_text" id="question_text" class="block mt-1 w-full" type="text" name="question_text" required /> <x-input-error :messages="$errors->get('question_text')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="code_snippet" value="Code snippet" /> <x-textarea wire:model="code_snippet" id="code_snippet" class="block mt-1 w-full" type="text" name="code_snippet" /> <x-input-error :messages="$errors->get('code_snippet')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="answer_explanation" value="Answer explanation" /> <x-textarea wire:model="answer_explanation" id="answer_explanation" class="block mt-1 w-full" type="text" name="answer_explanation" /> <x-input-error :messages="$errors->get('answer_explanation')" class="mt-2" /> </div> <div class="mt-4"> <x-input-label for="more_info_link" value="More info link" /> <x-text-input wire:model="more_info_link" id="more_info_link" class="block mt-1 w-full" type="text" name="more_info_link" /> <x-input-error :messages="$errors->get('more_info_link')" class="mt-2" /> </div> <div class="mt-4"> <x-primary-button> Save </x-primary-button> </div> </form> </div> </div> </div> </div></div>