@sia-ui/api 0.5.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,77 @@
1
1
  # @sia-ui/api
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Adaptateur TanStack Router, mises à jour en `PATCH`, et `secretChanges`.
8
+ - **`useDataTableQuery({ router })`** — adaptateur TanStack Router livré et
9
+ testé (`createTanStackRouterStorage`). La recette de la documentation mettait
10
+ `page=2` entre guillemets (`page=%222%22`) et la page retombait à 1 ; les
11
+ nombres passent désormais comme nombres, sans perte pour les filtres.
12
+ - **Un tableau n'écrit plus que ses propres clés** (`mergeTableQuery` dans
13
+ `@sia-ui/headless`) : deux tableaux préfixés sur une page, ou un onglet dans
14
+ l'URL, ne s'effacent plus l'un l'autre — avec l'URL du navigateur comme avec
15
+ un routeur.
16
+ - **`createQueryResource(service, { updateMethod: "PATCH" })`** — pour une API
17
+ qui n'accepte que des mises à jour partielles.
18
+ - **`secretChanges(entries, storedKeys)`** — ce qu'il faut envoyer après une
19
+ saisie dans `SecretFields` : `{ set, remove, missing }`.
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies
24
+ - @sia-ui/utils@0.7.0
25
+
26
+ ## 0.6.0
27
+
28
+ ### Minor Changes
29
+
30
+ - Rôles, requêtes serveur, ressources par mode, et deux blocs de console.
31
+ - **Rôles** — la couche d'accès lit les rôles de la session sous la forme
32
+ `role:<nom>` (`role("admin")`), avec les trois évaluateurs.
33
+ - **Tableaux servis par le serveur** — `useDataTableQuery` accepte un
34
+ `storage` (TanStack Router, React Router) et `serverParams` pour les noms
35
+ attendus (`limit`, `sort` + `order`, `ASC`/`DESC`). `toServerParams` dans
36
+ `@sia-ui/headless`.
37
+ - **TanStack Query** — `createQueryResource(service)` : clés emboîtées,
38
+ options de requête avec annulation, mutations qui invalident ce qu'elles
39
+ rendent périmé. Aucune dépendance à TanStack.
40
+ - **Ressources** — `inForm` et `required` par mode (`"create"`, `"edit"`),
41
+ `value` pour une colonne calculée.
42
+ - **En-têtes** — `PageHeader` `level`, `CrudPage` `headingLevel`. Le repli
43
+ suit la place disponible ; les actions ne s'écrasent plus.
44
+ - **Nouveaux** — `SecretFields` (secrets en écriture seule), `ActivityLog`
45
+ (journal filtré par le serveur), `StatusBadge` et `toneOf`, dix icônes de
46
+ console.
47
+ - Les icônes des boutons de `CrudPage` et `DataTable` passent par
48
+ `leftIcon` : elles ne se collent plus au libellé.
49
+
50
+ - `sia-ui update`, la feuille commune suivie, et des erreurs HTTP en français.
51
+ - **`sia-ui update [nom...]`** — met à jour les entrées en retard sans perdre
52
+ de retouche : un fichier intact est remplacé, un fichier modifié depuis son
53
+ installation est gardé et signalé (`sia-ui diff`, puis `--force` pour
54
+ l'écraser). Les dépendances de registre apparues sont installées, les
55
+ paquets npm annoncés. `--dry-run` montre sans rien écrire. Une entrée dont
56
+ un fichier a été gardé reste « en retard » dans le verrou.
57
+ - **`sia-ui.css` suivie** — son empreinte est notée dans `sia-ui.lock.json`.
58
+ `update` la met à jour comme un composant ; `add` aussi, si elle n'a pas
59
+ été retouchée. `init` ne fait plus qu'initialiser. Elle est lue au même
60
+ registre que les composants, HTTP compris. `doctor` signale une feuille en
61
+ retard, modifiée ou copiée avant le suivi.
62
+ - `list --outdated` et `doctor` conseillent `sia-ui update` au lieu de
63
+ `add --overwrite`, qui écrasait aussi les retouches.
64
+ - **`statusMessages`** (`@sia-ui/api`) — option du client, avec
65
+ `FRENCH_STATUS_MESSAGES`. Remplace les messages génériques du serveur
66
+ (« Conflict », « Forbidden resource », « Cannot GET /x »,
67
+ `ThrottlerException`) et l'échec réseau de `fetch`, jamais un message métier
68
+ ni une erreur de champs.
69
+
70
+ ### Patch Changes
71
+
72
+ - Updated dependencies
73
+ - @sia-ui/utils@0.6.0
74
+
3
75
  ## 0.5.0
4
76
 
5
77
  ### Minor Changes
package/README.md CHANGED
@@ -57,6 +57,78 @@ Le contenu ci-dessous reprend intégralement `CHANGELOG.md` pour rester visible
57
57
  <!-- sia:changelog:start -->
58
58
  # @sia-ui/api
59
59
 
60
+ ## 0.7.0
61
+
62
+ ### Minor Changes
63
+
64
+ - Adaptateur TanStack Router, mises à jour en `PATCH`, et `secretChanges`.
65
+ - **`useDataTableQuery({ router })`** — adaptateur TanStack Router livré et
66
+ testé (`createTanStackRouterStorage`). La recette de la documentation mettait
67
+ `page=2` entre guillemets (`page=%222%22`) et la page retombait à 1 ; les
68
+ nombres passent désormais comme nombres, sans perte pour les filtres.
69
+ - **Un tableau n'écrit plus que ses propres clés** (`mergeTableQuery` dans
70
+ `@sia-ui/headless`) : deux tableaux préfixés sur une page, ou un onglet dans
71
+ l'URL, ne s'effacent plus l'un l'autre — avec l'URL du navigateur comme avec
72
+ un routeur.
73
+ - **`createQueryResource(service, { updateMethod: "PATCH" })`** — pour une API
74
+ qui n'accepte que des mises à jour partielles.
75
+ - **`secretChanges(entries, storedKeys)`** — ce qu'il faut envoyer après une
76
+ saisie dans `SecretFields` : `{ set, remove, missing }`.
77
+
78
+ ### Patch Changes
79
+
80
+ - Updated dependencies
81
+ - @sia-ui/utils@0.7.0
82
+
83
+ ## 0.6.0
84
+
85
+ ### Minor Changes
86
+
87
+ - Rôles, requêtes serveur, ressources par mode, et deux blocs de console.
88
+ - **Rôles** — la couche d'accès lit les rôles de la session sous la forme
89
+ `role:<nom>` (`role("admin")`), avec les trois évaluateurs.
90
+ - **Tableaux servis par le serveur** — `useDataTableQuery` accepte un
91
+ `storage` (TanStack Router, React Router) et `serverParams` pour les noms
92
+ attendus (`limit`, `sort` + `order`, `ASC`/`DESC`). `toServerParams` dans
93
+ `@sia-ui/headless`.
94
+ - **TanStack Query** — `createQueryResource(service)` : clés emboîtées,
95
+ options de requête avec annulation, mutations qui invalident ce qu'elles
96
+ rendent périmé. Aucune dépendance à TanStack.
97
+ - **Ressources** — `inForm` et `required` par mode (`"create"`, `"edit"`),
98
+ `value` pour une colonne calculée.
99
+ - **En-têtes** — `PageHeader` `level`, `CrudPage` `headingLevel`. Le repli
100
+ suit la place disponible ; les actions ne s'écrasent plus.
101
+ - **Nouveaux** — `SecretFields` (secrets en écriture seule), `ActivityLog`
102
+ (journal filtré par le serveur), `StatusBadge` et `toneOf`, dix icônes de
103
+ console.
104
+ - Les icônes des boutons de `CrudPage` et `DataTable` passent par
105
+ `leftIcon` : elles ne se collent plus au libellé.
106
+
107
+ - `sia-ui update`, la feuille commune suivie, et des erreurs HTTP en français.
108
+ - **`sia-ui update [nom...]`** — met à jour les entrées en retard sans perdre
109
+ de retouche : un fichier intact est remplacé, un fichier modifié depuis son
110
+ installation est gardé et signalé (`sia-ui diff`, puis `--force` pour
111
+ l'écraser). Les dépendances de registre apparues sont installées, les
112
+ paquets npm annoncés. `--dry-run` montre sans rien écrire. Une entrée dont
113
+ un fichier a été gardé reste « en retard » dans le verrou.
114
+ - **`sia-ui.css` suivie** — son empreinte est notée dans `sia-ui.lock.json`.
115
+ `update` la met à jour comme un composant ; `add` aussi, si elle n'a pas
116
+ été retouchée. `init` ne fait plus qu'initialiser. Elle est lue au même
117
+ registre que les composants, HTTP compris. `doctor` signale une feuille en
118
+ retard, modifiée ou copiée avant le suivi.
119
+ - `list --outdated` et `doctor` conseillent `sia-ui update` au lieu de
120
+ `add --overwrite`, qui écrasait aussi les retouches.
121
+ - **`statusMessages`** (`@sia-ui/api`) — option du client, avec
122
+ `FRENCH_STATUS_MESSAGES`. Remplace les messages génériques du serveur
123
+ (« Conflict », « Forbidden resource », « Cannot GET /x »,
124
+ `ThrottlerException`) et l'échec réseau de `fetch`, jamais un message métier
125
+ ni une erreur de champs.
126
+
127
+ ### Patch Changes
128
+
129
+ - Updated dependencies
130
+ - @sia-ui/utils@0.6.0
131
+
60
132
  ## 0.5.0
61
133
 
62
134
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -138,6 +138,66 @@ var HttpError = class _HttpError extends Error {
138
138
  return this.status >= 500;
139
139
  }
140
140
  };
141
+
142
+ // src/status-messages.ts
143
+ var FRENCH_STATUS_MESSAGES = {
144
+ 0: "Le serveur est injoignable. V\xE9rifiez votre connexion.",
145
+ 400: "La demande est invalide.",
146
+ 401: "Votre session a expir\xE9. Reconnectez-vous.",
147
+ 403: "Vous n'avez pas les droits n\xE9cessaires pour cette action.",
148
+ 404: "L'\xE9l\xE9ment demand\xE9 est introuvable.",
149
+ 408: "Le serveur a mis trop de temps \xE0 r\xE9pondre.",
150
+ 409: "Cette op\xE9ration entre en conflit avec l'\xE9tat actuel. Rechargez puis r\xE9essayez.",
151
+ 413: "Le contenu envoy\xE9 est trop volumineux.",
152
+ 422: "Certaines valeurs sont invalides.",
153
+ 429: "Trop de tentatives. R\xE9essayez dans un instant.",
154
+ 500: "Une erreur est survenue sur le serveur.",
155
+ 502: "Le service est momentan\xE9ment indisponible.",
156
+ 503: "Le service est momentan\xE9ment indisponible.",
157
+ 504: "Le serveur a mis trop de temps \xE0 r\xE9pondre."
158
+ };
159
+ var ECHEC_RESEAU = /failed to fetch|networkerror|load failed/i;
160
+ var PHRASES = {
161
+ 400: "bad request",
162
+ 401: "unauthorized",
163
+ 403: "forbidden",
164
+ 404: "not found",
165
+ 405: "method not allowed",
166
+ 408: "request timeout",
167
+ 409: "conflict",
168
+ 413: "payload too large",
169
+ 415: "unsupported media type",
170
+ 422: "unprocessable entity",
171
+ 429: "too many requests",
172
+ 500: "internal server error",
173
+ 502: "bad gateway",
174
+ 503: "service unavailable",
175
+ 504: "gateway timeout"
176
+ };
177
+ function isGenericMessage(status, message, body) {
178
+ const texte = message?.trim().toLowerCase();
179
+ if (!texte || texte === `http ${status}`) return true;
180
+ const phrase = PHRASES[status];
181
+ if (phrase && (texte === phrase || texte === `${phrase} resource`)) return true;
182
+ const erreur = body && typeof body === "object" ? body.error : void 0;
183
+ if (typeof erreur === "string" && texte === erreur.trim().toLowerCase()) {
184
+ return true;
185
+ }
186
+ return /^cannot (get|post|put|patch|delete|head) /.test(texte) || /exception\b/.test(texte);
187
+ }
188
+ function localizeError(error, messages) {
189
+ if (error instanceof HttpError) {
190
+ const remplacant = messages[error.status];
191
+ if (remplacant && error.fields.length === 0 && isGenericMessage(error.status, error.message, error.body)) {
192
+ error.message = remplacant;
193
+ }
194
+ return error;
195
+ }
196
+ if (error instanceof TypeError && messages[0] && ECHEC_RESEAU.test(error.message)) {
197
+ error.message = messages[0];
198
+ }
199
+ return error;
200
+ }
141
201
  function buildQueryString(params) {
142
202
  return query.toQueryString(params);
143
203
  }
@@ -356,6 +416,7 @@ var ApiClient = class {
356
416
  defaultRetry;
357
417
  retryMethods;
358
418
  fieldErrors;
419
+ statusMessages;
359
420
  defaultTimeoutMs;
360
421
  plugins;
361
422
  responseHandler;
@@ -372,6 +433,7 @@ var ApiClient = class {
372
433
  (options.retryMethods ?? DEFAULT_RETRY_METHODS).map(normalizeMethod)
373
434
  );
374
435
  this.fieldErrors = options.fieldErrors;
436
+ this.statusMessages = options.statusMessages;
375
437
  this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
376
438
  this.plugins = options.plugins ?? [];
377
439
  this.responseHandler = new ResponseHandler(options.responseHandler);
@@ -469,7 +531,9 @@ var ApiClient = class {
469
531
  } catch (error) {
470
532
  const pluginResult = await this.runPlugins("onError", error, ctx);
471
533
  if (pluginResult !== void 0) return pluginResult;
472
- if (!this.shouldRetry(error, attempt, maxRetries)) throw error;
534
+ if (!this.shouldRetry(error, attempt, maxRetries)) {
535
+ throw this.statusMessages ? localizeError(error, this.statusMessages) : error;
536
+ }
473
537
  attempt += 1;
474
538
  const baseDelay = Math.min(1e3 * 2 ** attempt, 3e4);
475
539
  await async.sleep(baseDelay + Math.random() * 0.1 * baseDelay);
@@ -860,6 +924,10 @@ var BaseService = class {
860
924
  this.client = client;
861
925
  this.resource = resource;
862
926
  }
927
+ /** Le chemin de la ressource — sert aussi de racine aux clés de cache. */
928
+ get path() {
929
+ return this.resource;
930
+ }
863
931
  normalizeQueryParams(params) {
864
932
  if (!params || typeof params !== "object") return params;
865
933
  const normalized = { ...params };
@@ -943,10 +1011,54 @@ function createResourceService(client, resourcePath) {
943
1011
  return new BaseService(client, resourcePath);
944
1012
  }
945
1013
 
1014
+ // src/query-resource.ts
1015
+ function createQueryResource(service, options = {}) {
1016
+ const racine = options.name ?? service.path;
1017
+ const modifier = (id, values) => options.updateMethod === "PATCH" ? service.patch(id, values) : service.update(id, values);
1018
+ const keys = {
1019
+ all: [racine],
1020
+ lists: () => [racine, "list"],
1021
+ list: (params) => [racine, "list", params ?? {}],
1022
+ details: () => [racine, "detail"],
1023
+ detail: (id) => [racine, "detail", id]
1024
+ };
1025
+ const invalider = (client, ...cles) => Promise.all(cles.map((queryKey) => client.invalidateQueries({ queryKey })));
1026
+ return {
1027
+ keys,
1028
+ /** Une liste nue — `service.list`. */
1029
+ listQuery: (params) => ({
1030
+ queryKey: keys.list(params),
1031
+ queryFn: ({ signal } = {}) => service.list(params, { signal })
1032
+ }),
1033
+ /** Une page — `service.paginated`, rendue en `{ items, meta }`. */
1034
+ pageQuery: (page = {}) => ({
1035
+ queryKey: keys.list(page),
1036
+ queryFn: ({ signal } = {}) => service.paginated({ ...page, config: { signal } })
1037
+ }),
1038
+ detailQuery: (id) => ({
1039
+ queryKey: keys.detail(id),
1040
+ queryFn: ({ signal } = {}) => service.getById(id, { signal })
1041
+ }),
1042
+ /**
1043
+ * Crée sans `id`, modifie avec. Invalide les listes, et le détail
1044
+ * modifié : c'est ce que chaque écran écrivait à la main, ou oubliait.
1045
+ */
1046
+ saveMutation: (client) => ({
1047
+ mutationFn: ({ id, values }) => id === void 0 ? service.create(values) : modifier(id, values),
1048
+ onSuccess: (_, { id }) => id === void 0 ? invalider(client, keys.lists()) : invalider(client, keys.lists(), keys.detail(id))
1049
+ }),
1050
+ removeMutation: (client) => ({
1051
+ mutationFn: (id) => service.remove(id),
1052
+ onSuccess: (_, id) => invalider(client, keys.lists(), keys.detail(id))
1053
+ })
1054
+ };
1055
+ }
1056
+
946
1057
  exports.ACCESS_TOKEN_KEY = ACCESS_TOKEN_KEY;
947
1058
  exports.ApiClient = ApiClient;
948
1059
  exports.BaseService = BaseService;
949
1060
  exports.FILTERS_HEADER = FILTERS_HEADER;
1061
+ exports.FRENCH_STATUS_MESSAGES = FRENCH_STATUS_MESSAGES;
950
1062
  exports.HttpError = HttpError;
951
1063
  exports.REFRESH_TOKEN_KEY = REFRESH_TOKEN_KEY;
952
1064
  exports.ResponseHandler = ResponseHandler;
@@ -960,10 +1072,13 @@ exports.createHeaderSignalPlugin = createHeaderSignalPlugin;
960
1072
  exports.createIdempotencyPlugin = createIdempotencyPlugin;
961
1073
  exports.createLoggerPlugin = createLoggerPlugin;
962
1074
  exports.createMemoryTokenStorage = createMemoryTokenStorage;
1075
+ exports.createQueryResource = createQueryResource;
963
1076
  exports.createReadOnlyPlugin = createReadOnlyPlugin;
964
1077
  exports.createRefreshTokenPlugin = createRefreshTokenPlugin;
965
1078
  exports.createResourceService = createResourceService;
966
1079
  exports.createUnauthorizedPlugin = createUnauthorizedPlugin;
967
1080
  exports.filtersHeader = filtersHeader;
1081
+ exports.isGenericMessage = isGenericMessage;
1082
+ exports.localizeError = localizeError;
968
1083
  exports.nestFieldErrors = nestFieldErrors;
969
1084
  exports.normalizeFilters = normalizeFilters;
package/dist/index.d.cts CHANGED
@@ -157,6 +157,15 @@ interface ApiClientOptions {
157
157
  * lit en plus le `message: string[]` de class-validator.
158
158
  */
159
159
  fieldErrors?: ((body: unknown) => FieldError[]) | undefined;
160
+ /**
161
+ * Des messages par statut, à la place des messages génériques du serveur.
162
+ *
163
+ * `FRENCH_STATUS_MESSAGES` couvre les cas courants. Seul un message qui
164
+ * n'apprend rien est remplacé — « Conflict », « Forbidden resource »,
165
+ * « Cannot GET /x » — jamais un message métier. `0` vaut pour l'échec
166
+ * réseau.
167
+ */
168
+ statusMessages?: Readonly<Record<number, string>> | undefined;
160
169
  }
161
170
  type ApiRequestInput = string | {
162
171
  path: string;
@@ -180,6 +189,7 @@ declare class ApiClient {
180
189
  private defaultRetry;
181
190
  private retryMethods;
182
191
  private fieldErrors;
192
+ private statusMessages;
183
193
  private defaultTimeoutMs;
184
194
  private plugins;
185
195
  private responseHandler;
@@ -234,6 +244,31 @@ declare class ApiClient {
234
244
  }
235
245
  declare function createApiClient(options: ApiClientOptions): ApiClient;
236
246
 
247
+ /**
248
+ * Les messages par statut, en français.
249
+ *
250
+ * `0` couvre l'échec réseau : le serveur injoignable, la connexion coupée.
251
+ */
252
+ declare const FRENCH_STATUS_MESSAGES: Readonly<Record<number, string>>;
253
+ /**
254
+ * Vrai quand le message du serveur est générique.
255
+ *
256
+ * Absent, la phrase HTTP du statut, le champ `error` de NestJS
257
+ * (`"Conflict"`), sa variante `"Forbidden resource"`, le 404 de route
258
+ * (`"Cannot GET /x"`) ou le nom d'une exception
259
+ * (`"ThrottlerException: Too Many Requests"`). Un message métier —
260
+ * « Ce client existe déjà » — ne l'est pas : il reste tel quel.
261
+ */
262
+ declare function isGenericMessage(status: number, message: string | undefined, body?: unknown): boolean;
263
+ /**
264
+ * Remplace un message générique par celui de la table.
265
+ *
266
+ * L'erreur garde son type et son corps : seul `message` change, et seulement
267
+ * s'il n'apprenait rien. Une erreur de champs n'est jamais touchée — ses
268
+ * messages par champ disent déjà ce qui ne va pas.
269
+ */
270
+ declare function localizeError(error: unknown, messages: Readonly<Record<number, string>>): unknown;
271
+
237
272
  interface AuthPluginOptions {
238
273
  getToken: () => Promise<string | null> | string | null;
239
274
  scheme?: string;
@@ -390,6 +425,8 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
390
425
  protected client: ApiClient;
391
426
  protected resource: string;
392
427
  constructor(client: ApiClient, resource: string);
428
+ /** Le chemin de la ressource — sert aussi de racine aux clés de cache. */
429
+ get path(): string;
393
430
  protected normalizeQueryParams(params?: TListParams): TListParams | undefined;
394
431
  list(params?: TListParams, config?: ApiRequestConfig): Promise<TEntity[]>;
395
432
  getById(id: string, config?: ApiRequestConfig): Promise<TEntity>;
@@ -429,6 +466,100 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
429
466
  }
430
467
  declare function createResourceService<TEntity>(client: ApiClient, resourcePath: string): BaseService<TEntity, Partial<TEntity>, Partial<TEntity>, QueryParams>;
431
468
 
469
+ /**
470
+ * Ce que l'invalidation demande d'un client de cache.
471
+ *
472
+ * Le `QueryClient` de TanStack Query le satisfait tel quel ; aucune
473
+ * dépendance n'est donc tirée ici. Un autre cache n'a qu'à fournir cette
474
+ * seule méthode.
475
+ */
476
+ interface QueryInvalidator {
477
+ invalidateQueries: (filters: {
478
+ queryKey: readonly unknown[];
479
+ }) => unknown;
480
+ }
481
+ /** Le contexte qu'un `queryFn` reçoit — seul le signal d'annulation sert. */
482
+ interface QueryFnContext {
483
+ signal?: AbortSignal | undefined;
484
+ }
485
+ /** Ce que reçoit `saveMutation` : sans `id`, une création. */
486
+ interface SaveVariables<TCreate, TUpdate> {
487
+ id?: string | undefined;
488
+ values: TCreate | TUpdate;
489
+ }
490
+ interface QueryResourceOptions {
491
+ /**
492
+ * La racine des clés. À défaut, le chemin du service.
493
+ *
494
+ * Deux ressources sous la même racine s'invalideraient l'une l'autre ;
495
+ * c'est ce nom qui les sépare.
496
+ */
497
+ name?: string | undefined;
498
+ /**
499
+ * Comment `saveMutation` modifie une ligne. `PUT` par défaut
500
+ * (`service.update`) ; `PATCH` (`service.patch`) pour une API qui
501
+ * n'accepte que des mises à jour partielles.
502
+ */
503
+ updateMethod?: "PUT" | "PATCH" | undefined;
504
+ }
505
+ /**
506
+ * Les clés et les options de requête d'une ressource.
507
+ *
508
+ * `api.query` se contentait de rendre le chargeur : chaque écran réinventait
509
+ * ses clés de cache, et la moitié oubliait d'invalider la liste après une
510
+ * création. Ici, les clés s'emboîtent — `lists()` couvre toutes les listes,
511
+ * quels qu'en soient les filtres — et chaque mutation sait ce qu'elle rend
512
+ * périmé.
513
+ *
514
+ * ```tsx
515
+ * const factures = createQueryResource(factureService);
516
+ *
517
+ * const { data } = useQuery(factures.pageQuery({ page, limit: 20 }));
518
+ * const enregistrer = useMutation(factures.saveMutation(queryClient));
519
+ * enregistrer.mutate({ id, values }); // crée sans id, modifie avec
520
+ * ```
521
+ */
522
+ declare function createQueryResource<TEntity, TCreate = Partial<TEntity>, TUpdate = Partial<TEntity>, TParams extends QueryParams = QueryParams>(service: BaseService<TEntity, TCreate, TUpdate, TParams>, options?: QueryResourceOptions): {
523
+ keys: {
524
+ all: readonly [string];
525
+ lists: () => readonly [string, "list"];
526
+ list: (params?: unknown) => readonly [string, "list", {}];
527
+ details: () => readonly [string, "detail"];
528
+ detail: (id: string) => readonly [string, "detail", string];
529
+ };
530
+ /** Une liste nue — `service.list`. */
531
+ listQuery: (params?: TParams) => {
532
+ queryKey: readonly [string, "list", {}];
533
+ queryFn: ({ signal }?: QueryFnContext) => Promise<TEntity[]>;
534
+ };
535
+ /** Une page — `service.paginated`, rendue en `{ items, meta }`. */
536
+ pageQuery: (page?: {
537
+ page?: number;
538
+ limit?: number;
539
+ query?: TParams;
540
+ }) => {
541
+ queryKey: readonly [string, "list", {}];
542
+ queryFn: ({ signal }?: QueryFnContext) => Promise<PaginatedResponse<TEntity>>;
543
+ };
544
+ detailQuery: (id: string) => {
545
+ queryKey: readonly [string, "detail", string];
546
+ queryFn: ({ signal }?: QueryFnContext) => Promise<TEntity>;
547
+ };
548
+ /**
549
+ * Crée sans `id`, modifie avec. Invalide les listes, et le détail
550
+ * modifié : c'est ce que chaque écran écrivait à la main, ou oubliait.
551
+ */
552
+ saveMutation: (client: QueryInvalidator) => {
553
+ mutationFn: ({ id, values }: SaveVariables<TCreate, TUpdate>) => Promise<TEntity>;
554
+ onSuccess: (_: TEntity, { id }: SaveVariables<TCreate, TUpdate>) => Promise<unknown[]>;
555
+ };
556
+ removeMutation: (client: QueryInvalidator) => {
557
+ mutationFn: (id: string) => Promise<void>;
558
+ onSuccess: (_: void, id: string) => Promise<unknown[]>;
559
+ };
560
+ };
561
+ type QueryResource<TEntity> = ReturnType<typeof createQueryResource<TEntity>>;
562
+
432
563
  interface AxiosLikeInstance {
433
564
  request<T = unknown>(config: {
434
565
  url: string;
@@ -451,4 +582,4 @@ interface AxiosLikeInstance {
451
582
  declare function createFetchTransport(fetcher?: typeof fetch): ApiTransport;
452
583
  declare function createAxiosTransport(axios: AxiosLikeInstance): ApiTransport;
453
584
 
454
- export { ACCESS_TOKEN_KEY, ApiClient, type ApiClientOptions, type ApiFilters, type ApiMethod, type ApiPlugin, type ApiPluginHook, type ApiRequestConfig, type ApiRequestContext, type ApiRequestInput, type ApiTransport, type ApiTransportRequest, type ApiTransportResponse, type AuthPluginOptions, BaseService, type BrowserTokenStorageOptions, type CursorMeta, FILTERS_HEADER, type FieldError, type FieldErrorExtractor, type FilterCondition, type FilterGroup, type FilterOperator, type FilterValue, type HeaderSignalPluginOptions, HttpError, type IdempotencyPluginOptions, type LoggerPluginOptions, type PageMeta, type PaginatedResponse, type QueryParamObject, type QueryParams, REFRESH_TOKEN_KEY, type RefreshTokenConfig, ResponseHandler, type ResponseHandlerConfig, type RtkBaseQueryResult, type TokenStorage, type UnauthorizedPluginOptions, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, nestFieldErrors, normalizeFilters };
585
+ export { ACCESS_TOKEN_KEY, ApiClient, type ApiClientOptions, type ApiFilters, type ApiMethod, type ApiPlugin, type ApiPluginHook, type ApiRequestConfig, type ApiRequestContext, type ApiRequestInput, type ApiTransport, type ApiTransportRequest, type ApiTransportResponse, type AuthPluginOptions, BaseService, type BrowserTokenStorageOptions, type CursorMeta, FILTERS_HEADER, FRENCH_STATUS_MESSAGES, type FieldError, type FieldErrorExtractor, type FilterCondition, type FilterGroup, type FilterOperator, type FilterValue, type HeaderSignalPluginOptions, HttpError, type IdempotencyPluginOptions, type LoggerPluginOptions, type PageMeta, type PaginatedResponse, type QueryFnContext, type QueryInvalidator, type QueryParamObject, type QueryParams, type QueryResource, type QueryResourceOptions, REFRESH_TOKEN_KEY, type RefreshTokenConfig, ResponseHandler, type ResponseHandlerConfig, type RtkBaseQueryResult, type SaveVariables, type TokenStorage, type UnauthorizedPluginOptions, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createQueryResource, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, isGenericMessage, localizeError, nestFieldErrors, normalizeFilters };
package/dist/index.d.ts CHANGED
@@ -157,6 +157,15 @@ interface ApiClientOptions {
157
157
  * lit en plus le `message: string[]` de class-validator.
158
158
  */
159
159
  fieldErrors?: ((body: unknown) => FieldError[]) | undefined;
160
+ /**
161
+ * Des messages par statut, à la place des messages génériques du serveur.
162
+ *
163
+ * `FRENCH_STATUS_MESSAGES` couvre les cas courants. Seul un message qui
164
+ * n'apprend rien est remplacé — « Conflict », « Forbidden resource »,
165
+ * « Cannot GET /x » — jamais un message métier. `0` vaut pour l'échec
166
+ * réseau.
167
+ */
168
+ statusMessages?: Readonly<Record<number, string>> | undefined;
160
169
  }
161
170
  type ApiRequestInput = string | {
162
171
  path: string;
@@ -180,6 +189,7 @@ declare class ApiClient {
180
189
  private defaultRetry;
181
190
  private retryMethods;
182
191
  private fieldErrors;
192
+ private statusMessages;
183
193
  private defaultTimeoutMs;
184
194
  private plugins;
185
195
  private responseHandler;
@@ -234,6 +244,31 @@ declare class ApiClient {
234
244
  }
235
245
  declare function createApiClient(options: ApiClientOptions): ApiClient;
236
246
 
247
+ /**
248
+ * Les messages par statut, en français.
249
+ *
250
+ * `0` couvre l'échec réseau : le serveur injoignable, la connexion coupée.
251
+ */
252
+ declare const FRENCH_STATUS_MESSAGES: Readonly<Record<number, string>>;
253
+ /**
254
+ * Vrai quand le message du serveur est générique.
255
+ *
256
+ * Absent, la phrase HTTP du statut, le champ `error` de NestJS
257
+ * (`"Conflict"`), sa variante `"Forbidden resource"`, le 404 de route
258
+ * (`"Cannot GET /x"`) ou le nom d'une exception
259
+ * (`"ThrottlerException: Too Many Requests"`). Un message métier —
260
+ * « Ce client existe déjà » — ne l'est pas : il reste tel quel.
261
+ */
262
+ declare function isGenericMessage(status: number, message: string | undefined, body?: unknown): boolean;
263
+ /**
264
+ * Remplace un message générique par celui de la table.
265
+ *
266
+ * L'erreur garde son type et son corps : seul `message` change, et seulement
267
+ * s'il n'apprenait rien. Une erreur de champs n'est jamais touchée — ses
268
+ * messages par champ disent déjà ce qui ne va pas.
269
+ */
270
+ declare function localizeError(error: unknown, messages: Readonly<Record<number, string>>): unknown;
271
+
237
272
  interface AuthPluginOptions {
238
273
  getToken: () => Promise<string | null> | string | null;
239
274
  scheme?: string;
@@ -390,6 +425,8 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
390
425
  protected client: ApiClient;
391
426
  protected resource: string;
392
427
  constructor(client: ApiClient, resource: string);
428
+ /** Le chemin de la ressource — sert aussi de racine aux clés de cache. */
429
+ get path(): string;
393
430
  protected normalizeQueryParams(params?: TListParams): TListParams | undefined;
394
431
  list(params?: TListParams, config?: ApiRequestConfig): Promise<TEntity[]>;
395
432
  getById(id: string, config?: ApiRequestConfig): Promise<TEntity>;
@@ -429,6 +466,100 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
429
466
  }
430
467
  declare function createResourceService<TEntity>(client: ApiClient, resourcePath: string): BaseService<TEntity, Partial<TEntity>, Partial<TEntity>, QueryParams>;
431
468
 
469
+ /**
470
+ * Ce que l'invalidation demande d'un client de cache.
471
+ *
472
+ * Le `QueryClient` de TanStack Query le satisfait tel quel ; aucune
473
+ * dépendance n'est donc tirée ici. Un autre cache n'a qu'à fournir cette
474
+ * seule méthode.
475
+ */
476
+ interface QueryInvalidator {
477
+ invalidateQueries: (filters: {
478
+ queryKey: readonly unknown[];
479
+ }) => unknown;
480
+ }
481
+ /** Le contexte qu'un `queryFn` reçoit — seul le signal d'annulation sert. */
482
+ interface QueryFnContext {
483
+ signal?: AbortSignal | undefined;
484
+ }
485
+ /** Ce que reçoit `saveMutation` : sans `id`, une création. */
486
+ interface SaveVariables<TCreate, TUpdate> {
487
+ id?: string | undefined;
488
+ values: TCreate | TUpdate;
489
+ }
490
+ interface QueryResourceOptions {
491
+ /**
492
+ * La racine des clés. À défaut, le chemin du service.
493
+ *
494
+ * Deux ressources sous la même racine s'invalideraient l'une l'autre ;
495
+ * c'est ce nom qui les sépare.
496
+ */
497
+ name?: string | undefined;
498
+ /**
499
+ * Comment `saveMutation` modifie une ligne. `PUT` par défaut
500
+ * (`service.update`) ; `PATCH` (`service.patch`) pour une API qui
501
+ * n'accepte que des mises à jour partielles.
502
+ */
503
+ updateMethod?: "PUT" | "PATCH" | undefined;
504
+ }
505
+ /**
506
+ * Les clés et les options de requête d'une ressource.
507
+ *
508
+ * `api.query` se contentait de rendre le chargeur : chaque écran réinventait
509
+ * ses clés de cache, et la moitié oubliait d'invalider la liste après une
510
+ * création. Ici, les clés s'emboîtent — `lists()` couvre toutes les listes,
511
+ * quels qu'en soient les filtres — et chaque mutation sait ce qu'elle rend
512
+ * périmé.
513
+ *
514
+ * ```tsx
515
+ * const factures = createQueryResource(factureService);
516
+ *
517
+ * const { data } = useQuery(factures.pageQuery({ page, limit: 20 }));
518
+ * const enregistrer = useMutation(factures.saveMutation(queryClient));
519
+ * enregistrer.mutate({ id, values }); // crée sans id, modifie avec
520
+ * ```
521
+ */
522
+ declare function createQueryResource<TEntity, TCreate = Partial<TEntity>, TUpdate = Partial<TEntity>, TParams extends QueryParams = QueryParams>(service: BaseService<TEntity, TCreate, TUpdate, TParams>, options?: QueryResourceOptions): {
523
+ keys: {
524
+ all: readonly [string];
525
+ lists: () => readonly [string, "list"];
526
+ list: (params?: unknown) => readonly [string, "list", {}];
527
+ details: () => readonly [string, "detail"];
528
+ detail: (id: string) => readonly [string, "detail", string];
529
+ };
530
+ /** Une liste nue — `service.list`. */
531
+ listQuery: (params?: TParams) => {
532
+ queryKey: readonly [string, "list", {}];
533
+ queryFn: ({ signal }?: QueryFnContext) => Promise<TEntity[]>;
534
+ };
535
+ /** Une page — `service.paginated`, rendue en `{ items, meta }`. */
536
+ pageQuery: (page?: {
537
+ page?: number;
538
+ limit?: number;
539
+ query?: TParams;
540
+ }) => {
541
+ queryKey: readonly [string, "list", {}];
542
+ queryFn: ({ signal }?: QueryFnContext) => Promise<PaginatedResponse<TEntity>>;
543
+ };
544
+ detailQuery: (id: string) => {
545
+ queryKey: readonly [string, "detail", string];
546
+ queryFn: ({ signal }?: QueryFnContext) => Promise<TEntity>;
547
+ };
548
+ /**
549
+ * Crée sans `id`, modifie avec. Invalide les listes, et le détail
550
+ * modifié : c'est ce que chaque écran écrivait à la main, ou oubliait.
551
+ */
552
+ saveMutation: (client: QueryInvalidator) => {
553
+ mutationFn: ({ id, values }: SaveVariables<TCreate, TUpdate>) => Promise<TEntity>;
554
+ onSuccess: (_: TEntity, { id }: SaveVariables<TCreate, TUpdate>) => Promise<unknown[]>;
555
+ };
556
+ removeMutation: (client: QueryInvalidator) => {
557
+ mutationFn: (id: string) => Promise<void>;
558
+ onSuccess: (_: void, id: string) => Promise<unknown[]>;
559
+ };
560
+ };
561
+ type QueryResource<TEntity> = ReturnType<typeof createQueryResource<TEntity>>;
562
+
432
563
  interface AxiosLikeInstance {
433
564
  request<T = unknown>(config: {
434
565
  url: string;
@@ -451,4 +582,4 @@ interface AxiosLikeInstance {
451
582
  declare function createFetchTransport(fetcher?: typeof fetch): ApiTransport;
452
583
  declare function createAxiosTransport(axios: AxiosLikeInstance): ApiTransport;
453
584
 
454
- export { ACCESS_TOKEN_KEY, ApiClient, type ApiClientOptions, type ApiFilters, type ApiMethod, type ApiPlugin, type ApiPluginHook, type ApiRequestConfig, type ApiRequestContext, type ApiRequestInput, type ApiTransport, type ApiTransportRequest, type ApiTransportResponse, type AuthPluginOptions, BaseService, type BrowserTokenStorageOptions, type CursorMeta, FILTERS_HEADER, type FieldError, type FieldErrorExtractor, type FilterCondition, type FilterGroup, type FilterOperator, type FilterValue, type HeaderSignalPluginOptions, HttpError, type IdempotencyPluginOptions, type LoggerPluginOptions, type PageMeta, type PaginatedResponse, type QueryParamObject, type QueryParams, REFRESH_TOKEN_KEY, type RefreshTokenConfig, ResponseHandler, type ResponseHandlerConfig, type RtkBaseQueryResult, type TokenStorage, type UnauthorizedPluginOptions, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, nestFieldErrors, normalizeFilters };
585
+ export { ACCESS_TOKEN_KEY, ApiClient, type ApiClientOptions, type ApiFilters, type ApiMethod, type ApiPlugin, type ApiPluginHook, type ApiRequestConfig, type ApiRequestContext, type ApiRequestInput, type ApiTransport, type ApiTransportRequest, type ApiTransportResponse, type AuthPluginOptions, BaseService, type BrowserTokenStorageOptions, type CursorMeta, FILTERS_HEADER, FRENCH_STATUS_MESSAGES, type FieldError, type FieldErrorExtractor, type FilterCondition, type FilterGroup, type FilterOperator, type FilterValue, type HeaderSignalPluginOptions, HttpError, type IdempotencyPluginOptions, type LoggerPluginOptions, type PageMeta, type PaginatedResponse, type QueryFnContext, type QueryInvalidator, type QueryParamObject, type QueryParams, type QueryResource, type QueryResourceOptions, REFRESH_TOKEN_KEY, type RefreshTokenConfig, ResponseHandler, type ResponseHandlerConfig, type RtkBaseQueryResult, type SaveVariables, type TokenStorage, type UnauthorizedPluginOptions, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createQueryResource, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, isGenericMessage, localizeError, nestFieldErrors, normalizeFilters };
package/dist/index.js CHANGED
@@ -136,6 +136,66 @@ var HttpError = class _HttpError extends Error {
136
136
  return this.status >= 500;
137
137
  }
138
138
  };
139
+
140
+ // src/status-messages.ts
141
+ var FRENCH_STATUS_MESSAGES = {
142
+ 0: "Le serveur est injoignable. V\xE9rifiez votre connexion.",
143
+ 400: "La demande est invalide.",
144
+ 401: "Votre session a expir\xE9. Reconnectez-vous.",
145
+ 403: "Vous n'avez pas les droits n\xE9cessaires pour cette action.",
146
+ 404: "L'\xE9l\xE9ment demand\xE9 est introuvable.",
147
+ 408: "Le serveur a mis trop de temps \xE0 r\xE9pondre.",
148
+ 409: "Cette op\xE9ration entre en conflit avec l'\xE9tat actuel. Rechargez puis r\xE9essayez.",
149
+ 413: "Le contenu envoy\xE9 est trop volumineux.",
150
+ 422: "Certaines valeurs sont invalides.",
151
+ 429: "Trop de tentatives. R\xE9essayez dans un instant.",
152
+ 500: "Une erreur est survenue sur le serveur.",
153
+ 502: "Le service est momentan\xE9ment indisponible.",
154
+ 503: "Le service est momentan\xE9ment indisponible.",
155
+ 504: "Le serveur a mis trop de temps \xE0 r\xE9pondre."
156
+ };
157
+ var ECHEC_RESEAU = /failed to fetch|networkerror|load failed/i;
158
+ var PHRASES = {
159
+ 400: "bad request",
160
+ 401: "unauthorized",
161
+ 403: "forbidden",
162
+ 404: "not found",
163
+ 405: "method not allowed",
164
+ 408: "request timeout",
165
+ 409: "conflict",
166
+ 413: "payload too large",
167
+ 415: "unsupported media type",
168
+ 422: "unprocessable entity",
169
+ 429: "too many requests",
170
+ 500: "internal server error",
171
+ 502: "bad gateway",
172
+ 503: "service unavailable",
173
+ 504: "gateway timeout"
174
+ };
175
+ function isGenericMessage(status, message, body) {
176
+ const texte = message?.trim().toLowerCase();
177
+ if (!texte || texte === `http ${status}`) return true;
178
+ const phrase = PHRASES[status];
179
+ if (phrase && (texte === phrase || texte === `${phrase} resource`)) return true;
180
+ const erreur = body && typeof body === "object" ? body.error : void 0;
181
+ if (typeof erreur === "string" && texte === erreur.trim().toLowerCase()) {
182
+ return true;
183
+ }
184
+ return /^cannot (get|post|put|patch|delete|head) /.test(texte) || /exception\b/.test(texte);
185
+ }
186
+ function localizeError(error, messages) {
187
+ if (error instanceof HttpError) {
188
+ const remplacant = messages[error.status];
189
+ if (remplacant && error.fields.length === 0 && isGenericMessage(error.status, error.message, error.body)) {
190
+ error.message = remplacant;
191
+ }
192
+ return error;
193
+ }
194
+ if (error instanceof TypeError && messages[0] && ECHEC_RESEAU.test(error.message)) {
195
+ error.message = messages[0];
196
+ }
197
+ return error;
198
+ }
139
199
  function buildQueryString(params) {
140
200
  return toQueryString(params);
141
201
  }
@@ -354,6 +414,7 @@ var ApiClient = class {
354
414
  defaultRetry;
355
415
  retryMethods;
356
416
  fieldErrors;
417
+ statusMessages;
357
418
  defaultTimeoutMs;
358
419
  plugins;
359
420
  responseHandler;
@@ -370,6 +431,7 @@ var ApiClient = class {
370
431
  (options.retryMethods ?? DEFAULT_RETRY_METHODS).map(normalizeMethod)
371
432
  );
372
433
  this.fieldErrors = options.fieldErrors;
434
+ this.statusMessages = options.statusMessages;
373
435
  this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
374
436
  this.plugins = options.plugins ?? [];
375
437
  this.responseHandler = new ResponseHandler(options.responseHandler);
@@ -467,7 +529,9 @@ var ApiClient = class {
467
529
  } catch (error) {
468
530
  const pluginResult = await this.runPlugins("onError", error, ctx);
469
531
  if (pluginResult !== void 0) return pluginResult;
470
- if (!this.shouldRetry(error, attempt, maxRetries)) throw error;
532
+ if (!this.shouldRetry(error, attempt, maxRetries)) {
533
+ throw this.statusMessages ? localizeError(error, this.statusMessages) : error;
534
+ }
471
535
  attempt += 1;
472
536
  const baseDelay = Math.min(1e3 * 2 ** attempt, 3e4);
473
537
  await sleep(baseDelay + Math.random() * 0.1 * baseDelay);
@@ -858,6 +922,10 @@ var BaseService = class {
858
922
  this.client = client;
859
923
  this.resource = resource;
860
924
  }
925
+ /** Le chemin de la ressource — sert aussi de racine aux clés de cache. */
926
+ get path() {
927
+ return this.resource;
928
+ }
861
929
  normalizeQueryParams(params) {
862
930
  if (!params || typeof params !== "object") return params;
863
931
  const normalized = { ...params };
@@ -941,4 +1009,47 @@ function createResourceService(client, resourcePath) {
941
1009
  return new BaseService(client, resourcePath);
942
1010
  }
943
1011
 
944
- export { ACCESS_TOKEN_KEY, ApiClient, BaseService, FILTERS_HEADER, HttpError, REFRESH_TOKEN_KEY, ResponseHandler, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, nestFieldErrors, normalizeFilters };
1012
+ // src/query-resource.ts
1013
+ function createQueryResource(service, options = {}) {
1014
+ const racine = options.name ?? service.path;
1015
+ const modifier = (id, values) => options.updateMethod === "PATCH" ? service.patch(id, values) : service.update(id, values);
1016
+ const keys = {
1017
+ all: [racine],
1018
+ lists: () => [racine, "list"],
1019
+ list: (params) => [racine, "list", params ?? {}],
1020
+ details: () => [racine, "detail"],
1021
+ detail: (id) => [racine, "detail", id]
1022
+ };
1023
+ const invalider = (client, ...cles) => Promise.all(cles.map((queryKey) => client.invalidateQueries({ queryKey })));
1024
+ return {
1025
+ keys,
1026
+ /** Une liste nue — `service.list`. */
1027
+ listQuery: (params) => ({
1028
+ queryKey: keys.list(params),
1029
+ queryFn: ({ signal } = {}) => service.list(params, { signal })
1030
+ }),
1031
+ /** Une page — `service.paginated`, rendue en `{ items, meta }`. */
1032
+ pageQuery: (page = {}) => ({
1033
+ queryKey: keys.list(page),
1034
+ queryFn: ({ signal } = {}) => service.paginated({ ...page, config: { signal } })
1035
+ }),
1036
+ detailQuery: (id) => ({
1037
+ queryKey: keys.detail(id),
1038
+ queryFn: ({ signal } = {}) => service.getById(id, { signal })
1039
+ }),
1040
+ /**
1041
+ * Crée sans `id`, modifie avec. Invalide les listes, et le détail
1042
+ * modifié : c'est ce que chaque écran écrivait à la main, ou oubliait.
1043
+ */
1044
+ saveMutation: (client) => ({
1045
+ mutationFn: ({ id, values }) => id === void 0 ? service.create(values) : modifier(id, values),
1046
+ onSuccess: (_, { id }) => id === void 0 ? invalider(client, keys.lists()) : invalider(client, keys.lists(), keys.detail(id))
1047
+ }),
1048
+ removeMutation: (client) => ({
1049
+ mutationFn: (id) => service.remove(id),
1050
+ onSuccess: (_, id) => invalider(client, keys.lists(), keys.detail(id))
1051
+ })
1052
+ };
1053
+ }
1054
+
1055
+ export { ACCESS_TOKEN_KEY, ApiClient, BaseService, FILTERS_HEADER, FRENCH_STATUS_MESSAGES, HttpError, REFRESH_TOKEN_KEY, ResponseHandler, buildQueryString, createApiClient, createAuthPlugin, createAxiosTransport, createBrowserTokenStorage, createFetchTransport, createHeaderSignalPlugin, createIdempotencyPlugin, createLoggerPlugin, createMemoryTokenStorage, createQueryResource, createReadOnlyPlugin, createRefreshTokenPlugin, createResourceService, createUnauthorizedPlugin, filtersHeader, isGenericMessage, localizeError, nestFieldErrors, normalizeFilters };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sia-ui/api",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Typed HTTP client with CRUD services, plugins and injectable transports.",
5
5
  "keywords": [
6
6
  "sia-ui",
@@ -42,7 +42,7 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@sia-ui/utils": "0.5.0"
45
+ "@sia-ui/utils": "0.7.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "tsup": "^8.5.1",