Proje: Okul Platform · Hub: Okul Platform — Conventions

Responsive listeleme sayfalarında (article, school listing vb.) search/sort/ filter/pagination değişimlerinde tam sayfa yenileme yerine sadece grid’i AJAX ile yenileme pattern’ı. OKUL-770 makale listesi için kuruldu, diğer responsive listing sayfalarında aynı yapı kullanılacak.

Kural seti

  1. Grid item’ları ayrı partial: responsive/xxx/_grid-items.blade.php — yalnız @foreach($items as $item) @include('...card', [...]) @endforeach. Wrapper div YOK, sadece item’lar dönmeli.

  2. Controller’da ?partial=1 query için erken return:

if ($request->boolean('partial')) {
    return response()->view('responsive.article._grid-items', [
        'articles'           => $data['articles'],
        'articles_spec_name' => 'article_page_content',
        'gaEventCat'         => $data['gaEventCat'],
    ])
        ->header('X-Has-More',     $data['articles']->hasMorePages() ? '1' : '0')
        ->header('X-Current-Page', (string) $data['articles']->currentPage())
        ->header('X-Last-Page',    (string) $data['articles']->lastPage())
        ->header('X-Total',        (string) $data['articles']->total());
}

Partial response metadata/schema/redirectChecker hepsini bypass eder — sadece grid markup + response header’lar.

  1. IndexArticleRequest validation: HasPagination trait’i sort’u kendi whitelist’iyle validate ediyor (id/name/sorting). Custom UI sort (most-read, newest vb.) 422 atıyor. Çözüm: FE param adını sort yerine order yap — IndexArticleRequest bu adı validate etmez, pass through olur. Bkz 2026-04-16-index-article-request-sort-whitelist.

  2. Controller return type: partial için response()->view(...) Illuminate\Http\Response döner; method signature View|RedirectResponse ise TypeError fırlatır. Response ekle: View|RedirectResponse|Response.

  3. Alpine component (unified): tek articleListing x-data search / sort / levels / loadMore / clearAll / dropdown open-close hepsini yönetir. Nested filterDropdown x-data yerine parent’ın openDropdown state’i.

function articleListing(options) {
    return {
        query: options.initialQuery || '',
        order: options.initialOrder || 'most-read',
        levels: [...],
        basePath: options.basePath,
        currentPage: options.currentPage,
        lastPage: options.lastPage,
        total: options.total,
        loading: false,
        replacing: false,
 
        get hasMore() { return this.currentPage < this.lastPage; },
 
        _buildUrl(page, partial) {
            const p = new URLSearchParams();
            if (this.query.trim()) p.set('q', this.query.trim());
            if (this.order !== 'most-read') p.set('order', this.order);
            this.levels.forEach(l => p.append('levels[]', l));
            if (page > 1) p.set('page', String(page));
            if (partial) p.set('partial', '1');
            const qs = p.toString();
            return this.basePath + (qs ? '?' + qs : '');
        },
 
        async refresh() {            // filter/search değişiminde — REPLACE
            this.replacing = true;
            const res = await fetch(this._buildUrl(1, true), {
                headers: { 'X-Requested-With': 'XMLHttpRequest' },
                credentials: 'same-origin',
            });
            const html = await res.text();
            this.$refs.grid.innerHTML = html;
            this.currentPage = parseInt(res.headers.get('X-Current-Page') || '1', 10);
            this.lastPage    = parseInt(res.headers.get('X-Last-Page')    || '1', 10);
            this.total       = parseInt(res.headers.get('X-Total')        || '0', 10);
            history.replaceState(null, '', this._buildUrl(1, false));
            this.replacing = false;
        },
 
        async loadMore() {           // "Daha Fazla" — APPEND
            if (this.loading || !this.hasMore) return;
            this.loading = true;
            const next = this.currentPage + 1;
            const res = await fetch(this._buildUrl(next, true), { ... });
            const html = await res.text();
            const tmp = document.createElement('div');
            tmp.innerHTML = html.trim();
            const frag = document.createDocumentFragment();
            while (tmp.firstChild) frag.appendChild(tmp.firstChild);
            this.$refs.grid.appendChild(frag);
            this.currentPage = next;
            this.loading = false;
        },
    };
}
  1. Abort in-flight refresh: hızlı typing sırasında bir önceki fetch’i iptal et — this._abortCtrl = new AbortController(), fetch’e signal geçir, yeni refresh başlarken öncekini abort et.

  2. URL senkronizasyonu: her refresh sonrası history.replaceState ile URL’yi güncel state’e al (shareable link, reload durumunda doğru initial state).

Empty state + loading overlay

<div x-show="total > 0" style="display: {{ $articles->isEmpty() ? 'none' : 'block' }};">
    <div id="article-list-grid" x-ref="grid" class="...grid...">
        @include('responsive.xxx._grid-items', [...])
    </div>
    <!-- Load More button x-show="hasMore" -->
</div>
<div x-show="total === 0 && !replacing" style="display: {{ $articles->isEmpty() ? 'block' : 'none' }};">
    Sonuç bulunamadı
</div>
<!-- Refresh overlay x-show="replacing" absolute inset-0 -->

Initial state (server-render) ile Alpine state tutarlı olsun diye hem style="display:..." fallback’i hem x-show birlikte.