Get a peek at our latest innovations at Sanity Product Day on Dec 8th →

Prefixed Slug Input

By Christian Garrison

Prefix slugs for catch all routes

PrefixedSlugInput.jsx

import { useId } from '@reach/auto-id';
import { ChangeIndicatorCompareValueProvider, FormField } from '@sanity/base/components';
import PatchEvent, { set, unset } from '@sanity/form-builder/PatchEvent';
import { Box, Button, Flex, TextInput } from '@sanity/ui';
import { withDocument } from 'part:@sanity/form-builder';
import PropTypes from 'prop-types';
import React from 'react';
import slugify from 'slugify';
import styled from 'styled-components';

const SlugBox = styled(Flex)`
  align-items: center;
  width: 100%;

  & > span:nth-of-type(2) {
    flex: 1;
  }
`;

const SlugPrefix = styled(TextInput)`
  border-right: 0;
  border-bottom-right-radius: 0;
  border-top-right-radius: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  padding-right: 0;
`;

const SlugInput = styled(TextInput)`
  border-left: 0;
  border-bottom-left-radius: 0;
  border-top-left-radius: 0;
`;

const PrefixedSlugInput = React.forwardRef((props, ref) => {
  const {
    document, // The document being edited
    level, // The nesting level of the field
    compareValue, // The value of the field in the current document
    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
    onFocus, // Method to handle focus state
    onBlur, // Method to handle blur state,
    onChange, // Method to handle patch events
  } = props;

  const inputId = useId();
  const prefix = type?.options?.prefix ? `${type.options.prefix}/` : `${document?._type}/`;

  const handleChange = React.useCallback(
    (event) => {
      const slug = slugify(event.currentTarget.value, {
        lower: true,
        strict: true,
      });

      onChange(
        PatchEvent.from(slug ? set({ _type: 'slug', current: `${prefix}${slug}` }) : unset()),
      );
    },
    [onChange, prefix],
  );

  const handleGenerate = React.useCallback(() => {
    const inputSource = type?.options?.source;
    const source = inputSource ? document?.[inputSource] : document?.title;
    const slug = slugify(source, {
      lower: true,
      strict: true,
    });

    onChange(PatchEvent.from(set({ _type: 'slug', current: `${prefix}${slug}` })));
  }, [document, onChange, type, prefix]);

  return (
    <FormField
      title={type.title}
      description={type.description}
      level={level}
      compareValue={compareValue}
      __unstable_markers={markers}
      __unstable_presence={presence}
      __unstable_changeIndicator={false}
      inputId={inputId}
    >
      <ChangeIndicatorCompareValueProvider value={value} compareValue={compareValue}>
        <Flex align='center'>
          <SlugBox>
            <SlugPrefix value={prefix} readOnly />
            <SlugInput
              id={inputId}
              value={value?.current?.replace(prefix, '') || ''}
              onChange={handleChange}
              readOnly={readOnly}
              onFocus={onFocus}
              onBlur={onBlur}
              ref={ref}
            />
          </SlugBox>
          <Box marginLeft={1}>
            <Button
              mode='ghost'
              type='button'
              disabled={readOnly}
              text='Generate'
              onClick={handleGenerate}
            />
          </Box>
        </Flex>
      </ChangeIndicatorCompareValueProvider>
    </FormField>
  );
});

PrefixedSlugInput.propTypes = {
  document: PropTypes.shape({
    _type: PropTypes.string,
    title: PropTypes.string,
  }),
  level: PropTypes.number,
  compareValue: PropTypes.shape({
    current: PropTypes.string,
  }),
  type: PropTypes.shape({
    title: PropTypes.string,
    description: PropTypes.string,
    options: PropTypes.shape({
      prefix: PropTypes.string,
      source: PropTypes.string,
    }),
  }),
  value: PropTypes.shape({
    current: PropTypes.string,
  }),
  readOnly: PropTypes.bool,
  markers: PropTypes.arrayOf(
    PropTypes.shape({
      type: PropTypes.string,
    }),
  ),
  presence: PropTypes.arrayOf(
    PropTypes.shape({
      path: PropTypes.arrayOf(PropTypes.string),
    }),
  ),
  onFocus: PropTypes.func,
  onBlur: PropTypes.func,
  onChange: PropTypes.func,
};

export default withDocument(PrefixedSlugInput);

blog.js

import PrefixedSlugInput from '../../src/components/PrefixedSlugInput';

export default {
  name: 'blog',
  title: 'Blog',
  type: 'document',
  fields: [
    {
      name: 'title',
      title: 'Title',
      type: 'string',
      validation: (Rule) => Rule.required().error('A title is required'),
    },
    {
      name: 'slug',
      title: 'Slug',
      type: 'slug',
      inputComponent: PrefixedSlugInput,
      options: {
        source: 'title',
        maxLength: 96,
        prefix: 'news', // "news/my-slug" instead of "document._type/my-slug"
      },
      validation: (Rule) => Rule.required().error('A slug is required'),
    },
    {
      name: 'author',
      title: 'Author',
      type: 'reference',
      to: { type: 'person' },
    },
  ],
};

Contributor