Skip to content

feat: add extension directory (Plugins tab)#497

Merged
kilbot merged 9 commits intomainfrom
feature/extension-directory
Feb 8, 2026
Merged

feat: add extension directory (Plugins tab)#497
kilbot merged 9 commits intomainfrom
feature/extension-directory

Conversation

@kilbot
Copy link
Copy Markdown
Contributor

@kilbot kilbot commented Feb 8, 2026

Summary

  • Add browsable extension directory as a "Plugins" tab in POS Settings
  • New Extensions service fetches a remote catalog.json from GitHub, caches in a 12-hour WordPress transient, and enriches each entry with local plugin install/active status
  • New GET /wcpos/v1/extensions REST endpoint serves the enriched catalog (requires manage_woocommerce_pos)
  • Frontend displays a card grid with search, category pill filters, and per-extension status badges (active, inactive, update available, not installed)
  • Free users see a Pro upgrade banner; install button is disabled with a tooltip

Closes wcpos/roadmap#23 (WP plugin portion)

Design

See docs/plans/2026-02-07-extension-directory-design.md for the full design document covering the catalog infrastructure, Pro plugin endpoints, and CI workflow (those are separate follow-up work).

Test plan

  • Navigate to POS > Settings in WP Admin, verify "Plugins" appears in the sidebar between Sessions and License
  • Click Plugins tab — should show the card grid (or empty state if catalog.json doesn't exist yet)
  • Verify search input filters extensions by name, description, and tags
  • Verify category pill buttons filter the grid by category
  • Verify each card shows: icon (or puzzle-piece fallback), name, version, description, category badge, and status
  • Verify the "Install" button on not-installed extensions is disabled with a "Requires Pro" tooltip
  • Verify the Pro upgrade banner appears for free plugin users
  • Verify GET /wcpos/v1/extensions returns the catalog with status enrichment (use wp-json/wcpos/v1/extensions?wcpos=1 with an admin user)
  • Verify unauthenticated requests to the endpoint return 403
  • Verify that if the remote catalog URL is unreachable, the endpoint returns 200 with an empty array (graceful degradation)
  • Run composer run lint — no new errors
  • Run pnpm run test — all 672 tests pass

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added dedicated Extensions screen in settings to discover and manage available extensions.
    • Display extension status (active, inactive, update available, not installed) with key details.
    • Filter extensions by category and search by name.
  • Tests

    • Added comprehensive test coverage for extensions API and service endpoints.

Fetches catalog.json from GitHub, caches in a 12-hour transient,
and enriches entries with local plugin install/active status.
GET /wcpos/v1/extensions returns the extension catalog enriched
with local install/active status. Requires manage_woocommerce_pos.
Card grid with search and category filtering. Fetches extension
catalog from the REST API. Install button disabled for free users.
Includes Pro upgrade banner for non-Pro installations.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 8, 2026

📝 Walkthrough

Walkthrough

This PR implements an extensions directory feature by adding a remote catalog fetcher service with WordPress transient caching, a REST API controller to expose extensions data, frontend UI components to display and filter extensions, and comprehensive test coverage. It also fixes early notice timing issues in the Activator.

Changes

Cohort / File(s) Summary
Backend Service & API Registration
includes/Services/Extensions.php, includes/API/Extensions.php, includes/API.php
Adds Extensions singleton service that fetches and caches a remote catalog from GitHub, enriches entries with local plugin status (installed, active, update availability), and registers a read-only REST endpoint at /wcpos/v1/extensions with permission checks requiring manage_woocommerce_pos capability.
Initialization & Setup
includes/Init.php
Initializes the Extensions service alongside existing SettingsService and AuthService in the common init flow.
Frontend UI Components
packages/settings/src/screens/extensions/extension-card.tsx, packages/settings/src/screens/extensions/index.tsx
Adds new Extensions settings page with search/filter capability, category tabs, and ExtensionCard component displaying extension metadata, status badges, and install actions for both Pro and non-Pro users.
Router & Navigation
packages/settings/src/router.tsx, packages/settings/src/layouts/nav-sidebar.tsx
Adds new /extensions route with preloaded data and navigation item in the Settings sidebar.
Test Coverage
tests/includes/API/Test_Extensions_Controller.php, tests/includes/Services/Test_Extensions_Service.php
Adds comprehensive PHPUnit tests for the Extensions REST controller (route registration, response structure, authentication) and service (catalog fetch, caching, status enrichment logic across multiple plugin states).
Activation Timing Fix
includes/Activator.php
Defers admin notices to admin_init callback instead of immediate execution to avoid early-notice warnings in WordPress 6.7+.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Frontend as Frontend<br/>(React)
    participant API as REST API<br/>(Extensions Controller)
    participant Service as Extensions<br/>Service
    participant Cache as WP Transient<br/>Cache
    participant Remote as Remote Catalog<br/>(GitHub)
    participant WP as WordPress<br/>Plugin System

    User->>Frontend: Navigate to Extensions
    Frontend->>API: GET /wcpos/v1/extensions
    API->>Service: get_extensions()
    Service->>Cache: Check transient
    alt Cache Hit
        Cache-->>Service: Cached catalog
    else Cache Miss
        Service->>Remote: Fetch catalog.json
        Remote-->>Service: Return extensions list
        Service->>Cache: Store in transient (12h TTL)
    end
    Service->>WP: Get installed plugins
    Service->>Service: Enrich each entry<br/>(status, version)
    Service-->>API: Return enriched extensions array
    API->>API: check_permissions()
    API-->>Frontend: WP_REST_Response
    Frontend->>Frontend: Render Extension Cards<br/>(Filter, Search)
    Frontend-->>User: Display catalog with<br/>status badges
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Possibly related issues

  • Bump @wordpress/data from 5.2.0 to 6.0.0 #23: Plugin/extension directory — This PR directly implements the core feature from the roadmap: an extension directory with catalog fetching, status enrichment, REST API exposure, and UI components for browsing and managing extensions across both free and Pro versions.

Possibly related PRs

Poem

🐰 Hop, hop! Extensions now bloom,
A catalog fresh in each corner of the room,
Cache it quick, enrich with care,
Status badges dancing in the air! ✨
Free or Pro, the choice is fair!

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 inconclusive)
Check name Status Explanation Resolution
Out of Scope Changes check ❓ Inconclusive Changes to Activator.php defer admin notices to admin_init to avoid WordPress 6.7+ early notices, which is tangential to the extension directory feature but improves plugin stability. Clarify whether the Activator.php changes address a separate bug or should be split into a separate PR to keep scope focused on the extension directory feature.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: add extension directory (Plugins tab)' directly matches the main change: adding a browsable extension directory feature with a Plugins tab in POS Settings.
Linked Issues check ✅ Passed The code changes comprehensively implement the WP plugin portion of issue #23: Extensions service fetches remote catalog with caching, REST endpoint requires manage_woocommerce_pos, frontend displays search/category filters and status badges, Pro banner shown for free users, and permissions enforced.
Docstring Coverage ✅ Passed Docstring coverage is 93.48% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/extension-directory

No actionable comments were generated in the recent review. 🎉

🧹 Recent nitpick comments
packages/settings/src/screens/extensions/extension-card.tsx (1)

99-101: Consider displaying installed_version for installed extensions.

Currently, the card always displays latest_version. For extensions with status active, inactive, or update_available, it may be more informative to show the installed_version (available in the Extension interface) since that reflects what's actually running.

♻️ Optional: Show installed version when available
 					<span className="wcpos:text-xs wcpos:text-gray-400">
-						v{extension.latest_version}
+						v{extension.installed_version ?? extension.latest_version}
 					</span>
packages/settings/src/screens/extensions/index.tsx (3)

70-70: Consider adding a fallback for ext.name for consistency.

While name is a required field, adding a fallback would maintain consistency with the defensive approach used for other fields and prevent a crash if the API ever returns malformed data.

♻️ Optional fix
-			ext.name.toLowerCase().includes(q) ||
+			(ext.name || '').toLowerCase().includes(q) ||

82-90: Consider adding accessibility attributes to the search input.

The search input lacks an accessible label, which may affect screen reader users.

♻️ Optional: Add aria-label
 			<input
 				type="text"
 				placeholder={t('extensions.search_placeholder', 'Search extensions...')}
 				value={search}
 				onChange={(e) => setSearch(e.target.value)}
+				aria-label={t('extensions.search_placeholder', 'Search extensions...')}
 				className="wcpos:block wcpos:w-full wcpos:rounded-md wcpos:border wcpos:border-gray-300 wcpos:px-3 wcpos:py-2 wcpos:text-sm focus:wcpos:outline-none focus:wcpos:ring-2 focus:wcpos:ring-wp-admin-theme-color"
 			/>

93-107: Consider adding type="button" to prevent accidental form submission.

If this component is ever nested within a form, buttons without explicit type default to type="submit", which could cause unintended behavior.

♻️ Optional fix
 				<button
 					key={cat}
+					type="button"
 					onClick={() => setCategory(cat)}

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 8, 2026

E2E API Test Results

35 tests   35 ✅  3s ⏱️
17 suites   0 💤
 1 files     0 ❌

Results for commit 2cf0f42.

♻️ This comment has been updated with latest results.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@includes/API/Extensions.php`:
- Around line 63-71: In get_items, WP_REST_Response::header expects a string but
you pass an int from \count($extensions); update the call in get_items (the
WP_REST_Response $response and ->header invocation) to cast the count to string
(e.g., (string)\count($extensions)) so the second parameter is a string and
satisfies the type contract.

In `@includes/Services/Extensions.php`:
- Around line 132-149: Installed-but-inactive plugins are being marked only as
'inactive' so update availability is hidden; in the loop inside
includes/Services/Extensions.php (where find_plugin_file is used), compute
$local_version and $remote_version as currently done and set $status =
'update_available' when version_compare($local_version, $remote_version, '<') is
true regardless of $is_active, otherwise set $status to 'active' if $is_active
or 'inactive' if not and no update is available; update the conditional that
currently nests version_compare under the $is_active branch so that update
detection runs before/independent of the $is_active check.

In `@packages/settings/src/screens/plugins/index.tsx`:
- Around line 58-71: The filtering and category-memo code (the React.useMemo
blocks that compute categories and filtered) assume each extension has
description, tags, and category and will crash if any are missing; fix by
normalizing each ext before use (e.g., treat ext.description as ext.description
|| '', ext.tags as Array.isArray(ext.tags) ? ext.tags : [], and ext.category as
ext.category || 'uncategorized') or map extensions to a safe shape first, then
use those safe fields inside the categories set calculation and the filtered
predicate (references: the categories useMemo, the filtered useMemo, and the
variables extensions, category, search, ext.description, ext.tags,
ext.category). Ensure tags.some calls operate on the normalized array and string
methods run on non-null strings.
🧹 Nitpick comments (7)
includes/API/Extensions.php (1)

44-54: Consider adding a schema definition for discoverability.

For completeness with WP REST API standards, you may want to add get_item_schema() and include 'schema' => array( $this, 'get_public_item_schema' ) in the route registration. This enables clients to discover the response structure via OPTIONS requests.

packages/settings/src/screens/plugins/extension-card.tsx (1)

62-73: Consider using extension.requires_pro to conditionally show the tooltip.

The current implementation always shows "Requires Pro to install" for non-installed extensions. Per the Extension interface, there's a requires_pro boolean field. You may want to differentiate the UX for extensions that don't require Pro (if any exist in the catalog).

💡 Suggested enhancement
 		case 'not_installed':
 		default:
+			if (extension.requires_pro) {
+				return (
+					<Tooltip text={t('extensions.requires_pro', 'Requires Pro to install')}>
+						<span>
+							<Button variant="secondary" disabled>
+								{t('extensions.install', 'Install')}
+							</Button>
+						</span>
+					</Tooltip>
+				);
+			}
 			return (
-				<Tooltip text={t('extensions.requires_pro', 'Requires Pro to install')}>
-					<span>
-						<Button variant="secondary" disabled>
-							{t('extensions.install', 'Install')}
-						</Button>
-					</span>
-				</Tooltip>
+				<Button variant="secondary" disabled>
+					{t('extensions.install', 'Install')}
+				</Button>
 			);
 	}
tests/includes/Services/Test_Extensions_Service.php (1)

118-166: Consider adding URL validation to the mock filter.

The current check strpos($url, 'catalog.json') is loose and could match unintended URLs. For more precise test isolation, consider matching the full expected URL or using a more specific pattern.

💡 Suggested improvement
 	public function mock_catalog_response( $response, $parsed_args, $url ) {
-		if ( false === strpos( $url, 'catalog.json' ) ) {
+		// Match the specific catalog URL to avoid false positives
+		if ( ! preg_match( '#wcpos/extensions/.*/catalog\.json$#', $url ) ) {
 			return $response;
 		}
includes/Services/Extensions.php (1)

90-101: Cache failed fetches briefly to avoid repeated remote calls.
Right now a remote outage can trigger a fetch on every request. Consider caching an empty result for a short TTL to reduce load while still honoring the “empty array on failure” behavior.

♻️ Suggested tweak
-		if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
-			return array();
-		}
+		if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
+			// Avoid hammering the remote catalog during outages.
+			set_transient( self::TRANSIENT_KEY, array(), 5 * MINUTE_IN_SECONDS );
+			return array();
+		}
packages/settings/src/screens/plugins/index.tsx (1)

79-103: Add accessible labeling/state for search and category tabs.
Inputs and toggle buttons need explicit accessible labels and pressed state.

♿ Suggested a11y tweaks
 				<input
 					type="text"
 					placeholder={t('extensions.search_placeholder', 'Search extensions...')}
+					aria-label={t('extensions.search_label', 'Search extensions')}
 					value={search}
 					onChange={(e) => setSearch(e.target.value)}
 					className="wcpos:block wcpos:w-full wcpos:rounded-md wcpos:border wcpos:border-gray-300 wcpos:px-3 wcpos:py-2 wcpos:text-sm focus:wcpos:outline-none focus:wcpos:ring-2 focus:wcpos:ring-wp-admin-theme-color"
 				/>
 					<button
 						key={cat}
+						type="button"
 						onClick={() => setCategory(cat)}
+						aria-pressed={category === cat}
 						className={`wcpos:px-3 wcpos:py-1 wcpos:rounded-full wcpos:text-sm wcpos:font-medium wcpos:transition-colors ${
tests/includes/API/Test_Extensions_Controller.php (2)

53-82: Ensure HTTP filters are removed even if assertions fail.
If an assertion fails before remove_filter, the mock can leak into subsequent tests. A try/finally (or teardown cleanup) keeps isolation intact.

♻️ Suggested pattern
-		add_filter( 'pre_http_request', array( $this, 'mock_catalog_response' ), 10, 3 );
-
-		$request  = $this->wp_rest_get_request( '/wcpos/v1/extensions' );
-		$response = $this->server->dispatch( $request );
-
-		$this->assertEquals( 200, $response->get_status() );
-
-		$data = $response->get_data();
-		$this->assertIsArray( $data );
-		$this->assertCount( 2, $data );
-		$this->assertEquals( 'wcpos-stripe-terminal', $data[0]['slug'] );
-		$this->assertArrayHasKey( 'status', $data[0] );
-
-		remove_filter( 'pre_http_request', array( $this, 'mock_catalog_response' ) );
+		add_filter( 'pre_http_request', array( $this, 'mock_catalog_response' ), 10, 3 );
+		try {
+			$request  = $this->wp_rest_get_request( '/wcpos/v1/extensions' );
+			$response = $this->server->dispatch( $request );
+
+			$this->assertEquals( 200, $response->get_status() );
+
+			$data = $response->get_data();
+			$this->assertIsArray( $data );
+			$this->assertCount( 2, $data );
+			$this->assertEquals( 'wcpos-stripe-terminal', $data[0]['slug'] );
+			$this->assertArrayHasKey( 'status', $data[0] );
+		} finally {
+			remove_filter( 'pre_http_request', array( $this, 'mock_catalog_response' ) );
+		}

123-126: Silence PHPMD unused-parameter warnings in mock callbacks.
PHPMD flags $parsed_args as unused; add suppressions to keep lint clean.

🔧 Suggested suppression
-	public function mock_catalog_response( $response, $parsed_args, $url ) {
+	/**
+	 * `@SuppressWarnings`(PHPMD.UnusedFormalParameter)
+	 */
+	public function mock_catalog_response( $response, $parsed_args, $url ) {
-	public function mock_catalog_failure( $response, $parsed_args, $url ) {
+	/**
+	 * `@SuppressWarnings`(PHPMD.UnusedFormalParameter)
+	 */
+	public function mock_catalog_failure( $response, $parsed_args, $url ) {

Also applies to: 182-185

Comment thread includes/API/Extensions.php
Comment thread includes/Services/Extensions.php
Comment thread packages/settings/src/screens/extensions/index.tsx
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 8, 2026

PHPUnit Test Results

677 tests  +15   668 ✅ +15   54s ⏱️ -1s
 39 suites + 2     9 💤 ± 0 
  1 files   ± 0     0 ❌ ± 0 

Results for commit 2cf0f42. ± Comparison against base commit 96e56c1.

♻️ This comment has been updated with latest results.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@includes/API/Extensions.php`:
- Line 63: Add a PHPMD suppression for the unused $request parameter on the REST
callback(s): place a docblock above the get_items method (and the other similar
REST callback at the same spot) with a suppression annotation such as /**
`@SuppressWarnings`(PHPMD.UnusedFormalParameter) */ so the function signature
public function get_items( $request ): WP_REST_Response remains unchanged but
static analysis no longer flags $request as unused.

*
* @return WP_REST_Response
*/
public function get_items( $request ): WP_REST_Response {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Silence PHPMD unused-parameter warnings for $request.

These callbacks must accept the request object, but it’s unused. Add an explicit suppression to keep static analysis clean.

🔧 Suggested fix
 /**
  * Get all extensions with status.
  *
  * `@param` WP_REST_Request $request Request object.
  *
  * `@return` WP_REST_Response
+ * `@SuppressWarnings`(PHPMD.UnusedFormalParameter)
  */
 public function get_items( $request ): WP_REST_Response {
@@
 /**
  * Check if the current user has permission.
  *
  * `@param` WP_REST_Request $request Request object.
  *
  * `@return` bool|\WP_Error
+ * `@SuppressWarnings`(PHPMD.UnusedFormalParameter)
  */
 public function check_permissions( WP_REST_Request $request ) {

Also applies to: 80-80

🧰 Tools
🪛 PHPMD (2.15.0)

[warning] 63-63: Avoid unused parameters such as '$request'. (undefined)

(UnusedFormalParameter)

🤖 Prompt for AI Agents
In `@includes/API/Extensions.php` at line 63, Add a PHPMD suppression for the
unused $request parameter on the REST callback(s): place a docblock above the
get_items method (and the other similar REST callback at the same spot) with a
suppression annotation such as /**
`@SuppressWarnings`(PHPMD.UnusedFormalParameter) */ so the function signature
public function get_items( $request ): WP_REST_Response remains unchanged but
static analysis no longer flags $request as unused.

- Show update_available status for inactive plugins with newer versions
- Add null guards for missing catalog fields (description, tags, category)
Cover all 5 code paths in get_extensions() status logic:
- not_installed (plugin absent locally)
- active (installed, active, up to date)
- update_available (active, newer remote version)
- update_available (inactive, newer remote version)
- inactive (installed, deactivated, up to date)
…" notice

WordPress 6.7 triggers _load_textdomain_just_in_time warning when __()
is called before after_setup_theme. Activator methods (permalink_check,
php_check, woocommerce_check, pro_version_check) were calling __()
during plugins_loaded to build admin notice messages.

Wrap all __() + Admin\Notices::add() calls in admin_init closures so
translations load after after_setup_theme. The boolean check logic stays
at plugins_loaded to gate Init loading; only the notice messages are
deferred.
Rename the settings tab, route, component, and directory from
"Plugins" to "Extensions" to better reflect the feature's purpose.
@kilbot kilbot merged commit f19177d into main Feb 8, 2026
16 of 18 checks passed
@kilbot kilbot deleted the feature/extension-directory branch February 8, 2026 22:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Plugin/extension directory

1 participant