@wordpress/core-data 7.41.2-next.v.202603102151.0 → 7.42.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.
- package/CHANGELOG.md +1 -1
- package/README.md +19 -0
- package/build/actions.cjs +17 -25
- package/build/actions.cjs.map +2 -2
- package/build/awareness/post-editor-awareness.cjs +46 -6
- package/build/awareness/post-editor-awareness.cjs.map +2 -2
- package/build/awareness/types.cjs.map +1 -1
- package/build/entities.cjs +33 -7
- package/build/entities.cjs.map +2 -2
- package/build/hooks/use-entity-prop.cjs +2 -1
- package/build/hooks/use-entity-prop.cjs.map +2 -2
- package/build/hooks/use-post-editor-awareness-state.cjs +84 -3
- package/build/hooks/use-post-editor-awareness-state.cjs.map +2 -2
- package/build/index.cjs +3 -0
- package/build/index.cjs.map +2 -2
- package/build/private-apis.cjs +3 -1
- package/build/private-apis.cjs.map +2 -2
- package/build/queried-data/get-query-parts.cjs +7 -0
- package/build/queried-data/get-query-parts.cjs.map +2 -2
- package/build/queried-data/selectors.cjs +19 -5
- package/build/queried-data/selectors.cjs.map +2 -2
- package/build/reducer.cjs +6 -0
- package/build/reducer.cjs.map +2 -2
- package/build/resolvers.cjs +110 -74
- package/build/resolvers.cjs.map +2 -2
- package/build/selectors.cjs +29 -0
- package/build/selectors.cjs.map +2 -2
- package/build/sync.cjs +3 -0
- package/build/sync.cjs.map +2 -2
- package/build/types.cjs +16 -0
- package/build/types.cjs.map +3 -3
- package/build/utils/block-selection-history.cjs +1 -1
- package/build/utils/block-selection-history.cjs.map +2 -2
- package/build/utils/crdt-blocks.cjs +17 -3
- package/build/utils/crdt-blocks.cjs.map +2 -2
- package/build/utils/crdt-selection.cjs +4 -1
- package/build/utils/crdt-selection.cjs.map +2 -2
- package/build/utils/crdt-user-selections.cjs +9 -6
- package/build/utils/crdt-user-selections.cjs.map +2 -2
- package/build/utils/crdt-utils.cjs +54 -2
- package/build/utils/crdt-utils.cjs.map +2 -2
- package/build/utils/crdt.cjs +4 -23
- package/build/utils/crdt.cjs.map +2 -2
- package/build/utils/index.cjs +3 -0
- package/build/utils/index.cjs.map +2 -2
- package/build/utils/normalize-query-for-resolution.cjs +35 -0
- package/build/utils/normalize-query-for-resolution.cjs.map +7 -0
- package/build-module/actions.mjs +17 -25
- package/build-module/actions.mjs.map +2 -2
- package/build-module/awareness/post-editor-awareness.mjs +46 -6
- package/build-module/awareness/post-editor-awareness.mjs.map +2 -2
- package/build-module/entities.mjs +33 -7
- package/build-module/entities.mjs.map +2 -2
- package/build-module/hooks/use-entity-prop.mjs +2 -1
- package/build-module/hooks/use-entity-prop.mjs.map +2 -2
- package/build-module/hooks/use-post-editor-awareness-state.mjs +81 -2
- package/build-module/hooks/use-post-editor-awareness-state.mjs.map +2 -2
- package/build-module/index.mjs +2 -0
- package/build-module/index.mjs.map +2 -2
- package/build-module/private-apis.mjs +6 -2
- package/build-module/private-apis.mjs.map +2 -2
- package/build-module/queried-data/get-query-parts.mjs +7 -0
- package/build-module/queried-data/get-query-parts.mjs.map +2 -2
- package/build-module/queried-data/selectors.mjs +19 -5
- package/build-module/queried-data/selectors.mjs.map +2 -2
- package/build-module/reducer.mjs +6 -0
- package/build-module/reducer.mjs.map +2 -2
- package/build-module/resolvers.mjs +112 -75
- package/build-module/resolvers.mjs.map +2 -2
- package/build-module/selectors.mjs +28 -0
- package/build-module/selectors.mjs.map +2 -2
- package/build-module/sync.mjs +2 -0
- package/build-module/sync.mjs.map +2 -2
- package/build-module/types.mjs +9 -0
- package/build-module/types.mjs.map +4 -4
- package/build-module/utils/block-selection-history.mjs +5 -2
- package/build-module/utils/block-selection-history.mjs.map +2 -2
- package/build-module/utils/crdt-blocks.mjs +17 -3
- package/build-module/utils/crdt-blocks.mjs.map +2 -2
- package/build-module/utils/crdt-selection.mjs +8 -2
- package/build-module/utils/crdt-selection.mjs.map +2 -2
- package/build-module/utils/crdt-user-selections.mjs +10 -7
- package/build-module/utils/crdt-user-selections.mjs.map +2 -2
- package/build-module/utils/crdt-utils.mjs +51 -1
- package/build-module/utils/crdt-utils.mjs.map +2 -2
- package/build-module/utils/crdt.mjs +4 -23
- package/build-module/utils/crdt.mjs.map +2 -2
- package/build-module/utils/index.mjs +2 -0
- package/build-module/utils/index.mjs.map +2 -2
- package/build-module/utils/normalize-query-for-resolution.mjs +14 -0
- package/build-module/utils/normalize-query-for-resolution.mjs.map +7 -0
- package/build-types/actions.d.ts.map +1 -1
- package/build-types/awareness/post-editor-awareness.d.ts +2 -2
- package/build-types/awareness/post-editor-awareness.d.ts.map +1 -1
- package/build-types/awareness/types.d.ts +1 -1
- package/build-types/awareness/types.d.ts.map +1 -1
- package/build-types/entities.d.ts +1 -1
- package/build-types/entities.d.ts.map +1 -1
- package/build-types/hooks/use-entity-prop.d.ts.map +1 -1
- package/build-types/hooks/use-post-editor-awareness-state.d.ts +34 -10
- package/build-types/hooks/use-post-editor-awareness-state.d.ts.map +1 -1
- package/build-types/index.d.ts +2 -0
- package/build-types/index.d.ts.map +1 -1
- package/build-types/private-apis.d.ts.map +1 -1
- package/build-types/queried-data/get-query-parts.d.ts +7 -0
- package/build-types/queried-data/get-query-parts.d.ts.map +1 -1
- package/build-types/queried-data/selectors.d.ts.map +1 -1
- package/build-types/reducer.d.ts.map +1 -1
- package/build-types/resolvers.d.ts +2 -1
- package/build-types/resolvers.d.ts.map +1 -1
- package/build-types/selectors.d.ts +17 -0
- package/build-types/selectors.d.ts.map +1 -1
- package/build-types/sync.d.ts +2 -2
- package/build-types/sync.d.ts.map +1 -1
- package/build-types/types.d.ts +18 -1
- package/build-types/types.d.ts.map +1 -1
- package/build-types/utils/block-selection-history.d.ts.map +1 -1
- package/build-types/utils/crdt-blocks.d.ts.map +1 -1
- package/build-types/utils/crdt-selection.d.ts.map +1 -1
- package/build-types/utils/crdt-user-selections.d.ts +9 -5
- package/build-types/utils/crdt-user-selections.d.ts.map +1 -1
- package/build-types/utils/crdt-utils.d.ts +20 -0
- package/build-types/utils/crdt-utils.d.ts.map +1 -1
- package/build-types/utils/crdt.d.ts +6 -7
- package/build-types/utils/crdt.d.ts.map +1 -1
- package/build-types/utils/index.d.ts +1 -0
- package/build-types/utils/normalize-query-for-resolution.d.ts +12 -0
- package/build-types/utils/normalize-query-for-resolution.d.ts.map +1 -0
- package/build-types/utils/test/crdt-utils.d.ts +2 -0
- package/build-types/utils/test/crdt-utils.d.ts.map +1 -0
- package/package.json +18 -18
- package/src/actions.js +25 -40
- package/src/awareness/post-editor-awareness.ts +106 -7
- package/src/awareness/test/post-editor-awareness.ts +50 -10
- package/src/awareness/types.ts +1 -1
- package/src/entities.js +38 -6
- package/src/hooks/test/use-post-editor-awareness-state.ts +446 -3
- package/src/hooks/use-entity-prop.js +2 -0
- package/src/hooks/use-post-editor-awareness-state.ts +160 -8
- package/src/index.js +1 -0
- package/src/private-apis.js +6 -2
- package/src/queried-data/get-query-parts.js +13 -0
- package/src/queried-data/selectors.js +33 -8
- package/src/queried-data/test/get-query-parts.js +34 -0
- package/src/queried-data/test/selectors.js +183 -0
- package/src/reducer.js +11 -0
- package/src/resolvers.js +136 -88
- package/src/selectors.ts +56 -0
- package/src/sync.ts +2 -0
- package/src/test/entities.js +185 -1
- package/src/test/resolvers.js +64 -11
- package/src/test/selectors.js +150 -0
- package/src/test/store.js +66 -0
- package/src/types.ts +26 -1
- package/src/utils/block-selection-history.ts +5 -2
- package/src/utils/crdt-blocks.ts +32 -3
- package/src/utils/crdt-selection.ts +8 -2
- package/src/utils/crdt-user-selections.ts +20 -8
- package/src/utils/crdt-utils.ts +99 -0
- package/src/utils/crdt.ts +8 -32
- package/src/utils/index.js +1 -0
- package/src/utils/normalize-query-for-resolution.js +23 -0
- package/src/utils/test/crdt-blocks.ts +146 -0
- package/src/utils/test/crdt-user-selections.ts +5 -0
- package/src/utils/test/crdt-utils.ts +387 -0
- package/src/utils/test/crdt.ts +120 -53
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
useResolvedSelection,
|
|
12
12
|
useGetDebugData,
|
|
13
13
|
useIsDisconnected,
|
|
14
|
+
useOnCollaboratorJoin,
|
|
15
|
+
useOnCollaboratorLeave,
|
|
16
|
+
useOnPostSave,
|
|
14
17
|
} from '../use-post-editor-awareness-state';
|
|
15
18
|
import { getSyncManager } from '../../sync';
|
|
16
19
|
import { SelectionType } from '../../utils/crdt-user-selections';
|
|
@@ -66,6 +69,9 @@ describe( 'use-post-editor-awareness-state hooks', () => {
|
|
|
66
69
|
onStateChange: jest.Mock;
|
|
67
70
|
convertSelectionStateToAbsolute: jest.Mock;
|
|
68
71
|
getDebugData: jest.Mock;
|
|
72
|
+
doc: {
|
|
73
|
+
getMap: jest.Mock;
|
|
74
|
+
};
|
|
69
75
|
};
|
|
70
76
|
let mockSyncManager: {
|
|
71
77
|
getAwareness: jest.Mock;
|
|
@@ -73,9 +79,29 @@ describe( 'use-post-editor-awareness-state hooks', () => {
|
|
|
73
79
|
let stateChangeCallback:
|
|
74
80
|
| ( ( newState: PostEditorAwarenessState[] ) => void )
|
|
75
81
|
| null;
|
|
82
|
+
let stateMapObserver:
|
|
83
|
+
| ( ( event: { keysChanged: Set< string > } ) => void )
|
|
84
|
+
| null;
|
|
85
|
+
let mockStateMapData: Record< string, unknown >;
|
|
86
|
+
let mockRecordMapData: Record< string, unknown >;
|
|
76
87
|
|
|
77
88
|
beforeEach( () => {
|
|
78
89
|
stateChangeCallback = null;
|
|
90
|
+
stateMapObserver = null;
|
|
91
|
+
mockStateMapData = {};
|
|
92
|
+
mockRecordMapData = {};
|
|
93
|
+
|
|
94
|
+
const mockStateMap = {
|
|
95
|
+
get: jest.fn( ( key: string ) => mockStateMapData[ key ] ),
|
|
96
|
+
observe: jest.fn( ( observer: typeof stateMapObserver ) => {
|
|
97
|
+
stateMapObserver = observer;
|
|
98
|
+
} ),
|
|
99
|
+
unobserve: jest.fn(),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const mockRecordMap = {
|
|
103
|
+
get: jest.fn( ( key: string ) => mockRecordMapData[ key ] ),
|
|
104
|
+
};
|
|
79
105
|
|
|
80
106
|
mockAwareness = {
|
|
81
107
|
setUp: jest.fn(),
|
|
@@ -86,6 +112,17 @@ describe( 'use-post-editor-awareness-state hooks', () => {
|
|
|
86
112
|
} ),
|
|
87
113
|
convertSelectionStateToAbsolute: jest.fn().mockReturnValue( null ),
|
|
88
114
|
getDebugData: jest.fn().mockReturnValue( createMockDebugData() ),
|
|
115
|
+
doc: {
|
|
116
|
+
getMap: jest.fn( ( name: string ) => {
|
|
117
|
+
if ( name === 'state' ) {
|
|
118
|
+
return mockStateMap;
|
|
119
|
+
}
|
|
120
|
+
if ( name === 'document' ) {
|
|
121
|
+
return mockRecordMap;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
} ),
|
|
125
|
+
},
|
|
89
126
|
};
|
|
90
127
|
|
|
91
128
|
mockSyncManager = {
|
|
@@ -256,7 +293,7 @@ describe( 'use-post-editor-awareness-state hooks', () => {
|
|
|
256
293
|
};
|
|
257
294
|
|
|
258
295
|
expect( result.current( mockSelection ) ).toEqual( {
|
|
259
|
-
|
|
296
|
+
richTextOffset: null,
|
|
260
297
|
localClientId: null,
|
|
261
298
|
} );
|
|
262
299
|
} );
|
|
@@ -270,7 +307,7 @@ describe( 'use-post-editor-awareness-state hooks', () => {
|
|
|
270
307
|
},
|
|
271
308
|
};
|
|
272
309
|
mockAwareness.convertSelectionStateToAbsolute.mockReturnValue( {
|
|
273
|
-
|
|
310
|
+
richTextOffset: 10,
|
|
274
311
|
localClientId: 'block-1',
|
|
275
312
|
} );
|
|
276
313
|
|
|
@@ -284,7 +321,7 @@ describe( 'use-post-editor-awareness-state hooks', () => {
|
|
|
284
321
|
mockAwareness.convertSelectionStateToAbsolute
|
|
285
322
|
).toHaveBeenCalledWith( mockSelection );
|
|
286
323
|
expect( position ).toEqual( {
|
|
287
|
-
|
|
324
|
+
richTextOffset: 10,
|
|
288
325
|
localClientId: 'block-1',
|
|
289
326
|
} );
|
|
290
327
|
} );
|
|
@@ -481,4 +518,410 @@ describe( 'use-post-editor-awareness-state hooks', () => {
|
|
|
481
518
|
expect( result.current ).toBe( false );
|
|
482
519
|
} );
|
|
483
520
|
} );
|
|
521
|
+
|
|
522
|
+
describe( 'useOnCollaboratorJoin', () => {
|
|
523
|
+
const me = createMockActiveUser( {
|
|
524
|
+
clientId: 1,
|
|
525
|
+
isMe: true,
|
|
526
|
+
collaboratorInfo: {
|
|
527
|
+
id: 1,
|
|
528
|
+
name: 'Me',
|
|
529
|
+
slug: 'me',
|
|
530
|
+
avatar_urls: mockAvatarUrls,
|
|
531
|
+
browserType: 'Chrome',
|
|
532
|
+
enteredAt: 1704067200000,
|
|
533
|
+
},
|
|
534
|
+
} );
|
|
535
|
+
|
|
536
|
+
const alice = createMockActiveUser( {
|
|
537
|
+
clientId: 2,
|
|
538
|
+
isMe: false,
|
|
539
|
+
collaboratorInfo: {
|
|
540
|
+
id: 100,
|
|
541
|
+
name: 'Alice',
|
|
542
|
+
slug: 'alice',
|
|
543
|
+
avatar_urls: mockAvatarUrls,
|
|
544
|
+
browserType: 'Chrome',
|
|
545
|
+
enteredAt: 1704067300000,
|
|
546
|
+
},
|
|
547
|
+
} );
|
|
548
|
+
|
|
549
|
+
test( 'should not fire on initial mount', () => {
|
|
550
|
+
const callback = jest.fn();
|
|
551
|
+
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
|
|
552
|
+
|
|
553
|
+
renderHook( () => useOnCollaboratorJoin( 123, 'post', callback ) );
|
|
554
|
+
|
|
555
|
+
expect( callback ).not.toHaveBeenCalled();
|
|
556
|
+
} );
|
|
557
|
+
|
|
558
|
+
test( 'should not fire when collaborators load after initially empty state', async () => {
|
|
559
|
+
const callback = jest.fn();
|
|
560
|
+
mockAwareness.getCurrentState.mockReturnValue( [] );
|
|
561
|
+
|
|
562
|
+
renderHook( () => useOnCollaboratorJoin( 123, 'post', callback ) );
|
|
563
|
+
|
|
564
|
+
// Simulate store hydration: collaborators appear
|
|
565
|
+
act( () => {
|
|
566
|
+
stateChangeCallback?.( [ me, alice ] );
|
|
567
|
+
} );
|
|
568
|
+
|
|
569
|
+
await waitFor( () => {
|
|
570
|
+
expect( callback ).not.toHaveBeenCalled();
|
|
571
|
+
} );
|
|
572
|
+
} );
|
|
573
|
+
|
|
574
|
+
test( 'should fire callback when a new collaborator joins', async () => {
|
|
575
|
+
const callback = jest.fn();
|
|
576
|
+
mockAwareness.getCurrentState.mockReturnValue( [ me ] );
|
|
577
|
+
|
|
578
|
+
renderHook( () => useOnCollaboratorJoin( 123, 'post', callback ) );
|
|
579
|
+
|
|
580
|
+
// Alice joins
|
|
581
|
+
act( () => {
|
|
582
|
+
stateChangeCallback?.( [ me, alice ] );
|
|
583
|
+
} );
|
|
584
|
+
|
|
585
|
+
await waitFor( () => {
|
|
586
|
+
expect( callback ).toHaveBeenCalledWith( alice, me );
|
|
587
|
+
} );
|
|
588
|
+
} );
|
|
589
|
+
|
|
590
|
+
test( 'should not fire callback for the current user', async () => {
|
|
591
|
+
const callback = jest.fn();
|
|
592
|
+
mockAwareness.getCurrentState.mockReturnValue( [] );
|
|
593
|
+
|
|
594
|
+
renderHook( () => useOnCollaboratorJoin( 123, 'post', callback ) );
|
|
595
|
+
|
|
596
|
+
// First: hydrate with alice so prevCollaborators is non-empty
|
|
597
|
+
act( () => {
|
|
598
|
+
stateChangeCallback?.( [ alice ] );
|
|
599
|
+
} );
|
|
600
|
+
|
|
601
|
+
// Now "me" appears
|
|
602
|
+
act( () => {
|
|
603
|
+
stateChangeCallback?.( [ alice, me ] );
|
|
604
|
+
} );
|
|
605
|
+
|
|
606
|
+
await waitFor( () => {
|
|
607
|
+
expect( callback ).not.toHaveBeenCalled();
|
|
608
|
+
} );
|
|
609
|
+
} );
|
|
610
|
+
|
|
611
|
+
test( 'should not fire when postId is null', () => {
|
|
612
|
+
const callback = jest.fn();
|
|
613
|
+
|
|
614
|
+
renderHook( () => useOnCollaboratorJoin( null, 'post', callback ) );
|
|
615
|
+
|
|
616
|
+
expect( callback ).not.toHaveBeenCalled();
|
|
617
|
+
expect( mockSyncManager.getAwareness ).not.toHaveBeenCalled();
|
|
618
|
+
} );
|
|
619
|
+
} );
|
|
620
|
+
|
|
621
|
+
describe( 'useOnCollaboratorLeave', () => {
|
|
622
|
+
const me = createMockActiveUser( {
|
|
623
|
+
clientId: 1,
|
|
624
|
+
isMe: true,
|
|
625
|
+
} );
|
|
626
|
+
|
|
627
|
+
const alice = createMockActiveUser( {
|
|
628
|
+
clientId: 2,
|
|
629
|
+
isMe: false,
|
|
630
|
+
isConnected: true,
|
|
631
|
+
collaboratorInfo: {
|
|
632
|
+
id: 100,
|
|
633
|
+
name: 'Alice',
|
|
634
|
+
slug: 'alice',
|
|
635
|
+
avatar_urls: mockAvatarUrls,
|
|
636
|
+
browserType: 'Chrome',
|
|
637
|
+
enteredAt: 1704067300000,
|
|
638
|
+
},
|
|
639
|
+
} );
|
|
640
|
+
|
|
641
|
+
test( 'should fire callback when a connected collaborator disconnects', async () => {
|
|
642
|
+
const callback = jest.fn();
|
|
643
|
+
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
|
|
644
|
+
|
|
645
|
+
renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
|
|
646
|
+
|
|
647
|
+
// Alice disconnects
|
|
648
|
+
act( () => {
|
|
649
|
+
stateChangeCallback?.( [
|
|
650
|
+
me,
|
|
651
|
+
{ ...alice, isConnected: false },
|
|
652
|
+
] );
|
|
653
|
+
} );
|
|
654
|
+
|
|
655
|
+
await waitFor( () => {
|
|
656
|
+
expect( callback ).toHaveBeenCalledWith( alice );
|
|
657
|
+
} );
|
|
658
|
+
} );
|
|
659
|
+
|
|
660
|
+
test( 'should fire callback when a connected collaborator disappears from the list', async () => {
|
|
661
|
+
const callback = jest.fn();
|
|
662
|
+
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
|
|
663
|
+
|
|
664
|
+
renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
|
|
665
|
+
|
|
666
|
+
// Alice disappears entirely
|
|
667
|
+
act( () => {
|
|
668
|
+
stateChangeCallback?.( [ me ] );
|
|
669
|
+
} );
|
|
670
|
+
|
|
671
|
+
await waitFor( () => {
|
|
672
|
+
expect( callback ).toHaveBeenCalledWith( alice );
|
|
673
|
+
} );
|
|
674
|
+
} );
|
|
675
|
+
|
|
676
|
+
test( 'should not fire callback when an already-disconnected collaborator is removed', async () => {
|
|
677
|
+
const callback = jest.fn();
|
|
678
|
+
const disconnectedAlice = { ...alice, isConnected: false };
|
|
679
|
+
mockAwareness.getCurrentState.mockReturnValue( [
|
|
680
|
+
me,
|
|
681
|
+
disconnectedAlice,
|
|
682
|
+
] );
|
|
683
|
+
|
|
684
|
+
renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
|
|
685
|
+
|
|
686
|
+
// Disconnected Alice is removed from list (cleanup after delay)
|
|
687
|
+
act( () => {
|
|
688
|
+
stateChangeCallback?.( [ me ] );
|
|
689
|
+
} );
|
|
690
|
+
|
|
691
|
+
await waitFor( () => {
|
|
692
|
+
expect( callback ).not.toHaveBeenCalled();
|
|
693
|
+
} );
|
|
694
|
+
} );
|
|
695
|
+
|
|
696
|
+
test( 'should not fire callback for the current user disconnecting', async () => {
|
|
697
|
+
const callback = jest.fn();
|
|
698
|
+
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
|
|
699
|
+
|
|
700
|
+
renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
|
|
701
|
+
|
|
702
|
+
// "Me" disconnects
|
|
703
|
+
act( () => {
|
|
704
|
+
stateChangeCallback?.( [
|
|
705
|
+
{ ...me, isConnected: false },
|
|
706
|
+
alice,
|
|
707
|
+
] );
|
|
708
|
+
} );
|
|
709
|
+
|
|
710
|
+
await waitFor( () => {
|
|
711
|
+
expect( callback ).not.toHaveBeenCalled();
|
|
712
|
+
} );
|
|
713
|
+
} );
|
|
714
|
+
|
|
715
|
+
test( 'should not fire on initial mount', () => {
|
|
716
|
+
const callback = jest.fn();
|
|
717
|
+
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
|
|
718
|
+
|
|
719
|
+
renderHook( () => useOnCollaboratorLeave( 123, 'post', callback ) );
|
|
720
|
+
|
|
721
|
+
expect( callback ).not.toHaveBeenCalled();
|
|
722
|
+
} );
|
|
723
|
+
} );
|
|
724
|
+
|
|
725
|
+
describe( 'useOnPostSave', () => {
|
|
726
|
+
const me = createMockActiveUser( {
|
|
727
|
+
clientId: 1,
|
|
728
|
+
isMe: true,
|
|
729
|
+
} );
|
|
730
|
+
|
|
731
|
+
const alice = createMockActiveUser( {
|
|
732
|
+
clientId: 2,
|
|
733
|
+
isMe: false,
|
|
734
|
+
collaboratorInfo: {
|
|
735
|
+
id: 100,
|
|
736
|
+
name: 'Alice',
|
|
737
|
+
slug: 'alice',
|
|
738
|
+
avatar_urls: mockAvatarUrls,
|
|
739
|
+
browserType: 'Chrome',
|
|
740
|
+
enteredAt: 1704067300000,
|
|
741
|
+
},
|
|
742
|
+
} );
|
|
743
|
+
|
|
744
|
+
test( 'should fire callback when a remote collaborator saves', async () => {
|
|
745
|
+
const callback = jest.fn();
|
|
746
|
+
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
|
|
747
|
+
|
|
748
|
+
renderHook( () => useOnPostSave( 123, 'post', callback ) );
|
|
749
|
+
|
|
750
|
+
// Simulate a save event via the Y.Doc state map
|
|
751
|
+
const savedAt = Date.now() + 1000;
|
|
752
|
+
mockStateMapData = {
|
|
753
|
+
savedAt,
|
|
754
|
+
savedBy: alice.clientId,
|
|
755
|
+
};
|
|
756
|
+
mockRecordMapData = { status: 'draft' };
|
|
757
|
+
|
|
758
|
+
act( () => {
|
|
759
|
+
stateMapObserver?.( {
|
|
760
|
+
keysChanged: new Set( [ 'savedAt' ] ),
|
|
761
|
+
} );
|
|
762
|
+
} );
|
|
763
|
+
|
|
764
|
+
await waitFor( () => {
|
|
765
|
+
expect( callback ).toHaveBeenCalledWith(
|
|
766
|
+
{
|
|
767
|
+
savedAt,
|
|
768
|
+
savedByClientId: alice.clientId,
|
|
769
|
+
postStatus: 'draft',
|
|
770
|
+
},
|
|
771
|
+
alice,
|
|
772
|
+
null
|
|
773
|
+
);
|
|
774
|
+
} );
|
|
775
|
+
} );
|
|
776
|
+
|
|
777
|
+
test( 'should pass previous save event on subsequent saves', async () => {
|
|
778
|
+
const callback = jest.fn();
|
|
779
|
+
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
|
|
780
|
+
|
|
781
|
+
renderHook( () => useOnPostSave( 123, 'post', callback ) );
|
|
782
|
+
|
|
783
|
+
// First save
|
|
784
|
+
const firstSavedAt = Date.now() + 1000;
|
|
785
|
+
mockStateMapData = {
|
|
786
|
+
savedAt: firstSavedAt,
|
|
787
|
+
savedBy: alice.clientId,
|
|
788
|
+
};
|
|
789
|
+
mockRecordMapData = { status: 'draft' };
|
|
790
|
+
|
|
791
|
+
act( () => {
|
|
792
|
+
stateMapObserver?.( {
|
|
793
|
+
keysChanged: new Set( [ 'savedAt' ] ),
|
|
794
|
+
} );
|
|
795
|
+
} );
|
|
796
|
+
|
|
797
|
+
await waitFor( () => {
|
|
798
|
+
expect( callback ).toHaveBeenCalledTimes( 1 );
|
|
799
|
+
} );
|
|
800
|
+
|
|
801
|
+
// Second save
|
|
802
|
+
const secondSavedAt = Date.now() + 2000;
|
|
803
|
+
mockStateMapData = {
|
|
804
|
+
savedAt: secondSavedAt,
|
|
805
|
+
savedBy: alice.clientId,
|
|
806
|
+
};
|
|
807
|
+
mockRecordMapData = { status: 'publish' };
|
|
808
|
+
|
|
809
|
+
act( () => {
|
|
810
|
+
stateMapObserver?.( {
|
|
811
|
+
keysChanged: new Set( [ 'savedAt' ] ),
|
|
812
|
+
} );
|
|
813
|
+
} );
|
|
814
|
+
|
|
815
|
+
await waitFor( () => {
|
|
816
|
+
expect( callback ).toHaveBeenCalledTimes( 2 );
|
|
817
|
+
} );
|
|
818
|
+
|
|
819
|
+
expect( callback ).toHaveBeenLastCalledWith(
|
|
820
|
+
{
|
|
821
|
+
savedAt: secondSavedAt,
|
|
822
|
+
savedByClientId: alice.clientId,
|
|
823
|
+
postStatus: 'publish',
|
|
824
|
+
},
|
|
825
|
+
alice,
|
|
826
|
+
{
|
|
827
|
+
savedAt: firstSavedAt,
|
|
828
|
+
savedByClientId: alice.clientId,
|
|
829
|
+
postStatus: 'draft',
|
|
830
|
+
}
|
|
831
|
+
);
|
|
832
|
+
} );
|
|
833
|
+
|
|
834
|
+
test( 'should not fire callback when the current user saves', async () => {
|
|
835
|
+
const callback = jest.fn();
|
|
836
|
+
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
|
|
837
|
+
|
|
838
|
+
renderHook( () => useOnPostSave( 123, 'post', callback ) );
|
|
839
|
+
|
|
840
|
+
// Simulate a save event by "me"
|
|
841
|
+
const savedAt = Date.now() + 1000;
|
|
842
|
+
mockStateMapData = {
|
|
843
|
+
savedAt,
|
|
844
|
+
savedBy: me.clientId,
|
|
845
|
+
};
|
|
846
|
+
mockRecordMapData = { status: 'draft' };
|
|
847
|
+
|
|
848
|
+
act( () => {
|
|
849
|
+
stateMapObserver?.( {
|
|
850
|
+
keysChanged: new Set( [ 'savedAt' ] ),
|
|
851
|
+
} );
|
|
852
|
+
} );
|
|
853
|
+
|
|
854
|
+
await waitFor( () => {
|
|
855
|
+
expect( callback ).not.toHaveBeenCalled();
|
|
856
|
+
} );
|
|
857
|
+
} );
|
|
858
|
+
|
|
859
|
+
test( 'should not fire callback when saver is not in the collaborator list', async () => {
|
|
860
|
+
const callback = jest.fn();
|
|
861
|
+
mockAwareness.getCurrentState.mockReturnValue( [ me ] );
|
|
862
|
+
|
|
863
|
+
renderHook( () => useOnPostSave( 123, 'post', callback ) );
|
|
864
|
+
|
|
865
|
+
// Simulate a save from an unknown client
|
|
866
|
+
const savedAt = Date.now() + 1000;
|
|
867
|
+
mockStateMapData = {
|
|
868
|
+
savedAt,
|
|
869
|
+
savedBy: 99999,
|
|
870
|
+
};
|
|
871
|
+
mockRecordMapData = { status: 'draft' };
|
|
872
|
+
|
|
873
|
+
act( () => {
|
|
874
|
+
stateMapObserver?.( {
|
|
875
|
+
keysChanged: new Set( [ 'savedAt' ] ),
|
|
876
|
+
} );
|
|
877
|
+
} );
|
|
878
|
+
|
|
879
|
+
await waitFor( () => {
|
|
880
|
+
expect( callback ).not.toHaveBeenCalled();
|
|
881
|
+
} );
|
|
882
|
+
} );
|
|
883
|
+
|
|
884
|
+
test( 'should not fire duplicate callbacks for the same savedAt timestamp', async () => {
|
|
885
|
+
const callback = jest.fn();
|
|
886
|
+
mockAwareness.getCurrentState.mockReturnValue( [ me, alice ] );
|
|
887
|
+
|
|
888
|
+
renderHook( () => useOnPostSave( 123, 'post', callback ) );
|
|
889
|
+
|
|
890
|
+
const savedAt = Date.now() + 1000;
|
|
891
|
+
mockStateMapData = {
|
|
892
|
+
savedAt,
|
|
893
|
+
savedBy: alice.clientId,
|
|
894
|
+
};
|
|
895
|
+
mockRecordMapData = { status: 'draft' };
|
|
896
|
+
|
|
897
|
+
// First save event
|
|
898
|
+
act( () => {
|
|
899
|
+
stateMapObserver?.( {
|
|
900
|
+
keysChanged: new Set( [ 'savedAt' ] ),
|
|
901
|
+
} );
|
|
902
|
+
} );
|
|
903
|
+
|
|
904
|
+
await waitFor( () => {
|
|
905
|
+
expect( callback ).toHaveBeenCalledTimes( 1 );
|
|
906
|
+
} );
|
|
907
|
+
|
|
908
|
+
// Same savedAt again (e.g. component re-render)
|
|
909
|
+
act( () => {
|
|
910
|
+
stateMapObserver?.( {
|
|
911
|
+
keysChanged: new Set( [ 'savedAt' ] ),
|
|
912
|
+
} );
|
|
913
|
+
} );
|
|
914
|
+
|
|
915
|
+
// Should still be 1 call
|
|
916
|
+
expect( callback ).toHaveBeenCalledTimes( 1 );
|
|
917
|
+
} );
|
|
918
|
+
|
|
919
|
+
test( 'should not fire when postId is null', () => {
|
|
920
|
+
const callback = jest.fn();
|
|
921
|
+
|
|
922
|
+
renderHook( () => useOnPostSave( null, 'post', callback ) );
|
|
923
|
+
|
|
924
|
+
expect( callback ).not.toHaveBeenCalled();
|
|
925
|
+
} );
|
|
926
|
+
} );
|
|
484
927
|
} );
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* External dependencies
|
|
3
3
|
*/
|
|
4
|
+
import { usePrevious } from '@wordpress/compose';
|
|
4
5
|
import { useEffect, useState } from '@wordpress/element';
|
|
5
6
|
import type { Y } from '@wordpress/sync';
|
|
6
7
|
|
|
@@ -13,14 +14,9 @@ import type {
|
|
|
13
14
|
PostSaveEvent,
|
|
14
15
|
YDocDebugData,
|
|
15
16
|
} from '../awareness/types';
|
|
16
|
-
import type { SelectionState } from '../types';
|
|
17
|
+
import type { SelectionState, ResolvedSelection } from '../types';
|
|
17
18
|
import type { PostEditorAwareness } from '../awareness/post-editor-awareness';
|
|
18
19
|
|
|
19
|
-
interface ResolvedSelection {
|
|
20
|
-
textIndex: number | null;
|
|
21
|
-
localClientId: string | null;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
20
|
interface AwarenessState {
|
|
25
21
|
activeCollaborators: ActiveCollaborator[];
|
|
26
22
|
resolveSelection: ( selection: SelectionState ) => ResolvedSelection;
|
|
@@ -29,7 +25,7 @@ interface AwarenessState {
|
|
|
29
25
|
}
|
|
30
26
|
|
|
31
27
|
const defaultResolvedSelection: ResolvedSelection = {
|
|
32
|
-
|
|
28
|
+
richTextOffset: null,
|
|
33
29
|
localClientId: null,
|
|
34
30
|
};
|
|
35
31
|
|
|
@@ -167,7 +163,7 @@ export function useIsDisconnected(
|
|
|
167
163
|
* @param postId The ID of the post.
|
|
168
164
|
* @param postType The type of the post.
|
|
169
165
|
*/
|
|
170
|
-
|
|
166
|
+
function useLastPostSave(
|
|
171
167
|
postId: number | null,
|
|
172
168
|
postType: string | null
|
|
173
169
|
): PostSaveEvent | null {
|
|
@@ -226,3 +222,159 @@ export function useLastPostSave(
|
|
|
226
222
|
|
|
227
223
|
return lastSave;
|
|
228
224
|
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Hook that fires a callback when a new collaborator joins the post.
|
|
228
|
+
* Handles initial hydration and state diffing internally—consumers
|
|
229
|
+
* only receive "join" events for collaborators that appear after the
|
|
230
|
+
* initial state has loaded.
|
|
231
|
+
*
|
|
232
|
+
* The callback receives the joining collaborator and, when available,
|
|
233
|
+
* the current user's state (useful for comparing `enteredAt` timestamps).
|
|
234
|
+
*
|
|
235
|
+
* @param postId The ID of the post.
|
|
236
|
+
* @param postType The type of the post.
|
|
237
|
+
* @param callback Invoked for each collaborator that joins.
|
|
238
|
+
*/
|
|
239
|
+
export function useOnCollaboratorJoin(
|
|
240
|
+
postId: number | null,
|
|
241
|
+
postType: string | null,
|
|
242
|
+
callback: (
|
|
243
|
+
collaborator: ActiveCollaborator,
|
|
244
|
+
me?: ActiveCollaborator
|
|
245
|
+
) => void
|
|
246
|
+
): void {
|
|
247
|
+
const { activeCollaborators } = usePostEditorAwarenessState(
|
|
248
|
+
postId,
|
|
249
|
+
postType
|
|
250
|
+
);
|
|
251
|
+
const prevCollaborators = usePrevious( activeCollaborators );
|
|
252
|
+
|
|
253
|
+
useEffect( () => {
|
|
254
|
+
/*
|
|
255
|
+
* On first render usePrevious returns undefined. On subsequent
|
|
256
|
+
* renders the list may still be empty while the store hydrates.
|
|
257
|
+
* In both cases, skip to avoid spurious "joined" callbacks for
|
|
258
|
+
* users already present.
|
|
259
|
+
*/
|
|
260
|
+
if ( ! prevCollaborators || prevCollaborators.length === 0 ) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const prevMap = new Map< number, ActiveCollaborator >(
|
|
265
|
+
prevCollaborators.map( ( collaborator ) => [
|
|
266
|
+
collaborator.clientId,
|
|
267
|
+
collaborator,
|
|
268
|
+
] )
|
|
269
|
+
);
|
|
270
|
+
const me = activeCollaborators.find(
|
|
271
|
+
( collaborator ) => collaborator.isMe
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
for ( const collaborator of activeCollaborators ) {
|
|
275
|
+
if (
|
|
276
|
+
! prevMap.has( collaborator.clientId ) &&
|
|
277
|
+
! collaborator.isMe
|
|
278
|
+
) {
|
|
279
|
+
callback( collaborator, me );
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}, [ activeCollaborators, prevCollaborators, callback ] );
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Hook that fires a callback when a collaborator leaves the post.
|
|
287
|
+
* A "leave" is detected when a previously-connected collaborator either
|
|
288
|
+
* transitions to `isConnected = false` or disappears from the list
|
|
289
|
+
* entirely while still connected. Already-disconnected collaborators
|
|
290
|
+
* that are later removed from the list are silently ignored.
|
|
291
|
+
*
|
|
292
|
+
* @param postId The ID of the post.
|
|
293
|
+
* @param postType The type of the post.
|
|
294
|
+
* @param callback Invoked for each collaborator that leaves.
|
|
295
|
+
*/
|
|
296
|
+
export function useOnCollaboratorLeave(
|
|
297
|
+
postId: number | null,
|
|
298
|
+
postType: string | null,
|
|
299
|
+
callback: ( collaborator: ActiveCollaborator ) => void
|
|
300
|
+
): void {
|
|
301
|
+
const { activeCollaborators } = usePostEditorAwarenessState(
|
|
302
|
+
postId,
|
|
303
|
+
postType
|
|
304
|
+
);
|
|
305
|
+
const prevCollaborators = usePrevious( activeCollaborators );
|
|
306
|
+
|
|
307
|
+
useEffect( () => {
|
|
308
|
+
if ( ! prevCollaborators || prevCollaborators.length === 0 ) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const newMap = new Map< number, ActiveCollaborator >(
|
|
313
|
+
activeCollaborators.map( ( collaborator ) => [
|
|
314
|
+
collaborator.clientId,
|
|
315
|
+
collaborator,
|
|
316
|
+
] )
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
for ( const prevCollab of prevCollaborators ) {
|
|
320
|
+
if ( prevCollab.isMe || ! prevCollab.isConnected ) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const newCollab = newMap.get( prevCollab.clientId );
|
|
325
|
+
if ( ! newCollab?.isConnected ) {
|
|
326
|
+
callback( prevCollab );
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}, [ activeCollaborators, prevCollaborators, callback ] );
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Hook that fires a callback when a remote collaborator saves the post.
|
|
334
|
+
* Only fires for saves by other collaborators (not the current user).
|
|
335
|
+
* Deduplicates by `savedAt` timestamp so the same save event is never
|
|
336
|
+
* reported twice.
|
|
337
|
+
*
|
|
338
|
+
* @param postId The ID of the post.
|
|
339
|
+
* @param postType The type of the post.
|
|
340
|
+
* @param callback Invoked with the save event, the collaborator who saved,
|
|
341
|
+
* and the previous save event (if any) for transition detection.
|
|
342
|
+
*/
|
|
343
|
+
export function useOnPostSave(
|
|
344
|
+
postId: number | null,
|
|
345
|
+
postType: string | null,
|
|
346
|
+
callback: (
|
|
347
|
+
event: PostSaveEvent,
|
|
348
|
+
saver: ActiveCollaborator,
|
|
349
|
+
prevEvent: PostSaveEvent | null
|
|
350
|
+
) => void
|
|
351
|
+
): void {
|
|
352
|
+
const { activeCollaborators } = usePostEditorAwarenessState(
|
|
353
|
+
postId,
|
|
354
|
+
postType
|
|
355
|
+
);
|
|
356
|
+
const lastPostSave = useLastPostSave( postId, postType );
|
|
357
|
+
const prevPostSave = usePrevious( lastPostSave );
|
|
358
|
+
|
|
359
|
+
useEffect( () => {
|
|
360
|
+
if ( ! lastPostSave ) {
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if ( prevPostSave && lastPostSave.savedAt === prevPostSave.savedAt ) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const saver = activeCollaborators.find(
|
|
369
|
+
( collaborator ) =>
|
|
370
|
+
collaborator.clientId === lastPostSave.savedByClientId &&
|
|
371
|
+
! collaborator.isMe
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
if ( ! saver ) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
callback( lastPostSave, saver, prevPostSave ?? null );
|
|
379
|
+
}, [ lastPostSave, prevPostSave, activeCollaborators, callback ] );
|
|
380
|
+
}
|
package/src/index.js
CHANGED
|
@@ -138,6 +138,7 @@ register( store ); // Register store after unlocking private selectors to allow
|
|
|
138
138
|
* based on their values (they blur to string type).
|
|
139
139
|
*/
|
|
140
140
|
export { SelectionType } from './utils/crdt-user-selections';
|
|
141
|
+
export { SelectionDirection } from './types';
|
|
141
142
|
|
|
142
143
|
export { default as EntityProvider } from './entity-provider';
|
|
143
144
|
export * from './entity-provider';
|