diff --git a/app/Filament/Resources/TextWidgets/Pages/CreateTextWidget.php b/app/Filament/Resources/TextWidgets/Pages/CreateTextWidget.php new file mode 100644 index 0000000..24451ee --- /dev/null +++ b/app/Filament/Resources/TextWidgets/Pages/CreateTextWidget.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('title') + ->required() + ->live(onBlur: true), + TextInput::make('description') + ->nullable(), + Textarea::make('content') + ->rows(5) + ->columnSpanFull(), + + ]); + } +} diff --git a/app/Filament/Resources/TextWidgets/Tables/TextWidgetsTable.php b/app/Filament/Resources/TextWidgets/Tables/TextWidgetsTable.php new file mode 100644 index 0000000..cc9b034 --- /dev/null +++ b/app/Filament/Resources/TextWidgets/Tables/TextWidgetsTable.php @@ -0,0 +1,42 @@ +columns([ + TextColumn::make('title') + ->label('Title') + ->sortable() + ->searchable(), + TextColumn::make('created_at') + ->label('Created') + ->dateTime('M d, Y H:i') + ->sortable(), + TextColumn::make('updated_at') + ->label('Updated') + ->dateTime('M d, Y H:i') + ->sortable(), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/TextWidgets/TextWidgetResource.php b/app/Filament/Resources/TextWidgets/TextWidgetResource.php new file mode 100644 index 0000000..96092e5 --- /dev/null +++ b/app/Filament/Resources/TextWidgets/TextWidgetResource.php @@ -0,0 +1,50 @@ + ListTextWidgets::route('/'), + 'create' => CreateTextWidget::route('/create'), + 'edit' => EditTextWidget::route('/{record}/edit'), + ]; + } +} diff --git a/app/Http/Controllers/TextWidgetController.php b/app/Http/Controllers/TextWidgetController.php new file mode 100644 index 0000000..7318464 --- /dev/null +++ b/app/Http/Controllers/TextWidgetController.php @@ -0,0 +1,72 @@ +email !== config('app.admin_email')) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'description' => 'nullable|string', + 'content' => 'required|string', + ]); + + return TextWidget::create($validated); + } + + /** + * Display the specified resource. + */ + public function show(TextWidget $textWidget) + { + return $textWidget; + } + + /** + * Update the specified resource in storage. + */ + public function update(Request $request, TextWidget $textWidget) + { + $validated = $request->validate([ + 'title' => 'sometimes|required|string|max:255', + 'description' => 'sometimes|nullable|string', + 'content' => 'sometimes|required|string', + ]); + + $textWidget->update($validated); + + return $textWidget; + } + + /** + * Remove the specified resource from storage. + */ + public function destroy(TextWidget $textWidget) + { + $textWidget->delete(); + + return response()->noContent(); + } +} diff --git a/app/Models/TextWidget.php b/app/Models/TextWidget.php new file mode 100644 index 0000000..a7e6991 --- /dev/null +++ b/app/Models/TextWidget.php @@ -0,0 +1,17 @@ + + */ +class TextWidgetFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'title' => $this->faker->sentence(), + 'description' => $this->faker->paragraph(), + 'content' => $this->faker->text(500), + ]; + } +} diff --git a/database/migrations/2026_01_08_140636_create_text_widgets_table.php b/database/migrations/2026_01_08_140636_create_text_widgets_table.php new file mode 100644 index 0000000..b15d365 --- /dev/null +++ b/database/migrations/2026_01_08_140636_create_text_widgets_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('title'); + $table->text('description')->nullable(); + $table->mediumText('content')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('text_widgets'); + } +}; diff --git a/routes/api.php b/routes/api.php index fce0a30..07117ff 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,7 +1,7 @@ group(function () { @@ -10,4 +10,10 @@ Route::middleware('auth:sanctum')->group(function () { Route::get('/entries/{entry}', [EntryController::class, 'show']); Route::put('/entries/{entry}', [EntryController::class, 'update']); Route::delete('/entries/{entry}', [EntryController::class, 'destroy']); + + Route::get('/text-widgets', [TextWidgetController::class, 'index']); + Route::post('/text-widgets', [TextWidgetController::class, 'store']); + Route::get('/text-widgets/{textWidget}', [TextWidgetController::class, 'show']); + Route::put('/text-widgets/{textWidget}', [TextWidgetController::class, 'update']); + Route::delete('/text-widgets/{textWidget}', [TextWidgetController::class, 'destroy']); }); diff --git a/tests/Unit/TextWidgetApiTest.php b/tests/Unit/TextWidgetApiTest.php new file mode 100644 index 0000000..c7fc422 --- /dev/null +++ b/tests/Unit/TextWidgetApiTest.php @@ -0,0 +1,92 @@ +create(); + TextWidget::factory()->count(3)->create(); + + $response = $this->actingAs($user)->getJson('/api/text-widgets'); + + $response->assertOk() + ->assertJsonCount(3); +}); + +test('can create a text widget', function () { + $admin = User::factory()->create(['email' => config('app.admin_email')]); + + $this->actingAs($admin); + + $data = [ + 'title' => 'Sample Title', + 'content' => 'Sample Content', + ]; + + $response = $this->postJson('/api/text-widgets', $data); + + $response->assertCreated() + ->assertJsonFragment($data); +}); + +test('can show a text widget', function () { + $user = User::factory()->create(); + $textWidget = TextWidget::factory()->create(); + + $response = $this->actingAs($user)->getJson("/api/text-widgets/{$textWidget->id}"); + + $response->assertOk() + ->assertJsonFragment(['id' => $textWidget->id]); +}); + +test('can update a text widget', function () { + $user = User::factory()->create(); + $textWidget = TextWidget::factory()->create(); + $data = ['title' => 'Updated Title']; + + $response = $this->actingAs($user)->putJson("/api/text-widgets/{$textWidget->id}", $data); + + $response->assertOk() + ->assertJsonFragment($data); +}); + +test('can delete a text widget', function () { + $user = User::factory()->create(); + $textWidget = TextWidget::factory()->create(); + + $response = $this->actingAs($user)->deleteJson("/api/text-widgets/{$textWidget->id}"); + + $response->assertNoContent(); + + $this->assertDatabaseMissing('text_widgets', ['id' => $textWidget->id]); +}); + +test('only admin can create text widgets', function () { + $adminEmail = Config::get('app.admin_email'); + $user = User::factory()->create(['email' => $adminEmail]); + + $this->actingAs($user) + ->postJson('/api/text-widgets', ['title' => 'Test', 'content' => 'Test content']) + ->assertCreated(); + + $nonAdmin = User::factory()->create(['email' => 'nonadmin@example.com']); + + $this->actingAs($nonAdmin) + ->postJson('/api/text-widgets', ['title' => 'Test', 'content' => 'Test content']) + ->assertForbidden(); +}); + +test('authenticated users can read text widgets', function () { + $user = User::factory()->create(); + + $this->actingAs($user) + ->getJson('/api/text-widgets') + ->assertOk(); + + $this->getJson('/api/text-widgets') + ->assertOk(); +}); diff --git a/tests/Unit/TextWidgetModelTest.php b/tests/Unit/TextWidgetModelTest.php new file mode 100644 index 0000000..5dc13f6 --- /dev/null +++ b/tests/Unit/TextWidgetModelTest.php @@ -0,0 +1,15 @@ +getFillable())->toEqual($expected); +}); diff --git a/tests/Unit/TextWidgetResourceTest.php b/tests/Unit/TextWidgetResourceTest.php new file mode 100644 index 0000000..7ad5461 --- /dev/null +++ b/tests/Unit/TextWidgetResourceTest.php @@ -0,0 +1,47 @@ +getDefaultProperties(); + + expect($defaults['model'] ?? null)->toBe(TextWidget::class); + expect($defaults['recordTitleAttribute'] ?? null)->toBe('title'); +}); + +it('defines the expected pages', function () { + $pages = EntryResource::getPages(); + + expect(array_keys($pages))->toEqual(['index', 'create', 'view', 'edit']); +}); + +it('accepts new record when entered via the form schema', function () { + + $data = [ + 'title' => 'Test Entry', + 'description' => 'This is a test entry.', + 'content' => '

This is the content of the test entry.

', + ]; + + $entry = new TextWidget; + $entry->fill($data); + + expect($entry->title)->toBe('Test Entry'); +}); + +it('deletes a record correctly', function () { + $entry = TextWidget::create([ + 'title' => 'Test Entry to Delete', + 'description' => 'This is a test entry.', + 'content' => '

This is the content of the test entry.

', + ]); + + $entryId = $entry->id; + $entry->delete(); + + $deletedEntry = TextWidget::find($entryId); + expect($deletedEntry)->toBeNull(); +});