UNPKG

sanity-advanced-validators

Version:
690 lines (569 loc) 17.4 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) - [minCount](#minCount) - [maxCount](#maxCount) - [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 import { requiredIfSlugEq, requiredIfSiblingNeq, minDimensions, maxDimensions } from 'sanity-advanced-validators' 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-validators" 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-validators" 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 }) ), }) ``` --- ### minCount Enforces that an array contains at least _n_ items. Note that null values are fine; use `rule.required()` to enforce non-nulls. ```typescript n: number, message?: string // optional custom error message; replaces {n} with your minimum count ``` ```typescript defineField({ name: "thumbnails", type: "array", of: [ {type: 'image'} ], validation: (rule) => rule.required() .custom( minCount(3, "At least {n} thumbnails are required.") ), }), ``` --- ### maxCount Enforces that an array contains at most _n_ items. ```typescript n: number, message?: string // optional custom error message; replaces {n} with your maximum count ``` ```typescript defineField({ name: "thumbnails", type: "array", of: [ {type: 'image'} ], validation: (rule) => rule.custom( maxCount(3, "No more than {n} thumbnails.") ), }), ``` And of course it can be chained. ```typescript defineField({ name: "thumbnails", type: "array", of: [ {type: 'image'} ], validation: (rule) => rule.required() .custom( minCount(1, "At least one thumbnail is required.") ), .custom( maxCount(3, "1-3 thumbnails are required.") ), }), ``` --- ### 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. 🆕 Previously, this would not work for objects that were members of an array. That is fixed now. _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 | boolean | null | Array<string, number> // 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-validators' 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: [ 'typescript', 'rust', 'python', 'swift' ] }, validation: rule => rule.custom( requiredIfSiblingEq('occupation', 'software engineer') ), hidden: ({parent}) => parent.occuption !== 'software engineer', }), ], }) ``` And it also works for arrays. ```typescript defineType({ name: "person", type: "object", fields: [ // ... defineField({ name: "occupation", type: "string", options: { list: ["doctor", "lawyer", "software engineer", "linguist"], }, }), defineField({ name: "favoriteLanguage", type: "string", options: { list: ["typescript", "rust", "python", "swift", "latin", "urdu", "klingon"], }, validation: (rule) => rule.custom( requiredIfSiblingEq("occupation", ["software engineer", "linguist"]) ), hidden: ({ parent }) => !["software engineer", "linguist"].includes(parent.occupation), }), ], }) ``` It even 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." ) ) }) ], }) ``` --- ### requiredIfSiblingNeq For a given object that has multiple fields, mark a field as `required` if a sibling does _not_ have a particular value (or member of an array of values). 🆕 Previously, this would not work for objects that were members of an array. That is fixed now. _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 | boolean | null | Array<string, number> // 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-validators' defineType({ name: 'person', type: 'object', fields: [ 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( requiredIfSiblingNeq("occupation", "software engineer") ), hidden: ({ parent }) => parent.occuption === "software engineer", }), ], }) ``` --- ### requiredIfSlugEq Mark a field as `required` for documents with matching slugs. ```typescript operand: string | number | null | Array<string, number> // 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-validators" 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> // 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-validators" 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 recursive 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-validators" 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. ## Roadmap ### 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.