Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions packages/media-utils/src/components/media-upload-modal/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,19 @@ const NOTICES_CONTEXT = 'media-modal';
// Notice ID - reused for all upload-related notices to prevent flooding
const NOTICE_ID_UPLOAD_PROGRESS = 'media-modal-upload-progress';

type ViewQueryParams = Pick< View, 'page' | 'search' >;

const defaultQueryParams: ViewQueryParams = {
page: 1,
search: '',
};

const defaultView: View = {
type: LAYOUT_PICKER_GRID,
fields: [],
showTitle: false,
titleField: 'title',
mediaField: 'media_thumbnail',
search: '',
page: 1,
perPage: 50,
filters: [],
layout: {
Expand Down Expand Up @@ -230,15 +235,32 @@ export function MediaUploadModal( {
useDispatch( noticesStore );
const invalidateAttachmentResolutions =
useInvalidateAttachmentResolutions();
const [ queryParams, setQueryParams ] = useState< ViewQueryParams >(
() => defaultQueryParams
);

// Persist view configuration across sessions via the preferences store.
const { view, updateView, isModified, resetToDefault } = useView( {
kind: 'postType',
name: 'attachment',
slug: 'media-modal',
defaultView,
queryParams,
onChangeQueryParams: setQueryParams,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing I noticed is that the params persist. So when I reopen the modal with a search or pagination state, that's the first view I see and I have to clear the search/return to the first page if I want to see the start of my lib.

Is that intentional?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, good catch! It's a quick fix, so I'll update it to clear out when the modal closes, and add a test.

@andrewserong andrewserong May 1, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in: f882fe9

(I went with a useEffect instead of in handleModalClose as I imagine we want to clear this out both when the modal is closed and when an item is selected and then the modal closes)

} );

// Normalize undefined transient DataViews values so they do not persist as modified modal preferences.
const handleChangeView = useCallback(
( nextView: View ) => {
const normalizedView = { ...nextView };
if ( normalizedView.startPosition === undefined ) {
delete normalizedView.startPosition;
}
updateView( normalizedView );
},
[ updateView ]
);

// Build query args based on view properties, similar to PostList
const queryArgs = useMemo( () => {
const filters: Record< string, any > = {};
Expand Down Expand Up @@ -444,6 +466,12 @@ export function MediaUploadModal( {
onClose?.();
}, [ removeAllNotices, onClose ] );

useEffect( () => {
if ( ! isOpen ) {
setQueryParams( defaultQueryParams );
}
}, [ isOpen ] );

// Use onUpload if provided, otherwise fall back to uploadMedia
const handleUpload = onUpload || uploadMedia;

Expand Down Expand Up @@ -581,7 +609,7 @@ export function MediaUploadModal( {
data={ mediaRecords || [] }
fields={ fields }
view={ view }
onChangeView={ updateView }
onChangeView={ handleChangeView }
actions={ actions }
selection={ selection }
onChangeSelection={ setSelection }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* External dependencies
*/
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

/**
* WordPress dependencies
*/
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { privateApis as coreDataPrivateApis } from '@wordpress/core-data';
import { store as noticesStore } from '@wordpress/notices';
import { store as preferencesStore } from '@wordpress/preferences';

/**
* Internal dependencies
*/
import { MediaUploadModal } from '../index';
import { unlock } from '../../../lock-unlock';

const preferenceKey = 'dataviews-postType-attachment-media-modal';

jest.mock( '@wordpress/core-data', () => {
const { __dangerousOptInToUnstableAPIsOnlyForCoreModules } =
jest.requireActual( '@wordpress/private-apis' );
const { lock } = __dangerousOptInToUnstableAPIsOnlyForCoreModules(
'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.',
'@wordpress/core-data'
);
// Keep the private API contract real while replacing only the data hook.
const privateApis = {};
lock( privateApis, {
useEntityRecordsWithPermissions: jest.fn(),
} );

return {
privateApis,
store: {
name: 'core',
},
};
} );

const mockUseEntityRecordsWithPermissions = unlock( coreDataPrivateApis )
.useEntityRecordsWithPermissions as jest.Mock;

function renderModal( { isOpen = true } = {} ) {
const registry = createRegistry();
registry.register( noticesStore );
registry.register( preferencesStore );

const onSelect = jest.fn();
const onClose = jest.fn();

const view = render(
<RegistryProvider value={ registry }>
<MediaUploadModal
isOpen={ isOpen }
onSelect={ onSelect }
onClose={ onClose }
/>
</RegistryProvider>
);

const rerender = ( props: { isOpen: boolean } ) => {
view.rerender(
<RegistryProvider value={ registry }>
<MediaUploadModal
{ ...props }
onSelect={ onSelect }
onClose={ onClose }
/>
</RegistryProvider>
);
};

return { ...view, registry, rerender };
}

describe( 'MediaUploadModal', () => {
beforeEach( () => {
mockUseEntityRecordsWithPermissions.mockReturnValue( {
records: [],
isResolving: false,
totalItems: 100,
totalPages: 2,
} );
} );

afterEach( () => {
jest.clearAllMocks();
} );

it( 'resets page and search when the modal is closed and reopened', async () => {
const user = userEvent.setup();
const { rerender } = renderModal();

await user.click( screen.getByRole( 'button', { name: 'Next page' } ) );

await waitFor( () => {
expect(
mockUseEntityRecordsWithPermissions
).toHaveBeenLastCalledWith(
'postType',
'attachment',
expect.objectContaining( { page: 2 } )
);
} );

// Close the modal.
rerender( { isOpen: false } );

// Reopen the modal.
rerender( { isOpen: true } );

await waitFor( () => {
expect(
mockUseEntityRecordsWithPermissions
).toHaveBeenLastCalledWith(
'postType',
'attachment',
expect.objectContaining( { page: 1, search: '' } )
);
} );
} );

it( 'updates the media query when the picker changes page', async () => {
const user = userEvent.setup();
const { registry } = renderModal();

expect( mockUseEntityRecordsWithPermissions ).toHaveBeenLastCalledWith(
'postType',
'attachment',
expect.objectContaining( {
page: 1,
} )
);

await user.click( screen.getByRole( 'button', { name: 'Next page' } ) );

await waitFor( () => {
expect(
mockUseEntityRecordsWithPermissions
).toHaveBeenLastCalledWith(
'postType',
'attachment',
expect.objectContaining( {
page: 2,
} )
);
} );
expect(
registry
.select( preferencesStore )
.get( 'core/views', preferenceKey )
).toBeUndefined();
} );

it( 'updates the media query when the picker changes search', async () => {
const user = userEvent.setup();
const { registry } = renderModal();

await user.type(
screen.getByRole( 'searchbox', { name: 'Search media' } ),
'cat'
);

await waitFor( () => {
expect(
mockUseEntityRecordsWithPermissions
).toHaveBeenLastCalledWith(
'postType',
'attachment',
expect.objectContaining( {
page: 1,
search: 'cat',
} )
);
} );
expect(
registry
.select( preferencesStore )
.get( 'core/views', preferenceKey )
).toBeUndefined();
} );
} );
Loading