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.
- package/dist/server/adapters/drizzle/dataAdapter.js +76 -7
- package/dist/server/adapters/prisma/dataAdapter.js +19 -1
- package/dist/server/adapters/types.d.ts +33 -1
- package/dist/server/data.d.ts +18 -2
- package/dist/server/data.js +39 -8
- package/dist/server/handler.d.ts +35 -0
- package/dist/server/handler.js +67 -15
- package/dist/server/introspection/parser.d.ts +15 -0
- package/dist/server/introspection/parser.js +17 -0
- package/dist/server/mutations.d.ts +8 -4
- package/dist/server/mutations.js +203 -22
- package/dist/server/query/listColumns.d.ts +19 -0
- package/dist/server/query/listColumns.js +43 -0
- 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/runtime.d.ts +5 -0
- package/dist/server/runtime.js +54 -1
- 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 +71 -4
- package/dist/server/views/Form.svelte.d.ts +17 -0
- package/dist/server/views/Layout.svelte +5 -2
- package/dist/server/views/List.svelte +128 -23
- package/dist/server/views/List.svelte.d.ts +5 -0
- 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 +7 -0
- 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 {
|
|
3
|
+
import type { SubmittedForm } from '../submitted.js';
|
|
4
|
+
import { toLabel, fieldErrorAttrs } from './html.js';
|
|
4
5
|
|
|
5
|
-
let {
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
208
|
-
<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">
|
|
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
|
-
{#
|
|
227
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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>
|