diff --git a/lib/compat/wordpress-6.6/option.php b/lib/compat/wordpress-6.6/option.php new file mode 100644 index 00000000000000..71907fa62dd09c --- /dev/null +++ b/lib/compat/wordpress-6.6/option.php @@ -0,0 +1,50 @@ + __( 'Title' ), + 'blogdescription' => __( 'Tagline' ), + 'site_logo' => __( 'Logo' ), + 'site_icon' => __( 'Icon' ), + 'show_on_front' => __( 'Show on front' ), + 'page_on_front' => __( 'Page on front' ), + 'posts_per_page' => __( 'Maximum posts per page' ), + 'default_comment_status' => __( 'Allow comments on new posts' ), + ); + + if ( isset( $settings_label_map[ $option_name ] ) ) { + $args['label'] = $settings_label_map[ $option_name ]; + } + + // Don't update schema when label isn't provided. + if ( ! isset( $args['label'] ) ) { + return $args; + } + + $schema = array( 'title' => $args['label'] ); + if ( ! is_array( $args['show_in_rest'] ) ) { + $args['show_in_rest'] = array( + 'schema' => $schema, + ); + return $args; + } + + if ( ! empty( $args['show_in_rest']['schema'] ) ) { + $args['show_in_rest']['schema'] = array_merge( $args['show_in_rest']['schema'], $schema ); + } else { + $args['show_in_rest']['schema'] = $schema; + } + + return $args; +} +add_filter( 'register_setting_args', 'gutenberg_update_initial_settings', 10, 4 ); diff --git a/lib/load.php b/lib/load.php index a9ce52385ab48f..da0b04d2f269d0 100644 --- a/lib/load.php +++ b/lib/load.php @@ -126,6 +126,7 @@ function gutenberg_is_experiment_enabled( $name ) { // WordPress 6.6 compat. require __DIR__ . '/compat/wordpress-6.6/block-bindings/pattern-overrides.php'; +require __DIR__ . '/compat/wordpress-6.6/option.php'; // Experimental features. require __DIR__ . '/experimental/block-editor-settings-mobile.php'; diff --git a/packages/core-data/src/actions.js b/packages/core-data/src/actions.js index 49776d0562984f..454250e8ad13c9 100644 --- a/packages/core-data/src/actions.js +++ b/packages/core-data/src/actions.js @@ -286,7 +286,7 @@ export const deleteEntityRecord = { __unstableFetch = apiFetch, throwOnError = false } = {} ) => async ( { dispatch } ) => { - const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const configs = await dispatch( getOrLoadEntitiesConfig( kind, name ) ); const entityConfig = configs.find( ( config ) => config.kind === kind && config.name === name ); @@ -503,7 +503,7 @@ export const saveEntityRecord = } = {} ) => async ( { select, resolveSelect, dispatch } ) => { - const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const configs = await dispatch( getOrLoadEntitiesConfig( kind, name ) ); const entityConfig = configs.find( ( config ) => config.kind === kind && config.name === name ); @@ -780,7 +780,7 @@ export const saveEditedEntityRecord = if ( ! select.hasEditsForEntityRecord( kind, name, recordId ) ) { return; } - const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const configs = await dispatch( getOrLoadEntitiesConfig( kind, name ) ); const entityConfig = configs.find( ( config ) => config.kind === kind && config.name === name ); @@ -824,7 +824,7 @@ export const __experimentalSaveSpecifiedEntityEdits = setNestedValue( editsToSave, item, getNestedValue( edits, item ) ); } - const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const configs = await dispatch( getOrLoadEntitiesConfig( kind, name ) ); const entityConfig = configs.find( ( config ) => config.kind === kind && config.name === name ); @@ -948,7 +948,7 @@ export function receiveDefaultTemplateId( query, templateId ) { export const receiveRevisions = ( kind, name, recordKey, records, query, invalidateCache = false, meta ) => async ( { dispatch } ) => { - const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const configs = await dispatch( getOrLoadEntitiesConfig( kind, name ) ); const entityConfig = configs.find( ( config ) => config.kind === kind && config.name === name ); diff --git a/packages/core-data/src/entities.js b/packages/core-data/src/entities.js index 4587f6891c505b..e91744110faf32 100644 --- a/packages/core-data/src/entities.js +++ b/packages/core-data/src/entities.js @@ -61,36 +61,6 @@ export const rootEntitiesConfig = [ syncObjectType: 'root/base', getSyncObjectId: () => 'index', }, - { - label: __( 'Site' ), - name: 'site', - kind: 'root', - baseURL: '/wp/v2/settings', - // The entity doesn't support selecting multiple records. - // The property is maintained for backward compatibility. - plural: 'sites', - getTitle: ( record ) => { - return record?.title ?? __( 'Site Title' ); - }, - syncConfig: { - fetch: async () => { - return apiFetch( { path: '/wp/v2/settings' } ); - }, - applyChangesToDoc: ( doc, changes ) => { - const document = doc.getMap( 'document' ); - Object.entries( changes ).forEach( ( [ key, value ] ) => { - if ( document.get( key ) !== value ) { - document.set( key, value ); - } - } ); - }, - fromCRDTDoc: ( doc ) => { - return doc.getMap( 'document' ).toJSON(); - }, - }, - syncObjectType: 'root/site', - getSyncObjectId: () => 'index', - }, { label: __( 'Post Type' ), name: 'postType', @@ -253,6 +223,12 @@ export const rootEntitiesConfig = [ export const additionalEntityConfigLoaders = [ { kind: 'postType', loadEntities: loadPostTypeEntities }, { kind: 'taxonomy', loadEntities: loadTaxonomyEntities }, + { + kind: 'root', + name: 'site', + plural: 'sites', + loadEntities: loadSiteEntity, + }, ]; /** @@ -409,6 +385,56 @@ async function loadTaxonomyEntities() { } ); } +/** + * Returns the Site entity. + * + * @return {Promise} Entity promise + */ +async function loadSiteEntity() { + const entity = { + label: __( 'Site' ), + name: 'site', + kind: 'root', + baseURL: '/wp/v2/settings', + syncConfig: { + fetch: async () => { + return apiFetch( { path: '/wp/v2/settings' } ); + }, + applyChangesToDoc: ( doc, changes ) => { + const document = doc.getMap( 'document' ); + Object.entries( changes ).forEach( ( [ key, value ] ) => { + if ( document.get( key ) !== value ) { + document.set( key, value ); + } + } ); + }, + fromCRDTDoc: ( doc ) => { + return doc.getMap( 'document' ).toJSON(); + }, + }, + syncObjectType: 'root/site', + getSyncObjectId: () => 'index', + meta: {}, + }; + + const site = await apiFetch( { + path: entity.baseURL, + method: 'OPTIONS', + } ); + + const labels = {}; + Object.entries( site?.schema?.properties ?? {} ).forEach( + ( [ key, value ] ) => { + // Ignore properties `title` and `type` keys. + if ( typeof value === 'object' && value.title ) { + labels[ key ] = value.title; + } + } + ); + + return [ { ...entity, meta: { labels } } ]; +} + /** * Returns the entity's getter method name given its kind and name or plural name. * @@ -443,17 +469,21 @@ function registerSyncConfigs( configs ) { } /** - * Loads the kind entities into the store. + * Loads the entities into the store. * - * @param {string} kind Kind + * Note: The `name` argument is used for `root` entities requiring additional server data. * + * @param {string} kind Kind + * @param {string} name Name * @return {(thunkArgs: object) => Promise} Entities */ export const getOrLoadEntitiesConfig = - ( kind ) => + ( kind, name ) => async ( { select, dispatch } ) => { let configs = select.getEntitiesConfig( kind ); - if ( configs && configs.length !== 0 ) { + const hasConfig = !! select.getEntityConfig( kind, name ); + + if ( configs?.length > 0 && hasConfig ) { if ( window.__experimentalEnableSync ) { if ( process.env.IS_GUTENBERG_PLUGIN ) { registerSyncConfigs( configs ); @@ -463,9 +493,13 @@ export const getOrLoadEntitiesConfig = return configs; } - const loader = additionalEntityConfigLoaders.find( - ( l ) => l.kind === kind - ); + const loader = additionalEntityConfigLoaders.find( ( l ) => { + if ( ! name || ! l.name ) { + return l.kind === kind; + } + + return l.kind === kind && l.name === name; + } ); if ( ! loader ) { return []; } diff --git a/packages/core-data/src/index.js b/packages/core-data/src/index.js index 484622c6e40302..bd25fa8de9902b 100644 --- a/packages/core-data/src/index.js +++ b/packages/core-data/src/index.js @@ -12,7 +12,11 @@ import * as privateSelectors from './private-selectors'; import * as actions from './actions'; import * as resolvers from './resolvers'; import createLocksActions from './locks/actions'; -import { rootEntitiesConfig, getMethodName } from './entities'; +import { + rootEntitiesConfig, + additionalEntityConfigLoaders, + getMethodName, +} from './entities'; import { STORE_NAME } from './name'; import { unlock } from './private-apis'; @@ -20,8 +24,12 @@ import { unlock } from './private-apis'; // (getEntityRecord, getEntityRecords, updateEntityRecord, updateEntityRecords) // Instead of getEntityRecord, the consumer could use more user-friendly named selector: getPostType, getTaxonomy... // The "kind" and the "name" of the entity are combined to generate these shortcuts. +const entitiesConfig = [ + ...rootEntitiesConfig, + ...additionalEntityConfigLoaders.filter( ( config ) => !! config.name ), +]; -const entitySelectors = rootEntitiesConfig.reduce( ( result, entity ) => { +const entitySelectors = entitiesConfig.reduce( ( result, entity ) => { const { kind, name, plural } = entity; result[ getMethodName( kind, name ) ] = ( state, key, query ) => selectors.getEntityRecord( state, kind, name, key, query ); @@ -33,7 +41,7 @@ const entitySelectors = rootEntitiesConfig.reduce( ( result, entity ) => { return result; }, {} ); -const entityResolvers = rootEntitiesConfig.reduce( ( result, entity ) => { +const entityResolvers = entitiesConfig.reduce( ( result, entity ) => { const { kind, name, plural } = entity; result[ getMethodName( kind, name ) ] = ( key, query ) => resolvers.getEntityRecord( kind, name, key, query ); @@ -48,7 +56,7 @@ const entityResolvers = rootEntitiesConfig.reduce( ( result, entity ) => { return result; }, {} ); -const entityActions = rootEntitiesConfig.reduce( ( result, entity ) => { +const entityActions = entitiesConfig.reduce( ( result, entity ) => { const { kind, name } = entity; result[ getMethodName( kind, name, 'save' ) ] = ( record, options ) => actions.saveEntityRecord( kind, name, record, options ); diff --git a/packages/core-data/src/resolvers.js b/packages/core-data/src/resolvers.js index 154222dc183ebf..bc9a0ce28d9e94 100644 --- a/packages/core-data/src/resolvers.js +++ b/packages/core-data/src/resolvers.js @@ -59,7 +59,7 @@ export const getCurrentUser = export const getEntityRecord = ( kind, name, key = '', query ) => async ( { select, dispatch } ) => { - const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const configs = await dispatch( getOrLoadEntitiesConfig( kind, name ) ); const entityConfig = configs.find( ( config ) => config.name === name && config.kind === kind ); @@ -194,7 +194,7 @@ export const getEditedEntityRecord = forwardResolver( 'getEntityRecord' ); export const getEntityRecords = ( kind, name, query = {} ) => async ( { dispatch } ) => { - const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const configs = await dispatch( getOrLoadEntitiesConfig( kind, name ) ); const entityConfig = configs.find( ( config ) => config.name === name && config.kind === kind ); @@ -429,7 +429,7 @@ export const canUser = export const canUserEditEntityRecord = ( kind, name, recordId ) => async ( { dispatch } ) => { - const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const configs = await dispatch( getOrLoadEntitiesConfig( kind, name ) ); const entityConfig = configs.find( ( config ) => config.name === name && config.kind === kind ); @@ -726,7 +726,7 @@ export const getDefaultTemplateId = export const getRevisions = ( kind, name, recordKey, query = {} ) => async ( { dispatch } ) => { - const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const configs = await dispatch( getOrLoadEntitiesConfig( kind, name ) ); const entityConfig = configs.find( ( config ) => config.name === name && config.kind === kind ); @@ -851,7 +851,7 @@ getRevisions.shouldInvalidate = ( action, kind, name, recordKey ) => export const getRevision = ( kind, name, recordKey, revisionKey, query ) => async ( { dispatch } ) => { - const configs = await dispatch( getOrLoadEntitiesConfig( kind ) ); + const configs = await dispatch( getOrLoadEntitiesConfig( kind, name ) ); const entityConfig = configs.find( ( config ) => config.name === name && config.kind === kind ); diff --git a/packages/core-data/src/test/entities.js b/packages/core-data/src/test/entities.js index e142f9bf70c6c7..27b3d56e76865d 100644 --- a/packages/core-data/src/test/entities.js +++ b/packages/core-data/src/test/entities.js @@ -52,9 +52,16 @@ describe( 'getKindEntities', () => { const dispatch = jest.fn(); const select = { getEntitiesConfig: jest.fn( () => entities ), + getEntityConfig: jest.fn( () => ( { + kind: 'postType', + name: 'post', + } ) ), }; const entities = [ { kind: 'postType' } ]; - await getOrLoadEntitiesConfig( 'postType' )( { dispatch, select } ); + await getOrLoadEntitiesConfig( + 'postType', + 'post' + )( { dispatch, select } ); expect( dispatch ).not.toHaveBeenCalled(); } ); @@ -62,8 +69,12 @@ describe( 'getKindEntities', () => { const dispatch = jest.fn(); const select = { getEntitiesConfig: jest.fn( () => [] ), + getEntityConfig: jest.fn( () => undefined ), }; - await getOrLoadEntitiesConfig( 'unknownKind' )( { dispatch, select } ); + await getOrLoadEntitiesConfig( + 'unknownKind', + undefined + )( { dispatch, select } ); expect( dispatch ).not.toHaveBeenCalled(); } ); @@ -82,10 +93,14 @@ describe( 'getKindEntities', () => { const dispatch = jest.fn(); const select = { getEntitiesConfig: jest.fn( () => [] ), + getEntityConfig: jest.fn( () => undefined ), }; triggerFetch.mockImplementation( () => fetchedEntities ); - await getOrLoadEntitiesConfig( 'postType' )( { dispatch, select } ); + await getOrLoadEntitiesConfig( + 'postType', + 'post' + )( { dispatch, select } ); expect( dispatch ).toHaveBeenCalledTimes( 1 ); expect( dispatch.mock.calls[ 0 ][ 0 ].type ).toBe( 'ADD_ENTITIES' ); expect( dispatch.mock.calls[ 0 ][ 0 ].entities.length ).toBe( 1 ); diff --git a/packages/editor/src/components/entities-saved-states/hooks/use-is-dirty.js b/packages/editor/src/components/entities-saved-states/hooks/use-is-dirty.js index e630c60b8c633d..1103dcabe201ae 100644 --- a/packages/editor/src/components/entities-saved-states/hooks/use-is-dirty.js +++ b/packages/editor/src/components/entities-saved-states/hooks/use-is-dirty.js @@ -4,29 +4,24 @@ import { useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { useMemo, useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; - -const TRANSLATED_SITE_PROPERTIES = { - title: __( 'Title' ), - description: __( 'Tagline' ), - site_logo: __( 'Logo' ), - site_icon: __( 'Icon' ), - show_on_front: __( 'Show on front' ), - page_on_front: __( 'Page on front' ), - posts_per_page: __( 'Maximum posts per page' ), - default_comment_status: __( 'Allow comments on new posts' ), -}; export const useIsDirty = () => { - const { editedEntities, siteEdits } = useSelect( ( select ) => { - const { __experimentalGetDirtyEntityRecords, getEntityRecordEdits } = - select( coreStore ); + const { editedEntities, siteEdits, siteEntityConfig } = useSelect( + ( select ) => { + const { + __experimentalGetDirtyEntityRecords, + getEntityRecordEdits, + getEntityConfig, + } = select( coreStore ); - return { - editedEntities: __experimentalGetDirtyEntityRecords(), - siteEdits: getEntityRecordEdits( 'root', 'site' ), - }; - }, [] ); + return { + editedEntities: __experimentalGetDirtyEntityRecords(), + siteEdits: getEntityRecordEdits( 'root', 'site' ), + siteEntityConfig: getEntityConfig( 'root', 'site' ), + }; + }, + [] + ); const dirtyEntityRecords = useMemo( () => { // Remove site object and decouple into its edited pieces. @@ -34,18 +29,19 @@ export const useIsDirty = () => { ( record ) => ! ( record.kind === 'root' && record.name === 'site' ) ); + const siteEntityLabels = siteEntityConfig?.meta?.labels ?? {}; const editedSiteEntities = []; for ( const property in siteEdits ) { editedSiteEntities.push( { kind: 'root', name: 'site', - title: TRANSLATED_SITE_PROPERTIES[ property ] || property, + title: siteEntityLabels[ property ] || property, property, } ); } return [ ...editedEntitiesWithoutSite, ...editedSiteEntities ]; - }, [ editedEntities, siteEdits ] ); + }, [ editedEntities, siteEdits, siteEntityConfig ] ); // Unchecked entities to be ignored by save function. const [ unselectedEntities, _setUnselectedEntities ] = useState( [] ); diff --git a/packages/editor/src/components/entities-saved-states/test/use-is-dirty.js b/packages/editor/src/components/entities-saved-states/test/use-is-dirty.js index 04b6b4e566ef1f..287e990a14f92b 100644 --- a/packages/editor/src/components/entities-saved-states/test/use-is-dirty.js +++ b/packages/editor/src/components/entities-saved-states/test/use-is-dirty.js @@ -32,6 +32,9 @@ jest.mock( '@wordpress/data', () => { getEntityRecordEdits: jest.fn().mockReturnValue( { title: 'My Site', } ), + getEntityConfig: jest.fn().mockReturnValue( { + meta: { labels: { title: 'Title' } }, + } ), }; }; return fn( select );