sveltekit-admin 0.5.0 → 0.5.3

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dotNacer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -141,17 +141,22 @@ export function createAdminHandler(config) {
141
141
  * charger 10k lignes pour découvrir qu'il y en a 10k.
142
142
  */
143
143
  const loadRelationOptions = async (model, ctx, currentId) => {
144
- const out = new Map();
145
- for (const edge of relationGraph.edges.values()) {
144
+ const edges = [...relationGraph.edges.values()].filter((edge) => {
146
145
  if (edge.model !== model.name)
147
- continue;
146
+ return false;
148
147
  if (edge.kind !== 'to-one-owning' && edge.kind !== 'm2m-implicit')
149
- continue;
148
+ return false;
150
149
  if (edge.unsupported)
151
- continue;
150
+ return false;
151
+ const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
152
+ return relConfig?.widget !== 'hidden';
153
+ });
154
+ // Une relation ne dépend pas de l'autre : chargées en parallèle plutôt
155
+ // qu'en série (un modèle avec N relations ne doit pas payer N
156
+ // aller-retours DB empilés pour afficher un seul formulaire).
157
+ const entries = await Promise.all(edges.map(async (edge) => {
158
+ const key = `${edge.model}.${edge.field}`;
152
159
  const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
153
- if (relConfig?.widget === 'hidden')
154
- continue;
155
160
  const targetModel = schema.models.find((m) => m.name === edge.target);
156
161
  const where = relConfig?.where ? relConfig.where(ctx) : undefined;
157
162
  const prismaKey = toPrismaModel(edge.target);
@@ -161,8 +166,7 @@ export function createAdminHandler(config) {
161
166
  const selectedIds = edge.kind === 'm2m-implicit' && currentId
162
167
  ? await loadSelectedIds(model, edge, currentId, targetModel)
163
168
  : undefined;
164
- out.set(`${edge.model}.${edge.field}`, { tooMany: true, options: [], selectedIds });
165
- continue;
169
+ return [key, { tooMany: true, options: [], selectedIds }];
166
170
  }
167
171
  const rows = await prisma[prismaKey].findMany({
168
172
  where,
@@ -175,15 +179,15 @@ export function createAdminHandler(config) {
175
179
  const selectedIds = edge.kind === 'm2m-implicit' && currentId
176
180
  ? await loadSelectedIds(model, edge, currentId, targetModel)
177
181
  : undefined;
178
- out.set(`${edge.model}.${edge.field}`, { tooMany: false, options, selectedIds });
182
+ return [key, { tooMany: false, options, selectedIds }];
179
183
  }
180
184
  catch {
181
185
  // Cible absente de la base ou client incomplet : repli raw-id pour
182
186
  // garder le champ éditable plutôt que de faire échouer tout le form.
183
- out.set(`${edge.model}.${edge.field}`, { tooMany: true, options: [] });
187
+ return [key, { tooMany: true, options: [] }];
184
188
  }
185
- }
186
- return out;
189
+ }));
190
+ return new Map(entries);
187
191
  };
188
192
  /**
189
193
  * Options d'un filtre FK : charge et scope les valeurs possibles pour la
@@ -206,43 +210,48 @@ export function createAdminHandler(config) {
206
210
  const relConfig = modelsConfig[model.name]?.relations?.[edge.field];
207
211
  const scope = relConfig?.where ? relConfig.where(ctx) : undefined;
208
212
  const prismaKey = toPrismaModel(edge.target);
209
- // Options de la sidebar : scopées, comptées puis chargées si sous le seuil.
210
- let options = [];
211
- let tooMany = false;
212
- try {
213
- const total = await prisma[prismaKey].count({ where: scope });
214
- if (total > selectThreshold) {
215
- tooMany = true;
216
- }
217
- else {
213
+ // Options de la sidebar (comptées puis chargées si sous le seuil) et
214
+ // label du chip actif (§6.3.b) sont deux requêtes indépendantes — l'une
215
+ // ne dépend pas du résultat de l'autre — donc en parallèle plutôt qu'en
216
+ // série.
217
+ const loadOptions = async () => {
218
+ try {
219
+ const total = await prisma[prismaKey].count({ where: scope });
220
+ if (total > selectThreshold) {
221
+ return { options: [], tooMany: true };
222
+ }
218
223
  const rows = await prisma[prismaKey].findMany({
219
224
  where: scope,
220
225
  orderBy: relConfig?.orderBy
221
226
  });
222
- options = rows.map((row) => ({
227
+ const options = rows.map((row) => ({
223
228
  id: row[primaryKeyOf(targetModel)],
224
229
  label: resolveLabel(targetModel, row, relConfig?.labelTemplate)
225
230
  }));
231
+ return { options, tooMany: false };
226
232
  }
227
- }
228
- catch {
229
- tooMany = true;
230
- }
231
- // Label du chip actif : résolu via findFirst scopé (§6.3.b). Un ID hors
232
- // scope retourne null ici le composant affiche l'ID brut, pas de label.
233
- let activeLabel;
234
- if (activeRawValue !== undefined) {
233
+ catch {
234
+ return { options: [], tooMany: true };
235
+ }
236
+ };
237
+ // Un ID hors scope retourne null ici le composant affiche l'ID brut,
238
+ // jamais le label (sinon c'est un oracle sur le nom d'un enregistrement
239
+ // d'un autre tenant).
240
+ const loadActiveLabel = async () => {
241
+ if (activeRawValue === undefined)
242
+ return undefined;
235
243
  const activeId = coerceId(activeRawValue, targetModel);
236
244
  try {
237
245
  const row = await prisma[prismaKey].findFirst({
238
246
  where: scope ? { AND: [{ [primaryKeyOf(targetModel)]: activeId }, scope] } : { [primaryKeyOf(targetModel)]: activeId }
239
247
  });
240
- activeLabel = row ? resolveLabel(targetModel, row, relConfig?.labelTemplate) : undefined;
248
+ return row ? resolveLabel(targetModel, row, relConfig?.labelTemplate) : undefined;
241
249
  }
242
250
  catch {
243
- activeLabel = undefined;
251
+ return undefined;
244
252
  }
245
- }
253
+ };
254
+ const [{ options, tooMany }, activeLabel] = await Promise.all([loadOptions(), loadActiveLabel()]);
246
255
  return {
247
256
  field: fkFieldName,
248
257
  label,
@@ -280,27 +289,26 @@ export function createAdminHandler(config) {
280
289
  * le rendu du formulaire.
281
290
  */
282
291
  const loadRelatedCounts = async (model, currentId) => {
283
- const out = new Map();
284
- for (const edge of relationGraph.edges.values()) {
285
- if (edge.model !== model.name)
286
- continue;
287
- if (edge.kind !== 'to-many-inverse' && edge.kind !== 'to-one-inverse')
288
- continue;
292
+ const edges = [...relationGraph.edges.values()].filter((edge) => edge.model === model.name && (edge.kind === 'to-many-inverse' || edge.kind === 'to-one-inverse'));
293
+ // Un count par relation inverse, indépendants entre eux : en parallèle
294
+ // plutôt qu'empilés un par un (même raisonnement que loadRelationOptions).
295
+ const entries = await Promise.all(edges.map(async (edge) => {
289
296
  const owning = [...relationGraph.edges.values()].find((o) => o.model === edge.target && o.kind === 'to-one-owning' && o.relationName === edge.relationName);
290
297
  if (!owning || owning.unsupported)
291
- continue;
298
+ return undefined;
292
299
  const scalarName = owning.scalarFields[0];
300
+ const key = `${edge.model}.${edge.field}`;
293
301
  try {
294
302
  const count = await prisma[toPrismaModel(edge.target)].count({
295
303
  where: { [scalarName]: coerceId(currentId, model) }
296
304
  });
297
- out.set(`${edge.model}.${edge.field}`, count);
305
+ return [key, count];
298
306
  }
299
307
  catch {
300
- out.set(`${edge.model}.${edge.field}`, 0);
308
+ return [key, 0];
301
309
  }
302
- }
303
- return out;
310
+ }));
311
+ return new Map(entries.filter((e) => e !== undefined));
304
312
  };
305
313
  /**
306
314
  * Endpoint de recherche `GET {basePath}/_search?rel=Model.field&q=...&page=N`.
@@ -588,14 +596,16 @@ export function createAdminHandler(config) {
588
596
  const where = buildWhere(listQuery, listScope, caseInsensitiveSearch, model);
589
597
  const { items, total } = await listRecords(prisma, model, page, PER_PAGE, where);
590
598
  const listFilters = resolveListFilters(model, schema.enums, modelsConfig[model.name]?.listFilter, toLabel, relationGraph, hiddenFieldsOf(model), config.listFilterDefaults?.autoDetect ?? true);
591
- const fkFilterMeta = new Map();
592
- for (const filter of listFilters) {
593
- if (filter.kind !== 'fk')
594
- continue;
599
+ // Un filtre FK ne dépend pas de l'autre : résolus en parallèle
600
+ // plutôt qu'un par un (même raisonnement que loadRelationOptions).
601
+ const fkFilterEntries = await Promise.all(listFilters
602
+ .filter((filter) => filter.kind === 'fk')
603
+ .map(async (filter) => {
595
604
  const activeRawValue = listQuery.filters.find((f) => f.field === filter.field && f.op === 'equals')?.raw;
596
605
  const meta = await resolveFkFilterOptions(model, filter.field, filter.label, { locals: event.locals }, activeRawValue);
597
- fkFilterMeta.set(filter.field, meta);
598
- }
606
+ return [filter.field, meta];
607
+ }));
608
+ const fkFilterMeta = new Map(fkFilterEntries);
599
609
  content = render(List, {
600
610
  props: {
601
611
  model: viewModel(model),
@@ -32,7 +32,10 @@ export interface ListQuery {
32
32
  filters: ActiveFilter[];
33
33
  ignored: IgnoredFilter[];
34
34
  }
35
- /** Field-name candidates used both for relation labels and for the default search heuristic (§2.1). */
35
+ /**
36
+ * Field-name candidates for the default search heuristic (§2.1). Relation-label
37
+ * resolution (handler.ts) keeps its own separate list — the two are not shared.
38
+ */
36
39
  export declare const DEFAULT_LABEL_FIELDS: string[];
37
40
  /**
38
41
  * Fields eligible for the free-text search box.
@@ -14,8 +14,22 @@
14
14
  import { isSensitiveFieldName } from '../introspection/parser.js';
15
15
  /** Max length accepted for the free-text search term. Longer input is truncated. */
16
16
  const MAX_SEARCH_LENGTH = 200;
17
- /** Field-name candidates used both for relation labels and for the default search heuristic (§2.1). */
18
- export const DEFAULT_LABEL_FIELDS = ['name', 'title', 'label', 'email', 'username', 'slug'];
17
+ /**
18
+ * Field-name candidates for the default search heuristic (§2.1). Relation-label
19
+ * resolution (handler.ts) keeps its own separate list — the two are not shared.
20
+ */
21
+ export const DEFAULT_LABEL_FIELDS = [
22
+ 'name',
23
+ 'title',
24
+ 'label',
25
+ 'email',
26
+ 'username',
27
+ 'slug',
28
+ 'description',
29
+ 'content',
30
+ 'body',
31
+ 'text'
32
+ ];
19
33
  /** Types eligible for the free-text search heuristic (String only, see §2.1). */
20
34
  function isSearchableByHeuristic(field) {
21
35
  return (field.type === 'String' &&
@@ -392,61 +392,114 @@ export function styles(primaryColor) {
392
392
  .ska-filters {
393
393
  margin-bottom: 1.5rem;
394
394
  display: flex;
395
- flex-wrap: wrap;
396
- gap: 1.5rem;
395
+ flex-direction: column;
396
+ gap: 0.75rem;
397
397
  }
398
398
 
399
399
  .ska-filters__group {
400
- min-width: 140px;
400
+ display: flex;
401
+ align-items: center;
402
+ flex-wrap: wrap;
403
+ gap: 0.625rem;
404
+ }
405
+
406
+ .ska-filters__group + .ska-filters__group {
407
+ padding-top: 0.75rem;
408
+ border-top: 1px solid #f1f5f9;
401
409
  }
402
410
 
403
411
  .ska-filters__title {
404
- font-size: 0.75rem;
412
+ font-size: 0.6875rem;
405
413
  font-weight: 600;
406
414
  text-transform: uppercase;
407
415
  letter-spacing: 0.05em;
408
- color: #64748b;
409
- margin-bottom: 0.5rem;
416
+ color: #94a3b8;
417
+ min-width: 88px;
418
+ flex-shrink: 0;
410
419
  }
411
420
 
412
421
  .ska-filters__list {
413
422
  list-style: none;
414
423
  display: flex;
415
- flex-direction: column;
416
- gap: 0.25rem;
424
+ flex-wrap: wrap;
425
+ gap: 0.375rem;
417
426
  }
418
427
 
419
428
  .ska-filters__link {
420
- display: block;
421
- padding: 0.25rem 0.5rem;
422
- border-radius: 0.25rem;
429
+ display: inline-flex;
430
+ align-items: center;
431
+ padding: 0.25rem 0.625rem;
432
+ border-radius: 999px;
433
+ border: 1px solid #e2e8f0;
423
434
  color: #475569;
424
435
  text-decoration: none;
425
- font-size: 0.875rem;
436
+ font-size: 0.8125rem;
437
+ line-height: 1.25rem;
438
+ white-space: nowrap;
439
+ transition: all 0.15s;
426
440
  }
427
441
 
428
442
  .ska-filters__link:hover {
429
443
  background: #f1f5f9;
444
+ border-color: #cbd5e1;
430
445
  }
431
446
 
432
447
  .ska-filters__link--active {
433
- background: #eef2ff;
434
- color: var(--ska-primary);
448
+ background: var(--ska-primary);
449
+ border-color: var(--ska-primary);
450
+ color: white;
435
451
  font-weight: 500;
436
452
  }
437
453
 
438
- .ska-filters__range {
454
+ .ska-filters__range,
455
+ .ska-filters__select {
439
456
  display: flex;
440
- flex-direction: column;
441
- gap: 0.375rem;
457
+ align-items: center;
458
+ gap: 0.5rem;
442
459
  }
443
460
 
444
461
  .ska-filters__range-input {
445
- padding: 0.375rem 0.5rem;
462
+ padding: 0.25rem 0.5rem;
446
463
  border: 1px solid #e2e8f0;
447
464
  border-radius: 0.25rem;
448
465
  font-size: 0.8125rem;
449
- width: 100%;
466
+ width: 6.5rem;
467
+ }
468
+
469
+ .ska-filters__select-input {
470
+ padding: 0.25rem 0.625rem;
471
+ border: 1px solid #e2e8f0;
472
+ border-radius: 0.25rem;
473
+ font-size: 0.8125rem;
474
+ color: #475569;
475
+ background: white;
476
+ max-width: 220px;
477
+ }
478
+
479
+ .ska-filters__chip {
480
+ display: inline-flex;
481
+ align-items: center;
482
+ gap: 0.375rem;
483
+ padding: 0.25rem 0.5rem 0.25rem 0.625rem;
484
+ border-radius: 999px;
485
+ background: #eef2ff;
486
+ color: var(--ska-primary);
487
+ font-size: 0.8125rem;
488
+ }
489
+
490
+ .ska-filters__chip a {
491
+ color: inherit;
492
+ text-decoration: none;
493
+ }
494
+
495
+ .ska-filters__chip-clear {
496
+ color: #64748b;
497
+ text-decoration: none;
498
+ line-height: 1;
499
+ }
500
+
501
+ .ska-filters__chip-clear:hover {
502
+ color: #dc2626;
450
503
  }
451
504
 
452
505
  /* Back link */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sveltekit-admin",
3
- "version": "0.5.0",
3
+ "version": "0.5.3",
4
4
  "description": "Django-like admin panel for SvelteKit + Prisma",
5
5
  "type": "module",
6
6
  "svelte": "./dist/index.js",
@@ -71,7 +71,7 @@
71
71
  "license": "MIT",
72
72
  "repository": {
73
73
  "type": "git",
74
- "url": "https://github.com/dotNacer/sveltekit-admin.git"
74
+ "url": "git+https://github.com/dotNacer/sveltekit-admin.git"
75
75
  },
76
76
  "homepage": "https://github.com/dotNacer/sveltekit-admin#readme",
77
77
  "bugs": {