@wordpress/core-data 7.50.1-next.v.202607070741.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 (139) hide show
  1. package/CHANGELOG.md +12 -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 +3 -1
  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 +3 -1
  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 +3 -3
  47. package/build-types/actions.d.ts.map +1 -1
  48. package/build-types/awareness/base-awareness.d.ts +3 -0
  49. package/build-types/awareness/base-awareness.d.ts.map +1 -1
  50. package/build-types/awareness/post-editor-awareness.d.ts +7 -0
  51. package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
  52. package/build-types/batch/create-batch.d.ts +1 -1
  53. package/build-types/batch/create-batch.d.ts.map +1 -1
  54. package/build-types/components/entities-saved-states/entity-record-item.d.ts +6 -0
  55. package/build-types/components/entities-saved-states/entity-record-item.d.ts.map +1 -0
  56. package/build-types/components/entities-saved-states/entity-type-list.d.ts +6 -0
  57. package/build-types/components/entities-saved-states/entity-type-list.d.ts.map +1 -0
  58. package/build-types/components/entities-saved-states/hooks/use-is-dirty.d.ts +11 -0
  59. package/build-types/components/entities-saved-states/hooks/use-is-dirty.d.ts.map +1 -0
  60. package/build-types/components/entities-saved-states/index.d.ts +49 -0
  61. package/build-types/components/entities-saved-states/index.d.ts.map +1 -0
  62. package/build-types/entities.d.ts +2 -2
  63. package/build-types/entities.d.ts.map +1 -1
  64. package/build-types/entity-provider.d.ts +1 -1
  65. package/build-types/entity-provider.d.ts.map +1 -1
  66. package/build-types/entity-types/attachment.d.ts.map +1 -1
  67. package/build-types/entity-types/base-entity-records.d.ts.map +1 -1
  68. package/build-types/entity-types/base.d.ts.map +1 -1
  69. package/build-types/entity-types/comment.d.ts.map +1 -1
  70. package/build-types/entity-types/font-collection.d.ts.map +1 -1
  71. package/build-types/entity-types/font-family.d.ts.map +1 -1
  72. package/build-types/entity-types/global-styles-revision.d.ts.map +1 -1
  73. package/build-types/entity-types/icon.d.ts.map +1 -1
  74. package/build-types/entity-types/menu-location.d.ts.map +1 -1
  75. package/build-types/entity-types/nav-menu-item.d.ts.map +1 -1
  76. package/build-types/entity-types/nav-menu.d.ts.map +1 -1
  77. package/build-types/entity-types/page.d.ts.map +1 -1
  78. package/build-types/entity-types/plugin.d.ts.map +1 -1
  79. package/build-types/entity-types/post-revision.d.ts.map +1 -1
  80. package/build-types/entity-types/post-status.d.ts.map +1 -1
  81. package/build-types/entity-types/post.d.ts.map +1 -1
  82. package/build-types/entity-types/settings.d.ts.map +1 -1
  83. package/build-types/entity-types/sidebar.d.ts.map +1 -1
  84. package/build-types/entity-types/taxonomy.d.ts.map +1 -1
  85. package/build-types/entity-types/term.d.ts.map +1 -1
  86. package/build-types/entity-types/theme.d.ts.map +1 -1
  87. package/build-types/entity-types/type.d.ts.map +1 -1
  88. package/build-types/entity-types/user.d.ts.map +1 -1
  89. package/build-types/entity-types/widget-type.d.ts.map +1 -1
  90. package/build-types/entity-types/widget.d.ts.map +1 -1
  91. package/build-types/entity-types/wp-template-part.d.ts.map +1 -1
  92. package/build-types/entity-types/wp-template.d.ts.map +1 -1
  93. package/build-types/fetch/__experimental-fetch-url-data.d.ts +3 -0
  94. package/build-types/fetch/__experimental-fetch-url-data.d.ts.map +1 -1
  95. package/build-types/index.d.ts +17 -47
  96. package/build-types/index.d.ts.map +1 -1
  97. package/build-types/locks/actions.d.ts +1 -1
  98. package/build-types/locks/engine.d.ts +1 -1
  99. package/build-types/locks/utils.d.ts +1 -1
  100. package/build-types/locks/utils.d.ts.map +1 -1
  101. package/build-types/private-actions.d.ts +32 -1
  102. package/build-types/private-actions.d.ts.map +1 -1
  103. package/build-types/private-apis.d.ts +9 -0
  104. package/build-types/private-apis.d.ts.map +1 -1
  105. package/build-types/queried-data/actions.d.ts +1 -1
  106. package/build-types/queried-data/actions.d.ts.map +1 -1
  107. package/build-types/queried-data/get-query-parts.d.ts +21 -0
  108. package/build-types/queried-data/get-query-parts.d.ts.map +1 -1
  109. package/build-types/reducer.d.ts +3 -3
  110. package/build-types/reducer.d.ts.map +1 -1
  111. package/build-types/resolvers.d.ts +1 -1
  112. package/build-types/resolvers.d.ts.map +1 -1
  113. package/build-types/utils/crdt.d.ts +8 -0
  114. package/build-types/utils/crdt.d.ts.map +1 -1
  115. package/build-types/utils/get-template-info.d.ts +13 -0
  116. package/build-types/utils/get-template-info.d.ts.map +1 -0
  117. package/build-types/utils/get-template-part-icon.d.ts +9 -0
  118. package/build-types/utils/get-template-part-icon.d.ts.map +1 -0
  119. package/build-types/utils/log-entity-deprecation.d.ts +1 -1
  120. package/build-types/utils/log-entity-deprecation.d.ts.map +1 -1
  121. package/build-types/utils/on-sub-key.d.ts +4 -0
  122. package/build-types/utils/on-sub-key.d.ts.map +1 -0
  123. package/package.json +30 -25
  124. package/src/actions.js +92 -1
  125. package/src/components/entities-saved-states/entity-record-item.js +60 -0
  126. package/src/components/entities-saved-states/entity-type-list.js +130 -0
  127. package/src/components/entities-saved-states/hooks/use-is-dirty.js +101 -0
  128. package/src/components/entities-saved-states/index.js +242 -0
  129. package/src/components/entities-saved-states/style.scss +70 -0
  130. package/src/components/entities-saved-states/test/use-is-dirty.js +88 -0
  131. package/src/entities.js +3 -1
  132. package/src/private-actions.js +127 -0
  133. package/src/private-apis.ts +12 -0
  134. package/src/style.scss +1 -0
  135. package/src/test/actions.js +587 -0
  136. package/src/utils/crdt.ts +1 -1
  137. package/src/utils/get-template-info.js +52 -0
  138. package/src/utils/get-template-part-icon.js +30 -0
  139. package/src/utils/test/get-template-info.js +224 -0
@@ -973,6 +973,7 @@ describe( 'saveEntityRecord', () => {
973
973
  'local-undo-ignored',
974
974
  { isSave: true }
975
975
  );
976
+ expect( syncManager.update ).toHaveBeenCalledTimes( 1 );
976
977
  expect( liveSyncState ).toEqual( {
977
978
  isSaved: true,
978
979
  title: 'synced title',
@@ -980,6 +981,592 @@ describe( 'saveEntityRecord', () => {
980
981
  expect( result ).toBe( staleSaveResponse );
981
982
  } );
982
983
 
984
+ it( 'only passes server-mutated fields to SyncManager#update after saving', async () => {
985
+ const persistedRecord = {
986
+ id: 10,
987
+ title: 'Initial title',
988
+ content: 'Initial content',
989
+ template: 'single',
990
+ modified: '2026-07-01T00:00:00',
991
+ };
992
+ const edits = {
993
+ id: 10,
994
+ content: 'Updated content',
995
+ };
996
+ const configs = [
997
+ {
998
+ name: 'post',
999
+ kind: 'postType',
1000
+ baseURL: '/wp/v2/posts',
1001
+ syncConfig: {},
1002
+ },
1003
+ ];
1004
+ const syncManager = {
1005
+ update: jest.fn(),
1006
+ };
1007
+ const select = {
1008
+ getRawEntityRecord: () => persistedRecord,
1009
+ };
1010
+ const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) };
1011
+ const updatedRecord = {
1012
+ ...persistedRecord,
1013
+ content: edits.content,
1014
+ modified: '2026-07-02T00:00:00',
1015
+ };
1016
+ apiFetch.mockImplementation( () => updatedRecord );
1017
+ getSyncManager.mockReturnValue( syncManager );
1018
+
1019
+ const result = await saveEntityRecord(
1020
+ 'postType',
1021
+ 'post',
1022
+ edits
1023
+ )( { select, dispatch, resolveSelect } );
1024
+
1025
+ expect( syncManager.update ).toHaveBeenNthCalledWith(
1026
+ 1,
1027
+ 'postType/post',
1028
+ 10,
1029
+ edits,
1030
+ 'local-undo-ignored'
1031
+ );
1032
+ expect( syncManager.update ).toHaveBeenCalledWith(
1033
+ 'postType/post',
1034
+ 10,
1035
+ {
1036
+ modified: '2026-07-02T00:00:00',
1037
+ },
1038
+ 'local-undo-ignored',
1039
+ { isSave: true }
1040
+ );
1041
+ expect( syncManager.update ).toHaveBeenCalledTimes( 2 );
1042
+ expect( result ).toBe( updatedRecord );
1043
+ } );
1044
+
1045
+ it( 'does not pass unchanged meta fields to SyncManager#update after saving', async () => {
1046
+ const persistedRecord = {
1047
+ id: 10,
1048
+ content: 'Initial content',
1049
+ meta: {
1050
+ plugin_value: 'persisted',
1051
+ peer_value: 'persisted',
1052
+ _crdt_document: 'old-doc',
1053
+ },
1054
+ };
1055
+ const edits = {
1056
+ id: 10,
1057
+ content: 'Updated content',
1058
+ };
1059
+ const configs = [
1060
+ {
1061
+ name: 'post',
1062
+ kind: 'postType',
1063
+ baseURL: '/wp/v2/posts',
1064
+ syncConfig: {},
1065
+ __unstablePrePersist: async () => ( {
1066
+ meta: { _crdt_document: 'new-doc' },
1067
+ } ),
1068
+ },
1069
+ ];
1070
+ const syncManager = {
1071
+ update: jest.fn(),
1072
+ };
1073
+ const select = {
1074
+ getRawEntityRecord: () => persistedRecord,
1075
+ };
1076
+ const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) };
1077
+ const updatedRecord = {
1078
+ ...persistedRecord,
1079
+ content: edits.content,
1080
+ meta: {
1081
+ plugin_value: 'persisted',
1082
+ peer_value: 'persisted',
1083
+ _crdt_document: 'new-doc',
1084
+ },
1085
+ };
1086
+ apiFetch.mockImplementation( () => updatedRecord );
1087
+ getSyncManager.mockReturnValue( syncManager );
1088
+
1089
+ const result = await saveEntityRecord(
1090
+ 'postType',
1091
+ 'post',
1092
+ edits
1093
+ )( { select, dispatch, resolveSelect } );
1094
+
1095
+ expect( syncManager.update ).toHaveBeenNthCalledWith(
1096
+ 2,
1097
+ 'postType/post',
1098
+ 10,
1099
+ {},
1100
+ 'local-undo-ignored',
1101
+ { isSave: true }
1102
+ );
1103
+ expect( syncManager.update ).toHaveBeenCalledTimes( 2 );
1104
+ expect( result ).toBe( updatedRecord );
1105
+ } );
1106
+
1107
+ it( 'passes only server-mutated meta fields to SyncManager#update after saving', async () => {
1108
+ const persistedRecord = {
1109
+ id: 10,
1110
+ meta: {
1111
+ unchanged: 'persisted',
1112
+ peer_value: 'persisted',
1113
+ edited: 'initial',
1114
+ server_mutated: 'before',
1115
+ settings: { color: 'blue', size: 'small' },
1116
+ },
1117
+ };
1118
+ const edits = {
1119
+ id: 10,
1120
+ meta: {
1121
+ edited: 'local',
1122
+ server_mutated: 'Needs Normalizing',
1123
+ },
1124
+ };
1125
+ const configs = [
1126
+ {
1127
+ name: 'post',
1128
+ kind: 'postType',
1129
+ baseURL: '/wp/v2/posts',
1130
+ syncConfig: {},
1131
+ __unstablePrePersist: async () => ( {
1132
+ meta: {
1133
+ ...edits.meta,
1134
+ _crdt_document: 'new-doc',
1135
+ },
1136
+ } ),
1137
+ },
1138
+ ];
1139
+ const syncManager = {
1140
+ update: jest.fn(),
1141
+ };
1142
+ const select = {
1143
+ getRawEntityRecord: () => persistedRecord,
1144
+ };
1145
+ const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) };
1146
+ const updatedRecord = {
1147
+ id: 10,
1148
+ meta: {
1149
+ unchanged: 'persisted',
1150
+ peer_value: 'persisted',
1151
+ edited: 'local',
1152
+ server_mutated: 'needs-normalizing',
1153
+ settings: { color: 'red', size: 'small' },
1154
+ _crdt_document: 'new-doc',
1155
+ },
1156
+ };
1157
+ apiFetch.mockImplementation( () => updatedRecord );
1158
+ getSyncManager.mockReturnValue( syncManager );
1159
+
1160
+ const result = await saveEntityRecord(
1161
+ 'postType',
1162
+ 'post',
1163
+ edits
1164
+ )( { select, dispatch, resolveSelect } );
1165
+
1166
+ expect( syncManager.update ).toHaveBeenNthCalledWith(
1167
+ 1,
1168
+ 'postType/post',
1169
+ 10,
1170
+ edits,
1171
+ 'local-undo-ignored'
1172
+ );
1173
+ expect( syncManager.update ).toHaveBeenNthCalledWith(
1174
+ 2,
1175
+ 'postType/post',
1176
+ 10,
1177
+ {
1178
+ meta: {
1179
+ server_mutated: 'needs-normalizing',
1180
+ settings: { color: 'red', size: 'small' },
1181
+ },
1182
+ },
1183
+ 'local-undo-ignored',
1184
+ { isSave: true }
1185
+ );
1186
+ expect( syncManager.update ).toHaveBeenCalledTimes( 2 );
1187
+ expect( result ).toBe( updatedRecord );
1188
+ } );
1189
+
1190
+ it( 'syncs direct save changes before pre-persisting the record', async () => {
1191
+ const persistedRecord = {
1192
+ id: 10,
1193
+ status: 'auto-draft',
1194
+ template: '',
1195
+ };
1196
+ const edits = {
1197
+ id: 10,
1198
+ template: 'page-no-title',
1199
+ };
1200
+ const syncManager = {
1201
+ update: jest.fn(),
1202
+ };
1203
+ const prePersist = jest.fn( async () => {
1204
+ expect( syncManager.update ).toHaveBeenCalledTimes( 1 );
1205
+ expect( syncManager.update ).toHaveBeenLastCalledWith(
1206
+ 'postType/page',
1207
+ 10,
1208
+ edits,
1209
+ 'local-undo-ignored'
1210
+ );
1211
+
1212
+ return { status: 'draft' };
1213
+ } );
1214
+ const configs = [
1215
+ {
1216
+ name: 'page',
1217
+ kind: 'postType',
1218
+ baseURL: '/wp/v2/pages',
1219
+ syncConfig: {},
1220
+ __unstablePrePersist: prePersist,
1221
+ },
1222
+ ];
1223
+ const select = {
1224
+ getRawEntityRecord: () => persistedRecord,
1225
+ };
1226
+ const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) };
1227
+ const updatedRecord = {
1228
+ ...persistedRecord,
1229
+ ...edits,
1230
+ status: 'draft',
1231
+ };
1232
+ apiFetch.mockImplementation( () => updatedRecord );
1233
+ getSyncManager.mockReturnValue( syncManager );
1234
+
1235
+ const result = await saveEntityRecord(
1236
+ 'postType',
1237
+ 'page',
1238
+ edits
1239
+ )( { select, dispatch, resolveSelect } );
1240
+
1241
+ expect( prePersist ).toHaveBeenCalledWith( persistedRecord, edits );
1242
+ expect( apiFetch ).toHaveBeenCalledWith( {
1243
+ path: '/wp/v2/pages/10',
1244
+ method: 'PUT',
1245
+ data: { ...edits, status: 'draft' },
1246
+ } );
1247
+ expect( syncManager.update ).toHaveBeenNthCalledWith(
1248
+ 2,
1249
+ 'postType/page',
1250
+ 10,
1251
+ { status: 'draft' },
1252
+ 'local-undo-ignored',
1253
+ { isSave: true }
1254
+ );
1255
+ expect( syncManager.update ).toHaveBeenCalledTimes( 2 );
1256
+ expect( result ).toBe( updatedRecord );
1257
+ } );
1258
+
1259
+ it( 'does not mark pre-synced direct save changes as saved when the request fails', async () => {
1260
+ const persistedRecord = {
1261
+ id: 10,
1262
+ template: '',
1263
+ };
1264
+ const edits = {
1265
+ id: 10,
1266
+ template: 'page-no-title',
1267
+ };
1268
+ const configs = [
1269
+ {
1270
+ name: 'page',
1271
+ kind: 'postType',
1272
+ baseURL: '/wp/v2/pages',
1273
+ syncConfig: {},
1274
+ },
1275
+ ];
1276
+ const syncManager = {
1277
+ update: jest.fn(),
1278
+ };
1279
+ const select = {
1280
+ getRawEntityRecord: () => persistedRecord,
1281
+ };
1282
+ const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) };
1283
+ const error = new Error( 'API error' );
1284
+ apiFetch.mockRejectedValue( error );
1285
+ getSyncManager.mockReturnValue( syncManager );
1286
+
1287
+ await expect(
1288
+ saveEntityRecord( 'postType', 'page', edits, {
1289
+ throwOnError: true,
1290
+ } )( { select, dispatch, resolveSelect } )
1291
+ ).rejects.toBe( error );
1292
+
1293
+ expect( syncManager.update ).toHaveBeenCalledTimes( 1 );
1294
+ expect( syncManager.update ).toHaveBeenCalledWith(
1295
+ 'postType/page',
1296
+ 10,
1297
+ edits,
1298
+ 'local-undo-ignored'
1299
+ );
1300
+ } );
1301
+
1302
+ it( 'passes server-normalized edited fields to SyncManager#update after saving', async () => {
1303
+ const persistedRecord = {
1304
+ id: 10,
1305
+ slug: 'initial-slug',
1306
+ };
1307
+ const edits = {
1308
+ id: 10,
1309
+ slug: 'Needs Normalizing',
1310
+ };
1311
+ const configs = [
1312
+ {
1313
+ name: 'post',
1314
+ kind: 'postType',
1315
+ baseURL: '/wp/v2/posts',
1316
+ syncConfig: {},
1317
+ },
1318
+ ];
1319
+ const syncManager = {
1320
+ update: jest.fn(),
1321
+ };
1322
+ const select = {
1323
+ getRawEntityRecord: () => persistedRecord,
1324
+ };
1325
+ const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) };
1326
+ const updatedRecord = {
1327
+ id: 10,
1328
+ slug: 'needs-normalizing',
1329
+ };
1330
+ apiFetch.mockImplementation( () => updatedRecord );
1331
+ getSyncManager.mockReturnValue( syncManager );
1332
+
1333
+ const result = await saveEntityRecord(
1334
+ 'postType',
1335
+ 'post',
1336
+ edits
1337
+ )( { select, dispatch, resolveSelect } );
1338
+
1339
+ expect( syncManager.update ).toHaveBeenCalledWith(
1340
+ 'postType/post',
1341
+ 10,
1342
+ {
1343
+ slug: 'needs-normalizing',
1344
+ },
1345
+ 'local-undo-ignored',
1346
+ { isSave: true }
1347
+ );
1348
+ expect( syncManager.update ).toHaveBeenCalledTimes( 2 );
1349
+ expect( result ).toBe( updatedRecord );
1350
+ } );
1351
+
1352
+ it( 'does not pass unchanged raw-attribute fields to SyncManager#update after saving', async () => {
1353
+ // The raw entity record holds raw strings while the save response
1354
+ // nests them as `{ raw, rendered }`; the comparison must not treat
1355
+ // that shape difference as a server mutation.
1356
+ const persistedRecord = {
1357
+ id: 10,
1358
+ title: 'Initial title',
1359
+ content: '<p>Initial content</p>',
1360
+ excerpt: 'Initial excerpt',
1361
+ slug: 'initial-slug',
1362
+ modified: '2026-07-01T00:00:00',
1363
+ };
1364
+ const edits = {
1365
+ id: 10,
1366
+ slug: 'updated-slug',
1367
+ };
1368
+ const configs = [
1369
+ {
1370
+ name: 'post',
1371
+ kind: 'postType',
1372
+ baseURL: '/wp/v2/posts',
1373
+ syncConfig: {},
1374
+ },
1375
+ ];
1376
+ const syncManager = {
1377
+ update: jest.fn(),
1378
+ };
1379
+ const select = {
1380
+ getRawEntityRecord: () => persistedRecord,
1381
+ };
1382
+ const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) };
1383
+ const updatedRecord = {
1384
+ id: 10,
1385
+ title: { raw: 'Initial title', rendered: 'Initial title' },
1386
+ content: {
1387
+ raw: '<p>Initial content</p>',
1388
+ rendered: '<p>Initial content</p>',
1389
+ },
1390
+ excerpt: { raw: 'Initial excerpt', rendered: 'Initial excerpt' },
1391
+ slug: 'updated-slug',
1392
+ modified: '2026-07-02T00:00:00',
1393
+ };
1394
+ apiFetch.mockImplementation( () => updatedRecord );
1395
+ getSyncManager.mockReturnValue( syncManager );
1396
+
1397
+ const result = await saveEntityRecord(
1398
+ 'postType',
1399
+ 'post',
1400
+ edits
1401
+ )( { select, dispatch, resolveSelect } );
1402
+
1403
+ expect( syncManager.update ).toHaveBeenCalledWith(
1404
+ 'postType/post',
1405
+ 10,
1406
+ {
1407
+ modified: '2026-07-02T00:00:00',
1408
+ },
1409
+ 'local-undo-ignored',
1410
+ { isSave: true }
1411
+ );
1412
+ expect( syncManager.update ).toHaveBeenCalledTimes( 2 );
1413
+ expect( result ).toBe( updatedRecord );
1414
+ } );
1415
+
1416
+ it( 'does not pass edited raw-attribute fields to SyncManager#update when the server echoes them unchanged', async () => {
1417
+ const persistedRecord = {
1418
+ id: 10,
1419
+ content: '<p>Initial content</p>',
1420
+ modified: '2026-07-01T00:00:00',
1421
+ };
1422
+ const edits = {
1423
+ id: 10,
1424
+ content: '<p>Updated content</p>',
1425
+ };
1426
+ const configs = [
1427
+ {
1428
+ name: 'post',
1429
+ kind: 'postType',
1430
+ baseURL: '/wp/v2/posts',
1431
+ syncConfig: {},
1432
+ },
1433
+ ];
1434
+ const syncManager = {
1435
+ update: jest.fn(),
1436
+ };
1437
+ const select = {
1438
+ getRawEntityRecord: () => persistedRecord,
1439
+ };
1440
+ const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) };
1441
+ const updatedRecord = {
1442
+ id: 10,
1443
+ content: {
1444
+ raw: edits.content,
1445
+ // The rendered value differs from the raw baseline, but only
1446
+ // raw values are compared.
1447
+ rendered: '<p class="rendered">Updated content</p>\n',
1448
+ },
1449
+ modified: '2026-07-02T00:00:00',
1450
+ };
1451
+ apiFetch.mockImplementation( () => updatedRecord );
1452
+ getSyncManager.mockReturnValue( syncManager );
1453
+
1454
+ const result = await saveEntityRecord(
1455
+ 'postType',
1456
+ 'post',
1457
+ edits
1458
+ )( { select, dispatch, resolveSelect } );
1459
+
1460
+ expect( syncManager.update ).toHaveBeenCalledWith(
1461
+ 'postType/post',
1462
+ 10,
1463
+ {
1464
+ modified: '2026-07-02T00:00:00',
1465
+ },
1466
+ 'local-undo-ignored',
1467
+ { isSave: true }
1468
+ );
1469
+ expect( result ).toBe( updatedRecord );
1470
+ } );
1471
+
1472
+ it( 'passes raw-attribute fields whose raw value the server mutated to SyncManager#update', async () => {
1473
+ const persistedRecord = {
1474
+ id: 10,
1475
+ content: '<p>Initial content</p>',
1476
+ };
1477
+ const edits = {
1478
+ id: 10,
1479
+ content: '<p>Content with <script>bad</script> markup</p>',
1480
+ };
1481
+ const configs = [
1482
+ {
1483
+ name: 'post',
1484
+ kind: 'postType',
1485
+ baseURL: '/wp/v2/posts',
1486
+ syncConfig: {},
1487
+ },
1488
+ ];
1489
+ const syncManager = {
1490
+ update: jest.fn(),
1491
+ };
1492
+ const select = {
1493
+ getRawEntityRecord: () => persistedRecord,
1494
+ };
1495
+ const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) };
1496
+ // The server strips disallowed markup from the sent content.
1497
+ const updatedRecord = {
1498
+ id: 10,
1499
+ content: {
1500
+ raw: '<p>Content with bad markup</p>',
1501
+ rendered: '<p>Content with bad markup</p>',
1502
+ },
1503
+ };
1504
+ apiFetch.mockImplementation( () => updatedRecord );
1505
+ getSyncManager.mockReturnValue( syncManager );
1506
+
1507
+ const result = await saveEntityRecord(
1508
+ 'postType',
1509
+ 'post',
1510
+ edits
1511
+ )( { select, dispatch, resolveSelect } );
1512
+
1513
+ expect( syncManager.update ).toHaveBeenCalledWith(
1514
+ 'postType/post',
1515
+ 10,
1516
+ {
1517
+ content: updatedRecord.content,
1518
+ },
1519
+ 'local-undo-ignored',
1520
+ { isSave: true }
1521
+ );
1522
+ expect( result ).toBe( updatedRecord );
1523
+ } );
1524
+
1525
+ it( 'passes the full save response to SyncManager#update when the persisted record is missing', async () => {
1526
+ const edits = {
1527
+ id: 10,
1528
+ content: 'Updated content',
1529
+ };
1530
+ const configs = [
1531
+ {
1532
+ name: 'post',
1533
+ kind: 'postType',
1534
+ baseURL: '/wp/v2/posts',
1535
+ syncConfig: {},
1536
+ },
1537
+ ];
1538
+ const syncManager = {
1539
+ update: jest.fn(),
1540
+ };
1541
+ const select = {
1542
+ getRawEntityRecord: () => undefined,
1543
+ };
1544
+ const resolveSelect = { getEntitiesConfig: jest.fn( () => configs ) };
1545
+ const updatedRecord = {
1546
+ id: 10,
1547
+ content: 'Updated content',
1548
+ template: 'single',
1549
+ };
1550
+ apiFetch.mockImplementation( () => updatedRecord );
1551
+ getSyncManager.mockReturnValue( syncManager );
1552
+
1553
+ const result = await saveEntityRecord(
1554
+ 'postType',
1555
+ 'post',
1556
+ edits
1557
+ )( { select, dispatch, resolveSelect } );
1558
+
1559
+ expect( syncManager.update ).toHaveBeenCalledWith(
1560
+ 'postType/post',
1561
+ 10,
1562
+ updatedRecord,
1563
+ 'local-undo-ignored',
1564
+ { isSave: true }
1565
+ );
1566
+ expect( syncManager.update ).toHaveBeenCalledTimes( 1 );
1567
+ expect( result ).toBe( updatedRecord );
1568
+ } );
1569
+
983
1570
  it( 'triggers a PUT request for an existing record with a custom key', async () => {
984
1571
  const postType = { slug: 'page', title: 'Pages' };
985
1572
  const configs = [
package/src/utils/crdt.ts CHANGED
@@ -577,7 +577,7 @@ export const defaultCollectionSyncConfig: SyncConfig = {
577
577
  * @param {unknown} value The value to extract from.
578
578
  * @return {string|undefined} The raw string value, or undefined if it could not be determined.
579
579
  */
580
- function getRawValue( value?: unknown ): string | undefined {
580
+ export function getRawValue( value?: unknown ): string | undefined {
581
581
  // Value may be a string property or a nested object with a `raw` property.
582
582
  if ( 'string' === typeof value ) {
583
583
  return value;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import { layout } from '@wordpress/icons';
5
+ /**
6
+ * Internal dependencies
7
+ */
8
+ import { getTemplatePartIcon } from './get-template-part-icon';
9
+ const EMPTY_OBJECT = {};
10
+
11
+ /**
12
+ * Helper function to retrieve the corresponding template info for a given template.
13
+ * @param {Object} params
14
+ * @param {Array} params.templateTypes
15
+ * @param {Array} [params.templateAreas]
16
+ * @param {Object} params.template
17
+ */
18
+ export const getTemplateInfo = ( params ) => {
19
+ if ( ! params ) {
20
+ return EMPTY_OBJECT;
21
+ }
22
+
23
+ const { templateTypes, templateAreas, template } = params;
24
+
25
+ const { description, slug, title, area } = template;
26
+
27
+ const { title: defaultTitle, description: defaultDescription } =
28
+ Object.values( templateTypes ).find( ( type ) => type.slug === slug ) ??
29
+ EMPTY_OBJECT;
30
+
31
+ const templateTitle = typeof title === 'string' ? title : title?.rendered;
32
+ const templateDescription =
33
+ typeof description === 'string' ? description : description?.raw;
34
+
35
+ const templateAreasWithIcon = templateAreas?.map( ( item ) => ( {
36
+ ...item,
37
+ icon: getTemplatePartIcon( item.icon ),
38
+ } ) );
39
+
40
+ const templateIcon =
41
+ templateAreasWithIcon?.find( ( item ) => area === item.area )?.icon ||
42
+ layout;
43
+
44
+ return {
45
+ title:
46
+ templateTitle && templateTitle !== slug
47
+ ? templateTitle
48
+ : defaultTitle || slug,
49
+ description: templateDescription || defaultDescription,
50
+ icon: templateIcon,
51
+ };
52
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * WordPress dependencies
3
+ */
4
+ import {
5
+ header as headerIcon,
6
+ footer as footerIcon,
7
+ sidebar as sidebarIcon,
8
+ navigationOverlay as navigationOverlayIcon,
9
+ symbolFilled as symbolFilledIcon,
10
+ } from '@wordpress/icons';
11
+
12
+ /**
13
+ * Helper function to retrieve the corresponding icon by area name.
14
+ *
15
+ * @param {string} areaOrIconName The area name (e.g., 'header', 'navigation-overlay').
16
+ *
17
+ * @return {Object} The corresponding icon.
18
+ */
19
+ export function getTemplatePartIcon( areaOrIconName ) {
20
+ if ( 'header' === areaOrIconName ) {
21
+ return headerIcon;
22
+ } else if ( 'footer' === areaOrIconName ) {
23
+ return footerIcon;
24
+ } else if ( 'sidebar' === areaOrIconName ) {
25
+ return sidebarIcon;
26
+ } else if ( 'navigation-overlay' === areaOrIconName ) {
27
+ return navigationOverlayIcon;
28
+ }
29
+ return symbolFilledIcon;
30
+ }