🔮 Sanity Create is here. Writing is reinvented. Try now, no developer setup

Customizing the UI for an array of objects in a Slack thread.

6 replies
Last updated: May 20, 2024
Hey everyone!
I have just a simple array of objects the user can input in this field. I would like to customise the UI so that instead of the basic stacked list i would get something like a popover window with more descriptive cards for each possible item.

I really don't know where to start and I the documentation is not helping at all.
May 19, 2024, 6:30 PM
Resolved this problem. There was a nice useful snippet on some forum i was able to adopt.
May 19, 2024, 6:47 PM
You should post your solution! I’m sure it would help others
May 19, 2024, 6:54 PM
Gladly!
May 19, 2024, 7:27 PM
This is the custom component that will be attached to the array field. I didnt have time to style it much, but basically we are passing in all props and they are available in here for displaying anything. I also noticed it can be used to prepopulate fields is that is interesting.
import { ArrayOfObjectsInputProps, BooleanSchemaType, FileSchemaType, NumberSchemaType, ObjectSchemaType, ReferenceSchemaType, StringSchemaType } from "sanity";
import { Grid, Stack, Button, Dialog, Box, Card, Heading } from "@sanity/ui";
import { useCallback, useState } from "react";
import { AddIcon } from "@sanity/icons";
import { randomKey } from "@sanity/util/content";
import React from "react";

type Schema = BooleanSchemaType | FileSchemaType | NumberSchemaType | ObjectSchemaType | StringSchemaType | ReferenceSchemaType;

const PageBuilderInput = (props: ArrayOfObjectsInputProps) => {
  const { onInsert } = props;
  const [open, setOpen] = useState(false);
  const onClose = useCallback(() => setOpen(false), []);
  const onOpen = useCallback(() => setOpen(true), []);

  const onSelectItem = useCallback((schema: Schema) => {
    const key = randomKey(12);
    onInsert({
      items: [
        {
          _type: schema.name,
          _key: key,
        } as any,
      ],
      position: "after",
      referenceItem: -1,
      open: true,
    });
    onClose();
  }, [onInsert, onClose]);

  return (
    <>
      <Stack space={3}>
        {props.renderDefault({
          ...props,
          arrayFunctions: () => {
            return (
              <Button
                onClick={onOpen}
                icon={AddIcon}
                mode="ghost"
                text="Add item"
              />
            );
          },
        })}
      </Stack>

      {open && (
        <Dialog
          header="Select a section"
          id="dialog-example"
          width={4}
          onClose={onClose}
          zOffset={1000}
        >
          <Box padding={1}>
            <Grid autoCols={'auto'} columns={[1, 2, 2, 3, 4]} autoFlow={'row dense'} gap={[3]} padding={4}>
              {props.schemaType.of.map((schema, index) => {
                return (
                  <PreviewCard
                    key={index}
                    schema={schema}
                    onClick={() => onSelectItem(schema)}
                  />
                );
              })}
            </Grid>
          </Box>
        </Dialog>
      )}
    </>
  );
};

type PreviewProps = {
  onClick: React.MouseEventHandler<HTMLDivElement> | undefined,
  schema: Schema
}

function PreviewCard(props: PreviewProps) {
  const { onClick, schema } = props;
  const Icon = schema.icon || (() => <span />); // Default to empty span if no icon

  return (
    <Card
      role="button"
      padding={2}
      onClick={onClick}
      style={{
        cursor: "pointer",
        textAlign: "center",
        borderRadius: "10px",
        border: "1px solid #f0f0f0",
        backgroundColor: "#fff",
        boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)"
      }}
    >
      <Stack padding={2} space={[3]}>
        <div style={{ justifyContent: "center", alignItems: "center" }}>
          <div style={{ fontSize: "2rem", color: "#333" }}>
            <Icon />
          </div>
          <Heading as="h5" size={1} style={{ marginTop: "1rem", color: "#333" }}>
            {schema.title}
          </Heading>
          <p style={{ color: "#777", fontSize: "0.875rem", marginTop: "0.5rem" }}>
            {schema.description}
          </p>
        </div>
      </Stack>
    </Card>
  );
}

export default PageBuilderInput;
A simple example of a usecase bellow:

 defineField({
          name: 'content',
          title: 'Content',
          description: 'Content of the page. Build your page by adding blocks here.',
          validation: (Rule) => Rule.required().error('A page without content is not really a page...'),
          type: 'array',
          group: 'page',
          of: [
              defineArrayMember({
                type: "heroBlock",
                icon: DropIcon,
                description: 'A hero section with a title, subtitle, image and buttons',
              }),
              defineArrayMember({
                type: "textBlock",
                icon: SunIcon,
                description: 'A hero section with a title, subtitle, image and buttons',
              }),
              defineArrayMember({
                type: "ctaBlock",
                icon: SunIcon,
                description: 'A hero section with a title, subtitle, image and buttons',
              }),
              defineArrayMember({
                type: "imageCarousel",
                icon: SunIcon,
                description: 'A hero section with a title, subtitle, image and buttons',
              }),
              defineArrayMember({
                type: 'heroSection', 
                icon: SunIcon,
                description: 'A hero section with a title, subtitle, image and buttons',
              }),
          ],
          components: {
            input: PageBuilderInput,
          }
      }),
It will look something like this:
May 19, 2024, 7:28 PM
I've created a pretty nice custom array input component borrowing snippets from here and there.
Updating here for those that are interested.
user T

I had more time to play around with the custom input component. I added some improved styling and instead of icons I eventually decided it would be better to showcase an example image of the component to improve the user experience. I also added a search feature which is great after the number of possible content blocks grows. This new component is also adjusted to better respond to different screen sizes.


import {
  ArrayOfObjectsInputProps,
  BooleanSchemaType,
  FileSchemaType,
  NumberSchemaType,
  ObjectSchemaType,
  ReferenceSchemaType,
  StringSchemaType
} from "sanity";
import {
  Grid,
  Stack,
  Button,
  Dialog,
  Box,
  Card,
  Heading,
  Flex,
  Text,
  TextInput
} from "@sanity/ui";
import { useCallback, useState } from "react";
import { AddIcon, SearchIcon } from "@sanity/icons";
import { randomKey } from "@sanity/util/content";
import React from "react";

type Schema = BooleanSchemaType | FileSchemaType | NumberSchemaType | ObjectSchemaType | StringSchemaType | ReferenceSchemaType;

const PageBuilderInput = (props: ArrayOfObjectsInputProps) => {
  const { onInsert } = props;
  const [open, setOpen] = useState(false);
  const [searchValue, setSearchValue] = useState('');
  const onClose = useCallback(() => setOpen(false), []);
  const onOpen = useCallback(() => setOpen(true), []);

  const onSelectItem = useCallback((schema: Schema) => {
    const key = randomKey(12);
    onInsert({
      items: [
        {
          _type: schema.name,
          _key: key,
        } as any,
      ],
      position: "after",
      referenceItem: -1,
      open: true,
    });
    onClose();
  }, [onInsert, onClose]);

  const filteredSchemas = props.schemaType.of.filter((schema) => {
    const searchValueLower = searchValue.toLowerCase();
    return (
      schema.title?.toLowerCase().includes(searchValueLower) ||
      schema.description?.toLowerCase().includes(searchValueLower)
    );
  });

  return (
    <>
      <Stack space={3}>
        {props.renderDefault({
          ...props,
          arrayFunctions: () => {
            return (
              <Button
                onClick={onOpen}
                icon={AddIcon}
                mode="ghost"
                text="Add item"
              />
            );
          },
        })}
      </Stack>

      {open && (
        <Dialog
          header="Select a section"
          id="dialog-example"
          width={800}
          onClose={onClose}
          zOffset={1000}
        >
          <Box padding={4} style={{ borderBottom: '1px solid var(--card-border-color)' }}>
            <TextInput
              fontSize={[2]}
              onChange={(event) => setSearchValue(event.currentTarget.value)}
              padding={[3, 3, 4]}
              radius={2}
              placeholder="Search"
              value={searchValue}
              autoFocus={true}
              icon={SearchIcon}
            />
          </Box>
          <Grid columns={[1, 1, 1, 2, 3]} gap={4} padding={4}>
            {filteredSchemas.map((schema, index) => (
              <PreviewCard
                key={index}
                schema={schema}
                onClick={() => onSelectItem(schema)}
              />
            ))}
          </Grid>
        </Dialog>
      )}
    </>
  );
};

type PreviewProps = {
  onClick: React.MouseEventHandler<HTMLDivElement> | undefined,
  schema: Schema
}

function PreviewCard(props: PreviewProps) {
  const { onClick, schema } = props;
  const [isHovered, setIsHovered] = useState(false);

  return (
    <Card
      padding={[3, 3, 4]}
      radius={2}
      shadow={isHovered ? 2 : 1}
      onClick={onClick}
      style={{ cursor: 'pointer' }}
      tone="default"
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      <Flex gap={4} direction="column">
        <Card
          radius={2}
          shadow={1}
          style={{
            position: 'relative',
            aspectRatio: '2 / 1',
            backgroundColor: 'var(--card-skeleton-color-from)',
            backgroundImage: `url(/images/${schema.name}.png)`,
            backgroundSize: '100% auto',
            backgroundPosition: 'center center',
            backgroundRepeat: 'no-repeat',
          }}
        />

        <Flex gap={3} direction="column">
          <Text size={[2]} weight="medium">
            {schema.title}
          </Text>

          <Text size={1} muted>
            {schema.description ? schema.description : 'No description'}
          </Text>
        </Flex>
      </Flex>
    </Card>
  );
}

export default PageBuilderInput;
In this version the images come form a public/images folder setup at the root of the project and are fetched using the schema name. so as long as the naming for the files and the naming for the components is identical it works like a charm.


 defineField({
          name: 'content',
          title: 'Content',
          description: 'Content of the page. Build your page by adding blocks here.',
          validation: (Rule) => Rule.required().error('A page without content is not really a page...'),
          type: 'array',
          group: 'page',
          of: [
              defineArrayMember({
                type: "textBlock",
                description: 'Rich text content. Ideal for adding paragraphs of text.',
              }),
              defineArrayMember({
                type: "imageCarousel",
                description: 'Content: a list of images. Ideal for showcasing a collection of images.',
              }),
              defineArrayMember({
                type: 'heroSection', 
                description: 'Content:title, description, image and buttons. Ideal for presenting general information about a single entity.',
              }),
          ],
          components: {
            input: PageBuilderInput,
          }
      }),
This is something i've been meaning to do for a long time and i'm sure others will also have some use for this component.
May 20, 2024, 6:28 AM
Thanks for sharing!
May 20, 2024, 3:30 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?