feat: add Posts Livewire CRUD page
Adds the Posts index page (list/create/edit/delete) following the existing Sites/SocialAccounts pattern, and extends the shared nav layout with a Posts link. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f746c5d6a0
commit
be8e6e997f
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Posts;
|
||||
|
||||
use App\Models\Post;
|
||||
use App\Models\Site;
|
||||
use App\Models\SocialAccount;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Component;
|
||||
|
||||
#[Layout('components.layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
public bool $showForm = false;
|
||||
|
||||
public ?int $editingId = null;
|
||||
|
||||
public string $siteId = '';
|
||||
|
||||
public string $socialAccountId = '';
|
||||
|
||||
public string $type = '';
|
||||
|
||||
public string $title = '';
|
||||
|
||||
public string $body = '';
|
||||
|
||||
public string $status = 'draft';
|
||||
|
||||
public string $scheduledAt = '';
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.posts.index', [
|
||||
'posts' => Post::with(['site', 'socialAccount'])->orderByDesc('created_at')->get(),
|
||||
'sites' => Site::orderBy('name')->get(),
|
||||
'accounts' => SocialAccount::orderBy('username')->get(),
|
||||
'types' => Post::TYPES,
|
||||
'statuses' => Post::STATUSES,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): void
|
||||
{
|
||||
$this->resetForm();
|
||||
$this->showForm = true;
|
||||
}
|
||||
|
||||
public function edit(int $id): void
|
||||
{
|
||||
$post = Post::findOrFail($id);
|
||||
$this->editingId = $post->id;
|
||||
$this->siteId = (string) $post->site_id;
|
||||
$this->socialAccountId = $post->social_account_id ? (string) $post->social_account_id : '';
|
||||
$this->type = $post->type;
|
||||
$this->title = (string) $post->title;
|
||||
$this->body = $post->body;
|
||||
$this->status = $post->status;
|
||||
$this->scheduledAt = $post->scheduled_at?->format('Y-m-d\TH:i') ?? '';
|
||||
$this->showForm = true;
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$validated = $this->validate([
|
||||
'siteId' => ['required', 'integer', 'exists:sites,id'],
|
||||
'socialAccountId' => ['nullable', 'integer', 'exists:social_accounts,id'],
|
||||
'type' => ['required', 'string', 'in:' . implode(',', Post::TYPES)],
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'body' => ['required', 'string'],
|
||||
'status' => ['required', 'string', 'in:' . implode(',', Post::STATUSES)],
|
||||
'scheduledAt' => ['nullable', 'date'],
|
||||
]);
|
||||
|
||||
Post::updateOrCreate(['id' => $this->editingId], [
|
||||
'site_id' => $validated['siteId'],
|
||||
'social_account_id' => $validated['socialAccountId'] ?: null,
|
||||
'type' => $validated['type'],
|
||||
'title' => $validated['title'] ?: null,
|
||||
'body' => $validated['body'],
|
||||
'status' => $validated['status'],
|
||||
'scheduled_at' => $validated['scheduledAt'] ?: null,
|
||||
]);
|
||||
|
||||
$this->resetForm();
|
||||
$this->showForm = false;
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
Post::findOrFail($id)->delete();
|
||||
}
|
||||
|
||||
public function resetForm(): void
|
||||
{
|
||||
$this->editingId = null;
|
||||
$this->siteId = '';
|
||||
$this->socialAccountId = '';
|
||||
$this->type = '';
|
||||
$this->title = '';
|
||||
$this->body = '';
|
||||
$this->status = 'draft';
|
||||
$this->scheduledAt = '';
|
||||
$this->resetValidation();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<nav class="bg-white border-b border-gray-200 px-6 py-4 flex gap-6">
|
||||
<a href="{{ route('sites.index') }}" class="font-medium hover:text-blue-600">Sites</a>
|
||||
<a href="{{ route('social-accounts.index') }}" class="font-medium hover:text-blue-600">Social Accounts</a>
|
||||
<a href="{{ route('posts.index') }}" class="font-medium hover:text-blue-600">Posts</a>
|
||||
</nav>
|
||||
|
||||
<main class="p-6">
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h1 class="text-xl font-semibold">Posts</h1>
|
||||
<button wire:click="create" class="bg-blue-600 text-white px-4 py-2 rounded">Add Post</button>
|
||||
</div>
|
||||
|
||||
<table class="w-full bg-white border border-gray-200 rounded">
|
||||
<thead>
|
||||
<tr class="text-left border-b border-gray-200">
|
||||
<th class="p-3">Site</th>
|
||||
<th class="p-3">Type</th>
|
||||
<th class="p-3">Title</th>
|
||||
<th class="p-3">Status</th>
|
||||
<th class="p-3">Scheduled</th>
|
||||
<th class="p-3">Account</th>
|
||||
<th class="p-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ($posts as $post)
|
||||
<tr wire:key="post-{{ $post->id }}" class="border-b border-gray-100">
|
||||
<td class="p-3">{{ $post->site->name }}</td>
|
||||
<td class="p-3">{{ ucfirst($post->type) }}</td>
|
||||
<td class="p-3">{{ $post->title ?: \Illuminate\Support\Str::limit($post->body, 40) }}</td>
|
||||
<td class="p-3">{{ ucfirst($post->status) }}</td>
|
||||
<td class="p-3">{{ $post->scheduled_at?->format('Y-m-d H:i') ?? '—' }}</td>
|
||||
<td class="p-3">{{ $post->socialAccount?->username ?? '—' }}</td>
|
||||
<td class="p-3 text-right space-x-2">
|
||||
<button wire:click="edit({{ $post->id }})" class="text-blue-600">Edit</button>
|
||||
<button wire:click="delete({{ $post->id }})" wire:confirm="Delete this post?" class="text-red-600">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if ($showForm)
|
||||
<div class="fixed inset-0 bg-black/30 flex items-center justify-center">
|
||||
<div class="bg-white p-6 rounded w-full max-w-md space-y-4 max-h-[90vh] overflow-y-auto">
|
||||
<h2 class="text-lg font-semibold">{{ $editingId ? 'Edit Post' : 'Add Post' }}</h2>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Site</label>
|
||||
<select wire:model="siteId" class="w-full border rounded p-2">
|
||||
<option value="">Select site</option>
|
||||
@foreach ($sites as $site)
|
||||
<option value="{{ $site->id }}">{{ $site->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('siteId') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Social Account (optional)</label>
|
||||
<select wire:model="socialAccountId" class="w-full border rounded p-2">
|
||||
<option value="">None</option>
|
||||
@foreach ($accounts as $account)
|
||||
<option value="{{ $account->id }}">{{ $account->username }} ({{ ucfirst($account->network) }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('socialAccountId') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Type</label>
|
||||
<select wire:model="type" class="w-full border rounded p-2">
|
||||
<option value="">Select type</option>
|
||||
@foreach ($types as $t)
|
||||
<option value="{{ $t }}">{{ ucfirst($t) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('type') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Title (optional)</label>
|
||||
<input type="text" wire:model="title" class="w-full border rounded p-2">
|
||||
@error('title') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Body</label>
|
||||
<textarea wire:model="body" rows="4" class="w-full border rounded p-2"></textarea>
|
||||
@error('body') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Status</label>
|
||||
<select wire:model="status" class="w-full border rounded p-2">
|
||||
@foreach ($statuses as $s)
|
||||
<option value="{{ $s }}">{{ ucfirst($s) }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('status') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium">Scheduled At (optional)</label>
|
||||
<input type="datetime-local" wire:model="scheduledAt" class="w-full border rounded p-2">
|
||||
@error('scheduledAt') <span class="text-red-600 text-sm">{{ $message }}</span> @enderror
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button wire:click="$set('showForm', false)" class="px-4 py-2">Cancel</button>
|
||||
<button wire:click="save" class="bg-blue-600 text-white px-4 py-2 rounded">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Posts\Index as PostsIndex;
|
||||
use App\Livewire\Sites\Index as SitesIndex;
|
||||
use App\Livewire\SocialAccounts\Index as SocialAccountsIndex;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -10,3 +11,4 @@ Route::get('/', function () {
|
||||
|
||||
Route::get('/sites', SitesIndex::class)->name('sites.index');
|
||||
Route::get('/social-accounts', SocialAccountsIndex::class)->name('social-accounts.index');
|
||||
Route::get('/posts', PostsIndex::class)->name('posts.index');
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Posts\Index;
|
||||
use App\Models\Post;
|
||||
use App\Models\Site;
|
||||
use App\Models\SocialAccount;
|
||||
use Livewire\Livewire;
|
||||
|
||||
test('posts index page lists existing posts', function () {
|
||||
$site = Site::factory()->create(['name' => 'My Blog']);
|
||||
Post::factory()->create(['site_id' => $site->id, 'title' => 'Hello World Post']);
|
||||
|
||||
$this->get(route('posts.index'))
|
||||
->assertOk()
|
||||
->assertSee('Hello World Post')
|
||||
->assertSee('My Blog');
|
||||
});
|
||||
|
||||
test('a new post can be created with required fields only', function () {
|
||||
$site = Site::factory()->create();
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->set('siteId', $site->id)
|
||||
->set('type', 'blog')
|
||||
->set('body', 'New post body')
|
||||
->call('save');
|
||||
|
||||
$post = Post::where('body', 'New post body')->first();
|
||||
expect($post)->not->toBeNull();
|
||||
expect($post->site_id)->toBe($site->id);
|
||||
expect($post->status)->toBe('draft');
|
||||
});
|
||||
|
||||
test('a new post can be created with an account, title, status, and schedule', function () {
|
||||
$site = Site::factory()->create();
|
||||
$account = SocialAccount::factory()->create();
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->set('siteId', $site->id)
|
||||
->set('socialAccountId', $account->id)
|
||||
->set('type', 'twitter')
|
||||
->set('title', 'Launch')
|
||||
->set('body', 'We launched')
|
||||
->set('status', 'scheduled')
|
||||
->set('scheduledAt', '2026-08-01T09:00')
|
||||
->call('save');
|
||||
|
||||
$post = Post::where('title', 'Launch')->first();
|
||||
expect($post->social_account_id)->toBe($account->id);
|
||||
expect($post->status)->toBe('scheduled');
|
||||
expect($post->scheduled_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('site, type, and body are required to create a post', function () {
|
||||
Livewire::test(Index::class)
|
||||
->set('siteId', '')
|
||||
->set('type', '')
|
||||
->set('body', '')
|
||||
->call('save')
|
||||
->assertHasErrors(['siteId', 'type', 'body']);
|
||||
});
|
||||
|
||||
test('an existing post can be edited', function () {
|
||||
$site = Site::factory()->create();
|
||||
$post = Post::factory()->create(['site_id' => $site->id, 'title' => 'Old Title']);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->call('edit', $post->id)
|
||||
->set('title', 'New Title')
|
||||
->call('save');
|
||||
|
||||
expect($post->fresh()->title)->toBe('New Title');
|
||||
});
|
||||
|
||||
test('a post can be deleted', function () {
|
||||
$post = Post::factory()->create();
|
||||
|
||||
Livewire::test(Index::class)->call('delete', $post->id);
|
||||
|
||||
expect(Post::find($post->id))->toBeNull();
|
||||
});
|
||||
Reference in New Issue
Block a user