Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Experiments API #3098

Draft
wants to merge 2 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/Experimental/Admin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php
/**
* WordPress Dashboard functionality for Experiments.
*
* @package WPGraphQL\Experimental
*/

namespace WPGraphQL\Experimental;

/**
* Class - Admin
*/
class Admin {
/**
* The name of the option group
*
* @var string
*/
public static $option_group = 'graphql_experiments_settings';

/**
* Initialize Admin functionality for Experiments
*/
public function init(): void {
$this->register_settings();
}

/**
* Registers the Experiments section to the WPGraphQL Settings page.
*/
private function register_settings(): void {
// Register the section.
register_graphql_settings_section(
self::$option_group,
[
'title' => __( 'Experiments 🧪', 'wp-graphql' ),
'desc' => __( 'WPGraphQL Experiments are experimental features that are under active development. They may change, break, or disappear at any time.', 'wp-graphql' ),
]
);

$experiments = ExperimentRegistry::get_experiments();

// If there are no experiments to register, display a message.
if ( empty( $experiments ) ) {
register_graphql_settings_field(
self::$option_group,
[
'label' => __( 'No Experiments Available', 'wp-graphql' ),
'desc' => __( 'There are no experiments available at this time.', 'wp-graphql' ),
'type' => 'html',
]
);
return;
}

// Register the toggle for each Experiment.
$toggle_fields = [];

foreach ( $experiments as $experiment ) {
$config = $experiment->get_config();

$toggle_fields[] = [
'name' => $experiment->get_slug() . '_enabled',
'label' => $config['title'],
'desc' => $config['description'],
'type' => 'checkbox', // @todo we probably want a better type callback.
'default' => 'off',
'value' => $experiment->is_active() ? 'on' : 'off',
'disabled' => defined( 'GRAPHQL_EXPERIMENTAL_FEATURES' ),
];
}

register_graphql_settings_fields(
self::$option_group,
$toggle_fields
);
}
}
204 changes: 204 additions & 0 deletions src/Experimental/Experiment/AbstractExperiment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<?php
/**
* Abstract class for creating new experiments.
*
* ALL experiments should extend this class.
*
* @package WPGraphQL\Experimental\Experiment
* @since @todo
*/

namespace WPGraphQL\Experimental\Experiment;

use WPGraphQL\Experimental\Admin;

/**
* Class - Abstract Experiment
*/
abstract class AbstractExperiment {
/**
* The experiment unique slug.
*
* @var ?string
*/
protected static $slug;
/**
* The experiment's configuration.
*
* @var ?array{title:string,description:string}
*/
protected $config;

/**
* Whether the experiment is active.
*
* @var ?bool
*/
protected $is_active;

/**
* Defines the experiment slug.
*/
abstract protected static function slug(): string;

/**
* Defines the experiment configuration.
*
* @return array{title:string,description:string}
*/
abstract protected function config(): array;

/**
* Initializes the experiment.
*
* I.e where you put your hooks.
*/
abstract protected function init(): void;

/**
* Loads the experiment.
*
* @uses AbstractExperiment::init() to initialize the experiment.
*/
public function load(): void {
if ( ! $this->is_active() ) {
return;
}

$this->init();

/**
* Fires after the experiment is loaded.
*
* @param \WPGraphQL\Experimental\Experiment\AbstractExperiment $instance The experiment instance.
*/
do_action( 'wp_graphql_experiment_' . $this->get_slug() . '_loaded', $this );
}

/**
* Gets the experiment's configuration array.
*
* @return array{title:string,description:string}
*/
public function get_config(): array {
if ( ! isset( $this->config ) ) {
$this->config = $this->prepare_config();
}

return $this->config;
}

/**
* Returns the experiment's slug.
*
* This is static so it can be accessed outside of the class instantiation.
*
* @throws \Exception If the experiment is missing a slug.
*/
public static function get_slug(): string {
if ( ! isset( static::$slug ) ) {
$slug = static::slug();

if ( empty( $slug ) ) {
throw new \Exception(
sprintf(
/* translators: %s: The experiment's class name. */
esc_html__( 'The experiment %s is missing a slug. Ensure a valid `slug` is defined in the ::slug() method.', 'wp-graphql' ),
static::class
)
);
}

static::$slug = $slug;
}

return static::$slug;
}

/**
* Whether the experiment is active.
*/
public function is_active(): bool {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see the phpDoc for some methods are missing @return. Is this intentional?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, if the return type hint is identical to the signature

if ( isset( $this->is_active ) ) {
return $this->is_active;
}

// See if the experiment is set via the constant.
$is_active = defined( 'GRAPHQL_EXPERIMENTAL_FEATURES' ) && is_array( GRAPHQL_EXPERIMENTAL_FEATURES ) && isset( GRAPHQL_EXPERIMENTAL_FEATURES[ static::get_slug() ] ) ? (bool) GRAPHQL_EXPERIMENTAL_FEATURES[ static::get_slug() ] : null;

if ( ! isset( $is_active ) ) {
$setting_key = static::get_slug() . '_enabled';

$is_active = 'on' === get_graphql_setting( $setting_key, 'off', Admin::$option_group );
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per @renatonascalves other comment, this could be a good spot for a filter, e.g. if Experiment A requires Experiment B to be active.

$this->is_active = $is_active;

return $this->is_active;
}

/**
* Prepares the configuration.
*
* @return array{title:string,description:string}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we using phpstan now?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've been using it for a while. I'll probably unseal the type before final merge, but for now it makes sure nothing unnecessary is leaking in to the PR

*
* @throws \Exception If the experiment is missing a slug.
*/
protected function prepare_config(): array {
$slug = static::get_slug();
$config = $this->config();

/**
* Filters the experiment configuration.
*
* @param array{title:string,description:string} $config The experiment configuration.
* @param string $slug The experiment's slug.
*/
$config = apply_filters( 'wp_graphql_experiment_config', $config, $slug );
$config = apply_filters( 'wp_graphql_experiment_' . $slug . '_config', $config );

// Validate the config.
$this->validate_config( $config );

return $config;
}

/**
* Validates the $config array, throwing an exception if it's invalid.
*
* @param array<string,mixed> $config The experiment configuration.
*
* @throws \Exception If the config is invalid.
*/
protected function validate_config( array $config ): void {
if ( empty( $config ) ) {
throw new \Exception(
sprintf(
/* translators: %s: The experiment's class name. */
esc_html__( 'The experiment %s is missing a configuration. Ensure a valid `config` is defined in the ::config() method.', 'wp-graphql' ),
static::class
)
);
}

if ( ! isset( $config['title'] ) || ! is_string( $config['title'] ) ) {
throw new \Exception(
sprintf(
/* translators: %s: The experiment's class name. */
esc_html__( 'The experiment %s is missing a title in the configuration. Ensure a valid `title` is defined in the ::config() method.', 'wp-graphql' ),
static::class
)
);
}

if ( ! isset( $config['description'] ) || ! is_string( $config['description'] ) ) {
throw new \Exception(
sprintf(
/* translators: %s: The experiment's class name. */
esc_html__( 'The experiment %s is missing a description in the configuration. Ensure a valid `description` is defined in the ::config() method.', 'wp-graphql' ),
static::class
)
);
}
}
}
58 changes: 58 additions & 0 deletions src/Experimental/Experiment/TestExperiment.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php
/**
* An Example Experiment
*
* @package WPGraphQL\Experimental\Experiment
*/

namespace WPGraphQL\Experimental\Experiment;

use WPGraphQL\Experimental\Experiment\AbstractExperiment;

/**
* Class - TestExperiment
*/
class TestExperiment extends AbstractExperiment {
/**
* {@inheritDoc}
*/
protected static function slug(): string {
return 'test_experiment';
}

/**
* {@inheritDoc}
*/
protected function config(): array {
return [
'title' => __( 'Test Experiment', 'wp-graphql' ),
'description' => __( 'A test experiment for WPGraphQL. Registers the `RootQuery.testExperiment` field to the schema.', 'wp-graphql' ),
];
}

/**
* Initializes the experiment.
*
* I.e where you put your hooks.
*/
public function init(): void {
add_action( 'graphql_init', [ $this, 'register_field' ] );
}

/**
* Registers the field for the experiment.
*/
public function register_field(): void {
register_graphql_field(
'RootQuery',
'testExperiment',
[
'type' => 'String',
'description' => 'A test field for the Test Experiment.',
'resolve' => static function () {
return 'This is a test field for the Test Experiment.';
},
]
);
}
}