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
J
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.
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
J
Here are the document schemas I'm working with:Meal:
Ingredient:
Shopper:
Shopping List Item:
And here's hacky custom component I've pieced together so far:
Any tips/guidance would be greatly appreciated!
// 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.js export default { title: 'Ingredient', name: 'ingredient', type: 'document', fields: [ { title: 'Ingredient Name', name: 'ingredientName', type: 'string', }, ], }
// 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, }, }, ], }
// 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
J
Ah, I see from your example I wasn't wrapping my
set(...)call in the
onChangefunction. 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
J
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
J
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
J
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
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.