Commit 4980c8cd50c for woocommerce
commit 4980c8cd50c0e0fa8d7ae8b05dd2ac36720358bc
Author: Miroslav Mitev <m1r0@users.noreply.github.com>
Date: Sat Aug 22 22:47:28 2026 +0300
Fix and repair corrupted dashboard_sections user meta (#67888)
* Fix and repair corrupted dashboard_sections user meta
The Analytics dashboard crashed when `woocommerce_admin_dashboard_sections`
held an array of malformed entries, since every entry was dereferenced for
its `key`. Malformed entries are now dropped, and the sections the dashboard
falls back to are written back so the stored value is repaired on the fly.
Saving now also strips the `icon` and `component` React nodes for every code
path, not just renames, so hiding or moving a section stops persisting a
serialized SVG element tree into the user meta.
* Guard dashboard sections against a corrupted hiddenBlocks
Only `key` was validated, so an entry like `{"key":"charts","hiddenBlocks":null}`
counted as well formed and still crashed the dashboard, since every section
component calls `hiddenBlocks.includes()` on it. The merge now falls back to the
default `hiddenBlocks` when the stored one is not an array, and the preference is
treated as needing repair so the broken value gets written back clean. The
merchant's title, visibility, and order for that section are kept.
Drop the unreachable `sections.length > 0` fallback, and hand the repair tests a
new preference reference per render like the data store does, so they actually
cover the loop guard.
* Guard a corrupted title and tighten the sections repair
`title` is rendered as a React child by every section header, so a stored value
that is not a string took the dashboard down with the same "Oops, something went
wrong" screen a corrupted `hiddenBlocks` did. The fields that have to hold a
given type now live in one map that both the validation and the merge read, so
the two cannot drift apart again as a third field gets added.
The repair no longer drops well formed entries for keys the dashboard does not
know about. An extension that registers a section used to lose its stored
settings on the next Analytics visit while it was deactivated, with no user
action involved.
The repair also skips the write when the value it would store is not valid
itself, for example when the `woocommerce_dashboard_default_sections` filter
emptied the list. That was one REST write per page load with nothing to show for
it.
* Keep partly corrupted unknown sections and guard isVisible
The repair dropped an unknown key entirely when one of its fields was
corrupted, so a deactivated extension lost its stored section. The field
is dropped instead and the rest of the entry survives, since the default
section provides that field once the key is registered again.
A non boolean isVisible hid a section without listing it under Add more
sections, and it passed validation so no repair fired either. It is a
field check now.
An empty stored list no longer counts as corrupted, so it stops writing
the defaults on page load.
* Guard the filtered default sections and sanitize every write
`getDefaultSections` only validated the container, so an entry returned by the
`woocommerce_dashboard_default_sections` filter reached the merge unchecked. The
field repair patches a corrupted stored value up from the default section, which
means a corrupted default just wins: an extension returning a `hiddenBlocks` of
`null` took the dashboard down with the same "Oops, something went wrong" screen
a corrupted preference did. Entries no section can be built from are dropped
now, and so are fields that fail the checks. Nothing sits behind a default to
patch a field up from, except `hiddenBlocks` which every section component
dereferences, so that one falls back to an empty list. That also covers a
section registered without it.
The update callbacks are handed to the section components, so a third party one
could hand back a value the dashboard cannot read and have it persisted. Writes
go through `toUsableSection` now, so a preference the dashboard wrote never
needs a repair on the next read.
diff --git a/plugins/woocommerce/changelog/fix-53234-repair-corrupted-dashboard-sections b/plugins/woocommerce/changelog/fix-53234-repair-corrupted-dashboard-sections
new file mode 100644
index 00000000000..4c90e88f0ae
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-53234-repair-corrupted-dashboard-sections
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Render the Analytics dashboard and repair the stored preference when the woocommerce_admin_dashboard_sections user meta is corrupted.
diff --git a/plugins/woocommerce/client/admin/client/dashboard/customizable.js b/plugins/woocommerce/client/admin/client/dashboard/customizable.js
index 25e64ef7438..bb749d427ac 100644
--- a/plugins/woocommerce/client/admin/client/dashboard/customizable.js
+++ b/plugins/woocommerce/client/admin/client/dashboard/customizable.js
@@ -2,7 +2,7 @@
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
-import { useMemo } from '@wordpress/element';
+import { useEffect, useMemo, useRef } from '@wordpress/element';
import { compose } from '@wordpress/compose';
import { partial } from 'lodash';
import { Dropdown, Button } from '@wordpress/components';
@@ -30,6 +30,7 @@ const DASHBOARD_FILTERS_FILTER = 'woocommerce_admin_dashboard_filters';
/**
* @typedef {import('../analytics/report/index.js').filter} filter
+ * @typedef {import('./default-sections.js').section} section
*/
/**
@@ -40,52 +41,234 @@ const DASHBOARD_FILTERS_FILTER = 'woocommerce_admin_dashboard_filters';
*/
const filters = applyFilters( DASHBOARD_FILTERS_FILTER, [] );
-const mergeSectionsWithDefaults = ( prefSections ) => {
- if (
- ! prefSections ||
- ! Array.isArray( prefSections ) ||
- prefSections.length === 0
- ) {
- return defaultSections.reduce( ( sections, section ) => {
- return [ ...sections, { ...section } ];
- }, [] );
- }
+/**
+ * A stored section is only usable when it carries the `key` that ties it back to
+ * a default section. Corrupted `dashboard_sections` preferences have been seen
+ * holding `null` entries, which used to crash the whole dashboard.
+ *
+ * @param {*} section Entry of the stored `dashboard_sections` preference.
+ * @return {boolean} Whether the entry can be merged with a default section.
+ */
+const isValidSection = ( section ) =>
+ !! section &&
+ typeof section === 'object' &&
+ typeof section.key === 'string';
+
+/**
+ * Stored fields that take the dashboard down, or make a section unreachable,
+ * when they hold the wrong type, mapped to the check a usable value passes.
+ * `hiddenBlocks` is dereferenced by every section component, `title` is
+ * rendered as a React child, and a non boolean `isVisible` hides a section
+ * without listing it under "Add more sections". Each one is spread over the
+ * default section, so a corrupted one wins unless it is caught here.
+ * Validation, repair and the defaults all read this list so they cannot drift
+ * apart.
+ *
+ * @type {Object.<string, function(*): boolean>}
+ */
+const FIELD_CHECKS = {
+ hiddenBlocks: Array.isArray,
+ isVisible: ( value ) => typeof value === 'boolean',
+ title: ( value ) => typeof value === 'string',
+};
+
+/**
+ * Whether an entry of the stored preference carries usable values for the
+ * fields the dashboard dereferences. A missing field is fine, the default
+ * section provides it.
+ *
+ * @param {Object} section Well formed entry of the stored preference.
+ * @return {boolean} Whether every field the dashboard reads can be used as is.
+ */
+const hasUsableFields = ( section ) =>
+ Object.entries( FIELD_CHECKS ).every(
+ ( [ field, isUsable ] ) =>
+ undefined === section[ field ] || isUsable( section[ field ] )
+ );
+
+/**
+ * Whether an entry of the stored preference can be used without patching it up
+ * from a default section.
+ *
+ * @param {*} section Entry of the stored `dashboard_sections` preference.
+ * @return {boolean} Whether the entry can be used as is.
+ */
+const isUsableSection = ( section ) =>
+ isValidSection( section ) && hasUsableFields( section );
+
+/**
+ * Whether the stored `dashboard_sections` preference is well formed.
+ *
+ * @param {*} prefSections Stored `dashboard_sections` preference.
+ * @return {boolean} Whether the preference can be used as is.
+ */
+const isValidSectionsPreference = ( prefSections ) =>
+ Array.isArray( prefSections ) &&
+ prefSections.length > 0 &&
+ prefSections.every( isUsableSection );
+
+/**
+ * Whether the dashboard was never customized. An unset preference and an empty
+ * list both mean the defaults are in use, so there is nothing to repair.
+ *
+ * @param {*} prefSections Stored `dashboard_sections` preference.
+ * @return {boolean} Whether the preference holds nothing.
+ */
+const isEmptySectionsPreference = ( prefSections ) =>
+ ! prefSections ||
+ ( Array.isArray( prefSections ) && prefSections.length === 0 );
+
+/**
+ * `icon` and `component` are React nodes, they must never be persisted.
+ *
+ * @param {section} section Section to persist.
+ * @return {Object} Section without its React nodes.
+ */
+const toStorableSection = ( { icon, component, ...section } ) => section;
+
+/**
+ * Drops the fields the dashboard cannot read back, in place.
+ *
+ * @param {Object} section Section to strip.
+ * @return {Object} The same section, holding only usable values.
+ */
+const deleteUnusableFields = ( section ) => {
+ Object.entries( FIELD_CHECKS ).forEach( ( [ field, isUsable ] ) => {
+ if ( ! isUsable( section[ field ] ) ) {
+ delete section[ field ];
+ }
+ } );
+
+ return section;
+};
+
+/**
+ * A section to persist, without the fields the dashboard cannot use. There is
+ * no default to patch a corrupted field up from at this point, so it is
+ * dropped and whichever default section owns the key provides it on the next
+ * read.
+ *
+ * @param {section} section Section to persist.
+ * @return {Object} Section holding only values the dashboard can read back.
+ */
+const toUsableSection = ( section ) =>
+ deleteUnusableFields( toStorableSection( section ) );
+/**
+ * A default section is the last fallback, so it has to stand on its own. An
+ * entry with no key cannot be matched to a stored one and an entry with no
+ * component cannot be rendered, so neither is a section the dashboard can
+ * build.
+ *
+ * @param {*} section Entry returned by the default sections filter.
+ * @return {boolean} Whether a section can be built from the entry.
+ */
+const isUsableDefaultSection = ( section ) =>
+ isValidSection( section ) && !! section.component;
+
+/**
+ * Copy of the default sections, throwing a descriptive error when the
+ * `woocommerce_dashboard_default_sections` filter returned something unusable.
+ * The filter is a third party surface, so its entries get the same treatment as
+ * the stored ones: an entry no section can be built from is dropped, and a
+ * corrupted field is dropped so it cannot overwrite a valid stored value.
+ * Nothing sits behind a default to patch a field up from, except `hiddenBlocks`
+ * which every section component dereferences, so that one falls back to an
+ * empty list.
+ *
+ * @return {Array.<section>} Default sections.
+ */
+const getDefaultSections = () => {
if ( ! Array.isArray( defaultSections ) ) {
throw new Error(
`The \`defaultSections\` is not an array, please make sure \`${ DEFAULT_SECTIONS_FILTER }\` filter is used correctly.`
);
}
- const defaultKeys = defaultSections.map( ( section ) => section.key );
- const prefKeys = prefSections.map( ( section ) => section.key );
+ return defaultSections
+ .filter( isUsableDefaultSection )
+ .map( ( section ) => {
+ const usable = deleteUnusableFields( { ...section } );
+
+ if ( ! Array.isArray( usable.hiddenBlocks ) ) {
+ usable.hiddenBlocks = [];
+ }
+
+ return usable;
+ } );
+};
+
+export const mergeSectionsWithDefaults = ( prefSections ) => {
+ const defaults = getDefaultSections();
+ // Malformed entries are dropped instead of failing the whole dashboard.
+ const validPrefSections = Array.isArray( prefSections )
+ ? prefSections.filter( isValidSection )
+ : [];
+
+ if ( validPrefSections.length === 0 ) {
+ return defaults;
+ }
+
+ const defaultKeys = defaults.map( ( section ) => section.key );
+ const prefKeys = validPrefSections.map( ( section ) => section.key );
const keys = new Set( [ ...prefKeys, ...defaultKeys ] );
const sections = [];
keys.forEach( ( key ) => {
- const defaultSection = defaultSections.find(
+ const defaultSection = defaults.find(
( section ) => section.key === key
);
if ( ! defaultSection ) {
return;
}
- const prefSection = prefSections.find(
+ const prefSection = validPrefSections.find(
( section ) => section.key === key
);
- // Not defined by a string anymore.
- if ( prefSection ) {
- delete prefSection.icon;
- }
- sections.push( {
+ const section = {
...defaultSection,
- ...prefSection,
+ // A stored `icon` is a stale React node, the default one wins.
+ ...( prefSection ? toStorableSection( prefSection ) : {} ),
+ };
+
+ // The same goes for any field the dashboard cannot use, so a single
+ // corrupted field costs the merchant that field and not their whole
+ // section.
+ Object.entries( FIELD_CHECKS ).forEach( ( [ field, isUsable ] ) => {
+ if ( ! isUsable( section[ field ] ) ) {
+ section[ field ] = defaultSection[ field ];
+ }
} );
+
+ sections.push( section );
} );
return sections;
};
+/**
+ * The preference to store when repairing a corrupted one. Entries for keys the
+ * dashboard does not know about are kept, so an extension that registers a
+ * section does not lose its stored settings while it is deactivated. They are
+ * appended after the known sections, so such an entry loses its stored
+ * position.
+ *
+ * @param {Array.<section>} sections Sections the dashboard fell back to.
+ * @param {*} prefSections Stored `dashboard_sections` preference.
+ * @return {Array.<Object>} Sections to store.
+ */
+const toRepairedPreference = ( sections, prefSections ) => {
+ const knownKeys = sections.map( ( section ) => section.key );
+ const unknownSections = (
+ Array.isArray( prefSections ) ? prefSections : []
+ ).filter(
+ ( section ) =>
+ isValidSection( section ) && ! knownKeys.includes( section.key )
+ );
+
+ return [ ...sections, ...unknownSections ].map( toUsableSection );
+};
+
const CustomizableDashboard = ( { defaultDateRange, path, query } ) => {
const { updateUserPreferences, ...userPrefs } = useUserPreferences();
@@ -94,14 +277,47 @@ const CustomizableDashboard = ( { defaultDateRange, path, query } ) => {
[ userPrefs.dashboard_sections ]
);
+ // The update callbacks are handed to the section components, so a third
+ // party one can supply a value the dashboard cannot read back. Sanitizing
+ // here keeps a preference the dashboard wrote from needing a repair.
const updateSections = ( newSections ) => {
- updateUserPreferences( { dashboard_sections: newSections } );
+ updateUserPreferences( {
+ dashboard_sections: newSections.map( toUsableSection ),
+ } );
};
+ // Repair a corrupted `dashboard_sections` preference by storing the sections
+ // the dashboard fell back to. Without this the merchant keeps loading the
+ // broken value on every visit until they happen to customize a section.
+ const hasAttemptedRepair = useRef( false );
+ useEffect( () => {
+ const prefSections = userPrefs.dashboard_sections;
+
+ if (
+ hasAttemptedRepair.current ||
+ isEmptySectionsPreference( prefSections ) ||
+ isValidSectionsPreference( prefSections )
+ ) {
+ return;
+ }
+
+ hasAttemptedRepair.current = true;
+
+ const repaired = toRepairedPreference( sections, prefSections );
+
+ // Nothing to fall back to, for example when the default sections filter
+ // emptied the list. Storing this would only be repaired again on the
+ // next visit, one write per page load and no gain.
+ if ( ! isValidSectionsPreference( repaired ) ) {
+ return;
+ }
+
+ updateUserPreferences( { dashboard_sections: repaired } );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [ userPrefs.dashboard_sections ] );
+
const updateSection = ( updatedKey, newSettings ) => {
const newSections = sections.map( ( section ) => {
- // Do not save section icon as it is a component.
- delete section.icon;
if ( section.key === updatedKey ) {
return {
...section,
diff --git a/plugins/woocommerce/client/admin/client/dashboard/test/customizable.js b/plugins/woocommerce/client/admin/client/dashboard/test/customizable.js
new file mode 100644
index 00000000000..69e4581e061
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/dashboard/test/customizable.js
@@ -0,0 +1,411 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render } from '@testing-library/react';
+import { useUserPreferences } from '@woocommerce/data';
+
+/**
+ * Internal dependencies
+ */
+import CustomizableDashboard, {
+ mergeSectionsWithDefaults,
+} from '../customizable';
+
+const DEFAULT_SECTIONS = [
+ {
+ key: 'store-performance',
+ component: () => null,
+ title: 'Performance',
+ isVisible: true,
+ icon: 'arrow-right',
+ hiddenBlocks: [ 'taxes/order_tax' ],
+ },
+ {
+ key: 'charts',
+ component: () => null,
+ title: 'Charts',
+ isVisible: true,
+ icon: 'chart-bar',
+ hiddenBlocks: [ 'coupons_amount' ],
+ },
+];
+
+// Reassigned by the tests that need the `woocommerce_dashboard_default_sections`
+// filter to have returned something else. Read through a getter so the mock is
+// resolved when the dashboard dereferences it, not when the module is imported.
+let mockDefaultSections = DEFAULT_SECTIONS;
+
+jest.mock( '../default-sections', () => ( {
+ __esModule: true,
+ DEFAULT_SECTIONS_FILTER: 'woocommerce_dashboard_default_sections',
+ get default() {
+ return mockDefaultSections;
+ },
+} ) );
+
+jest.mock( '../section', () => ( { title, onRemove, onTitleUpdate } ) => (
+ <div>
+ { title }
+ <button title={ `Hide ${ title }` } onClick={ onRemove } />
+ <button
+ title={ `Rename ${ title }` }
+ onClick={ () => onTitleUpdate( { rendered: 'Renamed' } ) }
+ />
+ </div>
+) );
+
+jest.mock( '../../analytics/components/report-header', () => ( {
+ ReportHeader: () => null,
+} ) );
+
+jest.mock( '@woocommerce/data', () => ( {
+ settingsStore: 'wc/admin/settings',
+ useUserPreferences: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/data', () => ( {
+ ...jest.requireActual( '@wordpress/data' ),
+ withSelect: () => ( Component ) => ( props ) => (
+ <Component { ...props } defaultDateRange="period=month" />
+ ),
+} ) );
+
+afterEach( () => {
+ mockDefaultSections = DEFAULT_SECTIONS;
+} );
+
+describe( 'mergeSectionsWithDefaults', () => {
+ it( 'returns the defaults when nothing is stored', () => {
+ expect( mergeSectionsWithDefaults( undefined ) ).toHaveLength( 2 );
+ expect( mergeSectionsWithDefaults( '' ) ).toHaveLength( 2 );
+ expect( mergeSectionsWithDefaults( [] ) ).toHaveLength( 2 );
+ } );
+
+ it( 'returns the defaults when the stored preference is not an array', () => {
+ // `[,,]` is not valid JSON, so it reaches the dashboard as a raw string.
+ expect( mergeSectionsWithDefaults( '[,,]' ) ).toHaveLength( 2 );
+ expect( mergeSectionsWithDefaults( {} ) ).toHaveLength( 2 );
+ } );
+
+ it( 'returns the defaults when every stored section is malformed', () => {
+ expect( mergeSectionsWithDefaults( [ null, null ] ) ).toHaveLength( 2 );
+ expect(
+ mergeSectionsWithDefaults( [ undefined, 'charts', 42, {} ] )
+ ).toHaveLength( 2 );
+ } );
+
+ it( 'ignores a stored icon, which is a stale React node', () => {
+ const [ charts ] = mergeSectionsWithDefaults( [
+ { key: 'charts', icon: { props: {} } },
+ ] );
+
+ expect( charts.icon ).toBe( 'chart-bar' );
+ } );
+
+ it( 'keeps the well formed sections and drops the malformed ones', () => {
+ const sections = mergeSectionsWithDefaults( [
+ null,
+ { key: 'charts', title: 'My charts', isVisible: false },
+ ] );
+
+ expect( sections ).toHaveLength( 2 );
+ expect( sections[ 0 ] ).toMatchObject( {
+ key: 'charts',
+ title: 'My charts',
+ isVisible: false,
+ } );
+ expect( sections[ 1 ] ).toMatchObject( {
+ key: 'store-performance',
+ title: 'Performance',
+ } );
+ } );
+
+ it( 'drops stored keys that no longer exist', () => {
+ const sections = mergeSectionsWithDefaults( [ { key: 'gone' } ] );
+
+ expect( sections.map( ( section ) => section.key ) ).toEqual( [
+ 'store-performance',
+ 'charts',
+ ] );
+ } );
+
+ it( 'ignores a stored hiddenBlocks that is not an array', () => {
+ // Every section component calls `hiddenBlocks.includes()` on it.
+ const [ charts ] = mergeSectionsWithDefaults( [
+ { key: 'charts', title: 'My charts', hiddenBlocks: null },
+ ] );
+
+ expect( charts.hiddenBlocks ).toEqual( [ 'coupons_amount' ] );
+ expect( charts.title ).toBe( 'My charts' );
+ } );
+
+ it( 'ignores a stored isVisible that is not a boolean', () => {
+ // A non boolean hides the section without listing it under "Add more
+ // sections", so the merchant cannot bring it back.
+ const [ charts ] = mergeSectionsWithDefaults( [
+ { key: 'charts', title: 'My charts', isVisible: 0 },
+ ] );
+
+ expect( charts.isVisible ).toBe( true );
+ expect( charts.title ).toBe( 'My charts' );
+ } );
+
+ it( 'ignores a stored title that is not a string', () => {
+ // The title is rendered as a React child by every section header.
+ const [ charts ] = mergeSectionsWithDefaults( [
+ {
+ key: 'charts',
+ title: { rendered: 'My charts' },
+ isVisible: false,
+ },
+ ] );
+
+ expect( charts.title ).toBe( 'Charts' );
+ expect( charts.isVisible ).toBe( false );
+ } );
+
+ it( 'drops a default section that no section can be built from', () => {
+ // The default sections filter is a third party surface too.
+ mockDefaultSections = [
+ null,
+ 'charts',
+ { component: () => null, title: 'No key', isVisible: true },
+ { key: 'no-component', title: 'No component', isVisible: true },
+ ...DEFAULT_SECTIONS,
+ ];
+
+ const sections = mergeSectionsWithDefaults( undefined );
+
+ expect( sections.map( ( section ) => section.key ) ).toEqual( [
+ 'store-performance',
+ 'charts',
+ ] );
+ } );
+
+ it( 'fills in a default hiddenBlocks that is not an array', () => {
+ // Nothing sits behind a default, and every section component calls
+ // `hiddenBlocks.includes()` on it.
+ mockDefaultSections = [
+ { ...DEFAULT_SECTIONS[ 1 ], hiddenBlocks: null },
+ ];
+
+ const [ charts ] = mergeSectionsWithDefaults( [
+ { key: 'charts', hiddenBlocks: 'coupons_amount' },
+ ] );
+
+ expect( charts.hiddenBlocks ).toEqual( [] );
+ } );
+
+ it( 'does not fall back to a corrupted default field', () => {
+ mockDefaultSections = [ { ...DEFAULT_SECTIONS[ 1 ], title: {} } ];
+
+ const [ charts ] = mergeSectionsWithDefaults( [
+ { key: 'charts', title: { rendered: 'My charts' } },
+ ] );
+
+ expect( charts.title ).toBeUndefined();
+ } );
+} );
+
+describe( 'CustomizableDashboard', () => {
+ const updateUserPreferences = jest.fn();
+
+ const renderDashboard = ( dashboardSections ) => {
+ // The stored preference is JSON parsed on every render, so the dashboard
+ // gets a new reference each time and the repair has to guard itself.
+ useUserPreferences.mockImplementation( () => ( {
+ updateUserPreferences,
+ dashboard_sections: Array.isArray( dashboardSections )
+ ? [ ...dashboardSections ]
+ : dashboardSections,
+ } ) );
+
+ return render(
+ <CustomizableDashboard path="/analytics/overview" query={ {} } />
+ );
+ };
+
+ beforeEach( () => {
+ updateUserPreferences.mockReset();
+ } );
+
+ it( 'renders the default sections when the stored preference is corrupted', () => {
+ const { getByText } = renderDashboard( [ null, null ] );
+
+ expect( getByText( 'Performance' ) ).toBeInTheDocument();
+ expect( getByText( 'Charts' ) ).toBeInTheDocument();
+ } );
+
+ it( 'repairs a corrupted preference once, without the React nodes', () => {
+ const { rerender } = renderDashboard( [ null, null ] );
+
+ expect( updateUserPreferences ).toHaveBeenCalledTimes( 1 );
+ expect( updateUserPreferences ).toHaveBeenCalledWith( {
+ dashboard_sections: [
+ {
+ key: 'store-performance',
+ title: 'Performance',
+ isVisible: true,
+ hiddenBlocks: [ 'taxes/order_tax' ],
+ },
+ {
+ key: 'charts',
+ title: 'Charts',
+ isVisible: true,
+ hiddenBlocks: [ 'coupons_amount' ],
+ },
+ ],
+ } );
+
+ // The preference is re-read on every render, so the repair must not loop.
+ rerender(
+ <CustomizableDashboard path="/analytics/overview" query={ {} } />
+ );
+ expect( updateUserPreferences ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'repairs a preference stored as a raw string', () => {
+ renderDashboard( '[,,]' );
+
+ expect( updateUserPreferences ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'repairs a preference holding a corrupted hiddenBlocks', () => {
+ renderDashboard( [
+ { key: 'charts', isVisible: true, hiddenBlocks: null },
+ ] );
+
+ expect( updateUserPreferences ).toHaveBeenCalledTimes( 1 );
+ expect( updateUserPreferences ).toHaveBeenCalledWith( {
+ dashboard_sections: expect.arrayContaining( [
+ expect.objectContaining( {
+ key: 'charts',
+ hiddenBlocks: [ 'coupons_amount' ],
+ } ),
+ ] ),
+ } );
+ } );
+
+ it( 'repairs a preference holding a corrupted title', () => {
+ renderDashboard( [ { key: 'charts', isVisible: true, title: {} } ] );
+
+ expect( updateUserPreferences ).toHaveBeenCalledTimes( 1 );
+ expect( updateUserPreferences ).toHaveBeenCalledWith( {
+ dashboard_sections: expect.arrayContaining( [
+ expect.objectContaining( { key: 'charts', title: 'Charts' } ),
+ ] ),
+ } );
+ } );
+
+ it( 'repairs a preference holding a corrupted isVisible', () => {
+ renderDashboard( [ { key: 'charts', isVisible: 0 } ] );
+
+ expect( updateUserPreferences ).toHaveBeenCalledTimes( 1 );
+ expect( updateUserPreferences ).toHaveBeenCalledWith( {
+ dashboard_sections: expect.arrayContaining( [
+ expect.objectContaining( { key: 'charts', isVisible: true } ),
+ ] ),
+ } );
+ } );
+
+ it( 'keeps a stored section the dashboard does not know about', () => {
+ // A section registered by an extension that is currently deactivated.
+ renderDashboard( [
+ null,
+ { key: 'my-extension', title: 'Mine', isVisible: false },
+ ] );
+
+ expect( updateUserPreferences ).toHaveBeenCalledWith( {
+ dashboard_sections: expect.arrayContaining( [
+ { key: 'my-extension', title: 'Mine', isVisible: false },
+ ] ),
+ } );
+ } );
+
+ it( 'keeps an unknown section that holds a corrupted field', () => {
+ // There is no default to patch the field up from, so it is dropped and
+ // the rest of the entry survives.
+ renderDashboard( [
+ null,
+ {
+ key: 'my-extension',
+ title: 'Mine',
+ isVisible: false,
+ hiddenBlocks: null,
+ },
+ ] );
+
+ expect( updateUserPreferences ).toHaveBeenCalledWith( {
+ dashboard_sections: expect.arrayContaining( [
+ { key: 'my-extension', title: 'Mine', isVisible: false },
+ ] ),
+ } );
+ } );
+
+ it( 'does not store anything when there is nothing usable to fall back to', () => {
+ // Storing an empty list would only be repaired again on the next visit.
+ mockDefaultSections = [];
+
+ renderDashboard( [ null, null ] );
+
+ expect( updateUserPreferences ).not.toHaveBeenCalled();
+ } );
+
+ it( 'leaves a well formed preference alone', () => {
+ renderDashboard( [ { key: 'charts', isVisible: true } ] );
+
+ expect( updateUserPreferences ).not.toHaveBeenCalled();
+ } );
+
+ it( 'never stores the React nodes when a section is customized', () => {
+ const { getByTitle } = renderDashboard( [
+ { key: 'charts', title: 'Charts', isVisible: true },
+ {
+ key: 'store-performance',
+ title: 'Performance',
+ isVisible: true,
+ },
+ ] );
+
+ fireEvent.click( getByTitle( 'Hide Charts' ) );
+
+ const [ [ { dashboard_sections: stored } ] ] =
+ updateUserPreferences.mock.calls;
+ stored.forEach( ( section ) => {
+ expect( section ).not.toHaveProperty( 'icon' );
+ expect( section ).not.toHaveProperty( 'component' );
+ } );
+ } );
+
+ it( 'sanitizes a value a section component hands back', () => {
+ // The update callbacks are passed to third party section components.
+ const { getByTitle } = renderDashboard( [
+ { key: 'charts', title: 'Charts', isVisible: true },
+ ] );
+
+ fireEvent.click( getByTitle( 'Rename Charts' ) );
+
+ expect( updateUserPreferences ).toHaveBeenCalledWith( {
+ dashboard_sections: expect.arrayContaining( [
+ {
+ key: 'charts',
+ isVisible: true,
+ hiddenBlocks: [ 'coupons_amount' ],
+ },
+ ] ),
+ } );
+ } );
+
+ it( 'does not store anything when the dashboard was never customized', () => {
+ renderDashboard( '' );
+
+ expect( updateUserPreferences ).not.toHaveBeenCalled();
+ } );
+
+ it( 'does not store anything when the stored preference is empty', () => {
+ // An empty list means the defaults are in use, same as no preference.
+ renderDashboard( [] );
+
+ expect( updateUserPreferences ).not.toHaveBeenCalled();
+ } );
+} );