UNPKG

sanity-advanced-validators

Version:
567 lines (463 loc) • 15.3 kB
_🚓 Never trust a user! 👮_ # Sanity Advanced Validators This package includes a set of Sanity validators for aggressive and weird edge cases. _Maintain sanity with micro-managed validation._ ## Tools - [fileExtension](#fileExtension) - [minDimensions](#minDimensions) - [maxDimensions](#maxDimensions) - [requiredIfSiblingEq](#requiredIfSiblingEq) - [requiredIfSiblingNeq](#requiredIfSiblingNeq) - [requiredIfSlugEq](#requiredIfSlugEq) - [requiredIfSlugNeq](#requiredIfSlugNeq) - [regex](#regex) 🆕 - [referencedDocumentRequires](#referencedDocumentRequires) - [maxDepth](#maxDepth) ## Mega-example Imagine that you’ve got a document that has an optional video file, but… - it’s required on the `/about` page - if the video exists, it must be either **MP4** or **MOV** - and there must be a poster image that's between **1250x800** and **2500x1600** pixels in size ```typescript const Page = defineType({ name: "page", type: "document", fields: [ defineField({ name: 'slug', type: 'slug' }), defineField({ name: "someVideoFile", type: "file", validation: (rule) => rule.custom(requiredIfSlugEq('about', 'A video is required if {slugKey} is {operand}.')) .custom(fileExtension(['mp4', 'mov'])) }) defineField({ name: "posterImage", type: "image", hidden: ({ parent }) => parent.someVideoFile === null, validation: (rule) => rule.custom(requiredIfSiblingNeq('someVideoFile', null)) .custom(minDimensions({ x: 1250, y: 800 })) .custom(maxDimensions({ x: 2500, y: 1600 })), }) ] }) ``` ## Examples ### fileExtension Enforces that an uploaded file asset is of a certain format. ```typescript fileType: string | Array<string>, message?: string // optional custom error message; replaces {validFileExtension} with fileType (flattened) ``` ```typescript import { fileExtension } from "sanity-advanced-validation" const Page = defineType({ name: "page", type: "document", fields: [ defineField({ name: "catalog", type: "file", validation: (rule) => rule.custom(fileExtension("pdf")), }), defineField({ name: "video", type: "file", validation: (rule) => rule.custom(fileExtension(["mp4", "mov", "webm"])), }), ], }) ``` --- ### minDimensions Enforces that an uploaded image asset is at minimum certain dimensions. You can test on both, just x, or just y. ```typescript dimensions: {x?: number, y?: number}, message?: string // optional custom error message; replaces {x} and {y} with your dimension requirements, and {width} and {height} with submitted image dimensions ``` ```typescript import { minDimensions } from "sanity-advanced-validation" const ImageWithCaption = defineType({ name: "article", type: "object", fields: [ // … defineField({ name: "heroImage", type: "image", validation: (rule) => rule.custom(minDimensions({ x: 1200, y: 800 })), }), ], }) ``` --- ### maxDimensions Enforces that an uploaded image asset is at most certain dimensions. You can test on both, just x, or just y. ```typescript dimensions: {x?: number, y?: number}, message?: string // optional custom error message; replaces {x} and {y} with your dimension requirements, and {width} and {height} with submitted image dimensions ``` ```typescript defineField({ name: "heroImage", type: "image", validation: (rule) => rule.custom(maxDimensions({ x: 2400, y: 1600 })), }), ``` Chain for min and max dimensions: ```typescript defineField({ name: "heroImage", type: "image", description: "Min: 1200x800, max: 2400x1600.", validation: (rule) => rule .required() .custom(minDimensions({ x: 1200, y: 800 })) .custom(maxDimensions({ x: 2400, y: 1600 })), }) ``` --- ### requiredIfSiblingEq Mark a field as `required` if a sibling field has a particular value. This is the validator we use most. _It’s super effective!_ This is handy if you have a field that is hidden under some circumstances, but is `required()` when it’s visible. _note:_ This does not work for slugs, because they have to match a nested `.current` value. Use the [requiredIfSlugEq validator](#requiredIfSlugEq) instead. ```typescript key: string, // name of sibling operand: string | number | null | Array<string, number, null> // value that you’re testing for (i.e. if 'name' === operand) message?: string // optional custom error message; replaces {key} and {operand} with your input, and {siblingValue} with the value of the sibling you’re testing against. ``` ```typescript import {requiredIfSiblingEq} from 'sanity-advanced-validation' defineType({ name: 'person', type: 'object', fields: [ defineField({ name: 'name', type: 'string' }), defineField({ name: 'occupation', type: 'string', options: { list: ['doctor', 'lawyer', 'software engineer'] } }) defineField({ name: 'favoriteLanguage', type: 'string', options: { list: [ 'javascript', 'rust', 'python', 'swift' ] }, validation: rule => rule.custom(requiredIfSiblingEq('occupation', 'software engineer')), hidden: ({parent}) => parent.occuption !== 'software engineer', }), ], }) ``` “If not that, then this.” This also works for null. ```typescript defineType({ name: 'person', type: 'object', fields: [ defineField({ name: 'name', type: 'string' }), defineField({ name: 'email', type: 'string', }) defineField({ name: 'phone', type: 'string', validation: rule => rule.custom(requiredIfSiblingEq( 'email', null, "If you don’t have an email address, a phone number is required." )) }) ], }) ``` And it even works for arrays. ```typescript defineType({ name: "person", type: "object", fields: [ defineField({ name: "name", type: "string", }), defineField({ name: "name", type: "string", }), defineField({ name: "occupation", type: "string", options: { list: ["doctor", "lawyer", "software engineer"], }, }), defineField({ name: "explanation", description: "Why are you wasting your life this way?", type: "text", validation: (rule) => rule.custom(requiredIfSiblingEq("occupation", ["doctor", "lawyer"])), hidden: ({ parent }) => parent.occuption === "software engineer", }), ], }) ``` --- ### requiredIfSiblingNeq For a given object that has multiple fields, mark a field as `required` if a sibling does _not_ have a particular value. _note:_ This does not work for slugs, because they have to match a nested `.current` value. Use the [requiredIfSlugNeq validator](#requiredIfSlugNeq) instead. ```typescript key: string, // name of sibling operand: string | number | null | Array<string, number, null> // value that you’re testing for (i.e. if 'name' === operand) message?: string // optional custom error message; replaces {key} and {operand} with your input, and {siblingValue} with the value of the sibling you’re testing against. ``` ```typescript import {requiredIfSiblingNeq} from 'sanity-advanced-validation' defineType({ name: 'person', type: 'object', fields: [ defineField({ name: 'name', type: 'string' }), defineField({ name: 'occupation', type: 'string', options: { list: ['doctor', 'lawyer', 'software engineer'] } }) defineField({ name: 'why', description: 'How many years will you spend paying off your degree?', type: 'number', validation: rule => rule.custom(requiredIfSiblingNeq('occupation', 'software engineer')) }), ], }) ``` --- ### requiredIfSlugEq Mark a field as `required` for documents with matching slugs. ```typescript operand: string | number | null | Array<string, number, null> // possible slug or slugs you’re testing key?: string, // name of sibling if not "slug" message?: string // optional custom error message; replaces {slugKey} and {operand} with your input, and {siblingSlugValue} with the value of the sibling you’re testing against. ``` ```typescript import { requiredIfSlugEq } from "sanity-advanced-validation" defineType({ name: "page", type: "document", fields: [ defineField({ name: "slug", type: "slug", }), defineField({ name: "questionsAndAnswers", type: "array", of: [{ type: "qaItem" }], validation: (rule) => rule.custom(requiredIfSlugEq("faq")), hidden: ({ parent }) => parent.slug.current !== "faq", }), ], }) ``` And this can apply to multiple slugs… ```typescript defineField({ name: "questionsAndAnswers", validation: (rule) => rule.custom(requiredIfSlugEq(["faq", "about"])), }), ``` --- ### requiredIfSlugNeq Require fields on pages that don't match one or more slugs. ```typescript operand: string | number | null | Array<string, number, null> // possible slug or slugs you’re testing key?: string, // name of sibling if not "slug" message?: string // optional custom error message; replaces {slugKey} and {operand} with your input, and {siblingSlugValue} with the value of the sibling you’re testing against. ``` ```typescript import { requiredIfSlugNeq } from "sanity-advanced-validation" defineType({ name: "page", type: "document", fields: [ defineField({ name: "slug", type: "slug", }), defineField({ name: "subnav", description: `Subnav is required on documents that aren’t '/home'`, type: "array", of: [{ type: "navLink" }], validation: (rule) => rule.custom(requiredIfSlugNeq("home")), hidden: ({ parent }) => parent.slug.current !== "home", }), ], }) ``` --- ### regex Easily test any value against a regular expression. Values can be of type string, number, boolean… even objects! ```typescript pattern: RegExp // regular expression message?: string // optional custom error message; replaces {pattern} with your input and {value} as submitted field value ``` ```typescript defineField({ name: 'email', type: 'string', validation: (rule) => rule.custom( regex( /^([a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})*$/, "“{value}” is not a valid email address." ) ), }), ``` **Custom error messages are highly recommended here.** Without the custom message above, the default response would be: ``` “me@googlecom” does not match the pattern /^([a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6})*$/. ``` --- ### referencedDocumentRequires You might want to enforce some validation on a referenced document. This validator enforces that a given value is not null in the referenced document. ```typescript documentType: string // type of document you’re referring to field: string, // name of document field that is required message?: string // optional custom error message; replaces {documentType} and {field} with your input ``` ```typescript defineField({ name: 'referredArticle', description: 'An article (must include a valid poster image)', type: 'reference', to: [{type: 'article'}], validation: (rule) => rule.custom(referencedDocumentRequires('article', 'poster')), }), ``` --- ### maxDepth It can be useful to have a nested type. This often comes up when making some kind of navigation tree, like… ``` - Home - About - Articles - First Article - Second Article - Articles about Trees - Article about Elm Trees - Article about Willow Trees ``` Sanity can handle this without breaking a sweat: ```typescript const navigation = defineType({ name: "navigation", type: "document", fields: [ defineField({ name: "links", type: "array", of: [{ type: navLink }], }), ], }) const navLink = defineType({ name: "navLink", type: "object", fields: [ defineField({ name: "link", type: "url", }), defineField({ name: "label", type: "string", }), defineField({ name: "subnav", type: "array", of: [{ type: navigation }], // < circular reference }), ], }) ``` … but your users might get a little stupid with this, and you may want to enforce navigations only going _n_ layers deep. ```typescript maxDepth: number // maximum "depth" of embedding (including parent) key: string, // name of the field that includes the cursive value (i.e. the field’s own name) message?: string // optional custom error message; replaces {maxDepth} and {key} with your input ``` ```typescript import { maxDepth } from "sanity-advanced-validation" const navLink = defineType({ // … fields: [ // … defineField({ name: "subnav", type: "array", of: [{ type: navigation }], validation: (rule) => rule.custom(maxDepth(3, "subnav")), }), ], }) ``` This will enforce that a subnav list can embed in a subnav, which can also be embedded in a subnav — but no further. --- #### Note to any Sanity dev who looks at this I’d love to include similar logic on my `hidden:` attribute, but I don’t think that’t possible without a `path` array in `hidden`’s `ConditionalPropertyCallbackContext` that’s similar to the one fed to the `ValidationContext` (todo: type this correctly). Wouldn’t this be cool? ```typescript defineField({ name: "subnav", type: "array", of: [{ type: navigation }], hidden: ({ path }) => { let regex = new RegExp(String.raw`topLevelItems|subNav`) const paths = context.path.filter((e) => e.match(/topLevelItems|subnav/)) return paths.length > 3 }, }) ``` --- ## Extending these and writing your own Most of these validators rely on a function called `getSibling()`. If you’re thinking about picking this apart and writing your own custom validator, take a close look at how these validators use it. ## Upcoming ### Nested pathfinders Since building these validator, I took to putting my slugs in a metadata object. I need to update `requiredIfSlugEq` to accept a path, like `requiredIfSlugEq('metadata.slug', 'some-values')`. This pathfinding should be added to any validator that takes a sibling, like `requiredIfSiblingEq`. It can probably be snapped into `getSibling`. While I’m at it, there’s a possibility that `getSibling` could detect the target type. If that type is `slug`, then it could add `current` to the path, and then I can deprecate `requiredIfSlugEq` altogether. ### Image and File checks `minDimensions`, `maxDimensions`, and `fileExtension` should check to see if the field is of type `image` or `file`. Some of the other checks should probably make sure the field is _not_ `image` or `file`. ### new referencedDocumentFieldEq validator ``` // only articles by Jimmy Olsen rule => rule.custom(referencedDocumentFieldEq('article', 'author', 'Jimmy Olsen')) // only articles whose authors are not null. replaces `referencedDocumentRequires`. rule => rule.custom(referencedDocumentFieldNeq('article', 'author', null)) ``` ## MOAR Do you have any ideas or edge cases that these validators don’t cover? Leave an issue, maybe I can hack it out.