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
10 changes: 5 additions & 5 deletions packages/editor/src/private-apis.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
/**
* WordPress dependencies
*/
import {
CreateTemplatePartModal,
patternTitleField,
templateTitleField,
} from '@wordpress/fields';
import * as interfaceApis from '@wordpress/interface';

/**
Expand All @@ -18,11 +23,6 @@ import usePostFields from './components/post-fields';
import ToolsMoreMenuGroup from './components/more-menu/tools-more-menu-group';
import ViewMoreMenuGroup from './components/more-menu/view-more-menu-group';
import ResizableEditor from './components/resizable-editor';
import {
CreateTemplatePartModal,
patternTitleField,
templateTitleField,
} from '@wordpress/fields';
import { registerCoreBlockBindingsSources } from './bindings/api';
import { getTemplateInfo } from './utils/get-template-info';
import GlobalStylesUIWrapper from './components/global-styles';
Expand Down
39 changes: 39 additions & 0 deletions packages/fields/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,45 @@ Export action as JSON for Pattern.

Featured Image field for BasePostWithEmbeddedFeaturedMedia.

### MediaEdit

A media edit control component that provides a media picker UI with upload functionality for selecting WordPress media attachments. Supports both the traditional WordPress media library and the experimental DataViews media modal.

This component is intended to be used as the `Edit` property of a field definition when registering fields with `registerEntityField` from `@wordpress/editor`.

_Usage_

```tsx
import { MediaEdit } from '@wordpress/fields';
import type { DataFormControlProps } from '@wordpress/dataviews';

const featuredImageField = {
id: 'featured_media',
type: 'media',
label: 'Featured Image',
Edit: ( props: DataFormControlProps< MyPostType > ) => (
<MediaEdit { ...props } allowedTypes={ [ 'image' ] } />
),
};
```

_Parameters_

- _props_ `MediaEditProps<Item>`: - The component props.
- _props.data_ `Item`: - The item being edited.
- _props.field_ `Object`: - The field configuration with getValue and setValue methods.
- _props.onChange_ `Function`: - Callback function when the media selection changes.
- _props.allowedTypes_ `[string[]]`: - Array of allowed media types. Default `['image']`.
- _props.multiple_ `[boolean]`: - Whether to allow multiple media selections. Default `false`.

_Returns_

- `JSX.Element`: The media edit control component.

### MediaEditProps

Undocumented declaration.

### notesField

Notes count field for post types that support editor.notes.
Expand Down
306 changes: 306 additions & 0 deletions packages/fields/src/components/media-edit/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
/**
* WordPress dependencies
*/
import {
Button,
Icon,
__experimentalText as Text,
__experimentalTruncate as Truncate,
__experimentalVStack as VStack,
Tooltip,
VisuallyHidden,
} from '@wordpress/components';
import { store as coreStore, type Attachment } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
import { useCallback, useState } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { archive, audio, video, file } from '@wordpress/icons';
import {
MediaUpload,
privateApis as mediaUtilsPrivateApis,
} from '@wordpress/media-utils';

/**
* Internal dependencies
*/
import { unlock } from '../../lock-unlock';
import type { MediaEditProps } from '../../types';

const { MediaUploadModal } = unlock( mediaUtilsPrivateApis );

/**
* Conditional Media component that uses MediaUploadModal when experiment is enabled,
* otherwise falls back to media-utils MediaUpload.
*
* @param root0 Component props.
* @param root0.render Render prop function that receives { open } object.
* @param root0.multiple Whether to allow multiple media selections.
* @return The component.
*/
function ConditionalMediaUpload( { render, multiple, ...props }: any ) {
const [ isModalOpen, setIsModalOpen ] = useState( false );
if ( ( window as any ).__experimentalDataViewsMediaModal ) {
return (
<>
{ render && render( { open: () => setIsModalOpen( true ) } ) }
{ isModalOpen && (
<MediaUploadModal
{ ...props }
multiple={ multiple }
isOpen={ isModalOpen }
onClose={ () => {
setIsModalOpen( false );
props.onClose?.();
} }
onSelect={ ( media: any ) => {
setIsModalOpen( false );
props.onSelect?.( media );
} }
/>
) }
</>
);
}
// Fallback to media-utils MediaUpload when experiment is disabled.
return (
<MediaUpload
{ ...props }
render={ render }
multiple={ multiple ? 'add' : undefined }
/>
);
}

function MediaPickerButton( {
open,
children,
label,
showTooltip = false,
}: {
open: () => void;
children: React.ReactNode;
label: string;
showTooltip?: boolean;
} ) {
const mediaPickerButton = (
<div
className="fields__media-edit-picker-button"
role="button"
tabIndex={ 0 }
onClick={ open }
onKeyDown={ ( event ) => {
if ( event.key === 'Enter' || event.key === ' ' ) {
event.preventDefault();
open();
}
} }
aria-label={ label }
Comment thread
ntsekouras marked this conversation as resolved.
>
{ children }
</div>
Comment on lines +86 to +100
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.

Can this be a real button? Or do you want to turn this into a drop zone next? From the design, it looks like a drop zone to me.

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.

That's a good question. I didn't think of it much because it was existing implementation to have the wrapper as a div. For now it could be a button I think, but also I had in mind that this also would become a drop zone. I'm not sure if the drop zone affects the tag we use, but we can update then if needed.

);
if ( ! showTooltip ) {
return mediaPickerButton;
}
return <Tooltip text={ label }>{ mediaPickerButton }</Tooltip>;
}

const archiveMimeTypes = [
'application/zip',
'application/x-zip-compressed',
'application/x-rar-compressed',
'application/x-7z-compressed',
'application/x-tar',
'application/x-gzip',
];

function MediaPreview( {
url,
attachment,
}: {
url: string;
attachment: Attachment< 'view' > | null;
} ) {
if ( ! attachment ) {
return null;
}
const attachmentTitle = attachment.title.rendered;
const mimeType = attachment.mime_type;
let preview: JSX.Element = <Icon icon={ file } />;
if ( mimeType.startsWith( 'image/' ) ) {
preview = (
<img
className="fields__media-edit-thumbnail"
alt={ attachment.alt_text || '' }
src={ url }
/>
);
} else if ( mimeType.startsWith( 'audio/' ) ) {
preview = <Icon icon={ audio } />;
} else if ( mimeType.startsWith( 'video/' ) ) {
preview = <Icon icon={ video } />;
} else if ( archiveMimeTypes.includes( mimeType ) ) {
preview = <Icon icon={ archive } />;
}
return (
<>
{ preview }
<Truncate className="fields__media-edit-filename">
{ attachmentTitle }
</Truncate>
</>
);
}

/**
* A media edit control component that provides a media picker UI with upload functionality
* for selecting WordPress media attachments. Supports both the traditional WordPress media
* library and the experimental DataViews media modal.
*
* This component is intended to be used as the `Edit` property of a field definition when
* registering fields with `registerEntityField` from `@wordpress/editor`.
*
* @template Item - The type of the item being edited.
*
* @param {MediaEditProps<Item>} props - The component props.
* @param {Item} props.data - The item being edited.
* @param {Object} props.field - The field configuration with getValue and setValue methods.
* @param {Function} props.onChange - Callback function when the media selection changes.
Comment on lines +166 to +168
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.

This is missing other DataFormControlProps:

  • hideLabelFromVision: I notice this component doesn't come with a label. What if we have a label but hide it when hideLabelFromVision is true? That's how other components handle it (array Edit, regular layout sets hideLabelFromVision when labelPosition: none).
  • validity: does this Edit handle validation? What happens if the field is required, for example?
  • operator: I suppose this Edit can't act as a filter UI, so this can also be ignored.
  • config: this can be ignored.

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.

hideLabelFromVision and validity will need design and could possibly be in follow ups. For now I included a label hidden from vision.

The challenge with a label for this control is that in core (quick edit) we use the regular layout and mix it with panel layout for other fields. This means we cannot reliably handle the label inside the edit component to apply proper styles in any form layout.

@jameskoster any ideas for these?

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.

For hiding the label when in regular layout, we should use labelPosition: none to hide it in that form (example in storybook).

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.

btw, fine to be follow-ups 👍

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I notice this component doesn't come with a label

It should support one like in the designs.

For validation can we not use the standard design? I think we're still missing a way to display the validation message in panel layout when the label is hidden, but that's something to address 'globally'.

* @param {string[]} [props.allowedTypes] - Array of allowed media types. Default `['image']`.
* @param {boolean} [props.multiple] - Whether to allow multiple media selections. Default `false`.
*
* @return {JSX.Element} The media edit control component.
*
* @example
* ```tsx
* import { MediaEdit } from '@wordpress/fields';
* import type { DataFormControlProps } from '@wordpress/dataviews';
*
* const featuredImageField = {
* id: 'featured_media',
* type: 'media',
* label: 'Featured Image',
* Edit: (props: DataFormControlProps<MyPostType>) => (
* <MediaEdit
* {...props}
* allowedTypes={['image']}
* />
* ),
* };
* ```
*/
export default function MediaEdit< Item >( {
data,
field,
onChange,
allowedTypes = [ 'image' ],
multiple,
}: MediaEditProps< Item > ) {
const value = field.getValue( { item: data } );
const attachments = useSelect(
( select ) => {
if ( ! value ) {
return null;
}
const normalizedValue = Array.isArray( value ) ? value : [ value ];
const { getEntityRecords } = select( coreStore );
return getEntityRecords( 'postType', 'attachment', {
include: normalizedValue,
} ) as Attachment< 'view' >[] | null;
},
[ value ]
);
const onChangeControl = useCallback(
( newValue: number | number[] | undefined ) =>
onChange( field.setValue( { item: data, value: newValue } ) ),
[ data, field, onChange ]
);
const removeItem = ( itemId: number ) => {
const currentIds = Array.isArray( value ) ? value : [ value ];
const newIds = currentIds.filter( ( id ) => id !== itemId );
onChangeControl( newIds.length ? newIds : undefined );
};
return (
<fieldset className="fields__media-edit" data-field-id={ field.id }>
<ConditionalMediaUpload
onSelect={ ( selectedMedia: any ) => {
if ( multiple ) {
const newIds = Array.isArray( selectedMedia )
? selectedMedia.map( ( m: any ) => m.id )
: [ selectedMedia.id ];
onChangeControl( newIds );
} else {
onChangeControl( selectedMedia.id );
}
} }
allowedTypes={ allowedTypes }
value={ value }
multiple={ multiple }
title={ field.label }
render={ ( { open }: any ) => {
const addButtonLabel = attachments?.length
? __( 'Add files' )
: field.placeholder || __( 'Choose file' );
return (
<VStack spacing={ 2 }>
<VisuallyHidden as="label">
{ field.label }
</VisuallyHidden>
{ !! attachments?.length && (
<VStack spacing={ 2 }>
{ attachments.map( ( attachment ) => (
<div
key={ attachment.id }
className="fields__media-edit-row"
>
<MediaPickerButton
open={ open }
label={ __( 'Replace' ) }
showTooltip
>
<MediaPreview
url={
attachment.source_url
}
attachment={ attachment }
/>
</MediaPickerButton>
<Button
__next40pxDefaultSize
className="fields__media-edit-remove"
text={ __( 'Remove' ) }
variant="secondary"
onClick={ (
event: React.MouseEvent< HTMLButtonElement >
) => {
event.stopPropagation();
removeItem( attachment.id );
} }
/>
</div>
) ) }
</VStack>
) }
{ ( multiple || ! attachments?.length ) && (
<MediaPickerButton
open={ open }
label={ addButtonLabel }
>
<span className="fields__media-edit-placeholder">
{ addButtonLabel }
</span>
</MediaPickerButton>
) }

{ field.description && (
<Text variant="muted">
{ field.description }
</Text>
) }
</VStack>
);
} }
/>
</fieldset>
);
}
Loading
Loading