👋 Next.js Conf 2024: Come build, party, run, and connect with us! See all events

Variant Generator Custom Input Component for Products with Options

By Alex Nelson

Variant Generator for Products with Options

GenerateVariants.tsx

import { Stack, Button } from '@sanity/ui'
import { ResetIcon, SparklesIcon } from '@sanity/icons'
import { randomKey } from '@sanity/util/content'
import { useCallback } from 'react'
import { ArrayOfObjectsInputProps, set, insert, setIfMissing, useFormValue } from 'sanity'

type Variant = {
  _key: string, 
  _type: string, 
  name: string, 
  values: string[]
}

type VariantOptions = { 
  name: string, value: string 
}

export function GenerateVariants(props: ArrayOfObjectsInputProps) {
  const { onChange } = props

  // These values are used when creating a unique variant key
  const documentId = useFormValue(["_id"]) as string;
  const publishedDocumentId = documentId.includes('draft.') ? documentId.replace('draft.', '') : documentId;
  
  const variantOptions = useFormValue(['options']) as Variant[]; 

  // Helper function for generating all combinations of option values
  const cartesianProduct = (arr: {name: string, value: string }[][]): {name: string, value: string}[][] => {
    return arr.reduce<{name: string, value: string}[][]>((a, b) => {
      return a.map(x => b.map(y => x.concat([y]))).reduce((c, d) => c.concat(d), []);
    }, [[]]);
  };
  
  // Create a variant name for each combination of option values
  const generateVariantName = (variantOptions: VariantOptions[]) => {
    return variantOptions.map(option => `${option.name}: ${option.value}`).join(', ');
  };
  
  const handleClick = useCallback(() => {
    // Here create a variant key by appending the document id to the options combination ensure uniqueness across variants
    const generateVariantKey = (variantOptions: VariantOptions[]) => {
      return variantOptions.map(option => `${option.name}:${option.value}`).join('|') + '+' + publishedDocumentId;
    };

    const optionValues = variantOptions.map(opt => opt.values.map(v => ({ name: opt.name, value: v })));

    // Generate all combinations of option values
    const variants = cartesianProduct(optionValues).map((variant) => {
      const variantKey = generateVariantKey(variant);
      const variantName = generateVariantName(variant)

      // Assign a random key to each option in the variant
      const optionsWithKeys = variant.map(option => ({
        ...option,
        _key: randomKey(12)
      }));

      return {
        variantName: variantName,
        _type: 'variant',
        _key: variantKey,
        options: optionsWithKeys,
        quantity: 0
      };
    });

    // Individually insert items to append to the end of the array
    const variantPatches = variants.map((variant) =>
      insert([variant], 'after', [-1])
    )

    // Patch the document with the new variants array
    //
    // First clear out existing variants if case we're regenerating
    onChange(set([]))
    // Then set the new variants
    onChange([setIfMissing([]), ...variantPatches])
  },[onChange, publishedDocumentId, variantOptions])  

  // Clear out existing variants
  const handleClear = useCallback(() => {onChange(set([]))},[onChange])

  return (
    <Stack space={3}>
      <Button icon={SparklesIcon} text='Generate Variants' mode='ghost' onClick={handleClick} />
      {props.renderDefault(props)}
      <Button icon={ResetIcon} text='Clear Variants' mode='ghost' onClick={handleClear} />
    </Stack>
  )
}

product-schema.ts

import { defineType, defineField, defineArrayMember } from 'sanity';
import { BasketIcon, ControlsIcon, TagIcon } from '@sanity/icons'
import { GenerateVariants } from 'path/to/GenerateVariants';

const product = defineType({
  name: 'product',
  title: 'Products',
  type: 'document',
  icon: BasketIcon,
  groups: [
    {
      name: 'product',
      title: 'Product Information',
    },
    {
      name: 'media',
      title: 'Media',
    },
    {
      name: 'inventory',
      title: 'Inventory',
    },
  ],
  fields: [
    defineField({
      name: 'name',
      title: 'Name',
      type: 'string',
      group: 'product'
    }),
    defineField({
      name: 'slug',
      title: 'Slug',
      type: 'slug',
      group: 'product',
      options: {
        source: 'name',
        maxLength: 96,
      },
    }),
    defineField({
      name: 'description',
      title: 'Description',
      group: 'product',
      type: 'text',
    }),
    defineField({
      name: 'price',
      title: 'Price',
      group: 'product',
      description: 'Value is in smallest fractional unit, ie cents (500 = $5.00)',
      type: 'number',
    }),
		defineField({
      name: 'currency',
      title: 'Currency',
      group: 'product',
      type: 'string',
    }),
    defineField({
      name: 'image',
      title: 'Cover Image',
      type: 'image',
      group: 'media',
      options: {
        hotspot: true,
      },
    }),
    defineField({
      name: 'imageGallery',
      title: 'Image Gallery',
      group: 'media',
      type: 'array',
      options: {
        layout: 'grid',
      },
      of: [
        defineArrayMember({
          name: 'image',
          title: 'Images',
          type: 'image',
          options: {
            hotspot: true,
          },
        }),
      ]
    }),
    defineField({
      name: 'options',
      title: 'Options',
      type: 'array',
      group: 'inventory',
      of: [
        defineArrayMember({
          name: 'option',
          title: 'Option',
          type: 'object',
          fields: [
            defineField({
              name: 'name',
              description: 'Size, colour, etc.',
              title: 'Option Name',
              type: 'string',
            }),
            defineField({
              name: 'values',
              title: 'Option Values',
              type: 'array',
              of: [
                defineArrayMember({
                  name: 'value',
                  title: 'Value',
                  type: 'string',
                })
              ]
            })
          ],
          preview: {
            select: {
              title: 'name',
              subtitle: 'values'
            },
            prepare(selection) {
              const { title, subtitle } = selection;
              // Join the values array into a comma-separated string
              const valuesString = Array.isArray(subtitle) ? subtitle.join(', ') : '';
              return {
                title: title,
                subtitle: valuesString,
                icon: ControlsIcon
              }
            }
          }
        }),
      ]
    }),
    defineField({ 
      name: 'variants',
      title: 'Variants',
      group: 'inventory',
      type: 'array',
      components: {input: GenerateVariants},
      of: [
        defineArrayMember({
          name: 'variant',
          title: 'Variant',
          type: 'object',
          fields: [
            defineField({
              name: 'variantName',
              title: 'Variant Name',
              type: 'string',
            }),
            defineField({
              name: 'options',
              title: 'Variant Options',
              description: 'Avoid editing these directly. They are generated from the product options. If you need to change them, edit the product options instead and generate a new set.',
              type: 'array',
              of: [
                defineArrayMember({
                  name: 'option',
                  title: 'Option',
                  type: 'object',
                  fields: [
                    defineField({
                      name: 'name',
                      title: 'Name',
                      type: 'string',
                    }),
                    defineField({
                      name: 'value',
                      title: 'Value',
                      type: 'string',
                    })
                  ],
                  preview: {
                    select: {
                      title: 'name',
                      subtitle: 'value'
                    },
                    prepare(selection) {
                      const { title, subtitle } = selection;
                      return {
                        title: `${title}: ${subtitle}`,
                        icon: ControlsIcon
                      }
                    }
                  }
                })
              ]
            }),
            defineField({
              name: 'quantity',
              title: 'Stock',
              description: 'Set the quantity of available stock for this variant',
              type: 'number',
              initialValue: 0
            })
          ],
          preview: {
            select: {
              title: 'variantName',
              subtitle: 'quantity'
            },
            prepare(selection) {
              const {title, subtitle} = selection;
              return {
                title: title,
                subtitle: subtitle !== null ? `Stock: ${subtitle}` : 'No stock info',
                icon: TagIcon
              }
            }
          },
        })
        ],
      }),
  ],
	initialValue: {
    currency: 'USD',
  }
});

export default product;

This custom input component automatically generates all possible combinations of options and populates the variants into an array field. It's designed to be used along side the provided product schema, though all that's required from the product schema is the options and variants arrays. I created this so that Sanity could be used as the source of truth for products in combination with use-shopping-cart, Next.js and Stripe, but of course this could be used with whatever stack you prefer. Stock fields are included for tracking inventory on a per variant basis.

Contributor

Alex Nelson

Designer/Developer in Vancouver BC. Partner at Post Projects.

Alex is located at Vancouver, BC, Canada
Visit Alex Nelson's profile