Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
72b4e56
Build admin settings screen
Nov 2, 2025
013f8e7
Add test coverage for Example_Feature register and render methods
Nov 2, 2025
379e44f
Refactor React components into separate files
Nov 2, 2025
0fc0a70
Include build directory for plugin activation
Nov 2, 2025
7774324
Enable JavaScript linting in test workflow
Nov 2, 2025
a7eacdc
Format code with Prettier
Nov 2, 2025
bfc6154
Revert "Format code with Prettier"
Nov 2, 2025
0194419
Stop tracking build output
Nov 2, 2025
08f883c
Disable JS lint workflow again
Nov 2, 2025
89394cc
Remove generated ai.zip
Nov 2, 2025
876955d
Fix JS lint violations
Nov 2, 2025
cf77782
Restore bootstrap baseline
Nov 2, 2025
db48a13
Re-enable JS lint workflow
Nov 2, 2025
b6740e2
Fix plugin path constants for assets
Nov 2, 2025
6922346
Document admin settings architecture
Nov 2, 2025
cf7fc73
Restore footer illustration in developer guide
Nov 2, 2025
3035c5f
Resolve developer guide footer conflict
Nov 2, 2025
f11fdf4
Merge branch 'trunk' into feature/admin-settings-screen-issue-25
Ref34t Nov 2, 2025
20441b0
Silence phpstan require warning
Nov 3, 2025
2aca4bb
Refactor admin asset loader
Nov 3, 2025
87c57b3
Pass toggles service to default feature filter
Nov 3, 2025
48b2b28
Include build directory in package files
Nov 3, 2025
d2cc9a5
Build bundle before packaging
Nov 3, 2025
bedc00a
Fallback to Playground comment when body update fails
Nov 3, 2025
092ee3b
Guard Playground automation for upstream PRs
Nov 3, 2025
3bd52bf
Revert Playground automation guard
Nov 3, 2025
0d6537b
Re-run Playground workflow for testing
Nov 3, 2025
b6155d5
Restore Playground PR comment fallback
Nov 9, 2025
ce9c0d0
Refactor feature lifecycle and admin UI structure
Nov 9, 2025
b40d86b
Use abstract default-enabled hook directly
Nov 9, 2025
e799aae
Fix coding standard warnings
Nov 9, 2025
33fe657
Refresh developer guide for new structure
Nov 9, 2025
af8ef99
Align assignments per coding standards
Nov 9, 2025
dd1c67f
Tidy alignment per WPCS
Nov 9, 2025
1b1afb8
Fix indentation in feature toggle resolver
Nov 9, 2025
9d3121a
Normalize trait assignment alignment
Nov 9, 2025
d5e2afc
DRY admin settings page title
Nov 9, 2025
b9c502c
Keep notice visible during repeat toggles
Nov 9, 2025
fbe3665
Pin notice placeholder to prevent layout jump
Nov 9, 2025
57ee557
Prevent settings card jump when spinner shows
Nov 9, 2025
52a83e7
Align global toggle layout with feature cards
Nov 9, 2025
b7e6e43
Remove redundant helper copy
Nov 9, 2025
716750e
Decouple global vs feature saving state
Nov 9, 2025
77b7d22
Drop unused helper card body
Nov 9, 2025
d42255b
Add breathing room above global toggle card
Nov 9, 2025
229ed80
Keep feature cards rendered regardless of master toggle
Nov 9, 2025
ba48de1
Use WP_AI_DIR for style file path
Nov 9, 2025
8f44aad
Refine Asset_Loader includes
Nov 9, 2025
092d239
Import Asset_Loader in settings assets
Nov 9, 2025
6a56e7e
Use FQCN for Asset_Loader calls
Nov 9, 2025
3784468
Remove unused CardDivider import
Nov 9, 2025
16ab9e0
Align asset loader with shared implementation
Nov 9, 2025
5e0f0da
Provide helper for admin page title translation
Nov 9, 2025
b9714ee
Stabilize header spinner and fix alignment
Nov 9, 2025
4d40ed4
Remove global toggle spinner
Nov 9, 2025
07bbc1c
Clarify admin layout in developer guide
Nov 9, 2025
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
48 changes: 36 additions & 12 deletions .github/workflows/pull-request-comments.yml
Copy link
Collaborator

Choose a reason for hiding this comment

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

Looking at the changes here, it seems to avoid permission issues we're adding a comment instead of updating the PR description. I have a separate PR (#65) that fixes permission issues. I'd suggest we remove these changes from this PR and can continue discussion on that other PR to keep things more focused here

Copy link
Collaborator

Choose a reason for hiding this comment

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

Note I've brought the code changes here that ensure we have a PR description over to #65, though I didn't bring over the changes that add a comment instead of updating the PR description

Copy link
Contributor Author

Choose a reason for hiding this comment

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

reverted here.

Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,20 @@ jobs:
let foundString = false;

// Check PR body.
if (pr.body.includes(searchString)) {
if (pr.body && pr.body.includes(searchString)) {
foundString = true;
}

if (!foundString) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});

foundString = comments.some(comment => comment.body && comment.body.includes(searchString));
}

// Set outputs.
core.setOutput('string_found', foundString);

Expand Down Expand Up @@ -123,14 +133,28 @@ jobs:
[Click here to test this pull request](${blueprintUrl}).`;

// Append the comment to the existing PR body.
const updatedBody = pr.body + '\n\n' + playgroundComment;

// Update the PR body.
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
body: updatedBody
});

console.log('Successfully updated PR body with Playground comment');
const updatedBody = `${pr.body ?? ''}\n\n${playgroundComment}`.trim();

try {
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
body: updatedBody
});

console.log('Successfully updated PR body with Playground comment');
} catch (error) {
if (error.status === 403) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: playgroundComment
});

console.log('Posted Playground comment instead of updating PR body due to permissions');
} else {
throw error;
}
}
1 change: 0 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,6 @@ jobs:
permissions:
contents: read
timeout-minutes: 20
if: false # Temporarily disabled

steps:
- name: Checkout repository
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ node_modules/
.DS_Store

# Build
/build/
/dist/
/build/
Copy link
Collaborator

Choose a reason for hiding this comment

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

Seems this change just swaps the position of these two, any reason for that?

Copy link
Member

Choose a reason for hiding this comment

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

I think there was a commit that removed the build directory and then in a later commit it was added it back, just in a slightly different location

Copy link
Contributor Author

Choose a reason for hiding this comment

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

resolved


# Testing
/tests/wordpress/
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"scripts": {
"format": "phpcbf --standard=phpcs.xml.dist",
"lint": "phpcs --standard=phpcs.xml.dist",
"phpstan": "phpstan analyse --memory-limit=1G",
"stan": "phpstan analyse --memory-limit=1G",
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is there a reason for this naming change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just to have the same way as the rest. can be reverted if it doesn't make sense for you

"test": "phpunit --strict-coverage"
},
"scripts-descriptions": {
Expand Down
101 changes: 88 additions & 13 deletions docs/DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ ai/
│ └── Example_Feature/ # Each feature in own directory
│ ├── Example_Feature.php
│ └── README.md
├── admin/ # Admin interface (planned)
├── admin/ # Admin settings services, controllers, assets
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this supposed to be includes/Admin?

Would be good to list the subnamespaces and primary files too.

├── assets/ # CSS, JS, images
├── docs/ # Documentation
│ ├── DEVELOPER_GUIDE.md # This guide
Expand Down Expand Up @@ -213,6 +213,81 @@ Examples of how to use the feature.
Any settings or filters available.
```

## Admin Settings Architecture

The admin settings screen allows site administrators to manage AI Experiments globally and per feature. The PHP services live under `includes/Admin/` and the React application under `src/`.

```
includes/
├── Admin/
│ ├── Admin_Settings_Page.php # Registers the options page and fallback markup
│ ├── Settings_Page_Assets.php # Enqueues the React bundle when viewing the page
│ ├── Settings_Payload_Builder.php # Builds the data passed to the React app
│ └── Settings/
│ ├── Feature_Toggles.php # Persists per-feature enable/disable state
│ ├── Settings_Renderer.php # Renders the fallback UI for the toggle section
│ ├── Settings_Registry.php # Registry of settings sections registered by features
│ ├── Settings_Section.php # Immutable value object describing a section
│ ├── Settings_Service.php # Coordinates registration of toggles, sections, and page
│ └── Settings_Toggle.php # Manages the global experiments option
└── Features/
└── Traits/
└── Provides_Settings_Section.php # Helper trait for feature-owned sections

src/
├── index.tsx # React entry point mounted on the admin page
├── components/
│ ├── App.tsx # Top-level application component
│ ├── FeatureSection.tsx # Card UI for per-feature toggles
│ └── ToggleSection.tsx # Card UI for the global toggle
├── types.ts # Shared payload types
├── style.scss # Styles for the settings screen
└── global.d.ts # Ambient declaration for the payload on window
```

`includes/bootstrap.php` wires the settings services on the `init` hook via `initialize_admin_settings()`. That function:

1. Instantiates the toggle, registry, renderer, payload builder, page assets handler, and admin page controller.
2. Registers the shared `Feature_Toggles` service on the `ai_feature_toggles_service` filter so features can receive it through dependency injection.
3. Calls `Settings_Service::register()` to hook the global toggle option, expose REST fields, register the admin menu, and trigger section registration with `ai_register_settings_sections`.

Feature settings panels should be registered inside the `ai_register_settings_sections` hook. The `Provides_Settings_Section` trait streamlines the process:

```php
class Example_Feature extends Abstract_Feature {
use Provides_Settings_Section;

public function register(): void {
add_action( 'ai_register_settings_sections', array( $this, 'register_settings_sections' ) );

if ( ! $this->is_enabled() ) {
return;
}

// Register functional hooks only when enabled.
}

public function register_settings_sections( Settings_Registry $registry ): void {
$this->register_feature_settings_section(
$registry,
'example-feature',
__( 'Example Feature', 'ai' ),
array( $this, 'render_settings_section' ),
array(
'description' => __( 'Demonstration controls for the example feature.', 'ai' ),
'priority' => 20,
)
);
}

public function render_settings_section( Settings_Toggle $toggle, Settings_Section $section ): void {
// Output fallback markup when JavaScript is unavailable.
}
}
```

`Settings_Payload_Builder` serializes the registry into a payload consumed by the React app. Each section’s `enabled` state reflects persisted data from `Feature_Toggles`, ensuring the UI mirrors stored values immediately.

### Conditional Features

If your feature has requirements (PHP extensions, other plugins, etc.), implement validation in your constructor:
Expand Down Expand Up @@ -251,21 +326,21 @@ add_action( 'ai_register_features', function( $registry ) {

### Filtering Default Features

Modify the list of default feature classes before they are instantiated:
Modify the list of default feature instances before they are registered:

```php
add_filter( 'ai_default_feature_classes', function( $feature_classes ) {
add_filter( 'ai_default_features', function( $features, $feature_toggles ) {
// Add a custom feature
$feature_classes[] = 'My_Namespace\My_Custom_Feature';

// Remove a default feature
$key = array_search( 'WordPress\AI\Features\Example_Feature\Example_Feature', $feature_classes );
if ( false !== $key ) {
unset( $feature_classes[ $key ] );
}
$features[] = new My_Namespace\My_Custom_Feature( $feature_toggles );

return $feature_classes;
} );
// Remove the bundled Example Feature
return array_filter(
$features,
static function ( $feature ) {
return ! $feature instanceof WordPress\AI\Features\Example_Feature\Example_Feature;
}
);
}, 10, 2 );
```

### Disabling a Feature
Expand Down Expand Up @@ -371,4 +446,4 @@ GPL-2.0-or-later

---

<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p>
<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p>
81 changes: 78 additions & 3 deletions includes/Abstracts/Abstract_Feature.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,63 @@
*
* Provides common functionality for all features including enable/disable state.
*
* ## Rules for Creating Features:
*
* 1. **Implement load_feature_metadata()**: Return an array with 'id', 'label', and 'description'.
*
* 2. **Always register settings sections**: In register(), hook into 'ai_register_settings_sections'
* so your feature appears in the admin UI even when disabled.
*
* 3. **Check is_enabled() before functional hooks**: Only register functional hooks (like actions,
* filters, REST routes) if is_enabled() returns true. This allows users to disable features.
*
* 4. **Pass services as parameters**: Don't use global singletons or service locators. Accept
* services as method parameters (e.g., Settings_Registry passed to register_settings_sections()).
*
* 5. **Use Provides_Settings_Section trait**: To add a settings panel, use this trait and pass
* the registry as the first parameter to register_feature_settings_section().
*
* ## Example:
*
* ```php
* class My_Feature extends Abstract_Feature {
* use Provides_Settings_Section;
*
* protected function load_feature_metadata(): array {
* return array(
* 'id' => 'my-feature',
* 'label' => __( 'My Feature', 'ai' ),
* 'description' => __( 'Description of my feature.', 'ai' ),
* );
* }
*
* public function register(): void {
* // Always register settings sections.
* add_action( 'ai_register_settings_sections', array( $this, 'register_settings_sections' ) );
*
* // Only register functional hooks if enabled.
* if ( ! $this->is_enabled() ) {
* return;
* }
*
* add_action( 'init', array( $this, 'my_hook' ) );
* }
*
* public function register_settings_sections( Settings_Registry $registry ): void {
* $this->register_feature_settings_section(
* $registry, // Pass as parameter
* 'my-feature',
* __( 'My Feature', 'ai' ),
* array( $this, 'render_settings' )
* );
* }
*
* public function render_settings( Settings_Toggle $toggle, Settings_Section $section ): void {
* // Render settings UI.
* }
* }
* ```
*
* @since 0.1.0
*/
abstract class Abstract_Feature implements Feature {
Expand Down Expand Up @@ -50,17 +107,28 @@ abstract class Abstract_Feature implements Feature {
*/
private $enabled = true;

/**
* Feature toggles service.
*
* @since 0.1.0
* @var \WordPress\AI\Admin\Settings\Feature_Toggles|null
*/
private $feature_toggles = null;

/**
* Constructor.
*
* Loads feature metadata and initializes properties.
*
* @since 0.1.0
*
* @param \WordPress\AI\Admin\Settings\Feature_Toggles|null $feature_toggles Optional. Feature toggles service for checking enabled state.
*
* @throws \WordPress\AI\Exception\Invalid_Feature_Metadata_Exception If feature metadata is invalid.
*/
final public function __construct() {
$metadata = $this->load_feature_metadata();
final public function __construct( ?\WordPress\AI\Admin\Settings\Feature_Toggles $feature_toggles = null ) {
$this->feature_toggles = $feature_toggles;
$metadata = $this->load_feature_metadata();

if ( empty( $metadata['id'] ) ) {
throw new Invalid_Feature_Metadata_Exception(
Expand Down Expand Up @@ -132,12 +200,19 @@ public function get_description(): string {
/**
* Checks if feature is enabled.
*
* Uses injected Feature_Toggles service to check persisted toggle state.
* Falls back to default enabled state if service not available.
*
* @since 0.1.0
*
* @return bool True if enabled, false otherwise.
*/
final public function is_enabled(): bool {
$enabled = $this->enabled;
if ( null !== $this->feature_toggles ) {
$enabled = $this->feature_toggles->is_feature_enabled( $this->id );
} else {
$enabled = $this->enabled;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm a bit confused on the enablement checks. We have:

  • $this->feature_toggles->is_feature_enabled( $this->id );
  • $this->enabled
  • apply_filters( "ai_feature_{$this->id}_enabled", $enabled )

It's hard to know what the source of truth is. Do have needs for this complexity already? I wonder if the UI could store the enabled/disabled options somewhere and just use those with the existing filter and avoid all the new classes? Admittedly I don't have a full picture of the next steps and/or plans for this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for calling that out! I’ve flattened the enablement flow so there’s now a single source of truth:

  • Abstract_Feature::is_enabled() now starts from the feature’s is_enabled_by_default() value, hands it through the (lazy-loaded) Feature_Toggles service, and finally runs the existing ai_feature_{$this->id}_enabled filter. The old $this->enabled property is gone, so every call site reads the same value.

  • The React UI still persists toggles to wp_ai_feature_toggles, and Settings_Payload_Builder feeds those back into the payload, so admins see the exact stored state.


/**
* Filters the enabled status for a specific feature.
Expand Down
Loading
Loading