sveltekit-admin 0.8.1 → 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.
Files changed (40) hide show
  1. package/dist/server/adapters/drizzle/dataAdapter.js +76 -7
  2. package/dist/server/adapters/prisma/dataAdapter.js +19 -1
  3. package/dist/server/adapters/types.d.ts +33 -1
  4. package/dist/server/data.d.ts +18 -2
  5. package/dist/server/data.js +39 -8
  6. package/dist/server/handler.d.ts +35 -0
  7. package/dist/server/handler.js +67 -15
  8. package/dist/server/introspection/parser.d.ts +15 -0
  9. package/dist/server/introspection/parser.js +17 -0
  10. package/dist/server/mutations.d.ts +8 -4
  11. package/dist/server/mutations.js +203 -22
  12. package/dist/server/query/listColumns.d.ts +19 -0
  13. package/dist/server/query/listColumns.js +43 -0
  14. package/dist/server/query/pageSize.d.ts +19 -0
  15. package/dist/server/query/pageSize.js +26 -0
  16. package/dist/server/query/sortQuery.d.ts +31 -0
  17. package/dist/server/query/sortQuery.js +33 -0
  18. package/dist/server/query/urls.js +9 -1
  19. package/dist/server/runtime.d.ts +5 -0
  20. package/dist/server/runtime.js +54 -1
  21. package/dist/server/submitted.d.ts +20 -0
  22. package/dist/server/submitted.js +54 -0
  23. package/dist/server/views/FieldInput.svelte +114 -8
  24. package/dist/server/views/FieldInput.svelte.d.ts +7 -0
  25. package/dist/server/views/Form.svelte +71 -4
  26. package/dist/server/views/Form.svelte.d.ts +17 -0
  27. package/dist/server/views/Layout.svelte +5 -2
  28. package/dist/server/views/List.svelte +128 -23
  29. package/dist/server/views/List.svelte.d.ts +5 -0
  30. package/dist/server/views/RelationCheckboxes.svelte +26 -5
  31. package/dist/server/views/RelationCheckboxes.svelte.d.ts +9 -0
  32. package/dist/server/views/RelationSelect.svelte +14 -3
  33. package/dist/server/views/RelationSelect.svelte.d.ts +2 -0
  34. package/dist/server/views/html.d.ts +19 -0
  35. package/dist/server/views/html.js +25 -0
  36. package/dist/server/views/pagination.d.ts +9 -0
  37. package/dist/server/views/pagination.js +31 -0
  38. package/dist/server/views/theme.js +147 -3
  39. package/dist/server/views/types.d.ts +7 -0
  40. package/package.json +2 -1
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Extraction des valeurs réellement soumises par un POST de formulaire admin,
3
+ * pour les re-rendre après un échec de mutation.
4
+ *
5
+ * Ce module ne rend rien : il traduit un `FormData` en la forme minimale dont
6
+ * les vues ont besoin. Il porte en revanche la décision de sécurité de ce
7
+ * chemin — ce qui NE doit pas repartir dans le HTML.
8
+ */
9
+ import { isSensitiveFieldName } from './introspection/parser.js';
10
+ /** Clé de dispatch de `handleMutation`, jamais un champ du modèle. */
11
+ const ACTION_KEY = '_action';
12
+ /** Préfixes posés par `RelationCheckboxes.svelte`, cf. `mutations.ts`. */
13
+ const M2M_VALUE_PREFIX = '__rel__';
14
+ const M2M_SENTINEL_PREFIX = '__rel_present__';
15
+ /**
16
+ * Un champ jamais rendu par le formulaire (`hidden`) ne doit pas non plus
17
+ * reparaître par ce chemin, et une valeur sensible ne repart pas du tout :
18
+ * `isSensitiveFieldName` est le prédicat partagé du dépôt (affichage en liste,
19
+ * whitelist de recherche/filtres, rédaction d'audit), et un second heuristique
20
+ * local finirait par diverger du premier. Conséquence assumée, celle de Django
21
+ * (`PasswordInput(render_value=False)`) : un mot de passe est à retaper après
22
+ * une erreur.
23
+ */
24
+ function isEchoable(name, hidden) {
25
+ return !hidden.has(name) && !isSensitiveFieldName(name);
26
+ }
27
+ export function readSubmittedForm(formData, hidden) {
28
+ const values = {};
29
+ const m2m = {};
30
+ // Deux passes : les valeurs `__rel__` peuvent précéder leur sentinelle dans
31
+ // le corps (l'ordre est celui du DOM, pas un contrat), donc on ne peut pas
32
+ // décider de les garder au fil de la première itération.
33
+ for (const key of formData.keys()) {
34
+ if (!key.startsWith(M2M_SENTINEL_PREFIX))
35
+ continue;
36
+ const field = key.slice(M2M_SENTINEL_PREFIX.length);
37
+ if (!isEchoable(field, hidden))
38
+ continue;
39
+ m2m[field] = formData.getAll(`${M2M_VALUE_PREFIX}${field}`).map(String);
40
+ }
41
+ for (const [key, raw] of formData.entries()) {
42
+ if (key === ACTION_KEY)
43
+ continue;
44
+ // Les deux préfixes, testés séparément : `__rel_present__` ne commence pas
45
+ // par `__rel__` (5e caractère `p` contre `_`), donc un seul test laisserait
46
+ // le sentinelle passer pour un scalaire.
47
+ if (key.startsWith(M2M_VALUE_PREFIX) || key.startsWith(M2M_SENTINEL_PREFIX))
48
+ continue;
49
+ if (!isEchoable(key, hidden))
50
+ continue;
51
+ values[key] = String(raw);
52
+ }
53
+ return { values, m2m };
54
+ }
@@ -1,11 +1,45 @@
1
1
  <script lang="ts">
2
2
  import type { PrismaField } from '../introspection/parser.js';
3
- import { toLabel } from './html.js';
3
+ import type { SubmittedForm } from '../submitted.js';
4
+ import { toLabel, fieldErrorAttrs } from './html.js';
4
5
 
5
- let { field, value, isReadonly }: { field: PrismaField; value: any; isReadonly: boolean } =
6
- $props();
6
+ let {
7
+ field,
8
+ value,
9
+ isReadonly,
10
+ submitted,
11
+ errorMessage,
12
+ enums = new Map<string, string[]>()
13
+ }: {
14
+ field: PrismaField;
15
+ value: any;
16
+ isReadonly: boolean;
17
+ /** Voir `Form.svelte` : défini seulement sur un re-rendu après échec. */
18
+ submitted?: SubmittedForm;
19
+ /** Défini seulement si c'est ce champ que l'erreur désigne. */
20
+ errorMessage?: string;
21
+ /** Valeurs de chaque enum du schéma, indexées par nom de type. */
22
+ enums?: Map<string, string[]>;
23
+ } = $props();
24
+
25
+ const err = $derived(fieldErrorAttrs(field.name, errorMessage));
7
26
 
8
27
  const label = $derived(toLabel(field.name));
28
+
29
+ /**
30
+ * `submitted` défini signifie « ce formulaire vient d'être posté et refusé ».
31
+ * On teste sa présence, pas celle de la clé : une checkbox décochée et un
32
+ * champ sensible non ré-émis n'envoient rien, et retomber sur la valeur en
33
+ * base rallumerait à l'écran un booléen que l'utilisateur vient d'éteindre.
34
+ *
35
+ * Un champ readonly est exclu : la ligne en base fait foi puisqu'il n'est pas
36
+ * éditable. Un navigateur soumet bien un `readonly` (contrairement à un
37
+ * `disabled`), mais rien ne l'y oblige — un POST forgé ou un client partiel
38
+ * viderait alors l'ID et les horodatages à l'écran, sans que ce soit une
39
+ * donnée que l'utilisateur ait saisie.
40
+ */
41
+ const hasSubmission = $derived(submitted !== undefined && !isReadonly);
42
+ const submittedValue = $derived(submitted?.values[field.name]);
9
43
  const required = $derived(field.isRequired && !field.hasDefault && !isReadonly);
10
44
 
11
45
  const lower = $derived(field.name.toLowerCase());
@@ -29,35 +63,107 @@
29
63
  });
30
64
 
31
65
  const inputValue = $derived.by(() => {
66
+ // Rendue telle qu'elle a été tapée : la re-normaliser (un `datetime-local`
67
+ // incomplet, un nombre en cours de frappe) transformerait la valeur refusée
68
+ // en une autre, et l'utilisateur ne reconnaîtrait plus ce qu'il a saisi.
69
+ if (hasSubmission) return submittedValue ?? '';
32
70
  if (field.type === 'DateTime' && value) {
33
71
  return new Date(value).toISOString().slice(0, 16);
34
72
  }
35
73
  return value ?? '';
36
74
  });
37
75
 
38
- const jsonValue = $derived(value ? JSON.stringify(value, null, 2) : '');
76
+ /**
77
+ * Valeur retenue par le `<select>` d'un enum : même arbitrage que
78
+ * `inputValue` (ce qui vient d'être soumis prime sur la ligne en base),
79
+ * ramené à une chaîne pour se comparer aux valeurs déclarées de l'enum.
80
+ */
81
+ const enumValue = $derived(String(inputValue));
82
+
83
+ const jsonValue = $derived.by(() => {
84
+ if (hasSubmission) return submittedValue ?? '';
85
+ return value ? JSON.stringify(value, null, 2) : '';
86
+ });
87
+
88
+ // Absence = décochée : c'est ce que le navigateur envoie, et `formDataToPrisma`
89
+ // l'interprète déjà comme `false` côté écriture.
90
+ const isChecked = $derived(
91
+ hasSubmission ? submittedValue !== undefined : !!value
92
+ );
39
93
  </script>
40
94
 
41
95
  {#if field.type === 'Boolean'}
42
96
  <div class="ska-field">
43
97
  <label class="ska-checkbox-wrap">
44
- <input type="checkbox" name={field.name} class="ska-checkbox" checked={!!value} disabled={isReadonly} />
98
+ <input
99
+ type="checkbox"
100
+ name={field.name}
101
+ class="ska-checkbox"
102
+ checked={isChecked}
103
+ disabled={isReadonly}
104
+ aria-invalid={err.ariaInvalid}
105
+ aria-describedby={err.describedBy}
106
+ />
45
107
  <span class="ska-label">{label}</span>
46
108
  </label>
109
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
110
+ {@html err.html}
47
111
  </div>
48
112
  {:else if field.type === 'Json'}
49
113
  <div class="ska-field">
50
114
  <label class="ska-label" for={field.name}>{label}{required ? ' *' : ''}</label>
51
- <textarea id={field.name} name={field.name} class="ska-input" rows="4" readonly={isReadonly} required={required}>{jsonValue}</textarea>
115
+ <textarea id={field.name} name={field.name} class="ska-input" rows="4" readonly={isReadonly} required={required} aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy}>{jsonValue}</textarea>
116
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
117
+ {@html err.html}
52
118
  </div>
53
119
  {:else if isLongText}
54
120
  <div class="ska-field">
55
121
  <label class="ska-label" for={field.name}>{label}{required ? ' *' : ''}</label>
56
- <textarea id={field.name} name={field.name} class="ska-input" rows="4" readonly={isReadonly} required={required}>{inputValue}</textarea>
122
+ <textarea id={field.name} name={field.name} class="ska-input" rows="4" readonly={isReadonly} required={required} aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy}>{inputValue}</textarea>
123
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
124
+ {@html err.html}
125
+ </div>
126
+ {:else if field.isEnum && enums.has(field.type)}
127
+ <!--
128
+ Calqué sur `RelationSelect.svelte` (même structure, même « — aucun — »), à
129
+ deux écarts près, tous deux volontaires :
130
+
131
+ 1. Un placeholder désactivé quand la colonne est non nullable et n'a pas
132
+ encore de valeur. Sans lui le navigateur présélectionne la première
133
+ valeur de l'enum, et une création écrit un choix que l'utilisateur n'a
134
+ jamais fait. `RelationSelect` a le même défaut pour une FK obligatoire —
135
+ il n'est pas corrigé ici, ce serait un autre comportement dans une autre
136
+ PR.
137
+ 2. `disabled` et non `readonly` : un `<select>` n'a pas de `readonly`. Le
138
+ champ sort donc du POST, `formDataToPrisma` ignore la clé absente et la
139
+ colonne n'est pas réécrite — exactement ce que « lecture seule » veut
140
+ dire, et plus sûr que les branches texte qui, elles, réémettent la valeur.
141
+
142
+ L'option vide se décide sur `field.isRequired` (la colonne accepte-t-elle
143
+ NULL) et non sur `required` (l'attribut du widget, faux dès qu'il y a un
144
+ `@default`) : une colonne non nullable à défaut n'accepte toujours pas
145
+ « aucun ».
146
+ -->
147
+ <div class="ska-field">
148
+ <label class="ska-label" for={field.name}>{label}{required ? ' *' : ''}</label>
149
+ <select id={field.name} name={field.name} class="ska-input" disabled={isReadonly} required={required} aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy}>
150
+ {#if !field.isRequired}
151
+ <option value="">— aucun —</option>
152
+ {:else if !enumValue}
153
+ <option value="" disabled selected>Select…</option>
154
+ {/if}
155
+ {#each enums.get(field.type)! as v (v)}
156
+ <option value={v} selected={v === enumValue}>{v}</option>
157
+ {/each}
158
+ </select>
159
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
160
+ {@html err.html}
57
161
  </div>
58
162
  {:else}
59
163
  <div class="ska-field">
60
164
  <label class="ska-label" for={field.name}>{label}{required ? ' *' : ''}</label>
61
- <input id={field.name} type={inputType} name={field.name} value={inputValue} class="ska-input" readonly={isReadonly} required={required} />
165
+ <input id={field.name} type={inputType} name={field.name} value={inputValue} class="ska-input" readonly={isReadonly} required={required} aria-invalid={err.ariaInvalid} aria-describedby={err.describedBy} />
166
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -- fieldErrorAttrs échappe l'id et le message -->
167
+ {@html err.html}
62
168
  </div>
63
169
  {/if}
@@ -1,8 +1,15 @@
1
1
  import type { PrismaField } from '../introspection/parser.js';
2
+ import type { SubmittedForm } from '../submitted.js';
2
3
  type $$ComponentProps = {
3
4
  field: PrismaField;
4
5
  value: any;
5
6
  isReadonly: boolean;
7
+ /** Voir `Form.svelte` : défini seulement sur un re-rendu après échec. */
8
+ submitted?: SubmittedForm;
9
+ /** Défini seulement si c'est ce champ que l'erreur désigne. */
10
+ errorMessage?: string;
11
+ /** Valeurs de chaque enum du schéma, indexées par nom de type. */
12
+ enums?: Map<string, string[]>;
6
13
  };
7
14
  declare const FieldInput: import("svelte").Component<$$ComponentProps, {}, "">;
8
15
  type FieldInput = ReturnType<typeof FieldInput>;
@@ -1,6 +1,8 @@
1
1
  <script lang="ts">
2
2
  import type { AdminHandlerConfig } from '../handler.js';
3
3
  import type { RecordAction, ViewModel } from './types.js';
4
+ import type { SubmittedForm } from '../submitted.js';
5
+ import { isSensitiveStringField } from '../introspection/parser.js';
4
6
  import FieldInput from './FieldInput.svelte';
5
7
  import RelationSelect from './RelationSelect.svelte';
6
8
  import RelationCheckboxes from './RelationCheckboxes.svelte';
@@ -13,7 +15,9 @@
13
15
  basePath,
14
16
  config,
15
17
  item,
16
- recordActions = []
18
+ recordActions = [],
19
+ submitted,
20
+ mutationError
17
21
  }: {
18
22
  mode: 'create' | 'edit';
19
23
  model: ViewModel;
@@ -21,18 +25,53 @@
21
25
  config: AdminHandlerConfig;
22
26
  item?: any;
23
27
  recordActions?: RecordAction[];
28
+ /**
29
+ * Ce que le POST venait de soumettre, quand la mutation a échoué. Défini
30
+ * uniquement sur un re-rendu d'erreur : `undefined` veut dire « affichage
31
+ * normal », et c'est cette distinction — pas le contenu — qui permet à un
32
+ * booléen décoché de rester décoché.
33
+ */
34
+ submitted?: SubmittedForm;
35
+ /**
36
+ * L'échec qui a provoqué ce re-rendu. `field` est absent quand la cause ne
37
+ * désigne pas un champ (conflit d'unicité, message générique) : le message
38
+ * ne vit alors que dans la bannière de page, posée par `handler.ts`.
39
+ */
40
+ mutationError?: { message: string; field?: string };
24
41
  } = $props();
25
42
 
43
+ /**
44
+ * Le message n'est rendu qu'au widget que l'erreur nomme. Un champ absent du
45
+ * formulaire (masqué, ou un scalaire sans widget propre) ne fait rien
46
+ * apparaître : la bannière reste le canal de repli.
47
+ */
48
+ const errorFor = (...names: Array<string | undefined>) =>
49
+ mutationError?.field !== undefined && names.includes(mutationError.field)
50
+ ? mutationError.message
51
+ : undefined;
52
+
26
53
  const modelConfig = $derived(config.models?.[model.name] || {});
27
54
  const hidden = $derived(modelConfig.hidden || []);
28
55
  const readonly = $derived(modelConfig.readonly || []);
29
56
  const listPath = $derived(`${basePath}/${model.name.toLowerCase()}`);
30
57
 
58
+ /**
59
+ * `Bytes` n'a pas de widget : rendue dans la branche texte générique, la
60
+ * colonne renvoyait au pilote une chaîne là où il attend un `Uint8Array`,
61
+ * donc aucune écriture n'a jamais pu aboutir. L'exclure rend un modèle à
62
+ * colonne `Bytes` obligatoire incréable depuis l'admin — c'est déjà le cas
63
+ * en pratique, à ceci près que l'échec arrive maintenant avant le formulaire
64
+ * plutôt qu'après le POST. La liste l'écarte déjà pareillement
65
+ * (`List.svelte`), c'est la même décision au même endroit du schéma.
66
+ */
67
+ const isEditableType = (f: { type: string }) => f.type !== 'Bytes';
68
+
31
69
  const formFields = $derived(
32
70
  mode === 'create'
33
71
  ? model.fields.filter(
34
72
  (f) =>
35
73
  !hidden.includes(f.name) &&
74
+ isEditableType(f) &&
36
75
  !f.isId &&
37
76
  !f.isCreatedAt &&
38
77
  !f.isUpdatedAt &&
@@ -44,7 +83,15 @@
44
83
  : model.fields.filter(
45
84
  (f) =>
46
85
  !hidden.includes(f.name) &&
86
+ isEditableType(f) &&
47
87
  !f.relation &&
88
+ // Exclu à l'édition seulement : il existe ici une valeur stockée,
89
+ // donc quelque chose à exposer dans le HTML et à écraser au
90
+ // prochain enregistrement. Le formulaire de création le garde — il
91
+ // n'y a rien à fuiter avant que la ligne existe, et le retirer
92
+ // rendrait un modèle à colonne obligatoire incréable depuis
93
+ // l'admin. `mutations.ts` refuse le vide de ce côté-là.
94
+ !isSensitiveStringField(f) &&
48
95
  !model.relationGraph?.scalarToRelation.has(f.name)
49
96
  )
50
97
  );
@@ -65,7 +112,14 @@
65
112
  : []
66
113
  );
67
114
 
68
- const currentValueOf = (scalarName: string) => (item ? item[scalarName] : null);
115
+ /**
116
+ * Sur un re-rendu d'erreur, le scalaire soumis fait foi ; sinon la ligne en
117
+ * base. Le `??` ne suffirait pas : une relation optionnelle qu'on vient de
118
+ * vider arrive comme `''`, ce qui doit rester `''` et non retomber sur
119
+ * l'ancienne cible.
120
+ */
121
+ const currentValueOf = (scalarName: string) =>
122
+ submitted ? (submitted.values[scalarName] ?? '') : item ? item[scalarName] : null;
69
123
 
70
124
  const relationCheckboxGroups = $derived(
71
125
  model.relationGraph
@@ -120,7 +174,14 @@
120
174
  <form method="POST" class="ska-form">
121
175
  <input type="hidden" name="_action" value={mode === 'create' ? 'create' : 'update'} />
122
176
  {#each formFields as f (f.name)}
123
- <FieldInput field={f} value={item ? item[f.name] : null} isReadonly={isFieldReadonly(f)} />
177
+ <FieldInput
178
+ field={f}
179
+ value={item ? item[f.name] : null}
180
+ isReadonly={isFieldReadonly(f)}
181
+ {submitted}
182
+ errorMessage={errorFor(f.name)}
183
+ enums={model.enums}
184
+ />
124
185
  {/each}
125
186
  {#each relationSelects as edge (edge.field)}
126
187
  <RelationSelect
@@ -128,10 +189,16 @@
128
189
  meta={model.relationOptions!.get(`${edge.model}.${edge.field}`)!}
129
190
  currentValue={currentValueOf(edge.scalarFields[0])}
130
191
  {config}
192
+ errorMessage={errorFor(edge.field, edge.scalarFields[0])}
131
193
  />
132
194
  {/each}
133
195
  {#each relationCheckboxGroups as edge (edge.field)}
134
- <RelationCheckboxes {edge} meta={model.relationOptions!.get(`${edge.model}.${edge.field}`)!} />
196
+ <RelationCheckboxes
197
+ {edge}
198
+ meta={model.relationOptions!.get(`${edge.model}.${edge.field}`)!}
199
+ submittedIds={submitted?.m2m[edge.field]}
200
+ errorMessage={errorFor(edge.field)}
201
+ />
135
202
  {/each}
136
203
  <div class="ska-form__actions">
137
204
  <button type="submit" class="ska-btn ska-btn--primary">{mode === 'create' ? 'Create' : 'Save Changes'}</button>
@@ -1,5 +1,6 @@
1
1
  import type { AdminHandlerConfig } from '../handler.js';
2
2
  import type { RecordAction, ViewModel } from './types.js';
3
+ import type { SubmittedForm } from '../submitted.js';
3
4
  type $$ComponentProps = {
4
5
  mode: 'create' | 'edit';
5
6
  model: ViewModel;
@@ -7,6 +8,22 @@ type $$ComponentProps = {
7
8
  config: AdminHandlerConfig;
8
9
  item?: any;
9
10
  recordActions?: RecordAction[];
11
+ /**
12
+ * Ce que le POST venait de soumettre, quand la mutation a échoué. Défini
13
+ * uniquement sur un re-rendu d'erreur : `undefined` veut dire « affichage
14
+ * normal », et c'est cette distinction — pas le contenu — qui permet à un
15
+ * booléen décoché de rester décoché.
16
+ */
17
+ submitted?: SubmittedForm;
18
+ /**
19
+ * L'échec qui a provoqué ce re-rendu. `field` est absent quand la cause ne
20
+ * désigne pas un champ (conflit d'unicité, message générique) : le message
21
+ * ne vit alors que dans la bannière de page, posée par `handler.ts`.
22
+ */
23
+ mutationError?: {
24
+ message: string;
25
+ field?: string;
26
+ };
10
27
  };
11
28
  declare const Form: import("svelte").Component<$$ComponentProps, {}, "">;
12
29
  type Form = ReturnType<typeof Form>;
@@ -58,10 +58,13 @@
58
58
  </head>
59
59
  <!-- eslint-disable-next-line svelte/no-raw-special-elements -- server-only full-document template, never mounted client-side -->
60
60
  <body>
61
+ <!-- Premier élément focusable de la page : sans lui, atteindre le contenu au
62
+ clavier demande de retraverser toute la nav latérale à chaque page. -->
63
+ <a href="#ska-content" class="ska-skip">Skip to content</a>
61
64
  <div class="ska-layout">
62
65
  <aside class="ska-sidebar">
63
66
  <a href={basePath} class="ska-logo">{title}</a>
64
- <nav>
67
+ <nav aria-label="Main">
65
68
  <ul class="ska-nav">
66
69
  <li class="ska-nav__item">
67
70
  <a href={basePath} class="ska-nav__link" class:ska-nav__link--active={!currentModel}>
@@ -92,7 +95,7 @@
92
95
  </form>
93
96
  {/if}
94
97
  </aside>
95
- <main class="ska-main">
98
+ <main class="ska-main" id="ska-content" tabindex="-1">
96
99
  <!-- eslint-disable-next-line svelte/no-at-html-tags -- content is pre-rendered HTML from sibling view components / the handler's own escaped error string -->
97
100
  {@html content}
98
101
  </main>
@@ -3,9 +3,11 @@
3
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 { getDisplayFields } from '../introspection/parser.js';
8
+ import { resolveListColumns } from '../query/listColumns.js';
8
9
  import { buildListUrl, hiddenParams } from '../query/urls.js';
10
+ import { paginationWindow } from './pagination.js';
9
11
  import { escapeHtml, toLabel, formatValue } from './html.js';
10
12
  import ListFilters from './ListFilters.svelte';
11
13
 
@@ -19,6 +21,8 @@
19
21
  currentUrl,
20
22
  listFilters,
21
23
  fkFilterMeta,
24
+ sort,
25
+ pageSizes = [],
22
26
  recordActions = []
23
27
  }: {
24
28
  model: ViewModel;
@@ -34,6 +38,10 @@
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[];
37
45
  recordActions?: ListRecordAction[];
38
46
  } = $props();
39
47
 
@@ -41,24 +49,48 @@
41
49
  const hidden = $derived(modelConfig.hidden || []);
42
50
  const listFields = $derived(modelConfig.listFields);
43
51
 
44
- const displayFields = $derived.by(() => {
45
- const explicit = new Set(listFields ?? []);
46
- const safeNames = new Set(getDisplayFields(model).map((f) => f.name));
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 }));
47
55
 
48
- let fields = model.fields.filter(
49
- (f) =>
50
- (explicit.has(f.name) || safeNames.has(f.name)) &&
51
- !hidden.includes(f.name) &&
52
- !f.relation &&
53
- !['Json', 'Bytes'].includes(f.type)
54
- );
55
-
56
- if (listFields?.length) {
57
- fields = fields.filter((f) => listFields.includes(f.name));
58
- }
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));
59
63
 
60
- return fields.slice(0, 6);
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;
61
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' ? ' ↑' : ' ↓';
62
94
 
63
95
  const listPath = $derived(`${basePath}/${model.name.toLowerCase()}`);
64
96
  const totalPages = $derived(Math.ceil(pagination.total / pagination.perPage));
@@ -75,6 +107,14 @@
75
107
  if (!currentUrl) return (n: number) => `?page=${n}`;
76
108
  return (n: number) => buildListUrl(currentUrl, { page: String(n) });
77
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));
78
118
  const clearHref = $derived(currentUrl ? currentUrl.pathname : listPath);
79
119
  const searchHiddenParams = $derived(currentUrl ? hiddenParams(currentUrl, ['q']) : []);
80
120
 
@@ -204,11 +244,22 @@
204
244
  </form>
205
245
  {/if}
206
246
 
207
- {#if ignoredMessages.length > 0}
208
- <div class="ska-alert ska-alert--error">
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">
209
255
  {#each ignoredMessages as m (m.key)}
210
256
  <p>{m.text}</p>
211
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}
212
263
  </div>
213
264
  {/if}
214
265
 
@@ -218,25 +269,48 @@
218
269
  </p>
219
270
  {/if}
220
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
+
221
277
  <div class="ska-card">
222
278
  <div class="ska-table-wrap">
223
279
  <table class="ska-table">
224
280
  <thead>
225
281
  <tr>
226
- {#each displayFields as f (f.name)}<th>{toLabel(f.name)}</th>{/each}
227
- <th>Actions</th>
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>
228
290
  </tr>
229
291
  </thead>
230
292
  <tbody>
231
293
  {#if items.length === 0}
232
294
  <tr>
233
- <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;">
234
296
  {hasActiveCriteria ? 'No results for these criteria' : 'No records found'}
235
297
  </td>
236
298
  </tr>
237
299
  {:else}
238
300
  {#each items as item (item[model.primaryKey])}
239
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}
240
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 -->
241
315
  {#each displayFields as f (f.name)}<td>{@html formatValue(item[f.name], f.type)}</td>{/each}
242
316
  <td class="ska-table__actions">
@@ -258,8 +332,39 @@
258
332
  <span class="ska-pagination__info">
259
333
  Showing {(pagination.page - 1) * pagination.perPage + 1} to {Math.min(pagination.page * pagination.perPage, pagination.total)} of {pagination.total}
260
334
  </span>
261
- {#if pagination.page > 1}<a href={pageHref(pagination.page - 1)} class="ska-btn ska-btn--secondary ska-btn--sm">Previous</a>{/if}
262
- {#if pagination.page < totalPages}<a href={pageHref(pagination.page + 1)} class="ska-btn ska-btn--secondary ska-btn--sm">Next</a>{/if}
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}
263
368
  </div>
264
369
  {/if}
265
370
  </div>