@medyat/react-data-table 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,596 @@
1
+ # Backend entegrasyonu (Laravel + Laraql)
2
+
3
+ Bu belge `@medyat/react-data-table`'ı bir Laravel backend'e bağlamak için gereken her şeyi tek yerde toplar. Referans
4
+ uygulama Swors'tur (Laravel API + React SPA); aşağıdaki kod oradan alınmıştır ve çalışır durumdadır. Bir AI asistanına
5
+ "bu belgeye göre tabloyu backend'e bağla" denildiğinde baştan sona uygulanabilecek şekilde yazılmıştır.
6
+
7
+ Paket veri çekmez ve hiçbir şey kaydetmez. İstek nesnesini (`table.request`) üretir, görünüm ve tercih için iki adapter
8
+ arayüzü bekler. Backend'den üç şey gerekir:
9
+
10
+ | # | Ne | Backend | Frontend |
11
+ | --- | --------------------- | ----------------------------------------------- | --------------------------------------------- |
12
+ | 1 | Liste verisi | `POST api/query` (Laraql) | `table.request` → Laraql sorgusu → `result` |
13
+ | 2 | Kişisel görünümler | `list_views` tablosu + `api/list-views` uçları | `ViewsAdapter` |
14
+ | 3 | Kullanıcı tercihleri | `users.preferences` (jsonb) + `PATCH api/auth/preferences` | `PreferencesAdapter` |
15
+
16
+ 2 ve 3 isteğe bağlıdır: verilmezse paket görünüm ve tercihleri kaydetmez; `createLocalStorageViews` ve
17
+ `createLocalStoragePreferences` ile tarayıcıda da tutulabilir. Kullanıcı başka cihazda aynı görünümleri görecekse backend
18
+ gerekir.
19
+
20
+ **Önkoşullar:** PostgreSQL (jsonb operatörleri kullanılıyor; `uuidv7()` PostgreSQL 18'de yerleşik, eski sürümde
21
+ uygulamanın UUID üretimini kullanın), Sanctum ile kimliği doğrulanmış kullanıcı, React 19, TanStack Query (önerilen).
22
+
23
+ ---
24
+
25
+ ## 1. Liste verisi: Laraql
26
+
27
+ ### Backend
28
+
29
+ ```bash
30
+ composer require medyat/laraql
31
+ php artisan vendor:publish --tag="laraql-config"
32
+ ```
33
+
34
+ `config/laraql.php`:
35
+
36
+ ```php
37
+ return [
38
+ 'route' => [
39
+ 'path' => 'api/query',
40
+ 'middleware' => ['auth:sanctum', 'api'], // paket guard varsaymaz, kimlik doğrulamayı buraya koyun
41
+ ],
42
+ 'sources' => [
43
+ App\Laraql\Sources\ProductSource::class,
44
+ ],
45
+ ];
46
+ ```
47
+
48
+ Her liste bir **source**'tur: model, istemcinin okuyabileceği tipli kolonlar ve seçenekler. Ayrıntılar Laraql'ın
49
+ `README.md` ve `docs/protocol.md` belgelerinde. Kısa örnek:
50
+
51
+ ```php
52
+ final class ProductSource extends Source
53
+ {
54
+ public function name(): string { return 'product'; }
55
+ public function model(): string { return Product::class; }
56
+
57
+ public function columns(Context $context): array
58
+ {
59
+ return [
60
+ Column::id('code')->searchable(),
61
+ Column::string('title')->searchable(),
62
+ Column::select('status', ['draft', 'active']),
63
+ Column::money('price', 'TRY'),
64
+ Column::date('created_at'),
65
+ ];
66
+ }
67
+ }
68
+ ```
69
+
70
+ ### Sözleşme: paket ↔ Laraql
71
+
72
+ İkisi aynı protokol için tasarlandı; çeviri katmanı neredeyse yok.
73
+
74
+ | Paket (`table.request`) | Laraql sorgusu | Not |
75
+ | -------------------------------- | --------------------- | ------------------------------------------------------------------------ |
76
+ | `filters: FilterCondition[]` | `filters` | 18 operatör birebir aynı (`eq`, `contains`, `between`, `within`, ...) |
77
+ | `sort: [{ column, dir }]` | `sort` | Aynı biçim, en fazla 3 kural |
78
+ | `page: { number, size }` | `page` | 1'den başlar |
79
+ | `fields: string[]` | `fields` | Görünür sütunların yolları |
80
+ | `search: string` | `search` | Boşsa göndermeyin |
81
+ | — | `source`, `options`, `params` | Uygulamanın ekledikleri (ör. `options: ["only_trashed"]`) |
82
+
83
+ **Kural:** `ColumnDef.id` ile Laraql kolon adı aynıdır (`code`, `company.title`, `variables.color`). Farklı olursa
84
+ filtre ve sıralama yanlış kolona gider.
85
+
86
+ İstek ve yanıt:
87
+
88
+ ```jsonc
89
+ // POST api/query
90
+ { "queries": { "result": { "source": "product", "fields": ["code", "title"], "page": { "number": 1, "size": 100 } } } }
91
+
92
+ // 200
93
+ { "results": { "result": { "rows": [...], "page": { "number": 1, "size": 100, "hasMore": true }, "ignored": ["sort.0"] } } }
94
+
95
+ // Sorgu hatası da 200 ile gelir: { "results": { "result": { "error": { "type": "...", "status": 422, "errors": {...} } } } }
96
+ ```
97
+
98
+ - Toplam sayı yoktur; `hasMore` `size + 1` satır okunarak bulunur. Toplam istenirse `kind: "count"` sorgusu ayrı
99
+ gönderilir ve `useDataTable({ count })` ile footer'daki "Count total"a bağlanır.
100
+ - `ignored`: sunucunun atladığı yollar (eski bir görünümde kalmış, artık olmayan sütun gibi). Satırlar doğrudur, liste
101
+ çalışmaya devam eder.
102
+
103
+ ### Frontend
104
+
105
+ `lib/laraql.ts` (Swors'taki hali; `api` uygulamanın axios örneğidir):
106
+
107
+ ```ts
108
+ import { keepPreviousData, queryOptions } from '@tanstack/react-query'
109
+ import type { FilterCondition, SortRule } from '@medyat/react-data-table'
110
+
111
+ export type LaraqlQuery = {
112
+ source: string
113
+ fields?: string[]
114
+ filters?: FilterCondition[]
115
+ sort?: SortRule[]
116
+ page?: { number: number; size: number }
117
+ search?: string
118
+ options?: string[]
119
+ params?: Record<string, unknown>
120
+ }
121
+
122
+ export type LaraqlResult<TRow> = {
123
+ rows: TRow[]
124
+ page: { number: number; size: number; hasMore: boolean }
125
+ ignored?: string[]
126
+ }
127
+
128
+ async function runQuery<TRow>(query: LaraqlQuery, signal?: AbortSignal) {
129
+ const { data } = await api.post('api/query', { queries: { result: query } }, { signal })
130
+ const result = data.results.result
131
+ // Sorgu hatası HTTP 200 ile gelir; fırlatılmazsa TanStack Query başarı sayar.
132
+ if ('error' in result) throw toError(result.error)
133
+ return result as LaraqlResult<TRow>
134
+ }
135
+
136
+ export const laraqlQueryOptions = <TRow>(query: LaraqlQuery) =>
137
+ queryOptions({
138
+ queryKey: ['laraql', query],
139
+ queryFn: ({ signal }) => runQuery<TRow>(query, signal),
140
+ placeholderData: keepPreviousData, // filtre/sayfa değişirken eski satırlar yerinde kalsın
141
+ })
142
+ ```
143
+
144
+ Ekran:
145
+
146
+ ```tsx
147
+ const table = useDataTable({
148
+ id: 'products', // görünüm ve tercihlerin anahtarı (list_key); [a-z0-9-]
149
+ columns,
150
+ entityName: { one: 'product', other: 'products' },
151
+ views: [{ id: 'all', name: t('products.views.all'), kind: 'system', state: {}, default: true }],
152
+ })
153
+ const { search, ...rest } = table.request
154
+ const result = useQuery({
155
+ ...laraqlQueryOptions<ProductListItem>({ source: 'product', ...rest, ...(search && { search }) }),
156
+ select: (d) => ({ rows: d.rows, hasMore: d.page.hasMore }),
157
+ })
158
+ return <DataTable table={table} result={result} />
159
+ ```
160
+
161
+ ---
162
+
163
+ ## 2. Kişisel görünümler: `list_views`
164
+
165
+ Görünüm = filtre + sıralama + sütun düzeni (`ViewState`). Arama, yoğunluk ve sayfa görünüme yazılmaz. Sunucu `state`'i
166
+ yorumlamaz, olduğu gibi saklar. Kayıt yalnızca sahibine görünür; paylaşım yoktur.
167
+
168
+ ### Migration
169
+
170
+ ```php
171
+ Schema::create('list_views', function (Blueprint $table) {
172
+ $table->uuid('id')->primary()->default(DB::raw('uuidv7()'));
173
+ // Çok kiracılı uygulamada: $table->unsignedInteger('tenant_id')->index();
174
+ $table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete(); // users.id int ise foreignId
175
+ // İstemcinin tablo kimliği (`useDataTable({ id })`); sunucu bu listeyi tanımaz, yalnızca gruplar.
176
+ $table->string('list_key', 64);
177
+ $table->string('name', 60);
178
+ // Paketin {filters, sort, columns} anlık görüntüsü.
179
+ $table->jsonb('state');
180
+ $table->timestamps();
181
+ });
182
+
183
+ // Ad, aynı kullanıcının aynı listesinde büyük/küçük harf farkı gözetmeden tekildir.
184
+ DB::statement('CREATE UNIQUE INDEX list_views_owner_name_unique ON list_views (user_id, list_key, lower(name))');
185
+ ```
186
+
187
+ Çok kiracılıysa `tenant_id`'yi indeksin başına ekleyin ve modele kiracı kapsamını (Swors'ta `IsTenantModel`) verin.
188
+
189
+ ### Model
190
+
191
+ ```php
192
+ class ListView extends Model
193
+ {
194
+ protected $guarded = ['id'];
195
+
196
+ protected function casts(): array
197
+ {
198
+ return ['state' => 'array'];
199
+ }
200
+
201
+ public function user(): BelongsTo
202
+ {
203
+ return $this->belongsTo(User::class);
204
+ }
205
+ }
206
+ ```
207
+
208
+ `id` UUID ise modele `HasUuids` ekleyin ya da veritabanı varsayılanına bırakın (Swors ikincisini kullanır).
209
+
210
+ ### Uç noktalar
211
+
212
+ ```php
213
+ Route::middleware('auth:sanctum')->group(function () {
214
+ Route::apiResource('list-views', ListViewController::class)->only(['index', 'store', 'update', 'destroy']);
215
+ });
216
+ ```
217
+
218
+ | Metot | Yol | Gövde / parametre | Yanıt |
219
+ | -------- | ------------------------- | ------------------------------------------ | -------------------------------------- |
220
+ | `GET` | `api/list-views?list=products` | — | `{ data: [{ id, name, state, updated_at }] }`, ada göre sıralı |
221
+ | `POST` | `api/list-views` | `{ list, name, state }` | `{ data: { id, name, state, updated_at } }` |
222
+ | `PUT` | `api/list-views/{id}` | `{ name?, state? }` (`list` yasak) | `{ data: { id, name, state, updated_at } }` |
223
+ | `DELETE` | `api/list-views/{id}` | — | `204` |
224
+
225
+ Başkasının kaydı **404** döner (403 değil): kaydın varlığı sızdırılmaz.
226
+
227
+ ### Controller
228
+
229
+ ```php
230
+ class ListViewController extends Controller
231
+ {
232
+ public function index(Request $request)
233
+ {
234
+ $request->validate(['list' => ['required', 'string', 'max:64', 'regex:/^[a-z0-9-]+$/']]);
235
+
236
+ return JsonResource::collection(
237
+ ListView::query()
238
+ ->whereBelongsTo($request->user())
239
+ ->where('list_key', $request->query('list'))
240
+ ->orderBy('name')
241
+ ->get(['id', 'name', 'state', 'updated_at'])
242
+ );
243
+ }
244
+
245
+ public function store(ListViewRequest $request)
246
+ {
247
+ $data = $request->validated();
248
+
249
+ return $this->output(ListView::create([
250
+ 'user_id' => $request->user()->getKey(),
251
+ 'list_key' => $data['list'],
252
+ 'name' => $data['name'],
253
+ 'state' => $data['state'],
254
+ ]));
255
+ }
256
+
257
+ public function update(ListViewRequest $request, ListView $listView)
258
+ {
259
+ $this->guardOwner($request, $listView);
260
+ $listView->update($request->validated());
261
+
262
+ return $this->output($listView);
263
+ }
264
+
265
+ public function destroy(Request $request, ListView $listView, DeleteListViewAction $delete)
266
+ {
267
+ $this->guardOwner($request, $listView);
268
+ $delete($listView);
269
+
270
+ return response()->noContent();
271
+ }
272
+
273
+ private function guardOwner(Request $request, ListView $listView): void
274
+ {
275
+ abort_unless($listView->user_id === $request->user()->getKey(), 404);
276
+ }
277
+
278
+ private function output(ListView $listView): JsonResource
279
+ {
280
+ return new JsonResource($listView->only(['id', 'name', 'state', 'updated_at']));
281
+ }
282
+ }
283
+ ```
284
+
285
+ ### Doğrulama
286
+
287
+ ```php
288
+ class ListViewRequest extends FormRequest
289
+ {
290
+ private const int MAX_STATE_BYTES = 32768;
291
+
292
+ public function rules(): array
293
+ {
294
+ $listView = $this->route('list_view');
295
+ $required = $listView ? 'sometimes' : 'required';
296
+
297
+ return [
298
+ 'list' => [$listView ? 'prohibited' : 'required', 'string', 'max:64', 'regex:/^[a-z0-9-]+$/'],
299
+ 'name' => [$required, 'string', 'max:60', $this->uniqueName($listView)],
300
+ 'state' => [$required, 'array:filters,sort,columns', function ($attribute, $value, $fail) {
301
+ if (strlen(json_encode($value)) > self::MAX_STATE_BYTES) {
302
+ $fail('validation.max.string')->translate(['max' => self::MAX_STATE_BYTES]);
303
+ }
304
+ }],
305
+ 'state.filters' => ['sometimes', 'array'],
306
+ 'state.sort' => ['sometimes', 'array'],
307
+ 'state.columns' => ['sometimes', 'array'],
308
+ ];
309
+ }
310
+
311
+ protected function prepareForValidation(): void
312
+ {
313
+ if ($this->has('name')) {
314
+ $this->merge(['name' => trim((string) $this->input('name'))]);
315
+ }
316
+ }
317
+
318
+ // Veritabanı indeksiyle aynı kural; indeks yarışta son savunmadır, bu kural kullanıcıya alan hatası verir.
319
+ private function uniqueName(?ListView $listView): Closure
320
+ {
321
+ return function ($attribute, $value, $fail) use ($listView) {
322
+ $taken = ListView::query()
323
+ ->where('user_id', $this->user()->getKey())
324
+ ->where('list_key', $listView?->list_key ?? $this->input('list'))
325
+ ->whereRaw('lower(name) = lower(?)', [$value])
326
+ ->when($listView, fn ($q) => $q->whereKeyNot($listView->getKey()))
327
+ ->exists();
328
+
329
+ if ($taken) {
330
+ $fail('validation.unique')->translate();
331
+ }
332
+ };
333
+ }
334
+ }
335
+ ```
336
+
337
+ Paket ad çakışmasındaki 422'yi kaydetme penceresinde `name` alanının altında gösterir; mesaj backend'den gelir.
338
+
339
+ ### Silme: varsayılan tercihi de temizle
340
+
341
+ Silinen görünüm kullanıcının o listedeki varsayılanıysa tercihten de düşülür; yoksa istemci olmayan bir id'ye açılır.
342
+
343
+ ```php
344
+ class DeleteListViewAction
345
+ {
346
+ public function __invoke(ListView $listView): void
347
+ {
348
+ DB::transaction(function () use ($listView) {
349
+ $listView->delete();
350
+
351
+ // list_key [a-z0-9-] ile sınırlı; dizi literal'inde kaçış gerekmez.
352
+ User::query()
353
+ ->whereKey($listView->user_id)
354
+ ->whereRaw('preferences #>> ?::text[] = ?', ["{lists,{$listView->list_key},default_view_id}", $listView->getKey()])
355
+ ->update(['preferences' => DB::raw("preferences #- '{lists,{$listView->list_key},default_view_id}'")]);
356
+ });
357
+ }
358
+ }
359
+ ```
360
+
361
+ ---
362
+
363
+ ## 3. Tercihler: `users.preferences`
364
+
365
+ Yoğunluk, dikey çizgiler ve varsayılan görünüm. Biçim:
366
+
367
+ ```json
368
+ { "lists": { "products": { "default_view_id": "0193…", "density": "compact", "vertical_lines": true } } }
369
+ ```
370
+
371
+ Swors sayfa boyutunu adreste tutar, tercihe yazmaz. Adresi kullanmayan bir uygulamada `page_size` alanını aynı kalıpla
372
+ ekleyebilirsiniz.
373
+
374
+ ### Migration ve model
375
+
376
+ ```php
377
+ Schema::table('users', function (Blueprint $table) {
378
+ $table->jsonb('preferences')->nullable();
379
+ });
380
+ ```
381
+
382
+ `User` modelinde:
383
+
384
+ ```php
385
+ protected $hidden = [/* ... */, 'preferences']; // yalnızca kendi profilinde döner
386
+ protected function casts(): array { return [/* ... */ 'preferences' => 'array']; }
387
+ ```
388
+
389
+ Profil kaynağına (`GET api/auth/profile` ya da uygulamadaki karşılığı) `'preferences' => $this->preferences` ekleyin.
390
+ Yeni kullanıcıda `null` gelir; bazı yanıt sarmalayıcıları boş nesneyi `[]` yazar, istemci alanları isteğe bağlı okumalı.
391
+
392
+ ### Uç nokta
393
+
394
+ ```php
395
+ Route::patch('auth/preferences', [ProfileController::class, 'preferences'])->middleware('auth:sanctum');
396
+ ```
397
+
398
+ ```php
399
+ public function preferences(UpdatePreferencesRequest $request, UpdateUserPreferencesAction $update)
400
+ {
401
+ $update($request->user(), $request->validated('lists'));
402
+
403
+ return new UserProfileResource($request->user()->fresh()); // güncel profil döner
404
+ }
405
+ ```
406
+
407
+ Her istek kısmi bir yamadır: `{ "lists": { "products": { "density": "compact" } } }` yalnızca o alanı değiştirir.
408
+
409
+ ```php
410
+ class UpdatePreferencesRequest extends FormRequest
411
+ {
412
+ public function rules(): array
413
+ {
414
+ return [
415
+ 'lists' => ['required', 'array', 'min:1', 'max:10', function ($attribute, array $value, $fail) {
416
+ foreach (array_keys($value) as $key) {
417
+ if (! is_string($key) || strlen($key) > 64 || ! preg_match('/^[a-z0-9-]+$/', $key)) {
418
+ $fail('validation.regex')->translate();
419
+ }
420
+ }
421
+ }],
422
+ 'lists.*' => ['required', 'array:default_view_id,density,vertical_lines', 'min:1'],
423
+ 'lists.*.default_view_id' => ['sometimes', 'nullable', 'string', 'max:64'],
424
+ 'lists.*.density' => ['sometimes', Rule::in(['comfortable', 'standard', 'compact'])],
425
+ 'lists.*.vertical_lines' => ['sometimes', 'boolean'],
426
+ ];
427
+ }
428
+ }
429
+ ```
430
+
431
+ Birleştirme veritabanında yapılır: iki sekme farklı alanları aynı anda yazınca biri diğerini ezmez. Kullanıcı başına
432
+ 64 KB sınırı, bilinmeyen liste anahtarlarıyla sütunun şişirilmesini engeller.
433
+
434
+ ```php
435
+ class UpdateUserPreferencesAction
436
+ {
437
+ public const int MAX_BYTES = 65536;
438
+
439
+ public function __invoke(User $user, array $lists): void
440
+ {
441
+ DB::transaction(function () use ($user, $lists) {
442
+ foreach ($lists as $key => $values) {
443
+ $updated = DB::update(<<<'SQL'
444
+ UPDATE users SET preferences = jsonb_set(
445
+ coalesce(preferences, '{}'),
446
+ '{lists}',
447
+ coalesce(preferences -> 'lists', '{}') || jsonb_build_object(
448
+ ?::text,
449
+ coalesce(preferences #> ?::text[], '{}') || ?::jsonb
450
+ )
451
+ )
452
+ WHERE id = ? AND octet_length(coalesce(preferences, '{}')::text) < ?
453
+ SQL, [$key, "{lists,{$key}}", json_encode((object) $values), $user->getKey(), self::MAX_BYTES]);
454
+
455
+ abort_if($updated === 0, 422, __('messages.preferences.too_large'));
456
+ }
457
+ });
458
+ }
459
+ }
460
+ ```
461
+
462
+ ---
463
+
464
+ ## 4. Frontend: adapter'lar ve sağlayıcı
465
+
466
+ Sunucu `snake_case` döner, paket `camelCase` bekler; çeviri adapter'dadır. Aşağıdaki kod yanıtların `{ data: ... }`
467
+ zarfıyla geldiğini ve `api`'nin zarfı açtığını varsayar (Swors'ta öyle); farklıysa yalnızca `data` okumalarını uyarlayın.
468
+
469
+ ```ts
470
+ import type { PreferencesAdapter, View, ViewsAdapter } from '@medyat/react-data-table'
471
+
472
+ type ListView = Pick<View, 'id' | 'name' | 'state'> & { updated_at: string }
473
+
474
+ const toView = ({ updated_at, ...view }: ListView): View => ({ ...view, kind: 'personal', updatedAt: updated_at })
475
+
476
+ // Eksik alan pakete `undefined` olarak gitmemeli: tercihin yokluğu varsayılanı ezmesin.
477
+ const defined = <T extends object>(object: T) =>
478
+ Object.fromEntries(Object.entries(object).filter(([, v]) => v !== undefined)) as Partial<T>
479
+
480
+ const profileKey = profileQueryOptions().queryKey
481
+ const viewsKey = (list: string) => ['list-views', list]
482
+
483
+ // Görünümler yalnızca bu adapter'dan değişir: önbellek yazımlarla güncel tutulur, hiç bayatlamaz.
484
+ // Aynı anda gelen iki çağrı (StrictMode) tek isteği paylaşır.
485
+ const updateViews = (list: string, change: (views: View[]) => View[]) =>
486
+ queryClient.setQueryData<View[]>(viewsKey(list), (views) => (views ? change(views) : views))
487
+
488
+ export const viewsAdapter: ViewsAdapter = {
489
+ list: (list) =>
490
+ queryClient.fetchQuery({
491
+ queryKey: viewsKey(list),
492
+ queryFn: async ({ signal }) => {
493
+ const { data } = await api.get<ListView[]>('api/list-views', { params: { list }, signal })
494
+ return data.map(toView)
495
+ },
496
+ staleTime: Infinity,
497
+ }),
498
+ async create(list, input) {
499
+ const { data } = await api.post<ListView>('api/list-views', { list, ...input })
500
+ const view = toView(data)
501
+ updateViews(list, (views) => [...views, view])
502
+ return view
503
+ },
504
+ async update(list, id, patch) {
505
+ const { data } = await api.put<ListView>(`api/list-views/${id}`, patch)
506
+ const view = toView(data)
507
+ updateViews(list, (views) => views.map((v) => (v.id === id ? view : v)))
508
+ return view
509
+ },
510
+ async remove(list, id) {
511
+ await api.delete(`api/list-views/${id}`)
512
+ updateViews(list, (views) => views.filter((v) => v.id !== id))
513
+ // Sunucu silinen varsayılanı tercihten temizledi; profil önbelleği de aynısını yapmalı.
514
+ queryClient.setQueryData(profileKey, (profile) => {
515
+ const saved = profile?.preferences?.lists?.[list]
516
+ if (!profile || saved?.default_view_id !== id) return profile
517
+ return {
518
+ ...profile,
519
+ preferences: {
520
+ ...profile.preferences,
521
+ lists: { ...profile.preferences?.lists, [list]: { ...saved, default_view_id: undefined } },
522
+ },
523
+ }
524
+ })
525
+ },
526
+ }
527
+
528
+ /** Tercihler profil önbelleğinden eşzamanlı okunur: profil, tablo açılmadan önce yüklenmiş olmalı. */
529
+ export const preferencesAdapter: PreferencesAdapter = {
530
+ get(list) {
531
+ const saved = queryClient.getQueryData(profileKey)?.preferences?.lists?.[list]
532
+ return defined({
533
+ density: saved?.density,
534
+ verticalLines: saved?.vertical_lines,
535
+ defaultViewId: saved?.default_view_id,
536
+ })
537
+ },
538
+ async set(list, { density, verticalLines, defaultViewId }) {
539
+ const patch = defined({ density, vertical_lines: verticalLines, default_view_id: defaultViewId })
540
+ if (!Object.keys(patch).length) return
541
+
542
+ const { data } = await api.patch('api/auth/preferences', { lists: { [list]: patch } })
543
+ queryClient.setQueryData(profileKey, data)
544
+ },
545
+ }
546
+ ```
547
+
548
+ Sağlayıcı, oturum açılmış düzenin (layout) en dışına bir kez konur:
549
+
550
+ ```tsx
551
+ import { DataTableProvider } from '@medyat/react-data-table'
552
+ import '@medyat/react-data-table/style.css'
553
+ import '@medyat/react-data-table/shadcn-bridge.css' // shadcn teması varsa; HSL tema: shadcn-bridge-hsl.css
554
+
555
+ <DataTableProvider locale={i18n.language} viewsAdapter={viewsAdapter} preferences={preferencesAdapter}>
556
+ <Outlet />
557
+ </DataTableProvider>
558
+ ```
559
+
560
+ `preferences.get` eşzamanlıdır: profil sorgusu bu düzenin route loader'ında (`ensureQueryData`) beklenmelidir, yoksa
561
+ ilk açılışta tercihler boş okunur.
562
+
563
+ ### Durumu adrese bağlamak (isteğe bağlı)
564
+
565
+ Swors filtre, sıralama, arama, sayfa ve görünümü URL'de tutar; paylaşılan bağlantı aynı listeyi açar. README'deki
566
+ "Durumu adrese bağlamak" bölümüne bakın. Swors'ta `useListState(search)` bunu yapar ve route `validateSearch`'ü elle
567
+ bozulmuş değeri sessizce atar (`zod` + `.catch(undefined)`).
568
+
569
+ ---
570
+
571
+ ## 5. AI için uygulama listesi
572
+
573
+ Sırayla:
574
+
575
+ 1. **Backend, Laraql:** `medyat/laraql` kur, `config/laraql.php`'de `route.path = 'api/query'` ve `auth:sanctum`, listedeki
576
+ her ekran için bir `Source` yaz ve `sources`'a ekle.
577
+ 2. **Backend, görünümler:** `list_views` migration'ı + tekillik indeksi, `ListView` modeli, `ListViewRequest`,
578
+ `ListViewController`, `DeleteListViewAction`, `api/list-views` route'u.
579
+ 3. **Backend, tercihler:** `users.preferences` jsonb, `User::$hidden` + cast, profil yanıtına `preferences`,
580
+ `UpdatePreferencesRequest`, `UpdateUserPreferencesAction`, `PATCH api/auth/preferences`,
581
+ `messages.preferences.too_large` çevirisi.
582
+ 4. **Backend testleri:** başkasının görünümü 404; ad harf farkı gözetmeden tekil; varsayılan görünüm silinince tercih
583
+ temizlenir; tercih yaması öteki alanları korur; 64 KB sınırı 422.
584
+ 5. **Frontend:** paketi ve peer'ları kur (`@base-ui/react`, `@phosphor-icons/react`, `write-excel-file`), CSS'leri içe aktar,
585
+ `laraqlQueryOptions`, iki adapter, `DataTableProvider`, profili layout loader'ında yükle.
586
+ 6. **Her liste ekranı:** `useDataTable({ id, columns, views })`; `id` = `list_key` (`[a-z0-9-]`, en fazla 64);
587
+ `ColumnDef.id` = Laraql kolon adı; boş `search`'ü gönderme; `select` ile `{ rows, hasMore }`'a çevir.
588
+
589
+ ## Sık hatalar
590
+
591
+ - **Laraql hatası başarı sayılıyor:** sorgu hatası HTTP 200 içinde `{ error }` olarak gelir; `queryFn` fırlatmalıdır.
592
+ - **Tercihler ilk açılışta uygulanmıyor:** profil, tablo çizilmeden önce önbellekte değil.
593
+ - **`undefined` tercih varsayılanı eziyor:** adapter eksik alanları nesneden çıkarmalı (`defined`).
594
+ - **Silinen varsayılan görünüm:** sunucu temizler, ama profil önbelleği de güncellenmezse liste olmayan görünüme açılır.
595
+ - **Ad çakışması 500 dönüyor:** doğrulama kuralı yok, yalnızca indeks var; kural 422 ve alan hatası üretir.
596
+ - **Görünüm sütunu artık yok:** sorun değil; Laraql `ignored`'a yazar, tablo bilinmeyen sütunu atlar.