From dd70c03c9eae9fb0806e27cd390227a01f428ad3 Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Tue, 7 Jan 2025 14:48:38 +0000 Subject: [PATCH] Add: Media field changing ui to Dataviews and content preview field to posts and pages (#67278) Co-authored-by: jorgefilipecosta Co-authored-by: ntsekouras Co-authored-by: youknowriad Co-authored-by: oandregal Co-authored-by: jameskoster Co-authored-by: jasmussen --- .../dataviews-view-config/index.tsx | 212 +++++++++++++++--- .../dataviews-view-config/style.scss | 17 +- packages/dataviews/src/types.ts | 2 +- packages/editor/README.md | 2 +- .../editor/src/components/provider/index.js | 16 +- .../content-preview/content-preview-view.tsx | 108 +++++++++ .../fields/content-preview/index.tsx | 21 ++ .../fields/content-preview/style.scss | 21 ++ .../src/dataviews/store/private-actions.ts | 4 + packages/editor/src/style.scss | 1 + packages/fields/README.md | 4 + .../fields/src/fields/featured-image/index.ts | 2 +- packages/fields/src/index.ts | 2 +- packages/fields/src/types.ts | 4 + 14 files changed, 366 insertions(+), 50 deletions(-) create mode 100644 packages/editor/src/dataviews/fields/content-preview/content-preview-view.tsx create mode 100644 packages/editor/src/dataviews/fields/content-preview/index.tsx create mode 100644 packages/editor/src/dataviews/fields/content-preview/style.scss diff --git a/packages/dataviews/src/components/dataviews-view-config/index.tsx b/packages/dataviews/src/components/dataviews-view-config/index.tsx index 0b3512714e14a4..c80591caee255e 100644 --- a/packages/dataviews/src/components/dataviews-view-config/index.tsx +++ b/packages/dataviews/src/components/dataviews-view-config/index.tsx @@ -1,7 +1,8 @@ /** * External dependencies */ -import type { ChangeEvent } from 'react'; +import type { ChangeEvent, ReactNode } from 'react'; +import clsx from 'clsx'; /** * WordPress dependencies @@ -26,7 +27,7 @@ import { Icon, } from '@wordpress/components'; import { __, _x, sprintf } from '@wordpress/i18n'; -import { memo, useContext, useMemo } from '@wordpress/element'; +import { memo, useContext, useMemo, useState } from '@wordpress/element'; import { chevronDown, chevronUp, @@ -34,6 +35,7 @@ import { seen, unseen, lock, + moreVertical, } from '@wordpress/icons'; import warning from '@wordpress/warning'; import { useInstanceId } from '@wordpress/compose'; @@ -253,8 +255,66 @@ function ItemsPerPageControl() { ); } +function PreviewOptions( { + previewOptions, + onChangePreviewOption, + onMenuOpenChange, + activeOption, +}: { + previewOptions?: Array< { label: string; id: string } >; + onChangePreviewOption?: ( newPreviewOption: string ) => void; + onMenuOpenChange: ( isOpen: boolean ) => void; + activeOption?: string; +} ) { + const focusPreviewOptionsField = ( id: string ) => { + // Focus the visibility button to avoid focus loss. + // Our code is safe against the component being unmounted, so we don't need to worry about cleaning the timeout. + // eslint-disable-next-line @wordpress/react-no-unsafe-timeout + setTimeout( () => { + const element = document.querySelector( + `.dataviews-field-control__field-${ id } .dataviews-field-control__field-preview-options-button` + ); + if ( element instanceof HTMLElement ) { + element.focus(); + } + }, 50 ); + }; + return ( + + + } + /> + + { previewOptions?.map( ( { id, label } ) => { + return ( + { + onChangePreviewOption?.( id ); + focusPreviewOptionsField( id ); + } } + > + { label } + + ); + } ) } + + + ); +} function FieldItem( { field, + label, + description, isVisible, isFirst, isLast, @@ -262,8 +322,12 @@ function FieldItem( { onToggleVisibility, onMoveUp, onMoveDown, + previewOptions, + onChangePreviewOption, }: { field: NormalizedField< any >; + label?: string; + description?: string; isVisible: boolean; isFirst?: boolean; isLast?: boolean; @@ -271,7 +335,12 @@ function FieldItem( { onToggleVisibility?: () => void; onMoveUp?: () => void; onMoveDown?: () => void; + previewOptions?: Array< { label: string; id: string } >; + onChangePreviewOption?: ( newPreviewOption: string ) => void; } ) { + const [ isChangingPreviewOption, setIsChangingPreviewOption ] = + useState< boolean >( false ); + const focusVisibilityField = () => { // Focus the visibility button to avoid focus loss. // Our code is safe against the component being unmounted, so we don't need to worry about cleaning the timeout. @@ -290,7 +359,17 @@ function FieldItem( { @@ -461,7 +555,8 @@ function FieldControl() { const hiddenFields = fields.filter( ( f ) => ! visibleFieldIds.includes( f.id ) && - ! togglableFields.includes( f.id ) + ! togglableFields.includes( f.id ) && + f.type !== 'media' ); const visibleFields = visibleFieldIds .map( ( fieldId ) => fields.find( ( f ) => f.id === fieldId ) ) @@ -471,18 +566,50 @@ function FieldControl() { return null; } const titleField = fields.find( ( f ) => f.id === view.titleField ); - const mediaField = fields.find( ( f ) => f.id === view.mediaField ); + const previewField = fields.find( ( f ) => f.id === view.mediaField ); const descriptionField = fields.find( ( f ) => f.id === view.descriptionField ); + + const previewFields = fields.filter( ( f ) => f.type === 'media' ); + + let previewFieldUI; + if ( previewFields.length > 1 ) { + const isPreviewFieldVisible = + isDefined( previewField ) && ( view.showMedia ?? true ); + previewFieldUI = isDefined( previewField ) && ( + { + onChangeView( { + ...view, + showMedia: ! isPreviewFieldVisible, + } ); + } } + canMove={ false } + previewOptions={ previewFields.map( ( field ) => ( { + label: field.label, + id: field.id, + } ) ) } + onChangePreviewOption={ ( newPreviewId ) => + onChangeView( { ...view, mediaField: newPreviewId } ) + } + /> + ); + } const lockedFields = [ { field: titleField, isVisibleFlag: 'showTitle', }, { - field: mediaField, + field: previewField, isVisibleFlag: 'showMedia', + ui: previewFieldUI, }, { field: descriptionField, @@ -493,12 +620,20 @@ function FieldControl() { ( { field, isVisibleFlag } ) => // @ts-expect-error isDefined( field ) && ( view[ isVisibleFlag ] ?? true ) - ) as Array< { field: NormalizedField< any >; isVisibleFlag: string } >; + ) as Array< { + field: NormalizedField< any >; + isVisibleFlag: string; + ui?: ReactNode; + } >; const hiddenLockedFields = lockedFields.filter( ( { field, isVisibleFlag } ) => // @ts-expect-error isDefined( field ) && ! ( view[ isVisibleFlag ] ?? true ) - ) as Array< { field: NormalizedField< any >; isVisibleFlag: string } >; + ) as Array< { + field: NormalizedField< any >; + isVisibleFlag: string; + ui?: ReactNode; + } >; return ( @@ -507,20 +642,22 @@ function FieldControl() { !! visibleFields?.length ) && ( { visibleLockedFields.map( - ( { field, isVisibleFlag } ) => { + ( { field, isVisibleFlag, ui } ) => { return ( - { - onChangeView( { - ...view, - [ isVisibleFlag ]: false, - } ); - } } - canMove={ false } - /> + ui ?? ( + { + onChangeView( { + ...view, + [ isVisibleFlag ]: false, + } ); + } } + canMove={ false } + /> + ) ); } ) } @@ -550,20 +687,23 @@ function FieldControl() { { hiddenLockedFields.length > 0 && hiddenLockedFields.map( - ( { field, isVisibleFlag } ) => { + ( { field, isVisibleFlag, ui } ) => { return ( - { - onChangeView( { - ...view, - [ isVisibleFlag ]: true, - } ); - } } - canMove={ false } - /> + ui ?? ( + { + onChangeView( { + ...view, + [ isVisibleFlag ]: + true, + } ); + } } + canMove={ false } + /> + ) ); } ) } diff --git a/packages/dataviews/src/components/dataviews-view-config/style.scss b/packages/dataviews/src/components/dataviews-view-config/style.scss index 692dddfb7a90b4..fc38e345ec4ce2 100644 --- a/packages/dataviews/src/components/dataviews-view-config/style.scss +++ b/packages/dataviews/src/components/dataviews-view-config/style.scss @@ -68,7 +68,8 @@ } .dataviews-field-control__field:hover, -.dataviews-field-control__field:focus-within { +.dataviews-field-control__field:focus-within, +.dataviews-field-control__field.is-interacting { .dataviews-field-control__actions { position: unset; top: unset; @@ -80,6 +81,18 @@ width: $icon-size; } -.dataviews-field-control__label { +.dataviews-field-control__label-sub-label-container { flex-grow: 1; } + +.dataviews-field-control__label { + display: block; +} + +.dataviews-field-control__sub-label { + margin-top: $grid-unit-10; + margin-bottom: 0; + font-size: 11px; + font-style: normal; + color: $gray-700; +} diff --git a/packages/dataviews/src/types.ts b/packages/dataviews/src/types.ts index 820f75364df204..8ea13ed0b459cc 100644 --- a/packages/dataviews/src/types.ts +++ b/packages/dataviews/src/types.ts @@ -42,7 +42,7 @@ export type Operator = | 'isAll' | 'isNotAll'; -export type FieldType = 'text' | 'integer' | 'datetime'; +export type FieldType = 'text' | 'integer' | 'datetime' | 'media'; export type ValidationContext = { elements?: Option[]; diff --git a/packages/editor/README.md b/packages/editor/README.md index c006ec097982c9..3119f3f289637a 100644 --- a/packages/editor/README.md +++ b/packages/editor/README.md @@ -379,7 +379,7 @@ _Parameters_ - _props.post_ `[Object]`: The post object to edit. This is required. - _props.\_\_unstableTemplate_ `[Object]`: The template object wrapper the edited post. This is optional and can only be used when the post type supports templates (like posts and pages). - _props.settings_ `[Object]`: The settings object to use for the editor. This is optional and can be used to override the default settings. -- _props.children_ `[Element]`: Children elements for which the BlockEditorProvider context should apply. This is optional. +- _props.children_ `[React.ReactNode]`: Children elements for which the BlockEditorProvider context should apply. This is optional. _Returns_ diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index 1259eae623de93..133a52e2ce01bc 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -391,14 +391,14 @@ export const ExperimentalEditorProvider = withRegistryProvider( * * All modification and changes are performed to the `@wordpress/core-data` store. * - * @param {Object} props The component props. - * @param {Object} [props.post] The post object to edit. This is required. - * @param {Object} [props.__unstableTemplate] The template object wrapper the edited post. - * This is optional and can only be used when the post type supports templates (like posts and pages). - * @param {Object} [props.settings] The settings object to use for the editor. - * This is optional and can be used to override the default settings. - * @param {Element} [props.children] Children elements for which the BlockEditorProvider context should apply. - * This is optional. + * @param {Object} props The component props. + * @param {Object} [props.post] The post object to edit. This is required. + * @param {Object} [props.__unstableTemplate] The template object wrapper the edited post. + * This is optional and can only be used when the post type supports templates (like posts and pages). + * @param {Object} [props.settings] The settings object to use for the editor. + * This is optional and can be used to override the default settings. + * @param {React.ReactNode} [props.children] Children elements for which the BlockEditorProvider context should apply. + * This is optional. * * @example * ```jsx diff --git a/packages/editor/src/dataviews/fields/content-preview/content-preview-view.tsx b/packages/editor/src/dataviews/fields/content-preview/content-preview-view.tsx new file mode 100644 index 00000000000000..0a5b8387163083 --- /dev/null +++ b/packages/editor/src/dataviews/fields/content-preview/content-preview-view.tsx @@ -0,0 +1,108 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + BlockPreview, + privateApis as blockEditorPrivateApis, + // @ts-ignore +} from '@wordpress/block-editor'; +import type { BasePost } from '@wordpress/fields'; +import { useSelect } from '@wordpress/data'; +import { useEntityBlockEditor, store as coreStore } from '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import { EditorProvider } from '../../../components/provider'; +import { unlock } from '../../../lock-unlock'; +// @ts-ignore +import { store as editorStore } from '../../../store'; + +const { useGlobalStyle } = unlock( blockEditorPrivateApis ); + +function PostPreviewContainer( { + template, + post, +}: { + template: any; + post: any; +} ) { + const [ backgroundColor = 'white' ] = useGlobalStyle( 'color.background' ); + const [ postBlocks ] = useEntityBlockEditor( 'postType', post.type, { + id: post.id, + } ); + const [ templateBlocks ] = useEntityBlockEditor( + 'postType', + template?.type, + { + id: template?.id, + } + ); + const blocks = template && templateBlocks ? templateBlocks : postBlocks; + const isEmpty = ! blocks?.length; + return ( +
+ { isEmpty && ( + + { __( 'Empty content' ) } + + ) } + { ! isEmpty && ( + + + + ) } +
+ ); +} + +export default function PostPreviewView( { item }: { item: BasePost } ) { + const { settings, template } = useSelect( + ( select ) => { + const { canUser, getPostType, getTemplateId, getEntityRecord } = + unlock( select( coreStore ) ); + const canViewTemplate = canUser( 'read', { + kind: 'postType', + name: 'wp_template', + } ); + const _settings = select( editorStore ).getEditorSettings(); + // @ts-ignore + const supportsTemplateMode = _settings.supportsTemplateMode; + const isViewable = getPostType( item.type )?.viewable ?? false; + + const templateId = + supportsTemplateMode && isViewable && canViewTemplate + ? getTemplateId( item.type, item.id ) + : null; + return { + settings: _settings, + template: templateId + ? getEntityRecord( 'postType', 'wp_template', templateId ) + : undefined, + }; + }, + [ item.type, item.id ] + ); + // Wrap everything in a block editor provider to ensure 'styles' that are needed + // for the previews are synced between the site editor store and the block editor store. + // Additionally we need to have the `__experimentalBlockPatterns` setting in order to + // render patterns inside the previews. + // TODO: Same approach is used in the patterns list and it becomes obvious that some of + // the block editor settings are needed in context where we don't have the block editor. + // Explore how we can solve this in a better way. + return ( + + + + ); +} diff --git a/packages/editor/src/dataviews/fields/content-preview/index.tsx b/packages/editor/src/dataviews/fields/content-preview/index.tsx new file mode 100644 index 00000000000000..5dadc599ea232e --- /dev/null +++ b/packages/editor/src/dataviews/fields/content-preview/index.tsx @@ -0,0 +1,21 @@ +/** + * WordPress dependencies + */ +import type { Field } from '@wordpress/dataviews'; +import type { BasePost } from '@wordpress/fields'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import PostPreviewView from './content-preview-view'; + +const postPreviewField: Field< BasePost > = { + type: 'media', + id: 'content-preview', + label: __( 'Content preview' ), + render: PostPreviewView, + enableSorting: false, +}; + +export default postPreviewField; diff --git a/packages/editor/src/dataviews/fields/content-preview/style.scss b/packages/editor/src/dataviews/fields/content-preview/style.scss new file mode 100644 index 00000000000000..4f204dc5108c9b --- /dev/null +++ b/packages/editor/src/dataviews/fields/content-preview/style.scss @@ -0,0 +1,21 @@ +.editor-fields-content-preview { + display: flex; + flex-direction: column; + height: 100%; + border-radius: $radius-medium; + + .dataviews-view-table & { + width: 96px; + flex-grow: 0; + } + + .block-editor-block-preview__container, + .editor-fields-content-preview__empty { + margin-top: auto; + margin-bottom: auto; + } +} + +.editor-fields-content-preview__empty { + text-align: center; +} diff --git a/packages/editor/src/dataviews/store/private-actions.ts b/packages/editor/src/dataviews/store/private-actions.ts index 2119b52756e964..82c2c8911c7c96 100644 --- a/packages/editor/src/dataviews/store/private-actions.ts +++ b/packages/editor/src/dataviews/store/private-actions.ts @@ -38,6 +38,7 @@ import { * Internal dependencies */ import { store as editorStore } from '../../store'; +import postPreviewField from '../fields/content-preview'; import { unlock } from '../../lock-unlock'; export function registerEntityAction< Item >( @@ -175,6 +176,9 @@ export const registerPostTypeSchema = postTypeConfig.supports?.comments && commentStatusField, templateField, passwordField, + postTypeConfig.supports?.editor && + postTypeConfig.viewable && + postPreviewField, ].filter( Boolean ); if ( postTypeConfig.supports?.title ) { let _titleField; diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss index 1a8103ae2b16c4..c3366d6aa2266f 100644 --- a/packages/editor/src/style.scss +++ b/packages/editor/src/style.scss @@ -54,3 +54,4 @@ @import "./components/table-of-contents/style.scss"; @import "./components/text-editor/style.scss"; @import "./components/visual-editor/style.scss"; +@import "./dataviews/fields/content-preview/style.scss"; diff --git a/packages/fields/README.md b/packages/fields/README.md index 9ca08991aca51f..e8224a1e4849ac 100644 --- a/packages/fields/README.md +++ b/packages/fields/README.md @@ -18,6 +18,10 @@ npm install @wordpress/fields --save Author field for BasePost. +### BasePost + +Undocumented declaration. + ### BasePostWithEmbeddedAuthor Undocumented declaration. diff --git a/packages/fields/src/fields/featured-image/index.ts b/packages/fields/src/fields/featured-image/index.ts index d6f22176fc6704..7e17fb482e01c9 100644 --- a/packages/fields/src/fields/featured-image/index.ts +++ b/packages/fields/src/fields/featured-image/index.ts @@ -13,7 +13,7 @@ import { FeaturedImageView } from './featured-image-view'; const featuredImageField: Field< BasePost > = { id: 'featured_media', - type: 'text', + type: 'media', label: __( 'Featured Image' ), Edit: FeaturedImageEdit, render: FeaturedImageView, diff --git a/packages/fields/src/index.ts b/packages/fields/src/index.ts index 1658c9d8c51eee..bf1e4dfda2ddfd 100644 --- a/packages/fields/src/index.ts +++ b/packages/fields/src/index.ts @@ -1,4 +1,4 @@ export * from './fields'; export * from './actions'; export { default as CreateTemplatePartModal } from './components/create-template-part-modal'; -export type { BasePostWithEmbeddedAuthor, PostType } from './types'; +export type { BasePostWithEmbeddedAuthor, BasePost, PostType } from './types'; diff --git a/packages/fields/src/types.ts b/packages/fields/src/types.ts index 1b251d125b1be8..d9594c58e09793 100644 --- a/packages/fields/src/types.ts +++ b/packages/fields/src/types.ts @@ -32,6 +32,9 @@ interface EmbeddedAuthor { author: Author[]; } +/** + * BasePost interface used for all post types. + */ export interface BasePost extends CommonPost { comment_status?: 'open' | 'closed'; excerpt?: string | { raw: string; rendered: string }; @@ -100,6 +103,7 @@ export interface PostType { author?: string; thumbnail?: string; comments?: string; + editor?: boolean; }; }