diff --git a/public_html/wp-content/plugins/pattern-creator/package.json b/public_html/wp-content/plugins/pattern-creator/package.json index abfd604b1..f084ac127 100644 --- a/public_html/wp-content/plugins/pattern-creator/package.json +++ b/public_html/wp-content/plugins/pattern-creator/package.json @@ -9,7 +9,7 @@ "dev": "NODE_ENV=development wp-scripts build", "format:js": "wp-scripts format src -- --config=../../../../.prettierrc.js", "lint:css": "wp-scripts lint-style 'style.css' 'src/**/*.scss'", - "lint:js": "wp-scripts lint-js src", + "lint:js": "wp-scripts lint-js src --max-warnings=0", "start": "wp-scripts start", "test:unit": "wp-scripts test-unit-js" }, diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/editor/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/editor/index.js index 29ca43386..052b8558e 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/editor/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/editor/index.js @@ -45,18 +45,21 @@ const interfaceLabels = { }; function Editor( { onError, postId } ) { - const { isInserterOpen, isListViewOpen, post, sidebarIsOpened, settings } = useSelect( ( select ) => { - const { isInserterOpened, isListViewOpened, getSettings } = select( patternStore ); - const { getEntityRecord } = select( coreStore ); + const { isInserterOpen, isListViewOpen, post, sidebarIsOpened, settings } = useSelect( + ( select ) => { + const { isInserterOpened, isListViewOpened, getSettings } = select( patternStore ); + const { getEntityRecord } = select( coreStore ); - return { - isInserterOpen: isInserterOpened(), - isListViewOpen: isListViewOpened(), - post: getEntityRecord( 'postType', POST_TYPE, postId ), - sidebarIsOpened: !! select( interfaceStore ).getActiveComplementaryArea( STORE_NAME ), - settings: getSettings(), - }; - }, [] ); + return { + isInserterOpen: isInserterOpened(), + isListViewOpen: isListViewOpened(), + post: getEntityRecord( 'postType', POST_TYPE, postId ), + sidebarIsOpened: !! select( interfaceStore ).getActiveComplementaryArea( STORE_NAME ), + settings: getSettings(), + }; + }, + [ postId ] + ); const { setIsInserterOpened } = useDispatch( patternStore ); const { createInfoNotice } = useDispatch( noticesStore ); const [ isEntitiesSavedStatesOpen, setIsEntitiesSavedStatesOpen ] = useState( false ); @@ -73,7 +76,7 @@ function Editor( { onError, postId } ) { isDismissible: false, } ); } - }, [] ); + }, [ createInfoNotice ] ); // Don't render the Editor until the settings are set and loaded if ( ! settings?.siteUrl || ! post ) { diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/editor/test/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/editor/test/index.js new file mode 100644 index 000000000..dbcb8e643 --- /dev/null +++ b/public_html/wp-content/plugins/pattern-creator/src/components/editor/test/index.js @@ -0,0 +1,142 @@ +/** + * WordPress dependencies + */ +import { createElement, createRoot } from '@wordpress/element'; + +/* + * `@wordpress/element` renders with the React copy it bundles, which is a + * different instance than the one at the repo root (kept for forward-compat + * work). Pull `act` from that same copy so the render and its hooks share a + * single React dispatcher, otherwise React throws an invalid-hook-call error. + */ +const { act } = jest.requireActual( + require.resolve( 'react', { paths: [ require.resolve( '@wordpress/element' ) ] } ) +); + +const mockGetEntityRecord = jest.fn(); +// A stable reference: `useSelect` warns in dev if the mapping returns a new +// object for the same state, and the editor short-circuits when `siteUrl` is +// absent regardless. +const mockSettings = {}; + +/* + * Replace the stores with minimal stand-ins so `useSelect` (the unit under + * test) stays real while `@wordpress/core-data` and friends are not pulled in. + * `getSettings` returns no `siteUrl`, so the editor short-circuits to `null` + * before mounting its heavy render tree — but the `useSelect` mapping still + * runs, which is what reads `postId`. + */ +const mockMakeStore = ( name, selectors, actions = {} ) => { + const { createReduxStore, register } = jest.requireActual( '@wordpress/data' ); + const store = createReduxStore( name, { reducer: ( state = {} ) => state, selectors, actions } ); + register( store ); + return store; +}; + +jest.mock( '../../../store', () => ( { + POST_TYPE: 'wporg-pattern', + store: mockMakeStore( + 'test/editor-pattern', + { + isInserterOpened: () => false, + isListViewOpened: () => false, + getSettings: () => mockSettings, + }, + { setIsInserterOpened: () => ( { type: 'NOOP' } ) } + ), +} ) ); +jest.mock( '@wordpress/core-data', () => ( { + store: mockMakeStore( 'test/editor-core', { + getEntityRecord: ( state, kind, postType, id ) => mockGetEntityRecord( kind, postType, id ), + } ), +} ) ); +jest.mock( '@wordpress/notices', () => ( { + store: mockMakeStore( 'test/editor-notices', {}, { createInfoNotice: () => ( { type: 'NOOP' } ) } ), +} ) ); +jest.mock( '@wordpress/interface', () => { + const { createElement: el } = jest.requireActual( '@wordpress/element' ); + const Noop = () => null; + return { + store: mockMakeStore( 'test/editor-interface', { getActiveComplementaryArea: () => undefined } ), + ComplementaryArea: Object.assign( Noop, { Slot: Noop } ), + FullscreenMode: Noop, + InterfaceSkeleton: () => el( 'div', null ), + }; +} ); + +/* Stub the heavy components so importing the editor module stays cheap. */ +const Noop = () => null; +jest.mock( '@wordpress/editor', () => ( { + EditorNotices: Noop, + EditorProvider: Noop, + EditorSnackbars: Noop, + ErrorBoundary: Noop, + UnsavedChangesWarning: Noop, +} ) ); +jest.mock( '@wordpress/components', () => { + const { createElement: el } = jest.requireActual( '@wordpress/element' ); + const N = () => null; + return { + Notice: N, + Popover: Object.assign( N, { Slot: N } ), + SlotFillProvider: ( { children } ) => el( 'div', null, children ), + }; +} ); +jest.mock( '@wordpress/block-editor', () => ( { BlockBreadcrumb: Noop } ) ); +jest.mock( '@wordpress/keyboard-shortcuts', () => ( { ShortcutProvider: () => null } ) ); +jest.mock( '../../block-editor', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../../header', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../../secondary-sidebar/inserter-sidebar', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../../secondary-sidebar/list-view-sidebar', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../../keyboard-shortcuts', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../../sidebar', () => ( { SidebarComplementaryAreaFills: Noop } ) ); +jest.mock( '../../url-controller', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../../welcome-guide', () => ( { __esModule: true, default: Noop } ) ); +jest.mock( '../save-sidebar', () => ( { __esModule: true, default: Noop } ) ); + +describe( 'Editor', () => { + let Editor; + let container; + let root; + + beforeAll( () => { + global.IS_REACT_ACT_ENVIRONMENT = true; + global.wporgLocale = { id: 'en_US' }; + Editor = require( '../index' ).default; + } ); + + beforeEach( () => { + container = document.createElement( 'div' ); + document.body.appendChild( container ); + root = createRoot( container ); + mockGetEntityRecord.mockReset(); + } ); + + afterEach( () => { + act( () => root.unmount() ); + container.remove(); + } ); + + const render = ( postId ) => { + act( () => { + root.render( createElement( Editor, { postId, onError: () => {} } ) ); + } ); + }; + + it( 'looks up the post for the given postId', () => { + render( 1 ); + + expect( mockGetEntityRecord ).toHaveBeenCalledWith( 'postType', 'wporg-pattern', 1 ); + } ); + + it( 'looks up the new post when postId changes', () => { + render( 1 ); + mockGetEntityRecord.mockClear(); + + // Re-render with a different postId. Without `postId` in the `useSelect` + // dependency array, the memoized selector keeps reading the first id. + render( 2 ); + + expect( mockGetEntityRecord ).toHaveBeenCalledWith( 'postType', 'wporg-pattern', 2 ); + } ); +} ); diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/index.js index 96b893d88..c97b29df0 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/index.js @@ -26,9 +26,7 @@ export default function FeatureToggle( { feature, label, info, messageActivated, } }; - const isActive = useSelect( ( select ) => { - return select( patternStore ).isFeatureActive( feature ); - }, [] ); + const isActive = useSelect( ( select ) => select( patternStore ).isFeatureActive( feature ), [ feature ] ); const { toggleFeature } = useDispatch( patternStore ); diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/test/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/test/index.js new file mode 100644 index 000000000..9c12b897c --- /dev/null +++ b/public_html/wp-content/plugins/pattern-creator/src/components/header/feature-toggle/test/index.js @@ -0,0 +1,111 @@ +/** + * WordPress dependencies + */ +import { createElement, createRoot } from '@wordpress/element'; +import { dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { store as patternStore } from '../../../../store'; + +/* + * `@wordpress/element` renders with the React copy it bundles, which is a + * different instance than the one at the repo root (kept for forward-compat + * work). Pull `act` from that same copy so the render and its hooks share a + * single React dispatcher, otherwise React throws an invalid-hook-call error. + */ +const { act } = jest.requireActual( + require.resolve( 'react', { paths: [ require.resolve( '@wordpress/element' ) ] } ) +); + +/* + * Replace the real store with a minimal stand-in that mirrors `isFeatureActive` + * / `toggleFeature`. This keeps `useSelect` (the unit under test) real while + * avoiding the heavy `@wordpress/core-data` graph the real store pulls in. + */ +jest.mock( '../../../../store', () => { + const { createReduxStore, register } = jest.requireActual( '@wordpress/data' ); + const store = createReduxStore( 'test/feature-toggle', { + reducer: ( state = {}, action ) => + action.type === 'TOGGLE_FEATURE' ? { ...state, [ action.feature ]: ! state[ action.feature ] } : state, + actions: { + toggleFeature: ( feature ) => ( { type: 'TOGGLE_FEATURE', feature } ), + }, + selectors: { + isFeatureActive: ( state, feature ) => !! state[ feature ], + }, + } ); + register( store ); + return { store }; +} ); + +/* + * The unit under test is the `useSelect` dependency array that derives the + * selected state, not the `MenuItem` chrome. Stub it so the markup exposes that + * state directly. + */ +jest.mock( '@wordpress/components', () => { + const { createElement: el } = jest.requireActual( '@wordpress/element' ); + return { + MenuItem: ( { isSelected, children } ) => + el( 'button', { type: 'button', 'aria-checked': String( !! isSelected ) }, children ), + }; +} ); + +describe( 'FeatureToggle', () => { + let FeatureToggle; + let container; + let root; + + beforeAll( () => { + global.IS_REACT_ACT_ENVIRONMENT = true; + FeatureToggle = require( '../index' ).default; + } ); + + beforeEach( () => { + container = document.createElement( 'div' ); + document.body.appendChild( container ); + root = createRoot( container ); + } ); + + afterEach( () => { + act( () => root.unmount() ); + container.remove(); + } ); + + const render = ( feature ) => { + act( () => { + root.render( createElement( FeatureToggle, { feature, label: 'Toggle' } ) ); + } ); + }; + + const isActive = () => container.querySelector( 'button' ).getAttribute( 'aria-checked' ); + + it( 'reflects the active state of the feature it is given', () => { + dispatch( patternStore ).toggleFeature( 'reflects-active' ); + + render( 'reflects-active' ); + + expect( isActive() ).toBe( 'true' ); + } ); + + it( 'reports an untouched feature as inactive', () => { + render( 'never-toggled' ); + + expect( isActive() ).toBe( 'false' ); + } ); + + it( 'updates when the feature prop changes', () => { + dispatch( patternStore ).toggleFeature( 'prop-change-on' ); + + render( 'prop-change-on' ); + expect( isActive() ).toBe( 'true' ); + + // Re-render with a different, inactive feature. Without `feature` in the + // `useSelect` dependency array, the stale closure keeps reporting the + // first feature's state. + render( 'prop-change-off' ); + expect( isActive() ).toBe( 'false' ); + } ); +} ); diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/openverse/grid.js b/public_html/wp-content/plugins/pattern-creator/src/components/openverse/grid.js index 21d6fd035..b644909bd 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/openverse/grid.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/openverse/grid.js @@ -52,7 +52,7 @@ export default function OpenverseGrid( { searchTerm, onClose, onSelect, multiple // Set up a debounced search term, so we don't query constantly while someone is typing. useEffect( () => { setDebouncedSearchTerm( searchTerm ); - }, [ searchTerm ] ); + }, [ searchTerm, setDebouncedSearchTerm ] ); // When the search changes, reset back to page 1, and trigger a search. useEffect( () => { @@ -91,7 +91,7 @@ export default function OpenverseGrid( { searchTerm, onClose, onSelect, multiple } onClose(); - }, [ selected, multiple ] ); + }, [ selected, multiple, onSelect, onClose ] ); const onClick = useCallback( ( newValue ) => { diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/openverse/test/grid.js b/public_html/wp-content/plugins/pattern-creator/src/components/openverse/test/grid.js new file mode 100644 index 000000000..a2ca718f7 --- /dev/null +++ b/public_html/wp-content/plugins/pattern-creator/src/components/openverse/test/grid.js @@ -0,0 +1,147 @@ +/** + * WordPress dependencies + */ +import { createElement, createRoot } from '@wordpress/element'; + +/* + * `@wordpress/element` renders with the React copy it bundles, which is a + * different instance than the one at the repo root (kept for forward-compat + * work). Pull `act` from that same copy so the render and its hooks share a + * single React dispatcher, otherwise React throws an invalid-hook-call error. + */ +const { act } = jest.requireActual( + require.resolve( 'react', { paths: [ require.resolve( '@wordpress/element' ) ] } ) +); + +const mockFetchImages = jest.fn(); +jest.mock( '../utils', () => ( { + fetchImages: ( ...args ) => mockFetchImages( ...args ), +} ) ); + +// Make the search debounce synchronous so the fetch fires within `act`. +jest.mock( '@wordpress/compose', () => ( { + useDebounce: ( fn ) => fn, +} ) ); + +/* + * Stub the chrome so the test drives selection and commits directly. The unit + * under test is `onCommitSelected`'s dependency array, not the grid layout. + */ +jest.mock( '@wordpress/components', () => { + const { createElement: el } = jest.requireActual( '@wordpress/element' ); + return { + Button: ( { onClick, children } ) => el( 'button', { onClick }, children ), + Notice: ( { children } ) => el( 'div', null, children ), + Spinner: () => el( 'span', null, 'Loading' ), + }; +} ); +jest.mock( '../grid-items', () => { + const { createElement: el } = jest.requireActual( '@wordpress/element' ); + return { + __esModule: true, + default: ( { items, onSelect } ) => + el( + 'div', + null, + items.map( ( item ) => + el( 'button', { key: item.id, onClick: () => onSelect( item ) }, `select-${ item.id }` ) + ) + ), + }; +} ); +jest.mock( '../grid-actions', () => { + const { createElement: el } = jest.requireActual( '@wordpress/element' ); + return { + __esModule: true, + default: ( { actions } ) => el( 'div', null, actions ), + }; +} ); +jest.mock( '../pagination', () => ( { + __esModule: true, + default: () => null, +} ) ); + +describe( 'OpenverseGrid', () => { + let OpenverseGrid; + let container; + let root; + + beforeAll( () => { + global.IS_REACT_ACT_ENVIRONMENT = true; + OpenverseGrid = require( '../grid' ).default; + } ); + + beforeEach( () => { + mockFetchImages.mockResolvedValue( { + results: [ { id: 1, url: 'https://example.com/1.jpg', title: 'Cat' } ], + total: 1, + totalPages: 1, + } ); + container = document.createElement( 'div' ); + document.body.appendChild( container ); + root = createRoot( container ); + } ); + + afterEach( () => { + act( () => root.unmount() ); + container.remove(); + mockFetchImages.mockReset(); + } ); + + const renderGrid = async ( props ) => { + await act( async () => { + root.render( createElement( OpenverseGrid, { searchTerm: 'cat', multiple: false, ...props } ) ); + } ); + // Flush the resolved fetch and the follow-up render it triggers. + await act( async () => {} ); + }; + + const button = ( text ) => + [ ...container.querySelectorAll( 'button' ) ].find( ( el ) => el.textContent === text ); + + const click = ( el ) => act( () => el.click() ); + + it( 'commits the selected image to the current onSelect / onClose props', async () => { + const onSelect = jest.fn(); + const onClose = jest.fn(); + + await renderGrid( { onSelect, onClose } ); + click( button( 'select-1' ) ); + click( button( 'Add media' ) ); + + expect( onSelect ).toHaveBeenCalledWith( + expect.objectContaining( { url: 'https://example.com/1.jpg', caption: 'Cat' } ) + ); + expect( onClose ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'commits to the latest props after they change', async () => { + const firstSelect = jest.fn(); + const firstClose = jest.fn(); + const latestSelect = jest.fn(); + const latestClose = jest.fn(); + + await renderGrid( { onSelect: firstSelect, onClose: firstClose } ); + click( button( 'select-1' ) ); + + // Swap the callbacks while a selection is held. Without `onSelect` / + // `onClose` in the dependency array, the memoized handler keeps calling + // the stale props. + await act( async () => { + root.render( + createElement( OpenverseGrid, { + searchTerm: 'cat', + multiple: false, + onSelect: latestSelect, + onClose: latestClose, + } ) + ); + } ); + click( button( 'Add media' ) ); + + expect( latestSelect ).toHaveBeenCalledTimes( 1 ); + expect( latestClose ).toHaveBeenCalledTimes( 1 ); + expect( firstSelect ).not.toHaveBeenCalled(); + expect( firstClose ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/pattern-categories-control/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/pattern-categories-control/index.js index dbb27b701..7104d152a 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/pattern-categories-control/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/pattern-categories-control/index.js @@ -131,8 +131,9 @@ function PatternCategoriesControl( { selectedTerms = EMPTY_ARRAY, setTerms } ) { const availableTermsTree = useMemo( () => sortBySelected( availableTerms, selectedTerms ), - // Remove `terms` from the dependency list to avoid reordering every time - // checking or unchecking a term. + // `selectedTerms` is intentionally omitted from the dependency list to + // avoid reordering the list every time a term is checked or unchecked. + // eslint-disable-next-line react-hooks/exhaustive-deps [ availableTerms ] ); diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/index.js index c0fbf9831..317bdc5c3 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/index.js @@ -42,7 +42,7 @@ export function SidebarComplementaryAreaFills() { } else { enableComplementaryArea( STORE_NAME, SIDEBAR_PATTERN ); } - }, [ hasBlockSelection, isEditorSidebarOpened ] ); + }, [ hasBlockSelection, isEditorSidebarOpened, enableComplementaryArea ] ); let sidebarName = sidebar; if ( ! isEditorSidebarOpened ) { diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/pattern-settings/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/pattern-settings/index.js index 724af1318..2f86fee65 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/pattern-settings/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/sidebar/pattern-settings/index.js @@ -39,19 +39,31 @@ function PatternSettings() { } ); const { editPost } = useDispatch( editorStore ); - const setTitle = useCallback( ( value ) => { - editPost( { title: value } ); - } ); - const setDescription = useCallback( ( value ) => { - editPost( { meta: { ...meta, wpop_description: value } } ); - } ); - const setCategories = useCallback( ( value ) => { - editPost( { 'pattern-categories': value } ); - } ); - const setKeywords = useCallback( ( value ) => { - const keywordsString = value.join( ', ' ); - editPost( { meta: { ...meta, [ KEYWORD_SLUG ]: keywordsString } } ); - } ); + const setTitle = useCallback( + ( value ) => { + editPost( { title: value } ); + }, + [ editPost ] + ); + const setDescription = useCallback( + ( value ) => { + editPost( { meta: { ...meta, wpop_description: value } } ); + }, + [ editPost, meta ] + ); + const setCategories = useCallback( + ( value ) => { + editPost( { 'pattern-categories': value } ); + }, + [ editPost ] + ); + const setKeywords = useCallback( + ( value ) => { + const keywordsString = value.join( ', ' ); + editPost( { meta: { ...meta, [ KEYWORD_SLUG ]: keywordsString } } ); + }, + [ editPost, meta ] + ); return ( <> diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/index.js index 4b8161c30..ba9d70c4a 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/submission-modal/index.js @@ -79,6 +79,10 @@ export default function SubmissionModal( { onClose, onSubmit, status } ) { title, 'pattern-categories': selectedCategories, } ); + // Only sync the editor when the local form fields change. `meta` is + // intentionally omitted: this effect edits `meta`, so including it would + // loop, and `editPost` is a stable dispatcher. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ title, description, selectedCategories ] ); const goBack = () => { diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/index.js index 634da1c92..4a8c72fde 100644 --- a/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/index.js +++ b/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/index.js @@ -18,7 +18,7 @@ export default function UrlController( { postId } ) { const newUrl = `${ BASE_URL }/pattern/${ postId }/edit/`; window.history.replaceState( {}, '', newUrl ); } - }, [ post.status ] ); + }, [ post.status, postId ] ); return null; } diff --git a/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/test/index.js b/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/test/index.js new file mode 100644 index 000000000..6b750add7 --- /dev/null +++ b/public_html/wp-content/plugins/pattern-creator/src/components/url-controller/test/index.js @@ -0,0 +1,99 @@ +/** + * WordPress dependencies + */ +import { createElement, createRoot } from '@wordpress/element'; + +/* + * `@wordpress/element` renders with the React copy it bundles, which is a + * different instance than the one at the repo root (kept for forward-compat + * work). Pull `act` from that same copy so the render and its hooks share a + * single React dispatcher, otherwise React throws an invalid-hook-call error. + */ +const { act } = jest.requireActual( + require.resolve( 'react', { paths: [ require.resolve( '@wordpress/element' ) ] } ) +); + +/* + * Mock the data and store layers so the test can drive the `post` value + * directly. `useSelect` is replaced, but `useEffect` (from `@wordpress/element`) + * is left intact so the test exercises the real effect dependency array. + */ +let mockPost; +jest.mock( '@wordpress/data', () => ( { + useSelect: () => mockPost, +} ) ); +jest.mock( '@wordpress/core-data', () => ( { + store: 'core', +} ) ); +jest.mock( '../../../store', () => ( { + POST_TYPE: 'wporg-pattern', +} ) ); + +describe( 'UrlController', () => { + let UrlController; + let container; + let root; + + beforeAll( () => { + global.IS_REACT_ACT_ENVIRONMENT = true; + global.wporgBlockPattern = { siteUrl: 'https://example.com' }; + // `BASE_URL` is read at module load, so require after the global is set. + UrlController = require( '../index' ).default; + } ); + + beforeEach( () => { + container = document.createElement( 'div' ); + document.body.appendChild( container ); + root = createRoot( container ); + jest.spyOn( window.history, 'replaceState' ).mockImplementation( () => {} ); + } ); + + afterEach( () => { + act( () => root.unmount() ); + container.remove(); + jest.restoreAllMocks(); + } ); + + const render = ( postId ) => { + act( () => { + root.render( createElement( UrlController, { postId } ) ); + } ); + }; + + it( 'updates the URL once the post is no longer an auto-draft', () => { + mockPost = { status: 'publish' }; + + render( 1 ); + + expect( window.history.replaceState ).toHaveBeenCalledWith( + {}, + '', + 'https://example.com/pattern/1/edit/' + ); + } ); + + it( 'leaves the URL alone while the post is still an auto-draft', () => { + mockPost = { status: 'auto-draft' }; + + render( 1 ); + + expect( window.history.replaceState ).not.toHaveBeenCalled(); + } ); + + it( 'updates the URL when only the postId changes', () => { + mockPost = { status: 'publish' }; + + render( 1 ); + window.history.replaceState.mockClear(); + + // The status is unchanged; only `postId` changes. Without `postId` in the + // effect's dependency array the URL would keep the stale id. + render( 2 ); + + expect( window.history.replaceState ).toHaveBeenCalledWith( + {}, + '', + 'https://example.com/pattern/2/edit/' + ); + } ); +} ); diff --git a/public_html/wp-content/plugins/pattern-directory/package.json b/public_html/wp-content/plugins/pattern-directory/package.json index b1615e2ef..0e123b181 100644 --- a/public_html/wp-content/plugins/pattern-directory/package.json +++ b/public_html/wp-content/plugins/pattern-directory/package.json @@ -9,7 +9,7 @@ "dev": "NODE_ENV=development wp-scripts build pattern-post-type=./src/pattern-post-type.js", "format:js": "wp-scripts format src -- --config=../../../../.prettierrc.js", "lint:css": "wp-scripts lint-style 'src/**/*.scss'", - "lint:js": "wp-scripts lint-js src", + "lint:js": "wp-scripts lint-js src --max-warnings=0", "start": "wp-scripts start pattern-post-type=./src/pattern-post-type.js", "test:unit": "echo \"No JS tests.\"" }, diff --git a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/notice.js b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/notice.js index a1e28ec8d..7e433eda2 100644 --- a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/notice.js +++ b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/notice.js @@ -33,7 +33,7 @@ const UnlistNotice = () => { } else { removeNotice( NOTICE_ID ); } - }, [ status ] ); + }, [ status, createNotice, removeNotice ] ); return null; }; diff --git a/public_html/wp-content/themes/wporg-pattern-directory-2024/package.json b/public_html/wp-content/themes/wporg-pattern-directory-2024/package.json index 77ae3b2aa..1f6adf46d 100644 --- a/public_html/wp-content/themes/wporg-pattern-directory-2024/package.json +++ b/public_html/wp-content/themes/wporg-pattern-directory-2024/package.json @@ -27,7 +27,7 @@ "scripts": { "build": "wp-scripts build --experimental-modules", "start": "wp-scripts start --experimental-modules", - "lint:js": "wp-scripts lint-js src", + "lint:js": "wp-scripts lint-js src --max-warnings=0", "lint:css": "wp-scripts lint-style src/**/*.scss", "format": "wp-scripts format src -- --config=../../../../.prettierrc.js" }