@sjolystinnovation/app-kit 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -264,6 +264,10 @@ export const getRecipe = (id: number) =>
264
264
  `target`, so an edit shows at once. It posts to `/api/revalidate` unless `endpoint` says otherwise.
265
265
  It never throws. On failure the pages' own revalidate window is the fallback.
266
266
 
267
+ `apiFieldErrors(error)` reads the per-field messages of an ASP.NET validation problem from a failed
268
+ call or its body, keyed in camelCase to match the form: `{ errors: { OrgNumber: ["..."] } }` becomes
269
+ `{ orgNumber: ["..."] }`. It answers null when there are none, so the form falls back to one message.
270
+
267
271
  `createPublicClient(baseURL)` is the same without the session, for the API's anonymous endpoints. An
268
272
  empty `baseURL` throws, and requests only go to `baseURL`.
269
273
 
@@ -1016,6 +1020,20 @@ const gargeRule = passwordSchema().max(128, "Be at most 128 characters long.");
1016
1020
 
1017
1021
  `PASSWORD_MIN_LENGTH` holds the minimum, for hint text.
1018
1022
 
1023
+ ### `zodFieldErrors(error, options?)`
1024
+
1025
+ From `@sjolystinnovation/app-kit/validation`. A failed parse's issues by the top-level field they
1026
+ belong to, for showing beside each input. Each field gets its first issue's message, or every message
1027
+ in order with `all: true`. `message` replaces zod's text, such as with the app's own translations.
1028
+ Issues that belong to no field are left out.
1029
+
1030
+ ```ts
1031
+ import { zodFieldErrors } from "@sjolystinnovation/app-kit/validation";
1032
+
1033
+ const parsed = contactSchema.safeParse(form);
1034
+ if (!parsed.success) setErrors(zodFieldErrors(parsed.error, { message: (issue) => issueMessage(issue, dict) }));
1035
+ ```
1036
+
1019
1037
  ## Theming
1020
1038
 
1021
1039
  The components draw every colour, corner radius and label size from theme variables with a light
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjolystinnovation/app-kit",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Building blocks for Next.js apps that sign in against a JWT API through next-auth",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Sjølyst Innovation AS",
@@ -0,0 +1,23 @@
1
+ import axios from "axios";
2
+
3
+ /**
4
+ * The per-field messages of an ASP.NET validation problem, keyed by the field's name in camelCase so
5
+ * they match the form's own names: `{ errors: { OrgNumber: ["..."] } }` becomes
6
+ * `{ orgNumber: ["..."] }`. Takes the failed axios call or the response body. Answers null when there
7
+ * are no field errors, so the caller falls back to one message for the whole form.
8
+ */
9
+ export function apiFieldErrors(error: unknown): Record<string, string[]> | null {
10
+ const data = axios.isAxiosError(error) ? error.response?.data : error;
11
+ const errors = data && typeof data === "object" ? (data as { errors?: unknown }).errors : undefined;
12
+ if (!errors || typeof errors !== "object" || Array.isArray(errors)) return null;
13
+
14
+ // A Map, so a field named like an Object property such as "constructor" is still just a field.
15
+ const fields = new Map<string, string[]>();
16
+ for (const [key, value] of Object.entries(errors as Record<string, unknown>)) {
17
+ const messages = (Array.isArray(value) ? value : [value]).filter((m): m is string => typeof m === "string" && m !== "");
18
+ if (messages.length === 0) continue;
19
+ const field = key.charAt(0).toLowerCase() + key.slice(1);
20
+ fields.set(field, [...(fields.get(field) ?? []), ...messages]);
21
+ }
22
+ return fields.size > 0 ? Object.fromEntries(fields) : null;
23
+ }
package/src/api/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { apiFieldErrors } from "./apiFieldErrors";
1
2
  export { createApiClient } from "./createApiClient";
2
3
  export { request } from "./request";
3
4
  export { requestRevalidate } from "./requestRevalidate";
@@ -0,0 +1,34 @@
1
+ import type { z } from "zod";
2
+
3
+ /** Picks the text shown for an issue, such as a translated message by the issue's code. */
4
+ export type IssueMessage = (issue: z.core.$ZodIssue) => string;
5
+
6
+ /**
7
+ * A failed parse's issues by the top-level field they belong to, for showing beside each input. By
8
+ * default each field gets its first issue's message. With `all: true` it gets every message, in order.
9
+ * `message` replaces zod's text, such as with the app's own translations. Issues without a field,
10
+ * from a refinement of the whole object, are left out.
11
+ */
12
+ export function zodFieldErrors<T>(
13
+ error: z.ZodError<T>,
14
+ options?: { all?: false; message?: IssueMessage },
15
+ ): Partial<Record<Extract<keyof T, string>, string>>;
16
+ export function zodFieldErrors<T>(
17
+ error: z.ZodError<T>,
18
+ options: { all: true; message?: IssueMessage },
19
+ ): Partial<Record<Extract<keyof T, string>, string[]>>;
20
+ export function zodFieldErrors<T>(
21
+ error: z.ZodError<T>,
22
+ options: { all?: boolean; message?: IssueMessage } = {},
23
+ ): object {
24
+ // A Map, so a field named like an Object property such as "constructor" is still just a field.
25
+ const errors = new Map<string, string[]>();
26
+ for (const issue of error.issues) {
27
+ const field = issue.path[0];
28
+ if (typeof field !== "string") continue;
29
+ const messages = errors.get(field) ?? [];
30
+ messages.push(options.message ? options.message(issue) : issue.message);
31
+ errors.set(field, messages);
32
+ }
33
+ return Object.fromEntries([...errors].map(([field, messages]) => [field, options.all ? messages : messages[0]]));
34
+ }
@@ -1,2 +1,4 @@
1
1
  export { PASSWORD_MIN_LENGTH, defaultPasswordStrings, passwordSchema } from "./password";
2
2
  export type { PasswordStrings } from "./password";
3
+ export { zodFieldErrors } from "./fieldErrors";
4
+ export type { IssueMessage } from "./fieldErrors";