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

14 KiB

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:

composer require mews/purifier
php artisan vendor:publish --provider="Mews\Purifier\PurifierServiceProvider"

Configure config/purifier.php to allow only safe rich text tags:

'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

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

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

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

{{-- 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:

{{-- 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)

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)

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