From 887cba71cf9bcb2a79f48292c0c22547143abde5 Mon Sep 17 00:00:00 2001 From: moon Date: Wed, 13 Apr 2022 13:42:37 -0700 Subject: [PATCH] Add REST API Filters that allows us to modify API responses without modifying code. --- api/api.php | 2 + api/rest-api-filters/hook.php | 50 ++++++ api/rest-api-filters/rest-api-filters.php | 109 ++++++++++++ src/app/app.js | 26 +-- src/index.scss | 153 ++++++++++------ src/rest-api-filters/data/action-types.js | 9 + src/rest-api-filters/data/actions.js | 93 ++++++++++ src/rest-api-filters/data/constants.js | 5 + src/rest-api-filters/data/index.js | 22 +++ src/rest-api-filters/data/reducer.js | 55 ++++++ src/rest-api-filters/data/resolvers.js | 29 +++ src/rest-api-filters/data/selectors.js | 7 + src/rest-api-filters/index.js | 206 ++++++++++++++++++++++ 13 files changed, 700 insertions(+), 66 deletions(-) create mode 100644 api/rest-api-filters/hook.php create mode 100644 api/rest-api-filters/rest-api-filters.php create mode 100644 src/rest-api-filters/data/action-types.js create mode 100644 src/rest-api-filters/data/actions.js create mode 100644 src/rest-api-filters/data/constants.js create mode 100644 src/rest-api-filters/data/index.js create mode 100644 src/rest-api-filters/data/reducer.js create mode 100644 src/rest-api-filters/data/resolvers.js create mode 100644 src/rest-api-filters/data/selectors.js create mode 100644 src/rest-api-filters/index.js diff --git a/api/api.php b/api/api.php index cab00d3441b..36614cbe251 100644 --- a/api/api.php +++ b/api/api.php @@ -37,3 +37,5 @@ require( 'tools/disable-wc-email.php' ); require( 'tools/trigger-update-callbacks.php' ); require( 'tracks/tracks-debug-log.php' ); require( 'features/features.php' ); +require( 'rest-api-filters/rest-api-filters.php' ); +require( 'rest-api-filters/hook.php' ); diff --git a/api/rest-api-filters/hook.php b/api/rest-api-filters/hook.php new file mode 100644 index 00000000000..0cb88b9d296 --- /dev/null +++ b/api/rest-api-filters/hook.php @@ -0,0 +1,50 @@ + 1 ) { + $key = array_shift( $keys ); + if (! isset( $array[$key] ) || ! is_array( $array[$key]) ) { + $array[$key] = []; + } + $array = &$array[$key]; + } + + $array[ array_shift($keys) ] = $value; + return $array; +} + +add_filter( + 'rest_request_after_callbacks', + function( $response, array $handler, \WP_REST_Request $request ) use ( $filters ) { + if ( ! $response instanceof \WP_REST_Response ) { + return $response; + } + $route = $request->get_route(); + $filters = array_filter( $filters, function( $filter ) use ( $request, $route ) { + if ( $filter['enabled'] && $filter['endpoint'] == $route ) { + return true; + } + return false; + }); + + $data = $response->get_data(); + + foreach ( $filters as $filter ) { + array_dot_set( $data, $filter['dot_notation'], $filter['replacement'] ); + } + + $response->set_data( $data ); + + return $response; + }, + 10, + 3 +); \ No newline at end of file diff --git a/api/rest-api-filters/rest-api-filters.php b/api/rest-api-filters/rest-api-filters.php new file mode 100644 index 00000000000..edbcaec338d --- /dev/null +++ b/api/rest-api-filters/rest-api-filters.php @@ -0,0 +1,109 @@ + 'POST', + 'args' => array( + 'endpoint' => array( + 'description' => 'Rest API endpoint.', + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'dot_notation' => array( + 'description' => 'Dot notation of the target field.', + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + 'replacement' => array( + 'description' => 'Replacement value for the target field.', + 'type' => 'string', + 'required' => true, + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ) +); + +register_woocommerce_admin_test_helper_rest_route( + '/rest-api-filters', + [ WCA_Test_Helper_Rest_Api_Filters::class, 'delete' ], + array( + 'methods' => 'DELETE', + 'args' => array( + 'index' => array( + 'description' => 'Rest API endpoint.', + 'type' => 'integer', + 'required' => true, + ), + ), + ) +); + + +register_woocommerce_admin_test_helper_rest_route( + '/rest-api-filters/(?P\d+)/toggle', + [ WCA_Test_Helper_Rest_Api_Filters::class, 'toggle' ], + array( + 'methods' => 'POST', + ) +); + + +class WCA_Test_Helper_Rest_Api_Filters { + const WC_ADMIN_TEST_HELPER_REST_API_FILTER_OPTION = 'wc-admin-test-helper-rest-api-filters'; + + public static function create( $request ) { + $endpoint = $request->get_param( 'endpoint' ); + $dot_notation = $request->get_param( 'dot_notation' ); + $replacement = $request->get_param( 'replacement' ); + + if ( $replacement === 'false' ) { + $replacement = false; + } else if ( $replacement === 'true' ) { + $replacement = true; + } + + self::update( function( $filters ) use ( + $endpoint, + $dot_notation, + $replacement + ) { + $filters[] = array( + 'endpoint' => $endpoint, + 'dot_notation' => $dot_notation, + 'replacement' => $replacement, + 'enabled' => true, + ); + return $filters; + }); + return new WP_REST_RESPONSE( null, 204 ); + } + + public static function update( callable $callback ) { + $filters = get_option( self::WC_ADMIN_TEST_HELPER_REST_API_FILTER_OPTION, array() ); + $filters = $callback( $filters ); + return update_option( self::WC_ADMIN_TEST_HELPER_REST_API_FILTER_OPTION, $filters ); + } + + public static function delete( $request ) { + self::update(function($filters) use ($request) { + array_splice( $filters, $request->get_param( 'index' ), 1 ); + return $filters; + }); + + return new WP_REST_RESPONSE( null, 204 ); + } + + public static function toggle( $request ) { + self::update(function($filters) use ($request) { + $index = $request->get_param( 'index' ); + $filters[$index]['enabled'] = !$filters[$index]['enabled']; + return $filters; + }); + return new WP_REST_RESPONSE( null, 204 ); + } +} \ No newline at end of file diff --git a/src/app/app.js b/src/app/app.js index acc8790b431..44e52fe4d3e 100644 --- a/src/app/app.js +++ b/src/app/app.js @@ -12,8 +12,9 @@ import { default as Tools } from '../tools'; import { default as Options } from '../options'; import { default as Experiments } from '../experiments'; import { default as Features } from '../features'; +import { default as RestAPIFilters } from '../rest-api-filters'; -const tabs = applyFilters('woocommerce_admin_test_helper_tabs', [ +const tabs = applyFilters( 'woocommerce_admin_test_helper_tabs', [ { name: 'options', title: 'Options', @@ -39,7 +40,12 @@ const tabs = applyFilters('woocommerce_admin_test_helper_tabs', [ title: 'Features', content: , }, -]); + { + name: 'rest-api-filters', + title: 'REST API FIlters', + content: , + }, +] ); export function App() { return ( @@ -48,18 +54,18 @@ export function App() { - {(tab) => ( + { ( tab ) => ( <> - {tab.content} - {applyFilters( - `woocommerce_admin_test_helper_tab_${tab.name}`, + { tab.content } + { applyFilters( + `woocommerce_admin_test_helper_tab_${ tab.name }`, [] - )} + ) } - )} + ) } ); diff --git a/src/index.scss b/src/index.scss index 28e61ed7cc5..ee3234dbfab 100644 --- a/src/index.scss +++ b/src/index.scss @@ -1,76 +1,117 @@ +#woocommerce-admin-test-helper-app-root { + .btn-danger { + color: #fff; + background-color: #dc3545; + border-color: #dc3545; + } + + .btn-primary { + color: #fff; + background-color: #007bff; + border-color: #007bff; + } +} + .woocommerce-admin-test-helper__main-tab-panel { - .active-tab { - box-shadow: inset 0 1.5px #007cba; - box-shadow: inset 0 1.5px var(--wp-admin-theme-color); - } + .active-tab { + box-shadow: inset 0 1.5px #007cba; + box-shadow: inset 0 1.5px var( --wp-admin-theme-color ); + } } .woocommerce-admin-test-helper__action-status { - color: #007cba; - color: var(--wp-admin-theme-color); - font-family: monospace; + color: #007cba; + color: var( --wp-admin-theme-color ); + font-family: monospace; } .woocommerce-admin-test-helper__add-notes { - width: 410px; - display: flex; - justify-content: space-between; - .components-base-control__field { - margin-bottom: 0; - padding-top: 3px; - } + width: 410px; + display: flex; + justify-content: space-between; + .components-base-control__field { + margin-bottom: 0; + padding-top: 3px; + } } #wc-admin-test-helper-options { - div.search-box { - float: right; - margin-bottom:10px; - } + div.search-box { + float: right; + margin-bottom: 10px; + } - .btn-danger { - color: #fff; - background-color: #dc3545; - border-color: #dc3545; - } + .align-center { + text-align: center; + } - .align-center { - text-align: center; - } - - .components-notice { - margin: 0px 0px 10px 0px; - } + .components-notice { + margin: 0px 0px 10px 0px; + } } .wca-test-helper-option-editor { - width: 100%; - height: 300px; + width: 100%; + height: 300px; } .wca-test-helper-edit-btn-save { - float: right; + float: right; } -#wc-admin-test-helper-tools, #wc-admin-test-helper-experiments { - table.tools, table.experiments { - thead th { - text-align: center; - } - tbody td { - vertical-align: middle; - &.command { - white-space: nowrap; - } - .trigger-cron-job { - width: 40%; - padding-top: 4px; - .components-base-control__field { - margin-bottom: 0; - } - } - } - } - .components-notice { - margin: 0px 0px 10px 0px; - } -} \ No newline at end of file +#wc-admin-test-helper-tools, +#wc-admin-test-helper-experiments { + table.tools, + table.experiments { + thead th { + text-align: center; + } + tbody td { + vertical-align: middle; + &.command { + white-space: nowrap; + } + .trigger-cron-job { + width: 40%; + padding-top: 4px; + .components-base-control__field { + margin-bottom: 0; + } + } + } + } + .components-notice { + margin: 0px 0px 10px 0px; + } +} + +#wc-admin-test-helper-rest-api-filters { + .btn-new { + float: right; + } +} + +form.rest-api-filter-new-form { + .grid { + display: grid; + grid-template-columns: max-content max-content; + grid-gap: 5px; + input[type='text'] { + width: 350px; + } + label { + text-align: right; + } + label:after { + content: ':'; + } + } + + .btn-new { + color: #fff; + background-color: #007bff; + border-color: #007bff; + float: right; + margin-top: 10px; + } +} diff --git a/src/rest-api-filters/data/action-types.js b/src/rest-api-filters/data/action-types.js new file mode 100644 index 00000000000..4fd3c25daf5 --- /dev/null +++ b/src/rest-api-filters/data/action-types.js @@ -0,0 +1,9 @@ +const TYPES = { + SET_FILTERS: 'SET_FILTERS', + SET_IS_LOADING: 'SET_IS_LOADING', + DELETE_FILTER: 'DELETE_FILTER', + SAVE_FILTER: 'SAVE_FILTER', + TOGGLE_FILTER: 'TOGGLE_FILTER', +}; + +export default TYPES; diff --git a/src/rest-api-filters/data/actions.js b/src/rest-api-filters/data/actions.js new file mode 100644 index 00000000000..09f2d8fc604 --- /dev/null +++ b/src/rest-api-filters/data/actions.js @@ -0,0 +1,93 @@ +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { API_NAMESPACE } from './constants'; + +/** + * Initialize the state + * + * @param {Array} filter + * @param filters + */ +export function setFilters( filters ) { + return { + type: TYPES.SET_FILTERS, + filters, + }; +} + +export function setLoadingState( isLoading ) { + return { + type: TYPES.SET_IS_LOADING, + isLoading, + }; +} + +export function* toggleFilter( index ) { + try { + yield apiFetch( { + method: 'POST', + path: `${ API_NAMESPACE }/rest-api-filters/${ index }/toggle`, + headers: { 'content-type': 'application/json' }, + } ); + yield { + type: TYPES.TOGGLE_FILTER, + index, + }; + } catch { + throw new Error(); + } +} + +export function* deleteFilter( index ) { + try { + yield apiFetch( { + method: 'DELETE', + path: `${ API_NAMESPACE }/rest-api-filters/`, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( { + index, + } ), + } ); + + yield { + type: TYPES.DELETE_FILTER, + index, + }; + } catch { + throw new Error(); + } +} + +export function* saveFilter( endpoint, dotNotation, replacement ) { + try { + yield apiFetch( { + method: 'POST', + path: API_NAMESPACE + '/rest-api-filters', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify( { + endpoint, + dot_notation: dotNotation, + replacement, + } ), + } ); + + yield { + type: TYPES.SAVE_FILTER, + filter: { + endpoint, + dot_notation: dotNotation, + replacement, + enabled: true, + }, + }; + } catch { + throw new Error(); + } +} diff --git a/src/rest-api-filters/data/constants.js b/src/rest-api-filters/data/constants.js new file mode 100644 index 00000000000..a8375a21498 --- /dev/null +++ b/src/rest-api-filters/data/constants.js @@ -0,0 +1,5 @@ +export const STORE_KEY = 'wc-admin-helper/rest-api-filters'; +export const API_NAMESPACE = '/wc-admin-test-helper'; + +// Option name where we're going to save the filters. +export const FILTERS_OPTION_NAME = 'wc-admin-test-helper-rest-api-filters'; diff --git a/src/rest-api-filters/data/index.js b/src/rest-api-filters/data/index.js new file mode 100644 index 00000000000..e476479ea88 --- /dev/null +++ b/src/rest-api-filters/data/index.js @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import { registerStore } from '@wordpress/data'; +import { controls } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import * as actions from './actions'; +import * as resolvers from './resolvers'; +import * as selectors from './selectors'; +import reducer from './reducer'; +import { STORE_KEY } from './constants'; + +export default registerStore( STORE_KEY, { + actions, + selectors, + resolvers, + controls, + reducer, +} ); diff --git a/src/rest-api-filters/data/reducer.js b/src/rest-api-filters/data/reducer.js new file mode 100644 index 00000000000..3a906c78e49 --- /dev/null +++ b/src/rest-api-filters/data/reducer.js @@ -0,0 +1,55 @@ +/** + * Internal dependencies + */ +import TYPES from './action-types'; + +const DEFAULT_STATE = { + filters: [], + isLoading: true, + notice: { + status: 'success', + message: '', + }, +}; + +const reducer = ( state = DEFAULT_STATE, action ) => { + switch ( action.type ) { + case TYPES.TOGGLE_FILTER: + return { + ...state, + filters: state.filters.map( ( filter, index ) => { + if ( index === action.index ) { + filter.enabled = ! filter.enabled; + } + return filter; + } ), + }; + case TYPES.SET_IS_LOADING: + return { + ...state, + isLoading: action.isLoading, + }; + case TYPES.SET_FILTERS: + return { + ...state, + filters: action.filters, + isLoading: false, + }; + case TYPES.DELETE_FILTER: + return { + ...state, + filters: state.filters.filter( + ( item, index ) => index !== action.index + ), + }; + case TYPES.SAVE_FILTER: + return { + ...state, + filters: [ ...state.filters, action.filter ], + }; + default: + return state; + } +}; + +export default reducer; diff --git a/src/rest-api-filters/data/resolvers.js b/src/rest-api-filters/data/resolvers.js new file mode 100644 index 00000000000..0b88740a4cf --- /dev/null +++ b/src/rest-api-filters/data/resolvers.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import { FILTERS_OPTION_NAME } from './constants'; +import { setLoadingState, setFilters } from './actions'; + +export function* getFilters() { + const path = '/wc-admin/options?options=' + FILTERS_OPTION_NAME; + + yield setLoadingState( true ); + + try { + const response = yield apiFetch( { + path, + } ); + if ( response[ FILTERS_OPTION_NAME ] === false ) { + yield setFilters( [] ); + } else { + yield setFilters( response[ FILTERS_OPTION_NAME ] ); + } + } catch ( error ) { + throw new Error(); + } +} diff --git a/src/rest-api-filters/data/selectors.js b/src/rest-api-filters/data/selectors.js new file mode 100644 index 00000000000..44ca07aa452 --- /dev/null +++ b/src/rest-api-filters/data/selectors.js @@ -0,0 +1,7 @@ +export function getFilters( state ) { + return state.filters; +} + +export function isLoading( state ) { + return state.isLoading; +} diff --git a/src/rest-api-filters/index.js b/src/rest-api-filters/index.js new file mode 100644 index 00000000000..0cc2a13b7cd --- /dev/null +++ b/src/rest-api-filters/index.js @@ -0,0 +1,206 @@ +/** + * External dependencies + */ +import { withDispatch, withSelect } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; +import { Modal } from '@wordpress/components'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { STORE_KEY } from './data/constants'; +import './data'; + +function RestAPIFilters( { + filters, + deleteFilter, + isLoading, + saveFilter, + toggleFilter, +} ) { + const [ isNewModalOpen, setNewModalOpen ] = useState( false ); + + const submitAddForm = ( e ) => { + e.preventDefault(); + saveFilter( + e.target.endpoint.value, + e.target.dotNotation.value, + e.target.replacement.value + ); + setNewModalOpen( false ); + }; + + const renderLoading = () => { + return ( + + + Loading... + + + ); + }; + + const renderTableData = () => { + if ( filters.length === 0 ) { + return ( + + + No Filters Found + + + ); + } + + return filters.map( ( filter, index ) => { + // eslint-disable-next-line camelcase + const { endpoint, dot_notation, replacement, enabled } = filter; + + // eslint-disable-next-line camelcase + const dotNotation = dot_notation; + return ( + + { index + 1 } + { endpoint } + { dotNotation } + + { replacement + '' } + + + { enabled + '' } + + + + + + + + + ); + } ); + }; + + return ( + <> + { isNewModalOpen && ( + { + setNewModalOpen( false ); + } } + > +
+
+ + + + + + +
+ +
+
+ ) } +
+ setNewModalOpen( true ) } + /> +
+
+ + + + + + + + + + + + + + { isLoading ? renderLoading() : renderTableData() } + +
+ I.D + + Endpoint + + Dot Notation + + Replacement + + Enabled + + Toggle +
+
+ + ); +} + +export default compose( + withSelect( ( select ) => { + const { getFilters, isLoading } = select( STORE_KEY ); + const filters = getFilters(); + + return { + filters, + isLoading: isLoading(), + }; + } ), + withDispatch( ( dispatch ) => { + const { saveFilter, deleteFilter, toggleFilter } = dispatch( + STORE_KEY + ); + + return { + saveFilter, + deleteFilter, + toggleFilter, + }; + } ) +)( RestAPIFilters );