feat: add extension directory (Plugins tab)#497
Conversation
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.
📝 WalkthroughWalkthroughThis 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
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~22 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 inconclusive)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
No actionable comments were generated in the recent review. 🎉 🧹 Recent nitpick comments
Comment |
E2E API Test Results35 tests 35 ✅ 3s ⏱️ Results for commit 2cf0f42. ♻️ This comment has been updated with latest results. |
There was a problem hiding this comment.
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 usingextension.requires_proto conditionally show the tooltip.The current implementation always shows "Requires Pro to install" for non-installed extensions. Per the
Extensioninterface, there's arequires_proboolean 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 beforeremove_filter, the mock can leak into subsequent tests. Atry/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_argsas 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
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
Summary
Extensionsservice fetches a remotecatalog.jsonfrom GitHub, caches in a 12-hour WordPress transient, and enriches each entry with local plugin install/active statusGET /wcpos/v1/extensionsREST endpoint serves the enriched catalog (requiresmanage_woocommerce_pos)Closes wcpos/roadmap#23 (WP plugin portion)
Design
See
docs/plans/2026-02-07-extension-directory-design.mdfor the full design document covering the catalog infrastructure, Pro plugin endpoints, and CI workflow (those are separate follow-up work).Test plan
GET /wcpos/v1/extensionsreturns the catalog with status enrichment (usewp-json/wcpos/v1/extensions?wcpos=1with an admin user)composer run lint— no new errorspnpm run test— all 672 tests pass🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Tests