diff --git a/projects/packages/my-jetpack/_inc/components/product-card/index.jsx b/projects/packages/my-jetpack/_inc/components/product-card/index.jsx index 9be5bab09971a..c116013f949eb 100644 --- a/projects/packages/my-jetpack/_inc/components/product-card/index.jsx +++ b/projects/packages/my-jetpack/_inc/components/product-card/index.jsx @@ -45,6 +45,7 @@ const renderActionButton = ( { onManage, onFixConnection, onActivate, + isFetching, } ) => { if ( ! admin ) { return ( @@ -57,6 +58,11 @@ const renderActionButton = ( { ); } + const buttonState = { + isPressed: ! isFetching, + disabled: isFetching, + }; + switch ( status ) { case PRODUCT_STATUSES.ABSENT: return ( @@ -69,19 +75,19 @@ const renderActionButton = ( { ); case PRODUCT_STATUSES.ACTIVE: return ( - ); case PRODUCT_STATUSES.ERROR: return ( - ); case PRODUCT_STATUSES.INACTIVE: return ( - ); @@ -89,7 +95,7 @@ const renderActionButton = ( { }; const ProductCard = props => { - const { name, admin, description, icon, status, onDeactivate } = props; + const { name, admin, description, icon, status, onDeactivate, isFetching } = props; const isActive = status === PRODUCT_STATUSES.ACTIVE; const isError = status === PRODUCT_STATUSES.ERROR; const isInactive = status === PRODUCT_STATUSES.INACTIVE; @@ -105,6 +111,7 @@ const ProductCard = props => { [ styles.active ]: isActive, [ styles.inactive ]: isInactive, [ styles.error ]: isError, + [ styles[ 'is-fetching' ] ]: isFetching, } ); return ( @@ -120,9 +127,10 @@ const ProductCard = props => { { renderActionButton( props ) } {}, onManage: () => {}, onFixConnection: () => {}, diff --git a/projects/packages/my-jetpack/_inc/components/product-card/style.module.scss b/projects/packages/my-jetpack/_inc/components/product-card/style.module.scss index b70e31b52b9d1..da1dd270b75a6 100644 --- a/projects/packages/my-jetpack/_inc/components/product-card/style.module.scss +++ b/projects/packages/my-jetpack/_inc/components/product-card/style.module.scss @@ -83,5 +83,23 @@ } } } + + &.is-fetching { + &:before { + animation: blink-animation 0.5s linear infinite; + } + } +} + +@keyframes blink-animation{ + 0%{ + opacity: 0; + } + 50%{ + opacity: 0.5; + } + 100%{ + opacity: 0; + } } diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/backup-card.jsx b/projects/packages/my-jetpack/_inc/components/product-cards-section/backup-card.jsx index e2e110085d450..0074de03ab8bb 100644 --- a/projects/packages/my-jetpack/_inc/components/product-cards-section/backup-card.jsx +++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/backup-card.jsx @@ -22,7 +22,7 @@ const BackupIcon = () => ( ); const BackupCard = ( { admin } ) => { - const { status, activate, deactivate, detail } = useProduct( 'backup' ); + const { status, activate, deactivate, detail, isFetching } = useProduct( 'backup' ); const { name, description } = detail; return ( @@ -31,6 +31,7 @@ const BackupCard = ( { admin } ) => { description={ description } status={ status } icon={ } + isFetching={ isFetching } admin={ admin } onDeactivate={ deactivate } onActivate={ activate } diff --git a/projects/packages/my-jetpack/_inc/components/product-cards-section/boost-card.jsx b/projects/packages/my-jetpack/_inc/components/product-cards-section/boost-card.jsx index 149b5ac4bf97c..6c5de2458c636 100644 --- a/projects/packages/my-jetpack/_inc/components/product-cards-section/boost-card.jsx +++ b/projects/packages/my-jetpack/_inc/components/product-cards-section/boost-card.jsx @@ -7,7 +7,8 @@ import PropTypes from 'prop-types'; /** * Internal dependencies */ -import ProductCard, { PRODUCT_STATUSES } from '../product-card'; +import ProductCard from '../product-card'; +import { useProduct } from '../../hooks/use-product'; const BoostIcon = () => ( @@ -16,14 +17,19 @@ const BoostIcon = () => ( ); const BoostCard = ( { admin } ) => { - // @todo: implement action handlers + const { status, activate, deactivate, detail, isFetching } = useProduct( 'boost' ); + const { name, description } = detail; + return ( } admin={ admin } + isFetching={ isFetching } + onDeactivate={ deactivate } + onActivate={ activate } /> ); }; diff --git a/projects/packages/my-jetpack/_inc/hooks/use-product/Readme.md b/projects/packages/my-jetpack/_inc/hooks/use-product/Readme.md index e42b63a9ca089..b6b930b72d0c0 100644 --- a/projects/packages/my-jetpack/_inc/hooks/use-product/Readme.md +++ b/projects/packages/my-jetpack/_inc/hooks/use-product/Readme.md @@ -23,8 +23,6 @@ An object with details about the product. Returns the current products list of My Jetpack. -An array with all products list. - ### activate( ) A helper function to activate a product. diff --git a/projects/packages/my-jetpack/_inc/hooks/use-product/index.js b/projects/packages/my-jetpack/_inc/hooks/use-product/index.js index 766e4491d148b..de4fba79d455a 100644 --- a/projects/packages/my-jetpack/_inc/hooks/use-product/index.js +++ b/projects/packages/my-jetpack/_inc/hooks/use-product/index.js @@ -24,7 +24,7 @@ export function useProduct( productId ) { productsList: useSelect( select => select( STORE_ID ).getProducts() ), detail, isActive: detail.status === 'active', + isFetching: useSelect( select => select( STORE_ID ).isFetching( productId ) ), status: detail.status, // shorthand. Consider to remove. - isFetching: !! detail.isFetching, }; } diff --git a/projects/packages/my-jetpack/_inc/state/actions.js b/projects/packages/my-jetpack/_inc/state/actions.js index c01e0676fcb0b..94730d9f44274 100644 --- a/projects/packages/my-jetpack/_inc/state/actions.js +++ b/projects/packages/my-jetpack/_inc/state/actions.js @@ -15,10 +15,11 @@ import { REST_API_SITE_PRODUCTS_ENDPOINT } from './constants'; const SET_PURCHASES_IS_FETCHING = 'SET_PURCHASES_IS_FETCHING'; const FETCH_PURCHASES = 'FETCH_PURCHASES'; const SET_PURCHASES = 'SET_PURCHASES'; -const SET_PRODUCT_ACTION_ERROR = 'SET_PRODUCT_ACTION_ERROR'; +const SET_IS_FETCHING_PRODUCT = 'SET_IS_FETCHING_PRODUCT'; +const SET_PRODUCT = 'SET_PRODUCT'; +const SET_PRODUCT_REQUEST_ERROR = 'SET_PRODUCT_REQUEST_ERROR'; const ACTIVATE_PRODUCT = 'ACTIVATE_PRODUCT'; const DEACTIVATE_PRODUCT = 'DEACTIVATE_PRODUCT'; -const SET_FETCHING_PRODUCT_STATUS = 'SET_FETCHING_PRODUCT_STATUS'; const SET_PRODUCT_STATUS = 'SET_PRODUCT_STATUS'; const setPurchasesIsFetching = isFetching => { @@ -33,17 +34,33 @@ const setPurchases = purchases => { return { type: SET_PURCHASES, purchases }; }; +const setProduct = product => ( { type: SET_PRODUCT, product } ); + +const setRequestProductError = ( productId, error ) => ( { + type: SET_PRODUCT_REQUEST_ERROR, + productId, + error, +} ); + const setProductStatus = ( productId, status ) => { return { type: SET_PRODUCT_STATUS, productId, status }; }; -const setIsFetchingProductStatus = ( productId, isFetching ) => { - return { type: SET_FETCHING_PRODUCT_STATUS, productId, isFetching }; -}; - -const setProductActionError = error => { - return { type: SET_PRODUCT_ACTION_ERROR, error }; -}; +/** + * Action that set the `isFetching` state of the product, + * when the client hits the server. + * + * @param {string} productId - My Jetpack product ID. + * @param {boolean} isFetching - True if the product is being fetched. Otherwise, False. + * @returns {object} - Redux action. + */ +function setIsFetchingProduct( productId, isFetching ) { + return { + type: SET_IS_FETCHING_PRODUCT, + productId, + isFetching, + }; +} /** * Side effect action which will sync @@ -61,31 +78,34 @@ function requestProductStatus( productId, data, { select, dispatch } ) { // Check valid product. const isValid = select.isValidProduct( productId ); if ( ! isValid ) { + dispatch( setProductStatus( productId, { status: 'error' } ) ); return dispatch( - setProductActionError( { + setRequestProductError( productId, { code: 'invalid_product', message: __( 'Invalid product name', 'jetpack-my-jetpack' ), } ) ); } - dispatch( setIsFetchingProductStatus( productId, true ) ); + const method = data.activate ? 'POST' : 'DELETE'; + dispatch( setIsFetchingProduct( productId, true ) ); // Activate/deactivate product. return apiFetch( { path: `${ REST_API_SITE_PRODUCTS_ENDPOINT }/${ productId }`, - method: 'POST', + method, data, } ) - .then( status => { - dispatch( setIsFetchingProductStatus( productId, false ) ); - dispatch( setProductStatus( productId, status ) ); + .then( freshProduct => { + dispatch( setIsFetchingProduct( productId, false ) ); + dispatch( setProduct( freshProduct ) ); resolve( status ); } ) .catch( error => { - dispatch( setProductActionError( error ) ); + dispatch( setProductStatus( productId, { status: 'error' } ) ); + dispatch( setRequestProductError( productId, error ) ); reject( error ); - dispatch( setIsFetchingProductStatus( productId, false ) ); + dispatch( setIsFetchingProduct( productId, false ) ); } ); } ); } @@ -113,8 +133,11 @@ const deactivateProduct = productId => async store => { }; const productActions = { + setProduct, activateProduct, deactivateProduct, + setIsFetchingProduct, + setRequestProductError, }; const actions = { @@ -128,10 +151,11 @@ export { SET_PURCHASES_IS_FETCHING, FETCH_PURCHASES, SET_PURCHASES, - SET_PRODUCT_ACTION_ERROR, + SET_PRODUCT, + SET_PRODUCT_REQUEST_ERROR, ACTIVATE_PRODUCT, DEACTIVATE_PRODUCT, - SET_FETCHING_PRODUCT_STATUS, + SET_IS_FETCHING_PRODUCT, SET_PRODUCT_STATUS, actions as default, }; diff --git a/projects/packages/my-jetpack/_inc/state/reducers.js b/projects/packages/my-jetpack/_inc/state/reducers.js index 593ffb0fca3d8..5bb9d70985e75 100644 --- a/projects/packages/my-jetpack/_inc/state/reducers.js +++ b/projects/packages/my-jetpack/_inc/state/reducers.js @@ -9,13 +9,29 @@ import { combineReducers } from '@wordpress/data'; import { SET_PURCHASES, SET_PURCHASES_IS_FETCHING, - SET_PRODUCT_ACTION_ERROR, + SET_PRODUCT, SET_PRODUCT_STATUS, - SET_FETCHING_PRODUCT_STATUS, + SET_IS_FETCHING_PRODUCT, + SET_PRODUCT_REQUEST_ERROR, } from './actions'; const products = ( state = {}, action ) => { switch ( action.type ) { + case SET_IS_FETCHING_PRODUCT: { + const { productId, isFetching } = action; + return { + ...state, + isFetching: { + ...state.isFetching, + [ productId ]: isFetching, + }, + errors: { + ...state.errors, + [ productId ]: isFetching ? undefined : state.errors[ productId ], + }, + }; + } + case SET_PRODUCT_STATUS: { const { productId, status } = action; return { @@ -27,28 +43,29 @@ const products = ( state = {}, action ) => { ...status, }, }, - error: {}, }; } - case SET_PRODUCT_ACTION_ERROR: + case SET_PRODUCT: { + const { product } = action; + const { slug: productId } = product; return { ...state, - error: action.error, + items: { + ...state.items, + [ productId ]: product, + }, }; + } - case SET_FETCHING_PRODUCT_STATUS: { - const { productId, isFetching } = action; + case SET_PRODUCT_REQUEST_ERROR: { + const { productId, error } = action; return { ...state, - items: { - ...state.items, - [ productId ]: { - ...state.items[ productId ], - isFetching, - }, + errors: { + ...state.errors, + [ productId ]: error, }, - error: isFetching ? {} : state.error, }; } diff --git a/projects/packages/my-jetpack/_inc/state/resolvers.js b/projects/packages/my-jetpack/_inc/state/resolvers.js index 22c828d9cbe29..34b3a9483dffa 100644 --- a/projects/packages/my-jetpack/_inc/state/resolvers.js +++ b/projects/packages/my-jetpack/_inc/state/resolvers.js @@ -6,9 +6,29 @@ import apiFetch from '@wordpress/api-fetch'; /** * Internal dependencies */ -import { REST_API_SITE_PURCHASES_ENDPOINT } from './constants'; +import { REST_API_SITE_PURCHASES_ENDPOINT, REST_API_SITE_PRODUCTS_ENDPOINT } from './constants'; const myJetpackResolvers = { + getProduct: productId => async ( { dispatch } ) => { + dispatch.setIsFetchingProduct( productId, true ); + try { + dispatch.setProduct( + await apiFetch( { + path: `${ REST_API_SITE_PRODUCTS_ENDPOINT }/${ productId }`, + } ) + ); + dispatch.setIsFetchingProduct( productId, false ); + } catch ( error ) { + dispatch.setIsFetchingProduct( productId, false ); + + // Pick error from the response body. + if ( error?.code && error?.message ) { + dispatch.setRequestProductError( productId, error ); + } else { + throw new Error( error ); + } + } + }, getPurchases: () => async ( { dispatch } ) => { dispatch.setPurchasesIsFetching( true ); diff --git a/projects/packages/my-jetpack/_inc/state/selectors.js b/projects/packages/my-jetpack/_inc/state/selectors.js index 8caa5597946d3..b0251be083023 100644 --- a/projects/packages/my-jetpack/_inc/state/selectors.js +++ b/projects/packages/my-jetpack/_inc/state/selectors.js @@ -9,6 +9,7 @@ const productSelectors = { getProductNames, getProduct, isValidProduct, + isFetching: ( state, productId ) => state.products?.isFetching?.[ productId ] || false, }; const purchasesSelectors = { diff --git a/projects/packages/my-jetpack/changelog/update-my-jetpack-product-card-action-state b/projects/packages/my-jetpack/changelog/update-my-jetpack-product-card-action-state new file mode 100644 index 0000000000000..85f4868dfff17 --- /dev/null +++ b/projects/packages/my-jetpack/changelog/update-my-jetpack-product-card-action-state @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Update data handling - Implement request status in Product Card diff --git a/projects/packages/my-jetpack/src/class-products.php b/projects/packages/my-jetpack/src/class-products.php index 2dfae5cfc0e42..a8029585514ea 100644 --- a/projects/packages/my-jetpack/src/class-products.php +++ b/projects/packages/my-jetpack/src/class-products.php @@ -116,33 +116,6 @@ public static function get_anti_spam_data() { ); } - /** - * Activate Backup product - */ - public static function activate_backup_product() { - /* - * @todo: implement - * suggestion: when it enables, return an success array: - * array( 'status' => 'activated' ); - * Otherwise, an WP_Error instance will be nice. - */ - return array( - 'status' => 'active', - ); - } - - /** - * Deactivate Backup product - */ - public static function deactivate_backup_product() { - /* - * @todo: implement - */ - return array( - 'status' => 'inactive', - ); - } - /** * Returns information about the Backup product * diff --git a/projects/packages/my-jetpack/src/class-rest-products.php b/projects/packages/my-jetpack/src/class-rest-products.php index 3f638f993bc09..a1544830a3d73 100644 --- a/projects/packages/my-jetpack/src/class-rest-products.php +++ b/projects/packages/my-jetpack/src/class-rest-products.php @@ -32,7 +32,7 @@ public function __construct() { 'description' => __( 'Product slug', 'jetpack-my-jetpack' ), 'type' => 'string', 'enum' => Products::get_product_names(), - 'required' => false, + 'required' => true, 'validate_callback' => __CLASS__ . '::check_product_argument', ); @@ -68,6 +68,20 @@ public function __construct() { ); } + /** + * Get the schema for the products endpoint + * + * @return array + */ + public function get_products_schema() { + return array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'products', + 'type' => 'object', + 'properties' => Products::get_product_data_schema(), + ); + } + /** * Check user capability to access the endpoint. * @@ -107,9 +121,7 @@ public static function check_product_argument( $value ) { * @return array of site products list. */ public static function get_products() { - $response = array( - 'products' => Products::get_products(), - ); + $response = Products::get_products(); return rest_ensure_response( $response, 200 ); } @@ -121,10 +133,7 @@ public static function get_products() { */ public static function get_product( $request ) { $product_slug = $request->get_param( 'product' ); - $response = array( - 'products' => array( Products::get_product( $product_slug ) ), - ); - return rest_ensure_response( $response, 200 ); + return rest_ensure_response( Products::get_product( $product_slug ), 200 ); } /** @@ -152,30 +161,19 @@ public static function activate_product( $request ) { $product_slug = $request->get_param( 'product' ); $product = Products::get_product( $product_slug ); if ( ! isset( $product['class'] ) ) { - return new \WP_REST_Response( - array( - 'error_message' => 'not_implemented', - ), - 400 + return new \WP_Error( + 'not_implemented', + esc_html__( 'The product class handler is not implemented', 'jetpack-my-jetpack' ), + array( 'status' => 400 ) ); } - $success = call_user_func( array( $product['class'], 'activate' ) ); - $error_code = ''; - $error_message = ''; - if ( is_wp_error( $success ) ) { - $error_code = $success->get_error_code(); - $error_message = $success->get_error_message(); - $success = false; + $activate_product_result = call_user_func( array( $product['class'], 'activate' ) ); + if ( is_wp_error( $activate_product_result ) ) { + return $activate_product_result; } - $response = array( - 'success' => $success, - 'products' => array( Products::get_product( $product_slug ) ), - 'error_code' => $error_code, - 'error_message' => $error_message, - ); - return rest_ensure_response( $response, 200 ); + return rest_ensure_response( Products::get_product( $product_slug ), 200 ); } /** @@ -188,37 +186,19 @@ public static function deactivate_product( $request ) { $product_slug = $request->get_param( 'product' ); $product = Products::get_product( $product_slug ); if ( ! isset( $product['class'] ) ) { - return new \WP_REST_Response( - array( - 'error_message' => 'not_implemented', - ), - 400 + return new \WP_Error( + 'not_implemented', + esc_html__( 'The product class handler is not implemented', 'jetpack-my-jetpack' ), + array( 'status' => 400 ) ); } - $success = call_user_func( array( $product['class'], 'deactivate' ) ); - $response = array( - 'success' => $success, - 'products' => array( Products::get_product( $product_slug ) ), - ); - return rest_ensure_response( $response, 200 ); - - } - - /** - * Set site product state. - * - * @param \WP_REST_Request $request The request object. - * @return array of site products list. - */ - public static function set_product_state( $request ) { - $product_slug = $request->get_param( 'product' ); - $activate = $request->get_param( 'activate' ); - - if ( $activate ) { - return Products::activate_backup_product( $product_slug ); + $deactivate_product_result = call_user_func( array( $product['class'], 'deactivate' ) ); + if ( is_wp_error( $deactivate_product_result ) ) { + return $deactivate_product_result; } - return Products::deactivate_backup_product( $product_slug ); + return rest_ensure_response( Products::get_product( $product_slug ), 200 ); } + } diff --git a/projects/packages/my-jetpack/tests/php/test-products-rest.php b/projects/packages/my-jetpack/tests/php/test-products-rest.php index 2a599d3c3a841..c927eb51a8393 100644 --- a/projects/packages/my-jetpack/tests/php/test-products-rest.php +++ b/projects/packages/my-jetpack/tests/php/test-products-rest.php @@ -128,7 +128,7 @@ public function test_get_products() { $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( $products, $data['products'] ); + $this->assertEquals( $products, $data ); } /** @@ -169,7 +169,7 @@ public function test_get_product() { $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( $product, $data['products'][0] ); + $this->assertEquals( $product, $data ); } /** @@ -196,7 +196,8 @@ public function test_activate_boost() { $response = $this->server->dispatch( $this->request ); $data = $response->get_data(); - $this->assertEquals( 'active', $data['products'][0]['status'] ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'active', $data['status'] ); $this->assertTrue( is_plugin_active( $this->boost_mock_filename ) ); } @@ -214,7 +215,8 @@ public function test_deactivate_boost() { $response = $this->server->dispatch( $this->request ); $data = $response->get_data(); - $this->assertEquals( 'inactive', $data['products'][0]['status'] ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'inactive', $data['status'] ); $this->assertFalse( is_plugin_active( $this->boost_mock_filename ) ); } @@ -233,9 +235,8 @@ public function test_activate_uninstallable() { $response = $this->server->dispatch( $this->request ); $data = $response->get_data(); - $this->assertEquals( 'inactive', $data['products'][0]['status'] ); - $this->assertEquals( 'plugin_php_incompatible', $data['error_code'] ); - $this->assertFalse( $data['success'] ); + $this->assertEquals( 500, $response->get_status() ); + $this->assertEquals( 'plugin_php_incompatible', $data['code'] ); $this->assertFalse( is_plugin_active( $this->boost_mock_filename ) ); }