diff --git a/assets/js/components/RecoverableModules.js b/assets/js/components/RecoverableModules.js new file mode 100644 index 00000000000..816ea459e4f --- /dev/null +++ b/assets/js/components/RecoverableModules.js @@ -0,0 +1,84 @@ +/** + * RecoverableModules component. + * + * Site Kit by Google, Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +/** + * WordPress dependencies + */ +import { __, _x, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Data from 'googlesitekit-data'; +import { CORE_MODULES } from '../googlesitekit/modules/datastore/constants'; +import CTA from './notifications/CTA'; + +const { useSelect } = Data; + +export default function RecoverableModules( { moduleSlugs } ) { + const moduleNames = useSelect( ( select ) => { + const modules = select( CORE_MODULES ).getModules(); + + if ( modules === undefined ) { + return undefined; + } + + return moduleSlugs.map( ( moduleSlug ) => modules[ moduleSlug ].name ); + } ); + + if ( moduleNames === undefined ) { + return null; + } + + const description = + moduleNames.length === 1 + ? sprintf( + /* translators: %s: Module name */ + __( + '%s data was previously shared by an admin who no longer has access. Please contact another admin to restore it.', + 'google-site-kit' + ), + moduleNames[ 0 ] + ) + : sprintf( + /* translators: %s: List of module names */ + __( + 'The data for the following modules was previously shared by an admin who no longer has access: %s. Please contact another admin to restore it.', + 'google-site-kit' + ), + moduleNames.join( + _x( ', ', 'Recoverable modules', 'google-site-kit' ) + ) + ); + + return ( + + ); +} + +RecoverableModules.propTypes = { + moduleSlugs: PropTypes.arrayOf( PropTypes.string ).isRequired, +}; diff --git a/assets/js/googlesitekit/modules/datastore/modules.js b/assets/js/googlesitekit/modules/datastore/modules.js index 0fdee0de3cf..09324299359 100644 --- a/assets/js/googlesitekit/modules/datastore/modules.js +++ b/assets/js/googlesitekit/modules/datastore/modules.js @@ -100,6 +100,29 @@ const normalizeModules = memize( ( serverDefinitions, clientDefinitions ) => { }, {} ); } ); +/** + * Gets a memoized object mapping recoverable module slugs to their corresponding + * module objects. + * + * @since n.e.x.t + * + * @param {Object} modules Module definitions. + * @param {Array} recoverableModules Array of recoverable module slugs. + * @return {Object} Map of recoverable module slugs to their corresponding module objects. + */ +const calculateRecoverableModules = memize( ( modules, recoverableModules ) => + Object.values( modules ).reduce( ( recoverable, module ) => { + if ( recoverableModules.includes( module.slug ) ) { + return { + ...recoverable, + [ module.slug ]: module, + }; + } + + return recoverable; + }, {} ) +); + const fetchGetModulesStore = createFetchStore( { baseName: 'getModules', controlCallback: () => { @@ -1244,19 +1267,7 @@ const baseSelectors = { return undefined; } - return Object.values( modules ).reduce( - ( recoverableModules, module ) => { - if ( state.recoverableModules.includes( module.slug ) ) { - return { - ...recoverableModules, - [ module.slug ]: module, - }; - } - - return recoverableModules; - }, - {} - ); + return calculateRecoverableModules( modules, state.recoverableModules ); } ), /** diff --git a/assets/js/googlesitekit/widgets/components/WidgetAreaRenderer.test.js b/assets/js/googlesitekit/widgets/components/WidgetAreaRenderer.test.js index 609804bb9c5..a403913c517 100644 --- a/assets/js/googlesitekit/widgets/components/WidgetAreaRenderer.test.js +++ b/assets/js/googlesitekit/widgets/components/WidgetAreaRenderer.test.js @@ -16,6 +16,11 @@ * limitations under the License. */ +/** + * External dependencies + */ +import { getByText } from '@testing-library/dom'; + /** * Internal dependencies */ @@ -27,6 +32,7 @@ import { WIDGET_AREA_STYLES, } from '../datastore/constants'; import { CORE_SITE } from '../../../googlesitekit/datastore/site/constants'; +import { CORE_MODULES } from '../../modules/datastore/constants'; import { createTestRegistry, render, @@ -90,6 +96,7 @@ describe( 'WidgetAreaRenderer', () => { beforeEach( async () => { registry = createTestRegistryWithArea( areaName ); + const connection = { connected: true }; await registry.dispatch( CORE_SITE ).receiveGetConnection( connection ); } ); @@ -702,4 +709,67 @@ describe( 'WidgetAreaRenderer', () => { ) ).toHaveLength( 1 ); } ); + + it( 'should combine multiple widgets in RecoverableModules state with the same metadata into a single widget', () => { + provideModules( registry ); + registry + .dispatch( CORE_MODULES ) + .receiveRecoverableModules( [ 'search-console' ] ); + + provideUserCapabilities( registry, { + [ PERMISSION_VIEW_DASHBOARD ]: true, + [ `${ PERMISSION_READ_SHARED_MODULE_DATA }::["search-console"]` ]: true, + } ); + + createWidgets( registry, areaName, [ + { + Component: WidgetComponent, + slug: 'one', + modules: [ 'search-console' ], + }, + { + Component: WidgetComponent, + slug: 'two', + modules: [ 'search-console' ], + }, + ] ); + + const { container } = render( + , + { + registry, + viewContext: VIEW_CONTEXT_DASHBOARD_VIEW_ONLY, + features: [ 'dashboardSharing' ], + } + ); + + const visibleWidgetSelector = + '.googlesitekit-widget-area-widgets > .mdc-layout-grid__inner > .mdc-layout-grid__cell > .googlesitekit-widget'; + + // There should be a single visible widget. + expect( + container.firstChild.querySelectorAll( visibleWidgetSelector ) + ).toHaveLength( 1 ); + + // The visible widget should be rendered as the RecoverableModules component. + expect( + getByText( + container.firstChild.querySelector( visibleWidgetSelector ), + 'Search Console data was previously shared by an admin who no longer has access. Please contact another admin to restore it.' + ) + ).toBeInTheDocument(); + + // There should also be a hidden widget. + expect( + container.firstChild.querySelectorAll( + '.googlesitekit-widget-area-widgets .googlesitekit-hidden .googlesitekit-widget' + ) + ).toHaveLength( 1 ); + + expect( + container.firstChild.querySelector( + '.googlesitekit-widget-area-widgets' + ) + ).toMatchSnapshot(); + } ); } ); diff --git a/assets/js/googlesitekit/widgets/components/WidgetRecoverableModules.js b/assets/js/googlesitekit/widgets/components/WidgetRecoverableModules.js new file mode 100644 index 00000000000..0668faeba56 --- /dev/null +++ b/assets/js/googlesitekit/widgets/components/WidgetRecoverableModules.js @@ -0,0 +1,62 @@ +/** + * WidgetRecoverableModules component. + * + * Site Kit by Google, Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import PropTypes from 'prop-types'; + +/** + * WordPress dependencies + */ +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import useWidgetStateEffect from '../hooks/useWidgetStateEffect'; +import RecoverableModules from '../../../components/RecoverableModules'; + +// The supported props must match `RecoverableModules` (except `widgetSlug`). +export default function WidgetRecoverableModules( { + widgetSlug, + moduleSlugs, + ...props +} ) { + const metadata = useMemo( + () => ( { + // Here we serialize to `moduleSlug` for compatibility with the logic in + // `combineWidgets()`. In future we may wish to take a less "hacky" approach. + // See https://github.com/google/site-kit-wp/issues/5376#issuecomment-1165771399. + moduleSlug: [ ...moduleSlugs ].sort().join( ',' ), + // We also store `moduleSlugs` in the metadata in order for it to be passed back + // into RecoverableModules as a prop. + // See https://github.com/google/site-kit-wp/blob/c272c20eddcca61aae24c9812b6b11dbc15ec673/assets/js/googlesitekit/widgets/components/WidgetAreaRenderer.js#L171. + moduleSlugs, + } ), + [ moduleSlugs ] + ); + useWidgetStateEffect( widgetSlug, RecoverableModules, metadata ); + + return ; +} + +WidgetRecoverableModules.propTypes = { + widgetSlug: PropTypes.string.isRequired, + ...RecoverableModules.propTypes, +}; diff --git a/assets/js/googlesitekit/widgets/components/WidgetRenderer.js b/assets/js/googlesitekit/widgets/components/WidgetRenderer.js index 77463f13dd2..105c71dbd16 100644 --- a/assets/js/googlesitekit/widgets/components/WidgetRenderer.js +++ b/assets/js/googlesitekit/widgets/components/WidgetRenderer.js @@ -20,30 +20,52 @@ * External dependencies */ import PropTypes from 'prop-types'; +import intersection from 'lodash/intersection'; /** * WordPress dependencies */ -import { Fragment } from '@wordpress/element'; +import { useMemo, Fragment } from '@wordpress/element'; /** * Internal dependencies */ import Data from 'googlesitekit-data'; import { CORE_WIDGETS } from '../datastore/constants'; +import { CORE_MODULES } from '../../modules/datastore/constants'; import BaseWidget from './Widget'; +import WidgetRecoverableModules from './WidgetRecoverableModules'; import { getWidgetComponentProps } from '../util'; import { HIDDEN_CLASS } from '../util/constants'; +import useViewOnly from '../../../hooks/useViewOnly'; +import { useFeature } from '../../../hooks/useFeature'; const { useSelect } = Data; const WidgetRenderer = ( { slug, OverrideComponent } ) => { + const dashboardSharingEnabled = useFeature( 'dashboardSharing' ); + const widget = useSelect( ( select ) => select( CORE_WIDGETS ).getWidget( slug ) ); const widgetComponentProps = getWidgetComponentProps( slug ); const { Widget, WidgetNull } = widgetComponentProps; + const recoverableModules = useSelect( + ( select ) => + dashboardSharingEnabled && + select( CORE_MODULES ).getRecoverableModules() + ); + + const viewOnly = useViewOnly(); + const widgetRecoverableModules = useMemo( + () => + widget && + recoverableModules && + intersection( widget.modules, Object.keys( recoverableModules ) ), + [ recoverableModules, widget ] + ); + if ( ! widget ) { return ; } @@ -52,6 +74,15 @@ const WidgetRenderer = ( { slug, OverrideComponent } ) => { let widgetElement = ; + if ( viewOnly && widgetRecoverableModules?.length ) { + widgetElement = ( + + ); + } + if ( OverrideComponent ) { // If OverrideComponent passed, render it instead of the actual widget. // It always needs to be wrapped as it is expected to be a diff --git a/assets/js/googlesitekit/widgets/components/WidgetRenderer.test.js b/assets/js/googlesitekit/widgets/components/WidgetRenderer.test.js index 879aebab726..c48b91d78df 100644 --- a/assets/js/googlesitekit/widgets/components/WidgetRenderer.test.js +++ b/assets/js/googlesitekit/widgets/components/WidgetRenderer.test.js @@ -20,14 +20,27 @@ * Internal dependencies */ import WidgetRenderer from './WidgetRenderer'; +import { + VIEW_CONTEXT_DASHBOARD, + VIEW_CONTEXT_DASHBOARD_VIEW_ONLY, +} from '../../../googlesitekit/constants'; +import { CORE_MODULES } from '../../modules/datastore/constants'; import { CORE_WIDGETS } from '../datastore/constants'; -import { render } from '../../../../../tests/js/test-utils'; +import { provideModules, render } from '../../../../../tests/js/test-utils'; const setupRegistry = ( { Component = () =>
Test
, wrapWidget = false, + recoverableModules = [], } = {} ) => { - return ( { dispatch } ) => { + return ( registry ) => { + const { dispatch } = registry; + + provideModules( registry ); + dispatch( CORE_MODULES ).receiveRecoverableModules( + recoverableModules + ); + dispatch( CORE_WIDGETS ).registerWidgetArea( 'dashboard-header', { title: 'Dashboard Header', subtitle: 'Cool stuff for yoursite.com', @@ -40,6 +53,7 @@ const setupRegistry = ( { dispatch( CORE_WIDGETS ).registerWidget( 'TestWidget', { Component, wrapWidget, + modules: [ 'search-console', 'pagespeed-insights' ], } ); dispatch( CORE_WIDGETS ).assignWidget( 'TestWidget', @@ -78,4 +92,57 @@ describe( 'WidgetRenderer', () => { expect( container.firstChild ).toEqual( null ); } ); + + it( 'should output the recoverable modules component when the widget depends on a recoverable module in view-only mode', async () => { + const { getByText } = render( , { + setupRegistry: setupRegistry( { + recoverableModules: [ 'search-console' ], + } ), + viewContext: VIEW_CONTEXT_DASHBOARD_VIEW_ONLY, + features: [ 'dashboardSharing' ], + } ); + + expect( + getByText( + /Search Console data was previously shared by an admin who no longer has access/ + ) + ).toBeInTheDocument(); + } ); + + it( 'should output the recoverable modules component when the widget depends on multiple recoverable modules in view-only mode', async () => { + const { getByText } = render( , { + setupRegistry: setupRegistry( { + recoverableModules: [ 'search-console', 'pagespeed-insights' ], + } ), + viewContext: VIEW_CONTEXT_DASHBOARD_VIEW_ONLY, + features: [ 'dashboardSharing' ], + } ); + + expect( + getByText( + /The data for the following modules was previously shared by an admin who no longer has access: Search Console, PageSpeed Insights/ + ) + ).toBeInTheDocument(); + } ); + + it( 'should not output the recoverable modules component when the widget depends on a recoverable module and is not in view-only mode ', async () => { + const { getByText, queryByText } = render( + , + { + setupRegistry: setupRegistry( { + recoverableModules: [ 'search-console' ], + } ), + viewContext: VIEW_CONTEXT_DASHBOARD, + features: [ 'dashboardSharing' ], + } + ); + + expect( + queryByText( + /Search Console data was previously shared by an admin who no longer has access/ + ) + ).toBeNull(); + + expect( getByText( 'Test' ) ).toBeInTheDocument(); + } ); } ); diff --git a/assets/js/googlesitekit/widgets/components/__snapshots__/WidgetAreaRenderer.test.js.snap b/assets/js/googlesitekit/widgets/components/__snapshots__/WidgetAreaRenderer.test.js.snap index 25d73722829..e7c0faeab3a 100644 --- a/assets/js/googlesitekit/widgets/components/__snapshots__/WidgetAreaRenderer.test.js.snap +++ b/assets/js/googlesitekit/widgets/components/__snapshots__/WidgetAreaRenderer.test.js.snap @@ -1,5 +1,92 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`WidgetAreaRenderer should combine multiple widgets in RecoverableModules state with the same metadata into a single widget 1`] = ` +
+
+
+
+
+
+

+ Data Unavailable +

+

+ Search Console data was previously shared by an admin who no longer has access. Please contact another admin to restore it. +

+ + +
+
+
+
+
+

+ Data Unavailable +

+

+ Search Console data was previously shared by an admin who no longer has access. Please contact another admin to restore it. +

+ + +
+
+
+
+
+
+
+

+ Data Unavailable +

+

+ Search Console data was previously shared by an admin who no longer has access. Please contact another admin to restore it. +

+ + +
+
+
+
+
+
+`; + exports[`WidgetAreaRenderer should not resize widgets in a row that is smaller than 9 columns (3, 12, 3-3) 1`] = `