Skip to content

EricWVGG/sanity-advanced-validators

Repository files navigation

🚓 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

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
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.

fileType: string | Array<string>,
message?: string // optional custom error message; replaces {validFileExtension} with fileType (flattened)
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.

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
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.

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
defineField({
  name: "heroImage",
  type: "image",
  validation: (rule) => 
    rule.custom(
      maxDimensions({ x: 2400, y: 1600 })
    ),
}),

Chain for min and max dimensions:

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.

n: number,
message?: string // optional custom error message; replaces {n} with your minimum count
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.

n: number,
message?: string // optional custom error message; replaces {n} with your maximum count
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.

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.

note: This does not work for slugs, because they have to match a nested .current value. Use the requiredIfSlugEq validator instead.

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.
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.

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.

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).

note: This does not work for slugs, because they have to match a nested .current value. Use the requiredIfSlugNeq validator instead.

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.
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.

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.
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…

defineField({
  name: "questionsAndAnswers",
  validation: (rule) => 
    rule.custom(
      requiredIfSlugEq(["faq", "about"])
    ),
}),

requiredIfSlugNeq

Require fields on pages that don't match one or more slugs.

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.
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!

pattern: RegExp // regular expression
message?: string // optional custom error message; replaces {pattern} with your input and {value} as submitted field value
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.

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
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:

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.

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
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?

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.

Releases

No releases published

Packages

No packages published