Merge pull request #10585 from tchak/superstruct
refactor(js): use superstruct instead of zod
This commit is contained in:
commit
70cbcd0962
9 changed files with 201 additions and 152 deletions
|
@ -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();
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) =>
|
||||
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)
|
||||
}))
|
||||
)
|
||||
),
|
||||
formValue: z.enum(['text', 'key']),
|
||||
form: z.string(),
|
||||
data: z.record(z.string())
|
||||
);
|
||||
|
||||
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;
|
||||
|
|
|
@ -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)))
|
||||
});
|
||||
|
|
|
@ -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]
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
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()
|
||||
})
|
||||
.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({
|
||||
),
|
||||
{}
|
||||
),
|
||||
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
BIN
bun.lockb
Binary file not shown.
|
@ -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",
|
||||
|
|
Loading…
Reference in a new issue