@zevra/support-widget 0.2.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 +80 -0
- package/package.json +26 -0
- package/src/capture.ts +260 -0
- package/src/client-relais.ts +130 -0
- package/src/composants/briques.tsx +146 -0
- package/src/composants/onglet-beta.tsx +156 -0
- package/src/composants/onglet-mes-demandes.tsx +256 -0
- package/src/composants/onglet-signaler.tsx +369 -0
- package/src/composants/tiroir.tsx +163 -0
- package/src/contexte.ts +173 -0
- package/src/index.tsx +241 -0
- package/src/styles.ts +211 -0
- package/src/tampon-erreurs.ts +211 -0
- package/src/types.ts +105 -0
- package/src/validation.ts +113 -0
- package/src/zone.ts +284 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Les vingt dernières erreurs de console. PUR (le tampon), impur (la pose).
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// POURQUOI UN TAMPON, ET PAS UNE LECTURE AU MOMENT DU CLIC : la console d'un
|
|
6
|
+
// navigateur n'est pas lisible depuis JavaScript. Ce qui s'y est affiché avant
|
|
7
|
+
// l'ouverture du widget est perdu — or c'est exactement ce qu'on veut : la
|
|
8
|
+
// pile d'erreurs qui a précédé « ça ne marche pas ». On garde donc une copie,
|
|
9
|
+
// bornée, posée le plus tôt possible.
|
|
10
|
+
//
|
|
11
|
+
// ⚠️ CE TAMPON EST BRANCHÉ SUR LA CONSOLE DE L'APP HÔTE. Il ne doit JAMAIS
|
|
12
|
+
// jeter : une exception dans `console.error` casserait le journal de l'app, et
|
|
13
|
+
// l'app, et personne ne soupçonnerait le bouton « Aide ». D'où le try/catch
|
|
14
|
+
// autour de chaque formatage, et le repli « [argument illisible] » plutôt
|
|
15
|
+
// qu'une erreur propagée.
|
|
16
|
+
|
|
17
|
+
/** Vingt entrées (PLAN.md §7 lot 5). Au-delà, ce n'est plus un contexte. */
|
|
18
|
+
export const TAILLE_TAMPON = 20
|
|
19
|
+
|
|
20
|
+
/** Une entrée plus longue que ça est une pile complète : elle noie le reste. */
|
|
21
|
+
export const LONGUEUR_ENTREE = 300
|
|
22
|
+
|
|
23
|
+
export type NiveauConsole = 'error' | 'warn' | 'exception' | 'rejet'
|
|
24
|
+
|
|
25
|
+
const MARQUE_ILLISIBLE = '[argument illisible]'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Rend un argument de console en texte. PUR, TOTAL — ne jette jamais.
|
|
29
|
+
*
|
|
30
|
+
* Chaque branche existe pour une valeur qui a réellement fait tomber un
|
|
31
|
+
* formateur naïf :
|
|
32
|
+
* - un `Symbol` : `` `${valeur}` `` lève un TypeError (il faut `String()`) ;
|
|
33
|
+
* - un objet circulaire, un `BigInt`, un getter qui jette : `JSON.stringify`
|
|
34
|
+
* lève ;
|
|
35
|
+
* - `Object.create(null)` n'a pas de `toString` : `String()` lève.
|
|
36
|
+
*/
|
|
37
|
+
export function formaterArgument(valeur: unknown): string {
|
|
38
|
+
try {
|
|
39
|
+
if (valeur === null) return 'null'
|
|
40
|
+
if (valeur === undefined) return 'undefined'
|
|
41
|
+
if (typeof valeur === 'string') return valeur
|
|
42
|
+
if (typeof valeur === 'symbol') return String(valeur)
|
|
43
|
+
if (typeof valeur === 'function') {
|
|
44
|
+
const nom = (valeur as { name?: string }).name
|
|
45
|
+
return `[fonction ${nom && nom !== '' ? nom : 'anonyme'}]`
|
|
46
|
+
}
|
|
47
|
+
if (valeur instanceof Error) return formaterErreur(valeur)
|
|
48
|
+
if (typeof valeur === 'object') {
|
|
49
|
+
const rendu = JSON.stringify(valeur)
|
|
50
|
+
// `JSON.stringify` rend `undefined` sur certaines valeurs (une fonction
|
|
51
|
+
// dans un tableau, un objet de symboles seuls) sans jeter : le repli
|
|
52
|
+
// doit couvrir ce cas-là aussi.
|
|
53
|
+
return rendu === undefined ? '[objet non sérialisable]' : rendu
|
|
54
|
+
}
|
|
55
|
+
return String(valeur)
|
|
56
|
+
} catch {
|
|
57
|
+
// Circulaire, BigInt, getter qui jette, objet sans prototype : l'important
|
|
58
|
+
// est que l'entrée existe et que le tampon survive.
|
|
59
|
+
try {
|
|
60
|
+
return typeof valeur === 'object' ? '[objet non sérialisable]' : MARQUE_ILLISIBLE
|
|
61
|
+
} catch {
|
|
62
|
+
return MARQUE_ILLISIBLE
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Une erreur en une ligne : son nom, son message, et la PREMIÈRE ligne de
|
|
69
|
+
* pile.
|
|
70
|
+
*
|
|
71
|
+
* La pile entière ferait 4 Ko par entrée — vingt entrées suffiraient alors à
|
|
72
|
+
* dépasser à elles seules la limite de corps de l'API. La première ligne
|
|
73
|
+
* suffit à savoir d'où ça vient ; le reste est dans le navigateur de la
|
|
74
|
+
* personne, si on le lui demande.
|
|
75
|
+
*/
|
|
76
|
+
function formaterErreur(erreur: Error): string {
|
|
77
|
+
const base = `${erreur.name}: ${erreur.message}`
|
|
78
|
+
const pile = typeof erreur.stack === 'string' ? erreur.stack : ''
|
|
79
|
+
const lignes = pile.split('\n').map((l) => l.trim())
|
|
80
|
+
const premiereTrame = lignes.find((l) => l.startsWith('at '))
|
|
81
|
+
return premiereTrame ? `${base} — ${premiereTrame}` : base
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Formate une entrée complète, horodatée. PUR. */
|
|
85
|
+
export function formaterEntree(
|
|
86
|
+
niveau: NiveauConsole,
|
|
87
|
+
args: readonly unknown[],
|
|
88
|
+
horodatage?: Date,
|
|
89
|
+
): string {
|
|
90
|
+
const corps = args.map(formaterArgument).join(' ')
|
|
91
|
+
const heure = horodatage ? `${horodatage.toISOString().slice(11, 19)} ` : ''
|
|
92
|
+
const ligne = `${heure}[${niveau}] ${corps}`
|
|
93
|
+
return ligne.length <= LONGUEUR_ENTREE ? ligne : `${ligne.slice(0, LONGUEUR_ENTREE - 1)}…`
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Le tampon circulaire. PUR (aucune I/O), et incassable par construction.
|
|
98
|
+
*/
|
|
99
|
+
export class TamponErreurs {
|
|
100
|
+
private readonly entrees: string[] = []
|
|
101
|
+
|
|
102
|
+
constructor(private readonly taille: number = TAILLE_TAMPON) {}
|
|
103
|
+
|
|
104
|
+
/** Ajoute une entrée. Ne jette jamais : le formatage est déjà total. */
|
|
105
|
+
ajouter(niveau: NiveauConsole, args: readonly unknown[], horodatage?: Date): void {
|
|
106
|
+
try {
|
|
107
|
+
this.entrees.push(formaterEntree(niveau, args, horodatage))
|
|
108
|
+
// La plus ANCIENNE part : ce qui vient de se passer explique mieux que
|
|
109
|
+
// ce qui s'est passé au chargement de la page.
|
|
110
|
+
while (this.entrees.length > this.taille) this.entrees.shift()
|
|
111
|
+
} catch {
|
|
112
|
+
// Rien. Un tampon qui jette casse la console de l'app hôte.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Les entrées, de la plus ancienne à la plus récente. Copie défensive. */
|
|
117
|
+
lire(): string[] {
|
|
118
|
+
return [...this.entrees]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
vider(): void {
|
|
122
|
+
this.entrees.length = 0
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Le tampon partagé du widget.
|
|
128
|
+
*
|
|
129
|
+
* ⚠️ UN SEUL, au niveau du module : deux tampons se partageraient les erreurs
|
|
130
|
+
* au hasard de l'ordre des poses, et chacun en aurait la moitié.
|
|
131
|
+
*/
|
|
132
|
+
export const tamponErreurs = new TamponErreurs()
|
|
133
|
+
|
|
134
|
+
/* ─── La pose (impure) ──────────────────────────────────────────────── */
|
|
135
|
+
|
|
136
|
+
interface ConsoleObservee {
|
|
137
|
+
error?: (...args: unknown[]) => void
|
|
138
|
+
warn?: (...args: unknown[]) => void
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface PorteePose {
|
|
142
|
+
console?: ConsoleObservee
|
|
143
|
+
addEventListener?: (type: string, ecouteur: (evenement: unknown) => void) => void
|
|
144
|
+
removeEventListener?: (type: string, ecouteur: (evenement: unknown) => void) => void
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let poseFaite = false
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Branche le tampon sur la console et sur les erreurs non rattrapées.
|
|
151
|
+
*
|
|
152
|
+
* ⚠️ À APPELER LE PLUS TÔT POSSIBLE dans l'app hôte, avant le rendu — pas
|
|
153
|
+
* seulement au montage du widget. Posé au montage, le tampon manque
|
|
154
|
+
* exactement les erreurs du démarrage : celles qu'on signale.
|
|
155
|
+
*
|
|
156
|
+
* Rend la fonction de dépose. Idempotent : deux appels ne doublent pas les
|
|
157
|
+
* entrées (c'est ce qui arriverait si l'app le pose ET que le widget se monte).
|
|
158
|
+
*/
|
|
159
|
+
export function installerTamponErreurs(
|
|
160
|
+
portee: PorteePose = globalThis as unknown as PorteePose,
|
|
161
|
+
tampon: TamponErreurs = tamponErreurs,
|
|
162
|
+
): () => void {
|
|
163
|
+
if (poseFaite) return () => {}
|
|
164
|
+
const console_ = portee?.console
|
|
165
|
+
if (!console_) return () => {}
|
|
166
|
+
|
|
167
|
+
// ⚠️ On garde la référence ORIGINALE, pas une copie liée : c'est elle qu'on
|
|
168
|
+
// remettra à la dépose. Restaurer un `bind()` laisserait à l'app une
|
|
169
|
+
// fonction qui n'est plus la sienne — un autre outil posé par-dessus (une
|
|
170
|
+
// sonde d'erreurs, un test qui espionne la console) ne se retrouverait plus.
|
|
171
|
+
// L'appel, lui, passe par `apply` avec la console pour `this`.
|
|
172
|
+
const erreurOrigine = console_.error
|
|
173
|
+
const avertissementOrigine = console_.warn
|
|
174
|
+
|
|
175
|
+
if (erreurOrigine) {
|
|
176
|
+
console_.error = (...args: unknown[]) => {
|
|
177
|
+
tampon.ajouter('error', args, new Date())
|
|
178
|
+
erreurOrigine.apply(console_, args)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (avertissementOrigine) {
|
|
182
|
+
console_.warn = (...args: unknown[]) => {
|
|
183
|
+
tampon.ajouter('warn', args, new Date())
|
|
184
|
+
avertissementOrigine.apply(console_, args)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Une exception non rattrapée n'atteint PAS `console.error` du point de vue
|
|
189
|
+
// du code : le navigateur l'affiche lui-même. Sans ces deux écouteurs, le
|
|
190
|
+
// tampon serait vide précisément quand l'app vient de casser.
|
|
191
|
+
const surErreur = (evenement: unknown) => {
|
|
192
|
+
const e = evenement as { message?: string; error?: unknown }
|
|
193
|
+
tampon.ajouter('exception', [e?.error ?? e?.message ?? evenement], new Date())
|
|
194
|
+
}
|
|
195
|
+
const surRejet = (evenement: unknown) => {
|
|
196
|
+
const e = evenement as { reason?: unknown }
|
|
197
|
+
tampon.ajouter('rejet', [e?.reason ?? evenement], new Date())
|
|
198
|
+
}
|
|
199
|
+
portee.addEventListener?.('error', surErreur)
|
|
200
|
+
portee.addEventListener?.('unhandledrejection', surRejet)
|
|
201
|
+
|
|
202
|
+
poseFaite = true
|
|
203
|
+
|
|
204
|
+
return () => {
|
|
205
|
+
if (erreurOrigine) console_.error = erreurOrigine
|
|
206
|
+
if (avertissementOrigine) console_.warn = avertissementOrigine
|
|
207
|
+
portee.removeEventListener?.('error', surErreur)
|
|
208
|
+
portee.removeEventListener?.('unhandledrejection', surRejet)
|
|
209
|
+
poseFaite = false
|
|
210
|
+
}
|
|
211
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Ce que le widget voit passer. Le fil, vu du navigateur.
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ Ces types DOUBLENT ceux de `@zevra/support`, et c'est délibéré : le
|
|
6
|
+
// widget ne doit avoir aucun lien, même de type, avec le SDK serveur. Un
|
|
7
|
+
// `import type` suffirait à faire remonter le paquet serveur dans le graphe de
|
|
8
|
+
// dépendances de l'app cliente — le typage disparaît à la compilation, mais
|
|
9
|
+
// l'entrée dans `package.json`, elle, reste, et c'est par là qu'un jour
|
|
10
|
+
// quelqu'un importera « juste une fonction ». Le paquet qui détient la clé
|
|
11
|
+
// n'est pas installable dans un bundle de navigateur ; ce paquet-ci l'est.
|
|
12
|
+
//
|
|
13
|
+
// La forme vient de PLAN.md §4. Elle est rendue par les routes de relais, qui
|
|
14
|
+
// transmettent la réponse de l'API v1 telle quelle.
|
|
15
|
+
|
|
16
|
+
export type TypeDemande = 'bug' | 'question' | 'idee' | 'beta'
|
|
17
|
+
|
|
18
|
+
export type StatutDemande =
|
|
19
|
+
| 'nouvelle'
|
|
20
|
+
| 'ouverte'
|
|
21
|
+
| 'en_attente_requerant'
|
|
22
|
+
| 'resolue'
|
|
23
|
+
| 'fermee'
|
|
24
|
+
|
|
25
|
+
export type AuteurMessage = 'requerant' | 'equipe' | 'ia' | 'systeme'
|
|
26
|
+
|
|
27
|
+
export const LIBELLES_TYPE: Record<TypeDemande, string> = {
|
|
28
|
+
bug: 'Un problème',
|
|
29
|
+
question: 'Une question',
|
|
30
|
+
idee: 'Une idée',
|
|
31
|
+
beta: 'Retour de bêta',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const LIBELLES_STATUT: Record<StatutDemande, string> = {
|
|
35
|
+
nouvelle: 'Nouvelle',
|
|
36
|
+
ouverte: 'En cours',
|
|
37
|
+
en_attente_requerant: 'Votre réponse est attendue',
|
|
38
|
+
resolue: 'Résolue',
|
|
39
|
+
fermee: 'Fermée',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ContexteReleve {
|
|
43
|
+
url?: string
|
|
44
|
+
user_agent?: string
|
|
45
|
+
version?: string
|
|
46
|
+
os?: string
|
|
47
|
+
viewport?: string
|
|
48
|
+
langue?: string
|
|
49
|
+
console?: string[]
|
|
50
|
+
extra?: Record<string, unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ResumeDemande {
|
|
54
|
+
id: string
|
|
55
|
+
reference: string
|
|
56
|
+
type: TypeDemande
|
|
57
|
+
statut: StatutDemande
|
|
58
|
+
titre: string
|
|
59
|
+
created_at: string
|
|
60
|
+
dernier_message_at: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface MessageLu {
|
|
64
|
+
id: string
|
|
65
|
+
auteur: AuteurMessage
|
|
66
|
+
corps: string
|
|
67
|
+
created_at: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface PieceLue {
|
|
71
|
+
id: string
|
|
72
|
+
nom: string
|
|
73
|
+
type_mime: string
|
|
74
|
+
octets: number
|
|
75
|
+
url?: string | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface DemandeLue {
|
|
79
|
+
demande: ResumeDemande & { description: string }
|
|
80
|
+
messages: MessageLu[]
|
|
81
|
+
pieces: PieceLue[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ScenarioCampagne {
|
|
85
|
+
cle: string
|
|
86
|
+
titre: string
|
|
87
|
+
etapes: string[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface ProgrammeActif {
|
|
91
|
+
programme?: { id: string; nom: string; description?: string | null } | null
|
|
92
|
+
campagne?: {
|
|
93
|
+
id: string
|
|
94
|
+
titre: string
|
|
95
|
+
consignes?: string | null
|
|
96
|
+
scenarios: ScenarioCampagne[]
|
|
97
|
+
} | null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** L'enveloppe d'erreur, identique côté API v1 et côté relais. */
|
|
101
|
+
export interface ErreurDuRelais {
|
|
102
|
+
code: string
|
|
103
|
+
message: string
|
|
104
|
+
champs?: Record<string, string>
|
|
105
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Ce qu'un signalement doit contenir pour valoir la peine d'être envoyé. PUR.
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Le serveur revérifie tout (c'est lui qui décide), et c'est bien pour ça que
|
|
6
|
+
// cette validation-ci existe : elle ne protège pas la base, elle évite un
|
|
7
|
+
// aller-retour et un message générique là où on peut dire précisément ce qui
|
|
8
|
+
// manque, à côté du champ concerné.
|
|
9
|
+
//
|
|
10
|
+
// ⚠️ On rend TOUS les défauts d'un coup, comme `verifierDemandeEntrante` du
|
|
11
|
+
// socle. Corriger un champ pour découvrir le suivant à chaque envoi est la
|
|
12
|
+
// manière la plus sûre de faire abandonner quelqu'un qui voulait signaler un
|
|
13
|
+
// bug.
|
|
14
|
+
|
|
15
|
+
import type { TypeDemande } from './types'
|
|
16
|
+
|
|
17
|
+
/** Le schéma borne `demande.titre` à 200 (PLAN.md §3). */
|
|
18
|
+
export const LONGUEUR_TITRE = 200
|
|
19
|
+
|
|
20
|
+
/** La description part dans un corps borné à 256 Ko, contexte compris. */
|
|
21
|
+
export const LONGUEUR_DESCRIPTION = 5000
|
|
22
|
+
|
|
23
|
+
/** Nombre minimal de caractères pour qu'une description diagnostique. */
|
|
24
|
+
export const MINIMUM_DESCRIPTION = 10
|
|
25
|
+
|
|
26
|
+
const TYPES: readonly string[] = ['bug', 'question', 'idee', 'beta']
|
|
27
|
+
|
|
28
|
+
export interface SaisieSignalement {
|
|
29
|
+
type?: string | null
|
|
30
|
+
titre?: string | null
|
|
31
|
+
description?: string | null
|
|
32
|
+
/** Retour de bêta : 1 à 5, ou rien. */
|
|
33
|
+
score?: number | null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Rend un message par champ fautif. `{}` = rien à redire. PUR.
|
|
38
|
+
*
|
|
39
|
+
* Les messages sont écrits pour la personne qui remplit le formulaire, pas
|
|
40
|
+
* pour le journal : ils disent quoi faire, pas ce qui a échoué.
|
|
41
|
+
*/
|
|
42
|
+
export function verifierSignalement(saisie: SaisieSignalement): Record<string, string> {
|
|
43
|
+
const defauts: Record<string, string> = {}
|
|
44
|
+
|
|
45
|
+
// ⚠️ Type et titre FACULTATIFS : le formulaire ne demande plus qu'une
|
|
46
|
+
// phrase, et c'est le triage IA de Support qui les pose. Ils ne sont
|
|
47
|
+
// vérifiés que s'ils sont fournis (pré-remplissage d'un retour de bêta).
|
|
48
|
+
if (saisie.type !== null && saisie.type !== undefined && !TYPES.includes(saisie.type)) {
|
|
49
|
+
defauts.type = 'Choisissez de quoi il s’agit.'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const titre = (saisie.titre ?? '').trim()
|
|
53
|
+
if (titre.length > LONGUEUR_TITRE) {
|
|
54
|
+
// On DIT le dépassement plutôt que de tronquer en silence : un titre coupé
|
|
55
|
+
// au milieu d'un mot dans la boîte de l'équipe a l'air d'un bug du support.
|
|
56
|
+
defauts.titre = `Le titre dépasse ${LONGUEUR_TITRE} caractères (${titre.length}).`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const description = (saisie.description ?? '').trim()
|
|
60
|
+
if (description === '') {
|
|
61
|
+
defauts.description = 'Dites-nous en une phrase ce qui se passe.'
|
|
62
|
+
} else if (description.length < MINIMUM_DESCRIPTION) {
|
|
63
|
+
defauts.description = 'Quelques mots de plus aideraient beaucoup.'
|
|
64
|
+
} else if (description.length > LONGUEUR_DESCRIPTION) {
|
|
65
|
+
defauts.description = `La description dépasse ${LONGUEUR_DESCRIPTION} caractères (${description.length}). Une pièce jointe passe mieux qu’un copier-coller.`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Le score est FACULTATIF : absent, il ne vaut pas zéro. Un zéro écrit à la
|
|
69
|
+
// place d'une absence ferait descendre la moyenne d'une campagne de bêta
|
|
70
|
+
// avec des notes que personne n'a données.
|
|
71
|
+
if (saisie.score !== null && saisie.score !== undefined) {
|
|
72
|
+
const score = Number(saisie.score)
|
|
73
|
+
if (!Number.isFinite(score) || score < 1 || score > 5 || !Number.isInteger(score)) {
|
|
74
|
+
defauts.score = 'La note va de 1 à 5.'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return defauts
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `true` quand la saisie peut partir. PUR. */
|
|
82
|
+
export function signalementValide(saisie: SaisieSignalement): boolean {
|
|
83
|
+
return Object.keys(verifierSignalement(saisie)).length === 0
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Garde de type sur le vocabulaire fermé. PUR. */
|
|
87
|
+
export function estTypeDemande(valeur: unknown): valeur is TypeDemande {
|
|
88
|
+
return typeof valeur === 'string' && TYPES.includes(valeur)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Les types à proposer dans la liste déroulante. PUR.
|
|
93
|
+
*
|
|
94
|
+
* ⚠️ Le type pré-rempli est AJOUTÉ s'il manque, et ce n'est pas du confort.
|
|
95
|
+
* `typesOfferts` vaut par défaut `['bug','question','idee']` — pas `beta` —
|
|
96
|
+
* alors que l'onglet Bêta pré-remplit justement `beta`. Un `<select>` contrôlé
|
|
97
|
+
* dont la valeur n'a aucune option correspondante s'affiche VIDE
|
|
98
|
+
* (`selectedIndex = -1`) : le champ « de quoi s'agit-il ? » paraît non rempli,
|
|
99
|
+
* et le premier geste de la personne — ouvrir la liste et choisir — remplace
|
|
100
|
+
* silencieusement `beta` par `bug` tout en gardant la campagne et le scénario.
|
|
101
|
+
* Le retour de bêta arrive alors dans la console sous un type qui n'est pas le
|
|
102
|
+
* sien, et la campagne compte un problème de moins.
|
|
103
|
+
*
|
|
104
|
+
* Rien de tout cela ne lève d'erreur : c'est un champ qui a l'air vide.
|
|
105
|
+
*/
|
|
106
|
+
export function typesAfficher(
|
|
107
|
+
typesOfferts: readonly TypeDemande[],
|
|
108
|
+
typePrefill: TypeDemande | undefined,
|
|
109
|
+
): TypeDemande[] {
|
|
110
|
+
const liste = [...typesOfferts]
|
|
111
|
+
if (typePrefill && !liste.includes(typePrefill)) liste.unshift(typePrefill)
|
|
112
|
+
return liste
|
|
113
|
+
}
|