@medyat/react-data-table 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +206 -2
- package/dist/bottom-sheet-NHUT908D.js +1123 -0
- package/dist/bottom-sheet-NHUT908D.js.map +1 -0
- package/dist/columns-dialog-COcldc3X.js +419 -0
- package/dist/columns-dialog-COcldc3X.js.map +1 -0
- package/dist/date-editor-CJFd5YEo.js +378 -0
- package/dist/date-editor-CJFd5YEo.js.map +1 -0
- package/dist/filters-sheet-CWzMaMFo.js +298 -0
- package/dist/filters-sheet-CWzMaMFo.js.map +1 -0
- package/dist/index.d.ts +736 -0
- package/dist/index.js +4769 -0
- package/dist/index.js.map +1 -0
- package/dist/model-RkRyfM7C.js +288 -0
- package/dist/model-RkRyfM7C.js.map +1 -0
- package/dist/provider-CTTHEpE3.js +884 -0
- package/dist/provider-CTTHEpE3.js.map +1 -0
- package/dist/shadcn-bridge-hsl.css +24 -0
- package/dist/shadcn-bridge.css +25 -0
- package/dist/style.css +2 -0
- package/dist/switch-B8TN4kJ-.js +23 -0
- package/dist/switch-B8TN4kJ-.js.map +1 -0
- package/docs/backend-integration.md +596 -0
- package/package.json +95 -4
|
@@ -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.
|