Skip to content

Class structure and logic

JIX edited this page Jul 14, 2020 · 1 revision

General overview

Ultimate Fields uses a sophisticated class hierarchy. The more important classes in the plugin are:

Class name Purpose
Ultimate_Fields\Container The container is a hub that connect all other classes, having relatively few own properties.
Ultimate_Fields\Location Every location extends the Ultimate_Fields\Location class, adding additional rules and properties.
Ultimate_Fields\Controller For every location class, there is a controller and it links the container, which the location is associated to with the physical location where it will be displayed.
Ultimate_Fields\Datastore Datastore connect fields and container to the database.
Ultimate_Fields\Field Fields display inputs to let the user enter data and save it in a given datastore.

To better explain the logic here, let's take a look at an example and explore it step by step:

Container::create( 'Demo Container' )
	->add_location( 'post_type', 'page' )
	->add_fields(array(
		Field::create( 'text', 'demo_field' )
	));

First we create a container. Containers don't have child classes, so you are simply instantiating the base class. At this point the container will not be shown anywhere and does not have any fields.

The next thing that we do is to call the add_location method in order to let the container be displayed on page edit screens. This method is actually masking a big part of the connection between the container and the post edit screen, but we will invest this in a moment.

The last thing we're doing is to create and add a single field to the container. Fields in the plugin don't actually hold any values within themselves, so the field is virtually independent from the container and its data. The only result of this action is the awareness of the container, that it should use the field when being displayed and saved.

Container-Location-Controller

Above we masked that a lot is happening behind the curtains when the add_location method of a container is called. Let's see what happens when this line is executed:

->add_location( 'post_type', 'page' )

The first task of the container is to determine the location class, which is associated with post_type. This happens by concatenating Ultimate_Fields\Location\ with an upper-cased version of post_type, leading to Ultimate_Fields\Location\Post_Type. Then the constructor of that class is called with the rest of the arguments for add_location. Effectively the line above translates to:

$container->add_location( new UF\Location\Post_Type( 'page' ) );

The next action, after the location class has been determined and an instance has been created looks like this:

$controller = $location->get_controller();
$controller->attach( $this, $location );

In the last line the container attaches itself to a controller, generated by the location, along with the location itself. Controllers are the classes that handle the display, saving, validation and etc. processi. In this case the Ultimate_Fields\Location\Post_Type object returns a Ultimate_Fields\Controller\Post_Type controller. This controller adds metaboxes to edit screens, displays the containers within those screens and saves their data on the save_post action.

This logic allows a single container to work with multiple locations, using different, yet similar rules.

Container::create( 'Section Details' )
	->add_location( 'post_type', 'page', array(
		'templates' => 'template-home.php'
	))
	->add_location( 'post_type', 'page', array(
		'levels'    => 1
	))
	->add_fields(array( /*...*/ ));

In this example we are adding two locations to the container. Both of them will appear on the same post type, page, but one of them has additional rules based on template, while the other one has rules based on hierarchical levels. The container will be visible when either of those rules are satisfied, which means that we should only have the container once on the page, instead of twice.

As a high-level generalization, here is what controllers do:

  • They group location rules based on the container and then smartly combine those rules in an OR relationship for when the time comes to validate and display the container.
  • When saving, the validation process for a whole screen is initiated by controllers, meaning that all containers must report that their data is valid before any of them saves its data.
  • Ensures that multiple fields with the same names cannot be used within the same context
  • Manages admin columns
  • During database read-write operations, link containers to the proper datastore.

Datastores

Datastores are objects, which hold key-value data pairs. They are shared between controllers, containers, locations and fields and provide an interface between them and the database.

For most locations, there is a specific datastore, which uses a particular set of WordPress APIs. For example, Ultimate_Fields\Datastore\Post_Meta works with get_post_meta, update_post_meta and delete_post_meta. The specific datastore type is generated at runtime by every location.

Next you will see an abstract implementation of the saving process, with extremely simple classes. This way you can see how all of the classes so far interact with each-other.

class Container {
	protected $fields = array();
	protected $datastore;

	public function add_location( $location ) {
		$controller = $location->get_controller();
		$controller->attach( $this, $location );
	}

	public function add_field( $field ) {
		$this->fields[] = $field;
	}

	public function set_datastore( $datastore ) {
		$this->datastore = $datastore;
	}

	public function save( $data ) {
		$errors = array();
		
		foreach( $this->get_fields() as $field ) {
			$field->set_datastore( $this->datastore );
			$error = $field->save( $data );

			if( ! is_null( $error ) ) {
				$errors[] = $error;
			}
		}

		return $errors;
	}
}

class Location {
	public function get_controller() {
		static $controller;

		if( is_null( $controller ) ) {
			$controller = new Controller;
		}

		return $controller;
	}

	public function generate_datastore( $post_id ) {
		$datastore = new Datastore;
		$datastore->set_id( $post_id );
		return $datastore;
	}
}

class Field {
	protected $datastore;

	public function set_datastore( $datastore ) {
		$this->datastore = $datastore;
	}
	
	public function save( $data ) {
		if( isset( $data[ $this->name ] ) ) {
			$this->datastore->set( $this->name, $data[ $this->name ] );
		} else {
			return 'Error!';
		}
	}
}

class Datastore {
	var $data = array();
	var $id;

	public function set_id( $id ) {
		$this->id = $id;
	}

	public function set( $key, $value ) {
		$this->data[ $key ] = $value;
	}

	public function save() {
		foreach( $this->data as $key => $value ) {
			update_post_meta( $this->id, $key, $value );
		}
	}
}

class Controller {
	protected $combinations = array();

	function __construct() {
		add_action( 'save_post', array( $this, 'save' ) );
	}

	public function attach( $container, $location ) {
		$id = $container->get_id();

		if( array_key_exists( $id, $this->combinations ) ) {
			$this->combinations[ $id ][ 'locations' ][] = $location;
		} else {
			$this->combinations[ $id ] = array(
				'container' => $container,
				'locations' => array( $location )
			);
		}
	}

	public function save( $post_id ) {
		$datastores = array();
		$errors     = array();

		foreach( $this->combinations as $combination ) {
			$container = $combination[ 'container' ];

			foreach( $combination[ 'locations' ] as $location ) {
				$datastore = $location->create_datastore( $post_id );

				if( ! $datastore ) {
					continue;
				}

				$container->set_datastore( $datastore );
				$errors = array_merge( $errors, $container->save( $_POST[ $container->get_id() ] ) );
				$datastores[] = $datastore;

				break;
			}
		}

		if( ! empty( $errors ) ) {
			// Display errors :(
		}

		foreach( $datastores as $datastore ) {
			$datastore->save();
		}

		// Success!
	}
}

The excerpt is a bit long, but illustrates how datastores are carried along the way:

  1. Locations generate a datastore
  2. The container instructs fields to dump their values into the datastore
  3. The controller saves the final result, given that there are no errors.

Another important reason for the usage of datastores is that this way a field can be isolated from its parent and we can avoid recursion within our class.

Clone this wiki locally