Unlock seamless workflows and faster delivery with our latest releases - Join the deep dive

How to build a custom input component that dynamically displays a list of checkboxes based on the current value of a separate field.

10 replies
Last updated: Apr 4, 2024
Hi everyone! Is it possible to build a custom input component that dynamically displays a list of checkboxes w/ labels based on the current value of separate field?
For example, say it was a shopping list, and the goal would be for the contents of the list to update based on which meal was selected. I have the document schemas set up for this example (will post in thread below), but am struggling to get the component to 1) update the list when the new meal is selected, and 2) patch the current list to content lake.
Apr 3, 2024, 6:57 AM
Here are the document schemas I'm working with:Meal:

// Meal.js
export default {
  title: 'Meal',
  name: 'meal',
  type: 'document',
  fields: [
    {
      title: 'Name',
      name: 'name',
      type: 'string',
    },
    {
      title: 'Ingredients',
      name: 'ingredients',
      type: 'array',
      description: 'ingredients for the meal',
      of: [{type: 'ingredient'}],
    },
  ],
}
Ingredient:

// Ingredient.js
export default {
  title: 'Ingredient',
  name: 'ingredient',
  type: 'document',
  fields: [
    {
      title: 'Ingredient Name',
      name: 'ingredientName',
      type: 'string',
    },
  ],
}
Shopper:

// Shopper.js
import {MealIngredientsComponent} from './MealIngredientsComponent'

export default {
  title: 'Shopper',
  name: 'shopper',
  type: 'document',
  fields: [
    {
      title: 'Name',
      name: 'name',
      type: 'string',
    },
    {
      title: 'Meal',
      name: 'meal',
      type: 'reference',
      description: 'Select the meal for this shopper',
      to: [{type: 'meal'}],
    },
    {
      title: 'Shopping List',
      name: 'shoppingList',
      type: 'array',
      description: 'List of ingredients to buy for selected meal',
      of: [{type: 'shoppingListItem'}],
      components: {
        input: MealIngredientsComponent,
      },
    },
  ],
}
Shopping List Item:

// ShoppingListItem.js
export default {
  title: 'Shopping List Item',
  name: 'shoppingListItem',
  type: 'document',
  fields: [
    {
      title: 'Ingredient',
      name: 'ingredient',
      type: 'string',
    },
    {
      title: 'Purchased Item',
      name: 'purchased',
      type: 'boolean',
    },
  ],
}

And here's hacky custom component I've pieced together so far:

import {useFormValue, useDocumentStore, set, unset} from 'sanity'
import {useEffect, useCallback, useState} from 'react'
import {Checkbox} from '@sanity/ui'

export const MealIngredientsComponent = (props) => {
  const {onChange, value} = props

  // define component state
  const [shoppingList, setShoppingList] = useState([])
  const [meal, setMeal] = useState(null) // this will be the meal object that is selected in the shopper document

  // Set up a GROC query to get the ingredient list for the currently selected meal
  const docId = useFormValue(['_id']) // get the id of the currently active shopper
  console.log(docId)
  const query = '*[_id == $currentDoc]{meal->}'
  const documentStore = useDocumentStore()
  let queryResults = documentStore.listenQuery(query, {currentDoc: docId}, {})
  useEffect(() => {
    const subscription = queryResults.subscribe({
      next(result) {
        if (result[0].meal === undefined) return
        if (!result[0].meal) {
          setShoppingList([]) // update component state
          unset()
        } else {
          if (result[0].meal.name === meal) {
            return
          } else {
            setMeal(result[0].meal.name)
          }

          const requiredIngredients = result[0].meal.ingredients
          const newShoppingList = requiredIngredients.map((item) => ({
            ingredient: item.ingredientName,
            purchased: false,
          }))
          setShoppingList(newShoppingList)
        }
      },
    })
    return () => {
      subscription.unsubscribe()
    }
  })

  // define the event handler for the checkbox
  const handleChange = useCallback(
    (e, ingredient) => {
      const {checked} = e.target
      setShoppingList((shoppingList) => {
        return shoppingList.map((item) => {
          if (item.ingredient === ingredient) {
            return {
              ingredient: item.ingredient,
              purchased: checked,
            }
          }
          return item
        })
      })
      set(shoppingList)
    },
    [onChange],
  )

  let shoppingListNodes = shoppingList.map((r) => {
    return (
      <div key={r.ingredient}>
        <Checkbox checked={r.purchased} onChange={(e) => handleChange(e, r.ingredient)} />
        <label>{r.ingredient}</label>
      </div>
    )
  })

  return <div>{shoppingListNodes}</div>
}

Any tips/guidance would be greatly appreciated!
Apr 3, 2024, 7:01 AM
I did something similar in the past, though it was using references so your data would look different:
const handleClick = useCallback(
      (e) => {
        const inputValue = {
          _type: 'reference',
          _ref: e.target.value,
        }

        if (value) {
          if (value.some((country) => country._ref === inputValue._ref)) {
            onChange(set(value.filter((item) => item._ref != inputValue._ref)))
          } else {
            onChange(set([...value, inputValue]))
          }
        } else {
          onChange(set([inputValue]))
        }
      },
      [value],
    )
Apr 3, 2024, 5:20 PM
Ah, I see from your example I wasn't wrapping my
set(...)
call in the
onChange
function. I think I'm missing something fundamental about how these input form properties work (or really, input components in general 🙃). Are you updating the internal component state as well as the data in content lake? Would you mind sharing the code for your full component?
Apr 3, 2024, 5:42 PM
So, it’s a V2 component, so a lot of the syntax will be incorrect. I just updated a bit of it up there ☝️. The essential functionality is the same, though:
import React, { useEffect, useState } from 'react';
import { Card, Flex, Checkbox, Box, Text } from '@sanity/ui';
import { FormField } from '@sanity/base/components';
import PatchEvent, { set, unset } from '@sanity/form-builder/PatchEvent';
import { useId } from '@reach/auto-id';
import client from 'part:@sanity/base/client';

const studioClient = client.withConfig({ apiVersion: '2021-10-21' });

const ReferenceSelect = React.forwardRef((props, ref) => {
  const [countries, setCountries] = useState([]);

  useEffect(() => {
    const fetchCountries = async () => {
      await studioClient
        .fetch(
          `*[_type == 'country']{
          _id,
          title
        }`
        )
        .then(setCountries);
    };

    fetchCountries();
  }, []);

  const {
    type, // Schema information
    value, // Current field value
    readOnly, // Boolean if field is not editable
    markers, // Markers including validation rules
    presence, // Presence information for collaborative avatars
    compareValue, // Value to check for "edited" functionality
    onFocus, // Method to handle focus state
    onBlur, // Method to handle blur state
    onChange, // Method to handle patch events,
  } = props;

  const handleClick = React.useCallback(
    (e) => {
      const inputValue = {
        _key: e.target.value.slice(0, 10),
        _type: 'reference',
        _ref: e.target.value,
      };

      if (value) {
        if (value.some((country) => country._ref === inputValue._ref)) {
          onChange(
            PatchEvent.from(
              set(value.filter((item) => item._ref != inputValue._ref))
            )
          );
        } else {
          onChange(PatchEvent.from(set([...value, inputValue])));
        }
      } else {
        onChange(PatchEvent.from(set([inputValue])));
      }
    },
    [value]
  );

  const inputId = useId();

  return (
    <FormField
      description={type.description} // Creates description from schema
      title={type.title} // Creates label from schema title
      __unstable_markers={markers} // Handles all markers including validation
      __unstable_presence={presence} // Handles presence avatars
      compareValue={compareValue} // Handles "edited" status
      inputId={inputId} // Allows the label to connect to the input field
      readOnly={readOnly}
    >
      {countries.map(country => (
        <Card padding={2}>
          <Flex align='center'>
            <Checkbox
              id={country._id}
              style={{ display: 'block' }}
              onClick={handleClick}
              value={country._id}
              checked={
                value ? value.some((item) => item._ref === country._id) : false
              }
            />
            <Box flex={1} paddingLeft={3}>
              <Text>
                <label for={country._id}>{country.title}</label>
              </Text>
            </Box>
          </Flex>
        </Card>
      ))}
    </FormField>
  );
});

export default ReferenceSelect;
Apr 3, 2024, 5:47 PM
Thank you, much appreciated!! 🙏 I'll comb through this to see if I can find any clues about why mine isn't working as expected
Apr 3, 2024, 5:49 PM
If I get some time this afternoon I’ll reproduce your component and see what I can find!
Apr 3, 2024, 5:50 PM
Thank you, I'm in over my head here! I'll post some more specific issues I'm having once I have a better handle on what's going on
Apr 3, 2024, 5:51 PM
They’re definitely tough to grasp when you first start working with them. It’ll come together though!
Apr 3, 2024, 5:52 PM
I think I got it working! Your example was instrumental in helping me get there, so thank you again 😄 Here's where I landed, in case this issue is helpful for anyone else:
import {useClient, useFormValue, set} from 'sanity'
import {useEffect, useCallback, useState} from 'react'
import {Flex, Checkbox, Box, Text} from '@sanity/ui'

export const MealIngredientsComponent = (props) => {
  const {onChange, value} = props
  const [shoppingList, setShoppingList] = useState(value)
  const sanityClient = useClient({apiVersion: '2023-01-01'})
  const meal = useFormValue(['meal'])

  // --- Fetch ingredients for the current meal
  useEffect(() => {
    if (!meal) {
      updateShoppingList([])
    } else {
      const fetchMealIngredients = async () => {
        await sanityClient
          .fetch(`*[_type == "meal" && _id == "${meal._ref}"]{ingredients}`)
          .then((resp) => {
            updateShoppingList(resp[0].ingredients)
          })
      }
      fetchMealIngredients()
    }
  }, [meal])

  // --- Update shopping list based on new ingredients from current meal
  const updateShoppingList = (newIngredients) => {
    // remove any existing ingredients that are NOT in the list of new ingredients
    let newList = shoppingList.filter((item) => {
      return newIngredients.some((newItem) => newItem.ingredientName === item.ingredient)
    })

    // add new ingredients that are NOT in the existing list of ingredients
    newIngredients.forEach((newItem) => {
      if (!newList.some((item) => item.ingredient === newItem.ingredientName)) {
        newList.push({
          _key: newItem._key,
          ingredient: newItem.ingredientName,
          purchased: false,
        })
      }
    })

    // update component state and content lake
    setShoppingList(newList)
    onChange(set(newList))
  }

  // -- Event handler for each checkbox
  const handleChange = useCallback(
    (e, item) => {
      const {checked} = e.target
      const ingredientKey = item._key
      const updatedList = shoppingList.map((listItem) => {
        if (listItem._key === ingredientKey) {
          return {
            ...listItem,
            purchased: checked,
          }
        } else {
          return listItem
        }
      })
      setShoppingList(updatedList)
      onChange(set(updatedList))
    },
    [shoppingList],
  )

  return (
    <div>
      {shoppingList.map((item) => (
        <div key={item._key}>
          <Flex align="center" padding={1}>
            <Checkbox
              id={item._key}
              checked={item.purchased}
              onChange={(e) => handleChange(e, item)}
            />
            <Box flex={1} paddingLeft={3}>
              <Text>{item.ingredient}</Text>
            </Box>
          </Flex>
        </div>
      ))}
    </div>
  )
}
Apr 4, 2024, 5:37 AM
Thank you for sharing!
Apr 4, 2024, 4:42 PM

Sanity– build remarkable experiences at scale

Sanity is a modern headless CMS that treats content as data to power your digital business. Free to get started, and pay-as-you-go on all plans.

Was this answer helpful?