@sia-ui/api 0.4.0 → 0.6.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,93 @@
1
1
  # @sia-ui/api
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Rôles, requêtes serveur, ressources par mode, et deux blocs de console.
8
+ - **Rôles** — la couche d'accès lit les rôles de la session sous la forme
9
+ `role:<nom>` (`role("admin")`), avec les trois évaluateurs.
10
+ - **Tableaux servis par le serveur** — `useDataTableQuery` accepte un
11
+ `storage` (TanStack Router, React Router) et `serverParams` pour les noms
12
+ attendus (`limit`, `sort` + `order`, `ASC`/`DESC`). `toServerParams` dans
13
+ `@sia-ui/headless`.
14
+ - **TanStack Query** — `createQueryResource(service)` : clés emboîtées,
15
+ options de requête avec annulation, mutations qui invalident ce qu'elles
16
+ rendent périmé. Aucune dépendance à TanStack.
17
+ - **Ressources** — `inForm` et `required` par mode (`"create"`, `"edit"`),
18
+ `value` pour une colonne calculée.
19
+ - **En-têtes** — `PageHeader` `level`, `CrudPage` `headingLevel`. Le repli
20
+ suit la place disponible ; les actions ne s'écrasent plus.
21
+ - **Nouveaux** — `SecretFields` (secrets en écriture seule), `ActivityLog`
22
+ (journal filtré par le serveur), `StatusBadge` et `toneOf`, dix icônes de
23
+ console.
24
+ - Les icônes des boutons de `CrudPage` et `DataTable` passent par
25
+ `leftIcon` : elles ne se collent plus au libellé.
26
+
27
+ - `sia-ui update`, la feuille commune suivie, et des erreurs HTTP en français.
28
+ - **`sia-ui update [nom...]`** — met à jour les entrées en retard sans perdre
29
+ de retouche : un fichier intact est remplacé, un fichier modifié depuis son
30
+ installation est gardé et signalé (`sia-ui diff`, puis `--force` pour
31
+ l'écraser). Les dépendances de registre apparues sont installées, les
32
+ paquets npm annoncés. `--dry-run` montre sans rien écrire. Une entrée dont
33
+ un fichier a été gardé reste « en retard » dans le verrou.
34
+ - **`sia-ui.css` suivie** — son empreinte est notée dans `sia-ui.lock.json`.
35
+ `update` la met à jour comme un composant ; `add` aussi, si elle n'a pas
36
+ été retouchée. `init` ne fait plus qu'initialiser. Elle est lue au même
37
+ registre que les composants, HTTP compris. `doctor` signale une feuille en
38
+ retard, modifiée ou copiée avant le suivi.
39
+ - `list --outdated` et `doctor` conseillent `sia-ui update` au lieu de
40
+ `add --overwrite`, qui écrasait aussi les retouches.
41
+ - **`statusMessages`** (`@sia-ui/api`) — option du client, avec
42
+ `FRENCH_STATUS_MESSAGES`. Remplace les messages génériques du serveur
43
+ (« Conflict », « Forbidden resource », « Cannot GET /x »,
44
+ `ThrottlerException`) et l'échec réseau de `fetch`, jamais un message métier
45
+ ni une erreur de champs.
46
+
47
+ ### Patch Changes
48
+
49
+ - Updated dependencies
50
+ - @sia-ui/utils@0.6.0
51
+
52
+ ## 0.5.0
53
+
54
+ ### Minor Changes
55
+
56
+ - Erreurs serveur dans les formulaires, client HTTP plus sûr, registre vérifié.
57
+
58
+ ## Défauts
59
+ - **Registre `drawer`** — `context.ts` et la dépendance `icons` manquaient :
60
+ `sia-ui add app-shell` produisait un projet qui ne compilait pas. Un test
61
+ vérifie désormais que chaque import relatif de chaque entrée est apporté par
62
+ son installation.
63
+ - **`sia-ui.css`** pose `box-sizing: border-box` (spécificité nulle). Sans
64
+ reset côté projet, `AppShell` débordait de 32 px.
65
+ - **`@sia-ui/api`** ne rejoue plus d'office que `GET` et `HEAD`
66
+ (`retryMethods`). Une écriture n'est rejouée que si l'appel pose `retry`.
67
+ - Plus aucun avertissement `react-hooks/exhaustive-deps` dans les composants.
68
+
69
+ ## Formulaires et erreurs serveur
70
+ - `Form` intercepte un envoi rejeté : erreurs de champ sous les champs, le
71
+ reste dans une alerte. `onSubmit` peut aussi rendre `{ champ: message }`
72
+ (`FormSubmitResult`), dans `useLocalForm`, `Form`, `CrudPage` et
73
+ l'adaptateur react-hook-form. `CrudPage` garde alors sa boîte ouverte.
74
+ - Les erreurs posées par `setErrors` survivent à la validation locale jusqu'à
75
+ la modification de leur champ.
76
+ - `HttpError.toFormErrors()`, option `fieldErrors` du client et préréglage
77
+ `nestFieldErrors` (class-validator). `readSubmitError` et `hasFormErrors`
78
+ dans `@sia-ui/headless`.
79
+ - La pagination à plat `{ data, total, page, limit }` est reconnue, et `get`
80
+ ne déballe plus `data` quand l'objet porte une pagination.
81
+
82
+ ## Tableaux
83
+ - `searchDelay` sur `DataTable` et `CrudPage`.
84
+ - `CrudPage` place les `extraRowActions` avant « Supprimer ».
85
+
86
+ ### Patch Changes
87
+
88
+ - Updated dependencies
89
+ - @sia-ui/utils@0.5.0
90
+
3
91
  ## 0.4.0
4
92
 
5
93
  ### Minor Changes
package/README.md CHANGED
@@ -57,6 +57,94 @@ 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.6.0
61
+
62
+ ### Minor Changes
63
+
64
+ - Rôles, requêtes serveur, ressources par mode, et deux blocs de console.
65
+ - **Rôles** — la couche d'accès lit les rôles de la session sous la forme
66
+ `role:<nom>` (`role("admin")`), avec les trois évaluateurs.
67
+ - **Tableaux servis par le serveur** — `useDataTableQuery` accepte un
68
+ `storage` (TanStack Router, React Router) et `serverParams` pour les noms
69
+ attendus (`limit`, `sort` + `order`, `ASC`/`DESC`). `toServerParams` dans
70
+ `@sia-ui/headless`.
71
+ - **TanStack Query** — `createQueryResource(service)` : clés emboîtées,
72
+ options de requête avec annulation, mutations qui invalident ce qu'elles
73
+ rendent périmé. Aucune dépendance à TanStack.
74
+ - **Ressources** — `inForm` et `required` par mode (`"create"`, `"edit"`),
75
+ `value` pour une colonne calculée.
76
+ - **En-têtes** — `PageHeader` `level`, `CrudPage` `headingLevel`. Le repli
77
+ suit la place disponible ; les actions ne s'écrasent plus.
78
+ - **Nouveaux** — `SecretFields` (secrets en écriture seule), `ActivityLog`
79
+ (journal filtré par le serveur), `StatusBadge` et `toneOf`, dix icônes de
80
+ console.
81
+ - Les icônes des boutons de `CrudPage` et `DataTable` passent par
82
+ `leftIcon` : elles ne se collent plus au libellé.
83
+
84
+ - `sia-ui update`, la feuille commune suivie, et des erreurs HTTP en français.
85
+ - **`sia-ui update [nom...]`** — met à jour les entrées en retard sans perdre
86
+ de retouche : un fichier intact est remplacé, un fichier modifié depuis son
87
+ installation est gardé et signalé (`sia-ui diff`, puis `--force` pour
88
+ l'écraser). Les dépendances de registre apparues sont installées, les
89
+ paquets npm annoncés. `--dry-run` montre sans rien écrire. Une entrée dont
90
+ un fichier a été gardé reste « en retard » dans le verrou.
91
+ - **`sia-ui.css` suivie** — son empreinte est notée dans `sia-ui.lock.json`.
92
+ `update` la met à jour comme un composant ; `add` aussi, si elle n'a pas
93
+ été retouchée. `init` ne fait plus qu'initialiser. Elle est lue au même
94
+ registre que les composants, HTTP compris. `doctor` signale une feuille en
95
+ retard, modifiée ou copiée avant le suivi.
96
+ - `list --outdated` et `doctor` conseillent `sia-ui update` au lieu de
97
+ `add --overwrite`, qui écrasait aussi les retouches.
98
+ - **`statusMessages`** (`@sia-ui/api`) — option du client, avec
99
+ `FRENCH_STATUS_MESSAGES`. Remplace les messages génériques du serveur
100
+ (« Conflict », « Forbidden resource », « Cannot GET /x »,
101
+ `ThrottlerException`) et l'échec réseau de `fetch`, jamais un message métier
102
+ ni une erreur de champs.
103
+
104
+ ### Patch Changes
105
+
106
+ - Updated dependencies
107
+ - @sia-ui/utils@0.6.0
108
+
109
+ ## 0.5.0
110
+
111
+ ### Minor Changes
112
+
113
+ - Erreurs serveur dans les formulaires, client HTTP plus sûr, registre vérifié.
114
+
115
+ ## Défauts
116
+ - **Registre `drawer`** — `context.ts` et la dépendance `icons` manquaient :
117
+ `sia-ui add app-shell` produisait un projet qui ne compilait pas. Un test
118
+ vérifie désormais que chaque import relatif de chaque entrée est apporté par
119
+ son installation.
120
+ - **`sia-ui.css`** pose `box-sizing: border-box` (spécificité nulle). Sans
121
+ reset côté projet, `AppShell` débordait de 32 px.
122
+ - **`@sia-ui/api`** ne rejoue plus d'office que `GET` et `HEAD`
123
+ (`retryMethods`). Une écriture n'est rejouée que si l'appel pose `retry`.
124
+ - Plus aucun avertissement `react-hooks/exhaustive-deps` dans les composants.
125
+
126
+ ## Formulaires et erreurs serveur
127
+ - `Form` intercepte un envoi rejeté : erreurs de champ sous les champs, le
128
+ reste dans une alerte. `onSubmit` peut aussi rendre `{ champ: message }`
129
+ (`FormSubmitResult`), dans `useLocalForm`, `Form`, `CrudPage` et
130
+ l'adaptateur react-hook-form. `CrudPage` garde alors sa boîte ouverte.
131
+ - Les erreurs posées par `setErrors` survivent à la validation locale jusqu'à
132
+ la modification de leur champ.
133
+ - `HttpError.toFormErrors()`, option `fieldErrors` du client et préréglage
134
+ `nestFieldErrors` (class-validator). `readSubmitError` et `hasFormErrors`
135
+ dans `@sia-ui/headless`.
136
+ - La pagination à plat `{ data, total, page, limit }` est reconnue, et `get`
137
+ ne déballe plus `data` quand l'objet porte une pagination.
138
+
139
+ ## Tableaux
140
+ - `searchDelay` sur `DataTable` et `CrudPage`.
141
+ - `CrudPage` place les `extraRowActions` avant « Supprimer ».
142
+
143
+ ### Patch Changes
144
+
145
+ - Updated dependencies
146
+ - @sia-ui/utils@0.5.0
147
+
60
148
  ## 0.4.0
61
149
 
62
150
  ### Minor Changes
package/dist/index.cjs CHANGED
@@ -4,21 +4,46 @@ var async = require('@sia-ui/utils/async');
4
4
  var query = require('@sia-ui/utils/query');
5
5
 
6
6
  // src/http-error.ts
7
+ var NEST_FIELD = /^([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\s/;
8
+ var nestFieldErrors = (body) => {
9
+ const standard = HttpError.extractFields(body);
10
+ if (standard.length > 0 || !body || typeof body !== "object") return standard;
11
+ const messages = body.message;
12
+ if (!Array.isArray(messages)) return [];
13
+ return messages.flatMap((message) => {
14
+ if (typeof message !== "string") return [];
15
+ const field = NEST_FIELD.exec(message)?.[1];
16
+ return field ? [{ field, message }] : [];
17
+ });
18
+ };
7
19
  var HttpError = class _HttpError extends Error {
8
20
  status;
9
21
  body;
10
22
  headers;
11
23
  /** Les erreurs par champ, quand le serveur en renvoie. */
12
24
  fields;
13
- constructor(status, body = null, message, headers = {}) {
25
+ constructor(status, body = null, message, headers = {}, extractFields = _HttpError.extractFields) {
14
26
  super(message ?? _HttpError.extractMessage(body) ?? `HTTP ${status}`);
15
27
  this.name = "HttpError";
16
28
  this.status = status;
17
29
  this.body = body;
18
30
  this.headers = headers;
19
- this.fields = _HttpError.extractFields(body);
31
+ this.fields = extractFields(body);
20
32
  Object.setPrototypeOf(this, _HttpError.prototype);
21
33
  }
34
+ /**
35
+ * Les erreurs par champ, sous la forme qu'attend un formulaire.
36
+ *
37
+ * `FormAdapter.setErrors` prend `{ champ: message }` : c'est cette carte.
38
+ * Le premier message d'un champ l'emporte — un champ n'en affiche qu'un.
39
+ */
40
+ toFormErrors() {
41
+ const errors = {};
42
+ for (const { field, message } of this.fields) {
43
+ if (!(field in errors)) errors[field] = message;
44
+ }
45
+ return errors;
46
+ }
22
47
  static async fromResponse(response) {
23
48
  const headers = _HttpError.normalizeHeaders(response.headers);
24
49
  const contentType = headers["content-type"] ?? "";
@@ -113,6 +138,66 @@ var HttpError = class _HttpError extends Error {
113
138
  return this.status >= 500;
114
139
  }
115
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
+ }
116
201
  function buildQueryString(params) {
117
202
  return query.toQueryString(params);
118
203
  }
@@ -121,6 +206,10 @@ function buildQueryString(params) {
121
206
  function asRecord(value) {
122
207
  return value && typeof value === "object" ? value : null;
123
208
  }
209
+ var PAGE_KEYS = ["total", "page", "limit", "totalPages"];
210
+ function hasPageKeys(record) {
211
+ return PAGE_KEYS.some((key) => record[key] !== void 0);
212
+ }
124
213
  var ResponseHandler = class {
125
214
  config;
126
215
  constructor(config = {}) {
@@ -129,7 +218,9 @@ var ResponseHandler = class {
129
218
  extractData(raw) {
130
219
  if (this.config.extractData) return this.config.extractData(raw);
131
220
  const record = asRecord(raw);
132
- if (record && record.data !== void 0) return record.data;
221
+ if (record && record.data !== void 0 && !hasPageKeys(record)) {
222
+ return record.data;
223
+ }
133
224
  return raw;
134
225
  }
135
226
  extractPaginated(raw) {
@@ -155,11 +246,12 @@ var ResponseHandler = class {
155
246
  if (Array.isArray(data) && meta) {
156
247
  return { items: data, meta };
157
248
  }
158
- if (Array.isArray(record.items)) {
159
- const limit = Number(record.limit ?? record.items.length);
249
+ const items = Array.isArray(record.items) ? record.items : Array.isArray(data) && hasPageKeys(record) ? data : null;
250
+ if (items) {
251
+ const limit = Number(record.limit ?? items.length);
160
252
  const total = record.total === void 0 ? void 0 : Number(record.total);
161
253
  return {
162
- items: record.items,
254
+ items,
163
255
  meta: {
164
256
  page: Number(record.page ?? 1),
165
257
  limit,
@@ -305,6 +397,7 @@ function createAxiosTransport(axios) {
305
397
 
306
398
  // src/client.ts
307
399
  var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 429, 502, 503, 504]);
400
+ var DEFAULT_RETRY_METHODS = ["GET", "HEAD"];
308
401
  function isAbsoluteUrl(path) {
309
402
  return /^https?:\/\//i.test(path);
310
403
  }
@@ -321,6 +414,9 @@ var ApiClient = class {
321
414
  getLanguage;
322
415
  defaultHeaders;
323
416
  defaultRetry;
417
+ retryMethods;
418
+ fieldErrors;
419
+ statusMessages;
324
420
  defaultTimeoutMs;
325
421
  plugins;
326
422
  responseHandler;
@@ -333,6 +429,11 @@ var ApiClient = class {
333
429
  "Content-Type": "application/json"
334
430
  };
335
431
  this.defaultRetry = options.defaultRetry ?? 2;
432
+ this.retryMethods = new Set(
433
+ (options.retryMethods ?? DEFAULT_RETRY_METHODS).map(normalizeMethod)
434
+ );
435
+ this.fieldErrors = options.fieldErrors;
436
+ this.statusMessages = options.statusMessages;
336
437
  this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
337
438
  this.plugins = options.plugins ?? [];
338
439
  this.responseHandler = new ResponseHandler(options.responseHandler);
@@ -397,7 +498,7 @@ var ApiClient = class {
397
498
  };
398
499
  const onRequestResult = await this.runPlugins("onRequest", ctx);
399
500
  if (onRequestResult !== void 0) return onRequestResult;
400
- const maxRetries = config.retry ?? this.defaultRetry;
501
+ const maxRetries = config.retry ?? (this.retryMethods.has(ctx.method) ? this.defaultRetry : 0);
401
502
  let attempt = 0;
402
503
  while (true) {
403
504
  try {
@@ -421,7 +522,8 @@ var ApiClient = class {
421
522
  response.status,
422
523
  response.data,
423
524
  void 0,
424
- response.headers
525
+ response.headers,
526
+ this.fieldErrors
425
527
  );
426
528
  }
427
529
  if (response.status === 204) return null;
@@ -429,7 +531,9 @@ var ApiClient = class {
429
531
  } catch (error) {
430
532
  const pluginResult = await this.runPlugins("onError", error, ctx);
431
533
  if (pluginResult !== void 0) return pluginResult;
432
- 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
+ }
433
537
  attempt += 1;
434
538
  const baseDelay = Math.min(1e3 * 2 ** attempt, 3e4);
435
539
  await async.sleep(baseDelay + Math.random() * 0.1 * baseDelay);
@@ -820,6 +924,10 @@ var BaseService = class {
820
924
  this.client = client;
821
925
  this.resource = resource;
822
926
  }
927
+ /** Le chemin de la ressource — sert aussi de racine aux clés de cache. */
928
+ get path() {
929
+ return this.resource;
930
+ }
823
931
  normalizeQueryParams(params) {
824
932
  if (!params || typeof params !== "object") return params;
825
933
  const normalized = { ...params };
@@ -903,10 +1011,53 @@ function createResourceService(client, resourcePath) {
903
1011
  return new BaseService(client, resourcePath);
904
1012
  }
905
1013
 
1014
+ // src/query-resource.ts
1015
+ function createQueryResource(service, options = {}) {
1016
+ const racine = options.name ?? service.path;
1017
+ const keys = {
1018
+ all: [racine],
1019
+ lists: () => [racine, "list"],
1020
+ list: (params) => [racine, "list", params ?? {}],
1021
+ details: () => [racine, "detail"],
1022
+ detail: (id) => [racine, "detail", id]
1023
+ };
1024
+ const invalider = (client, ...cles) => Promise.all(cles.map((queryKey) => client.invalidateQueries({ queryKey })));
1025
+ return {
1026
+ keys,
1027
+ /** Une liste nue — `service.list`. */
1028
+ listQuery: (params) => ({
1029
+ queryKey: keys.list(params),
1030
+ queryFn: ({ signal } = {}) => service.list(params, { signal })
1031
+ }),
1032
+ /** Une page — `service.paginated`, rendue en `{ items, meta }`. */
1033
+ pageQuery: (page = {}) => ({
1034
+ queryKey: keys.list(page),
1035
+ queryFn: ({ signal } = {}) => service.paginated({ ...page, config: { signal } })
1036
+ }),
1037
+ detailQuery: (id) => ({
1038
+ queryKey: keys.detail(id),
1039
+ queryFn: ({ signal } = {}) => service.getById(id, { signal })
1040
+ }),
1041
+ /**
1042
+ * Crée sans `id`, modifie avec. Invalide les listes, et le détail
1043
+ * modifié : c'est ce que chaque écran écrivait à la main, ou oubliait.
1044
+ */
1045
+ saveMutation: (client) => ({
1046
+ mutationFn: ({ id, values }) => id === void 0 ? service.create(values) : service.update(id, values),
1047
+ onSuccess: (_, { id }) => id === void 0 ? invalider(client, keys.lists()) : invalider(client, keys.lists(), keys.detail(id))
1048
+ }),
1049
+ removeMutation: (client) => ({
1050
+ mutationFn: (id) => service.remove(id),
1051
+ onSuccess: (_, id) => invalider(client, keys.lists(), keys.detail(id))
1052
+ })
1053
+ };
1054
+ }
1055
+
906
1056
  exports.ACCESS_TOKEN_KEY = ACCESS_TOKEN_KEY;
907
1057
  exports.ApiClient = ApiClient;
908
1058
  exports.BaseService = BaseService;
909
1059
  exports.FILTERS_HEADER = FILTERS_HEADER;
1060
+ exports.FRENCH_STATUS_MESSAGES = FRENCH_STATUS_MESSAGES;
910
1061
  exports.HttpError = HttpError;
911
1062
  exports.REFRESH_TOKEN_KEY = REFRESH_TOKEN_KEY;
912
1063
  exports.ResponseHandler = ResponseHandler;
@@ -920,9 +1071,13 @@ exports.createHeaderSignalPlugin = createHeaderSignalPlugin;
920
1071
  exports.createIdempotencyPlugin = createIdempotencyPlugin;
921
1072
  exports.createLoggerPlugin = createLoggerPlugin;
922
1073
  exports.createMemoryTokenStorage = createMemoryTokenStorage;
1074
+ exports.createQueryResource = createQueryResource;
923
1075
  exports.createReadOnlyPlugin = createReadOnlyPlugin;
924
1076
  exports.createRefreshTokenPlugin = createRefreshTokenPlugin;
925
1077
  exports.createResourceService = createResourceService;
926
1078
  exports.createUnauthorizedPlugin = createUnauthorizedPlugin;
927
1079
  exports.filtersHeader = filtersHeader;
1080
+ exports.isGenericMessage = isGenericMessage;
1081
+ exports.localizeError = localizeError;
1082
+ exports.nestFieldErrors = nestFieldErrors;
928
1083
  exports.normalizeFilters = normalizeFilters;
package/dist/index.d.cts CHANGED
@@ -1,3 +1,63 @@
1
+ /**
2
+ * Une erreur de validation rattachée à un champ.
3
+ *
4
+ * C'est ce qui permet à un formulaire d'afficher « adresse invalide » sous
5
+ * l'adresse plutôt qu'un bandeau générique en haut de page.
6
+ */
7
+ interface FieldError {
8
+ field: string;
9
+ message: string;
10
+ code?: string | undefined;
11
+ }
12
+ /**
13
+ * Lit les erreurs par champ dans un corps d'erreur.
14
+ *
15
+ * Chaque serveur a sa forme ; celle-ci se règle une fois sur le client
16
+ * (`fieldErrors` de `createApiClient`) plutôt qu'à chaque formulaire.
17
+ */
18
+ type FieldErrorExtractor = (body: unknown) => FieldError[];
19
+ /**
20
+ * Le préréglage NestJS.
21
+ *
22
+ * `ValidationPipe` renvoie `{ message: string[] }`, chaque phrase commençant
23
+ * par le chemin du champ : « email must be an email », « address.city should
24
+ * not be empty ». Le champ est ce premier mot ; le message reste la phrase
25
+ * entière, que le serveur a écrite pour être lue. Les formes génériques
26
+ * (`errors`, `fieldErrors`, `violations`) passent d'abord : un
27
+ * `exceptionFactory` personnalisé les produit souvent.
28
+ */
29
+ declare const nestFieldErrors: FieldErrorExtractor;
30
+ declare class HttpError extends Error {
31
+ status: number;
32
+ body: unknown;
33
+ headers: Record<string, string>;
34
+ /** Les erreurs par champ, quand le serveur en renvoie. */
35
+ fields: FieldError[];
36
+ constructor(status: number, body?: unknown, message?: string, headers?: Record<string, string>, extractFields?: FieldErrorExtractor);
37
+ /**
38
+ * Les erreurs par champ, sous la forme qu'attend un formulaire.
39
+ *
40
+ * `FormAdapter.setErrors` prend `{ champ: message }` : c'est cette carte.
41
+ * Le premier message d'un champ l'emporte — un champ n'en affiche qu'un.
42
+ */
43
+ toFormErrors(): Record<string, string>;
44
+ static fromResponse(response: Response): Promise<HttpError>;
45
+ /**
46
+ * Retrouve les erreurs par champ dans un corps d'erreur.
47
+ *
48
+ * Trois formes couvertes, parce que trois serveurs sur quatre en utilisent
49
+ * une : la liste (`errors: [{ field, message }]`), la carte
50
+ * (`errors: { email: "…" }`) et la carte de listes, que produit Laravel.
51
+ */
52
+ static extractFields(body: unknown): FieldError[];
53
+ /** Le message rattaché à ce champ, s'il y en a un. */
54
+ fieldError(field: string): string | undefined;
55
+ static normalizeHeaders(headers: unknown): Record<string, string>;
56
+ static extractMessage(body: unknown): string | null;
57
+ isClientError(): boolean;
58
+ isServerError(): boolean;
59
+ }
60
+
1
61
  type ApiMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
2
62
  type QueryParamObject = Record<string, unknown>;
3
63
  type QueryParams = Record<string, string | string[] | number | number[] | boolean | boolean[] | QueryParamObject | QueryParamObject[] | null | undefined>;
@@ -79,10 +139,33 @@ interface ApiClientOptions {
79
139
  getToken?: (() => string | null | Promise<string | null>) | undefined;
80
140
  getLanguage?: (() => string | null | Promise<string | null>) | undefined;
81
141
  defaultHeaders?: Record<string, string> | undefined;
142
+ /**
143
+ * Nouvelles tentatives sur erreur réseau ou statut transitoire (408, 429,
144
+ * 502, 503, 504), pour les seules `retryMethods`. `retry` sur un appel
145
+ * passe devant.
146
+ */
82
147
  defaultRetry?: number | undefined;
148
+ /** Les méthodes rejouées d'office. Par défaut `GET` et `HEAD`. */
149
+ retryMethods?: ApiMethod[] | undefined;
83
150
  defaultTimeoutMs?: number | undefined;
84
151
  plugins?: ApiPlugin[] | undefined;
85
152
  responseHandler?: ResponseHandlerConfig | undefined;
153
+ /**
154
+ * Où lire les erreurs par champ d'une réponse en échec.
155
+ *
156
+ * Par défaut `errors`, `fieldErrors` ou `violations`. `nestFieldErrors`
157
+ * lit en plus le `message: string[]` de class-validator.
158
+ */
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;
86
169
  }
87
170
  type ApiRequestInput = string | {
88
171
  path: string;
@@ -104,6 +187,9 @@ declare class ApiClient {
104
187
  private getLanguage;
105
188
  private defaultHeaders;
106
189
  private defaultRetry;
190
+ private retryMethods;
191
+ private fieldErrors;
192
+ private statusMessages;
107
193
  private defaultTimeoutMs;
108
194
  private plugins;
109
195
  private responseHandler;
@@ -159,39 +245,29 @@ declare class ApiClient {
159
245
  declare function createApiClient(options: ApiClientOptions): ApiClient;
160
246
 
161
247
  /**
162
- * Une erreur de validation rattachée à un champ.
248
+ * Les messages par statut, en français.
163
249
  *
164
- * C'est ce qui permet à un formulaire d'afficher « adresse invalide » sous
165
- * l'adresse plutôt qu'un bandeau générique en haut de page.
250
+ * `0` couvre l'échec réseau : le serveur injoignable, la connexion coupée.
166
251
  */
167
- interface FieldError {
168
- field: string;
169
- message: string;
170
- code?: string | undefined;
171
- }
172
- declare class HttpError extends Error {
173
- status: number;
174
- body: unknown;
175
- headers: Record<string, string>;
176
- /** Les erreurs par champ, quand le serveur en renvoie. */
177
- fields: FieldError[];
178
- constructor(status: number, body?: unknown, message?: string, headers?: Record<string, string>);
179
- static fromResponse(response: Response): Promise<HttpError>;
180
- /**
181
- * Retrouve les erreurs par champ dans un corps d'erreur.
182
- *
183
- * Trois formes couvertes, parce que trois serveurs sur quatre en utilisent
184
- * une : la liste (`errors: [{ field, message }]`), la carte
185
- * (`errors: { email: "…" }`) et la carte de listes, que produit Laravel.
186
- */
187
- static extractFields(body: unknown): FieldError[];
188
- /** Le message rattaché à ce champ, s'il y en a un. */
189
- fieldError(field: string): string | undefined;
190
- static normalizeHeaders(headers: unknown): Record<string, string>;
191
- static extractMessage(body: unknown): string | null;
192
- isClientError(): boolean;
193
- isServerError(): boolean;
194
- }
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;
195
271
 
196
272
  interface AuthPluginOptions {
197
273
  getToken: () => Promise<string | null> | string | null;
@@ -349,6 +425,8 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
349
425
  protected client: ApiClient;
350
426
  protected resource: string;
351
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;
352
430
  protected normalizeQueryParams(params?: TListParams): TListParams | undefined;
353
431
  list(params?: TListParams, config?: ApiRequestConfig): Promise<TEntity[]>;
354
432
  getById(id: string, config?: ApiRequestConfig): Promise<TEntity>;
@@ -388,6 +466,94 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
388
466
  }
389
467
  declare function createResourceService<TEntity>(client: ApiClient, resourcePath: string): BaseService<TEntity, Partial<TEntity>, Partial<TEntity>, QueryParams>;
390
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
+ /**
500
+ * Les clés et les options de requête d'une ressource.
501
+ *
502
+ * `api.query` se contentait de rendre le chargeur : chaque écran réinventait
503
+ * ses clés de cache, et la moitié oubliait d'invalider la liste après une
504
+ * création. Ici, les clés s'emboîtent — `lists()` couvre toutes les listes,
505
+ * quels qu'en soient les filtres — et chaque mutation sait ce qu'elle rend
506
+ * périmé.
507
+ *
508
+ * ```tsx
509
+ * const factures = createQueryResource(factureService);
510
+ *
511
+ * const { data } = useQuery(factures.pageQuery({ page, limit: 20 }));
512
+ * const enregistrer = useMutation(factures.saveMutation(queryClient));
513
+ * enregistrer.mutate({ id, values }); // crée sans id, modifie avec
514
+ * ```
515
+ */
516
+ declare function createQueryResource<TEntity, TCreate = Partial<TEntity>, TUpdate = Partial<TEntity>, TParams extends QueryParams = QueryParams>(service: BaseService<TEntity, TCreate, TUpdate, TParams>, options?: QueryResourceOptions): {
517
+ keys: {
518
+ all: readonly [string];
519
+ lists: () => readonly [string, "list"];
520
+ list: (params?: unknown) => readonly [string, "list", {}];
521
+ details: () => readonly [string, "detail"];
522
+ detail: (id: string) => readonly [string, "detail", string];
523
+ };
524
+ /** Une liste nue — `service.list`. */
525
+ listQuery: (params?: TParams) => {
526
+ queryKey: readonly [string, "list", {}];
527
+ queryFn: ({ signal }?: QueryFnContext) => Promise<TEntity[]>;
528
+ };
529
+ /** Une page — `service.paginated`, rendue en `{ items, meta }`. */
530
+ pageQuery: (page?: {
531
+ page?: number;
532
+ limit?: number;
533
+ query?: TParams;
534
+ }) => {
535
+ queryKey: readonly [string, "list", {}];
536
+ queryFn: ({ signal }?: QueryFnContext) => Promise<PaginatedResponse<TEntity>>;
537
+ };
538
+ detailQuery: (id: string) => {
539
+ queryKey: readonly [string, "detail", string];
540
+ queryFn: ({ signal }?: QueryFnContext) => Promise<TEntity>;
541
+ };
542
+ /**
543
+ * Crée sans `id`, modifie avec. Invalide les listes, et le détail
544
+ * modifié : c'est ce que chaque écran écrivait à la main, ou oubliait.
545
+ */
546
+ saveMutation: (client: QueryInvalidator) => {
547
+ mutationFn: ({ id, values }: SaveVariables<TCreate, TUpdate>) => Promise<TEntity>;
548
+ onSuccess: (_: TEntity, { id }: SaveVariables<TCreate, TUpdate>) => Promise<unknown[]>;
549
+ };
550
+ removeMutation: (client: QueryInvalidator) => {
551
+ mutationFn: (id: string) => Promise<void>;
552
+ onSuccess: (_: void, id: string) => Promise<unknown[]>;
553
+ };
554
+ };
555
+ type QueryResource<TEntity> = ReturnType<typeof createQueryResource<TEntity>>;
556
+
391
557
  interface AxiosLikeInstance {
392
558
  request<T = unknown>(config: {
393
559
  url: string;
@@ -410,4 +576,4 @@ interface AxiosLikeInstance {
410
576
  declare function createFetchTransport(fetcher?: typeof fetch): ApiTransport;
411
577
  declare function createAxiosTransport(axios: AxiosLikeInstance): ApiTransport;
412
578
 
413
- 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 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, normalizeFilters };
579
+ 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
@@ -1,3 +1,63 @@
1
+ /**
2
+ * Une erreur de validation rattachée à un champ.
3
+ *
4
+ * C'est ce qui permet à un formulaire d'afficher « adresse invalide » sous
5
+ * l'adresse plutôt qu'un bandeau générique en haut de page.
6
+ */
7
+ interface FieldError {
8
+ field: string;
9
+ message: string;
10
+ code?: string | undefined;
11
+ }
12
+ /**
13
+ * Lit les erreurs par champ dans un corps d'erreur.
14
+ *
15
+ * Chaque serveur a sa forme ; celle-ci se règle une fois sur le client
16
+ * (`fieldErrors` de `createApiClient`) plutôt qu'à chaque formulaire.
17
+ */
18
+ type FieldErrorExtractor = (body: unknown) => FieldError[];
19
+ /**
20
+ * Le préréglage NestJS.
21
+ *
22
+ * `ValidationPipe` renvoie `{ message: string[] }`, chaque phrase commençant
23
+ * par le chemin du champ : « email must be an email », « address.city should
24
+ * not be empty ». Le champ est ce premier mot ; le message reste la phrase
25
+ * entière, que le serveur a écrite pour être lue. Les formes génériques
26
+ * (`errors`, `fieldErrors`, `violations`) passent d'abord : un
27
+ * `exceptionFactory` personnalisé les produit souvent.
28
+ */
29
+ declare const nestFieldErrors: FieldErrorExtractor;
30
+ declare class HttpError extends Error {
31
+ status: number;
32
+ body: unknown;
33
+ headers: Record<string, string>;
34
+ /** Les erreurs par champ, quand le serveur en renvoie. */
35
+ fields: FieldError[];
36
+ constructor(status: number, body?: unknown, message?: string, headers?: Record<string, string>, extractFields?: FieldErrorExtractor);
37
+ /**
38
+ * Les erreurs par champ, sous la forme qu'attend un formulaire.
39
+ *
40
+ * `FormAdapter.setErrors` prend `{ champ: message }` : c'est cette carte.
41
+ * Le premier message d'un champ l'emporte — un champ n'en affiche qu'un.
42
+ */
43
+ toFormErrors(): Record<string, string>;
44
+ static fromResponse(response: Response): Promise<HttpError>;
45
+ /**
46
+ * Retrouve les erreurs par champ dans un corps d'erreur.
47
+ *
48
+ * Trois formes couvertes, parce que trois serveurs sur quatre en utilisent
49
+ * une : la liste (`errors: [{ field, message }]`), la carte
50
+ * (`errors: { email: "…" }`) et la carte de listes, que produit Laravel.
51
+ */
52
+ static extractFields(body: unknown): FieldError[];
53
+ /** Le message rattaché à ce champ, s'il y en a un. */
54
+ fieldError(field: string): string | undefined;
55
+ static normalizeHeaders(headers: unknown): Record<string, string>;
56
+ static extractMessage(body: unknown): string | null;
57
+ isClientError(): boolean;
58
+ isServerError(): boolean;
59
+ }
60
+
1
61
  type ApiMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD";
2
62
  type QueryParamObject = Record<string, unknown>;
3
63
  type QueryParams = Record<string, string | string[] | number | number[] | boolean | boolean[] | QueryParamObject | QueryParamObject[] | null | undefined>;
@@ -79,10 +139,33 @@ interface ApiClientOptions {
79
139
  getToken?: (() => string | null | Promise<string | null>) | undefined;
80
140
  getLanguage?: (() => string | null | Promise<string | null>) | undefined;
81
141
  defaultHeaders?: Record<string, string> | undefined;
142
+ /**
143
+ * Nouvelles tentatives sur erreur réseau ou statut transitoire (408, 429,
144
+ * 502, 503, 504), pour les seules `retryMethods`. `retry` sur un appel
145
+ * passe devant.
146
+ */
82
147
  defaultRetry?: number | undefined;
148
+ /** Les méthodes rejouées d'office. Par défaut `GET` et `HEAD`. */
149
+ retryMethods?: ApiMethod[] | undefined;
83
150
  defaultTimeoutMs?: number | undefined;
84
151
  plugins?: ApiPlugin[] | undefined;
85
152
  responseHandler?: ResponseHandlerConfig | undefined;
153
+ /**
154
+ * Où lire les erreurs par champ d'une réponse en échec.
155
+ *
156
+ * Par défaut `errors`, `fieldErrors` ou `violations`. `nestFieldErrors`
157
+ * lit en plus le `message: string[]` de class-validator.
158
+ */
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;
86
169
  }
87
170
  type ApiRequestInput = string | {
88
171
  path: string;
@@ -104,6 +187,9 @@ declare class ApiClient {
104
187
  private getLanguage;
105
188
  private defaultHeaders;
106
189
  private defaultRetry;
190
+ private retryMethods;
191
+ private fieldErrors;
192
+ private statusMessages;
107
193
  private defaultTimeoutMs;
108
194
  private plugins;
109
195
  private responseHandler;
@@ -159,39 +245,29 @@ declare class ApiClient {
159
245
  declare function createApiClient(options: ApiClientOptions): ApiClient;
160
246
 
161
247
  /**
162
- * Une erreur de validation rattachée à un champ.
248
+ * Les messages par statut, en français.
163
249
  *
164
- * C'est ce qui permet à un formulaire d'afficher « adresse invalide » sous
165
- * l'adresse plutôt qu'un bandeau générique en haut de page.
250
+ * `0` couvre l'échec réseau : le serveur injoignable, la connexion coupée.
166
251
  */
167
- interface FieldError {
168
- field: string;
169
- message: string;
170
- code?: string | undefined;
171
- }
172
- declare class HttpError extends Error {
173
- status: number;
174
- body: unknown;
175
- headers: Record<string, string>;
176
- /** Les erreurs par champ, quand le serveur en renvoie. */
177
- fields: FieldError[];
178
- constructor(status: number, body?: unknown, message?: string, headers?: Record<string, string>);
179
- static fromResponse(response: Response): Promise<HttpError>;
180
- /**
181
- * Retrouve les erreurs par champ dans un corps d'erreur.
182
- *
183
- * Trois formes couvertes, parce que trois serveurs sur quatre en utilisent
184
- * une : la liste (`errors: [{ field, message }]`), la carte
185
- * (`errors: { email: "…" }`) et la carte de listes, que produit Laravel.
186
- */
187
- static extractFields(body: unknown): FieldError[];
188
- /** Le message rattaché à ce champ, s'il y en a un. */
189
- fieldError(field: string): string | undefined;
190
- static normalizeHeaders(headers: unknown): Record<string, string>;
191
- static extractMessage(body: unknown): string | null;
192
- isClientError(): boolean;
193
- isServerError(): boolean;
194
- }
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;
195
271
 
196
272
  interface AuthPluginOptions {
197
273
  getToken: () => Promise<string | null> | string | null;
@@ -349,6 +425,8 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
349
425
  protected client: ApiClient;
350
426
  protected resource: string;
351
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;
352
430
  protected normalizeQueryParams(params?: TListParams): TListParams | undefined;
353
431
  list(params?: TListParams, config?: ApiRequestConfig): Promise<TEntity[]>;
354
432
  getById(id: string, config?: ApiRequestConfig): Promise<TEntity>;
@@ -388,6 +466,94 @@ declare class BaseService<TEntity, TCreateDTO = Partial<TEntity>, TUpdateDTO = P
388
466
  }
389
467
  declare function createResourceService<TEntity>(client: ApiClient, resourcePath: string): BaseService<TEntity, Partial<TEntity>, Partial<TEntity>, QueryParams>;
390
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
+ /**
500
+ * Les clés et les options de requête d'une ressource.
501
+ *
502
+ * `api.query` se contentait de rendre le chargeur : chaque écran réinventait
503
+ * ses clés de cache, et la moitié oubliait d'invalider la liste après une
504
+ * création. Ici, les clés s'emboîtent — `lists()` couvre toutes les listes,
505
+ * quels qu'en soient les filtres — et chaque mutation sait ce qu'elle rend
506
+ * périmé.
507
+ *
508
+ * ```tsx
509
+ * const factures = createQueryResource(factureService);
510
+ *
511
+ * const { data } = useQuery(factures.pageQuery({ page, limit: 20 }));
512
+ * const enregistrer = useMutation(factures.saveMutation(queryClient));
513
+ * enregistrer.mutate({ id, values }); // crée sans id, modifie avec
514
+ * ```
515
+ */
516
+ declare function createQueryResource<TEntity, TCreate = Partial<TEntity>, TUpdate = Partial<TEntity>, TParams extends QueryParams = QueryParams>(service: BaseService<TEntity, TCreate, TUpdate, TParams>, options?: QueryResourceOptions): {
517
+ keys: {
518
+ all: readonly [string];
519
+ lists: () => readonly [string, "list"];
520
+ list: (params?: unknown) => readonly [string, "list", {}];
521
+ details: () => readonly [string, "detail"];
522
+ detail: (id: string) => readonly [string, "detail", string];
523
+ };
524
+ /** Une liste nue — `service.list`. */
525
+ listQuery: (params?: TParams) => {
526
+ queryKey: readonly [string, "list", {}];
527
+ queryFn: ({ signal }?: QueryFnContext) => Promise<TEntity[]>;
528
+ };
529
+ /** Une page — `service.paginated`, rendue en `{ items, meta }`. */
530
+ pageQuery: (page?: {
531
+ page?: number;
532
+ limit?: number;
533
+ query?: TParams;
534
+ }) => {
535
+ queryKey: readonly [string, "list", {}];
536
+ queryFn: ({ signal }?: QueryFnContext) => Promise<PaginatedResponse<TEntity>>;
537
+ };
538
+ detailQuery: (id: string) => {
539
+ queryKey: readonly [string, "detail", string];
540
+ queryFn: ({ signal }?: QueryFnContext) => Promise<TEntity>;
541
+ };
542
+ /**
543
+ * Crée sans `id`, modifie avec. Invalide les listes, et le détail
544
+ * modifié : c'est ce que chaque écran écrivait à la main, ou oubliait.
545
+ */
546
+ saveMutation: (client: QueryInvalidator) => {
547
+ mutationFn: ({ id, values }: SaveVariables<TCreate, TUpdate>) => Promise<TEntity>;
548
+ onSuccess: (_: TEntity, { id }: SaveVariables<TCreate, TUpdate>) => Promise<unknown[]>;
549
+ };
550
+ removeMutation: (client: QueryInvalidator) => {
551
+ mutationFn: (id: string) => Promise<void>;
552
+ onSuccess: (_: void, id: string) => Promise<unknown[]>;
553
+ };
554
+ };
555
+ type QueryResource<TEntity> = ReturnType<typeof createQueryResource<TEntity>>;
556
+
391
557
  interface AxiosLikeInstance {
392
558
  request<T = unknown>(config: {
393
559
  url: string;
@@ -410,4 +576,4 @@ interface AxiosLikeInstance {
410
576
  declare function createFetchTransport(fetcher?: typeof fetch): ApiTransport;
411
577
  declare function createAxiosTransport(axios: AxiosLikeInstance): ApiTransport;
412
578
 
413
- 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 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, normalizeFilters };
579
+ 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
@@ -2,21 +2,46 @@ import { sleep } from '@sia-ui/utils/async';
2
2
  import { toQueryString } from '@sia-ui/utils/query';
3
3
 
4
4
  // src/http-error.ts
5
+ var NEST_FIELD = /^([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\s/;
6
+ var nestFieldErrors = (body) => {
7
+ const standard = HttpError.extractFields(body);
8
+ if (standard.length > 0 || !body || typeof body !== "object") return standard;
9
+ const messages = body.message;
10
+ if (!Array.isArray(messages)) return [];
11
+ return messages.flatMap((message) => {
12
+ if (typeof message !== "string") return [];
13
+ const field = NEST_FIELD.exec(message)?.[1];
14
+ return field ? [{ field, message }] : [];
15
+ });
16
+ };
5
17
  var HttpError = class _HttpError extends Error {
6
18
  status;
7
19
  body;
8
20
  headers;
9
21
  /** Les erreurs par champ, quand le serveur en renvoie. */
10
22
  fields;
11
- constructor(status, body = null, message, headers = {}) {
23
+ constructor(status, body = null, message, headers = {}, extractFields = _HttpError.extractFields) {
12
24
  super(message ?? _HttpError.extractMessage(body) ?? `HTTP ${status}`);
13
25
  this.name = "HttpError";
14
26
  this.status = status;
15
27
  this.body = body;
16
28
  this.headers = headers;
17
- this.fields = _HttpError.extractFields(body);
29
+ this.fields = extractFields(body);
18
30
  Object.setPrototypeOf(this, _HttpError.prototype);
19
31
  }
32
+ /**
33
+ * Les erreurs par champ, sous la forme qu'attend un formulaire.
34
+ *
35
+ * `FormAdapter.setErrors` prend `{ champ: message }` : c'est cette carte.
36
+ * Le premier message d'un champ l'emporte — un champ n'en affiche qu'un.
37
+ */
38
+ toFormErrors() {
39
+ const errors = {};
40
+ for (const { field, message } of this.fields) {
41
+ if (!(field in errors)) errors[field] = message;
42
+ }
43
+ return errors;
44
+ }
20
45
  static async fromResponse(response) {
21
46
  const headers = _HttpError.normalizeHeaders(response.headers);
22
47
  const contentType = headers["content-type"] ?? "";
@@ -111,6 +136,66 @@ var HttpError = class _HttpError extends Error {
111
136
  return this.status >= 500;
112
137
  }
113
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
+ }
114
199
  function buildQueryString(params) {
115
200
  return toQueryString(params);
116
201
  }
@@ -119,6 +204,10 @@ function buildQueryString(params) {
119
204
  function asRecord(value) {
120
205
  return value && typeof value === "object" ? value : null;
121
206
  }
207
+ var PAGE_KEYS = ["total", "page", "limit", "totalPages"];
208
+ function hasPageKeys(record) {
209
+ return PAGE_KEYS.some((key) => record[key] !== void 0);
210
+ }
122
211
  var ResponseHandler = class {
123
212
  config;
124
213
  constructor(config = {}) {
@@ -127,7 +216,9 @@ var ResponseHandler = class {
127
216
  extractData(raw) {
128
217
  if (this.config.extractData) return this.config.extractData(raw);
129
218
  const record = asRecord(raw);
130
- if (record && record.data !== void 0) return record.data;
219
+ if (record && record.data !== void 0 && !hasPageKeys(record)) {
220
+ return record.data;
221
+ }
131
222
  return raw;
132
223
  }
133
224
  extractPaginated(raw) {
@@ -153,11 +244,12 @@ var ResponseHandler = class {
153
244
  if (Array.isArray(data) && meta) {
154
245
  return { items: data, meta };
155
246
  }
156
- if (Array.isArray(record.items)) {
157
- const limit = Number(record.limit ?? record.items.length);
247
+ const items = Array.isArray(record.items) ? record.items : Array.isArray(data) && hasPageKeys(record) ? data : null;
248
+ if (items) {
249
+ const limit = Number(record.limit ?? items.length);
158
250
  const total = record.total === void 0 ? void 0 : Number(record.total);
159
251
  return {
160
- items: record.items,
252
+ items,
161
253
  meta: {
162
254
  page: Number(record.page ?? 1),
163
255
  limit,
@@ -303,6 +395,7 @@ function createAxiosTransport(axios) {
303
395
 
304
396
  // src/client.ts
305
397
  var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([408, 429, 502, 503, 504]);
398
+ var DEFAULT_RETRY_METHODS = ["GET", "HEAD"];
306
399
  function isAbsoluteUrl(path) {
307
400
  return /^https?:\/\//i.test(path);
308
401
  }
@@ -319,6 +412,9 @@ var ApiClient = class {
319
412
  getLanguage;
320
413
  defaultHeaders;
321
414
  defaultRetry;
415
+ retryMethods;
416
+ fieldErrors;
417
+ statusMessages;
322
418
  defaultTimeoutMs;
323
419
  plugins;
324
420
  responseHandler;
@@ -331,6 +427,11 @@ var ApiClient = class {
331
427
  "Content-Type": "application/json"
332
428
  };
333
429
  this.defaultRetry = options.defaultRetry ?? 2;
430
+ this.retryMethods = new Set(
431
+ (options.retryMethods ?? DEFAULT_RETRY_METHODS).map(normalizeMethod)
432
+ );
433
+ this.fieldErrors = options.fieldErrors;
434
+ this.statusMessages = options.statusMessages;
334
435
  this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
335
436
  this.plugins = options.plugins ?? [];
336
437
  this.responseHandler = new ResponseHandler(options.responseHandler);
@@ -395,7 +496,7 @@ var ApiClient = class {
395
496
  };
396
497
  const onRequestResult = await this.runPlugins("onRequest", ctx);
397
498
  if (onRequestResult !== void 0) return onRequestResult;
398
- const maxRetries = config.retry ?? this.defaultRetry;
499
+ const maxRetries = config.retry ?? (this.retryMethods.has(ctx.method) ? this.defaultRetry : 0);
399
500
  let attempt = 0;
400
501
  while (true) {
401
502
  try {
@@ -419,7 +520,8 @@ var ApiClient = class {
419
520
  response.status,
420
521
  response.data,
421
522
  void 0,
422
- response.headers
523
+ response.headers,
524
+ this.fieldErrors
423
525
  );
424
526
  }
425
527
  if (response.status === 204) return null;
@@ -427,7 +529,9 @@ var ApiClient = class {
427
529
  } catch (error) {
428
530
  const pluginResult = await this.runPlugins("onError", error, ctx);
429
531
  if (pluginResult !== void 0) return pluginResult;
430
- 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
+ }
431
535
  attempt += 1;
432
536
  const baseDelay = Math.min(1e3 * 2 ** attempt, 3e4);
433
537
  await sleep(baseDelay + Math.random() * 0.1 * baseDelay);
@@ -818,6 +922,10 @@ var BaseService = class {
818
922
  this.client = client;
819
923
  this.resource = resource;
820
924
  }
925
+ /** Le chemin de la ressource — sert aussi de racine aux clés de cache. */
926
+ get path() {
927
+ return this.resource;
928
+ }
821
929
  normalizeQueryParams(params) {
822
930
  if (!params || typeof params !== "object") return params;
823
931
  const normalized = { ...params };
@@ -901,4 +1009,46 @@ function createResourceService(client, resourcePath) {
901
1009
  return new BaseService(client, resourcePath);
902
1010
  }
903
1011
 
904
- 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, normalizeFilters };
1012
+ // src/query-resource.ts
1013
+ function createQueryResource(service, options = {}) {
1014
+ const racine = options.name ?? service.path;
1015
+ const keys = {
1016
+ all: [racine],
1017
+ lists: () => [racine, "list"],
1018
+ list: (params) => [racine, "list", params ?? {}],
1019
+ details: () => [racine, "detail"],
1020
+ detail: (id) => [racine, "detail", id]
1021
+ };
1022
+ const invalider = (client, ...cles) => Promise.all(cles.map((queryKey) => client.invalidateQueries({ queryKey })));
1023
+ return {
1024
+ keys,
1025
+ /** Une liste nue — `service.list`. */
1026
+ listQuery: (params) => ({
1027
+ queryKey: keys.list(params),
1028
+ queryFn: ({ signal } = {}) => service.list(params, { signal })
1029
+ }),
1030
+ /** Une page — `service.paginated`, rendue en `{ items, meta }`. */
1031
+ pageQuery: (page = {}) => ({
1032
+ queryKey: keys.list(page),
1033
+ queryFn: ({ signal } = {}) => service.paginated({ ...page, config: { signal } })
1034
+ }),
1035
+ detailQuery: (id) => ({
1036
+ queryKey: keys.detail(id),
1037
+ queryFn: ({ signal } = {}) => service.getById(id, { signal })
1038
+ }),
1039
+ /**
1040
+ * Crée sans `id`, modifie avec. Invalide les listes, et le détail
1041
+ * modifié : c'est ce que chaque écran écrivait à la main, ou oubliait.
1042
+ */
1043
+ saveMutation: (client) => ({
1044
+ mutationFn: ({ id, values }) => id === void 0 ? service.create(values) : service.update(id, values),
1045
+ onSuccess: (_, { id }) => id === void 0 ? invalider(client, keys.lists()) : invalider(client, keys.lists(), keys.detail(id))
1046
+ }),
1047
+ removeMutation: (client) => ({
1048
+ mutationFn: (id) => service.remove(id),
1049
+ onSuccess: (_, id) => invalider(client, keys.lists(), keys.detail(id))
1050
+ })
1051
+ };
1052
+ }
1053
+
1054
+ 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.4.0",
3
+ "version": "0.6.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.4.0"
45
+ "@sia-ui/utils": "0.6.0"
46
46
  },
47
47
  "devDependencies": {
48
48
  "tsup": "^8.5.1",