Cover
TinaCMS

Let's create a relation field for TinaCMS

Introduction
Let's create a relation field for TinaCMS

I am currently migrating Baretheme and while modeling my content I've come to a point where I was in need for relation fields. I try to use those fields as much as possible in order to avoid losing connection between data.  In this guide I will use TinaCMS together with Gatsby, if you are having a different setup things might be different here and there.

I will quickly walk you through this and only explain the interesting parts! You will be able to see the whole code in a Gist provided at the end of this article.

Why relations matter

Let's say you create a menu for your site, you could now use a simple group-list and add a slug field to each item. However if you now go to a page and change its slug, your menu will have to be updated as well. If you forget this, your menu will break. With a relation field you would just point to that page and use the same slug.

What we will create

We will create a field that lets you choose one or more entries based on some data. This field can be added to your fields definition in a form passed to TinaCMS. This field definition will have the following interface:

{
 name: string // internal name of the field
 component: string // relation to be used
 label: string // label of the field
 noDataText: string // displayed when no data available
 itemProps: function // determines key and label fields of your data
 disabled?: boolean // disable the field?
 sortable?: boolean // allow sorting?
}

Sneak peak on sorting a single item

Sneak peak on sorting multiple items

So many undefined components

I've left out many of the components I was using, because they are all just providing styles via styled-components . You will find all of them in the linked Gist.

Register a new field

To register a new field you must first register this field to TinaCMS. To do so you need to call the window.tinacms.fields.add method. In Gatsby you have to do this in the gatsby-browser.js file. For my collections this looks like this:

import LanguageField from "./src/fields/language"
import CollectionsField from "./src/fields/collections"
import DocumentField from "./src/fields/document"

export const onClientEntry = () => {
  window.tinacms.fields.add({
    name: "language",
    Component: LanguageField,
  })

  window.tinacms.fields.add({
    name: "collections",
    Component: CollectionsField,
  })

  window.tinacms.fields.add({
    name: "document",
    Component: DocumentField,
  })
}

I am registering the fields language, collections and document. To activate such a field you need to add it to your field definitions like:

{ name: "collections", component: "collections", label: "Collections" }

Hint: There are two ways to solve the problem: either to register multiple fields or to use a single "relation" field. I prefer this way because it's more verbose and you don't need to provide the data and the itemProps all the time. You'll see later.

Lets get our hands dirty

Now we need to add some code! Go ahead and create a new file src/field-templates/relation.js. Let's just start with a very simple React component:

const Relation = ({ multiple, ...props }) => {
  if (multiple) {
    return <MultipleRelations {...props} />
  }

  return <SingleRelation {...props} />
}

Relation.defaultProps = {
  multiple: false,
  noDataText: 'There is no data.',
}

export default Relation;

All we are doing for now is to make a difference between a relationship that can have multiple entries or one that just contains a single relation. We are passing all props to the next components.

Creating a single relationship

Let's first create a field for a single relationship, because it's much easier.

My use case are languages: when I create a new document I want to create it for a specific language. So create a new file src/fields/language.js.

import React from 'react';
import Relation from '../field-templates/relation';
import useLanguages from '../hooks/use-languages';

export default (props) => {
  const languages = useLanguages();
  return (
    <Relation
      data={languages}
      itemProps={item => ({
        key: item.key,
        label: item.label,
      })}
      noDataText="You didn't create any languages."
      {...props}
    />
  )
}

This component uses our Relation component and passes some props. First we give it a simple title. Then we are passing data. This is necessary because you need data in order to choose from existing items. We get this data from a useLanguages hook. Next we define a callback for the itemProps. This function receives a single item as an argument, this is needed to determine the location of two fields: key and label. Otherwise you would have to add these fields to every single item in your data. For example if you don't have a key and a label but an id and a title you would use it like so:

itemProps={item => ({
  key: item.id,
  label: item.title
})

The noDataText is simply a text that is displayed if you don't have any entries. Last but not least we are passing all props down. Do not forget this step!

Receiving the data

As you saw before we are getting the data from the useLanguages hook. In this hook you need to fetch the data which is depending on your project structure. For me it looks like this:

import { useStaticQuery, graphql } from "gatsby"

export default () => {
  const { internationalization } = useStaticQuery(
    graphql`
      query internationalizationQuery {
        internationalization: dataJson(
          fileRelativePath: { eq: "/content/data/internationalization.json" }
        ) {
          languages {
            key
            label
          }
        }
      }
    `
  )

  return internationalization.languages
}

I've registered a folder src/data to gatsby-tranformer-json and that's why I can use dataJson() in my graphql query.

Create the SingleRelation component

Our code still doesn't do a lot. That's because the SingleRelation doesn't yet exist. Add the following code to src/field-templates/relation.js:

const SingleRelation = ({ data, itemProps, noDataText, input, field }) => {
  const options = data.map(item => itemProps(item));

  const selectOptions = [
    {
      key: null,
      label: '---',
    },
    ...options
  ]

  return (
    <>
      <RelationHeader>
        <FieldLabel>{field.label}</FieldLabel>
      </RelationHeader>
      <RelationBody>
        <Select
          input={input}
          field={field}
          options={selectOptions}
          noDataText={noDataText}
        />
      </RelationBody>
    </>
  )
};

This component receives some props you probably won't know until now! We didn't have to define those before because we were just passing everything down until this step. The props title , data and itemProps should already be familiar, because we passed them to our Relation component.

The props input and field are new. Those are passed from TinaCMS and contain some very important information about the field and input. Here we make use of the onChange method of the input and we pass the field down to a Select. Besides some mechanics provided by TinaCMS our field also contains attributes we define in our field definition.

Next add the actual select HTML element:

const Select = ({ input, field, options, noDataText }) => {
  return (
    <SelectElement>
      <select
        id={input.name}
        value={input.value}
        onChange={input.onChange}
        disabled={field.disabled}
        {...input}
      >
        {options ? (
          options.map(option => (
            <option value={option.key} key={option.key}>
              {option.label}
            </option>
          ))
        ) : (
          <option>{noDataText}</option>
        )}
      </select>
    </SelectElement>
  )
}

This is where we make use of some more fields of the input and also make the disabled attribute in our field definition useful.

Creating a multiple relation field

Alright this one is a little bit trickier, as it requires more logic, but it's also a super powerful field. Things we will consider:

  • Sorting
  • Only showing available items, items that are already added should not be an option

Okay I will split this component a little bit so it's easier to understand. Let's first create a simple component:

const MultipleRelations = ({ data, itemProps, noDataText, input, field, form, sortable }) => {
 return ();
});

This is just doing nothing for now, but it already provides all props that we need. First thing to do is, to add the DragDropContext from react-beautiful-dnd.

const MultipleRelations = ({ title, data, itemProps, noDataText, input, field, form, sortable }) => {
  const moveArrayItem = React.useCallback(
    (result) => {
      if (!result.destination || !form) return
      const name = result.type
      form.mutators.move(
        name,
        result.source.index,
        result.destination.index
      )
    },
    [form]
  )
  
 return (
   <DragDropContext onDragEnd={moveArrayItem}>
     // ..
   </DragDropContext>
 );
});

This will initialize the context needed to make drag and drop available. We use the onDragEnd callback to move our items in the array. We don't care too much about this because we are using the helper function form.mutators.move provided by TinaCMS and pass everything it needs.

Next we need to add our Droppable's.

const MultipleRelations = ({ data, itemProps, noDataText, input, field, form, sortable }) => {
  const removeRelation = (index, item, field) => {
    form.mutators.remove(field.name, index);
  }

  const moveArrayItem = React.useCallback(
    (result) => {
      if (!result.destination || !form) return
      const name = result.type
      form.mutators.move(
        name,
        result.source.index,
        result.destination.index
      )
    },
    [form]
  )
  
 return (
   <DragDropContext onDragEnd={moveArrayItem}>
     <Droppable droppableId={field.name} type={field.name}>
        {provider => (
          <RelationBody ref={provider.innerRef}>
            {data.length === 0 && (
              <EmptyList>{noDataText}</EmptyList>
            )}
            {value.map((key, index) => {
              const item = data.find(item => itemProps(item).key === key)
              return (
                <RelationListItem
                  item={item}
                  form={form}
                  field={field}
                  index={index}
                  sortable={sortable}
                  key={key}
                  onRemove={removeRelation}
                  isDragDisabled={sortable !== false || value.length <= 1}
                />
              )
            })}
            {provider.placeholder}
          </RelationBody>
        )}
      </Droppable>
   </DragDropContext>
 );
});

Within those we iterate through our data to display it in a RelationListItem component. We pass the props item, form and field. In that case we want to disable drag and drop if sorting is not allowed or if the length of our value is <= 1 because we do not need to sort a single item. Also we define a onRemove callback function that calls removeRelation, which again just calls a helper function of TinaCMS.

The {provider.placeholder} is needed by react-beautiful-dnd to work.

Lastly we add the header where you can also add new items and our final MultipleRelations component will look like this:

const MultipleRelations = ({ data, itemProps, noDataText, input, field, form, sortable }) => {
  const [visible, setVisible] = React.useState(false)
  const [availableData, setAvailableData] = React.useState(data)
  const value = input.value || [];

  React.useEffect(() => {
    setAvailableData(data);
  }, [data])

  React.useEffect(() => {
    const newAvailableData = data.filter(i => !value.includes(i.key));
    setAvailableData(newAvailableData);
  }, [value])

  const addRelation = React.useCallback(
    value => {
      form.mutators.insert(field.name, 0, value)
    },
    [field.name, form.mutators]
  )

  const moveArrayItem = React.useCallback(
    (result) => {
      if (!result.destination || !form) return
      const name = result.type
      form.mutators.move(
        name,
        result.source.index,
        result.destination.index
      )
    },
    [form]
  )

  const removeRelation = (index, field) => {
    form.mutators.remove(field.name, index);
  }

  return (
    <DragDropContext onDragEnd={moveArrayItem}>
      <RelationHeader>
        <FieldLabel>{field.label}</FieldLabel>
        { !!availableData.length && (
          <>
            <IconButton
              primary
              small
              onClick={() => setVisible(!visible)}
              open={visible}
            >
              <AddIcon />
            </IconButton>
            <RelationMenu open={visible}>
              <RelationMenuList>
                {availableData.map(item => {
                  const props = itemProps(item);
                  return (
                    <RelationOption
                      key={props.key}
                      onClick={() => {
                        addRelation(props.key)
                        setVisible(false)
                      }}
                    >
                      {props.label}
                    </RelationOption>
                  )
                })}
              </RelationMenuList>
            </RelationMenu>
          </>
        )}
      </RelationHeader>
      <Droppable droppableId={field.name} type={field.name}>
        {provider => (
          <RelationBody ref={provider.innerRef}>
            {data.length === 0 && (
              <EmptyList>{noDataText}</EmptyList>
            )}
            {value.map((key, index) => {
              const item = data.find(item => itemProps(item).key === key)
              return (
                <RelationListItem
                  item={item}
                  form={form}
                  field={field}
                  index={index}
                  key={key}
                  onRemove={removeRelation}
                  isDragDisabled={sortable === false || value.length <= 1}
                />
              )
            })}
            {provider.placeholder}
          </RelationBody>
        )}
      </Droppable>
    </DragDropContext>
  )
}

There we call the addRelation method on click which again calls just a helper function to save the relation. Two other things to note:

React.useEffect(() => {
    setAvailableData(data);
  }, [data])

This will make sure our data is updated when we receive something new.

React.useEffect(() => {
    const newAvailableData = data.filter(i => !value.includes(i.key));
    setAvailableData(newAvailableData);
  }, [value])
 

This will filter already saved values from the data, we won't be able to add the same item twice.

Gist

This blog article is not a good place to store code snippets. For now I've published my code as a Gist on Github, I will probably make a package from this as soon as I find the time, but for now you can just copy & paste it from here:

TinaCMS relation field
TinaCMS relation field. GitHub Gist: instantly share code, notes, and snippets.
Marc Mintel
Author

Marc Mintel

Marc Mintel is a self taught JavaScript and Frontend Developer with heavy focus on React and Vue.

View Comments
Next Post

Let's create a conditional field for TinaCMS

Previous Post

How to become an open source contributor

Success! Your membership now is active.