Skip to content
This repository was archived by the owner on Jul 23, 2024. It is now read-only.

feat: Support mutations for ACF fields #351

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions src/class-config.php
Original file line number Diff line number Diff line change
@@ -76,6 +76,12 @@ public function init( TypeRegistry $type_registry ) {
$this->add_options_pages_to_schema();
$this->add_acf_fields_to_graphql_types();

/**
* Add ACF Fields to GraphQL mutations
*/
$mutations_obj = new Mutations();
$mutations_obj->init( $type_registry, $this );

// This filter tells WPGraphQL to resolve revision meta for ACF fields from the revision's meta, instead
// of the parent (published post) meta.
add_filter( 'graphql_resolve_revision_meta_from_parent', function( $should, $object_id, $meta_key, $single ) {
@@ -167,7 +173,7 @@ public function register_initial_types() {
* Gets the location rules
* @return array
*/
protected function get_location_rules() {
public static function get_location_rules() {

$field_groups = acf_get_field_groups();
if ( empty( $field_groups ) || ! is_array( $field_groups ) ) {
@@ -296,7 +302,7 @@ protected function add_options_pages_to_schema() {
*
* @return bool
*/
protected function should_field_group_show_in_graphql( $field_group ) {
public function should_field_group_show_in_graphql( $field_group ) {

/**
* By default, field groups will not be exposed to GraphQL.
@@ -596,19 +602,20 @@ protected function register_graphql_field( string $type_name, string $field_name
*
* @see: https://github.com/wp-graphql/wp-graphql-acf/issues/25
*/
$field_type = $this->register_choices_of_acf_fields_as_enum_type( $acf_field );
if ( empty( $acf_field['multiple'] ) ) {
if('array' === $acf_field['return_format'] ){
$field_config['type'] = [ 'list_of' => 'String' ];
$field_config['type'] = [ 'list_of' => $field_type ];
$field_config['resolve'] = function( $root ) use ( $acf_field) {
$value = $this->get_acf_field_value( $root, $acf_field, true);

return ! empty( $value ) && is_array( $value ) ? $value : [];
};
}else{
$field_config['type'] = 'String';
$field_config['type'] = $field_type;
}
} else {
$field_config['type'] = [ 'list_of' => 'String' ];
$field_config['type'] = [ 'list_of' => $field_type ];
$field_config['resolve'] = function( $root ) use ( $acf_field ) {
$value = $this->get_acf_field_value( $root, $acf_field );

@@ -617,7 +624,8 @@ protected function register_graphql_field( string $type_name, string $field_name
}
break;
case 'radio':
$field_config['type'] = 'String';
$field_type = $this->register_choices_of_acf_fields_as_enum_type( $acf_field );
$field_config['type'] = $field_type;
break;
case 'number':
case 'range':
@@ -684,7 +692,11 @@ protected function register_graphql_field( string $type_name, string $field_name
$relationship = [];
$value = $this->get_acf_field_value( $root, $acf_field );

if ( ! empty( $value ) && is_array( $value ) ) {
if ( ! empty( $value ) ) {
// It sometimes saved as single id like in case of WPML sync acf field to translations posts
if ( ! is_array( $value ) ) {
$value = [ $value ];
}
foreach ( $value as $post_id ) {
$post_object = get_post( $post_id );
if ( $post_object instanceof \WP_Post ) {
@@ -1495,4 +1507,42 @@ protected function add_acf_fields_to_graphql_types() {

}

public function register_choices_of_acf_fields_as_enum_type( array $acf_field ): string {
// If the field isn't a select or radio field or if there are no choices available, return 'String'.
if ( ( 'select' !== $acf_field['type'] && 'radio' !== $acf_field['type'] ) || empty( $acf_field['choices'] ) ) {
return 'String';
}

// Generate a unique name for the enum type using the field name.
$enum_type_name = ucfirst( self::camel_case( $acf_field['name'] ) ) . 'Enum';
if ( ! $this->type_registry->has_type( $enum_type_name ) ) {
// Initialize an empty array to hold your enum values.
$enum_values = [];

// Loop over the choices in the field and add them to the enum values array.
foreach ( $acf_field['choices'] as $key => $choice ) {
// Use the sanitize_key function to create a valid enum name from the choice key.
$enum_key = strtoupper( sanitize_key( $key ) );

// Add the choice to the enum values array.
$enum_values[ $enum_key ] = [
'value' => $key,
'description' => $choice,
];
}

// Register enum type.
$this->type_registry->register_enum_type(
$enum_type_name,
[
'description' => $acf_field['label'],
'values' => $enum_values,
]
);
}

// Return the enum type name.
return $enum_type_name;
}

}
604 changes: 604 additions & 0 deletions src/class-mutations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,604 @@
<?php

namespace WPGraphQL\ACF;

use WP_Post;
use WPGraphQL\Registry\TypeRegistry;
use WPGraphQL\Utils\Utils;

class Mutations
{

const POST_OBJECT_TYPE = 'Post';
const TERM_OBJECT_TYPE = 'Term';

/**
* @var TypeRegistry
*/
private $type_registry;

/**
* @var Config
*/
private $config;

/**
* Stores the location rules for back compat
* @var array
*/
private $location_rules = [];

/**
* Stores the ACF field groups
* @var array
*/
private $field_groups = [];

/**
* Stores the registered ACF fields
* @var array
*/
private $registered_fields = [];

public function init( TypeRegistry $type_registry, Config $config ): void
{
$this->type_registry = $type_registry;
$this->config = $config;

/**
* Get all the field groups
*/
$this->field_groups = acf_get_field_groups();

/**
* If there are no acf field groups, bail
*/
if ( empty( $this->field_groups ) || ! is_array( $this->field_groups ) ) {
return;
}

/**
* Gets the location rules for those fields that do not have "graphql_type".
*
* This allows for ACF Field Groups that were registered before the "graphql_types" ( backward compatibility )
* or registered from code can still work with the old GraphQL Schema rules that mapped
* from the ACF Location rules.
*/
$this->location_rules = Config::get_location_rules();

/**
* Add ACF Fields to GraphQL Mutations
*/
$this->add_acf_fields_to_graphql_types();

// Use same hook that acf normally used to save its data when post create/update from UI and that to be
// more compatible with other plugins that waiting the acf to save to do something depending on it like
// WPML sync feature that sync acf data from master post to its translations.
add_filter( 'graphql_post_object_insert_post_args', function ( array $insert_post_args, array $input ) {
self::add_action_once( 'save_post', function ( int $post_id, WP_Post $post ) use ( $input ) {
// Ignore revision because it's not the post that updated and we don't want to run saving ACF data into it.
if ( 'revision' === $post->post_type ) {
// Return false to prevent removing action because it's not the action that we need.
return false;
}

$this->save_registered_fields_data( $post_id, self::POST_OBJECT_TYPE, $input, $this->registered_fields );
}, 10, 2 );

return $insert_post_args;
}, 10, 2 );

add_filter( 'graphql_term_object_insert_term_args', function ( array $insert_args, array $input ) {
self::add_action_once( 'create_term', function ( int $term_id ) use ( $input ) {
$this->save_registered_fields_data( $term_id, self::TERM_OBJECT_TYPE, $input, $this->registered_fields );
} );
self::add_action_once( 'edit_term', function ( int $term_id ) use ( $input ) {
$this->save_registered_fields_data( $term_id, self::TERM_OBJECT_TYPE, $input, $this->registered_fields );
} );

return $insert_args;
}, 10, 2 );
}

private function save_registered_fields_data( int $object_id, string $object_type, array $fields_data, array $registered_fields ): void
{
$acf_changed = false;
foreach ( $fields_data as $key => $value ) {
if ( ! empty( $registered_fields[$key] ) ) {
if ( ! empty( $registered_fields[$key]['sub_fields_config'] ) ) {
$this->save_registered_fields_data( $object_id, $object_type, $value, $registered_fields[$key]['sub_fields_config'] );
}
else if (
! empty( $registered_fields[$key]['mutate'] )
&& is_callable( $registered_fields[$key]['mutate'] )
) {
call_user_func_array( $registered_fields[$key]['mutate'], [ $object_id, $object_type, $value, $registered_fields[$key] ] );
}
else {
$this->update_acf_field_value( $object_id, $object_type, $value, $registered_fields[$key] );
}
$acf_changed = true;
}
}

// We need this to let the revision code of ACF to work correctly.
// advanced-custom-fields-pro/includes/revisions.php
if ( $acf_changed && $object_type === self::POST_OBJECT_TYPE ) {
$_POST['_acf_changed'] = 1;
do_action( 'acf/save_post', $object_id );
}
}

/**
* Given a field group array, this adds the fields to the specified Type in the Schema
*
* @param array $field_group The group to add to the Schema.
* @param bool $layout Whether or not these fields are part of a Flex Content layout.
*
* @return array|null
*/
private function add_field_group_fields( array $field_group, string $parent_type_name, bool $layout = false ) {

/**
* If the field group has the show_in_graphql setting configured, respect it's setting
* otherwise default to true (for nested fields)
*/
$field_group['show_in_graphql'] = isset( $field_group['show_in_graphql'] ) ? (boolean) $field_group['show_in_graphql'] : true;

/**
* Determine if the field group should be exposed
* to graphql
*/
if ( ! $this->config->should_field_group_show_in_graphql( $field_group ) ) {
return null;
}

/**
* Get the fields in the group.
*/
$acf_fields = ! empty( $field_group['sub_fields'] ) || $layout ? $field_group['sub_fields'] : acf_get_fields( $field_group );

/**
* If there are no fields, bail
*/
if ( empty( $acf_fields ) || ! is_array( $acf_fields ) ) {
return null;
}

/**
* Stores field keys to prevent duplicate field registration for cloned fields
*/
$processed_keys = [];

$registered_fields = [];
/**
* Loop over the fields and register them to the Schema
*/
foreach ( $acf_fields as $acf_field ) {
if ( in_array( $acf_field['key'], $processed_keys, true ) ) {
continue;
} else {
$processed_keys[] = $acf_field['key'];
}

/**
* Setup data for register_graphql_field
*/
$explicit_name = ! empty( $acf_field['graphql_field_name'] ) ? $acf_field['graphql_field_name'] : null;
$name = empty( $explicit_name ) && ! empty( $acf_field['name'] ) ? Config::camel_case( $acf_field['name'] ) : $explicit_name;
$show_in_graphql = isset( $acf_field['show_in_graphql'] ) ? (bool) $acf_field['show_in_graphql'] : true;
$description = isset( $acf_field['instructions'] ) ? $acf_field['instructions'] : __( 'ACF Field added to the Schema by WPGraphQL ACF' );

/**
* If the field is missing a name or a type,
* we can't add it to the Schema.
*/
if (
empty( $name ) ||
true != $show_in_graphql
) {

/**
* Uncomment line below to determine what fields are not going to be output
* in the Schema.
*/
continue;
}

$config = [
'name' => $name,
'description' => $description,
'acf_field' => $acf_field,
'acf_field_group' => $field_group,
];

$field_config = $this->register_graphql_field( $config, $parent_type_name );
if ( ! empty( $field_config ) ) {
$registered_fields[$name] = $field_config;
}

}

return $registered_fields;
}

private function get_field_key( $acf_field ) {
/**
* Check if cloned field and retrieve the key accordingly.
*/
if ( ! empty( $acf_field['_clone'] ) ) {
$key = $acf_field['__key'];
} else {
$key = $acf_field['key'];
}

return $key;
}

private function maybe_filter_value( $field_config, $value ) {
$original_value = $value;
$filtered_value = $value;

if (
! empty( $field_config['filter_value'] )
&& is_callable( $field_config['filter_value'] )
) {
$filtered_value = call_user_func_array( $field_config['filter_value'], [ $filtered_value ] );
}

$acf_type = $field_config['acf_field']['type'];
$field_name = $field_config['name'];

return apply_filters( 'wpgraphql_acf_filter_mutation_field_value', $filtered_value, $acf_type, $original_value, $field_name, $field_config );
}

private function update_acf_field_value( int $object_id, string $object_type, $value, array $field_config, bool $use_add_row = false ) {

switch ( $object_type ) {
case self::TERM_OBJECT_TYPE:
$object_id = 'term_' . $object_id;
break;
case self::POST_OBJECT_TYPE:
// do nothing in this case
break;
// case $root instanceof MenuItem:
// $id = absint( $root->menuItemId );
// break;
// case $root instanceof Menu:
// $id = 'term_' . $root->menuId;
// break;
// case $root instanceof User:
// $id = 'user_' . absint( $root->userId );
// break;
// case $root instanceof Comment:
// $id = 'comment_' . absint( $root->databaseId );
// break;
// case is_array( $root ) && ! empty( $root['type'] ) && 'options_page' === $root['type']:
// $id = $root['post_id'];
// break;
default:
$object_id = null;
break;
}

if ( empty( $object_id ) ) {
return null;
}

$acf_field = $field_config['acf_field'];
$key = $this->get_field_key( $acf_field );

$value = $this->maybe_filter_value( $field_config, $value );

if ( $value !== null ) {
if ( $use_add_row ) {
add_row( $key, $value, $object_id );
}
else {
update_field( $key, $value, $object_id );
}
}
}

private function prepare_input_type_name( string $acf_field_name, string $parent_type_name ): string {
$prefix = '';
if ( ! empty( $parent_type_name ) ) {
$prefix = "{$parent_type_name}_";
}

return $prefix . ucfirst( Config::camel_case( $acf_field_name ) ) . 'Input';
}

/**
* Undocumented function
*
* @param array $config The GraphQL configuration of the field.
*
* @return array|null
*/
private function register_graphql_field( array $config, string $parent_type_name = '' ) {
$acf_field = isset( $config['acf_field'] ) ? $config['acf_field'] : null;
$acf_type = isset( $acf_field['type'] ) ? $acf_field['type'] : null;

if ( empty( $acf_type ) ) {
return null;
}

$field_config = [
'type' => null,
];

switch ( $acf_type ) {
case 'button_group':
case 'color_picker':
case 'email':
case 'text':
case 'message':
case 'oembed':
case 'password':
case 'wysiwyg':
case 'url':
case 'textarea':
$field_config['type'] = 'String';
break;
case 'radio':
$field_type = $this->config->register_choices_of_acf_fields_as_enum_type( $acf_field );
$field_config['type'] = $field_type;
break;
case 'select':

/**
* If the select field is configured to not allow multiple values
* the field will accept a string, but if it is configured to allow
* multiple values it will accept a list of strings
*/
$field_type = $this->config->register_choices_of_acf_fields_as_enum_type( $acf_field );
if ( empty( $acf_field['multiple'] ) ) {
$field_config['type'] = $field_type;
} else {
$field_config['type'] = [ 'list_of' => $field_type ];
}
break;
case 'number':
case 'range':
$field_config['type'] = 'Float';
break;
case 'true_false':
$field_config['type'] = 'Boolean';
break;
case 'date_picker':
case 'time_picker':
case 'date_time_picker':
$field_config = [
'type' => 'String',
'filter_value' => function( $value ) use ( $acf_type ) {
$timestamp = strtotime( $value );
if ( $timestamp !== false ) {
switch ( $acf_type ) {
case 'time_picker':
$value = gmdate( 'H:i:s', $timestamp );
break;
case 'date_picker':
$value = gmdate( 'Y-m-d', $timestamp );
break;
default:// 'date_time_picker'
$value = gmdate( 'Y-m-d H:i:s', $timestamp );
break;
}

return $value;
}
return null;
}
];
break;
case 'relationship':
$field_config['type'] = [ 'list_of' => 'ID' ];
break;
case 'image':
case 'file':
$field_config = [
'type' => 'String',
'filter_value' => function( $value ) {
$attachment_url = $value;
if ( ! empty( $attachment_url ) ) {
$attach_id = attachment_url_to_postid( $attachment_url );

if ( ! empty( $attach_id ) ) {
return $attach_id;
}
}
return null;
},
];
break;
case 'checkbox':
$field_config['type'] = [ 'list_of' => 'String' ];
break;
case 'taxonomy':
$is_multiple = isset( $acf_field['field_type'] ) && in_array( $acf_field['field_type'], [ 'checkbox', 'multi_select' ] );

$field_config['type'] = $is_multiple ? [ 'list_of' => 'ID' ] : 'ID';
break;
// Accordions are not represented in the GraphQL Schema.
case 'accordion':
$field_config = null;
break;
case 'group':

$field_type_name = $this->prepare_input_type_name( $acf_field['name'], $parent_type_name );

$sub_fields_config = $this->add_field_group_fields( $acf_field, $field_type_name );

if ( ! empty( $sub_fields_config ) ) {
$this->type_registry->register_input_type(
$field_type_name,
[
'description' => __( 'Field Group', 'wp-graphql-acf' ),
'fields' => $sub_fields_config,
]
);

$field_config = [
'type' => $field_type_name,
'sub_fields_config' => $sub_fields_config,
];
}
break;
case 'repeater':

$field_type_name = $this->prepare_input_type_name( $acf_field['name'], $parent_type_name );

$sub_fields_config = $this->add_field_group_fields( $acf_field, $field_type_name );

if ( ! empty( $sub_fields_config ) ) {
$this->type_registry->register_input_type(
$field_type_name,
[
'description' => __( 'Field Group', 'wp-graphql-acf' ),
'fields' => $sub_fields_config,
]
);

$field_config = [
'type' => [ 'list_of' => $field_type_name ],
'repeater_sub_fields_config' => $sub_fields_config,
'mutate' => function( $object_id, $object_type, $rows, $field_config ) use ( $sub_fields_config ) {
if ( ! empty( $rows ) && is_array( $rows ) ) {
foreach ( $rows as $row ) {
$row_value = [];
foreach ( $row as $sub_field_key => $sub_field_value ) {
$sub_field_config = $sub_fields_config[$sub_field_key];
if ( ! empty( $sub_field_config ) ) {
$sub_field_value = $this->maybe_filter_value( $sub_field_config, $sub_field_value );

if ( $sub_field_value !== null ) {
$acf_key = $this->get_field_key( $sub_field_config['acf_field'] );
$row_value[$acf_key] = $sub_field_value;
}
}
}

if ( ! empty( $row_value ) ) {
$this->update_acf_field_value( $object_id, $object_type, $row_value, $field_config, true );
}
}
}
},
];
}
break;
// case 'page_link':
// case 'post_object':
// case 'link':
// case 'gallery':
// case 'user':
// case 'google_map':
// case 'flexible_content':
// break;
default:
break;
}

if ( empty( $field_config ) || empty( $field_config['type'] ) ) {
return null;
}

return array_merge( $config, $field_config );
}

/**
* Adds acf field groups to GraphQL Mutations.
*/
private function add_acf_fields_to_graphql_types() {
/**
* Loop over all the field groups
*/
foreach ( $this->field_groups as $field_group ) {

$field_group_name = isset( $field_group['graphql_field_name'] ) ? $field_group['graphql_field_name'] : $field_group['title'];
$field_group_name = Utils::format_field_name( $field_group_name );

$manually_set_graphql_types = isset( $field_group['map_graphql_types_from_location_rules'] ) ? (bool) $field_group['map_graphql_types_from_location_rules'] : false;

if ( false === $manually_set_graphql_types ) {
if ( ! isset( $field_group['graphql_types'] ) || empty( $field_group['graphql_types'] ) ) {
$field_group['graphql_types'] = [];
if ( isset( $this->location_rules[ $field_group_name ] ) ) {
$field_group['graphql_types'] = $this->location_rules[ $field_group_name ];
}
}
}

if ( ! is_array( $field_group['graphql_types'] ) || empty( $field_group['graphql_types'] ) ) {
continue;
}

/**
* Determine if the field group should be exposed
* to graphql
*/
if ( ! $this->config->should_field_group_show_in_graphql( $field_group ) ) {
continue;
}

$graphql_types = array_unique( $field_group['graphql_types'] );
$graphql_types = array_filter( $graphql_types );

/**
* Prepare default info
*/
$field_name = isset( $field_group['graphql_field_name'] ) ? $field_group['graphql_field_name'] : Config::camel_case( $field_group['title'] );
$field_group['type'] = 'group';
$field_group['name'] = $field_name;
$config = [
'name' => $field_name,
'acf_field' => $field_group,
'acf_field_group' => null,
];

$qualifier = sprintf( __( 'Added to the GraphQL Schema because the ACF Field Group "%1$s" was set to Show in GraphQL.', 'wp-graphql-acf' ), $field_group['title'] );
$config['description'] = $field_group['description'] ? $field_group['description'] . ' | ' . $qualifier : $qualifier;

$field_config = $this->register_graphql_field( $config );
if ( ! empty( $field_config ) ) {
/**
* Loop over the GraphQL types for this field group on
*/
foreach ( $graphql_types as $graphql_type ) {
$this->type_registry->register_field( "Create{$graphql_type}Input", $field_name, $field_config );
$this->type_registry->register_field( "Update{$graphql_type}Input", $field_name, $field_config );
}

$this->registered_fields[$field_name] = $field_config;
}
}
}

/**
* Register an action to run exactly one time.
*
* The arguments match that of add_action(), but this function will also register a second
* callback designed to remove the first immediately after it runs.
*
* @param string $hook_name The name of the action to add the callback to.
* @param callable $callback The callback to be run when the action is called.
* @param int $priority Optional. Used to specify the order in which the functions
* associated with a particular action are executed.
* Lower numbers correspond with earlier execution,
* and functions with the same priority are executed
* in the order in which they were added to the action. Default 10.
* @param int $accepted_args Optional. The number of arguments the function accepts. Default 1.
* @return bool Like add_action(), this function always returns true.
*/
public static function add_action_once( string $hook_name, callable $callback, int $priority = 10, int $accepted_args = 1 ): bool {
$singular = function () use ( $hook_name, $callback, $priority, $accepted_args, &$singular ) {
$should_be_removed = call_user_func_array( $callback, func_get_args() );
if ( false !== $should_be_removed ) {
remove_action( $hook_name, $singular, $priority );
}
};

return add_action( $hook_name, $singular, $priority, $accepted_args );
}
}