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
-
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. -
Controller’da
?partial=1query 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.
-
IndexArticleRequestvalidation:HasPaginationtrait’isort’u kendi whitelist’iyle validate ediyor (id/name/sorting). Custom UI sort (most-read,newestvb.) 422 atıyor. Çözüm: FE param adınısortyerineorderyap — IndexArticleRequest bu adı validate etmez, pass through olur. Bkz 2026-04-16-index-article-request-sort-whitelist. -
Controller return type: partial için
response()->view(...)Illuminate\Http\Responsedöner; method signatureView|RedirectResponseise TypeError fırlatır.Responseekle:View|RedirectResponse|Response. -
Alpine component (unified): tek
articleListingx-data search / sort / levels / loadMore / clearAll / dropdown open-close hepsini yönetir. Nested filterDropdown x-data yerine parent’ınopenDropdownstate’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;
},
};
}-
Abort in-flight refresh: hızlı typing sırasında bir önceki fetch’i iptal et —
this._abortCtrl = new AbortController(), fetch’esignalgeçir, yeni refresh başlarken öncekini abort et. -
URL senkronizasyonu: her refresh sonrası
history.replaceStateile 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.
Related
- 2026-04-16-alpine-x-data-payload-and-load-order — Alpine props + timing
- 2026-04-16-index-article-request-sort-whitelist —
sortvalidation collision - Okul Platform — Conventions