Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion build-dev/index.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-i18n'), 'version' => 'f95bfcf50556ee6ab0a1');
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-i18n'), 'version' => '0cce0fa815bd00bd2c29');
28 changes: 11 additions & 17 deletions build-dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1656,7 +1656,7 @@ function VersionDisplay({
installableBusy = false,
onUpdate
}) {
if (feature.has_update) {
if (feature.update_version) {
return /*#__PURE__*/(0,react_jsx_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxs)("div", {
className: "flex items-center gap-1.5",
children: [/*#__PURE__*/(0,react_jsx_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxs)("span", {
Expand All @@ -1667,7 +1667,7 @@ function VersionDisplay({
children: "\u2192"
}), /*#__PURE__*/(0,react_jsx_runtime__WEBPACK_IMPORTED_MODULE_1__.jsxs)("span", {
className: "text-xs font-mono font-bold",
children: ["v", feature.version]
children: ["v", feature.update_version]
}), (upgradeLabel || onUpdate) && /*#__PURE__*/(0,react_jsx_runtime__WEBPACK_IMPORTED_MODULE_1__.jsx)(_components_atoms_UpdateButton__WEBPACK_IMPORTED_MODULE_0__.UpdateButton, {
featureName: feature.name,
disabled: !!pendingAction || installableBusy,
Expand All @@ -1676,12 +1676,12 @@ function VersionDisplay({
})]
});
}
if (!feature.version && !feature.installed_version) {
if (!feature.installed_version) {
return null;
}
return /*#__PURE__*/(0,react_jsx_runtime__WEBPACK_IMPORTED_MODULE_1__.jsx)("span", {
className: "text-xs font-mono text-muted-foreground text-right",
children: `v${feature.installed_version ?? feature.version}`
children: `v${feature.installed_version}`
});
}

Expand Down Expand Up @@ -3601,7 +3601,7 @@ function useFeatureRow(feature) {
disableFeature,
updateFeature
} = (0,_wordpress_data__WEBPACK_IMPORTED_MODULE_2__.useDispatch)(_store__WEBPACK_IMPORTED_MODULE_3__.store);
const installableBusy = (0,_wordpress_data__WEBPACK_IMPORTED_MODULE_2__.useSelect)(select => feature.type !== 'flag' && select(_store__WEBPACK_IMPORTED_MODULE_3__.store).isAnyInstallableBusy(), [feature.type]);
const installableBusy = (0,_wordpress_data__WEBPACK_IMPORTED_MODULE_2__.useSelect)(select => select(_store__WEBPACK_IMPORTED_MODULE_3__.store).isAnyInstallableBusy(), []);
const showLegacyBadge = (0,_wordpress_data__WEBPACK_IMPORTED_MODULE_2__.useSelect)(select => {
const activeLegacy = select(_store__WEBPACK_IMPORTED_MODULE_3__.store).getActiveLegacyLicense(feature.slug);
if (!activeLegacy) return false;
Expand Down Expand Up @@ -4743,27 +4743,21 @@ const getFeatureError = (state, slug) => state.features.errorBySlug[slug] ?? nul
const isFeatureUpdating = (state, slug) => state.features.updating[slug] ?? false;

/**
* True when any plugin or theme feature is being toggled or updated.
* True when any feature is being toggled or updated.
*
* Both toggle and update operations trigger WordPress install/activate/deactivate
* operations that should not run concurrently. Flag features are exempt
* because they only flip a database option.
* Toggle and update operations trigger WordPress install/activate/deactivate
* operations that should not run concurrently.
*
* Memoized via createSelector so the loops only re-run when
* the relevant sub-trees actually change.
*/
const isAnyInstallableBusy = (0,_wordpress_data__WEBPACK_IMPORTED_MODULE_0__.createSelector)(state => {
const {
toggling,
updating,
bySlug
updating
} = state.features;
const isNonFlag = slug => {
const feature = bySlug[slug];
return feature !== undefined && feature.type !== 'flag';
};
return Object.keys(toggling).some(isNonFlag) || Object.keys(updating).some(isNonFlag);
}, state => [state.features.toggling, state.features.updating, state.features.bySlug]);
return Object.keys(toggling).length > 0 || Object.keys(updating).length > 0;
}, state => [state.features.toggling, state.features.updating]);

// ---------------------------------------------------------------------------
// Legacy licenses
Expand Down
2 changes: 1 addition & 1 deletion build-dev/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/index.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-i18n'), 'version' => 'afcb52a5136f0fcc3551');
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-data', 'wp-i18n'), 'version' => '07ada775aae670d37966');
2 changes: 1 addition & 1 deletion build/index.js

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ The catalog defines which features exist, their metadata (name, description, typ

For `Installable` features (Plugin, Theme), the resolver also reads `installed_version` from disk and stores it on the resolved Feature. This is the version currently on the site, distinct from the catalog's `version` which is the latest available. Flag features always have `installed_version: null`.

`has_update()` is a computed method on the `Installable` interface that centralizes the update-available check. It returns `true` when the feature is installed on disk and `version_compare( catalog_version, installed_version, '>' )`. Both Plugin and Theme implement this method. The update handlers (`Plugin_Handler`, `Theme_Handler`) delegate to this result (via the `has_update` field in the update data array) rather than performing their own inline comparison.
Update availability is not stamped onto the Feature objects themselves. The update handlers (`Plugin_Handler`, `Theme_Handler`) inject entries into the WordPress update transients (`update_plugins`, `update_themes`) after applying additional gating (dot-org exclusion, license checks). The REST layer uses `Feature_Resource` to read those transients and expose `update_version` — the version from the transient's `response` entry, or `null` when no update is available. This avoids a circular dependency: reading the transient fires our `site_transient` filter, which calls the resolver, so the transient can only be read after resolution is complete.

Edge cases:

Expand Down Expand Up @@ -135,7 +135,7 @@ Five endpoints under `liquidweb/harbor/v1`. All require `manage_options`.
| `/features/{slug}/disable` | POST | Disable a feature |
| `/features/{slug}/update` | POST | Update a feature to the latest available version |

Each Feature object includes `is_enabled`, stamped with live state from its strategy by the Manager before any consumer receives it. Installable features (Plugin, Theme) additionally include `has_update` — a pre-computed boolean the frontend can read directly without doing any version parsing.
Each Feature object includes `is_enabled`, stamped with live state from its strategy by the Manager before any consumer receives it. The REST layer wraps each Feature in a `Feature_Resource` that reads the WordPress update transient to resolve `update_version` — the version available via the transient, or `null` when no update is available. A non-null `update_version` is the signal that an update is available.

## Error Codes

Expand Down Expand Up @@ -181,7 +181,7 @@ Each Feature object includes `is_enabled`, stamped with live state from its stra
| **Whether available** (`is_available`) | **Licensing capabilities array** — feature slug present in `Product_Entry::get_capabilities()`. Falls back to catalog tier rank 0 when unlicensed. |
| **Whether enabled** (`is_enabled`) | Live WordPress state (plugin activation / theme disk / flag option), stamped by Manager |
| **Installed version** (`installed_version`) | Read from disk during resolution via `Installable`. Null for flags and uninstalled extensions |
| **Update available** (`has_update`) | Computed by `Installable::has_update()`: `version_compare( catalog_version, installed_version, '>' )`. False when not installed or catalog version is absent. Plugin and Theme only |
| **Update available** (`update_version`) | Derived from WordPress update transients by `Feature_Resource`. Non-null when the transient's `response` array contains an entry (meaning the update handlers have approved the update). Plugin and Theme only |

## What Features Does Not Do

Expand Down
8 changes: 4 additions & 4 deletions resources/js/components/molecules/VersionDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,15 @@ export function VersionDisplay( {
installableBusy = false,
onUpdate,
}: VersionDisplayProps ) {
if ( feature.has_update ) {
if ( feature.update_version ) {
return (
<div className="flex items-center gap-1.5">
<span className="text-xs font-mono text-muted-foreground line-through">
v{ feature.installed_version }
</span>
<span className="text-muted-foreground text-xs">→</span>
<span className="text-xs font-mono font-bold">
v{ feature.version }
v{ feature.update_version }
</span>
{ ( upgradeLabel || onUpdate ) && (
<UpdateButton
Expand All @@ -53,13 +53,13 @@ export function VersionDisplay( {
);
}

if ( ! feature.version && ! feature.installed_version ) {
if ( ! feature.installed_version ) {
return null;
}

return (
<span className="text-xs font-mono text-muted-foreground text-right">
{ `v${ feature.installed_version ?? feature.version }` }
{ `v${ feature.installed_version }` }
</span>
);
}
6 changes: 2 additions & 4 deletions resources/js/hooks/useFeatureRow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,8 @@ export function useFeatureRow( feature: Feature ): FeatureRowState {
const { enableFeature, disableFeature, updateFeature } = useDispatch( harborStore );

const installableBusy = useSelect(
( select ) =>
feature.type !== 'flag' &&
select( harborStore ).isAnyInstallableBusy(),
[ feature.type ]
( select ) => select( harborStore ).isAnyInstallableBusy(),
[]
);

const showLegacyBadge = useSelect(
Expand Down
18 changes: 6 additions & 12 deletions resources/js/store/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,31 +47,25 @@ export const isFeatureUpdating = (state: State, slug: string): boolean =>
state.features.updating[slug] ?? false;

/**
* True when any plugin or theme feature is being toggled or updated.
* True when any feature is being toggled or updated.
*
* Both toggle and update operations trigger WordPress install/activate/deactivate
* operations that should not run concurrently. Flag features are exempt
* because they only flip a database option.
* Toggle and update operations trigger WordPress install/activate/deactivate
* operations that should not run concurrently.
*
* Memoized via createSelector so the loops only re-run when
* the relevant sub-trees actually change.
*/
export const isAnyInstallableBusy = createSelector(
(state: State): boolean => {
const { toggling, updating, bySlug } = state.features;
const isNonFlag = (slug: string): boolean => {
const feature = bySlug[slug];
return feature !== undefined && feature.type !== 'flag';
};
const { toggling, updating } = state.features;
return (
Object.keys(toggling).some(isNonFlag) ||
Object.keys(updating).some(isNonFlag)
Object.keys(toggling).length > 0 ||
Object.keys(updating).length > 0
);
},
(state: State) => [
state.features.toggling,
state.features.updating,
state.features.bySlug,
]
);

Expand Down
50 changes: 18 additions & 32 deletions resources/js/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
*
* @since 1.0.0
*/
export type FeatureType = 'plugin' | 'theme' | 'flag';
export type FeatureType = 'plugin' | 'theme';

/**
* Base properties shared by all feature types.
Expand Down Expand Up @@ -53,19 +53,6 @@ interface BaseFeature {
* Whether the feature is currently enabled (persisted server-side).
*/
is_enabled: boolean;
/**
* Latest available version string, if known.
*/
version?: string;
/**
* Installed version string, if known.
*/
installed_version?: string;
/**
* Whether a newer version is available and the feature is currently installed.
* Only present for installable features (plugin/theme).
*/
has_update?: boolean;
}

/**
Expand All @@ -91,6 +78,14 @@ export interface PluginFeature extends BaseFeature {
* Whether the plugin is hosted on WordPress.org.
*/
is_dot_org: boolean;
/**
* Currently installed version, or null if not installed.
*/
installed_version: string | null;
/**
* Version available via WordPress update transients, or null if no update.
*/
update_version: string | null;
}

/**
Expand All @@ -108,31 +103,22 @@ export interface ThemeFeature extends BaseFeature {
* Whether the theme is hosted on WordPress.org.
*/
is_dot_org: boolean;
}

/**
* A feature gated by a database option flag.
*
* @since 1.0.0
*/
export interface FlagFeature extends BaseFeature {
type: 'flag';
/**
* Currently installed version, or null if not installed.
*/
installed_version: string | null;
/**
* Version available via WordPress update transients, or null if no update.
*/
update_version: string | null;
}

/**
* Discriminated union of all feature types as returned by the REST API.
*
* @since 1.0.0
*/
export type Feature = PluginFeature | ThemeFeature | FlagFeature;

/**
* Plugin and theme features that trigger WordPress install/activate/deactivate
* operations. Flag features are excluded because they only flip a DB option.
*
* @since 1.0.0
*/
export type InstallableFeature = PluginFeature | ThemeFeature;
export type Feature = PluginFeature | ThemeFeature;

// ---------------------------------------------------------------------------
// Catalog types — GET /liquidweb/harbor/v1/catalog
Expand Down
10 changes: 0 additions & 10 deletions resources/js/types/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import type {
Feature,
PluginFeature,
ThemeFeature,
FlagFeature,
InstallableFeature,
} from '@/types/api';

export function isPluginFeature( feature: Feature ): feature is PluginFeature {
Expand All @@ -18,11 +16,3 @@ export function isPluginFeature( feature: Feature ): feature is PluginFeature {
export function isThemeFeature( feature: Feature ): feature is ThemeFeature {
return feature.type === 'theme';
}

export function isFlagFeature( feature: Feature ): feature is FlagFeature {
return feature.type === 'flag';
}

export function isInstallableFeature( feature: Feature ): feature is InstallableFeature {
return feature.type === 'plugin' || feature.type === 'theme';
}
32 changes: 9 additions & 23 deletions src/Harbor/API/REST/V1/Feature_Controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace LiquidWeb\Harbor\API\REST\V1;

use LiquidWeb\Harbor\Features\Error_Code;
use LiquidWeb\Harbor\Features\Feature_Resource;
use LiquidWeb\Harbor\Features\Manager;
use LiquidWeb\Harbor\Features\Types\Feature;
use LiquidWeb\Harbor\Utils\Cast;
Expand Down Expand Up @@ -191,7 +192,7 @@ public function get_items( $request ) {
$data = [];

foreach ( $features as $feature ) {
$data[] = $feature->to_array();
$data[] = Feature_Resource::from_feature( $feature )->to_array();
}

return new WP_REST_Response( $data );
Expand Down Expand Up @@ -224,7 +225,7 @@ public function get_item( $request ) {
);
}

return new WP_REST_Response( $feature->to_array() );
return new WP_REST_Response( Feature_Resource::from_feature( $feature )->to_array() );
}

/**
Expand All @@ -246,7 +247,7 @@ public function enable( WP_REST_Request $request ) {
}

return new WP_REST_Response(
$feature->to_array()
Feature_Resource::from_feature( $feature )->to_array()
);
}

Expand All @@ -268,7 +269,7 @@ public function disable( WP_REST_Request $request ) {
}

return new WP_REST_Response(
$feature->to_array()
Feature_Resource::from_feature( $feature )->to_array()
);
}

Expand All @@ -290,7 +291,7 @@ public function update_item( $request ) {
}

return new WP_REST_Response(
$feature->to_array()
Feature_Resource::from_feature( $feature )->to_array()
);
}

Expand Down Expand Up @@ -372,12 +373,6 @@ public function get_item_schema(): array {
'readonly' => true,
'context' => [ 'view' ],
],
'version' => [
'description' => __( 'Latest available version from the catalog, or null. Only present for installable features.', '%TEXTDOMAIN%' ),
'type' => [ 'string', 'null' ],
'readonly' => true,
'context' => [ 'view' ],
],
'changelog' => [
'description' => __( 'Changelog HTML for the latest version, or null. Only present for installable features.', '%TEXTDOMAIN%' ),
'type' => [ 'string', 'null' ],
Expand Down Expand Up @@ -405,9 +400,9 @@ public function get_item_schema(): array {
'readonly' => true,
'context' => [ 'view' ],
],
'has_update' => [
'description' => __( 'Whether a newer version is available and the feature is currently installed.', '%TEXTDOMAIN%' ),
'type' => 'boolean',
'update_version' => [
'description' => __( 'The version available via WordPress update transients, or null if no update is available.', '%TEXTDOMAIN%' ),
'type' => [ 'string', 'null' ],
'readonly' => true,
'context' => [ 'view' ],
],
Expand Down Expand Up @@ -447,15 +442,6 @@ public function get_item_schema(): array {
$installable_properties
),
],
[
'title' => 'flag',
'type' => 'object',
'additionalProperties' => true,
'properties' => array_merge(
$base_properties,
[ 'type' => array_merge( $base_properties['type'], [ 'enum' => [ Feature::TYPE_FLAG ] ] ) ]
),
],
],
];

Expand Down
Loading