Merge pull request 'feat: add TextWidget' (#9) from feat/add-text-widgets into dev

Reviewed-on: https://codeberg.org/headshed/share-lt/pulls/9
This commit is contained in:
Jon Brookes 2026-01-08 17:52:58 +01:00
commit c83028b4d4
18 changed files with 536 additions and 1 deletions

View file

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\TextWidgets\Pages;
use App\Filament\Resources\TextWidgets\TextWidgetResource;
use Filament\Resources\Pages\CreateRecord;
class CreateTextWidget extends CreateRecord
{
protected static string $resource = TextWidgetResource::class;
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TextWidgets\Pages;
use App\Filament\Resources\TextWidgets\TextWidgetResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditTextWidget extends EditRecord
{
protected static string $resource = TextWidgetResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\TextWidgets\Pages;
use App\Filament\Resources\TextWidgets\TextWidgetResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListTextWidgets extends ListRecords
{
protected static string $resource = TextWidgetResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\TextWidgets\Schemas;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class TextWidgetForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('title')
->required()
->live(onBlur: true),
TextInput::make('description')
->nullable(),
Textarea::make('content')
->rows(5)
->columnSpanFull(),
]);
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Filament\Resources\TextWidgets\Tables;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class TextWidgetsTable
{
public static function configure(Table $table): Table
{
return $table
->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(),
]),
]);
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\TextWidgets;
use App\Filament\Resources\TextWidgets\Pages\CreateTextWidget;
use App\Filament\Resources\TextWidgets\Pages\EditTextWidget;
use App\Filament\Resources\TextWidgets\Pages\ListTextWidgets;
use App\Filament\Resources\TextWidgets\Schemas\TextWidgetForm;
use App\Filament\Resources\TextWidgets\Tables\TextWidgetsTable;
use App\Models\TextWidget;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class TextWidgetResource extends Resource
{
protected static ?string $model = TextWidget::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::DocumentText;
protected static ?string $recordTitleAttribute = 'title';
public static function form(Schema $schema): Schema
{
return TextWidgetForm::configure($schema);
}
public static function table(Table $table): Table
{
return TextWidgetsTable::configure($table);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => ListTextWidgets::route('/'),
'create' => CreateTextWidget::route('/create'),
'edit' => EditTextWidget::route('/{record}/edit'),
];
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Models\TextWidget;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class TextWidgetController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return TextWidget::all();
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$user = Auth::user();
if (! $user || $user->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();
}
}

17
app/Models/TextWidget.php Normal file
View file

@ -0,0 +1,17 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class TextWidget extends Model
{
use HasFactory;
protected $fillable = [
'title',
'description',
'content',
];
}

15
cmd/curl_get_text_widget.sh Executable file
View file

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# TOKEN="your_api_token_here"
# ensure to have set TOKEN to a valid value before running
# ideally add this to an .envrc file and source it
# tokens need to be created with tinker or similar method
URL='http://127.0.0.1:8000/api/text-widgets'
curl -s -X GET \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
$URL

View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
# this should fail, no token provided
# users need to be authenticated and have been
# granted access to view text widgets by being given
# a token
URL='http://127.0.0.1:8000/api/text-widgets'
curl -s -X GET \
-H "Accept: application/json" \
$URL

20
cmd/curl_post_text_widget.sh Executable file
View file

@ -0,0 +1,20 @@
#!/usr/bin/env bash
# TOKEN="your_api_token_here"
# ensure to have set TOKEN to a valid value before running
# ideally add this to an .envrc file and source it
# only the admin user can create entries so this should
# fail unless .env has ADMIN_EMAIL set to the user that
# the token belongs to
URL='http://127.0.0.1:8000/api/text-widgets'
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"title": "Yet Another New text widget Title",
"content": "This is the content yet again of the new text widget."
}' \
$URL

View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
# this should fail as no token is provided
# user is not authenticated
# no token has been granted
URL='http://127.0.0.1:8000/api/text-widgets'
curl -X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{
"title": "Yet Another New Entry Title",
"content": "This is the content yet again of the new entry."
}' \
$URL

View file

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TextWidget>
*/
class TextWidgetFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'title' => $this->faker->sentence(),
'description' => $this->faker->paragraph(),
'content' => $this->faker->text(500),
];
}
}

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('text_widgets', function (Blueprint $table) {
$table->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');
}
};

View file

@ -1,7 +1,7 @@
<?php
use App\Http\Controllers\EntryController;
use Illuminate\Http\Request;
use App\Http\Controllers\TextWidgetController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->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']);
});

View file

@ -0,0 +1,92 @@
<?php
use App\Models\TextWidget;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
uses(RefreshDatabase::class);
test('can list text widgets', function () {
$user = User::factory()->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();
});

View file

@ -0,0 +1,15 @@
<?php
use App\Models\TextWidget;
it('has correct fillable attributes', function () {
$expected = [
'title',
'description',
'content',
];
$entry = new TextWidget;
expect($entry->getFillable())->toEqual($expected);
});

View file

@ -0,0 +1,47 @@
<?php
use App\Filament\Resources\Entries\EntryResource;
use App\Filament\Resources\TextWidgets\TextWidgetResource;
use App\Models\TextWidget;
use ReflectionClass;
it('references the correct model and record title attribute', function () {
$defaults = (new ReflectionClass(TextWidgetResource::class))->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' => '<p>This is the content of the test entry.</p>',
];
$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' => '<p>This is the content of the test entry.</p>',
]);
$entryId = $entry->id;
$entry->delete();
$deletedEntry = TextWidget::find($entryId);
expect($deletedEntry)->toBeNull();
});