😎 Discover cool tips and tricks for customization in our next Developer Deep Dive virtual event - sign up now!

Custom Input Component with Webhook

By Andy Fitzgerald

Custom input component with a DIY webhook for connecting to APIs beyond publish, update, and delete events.

Custom input component

import React from 'react';
import { FormField } from '@sanity/base/components';
import { TextInput, Stack, Text, Flex, Button, Box, useToast } from '@sanity/ui';
import PatchEvent, {set, unset} from '@sanity/form-builder/PatchEvent';
import { useId } from "@reach/auto-id";

const LDHyperlink = React.forwardRef((props, ref) => {

  const { 
    type,         // Schema information
    value,        // Current field value
    readOnly,     // Boolean if field is not editable
    placeholder,  // Placeholder text from the schema
    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
    parent,       // Parent document data
  } = props

  // Webhook payload. If you need to specify particular keys—-such as `event-type` for GitHub Actions workflows--those can be added here. 
  const webHookData = {
    link: value,  
    resourceId: parent._id
  }; 

  const inputId = useId();
  const toast = useToast();

  const apiBaseURL = process.env.SANITY_STUDIO_DEV_API_URL || 'https://api.uxmethods.org';

  const webHook = () =>
    fetch(
      `${apiBaseURL}/ld`, // URL to which to POST the webhook 
      {
        method: 'POST',
        headers: {
          'User-Agent': 'UXMethods'
        },
        body: JSON.stringify(webHookData)
      }
    ).then(response => {
      if (response.ok) {
        console.log("Webhook successfully received.");
        console.log(webHookData);
        toast.push({
          status: 'info',
          title: 'Linked Data received',
          closable: true
        });
      } else {
        return Promise.reject(response);
      }
    }).catch(err => {
      console.warn('There was a problem', err);
      toast.push({
        status: 'error',
        title: 'There was a problem:',
        description: 'The Linked Data request failed. Check the console for error messages.',
        closable: true
      });
    });


  // Creates a change handler for patching data
  const handleChange = React.useCallback(
    (event) => {
      const inputValue = event.currentTarget.value // get current value
      // if the value exists, set the data, if not, unset the data
      onChange(PatchEvent.from(inputValue ? set(inputValue) : unset()))
    },
    [onChange]
  )

  const isURL = (str) => {
    const pattern = new RegExp('^(https?:\\/\\/)?'+ // protocol
      '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|'+ // domain name
      '((\\d{1,3}\\.){3}\\d{1,3}))'+ // OR ip (v4) address
      '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'+ // port and path
      '(\\?[;&a-z\\d%_.~+=-]*)?'+ // query string
      '(\\#[-a-z\\d_]*)?$','i'); // fragment locator
    return !!pattern.test(str);
  }

  return (
    <Stack space={1}>
      <FormField
        description={type.description}
        title={type.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
      >
        <Flex>
          <Box flex={[1]}>
            <TextInput 
              id={inputId}                  // A unique ID for this input
              onChange={handleChange}       // A function to call when the input value changes
              value={value}                 // Current field value
              readOnly={readOnly}           // If "readOnly" is defined make this field read only
              placeholder={placeholder}     // If placeholder is defined, display placeholder text
              onFocus={onFocus}             // Handles focus events
              onBlur={onBlur}               // Handles blur events
              ref={ref}
            />
          </Box>
          <Box marginLeft={[1]}>
            <Button
              fontSize={[2]}
              padding={[3]}
              text="Get Linked Data"
              mode="ghost"
              disabled={!isURL(value)}      // Button disables until a valid URL is entered
              tone="default"
              justify="flex-end"
              onClick={() => {
                toast.push({
                  status: 'info',
                  title: 'Linked Data request sent.',
                  closable: true
                });
                webHook();
              }}
            />
          </Box>
        </Flex>
      </FormField>
    </Stack>
  )
})

export default LDHyperlink

Sanity GROQ powered webhooks are awesome, but sometimes you may want to trigger a microservice outside of a publish, update, or delete event. This custom input component adds ad-hoc webhook functionality to trigger a webhook ready API with field data as part of its payload. In this case, I'm using a custom API running on a subdomain to crawl and fetch linked data from a URL, in order to more easily populate fields for a shareable resource.

The end result of this component (and its connected service) is similar to Espen Hovlandsdal's URL Metadata Input component, but I wanted a bit more control over which linked data I fetched and how, with the idea being that this can later fit into a larger Linked Data pipeline. If you're just looking to populate metadata, Espen's plugin may be simpler.

Security caveats: Since this webhook is entirely on the front end (and in the repo), be sure not to include any API keys or passwords. I initially set this up with a GitHub Action, but wasn't able to secure the access token in a way I was comfortable with, so I put my API on a subdomain where I could control CORS access. If you host on Netlify, you may have access to "secrets" through an environment variable that would allow you to connect to services more easily.

Contributor