17 KiB
Story 5.2: Post Management Dashboard
Epic Reference
Epic 5: Posts/Blog System
User Story
As an admin, I want a dashboard to manage all blog posts, So that I can organize, publish, and maintain content efficiently.
Story Context
Existing System Integration
- Integrates with: posts table, admin_logs table
- Technology: Livewire Volt with pagination
- Follows pattern: Admin list pattern (see similar components in
resources/views/livewire/admin/) - Touch points: Post CRUD operations, AdminLog audit trail
- Note: HTML sanitization uses
clean()helper (from mews/purifier package, configured in Story 5.1)
Acceptance Criteria
List View
- Display all posts with:
- Title (in current admin language)
- Status (draft/published)
- Created date
- Last updated date
- Pagination (10/25/50 per page)
Filtering & Search
- Filter by status (draft/published/all)
- Search by title or body content
- Sort by date (newest/oldest)
Quick Actions
- Edit post
- Delete (with confirmation)
- Publish/unpublish toggle
- Bulk delete option (optional) - OUT OF SCOPE
Quality Requirements
- Bilingual labels
- Audit log for delete and status change actions
- Authorization check (admin only) on all actions
- Tests for list operations
Edge Cases
- Handle post not found gracefully (race condition on delete)
- Per-page selector visible in UI (10/25/50 options)
- Bulk delete is out of scope for this story (marked optional)
Technical Notes
File Location
Create Volt component at: resources/views/livewire/admin/posts/index.blade.php
Route
// routes/web.php (admin group)
Route::get('/admin/posts', function () {
return view('livewire.admin.posts.index');
})->middleware(['auth', 'admin'])->name('admin.posts.index');
Volt Component
<?php
use App\Models\Post;
use App\Models\AdminLog;
use Livewire\Volt\Component;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public string $search = '';
public string $statusFilter = '';
public string $sortBy = 'created_at';
public string $sortDir = 'desc';
public int $perPage = 10;
public function updatedSearch()
{
$this->resetPage();
}
public function sort(string $column): void
{
if ($this->sortBy === $column) {
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
$this->sortDir = 'desc';
}
}
public function togglePublish(int $id): void
{
$post = Post::findOrFail($id);
$newStatus = $post->status === 'published' ? 'draft' : 'published';
$post->update(['status' => $newStatus]);
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'status_change',
'target_type' => 'post',
'target_id' => $post->id,
'old_values' => ['status' => $post->getOriginal('status')],
'new_values' => ['status' => $newStatus],
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.post_status_updated'));
}
public function delete(int $id): void
{
$post = Post::findOrFail($id);
AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => 'delete',
'target_type' => 'post',
'target_id' => $post->id,
'old_values' => $post->toArray(),
'ip_address' => request()->ip(),
]);
$post->delete();
session()->flash('success', __('messages.post_deleted'));
}
public function with(): array
{
$locale = app()->getLocale();
return [
'posts' => Post::query()
->when($this->search, fn($q) => $q->where(function($q) use ($locale) {
$q->where("title_{$locale}", 'like', "%{$this->search}%")
->orWhere("body_{$locale}", 'like', "%{$this->search}%");
}))
->when($this->statusFilter, fn($q) => $q->where('status', $this->statusFilter))
->orderBy($this->sortBy, $this->sortDir)
->paginate($this->perPage),
];
}
};
Template
<div>
<div class="flex justify-between items-center mb-6">
<flux:heading>{{ __('admin.posts') }}</flux:heading>
<flux:button href="{{ route('admin.posts.create') }}">
{{ __('admin.create_post') }}
</flux:button>
</div>
<!-- Filters -->
<div class="flex gap-4 mb-4">
<flux:input
wire:model.live.debounce="search"
placeholder="{{ __('admin.search_posts') }}"
class="w-64"
/>
<flux:select wire:model.live="statusFilter">
<option value="">{{ __('admin.all_statuses') }}</option>
<option value="draft">{{ __('admin.draft') }}</option>
<option value="published">{{ __('admin.published') }}</option>
</flux:select>
<flux:select wire:model.live="perPage">
<option value="10">10 {{ __('admin.per_page') }}</option>
<option value="25">25 {{ __('admin.per_page') }}</option>
<option value="50">50 {{ __('admin.per_page') }}</option>
</flux:select>
</div>
<!-- Posts Table -->
<table class="w-full">
<thead>
<tr>
<th wire:click="sort('title_{{ app()->getLocale() }}')" class="cursor-pointer">
{{ __('admin.title') }}
</th>
<th>{{ __('admin.status') }}</th>
<th wire:click="sort('created_at')" class="cursor-pointer">
{{ __('admin.created') }}
</th>
<th wire:click="sort('updated_at')" class="cursor-pointer">
{{ __('admin.updated') }}
</th>
<th>{{ __('admin.actions') }}</th>
</tr>
</thead>
<tbody>
@forelse($posts as $post)
<tr>
<td>{{ $post->title }}</td>
<td>
<flux:badge :variant="$post->status === 'published' ? 'success' : 'secondary'">
{{ __('admin.' . $post->status) }}
</flux:badge>
</td>
<td>{{ $post->created_at->format('d/m/Y') }}</td>
<td>{{ $post->updated_at->diffForHumans() }}</td>
<td>
<div class="flex gap-2">
<flux:button size="sm" href="{{ route('admin.posts.edit', $post) }}">
{{ __('admin.edit') }}
</flux:button>
<flux:button
size="sm"
wire:click="togglePublish({{ $post->id }})"
>
{{ $post->status === 'published' ? __('admin.unpublish') : __('admin.publish') }}
</flux:button>
<flux:button
size="sm"
variant="danger"
wire:click="delete({{ $post->id }})"
wire:confirm="{{ __('admin.confirm_delete_post') }}"
>
{{ __('admin.delete') }}
</flux:button>
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="text-center py-8 text-charcoal/70">
{{ __('admin.no_posts') }}
</td>
</tr>
@endforelse
</tbody>
</table>
{{ $posts->links() }}
</div>
Testing Requirements
Test Scenarios
// tests/Feature/Admin/PostManagementTest.php
test('admin can view posts list', function () {
// Create admin, create posts, visit index, assert sees posts
});
test('admin can filter posts by status', function () {
// Create draft and published posts
// Filter by 'draft' - only drafts visible
// Filter by 'published' - only published visible
// Filter by '' (all) - all visible
});
test('admin can search posts by title in current locale', function () {
// Create posts with known titles
// Search by partial title - matches appear
// Search with no matches - empty state shown
});
test('admin can search posts by body content', function () {
// Create posts with known body content
// Search by body text - matches appear
});
test('admin can sort posts by date', function () {
// Create posts with different dates
// Sort desc - newest first
// Sort asc - oldest first
});
test('admin can toggle post publish status', function () {
// Create draft post
// Toggle - becomes published, AdminLog created
// Toggle again - becomes draft, AdminLog created
});
test('admin can delete post with confirmation', function () {
// Create post
// Delete - post removed, AdminLog created
});
test('pagination works correctly', function () {
// Create 15 posts
// Page 1 shows 10, page 2 shows 5
});
test('audit log records status changes', function () {
// Toggle status, verify AdminLog entry with old/new values
});
test('audit log records deletions', function () {
// Delete post, verify AdminLog entry with old values
});
Definition of Done
- List displays all posts
- Filter by status works
- Search by title/body works
- Sort by date works
- Quick publish/unpublish toggle works
- Delete with confirmation works
- Pagination works
- Per-page selector works (10/25/50)
- Audit log for actions
- All test scenarios pass
- Code formatted with Pint
Dependencies
- Story 5.1: Post creation -
docs/stories/story-5.1-post-creation-editing.md - Requires: Post model with bilingual accessors, AdminLog model
Estimation
Complexity: Medium Estimated Effort: 3-4 hours
Dev Agent Record
Status
Ready for Review
Agent Model Used
Claude Opus 4.5 (claude-opus-4-5-20251101)
File List
| File | Action |
|---|---|
resources/views/livewire/admin/posts/index.blade.php |
Modified - Added togglePublish, delete methods, body search, per-page 10/25/50 |
lang/en/posts.php |
Modified - Added post_status_updated, post_not_found, confirm_delete keys |
lang/ar/posts.php |
Modified - Added post_status_updated, post_not_found, confirm_delete keys |
tests/Feature/Admin/PostManagementTest.php |
Modified - Added 13 new tests for index page features |
Completion Notes
- Post management dashboard index page was already partially implemented from Story 5.1
- Added missing features: togglePublish, delete with confirmation, body search, audit logging
- Changed per-page default from 15 to 10 and options from 15/25/50 to 10/25/50 per story requirements
- Uses DB transaction with lockForUpdate for race condition protection
- All 41 post management tests pass (13 new tests added for index page features)
- Full test suite (682 tests) passes
- Code formatted with Pint
Change Log
| Date | Change |
|---|---|
| 2025-12-27 | Initial implementation of Story 5.2 features |
QA Results
Review Date: 2025-12-27
Reviewed By: Quinn (Test Architect)
Risk Assessment
- Risk Level: Low-Medium
- Escalation Triggers: None triggered
- No auth/payment/security-sensitive changes (post management is admin-only)
- Tests added: 13 new tests for index page features
- Diff size: Reasonable (~300 lines of Livewire component + tests)
- Previous gate: N/A (first review)
- Acceptance criteria count: 9 items (moderate complexity)
Code Quality Assessment
Overall Grade: Excellent
The implementation demonstrates high-quality Laravel/Livewire practices:
- Architecture & Design: Clean Volt class-based component following project conventions
- Database Safety: Proper use of
DB::transaction()withlockForUpdate()for race condition protection - Error Handling: Graceful handling of "post not found" scenarios with user-friendly error messages
- Bilingual Support: Search properly queries both Arabic and English JSON fields
- State Management: Proper use of
updatedX()lifecycle hooks for pagination reset - Code Organization: Clear separation of concerns between filtering, sorting, and CRUD operations
Refactoring Performed
None required. The code is well-structured and follows project conventions.
Requirements Traceability (Given-When-Then Mapping)
| AC# | Acceptance Criteria | Test Coverage | Status |
|---|---|---|---|
| 1 | Display posts with title, status, dates | admin can see list of posts, posts list shows status badges |
✓ |
| 2 | Pagination (10/25/50 per page) | pagination works correctly with per page selector |
✓ |
| 3 | Filter by status | admin can filter posts by status |
✓ |
| 4 | Search by title/body | admin can search posts by title, admin can search posts by body content |
✓ |
| 5 | Sort by date | admin can sort posts by created date, admin can sort posts by updated date |
✓ |
| 6 | Edit post | admin can view post edit form, edit existing post updates content |
✓ |
| 7 | Delete with confirmation | admin can delete post from index, delete handles post not found gracefully |
✓ |
| 8 | Publish/unpublish toggle | admin can toggle post publish status from draft to published, ...from published to draft |
✓ |
| 9 | Audit logging | toggle publish creates audit log, delete post creates audit log |
✓ |
| 10 | Authorization (admin only) | non-admin cannot access posts index, guest cannot access posts index |
✓ |
Compliance Check
- Coding Standards: ✓ Code formatted with Pint
- Project Structure: ✓ Follows Volt class-based component pattern in correct location
- Testing Strategy: ✓ Comprehensive feature tests using Pest with Volt::test()
- All ACs Met: ✓ All acceptance criteria have corresponding test coverage
Improvements Checklist
All items handled - no required changes:
- DB transactions with lockForUpdate() for race condition protection
- Graceful error handling for "not found" scenarios
- Bilingual search across all JSON fields
- Pagination reset on filter changes
- Audit logging for status changes and deletions
- Authorization via admin middleware
- Per-page selector with 10/25/50 options per story requirements
Test Architecture Assessment
Tests Reviewed: 41 tests (13 specific to index page features) Coverage Quality: Excellent
| Category | Count | Quality |
|---|---|---|
| Index page CRUD | 16 | ✓ Complete |
| Authorization | 4 | ✓ Complete |
| Validation | 2 | ✓ Complete |
| Create/Edit pages | 15 | ✓ Complete |
| Model tests | 4 | ✓ Complete |
Test Design Strengths:
- Uses factory states (
->draft(),->published()) appropriately - Tests both happy paths and edge cases (not found scenarios)
- Verifies audit log creation with correct old/new values
- Tests all filter/sort combinations
Security Review
- ✓ Authorization: Admin middleware protects all routes
- ✓ Input Validation: Search input used in parameterized queries (no SQL injection)
- ✓ XSS Prevention: HTML sanitization via
clean()helper (mews/purifier) - ✓ CSRF: Handled by Livewire automatically
- ✓ Race Conditions: Protected with
lockForUpdate()in transactions
Performance Considerations
- ✓ Pagination implemented with configurable per-page limit
- ✓ JSON search uses MySQL JSON_EXTRACT (appropriate for moderate data volumes)
- ✓ Sorting uses indexed columns (updated_at, created_at)
- Note: For very large datasets, consider adding full-text search index on JSON columns
Non-Functional Requirements Validation
| NFR | Status | Notes |
|---|---|---|
| Security | PASS | Admin-only access, parameterized queries, XSS protection |
| Performance | PASS | Pagination, indexed sorting columns |
| Reliability | PASS | Transaction protection, graceful error handling |
| Maintainability | PASS | Clean code, comprehensive tests, bilingual support |
Files Modified During Review
None - no refactoring was needed.
Gate Status
Gate: PASS → docs/qa/gates/5.2-post-management-dashboard.yml
Recommended Status
✓ Ready for Done
All acceptance criteria are met, tests pass, code quality is excellent, and security considerations are properly addressed. The implementation follows Laravel/Livewire best practices and project conventions.