libra/docs/stories/story-5.5-post-search.md

8.0 KiB

Story 5.5: Post Search

Epic Reference

Epic 5: Posts/Blog System

User Story

As a website visitor, I want to search through blog posts, So that I can find relevant legal articles and information.

Story Context

Existing System Integration

  • Integrates with: posts table, public posts listing
  • Technology: Livewire Volt with real-time search
  • Follows pattern: Search component pattern
  • Touch points: Posts listing page

Acceptance Criteria

Search Functionality

  • Search input on posts listing page
  • Search by title (both languages)
  • Search by body content (both languages)
  • Real-time search results (debounced)

User Experience

  • "No results found" message when empty
  • Clear search button
  • Search works in both Arabic and English
  • Only searches published posts

Optional Enhancements

  • Search highlights in results
  • Search suggestions

Quality Requirements

  • Debounce to reduce server load (300ms)
  • Case-insensitive search
  • Tests for search functionality

Technical Notes

Updated Posts Index Component

<?php

use App\Models\Post;
use Livewire\Volt\Component;
use Livewire\WithPagination;

new class extends Component {
    use WithPagination;

    public string $search = '';

    public function updatedSearch()
    {
        $this->resetPage();
    }

    public function clearSearch(): void
    {
        $this->search = '';
        $this->resetPage();
    }

    public function with(): array
    {
        return [
            'posts' => Post::published()
                ->when($this->search, function ($query) {
                    $search = $this->search;
                    $query->where(function ($q) use ($search) {
                        $q->where('title_ar', 'like', "%{$search}%")
                          ->orWhere('title_en', 'like', "%{$search}%")
                          ->orWhere('body_ar', 'like', "%{$search}%")
                          ->orWhere('body_en', 'like', "%{$search}%");
                    });
                })
                ->latest()
                ->paginate(10),
        ];
    }
}; ?>

<div class="max-w-4xl mx-auto">
    <flux:heading>{{ __('posts.title') }}</flux:heading>

    <!-- Search Bar -->
    <div class="mt-6 relative">
        <flux:input
            wire:model.live.debounce.300ms="search"
            placeholder="{{ __('posts.search_placeholder') }}"
            class="w-full"
        >
            <x-slot:leading>
                <flux:icon name="magnifying-glass" class="w-5 h-5 text-charcoal/50" />
            </x-slot:leading>
        </flux:input>

        @if($search)
            <button
                wire:click="clearSearch"
                class="absolute {{ app()->getLocale() === 'ar' ? 'left-3' : 'right-3' }} top-1/2 -translate-y-1/2 text-charcoal/50 hover:text-charcoal"
            >
                <flux:icon name="x-mark" class="w-5 h-5" />
            </button>
        @endif
    </div>

    <!-- Search Results Info -->
    @if($search)
        <p class="mt-4 text-sm text-charcoal/70">
            @if($posts->total() > 0)
                {{ __('posts.search_results', ['count' => $posts->total(), 'query' => $search]) }}
            @else
                {{ __('posts.no_results', ['query' => $search]) }}
            @endif
        </p>
    @endif

    <!-- Posts List -->
    <div class="mt-8 space-y-6">
        @forelse($posts as $post)
            <article class="bg-cream p-6 rounded-lg shadow-sm hover:shadow-md transition-shadow">
                <h2 class="text-xl font-semibold text-navy">
                    <a href="{{ route('posts.show', $post) }}" class="hover:text-gold">
                        @if($search)
                            {!! $this->highlightSearch($post->title, $search) !!}
                        @else
                            {{ $post->title }}
                        @endif
                    </a>
                </h2>

                <time class="text-sm text-charcoal/70 mt-2 block">
                    {{ $post->created_at->translatedFormat('d F Y') }}
                </time>

                <p class="mt-3 text-charcoal">
                    @if($search)
                        {!! $this->highlightSearch($post->excerpt, $search) !!}
                    @else
                        {{ $post->excerpt }}
                    @endif
                </p>

                <a href="{{ route('posts.show', $post) }}" class="text-gold hover:underline mt-4 inline-block">
                    {{ __('posts.read_more') }} &rarr;
                </a>
            </article>
        @empty
            <div class="text-center py-12">
                @if($search)
                    <flux:icon name="magnifying-glass" class="w-12 h-12 text-charcoal/30 mx-auto mb-4" />
                    <p class="text-charcoal/70">{{ __('posts.no_results', ['query' => $search]) }}</p>
                    <flux:button wire:click="clearSearch" class="mt-4">
                        {{ __('posts.clear_search') }}
                    </flux:button>
                @else
                    <p class="text-charcoal/70">{{ __('posts.no_posts') }}</p>
                @endif
            </div>
        @endforelse
    </div>

    {{ $posts->links() }}
</div>

Highlight Helper Method

public function highlightSearch(string $text, string $search): string
{
    if (empty($search)) {
        return e($text);
    }

    $escapedText = e($text);
    $escapedSearch = e($search);

    return preg_replace(
        '/(' . preg_quote($escapedSearch, '/') . ')/iu',
        '<mark class="bg-gold/30 rounded px-1">$1</mark>',
        $escapedText
    );
}

Translation Strings

// resources/lang/en/posts.php
return [
    'search_placeholder' => 'Search articles...',
    'search_results' => 'Found :count results for ":query"',
    'no_results' => 'No results found for ":query"',
    'clear_search' => 'Clear search',
];

// resources/lang/ar/posts.php
return [
    'search_placeholder' => 'البحث في المقالات...',
    'search_results' => 'تم العثور على :count نتيجة لـ ":query"',
    'no_results' => 'لم يتم العثور على نتائج لـ ":query"',
    'clear_search' => 'مسح البحث',
];

Testing

it('searches posts by title', function () {
    Post::factory()->published()->create(['title_en' => 'Legal Rights Guide']);
    Post::factory()->published()->create(['title_en' => 'Tax Information']);

    Volt::test('posts.index')
        ->set('search', 'Legal')
        ->assertSee('Legal Rights Guide')
        ->assertDontSee('Tax Information');
});

it('searches posts by body content', function () {
    Post::factory()->published()->create([
        'title_en' => 'Post 1',
        'body_en' => 'This discusses property law topics.',
    ]);
    Post::factory()->published()->create([
        'title_en' => 'Post 2',
        'body_en' => 'This is about family matters.',
    ]);

    Volt::test('posts.index')
        ->set('search', 'property')
        ->assertSee('Post 1')
        ->assertDontSee('Post 2');
});

it('shows no results message', function () {
    Volt::test('posts.index')
        ->set('search', 'nonexistent')
        ->assertSee('No results found');
});

it('only searches published posts', function () {
    Post::factory()->create(['status' => 'draft', 'title_en' => 'Draft Post']);
    Post::factory()->published()->create(['title_en' => 'Published Post']);

    Volt::test('posts.index')
        ->set('search', 'Post')
        ->assertSee('Published Post')
        ->assertDontSee('Draft Post');
});

Definition of Done

  • Search input on posts page
  • Searches title in both languages
  • Searches body in both languages
  • Real-time results with debounce
  • Clear search button works
  • "No results" message shows
  • Only published posts searched
  • Search highlighting works
  • Tests pass
  • Code formatted with Pint

Dependencies

  • Story 5.4: Public posts display

Estimation

Complexity: Low-Medium Estimated Effort: 2-3 hours