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 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();

View file

@ -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 {

View file

@ -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<typeof Item>;
export type Item = s.Infer<typeof Item>;
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<Item>((label) => ({ label, value: label }))
)
)
.or(
z
.tuple([z.string(), z.string().or(z.number())])
.array()
.transform((items) =>
items.map<Item>(([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<Item>(([label, value]) => ({
label,
value: String(value)
}))
);
const ArrayOfStrings = s.coerce(s.array(Item), s.array(s.string()), (items) =>
items.map<Item>((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<typeof SingleComboBoxProps> & {
);
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<typeof SingleComboBoxProps> & {
children?: ReactNode;
};
export type MultiComboBoxProps = z.infer<typeof MultiComboBoxProps>;
export type RemoteComboBoxProps = z.infer<typeof RemoteComboBoxProps> & {
export type MultiComboBoxProps = s.Infer<typeof MultiComboBoxProps>;
export type RemoteComboBoxProps = s.Infer<typeof RemoteComboBoxProps> & {
children?: ReactNode;
loader: Loader | string;
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 { 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<JSONContent> = 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<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 { 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<string, (editor: Editor) => EditorAction> = {
@ -109,8 +109,15 @@ const EDITOR_ACTIONS: Record<string, (editor: Editor) => 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]
);

View file

@ -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<typeof tagSchema>;
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<typeof tagSchema>;
class SuggestionMenu {
#selectedIndex = 0;

View file

@ -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<T, S>(struct: s.Struct<T, S>) {
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) {

BIN
bun.lockb

Binary file not shown.

View file

@ -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",