@wordpress/core-data 7.51.0 → 7.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/build/actions.cjs +54 -1
  3. package/build/actions.cjs.map +3 -3
  4. package/build/components/entities-saved-states/entity-record-item.cjs +67 -0
  5. package/build/components/entities-saved-states/entity-record-item.cjs.map +7 -0
  6. package/build/components/entities-saved-states/entity-type-list.cjs +135 -0
  7. package/build/components/entities-saved-states/entity-type-list.cjs.map +7 -0
  8. package/build/components/entities-saved-states/hooks/use-is-dirty.cjs +97 -0
  9. package/build/components/entities-saved-states/hooks/use-is-dirty.cjs.map +7 -0
  10. package/build/components/entities-saved-states/index.cjs +215 -0
  11. package/build/components/entities-saved-states/index.cjs.map +7 -0
  12. package/build/entities.cjs +2 -0
  13. package/build/entities.cjs.map +2 -2
  14. package/build/private-actions.cjs +85 -0
  15. package/build/private-actions.cjs.map +3 -3
  16. package/build/private-apis.cjs +19 -0
  17. package/build/private-apis.cjs.map +3 -3
  18. package/build/utils/crdt.cjs +4 -2
  19. package/build/utils/crdt.cjs.map +2 -2
  20. package/build/utils/get-template-info.cjs +53 -0
  21. package/build/utils/get-template-info.cjs.map +7 -0
  22. package/build/utils/get-template-part-icon.cjs +43 -0
  23. package/build/utils/get-template-part-icon.cjs.map +7 -0
  24. package/build-module/actions.mjs +57 -1
  25. package/build-module/actions.mjs.map +2 -2
  26. package/build-module/components/entities-saved-states/entity-record-item.mjs +46 -0
  27. package/build-module/components/entities-saved-states/entity-record-item.mjs.map +7 -0
  28. package/build-module/components/entities-saved-states/entity-type-list.mjs +104 -0
  29. package/build-module/components/entities-saved-states/entity-type-list.mjs.map +7 -0
  30. package/build-module/components/entities-saved-states/hooks/use-is-dirty.mjs +72 -0
  31. package/build-module/components/entities-saved-states/hooks/use-is-dirty.mjs.map +7 -0
  32. package/build-module/components/entities-saved-states/index.mjs +187 -0
  33. package/build-module/components/entities-saved-states/index.mjs.map +7 -0
  34. package/build-module/entities.mjs +2 -0
  35. package/build-module/entities.mjs.map +2 -2
  36. package/build-module/private-actions.mjs +84 -0
  37. package/build-module/private-actions.mjs.map +2 -2
  38. package/build-module/private-apis.mjs +12 -0
  39. package/build-module/private-apis.mjs.map +2 -2
  40. package/build-module/utils/crdt.mjs +2 -1
  41. package/build-module/utils/crdt.mjs.map +2 -2
  42. package/build-module/utils/get-template-info.mjs +28 -0
  43. package/build-module/utils/get-template-info.mjs.map +7 -0
  44. package/build-module/utils/get-template-part-icon.mjs +24 -0
  45. package/build-module/utils/get-template-part-icon.mjs.map +7 -0
  46. package/build-types/actions.d.ts.map +1 -1
  47. package/build-types/components/entities-saved-states/entity-record-item.d.ts +6 -0
  48. package/build-types/components/entities-saved-states/entity-record-item.d.ts.map +1 -0
  49. package/build-types/components/entities-saved-states/entity-type-list.d.ts +6 -0
  50. package/build-types/components/entities-saved-states/entity-type-list.d.ts.map +1 -0
  51. package/build-types/components/entities-saved-states/hooks/use-is-dirty.d.ts +11 -0
  52. package/build-types/components/entities-saved-states/hooks/use-is-dirty.d.ts.map +1 -0
  53. package/build-types/components/entities-saved-states/index.d.ts +49 -0
  54. package/build-types/components/entities-saved-states/index.d.ts.map +1 -0
  55. package/build-types/entities.d.ts.map +1 -1
  56. package/build-types/private-actions.d.ts +19 -0
  57. package/build-types/private-actions.d.ts.map +1 -1
  58. package/build-types/private-apis.d.ts +9 -0
  59. package/build-types/private-apis.d.ts.map +1 -1
  60. package/build-types/utils/crdt.d.ts +8 -0
  61. package/build-types/utils/crdt.d.ts.map +1 -1
  62. package/build-types/utils/get-template-info.d.ts +13 -0
  63. package/build-types/utils/get-template-info.d.ts.map +1 -0
  64. package/build-types/utils/get-template-part-icon.d.ts +9 -0
  65. package/build-types/utils/get-template-part-icon.d.ts.map +1 -0
  66. package/build-types/utils/on-sub-key.d.ts +4 -0
  67. package/build-types/utils/on-sub-key.d.ts.map +1 -0
  68. package/package.json +27 -22
  69. package/src/actions.js +92 -1
  70. package/src/components/entities-saved-states/entity-record-item.js +60 -0
  71. package/src/components/entities-saved-states/entity-type-list.js +130 -0
  72. package/src/components/entities-saved-states/hooks/use-is-dirty.js +101 -0
  73. package/src/components/entities-saved-states/index.js +242 -0
  74. package/src/components/entities-saved-states/style.scss +70 -0
  75. package/src/components/entities-saved-states/test/use-is-dirty.js +88 -0
  76. package/src/entities.js +2 -0
  77. package/src/private-actions.js +127 -0
  78. package/src/private-apis.ts +12 -0
  79. package/src/style.scss +1 -0
  80. package/src/test/actions.js +587 -0
  81. package/src/utils/crdt.ts +1 -1
  82. package/src/utils/get-template-info.js +52 -0
  83. package/src/utils/get-template-part-icon.js +30 -0
  84. package/src/utils/test/get-template-info.js +224 -0
package/src/actions.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * External dependencies
3
3
  */
4
+ import fastDeepEqual from 'fast-deep-equal/es6/index.js';
4
5
  import { v4 as uuid } from 'uuid';
5
6
 
6
7
  /**
@@ -24,11 +25,71 @@ import {
24
25
  getSyncManager,
25
26
  } from './sync';
26
27
  import logEntityDeprecation from './utils/log-entity-deprecation';
28
+ import {
29
+ getRawValue,
30
+ POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE,
31
+ } from './utils/crdt';
27
32
 
28
33
  function addTitleToAutoDraft( record ) {
29
34
  return record.status === 'auto-draft' ? { ...record, title: '' } : record;
30
35
  }
31
36
 
37
+ // Post meta is applied to the CRDT one subkey at a time, so compare the save
38
+ // response at the same granularity to avoid carrying stale sibling values.
39
+ function getServerMutatedMetaFields( updatedMeta, persistedMeta, syncedMeta ) {
40
+ const baseline = { ...persistedMeta, ...syncedMeta };
41
+
42
+ return Object.fromEntries(
43
+ Object.entries( updatedMeta ?? {} ).filter( ( [ key, value ] ) => {
44
+ if ( key === POST_META_KEY_FOR_CRDT_DOC_PERSISTENCE ) {
45
+ // The persisted CRDT snapshot may change on save and is
46
+ // intentionally excluded from CRDT meta synchronization, so it
47
+ // is not a server mutation.
48
+ return false;
49
+ }
50
+
51
+ return ! fastDeepEqual( value, baseline[ key ] );
52
+ } )
53
+ );
54
+ }
55
+
56
+ function getServerMutatedFields(
57
+ updatedRecord,
58
+ persistedRecord,
59
+ syncedChanges
60
+ ) {
61
+ return Object.fromEntries(
62
+ Object.entries( updatedRecord ).flatMap( ( [ key, value ] ) => {
63
+ if ( key === 'meta' ) {
64
+ const serverMutatedMeta = getServerMutatedMetaFields(
65
+ value,
66
+ persistedRecord.meta,
67
+ syncedChanges.meta
68
+ );
69
+
70
+ return Object.keys( serverMutatedMeta ).length
71
+ ? [ [ key, serverMutatedMeta ] ]
72
+ : [];
73
+ }
74
+
75
+ const baseline =
76
+ key in syncedChanges
77
+ ? syncedChanges[ key ]
78
+ : persistedRecord[ key ];
79
+
80
+ // The save response nests raw attributes as `{ raw, rendered }`
81
+ // while the baseline holds raw strings; compare raw values so the
82
+ // shape difference does not read as a server mutation.
83
+ const wasServerMutated = ! fastDeepEqual(
84
+ getRawValue( value ) ?? value,
85
+ getRawValue( baseline ) ?? baseline
86
+ );
87
+
88
+ return wasServerMutated ? [ [ key, value ] ] : [];
89
+ } )
90
+ );
91
+ }
92
+
32
93
  /**
33
94
  * Returns an action object used in signalling that authors have been received.
34
95
  * Ignored from documentation as it's internal to the data store.
@@ -759,6 +820,23 @@ export const saveEntityRecord =
759
820
  );
760
821
  }
761
822
  } else {
823
+ // `saveEntityRecord` can be called directly, bypassing
824
+ // `editEntityRecord`, so make sure its changes enter the
825
+ // CRDT before the persisted document is created below.
826
+ if (
827
+ entityConfig.syncConfig &&
828
+ ! __unstableSkipSyncUpdate &&
829
+ ! isNewRecord &&
830
+ persistedRecord
831
+ ) {
832
+ getSyncManager()?.update(
833
+ `${ kind }/${ name }`,
834
+ recordId,
835
+ record,
836
+ LOCAL_UNDO_IGNORED_ORIGIN
837
+ );
838
+ }
839
+
762
840
  let edits = record;
763
841
  if ( entityConfig.__unstablePrePersist ) {
764
842
  edits = {
@@ -783,12 +861,25 @@ export const saveEntityRecord =
783
861
  edits
784
862
  );
785
863
  if ( entityConfig.syncConfig ) {
864
+ let syncChanges;
865
+ if ( __unstableSkipSyncUpdate ) {
866
+ syncChanges = {};
867
+ } else if ( isNewRecord || ! persistedRecord ) {
868
+ syncChanges = updatedRecord;
869
+ } else {
870
+ syncChanges = getServerMutatedFields(
871
+ updatedRecord,
872
+ persistedRecord,
873
+ record
874
+ );
875
+ }
876
+
786
877
  // Use an untracked origin so that the save
787
878
  // response does not create undo levels.
788
879
  getSyncManager()?.update(
789
880
  `${ kind }/${ name }`,
790
881
  recordId,
791
- __unstableSkipSyncUpdate ? {} : updatedRecord,
882
+ syncChanges,
792
883
  LOCAL_UNDO_IGNORED_ORIGIN,
793
884
  { isSave: true }
794
885
  );
@@ -0,0 +1,60 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { CheckboxControl, PanelRow } from '@wordpress/components';
5
+ import { __ } from '@wordpress/i18n';
6
+ import { useSelect } from '@wordpress/data';
7
+ import { decodeEntities } from '@wordpress/html-entities';
8
+
9
+ /**
10
+ * Internal dependencies
11
+ */
12
+ import { STORE_NAME } from '../../name';
13
+ import { getTemplateInfo } from '../../utils/get-template-info';
14
+
15
+ export default function EntityRecordItem( { record, checked, onChange } ) {
16
+ const { name, kind, title, key } = record;
17
+
18
+ // Handle templates that might use default descriptive titles.
19
+ const { entityRecordTitle } = useSelect(
20
+ ( select ) => {
21
+ if ( 'postType' !== kind || 'wp_template' !== name ) {
22
+ return {
23
+ entityRecordTitle: title,
24
+ };
25
+ }
26
+
27
+ const template = select( STORE_NAME ).getEditedEntityRecord(
28
+ kind,
29
+ name,
30
+ key
31
+ );
32
+
33
+ const { default_template_types: templateTypes = [] } =
34
+ select( STORE_NAME ).getCurrentTheme() ?? {};
35
+
36
+ return {
37
+ entityRecordTitle: getTemplateInfo( {
38
+ template,
39
+ templateTypes,
40
+ } ).title,
41
+ };
42
+ },
43
+ [ name, kind, title, key ]
44
+ );
45
+
46
+ return (
47
+ <>
48
+ <PanelRow>
49
+ <CheckboxControl
50
+ label={
51
+ decodeEntities( entityRecordTitle ) || __( 'Untitled' )
52
+ }
53
+ checked={ checked }
54
+ onChange={ onChange }
55
+ className="entities-saved-states__change-control"
56
+ />
57
+ </PanelRow>
58
+ </>
59
+ );
60
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { __ } from '@wordpress/i18n';
5
+ import { useSelect } from '@wordpress/data';
6
+ import { PanelBody, PanelRow } from '@wordpress/components';
7
+ import { getGlobalStylesChanges } from '@wordpress/global-styles-engine';
8
+
9
+ /**
10
+ * Internal dependencies
11
+ */
12
+ import EntityRecordItem from './entity-record-item';
13
+ import { STORE_NAME } from '../../name';
14
+
15
+ function getEntityDescription( entity, count ) {
16
+ switch ( entity ) {
17
+ case 'site':
18
+ return 1 === count
19
+ ? __( 'This change will affect your whole site.' )
20
+ : __( 'These changes will affect your whole site.' );
21
+ case 'wp_template':
22
+ return __(
23
+ 'This change will affect other parts of your site that use this template.'
24
+ );
25
+ case 'page':
26
+ case 'post':
27
+ return __( 'The following has been modified.' );
28
+ }
29
+ }
30
+
31
+ function GlobalStylesDescription( { record } ) {
32
+ const { editedRecord, savedRecord } = useSelect(
33
+ ( select ) => {
34
+ const { getEditedEntityRecord, getEntityRecord } =
35
+ select( STORE_NAME );
36
+ return {
37
+ editedRecord: getEditedEntityRecord(
38
+ record.kind,
39
+ record.name,
40
+ record.key
41
+ ),
42
+ savedRecord: getEntityRecord(
43
+ record.kind,
44
+ record.name,
45
+ record.key
46
+ ),
47
+ };
48
+ },
49
+ [ record.kind, record.name, record.key ]
50
+ );
51
+
52
+ const globalStylesChanges = getGlobalStylesChanges(
53
+ editedRecord,
54
+ savedRecord,
55
+ {
56
+ maxResults: 10,
57
+ }
58
+ );
59
+ return globalStylesChanges.length ? (
60
+ <ul className="entities-saved-states__changes">
61
+ { globalStylesChanges.map( ( change ) => (
62
+ <li key={ change }>{ change }</li>
63
+ ) ) }
64
+ </ul>
65
+ ) : null;
66
+ }
67
+
68
+ function EntityDescription( { record, count } ) {
69
+ if ( 'globalStyles' === record?.name ) {
70
+ return null;
71
+ }
72
+ const description = getEntityDescription( record?.name, count );
73
+ return description ? <PanelRow>{ description }</PanelRow> : null;
74
+ }
75
+
76
+ export default function EntityTypeList( {
77
+ list,
78
+ unselectedEntities,
79
+ setUnselectedEntities,
80
+ } ) {
81
+ const count = list.length;
82
+ const firstRecord = list[ 0 ];
83
+ const entityConfig = useSelect(
84
+ ( select ) =>
85
+ select( STORE_NAME ).getEntityConfig(
86
+ firstRecord.kind,
87
+ firstRecord.name
88
+ ),
89
+ [ firstRecord.kind, firstRecord.name ]
90
+ );
91
+
92
+ let entityLabel = entityConfig.label;
93
+ if ( firstRecord?.name === 'wp_template_part' ) {
94
+ entityLabel =
95
+ 1 === count ? __( 'Template Part' ) : __( 'Template Parts' );
96
+ }
97
+
98
+ return (
99
+ <PanelBody
100
+ title={ entityLabel }
101
+ initialOpen
102
+ className="entities-saved-states__panel-body"
103
+ >
104
+ <EntityDescription record={ firstRecord } count={ count } />
105
+ { list.map( ( record ) => {
106
+ return (
107
+ <EntityRecordItem
108
+ key={ record.key || record.property }
109
+ record={ record }
110
+ checked={
111
+ ! unselectedEntities.some(
112
+ ( elt ) =>
113
+ elt.kind === record.kind &&
114
+ elt.name === record.name &&
115
+ elt.key === record.key &&
116
+ elt.property === record.property
117
+ )
118
+ }
119
+ onChange={ ( value ) =>
120
+ setUnselectedEntities( record, value )
121
+ }
122
+ />
123
+ );
124
+ } ) }
125
+ { 'globalStyles' === firstRecord?.name && (
126
+ <GlobalStylesDescription record={ firstRecord } />
127
+ ) }
128
+ </PanelBody>
129
+ );
130
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { useSelect } from '@wordpress/data';
5
+ import { useMemo, useState } from '@wordpress/element';
6
+
7
+ /**
8
+ * Internal dependencies
9
+ */
10
+ import { STORE_NAME } from '../../../name';
11
+
12
+ /**
13
+ * Custom hook that determines if any entities are dirty (edited) and provides a way to manage selected/unselected entities.
14
+ *
15
+ * @return {Object} An object containing the following properties:
16
+ * - dirtyEntityRecords: An array of dirty entity records.
17
+ * - isDirty: A boolean indicating if there are any dirty entity records.
18
+ * - setUnselectedEntities: A function to set the unselected entities.
19
+ * - unselectedEntities: An array of unselected entities.
20
+ */
21
+ export const useIsDirty = () => {
22
+ const { editedEntities, siteEdits, siteEntityConfig } = useSelect(
23
+ ( select ) => {
24
+ const {
25
+ __experimentalGetDirtyEntityRecords,
26
+ getEntityRecordEdits,
27
+ getEntityConfig,
28
+ } = select( STORE_NAME );
29
+
30
+ return {
31
+ editedEntities: __experimentalGetDirtyEntityRecords(),
32
+ siteEdits: getEntityRecordEdits( 'root', 'site' ),
33
+ siteEntityConfig: getEntityConfig( 'root', 'site' ),
34
+ };
35
+ },
36
+ []
37
+ );
38
+
39
+ const dirtyEntityRecords = useMemo( () => {
40
+ // Remove site object and decouple into its edited pieces.
41
+ const editedEntitiesWithoutSite = editedEntities.filter(
42
+ ( record ) => ! ( record.kind === 'root' && record.name === 'site' )
43
+ );
44
+
45
+ const siteEntityLabels = siteEntityConfig?.meta?.labels ?? {};
46
+ const {
47
+ site_logo: siteLogoEdit,
48
+ site_icon: siteIconEdit,
49
+ ...otherSiteEdits
50
+ } = siteEdits ?? {};
51
+ const orderedSiteProperties = [
52
+ siteLogoEdit !== undefined && 'site_logo',
53
+ siteIconEdit !== undefined && 'site_icon',
54
+ ...Object.keys( otherSiteEdits ),
55
+ ].filter( Boolean );
56
+ const editedSiteEntities = orderedSiteProperties.map(
57
+ ( property ) => ( {
58
+ kind: 'root',
59
+ name: 'site',
60
+ title: siteEntityLabels[ property ] || property,
61
+ property,
62
+ } )
63
+ );
64
+
65
+ return [ ...editedEntitiesWithoutSite, ...editedSiteEntities ];
66
+ }, [ editedEntities, siteEdits, siteEntityConfig ] );
67
+
68
+ // Unchecked entities to be ignored by save function.
69
+ const [ unselectedEntities, _setUnselectedEntities ] = useState( [] );
70
+
71
+ const setUnselectedEntities = (
72
+ { kind, name, key, property },
73
+ checked
74
+ ) => {
75
+ if ( checked ) {
76
+ _setUnselectedEntities(
77
+ unselectedEntities.filter(
78
+ ( elt ) =>
79
+ elt.kind !== kind ||
80
+ elt.name !== name ||
81
+ elt.key !== key ||
82
+ elt.property !== property
83
+ )
84
+ );
85
+ } else {
86
+ _setUnselectedEntities( [
87
+ ...unselectedEntities,
88
+ { kind, name, key, property },
89
+ ] );
90
+ }
91
+ };
92
+
93
+ const isDirty = dirtyEntityRecords.length - unselectedEntities.length > 0;
94
+
95
+ return {
96
+ dirtyEntityRecords,
97
+ isDirty,
98
+ setUnselectedEntities,
99
+ unselectedEntities,
100
+ };
101
+ };
@@ -0,0 +1,242 @@
1
+ /**
2
+ * External dependencies
3
+ */
4
+ import clsx from 'clsx';
5
+
6
+ /**
7
+ * WordPress dependencies
8
+ */
9
+ import { Button, Flex, FlexItem } from '@wordpress/components';
10
+ import { __, _n, sprintf } from '@wordpress/i18n';
11
+ import {
12
+ useCallback,
13
+ useRef,
14
+ createInterpolateElement,
15
+ } from '@wordpress/element';
16
+ import {
17
+ __experimentalUseDialog as useDialog,
18
+ useInstanceId,
19
+ } from '@wordpress/compose';
20
+ import { useDispatch } from '@wordpress/data';
21
+
22
+ /**
23
+ * Internal dependencies
24
+ */
25
+ import EntityTypeList from './entity-type-list';
26
+ import { useIsDirty } from './hooks/use-is-dirty';
27
+ import { unlock } from '../../lock-unlock';
28
+ import { STORE_NAME } from '../../name';
29
+
30
+ /**
31
+ * Renders the component for managing saved states of entities.
32
+ *
33
+ * @param {Object} props The component props.
34
+ * @param {Function} props.close The function to close the dialog.
35
+ * @param {boolean=} props.renderDialog Whether to render the component with modal dialog behavior.
36
+ * @param {string} props.variant Changes the layout of the component. When an `inline` value is provided, the action buttons are rendered at the end of the component instead of at the start.
37
+ *
38
+ * @return {React.ReactNode} The rendered component.
39
+ */
40
+ export default function EntitiesSavedStates( {
41
+ close,
42
+ renderDialog,
43
+ variant,
44
+ } ) {
45
+ const isDirtyProps = useIsDirty();
46
+ return (
47
+ <EntitiesSavedStatesExtensible
48
+ close={ close }
49
+ renderDialog={ renderDialog }
50
+ variant={ variant }
51
+ { ...isDirtyProps }
52
+ />
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Renders a panel for saving entities with dirty records.
58
+ *
59
+ * @param {Object} props The component props.
60
+ * @param {string} props.additionalPrompt Additional prompt to display.
61
+ * @param {Function} props.close Function to close the panel.
62
+ * @param {Function} props.onSave Function to call when saving entities.
63
+ * @param {boolean} props.saveEnabled Flag indicating if save is enabled.
64
+ * @param {string} props.saveLabel Label for the save button.
65
+ * @param {boolean} props.renderDialog Whether to render the component with modal dialog behavior.
66
+ * @param {Array} props.dirtyEntityRecords Array of dirty entity records.
67
+ * @param {boolean} props.isDirty Flag indicating if there are dirty entities.
68
+ * @param {Function} props.setUnselectedEntities Function to set unselected entities.
69
+ * @param {Array} props.unselectedEntities Array of unselected entities.
70
+ * @param {string} props.variant Changes the layout of the component. When an `inline` value is provided, the action buttons are rendered at the end of the component instead of at the start.
71
+ * @param {string} props.successNoticeContent Optional custom success notice content.
72
+ *
73
+ * @return {React.ReactNode} The rendered component.
74
+ */
75
+ export function EntitiesSavedStatesExtensible( {
76
+ additionalPrompt = undefined,
77
+ close,
78
+ onSave = undefined,
79
+ saveEnabled: saveEnabledProp = undefined,
80
+ saveLabel = __( 'Save' ),
81
+ renderDialog,
82
+ dirtyEntityRecords,
83
+ isDirty,
84
+ setUnselectedEntities,
85
+ unselectedEntities,
86
+ variant = 'default',
87
+ successNoticeContent,
88
+ } ) {
89
+ const saveButtonRef = useRef();
90
+ const { saveDirtyEntities } = unlock( useDispatch( STORE_NAME ) );
91
+ // To group entities by type.
92
+ const partitionedSavables = dirtyEntityRecords.reduce( ( acc, record ) => {
93
+ const { name } = record;
94
+ if ( ! acc[ name ] ) {
95
+ acc[ name ] = [];
96
+ }
97
+ acc[ name ].push( record );
98
+ return acc;
99
+ }, {} );
100
+
101
+ // Sort entity groups.
102
+ const {
103
+ site: siteSavables,
104
+ wp_template: templateSavables,
105
+ wp_template_part: templatePartSavables,
106
+ ...contentSavables
107
+ } = partitionedSavables;
108
+ const sortedPartitionedSavables = [
109
+ siteSavables,
110
+ templateSavables,
111
+ templatePartSavables,
112
+ ...Object.values( contentSavables ),
113
+ ].filter( Array.isArray );
114
+
115
+ const saveEnabled = saveEnabledProp ?? isDirty;
116
+ // Explicitly define this with no argument passed. Using `close` on
117
+ // its own will use the event object in place of the expected saved entities.
118
+ const dismissPanel = useCallback( () => close(), [ close ] );
119
+
120
+ const [ saveDialogRef, saveDialogProps ] = useDialog( {
121
+ onClose: () => dismissPanel(),
122
+ } );
123
+ const dialogLabelId = useInstanceId(
124
+ EntitiesSavedStatesExtensible,
125
+ 'entities-saved-states__panel-label'
126
+ );
127
+ const dialogDescriptionId = useInstanceId(
128
+ EntitiesSavedStatesExtensible,
129
+ 'entities-saved-states__panel-description'
130
+ );
131
+
132
+ const selectItemsToSaveDescription = !! dirtyEntityRecords.length
133
+ ? __( 'Select the items you want to save.' )
134
+ : undefined;
135
+
136
+ const isInline = variant === 'inline';
137
+
138
+ const actionButtons = (
139
+ <>
140
+ <FlexItem
141
+ isBlock={ isInline ? false : true }
142
+ as={ Button }
143
+ variant={ isInline ? 'tertiary' : 'secondary' }
144
+ size={ isInline ? undefined : 'compact' }
145
+ onClick={ dismissPanel }
146
+ >
147
+ { __( 'Cancel' ) }
148
+ </FlexItem>
149
+ <FlexItem
150
+ isBlock={ isInline ? false : true }
151
+ as={ Button }
152
+ ref={ saveButtonRef }
153
+ variant="primary"
154
+ size={ isInline ? undefined : 'compact' }
155
+ disabled={ ! saveEnabled }
156
+ accessibleWhenDisabled
157
+ onClick={ () =>
158
+ saveDirtyEntities( {
159
+ onSave,
160
+ dirtyEntityRecords,
161
+ entitiesToSkip: unselectedEntities,
162
+ close,
163
+ successNoticeContent,
164
+ } )
165
+ }
166
+ className="entities-saved-states__save-button"
167
+ >
168
+ { saveLabel }
169
+ </FlexItem>
170
+ </>
171
+ );
172
+
173
+ return (
174
+ <div
175
+ ref={ renderDialog ? saveDialogRef : undefined }
176
+ { ...( renderDialog && saveDialogProps ) }
177
+ className={ clsx( 'entities-saved-states__panel', {
178
+ 'is-inline': isInline,
179
+ } ) }
180
+ role={ renderDialog ? 'dialog' : undefined }
181
+ aria-labelledby={ renderDialog ? dialogLabelId : undefined }
182
+ aria-describedby={ renderDialog ? dialogDescriptionId : undefined }
183
+ >
184
+ { ! isInline && (
185
+ <Flex className="entities-saved-states__panel-header" gap={ 2 }>
186
+ { actionButtons }
187
+ </Flex>
188
+ ) }
189
+
190
+ <div className="entities-saved-states__text-prompt">
191
+ <div className="entities-saved-states__text-prompt--header-wrapper">
192
+ <strong
193
+ id={ renderDialog ? dialogLabelId : undefined }
194
+ className="entities-saved-states__text-prompt--header"
195
+ >
196
+ { __( 'Are you ready to save?' ) }
197
+ </strong>
198
+ </div>
199
+ <div id={ renderDialog ? dialogDescriptionId : undefined }>
200
+ { additionalPrompt }
201
+ <p className="entities-saved-states__text-prompt--changes-count">
202
+ { isDirty
203
+ ? createInterpolateElement(
204
+ sprintf(
205
+ /* translators: %d: number of site changes waiting to be saved. */
206
+ _n(
207
+ 'There is <strong>%d site change</strong> waiting to be saved.',
208
+ 'There are <strong>%d site changes</strong> waiting to be saved.',
209
+ dirtyEntityRecords.length
210
+ ),
211
+ dirtyEntityRecords.length
212
+ ),
213
+ { strong: <strong /> }
214
+ )
215
+ : selectItemsToSaveDescription }
216
+ </p>
217
+ </div>
218
+ </div>
219
+
220
+ { sortedPartitionedSavables.map( ( list ) => {
221
+ return (
222
+ <EntityTypeList
223
+ key={ list[ 0 ].name }
224
+ list={ list }
225
+ unselectedEntities={ unselectedEntities }
226
+ setUnselectedEntities={ setUnselectedEntities }
227
+ />
228
+ );
229
+ } ) }
230
+
231
+ { isInline && (
232
+ <Flex
233
+ direction="row"
234
+ justify="flex-end"
235
+ className="entities-saved-states__panel-footer"
236
+ >
237
+ { actionButtons }
238
+ </Flex>
239
+ ) }
240
+ </div>
241
+ );
242
+ }