📝 Custom ACF Fields

Overview

Add custom fields to ACF, with WPGraphQL Support

Overview

Sometimes it's necessary, or just useful, to create custom ACF fields — it can provide a better experience for content authors and developers alike.

Examples of custom fields include:

  • A field that allows you to select specific post types
  • A field that allows you to paste in Vimeo or YouTube URLs
  • A field that allows you to select items from an API
  • A field that allows for more complex UI than ACF can provide

A note on alternatives

ACF provides a lot of fields out of the box, and it's worth considering if you can use one of those fields instead of creating a custom field.

For example, if you'd just like to populate a select box, you can use the acf/load_value/name={field_key} filter to populate the select box with data programmatically. Read More.

Creating a custom field

Fields should be defined in the backend/fields directory, which you'll need to create manually. Once defined, fields will be loaded automatically.

There are two main components to creating a custom field:

  • Defining a React component which will be used for rendering the custom field.
  • Writing PHP code which defines the field — registering it with ACF, as well as providing GraphQL typings for when the field is used.

You'll want to come up with a unique slug for your field type. This will be used in a few spots.

  • You'll need a file called /backend/fields/<slug>.php, which will define the field. The slug will also be the first parameter to ED()->registerFieldType().
  • A file called /backend/fields/<slug>.jsx which will define the React component for the field.

Database values vs GraphQL values

You always want to store the minimum amount of data required in the ACF field, and then expand upon that data in the GraphQL layer.

For example, if you're building a field that allows you to select a post, you'll want to store the post ID in the ACF field, and then use the resolve function to expand the post ID into a full post object.

You must also specify a "type" for the field, which is the GraphQL return type. If the type already exists within GraphQL (ie, a post type or similar), you can set the fields type to the name of the type (see Example 1). Otherwise, if your field returns a new type of structure, you can use objectType to define a new type (see Example 2).

Example 1 — Post Selector

In this example, we'll be storing a post ID in the ACF field value, but we'll specify that in GraphQL we want to return a full post object.

/backend/fields/project-selector.tsx
// Using a GraphQL query for the datasource, but this can obviously be anything.
import { useProjectListingAdmin } from "@hooks/queries"
import { defineField } from "eddev/admin"

// We use <number> to specify that the field stores a 'number' in the database.
export default defineField<number>({
  render({ value, onChange }) {
    // The render function receives a `value` (a number) and an `onChange` function (which takes a number)
    const { data, loading } = useProjectListingAdmin()

    if (loading || !data) {
      return <div>Loading...</div>
    }

    return (
      <div>
        <select value={value} onChange={(e) => onChange(Number(e.currentTarget.value))}>
          <option></option>
          {data.projects?.nodes?.map((project) => (
            <option key={project?.databaseId!} value={project?.databaseId!}>
              {project?.title!}
            </option>
          ))}
        </select>
      </div>
    )
  },
})

In the example code above, we're using useProjectListingAdmin which is a regular Query Hook. It might look like this:

/queries/UseProjectListingAdmin.graphql
query UseProjectListingAdmin {
	projects {
		nodes {
			databaseId
			title
		}
	}
}

The PHP code below registers the field, and also declares that it's GraphQL type is an existing type called 'Project'. The resolve function takes the post ID, and returns a WPGraphQL Post object.

/backend/fields/project-selector.php
<?php

  ED()->registerFieldType('project-selector', [
    'label' => 'Project Selector',
    // The GraphQL type name, which this field will return when queried by GraphQL.
    'type' => 'Project',
    // Load the value from ACF. It's always an Integer
    'loadValue' => function($value, $postID, $field) {
      return (int)$value;
    },
    // Using the ACF value (from $value), load a post object.
    'resolve' => function($root, $args, $context, $info, $value) {
      if ($value) {
        return WPGraphQL\Data\DataSource::resolve_post_object($value, $context);
      } else {
        return null;
      }
    }
  ]);

After a successful build, you should be able to add this new field type to a field group, and begin using it like any other field type.

Example 2 — Vimeo Video

This example is useful, because it comes up a lot — so feel free to copy/paste into your project.

One odd thing you may notice about the example is that it includes a WP REST endpoint for fetching Vimeo data.

In this case, the ACF field is storing an object { vimeoUrl: string, enableControls: boolean}, and the GraphQL value contains { videoUrl: string, enableControls: boolean, thumbnail: string, width: number, height: number }.

Note that this example assumes an ACF setting named vimeo_api_key is available, and populatd with a Vimeo Standard-plan API key.

(Get ready, this is a long one)

/backend/fields/vimeo-video.tsx
import { styled } from "@theme"
import { defineField } from "eddev/admin"
import { useEffect, useState } from "react"
import getVideoID from "get-video-id"

// Since this field stores it's value as an object, let's define it's type
type InputType = {
  vimeoUrl: string
  enableControls: boolean
}

// We're controlling the state for this component using an object
type State = {
  loading: boolean
  error?: string
  thumbnail?: string
  url?: string
  success?: boolean
}

export default defineField<InputType>({
  // Define a default value, so that the field doesn't break when it's empty
  defaultValue: { vimeoUrl: "", enableControls: true },
  // The render function is a React component
  render({ value, onChange }) {
    // Maintain state for this component
    const [state, setState] = useState<State>({
      loading: false,
    })

    // Load the thumbnail and video URL whenever URL changes
    useEffect(() => {
      if (!value?.vimeoUrl) {
        // No Vimeo URL has been entered
        setState({ loading: false })
        return
      }
      let cancelled = false

      // Indicate that we're loading
      setState({
        loading: true,
      })

      // Get the Vimeo ID and ensure it's valid
      const lookup = getVideoID(value.vimeoUrl)
      if (lookup.service !== "vimeo" || !lookup.id) {
        setState({
          loading: false,
          error: "Not a valid Vimeo URL",
        })
        return
      }

      // Load the thumbnail and video URL from a custom WP REST API
      fetch("/wp-json/ed/v1/vimeo/" + lookup.id)
        .then((response) => response.json())
        .then((item) => {
          // If the component is unmounted, or another URL has been entered, ignore the result
          if (cancelled) return
          // Update state, so we can present the results
          setState({
            loading: false,
            error: !item ? "Could not find video. Is it part of your account?" : undefined,
            thumbnail: item?.thumbnail,
            url: item?.url,
            success: !!item,
          })
        })

      return () => {
        cancelled = true
      }
    }, [value?.vimeoUrl])

    return (
      <div>
        <Wrapper>
          <URLInput
            type="text"
            placeholder="eg. https://vimeo.com/759250593"
            value={value?.vimeoUrl}
            onChange={(e) =>
              onChange({
                ...(value || {}),
                vimeoUrl: e.currentTarget.value,
              })
            }
          />

          {/* Playback control option */}
          <label>
            <input
              type="checkbox"
              checked={value.enableControls}
              onChange={(e) => {
                onChange({
                  ...(value || {}),
                  enableControls: e.currentTarget.checked,
                })
              }}
            />{" "}
            Enable Playback Controls
          </label>

          {/* Show loading/error/success states */}
          {state.loading && <VimeoNote type="success">One moment...</VimeoNote>}
          {state.error && <VimeoNote type="error">{state.error}</VimeoNote>}
          {state.success && <VimeoNote type="success">Video checks out!</VimeoNote>}

          {/* Show a preview of the video */}
          {state.url && (
            <video
              key={state.url + "_" + value.enableControls}
              src={state.url}
              poster={state.thumbnail}
              controls={value.enableControls}
              loop={!value.enableControls}
              playsInline={!value.enableControls}
              muted={!value.enableControls}
              autoPlay={!value.enableControls}
            />
          )}
        </Wrapper>
      </div>
    )
  },
})

const Wrapper = styled("div", {
  position: "relative",
  display: "flex",
  flexDirection: "column",
  border: "1px solid #eeeeee",
  borderRadius: "5px",
  padding: "10px",
  gap: "8px",
  video: {
    maxWidth: "min(100%, 400px)",
  },
})

const URLInput = styled("input", {
  width: "100%",
  margin: 0,
})

const VimeoNote = styled("div", {
  variants: {
    type: {
      error: {
        color: "red",
      },
      success: {
        color: "green",
      },
    },
  },
})

Meanwhile, in PHP land:

/backend/fields/vimeo-video.php
<?php

  // Register our ACF field type, as well as define how it should be treated via GraphQL
  ED()->registerFieldType('vimeo-video', [
    'label' => 'Vimeo Video',
    // The GraphQL type in this case is an Object type, defined inline.
    // You could also use `register_graphql_object_type` elsewhere, and use `"type": "MyObjectType"` instead.
    'objectType' => [
      'description' => 'Basic image details',
      'fields' => [
        // The loadable URL of the video
        'url' => [
          'type' => 'String',
        ],
        'width' => [
          'type' => 'Number',
        ],
        'height' => [
          'type' => 'Number',
        ],
        // Link to a thumbnail image
        'thumbnail' => [
          'type' => 'String'
        ],
        'enableControls' => [
          'type' => 'Boolean',
          'description' => 'Whether or not this video should initialize paused, and have playback controls.'
        ]
      ]
    ],
    // Load the value from ACF, and ensure it's valid.
    'loadValue' => function($value, $postID, $field) {
      if (!$value || !is_array($value)) return null;
      return $value;
    },
    // Using the ACF value (from $value), load a post object.
    'resolve' => function($root, $args, $context, $info, $value) {
      if (!$value) return null;
      // Load the details from Vimeo, which will be cached
      $result = getVimeoDetailsByURL($value['vimeoUrl'], false);
      if ($result) {
        $result['enableControls'] = (boolean)$value['enableControls'];
        return $result;
      } else {
        return null;
      }
    }
  ]);

  // Add a WP REST method for retrieving info about a video, for the admin preview only.
  add_action('rest_api_init', function() {
    register_rest_route('ed/v1', '/vimeo/(?P<vimeoID>[A-Za-z0-9\/\_\-]+)', [
      'methods' => 'GET',
      'callback' => function($data) {
        return getVimeoDetailsByID($data['vimeoID'], true) ?? 0;
      }
    ]);
  });

  /** Vimeo helpers go here (see below) **/
For brevity, the Vimeo helpers are in here:
/backend/fields/vimeo-video.php
  // Returns the ID of the Vimeo video, for a given URL.
  function getVimeoVideoIdFromUrl($url = '') {
    $regs = array();
    $id = '';

    if (preg_match('%^https?:\/\/(?:www\.|player\.)?vimeo.com\/(?:channels\/(?:\w+\/)?|groups\/([^\/]*)\/videos\/|album\/(\d+)\/video\/|video\/|)(\d+)(?:$|\/|\?)(?:[?]?.*)$%im', $url, $regs)) {
      $id = $regs[3];
    }
    return $id;
  }

  // Returns the details of a Vimeo video, for a given URL.
  function getVimeoDetailsByURL($url, $refresh = false) {
    if (preg_match("/\.m3u8/", $id)) return $id;

    $id = getVimeoVideoIdFromUrl($url);
    return getVimeoDetailsByID($id, $refresh);
  }

  // Returns the details of a Vimeo video, for a given Vimeo ID.
  // Uses a cache if $refresh is false (default)
  function getVimeoDetailsByID($id, $refresh = false) {
    if (!$id) return null;

    // Grab API key
    $apiKey = get_field('vimeo_api_key', 'option');

    // Use cache if possible
    $cacheKey = md5($id.$apiKey);
    $cached = get_transient($cacheKey);

    if ($cached && !$refresh) {
      return $cached;
    } else {

      // Connect to the Vimeo API
      $result = @json_decode(file_get_contents(
        "https://api.vimeo.com/videos/$id",
        false,
        stream_context_create([
          'http' => [
            'method' => 'GET',
            'header' => "Authorization: Bearer $apiKey"
          ],
          "ssl" => [
            "verify_peer" => false,
            "verify_peer_name" => false,
          ]
        ])
      ));

      if ($result) {
        $value = [
          'url' => '',
          'width' => $result->width,
          'height' => $result->height,
          'thumbnail' => @end($result->pictures->sizes)->link
        ];

        // Find the first HD video (an mp4, most likely)
        foreach ($result->files as $file) {
          if ($file->quality === 'hd') {
            $value['url'] = $file->link;
            break;
          }
        }

        // Stagger the cache times, so they don't all expire at once.
        $cacheTime = rand(36, 48) * 3600;
      } else {
        $value = null;
        $cacheTime = 3600;
      }

      // Cache the value
      set_transient($cacheKey, $value ?? 0, $cacheTime);

      return $value;
    }
  }