sveltekit-admin 0.6.0 → 0.9.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/README.md +61 -4
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -1
- package/dist/server/adapters/drizzle/dataAdapter.js +196 -42
- package/dist/server/adapters/drizzle/index.d.ts +10 -1
- package/dist/server/adapters/drizzle/index.js +4 -0
- package/dist/server/adapters/prisma/dataAdapter.js +71 -11
- package/dist/server/adapters/prisma/handler.d.ts +14 -0
- package/dist/server/adapters/prisma/handler.js +37 -0
- package/dist/server/adapters/retry.d.ts +27 -0
- package/dist/server/adapters/retry.js +53 -0
- package/dist/server/adapters/types.d.ts +42 -3
- package/dist/server/audit.d.ts +65 -0
- package/dist/server/audit.js +106 -0
- package/dist/server/csrf.d.ts +33 -0
- package/dist/server/csrf.js +55 -0
- package/dist/server/data.d.ts +18 -2
- package/dist/server/data.js +39 -8
- package/dist/server/errors.d.ts +47 -0
- package/dist/server/errors.js +90 -0
- package/dist/server/handler.d.ts +92 -26
- package/dist/server/handler.js +263 -560
- package/dist/server/introspection/parser.d.ts +15 -0
- package/dist/server/introspection/parser.js +17 -0
- package/dist/server/mutations.d.ts +13 -0
- package/dist/server/mutations.js +476 -0
- package/dist/server/plugin.d.ts +47 -0
- package/dist/server/plugin.js +1 -0
- package/dist/server/pluginAccess.d.ts +7 -0
- package/dist/server/pluginAccess.js +79 -0
- package/dist/server/pluginRegistry.d.ts +12 -0
- package/dist/server/pluginRegistry.js +72 -0
- package/dist/server/query/listColumns.d.ts +19 -0
- package/dist/server/query/listColumns.js +43 -0
- package/dist/server/query/listQuery.d.ts +1 -1
- package/dist/server/query/pageSize.d.ts +19 -0
- package/dist/server/query/pageSize.js +26 -0
- package/dist/server/query/sortQuery.d.ts +31 -0
- package/dist/server/query/sortQuery.js +33 -0
- package/dist/server/query/urls.js +9 -1
- package/dist/server/relationLoaders.d.ts +42 -0
- package/dist/server/relationLoaders.js +188 -0
- package/dist/server/router.d.ts +10 -0
- package/dist/server/router.js +42 -19
- package/dist/server/runtime.d.ts +51 -0
- package/dist/server/runtime.js +263 -0
- package/dist/server/search.d.ts +14 -0
- package/dist/server/search.js +78 -0
- package/dist/server/submitted.d.ts +20 -0
- package/dist/server/submitted.js +54 -0
- package/dist/server/views/FieldInput.svelte +114 -8
- package/dist/server/views/FieldInput.svelte.d.ts +7 -0
- package/dist/server/views/Form.svelte +96 -5
- package/dist/server/views/Form.svelte.d.ts +19 -1
- package/dist/server/views/Layout.svelte +32 -4
- package/dist/server/views/Layout.svelte.d.ts +2 -0
- package/dist/server/views/List.svelte +156 -26
- package/dist/server/views/List.svelte.d.ts +7 -2
- package/dist/server/views/RelationCheckboxes.svelte +26 -5
- package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
- package/dist/server/views/RelationSelect.svelte +14 -3
- package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
- package/dist/server/views/html.d.ts +19 -0
- package/dist/server/views/html.js +25 -0
- package/dist/server/views/pagination.d.ts +9 -0
- package/dist/server/views/pagination.js +31 -0
- package/dist/server/views/theme.js +147 -3
- package/dist/server/views/types.d.ts +15 -0
- package/package.json +24 -21
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { AdminHandlerConfig } from '../handler.js';
|
|
3
|
-
import type { ViewModel } from './types.js';
|
|
3
|
+
import type { ViewModel, ListRecordAction, FkFilterMeta } from './types.js';
|
|
4
4
|
import type { ListQuery } from '../query/listQuery.js';
|
|
5
5
|
import type { ResolvedFilterField } from '../query/filterDetection.js';
|
|
6
|
+
import type { SortState } from '../query/sortQuery.js';
|
|
6
7
|
import { DATETIME_PRESETS } from '../query/filterDetection.js';
|
|
7
|
-
import
|
|
8
|
-
import { getDisplayFields } from '../introspection/parser.js';
|
|
8
|
+
import { resolveListColumns } from '../query/listColumns.js';
|
|
9
9
|
import { buildListUrl, hiddenParams } from '../query/urls.js';
|
|
10
|
+
import { paginationWindow } from './pagination.js';
|
|
10
11
|
import { escapeHtml, toLabel, formatValue } from './html.js';
|
|
11
12
|
import ListFilters from './ListFilters.svelte';
|
|
12
13
|
|
|
@@ -19,7 +20,10 @@
|
|
|
19
20
|
query,
|
|
20
21
|
currentUrl,
|
|
21
22
|
listFilters,
|
|
22
|
-
fkFilterMeta
|
|
23
|
+
fkFilterMeta,
|
|
24
|
+
sort,
|
|
25
|
+
pageSizes = [],
|
|
26
|
+
recordActions = []
|
|
23
27
|
}: {
|
|
24
28
|
model: ViewModel;
|
|
25
29
|
items: any[];
|
|
@@ -34,30 +38,59 @@
|
|
|
34
38
|
listFilters?: ResolvedFilterField[];
|
|
35
39
|
/** Métadonnées async (options scopées + label actif) pour les filtres FK configurés. */
|
|
36
40
|
fkFilterMeta?: Map<string, FkFilterMeta>;
|
|
41
|
+
/** Tri actif + drapeau « colonne refusée ». Absent = en-têtes non cliquables. */
|
|
42
|
+
sort?: SortState;
|
|
43
|
+
/** Tailles de page proposées. Vide/absent = pas de sélecteur. */
|
|
44
|
+
pageSizes?: number[];
|
|
45
|
+
recordActions?: ListRecordAction[];
|
|
37
46
|
} = $props();
|
|
38
47
|
|
|
39
48
|
const modelConfig = $derived(config.models?.[model.name] || {});
|
|
40
49
|
const hidden = $derived(modelConfig.hidden || []);
|
|
41
50
|
const listFields = $derived(modelConfig.listFields);
|
|
42
51
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
// Résolu par `resolveListColumns` et non ici : le handler applique la MÊME
|
|
53
|
+
// fonction pour décider quels champs un `?sort=` a le droit de désigner.
|
|
54
|
+
const displayFields = $derived(resolveListColumns(model.fields, { hidden, listFields }));
|
|
46
55
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (listFields?.length) {
|
|
56
|
-
fields = fields.filter((f) => listFields.includes(f.name));
|
|
57
|
-
}
|
|
56
|
+
/**
|
|
57
|
+
* En-têtes cliquables seulement quand l'URL courante est connue : sans elle
|
|
58
|
+
* on ne sait pas construire un lien qui préserve recherche et filtres, et un
|
|
59
|
+
* en-tête qui les effacerait silencieusement serait pire que pas de tri.
|
|
60
|
+
* Même convention que la pagination.
|
|
61
|
+
*/
|
|
62
|
+
const sortable = $derived(Boolean(currentUrl));
|
|
58
63
|
|
|
59
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Compte rendu d'une suppression en masse, posé par la redirection après
|
|
66
|
+
* écriture. Validé comme n'importe quelle entrée d'URL : seul un entier
|
|
67
|
+
* positif est rendu, tout le reste est ignoré plutôt qu'affiché.
|
|
68
|
+
*/
|
|
69
|
+
const deletedCount = $derived.by(() => {
|
|
70
|
+
const raw = currentUrl?.searchParams.get('deleted');
|
|
71
|
+
if (raw === null || raw === undefined) return null;
|
|
72
|
+
const parsed = Number(raw);
|
|
73
|
+
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
|
60
74
|
});
|
|
75
|
+
const activeSort = $derived(sort?.active ?? null);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Cliquer une colonne déjà triée inverse la direction ; cliquer une autre
|
|
79
|
+
* colonne repart en ascendant. `buildListUrl` retire `page` de lui-même :
|
|
80
|
+
* changer de tri ne garde aucun sens sur la page 3, ce ne sont plus les
|
|
81
|
+
* mêmes lignes.
|
|
82
|
+
*/
|
|
83
|
+
const sortHref = (field: string) => {
|
|
84
|
+
const dir = activeSort?.field === field && activeSort.dir === 'asc' ? 'desc' : null;
|
|
85
|
+
return buildListUrl(currentUrl!, { sort: field, dir });
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const ariaSortFor = (field: string) =>
|
|
89
|
+
activeSort?.field !== field ? 'none' : activeSort.dir === 'asc' ? 'ascending' : 'descending';
|
|
90
|
+
|
|
91
|
+
/** Indicateur visuel, doublé par `aria-sort` côté lecteur d'écran. */
|
|
92
|
+
const sortMarkFor = (field: string) =>
|
|
93
|
+
activeSort?.field !== field ? '' : activeSort.dir === 'asc' ? ' ↑' : ' ↓';
|
|
61
94
|
|
|
62
95
|
const listPath = $derived(`${basePath}/${model.name.toLowerCase()}`);
|
|
63
96
|
const totalPages = $derived(Math.ceil(pagination.total / pagination.perPage));
|
|
@@ -74,6 +107,14 @@
|
|
|
74
107
|
if (!currentUrl) return (n: number) => `?page=${n}`;
|
|
75
108
|
return (n: number) => buildListUrl(currentUrl, { page: String(n) });
|
|
76
109
|
});
|
|
110
|
+
const pageWindow = $derived(paginationWindow(pagination.page, totalPages));
|
|
111
|
+
// Rendu en liens plutôt qu'en `<select>` : la page n'est jamais hydratée, un
|
|
112
|
+
// select aurait donc besoin soit d'un bouton « appliquer », soit d'un
|
|
113
|
+
// `onchange` inline. Quatre liens font le même travail sans script.
|
|
114
|
+
// `buildListUrl` retire `page` : changer de taille ne garde aucun sens sur la
|
|
115
|
+
// page 3, ce ne sont plus les mêmes lignes.
|
|
116
|
+
const sizeHref = (size: number) => buildListUrl(currentUrl!, { perPage: String(size) });
|
|
117
|
+
const showSizes = $derived(pageSizes.length > 0 && Boolean(currentUrl));
|
|
77
118
|
const clearHref = $derived(currentUrl ? currentUrl.pathname : listPath);
|
|
78
119
|
const searchHiddenParams = $derived(currentUrl ? hiddenParams(currentUrl, ['q']) : []);
|
|
79
120
|
|
|
@@ -126,6 +167,28 @@
|
|
|
126
167
|
* pour un champ sensible que pour un champ inconnu (§0.a, §5.4) — ne
|
|
127
168
|
* jamais dire "champ interdit", ça confirmerait son existence.
|
|
128
169
|
*/
|
|
170
|
+
// A per-row function (rather than {#each} in the markup) so an empty recordActions
|
|
171
|
+
// keeps this to the smallest possible footprint: Svelte 5's SSR wraps every
|
|
172
|
+
// {#each}/{@html} node in its own hydration-boundary comment regardless of the
|
|
173
|
+
// array's length/content (verified empirically — even {@html ''} still emits
|
|
174
|
+
// `<!--hash--><!---->`), so there is no template-level construct that renders zero
|
|
175
|
+
// bytes for an empty array here. Folding this into the pre-existing delete-form
|
|
176
|
+
// {@html} call (right below) was considered and rejected: recordActions must render
|
|
177
|
+
// *before* Edit (see list.test.ts "rend le lien avant Edit"), but the delete form's
|
|
178
|
+
// pre-existing {@html} — and thus its hydration marker — sits *after* Edit, so
|
|
179
|
+
// reusing it would either reorder Edit/recordActions or move the marker in front of
|
|
180
|
+
// Edit for every row, not just when recordActions is non-empty. Neither is
|
|
181
|
+
// byte-identical to the pre-recordActions baseline (see task-6-report.md fix-round-1
|
|
182
|
+
// notes). `action.label` and `hrefFor`'s return value are both escaped manually
|
|
183
|
+
// since this goes through @html instead of Svelte's auto-escaped text/attributes.
|
|
184
|
+
const recordActionsHtml = (id: string | number) =>
|
|
185
|
+
recordActions
|
|
186
|
+
.map(
|
|
187
|
+
(action) =>
|
|
188
|
+
`<a href="${escapeHtml(action.hrefFor(id))}" class="ska-btn ska-btn--secondary ska-btn--sm">${escapeHtml(action.label)}</a>`
|
|
189
|
+
)
|
|
190
|
+
.join('');
|
|
191
|
+
|
|
129
192
|
const ignoredMessages = $derived.by(() => {
|
|
130
193
|
return (query?.ignored ?? []).map((entry) => {
|
|
131
194
|
// `param` est soit `f.<field>` / `f.<field>__<op>` (nouveau format),
|
|
@@ -181,11 +244,22 @@
|
|
|
181
244
|
</form>
|
|
182
245
|
{/if}
|
|
183
246
|
|
|
184
|
-
{#if
|
|
185
|
-
<div class="ska-alert ska-alert--
|
|
247
|
+
{#if deletedCount !== null}
|
|
248
|
+
<div class="ska-alert ska-alert--success" role="status">
|
|
249
|
+
<p>{deletedCount} {deletedCount === 1 ? 'record' : 'records'} deleted</p>
|
|
250
|
+
</div>
|
|
251
|
+
{/if}
|
|
252
|
+
|
|
253
|
+
{#if ignoredMessages.length > 0 || sort?.ignored}
|
|
254
|
+
<div class="ska-alert ska-alert--error" role="alert">
|
|
186
255
|
{#each ignoredMessages as m (m.key)}
|
|
187
256
|
<p>{m.text}</p>
|
|
188
257
|
{/each}
|
|
258
|
+
{#if sort?.ignored}
|
|
259
|
+
<!-- Même neutralité que pour un filtre refusé : ne jamais dire si la
|
|
260
|
+
colonne existe, seulement qu'on ne trie pas dessus. -->
|
|
261
|
+
<p>Ignored sort: this column cannot be sorted</p>
|
|
262
|
+
{/if}
|
|
189
263
|
</div>
|
|
190
264
|
{/if}
|
|
191
265
|
|
|
@@ -195,28 +269,53 @@
|
|
|
195
269
|
</p>
|
|
196
270
|
{/if}
|
|
197
271
|
|
|
272
|
+
{#if sortable}
|
|
273
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- chaîne littérale, sans donnée interpolée ; Svelte 5 refuse un onsubmit littéral comme attribut d'événement -->
|
|
274
|
+
{@html `<form id="ska-bulk" method="POST" action="${listPath}" class="ska-bulk" onsubmit="return confirm('Delete the selected records?')"><input type="hidden" name="_action" value="bulk-delete"><button type="submit" class="ska-btn ska-btn--danger ska-btn--sm">Delete selected</button></form>`}
|
|
275
|
+
{/if}
|
|
276
|
+
|
|
198
277
|
<div class="ska-card">
|
|
199
278
|
<div class="ska-table-wrap">
|
|
200
279
|
<table class="ska-table">
|
|
201
280
|
<thead>
|
|
202
281
|
<tr>
|
|
203
|
-
{#
|
|
204
|
-
|
|
282
|
+
{#if sortable}
|
|
283
|
+
<th class="ska-table__select" scope="col">
|
|
284
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- chaîne littérale ; le « tout cocher » est le seul endroit de l'admin qui demande du script, et le reste fonctionne sans lui -->
|
|
285
|
+
{@html `<input type="checkbox" class="ska-checkbox" aria-label="Select all on this page" onclick="for (const box of this.closest('table').querySelectorAll('input[name=ids]')) box.checked = this.checked">`}
|
|
286
|
+
</th>
|
|
287
|
+
{/if}
|
|
288
|
+
{#each displayFields as f (f.name)}{#if sortable}<th scope="col" aria-sort={ariaSortFor(f.name)}><a href={sortHref(f.name)} class="ska-th-sort">{toLabel(f.name)}<span aria-hidden="true">{sortMarkFor(f.name)}</span></a></th>{:else}<th scope="col">{toLabel(f.name)}</th>{/if}{/each}
|
|
289
|
+
<th scope="col">Actions</th>
|
|
205
290
|
</tr>
|
|
206
291
|
</thead>
|
|
207
292
|
<tbody>
|
|
208
293
|
{#if items.length === 0}
|
|
209
294
|
<tr>
|
|
210
|
-
<td colspan={displayFields.length + 1} style="text-align: center; color: #64748b; padding: 2rem;">
|
|
295
|
+
<td colspan={displayFields.length + (sortable ? 2 : 1)} style="text-align: center; color: #64748b; padding: 2rem;">
|
|
211
296
|
{hasActiveCriteria ? 'No results for these criteria' : 'No records found'}
|
|
212
297
|
</td>
|
|
213
298
|
</tr>
|
|
214
299
|
{:else}
|
|
215
300
|
{#each items as item (item[model.primaryKey])}
|
|
216
301
|
<tr>
|
|
302
|
+
{#if sortable}
|
|
303
|
+
<td class="ska-table__select">
|
|
304
|
+
<input
|
|
305
|
+
type="checkbox"
|
|
306
|
+
class="ska-checkbox"
|
|
307
|
+
name="ids"
|
|
308
|
+
value={item[model.primaryKey]}
|
|
309
|
+
form="ska-bulk"
|
|
310
|
+
aria-label="Select record {item[model.primaryKey]}"
|
|
311
|
+
/>
|
|
312
|
+
</td>
|
|
313
|
+
{/if}
|
|
217
314
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- formatValue already escapes string values itself and returns a literal <span> only for null/undefined -->
|
|
218
315
|
{#each displayFields as f (f.name)}<td>{@html formatValue(item[f.name], f.type)}</td>{/each}
|
|
219
316
|
<td class="ska-table__actions">
|
|
317
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- recordActionsHtml escapes both action.label and hrefFor's return value via escapeHtml -->
|
|
318
|
+
{@html recordActionsHtml(item[model.primaryKey])}
|
|
220
319
|
<a href="{listPath}/{item[model.primaryKey]}" class="ska-btn ska-btn--secondary ska-btn--sm">Edit</a>
|
|
221
320
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Svelte 5 rejects a literal onsubmit string as an event attribute; the PK is escaped manually here since it can't go through Svelte's native attribute escaping; the whole form (not just onsubmit) is rendered as raw HTML because there's no native-Svelte way to attach a plain inline onsubmit="..." string attribute at all in Svelte 5 templates, so the whole element had to be raw text to preserve the exact prior confirm-dialog behavior in a page that's never hydrated by a Svelte runtime -->
|
|
222
321
|
{@html `<form method="POST" action="${listPath}/${escapeHtml(String(item[model.primaryKey]))}" style="display:inline" onsubmit="return confirm('Delete this item?')"><input type="hidden" name="_action" value="delete"><button type="submit" class="ska-btn ska-btn--danger ska-btn--sm">Delete</button></form>`}
|
|
@@ -233,8 +332,39 @@
|
|
|
233
332
|
<span class="ska-pagination__info">
|
|
234
333
|
Showing {(pagination.page - 1) * pagination.perPage + 1} to {Math.min(pagination.page * pagination.perPage, pagination.total)} of {pagination.total}
|
|
235
334
|
</span>
|
|
236
|
-
|
|
237
|
-
|
|
335
|
+
<nav class="ska-pagination__pages" aria-label="Pagination">
|
|
336
|
+
{#if pagination.page > 1}
|
|
337
|
+
<a href={pageHref(1)} class="ska-btn ska-btn--secondary ska-btn--sm" aria-label="First page">«</a>
|
|
338
|
+
<a href={pageHref(pagination.page - 1)} class="ska-btn ska-btn--secondary ska-btn--sm">Previous</a>
|
|
339
|
+
{/if}
|
|
340
|
+
{#each pageWindow as entry, i (i)}
|
|
341
|
+
{#if entry === 'gap'}
|
|
342
|
+
<span class="ska-pagination__gap" aria-hidden="true">…</span>
|
|
343
|
+
{:else if entry === pagination.page}
|
|
344
|
+
<!-- Page courante : repère, pas lien. Un lien vers l'endroit où on
|
|
345
|
+
est déjà est un piège au clavier autant qu'un bruit inutile. -->
|
|
346
|
+
<span class="ska-btn ska-btn--sm ska-pagination__current" aria-current="page">{entry}</span>
|
|
347
|
+
{:else}
|
|
348
|
+
<a href={pageHref(entry)} class="ska-btn ska-btn--secondary ska-btn--sm">{entry}</a>
|
|
349
|
+
{/if}
|
|
350
|
+
{/each}
|
|
351
|
+
{#if pagination.page < totalPages}
|
|
352
|
+
<a href={pageHref(pagination.page + 1)} class="ska-btn ska-btn--secondary ska-btn--sm">Next</a>
|
|
353
|
+
<a href={pageHref(totalPages)} class="ska-btn ska-btn--secondary ska-btn--sm" aria-label="Last page">»</a>
|
|
354
|
+
{/if}
|
|
355
|
+
</nav>
|
|
356
|
+
{#if showSizes}
|
|
357
|
+
<div class="ska-pagination__sizes">
|
|
358
|
+
<span class="ska-pagination__info">Rows</span>
|
|
359
|
+
{#each pageSizes as size (size)}
|
|
360
|
+
{#if size === pagination.perPage}
|
|
361
|
+
<span class="ska-btn ska-btn--sm ska-pagination__current" aria-current="true">{size}</span>
|
|
362
|
+
{:else}
|
|
363
|
+
<a href={sizeHref(size)} class="ska-btn ska-btn--secondary ska-btn--sm">{size}</a>
|
|
364
|
+
{/if}
|
|
365
|
+
{/each}
|
|
366
|
+
</div>
|
|
367
|
+
{/if}
|
|
238
368
|
</div>
|
|
239
369
|
{/if}
|
|
240
370
|
</div>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { AdminHandlerConfig } from '../handler.js';
|
|
2
|
-
import type { ViewModel } from './types.js';
|
|
2
|
+
import type { ViewModel, ListRecordAction, FkFilterMeta } from './types.js';
|
|
3
3
|
import type { ListQuery } from '../query/listQuery.js';
|
|
4
4
|
import type { ResolvedFilterField } from '../query/filterDetection.js';
|
|
5
|
-
import type {
|
|
5
|
+
import type { SortState } from '../query/sortQuery.js';
|
|
6
6
|
type $$ComponentProps = {
|
|
7
7
|
model: ViewModel;
|
|
8
8
|
items: any[];
|
|
@@ -21,6 +21,11 @@ type $$ComponentProps = {
|
|
|
21
21
|
listFilters?: ResolvedFilterField[];
|
|
22
22
|
/** Métadonnées async (options scopées + label actif) pour les filtres FK configurés. */
|
|
23
23
|
fkFilterMeta?: Map<string, FkFilterMeta>;
|
|
24
|
+
/** Tri actif + drapeau « colonne refusée ». Absent = en-têtes non cliquables. */
|
|
25
|
+
sort?: SortState;
|
|
26
|
+
/** Tailles de page proposées. Vide/absent = pas de sélecteur. */
|
|
27
|
+
pageSizes?: number[];
|
|
28
|
+
recordActions?: ListRecordAction[];
|
|
24
29
|
};
|
|
25
30
|
declare const List: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
26
31
|
type List = ReturnType<typeof List>;
|
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { RelationEdge } from '../introspection/relations.js';
|
|
3
3
|
import type { RelationMeta } from './types.js';
|
|
4
|
-
import { toLabel } from './html.js';
|
|
4
|
+
import { toLabel, fieldErrorAttrs } from './html.js';
|
|
5
5
|
|
|
6
6
|
let {
|
|
7
7
|
edge,
|
|
8
|
-
meta
|
|
8
|
+
meta,
|
|
9
|
+
submittedIds,
|
|
10
|
+
errorMessage
|
|
9
11
|
}: {
|
|
10
12
|
edge: RelationEdge;
|
|
11
13
|
meta: RelationMeta;
|
|
14
|
+
/**
|
|
15
|
+
* IDs cochés au dernier POST, quand la mutation a échoué. Un tableau vide
|
|
16
|
+
* dit « tout décoché » et doit gagner sur `meta.selectedIds` ; `undefined`
|
|
17
|
+
* dit « pas un re-rendu d'erreur ». C'est la même distinction que le
|
|
18
|
+
* sentinelle `__rel_present__` côté écriture.
|
|
19
|
+
*/
|
|
20
|
+
submittedIds?: string[];
|
|
21
|
+
/** Défini seulement si c'est cette relation que l'erreur désigne. */
|
|
22
|
+
errorMessage?: string;
|
|
12
23
|
} = $props();
|
|
13
24
|
|
|
14
25
|
const label = $derived(toLabel(edge.field));
|
|
15
26
|
const inputName = $derived(`__rel__${edge.field}`);
|
|
16
|
-
const selected = $derived(
|
|
27
|
+
const selected = $derived(
|
|
28
|
+
new Set((submittedIds ?? meta.selectedIds ?? []).map(String))
|
|
29
|
+
);
|
|
30
|
+
const err = $derived(fieldErrorAttrs(inputName, errorMessage));
|
|
17
31
|
</script>
|
|
18
32
|
|
|
19
33
|
<!--
|
|
@@ -29,13 +43,18 @@
|
|
|
29
43
|
<div class="ska-field">
|
|
30
44
|
<label class="ska-label" for={inputName}>{label} (IDs séparés par des virgules)</label>
|
|
31
45
|
<input type="hidden" name="__rel_present__{edge.field}" value="1" />
|
|
32
|
-
<input id={inputName} type="text" name={inputName} value={[...selected].join(',')} class="ska-input" />
|
|
46
|
+
<input id={inputName} type="text" name={inputName} value={[...selected].join(',')} class="ska-input" aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy} />
|
|
47
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
|
|
48
|
+
{@html err.html}
|
|
33
49
|
</div>
|
|
34
50
|
{:else}
|
|
35
51
|
<div class="ska-field">
|
|
36
52
|
<label class="ska-label" for={inputName}>{label}</label>
|
|
37
53
|
<input type="hidden" name="__rel_present__{edge.field}" value="1" />
|
|
38
|
-
|
|
54
|
+
<!-- Pas d'`aria-invalid` ici : l'attribut n'est pas permis sur le rôle
|
|
55
|
+
`group` implicite d'un `<fieldset>`. Le rattachement du message se fait
|
|
56
|
+
par `aria-describedby` seul, qui l'est. -->
|
|
57
|
+
<fieldset id={inputName} class="ska-checkbox-group" aria-describedby={err.describedBy}>
|
|
39
58
|
{#each meta.options as opt (opt.id)}
|
|
40
59
|
<label class="ska-checkbox-wrap">
|
|
41
60
|
<input
|
|
@@ -49,5 +68,7 @@
|
|
|
49
68
|
</label>
|
|
50
69
|
{/each}
|
|
51
70
|
</fieldset>
|
|
71
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
|
|
72
|
+
{@html err.html}
|
|
52
73
|
</div>
|
|
53
74
|
{/if}
|
|
@@ -3,6 +3,15 @@ import type { RelationMeta } from './types.js';
|
|
|
3
3
|
type $$ComponentProps = {
|
|
4
4
|
edge: RelationEdge;
|
|
5
5
|
meta: RelationMeta;
|
|
6
|
+
/**
|
|
7
|
+
* IDs cochés au dernier POST, quand la mutation a échoué. Un tableau vide
|
|
8
|
+
* dit « tout décoché » et doit gagner sur `meta.selectedIds` ; `undefined`
|
|
9
|
+
* dit « pas un re-rendu d'erreur ». C'est la même distinction que le
|
|
10
|
+
* sentinelle `__rel_present__` côté écriture.
|
|
11
|
+
*/
|
|
12
|
+
submittedIds?: string[];
|
|
13
|
+
/** Défini seulement si c'est cette relation que l'erreur désigne. */
|
|
14
|
+
errorMessage?: string;
|
|
6
15
|
};
|
|
7
16
|
declare const RelationCheckboxes: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
17
|
type RelationCheckboxes = ReturnType<typeof RelationCheckboxes>;
|
|
@@ -2,18 +2,21 @@
|
|
|
2
2
|
import type { AdminHandlerConfig } from '../handler.js';
|
|
3
3
|
import type { RelationEdge } from '../introspection/relations.js';
|
|
4
4
|
import type { RelationMeta } from './types.js';
|
|
5
|
-
import { toLabel } from './html.js';
|
|
5
|
+
import { toLabel, fieldErrorAttrs } from './html.js';
|
|
6
6
|
|
|
7
7
|
let {
|
|
8
8
|
edge,
|
|
9
9
|
meta,
|
|
10
10
|
currentValue,
|
|
11
|
-
config
|
|
11
|
+
config,
|
|
12
|
+
errorMessage
|
|
12
13
|
}: {
|
|
13
14
|
edge: RelationEdge;
|
|
14
15
|
meta: RelationMeta;
|
|
15
16
|
currentValue: unknown;
|
|
16
17
|
config: AdminHandlerConfig;
|
|
18
|
+
/** Défini seulement si c'est cette relation que l'erreur désigne. */
|
|
19
|
+
errorMessage?: string;
|
|
17
20
|
} = $props();
|
|
18
21
|
|
|
19
22
|
const relConfig = $derived(config.models?.[edge.model]?.relations?.[edge.field]);
|
|
@@ -23,6 +26,8 @@
|
|
|
23
26
|
// Toujours défini pour une arête to-one-owning : c'est ce qui la distingue
|
|
24
27
|
// des autres kinds dans le graphe (voir buildRelationGraph).
|
|
25
28
|
const scalarName = $derived(edge.scalarFields[0]);
|
|
29
|
+
// Rattaché au scalaire : c'est lui qui porte l'`id` et le `name` du widget.
|
|
30
|
+
const err = $derived(fieldErrorAttrs(scalarName, errorMessage));
|
|
26
31
|
</script>
|
|
27
32
|
|
|
28
33
|
{#if meta.tooMany}
|
|
@@ -36,12 +41,16 @@
|
|
|
36
41
|
value={currentValue ?? ''}
|
|
37
42
|
class="ska-input"
|
|
38
43
|
required={required}
|
|
44
|
+
aria-invalid={err.ariaInvalid}
|
|
45
|
+
aria-describedby={err.describedBy}
|
|
39
46
|
/>
|
|
47
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
|
|
48
|
+
{@html err.html}
|
|
40
49
|
</div>
|
|
41
50
|
{:else}
|
|
42
51
|
<div class="ska-field">
|
|
43
52
|
<label class="ska-label" for={scalarName}>{label}{required ? ' *' : ''}</label>
|
|
44
|
-
<select id={scalarName} name={scalarName} class="ska-input" required={required}>
|
|
53
|
+
<select id={scalarName} name={scalarName} class="ska-input" required={required} aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy}>
|
|
45
54
|
{#if !required}
|
|
46
55
|
<option value="">{nullLabel}</option>
|
|
47
56
|
{/if}
|
|
@@ -49,5 +58,7 @@
|
|
|
49
58
|
<option value={opt.id} selected={String(opt.id) === String(currentValue ?? '')}>{opt.label}</option>
|
|
50
59
|
{/each}
|
|
51
60
|
</select>
|
|
61
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
|
|
62
|
+
{@html err.html}
|
|
52
63
|
</div>
|
|
53
64
|
{/if}
|
|
@@ -6,6 +6,8 @@ type $$ComponentProps = {
|
|
|
6
6
|
meta: RelationMeta;
|
|
7
7
|
currentValue: unknown;
|
|
8
8
|
config: AdminHandlerConfig;
|
|
9
|
+
/** Défini seulement si c'est cette relation que l'erreur désigne. */
|
|
10
|
+
errorMessage?: string;
|
|
9
11
|
};
|
|
10
12
|
declare const RelationSelect: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
13
|
type RelationSelect = ReturnType<typeof RelationSelect>;
|
|
@@ -3,3 +3,22 @@ export declare function escapeHtml(str: string): string;
|
|
|
3
3
|
export declare function toLabel(name: string): string;
|
|
4
4
|
export declare function adjustColor(hex: string, percent: number): string;
|
|
5
5
|
export declare function formatValue(value: any, type: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Markup du message d'erreur d'un champ, et les attributs qui l'y rattachent.
|
|
8
|
+
*
|
|
9
|
+
* Rendu comme une chaîne passée à `{@html}` plutôt qu'en `{#if}` : la SSR de
|
|
10
|
+
* Svelte 5 pose de toute façon un commentaire de frontière autour des deux, et
|
|
11
|
+
* les deux coûtent la même chose sur un formulaire sans erreur (mesuré : 18
|
|
12
|
+
* octets par champ pour `{@html}`, 19 pour `{#if}`). Le `{@html}` est retenu
|
|
13
|
+
* pour la cohérence avec `recordActionsHtml` dans `Form.svelte`, qui documente
|
|
14
|
+
* déjà ce compromis, et parce qu'il garde l'échappement explicite ici plutôt
|
|
15
|
+
* que réparti dans quatre branches de gabarit.
|
|
16
|
+
*
|
|
17
|
+
* `inputId` doit être l'`id` du champ décrit : c'est lui qui fabrique la cible
|
|
18
|
+
* d'`aria-describedby`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function fieldErrorAttrs(inputId: string, message: string | undefined): {
|
|
21
|
+
ariaInvalid: 'true' | undefined;
|
|
22
|
+
describedBy: string | undefined;
|
|
23
|
+
html: string;
|
|
24
|
+
};
|
|
@@ -39,3 +39,28 @@ export function formatValue(value, type) {
|
|
|
39
39
|
}
|
|
40
40
|
return escapeHtml(str);
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Markup du message d'erreur d'un champ, et les attributs qui l'y rattachent.
|
|
44
|
+
*
|
|
45
|
+
* Rendu comme une chaîne passée à `{@html}` plutôt qu'en `{#if}` : la SSR de
|
|
46
|
+
* Svelte 5 pose de toute façon un commentaire de frontière autour des deux, et
|
|
47
|
+
* les deux coûtent la même chose sur un formulaire sans erreur (mesuré : 18
|
|
48
|
+
* octets par champ pour `{@html}`, 19 pour `{#if}`). Le `{@html}` est retenu
|
|
49
|
+
* pour la cohérence avec `recordActionsHtml` dans `Form.svelte`, qui documente
|
|
50
|
+
* déjà ce compromis, et parce qu'il garde l'échappement explicite ici plutôt
|
|
51
|
+
* que réparti dans quatre branches de gabarit.
|
|
52
|
+
*
|
|
53
|
+
* `inputId` doit être l'`id` du champ décrit : c'est lui qui fabrique la cible
|
|
54
|
+
* d'`aria-describedby`.
|
|
55
|
+
*/
|
|
56
|
+
export function fieldErrorAttrs(inputId, message) {
|
|
57
|
+
if (message === undefined) {
|
|
58
|
+
return { ariaInvalid: undefined, describedBy: undefined, html: '' };
|
|
59
|
+
}
|
|
60
|
+
const errorId = `${inputId}-error`;
|
|
61
|
+
return {
|
|
62
|
+
ariaInvalid: 'true',
|
|
63
|
+
describedBy: errorId,
|
|
64
|
+
html: `<p class="ska-field__error" id="${escapeHtml(errorId)}">${escapeHtml(message)}</p>`
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fenêtre de numéros de page : « 1 … 8 9 [10] 11 12 … 20 ».
|
|
3
|
+
*
|
|
4
|
+
* Pure, et volontairement hors du composant : les cas limites (premiers et
|
|
5
|
+
* derniers écrans, trous d'une seule page) se testent sur des nombres plutôt
|
|
6
|
+
* qu'en fouillant du HTML.
|
|
7
|
+
*/
|
|
8
|
+
export type PageEntry = number | 'gap';
|
|
9
|
+
export declare function paginationWindow(page: number, totalPages: number): PageEntry[];
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fenêtre de numéros de page : « 1 … 8 9 [10] 11 12 … 20 ».
|
|
3
|
+
*
|
|
4
|
+
* Pure, et volontairement hors du composant : les cas limites (premiers et
|
|
5
|
+
* derniers écrans, trous d'une seule page) se testent sur des nombres plutôt
|
|
6
|
+
* qu'en fouillant du HTML.
|
|
7
|
+
*/
|
|
8
|
+
/** Nombre de pages affichées de part et d'autre de la page courante. */
|
|
9
|
+
const SPAN = 2;
|
|
10
|
+
export function paginationWindow(page, totalPages) {
|
|
11
|
+
if (totalPages <= 1)
|
|
12
|
+
return [];
|
|
13
|
+
const first = 1;
|
|
14
|
+
const last = totalPages;
|
|
15
|
+
const from = Math.max(first, page - SPAN);
|
|
16
|
+
const to = Math.min(last, page + SPAN);
|
|
17
|
+
const entries = [];
|
|
18
|
+
for (let n = from; n <= to; n++)
|
|
19
|
+
entries.push(n);
|
|
20
|
+
// Un trou qui ne cacherait qu'une seule page ne gagne aucune place et coûte
|
|
21
|
+
// un clic : on montre la page à la place. D'où la comparaison à 1 et non 0.
|
|
22
|
+
if (from - first > 1)
|
|
23
|
+
entries.unshift(first, 'gap');
|
|
24
|
+
else if (from > first)
|
|
25
|
+
entries.unshift(first);
|
|
26
|
+
if (last - to > 1)
|
|
27
|
+
entries.push('gap', last);
|
|
28
|
+
else if (to < last)
|
|
29
|
+
entries.push(last);
|
|
30
|
+
return entries;
|
|
31
|
+
}
|