refactor(js): use superstruct instead of zod

This commit is contained in:
Paul Chavard 2024-06-04 23:06:34 +02:00
parent fea8d8971b
commit 14a1bfa1a3
No known key found for this signature in database
9 changed files with 201 additions and 152 deletions

View file

@ -15,6 +15,7 @@ import {
import { useMemo, useRef, createContext, useContext } from 'react'; import { useMemo, useRef, createContext, useContext } from 'react';
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import { findOrCreateContainerElement } from '@coldwired/react'; import { findOrCreateContainerElement } from '@coldwired/react';
import * as s from 'superstruct';
import { import {
useLabelledBy, useLabelledBy,
@ -98,7 +99,7 @@ export function SingleComboBox({
form, form,
data, data,
...props ...props
} = useMemo(() => SingleComboBoxProps.parse(maybeProps), [maybeProps]); } = useMemo(() => s.create(maybeProps, SingleComboBoxProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby); const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent(); const { ref, dispatch } = useDispatchChangeEvent();
@ -146,7 +147,7 @@ export function MultiComboBox(maybeProps: MultiComboBoxProps) {
allowsCustomValue, allowsCustomValue,
valueSeparator, valueSeparator,
...props ...props
} = useMemo(() => MultiComboBoxProps.parse(maybeProps), [maybeProps]); } = useMemo(() => s.create(maybeProps, MultiComboBoxProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby); const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent(); const { ref, dispatch } = useDispatchChangeEvent();
@ -235,7 +236,7 @@ export function RemoteComboBox({
form, form,
data, data,
...props ...props
} = useMemo(() => RemoteComboBoxProps.parse(maybeProps), [maybeProps]); } = useMemo(() => s.create(maybeProps, RemoteComboBoxProps), [maybeProps]);
const labelledby = useLabelledBy(props.id, ariaLabelledby); const labelledby = useLabelledBy(props.id, ariaLabelledby);
const { ref, dispatch } = useDispatchChangeEvent(); const { ref, dispatch } = useDispatchChangeEvent();

View file

@ -9,6 +9,7 @@ import { matchSorter } from 'match-sorter';
import { useDebounceCallback } from 'usehooks-ts'; import { useDebounceCallback } from 'usehooks-ts';
import { useEvent } from 'react-use-event-hook'; import { useEvent } from 'react-use-event-hook';
import isEqual from 'react-fast-compare'; import isEqual from 'react-fast-compare';
import * as s from 'superstruct';
import { Item } from './props'; import { Item } from './props';
@ -420,9 +421,9 @@ export const createLoader: (
}); });
if (response.ok) { if (response.ok) {
const json = await response.json(); const json = await response.json();
const result = Item.array().safeParse(json); const [err, result] = s.validate(json, s.array(Item), { coerce: true });
if (result.success) { if (!err) {
const items = matchSorter(result.data, filterText, { const items = matchSorter(result, filterText, {
keys: ['label'] keys: ['label']
}); });
return { return {

View file

@ -1,72 +1,81 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { z } from 'zod'; import * as s from 'superstruct';
import type { Loader } from './hooks'; import type { Loader } from './hooks';
export const Item = z.object({ export const Item = s.object({
label: z.string(), label: s.string(),
value: z.string(), value: s.string(),
data: z.any().optional() data: s.any()
}); });
export type Item = z.infer<typeof Item>; export type Item = s.Infer<typeof Item>;
const ComboBoxPropsSchema = z const ArrayOfTuples = s.coerce(
.object({ s.array(Item),
id: z.string(), s.array(s.tuple([s.string(), s.union([s.string(), s.number()])])),
className: z.string(), (items) =>
name: z.string(), items.map<Item>(([label, value]) => ({
label: z.string(), label,
description: z.string(), value: String(value)
isRequired: z.boolean(), }))
'aria-label': z.string(), );
'aria-labelledby': z.string(),
'aria-describedby': z.string(), const ArrayOfStrings = s.coerce(s.array(Item), s.array(s.string()), (items) =>
items: z items.map<Item>((label) => ({ label, value: label }))
.array(Item) );
.or(
z const ComboBoxPropsSchema = s.partial(
.string() s.object({
.array() id: s.string(),
.transform((items) => className: s.string(),
items.map<Item>((label) => ({ label, value: label })) name: s.string(),
) label: s.string(),
) description: s.string(),
.or( isRequired: s.boolean(),
z 'aria-label': s.string(),
.tuple([z.string(), z.string().or(z.number())]) 'aria-labelledby': s.string(),
.array() 'aria-describedby': s.string(),
.transform((items) => items: s.union([s.array(Item), ArrayOfStrings, ArrayOfTuples]),
items.map<Item>(([label, value]) => ({ formValue: s.enums(['text', 'key']),
label, form: s.string(),
value: String(value) data: s.record(s.string(), s.string())
}))
)
),
formValue: z.enum(['text', 'key']),
form: z.string(),
data: z.record(z.string())
}) })
.partial(); );
export const SingleComboBoxProps = ComboBoxPropsSchema.extend({ export const SingleComboBoxProps = s.assign(
selectedKey: z.string().nullable(), ComboBoxPropsSchema,
emptyFilterKey: z.string() s.partial(
}).partial(); s.object({
export const MultiComboBoxProps = ComboBoxPropsSchema.extend({ selectedKey: s.nullable(s.string()),
selectedKeys: z.string().array(), emptyFilterKey: s.string()
allowsCustomValue: z.boolean(), })
valueSeparator: z.string() )
}).partial(); );
export const RemoteComboBoxProps = ComboBoxPropsSchema.extend({ export const MultiComboBoxProps = s.assign(
selectedKey: z.string().nullable(), ComboBoxPropsSchema,
minimumInputLength: z.number(), s.partial(
limit: z.number(), s.object({
allowsCustomValue: z.boolean() selectedKeys: s.array(s.string()),
}).partial(); allowsCustomValue: s.boolean(),
export type SingleComboBoxProps = z.infer<typeof SingleComboBoxProps> & { 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<typeof SingleComboBoxProps> & {
children?: ReactNode; children?: ReactNode;
}; };
export type MultiComboBoxProps = z.infer<typeof MultiComboBoxProps>; export type MultiComboBoxProps = s.Infer<typeof MultiComboBoxProps>;
export type RemoteComboBoxProps = z.infer<typeof RemoteComboBoxProps> & { export type RemoteComboBoxProps = s.Infer<typeof RemoteComboBoxProps> & {
children?: ReactNode; children?: ReactNode;
loader: Loader | string; loader: Loader | string;
onChange?: (item: Item | null) => void; onChange?: (item: Item | null) => void;

View file

@ -1,6 +1,6 @@
import { Editor, type JSONContent } from '@tiptap/core'; import { Editor } from '@tiptap/core';
import { isButtonElement, isHTMLElement } from '@coldwired/utils'; import { isButtonElement, isHTMLElement } from '@coldwired/utils';
import { z } from 'zod'; import * as s from 'superstruct';
import { ApplicationController } from '../application_controller'; import { ApplicationController } from '../application_controller';
import { getAction } from '../../shared/tiptap/actions'; import { getAction } from '../../shared/tiptap/actions';
@ -61,7 +61,7 @@ export class TiptapController extends ApplicationController {
insertTag(event: MouseEvent) { insertTag(event: MouseEvent) {
if (this.#editor && isHTMLElement(event.target)) { 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 const editor = this.#editor
.chain() .chain()
.focus() .focus()
@ -77,12 +77,12 @@ export class TiptapController extends ApplicationController {
private get content() { private get content() {
const value = this.inputTarget.value; const value = this.inputTarget.value;
if (value) { if (value) {
return jsonContentSchema.parse(JSON.parse(value)); return s.create(JSON.parse(value), jsonContentSchema);
} }
} }
private get tags(): TagSchema[] { 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() { private get menuButtons() {
@ -92,13 +92,24 @@ export class TiptapController extends ApplicationController {
} }
} }
const jsonContentSchema: z.ZodType<JSONContent> = z.object({ const Attrs = s.record(s.string(), s.any());
type: z.string().optional(), const Marks = s.array(
text: z.string().optional(), s.type({
attrs: z.record(z.any()).optional(), type: s.string(),
marks: z attrs: s.optional(Attrs)
.object({ type: z.string(), attrs: z.record(z.any()).optional() }) })
.array() );
.optional(), type JSONContent = {
content: z.lazy(() => z.array(jsonContentSchema).optional()) type?: string;
text?: string;
attrs?: s.Infer<typeof Attrs>;
marks?: s.Infer<typeof Marks>;
content?: JSONContent[];
};
const jsonContentSchema: s.Describe<JSONContent> = 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)))
}); });

View file

@ -1,5 +1,5 @@
import { Editor } from '@tiptap/core'; import { Editor } from '@tiptap/core';
import { z } from 'zod'; import * as s from 'superstruct';
type EditorAction = { type EditorAction = {
run(): void; run(): void;
@ -11,7 +11,7 @@ export function getAction(
editor: Editor, editor: Editor,
button: HTMLButtonElement button: HTMLButtonElement
): EditorAction { ): EditorAction {
return tiptapActionSchema.parse(button.dataset)(editor); return s.create(button.dataset, tiptapActionSchema)(editor);
} }
const EDITOR_ACTIONS: Record<string, (editor: Editor) => EditorAction> = { const EDITOR_ACTIONS: Record<string, (editor: Editor) => EditorAction> = {
@ -109,8 +109,15 @@ const EDITOR_ACTIONS: Record<string, (editor: Editor) => EditorAction> = {
}) })
}; };
const tiptapActionSchema = z const EditorActionFn = s.define<(editor: Editor) => EditorAction>(
.object({ 'EditorActionFn',
tiptapAction: z.enum(Object.keys(EDITOR_ACTIONS) as [string, ...string[]]) (fn) => typeof fn == 'function'
}) );
.transform(({ tiptapAction }) => EDITOR_ACTIONS[tiptapAction]);
const tiptapActionSchema = s.coerce(
EditorActionFn,
s.type({
tiptapAction: s.enums(Object.keys(EDITOR_ACTIONS) as [string, ...string[]])
}),
({ tiptapAction }) => EDITOR_ACTIONS[tiptapAction]
);

View file

@ -1,12 +1,17 @@
import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'; 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 tippy, { type Instance as TippyInstance } from 'tippy.js';
import { matchSorter } from 'match-sorter'; import { matchSorter } from 'match-sorter';
export const tagSchema = z export const tagSchema = s.coerce(
.object({ tagLabel: z.string(), tagId: z.string() }) s.object({ label: s.string(), id: s.string() }),
.transform(({ tagId, tagLabel }) => ({ label: tagLabel, id: tagId })); s.type({
export type TagSchema = z.infer<typeof tagSchema>; tagLabel: s.string(),
tagId: s.string()
}),
({ tagId, tagLabel }) => ({ label: tagLabel, id: tagId })
);
export type TagSchema = s.Infer<typeof tagSchema>;
class SuggestionMenu { class SuggestionMenu {
#selectedIndex = 0; #selectedIndex = 0;

View file

@ -1,72 +1,87 @@
import { session } from '@hotwired/turbo'; import { session } from '@hotwired/turbo';
import { z } from 'zod'; import * as s from 'superstruct';
const Gon = z function nullish<T, S>(struct: s.Struct<T, S>) {
.object({ return s.optional(s.union([s.literal(null), struct]));
autosave: z }
.object({
debounce_delay: z.number().default(0), const Gon = s.defaulted(
status_visible_duration: z.number().default(0) s.type({
}) autosave: s.defaulted(
.default({}), s.type({
autocomplete: z debounce_delay: s.defaulted(s.number(), 0),
.object({ status_visible_duration: s.defaulted(s.number(), 0)
api_geo_url: z.string().optional(), }),
api_adresse_url: z.string().optional(), {}
api_education_url: z.string().optional() ),
}) autocomplete: s.defaulted(
.default({}), s.partial(
locale: z.string().default('fr'), s.type({
matomo: z api_geo_url: s.string(),
.object({ api_adresse_url: s.string(),
cookieDomain: z.string().optional(), api_education_url: s.string()
domain: z.string().optional(), })
enabled: z.boolean().default(false), ),
host: z.string().optional(), {}
key: z.string().or(z.number()).nullish() ),
}) locale: s.defaulted(s.string(), 'fr'),
.default({}), matomo: s.defaulted(
sentry: z s.type({
.object({ cookieDomain: s.optional(s.string()),
key: z.string().nullish(), domain: s.optional(s.string()),
enabled: z.boolean().default(false), enabled: s.defaulted(s.boolean(), false),
environment: z.string().optional(), host: s.optional(s.string()),
user: z.object({ id: z.string() }).default({ id: '' }), key: nullish(s.union([s.string(), s.number()]))
browser: z.object({ modern: z.boolean() }).default({ modern: false }), }),
release: z.string().nullish() {}
}) ),
.default({}), sentry: s.defaulted(
crisp: z s.type({
.object({ key: nullish(s.string()),
key: z.string().nullish(), enabled: s.defaulted(s.boolean(), false),
enabled: z.boolean().default(false), environment: s.optional(s.string()),
administrateur: z user: s.defaulted(s.type({ id: s.string() }), { id: '' }),
.object({ browser: s.defaulted(s.type({ modern: s.boolean() }), {
email: z.string(), modern: false
DS_SIGN_IN_COUNT: z.number(), }),
DS_NB_DEMARCHES_BROUILLONS: z.number(), release: nullish(s.string())
DS_NB_DEMARCHES_ACTIVES: z.number(), }),
DS_NB_DEMARCHES_ARCHIVES: z.number(), {}
DS_ID: z.number() ),
}) crisp: s.defaulted(
.default({ 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: '', email: '',
DS_SIGN_IN_COUNT: 0, DS_SIGN_IN_COUNT: 0,
DS_NB_DEMARCHES_BROUILLONS: 0, DS_NB_DEMARCHES_BROUILLONS: 0,
DS_NB_DEMARCHES_ACTIVES: 0, DS_NB_DEMARCHES_ACTIVES: 0,
DS_NB_DEMARCHES_ARCHIVES: 0, DS_NB_DEMARCHES_ARCHIVES: 0,
DS_ID: 0 DS_ID: 0
}) }
}) )
.default({}), }),
defaultQuery: z.string().optional(), {}
defaultVariables: z.string().optional() ),
}) defaultQuery: s.optional(s.string()),
.default({}); defaultVariables: s.optional(s.string())
}),
{}
);
declare const window: Window & typeof globalThis & { gon: unknown }; declare const window: Window & typeof globalThis & { gon: unknown };
export function getConfig() { export function getConfig() {
return Gon.parse(window.gon); return s.create(window.gon, Gon);
} }
export function show(el: Element | null) { export function show(el: Element | null) {

BIN
bun.lockb

Binary file not shown.

View file

@ -62,12 +62,12 @@
"react-use-event-hook": "^0.9.6", "react-use-event-hook": "^0.9.6",
"spectaql": "^2.3.1", "spectaql": "^2.3.1",
"stimulus-use": "^0.52.2", "stimulus-use": "^0.52.2",
"superstruct": "^1.0.4",
"terser": "^5.31.0", "terser": "^5.31.0",
"tiny-invariant": "^1.3.3", "tiny-invariant": "^1.3.3",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"trix": "^1.2.3", "trix": "^1.2.3",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.0"
"zod": "^3.20.2"
}, },
"devDependencies": { "devDependencies": {
"@esbuild/darwin-arm64": "=0.19.9", "@esbuild/darwin-arm64": "=0.19.9",