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