sanity-typed
sanity-typed copied to clipboard
Generic function schema "builders" and conditionally required
Hi!
In our Sanity schemas we often create custom functions instead of reusable fields to give more customisation and control over the schema building. An example of this is a re-usable externalLink function that takes the required as a prop, see the example here:
// Define a generic FieldDef type that captures additional properties via TOtherProps
export type FieldDef<
T,
TName extends string,
TRequired extends boolean = boolean,
TOtherProps = object,
> = Omit<T, "type"> & {
name: TName
required: TRequired
group?: string
} & TOtherProps
export function externalLinkField<
TName extends string,
TRequired extends boolean,
TOtherProps = object,
>(props: FieldDef<UrlDefinition<TRequired>, TName, TRequired, TOtherProps>) {
if (props.required === true) {
return defineField({
...props,
type: "url",
options: {
...props.options,
required: props.required,
},
validation: (Rule) => {
const rules = [
Rule.uri({
scheme: ["https", "http", "mailto", "tel"],
}).error(
'Invalid URL. The URL must start with "https://", "http://", "mailto:" or "tel:',
),
Rule.required().error("This field is required."),
]
return rules
},
})
}
return defineField({
...props,
type: "url",
options: {
...props.options,
required: props.required,
},
validation: (Rule) => {
const rules = [
Rule.uri({
scheme: ["https", "http", "mailto", "tel"],
}).error(
'Invalid URL. The URL must start with "https://", "http://", "mailto:" or "tel:',
),
]
return rules
},
})
}
This works fine but is a bit verbose. It does not work with a conditional validation on the rule.
A reason why we want to do this is to be more aware about validation when setting up schemas, as well as render a simple custom input component when fields are required (by checking schemaType.options.required).
My first question is then – is there any better way to do this pattern?
We also use these kinds of functions for fields with different options. A good example is this "figure" schema:
const altText = defineField({
name: "alt",
title: "Alt text",
type: "string",
description:
"Describe the content of the image. Important for SEO and accessiblity.",
hidden: ({
parent,
}: {
parent: {
decorative: boolean
}
}) => {
return parent?.decorative
},
validation: (Rule) =>
Rule.custom((field, context) => {
const parent = context.parent as ImageFieldsType
if (!parent) return true
if (parent?.decorative || !parent?.asset || (field && field.length > 0)) {
return true
}
return "Alt text is required"
}),
})
export const figureField = <
TName extends string,
TRequired extends boolean,
TOtherProps extends {
showAlt?: boolean
showDecorative?: boolean
showCaption?: boolean
},
>(
props: FieldDef<
ImageDefinition<
true,
{
name: TName
},
TRequired
>,
TName,
TRequired,
TOtherProps
>,
) => {
const {
name,
title,
group,
hidden,
showAlt = true,
showCaption = true,
showDecorative = true,
} = props
//const excludeAllFields = excludeCaption && excludeAlt
return defineField({
name,
title: title,
type: "image",
group,
hidden,
options: {
hotspot: true,
},
fields: [
...(showAlt ? [altText] : []),
...(showDecorative
? [
defineField({
name: "decorative",
title: "Is the image purely decorative?",
type: "boolean",
initialValue: false,
}),
]
: []),
...(showCaption
? [
defineField({
name: "caption",
title: "Caption",
type: "string",
}),
]
: []),
],
validation: (Rule) => {
return props.required ? Rule.required().assetRequired() : Rule.optional()
},
preview: {
select: {
imageUrl: "asset.url",
title: "caption",
},
prepare({ title, imageUrl }) {
return {
title: title ?? " ",
imageUrl,
}
},
},
})
}
Since neither of these add on fields are always required, they are defined as possibly undefined in the schema which is fine, but if we add a required to i.e. the caption our type expects it to always be a string regardless.
figureField({
name: "figure",
title: "Figure",
showCaption: false,
showAlt: false,
required: false,
}),
// Inferred type:
figure: {
_type: "image";
asset: {
_ref: string;
_type: "reference";
[referenced]: "sanity.imageAsset";
};
alt?: string | undefined;
decorative?: boolean | undefined;
caption: string;
crop: {
_type?: "sanity.imageCrop" | undefined;
left: number;
bottom: number;
right: number;
top: number;
};
hotspot: {
_type?: "sanity.imageHotspot" | undefined;
width: number;
height: number;
x: number;
y: number;
};
};
Is there any way to make this work? I guess I can solve this case by making the required conditional based on the showCaption
Also for some reason the "hack" with duplicate defineField does not work for this figureField.
Hopefully this is not out of bounds of this package, because it is a very powerful tool to create schemas. Any pointers or tips would be greatly appreciated!