underpin icon indicating copy to clipboard operation
underpin copied to clipboard

Introduce Integrations API

Open alexstandiford opened this issue 2 years ago • 2 comments

Something I've been thinking about a lot for a few years now is how nice it would be to integrate Underpin in a way that makes re-using code between plugins easier. A way to-do this would be by building different plugin "types", wrangling up as much of the common functionality as possible, and running them through some kind-of consistent pattern that can be re-used throughout other plugins.

This could drastically improve plugin development speeds, and make working with third party plugins significantly easier.

TL;DR

I think Underpin needs a way to load in pre-made integration classes that contain all of the information needed to work with different plugin types in a consistent fashion. The goal would be to make it so that long as a developer sticks to the methods that are included in the Underpin integration class their plugin should be able to be compatible with all of the plugins that work with the integration for free.

From this, we could build integration packages for Underpin that pre-make the integrations form common plugins. These could be installed, and used directly.

Example:

// Submit a form using an active forms plugin
// everything after forms() would come from a composer package that extends Underpin. Probably underpin/forms-integration or similar.
$form = underpin()->integrations()->forms()->find(['active' => true]);

if( !is_wp_error( $form ) ){
    $form->submit( 1, ['key' => 'value'] );
}

This would allow developers to integrate with several plugins at one time, and also prevent common pitfalls with coupling their code directly with a forms plugin directly.

This could also make it possible for theme developers to integrate with more plugins without re-writing a bunch of code in the process.

The Scenario:

You build a plugin for a customer that integrates Ninja Forms in some cool way. Later on, you need to do something identical for another customer, only this time you have to set it up to work with Gravity Forms, instead.

To accomplish this in Underpin, I would probably create a custom loader called integrations that would actually use whichever plugin is installed. Something like this:

// Create the integration class
abstract class Integration{
    use Feature_Extension;

    abstract public function get_form( $form_id );

    abstract public function submit( $form_id, $data );

    abstract public function fields( $form_id );
}

class Integration_Instance extends Integration{
    	use Instance_Setter;

	protected $custom_form_action;

	/**
	 * Block_Instance constructor.
	 *
	 * @param array $args Overrides to default args in the object
	 */
	public function __construct( array $args = [] ) {
		$this->set_values( $args );

		parent::__construct();
	}

	public function get_form( $form_id ){
		return $this->set_callable( $this->get_form_action, form_id );
	}

 	 abstract function submit( $form_id, $data ){
		return $this->set_callable( $this->submit_action, form_id, $data );
	}

	abstract function fields( $form_id ){
		return $this->set_callable( $this->fields_action, form_id );
	}
}

// Register the custom loader
underpin()->loaders()->add('integrations',[
    'abstraction_class' => 'Integration',
    'default_factory'     => 'Integration_Instance'
]);

// Register Ninja Forms Integration
underpin()->integrations()->add('ninja_forms', [
  'get_form_action' => function( $form_id ){
    return Ninja_Forms()->form( 1 )->get();
  }
 /* Plus all the other callable actions */
]);

// Register Gravity Forms Integration
underpin()->integrations()->add('gravity_forms', [
  'get_form_action' => function( $form_id ){
    return GFAPI::get_form( $form_id );
  },
 /* Plus all the other callable actions */
]);

$ninja_forms_integration = underpin()->integrations()->get('ninja_forms'); //Do things with the Ninja Forms plugin
$gravity_forms_integration = underpin()->integrations()->get('gravity_forms'); //Do Things with the Gravity Forms plugin

// Gets the form using Ninja Forms.
$ninja_forms_integration->get_form();

// Gets the form using Gravity Forms.
$gravity_forms_integration->get_form();

How Could this be Improved?

This is a common scenario - building a third party plugin that needs to integrate with multiple, similar plugins. It would be nice if Underpin had some libraries pre-built to make it possible to work with these systems directly. So take all of the example code above, modify it to work as an integration, and make these work as loaders. This integration could be loaded in using composer, perhaps something like composer require underpin/forms-integration, which would contain all of the information needed to work with any forms plugin that is registered in the system. So, as long as a developer sticks to the methods that are included in Underpin's integration class, their plugin should be able to be compatible with all of the plugins that work with the integration for free.

This would allow developers to integrate with several plugins at one time, and also prevent common pitfalls with coupling their code directly with a forms plugin directly.

This could also make it possible for theme developers to integrate with more plugins without re-writing a bunch of code in the process.

Here's a few possible patterns:

// Get the integration class to work with the plugin.
// The intent is to make it so that this class would replace the need to use any direct functions or methods inside any forms plugin
$form_plugin = underpin()->integrations()->forms()->get( 'ninja_forms' );

$form_plugin->submit();

$form_plugin->get_fields();

Since this is technically a loader registry, you could also query registered integrations, and run something on each:

// Query integrations
$integrations = underpin()->integrations()->forms()->find([
  'features__in' => 'stored_submissions', // Only get form plugins that actually store submissions
]);

foreach( $integrations as $integration ){
    if($integration->is_active()){
      // Do something with the active integration
    }
}

class Integrations extends Loader_Registry{
  // Set up integrations as a loader registry. This would be built into Underpin, and be accessible via integrations()
  // It would probably have a __call method similar to the base Underpin class.
}

abstract class Integration{
  abstract public function is_active();
}

// Create the integration class
abstract class Form_Integration extends Integration{
    use Feature_Extension;

    abstract public function get_form( $form_id );

    abstract public function submit( $form_id, $data );

    abstract public function fields( $form_id );
}

class Form_Integration_Instance extends Form_Integration{
    	use Instance_Setter;

	protected $get_form_action;

	/**
	 * Block_Instance constructor.
	 *
	 * @param array $args Overrides to default args in the object
	 */
	public function __construct( array $args = [] ) {
		$this->set_values( $args );

		parent::__construct();
	}

	public function get_form( $form_id ){
		return $this->set_callable( $this->get_form_action, form_id );
	}

 	 abstract function submit( $form_id, $data ){
		return $this->set_callable( $this->submit_action, form_id, $data );
	}

	abstract function fields( $form_id ){
		return $this->set_callable( $this->fields_action, form_id );
	}
}

// Register the custom loader
underpin()->integrations()->add('forms',[
    'abstraction_class' => 'Form_Integration',
    'default_factory'     => 'Form_Integration_Instance'
]);

// Register Ninja Forms Integration
underpin()->integrations()->forms()->add('ninja_forms', [
  'get_form_action' => function( $form_id ){
    return Ninja_Forms()->form( $form_id )->get();
  }
 /* Plus all the other callable actions */
]);

// Register Gravity Forms Integration
underpin()->integrations()->forms()->add('gravity_forms', [
  'get_form_action' => function( $form_id ){
    return GFAPI::get_form( $form_id );
  },
 /* Plus all the other callable actions */
]);

$ninja_forms_integration = underpin()->integrations()->forms()->get('ninja_forms'); //Do things with the Ninja Forms plugin
$gravity_forms_integration = underpin()->integrations()->forms()->get('gravity_forms'); //Do Things with the Gravity Forms plugin

// Submits the form using Ninja Forms.
$ninja_forms_integration->submit( 1, ['key' => 'value'] );

// Submits the form using Gravity Forms.
$gravity_forms_integration->submit( 1, ['key' => 'value'] );

Now with the example above, let's say you wanted to automatically submit a form when something else happens on your site. You don't actually care what forms plugin is used, you just want the data to be submitted.


// Helper function to get the current forms integration
function get_forms_integration(){
    $current_integration = underpin()->options()->get( 'integration' )->get();

    $integration = underpin()->integrations()->forms()->get( $current_integration );

    if( is_wp_error( $integration ) ){
        underpin()->logger()->log_wp_error( $integration );
    }

    return $integration;
}

// Now a form submission will occur, regardless of what forms plugin was used.
add_action( 'my_super_cool_custom_hook', function( $data ){
    if(!is_wp_error(get_forms_integration()){
        get_forms_integration()->submit( 1, $data );
    }
} );

Other potential integrations that could work with this:

  1. E-Commerce plugins (EDD, WooCommerce, LifterLMS, LearnDash)
  2. Affiliate Plugins (AffiliateWP, SliceWP)
  3. Forms Plugins (Ninja Forms, Gravity Forms, Formidable Forms)
  4. SEO Plugins (Yoast, All in One SEO)
  5. Calendar Plugins (The Events Calendar, Sugar Calendar)
  6. LMS Plugins (LearnDash, LifterLMS)

I'm sure there's many more.

So What Needs Changed?

  1. Underpin needs to support a way to create, and register integrations. This pattern is already well-established in other parts of Underpin, such as loaders, so this is pretty straightforward.
  2. The example above only talks about working with the form, but what happens if we want to do something like get_forms_integration()->get_fields()? The resulting array of fields needs to be normalized in a standard Field object, so that the signature doesn't change between plugins. Something like get_forms_integration()->get_fields()->get_value() should work for every single type of forms plugin. Underpin might need to have some kind of abstraction to facilitate this.
  3. We need to be able to call the integrations registry by calling plugin_name()->integrations(), and the Integrations class should extend Loader_Registry so we can use helpers like find and filter.
  4. There needs to be a way to hook into the integrations loader and register integrations via a composer package. This may not need any additional code - it just depends on if we can hook into integrations in the same way we currently hook into loaders. Example.
  5. There needs to be a way to filter out integrations based on what they can, and cannot support. For example, not all forms plugins actually store the form submission data in the database. This should be stored as a hard variable, not a function, so the filter and find methods can query against it.
  6. It would be nice if it were possible to filter against which integration plugins are active, as well. This probably needs to run inside of a methods is_active, but it would be useful if the system knew that when active is added to filter/find that it would filter those plugins out.

This implementation probably needs to be coupled with a pre-built integration. This will help us figure out the nuance of working with a system like this, and also give us a concrete example of how to use/extend integrations.

alexstandiford avatar Nov 05 '21 20:11 alexstandiford

It would also be very nice to be able to tie actions to standardized sets, as well. This would drastically simplify a lot of logic around writing integrations. For example, let's say you wanted to add some kind-of hook on any event plugin that's activated, and supports event creation:

foreach ( underpin()->integrations()->events()->filter( [ 'is_active' => true, 'supports' => 'event_creation' ] ) as $integration ) {
	// Behind the scenes, 'event_created' is basically a proxy for whatever the actual action is specific to this plugin.
	// This also may give us a chance to apply an adapter to the event item that is passed.
	$integration->add_action( 'event_created', function ( Adapted_Event $event ) {
		// Do something with this event.
	} );
}

Now, instead of needing to write custom integration classes for every integration, you only need to ensure the hooks are supported by the integration built-into underpin()->integrations()->events()

alexstandiford avatar Nov 16 '21 04:11 alexstandiford

I think it's clear that most of this functionality won't exist inside Underpin, but it will exist as packages that work with Underpin.

The question is, what needs to be in Underpin in-order to facilitate this functionality?

alexstandiford avatar Nov 30 '21 13:11 alexstandiford