diff --git a/app/javascript/components/ComboBox.tsx b/app/javascript/components/ComboBox.tsx index 3bee420c9..d93b957e8 100644 --- a/app/javascript/components/ComboBox.tsx +++ b/app/javascript/components/ComboBox.tsx @@ -15,6 +15,7 @@ import { import { useMemo, useRef, createContext, useContext } from 'react'; import type { RefObject } from 'react'; import { findOrCreateContainerElement } from '@coldwired/react'; +import * as s from 'superstruct'; import { useLabelledBy, @@ -98,7 +99,7 @@ export function SingleComboBox({ form, data, ...props - } = useMemo(() => SingleComboBoxProps.parse(maybeProps), [maybeProps]); + } = useMemo(() => s.create(maybeProps, SingleComboBoxProps), [maybeProps]); const labelledby = useLabelledBy(props.id, ariaLabelledby); const { ref, dispatch } = useDispatchChangeEvent(); @@ -146,7 +147,7 @@ export function MultiComboBox(maybeProps: MultiComboBoxProps) { allowsCustomValue, valueSeparator, ...props - } = useMemo(() => MultiComboBoxProps.parse(maybeProps), [maybeProps]); + } = useMemo(() => s.create(maybeProps, MultiComboBoxProps), [maybeProps]); const labelledby = useLabelledBy(props.id, ariaLabelledby); const { ref, dispatch } = useDispatchChangeEvent(); @@ -235,7 +236,7 @@ export function RemoteComboBox({ form, data, ...props - } = useMemo(() => RemoteComboBoxProps.parse(maybeProps), [maybeProps]); + } = useMemo(() => s.create(maybeProps, RemoteComboBoxProps), [maybeProps]); const labelledby = useLabelledBy(props.id, ariaLabelledby); const { ref, dispatch } = useDispatchChangeEvent(); diff --git a/app/javascript/components/react-aria/hooks.ts b/app/javascript/components/react-aria/hooks.ts index 2c75e8540..e2683b919 100644 --- a/app/javascript/components/react-aria/hooks.ts +++ b/app/javascript/components/react-aria/hooks.ts @@ -9,6 +9,7 @@ import { matchSorter } from 'match-sorter'; import { useDebounceCallback } from 'usehooks-ts'; import { useEvent } from 'react-use-event-hook'; import isEqual from 'react-fast-compare'; +import * as s from 'superstruct'; import { Item } from './props'; @@ -420,9 +421,9 @@ export const createLoader: ( }); if (response.ok) { const json = await response.json(); - const result = Item.array().safeParse(json); - if (result.success) { - const items = matchSorter(result.data, filterText, { + const [err, result] = s.validate(json, s.array(Item), { coerce: true }); + if (!err) { + const items = matchSorter(result, filterText, { keys: ['label'] }); return { diff --git a/app/javascript/components/react-aria/props.ts b/app/javascript/components/react-aria/props.ts index e67ac1096..835b086ea 100644 --- a/app/javascript/components/react-aria/props.ts +++ b/app/javascript/components/react-aria/props.ts @@ -1,72 +1,81 @@ import type { ReactNode } from 'react'; -import { z } from 'zod'; +import * as s from 'superstruct'; import type { Loader } from './hooks'; -export const Item = z.object({ - label: z.string(), - value: z.string(), - data: z.any().optional() +export const Item = s.object({ + label: s.string(), + value: s.string(), + data: s.any() }); -export type Item = z.infer; +export type Item = s.Infer; -const ComboBoxPropsSchema = z - .object({ - id: z.string(), - className: z.string(), - name: z.string(), - label: z.string(), - description: z.string(), - isRequired: z.boolean(), - 'aria-label': z.string(), - 'aria-labelledby': z.string(), - 'aria-describedby': z.string(), - items: z - .array(Item) - .or( - z - .string() - .array() - .transform((items) => - items.map((label) => ({ label, value: label })) - ) - ) - .or( - z - .tuple([z.string(), z.string().or(z.number())]) - .array() - .transform((items) => - items.map(([label, value]) => ({ - label, - value: String(value) - })) - ) - ), - formValue: z.enum(['text', 'key']), - form: z.string(), - data: z.record(z.string()) +const ArrayOfTuples = s.coerce( + s.array(Item), + s.array(s.tuple([s.string(), s.union([s.string(), s.number()])])), + (items) => + items.map(([label, value]) => ({ + label, + value: String(value) + })) +); + +const ArrayOfStrings = s.coerce(s.array(Item), s.array(s.string()), (items) => + items.map((label) => ({ label, value: label })) +); + +const ComboBoxPropsSchema = s.partial( + s.object({ + id: s.string(), + className: s.string(), + name: s.string(), + label: s.string(), + description: s.string(), + isRequired: s.boolean(), + 'aria-label': s.string(), + 'aria-labelledby': s.string(), + 'aria-describedby': s.string(), + items: s.union([s.array(Item), ArrayOfStrings, ArrayOfTuples]), + formValue: s.enums(['text', 'key']), + form: s.string(), + data: s.record(s.string(), s.string()) }) - .partial(); -export const SingleComboBoxProps = ComboBoxPropsSchema.extend({ - selectedKey: z.string().nullable(), - emptyFilterKey: z.string() -}).partial(); -export const MultiComboBoxProps = ComboBoxPropsSchema.extend({ - selectedKeys: z.string().array(), - allowsCustomValue: z.boolean(), - valueSeparator: z.string() -}).partial(); -export const RemoteComboBoxProps = ComboBoxPropsSchema.extend({ - selectedKey: z.string().nullable(), - minimumInputLength: z.number(), - limit: z.number(), - allowsCustomValue: z.boolean() -}).partial(); -export type SingleComboBoxProps = z.infer & { +); +export const SingleComboBoxProps = s.assign( + ComboBoxPropsSchema, + s.partial( + s.object({ + selectedKey: s.nullable(s.string()), + emptyFilterKey: s.string() + }) + ) +); +export const MultiComboBoxProps = s.assign( + ComboBoxPropsSchema, + s.partial( + s.object({ + selectedKeys: s.array(s.string()), + allowsCustomValue: s.boolean(), + valueSeparator: s.string() + }) + ) +); +export const RemoteComboBoxProps = s.assign( + ComboBoxPropsSchema, + s.partial( + s.object({ + selectedKey: s.nullable(s.string()), + minimumInputLength: s.number(), + limit: s.number(), + allowsCustomValue: s.boolean() + }) + ) +); +export type SingleComboBoxProps = s.Infer & { children?: ReactNode; }; -export type MultiComboBoxProps = z.infer; -export type RemoteComboBoxProps = z.infer & { +export type MultiComboBoxProps = s.Infer; +export type RemoteComboBoxProps = s.Infer & { children?: ReactNode; loader: Loader | string; onChange?: (item: Item | null) => void; diff --git a/app/javascript/controllers/lazy/tiptap_controller.ts b/app/javascript/controllers/lazy/tiptap_controller.ts index e80d2e599..58336e926 100644 --- a/app/javascript/controllers/lazy/tiptap_controller.ts +++ b/app/javascript/controllers/lazy/tiptap_controller.ts @@ -1,6 +1,6 @@ -import { Editor, type JSONContent } from '@tiptap/core'; +import { Editor } from '@tiptap/core'; import { isButtonElement, isHTMLElement } from '@coldwired/utils'; -import { z } from 'zod'; +import * as s from 'superstruct'; import { ApplicationController } from '../application_controller'; import { getAction } from '../../shared/tiptap/actions'; @@ -61,7 +61,7 @@ export class TiptapController extends ApplicationController { insertTag(event: MouseEvent) { if (this.#editor && isHTMLElement(event.target)) { - const tag = tagSchema.parse(event.target.dataset); + const tag = s.create(event.target.dataset, tagSchema); const editor = this.#editor .chain() .focus() @@ -77,12 +77,12 @@ export class TiptapController extends ApplicationController { private get content() { const value = this.inputTarget.value; if (value) { - return jsonContentSchema.parse(JSON.parse(value)); + return s.create(JSON.parse(value), jsonContentSchema); } } private get tags(): TagSchema[] { - return this.tagTargets.map((tag) => tagSchema.parse(tag.dataset)); + return this.tagTargets.map((tag) => s.create(tag.dataset, tagSchema)); } private get menuButtons() { @@ -92,13 +92,24 @@ export class TiptapController extends ApplicationController { } } -const jsonContentSchema: z.ZodType = z.object({ - type: z.string().optional(), - text: z.string().optional(), - attrs: z.record(z.any()).optional(), - marks: z - .object({ type: z.string(), attrs: z.record(z.any()).optional() }) - .array() - .optional(), - content: z.lazy(() => z.array(jsonContentSchema).optional()) +const Attrs = s.record(s.string(), s.any()); +const Marks = s.array( + s.type({ + type: s.string(), + attrs: s.optional(Attrs) + }) +); +type JSONContent = { + type?: string; + text?: string; + attrs?: s.Infer; + marks?: s.Infer; + content?: JSONContent[]; +}; +const jsonContentSchema: s.Describe = s.type({ + type: s.optional(s.string()), + text: s.optional(s.string()), + attrs: s.optional(Attrs), + marks: s.optional(Marks), + content: s.lazy(() => s.optional(s.array(jsonContentSchema))) }); diff --git a/app/javascript/shared/tiptap/actions.ts b/app/javascript/shared/tiptap/actions.ts index 727331112..6a1d6fcae 100644 --- a/app/javascript/shared/tiptap/actions.ts +++ b/app/javascript/shared/tiptap/actions.ts @@ -1,5 +1,5 @@ import { Editor } from '@tiptap/core'; -import { z } from 'zod'; +import * as s from 'superstruct'; type EditorAction = { run(): void; @@ -11,7 +11,7 @@ export function getAction( editor: Editor, button: HTMLButtonElement ): EditorAction { - return tiptapActionSchema.parse(button.dataset)(editor); + return s.create(button.dataset, tiptapActionSchema)(editor); } const EDITOR_ACTIONS: Record EditorAction> = { @@ -109,8 +109,15 @@ const EDITOR_ACTIONS: Record EditorAction> = { }) }; -const tiptapActionSchema = z - .object({ - tiptapAction: z.enum(Object.keys(EDITOR_ACTIONS) as [string, ...string[]]) - }) - .transform(({ tiptapAction }) => EDITOR_ACTIONS[tiptapAction]); +const EditorActionFn = s.define<(editor: Editor) => EditorAction>( + 'EditorActionFn', + (fn) => typeof fn == 'function' +); + +const tiptapActionSchema = s.coerce( + EditorActionFn, + s.type({ + tiptapAction: s.enums(Object.keys(EDITOR_ACTIONS) as [string, ...string[]]) + }), + ({ tiptapAction }) => EDITOR_ACTIONS[tiptapAction] +); diff --git a/app/javascript/shared/tiptap/tags.ts b/app/javascript/shared/tiptap/tags.ts index 3a76ca1cd..fc79ffeb5 100644 --- a/app/javascript/shared/tiptap/tags.ts +++ b/app/javascript/shared/tiptap/tags.ts @@ -1,12 +1,17 @@ import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'; -import { z } from 'zod'; +import * as s from 'superstruct'; import tippy, { type Instance as TippyInstance } from 'tippy.js'; import { matchSorter } from 'match-sorter'; -export const tagSchema = z - .object({ tagLabel: z.string(), tagId: z.string() }) - .transform(({ tagId, tagLabel }) => ({ label: tagLabel, id: tagId })); -export type TagSchema = z.infer; +export const tagSchema = s.coerce( + s.object({ label: s.string(), id: s.string() }), + s.type({ + tagLabel: s.string(), + tagId: s.string() + }), + ({ tagId, tagLabel }) => ({ label: tagLabel, id: tagId }) +); +export type TagSchema = s.Infer; class SuggestionMenu { #selectedIndex = 0; diff --git a/app/javascript/shared/utils.ts b/app/javascript/shared/utils.ts index 5a756a850..8602ff8e8 100644 --- a/app/javascript/shared/utils.ts +++ b/app/javascript/shared/utils.ts @@ -1,72 +1,87 @@ import { session } from '@hotwired/turbo'; -import { z } from 'zod'; +import * as s from 'superstruct'; -const Gon = z - .object({ - autosave: z - .object({ - debounce_delay: z.number().default(0), - status_visible_duration: z.number().default(0) - }) - .default({}), - autocomplete: z - .object({ - api_geo_url: z.string().optional(), - api_adresse_url: z.string().optional(), - api_education_url: z.string().optional() - }) - .default({}), - locale: z.string().default('fr'), - matomo: z - .object({ - cookieDomain: z.string().optional(), - domain: z.string().optional(), - enabled: z.boolean().default(false), - host: z.string().optional(), - key: z.string().or(z.number()).nullish() - }) - .default({}), - sentry: z - .object({ - key: z.string().nullish(), - enabled: z.boolean().default(false), - environment: z.string().optional(), - user: z.object({ id: z.string() }).default({ id: '' }), - browser: z.object({ modern: z.boolean() }).default({ modern: false }), - release: z.string().nullish() - }) - .default({}), - crisp: z - .object({ - key: z.string().nullish(), - enabled: z.boolean().default(false), - administrateur: z - .object({ - email: z.string(), - DS_SIGN_IN_COUNT: z.number(), - DS_NB_DEMARCHES_BROUILLONS: z.number(), - DS_NB_DEMARCHES_ACTIVES: z.number(), - DS_NB_DEMARCHES_ARCHIVES: z.number(), - DS_ID: z.number() - }) - .default({ +function nullish(struct: s.Struct) { + return s.optional(s.union([s.literal(null), struct])); +} + +const Gon = s.defaulted( + s.type({ + autosave: s.defaulted( + s.type({ + debounce_delay: s.defaulted(s.number(), 0), + status_visible_duration: s.defaulted(s.number(), 0) + }), + {} + ), + autocomplete: s.defaulted( + s.partial( + s.type({ + api_geo_url: s.string(), + api_adresse_url: s.string(), + api_education_url: s.string() + }) + ), + {} + ), + locale: s.defaulted(s.string(), 'fr'), + matomo: s.defaulted( + s.type({ + cookieDomain: s.optional(s.string()), + domain: s.optional(s.string()), + enabled: s.defaulted(s.boolean(), false), + host: s.optional(s.string()), + key: nullish(s.union([s.string(), s.number()])) + }), + {} + ), + sentry: s.defaulted( + s.type({ + key: nullish(s.string()), + enabled: s.defaulted(s.boolean(), false), + environment: s.optional(s.string()), + user: s.defaulted(s.type({ id: s.string() }), { id: '' }), + browser: s.defaulted(s.type({ modern: s.boolean() }), { + modern: false + }), + release: nullish(s.string()) + }), + {} + ), + crisp: s.defaulted( + s.type({ + key: nullish(s.string()), + enabled: s.defaulted(s.boolean(), false), + administrateur: s.defaulted( + s.type({ + email: s.string(), + DS_SIGN_IN_COUNT: s.number(), + DS_NB_DEMARCHES_BROUILLONS: s.number(), + DS_NB_DEMARCHES_ACTIVES: s.number(), + DS_NB_DEMARCHES_ARCHIVES: s.number(), + DS_ID: s.number() + }), + { email: '', DS_SIGN_IN_COUNT: 0, DS_NB_DEMARCHES_BROUILLONS: 0, DS_NB_DEMARCHES_ACTIVES: 0, DS_NB_DEMARCHES_ARCHIVES: 0, DS_ID: 0 - }) - }) - .default({}), - defaultQuery: z.string().optional(), - defaultVariables: z.string().optional() - }) - .default({}); + } + ) + }), + {} + ), + defaultQuery: s.optional(s.string()), + defaultVariables: s.optional(s.string()) + }), + {} +); declare const window: Window & typeof globalThis & { gon: unknown }; export function getConfig() { - return Gon.parse(window.gon); + return s.create(window.gon, Gon); } export function show(el: Element | null) { diff --git a/bun.lockb b/bun.lockb index 608dba50f..beab4b760 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 1029ea7db..d301bee84 100644 --- a/package.json +++ b/package.json @@ -62,12 +62,12 @@ "react-use-event-hook": "^0.9.6", "spectaql": "^2.3.1", "stimulus-use": "^0.52.2", + "superstruct": "^1.0.4", "terser": "^5.31.0", "tiny-invariant": "^1.3.3", "tippy.js": "^6.3.7", "trix": "^1.2.3", - "usehooks-ts": "^3.1.0", - "zod": "^3.20.2" + "usehooks-ts": "^3.1.0" }, "devDependencies": { "@esbuild/darwin-arm64": "=0.19.9",