sanity-io / sanity

Sanity Studio – Rapidly configure content workspaces powered by structured content
https://www.sanity.io
MIT License
5.15k stars 414 forks source link

Array field with both references and non-references causes a TypeError when trying to deploy the graphql #7130

Open kupe517 opened 2 months ago

kupe517 commented 2 months ago

If you find a security vulnerability, do NOT open an issue. Email security@sanity.io instead.

Describe the bug

When using an array which combines both references and an object there is an error that occurs which prevents deploying the graphql via sanity graphql deploy command. The error is: TypeError: Cannot read properties of undefined (reading 'name').

To Reproduce

Steps to reproduce the behavior:

  1. Create a schema that has is of type array.
  2. In the of array include an object which is of type reference. Create another object which is of a type that references an object.
  3. Try to deploy the schema using sanity graphql deploy
{
      name: 'featuredEntries',
      type: 'array',
      title: 'Featured Entries',
      of: [
        {
          type: 'reference',
          to: [{ type: 'program' }, { type: 'event' }, { type: 'exhibit' }, { type: 'page' }],
        },
        {
          type: 'customPromo',
        },
      ],
      validation: (Rule) => Rule.required().min(2).max(3),
    },

Expected behavior

The graphql should deploy with no issue. This setup is documented here.

Which versions of Sanity are you using?

@sanity/cli (global) 3.30.0 (latest: 3.49.0) @sanity/color-input 3.1.1 (up to date) @sanity/document-internationalization 3.0.0 (up to date) @sanity/eslint-config-studio 4.0.0 (up to date) @sanity/vision 3.49.0 (up to date) sanity 3.49.0 (up to date)

What operating system are you using? MacOS Sonoma 14.5

Which versions of Node.js / npm are you running?

10.2.3 v18.19.0

cngonzalez commented 1 month ago

Hi @kupe517 ! Thanks for reporting. I'm afraid I'm not able to reproduce, but I'm also using a pretty simple object in my array along with a reference to two possible document types. Would you mind passing along the definition of customPromo -- maybe there are some clues there that will help us reproduce this error.

kupe517 commented 1 month ago

@cngonzalez Here is customPromo along with the other objects that appear in it:

customPromo

export default {
  name: 'customPromo',
  title: 'Custom Promo',
  type: 'object',
  fields: [
    {
      name: 'image',
      title: 'Image',
      type: 'imageWithAltAndMask',
      validation: (Rule) => Rule.required(),
    },
    {
      name: 'title',
      title: 'Title',
      type: 'string',
      validation: (Rule) => Rule.required(),
    },
    {
      name: 'subtitle',
      title: 'Subtitle',
      type: 'string',
    },
    {
      name: 'price',
      title: 'Price',
      type: 'number',
    },
    {
      name: 'duration',
      title: 'Duration',
      type: 'string',
      options: {
        list: [
          { title: 'Monthly', value: 'month' },
          { title: 'Yearly', value: 'year' },
          { title: 'Weekly', value: 'week' },
          { title: 'Daily', value: 'day' },
        ],
      },
      validation: (Rule) =>
        Rule.custom((value, { parent }) => {
          if (!value && parent?.price) {
            return 'Required';
          }
          return true;
        }),
    },
    {
      name: 'blurb',
      title: 'Blurb',
      type: 'basicBlock',
    },
    {
      name: 'footer',
      title: 'Footer',
      type: 'basicBlock',
    },
    {
      name: 'includeCta',
      title: 'Include CTA?',
      type: 'boolean',
    },
    {
      name: 'cta',
      title: 'CTA',
      type: 'link',
      hidden: ({ parent }) => !parent?.includeCta,
      validation: (Rule) =>
        Rule.custom((value, { parent }) => {
          if (parent?.includeCta && !value) {
            return 'CTA information is required.';
          }
          if (value && !value.label) {
            return 'CTA label is required.';
          }
          if (value && !value.internalLink && !value.externalLink) {
            return 'Either an internal or external link is required.';
          }
          return true;
        }),
    },
  ],
  preview: {
    select: {
      title: 'title',
      media: 'image.image',
    },
    prepare({ title, media }) {
      return {
        title: title || '',
        subtitle: 'Custom Promo',
        media: media,
      };
    },
  },
};

imageWithAltAndMask

export default {
  name: 'imageWithAltAndMask',
  title: 'Image With Alt and Mask',
  type: 'object',
  fields: [
    {
      name: 'image',
      title: 'Image',
      type: 'image',
      options: {
        hotspot: true,
      },
      validation: (Rule) =>
        Rule.custom((value, { parent }) => {
          if (value && value.asset && value.asset._ref) {
            return true;
          } else {
            return 'Please select an image';
          }
        }),
    },
    {
      name: 'alt',
      type: 'string',
      title: 'Alt Text',
      validation: (Rule) =>
        Rule.custom((value, { parent }) => {
          if (parent && parent.alt) {
            return true;
          } else {
            return 'Please provide alt text';
          }
        }),
      options: {
        isHighlighted: true,
      },
    },
    {
      name: 'mask',
      title: 'Image Mask',
      type: 'string',
      options: {
        list: [
          { title: 'Square', value: 'square' },
          { title: 'Circle', value: 'circle' },
          { title: 'Blob', value: 'blob' },
          { title: 'Shield', value: 'shield' },
          { title: 'No Mask', value: 'no-mask' },
        ],
        layout: 'dropdown',
      },
      validation: (Rule) =>
        Rule.custom((value, { parent }) => {
          if (parent && parent.mask) {
            return true;
          } else {
            return 'Please select an image mask';
          }
        }),
    },
  ],
};

basicBlock

import { LinkIcon, DocumentIcon } from '@sanity/icons';

export default {
  title: 'Basic Block',
  name: 'basicBlock',
  type: 'array',
  of: [
    {
      title: 'Block',
      type: 'block',
      styles: [{ title: 'Normal', value: 'normal' }],
      lists: [
        { title: 'Bullet', value: 'bullet' },
        { title: 'Numbered', value: 'number' },
      ],
      marks: {
        decorators: [
          { title: 'Strong', value: 'strong' },
          { title: 'Emphasis', value: 'em' },
        ],
        annotations: [
          {
            name: 'internalLink',
            icon: DocumentIcon,
            type: 'object',
            title: 'Internal Link',
            fields: [
              {
                name: 'reference',
                type: 'reference',
                title: 'Reference',
                to: [{ type: 'page' }],
                validation: (Rule) => Rule.required(),
              },
            ],
          },
          {
            name: 'externalLink',
            type: 'object',
            title: 'External Link',
            icon: LinkIcon,
            fields: [
              {
                name: 'url',
                type: 'url',
                title: 'URL',
                validation: (Rule) => Rule.required(),
              },
            ],
          },
        ],
      },
    },
  ],
};
runeb commented 1 month ago

Thank you for the follow up @kupe517

I tried to reproduce this on version 3.52.2 with the schemas you sent us, but I could not. The problem does not seem to be a mix of a reference and an object.

I had to comment out a few types which were omitted, so perhaps the issue is in those types. I had do modify your schema to avoid missing types event, exhibit, page, link

{
      name: 'featuredEntries',
      type: 'array',
      title: 'Featured Entries',
      of: [
        {
          type: 'reference',
          to: [{ type: 'program' },
            //{ type: 'event' }, { type: 'exhibit' }, { type: 'page' }
          ],
        },
        {
          type: 'customPromo',
        },
      ],
      validation: (Rule) => Rule.required().min(2).max(3),
    },

Full schema definitions I tested with below

import { defineType } from "sanity"

const customPromo = defineType({
  name: 'customPromo',
  title: 'Custom Promo',
  type: 'object',
  fields: [
    {
      name: 'image',
      title: 'Image',
      type: 'imageWithAltAndMask',
      validation: (Rule) => Rule.required(),
    },
    {
      name: 'title',
      title: 'Title',
      type: 'string',
      validation: (Rule) => Rule.required(),
    },
    {
      name: 'subtitle',
      title: 'Subtitle',
      type: 'string',
    },
    {
      name: 'price',
      title: 'Price',
      type: 'number',
    },
    {
      name: 'duration',
      title: 'Duration',
      type: 'string',
      options: {
        list: [
          { title: 'Monthly', value: 'month' },
          { title: 'Yearly', value: 'year' },
          { title: 'Weekly', value: 'week' },
          { title: 'Daily', value: 'day' },
        ],
      },
      validation: (Rule) =>
        Rule.custom((value, { parent }) => {
          if (!value && parent?.price) {
            return 'Required';
          }
          return true;
        }),
    },
    {
      name: 'blurb',
      title: 'Blurb',
      type: 'basicBlock',
    },
    {
      name: 'footer',
      title: 'Footer',
      type: 'basicBlock',
    },
    {
      name: 'includeCta',
      title: 'Include CTA?',
      type: 'boolean',
    },
    //{
    //  name: 'cta',
    //  title: 'CTA',
    //  type: 'link',
    //  hidden: ({ parent }) => !parent?.includeCta,
    //  validation: (Rule) =>
    //    Rule.custom((value, { parent }) => {
    //      if (parent?.includeCta && !value) {
    //        return 'CTA information is required.';
    //      }
    //      if (value && !value.label) {
    //        return 'CTA label is required.';
    //      }
    //      if (value && !value.internalLink && !value.externalLink) {
    //        return 'Either an internal or external link is required.';
    //      }
    //      return true;
    //    }),
    //},
  ],
  preview: {
    select: {
      title: 'title',
      media: 'image.image',
    },
    prepare({ title, media }) {
      return {
        title: title || '',
        subtitle: 'Custom Promo',
        media: media,
      };
    },
  },
})

const program = defineType({
  name: "program",
  type: "document",
  fields: [
    {
      type: "string",
      name: "title"
    }
  ]
})

const testDoc = defineType({
  name: "testDoc",
  type: "document",
  fields: [
    {
      name: 'featuredEntries',
      type: 'array',
      title: 'Featured Entries',
      of: [
        {
          type: 'reference',
          to: [{ type: 'program' },
            //{ type: 'event' }, { type: 'exhibit' }, { type: 'page' }
          ],
        },
        {
          type: 'customPromo',
        },
      ],
      validation: (Rule) => Rule.required().min(2).max(3),
    },

  ]
})

import { LinkIcon, DocumentIcon } from '@sanity/icons';
const basicBlock = defineType({
  title: 'Basic Block',
  name: 'basicBlock',
  type: 'array',
  of: [
    {
      title: 'Block',
      type: 'block',
      styles: [{ title: 'Normal', value: 'normal' }],
      lists: [
        { title: 'Bullet', value: 'bullet' },
        { title: 'Numbered', value: 'number' },
      ],
      marks: {
        decorators: [
          { title: 'Strong', value: 'strong' },
          { title: 'Emphasis', value: 'em' },
        ],
        annotations: [
          {
            name: 'internalLink',
            icon: DocumentIcon,
            type: 'object',
            title: 'Internal Link',
            fields: [
              {
                name: 'reference',
                type: 'reference',
                title: 'Reference',
                //to: [{ type: 'page' }],
                to: [{ type: 'program' }],
                validation: (Rule) => Rule.required(),
              },
            ],
          },
          {
            name: 'externalLink',
            type: 'object',
            title: 'External Link',
            icon: LinkIcon,
            fields: [
              {
                name: 'url',
                type: 'url',
                title: 'URL',
                validation: (Rule) => Rule.required(),
              },
            ],
          },
        ],
      },
    },
  ],
})

const imageWithAltAndMask = defineType({
  name: 'imageWithAltAndMask',
  title: 'Image With Alt and Mask',
  type: 'object',
  fields: [
    {
      name: 'image',
      title: 'Image',
      type: 'image',
      options: {
        hotspot: true,
      },
      validation: (Rule) =>
        Rule.custom((value, { parent }) => {
          if (value && value.asset && value.asset._ref) {
            return true;
          } else {
            return 'Please select an image';
          }
        }),
    },
    {
      name: 'alt',
      type: 'string',
      title: 'Alt Text',
      validation: (Rule) =>
        Rule.custom((value, { parent }) => {
          if (parent && parent.alt) {
            return true;
          } else {
            return 'Please provide alt text';
          }
        }),
      options: {
        isHighlighted: true,
      },
    },
    {
      name: 'mask',
      title: 'Image Mask',
      type: 'string',
      options: {
        list: [
          { title: 'Square', value: 'square' },
          { title: 'Circle', value: 'circle' },
          { title: 'Blob', value: 'blob' },
          { title: 'Shield', value: 'shield' },
          { title: 'No Mask', value: 'no-mask' },
        ],
        layout: 'dropdown',
      },
      validation: (Rule) =>
        Rule.custom((value, { parent }) => {
          if (parent && parent.mask) {
            return true;
          } else {
            return 'Please select an image mask';
          }
        }),
    },
  ],
})

export const schemaTypes = [program, testDoc, customPromo, basicBlock, imageWithAltAndMask]
kupe517 commented 1 month ago

@runeb That was an interesting discovery you found. In further debugging I discovered that the graphQL will build if there is only a single item in the to field for references, like the example you tested. As soon as I add an additional item into the array I get the same error.

To help you further test this, here is a simple person schema that you can add and then include in the to array for testing:

person.js

import { IoPersonAddOutline as icon } from 'react-icons/io5';

export default {
  name: 'person',
  title: 'Person',
  type: 'document',
  icon,
  fields: [
    {
      name: 'firstName',
      title: 'First Name',
      type: 'string',
      validation: (Rule) => Rule.required(),
    },
    {
      name: 'lastName',
      title: 'Last Name',
      type: 'string',
      validation: (Rule) => Rule.required(),
    },
  ],
  preview: {
    select: {
      firstName: 'firstName',
      lastName: 'lastName',
    },
    prepare({ firstName, lastName }) {
      return {
        title: firstName,
        subtitle: lastName,
      };
    },
  },
};

featuredEntries object

{
      name: 'featuredEntries',
      type: 'array',
      title: 'Featured Entries',
      of: [
        {
          type: 'reference',
          to: [{ type: 'program' }, { type: 'person' }],
        },
        {
          type: 'customPromo',
        },
      ],
    },