diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php index f9d561ad97f..ce6a4e617ba 100644 --- a/.phpstorm.meta.php +++ b/.phpstorm.meta.php @@ -50,6 +50,7 @@ 'reader_theme_loader' => \AmpProject\AmpWP\ReaderThemeLoader::class, 'reader_theme_support_features' => \AmpProject\AmpWP\ReaderThemeSupportFeatures::class, 'rest.options_controller' => \AmpProject\AmpWP\OptionsRESTController::class, + 'rest.scannable_urls_controller' => \AmpProject\AmpWP\Validation\ScannableURLsRestController::class, 'rest.validation_counts_controller' => \AmpProject\AmpWP\Validation\ValidationCountsRestController::class, 'sandboxing' => \AmpProject\AmpWP\Sandboxing::class, 'save_post_validation_event' => \AmpProject\AmpWP\Validation\SavePostValidationEvent::class, diff --git a/assets/src/common/helpers/get-plugin-slug-from-file.js b/assets/src/common/helpers/get-plugin-slug-from-file.js new file mode 100644 index 00000000000..bfced3fde52 --- /dev/null +++ b/assets/src/common/helpers/get-plugin-slug-from-file.js @@ -0,0 +1,16 @@ +/** + * Get plugin slug from file path. + * + * If the plugin file is in a directory, then the slug is just the directory name. Otherwise, if the file is not + * inside of a directory and is just a single-file plugin, then the slug is the filename of the PHP file. + * + * If the file path contains a file extension, it will be stripped as well. + * + * See the corresponding PHP logic in `\AmpProject\AmpWP\get_plugin_slug_from_file()`. + * + * @param {string} path Plugin file path. + * @return {string} Plugin slug. + */ +export function getPluginSlugFromFile( path = '' ) { + return path.replace( /\/.*$/, '' ).replace( /\.php$/, '' ); +} diff --git a/assets/src/common/helpers/test/get-plugin-slug-from-file.js b/assets/src/common/helpers/test/get-plugin-slug-from-file.js new file mode 100644 index 00000000000..4d4813448fe --- /dev/null +++ b/assets/src/common/helpers/test/get-plugin-slug-from-file.js @@ -0,0 +1,13 @@ +/** + * Internal dependencies + */ +import { getPluginSlugFromFile } from '../get-plugin-slug-from-file'; + +describe( 'getPluginSlugFromFile', () => { + it( 'should return correct plugin slug', () => { + expect( getPluginSlugFromFile( 'foo' ) ).toBe( 'foo' ); + expect( getPluginSlugFromFile( 'foo.php' ) ).toBe( 'foo' ); + expect( getPluginSlugFromFile( 'foo/bar' ) ).toBe( 'foo' ); + expect( getPluginSlugFromFile( 'foo/baz.php' ) ).toBe( 'foo' ); + } ); +} ); diff --git a/assets/src/components/amp-drawer/index.js b/assets/src/components/amp-drawer/index.js index 7b4118969cc..6b81cd6f71b 100644 --- a/assets/src/components/amp-drawer/index.js +++ b/assets/src/components/amp-drawer/index.js @@ -28,11 +28,22 @@ export const HANDLE_TYPE_RIGHT = 'right'; * @param {any} props.heading Content for the drawer heading. * @param {string} props.id A unique ID for the component. * @param {boolean} props.initialOpen Whether the drawer should be initially open. + * @param {Object} props.labelExtra Optional. Extra content to display on the right side of the option label. * @param {boolean} props.selected Whether to apply the selectable components selected CSS class. * @param {string} props.hiddenTitle A title to go with the button that expands the drawer. * @param {string} props.handleType Display style for the drawer handle. Either 'full-width' or 'right'. */ -export function AMPDrawer( { children = null, className, heading, handleType = HANDLE_TYPE_FULL_WIDTH, id, initialOpen = false, selected = false, hiddenTitle } ) { +export function AMPDrawer( { + children = null, + className, + heading, + handleType = HANDLE_TYPE_FULL_WIDTH, + id, + initialOpen = false, + labelExtra = null, + selected = false, + hiddenTitle, +} ) { const [ opened, setOpened ] = useState( initialOpen ); const [ resetStatus, setResetStatus ] = useState( null ); @@ -94,9 +105,16 @@ export function AMPDrawer( { children = null, className, heading, handleType = H selected={ selected } > { handleType === HANDLE_TYPE_RIGHT && ( -
- { heading } -
+ <> +
+ { heading } +
+ { labelExtra && ( +
+ { labelExtra } +
+ ) } + ) } { 'resetting' !== resetStatus && ( ) : ( -
- { heading } -
+ <> +
+ { heading } +
+ { labelExtra && ( +
+ { labelExtra } +
+ ) } + ) } className="amp-drawer__panel-body" initialOpen={ initialOpen } @@ -129,5 +154,6 @@ AMPDrawer.propTypes = { hiddenTitle: PropTypes.node.isRequired, id: PropTypes.string.isRequired, initialOpen: PropTypes.bool, + labelExtra: PropTypes.node, selected: PropTypes.bool, }; diff --git a/assets/src/components/amp-drawer/style.css b/assets/src/components/amp-drawer/style.css index f1c80b96f5c..7d55e366754 100644 --- a/assets/src/components/amp-drawer/style.css +++ b/assets/src/components/amp-drawer/style.css @@ -25,9 +25,7 @@ padding-left: 0.75rem; padding-right: 0.75rem; overflow: hidden; - position: absolute; - top: 0; - z-index: 1; + flex-grow: 1; @media (min-width: 783px) { padding-left: 3rem; @@ -45,6 +43,14 @@ margin-bottom: 0; } +.amp-drawer__heading svg { + margin-right: 1rem; +} + +.amp-drawer__label-extra svg { + fill: none; +} + .amp .amp-drawer .components-panel__body-title { height: var(--heading-height); margin: 0 0 0 auto; @@ -63,7 +69,6 @@ border-radius: 5px; display: flex; align-items: center; - justify-content: center; } .amp .amp-drawer .components-panel__body-title button { @@ -77,16 +82,15 @@ } -.amp .amp-drawer .components-panel__body-title button span { +.amp .amp-drawer .components-panel__body-title > button > span { align-items: center; display: flex; - height: 100%; - margin-left: auto; justify-content: center; width: var(--panel-button-width); + order: 100; } -.amp .amp-drawer .components-panel__body-title svg { +.amp .amp-drawer .components-panel__body-toggle.components-button .components-panel__arrow { height: 30px; position: static; transform: none; @@ -98,7 +102,8 @@ } } -.amp .amp-drawer--handle-type-full-width .components-panel__body-title svg { + +.amp .amp-drawer--handle-type-full-width .components-panel__body-title > button > svg { margin-left: auto; @media (min-width: 783px) { @@ -110,6 +115,11 @@ border-radius: 5px; } +.amp .amp-drawer .components-panel__body-title .amp-notice { + font-family: var(--font-default); + font-weight: 400; +} + .amp .amp-drawer__panel-body { padding: 0; border-top-width: 0; @@ -135,6 +145,7 @@ .amp .amp-drawer--handle-type-right .amp-drawer__heading { right: var(--panel-button-width); + width: calc(100% - var(--panel-button-width)); } .amp-drawer--handle-type-right .components-panel__body-title { diff --git a/assets/src/components/amp-notice/index.js b/assets/src/components/amp-notice/index.js index 187858f094d..869edad0525 100644 --- a/assets/src/components/amp-notice/index.js +++ b/assets/src/components/amp-notice/index.js @@ -12,6 +12,7 @@ import './style.css'; export const NOTICE_TYPE_ERROR = 'error'; export const NOTICE_TYPE_WARNING = 'warning'; export const NOTICE_TYPE_INFO = 'info'; +export const NOTICE_TYPE_PLAIN = 'plain'; export const NOTICE_TYPE_SUCCESS = 'success'; export const NOTICE_SIZE_SMALL = 'small'; @@ -103,5 +104,5 @@ AMPNotice.propTypes = { children: PropTypes.node, className: PropTypes.string, size: PropTypes.oneOf( [ NOTICE_SIZE_LARGE, NOTICE_SIZE_SMALL ] ), - type: PropTypes.oneOf( [ NOTICE_TYPE_INFO, NOTICE_TYPE_SUCCESS, NOTICE_TYPE_ERROR, NOTICE_TYPE_WARNING ] ), + type: PropTypes.oneOf( [ NOTICE_TYPE_PLAIN, NOTICE_TYPE_INFO, NOTICE_TYPE_SUCCESS, NOTICE_TYPE_ERROR, NOTICE_TYPE_WARNING ] ), }; diff --git a/assets/src/components/amp-notice/style.css b/assets/src/components/amp-notice/style.css index 7a30bae1f0e..8bd713ae4e7 100644 --- a/assets/src/components/amp-notice/style.css +++ b/assets/src/components/amp-notice/style.css @@ -34,7 +34,8 @@ background-color: #effbff; } -.amp-notice--info svg { +.amp-notice--info svg, +.amp-notice--plain svg { color: var(--amp-settings-color-brand); } @@ -51,6 +52,10 @@ background-color: #ffefef; } +.amp-notice.amp-notice--plain { + padding: 1px 5px; +} + .amp-notice--small { font-size: 14px; line-height: 1.5; diff --git a/assets/src/components/amp-notice/test/index.js b/assets/src/components/amp-notice/test/index.js index 4dcfa4848d7..a898f57d966 100644 --- a/assets/src/components/amp-notice/test/index.js +++ b/assets/src/components/amp-notice/test/index.js @@ -12,7 +12,15 @@ import { render } from '@wordpress/element'; /** * Internal dependencies */ -import { AMPNotice, NOTICE_TYPE_SUCCESS, NOTICE_SIZE_LARGE, NOTICE_TYPE_ERROR, NOTICE_SIZE_SMALL, NOTICE_TYPE_INFO } from '..'; +import { + AMPNotice, + NOTICE_TYPE_SUCCESS, + NOTICE_SIZE_LARGE, + NOTICE_TYPE_ERROR, + NOTICE_SIZE_SMALL, + NOTICE_TYPE_INFO, + NOTICE_TYPE_PLAIN, +} from '..'; let container; @@ -78,5 +86,16 @@ describe( 'AMPNotice', () => { } ); expect( container.querySelector( 'div' ).getAttribute( 'class' ) ).toBe( 'amp-notice amp-notice--info amp-notice--small' ); + + act( () => { + render( + + { 'children' } + , + container, + ); + } ); + + expect( container.querySelector( 'div' ).getAttribute( 'class' ) ).toBe( 'amp-notice amp-notice--plain amp-notice--small' ); } ); } ); diff --git a/assets/src/components/plugins-context-provider/__mocks__/index.js b/assets/src/components/plugins-context-provider/__mocks__/index.js new file mode 100644 index 00000000000..0b57ff8ec22 --- /dev/null +++ b/assets/src/components/plugins-context-provider/__mocks__/index.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +/** + * WordPress dependencies + */ +import { createContext } from '@wordpress/element'; + +export const Plugins = createContext(); + +/** + * MOCK. + * + * @param {Object} props + * @param {any} props.children Component children. + * @param {boolean} props.fetchingPlugins Whether fetching plugins or not. + * @param {Array} props.plugins An array of fetched plugins. + */ +export function PluginsContextProvider( { + children, + fetchingPlugins = false, + plugins = [], +} ) { + return ( + + { children } + + ); +} +PluginsContextProvider.propTypes = { + children: PropTypes.any, + fetchingPlugins: PropTypes.bool, + plugins: PropTypes.array, +}; diff --git a/assets/src/components/plugins-context-provider/index.js b/assets/src/components/plugins-context-provider/index.js new file mode 100644 index 00000000000..e80c57e982a --- /dev/null +++ b/assets/src/components/plugins-context-provider/index.js @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +/** + * WordPress dependencies + */ +import { + createContext, + useContext, + useEffect, + useRef, + useState, +} from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { ErrorContext } from '../error-context-provider'; +import { useAsyncError } from '../../utils/use-async-error'; + +export const Plugins = createContext(); + +/** + * Plugins context provider. + * + * @param {Object} props Component props. + * @param {any} props.children Component children. + * @param {boolean} props.hasErrorBoundary Whether the component is wrapped in an error boundary. + */ +export function PluginsContextProvider( { + children, + hasErrorBoundary = false, +} ) { + const [ plugins, setPlugins ] = useState( [] ); + const [ fetchingPlugins, setFetchingPlugins ] = useState( null ); + + const { error, setError } = useContext( ErrorContext ); + const { setAsyncError } = useAsyncError(); + + /** + * This component sets state inside async functions. + * Use this ref to prevent state updates after unmount. + */ + const hasUnmounted = useRef( false ); + useEffect( () => () => { + hasUnmounted.current = true; + }, [] ); + + /** + * Fetches validated URL data. + */ + useEffect( () => { + if ( error || plugins.length > 0 || fetchingPlugins ) { + return; + } + + ( async () => { + setFetchingPlugins( true ); + + try { + const fetchedPlugins = await apiFetch( { + path: '/wp/v2/plugins', + } ); + + if ( hasUnmounted.current === true ) { + return; + } + + setPlugins( fetchedPlugins ); + } catch ( e ) { + if ( hasUnmounted.current === true ) { + return; + } + + setError( e ); + + if ( hasErrorBoundary ) { + setAsyncError( e ); + } + + return; + } + + setFetchingPlugins( false ); + } )(); + }, [ error, fetchingPlugins, hasErrorBoundary, plugins, setAsyncError, setError ] ); + + return ( + + { children } + + ); +} +PluginsContextProvider.propTypes = { + children: PropTypes.any, + hasErrorBoundary: PropTypes.bool, +}; diff --git a/assets/src/components/plugins-context-provider/test/use-normalized-plugins-data.js b/assets/src/components/plugins-context-provider/test/use-normalized-plugins-data.js new file mode 100644 index 00000000000..0f1b561acfe --- /dev/null +++ b/assets/src/components/plugins-context-provider/test/use-normalized-plugins-data.js @@ -0,0 +1,132 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { act } from 'react-dom/test-utils'; + +/** + * WordPress dependencies + */ +import { render, unmountComponentAtNode } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ErrorContextProvider } from '../../error-context-provider'; +import { PluginsContextProvider } from '../index'; +import { useNormalizedPluginsData } from '../use-normalized-plugins-data'; + +jest.mock( '../index' ); + +let returnValue = {}; + +function ComponentContainingHook() { + returnValue = useNormalizedPluginsData(); + return null; +} + +const Providers = ( { children, fetchingPlugins, plugins = [] } ) => ( + + + { children } + + +); +Providers.propTypes = { + children: PropTypes.any, + fetchingPlugins: PropTypes.bool, + plugins: PropTypes.array, +}; + +describe( 'useNormalizedPluginsData', () => { + let container = null; + + beforeEach( () => { + container = document.createElement( 'div' ); + document.body.appendChild( container ); + } ); + + afterEach( () => { + unmountComponentAtNode( container ); + container.remove(); + container = null; + returnValue = {}; + } ); + + it( 'returns empty an array if plugins are being fetched', () => { + act( () => { + render( + + + , + container, + ); + } ); + + expect( returnValue ).toHaveLength( 0 ); + } ); + + it( 'returns a normalized array of plugins', () => { + act( () => { + render( + Acme Inc.', + }, + author_uri: { + raw: 'http://example.com', + rendered: 'http://example.com', + }, + name: 'Acme Plugin', + plugin: 'acme-inc', + status: 'inactive', + version: '1.0.1', + }, + { + author: 'AMP Project Contributors', + author_uri: 'https://github.com/ampproject/amp-wp/graphs/contributors', + name: { + raw: 'AMP', + rendered: 'AMP', + }, + plugin: 'amp/amp', + status: 'active', + version: '2.2.0-alpha', + }, + ] } + > + + , + container, + ); + } ); + + expect( returnValue ).toStrictEqual( { + 'acme-inc': { + author: 'Acme Inc.', + author_uri: 'http://example.com', + name: 'Acme Plugin', + plugin: 'acme-inc', + status: 'inactive', + slug: 'acme-inc', + version: '1.0.1', + }, + amp: { + author: 'AMP Project Contributors', + author_uri: 'https://github.com/ampproject/amp-wp/graphs/contributors', + name: 'AMP', + plugin: 'amp/amp', + status: 'active', + slug: 'amp', + version: '2.2.0-alpha', + }, + } ); + } ); +} ); diff --git a/assets/src/components/plugins-context-provider/use-normalized-plugins-data.js b/assets/src/components/plugins-context-provider/use-normalized-plugins-data.js new file mode 100644 index 00000000000..aebc41c95fb --- /dev/null +++ b/assets/src/components/plugins-context-provider/use-normalized-plugins-data.js @@ -0,0 +1,37 @@ +/** + * WordPress dependencies + */ +import { useContext, useEffect, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { getPluginSlugFromFile } from '../../common/helpers/get-plugin-slug-from-file'; +import { Plugins } from './index'; + +export function useNormalizedPluginsData() { + const { fetchingPlugins, plugins } = useContext( Plugins ); + const [ normalizedPluginsData, setNormalizedPluginsData ] = useState( [] ); + + useEffect( () => { + if ( fetchingPlugins || plugins.length === 0 ) { + return; + } + + setNormalizedPluginsData( plugins.reduce( ( accumulatedPluginsData, source ) => { + const slug = getPluginSlugFromFile( source.plugin ); + + return { + ...accumulatedPluginsData, + [ slug ]: Object.keys( source ).reduce( ( props, key ) => ( { + ...props, + slug, + // Flatten every prop that contains a `raw` member. + [ key ]: source[ key ]?.raw ?? source[ key ], + } ), {} ), + }; + }, {} ) ); + }, [ fetchingPlugins, plugins ] ); + + return normalizedPluginsData; +} diff --git a/assets/src/components/progress-bar/index.js b/assets/src/components/progress-bar/index.js new file mode 100644 index 00000000000..2a71b039da3 --- /dev/null +++ b/assets/src/components/progress-bar/index.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import './style.scss'; + +/** + * Progress bar component. + * + * @param {number} value The value of the progress bar indicator (between 0 and 100). + */ +export function ProgressBar( { value } ) { + const style = { + transform: `translateX(${ Math.max( value, 3 ) - 100 }%)`, + transitionDuration: `${ value < 100 ? 800 : 200 }ms`, + }; + + return ( +
+
+
+
+
+ ); +} + +ProgressBar.propTypes = { + value: PropTypes.number.isRequired, +}; diff --git a/assets/src/components/progress-bar/style.scss b/assets/src/components/progress-bar/style.scss new file mode 100644 index 00000000000..74b425ef39a --- /dev/null +++ b/assets/src/components/progress-bar/style.scss @@ -0,0 +1,33 @@ +:root { + --amp-progress-bar-color: var(--amp-brand); + --amp-progress-bar-height: 34px; +} + +.progress-bar { + border: 2px solid var(--amp-progress-bar-color); + border-radius: var(--amp-progress-bar-height); + height: var(--amp-progress-bar-height); + margin-bottom: 1.5rem; + margin-top: 1.5rem; + overflow: hidden; +} + +.progress-bar__track { + border-radius: var(--amp-progress-bar-height); + height: calc(100% - 8px); + margin: 4px; + overflow: hidden; + position: relative; + width: calc(100% - 8px); +} + +.progress-bar__indicator { + background-color: var(--amp-progress-bar-color); + border-radius: var(--amp-progress-bar-height); + height: 100%; + left: 0; + position: absolute; + top: 0; + transition: transform 800ms ease-out; + width: 100%; +} diff --git a/assets/src/components/progress-bar/test/__snapshots__/progress-bar.js.snap b/assets/src/components/progress-bar/test/__snapshots__/progress-bar.js.snap new file mode 100644 index 00000000000..33dd0801414 --- /dev/null +++ b/assets/src/components/progress-bar/test/__snapshots__/progress-bar.js.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProgressBar matches the snapshot 1`] = ` +
+
+
+
+
+`; diff --git a/assets/src/components/progress-bar/test/progress-bar.js b/assets/src/components/progress-bar/test/progress-bar.js new file mode 100644 index 00000000000..04e03bafe15 --- /dev/null +++ b/assets/src/components/progress-bar/test/progress-bar.js @@ -0,0 +1,76 @@ + +/** + * External dependencies + */ +import { act } from 'react-dom/test-utils'; +import { create } from 'react-test-renderer'; + +/** + * WordPress dependencies + */ +import { render } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ProgressBar } from '../index'; + +let container; + +describe( 'ProgressBar', () => { + beforeEach( () => { + container = document.createElement( 'div' ); + document.body.appendChild( container ); + } ); + + afterEach( () => { + document.body.removeChild( container ); + container = null; + } ); + + it( 'matches the snapshot', () => { + const wrapper = create( ); + + expect( wrapper.toJSON() ).toMatchSnapshot(); + } ); + + it( 'renders a progress bar', () => { + act( () => { + render( + , + container, + ); + } ); + + expect( container.querySelector( '.progress-bar[role="progressbar"]' ) ).not.toBeNull(); + expect( container.querySelector( '.progress-bar[aria-valuemin="0"]' ) ).not.toBeNull(); + expect( container.querySelector( '.progress-bar[aria-valuemax="100"]' ) ).not.toBeNull(); + expect( container.querySelector( '.progress-bar[aria-valuenow="33"]' ) ).not.toBeNull(); + } ); + + it( 'the bar is shifted correctly', () => { + act( () => { + render( + , + container, + ); + } ); + + expect( container.querySelector( '.progress-bar[aria-valuenow="75"]' ) ).not.toBeNull(); + expect( container.querySelector( '.progress-bar__indicator' ) ).not.toBeNull(); + expect( container.querySelector( '.progress-bar__indicator' ).style.transform ).toBe( 'translateX(-25%)' ); + } ); + + it( 'does not allow the bar to be completely out of view for low values', () => { + act( () => { + render( + , + container, + ); + } ); + + expect( container.querySelector( '.progress-bar[aria-valuenow="1"]' ) ).not.toBeNull(); + expect( container.querySelector( '.progress-bar__indicator' ) ).not.toBeNull(); + expect( container.querySelector( '.progress-bar__indicator' ).style.transform ).toBe( 'translateX(-97%)' ); + } ); +} ); diff --git a/assets/src/components/site-scan-context-provider/get-slugs-from-validation-results.js b/assets/src/components/site-scan-context-provider/get-slugs-from-validation-results.js new file mode 100644 index 00000000000..3da8237e2f3 --- /dev/null +++ b/assets/src/components/site-scan-context-provider/get-slugs-from-validation-results.js @@ -0,0 +1,42 @@ +/** + * Internal dependencies + */ +import { getPluginSlugFromFile } from '../../common/helpers/get-plugin-slug-from-file'; + +/** + * Retrieve slugs of plugins and themes from a list of validation results. + * + * See the corresponding PHP logic in `\AMP_Validated_URL_Post_Type::render_sources_column()`. + * + * @param {Object[]} validationResults + * @return {Object} An object consisting of `pluginSlugs` and `themeSlugs` arrays. + */ +export function getSlugsFromValidationResults( validationResults = [] ) { + const plugins = new Set(); + const themes = new Set(); + + for ( const result of validationResults ) { + if ( ! result.sources ) { + continue; + } + + for ( const source of result.sources ) { + if ( source.type === 'plugin' ) { + const pluginSlug = getPluginSlugFromFile( source.name ); + if ( 'gutenberg' !== pluginSlug || result.sources.length === 1 ) { + plugins.add( pluginSlug ); + } + } else if ( source.type === 'theme' ) { + themes.add( source.name ); + } + } + } + + // Skip including AMP in the summary, since AMP is like core. + plugins.delete( 'amp' ); + + return { + plugins: [ ...plugins ], + themes: [ ...themes ], + }; +} diff --git a/assets/src/components/site-scan-context-provider/index.js b/assets/src/components/site-scan-context-provider/index.js new file mode 100644 index 00000000000..cb339538b69 --- /dev/null +++ b/assets/src/components/site-scan-context-provider/index.js @@ -0,0 +1,399 @@ +/** + * WordPress dependencies + */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useReducer, + useRef, +} from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; +import { usePrevious } from '@wordpress/compose'; + +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import { STANDARD } from '../../common/constants'; +import { useAsyncError } from '../../utils/use-async-error'; +import { Options } from '../options-context-provider'; +import { getSlugsFromValidationResults } from './get-slugs-from-validation-results'; + +export const SiteScan = createContext(); + +/** + * Site Scan Actions. + */ +const ACTION_SCANNABLE_URLS_REQUEST = 'ACTION_SCANNABLE_URLS_REQUEST'; +const ACTION_SCANNABLE_URLS_FETCH = 'ACTION_SCANNABLE_URLS_FETCH'; +const ACTION_SCANNABLE_URLS_RECEIVE = 'ACTION_SCANNABLE_URLS_RECEIVE'; +const ACTION_SCAN_INITIALIZE = 'ACTION_SCAN_INITIALIZE'; +const ACTION_SCAN_VALIDATE_URL = 'ACTION_SCAN_VALIDATE_URL'; +const ACTION_SCAN_RECEIVE_VALIDATION_ERRORS = 'ACTION_SCAN_RECEIVE_VALIDATION_ERRORS'; +const ACTION_SCAN_NEXT_URL = 'ACTION_SCAN_NEXT_URL'; +const ACTION_SCAN_CANCEL = 'ACTION_SCAN_CANCEL'; + +/** + * Site Scan Statuses. + */ +const STATUS_REQUEST_SCANNABLE_URLS = 'STATUS_REQUEST_SCANNABLE_URLS'; +const STATUS_FETCHING_SCANNABLE_URLS = 'STATUS_FETCHING_SCANNABLE_URLS'; +const STATUS_READY = 'STATUS_READY'; +const STATUS_IDLE = 'STATUS_IDLE'; +const STATUS_IN_PROGRESS = 'STATUS_IN_PROGRESS'; +const STATUS_COMPLETED = 'STATUS_COMPLETED'; +const STATUS_FAILED = 'STATUS_FAILED'; +const STATUS_CANCELLED = 'STATUS_CANCELLED'; + +/** + * Initial Site Scan state. + * + * @type {Object} + */ +const INITIAL_STATE = { + cache: false, + currentlyScannedUrlIndex: 0, + scannableUrls: [], + status: '', +}; + +/** + * Site Scan Reducer. + * + * @param {Object} state Current state. + * @param {Object} action Action to call. + * @return {Object} New state. + */ +function siteScanReducer( state, action ) { + switch ( action.type ) { + case ACTION_SCANNABLE_URLS_REQUEST: { + return { + ...state, + status: STATUS_REQUEST_SCANNABLE_URLS, + currentlyScannedUrlIndex: INITIAL_STATE.currentlyScannedUrlIndex, + }; + } + case ACTION_SCANNABLE_URLS_FETCH: { + return { + ...state, + status: STATUS_FETCHING_SCANNABLE_URLS, + }; + } + case ACTION_SCANNABLE_URLS_RECEIVE: { + return { + ...state, + status: action.scannableUrls?.length > 0 ? STATUS_READY : STATUS_COMPLETED, + scannableUrls: action.scannableUrls, + }; + } + case ACTION_SCAN_INITIALIZE: { + if ( ! [ STATUS_READY, STATUS_COMPLETED, STATUS_FAILED, STATUS_CANCELLED ].includes( state.status ) ) { + return state; + } + + return { + ...state, + status: STATUS_IDLE, + cache: action.cache, + currentlyScannedUrlIndex: INITIAL_STATE.currentlyScannedUrlIndex, + }; + } + case ACTION_SCAN_VALIDATE_URL: { + return { + ...state, + status: STATUS_IN_PROGRESS, + }; + } + case ACTION_SCAN_RECEIVE_VALIDATION_ERRORS: { + return { + ...state, + scannableUrls: [ + ...state.scannableUrls.slice( 0, action.scannedUrlIndex ), + { + ...state.scannableUrls[ action.scannedUrlIndex ], + stale: false, + error: action.error ?? false, + validated_url_post: action.error ? {} : action.validatedUrlPost, + validation_errors: action.error ? [] : action.validationErrors, + }, + ...state.scannableUrls.slice( action.scannedUrlIndex + 1 ), + ], + }; + } + case ACTION_SCAN_NEXT_URL: { + if ( ! [ STATUS_IDLE, STATUS_IN_PROGRESS ].includes( state.status ) ) { + return state; + } + + if ( state.currentlyScannedUrlIndex < state.scannableUrls.length - 1 ) { + return { + ...state, + status: STATUS_IDLE, + currentlyScannedUrlIndex: state.currentlyScannedUrlIndex + 1, + }; + } + + const hasFailed = state.scannableUrls.every( ( scannableUrl ) => Boolean( scannableUrl.error ) ); + + return { + ...state, + status: hasFailed ? STATUS_FAILED : STATUS_COMPLETED, + }; + } + case ACTION_SCAN_CANCEL: { + if ( ! [ STATUS_IDLE, STATUS_IN_PROGRESS ].includes( state.status ) ) { + return state; + } + + return { + ...state, + status: STATUS_CANCELLED, + currentlyScannedUrlIndex: INITIAL_STATE.currentlyScannedUrlIndex, + }; + } + default: { + throw new Error( `Unhandled action type: ${ action.type }` ); + } + } +} + +/** + * Context provider for site scanning. + * + * @param {Object} props Component props. + * @param {boolean} props.ampFirst Whether scanning should be done with Standard mode being forced. + * @param {?any} props.children Component children. + * @param {boolean} props.fetchCachedValidationErrors Whether to fetch cached validation errors on mount. + * @param {string} props.scannableUrlsRestPath The REST path for interacting with the scannable URL resources. + * @param {string} props.validateNonce The AMP validate nonce. + */ +export function SiteScanContextProvider( { + ampFirst = false, + children, + fetchCachedValidationErrors = false, + scannableUrlsRestPath, + validateNonce, +} ) { + const { + didSaveOptions, + originalOptions: { + theme_support: themeSupport, + }, + } = useContext( Options ); + const { setAsyncError } = useAsyncError(); + const [ state, dispatch ] = useReducer( siteScanReducer, INITIAL_STATE ); + const { + cache, + currentlyScannedUrlIndex, + scannableUrls, + status, + } = state; + const urlType = ampFirst || themeSupport === STANDARD ? 'url' : 'amp_url'; + const previewPermalink = scannableUrls?.[ 0 ]?.[ urlType ] ?? ''; + + /** + * Memoize properties. + */ + const { + hasSiteScanResults, + pluginsWithAmpIncompatibility, + stale, + themesWithAmpIncompatibility, + } = useMemo( () => { + // Skip if the scan is in progress. + if ( ! [ STATUS_READY, STATUS_COMPLETED ].includes( status ) ) { + return { + hasSiteScanResults: false, + pluginsWithAmpIncompatibility: [], + stale: false, + themesWithAmpIncompatibility: [], + }; + } + + const validationErrors = scannableUrls.reduce( ( accumulatedValidationErrors, scannableUrl ) => [ ...accumulatedValidationErrors, ...scannableUrl?.validation_errors ?? [] ], [] ); + const slugs = getSlugsFromValidationResults( validationErrors ); + + return { + hasSiteScanResults: scannableUrls.some( ( scannableUrl ) => Boolean( scannableUrl?.validation_errors ) ), + pluginsWithAmpIncompatibility: slugs.plugins, + stale: scannableUrls.some( ( scannableUrl ) => scannableUrl?.stale === true ), + themesWithAmpIncompatibility: slugs.themes, + }; + }, [ scannableUrls, status ] ); + + /** + * Preflight check. + */ + useEffect( () => { + if ( status ) { + return; + } + + if ( ! validateNonce ) { + throw new Error( 'Invalid site scan configuration' ); + } + + dispatch( { type: ACTION_SCANNABLE_URLS_REQUEST } ); + }, [ status, validateNonce ] ); + + /** + * This component sets state inside async functions. Use this ref to prevent + * state updates after unmount. + */ + const hasUnmounted = useRef( false ); + useEffect( () => () => { + hasUnmounted.current = true; + }, [] ); + + const startSiteScan = useCallback( ( args = {} ) => { + dispatch( { + type: ACTION_SCAN_INITIALIZE, + cache: args?.cache, + } ); + }, [] ); + + const cancelSiteScan = useCallback( () => { + dispatch( { type: ACTION_SCAN_CANCEL } ); + }, [] ); + + /** + * Whenever options change, cancel the current scan (if in progress) and + * refetch the scannable URLs. + */ + const previousDidSaveOptions = usePrevious( didSaveOptions ); + useEffect( () => { + if ( ! previousDidSaveOptions && didSaveOptions ) { + dispatch( { type: ACTION_SCANNABLE_URLS_REQUEST } ); + } + }, [ didSaveOptions, previousDidSaveOptions ] ); + + /** + * Fetch scannable URLs from the REST endpoint. + */ + useEffect( () => { + ( async () => { + if ( status !== STATUS_REQUEST_SCANNABLE_URLS ) { + return; + } + + dispatch( { type: ACTION_SCANNABLE_URLS_FETCH } ); + + try { + const fields = [ 'url', 'amp_url', 'type', 'label' ]; + const response = await apiFetch( { + path: addQueryArgs( scannableUrlsRestPath, { + _fields: fetchCachedValidationErrors ? [ ...fields, 'validation_errors', 'stale' ] : fields, + } ), + } ); + + if ( true === hasUnmounted.current ) { + return; + } + + dispatch( { + type: ACTION_SCANNABLE_URLS_RECEIVE, + scannableUrls: response, + } ); + } catch ( e ) { + setAsyncError( e ); + } + } )(); + }, [ fetchCachedValidationErrors, scannableUrlsRestPath, setAsyncError, status ] ); + + /** + * Scan site URLs sequentially. + */ + useEffect( () => { + ( async () => { + if ( status !== STATUS_IDLE ) { + return; + } + + dispatch( { type: ACTION_SCAN_VALIDATE_URL } ); + + try { + const url = scannableUrls[ currentlyScannedUrlIndex ][ urlType ]; + const args = { + 'amp-first': ampFirst || undefined, + amp_validate: { + cache: cache || undefined, + nonce: validateNonce, + omit_stylesheets: true, + cache_bust: Math.random(), + }, + }; + + const response = await fetch( addQueryArgs( url, args ) ); + const data = await response.json(); + + if ( true === hasUnmounted.current ) { + return; + } + + if ( response.ok ) { + dispatch( { + type: ACTION_SCAN_RECEIVE_VALIDATION_ERRORS, + scannedUrlIndex: currentlyScannedUrlIndex, + validatedUrlPost: data.validated_url_post, + validationErrors: data.results.map( ( { error } ) => error ), + } ); + } else { + dispatch( { + type: ACTION_SCAN_RECEIVE_VALIDATION_ERRORS, + scannedUrlIndex: currentlyScannedUrlIndex, + error: data?.code || true, + } ); + } + } catch ( e ) { + dispatch( { + type: ACTION_SCAN_RECEIVE_VALIDATION_ERRORS, + scannedUrlIndex: currentlyScannedUrlIndex, + error: true, + } ); + } + + dispatch( { type: ACTION_SCAN_NEXT_URL } ); + } )(); + }, [ ampFirst, cache, currentlyScannedUrlIndex, scannableUrls, setAsyncError, status, urlType, validateNonce ] ); + + return ( + 0, + pluginsWithAmpIncompatibility, + previewPermalink, + scannableUrls, + stale, + startSiteScan, + themesWithAmpIncompatibility, + } } + > + { children } + + ); +} + +SiteScanContextProvider.propTypes = { + ampFirst: PropTypes.bool, + children: PropTypes.any, + fetchCachedValidationErrors: PropTypes.bool, + scannableUrlsRestPath: PropTypes.string, + validateNonce: PropTypes.string, +}; diff --git a/assets/src/components/site-scan-context-provider/test/get-slugs-from-validation-results.js b/assets/src/components/site-scan-context-provider/test/get-slugs-from-validation-results.js new file mode 100644 index 00000000000..657e2b3fcbe --- /dev/null +++ b/assets/src/components/site-scan-context-provider/test/get-slugs-from-validation-results.js @@ -0,0 +1,101 @@ +/** + * Internal dependencies + */ +import { getSlugsFromValidationResults } from '../get-slugs-from-validation-results'; + +describe( 'getSlugsFromValidationResults', () => { + it( 'returns empty arrays if no validation results are passed', () => { + expect( getSlugsFromValidationResults() ).toMatchObject( { plugins: [], themes: [] } ); + expect( getSlugsFromValidationResults( [ { foo: 'bar' } ] ) ).toMatchObject( { plugins: [], themes: [] } ); + } ); + + it( 'returns plugin and theme slugs', () => { + const validationResult = [ + { + sources: [ + { type: 'core', name: 'wp-includes' }, + { type: 'plugin', name: 'amp' }, + ], + }, + { + sources: [ + { type: 'plugin', name: 'gutenberg' }, + ], + }, + { + sources: [ + { type: 'core', name: 'wp-includes' }, + { type: 'plugin', name: 'jetpack' }, + ], + }, + { + sources: [ + { type: 'plugin', name: 'jetpack' }, + ], + }, + { + sources: [ + { type: 'plugin', name: 'foo-bar/foo-bar.php' }, + ], + }, + { + sources: [ + { type: 'theme', name: 'twentytwenty' }, + { type: 'core', name: 'wp-includes' }, + ], + }, + { + sources: [ + { type: 'core', name: 'wp-includes' }, + ], + }, + { + sources: [ + { type: 'theme', name: 'twentytwenty' }, + { type: 'core', name: 'wp-includes' }, + ], + }, + ]; + + const slugs = getSlugsFromValidationResults( validationResult ); + + expect( slugs.plugins ).toStrictEqual( [ 'gutenberg', 'jetpack', 'foo-bar' ] ); + expect( slugs.themes ).toStrictEqual( [ 'twentytwenty' ] ); + } ); + + it( 'returns Gutenberg if it is the only plugin for a single validation error', () => { + const validationResult = [ + { + sources: [ + { type: 'plugin', name: 'gutenberg' }, + ], + }, + { + sources: [ + { type: 'theme', name: 'twentytwenty' }, + { type: 'core', name: 'wp-includes' }, + ], + }, + ]; + + const slugs = getSlugsFromValidationResults( validationResult ); + + expect( slugs.plugins ).toStrictEqual( [ 'gutenberg' ] ); + expect( slugs.themes ).toStrictEqual( [ 'twentytwenty' ] ); + } ); + + it( 'does not return Gutenberg if there are other plugins for the same validation error', () => { + const validationResult = [ + { + sources: [ + { type: 'plugin', name: 'gutenberg' }, + { type: 'plugin', name: 'jetpack' }, + ], + }, + ]; + + const slugs = getSlugsFromValidationResults( validationResult ); + + expect( slugs.plugins ).toStrictEqual( [ 'jetpack' ] ); + } ); +} ); diff --git a/assets/src/components/site-scan-results/index.js b/assets/src/components/site-scan-results/index.js new file mode 100644 index 00000000000..610da7a7643 --- /dev/null +++ b/assets/src/components/site-scan-results/index.js @@ -0,0 +1,3 @@ +export { SiteScanResults } from './site-scan-results'; +export { ThemesWithAmpIncompatibility } from './themes-with-amp-incompatibility'; +export { PluginsWithAmpIncompatibility } from './plugins-with-amp-incompatibility'; diff --git a/assets/src/components/site-scan-results/plugins-with-amp-incompatibility.js b/assets/src/components/site-scan-results/plugins-with-amp-incompatibility.js new file mode 100644 index 00000000000..5ba64d653a7 --- /dev/null +++ b/assets/src/components/site-scan-results/plugins-with-amp-incompatibility.js @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useNormalizedPluginsData } from '../plugins-context-provider/use-normalized-plugins-data'; +import { IconLaptopPlug } from '../svg/laptop-plug'; +import { SiteScanSourcesList } from './site-scan-sources-list'; +import { SiteScanResults } from './index'; + +/** + * Render a list of plugins that cause AMP Incompatibility. + * + * @param {Object} props Component props. + * @param {string} props.className Component class name. + * @param {string[]} props.slugs List of plugins slugs. + */ +export function PluginsWithAmpIncompatibility( { className, slugs = [], ...props } ) { + const pluginsData = useNormalizedPluginsData(); + const sources = useMemo( () => slugs?.map( ( slug ) => pluginsData?.[ slug ] ?? { + slug, + status: 'uninstalled', + } ) || [], [ pluginsData, slugs ] ); + + return ( + } + count={ slugs.length } + className={ classnames( 'site-scan-results--plugins', className ) } + { ...props } + > + + + ); +} + +PluginsWithAmpIncompatibility.propTypes = { + className: PropTypes.string, + slugs: PropTypes.arrayOf( PropTypes.string ).isRequired, +}; diff --git a/assets/src/components/site-scan-results/site-scan-results.js b/assets/src/components/site-scan-results/site-scan-results.js new file mode 100644 index 00000000000..0a8bb2537cd --- /dev/null +++ b/assets/src/components/site-scan-results/site-scan-results.js @@ -0,0 +1,70 @@ +/** + * WordPress dependencies + */ +import { VisuallyHidden } from '@wordpress/components'; + +/** + * External dependencies + */ +import classnames from 'classnames'; +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import './style.scss'; +import { Selectable } from '../selectable'; + +/** + * Renders a panel with a site scan results. + * + * @param {Object} props Component props. + * @param {Object} props.callToAction A call to action element. + * @param {Object} props.children Component children. + * @param {number} props.count Incompatibilities count. + * @param {string} props.className Additional class names. + * @param {Element} props.icon Panel icon. + * @param {string} props.title Panel title. + */ +export function SiteScanResults( { + callToAction, + children, + className, + count, + icon, + title, +} ) { + return ( + +
+ { icon } +

+ { title } + + { `(${ count })` } + +

+
+
+ { children } + { callToAction && ( +

+ { callToAction } +

+ ) } +
+
+ ); +} + +SiteScanResults.propTypes = { + callToAction: PropTypes.element, + children: PropTypes.any, + className: PropTypes.string, + count: PropTypes.number, + icon: PropTypes.element, + title: PropTypes.string, +}; diff --git a/assets/src/components/site-scan-results/site-scan-sources-list.js b/assets/src/components/site-scan-results/site-scan-sources-list.js new file mode 100644 index 00000000000..482f77a7094 --- /dev/null +++ b/assets/src/components/site-scan-results/site-scan-sources-list.js @@ -0,0 +1,95 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { Loading } from '../loading'; +import { AMPNotice, NOTICE_TYPE_PLAIN, NOTICE_SIZE_SMALL } from '../amp-notice'; + +/** + * Site Scan sources list component. + * + * @param {Object} props Component props. + * @param {Array} props.sources Sources data. + * @param {string} props.inactiveSourceNotice Message to show next to an inactive source. + * @param {string} props.uninstalledSourceNotice Message to show next to an uninstalled source. + */ +export function SiteScanSourcesList( { + sources, + inactiveSourceNotice, + uninstalledSourceNotice, +} ) { + if ( sources.length === 0 ) { + return ; + } + + return ( +
    + { sources.map( ( { author, name, slug, status, version } ) => ( +
  • + { name && ( + + { name } + + ) } + { ! name && slug && ( + + { slug } + + ) } + { status === 'active' ? ( + <> + { author && ( + + { sprintf( + /* translators: %s is an author name. */ + __( 'by %s', 'amp' ), + author, + ) } + + ) } + { version && ( + + { sprintf( + /* translators: %s is a version number. */ + __( 'Version %s', 'amp' ), + version, + ) } + + ) } + + ) : ( + + { status === 'inactive' ? inactiveSourceNotice : null } + { status === 'uninstalled' ? uninstalledSourceNotice : null } + + ) } +
  • + ) ) } +
+ ); +} + +SiteScanSourcesList.propTypes = { + sources: PropTypes.array.isRequired, + inactiveSourceNotice: PropTypes.string, + uninstalledSourceNotice: PropTypes.string, +}; diff --git a/assets/src/components/site-scan-results/style.scss b/assets/src/components/site-scan-results/style.scss new file mode 100644 index 00000000000..c8868462765 --- /dev/null +++ b/assets/src/components/site-scan-results/style.scss @@ -0,0 +1,99 @@ +.site-scan-results { + padding: 0; +} + +.site-scan-results + .site-scan-results { + margin-top: 1.5rem; +} + +.site-scan-results__header { + align-items: center; + border-bottom: 1px solid var(--amp-settings-color-border); + display: flex; + flex-flow: row nowrap; + padding: 0.5rem; + + @media (min-width: 783px) { + padding: 1rem 2rem; + } +} + +.site-scan-results__heading { + font-size: 16px; + font-weight: 700; + margin-left: 1rem; + + &[data-badge-content]::after { + align-items: center; + background-color: var(--light-gray); + border-radius: 50%; + content: attr(data-badge-content); + display: inline-flex; + height: 30px; + justify-content: center; + letter-spacing: -0.05em; + margin: 0 0.5rem; + width: 30px; + } +} + +.site-scan-results__content { + padding: 1rem 0.5rem; + + @media (min-width: 783px) { + padding: 1.25rem 2rem; + } +} + +.site-scan-results__sources { + border: 2px solid var(--amp-settings-color-border); +} + +.site-scan-results__source { + align-items: center; + display: flex; + flex-flow: row nowrap; + font-size: 14px; + margin: 0; + min-height: 3.5rem; + padding: 1rem; + + &:nth-child(even) { + background-color: var(--amp-settings-color-background-light); + } + + & + & { + border-top: 2px solid var(--amp-settings-color-border); + } +} + +.site-scan-results__source-name { + font-weight: 700; +} + +.site-scan-results__source-name--inactive { + color: var(--gray); +} + +.site-scan-results__source-author::before { + border-left: 1px solid; + content: ""; + display: inline-block; + height: 1em; + margin: 0 0.5em; + vertical-align: middle; +} + +.site-scan-results__source-version, +.site-scan-results__source-notice { + margin-left: auto; +} + +.site-scan-results__cta.site-scan-results__cta { + font-size: 14px; + margin-bottom: 0; + + .components-external-link__icon { + fill: var(--amp-settings-color-brand); + } +} diff --git a/assets/src/components/site-scan-results/themes-with-amp-incompatibility.js b/assets/src/components/site-scan-results/themes-with-amp-incompatibility.js new file mode 100644 index 00000000000..f057e199569 --- /dev/null +++ b/assets/src/components/site-scan-results/themes-with-amp-incompatibility.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useNormalizedThemesData } from '../themes-context-provider/use-normalized-themes-data'; +import { IconWebsitePaintBrush } from '../svg/website-paint-brush'; +import { SiteScanSourcesList } from './site-scan-sources-list'; +import { SiteScanResults } from './index'; + +/** + * Render a list of themes that cause AMP Incompatibility. + * + * @param {Object} props Component props. + * @param {string} props.className Component class name. + * @param {string[]} props.slugs List of theme slugs. + */ +export function ThemesWithAmpIncompatibility( { className, slugs = [], ...props } ) { + const themesData = useNormalizedThemesData(); + const sources = useMemo( () => slugs?.map( ( slug ) => themesData?.[ slug ] ?? { + slug, + status: 'uninstalled', + } ) || [], [ slugs, themesData ] ); + + return ( + } + count={ slugs.length } + sources={ sources } + className={ classnames( 'site-scan-results--themes', className ) } + { ...props } + > + + + ); +} + +ThemesWithAmpIncompatibility.propTypes = { + className: PropTypes.string, + slugs: PropTypes.arrayOf( PropTypes.string ).isRequired, +}; diff --git a/assets/src/components/svg/landscape-hills-cogs-alt.js b/assets/src/components/svg/landscape-hills-cogs-alt.js new file mode 100644 index 00000000000..15dd7a74caf --- /dev/null +++ b/assets/src/components/svg/landscape-hills-cogs-alt.js @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +/** + * WordPress dependencies + */ +import { useInstanceId } from '@wordpress/compose'; + +const INTRINSIC_ICON_WIDTH = 59; +const INTRINSIC_ICON_HEIGHT = 44; +const INTRINSIC_STROKE_WIDTH = 2; + +export function IconLandscapeHillsCogsAlt( { width = INTRINSIC_ICON_WIDTH, ...props } ) { + const clipPathId = `clip-icon-landscape-hills-cogs-alt-${ useInstanceId( IconLandscapeHillsCogsAlt ) }`; + const strokeWidth = INTRINSIC_STROKE_WIDTH * ( INTRINSIC_ICON_WIDTH / width ); + + return ( + + + + + + + + + + + + + + + ); +} +IconLandscapeHillsCogsAlt.propTypes = { + width: PropTypes.number, +}; diff --git a/assets/src/components/svg/landscape-hills-cogs.js b/assets/src/components/svg/landscape-hills-cogs.js new file mode 100644 index 00000000000..9c4da7d7051 --- /dev/null +++ b/assets/src/components/svg/landscape-hills-cogs.js @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +const INTRINSIC_ICON_WIDTH = 144; +const INTRINSIC_ICON_HEIGHT = 69; +const INTRINSIC_STROKE_WIDTH = 2; + +export function IconLandscapeHillsCogs( { width = INTRINSIC_ICON_WIDTH, ...props } ) { + const strokeWidth = INTRINSIC_STROKE_WIDTH * ( INTRINSIC_ICON_WIDTH / width ); + + return ( + + + + + + + + + + + ); +} +IconLandscapeHillsCogs.propTypes = { + width: PropTypes.number, +}; diff --git a/assets/src/components/svg/laptop-plug.js b/assets/src/components/svg/laptop-plug.js new file mode 100644 index 00000000000..730590a2e52 --- /dev/null +++ b/assets/src/components/svg/laptop-plug.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +const INTRINSIC_ICON_WIDTH = 58; +const INTRINSIC_ICON_HEIGHT = 40; +const INTRINSIC_STROKE_WIDTH = 2; + +export function IconLaptopPlug( { width = INTRINSIC_ICON_WIDTH, ...props } ) { + const strokeWidth = INTRINSIC_STROKE_WIDTH * ( INTRINSIC_ICON_WIDTH / width ); + + return ( + + + + + + + + ); +} +IconLaptopPlug.propTypes = { + width: PropTypes.number, +}; diff --git a/assets/src/components/svg/website-paint-brush.js b/assets/src/components/svg/website-paint-brush.js new file mode 100644 index 00000000000..2f41ff2fbbc --- /dev/null +++ b/assets/src/components/svg/website-paint-brush.js @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +const INTRINSIC_ICON_WIDTH = 58; +const INTRINSIC_ICON_HEIGHT = 38; +const INTRINSIC_STROKE_WIDTH = 2; + +export function IconWebsitePaintBrush( { width = INTRINSIC_ICON_WIDTH, ...props } ) { + const strokeWidth = INTRINSIC_STROKE_WIDTH * ( INTRINSIC_ICON_WIDTH / width ); + + return ( + + + + + + + + + + ); +} +IconWebsitePaintBrush.propTypes = { + width: PropTypes.number, +}; diff --git a/assets/src/components/template-mode-option/index.js b/assets/src/components/template-mode-option/index.js index 417b158ae60..ff7dbba6288 100644 --- a/assets/src/components/template-mode-option/index.js +++ b/assets/src/components/template-mode-option/index.js @@ -12,16 +12,15 @@ import { useContext } from '@wordpress/element'; /** * Internal dependencies */ +import './style.css'; +import { READER, STANDARD, TRANSITIONAL } from '../../common/constants'; +import { AMPDrawer, HANDLE_TYPE_RIGHT } from '../amp-drawer'; import { AMPInfo } from '../amp-info'; import { Standard } from '../svg/standard'; import { Transitional } from '../svg/transitional'; import { Reader } from '../svg/reader'; import { Options } from '../options-context-provider'; -import './style.css'; -import { READER, STANDARD, TRANSITIONAL } from '../../common/constants'; -import { AMPDrawer, HANDLE_TYPE_RIGHT } from '../amp-drawer'; - /** * Mode-specific illustration. * @@ -82,7 +81,7 @@ export function getId( mode ) { * * @param {Object} props Component props. * @param {string|Object} props.children Section content. - * @param {string} props.details Mode details. + * @param {string|Array} props.details The template mode details. * @param {string} props.detailsUrl Mode details URL. * @param {string} props.mode The template mode. * @param {boolean} props.previouslySelected Optional. Whether the option was selected previously. @@ -137,14 +136,32 @@ export function TemplateModeOption( { children, details, detailsUrl, initialOpen selected={ mode === themeSupport } >
-

- - { ' ' } - - { __( 'Learn more.', 'amp' ) } - -

{ children } + { Array.isArray( details ) && ( +
    + { details.map( ( detail, index ) => ( +
  • + ) ) } +
+ ) } + { details && ! Array.isArray( details ) && ( +

+ + { detailsUrl && ( + <> + { ' ' } + + { __( 'Learn more.', 'amp' ) } + + + ) } +

+ ) }
); @@ -152,8 +169,11 @@ export function TemplateModeOption( { children, details, detailsUrl, initialOpen TemplateModeOption.propTypes = { children: PropTypes.any, - details: PropTypes.string.isRequired, - detailsUrl: PropTypes.string.isRequired, + details: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.arrayOf( PropTypes.string ), + ] ), + detailsUrl: PropTypes.string, initialOpen: PropTypes.bool, labelExtra: PropTypes.node, mode: PropTypes.oneOf( [ READER, STANDARD, TRANSITIONAL ] ).isRequired, diff --git a/assets/src/components/template-mode-option/style.css b/assets/src/components/template-mode-option/style.css index b9a99817037..e60aded4a73 100644 --- a/assets/src/components/template-mode-option/style.css +++ b/assets/src/components/template-mode-option/style.css @@ -1,11 +1,3 @@ -.template-mode-selection__details { - padding: 1rem 1.5rem; - - @media (min-width: 783px) { - padding: 1rem 3rem; - } -} - .template-mode-option__label { align-items: center; background-color: var(--amp-settings-color-background); @@ -19,16 +11,6 @@ } } - -.template-mode-selection__illustration svg { - height: auto; - width: 45px; - - @media (min-width: 783px) { - width: 66px; - } -} - .template-mode-selection__input-container { margin-right: 0.75rem; @@ -54,7 +36,7 @@ } .template-mode-option .amp-info { - margin-bottom: 0.5rem; + margin-bottom: 0; @media screen and (min-width: 783px) { margin-left: 1.5rem; @@ -62,7 +44,18 @@ } .template-mode-selection__details { + font-size: 14px; margin-bottom: 1rem; + padding: 1rem 1.5rem; + + @media (min-width: 783px) { + padding: 1rem 3rem; + } +} + +.template-mode-selection__details-list { + list-style: disc; + padding-left: 1.625rem; } .template-mode-option .components-panel__row { @@ -118,6 +111,12 @@ right: 0; } +.template-mode-option .components-panel__body-title { + position: absolute; + top: 0; + right: 0; +} + .template-mode-option .components-panel__body-title:hover { background: rgba(0, 0, 0, 0); } diff --git a/assets/src/components/themes-context-provider/__mocks__/index.js b/assets/src/components/themes-context-provider/__mocks__/index.js new file mode 100644 index 00000000000..484a976435a --- /dev/null +++ b/assets/src/components/themes-context-provider/__mocks__/index.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +/** + * WordPress dependencies + */ +import { createContext } from '@wordpress/element'; + +export const Themes = createContext(); + +/** + * MOCK. + * + * @param {Object} props + * @param {any} props.children Component children. + * @param {boolean} props.fetchingThemes Whether fetching themes or not. + * @param {Array} props.themes An array of fetched themes. + */ +export function ThemesContextProvider( { + children, + fetchingThemes = false, + themes = [], +} ) { + return ( + + { children } + + ); +} +ThemesContextProvider.propTypes = { + children: PropTypes.any, + fetchingThemes: PropTypes.bool, + themes: PropTypes.array, +}; diff --git a/assets/src/components/themes-context-provider/index.js b/assets/src/components/themes-context-provider/index.js new file mode 100644 index 00000000000..ffd57c2243b --- /dev/null +++ b/assets/src/components/themes-context-provider/index.js @@ -0,0 +1,105 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +/** + * WordPress dependencies + */ +import { + createContext, + useContext, + useEffect, + useRef, + useState, +} from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { ErrorContext } from '../error-context-provider'; +import { useAsyncError } from '../../utils/use-async-error'; + +export const Themes = createContext(); + +/** + * Themes context provider. + * + * @param {Object} props Component props. + * @param {any} props.children Component children. + * @param {boolean} props.hasErrorBoundary Whether the component is wrapped in an error boundary. + */ +export function ThemesContextProvider( { + children, + hasErrorBoundary = false, +} ) { + const [ themes, setThemes ] = useState( [] ); + const [ fetchingThemes, setFetchingThemes ] = useState( null ); + + const { error, setError } = useContext( ErrorContext ); + const { setAsyncError } = useAsyncError(); + + /** + * This component sets state inside async functions. + * Use this ref to prevent state updates after unmount. + */ + const hasUnmounted = useRef( false ); + useEffect( () => () => { + hasUnmounted.current = true; + }, [] ); + + /** + * Fetches the themes data. + */ + useEffect( () => { + if ( error || themes.length > 0 || fetchingThemes ) { + return; + } + + ( async () => { + setFetchingThemes( true ); + + try { + const fetchedThemes = await apiFetch( { + path: '/wp/v2/themes', + } ); + + if ( hasUnmounted.current === true ) { + return; + } + + setThemes( fetchedThemes ); + } catch ( e ) { + if ( hasUnmounted.current === true ) { + return; + } + + setError( e ); + + if ( hasErrorBoundary ) { + setAsyncError( e ); + } + + return; + } + + setFetchingThemes( false ); + } )(); + }, [ error, fetchingThemes, hasErrorBoundary, themes, setAsyncError, setError ] ); + + return ( + + { children } + + ); +} +ThemesContextProvider.propTypes = { + children: PropTypes.any, + hasErrorBoundary: PropTypes.bool, +}; diff --git a/assets/src/components/themes-context-provider/test/use-normalized-themes-data.js b/assets/src/components/themes-context-provider/test/use-normalized-themes-data.js new file mode 100644 index 00000000000..7e107ff877b --- /dev/null +++ b/assets/src/components/themes-context-provider/test/use-normalized-themes-data.js @@ -0,0 +1,132 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { act } from 'react-dom/test-utils'; + +/** + * WordPress dependencies + */ +import { render, unmountComponentAtNode } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ErrorContextProvider } from '../../error-context-provider'; +import { ThemesContextProvider } from '../index'; +import { useNormalizedThemesData } from '../use-normalized-themes-data'; + +jest.mock( '../index' ); + +let returnValue = {}; + +function ComponentContainingHook() { + returnValue = useNormalizedThemesData(); + return null; +} + +const Providers = ( { children, fetchingThemes, themes = [] } ) => ( + + + { children } + + +); +Providers.propTypes = { + children: PropTypes.any, + fetchingThemes: PropTypes.bool, + themes: PropTypes.array, +}; + +describe( 'useNormalizedThemesData', () => { + let container = null; + + beforeEach( () => { + container = document.createElement( 'div' ); + document.body.appendChild( container ); + } ); + + afterEach( () => { + unmountComponentAtNode( container ); + container.remove(); + container = null; + returnValue = {}; + } ); + + it( 'returns empty an array if themes are being fetched', () => { + act( () => { + render( + + + , + container, + ); + } ); + + expect( returnValue ).toHaveLength( 0 ); + } ); + + it( 'returns a normalized array of themes', () => { + act( () => { + render( + the WordPress team', + }, + author_uri: { + raw: 'https://wordpress.org/', + rendered: 'https://wordpress.org/', + }, + name: 'Twenty Fifteen', + stylesheet: 'twentyfifteen', + status: 'inactive', + version: '3.0', + }, + { + author: 'the WordPress team', + author_uri: 'https://wordpress.org/', + name: { + raw: 'Twenty Twenty', + rendered: 'Twenty Twenty', + }, + stylesheet: 'twentytwenty', + status: 'active', + version: '1.7', + }, + ] } + > + + , + container, + ); + } ); + + expect( returnValue ).toStrictEqual( { + twentyfifteen: { + author: 'the WordPress team', + author_uri: 'https://wordpress.org/', + name: 'Twenty Fifteen', + slug: 'twentyfifteen', + stylesheet: 'twentyfifteen', + status: 'inactive', + version: '3.0', + }, + twentytwenty: { + author: 'the WordPress team', + author_uri: 'https://wordpress.org/', + name: 'Twenty Twenty', + slug: 'twentytwenty', + stylesheet: 'twentytwenty', + status: 'active', + version: '1.7', + }, + } ); + } ); +} ); diff --git a/assets/src/components/themes-context-provider/use-normalized-themes-data.js b/assets/src/components/themes-context-provider/use-normalized-themes-data.js new file mode 100644 index 00000000000..b4d63b5e79b --- /dev/null +++ b/assets/src/components/themes-context-provider/use-normalized-themes-data.js @@ -0,0 +1,32 @@ +/** + * WordPress dependencies + */ +import { useContext, useEffect, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Themes } from './index'; + +export function useNormalizedThemesData() { + const { fetchingThemes, themes } = useContext( Themes ); + const [ normalizedThemesData, setNormalizedThemesData ] = useState( [] ); + + useEffect( () => { + if ( fetchingThemes || themes.length === 0 ) { + return; + } + + setNormalizedThemesData( () => themes.reduce( ( accumulatedThemesData, source ) => ( { + ...accumulatedThemesData, + [ source.stylesheet ]: Object.keys( source ).reduce( ( props, key ) => ( { + ...props, + slug: source.stylesheet, + // Flatten every prop that contains a `raw` member. + [ key ]: source[ key ]?.raw ?? source[ key ], + } ), {} ), + } ), {} ) ); + }, [ fetchingThemes, themes ] ); + + return normalizedThemesData; +} diff --git a/assets/src/components/use-template-mode-recommendation/index.js b/assets/src/components/use-template-mode-recommendation/index.js new file mode 100644 index 00000000000..301b716fbe3 --- /dev/null +++ b/assets/src/components/use-template-mode-recommendation/index.js @@ -0,0 +1,388 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useContext, useLayoutEffect, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { READER, STANDARD, TRANSITIONAL } from '../../common/constants'; +import { ReaderThemes } from '../reader-themes-context-provider'; +import { SiteScan as SiteScanContext } from '../site-scan-context-provider'; +import { User } from '../user-context-provider'; +import { Options } from '../options-context-provider'; + +// Recommendation levels. +export const RECOMMENDED = 'recommended'; +export const NEUTRAL = 'neutral'; +export const NOT_RECOMMENDED = 'notRecommended'; + +// Technical levels. +export const TECHNICAL = 'technical'; +export const NON_TECHNICAL = 'nonTechnical'; + +export function useTemplateModeRecommendation() { + const { currentTheme: { is_reader_theme: currentThemeIsAmongReaderThemes } } = useContext( ReaderThemes ); + const { + hasSiteScanResults, + isBusy, + isFetchingScannableUrls, + pluginsWithAmpIncompatibility, + stale, + themesWithAmpIncompatibility, + } = useContext( SiteScanContext ); + const { developerToolsOption, fetchingUser, savingDeveloperToolsOption } = useContext( User ); + const { fetchingOptions, savingOptions } = useContext( Options ); + const [ templateModeRecommendation, setTemplateModeRecommendation ] = useState( null ); + + useLayoutEffect( () => { + if ( isBusy || isFetchingScannableUrls || fetchingOptions || savingOptions || fetchingUser || savingDeveloperToolsOption ) { + return; + } + + setTemplateModeRecommendation( getTemplateModeRecommendation( { + currentThemeIsAmongReaderThemes, + hasPluginIssues: pluginsWithAmpIncompatibility?.length > 0, + hasSiteScanResults: hasSiteScanResults && ! stale, + hasThemeIssues: themesWithAmpIncompatibility?.length > 0, + userIsTechnical: developerToolsOption === true, + } ) ); + }, [ currentThemeIsAmongReaderThemes, developerToolsOption, fetchingOptions, fetchingUser, hasSiteScanResults, isBusy, isFetchingScannableUrls, pluginsWithAmpIncompatibility?.length, savingDeveloperToolsOption, savingOptions, stale, themesWithAmpIncompatibility?.length ] ); + + return { + templateModeRecommendation, + staleTemplateModeRecommendation: stale, + }; +} + +/* eslint-disable complexity */ + +/** + * Returns the degree to which each mode is recommended for the current site and user. + * + * @param {Object} args + * @param {boolean} args.currentThemeIsAmongReaderThemes Whether the currently active theme is in the reader themes list. + * @param {boolean} args.hasPluginIssues Whether the site scan found plugins with AMP incompatibility. + * @param {boolean} args.hasSiteScanResults Whether there are available site scan results. + * @param {boolean} args.hasThemeIssues Whether the site scan found themes with AMP incompatibility. + * @param {boolean} args.userIsTechnical Whether the user answered yes to the technical question. + */ +export function getTemplateModeRecommendation( { + currentThemeIsAmongReaderThemes, + hasPluginIssues, + hasSiteScanResults, + hasThemeIssues, + userIsTechnical, +} ) { + switch ( true ) { + /** + * #1 + */ + case hasThemeIssues && hasPluginIssues && userIsTechnical: + return { + [ READER ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'Possible choice if you want to enable AMP on your site despite the compatibility issues found.', 'amp' ), + __( 'Your site will have non-AMP and AMP versions, each with its own theme.', 'amp' ), + ], + }, + [ TRANSITIONAL ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'Choose this mode temporarily if issues can be fixed or if your theme degrades gracefully when JavaScript is disabled.', 'amp' ), + __( 'Your site will have non-AMP and AMP versions with the same theme.', 'amp' ), + ], + }, + [ STANDARD ]: { + recommendationLevel: RECOMMENDED, + details: [ + __( 'Recommended, if you can fix the issues detected with plugins with and your theme.', 'amp' ), + __( 'Your site will be completely AMP (except where you opt-out of AMP for specific areas), and will use a single theme.', 'amp' ), + ], + }, + }; + + /** + * #2 + */ + case hasThemeIssues && hasPluginIssues && ! userIsTechnical: + return { + [ READER ]: { + recommendationLevel: RECOMMENDED, + details: [ + __( 'Recommended as an easy way to enable AMP on your site despite the issues detected during site scanning.', 'amp' ), + __( 'Your site will have non-AMP and AMP versions, each using its own theme.', 'amp' ), + ], + }, + [ TRANSITIONAL ]: { + recommendationLevel: NOT_RECOMMENDED, + details: [ + __( 'Not recommended as key functionality may be missing and development work might be required.', 'amp' ), + ], + }, + [ STANDARD ]: { + recommendationLevel: NOT_RECOMMENDED, + details: [ + __( 'Not recommended as key functionality may be missing and development work might be required.', 'amp' ), + ], + }, + }; + + /** + * #3 + */ + case hasThemeIssues && ! hasPluginIssues && userIsTechnical: + return { + [ READER ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'Possible choice if you want to enable AMP on your site despite the compatibility issues found.', 'amp' ), + __( 'Your site will have non-AMP and AMP versions, each with its own theme.', 'amp' ), + ], + }, + [ TRANSITIONAL ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'Choose this mode temporarily if issues can be fixed or if your theme degrades gracefully when JavaScript is disabled.', 'amp' ), + __( 'Your site will have non-AMP and AMP versions with the same theme.', 'amp' ), + ], + }, + [ STANDARD ]: { + recommendationLevel: RECOMMENDED, + details: [ + __( 'Recommended, if you can fix the issues detected with plugins with and your theme.', 'amp' ), + __( 'Your site will be completely AMP (except where you opt-out of AMP for specific areas), and will use a single theme.', 'amp' ), + ], + }, + }; + + /** + * #4 + */ + case hasThemeIssues && ! hasPluginIssues && ! userIsTechnical: + return { + [ READER ]: { + recommendationLevel: RECOMMENDED, + details: [ + __( 'Recommended to easily enable AMP on your site despite the issues detected on your theme.', 'amp' ), + __( 'Your site will have non-AMP and AMP versions, each using its own theme.', 'amp' ), + ], + }, + [ TRANSITIONAL ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'Choose this mode if your theme degrades gracefully when JavaScript is disabled.', 'amp' ), + __( 'Your site will have non-AMP and AMP versions with the same theme.', 'amp' ), + ], + }, + [ STANDARD ]: { + recommendationLevel: NOT_RECOMMENDED, + details: [ + __( 'Not recommended as key functionality may be missing and development work might be required.', 'amp' ), + ], + }, + }; + + /** + * #5 + */ + case ! hasThemeIssues && hasPluginIssues && userIsTechnical: + return { + [ READER ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'Possible choice if you want to enable AMP on your site despite the compatibility issues found.', 'amp' ), + __( 'Your site will have non-AMP and AMP versions, each with its own theme.', 'amp' ), + ], + }, + [ TRANSITIONAL ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'Choose this mode temporarily if issues can be fixed or if your theme degrades gracefully when JavaScript is disabled.', 'amp' ), + __( 'Your site will have non-AMP and AMP versions with the same theme.', 'amp' ), + ], + }, + [ STANDARD ]: { + recommendationLevel: RECOMMENDED, + details: [ + __( 'Recommended, if you can fix the issues detected with plugins with and your theme.', 'amp' ), + __( 'Your site will be completely AMP (except where you opt-out of AMP for specific areas), and will use a single theme.', 'amp' ), + ], + }, + }; + + /** + * #6 + */ + case ! hasThemeIssues && hasPluginIssues && ! userIsTechnical: + return { + [ READER ]: { + recommendationLevel: RECOMMENDED, + details: [ + __( 'Recommended as an easy way to enable AMP on your site despite the issues detected during site scanning.', 'amp' ), + __( 'Your site will have non-AMP and AMP versions, each using its own theme.', 'amp' ), + ], + }, + [ TRANSITIONAL ]: { + recommendationLevel: NOT_RECOMMENDED, + details: [ + __( 'Not recommended as key functionality may be missing and development work might be required.', 'amp' ), + ], + }, + [ STANDARD ]: { + recommendationLevel: NOT_RECOMMENDED, + details: [ + __( 'Not recommended as key functionality may be missing and development work might be required.', 'amp' ), + ], + }, + }; + + /** + * #7 + */ + case ! hasThemeIssues && ! hasPluginIssues && userIsTechnical: + return { + [ READER ]: { + recommendationLevel: NOT_RECOMMENDED, + details: [ + __( 'Not recommended as you have an AMP-compatible theme and no issues were detected with any of the plugins on your site.', 'amp' ), + ], + }, + [ TRANSITIONAL ]: { + recommendationLevel: NOT_RECOMMENDED, + details: [ + __( 'Not recommended as you have an AMP-compatible theme and no issues were detected with any of the plugins on your site.', 'amp' ), + ], + }, + [ STANDARD ]: { + recommendationLevel: RECOMMENDED, + details: [ + __( 'Recommended as you have an AMP-compatible theme and no issues were detected with any of the plugins on your site.', 'amp' ), + ], + }, + }; + + /** + * #8 + */ + case ! hasThemeIssues && ! hasPluginIssues && ! userIsTechnical: + return { + [ READER ]: { + recommendationLevel: NOT_RECOMMENDED, + details: [ + __( 'Not recommended as you have an AMP-compatible theme and no issues were detected with any of the plugins on your site.', 'amp' ), + ], + }, + [ TRANSITIONAL ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'Recommended choice if you can’t commit to choosing plugins that are AMP compatible when extending your site. This mode will make it easy to keep AMP content even if non-AMP-compatible plugins are used later on.', 'amp' ), + ], + }, + [ STANDARD ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'Recommended choice if you can commit to always choosing plugins that are AMP compatible when extending your site.', 'amp' ), + ], + }, + }; + + /** + * No site scan scenarios. + */ + case ! hasSiteScanResults && currentThemeIsAmongReaderThemes && ! userIsTechnical: + return { + [ READER ]: { + recommendationLevel: RECOMMENDED, + details: [ + __( 'In Reader mode your site will have a non-AMP and an AMP version, and each version will use its own theme. If automatic mobile redirection is enabled, the AMP version of the content will be served on mobile devices. If AMP-to-AMP linking is enabled, once users are on an AMP page, they will continue navigating your AMP content.', 'amp' ), + ], + }, + [ TRANSITIONAL ]: { + recommendationLevel: RECOMMENDED, + details: [ + __( 'In Transitional mode your site will have a non-AMP and an AMP version, and both will use the same theme. If automatic mobile redirection is enabled, the AMP version of the content will be served on mobile devices. If AMP-to-AMP linking is enabled, once users are on an AMP page, they will continue navigating your AMP content.', 'amp' ), + ], + }, + [ STANDARD ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'In Standard mode your site will be completely AMP (except in cases where you opt-out of AMP for specific parts of your site), and it will use a single theme.', 'amp' ), + ], + }, + }; + + case ! hasSiteScanResults && currentThemeIsAmongReaderThemes && userIsTechnical: + return { + [ READER ]: { + recommendationLevel: RECOMMENDED, + details: [ + __( 'In Reader mode your site will have a non-AMP and an AMP version, and each version will use its own theme. If automatic mobile redirection is enabled, the AMP version of the content will be served on mobile devices. If AMP-to-AMP linking is enabled, once users are on an AMP page, they will continue navigating your AMP content.', 'amp' ), + ], + }, + [ TRANSITIONAL ]: { + recommendationLevel: RECOMMENDED, + details: [ + __( 'In Transitional mode your site will have a non-AMP and an AMP version, and both will use the same theme. If automatic mobile redirection is enabled, the AMP version of the content will be served on mobile devices. If AMP-to-AMP linking is enabled, once users are on an AMP page, they will continue navigating your AMP content.', 'amp' ), + ], + }, + [ STANDARD ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'In Standard mode your site will be completely AMP (except in cases where you opt-out of AMP for specific parts of your site), and it will use a single theme.', 'amp' ), + ], + }, + }; + + case ! hasSiteScanResults && ! currentThemeIsAmongReaderThemes && ! userIsTechnical: + return { + [ READER ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'In Reader mode your site will have a non-AMP and an AMP version, and each version will use its own theme. If automatic mobile redirection is enabled, the AMP version of the content will be served on mobile devices. If AMP-to-AMP linking is enabled, once users are on an AMP page, they will continue navigating your AMP content.', 'amp' ), + ], + }, + [ TRANSITIONAL ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'In Transitional mode your site will have a non-AMP and an AMP version, and both will use the same theme. If automatic mobile redirection is enabled, the AMP version of the content will be served on mobile devices. If AMP-to-AMP linking is enabled, once users are on an AMP page, they will continue navigating your AMP content.', 'amp' ), + ], + }, + [ STANDARD ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'In Standard mode your site will be completely AMP (except in cases where you opt-out of AMP for specific parts of your site), and it will use a single theme.', 'amp' ), + ], + }, + }; + + case ! hasSiteScanResults && ! currentThemeIsAmongReaderThemes && userIsTechnical: + return { + [ READER ]: { + recommendationLevel: RECOMMENDED, + details: [ + __( 'In Reader mode your site will have a non-AMP and an AMP version, and each version will use its own theme. If automatic mobile redirection is enabled, the AMP version of the content will be served on mobile devices. If AMP-to-AMP linking is enabled, once users are on an AMP page, they will continue navigating your AMP content.', 'amp' ), + ], + }, + [ TRANSITIONAL ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'In Transitional mode your site will have a non-AMP and an AMP version, and both will use the same theme. If automatic mobile redirection is enabled, the AMP version of the content will be served on mobile devices. If AMP-to-AMP linking is enabled, once users are on an AMP page, they will continue navigating your AMP content.', 'amp' ), + ], + }, + [ STANDARD ]: { + recommendationLevel: NEUTRAL, + details: [ + __( 'In Standard mode your site will be completely AMP (except in cases where you opt-out of AMP for specific parts of your site), and it will use a single theme.', 'amp' ), + ], + }, + }; + + default: + throw new Error( __( 'A template mode recommendation case was not accounted for.', 'amp' ) ); + } +} + +/* eslint-enable complexity */ diff --git a/assets/src/components/use-template-mode-recommendation/test/get-template-mode-recommendation.js b/assets/src/components/use-template-mode-recommendation/test/get-template-mode-recommendation.js new file mode 100644 index 00000000000..4f47245e7a6 --- /dev/null +++ b/assets/src/components/use-template-mode-recommendation/test/get-template-mode-recommendation.js @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ +import { getTemplateModeRecommendation } from '../index'; + +describe( 'getTemplateModeRecommendation', () => { + it( 'throws no errors', () => { + [ true, false ].forEach( ( hasPluginIssues ) => { + [ true, false ].forEach( ( hasThemeIssues ) => { + [ true, false ].forEach( ( userIsTechnical ) => { + const cb = () => getTemplateModeRecommendation( { hasPluginIssues, hasThemeIssues, userIsTechnical } ); + expect( cb ).not.toThrow(); + } ); + } ); + } ); + + [ true, false ].forEach( ( hasSiteScanResults ) => { + [ true, false ].forEach( ( currentThemeIsAmongReaderThemes ) => { + [ true, false ].forEach( ( userIsTechnical ) => { + const cb = () => getTemplateModeRecommendation( { userIsTechnical, hasSiteScanResults, currentThemeIsAmongReaderThemes } ); + expect( cb ).not.toThrow(); + } ); + } ); + } ); + } ); +} ); diff --git a/assets/src/css/core-components.css b/assets/src/css/core-components.css index b140e4e6daf..1501e83fb51 100644 --- a/assets/src/css/core-components.css +++ b/assets/src/css/core-components.css @@ -26,11 +26,6 @@ background: var(--amp-settings-color-background); } -.amp .components-button.components-panel__body-toggle svg { - height: 30px; - width: 30px; -} - .amp .components-button.is-link, .amp .components-button.is-link:hover, .amp .components-button.is-link:hover:not(:disabled), diff --git a/assets/src/css/variables.css b/assets/src/css/variables.css index 3c906a1e4dd..635223edfc0 100644 --- a/assets/src/css/variables.css +++ b/assets/src/css/variables.css @@ -4,17 +4,21 @@ * @todo Make the naming of these variables more semantic. */ :root { + --gray: #6c7781; + --light-gray: #c4c4c4; + --very-light-gray: #fafafc; + --amp-brand: #2459e7; --amp-settings-color-black: #212121; --amp-settings-color-dark-gray: #333; --amp-settings-color-brand: #2459e7; --amp-settings-color-muted: #48525c; - --gray: #6c7781; --amp-settings-color-border: #e8e8e8; - --very-light-gray: #fafafc; --amp-settings-color-background: #fff; + --amp-settings-color-background-light: #f8f8f8; --amp-settings-color-warning: #ff9f00; --font-noto: "Noto Sans", sans-serif; --font-poppins: poppins, sans-serif; + --font-default: -apple-system, "BlinkMacSystemFont", "Segoe UI", "Roboto", "Oxygen-Sans", "Ubuntu", "Cantarell", "Helvetica Neue", sans-serif; --color-valid: #46b450; --amp-settings-color-danger: #dc3232; --color-gray-medium: rgba(0, 0, 0, 0.54); diff --git a/assets/src/onboarding-wizard/components/site-scan-context-provider.js b/assets/src/onboarding-wizard/components/site-scan-context-provider.js deleted file mode 100644 index cf14b71a52f..00000000000 --- a/assets/src/onboarding-wizard/components/site-scan-context-provider.js +++ /dev/null @@ -1,67 +0,0 @@ -/** - * WordPress dependencies - */ -import { createContext, useEffect, useState } from '@wordpress/element'; - -/** - * External dependencies - */ -import PropTypes from 'prop-types'; -import { getQueryArg } from '@wordpress/url'; - -export const SiteScan = createContext(); - -/** - * Context provider for site scanning. - * - * @param {Object} props Component props. - * @param {?any} props.children Component children. - */ -export function SiteScanContextProvider( { children } ) { - const [ themeIssues, setThemeIssues ] = useState( null ); - const [ pluginIssues, setPluginIssues ] = useState( null ); - const [ scanningSite, setScanningSite ] = useState( true ); - - /** - * @todo Note: The following effects will be updated for version 2.1 when site scan is implemented in the wizard. For now, - * we will keep themeIssues and pluginIssues set to null, emulating an unsuccessful site scan. The wizard will then make - * a mode recommendation based only on how the user has answered the technical question. - */ - useEffect( () => { - if ( ! scanningSite && ! themeIssues ) { - setThemeIssues( getQueryArg( global.location.href, 'amp-theme-issues' ) ? [ 'Theme issue 1' ] : null ); // URL param is for testing. - } - }, [ scanningSite, themeIssues ] ); - - // See note above. - useEffect( () => { - if ( ! scanningSite && ! pluginIssues ) { - setPluginIssues( getQueryArg( global.location.href, 'amp-plugin-issues' ) ? [ 'Plugin issue 1' ] : null ); // URL param is for testing. - } - }, [ scanningSite, pluginIssues ] ); - - // See note above. - useEffect( () => { - if ( true === scanningSite ) { - setScanningSite( false ); - } - }, [ scanningSite ] ); - - return ( - - { children } - - ); -} - -SiteScanContextProvider.propTypes = { - children: PropTypes.any, -}; diff --git a/assets/src/onboarding-wizard/index.js b/assets/src/onboarding-wizard/index.js index 4571a468e65..ff85ed54142 100644 --- a/assets/src/onboarding-wizard/index.js +++ b/assets/src/onboarding-wizard/index.js @@ -15,9 +15,11 @@ import { SETTINGS_LINK, OPTIONS_REST_PATH, READER_THEMES_REST_PATH, + SCANNABLE_URLS_REST_PATH, UPDATES_NONCE, USER_FIELD_DEVELOPER_TOOLS_ENABLED, USERS_RESOURCE_REST_PATH, + VALIDATE_NONCE, } from 'amp-settings'; // From WP inline script. import PropTypes from 'prop-types'; @@ -33,11 +35,13 @@ import { ReaderThemesContextProvider } from '../components/reader-themes-context import { ErrorBoundary } from '../components/error-boundary'; import { ErrorContextProvider } from '../components/error-context-provider'; import { ErrorScreen } from '../components/error-screen'; +import { SiteScanContextProvider } from '../components/site-scan-context-provider'; import { UserContextProvider } from '../components/user-context-provider'; +import { PluginsContextProvider } from '../components/plugins-context-provider'; +import { ThemesContextProvider } from '../components/themes-context-provider'; import { PAGES } from './pages'; import { SetupWizard } from './setup-wizard'; import { NavigationContextProvider } from './components/navigation-context-provider'; -import { SiteScanContextProvider } from './components/site-scan-context-provider'; import { TemplateModeOverrideContextProvider } from './components/template-mode-override-context-provider'; const { ajaxurl: wpAjaxUrl } = global; @@ -71,19 +75,27 @@ export function Providers( { children } ) { usersResourceRestPath={ USERS_RESOURCE_REST_PATH } > - - - - { children } - - - + + + + + + { children } + + + + + diff --git a/assets/src/onboarding-wizard/pages/done/use-preview.js b/assets/src/onboarding-wizard/pages/done/use-preview.js index e9a2b708f75..4834e729346 100644 --- a/assets/src/onboarding-wizard/pages/done/use-preview.js +++ b/assets/src/onboarding-wizard/pages/done/use-preview.js @@ -3,33 +3,30 @@ */ import { useContext, useMemo, useState } from '@wordpress/element'; -/** - * External dependencies - */ -import { PREVIEW_URLS } from 'amp-settings'; // From WP inline script. - /** * Internal dependencies */ import { Options } from '../../../components/options-context-provider'; +import { SiteScan } from '../../../components/site-scan-context-provider'; import { STANDARD } from '../../../common/constants'; export function usePreview() { - const hasPreview = PREVIEW_URLS.length > 0; - + const { scannableUrls } = useContext( SiteScan ); const { editedOptions: { theme_support: themeSupport } } = useContext( Options ); + + const hasPreview = scannableUrls.length > 0; const [ isPreviewingAMP, setIsPreviewingAMP ] = useState( themeSupport !== STANDARD ); - const [ previewedPageType, setPreviewedPageType ] = useState( hasPreview ? PREVIEW_URLS[ 0 ].type : null ); + const [ previewedPageType, setPreviewedPageType ] = useState( hasPreview ? scannableUrls[ 0 ].type : null ); const toggleIsPreviewingAMP = () => setIsPreviewingAMP( ( mode ) => ! mode ); const setActivePreviewLink = ( link ) => setPreviewedPageType( link.type ); - const previewLinks = useMemo( () => PREVIEW_URLS.map( ( { url, amp_url: ampUrl, type, label } ) => ( { + const previewLinks = useMemo( () => scannableUrls.map( ( { url, amp_url: ampUrl, type, label } ) => ( { type, label, url: isPreviewingAMP ? ampUrl : url, isActive: type === previewedPageType, - } ) ), [ isPreviewingAMP, previewedPageType ] ); + } ) ), [ isPreviewingAMP, previewedPageType, scannableUrls ] ); const previewUrl = useMemo( () => previewLinks.find( ( link ) => link.isActive )?.url, [ previewLinks ] ); diff --git a/assets/src/onboarding-wizard/pages/index.js b/assets/src/onboarding-wizard/pages/index.js index 8df208b24ab..64e5bee4001 100644 --- a/assets/src/onboarding-wizard/pages/index.js +++ b/assets/src/onboarding-wizard/pages/index.js @@ -7,6 +7,7 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ import { TechnicalBackground } from './technical-background'; +import { SiteScan } from './site-scan'; import { TemplateMode } from './template-mode'; import { ChooseReaderTheme } from './choose-reader-theme'; import { Done } from './done'; @@ -28,10 +29,16 @@ export const PAGES = [ PageComponent: TechnicalBackground, showTitle: false, }, + { + slug: 'site-scan', + title: __( 'Site Scan', 'amp' ), + PageComponent: SiteScan, + }, { slug: 'template-modes', title: __( 'Template Modes', 'amp' ), PageComponent: TemplateMode, + showTitle: false, }, { slug: 'theme-selection', diff --git a/assets/src/onboarding-wizard/pages/site-scan/index.js b/assets/src/onboarding-wizard/pages/site-scan/index.js new file mode 100644 index 00000000000..e42ae50eebe --- /dev/null +++ b/assets/src/onboarding-wizard/pages/site-scan/index.js @@ -0,0 +1,202 @@ +/** + * External dependencies + */ +import { VALIDATED_URLS_LINK } from 'amp-settings'; // From WP inline script. +import PropTypes from 'prop-types'; + +/** + * WordPress dependencies + */ +import { useContext, useEffect, useMemo } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { ExternalLink } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import './style.scss'; +import { Navigation } from '../../components/navigation-context-provider'; +import { SiteScan as SiteScanContext } from '../../../components/site-scan-context-provider'; +import { User } from '../../../components/user-context-provider'; +import { Loading } from '../../../components/loading'; +import { Selectable } from '../../../components/selectable'; +import { IconLandscapeHillsCogs } from '../../../components/svg/landscape-hills-cogs'; +import { ProgressBar } from '../../../components/progress-bar'; +import { PluginsWithAmpIncompatibility, ThemesWithAmpIncompatibility } from '../../../components/site-scan-results'; +import useDelayedFlag from '../../../utils/use-delayed-flag'; + +/** + * Screen for visualizing a site scan. + */ +export function SiteScan() { + const { setCanGoForward } = useContext( Navigation ); + const { + cancelSiteScan, + currentlyScannedUrlIndex, + isCancelled, + isCompleted, + isFailed, + isFetchingScannableUrls, + isReady, + pluginsWithAmpIncompatibility, + scannableUrls, + startSiteScan, + themesWithAmpIncompatibility, + } = useContext( SiteScanContext ); + const { developerToolsOption } = useContext( User ); + const userIsTechnical = useMemo( () => developerToolsOption === true, [ developerToolsOption ] ); + /** + * Cancel scan on component unmount. + */ + useEffect( () => () => cancelSiteScan(), [ cancelSiteScan ] ); + + useEffect( () => { + if ( isReady || isCancelled ) { + startSiteScan(); + } + }, [ isCancelled, isReady, startSiteScan ] ); + + /** + * Allow moving forward. + */ + useEffect( () => { + if ( isCompleted || isFailed ) { + setCanGoForward( true ); + } + }, [ isCompleted, isFailed, setCanGoForward ] ); + + /** + * Delay the `isCompleted` flag so that the progress bar stays at 100% for a + * brief moment. + */ + const isDelayedCompleted = useDelayedFlag( isCompleted ); + + if ( isFetchingScannableUrls ) { + return ( + } + /> + ); + } + + if ( isFailed ) { + return ( + +

+ { __( 'Site scan was unsuccessful.', 'amp' ) } +

+

+ { __( 'You can trigger the site scan again on the AMP Settings page after completing the Wizard.', 'amp' ) } +

+ + ) } + /> + ); + } + + if ( isDelayedCompleted ) { + return ( + + { themesWithAmpIncompatibility.length > 0 || pluginsWithAmpIncompatibility.length > 0 + ? __( 'Site scan found issues on your site. Proceed to the next step to follow recommendations for choosing a template mode.', 'amp' ) + : __( 'Site scan found no issues on your site. Proceed to the next step to follow recommendations for choosing a template mode.', 'amp' ) + } +

+ ) } + > + { themesWithAmpIncompatibility.length > 0 && ( + + { __( 'Review Validated URLs', 'amp' ) } + + ) : null } + /> + ) } + { pluginsWithAmpIncompatibility.length > 0 && ( + + { __( 'Review Validated URLs', 'amp' ) } + + ) : null } + /> + ) } +
+ ); + } + + return ( + +

+ { __( 'Site scan is checking if there are AMP compatibility issues with your active theme and plugins. We’ll then recommend how to use the AMP plugin.', 'amp' ) } +

+ +

+ { isCompleted + ? __( 'Scan complete', 'amp' ) + : sprintf( + // translators: 1: currently scanned URL index; 2: scannable URLs count; 3: scanned page type. + __( 'Scanning %1$d/%2$d URLs: Checking %3$s…', 'amp' ), + currentlyScannedUrlIndex + 1, + scannableUrls.length, + scannableUrls[ currentlyScannedUrlIndex ]?.label, + ) + } +

+ + ) } + /> + ); +} + +/** + * Site Scan panel. + * + * @param {Object} props Component props. + * @param {any} props.children Component children. + * @param {any} props.headerContent Component header content. + * @param {string} props.title Component title. + */ +function SiteScanPanel( { + children, + headerContent, + title, +} ) { + return ( +
+ +
+ +

+ { title } +

+
+ { headerContent } +
+ { children } +
+ ); +} +SiteScanPanel.propTypes = { + children: PropTypes.any, + headerContent: PropTypes.any, + title: PropTypes.string, +}; diff --git a/assets/src/onboarding-wizard/pages/site-scan/style.scss b/assets/src/onboarding-wizard/pages/site-scan/style.scss new file mode 100644 index 00000000000..c21749ac334 --- /dev/null +++ b/assets/src/onboarding-wizard/pages/site-scan/style.scss @@ -0,0 +1,18 @@ +.site-scan__section + .site-scan__section { + margin-top: 1.5rem; +} + +.site-scan__header { + align-items: center; + border-bottom: 1px solid var(--amp-settings-color-border); + display: flex; + flex-flow: row nowrap; + padding-bottom: 1rem; + padding-top: 0.5rem; +} + +.site-scan__heading { + font-size: 16px; + font-weight: 700; + margin-left: 2rem; +} diff --git a/assets/src/onboarding-wizard/pages/template-mode/get-selection-details.js b/assets/src/onboarding-wizard/pages/template-mode/get-selection-details.js deleted file mode 100644 index a27f065e513..00000000000 --- a/assets/src/onboarding-wizard/pages/template-mode/get-selection-details.js +++ /dev/null @@ -1,207 +0,0 @@ -/* eslint-disable complexity */ -/* eslint-disable jsdoc/check-param-names */ - -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import isShallowEqual from '@wordpress/is-shallow-equal'; -/** - * Internal dependencies - */ -import { READER, STANDARD, TRANSITIONAL } from '../../../common/constants'; - -// Sections. -export const COMPATIBILITY = 'compatibility'; -export const DETAILS = 'details'; - -// Recommendation levels. -export const MOST_RECOMMENDED = 'mostRecommended'; -export const NOT_RECOMMENDED = 'notRecommended'; -export const RECOMMENDED = 'recommended'; - -// Technical levels. -export const TECHNICAL = 'technical'; -export const NON_TECHNICAL = 'nonTechnical'; - -/** - * Returns the degree to which each mode is recommended for the current site and user. - * - * @param {Object} args - * @param {boolean} args.currentThemeIsAmongReaderThemes Whether the currently active theme is in the reader themes list. - * @param {boolean} args.userIsTechnical Whether the user answered yes to the technical question. - * @param {boolean} args.hasPluginIssues Whether the site scan found plugin issues. - * @param {boolean} args.hasThemeIssues Whether the site scan found theme issues. - * @param {boolean} args.hasScanResults Whether there are available scan results. - */ -export function getRecommendationLevels( { currentThemeIsAmongReaderThemes, userIsTechnical, hasPluginIssues, hasThemeIssues, hasScanResults = true } ) { - // Handle case where scanning has failed or did not run. - if ( ! hasScanResults ) { - if ( userIsTechnical ) { - return { - [ READER ]: currentThemeIsAmongReaderThemes ? MOST_RECOMMENDED : RECOMMENDED, - [ STANDARD ]: RECOMMENDED, - [ TRANSITIONAL ]: currentThemeIsAmongReaderThemes ? MOST_RECOMMENDED : RECOMMENDED, - }; - } - return { - [ READER ]: MOST_RECOMMENDED, - [ STANDARD ]: RECOMMENDED, - [ TRANSITIONAL ]: currentThemeIsAmongReaderThemes ? MOST_RECOMMENDED : RECOMMENDED, - }; - } - - switch ( true ) { - case hasThemeIssues && hasPluginIssues && userIsTechnical: - case hasThemeIssues && ! hasPluginIssues && userIsTechnical: - return { - [ READER ]: MOST_RECOMMENDED, - [ STANDARD ]: NOT_RECOMMENDED, - [ TRANSITIONAL ]: RECOMMENDED, - }; - - case hasThemeIssues && hasPluginIssues && ! userIsTechnical: - case hasThemeIssues && ! hasPluginIssues && ! userIsTechnical: - return { - [ READER ]: MOST_RECOMMENDED, - [ STANDARD ]: NOT_RECOMMENDED, - [ TRANSITIONAL ]: NOT_RECOMMENDED, - }; - - case ! hasThemeIssues && hasPluginIssues && userIsTechnical: - return { - [ READER ]: NOT_RECOMMENDED, - [ STANDARD ]: NOT_RECOMMENDED, - [ TRANSITIONAL ]: MOST_RECOMMENDED, - }; - - case ! hasThemeIssues && hasPluginIssues && ! userIsTechnical: - return { - [ READER ]: NOT_RECOMMENDED, - [ STANDARD ]: NOT_RECOMMENDED, - [ TRANSITIONAL ]: MOST_RECOMMENDED, - }; - - case ! hasThemeIssues && ! hasPluginIssues && userIsTechnical: - return { - [ READER ]: NOT_RECOMMENDED, - [ STANDARD ]: MOST_RECOMMENDED, - [ TRANSITIONAL ]: NOT_RECOMMENDED, - }; - - case ! hasThemeIssues && ! hasPluginIssues && ! userIsTechnical: - return { - [ READER ]: NOT_RECOMMENDED, - [ STANDARD ]: NOT_RECOMMENDED, - [ TRANSITIONAL ]: MOST_RECOMMENDED, - }; - - default: { - throw new Error( __( 'A template mode recommendation case was not accounted for.', 'amp' ) ); - } - } -} - -/** - * Provides details on copy and UI for the template modes screen. - * - * @param {Array} args Function args. - * @param {string} section The section for which to provide text. - * @param {string} mode The mode to generate text for. - * @param {string} recommendationLevel String representing whether the mode is not recommended, recommended, or most recommended. - * @param {string} technicalLevel String representing whether the user is technical. - */ -export function getSelectionText( ...args ) { - const match = ( ...test ) => isShallowEqual( test, args ); - - switch ( true ) { - case match( COMPATIBILITY, READER, MOST_RECOMMENDED, NON_TECHNICAL ): - case match( COMPATIBILITY, READER, MOST_RECOMMENDED, TECHNICAL ): - return __( 'Reader mode is the best choice if you don\'t have a technical background or would like a simpler setup.', 'amp' ); - - case match( COMPATIBILITY, READER, RECOMMENDED, NON_TECHNICAL ): - case match( COMPATIBILITY, READER, RECOMMENDED, TECHNICAL ): - return __( 'Reader mode makes it easy to bring AMP content to your site, but your site will use two different themes.', 'amp' ); - - case match( COMPATIBILITY, READER, NOT_RECOMMENDED, NON_TECHNICAL ): - case match( COMPATIBILITY, READER, NOT_RECOMMENDED, TECHNICAL ): - case match( COMPATIBILITY, TRANSITIONAL, NOT_RECOMMENDED, NON_TECHNICAL ): - case match( COMPATIBILITY, TRANSITIONAL, NOT_RECOMMENDED, TECHNICAL ): - return __( 'There is no reason to use this mode, as you have an AMP-compatible theme that you can use for both the non-AMP and AMP versions of your site.', 'amp' ); - - case match( COMPATIBILITY, STANDARD, NOT_RECOMMENDED, NON_TECHNICAL ): - case match( COMPATIBILITY, STANDARD, NOT_RECOMMENDED, TECHNICAL ): - return __( 'Standard mode is not recommended as key functionality may be missing and development work might be required. ', 'amp' ); - - case match( COMPATIBILITY, STANDARD, MOST_RECOMMENDED, NON_TECHNICAL ): - case match( COMPATIBILITY, STANDARD, MOST_RECOMMENDED, TECHNICAL ): - return __( 'Standard mode is the best choice for your site because you are using an AMP-compatible theme and no plugin issues were detected.', 'amp' ); - - case match( COMPATIBILITY, TRANSITIONAL, MOST_RECOMMENDED, NON_TECHNICAL ): - case match( COMPATIBILITY, TRANSITIONAL, MOST_RECOMMENDED, TECHNICAL ): - return __( 'Transitional mode is recommended because it makes it easy to keep your content as valid AMP even if non-AMP-compatible plugins are installed later.', 'amp' ); - - case match( COMPATIBILITY, TRANSITIONAL, RECOMMENDED, NON_TECHNICAL ): - case match( COMPATIBILITY, TRANSITIONAL, RECOMMENDED, TECHNICAL ): - return __( 'Transitional mode is a good choice if you are willing and able to address any issues around AMP-compatibility that may arise as your site evolves.', 'amp' ); - - case match( DETAILS, READER, NOT_RECOMMENDED, NON_TECHNICAL ): - case match( DETAILS, READER, NOT_RECOMMENDED, TECHNICAL ): - case match( DETAILS, READER, MOST_RECOMMENDED, NON_TECHNICAL ): - case match( DETAILS, READER, MOST_RECOMMENDED, TECHNICAL ): - case match( DETAILS, READER, RECOMMENDED, NON_TECHNICAL ): - case match( DETAILS, READER, RECOMMENDED, TECHNICAL ): - return __( 'In Reader mode your site will have a non-AMP and an AMP version, and each version will use its own theme. If automatic mobile redirection is enabled, the AMP version of the content will be served on mobile devices. If AMP-to-AMP linking is enabled, once users are on an AMP page, they will continue navigating your AMP content.', 'amp' ); - - case match( DETAILS, TRANSITIONAL, NOT_RECOMMENDED, NON_TECHNICAL ): - case match( DETAILS, TRANSITIONAL, NOT_RECOMMENDED, TECHNICAL ): - case match( DETAILS, TRANSITIONAL, MOST_RECOMMENDED, NON_TECHNICAL ): - case match( DETAILS, TRANSITIONAL, MOST_RECOMMENDED, TECHNICAL ): - case match( DETAILS, TRANSITIONAL, RECOMMENDED, NON_TECHNICAL ): - case match( DETAILS, TRANSITIONAL, RECOMMENDED, TECHNICAL ): - return __( 'In Transitional mode your site will have a non-AMP and an AMP version, and both will use the same theme. If automatic mobile redirection is enabled, the AMP version of the content will be served on mobile devices. If AMP-to-AMP linking is enabled, once users are on an AMP page, they will continue navigating your AMP content. ', 'amp' ); - - case match( DETAILS, STANDARD, RECOMMENDED, NON_TECHNICAL ): - case match( DETAILS, STANDARD, RECOMMENDED, TECHNICAL ): - case match( DETAILS, STANDARD, MOST_RECOMMENDED, NON_TECHNICAL ): - case match( DETAILS, STANDARD, MOST_RECOMMENDED, TECHNICAL ): - case match( DETAILS, STANDARD, NOT_RECOMMENDED, NON_TECHNICAL ): - case match( DETAILS, STANDARD, NOT_RECOMMENDED, TECHNICAL ): - return __( 'In Standard mode your site will be completely AMP (except in cases where you opt-out of AMP for specific parts of your site), and it will use a single theme. ', 'amp' ); - - // Cases potentially never used. - case match( COMPATIBILITY, STANDARD, RECOMMENDED, NON_TECHNICAL ): - case match( COMPATIBILITY, STANDARD, RECOMMENDED, TECHNICAL ): - return 'Standard mode is a good choice if your site uses an AMP-compatible theme and only uses AMP-compatible plugins. If you\'re not sure of the compatibility of your themes and plugins, Reader mode may be a better option.'; - - default: { - throw new Error( __( 'A selection text recommendation was not accounted for. ', 'amp' ) + JSON.stringify( args ) ); - } - } -} - -/** - * Gets all the selection text for the ScreenUI component. - * - * @param {Object} recommendationLevels Result of getRecommendationLevels. - * @param {string} technicalLevel A technical level. - */ -export function getAllSelectionText( recommendationLevels, technicalLevel ) { - return { - [ READER ]: { - [ COMPATIBILITY ]: getSelectionText( COMPATIBILITY, READER, recommendationLevels[ READER ], technicalLevel ), - [ DETAILS ]: getSelectionText( DETAILS, READER, recommendationLevels[ READER ], technicalLevel ), - }, - [ STANDARD ]: { - [ COMPATIBILITY ]: getSelectionText( COMPATIBILITY, STANDARD, recommendationLevels[ STANDARD ], technicalLevel ), - [ DETAILS ]: getSelectionText( DETAILS, STANDARD, recommendationLevels[ STANDARD ], technicalLevel ), - }, - [ TRANSITIONAL ]: { - [ COMPATIBILITY ]: getSelectionText( COMPATIBILITY, TRANSITIONAL, recommendationLevels[ TRANSITIONAL ], technicalLevel ), - [ DETAILS ]: getSelectionText( DETAILS, TRANSITIONAL, recommendationLevels[ TRANSITIONAL ], technicalLevel ), - }, - }; -} - -/* eslint-enable complexity */ -/* eslint-enable jsdoc/check-param-names */ diff --git a/assets/src/onboarding-wizard/pages/template-mode/index.js b/assets/src/onboarding-wizard/pages/template-mode/index.js index 87143108857..fbf552e3d39 100644 --- a/assets/src/onboarding-wizard/pages/template-mode/index.js +++ b/assets/src/onboarding-wizard/pages/template-mode/index.js @@ -2,15 +2,15 @@ * WordPress dependencies */ import { useEffect, useContext } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; + /** * Internal dependencies */ +import './style.scss'; +import { useTemplateModeRecommendation } from '../../../components/use-template-mode-recommendation'; import { Navigation } from '../../components/navigation-context-provider'; -import { SiteScan } from '../../components/site-scan-context-provider'; -import { ReaderThemes } from '../../../components/reader-themes-context-provider'; -import { User } from '../../../components/user-context-provider'; import { Options } from '../../../components/options-context-provider'; -import { Loading } from '../../../components/loading'; import { TemplateModeOverride } from '../../components/template-mode-override-context-provider'; import { ScreenUI } from './screen-ui'; @@ -19,41 +19,43 @@ import { ScreenUI } from './screen-ui'; */ export function TemplateMode() { const { setCanGoForward } = useContext( Navigation ); - const { editedOptions, originalOptions, updateOptions } = useContext( Options ); - const { developerToolsOption } = useContext( User ); - const { pluginIssues, themeIssues, scanningSite } = useContext( SiteScan ); - const { currentTheme } = useContext( ReaderThemes ); + const { editedOptions: { theme_support: themeSupport }, originalOptions } = useContext( Options ); const { technicalQuestionChangedAtLeastOnce } = useContext( TemplateModeOverride ); - - const { theme_support: themeSupport } = editedOptions; + const { templateModeRecommendation } = useTemplateModeRecommendation(); /** * Allow moving forward. */ useEffect( () => { - if ( false === scanningSite && undefined !== themeSupport ) { + if ( undefined !== themeSupport ) { setCanGoForward( true ); } - }, [ setCanGoForward, scanningSite, themeSupport ] ); - - if ( scanningSite ) { - return ; - } + }, [ setCanGoForward, themeSupport ] ); // The actual display component should avoid using global context directly. This will facilitate developing and testing the UI using different options. return ( - { - updateOptions( { theme_support: mode } ); - } } - technicalQuestionChanged={ technicalQuestionChangedAtLeastOnce } - themeIssues={ themeIssues } - /> +
+
+

+ { __( 'Template Modes', 'amp' ) } +

+ { /* dangerouslySetInnerHTML reason: Injection of links. */ } +

AMP experience with different modes and availability of AMP components in the ecosystem.', 'amp' ), + 'https://amp-wp.org/documentation/getting-started/template-modes/', + 'https://amp-wp.org/ecosystem/', + ), + } } /> +

+ +
); } diff --git a/assets/src/onboarding-wizard/pages/template-mode/screen-ui.js b/assets/src/onboarding-wizard/pages/template-mode/screen-ui.js index 6f1541a1039..92215f400a0 100644 --- a/assets/src/onboarding-wizard/pages/template-mode/screen-ui.js +++ b/assets/src/onboarding-wizard/pages/template-mode/screen-ui.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; /** * External dependencies @@ -12,101 +12,110 @@ import PropTypes from 'prop-types'; /** * Internal dependencies */ -import { AMPNotice, NOTICE_TYPE_SUCCESS, NOTICE_TYPE_INFO, NOTICE_TYPE_ERROR, NOTICE_SIZE_LARGE } from '../../../components/amp-notice'; +import { AMPNotice, NOTICE_TYPE_SUCCESS, NOTICE_SIZE_SMALL } from '../../../components/amp-notice'; import { TemplateModeOption } from '../../../components/template-mode-option'; import { READER, STANDARD, TRANSITIONAL } from '../../../common/constants'; -import { MOST_RECOMMENDED, RECOMMENDED, getRecommendationLevels, getAllSelectionText, TECHNICAL, NON_TECHNICAL } from './get-selection-details'; +import { RECOMMENDED, NOT_RECOMMENDED } from '../../../components/use-template-mode-recommendation'; /** - * The interface for the mode selection screen. Avoids using context for easier testing. - * - * @param {Object} props Component props. - * @param {boolean} props.currentThemeIsAmongReaderThemes Whether the currently active theme is in the list of reader themes. - * @param {boolean} props.developerToolsOption Whether the user has enabled developer tools. - * @param {boolean} props.firstTimeInWizard Whether the wizard is running for the first time. - * @param {boolean} props.technicalQuestionChanged Whether the user changed their technical question from the previous option. - * @param {Array} props.pluginIssues The plugin issues found in the site scan. - * @param {string} props.savedCurrentMode The current selected mode saved in the database. - * @param {Array} props.themeIssues The theme issues found in the site scan. + * Small notice indicating a mode is recommended. */ -export function ScreenUI( { currentThemeIsAmongReaderThemes, developerToolsOption, firstTimeInWizard, technicalQuestionChanged, pluginIssues, savedCurrentMode, themeIssues } ) { - const userIsTechnical = useMemo( () => developerToolsOption === true, [ developerToolsOption ] ); - - const recommendationLevels = useMemo( () => getRecommendationLevels( - { - currentThemeIsAmongReaderThemes, - userIsTechnical, - hasScanResults: null !== pluginIssues && null !== themeIssues, - hasPluginIssues: pluginIssues && 0 < pluginIssues.length, - hasThemeIssues: themeIssues && 0 < themeIssues.length, - }, - ), [ currentThemeIsAmongReaderThemes, themeIssues, pluginIssues, userIsTechnical ] ); - - const sectionText = useMemo( - () => getAllSelectionText( recommendationLevels, userIsTechnical ? TECHNICAL : NON_TECHNICAL ), - [ recommendationLevels, userIsTechnical ], +function RecommendedNotice() { + return ( + + { __( 'Recommended', 'amp' ) } + ); +} - const getRecommendationLevelType = ( recommended ) => { - switch ( recommended ) { - case MOST_RECOMMENDED: - return NOTICE_TYPE_SUCCESS; +/** + * Determine if a template mode option should be initially open. + * + * @param {string} mode Template mode to check. + * @param {Object} selectionDetails Selection details. + * @param {string} savedCurrentMode Currently saved template mode. + */ +function isInitiallyOpen( mode, selectionDetails, savedCurrentMode ) { + if ( savedCurrentMode === mode || ! selectionDetails ) { + return true; + } + + switch ( selectionDetails[ mode ].recommendationLevel ) { + case RECOMMENDED: + return true; - case RECOMMENDED: - return NOTICE_TYPE_INFO; + case NOT_RECOMMENDED: + return false; - default: - return NOTICE_TYPE_ERROR; - } - }; + /** + * For NEUTRAL, the option should be initially open if no other mode is + * RECOMMENDED. + */ + default: + return ! Object.values( selectionDetails ).some( ( item ) => item.recommendationLevel === RECOMMENDED ); + } +} +/** + * The interface for the mode selection screen. Avoids using context for easier testing. + * + * @param {Object} props Component props. + * @param {boolean} props.firstTimeInWizard Whether the wizard is running for the first time. + * @param {boolean} props.technicalQuestionChanged Whether the user changed their technical question from the previous option. + * @param {Object} props.templateModeRecommendation Recommendations for each template mode. + * @param {string} props.savedCurrentMode The current selected mode saved in the database. + */ +export function ScreenUI( { + firstTimeInWizard, + savedCurrentMode, + technicalQuestionChanged, + templateModeRecommendation, +} ) { return (
- - { sectionText.standard.compatibility } - - + details={ templateModeRecommendation?.[ READER ]?.details } + initialOpen={ isInitiallyOpen( READER, templateModeRecommendation, savedCurrentMode ) } + mode={ READER } + previouslySelected={ savedCurrentMode === READER && technicalQuestionChanged && ! firstTimeInWizard } + labelExtra={ templateModeRecommendation?.[ READER ]?.recommendationLevel === RECOMMENDED ? : null } + /> - - { sectionText.transitional.compatibility } - - + labelExtra={ templateModeRecommendation?.[ TRANSITIONAL ]?.recommendationLevel === RECOMMENDED ? : null } + /> - - { sectionText.reader.compatibility } - - + details={ templateModeRecommendation?.[ STANDARD ]?.details } + initialOpen={ isInitiallyOpen( STANDARD, templateModeRecommendation, savedCurrentMode ) } + mode={ STANDARD } + previouslySelected={ savedCurrentMode === STANDARD && technicalQuestionChanged && ! firstTimeInWizard } + labelExtra={ templateModeRecommendation?.[ STANDARD ]?.recommendationLevel === RECOMMENDED ? : null } + /> ); } ScreenUI.propTypes = { - currentThemeIsAmongReaderThemes: PropTypes.bool.isRequired, - developerToolsOption: PropTypes.bool, firstTimeInWizard: PropTypes.bool, - technicalQuestionChanged: PropTypes.bool, - pluginIssues: PropTypes.arrayOf( PropTypes.string ), savedCurrentMode: PropTypes.string, - themeIssues: PropTypes.arrayOf( PropTypes.string ), + technicalQuestionChanged: PropTypes.bool, + templateModeRecommendation: PropTypes.shape( { + [ READER ]: PropTypes.shape( { + recommendationLevel: PropTypes.string, + details: PropTypes.array, + } ), + [ TRANSITIONAL ]: PropTypes.shape( { + recommendationLevel: PropTypes.string, + details: PropTypes.array, + } ), + [ STANDARD ]: PropTypes.shape( { + recommendationLevel: PropTypes.string, + details: PropTypes.array, + } ), + } ), }; diff --git a/assets/src/onboarding-wizard/pages/template-mode/style.scss b/assets/src/onboarding-wizard/pages/template-mode/style.scss new file mode 100644 index 00000000000..10ca8f0cce7 --- /dev/null +++ b/assets/src/onboarding-wizard/pages/template-mode/style.scss @@ -0,0 +1,3 @@ +.template-modes__header { + margin-bottom: 1.75rem; +} diff --git a/assets/src/onboarding-wizard/pages/template-mode/test/get-selection-details.js b/assets/src/onboarding-wizard/pages/template-mode/test/get-selection-details.js deleted file mode 100644 index 0df4f8e30d2..00000000000 --- a/assets/src/onboarding-wizard/pages/template-mode/test/get-selection-details.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Internal dependencies - */ -import { - getRecommendationLevels, - getSelectionText, - COMPATIBILITY, - DETAILS, - MOST_RECOMMENDED, - NOT_RECOMMENDED, - RECOMMENDED, - TECHNICAL, - NON_TECHNICAL, -} from '../get-selection-details'; -import { READER, STANDARD, TRANSITIONAL } from '../../../../common/constants'; - -describe( 'getRecommendationLevels', () => { - it( 'throws no errors', () => { - [ true, false ].forEach( ( hasPluginIssues ) => { - [ true, false ].forEach( ( hasThemeIssues ) => { - [ true, false ].forEach( ( userIsTechnical ) => { - const cb = () => getRecommendationLevels( { hasPluginIssues, hasThemeIssues, userIsTechnical } ); - expect( cb ).not.toThrow(); - } ); - } ); - } ); - } ); -} ); - -describe( 'getSelectionText', () => { - it( 'throws no errors', () => { - [ COMPATIBILITY, DETAILS ].forEach( ( section ) => { - [ READER, STANDARD, TRANSITIONAL ].forEach( ( mode ) => { - [ MOST_RECOMMENDED, NOT_RECOMMENDED, RECOMMENDED ].forEach( ( recommendationLevel ) => { - [ NON_TECHNICAL, TECHNICAL ].forEach( ( technicalLevel ) => { - const cb = () => getSelectionText( section, mode, recommendationLevel, technicalLevel ); - expect( cb ).not.toThrow(); - } ); - } ); - } ); - } ); - } ); -} ); diff --git a/assets/src/settings-page/index.js b/assets/src/settings-page/index.js index 0e5026932db..7eb610580a8 100644 --- a/assets/src/settings-page/index.js +++ b/assets/src/settings-page/index.js @@ -7,10 +7,12 @@ import { HAS_DEPENDENCY_SUPPORT, OPTIONS_REST_PATH, READER_THEMES_REST_PATH, + SCANNABLE_URLS_REST_PATH, UPDATES_NONCE, USER_FIELD_DEVELOPER_TOOLS_ENABLED, USER_FIELD_REVIEW_PANEL_DISMISSED_FOR_TEMPLATE_MODE, USERS_RESOURCE_REST_PATH, + VALIDATE_NONCE, } from 'amp-settings'; /** @@ -38,6 +40,9 @@ import { AMPDrawer } from '../components/amp-drawer'; import { AMPNotice, NOTICE_SIZE_LARGE } from '../components/amp-notice'; import { ErrorScreen } from '../components/error-screen'; import { User, UserContextProvider } from '../components/user-context-provider'; +import { PluginsContextProvider } from '../components/plugins-context-provider'; +import { ThemesContextProvider } from '../components/themes-context-provider'; +import { SiteScanContextProvider } from '../components/site-scan-context-provider'; import { Welcome } from './welcome'; import { TemplateModes } from './template-modes'; import { SupportedTemplates } from './supported-templates'; @@ -48,6 +53,7 @@ import { Analytics } from './analytics'; import { PairedUrlStructure } from './paired-url-structure'; import { MobileRedirection } from './mobile-redirection'; import { DeveloperTools } from './developer-tools'; +import { SiteScan } from './site-scan'; import { DeleteDataAtUninstall } from './delete-data-at-uninstall'; const { ajaxurl: wpAjaxUrl } = global; @@ -86,7 +92,18 @@ function Providers( { children } ) { updatesNonce={ UPDATES_NONCE } wpAjaxUrl={ wpAjaxUrl } > - { children } + + + + { children } + + + @@ -182,6 +199,10 @@ function Root( { appRoot } ) { }; }, [ fetchingOptions ] ); + const focusSiteScanSection = useCallback( () => { + setFocusedSection( 'site-scan' ); + }, [] ); + if ( false !== fetchingOptions || null === templateModeWasOverridden ) { return ; } @@ -194,6 +215,7 @@ function Root( { appRoot } ) { ) } +
diff --git a/assets/src/settings-page/site-review.js b/assets/src/settings-page/site-review.js index 00c29ea232b..044f122bd7d 100644 --- a/assets/src/settings-page/site-review.js +++ b/assets/src/settings-page/site-review.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { HOME_URL } from 'amp-settings'; - /** * WordPress dependencies */ @@ -19,6 +14,7 @@ import { IconLaptopSearch } from '../components/svg/icon-laptop-search'; import { Options } from '../components/options-context-provider'; import { User } from '../components/user-context-provider'; import { READER, STANDARD, TRANSITIONAL } from '../common/constants'; +import { SiteScan as SiteScanContext } from '../components/site-scan-context-provider'; /** * Review component on the settings screen. @@ -29,19 +25,14 @@ export function SiteReview() { saveReviewPanelDismissedForTemplateMode, savingReviewPanelDismissedForTemplateMode, } = useContext( User ); + const { previewPermalink } = useContext( SiteScanContext ); const { originalOptions } = useContext( Options ); - const { - paired_url_examples: pairedUrlExamples, - paired_url_structure: pairedUrlStructure, - theme_support: themeSupport, - } = originalOptions; + const { theme_support: themeSupport } = originalOptions; if ( savingReviewPanelDismissedForTemplateMode || reviewPanelDismissedForTemplateMode === themeSupport ) { return null; } - const previewPermalink = STANDARD === themeSupport ? HOME_URL : pairedUrlExamples?.[ pairedUrlStructure ]?.[ 0 ] ?? HOME_URL; - return (
- + { previewPermalink && ( + + ) } + { hasSiteScanResults && ( + + ) } + + ) } + > + { getContent() } + + ); +} +SiteScan.propTypes = { + onSiteScan: PropTypes.func, +}; + +/** + * Site Scan drawer (settings panel). + * + * @param {Object} props Component props. + * @param {any} props.children Component children. + * @param {Object} props.footerContent Component footer content. + */ +function SiteScanDrawer( { children, footerContent, ...props } ) { + return ( + + + { __( 'Site Scan', 'amp' ) } + + ) } + hiddenTitle={ __( 'Site Scan', 'amp' ) } + id="site-scan" + { ...props } + > +
+ { children } + { footerContent && ( +
+ { footerContent } +
+ ) } +
+
+ ); +} +SiteScanDrawer.propTypes = { + children: PropTypes.any, + footerContent: PropTypes.node, +}; + +/** + * Site Scan - in progress state. + */ +function SiteScanInProgress() { + const { + currentlyScannedUrlIndex, + isCompleted, + scannableUrls, + } = useContext( SiteScanContext ); + + return ( + <> +

+ { __( 'Site scan is checking if there are AMP compatibility issues with your active theme and plugins. We’ll then recommend how to use the AMP plugin.', 'amp' ) } +

+ +

+ { isCompleted + ? __( 'Scan complete', 'amp' ) + : sprintf( + // translators: 1: currently scanned URL index; 2: scannable URLs count; 3: scanned page type. + __( 'Scanning %1$d/%2$d URLs: Checking %3$s…', 'amp' ), + currentlyScannedUrlIndex + 1, + scannableUrls.length, + scannableUrls[ currentlyScannedUrlIndex ]?.label, + ) + } +

+ + ); +} + +/** + * Site Scan - summary state. + */ +function SiteScanSummary() { + const { + hasSiteScanResults, + isReady, + pluginsWithAmpIncompatibility, + stale, + themesWithAmpIncompatibility, + } = useContext( SiteScanContext ); + const hasSiteIssues = themesWithAmpIncompatibility.length > 0 || pluginsWithAmpIncompatibility.length > 0; + const { developerToolsOption } = useContext( User ); + const userIsTechnical = useMemo( () => developerToolsOption === true, [ developerToolsOption ] ); + + if ( isReady && ! hasSiteScanResults ) { + return ( + +

+ { __( 'The site has not been scanned yet. Scan your site to ensure everything is working properly.', 'amp' ) } +

+
+ ); + } + + if ( isReady && ! hasSiteIssues && ! stale ) { + return ( + +

+ { __( 'Site scan found no issues on your site. Browse your site to ensure everything is working as expected.', 'amp' ) } +

+
+ ); + } + + return ( + <> + { isReady ? ( + +

+ { stale + ? __( 'Stale results. Rescan your site to ensure everything is working properly.', 'amp' ) + : __( 'No changes since your last scan.', 'amp' ) + } +

+
+ ) : ( + <> + { stale && ( + +

+ { __( 'Stale results. Rescan your site to ensure everything is working properly.', 'amp' ) } +

+
+ ) } + { hasSiteIssues && ( +

template mode recommendations below. Because of plugin issues, you may also want to review and suppress plugins.', 'amp' ), + '#template-modes', + '#plugin-suppression', + ), + } } + /> + ) } + { ! hasSiteIssues && ! stale && ( + +

+ { __( 'Site scan found no issues on your site. Browse your site to ensure everything is working as expected.', 'amp' ) } +

+ + ) } + + ) } + { themesWithAmpIncompatibility.length > 0 && ( + + { __( 'Review Validated URLs', 'amp' ) } + + ) : null } + /> + ) } + { pluginsWithAmpIncompatibility.length > 0 && ( + + { __( 'Review Validated URLs', 'amp' ) } + + ) : null } + /> + ) } + + ); +} diff --git a/assets/src/settings-page/style.css b/assets/src/settings-page/style.css index da63d62e668..d6b884f2c00 100644 --- a/assets/src/settings-page/style.css +++ b/assets/src/settings-page/style.css @@ -367,6 +367,7 @@ li.error-kept { } } +#site-scan .amp-drawer__panel-body-inner, #site-review .amp-drawer__panel-body-inner, #plugin-suppression .amp-drawer__panel-body-inner, .amp-analytics .amp-drawer__panel-body-inner, @@ -379,6 +380,7 @@ li.error-kept { } } +#site-scan .amp-drawer__panel-body-inner > p, #site-review .amp-drawer__panel-body-inner > p, #plugin-suppression .amp-drawer__panel-body-inner > p, #paired-url-structure .amp-drawer__panel-body-inner > p, @@ -542,6 +544,39 @@ li.error-kept { width: 24px; } +/* Site Scan. */ +#site-scan { + margin-bottom: 2.5rem; +} + +#site-scan .amp-drawer__heading { + font-size: 1.2rem; +} + +#site-scan .amp-drawer__heading svg { + fill: transparent; +} + +#site-scan .amp-drawer__heading > svg { + width: 55px; +} + +.settings-site-scan > * + * { + margin-top: 1.5rem; +} + +.settings-site-scan__footer { + align-items: center; + display: flex; + flex-flow: row nowrap; +} + +.amp .settings-site-scan__footer .components-button { + height: 40px; + padding-left: 2.25rem; + padding-right: 2.25rem; +} + /* Review. */ #site-review { margin-bottom: 2.5rem; diff --git a/assets/src/settings-page/template-modes.js b/assets/src/settings-page/template-modes.js index 1817f5cfcaf..624d3d0eb55 100644 --- a/assets/src/settings-page/template-modes.js +++ b/assets/src/settings-page/template-modes.js @@ -3,31 +3,33 @@ */ import PropTypes from 'prop-types'; -/** - * External dependencies - */ -import { - IS_CORE_THEME, - THEME_SUPPORT_ARGS, - THEME_SUPPORTS_READER_MODE, -} from 'amp-settings'; - /** * WordPress dependencies */ import { __, sprintf } from '@wordpress/i18n'; -import { useContext, useMemo } from '@wordpress/element'; +import { useCallback, useContext } from '@wordpress/element'; /** * Internal dependencies */ import { TemplateModeOption } from '../components/template-mode-option'; -import { AMPNotice, NOTICE_SIZE_LARGE, NOTICE_TYPE_INFO, NOTICE_SIZE_SMALL, NOTICE_TYPE_WARNING } from '../components/amp-notice'; +import { + AMPNotice, + NOTICE_SIZE_LARGE, + NOTICE_TYPE_INFO, + NOTICE_SIZE_SMALL, + NOTICE_TYPE_WARNING, +} from '../components/amp-notice'; import { Options } from '../components/options-context-provider'; import { READER, STANDARD, TRANSITIONAL } from '../common/constants'; import { AMPDrawer } from '../components/amp-drawer'; import { ReaderThemes } from '../components/reader-themes-context-provider'; import { ReaderThemeCarousel } from '../components/reader-theme-carousel'; +import { + NOT_RECOMMENDED, + RECOMMENDED, + useTemplateModeRecommendation, +} from '../components/use-template-mode-recommendation'; /** * Small notice indicating a mode is recommended. @@ -51,49 +53,6 @@ function NotRecommendedNotice() { ); } -/** - * Provides the notice to show in the reader theme support mode selection. - * - * @param {boolean} selected Whether reader mode is selected. - */ -function getReaderNotice( selected ) { - switch ( true ) { - // Theme has built-in support or has declared theme support with the paired flag set to false. - case selected && ( 'object' === typeof THEME_SUPPORT_ARGS && false === THEME_SUPPORT_ARGS.paired ): - return { - readerNoticeSmall: selected ? : null, - readerNoticeLarge: ( - - { __( 'Your active theme is known to work well in standard mode.', 'amp' ) } - - ), - }; - - // Theme has built-in support or has declared theme support with the paired flag set to true. - case selected && ( IS_CORE_THEME || ( 'object' === typeof THEME_SUPPORT_ARGS && false !== THEME_SUPPORT_ARGS.paired ) ): - return { - readerNoticeSmall: selected ? : null, - readerNoticeLarge: ( - - { __( 'Your active theme is known to work well in standard and transitional mode.', 'amp' ) } - - ) }; - - // Support for reader mode was detected. - case THEME_SUPPORTS_READER_MODE: - return { - readerNoticeSmall: , - readerNoticeLarge: ( - - { __( 'Your theme indicates it has special support for the legacy templates in Reader mode.', 'amp' ) } - - ) }; - - default: - return { readerNoticeSmall: null, readerNoticeLarge: null }; - } -} - /** * Template modes section of the settings page. * @@ -101,15 +60,30 @@ function getReaderNotice( selected ) { * @param {boolean} props.focusReaderThemes Whether the reader themes drawer should be opened and focused. */ export function TemplateModes( { focusReaderThemes } ) { - const { editedOptions, updateOptions } = useContext( Options ); + const { + editedOptions: { + sandboxing_level: sandboxingLevel, + theme_support: editedThemeSupport, + }, + updateOptions, + } = useContext( Options ); const { selectedTheme, templateModeWasOverridden } = useContext( ReaderThemes ); + const { templateModeRecommendation, staleTemplateModeRecommendation } = useTemplateModeRecommendation(); - const { theme_support: themeSupport, sandboxing_level: sandboxingLevel } = editedOptions; + const getLabelForTemplateMode = useCallback( ( mode ) => { + if ( ! templateModeRecommendation ) { + return null; + } - const { readerNoticeSmall, readerNoticeLarge } = useMemo( - () => getReaderNotice( READER === themeSupport ), - [ themeSupport ], - ); + switch ( templateModeRecommendation[ mode ].recommendationLevel ) { + case RECOMMENDED: + return ; + case NOT_RECOMMENDED: + return ; + default: + return null; + } + }, [ templateModeRecommendation ] ); return (
@@ -121,23 +95,18 @@ export function TemplateModes( { focusReaderThemes } ) { { __( 'Because you selected a Reader theme that is the same as your site\'s active theme, your site has automatically been switched to Transitional template mode.', 'amp' ) } ) } + { staleTemplateModeRecommendation && ( + + { __( 'Because the Site Scan results are stale, the Template Mode recommendation may not be accurate. Rescan your site to ensure the recommendation is up to date.', 'amp' ) } + + ) } : null } + labelExtra={ getLabelForTemplateMode( STANDARD ) } > - { - // Plugin is not configured; active theme has built-in support or has declared theme support without the paired flag. - ( IS_CORE_THEME || 'object' === typeof THEME_SUPPORT_ARGS ) && ( - -

- { __( 'Your active theme is known to work well in standard mode.', 'amp' ) } -

-
- ) - } { sandboxingLevel && (
@@ -203,33 +172,20 @@ export function TemplateModes( { focusReaderThemes } ) { } : null } - > - { - // Plugin is not configured; active theme has built-in support or has declared theme support with the paired flag. - ( IS_CORE_THEME || ( 'object' === typeof THEME_SUPPORT_ARGS && true === THEME_SUPPORT_ARGS.paired ) ) && ( - -

- { __( 'Your active theme is known to work well in transitional mode.', 'amp' ) } -

-
- ) - } -
+ labelExtra={ getLabelForTemplateMode( TRANSITIONAL ) } + /> - { readerNoticeLarge } - - { READER === themeSupport && ( + labelExtra={ getLabelForTemplateMode( READER ) } + /> + { READER === editedThemeSupport && ( { + let cleanup = () => {}; + + if ( flag && ! delayedFlag ) { + cleanup = setTimeout( () => setDelayedFlag( true ), delay ); + } else if ( ! flag && delayedFlag ) { + setDelayedFlag( false ); + } + + return cleanup; + }, [ flag, delayedFlag, delay ] ); + + return delayedFlag; +} diff --git a/src/Admin/OnboardingWizardSubmenuPage.php b/src/Admin/OnboardingWizardSubmenuPage.php index bbb4a105a09..a78a2815744 100644 --- a/src/Admin/OnboardingWizardSubmenuPage.php +++ b/src/Admin/OnboardingWizardSubmenuPage.php @@ -9,12 +9,13 @@ namespace AmpProject\AmpWP\Admin; use AMP_Options_Manager; +use AMP_Validated_URL_Post_Type; +use AMP_Validation_Manager; use AmpProject\AmpWP\DevTools\UserAccess; use AmpProject\AmpWP\Infrastructure\Delayed; use AmpProject\AmpWP\Infrastructure\Registerable; use AmpProject\AmpWP\Infrastructure\Service; use AmpProject\AmpWP\LoadingError; -use AmpProject\AmpWP\Validation\ScannableURLProvider; /** * AMP setup wizard submenu page class. @@ -69,28 +70,19 @@ final class OnboardingWizardSubmenuPage implements Delayed, Registerable, Servic */ private $loading_error; - /** - * ScannableURLProvider instance. - * - * @var ScannableURLProvider - */ - private $scannable_url_provider; - /** * OnboardingWizardSubmenuPage constructor. * - * @param GoogleFonts $google_fonts An instance of the GoogleFonts service. - * @param ReaderThemes $reader_themes An instance of the ReaderThemes class. - * @param RESTPreloader $rest_preloader An instance of the RESTPreloader class. - * @param LoadingError $loading_error An instance of the LoadingError class. - * @param ScannableURLProvider $scannable_url_provider An instance of the ScannableURLProvider class. + * @param GoogleFonts $google_fonts An instance of the GoogleFonts service. + * @param ReaderThemes $reader_themes An instance of the ReaderThemes class. + * @param RESTPreloader $rest_preloader An instance of the RESTPreloader class. + * @param LoadingError $loading_error An instance of the LoadingError class. */ - public function __construct( GoogleFonts $google_fonts, ReaderThemes $reader_themes, RESTPreloader $rest_preloader, LoadingError $loading_error, ScannableURLProvider $scannable_url_provider ) { - $this->google_fonts = $google_fonts; - $this->reader_themes = $reader_themes; - $this->rest_preloader = $rest_preloader; - $this->loading_error = $loading_error; - $this->scannable_url_provider = $scannable_url_provider; + public function __construct( GoogleFonts $google_fonts, ReaderThemes $reader_themes, RESTPreloader $rest_preloader, LoadingError $loading_error ) { + $this->google_fonts = $google_fonts; + $this->reader_themes = $reader_themes; + $this->rest_preloader = $rest_preloader; + $this->loading_error = $loading_error; } /** @@ -224,7 +216,13 @@ public function enqueue_assets( $hook_suffix ) { $theme = wp_get_theme(); $is_reader_theme = $this->reader_themes->theme_data_exists( get_stylesheet() ); - $amp_settings_link = menu_page_url( AMP_Options_Manager::OPTION_NAME, false ); + $amp_settings_link = menu_page_url( AMP_Options_Manager::OPTION_NAME, false ); + $amp_validated_urls_link = admin_url( + add_query_arg( + [ 'post_type' => AMP_Validated_URL_Post_Type::POST_TYPE_SLUG ], + 'edit.php' + ) + ); $setup_wizard_data = [ 'AMP_OPTIONS_KEY' => AMP_Options_Manager::OPTION_NAME, @@ -247,13 +245,15 @@ public function enqueue_assets( $hook_suffix ) { 'url' => $theme->get( 'ThemeURI' ), ], 'USING_FALLBACK_READER_THEME' => $this->reader_themes->using_fallback_theme(), + 'SCANNABLE_URLS_REST_PATH' => '/amp/v1/scannable-urls', 'SETTINGS_LINK' => $amp_settings_link, 'OPTIONS_REST_PATH' => '/amp/v1/options', - 'PREVIEW_URLS' => $this->get_preview_urls( $this->scannable_url_provider->get_urls() ), 'READER_THEMES_REST_PATH' => '/amp/v1/reader-themes', 'UPDATES_NONCE' => wp_create_nonce( 'updates' ), 'USER_FIELD_DEVELOPER_TOOLS_ENABLED' => UserAccess::USER_FIELD_DEVELOPER_TOOLS_ENABLED, 'USERS_RESOURCE_REST_PATH' => '/wp/v2/users', + 'VALIDATE_NONCE' => AMP_Validation_Manager::get_amp_validate_nonce(), + 'VALIDATED_URLS_LINK' => $amp_validated_urls_link, ]; wp_add_inline_script( @@ -288,7 +288,14 @@ protected function add_preload_rest_paths() { $paths = [ '/amp/v1/options', '/amp/v1/reader-themes', + add_query_arg( + '_fields', + [ 'url', 'amp_url', 'type', 'label' ], + '/amp/v1/scannable-urls' + ), + '/wp/v2/plugins', '/wp/v2/settings', + '/wp/v2/themes', '/wp/v2/users/me', ]; @@ -312,21 +319,4 @@ public function get_close_link() { // Default to the AMP Settings page if a referrer link could not be determined. return menu_page_url( AMP_Options_Manager::OPTION_NAME, false ); } - - /** - * Add AMP URLs to the list of scannable URLs. - * - * @since 2.2 - * - * @param array $scannable_urls Array of scannable URLs. - * - * @return array Preview URLs. - */ - public function get_preview_urls( $scannable_urls ) { - foreach ( $scannable_urls as &$scannable_url ) { - $scannable_url['amp_url'] = amp_add_paired_endpoint( $scannable_url['url'] ); - } - - return $scannable_urls; - } } diff --git a/src/Admin/OptionsMenu.php b/src/Admin/OptionsMenu.php index f22c24c0132..95505dba915 100644 --- a/src/Admin/OptionsMenu.php +++ b/src/Admin/OptionsMenu.php @@ -7,9 +7,9 @@ namespace AmpProject\AmpWP\Admin; -use AMP_Core_Theme_Sanitizer; use AMP_Options_Manager; -use AMP_Theme_Support; +use AMP_Validated_URL_Post_Type; +use AMP_Validation_Manager; use AmpProject\AmpWP\DependencySupport; use AmpProject\AmpWP\DevTools\UserAccess; use AmpProject\AmpWP\Infrastructure\Conditional; @@ -227,6 +227,13 @@ public function enqueue_assets( $hook_suffix ) { $theme = wp_get_theme(); $is_reader_theme = $this->reader_themes->theme_data_exists( get_stylesheet() ); + $amp_validated_urls_link = admin_url( + add_query_arg( + [ 'post_type' => AMP_Validated_URL_Post_Type::POST_TYPE_SLUG ], + 'edit.php' + ) + ); + $js_data = [ 'AMP_QUERY_VAR' => amp_get_slug(), 'CURRENT_THEME' => [ @@ -237,22 +244,17 @@ public function enqueue_assets( $hook_suffix ) { 'url' => $theme->get( 'ThemeURI' ), ], 'HAS_DEPENDENCY_SUPPORT' => $this->dependency_support->has_support(), - 'HOME_URL' => home_url( '/' ), 'OPTIONS_REST_PATH' => '/amp/v1/options', 'READER_THEMES_REST_PATH' => '/amp/v1/reader-themes', - 'IS_CORE_THEME' => in_array( - get_stylesheet(), - AMP_Core_Theme_Sanitizer::get_supported_themes(), - true - ), + 'SCANNABLE_URLS_REST_PATH' => '/amp/v1/scannable-urls', 'LEGACY_THEME_SLUG' => ReaderThemes::DEFAULT_READER_THEME, 'USING_FALLBACK_READER_THEME' => $this->reader_themes->using_fallback_theme(), - 'THEME_SUPPORT_ARGS' => AMP_Theme_Support::get_theme_support_args(), - 'THEME_SUPPORTS_READER_MODE' => AMP_Theme_Support::supports_reader_mode(), 'UPDATES_NONCE' => wp_create_nonce( 'updates' ), 'USER_FIELD_DEVELOPER_TOOLS_ENABLED' => UserAccess::USER_FIELD_DEVELOPER_TOOLS_ENABLED, 'USER_FIELD_REVIEW_PANEL_DISMISSED_FOR_TEMPLATE_MODE' => UserRESTEndpointExtension::USER_FIELD_REVIEW_PANEL_DISMISSED_FOR_TEMPLATE_MODE, 'USERS_RESOURCE_REST_PATH' => '/wp/v2/users', + 'VALIDATE_NONCE' => AMP_Validation_Manager::get_amp_validate_nonce(), + 'VALIDATED_URLS_LINK' => $amp_validated_urls_link, 'HAS_PAGE_CACHING' => $this->site_health->has_page_caching( true ), ]; @@ -309,7 +311,14 @@ protected function add_preload_rest_paths() { $paths = [ '/amp/v1/options', '/amp/v1/reader-themes', + add_query_arg( + '_fields', + [ 'url', 'amp_url', 'type', 'label', 'validation_errors', 'stale' ], + '/amp/v1/scannable-urls' + ), + '/wp/v2/plugins', '/wp/v2/settings', + '/wp/v2/themes', '/wp/v2/users/me', ]; diff --git a/src/AmpWpPlugin.php b/src/AmpWpPlugin.php index 4ca2da42317..7deb307216d 100644 --- a/src/AmpWpPlugin.php +++ b/src/AmpWpPlugin.php @@ -113,6 +113,7 @@ final class AmpWpPlugin extends ServiceBasedPlugin { 'reader_theme_loader' => ReaderThemeLoader::class, 'reader_theme_support_features' => ReaderThemeSupportFeatures::class, 'rest.options_controller' => OptionsRESTController::class, + 'rest.scannable_urls_controller' => Validation\ScannableURLsRestController::class, 'rest.validation_counts_controller' => Validation\ValidationCountsRestController::class, 'sandboxing' => Sandboxing::class, 'save_post_validation_event' => SavePostValidationEvent::class, diff --git a/src/Validation/ScannableURLsRestController.php b/src/Validation/ScannableURLsRestController.php new file mode 100644 index 00000000000..4a26e32ad97 --- /dev/null +++ b/src/Validation/ScannableURLsRestController.php @@ -0,0 +1,232 @@ +namespace = 'amp/v1'; + $this->rest_base = 'scannable-urls'; + $this->scannable_url_provider = $scannable_url_provider; + $this->paired_routing = $paired_routing; + } + + /** + * Registers all routes for the controller. + */ + public function register() { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_items' ], + 'permission_callback' => [ $this, 'get_items_permissions_check' ], + 'args' => [], + ], + 'schema' => [ $this, 'get_public_item_schema' ], + ] + ); + } + + /** + * Checks if a given request has access to get items. + * + * @param WP_REST_Request $request Full details about the request. + * @return true|WP_Error True if the request has read access, WP_Error object otherwise. + */ + public function get_items_permissions_check( $request ) { + if ( ! AMP_Validation_Manager::has_cap() ) { + return new WP_Error( + 'amp_rest_cannot_validate_urls', + __( 'Sorry, you are not allowed to access validation data.', 'amp' ), + [ 'status' => rest_authorization_required_code() ] + ); + } + + return true; + } + + /** + * Retrieves a list of scannable URLs. + * + * Besides the page URL, each item contains a page `type` (e.g. 'home' or + * 'search') and a URL to a corresponding AMP page (`amp_url`). + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + return rest_ensure_response( + array_map( + function ( $item ) use ( $request ) { + return $this->prepare_item_for_response( $item, $request )->get_data(); + }, + $this->scannable_url_provider->get_urls() + ) + ); + } + + /** + * Prepares the scannable URL entry for the REST response. + * + * @param array $item Scannable URL entry. + * @param WP_REST_Request $request Request object. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $item, $request ) { + $item = wp_array_slice_assoc( $item, [ 'url', 'type', 'label' ] ); + + if ( amp_is_canonical() ) { + $item['amp_url'] = $item['url']; + } else { + $item['amp_url'] = $this->paired_routing->add_endpoint( $item['url'] ); + } + + $validated_url_post = AMP_Validated_URL_Post_Type::get_invalid_url_post( $item['url'] ); + if ( $validated_url_post instanceof WP_Post ) { + $item['validation_errors'] = []; + + $data = json_decode( $validated_url_post->post_content, true ); + if ( is_array( $data ) ) { + $item['validation_errors'] = wp_list_pluck( $data, 'data' ); + } + + $item['validated_url_post'] = [ + 'id' => $validated_url_post->ID, + 'edit_link' => get_edit_post_link( $validated_url_post->ID, 'raw' ), + ]; + + $item['stale'] = ( count( AMP_Validated_URL_Post_Type::get_post_staleness( $validated_url_post ) ) > 0 ); + } else { + $item['validation_errors'] = null; + $item['validated_url_post'] = null; + $item['stale'] = null; + } + + return rest_ensure_response( $item ); + } + + /** + * Retrieves the block type' schema, conforming to JSON Schema. + * + * @return array Item schema data. + */ + public function get_item_schema() { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'amp-wp-' . $this->rest_base, + 'type' => 'object', + 'properties' => [ + 'url' => [ + 'description' => __( 'URL', 'amp' ), + 'type' => 'string', + 'format' => 'uri', + 'readonly' => true, + 'context' => [ 'view' ], + ], + 'amp_url' => [ + 'description' => __( 'AMP URL', 'amp' ), + 'type' => 'string', + 'format' => 'uri', + 'readonly' => true, + 'context' => [ 'view' ], + ], + 'type' => [ + 'description' => __( 'Type', 'amp' ), + 'type' => 'string', + 'readonly' => true, + 'context' => [ 'view' ], + ], + 'label' => [ + 'description' => __( 'Label', 'amp' ), + 'type' => 'string', + 'readonly' => true, + 'context' => [ 'view' ], + ], + 'validated_url_post' => [ + 'description' => __( 'Validated URL post if previously scanned.', 'amp' ), + 'type' => [ 'object', 'null' ], + 'properties' => [ + 'id' => [ + 'type' => 'integer', + ], + 'edit_link' => [ + 'type' => 'string', + 'format' => 'uri', + ], + ], + 'readonly' => true, + 'context' => [ 'view' ], + ], + 'validation_errors' => [ + 'description' => __( 'Validation errors for validated URL if previously scanned.', 'amp' ), + 'type' => [ 'array', 'null' ], + 'readonly' => true, + 'context' => [ 'view' ], + ], + 'stale' => [ + 'description' => __( 'Whether the Validated URL post is stale.', 'amp' ), + 'type' => [ 'boolean', 'null' ], + 'readonly' => true, + 'context' => [ 'view' ], + ], + ], + ]; + } +} diff --git a/src/Validation/URLValidationRESTController.php b/src/Validation/URLValidationRESTController.php index 945b198393a..c624dde3acd 100644 --- a/src/Validation/URLValidationRESTController.php +++ b/src/Validation/URLValidationRESTController.php @@ -24,6 +24,8 @@ /** * URLValidationRESTController class. * + * @todo This can now be eliminated in favor of making validate requests to the frontend with `?amp_validate[cache]=true`. + * * @since 2.1 * @internal */ diff --git a/tests/e2e/config/bootstrap.js b/tests/e2e/config/bootstrap.js index 6237d5d1704..a7239b498d9 100644 --- a/tests/e2e/config/bootstrap.js +++ b/tests/e2e/config/bootstrap.js @@ -12,6 +12,7 @@ import { isOfflineMode, setBrowserViewport, trashAllPosts, + visitAdminPage, } from '@wordpress/e2e-test-utils'; /** @@ -213,6 +214,20 @@ async function setupBrowser() { } ); } +/** + * Create test posts so that the WordPress instance has some data. + */ +async function createTestData() { + await visitAdminPage( 'admin.php', 'page=amp-options' ); + await page.waitForSelector( '.amp-settings-nav' ); + await page.evaluate( async () => { + await Promise.all( [ + wp.apiFetch( { path: '/wp/v2/posts', method: 'POST', data: { title: 'Test Post 1', status: 'publish' } } ), + wp.apiFetch( { path: '/wp/v2/posts', method: 'POST', data: { title: 'Test Post 2', status: 'publish' } } ), + ] ); + } ); +} + /** * Before every test suite run, delete all content created by the test. This ensures * other posts/comments/etc. aren't dirtying tests and tests don't depend on @@ -225,6 +240,7 @@ beforeAll( async () => { observeConsoleLogging(); await setupBrowser(); await trashAllPosts(); + await createTestData(); await cleanUpSettings(); await page.setDefaultNavigationTimeout( 10000 ); await page.setDefaultTimeout( 10000 ); diff --git a/tests/e2e/specs/admin/amp-options.js b/tests/e2e/specs/admin/amp-options.js index b2b93e8aa55..e4ec32ae16f 100644 --- a/tests/e2e/specs/admin/amp-options.js +++ b/tests/e2e/specs/admin/amp-options.js @@ -7,6 +7,7 @@ import { visitAdminPage, activateTheme, installTheme } from '@wordpress/e2e-test * Internal dependencies */ import { completeWizard, cleanUpSettings, clickMode, scrollToElement } from '../../utils/onboarding-wizard-utils'; +import { setTemplateMode } from '../../utils/amp-settings-utils'; describe( 'AMP settings screen newly activated', () => { beforeEach( async () => { @@ -55,7 +56,7 @@ describe( 'Settings screen when reader theme is active theme', () => { await clickMode( 'reader' ); await scrollToElement( { selector: '#template-mode-reader-container .components-panel__body-toggle', click: true } ); - await scrollToElement( { selector: '#reader-themes .amp-notice__body' } ); + await scrollToElement( { selector: '#reader-themes .components-panel__body-toggle', click: true } ); await expect( page ).toMatchElement( '.amp-notice__body', { text: /^Your active theme/ } ); await activateTheme( 'twentytwenty' ); @@ -63,18 +64,49 @@ describe( 'Settings screen when reader theme is active theme', () => { } ); describe( 'Mode info notices', () => { - it( 'shows expected notices for theme with built-in support', async () => { - await activateTheme( 'twentytwenty' ); + const timeout = 10000; + + beforeEach( async () => { + await cleanUpSettings(); await visitAdminPage( 'admin.php', 'page=amp-options' ); + } ); - await expect( page ).toMatchElement( '#template-mode-standard-container .amp-notice--info' ); - await expect( page ).toMatchElement( '#template-mode-transitional-container .amp-notice--info' ); + afterEach( async () => { + await cleanUpSettings(); + } ); - await clickMode( 'reader' ); + it( 'show information in the Template Mode section if site scan results are stale', async () => { + // Trigger a site scan. + await page.waitForSelector( '#site-scan' ); + + const isPanelCollapsed = await page.$eval( '#site-scan .components-panel__body-toggle', ( el ) => el.ariaExpanded === 'false' ); + if ( isPanelCollapsed ) { + await scrollToElement( { selector: '#site-scan .components-panel__body-toggle', click: true } ); + } + + await scrollToElement( { selector: '#site-scan .settings-site-scan__footer .is-primary', click: true } ); + await expect( page ).toMatchElement( '#site-scan .settings-site-scan__footer .is-primary', { text: 'Rescan Site', timeout } ); + + await scrollToElement( { selector: '#template-modes' } ); + + // Confirm there is no notice about stale results. + const noticeXpath = '//*[@id="template-modes"]/*[contains(@class, "amp-notice--info")]/*[contains(text(), "Site Scan results are stale")]'; + + const noticeBefore = await page.$x( noticeXpath ); + expect( noticeBefore ).toHaveLength( 0 ); - await expect( page ).toMatchElement( '#template-mode-reader-container .amp-notice--warning' ); + // Change template mode to make the scan results stale. + await setTemplateMode( 'transitional' ); + + await page.waitForSelector( '.settings-site-scan__footer .is-primary', { timeout } ); + + await scrollToElement( { selector: '#template-modes' } ); + + const noticeAfter = await page.$x( noticeXpath ); + expect( noticeAfter ).toHaveLength( 1 ); } ); + it.todo( 'shows expected notices for theme with built-in support' ); it.todo( 'shows expected notices for theme with paired flag false' ); it.todo( 'shows expected notices for theme that only supports reader mode' ); } ); @@ -135,103 +167,3 @@ describe( 'Saving', () => { await testSave(); } ); } ); - -describe( 'AMP settings screen Review panel', () => { - let testPost; - - beforeAll( async () => { - await visitAdminPage( 'admin.php', 'page=amp-options' ); - - testPost = await page.evaluate( () => wp.apiFetch( { - path: '/wp/v2/posts', - method: 'POST', - data: { title: 'Test Post', status: 'publish' }, - } ) ); - } ); - - afterAll( async () => { - await visitAdminPage( 'admin.php', 'page=amp-options' ); - - if ( testPost?.id ) { - await page.evaluate( ( id ) => wp.apiFetch( { - path: `/wp/v2/posts/${ id }`, - method: 'DELETE', - data: { force: true }, - } ), testPost.id ); - } - } ); - - beforeEach( async () => { - await visitAdminPage( 'admin.php', 'page=amp-options' ); - } ); - - afterEach( async () => { - await cleanUpSettings(); - } ); - - async function changeAndSaveTemplateMode( mode ) { - await clickMode( mode ); - - await Promise.all( [ - scrollToElement( { selector: '.amp-settings-nav button[type="submit"]', click: true } ), - page.waitForResponse( ( response ) => response.url().includes( '/wp-json/amp/v1/options' ), { timeout: 10000 } ), - ] ); - } - - it( 'is present on the page', async () => { - await page.waitForSelector( '.settings-site-review' ); - await expect( page ).toMatchElement( 'h2', { text: 'Review' } ); - await expect( page ).toMatchElement( 'h3', { text: 'Need help?' } ); - await expect( page ).toMatchElement( '.settings-site-review__list li', { text: /support forums/i } ); - await expect( page ).toMatchElement( '.settings-site-review__list li', { text: /different template mode/i } ); - await expect( page ).toMatchElement( '.settings-site-review__list li', { text: /how the AMP plugin works/i } ); - } ); - - it( 'button redirects to an AMP page in transitional mode', async () => { - await changeAndSaveTemplateMode( 'transitional' ); - - await expect( page ).toClick( 'a', { text: 'Browse Site' } ); - await page.waitForNavigation(); - - await page.waitForSelector( 'html[amp]' ); - await expect( page ).toMatchElement( 'html[amp]' ); - } ); - - it( 'button redirects to an AMP page in reader mode', async () => { - await expect( page ).toClick( 'a', { text: 'Browse Site' } ); - await page.waitForNavigation(); - - await page.waitForSelector( 'html[amp]' ); - await expect( page ).toMatchElement( 'html[amp]' ); - } ); - - it( 'button redirects to an AMP page in standard mode', async () => { - await changeAndSaveTemplateMode( 'standard' ); - - await expect( page ).toClick( 'a', { text: 'Browse Site' } ); - await page.waitForNavigation(); - - await page.waitForSelector( 'html[amp]' ); - await expect( page ).toMatchElement( 'html[amp]' ); - } ); - - it( 'can be dismissed and shows up again only after a template mode change', async () => { - await page.waitForSelector( '.settings-site-review' ); - await expect( page ).toMatchElement( 'button', { text: 'Dismiss' } ); - await expect( page ).toClick( 'button', { text: 'Dismiss' } ); - - // Give the Review panel some time disappear. - await page.waitForTimeout( 100 ); - await expect( page ).not.toMatchElement( '.settings-site-review' ); - - // There should be no Review panel after page reload. - await visitAdminPage( 'admin.php', 'page=amp-options' ); - await page.waitForSelector( '#amp-settings-root' ); - await expect( page ).not.toMatchElement( '.settings-site-review' ); - - await changeAndSaveTemplateMode( 'standard' ); - - await page.waitForSelector( '.settings-site-review' ); - await expect( page ).toMatchElement( 'h2', { text: 'Review' } ); - } ); -} ); diff --git a/tests/e2e/specs/admin/site-review-panel.js b/tests/e2e/specs/admin/site-review-panel.js new file mode 100644 index 00000000000..fdcb2cfeb25 --- /dev/null +++ b/tests/e2e/specs/admin/site-review-panel.js @@ -0,0 +1,93 @@ +/** + * WordPress dependencies + */ +import { visitAdminPage } from '@wordpress/e2e-test-utils'; + +/** + * Internal dependencies + */ +import { cleanUpSettings, scrollToElement } from '../../utils/onboarding-wizard-utils'; +import { setTemplateMode } from '../../utils/amp-settings-utils'; + +describe( 'AMP settings screen Review panel', () => { + const timeout = 10000; + + beforeAll( async () => { + await cleanUpSettings(); + } ); + + beforeEach( async () => { + await visitAdminPage( 'admin.php', 'page=amp-options' ); + } ); + + afterEach( async () => { + await cleanUpSettings(); + } ); + + it( 'is present on the page', async () => { + await page.waitForSelector( '.settings-site-review' ); + await expect( page ).toMatchElement( 'h2', { text: 'Review' } ); + await expect( page ).toMatchElement( 'h3', { text: 'Need help?' } ); + await expect( page ).toMatchElement( '.settings-site-review__list li', { text: /support forums/i } ); + await expect( page ).toMatchElement( '.settings-site-review__list li', { text: /different template mode/i } ); + await expect( page ).toMatchElement( '.settings-site-review__list li', { text: /how the AMP plugin works/i } ); + } ); + + it( 'button redirects to an AMP page in transitional mode', async () => { + await setTemplateMode( 'transitional' ); + + await Promise.all( [ + scrollToElement( { selector: '.settings-site-review__actions .is-primary', click: true, timeout } ), + page.waitForNavigation( { timeout } ), + ] ); + + const htmlAttributes = await page.$eval( 'html', ( el ) => el.getAttributeNames() ); + await expect( htmlAttributes ).toContain( 'amp' ); + } ); + + it( 'button redirects to an AMP page in reader mode', async () => { + await Promise.all( [ + scrollToElement( { selector: '.settings-site-review__actions .is-primary', click: true, timeout } ), + page.waitForNavigation( { timeout } ), + ] ); + + const htmlAttributes = await page.$eval( 'html', ( el ) => el.getAttributeNames() ); + await expect( htmlAttributes ).toContain( 'amp' ); + } ); + + it( 'button redirects to an AMP page in standard mode', async () => { + await setTemplateMode( 'standard' ); + + await Promise.all( [ + scrollToElement( { selector: '.settings-site-review__actions .is-primary', click: true, timeout } ), + page.waitForNavigation( { timeout } ), + ] ); + + const htmlAttributes = await page.$eval( 'html', ( el ) => el.getAttributeNames() ); + await expect( htmlAttributes ).toContain( 'amp' ); + } ); + + it( 'can be dismissed and shows up again only after a template mode change', async () => { + const dismissButtonSelector = '.settings-site-review__actions button.is-link'; + + await page.waitForSelector( dismissButtonSelector ); + + // Click the "Dismiss" button and wait for the HTTP response. + await Promise.all( [ + scrollToElement( { selector: dismissButtonSelector, click: true } ), + page.waitForResponse( ( response ) => response.url().includes( '/wp/v2/users/me' ) ), + ] ); + + await expect( page ).not.toMatchElement( '.settings-site-review' ); + + // There should be no Review panel after page reload. + await visitAdminPage( 'admin.php', 'page=amp-options' ); + await page.waitForSelector( '#amp-settings-root' ); + await expect( page ).not.toMatchElement( '.settings-site-review' ); + + await setTemplateMode( 'standard' ); + + await page.waitForSelector( '.settings-site-review' ); + await expect( page ).toMatchElement( 'h2', { text: 'Review' } ); + } ); +} ); diff --git a/tests/e2e/specs/admin/site-scan-panel.js b/tests/e2e/specs/admin/site-scan-panel.js new file mode 100644 index 00000000000..596edd07a1a --- /dev/null +++ b/tests/e2e/specs/admin/site-scan-panel.js @@ -0,0 +1,162 @@ +/** + * WordPress dependencies + */ +import { + activateTheme, + deleteTheme, + installTheme, + visitAdminPage, +} from '@wordpress/e2e-test-utils'; + +/** + * Internal dependencies + */ +import { + activatePlugin, + deactivatePlugin, + installPlugin, + setTemplateMode, + uninstallPlugin, +} from '../../utils/amp-settings-utils'; +import { cleanUpSettings, scrollToElement } from '../../utils/onboarding-wizard-utils'; +import { testSiteScanning } from '../../utils/site-scan-utils'; + +describe( 'AMP settings screen Site Scan panel', () => { + const timeout = 10000; + + beforeAll( async () => { + await installTheme( 'hestia' ); + await installPlugin( 'autoptimize' ); + + await cleanUpSettings(); + + await visitAdminPage( 'admin.php', 'page=amp-options' ); + await setTemplateMode( 'transitional' ); + } ); + + afterAll( async () => { + await deleteTheme( 'hestia', { newThemeSlug: 'twentytwenty' } ); + await uninstallPlugin( 'autoptimize' ); + + await cleanUpSettings(); + } ); + + async function triggerSiteRescan() { + await expect( page ).toMatchElement( '#site-scan h2', { text: 'Site Scan' } ); + + const isPanelCollapsed = await page.$eval( '#site-scan .components-panel__body-toggle', ( el ) => el.ariaExpanded === 'false' ); + if ( isPanelCollapsed ) { + await scrollToElement( { selector: '#site-scan .components-panel__body-toggle', click: true } ); + } + + // Start the site scan. + await Promise.all( [ + scrollToElement( { selector: '.settings-site-scan__footer button.is-primary', click: true } ), + testSiteScanning( { + statusElementClassName: 'settings-site-scan__status', + isAmpFirst: false, + } ), + ] ); + + await expect( page ).toMatchElement( '.settings-site-scan__footer .is-primary', { text: 'Rescan Site', timeout } ); + await expect( page ).toMatchElement( '.settings-site-scan__footer .is-link', { text: 'Browse Site' } ); + } + + it( 'does not list issues if an AMP compatible theme is activated', async () => { + await visitAdminPage( 'admin.php', 'page=amp-options' ); + + await triggerSiteRescan(); + + await expect( page ).toMatchElement( '.settings-site-scan .amp-notice--success', { timeout } ); + + await expect( page ).not.toMatchElement( '.site-scan-results--themes' ); + await expect( page ).not.toMatchElement( '.site-scan-results--plugins' ); + + // Reload the page and confirm that the panel is collapsed. + await page.reload(); + await expect( page ).toMatchElement( '#site-scan .components-panel__body-toggle[aria-expanded="false"]' ); + + // Switch template mode to check if the scan results are marked as stale and the panel is initially expanded. + await setTemplateMode( 'standard' ); + + await expect( page ).toMatchElement( '#site-scan .components-panel__body-toggle[aria-expanded="true"]', { timeout } ); + await expect( page ).toMatchElement( '.settings-site-scan .amp-notice--info', { text: /^Stale results/ } ); + } ); + + it( 'lists Hestia theme as causing AMP incompatibility', async () => { + await activateTheme( 'hestia' ); + + await visitAdminPage( 'admin.php', 'page=amp-options' ); + + await triggerSiteRescan(); + + await expect( page ).toMatchElement( '.site-scan-results--themes .site-scan-results__heading[data-badge-content="1"]', { text: /^Themes/, timeout } ); + await expect( page ).toMatchElement( '.site-scan-results--themes .site-scan-results__source-name', { text: /Hestia/ } ); + } ); + + it( 'lists Autoptimize plugin as causing AMP incompatibility', async () => { + await activateTheme( 'twentytwenty' ); + await activatePlugin( 'autoptimize' ); + + await visitAdminPage( 'admin.php', 'page=amp-options' ); + + await triggerSiteRescan(); + + await expect( page ).toMatchElement( '.site-scan-results--plugins .site-scan-results__heading[data-badge-content="1"]', { text: /^Plugins/, timeout } ); + await expect( page ).toMatchElement( '.site-scan-results--plugins .site-scan-results__source-name', { text: /Autoptimize/ } ); + + await expect( page ).not.toMatchElement( '.site-scan-results--themes' ); + + await deactivatePlugin( 'autoptimize' ); + } ); + + it( 'lists Hestia theme and Autoptimize plugin for causing AMP incompatibilities', async () => { + await activateTheme( 'hestia' ); + await activatePlugin( 'autoptimize' ); + + await visitAdminPage( 'admin.php', 'page=amp-options' ); + + await triggerSiteRescan(); + + await expect( page ).toMatchElement( '.site-scan-results--themes', { timeout } ); + await expect( page ).toMatchElement( '.site-scan-results--plugins' ); + + const totalIssuesCount = await page.$$eval( '.site-scan-results__source', ( sources ) => sources.length ); + expect( totalIssuesCount ).toBe( 2 ); + + await expect( page ).toMatchElement( '.site-scan-results--themes .site-scan-results__source-name', { text: /Hestia/ } ); + await expect( page ).toMatchElement( '.site-scan-results--plugins .site-scan-results__source-name', { text: /Autoptimize/ } ); + + await deactivatePlugin( 'autoptimize' ); + } ); + + it( 'displays a notice if a plugin has been deactivated or removed', async () => { + await activateTheme( 'twentytwenty' ); + await activatePlugin( 'autoptimize' ); + + await visitAdminPage( 'admin.php', 'page=amp-options' ); + + await triggerSiteRescan(); + + await expect( page ).toMatchElement( '.site-scan-results--plugins .site-scan-results__source-name', { text: /Autoptimize/, timeout } ); + + // Deactivate the plugin and test. + await deactivatePlugin( 'autoptimize' ); + + await visitAdminPage( 'admin.php', 'page=amp-options' ); + + await expect( page ).toMatchElement( '.site-scan-results--plugins .site-scan-results__source-name', { text: /Autoptimize/ } ); + await expect( page ).toMatchElement( '.site-scan-results--plugins .site-scan-results__source-notice', { text: /This plugin has been deactivated since last site scan./ } ); + + // Uninstall the plugin and test. + await uninstallPlugin( 'autoptimize' ); + + await visitAdminPage( 'admin.php', 'page=amp-options' ); + + await expect( page ).toMatchElement( '.site-scan-results--plugins .site-scan-results__source-slug', { text: /autoptimize/ } ); + await expect( page ).toMatchElement( '.site-scan-results--plugins .site-scan-results__source-notice', { text: /This plugin has been uninstalled since last site scan./ } ); + + // Clean up. + await installPlugin( 'autoptimize' ); + } ); +} ); diff --git a/tests/e2e/specs/amp-onboarding/done.js b/tests/e2e/specs/amp-onboarding/done.js index c80fb1d2d0d..be424688f6b 100644 --- a/tests/e2e/specs/amp-onboarding/done.js +++ b/tests/e2e/specs/amp-onboarding/done.js @@ -1,8 +1,3 @@ -/** - * WordPress dependencies - */ -import { trashAllPosts, visitAdminPage } from '@wordpress/e2e-test-utils'; - /** * Internal dependencies */ @@ -34,43 +29,6 @@ async function testCommonDoneStepElements() { } describe( 'Done', () => { - let testPost; - let testPage; - - beforeAll( async () => { - await visitAdminPage( 'admin.php', 'page=amp-options' ); - - testPost = await page.evaluate( () => wp.apiFetch( { - path: '/wp/v2/posts', - method: 'POST', - data: { title: 'Test Post', status: 'publish' }, - } ) ); - testPage = await page.evaluate( () => wp.apiFetch( { - path: '/wp/v2/pages', - method: 'POST', - data: { title: 'Test Page', status: 'publish' }, - } ) ); - } ); - - afterAll( async () => { - await visitAdminPage( 'admin.php', 'page=amp-options' ); - - if ( testPost?.id ) { - await page.evaluate( ( id ) => wp.apiFetch( { - path: `/wp/v2/posts/${ id }`, - method: 'DELETE', - data: { force: true }, - } ), testPost.id ); - } - if ( testPage?.id ) { - await page.evaluate( ( id ) => wp.apiFetch( { - path: `/wp/v2/pages/${ id }`, - method: 'DELETE', - data: { force: true }, - } ), testPage.id ); - } - } ); - afterEach( async () => { await cleanUpSettings(); } ); @@ -119,14 +77,4 @@ describe( 'Done', () => { await expect( page ).toMatchElement( 'p', { text: /Reader mode/i } ); await expect( page ).toMatchElement( '.done__preview-container input[type="checkbox"]' ); } ); - - it( 'does not render site preview in reader mode if there are no posts and pages', async () => { - await trashAllPosts(); - await trashAllPosts( 'page' ); - - await moveToDoneScreen( { mode: 'reader' } ); - - await expect( page ).toMatchElement( 'h1', { text: 'Done' } ); - await expect( page ).not.toMatchElement( '.done__preview-iframe' ); - } ); } ); diff --git a/tests/e2e/specs/amp-onboarding/reader-themes.js b/tests/e2e/specs/amp-onboarding/reader-themes.js index a56f1a9291a..64245f8f3bc 100644 --- a/tests/e2e/specs/amp-onboarding/reader-themes.js +++ b/tests/e2e/specs/amp-onboarding/reader-themes.js @@ -10,7 +10,7 @@ describe( 'Reader themes', () => { it( 'shows the correct active stepper item', async () => { const itemCount = await page.$$eval( '.amp-stepper__item', ( els ) => els.length ); - expect( itemCount ).toBe( 5 ); + expect( itemCount ).toBe( 6 ); await expect( page ).toMatchElement( '.amp-stepper__item--active', { text: 'Theme Selection' } ); } ); @@ -20,8 +20,8 @@ describe( 'Reader themes', () => { expect( itemCount ).toBe( 11 ); await expect( page ).not.toMatchElement( 'input[type="radio"]:checked' ); - testNextButton( { text: 'Next', disabled: true } ); - testPreviousButton( { text: 'Previous' } ); + await testNextButton( { text: 'Next', disabled: true } ); + await testPreviousButton( { text: 'Previous' } ); } ); it( 'should allow different themes to be selected', async () => { @@ -34,7 +34,7 @@ describe( 'Reader themes', () => { await selectReaderTheme( 'twentysixteen' ); await expect( page ).toMatchElement( '.selectable--selected h4', { text: 'Twenty Sixteen' } ); - testNextButton( { text: 'Next' } ); + await testNextButton( { text: 'Next' } ); } ); } ); diff --git a/tests/e2e/specs/amp-onboarding/site-scan.js b/tests/e2e/specs/amp-onboarding/site-scan.js new file mode 100644 index 00000000000..3a54563c4b0 --- /dev/null +++ b/tests/e2e/specs/amp-onboarding/site-scan.js @@ -0,0 +1,87 @@ +/** + * WordPress dependencies + */ +import { + activateTheme, + deleteTheme, + installTheme, +} from '@wordpress/e2e-test-utils'; + +/** + * Internal dependencies + */ +import { + moveToSiteScanScreen, + testNextButton, + testPreviousButton, +} from '../../utils/onboarding-wizard-utils'; +import { testSiteScanning } from '../../utils/site-scan-utils'; +import { + activatePlugin, + deactivatePlugin, + installPlugin, + uninstallPlugin, +} from '../../utils/amp-settings-utils'; + +describe( 'Onboarding Wizard Site Scan Step', () => { + beforeAll( async () => { + await installTheme( 'hestia' ); + await installPlugin( 'autoptimize' ); + } ); + + afterAll( async () => { + await deleteTheme( 'hestia', { newThemeSlug: 'twentytwenty' } ); + await uninstallPlugin( 'autoptimize' ); + } ); + + it( 'should start a site scan immediately', async () => { + await moveToSiteScanScreen( { technical: true } ); + + await Promise.all( [ + expect( page ).toMatchElement( '.amp-onboarding-wizard-panel h1', { text: 'Site Scan' } ), + expect( page ).toMatchElement( '.site-scan__heading', { text: 'Please wait a minute' } ), + testNextButton( { text: 'Next', disabled: true } ), + testPreviousButton( { text: 'Previous' } ), + testSiteScanning( { + statusElementClassName: 'site-scan__status', + isAmpFirst: true, + } ), + ] ); + + await expect( page ).toMatchElement( '.site-scan__heading', { text: 'Scan complete', timeout: 10000 } ); + await expect( page ).toMatchElement( '.site-scan__section p', { text: /Site scan found no issues/ } ); + + await testNextButton( { text: 'Next' } ); + await testPreviousButton( { text: 'Previous' } ); + } ); + + it( 'should list out plugin and theme issues after the scan', async () => { + await activateTheme( 'hestia' ); + await activatePlugin( 'autoptimize' ); + + await moveToSiteScanScreen( { technical: true } ); + + await testSiteScanning( { + statusElementClassName: 'site-scan__status', + isAmpFirst: true, + } ); + + await expect( page ).toMatchElement( '.site-scan__heading', { text: 'Scan complete', timeout: 10000 } ); + await expect( page ).toMatchElement( '.site-scan__section p', { text: /Site scan found issues/ } ); + + await expect( page ).toMatchElement( '.site-scan-results--themes' ); + await expect( page ).toMatchElement( '.site-scan-results--plugins' ); + + const totalIssuesCount = await page.$$eval( '.site-scan-results__source', ( sources ) => sources.length ); + expect( totalIssuesCount ).toBe( 2 ); + + await expect( page ).toMatchElement( '.site-scan-results--themes .site-scan-results__source-name', { text: /Hestia/ } ); + await expect( page ).toMatchElement( '.site-scan-results--plugins .site-scan-results__source-name', { text: /Autoptimize/ } ); + + await testNextButton( { text: 'Next' } ); + await testPreviousButton( { text: 'Previous' } ); + + await deactivatePlugin( 'autoptimize' ); + await activateTheme( 'twentytwenty' ); + } ); +} ); diff --git a/tests/e2e/specs/amp-onboarding/technical-background.js b/tests/e2e/specs/amp-onboarding/technical-background.js index 6115b0d7869..a842a9c04a2 100644 --- a/tests/e2e/specs/amp-onboarding/technical-background.js +++ b/tests/e2e/specs/amp-onboarding/technical-background.js @@ -11,8 +11,8 @@ describe( 'Technical background', () => { await expect( page ).toMatchElement( 'p', { text: /^To recommend/ } ); - testNextButton( { text: 'Next', disabled: true } ); - testPreviousButton( { text: 'Previous' } ); + await testNextButton( { text: 'Next', disabled: true } ); + await testPreviousButton( { text: 'Previous' } ); } ); it( 'should show two options, none checked', async () => { @@ -32,6 +32,6 @@ describe( 'Technical background', () => { await expect( page ).toClick( 'label', { text: /Non-technical/ } ); await expect( page ).toMatchElement( '.selectable--selected h2', { text: 'Non-technical or wanting a simpler setup' } ); - testNextButton( { text: 'Next', disabled: false } ); + await testNextButton( { text: 'Next', disabled: false } ); } ); } ); diff --git a/tests/e2e/specs/amp-onboarding/template-mode.js b/tests/e2e/specs/amp-onboarding/template-mode.js index 796d038f41c..3a06c06ab67 100644 --- a/tests/e2e/specs/amp-onboarding/template-mode.js +++ b/tests/e2e/specs/amp-onboarding/template-mode.js @@ -26,8 +26,8 @@ describe( 'Template mode', () => { await expect( page ).not.toMatchElement( 'input[type="radio"]:checked' ); - testNextButton( { text: 'Next', disabled: true } ); - testPreviousButton( { text: 'Previous' } ); + await testNextButton( { text: 'Next', disabled: true } ); + await testPreviousButton( { text: 'Previous' } ); } ); it( 'should allow options to be selected', async () => { @@ -40,7 +40,7 @@ describe( 'Template mode', () => { await clickMode( 'reader' ); await expect( page ).toMatchElement( '.selectable--selected h2', { text: 'Reader' } ); - testNextButton( { text: 'Next' } ); + await testNextButton( { text: 'Next' } ); } ); } ); @@ -49,38 +49,32 @@ describe( 'Template mode recommendations with reader theme active', () => { await activateTheme( 'twentytwenty' ); } ); - it.each( - [ 'technical', 'nontechnical' ], - )( 'makes correct recommendations when user is not %s and the current theme is a reader theme', async ( technical ) => { - await moveToTemplateModeScreen( { technical: technical === 'technical' } ); + it( 'makes correct recommendations when user is not technical and the current theme is a reader theme', async () => { + await moveToTemplateModeScreen( { technical: false } ); - await expect( page ).toMatchElement( '#template-mode-standard-container .amp-notice--info' ); - await expect( page ).toMatchElement( '#template-mode-transitional-container .amp-notice--success' ); - await expect( page ).toMatchElement( '#template-mode-reader-container .amp-notice--success' ); - } ); -} ); + // The Reader option should be collapsed. + await expect( page ).toMatchElement( '#template-mode-reader-container .components-panel__body-title button[aria-expanded="false"]' ); -describe( 'Stepper item modifications', () => { - beforeEach( async () => { - await moveToTemplateModeScreen( { technical: 'technical' } ); - } ); - - it( 'adds the "Theme Selection" page when reader mode is selected', async () => { - await clickMode( 'reader' ); + // The Transitional and Standard modes should be expanded and should contain a "Recommended" string. + await expect( page ).toMatchElement( '#template-mode-transitional-container .components-panel__body-title button[aria-expanded="true"]' ); + const transitionalCopy = await page.$eval( '#template-mode-transitional-container .amp-drawer__panel-body', ( el ) => el.innerText ); + expect( transitionalCopy ).toContain( 'Recommended' ); - const itemCount = await page.$$eval( '.amp-stepper__item', ( els ) => els.length ); - expect( itemCount ).toBe( 5 ); - - await expect( page ).toMatchElement( '.amp-stepper__item-title', { text: 'Theme Selection' } ); + await expect( page ).toMatchElement( '#template-mode-standard-container .components-panel__body-title button[aria-expanded="true"]' ); + const standardCopy = await page.$eval( '#template-mode-standard-container .amp-drawer__panel-body', ( el ) => el.innerText ); + expect( standardCopy ).toContain( 'Recommended' ); } ); - it( 'removes the "Theme Selection" page when reader mode is not selected', async () => { - await clickMode( 'transitional' ); + it( 'makes correct recommendations when user is technical and the current theme is a reader theme', async () => { + await moveToTemplateModeScreen( { technical: true } ); - const itemCount = await page.$$eval( '.amp-stepper__item', ( els ) => els.length ); - expect( itemCount ).toBe( 4 ); + // The Reader and Transitional options should be collapsed. + await expect( page ).toMatchElement( '#template-mode-reader-container .components-panel__body-title button[aria-expanded="false"]' ); + await expect( page ).toMatchElement( '#template-mode-transitional-container .components-panel__body-title button[aria-expanded="false"]' ); - await expect( page ).not.toMatchElement( '.amp-stepper__item-title', { text: 'Theme Selection' } ); + // The Standard mode should be expanded and should contain a success notice. + await expect( page ).toMatchElement( '#template-mode-standard-container .components-panel__body-title button[aria-expanded="true"]' ); + await expect( page ).toMatchElement( '#template-mode-standard-container .amp-notice--success' ); } ); } ); @@ -98,8 +92,48 @@ describe( 'Template mode recommendations with non-reader-theme active', () => { it( 'makes correct recommendations when user is not technical and the current theme is not a reader theme', async () => { await moveToTemplateModeScreen( { technical: false } ); - await expect( page ).toMatchElement( '#template-mode-standard-container .amp-notice--info' ); - await expect( page ).toMatchElement( '#template-mode-transitional-container .amp-notice--info' ); + // The Reader mode should be recommended. + await expect( page ).toMatchElement( '#template-mode-reader-container .components-panel__body-title button[aria-expanded="true"]' ); await expect( page ).toMatchElement( '#template-mode-reader-container .amp-notice--success' ); + + // The Standard and Transitional options should be collapsed. + await expect( page ).toMatchElement( '#template-mode-standard-container .components-panel__body-title button[aria-expanded="false"]' ); + await expect( page ).toMatchElement( '#template-mode-transitional-container .components-panel__body-title button[aria-expanded="false"]' ); + } ); + + it( 'makes correct recommendations when user is technical and the current theme is not a reader theme', async () => { + await moveToTemplateModeScreen( { technical: true } ); + + // The Standard mode should be recommended. + await expect( page ).toMatchElement( '#template-mode-standard-container .components-panel__body-title button[aria-expanded="true"]' ); + await expect( page ).toMatchElement( '#template-mode-standard-container .amp-notice--success' ); + + // The Reader and Transitional options should be collapsed. + await expect( page ).toMatchElement( '#template-mode-reader-container .components-panel__body-title button[aria-expanded="false"]' ); + await expect( page ).toMatchElement( '#template-mode-transitional-container .components-panel__body-title button[aria-expanded="false"]' ); + } ); +} ); + +describe( 'Stepper item modifications', () => { + beforeEach( async () => { + await moveToTemplateModeScreen( { technical: 'technical' } ); + } ); + + it( 'adds the "Theme Selection" page when reader mode is selected', async () => { + await clickMode( 'reader' ); + + const itemCount = await page.$$eval( '.amp-stepper__item', ( els ) => els.length ); + expect( itemCount ).toBe( 6 ); + + await expect( page ).toMatchElement( '.amp-stepper__item-title', { text: 'Theme Selection' } ); + } ); + + it( 'removes the "Theme Selection" page when reader mode is not selected', async () => { + await clickMode( 'transitional' ); + + const itemCount = await page.$$eval( '.amp-stepper__item', ( els ) => els.length ); + expect( itemCount ).toBe( 5 ); + + await expect( page ).not.toMatchElement( '.amp-stepper__item-title', { text: 'Theme Selection' } ); } ); } ); diff --git a/tests/e2e/specs/amp-onboarding/transitional-recommendation.js b/tests/e2e/specs/amp-onboarding/transitional-recommendation.js index 282153b18bc..70320866ebd 100644 --- a/tests/e2e/specs/amp-onboarding/transitional-recommendation.js +++ b/tests/e2e/specs/amp-onboarding/transitional-recommendation.js @@ -1,13 +1,10 @@ /** * Internal dependencies */ -import { moveToReaderThemesScreen, moveToTemplateModeScreen, moveToDoneScreen } from '../../utils/onboarding-wizard-utils'; +import { moveToReaderThemesScreen, moveToDoneScreen } from '../../utils/onboarding-wizard-utils'; /** - * When a site has a Reader theme already set as the active theme (e.g. Twenty Twenty), when the user expresses they - * are non-technical then both the Reader mode and the Transitional mode should show as ✅ recommended options. - * - * Additionally, when selecting Reader mode, the list of themes should no longer omit the active theme from the list. + * When selecting Reader mode, the list of themes should no longer omit the active theme from the list. * Instead, if the user selects the active theme to be the Reader theme, then the template mode should be automatically * switched from reader to transitional, and a notice can appear on the summary screen to make them aware of this. * @@ -16,13 +13,6 @@ import { moveToReaderThemesScreen, moveToTemplateModeScreen, moveToDoneScreen } * @see https://github.com/ampproject/amp-wp/issues/4975 */ describe( 'Current active theme is reader theme and user is nontechnical', () => { - it( 'correctly recommends transitional when the user is nontechnical and the active theme is a reader theme', async () => { - await moveToTemplateModeScreen( { technical: false } ); - - await expect( '.amp-notice--info' ).countToBe( 1 ); // Standard. - await expect( '.amp-notice--success' ).countToBe( 2 ); // Reader and transitional. - } ); - it( 'includes active theme in reader theme list', async () => { await moveToReaderThemesScreen( { technical: false } ); @@ -33,10 +23,7 @@ describe( 'Current active theme is reader theme and user is nontechnical', () => await moveToDoneScreen( { technical: false, readerTheme: 'twentytwenty', mode: 'reader' } ); const stepperItemCount = await page.$$eval( '.amp-stepper__item', ( els ) => els.length ); - expect( stepperItemCount ).toBe( 4 ); - - // Wait for the settings to get saved. - await page.waitForTimeout( 1000 ); + expect( stepperItemCount ).toBe( 5 ); await expect( page ).toMatchElement( 'p', { text: /transitional mode/i } ); await expect( page ).toMatchElement( '.amp-notice--info', { text: /switched to Transitional/i } ); diff --git a/tests/e2e/specs/amp-onboarding/welcome.js b/tests/e2e/specs/amp-onboarding/welcome.js index bf865610d10..f62c8fc2e0e 100644 --- a/tests/e2e/specs/amp-onboarding/welcome.js +++ b/tests/e2e/specs/amp-onboarding/welcome.js @@ -17,7 +17,7 @@ describe( 'welcome', () => { it( 'should contain content', async () => { await expect( page ).toMatchElement( '.welcome' ); - testPreviousButton( { exists: false } ); - testNextButton( { text: 'Next' } ); + await testPreviousButton( { exists: false } ); + await testNextButton( { text: 'Next' } ); } ); } ); diff --git a/tests/e2e/utils/amp-settings-utils.js b/tests/e2e/utils/amp-settings-utils.js new file mode 100644 index 00000000000..67b61a57adb --- /dev/null +++ b/tests/e2e/utils/amp-settings-utils.js @@ -0,0 +1,72 @@ +/** + * WordPress dependencies + */ +import { + activatePlugin as _activatePlugin, + deactivatePlugin as _deactivatePlugin, + installPlugin as _installPlugin, + switchUserToAdmin, + switchUserToTest, + uninstallPlugin as _uninstallPlugin, + visitAdminPage, +} from '@wordpress/e2e-test-utils'; + +/** + * Internal dependencies + */ +import { scrollToElement } from './onboarding-wizard-utils'; + +export async function setTemplateMode( mode ) { + // Set template mode. + await scrollToElement( { selector: `#template-mode-${ mode }`, click: true } ); + + // Save options and wait for the request to succeed. + await Promise.all( [ + scrollToElement( { selector: '.amp-settings-nav button[type="submit"]', click: true } ), + page.waitForResponse( ( response ) => response.url().includes( '/wp-json/amp/v1/options' ) ), + ] ); +} + +export async function isPluginInstalled( slug, settings ) { + await switchUserToAdmin(); + await visitAdminPage( 'plugins.php' ); + await page.waitForSelector( 'h1', { text: 'Plugins' } ); + + const found = await page.$( `tr${ settings?.checkIsActivated ? '.active' : '' }[data-slug="${ slug }"]` ); + + await switchUserToTest(); + + return Boolean( found ); +} + +export function isPluginActivated( slug ) { + return isPluginInstalled( slug, { checkIsActivated: true } ); +} + +export async function installPlugin( slug ) { + if ( ! await isPluginInstalled( slug ) ) { + await _installPlugin( slug ); + } +} + +export async function activatePlugin( slug ) { + await installPlugin( slug ); + + if ( ! await isPluginActivated( slug ) ) { + await _activatePlugin( slug ); + } +} + +export async function deactivatePlugin( slug ) { + if ( await isPluginActivated( slug ) ) { + await _deactivatePlugin( slug ); + } +} + +export async function uninstallPlugin( slug ) { + await deactivatePlugin( slug ); + + if ( await isPluginInstalled( slug ) ) { + await _uninstallPlugin( slug ); + } +} diff --git a/tests/e2e/utils/onboarding-wizard-utils.js b/tests/e2e/utils/onboarding-wizard-utils.js index e9f8286a262..7efc4d7b047 100644 --- a/tests/e2e/utils/onboarding-wizard-utils.js +++ b/tests/e2e/utils/onboarding-wizard-utils.js @@ -14,6 +14,7 @@ export async function goToOnboardingWizard() { } export async function clickNextButton() { + await page.waitForSelector( `${ NEXT_BUTTON_SELECTOR }:not([disabled])` ); await expect( page ).toClick( `${ NEXT_BUTTON_SELECTOR }:not([disabled])` ); } @@ -27,12 +28,19 @@ export async function moveToTechnicalScreen() { await expect( page ).toMatchElement( '.technical-background-option' ); } -export async function moveToTemplateModeScreen( { technical } ) { +export async function moveToSiteScanScreen( { technical } ) { await moveToTechnicalScreen(); const radioSelector = technical ? '#technical-background-enable' : '#technical-background-disable'; await expect( page ).toClick( radioSelector ); + await clickNextButton(); + await expect( page ).toMatchElement( '.site-scan' ); +} + +export async function moveToTemplateModeScreen( { technical } ) { + await moveToSiteScanScreen( { technical } ); + await clickNextButton(); await expect( page ).toMatchElement( '.template-mode-option' ); } @@ -76,9 +84,11 @@ export async function moveToDoneScreen( { technical = true, mode, readerTheme = await clickMode( mode ); } - await clickNextButton(); - - await page.waitForSelector( '.done' ); + await Promise.all( [ + clickNextButton(), + page.waitForResponse( ( response ) => response.url().includes( '/wp-json/amp/v1/options' ) ), + page.waitForSelector( '.done' ), + ] ); } export async function completeWizard( { technical = true, mode, readerTheme = 'legacy' } ) { @@ -100,20 +110,20 @@ export async function testCloseButton( { exists = true } ) { } } -export async function testPreviousButton( { exists = true, disabled = false } ) { +export async function testPreviousButton( { exists = true, text = 'Previous', disabled = false } ) { if ( exists ) { - await expect( page ).toMatchElement( `button${ disabled ? '[disabled]' : '' }`, { text: 'Previous' } ); + await expect( page ).toMatchElement( `button${ disabled ? '[disabled]' : '' }`, { text } ); } else { - await expect( page ).not.toMatchElement( `button${ disabled ? '[disabled]' : '' }`, { text: 'Previous' } ); + await expect( page ).not.toMatchElement( `button${ disabled ? '[disabled]' : '' }`, { text } ); } } -export function testNextButton( { element = 'button', text, disabled = false } ) { - expect( page ).toMatchElement( `${ element }${ disabled ? '[disabled]' : '' }`, { text } ); +export async function testNextButton( { element = 'button', text = 'Next', disabled = false } ) { + await expect( page ).toMatchElement( `${ element }${ disabled ? '[disabled]' : '' }`, { text } ); } -export function testTitle( { text, element = 'h1' } ) { - expect( page ).toMatchElement( element, { text } ); +export async function testTitle( { text, element = 'h1' } ) { + await expect( page ).toMatchElement( element, { text } ); } /** @@ -134,7 +144,6 @@ export async function cleanUpSettings() { theme_support: 'reader', plugin_configured: false, } } ), - ], - ); + ] ); } ); } diff --git a/tests/e2e/utils/site-scan-utils.js b/tests/e2e/utils/site-scan-utils.js new file mode 100644 index 00000000000..d01f8b8fa95 --- /dev/null +++ b/tests/e2e/utils/site-scan-utils.js @@ -0,0 +1,27 @@ +export async function testSiteScanning( { statusElementClassName, isAmpFirst } ) { + await page.waitForSelector( `.${ statusElementClassName }` ); + + const statusTextRegex = /^Scanning ([\d])+\/([\d]+) URLs/; + const statusText = await page.$eval( `.${ statusElementClassName }`, ( el ) => el.innerText ); + + expect( statusText ).toMatch( statusTextRegex ); + + const currentlyScannedIndex = Number( statusText.match( statusTextRegex )[ 1 ] ) - 1; + const scannableUrlsCount = Number( statusText.match( statusTextRegex )[ 2 ] ); + const urls = [ ...Array( scannableUrlsCount - currentlyScannedIndex ) ]; + + const expectedParams = [ + 'amp_validate[nonce]', + 'amp_validate[omit_stylesheets]', + 'amp_validate[cache_bust]', + ].map( encodeURI ); + + // Use generous timeout since site scan may take a while. + const timeout = 20000; + + await Promise.all( [ + ...urls.map( ( url, index ) => page.waitForXPath( `//p[@class='${ statusElementClassName }'][contains(text(), 'Scanning ${ index + 1 }/${ scannableUrlsCount } URLs')]`, { timeout } ) ), + page.waitForResponse( ( response ) => isAmpFirst === response.url().includes( 'amp-first' ) && expectedParams.every( ( param ) => response.url().includes( param ) ), { timeout } ), + page.waitForXPath( `//p[@class='${ statusElementClassName }'][contains(text(), 'Scan complete')]`, { timeout } ), + ] ); +} diff --git a/tests/php/src/Admin/OnboardingWizardSubmenuPageTest.php b/tests/php/src/Admin/OnboardingWizardSubmenuPageTest.php index e85a493979b..7f6e1cb247e 100644 --- a/tests/php/src/Admin/OnboardingWizardSubmenuPageTest.php +++ b/tests/php/src/Admin/OnboardingWizardSubmenuPageTest.php @@ -12,6 +12,7 @@ use AmpProject\AmpWP\Infrastructure\Delayed; use AmpProject\AmpWP\Infrastructure\Registerable; use AmpProject\AmpWP\Infrastructure\Service; +use AmpProject\AmpWP\Tests\Helpers\PrivateAccess; use AmpProject\AmpWP\Tests\DependencyInjectedTestCase; use AMP_Options_Manager; @@ -26,6 +27,8 @@ */ class OnboardingWizardSubmenuPageTest extends DependencyInjectedTestCase { + use PrivateAccess; + /** * Test instance. * @@ -116,13 +119,32 @@ public function test_screen_handle() { * Tests OnboardingWizardSubmenuPage::enqueue_assets * * @covers ::enqueue_assets() + * @covers ::add_preload_rest_paths() */ public function test_enqueue_assets() { $handle = 'amp-onboarding-wizard'; + $rest_preloader = $this->get_private_property( $this->onboarding_wizard_submenu_page, 'rest_preloader' ); + $this->assertCount( 0, $this->get_private_property( $rest_preloader, 'paths' ) ); + $this->onboarding_wizard_submenu_page->enqueue_assets( $this->onboarding_wizard_submenu_page->screen_handle() ); $this->assertTrue( wp_script_is( $handle ) ); $this->assertTrue( wp_style_is( $handle ) ); + + if ( function_exists( 'rest_preload_api_request' ) ) { + $this->assertEqualSets( + [ + '/amp/v1/options', + '/amp/v1/reader-themes', + '/amp/v1/scannable-urls?_fields%5B0%5D=url&_fields%5B1%5D=amp_url&_fields%5B2%5D=type&_fields%5B3%5D=label', + '/wp/v2/plugins', + '/wp/v2/settings', + '/wp/v2/themes', + '/wp/v2/users/me', + ], + $this->get_private_property( $rest_preloader, 'paths' ) + ); + } } /** @return array */ @@ -167,55 +189,4 @@ public function test_get_close_link( $referrer_link_callback, $expected_referrer $this->onboarding_wizard_submenu_page->get_close_link() ); } - - /** - * Tests OnboardingWizardSubmenuPage::get_preview_urls() - * - * @covers ::get_preview_urls() - */ - public function test_get_preview_urls() { - $scannable_urls = [ - [ - 'type' => 'home', - 'url' => 'https://example.com', - 'label' => 'Homepage', - ], - [ - 'type' => 'page', - 'url' => 'https://example.com/sample-page', - 'label' => 'Page', - ], - [ - 'type' => 'search', - 'url' => 'https://example.com/?s=foobar', - 'label' => 'Search Results', - ], - ]; - - $expected_urls = [ - [ - 'type' => 'home', - 'url' => 'https://example.com', - 'amp_url' => amp_add_paired_endpoint( 'https://example.com' ), - 'label' => 'Homepage', - ], - [ - 'type' => 'page', - 'url' => 'https://example.com/sample-page', - 'amp_url' => amp_add_paired_endpoint( 'https://example.com/sample-page' ), - 'label' => 'Page', - ], - [ - 'type' => 'search', - 'url' => 'https://example.com/?s=foobar', - 'amp_url' => amp_add_paired_endpoint( 'https://example.com/?s=foobar' ), - 'label' => 'Search Results', - ], - ]; - - $this->assertEquals( - $expected_urls, - $this->onboarding_wizard_submenu_page->get_preview_urls( $scannable_urls ) - ); - } } diff --git a/tests/php/src/Admin/OptionsMenuTest.php b/tests/php/src/Admin/OptionsMenuTest.php index 8c132a62dcd..d8f6cb754e1 100644 --- a/tests/php/src/Admin/OptionsMenuTest.php +++ b/tests/php/src/Admin/OptionsMenuTest.php @@ -18,6 +18,7 @@ use AmpProject\AmpWP\Infrastructure\Service; use AmpProject\AmpWP\LoadingError; use AmpProject\AmpWP\Tests\DependencyInjectedTestCase; +use AmpProject\AmpWP\Tests\Helpers\PrivateAccess; use AMP_Options_Manager; /** @@ -28,6 +29,8 @@ */ class OptionsMenuTest extends DependencyInjectedTestCase { + use PrivateAccess; + /** * Instance of OptionsMenu * @@ -171,11 +174,17 @@ public function test_enqueue_assets_wrong_hook_suffix() { $this->assertFalse( wp_style_is( OptionsMenu::ASSET_HANDLE, 'enqueued' ) ); } - /** @covers ::enqueue_assets() */ + /** + * @covers ::enqueue_assets() + * @covers ::add_preload_rest_paths() + */ public function test_enqueue_assets_right_hook_suffix() { wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); set_current_screen( $this->instance->screen_handle() ); + $rest_preloader = $this->get_private_property( $this->instance, 'rest_preloader' ); + $this->assertCount( 0, $this->get_private_property( $rest_preloader, 'paths' ) ); + $this->assertFalse( wp_script_is( OptionsMenu::ASSET_HANDLE, 'enqueued' ) ); $this->assertFalse( wp_style_is( OptionsMenu::ASSET_HANDLE, 'enqueued' ) ); @@ -191,12 +200,19 @@ public function test_enqueue_assets_right_hook_suffix() { $this->assertStringContainsString( 'USER_FIELD_DEVELOPER_TOOLS_ENABLED', $script_before ); $this->assertStringContainsString( 'USERS_RESOURCE_REST_PATH', $script_before ); - $wp_api_fetch_after = implode( "\n", wp_scripts()->get_data( 'wp-api-fetch', 'after' ) ); if ( function_exists( 'rest_preload_api_request' ) ) { - $this->assertStringContainsString( wp_json_encode( '/amp/v1/options' ), $wp_api_fetch_after ); - $this->assertStringContainsString( wp_json_encode( '/amp/v1/reader-themes' ), $wp_api_fetch_after ); - $this->assertStringContainsString( wp_json_encode( '/wp/v2/settings' ), $wp_api_fetch_after ); - $this->assertStringContainsString( wp_json_encode( '/wp/v2/users/me' ), $wp_api_fetch_after ); + $this->assertEqualSets( + [ + '/amp/v1/options', + '/amp/v1/reader-themes', + '/amp/v1/scannable-urls?_fields%5B0%5D=url&_fields%5B1%5D=amp_url&_fields%5B2%5D=type&_fields%5B3%5D=label&_fields%5B4%5D=validation_errors&_fields%5B5%5D=stale', + '/wp/v2/plugins', + '/wp/v2/settings', + '/wp/v2/themes', + '/wp/v2/users/me', + ], + $this->get_private_property( $rest_preloader, 'paths' ) + ); } } diff --git a/tests/php/src/Validation/ScannableURLsRestControllerTest.php b/tests/php/src/Validation/ScannableURLsRestControllerTest.php new file mode 100644 index 00000000000..183c1b43a5f --- /dev/null +++ b/tests/php/src/Validation/ScannableURLsRestControllerTest.php @@ -0,0 +1,178 @@ +controller = $this->injector->make( ScannableURLsRestController::class ); + add_filter( 'pre_http_request', [ $this, 'get_validate_response' ] ); + } + + /** @covers ::get_registration_action() */ + public function test_get_registration_action() { + $this->assertEquals( 'rest_api_init', ScannableURLsRestController::get_registration_action() ); + } + + /** @covers ::__construct() */ + public function test__construct() { + $this->assertInstanceOf( Delayed::class, $this->controller ); + $this->assertInstanceOf( ScannableURLsRestController::class, $this->controller ); + $this->assertInstanceOf( WP_REST_Controller::class, $this->controller ); + } + + /** @covers ::register() */ + public function test_register() { + $this->controller->register(); + + $this->assertContains( '/amp/v1/scannable-urls', array_keys( rest_get_server()->get_routes() ) ); + } + + /** @covers ::get_items_permissions_check() */ + public function test_get_items_permissions_check() { + $this->assertWPError( $this->controller->get_items_permissions_check( new WP_REST_Request( 'GET', '/amp/v1/scannable-urls/' ) ) ); + + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + $this->assertTrue( $this->controller->get_items_permissions_check( new WP_REST_Request( 'GET', '/amp/v1/scannable-urls/' ) ) ); + } + + /** + * @covers ::get_items() + * @covers ::prepare_item_for_response() + * @covers ::get_item_schema() + */ + public function test_get_items() { + $this->assertTrue( amp_is_legacy() ); + $post_id = self::factory()->post->create( [ 'post_type' => 'post' ] ); + $page_id = self::factory()->post->create( [ 'post_type' => 'page' ] ); + AMP_Validation_Manager::validate_url_and_store( get_permalink( $post_id ) ); + + $this->controller->register(); + $item_schema = $this->controller->get_item_schema(); + + $request = new WP_REST_Request( 'GET', '/amp/v1/scannable-urls' ); + + wp_set_current_user( 0 ); + $response = rest_get_server()->dispatch( $request ); + $this->assertTrue( $response->is_error() ); + $error = $response->as_error(); + $this->assertEquals( 'amp_rest_cannot_validate_urls', $error->get_error_code() ); + + wp_set_current_user( self::factory()->user->create( [ 'role' => 'subscriber' ] ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertTrue( $response->is_error() ); + $error = $response->as_error(); + $this->assertEquals( 'amp_rest_cannot_validate_urls', $error->get_error_code() ); + + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + $response = rest_get_server()->dispatch( $request ); + $this->assertFalse( $response->is_error() ); + $scannable_urls = $response->get_data(); + $this->assertCount( 2, $scannable_urls, 'Expected there to be only two URLs since in legacy Reader mode.' ); + + foreach ( $scannable_urls as $scannable_url_entry ) { + $this->assertEqualSets( + array_keys( $item_schema['properties'] ), + array_keys( $scannable_url_entry ) + ); + + $this->assertContains( + $scannable_url_entry['url'], + [ get_permalink( $post_id ), get_permalink( $page_id ) ] + ); + $this->assertContains( + $scannable_url_entry['amp_url'], + [ amp_get_permalink( $post_id ), amp_get_permalink( $page_id ) ] + ); + + if ( get_permalink( $post_id ) === $scannable_url_entry['url'] ) { + $this->assertIsArray( $scannable_url_entry['validated_url_post'] ); + + $this->assertEqualSets( + [ 'id', 'edit_link' ], + array_keys( $scannable_url_entry['validated_url_post'] ) + ); + $validated_url_post = get_post( $scannable_url_entry['validated_url_post']['id'] ); + $this->assertInstanceOf( WP_Post::class, $validated_url_post ); + $this->assertEquals( + get_edit_post_link( $validated_url_post, 'raw' ), + $scannable_url_entry['validated_url_post']['edit_link'] + ); + + $this->assertIsArray( $scannable_url_entry['validation_errors'] ); + $this->assertCount( 1, $scannable_url_entry['validation_errors'] ); + + $this->assertFalse( $scannable_url_entry['stale'] ); + } else { + $this->assertNull( $scannable_url_entry['validated_url_post'] ); + $this->assertNull( $scannable_url_entry['validation_errors'] ); + $this->assertNull( $scannable_url_entry['stale'] ); + } + } + } + + /** + * Tests ScannableURLsRestController::get_item_schema. + * + * @covers ::get_item_schema() + */ + public function test_get_item_schema() { + $schema = $this->controller->get_item_schema(); + + $this->assertEquals( + [ + '$schema', + 'title', + 'type', + 'properties', + ], + array_keys( $schema ) + ); + + $this->assertEqualSets( + [ + 'url', + 'amp_url', + 'type', + 'label', + 'validated_url_post', + 'validation_errors', + 'stale', + ], + array_keys( $schema['properties'] ) + ); + } +} diff --git a/tests/php/src/Validation/URLValidationRESTControllerTest.php b/tests/php/src/Validation/URLValidationRESTControllerTest.php index b0470220e92..4643f3c5765 100644 --- a/tests/php/src/Validation/URLValidationRESTControllerTest.php +++ b/tests/php/src/Validation/URLValidationRESTControllerTest.php @@ -66,7 +66,7 @@ public function test__construct() { public function test_register() { $this->controller->register(); - $this->assertStringContainsString( '/amp/v1/validate-post-url', array_keys( rest_get_server()->get_routes() ) ); + $this->assertContains( '/amp/v1/validate-post-url', array_keys( rest_get_server()->get_routes() ) ); } /** @covers ::create_item_permissions_check() */