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:
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.
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:
You'll want to come up with a unique slug for your field type. This will be used in a few spots.
/backend/fields/<slug>.php, which will define the field. The slug will also be the first parameter to ED()->registerFieldType()./backend/fields/<slug>.jsx which will define the React component for the field.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).
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.
// 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:
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.
<?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.
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)
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:
<?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) **/
// 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;
}
}