libra/docs/stories/story-5.1-post-creation-edi...

484 lines
14 KiB
Markdown

# Story 5.1: Post Creation & Editing
## Epic Reference
**Epic 5:** Posts/Blog System
## User Story
As an **admin**,
I want **to create and edit blog posts with rich text formatting**,
So that **I can publish professional legal content for website visitors**.
## Story Context
### Existing System Integration
- **Integrates with:** posts table
- **Technology:** Livewire Volt, TinyMCE or similar rich text editor
- **Follows pattern:** Admin CRUD pattern
- **Touch points:** Public posts display
## Acceptance Criteria
### Post Creation Form
- [ ] Title (required, bilingual: Arabic and English)
- [ ] Body content (required, bilingual)
- [ ] Status (draft/published)
### Rich Text Editor
- [ ] Bold, italic, underline
- [ ] Headings (H2, H3)
- [ ] Bullet and numbered lists
- [ ] Links
- [ ] Blockquotes
### Saving Features
- [ ] Save as draft functionality
- [ ] Preview post before publishing
- [ ] Edit published posts
- [ ] Auto-save draft periodically (every 60 seconds)
- [ ] Immediate publishing (no scheduling)
### Timestamps
- [ ] created_at recorded on creation
- [ ] updated_at updated on edit
### Quality Requirements
- [ ] HTML sanitization for XSS prevention
- [ ] Bilingual form labels
- [ ] Audit log for create/edit
- [ ] Tests for CRUD operations
## Technical Notes
### HTML Sanitization
Use the `mews/purifier` package for HTML sanitization via the `clean()` helper:
```bash
composer require mews/purifier
php artisan vendor:publish --provider="Mews\Purifier\PurifierServiceProvider"
```
Configure `config/purifier.php` to allow only safe rich text tags:
```php
'default' => [
'HTML.Allowed' => 'h2,h3,p,br,b,strong,i,em,u,ul,ol,li,a[href|title],blockquote',
'AutoFormat.AutoParagraph' => true,
'AutoFormat.RemoveEmpty' => true,
],
```
### Database Schema
```php
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title_ar');
$table->string('title_en');
$table->text('body_ar');
$table->text('body_en');
$table->enum('status', ['draft', 'published'])->default('draft');
$table->timestamps();
});
```
### Post Model
```php
class Post extends Model
{
protected $fillable = [
'title_ar', 'title_en', 'body_ar', 'body_en', 'status',
];
public function getTitleAttribute(): string
{
$locale = app()->getLocale();
return $this->{"title_{$locale}"} ?? $this->title_en;
}
public function getBodyAttribute(): string
{
$locale = app()->getLocale();
return $this->{"body_{$locale}"} ?? $this->body_en;
}
public function getExcerptAttribute(): string
{
return Str::limit(strip_tags($this->body), 150);
}
public function scopePublished($query)
{
return $query->where('status', 'published');
}
public function scopeDraft($query)
{
return $query->where('status', 'draft');
}
}
```
### Volt Component
```php
<?php
use App\Models\Post;
use Livewire\Volt\Component;
new class extends Component {
public ?Post $post = null;
public string $title_ar = '';
public string $title_en = '';
public string $body_ar = '';
public string $body_en = '';
public string $status = 'draft';
public function mount(?Post $post = null): void
{
if ($post?->exists) {
$this->post = $post;
$this->fill($post->only([
'title_ar', 'title_en', 'body_ar', 'body_en', 'status'
]));
}
}
public function save(): void
{
$validated = $this->validate([
'title_ar' => ['required', 'string', 'max:255'],
'title_en' => ['required', 'string', 'max:255'],
'body_ar' => ['required', 'string'],
'body_en' => ['required', 'string'],
'status' => ['required', 'in:draft,published'],
]);
// Sanitize HTML
$validated['body_ar'] = clean($validated['body_ar']);
$validated['body_en'] = clean($validated['body_en']);
if ($this->post) {
$this->post->update($validated);
$action = 'update';
} else {
$this->post = Post::create($validated);
$action = 'create';
}
// AdminLog model exists from Epic 1 (Story 1.1) - see admin_logs table in schema
\App\Models\AdminLog::create([
'admin_id' => auth()->id(),
'action_type' => $action,
'target_type' => 'post',
'target_id' => $this->post->id,
'ip_address' => request()->ip(),
]);
session()->flash('success', __('messages.post_saved'));
}
public function saveDraft(): void
{
$this->status = 'draft';
$this->save();
}
public function publish(): void
{
$this->status = 'published';
$this->save();
}
public function autoSave(): void
{
if ($this->post && $this->status === 'draft') {
$this->post->update([
'title_ar' => $this->title_ar,
'title_en' => $this->title_en,
'body_ar' => clean($this->body_ar),
'body_en' => clean($this->body_en),
]);
}
}
// Preview opens modal with sanitized rendered content
public bool $showPreview = false;
public function preview(): void
{
$this->showPreview = true;
}
public function closePreview(): void
{
$this->showPreview = false;
}
};
```
### Template with Rich Text Editor
```blade
{{-- wire:poll.60s triggers autoSave every 60 seconds for draft posts --}}
<div wire:poll.60s="autoSave">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Arabic Fields -->
<div class="space-y-4">
<flux:heading size="sm">{{ __('admin.arabic_content') }}</flux:heading>
<flux:field>
<flux:label>{{ __('admin.title') }} (عربي) *</flux:label>
<flux:input wire:model="title_ar" dir="rtl" />
<flux:error name="title_ar" />
</flux:field>
<flux:field>
<flux:label>{{ __('admin.body') }} (عربي) *</flux:label>
<div wire:ignore>
<trix-editor
x-data
x-on:trix-change="$wire.set('body_ar', $event.target.value)"
dir="rtl"
>{!! $body_ar !!}</trix-editor>
</div>
<flux:error name="body_ar" />
</flux:field>
</div>
<!-- English Fields -->
<div class="space-y-4">
<flux:heading size="sm">{{ __('admin.english_content') }}</flux:heading>
<flux:field>
<flux:label>{{ __('admin.title') }} (English) *</flux:label>
<flux:input wire:model="title_en" />
<flux:error name="title_en" />
</flux:field>
<flux:field>
<flux:label>{{ __('admin.body') }} (English) *</flux:label>
<div wire:ignore>
<trix-editor
x-data
x-on:trix-change="$wire.set('body_en', $event.target.value)"
>{!! $body_en !!}</trix-editor>
</div>
<flux:error name="body_en" />
</flux:field>
</div>
</div>
<div class="flex gap-3 mt-6">
<flux:button wire:click="saveDraft">
{{ __('admin.save_draft') }}
</flux:button>
<flux:button wire:click="preview">
{{ __('admin.preview') }}
</flux:button>
<flux:button variant="primary" wire:click="publish">
{{ __('admin.publish') }}
</flux:button>
</div>
{{-- Preview Modal --}}
<flux:modal wire:model="showPreview" class="max-w-4xl">
<flux:heading>{{ __('admin.preview') }}</flux:heading>
<div class="mt-4 space-y-6">
<div class="border-b pb-4">
<h3 class="font-semibold text-lg mb-2">{{ __('admin.arabic_content') }}</h3>
<h2 class="text-xl font-bold" dir="rtl">{{ $title_ar }}</h2>
<div class="prose mt-2" dir="rtl">{!! clean($body_ar) !!}</div>
</div>
<div>
<h3 class="font-semibold text-lg mb-2">{{ __('admin.english_content') }}</h3>
<h2 class="text-xl font-bold">{{ $title_en }}</h2>
<div class="prose mt-2">{!! clean($body_en) !!}</div>
</div>
</div>
<div class="mt-6 flex justify-end">
<flux:button wire:click="closePreview">{{ __('admin.close') }}</flux:button>
</div>
</flux:modal>
</div>
```
### Trix Editor Setup
Include Trix editor assets in your layout or component:
```blade
{{-- In your layout head --}}
<link rel="stylesheet" href="https://unpkg.com/trix@2.0.0/dist/trix.css">
<script src="https://unpkg.com/trix@2.0.0/dist/trix.umd.min.js"></script>
```
## Definition of Done
- [ ] Can create post with bilingual content
- [ ] Rich text editor works
- [ ] Can save as draft
- [ ] Can publish directly
- [ ] Can edit existing posts
- [ ] Auto-save works for drafts
- [ ] Preview modal displays sanitized content
- [ ] HTML properly sanitized
- [ ] Audit log created
- [ ] Tests pass
- [ ] Code formatted with Pint
## Test Scenarios
### Unit Tests (Post Model)
```php
test('post has bilingual title accessor', function () {
$post = Post::factory()->create(['title_ar' => 'عنوان', 'title_en' => 'Title']);
app()->setLocale('ar');
expect($post->title)->toBe('عنوان');
app()->setLocale('en');
expect($post->title)->toBe('Title');
});
test('post excerpt strips HTML and limits to 150 chars', function () {
$post = Post::factory()->create(['body_en' => '<p>' . str_repeat('a', 200) . '</p>']);
expect(strlen($post->excerpt))->toBeLessThanOrEqual(153); // 150 + '...'
expect($post->excerpt)->not->toContain('<p>');
});
test('published scope returns only published posts', function () {
Post::factory()->create(['status' => 'draft']);
Post::factory()->create(['status' => 'published']);
expect(Post::published()->count())->toBe(1);
});
```
### Feature Tests (Volt Component)
```php
use Livewire\Volt\Volt;
test('admin can create post with valid bilingual content', function () {
$admin = User::factory()->admin()->create();
Volt::test('admin.posts.create')
->actingAs($admin)
->set('title_ar', 'عنوان المقال')
->set('title_en', 'Article Title')
->set('body_ar', '<p>محتوى المقال</p>')
->set('body_en', '<p>Article content</p>')
->call('saveDraft')
->assertHasNoErrors();
expect(Post::where('title_en', 'Article Title')->exists())->toBeTrue();
});
test('create post fails with missing required fields', function () {
$admin = User::factory()->admin()->create();
Volt::test('admin.posts.create')
->actingAs($admin)
->set('title_ar', '')
->set('title_en', 'Title')
->call('save')
->assertHasErrors(['title_ar']);
});
test('save draft preserves draft status', function () {
$admin = User::factory()->admin()->create();
Volt::test('admin.posts.create')
->actingAs($admin)
->set('title_ar', 'عنوان')
->set('title_en', 'Title')
->set('body_ar', 'محتوى')
->set('body_en', 'Content')
->call('saveDraft');
expect(Post::first()->status)->toBe('draft');
});
test('publish changes status to published', function () {
$admin = User::factory()->admin()->create();
Volt::test('admin.posts.create')
->actingAs($admin)
->set('title_ar', 'عنوان')
->set('title_en', 'Title')
->set('body_ar', 'محتوى')
->set('body_en', 'Content')
->call('publish');
expect(Post::first()->status)->toBe('published');
});
test('edit existing post updates content', function () {
$admin = User::factory()->admin()->create();
$post = Post::factory()->create(['title_en' => 'Original']);
Volt::test('admin.posts.edit', ['post' => $post])
->actingAs($admin)
->set('title_en', 'Updated')
->call('save')
->assertHasNoErrors();
expect($post->fresh()->title_en)->toBe('Updated');
});
test('auto-save only fires for draft posts', function () {
$admin = User::factory()->admin()->create();
$post = Post::factory()->create(['status' => 'published', 'title_en' => 'Original']);
Volt::test('admin.posts.edit', ['post' => $post])
->actingAs($admin)
->set('title_en', 'Changed')
->call('autoSave');
// Published post should NOT be auto-saved
expect($post->fresh()->title_en)->toBe('Original');
});
test('HTML sanitization removes script tags but keeps allowed formatting', function () {
$admin = User::factory()->admin()->create();
Volt::test('admin.posts.create')
->actingAs($admin)
->set('title_ar', 'عنوان')
->set('title_en', 'Title')
->set('body_ar', '<p>نص</p>')
->set('body_en', '<p>Safe</p><script>alert("xss")</script><strong>Bold</strong>')
->call('saveDraft');
$post = Post::first();
expect($post->body_en)->not->toContain('<script>');
expect($post->body_en)->toContain('<strong>Bold</strong>');
});
test('admin log created on post create', function () {
$admin = User::factory()->admin()->create();
Volt::test('admin.posts.create')
->actingAs($admin)
->set('title_ar', 'عنوان')
->set('title_en', 'Title')
->set('body_ar', 'محتوى')
->set('body_en', 'Content')
->call('saveDraft');
expect(AdminLog::where('action_type', 'create')->where('target_type', 'post')->exists())->toBeTrue();
});
test('preview modal can be opened and closed', function () {
$admin = User::factory()->admin()->create();
Volt::test('admin.posts.create')
->actingAs($admin)
->assertSet('showPreview', false)
->call('preview')
->assertSet('showPreview', true)
->call('closePreview')
->assertSet('showPreview', false);
});
```
## Dependencies
- **Epic 1:** Database schema, admin authentication
## Estimation
**Complexity:** Medium
**Estimated Effort:** 4-5 hours