@elementor/editor-documents 0.1.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.
@@ -0,0 +1,51 @@
1
+ import { renderHook } from '@testing-library/react-hooks';
2
+ import useActiveDocumentActions from '../use-active-document-actions';
3
+ import { openRoute, runCommand } from '@elementor/editor-v1-adapters';
4
+
5
+ jest.mock( '@elementor/editor-v1-adapters' );
6
+
7
+ describe( '@elementor/editor-documents - useActiveDocumentActions', () => {
8
+ it( 'should run documents actions', () => {
9
+ // Arrange.
10
+ const { result } = renderHook( () => useActiveDocumentActions() );
11
+
12
+ const {
13
+ save,
14
+ saveDraft,
15
+ saveTemplate,
16
+ } = result.current;
17
+
18
+ // Act.
19
+ save();
20
+ saveDraft();
21
+ saveTemplate();
22
+
23
+ // Assert.
24
+ expect( runCommand ).toHaveBeenCalledTimes( 2 );
25
+
26
+ expect( runCommand ).toHaveBeenNthCalledWith( 1, 'document/save/default' );
27
+ expect( runCommand ).toHaveBeenNthCalledWith( 2, 'document/save/draft' );
28
+
29
+ expect( openRoute ).toHaveBeenCalledTimes( 1 );
30
+ expect( openRoute ).toHaveBeenCalledWith( 'library/save-template' );
31
+ } );
32
+
33
+ it( 'should return memoized callbacks', () => {
34
+ // Arrange.
35
+ const { result, rerender } = renderHook( () => useActiveDocumentActions() );
36
+
37
+ const {
38
+ save,
39
+ saveDraft,
40
+ saveTemplate,
41
+ } = result.current;
42
+
43
+ // Act.
44
+ rerender();
45
+
46
+ // Assert.
47
+ expect( result.current.save ).toBe( save );
48
+ expect( result.current.saveDraft ).toBe( saveDraft );
49
+ expect( result.current.saveTemplate ).toBe( saveTemplate );
50
+ } );
51
+ } );
@@ -0,0 +1,35 @@
1
+ import { Slice, createSlice } from '../../store';
2
+ import useActiveDocument from '../use-active-document';
3
+ import { createStore, dispatch, SliceState, Store } from '@elementor/store';
4
+ import { createMockDocument, renderHookWithStore } from 'test-utils';
5
+
6
+ describe( '@elementor/editor-documents - useActiveDocument', () => {
7
+ let store: Store<SliceState<Slice>>;
8
+ let slice: Slice;
9
+
10
+ beforeEach( () => {
11
+ slice = createSlice();
12
+ store = createStore();
13
+ } );
14
+
15
+ it( 'should return the current document', () => {
16
+ // Arrange.
17
+ const mockDocument = createMockDocument();
18
+
19
+ dispatch( slice.actions.activateDocument( mockDocument ) );
20
+
21
+ // Act.
22
+ const { result } = renderHookWithStore( useActiveDocument, store );
23
+
24
+ // Assert.
25
+ expect( result.current ).toBe( mockDocument );
26
+ } );
27
+
28
+ it( 'should return null when the current document is not found', () => {
29
+ // Act.
30
+ const { result } = renderHookWithStore( useActiveDocument, store );
31
+
32
+ // Assert.
33
+ expect( result.current ).toBeNull();
34
+ } );
35
+ } );
@@ -0,0 +1,46 @@
1
+ import { Slice, createSlice } from '../../store';
2
+ import { createStore, dispatch, SliceState, Store } from '@elementor/store';
3
+ import useHostDocument from '../use-host-document';
4
+ import { createMockDocument, renderHookWithStore } from 'test-utils';
5
+
6
+ describe( '@elementor/editor-documents - useHostDocument', () => {
7
+ const mockDocument = createMockDocument();
8
+
9
+ let store: Store<SliceState<Slice>>;
10
+ let slice: Slice;
11
+
12
+ beforeEach( () => {
13
+ slice = createSlice();
14
+ store = createStore();
15
+ } );
16
+
17
+ it( 'should return the host document', () => {
18
+ // Arrange.
19
+ dispatch( slice.actions.init( {
20
+ entities: { [ mockDocument.id ]: mockDocument },
21
+ activeId: null,
22
+ hostId: mockDocument.id,
23
+ } ) );
24
+
25
+ // Act.
26
+ const { result } = renderHookWithStore( useHostDocument, store );
27
+
28
+ // Assert.
29
+ expect( result.current ).toEqual( expect.objectContaining( { id: mockDocument.id } ) );
30
+ } );
31
+
32
+ it( 'should return null when the host document is not found', () => {
33
+ // Arrange.
34
+ dispatch( slice.actions.init( {
35
+ entities: { [ mockDocument.id ]: mockDocument },
36
+ activeId: null,
37
+ hostId: null,
38
+ } ) );
39
+
40
+ // Act.
41
+ const { result } = renderHookWithStore( useHostDocument, store );
42
+
43
+ // Assert.
44
+ expect( result.current ).toBeNull();
45
+ } );
46
+ } );
@@ -0,0 +1,3 @@
1
+ export { default as useActiveDocument } from './use-active-document';
2
+ export { default as useActiveDocumentActions } from './use-active-document-actions';
3
+ export { default as useHostDocument } from './use-host-document';
@@ -0,0 +1,16 @@
1
+ import { useCallback } from 'react';
2
+ import { openRoute, runCommand } from '@elementor/editor-v1-adapters';
3
+
4
+ export default function useActiveDocumentActions() {
5
+ const save = useCallback( () => runCommand( 'document/save/default' ), [] );
6
+
7
+ const saveDraft = useCallback( () => runCommand( 'document/save/draft' ), [] );
8
+
9
+ const saveTemplate = useCallback( () => openRoute( 'library/save-template' ), [] );
10
+
11
+ return {
12
+ save,
13
+ saveDraft,
14
+ saveTemplate,
15
+ };
16
+ }
@@ -0,0 +1,6 @@
1
+ import { useSelector } from '@elementor/store';
2
+ import { selectActiveDocument } from '../store/selectors';
3
+
4
+ export default function useActiveDocument() {
5
+ return useSelector( selectActiveDocument );
6
+ }
@@ -0,0 +1,6 @@
1
+ import { useSelector } from '@elementor/store';
2
+ import { selectHostDocument } from '../store/selectors';
3
+
4
+ export default function useHostDocument() {
5
+ return useSelector( selectHostDocument );
6
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ import init from './init';
2
+
3
+ export * from './hooks';
4
+ export * from './types';
5
+
6
+ init();
package/src/init.ts ADDED
@@ -0,0 +1,12 @@
1
+ import { syncStore } from './sync';
2
+ import { createSlice } from './store';
3
+
4
+ export default function init() {
5
+ initStore();
6
+ }
7
+
8
+ function initStore() {
9
+ const slice = createSlice();
10
+
11
+ syncStore( slice );
12
+ }
@@ -0,0 +1,83 @@
1
+ import { Document } from '../types';
2
+ import { addSlice, PayloadAction } from '@elementor/store';
3
+
4
+ type State = {
5
+ entities: Record<Document['id'], Document>,
6
+ activeId: Document['id'] | null, // The currently editing document.
7
+ hostId: Document['id'] | null, // The document that host all the other documents.
8
+ }
9
+
10
+ export type Slice = ReturnType<typeof createSlice>;
11
+
12
+ const initialState: State = {
13
+ entities: {},
14
+ activeId: null,
15
+ hostId: null,
16
+ };
17
+
18
+ type StateWithActiveId = Omit<State, 'activeId'> & { activeId: NonNullable<State['activeId']> };
19
+
20
+ function hasActiveEntity( state: State ): state is StateWithActiveId {
21
+ return !! ( state.activeId && state.entities[ state.activeId ] );
22
+ }
23
+
24
+ export function createSlice() {
25
+ return addSlice( {
26
+ name: 'documents',
27
+ initialState,
28
+ reducers: {
29
+ init( state, { payload } : PayloadAction<State> ) {
30
+ state.entities = payload.entities;
31
+ state.hostId = payload.hostId;
32
+ state.activeId = payload.activeId;
33
+ },
34
+
35
+ activateDocument( state, action: PayloadAction<Document> ) {
36
+ state.entities[ action.payload.id ] = action.payload;
37
+ state.activeId = action.payload.id;
38
+ },
39
+
40
+ startSaving( state ) {
41
+ if ( hasActiveEntity( state ) ) {
42
+ state.entities[ state.activeId ].isSaving = true;
43
+ }
44
+ },
45
+
46
+ endSaving( state, action: PayloadAction<Document> ) {
47
+ if ( hasActiveEntity( state ) ) {
48
+ state.entities[ state.activeId ] = {
49
+ ...action.payload,
50
+ isSaving: false,
51
+ };
52
+ }
53
+ },
54
+
55
+ startSavingDraft: ( state ) => {
56
+ if ( hasActiveEntity( state ) ) {
57
+ state.entities[ state.activeId ].isSavingDraft = true;
58
+ }
59
+ },
60
+
61
+ endSavingDraft( state, action: PayloadAction<Document> ) {
62
+ if ( hasActiveEntity( state ) ) {
63
+ state.entities[ state.activeId ] = {
64
+ ...action.payload,
65
+ isSavingDraft: false,
66
+ };
67
+ }
68
+ },
69
+
70
+ markAsDirty( state ) {
71
+ if ( hasActiveEntity( state ) ) {
72
+ state.entities[ state.activeId ].isDirty = true;
73
+ }
74
+ },
75
+
76
+ markAsPristine( state ) {
77
+ if ( hasActiveEntity( state ) ) {
78
+ state.entities[ state.activeId ].isDirty = false;
79
+ }
80
+ },
81
+ },
82
+ } );
83
+ }
@@ -0,0 +1,24 @@
1
+ import type { Slice } from './index';
2
+ import { createSelector, SliceState } from '@elementor/store';
3
+
4
+ type State = SliceState<Slice>;
5
+
6
+ const selectEntities = ( state: State ) => state.documents.entities;
7
+ const selectActiveId = ( state: State ) => state.documents.activeId;
8
+ const selectHostId = ( state: State ) => state.documents.hostId;
9
+
10
+ export const selectActiveDocument = createSelector(
11
+ selectEntities,
12
+ selectActiveId,
13
+ ( entities, activeId ) => activeId && entities[ activeId ]
14
+ ? entities[ activeId ]
15
+ : null,
16
+ );
17
+
18
+ export const selectHostDocument = createSelector(
19
+ selectEntities,
20
+ selectHostId,
21
+ ( entities, hostId ) => hostId && entities[ hostId ]
22
+ ? entities[ hostId ]
23
+ : null,
24
+ );
@@ -0,0 +1,295 @@
1
+ import { syncStore } from '../';
2
+ import { Slice, createSlice } from '../../store';
3
+ import { ExtendedWindow, V1Document } from '../../types';
4
+ import { createStore, SliceState, Store } from '@elementor/store';
5
+ import {
6
+ dispatchCommandAfter,
7
+ dispatchCommandBefore,
8
+ dispatchV1ReadyEvent,
9
+ makeDocumentsManager,
10
+ makeMockV1Document,
11
+ } from './test-utils';
12
+ import { selectActiveDocument } from '../../store/selectors';
13
+
14
+ type WindowWithOptionalElementor = Omit<ExtendedWindow, 'elementor'> & {
15
+ elementor?: ExtendedWindow['elementor'];
16
+ }
17
+
18
+ describe( '@elementor/editor-documents - Sync Store', () => {
19
+ let store: Store<SliceState<Slice>>;
20
+ let slice: Slice;
21
+
22
+ beforeEach( () => {
23
+ slice = createSlice();
24
+ store = createStore();
25
+
26
+ syncStore( slice );
27
+ } );
28
+
29
+ it( 'should sync documents on V1 load', () => {
30
+ // Arrange.
31
+ mockV1DocumentsManager( [
32
+ makeMockV1Document( { id: 1 } ),
33
+ makeMockV1Document( { id: 2 } ),
34
+ ] );
35
+
36
+ // Act.
37
+ dispatchV1ReadyEvent();
38
+
39
+ // Assert.
40
+ const storeState = store.getState();
41
+
42
+ expect( storeState.documents.entities ).toEqual( {
43
+ 1: {
44
+ id: 1,
45
+ title: 'Document 1',
46
+ type: {
47
+ value: 'wp-page',
48
+ label: 'WP-PAGE',
49
+ },
50
+ status: {
51
+ value: 'publish',
52
+ label: 'PUBLISH',
53
+ },
54
+ isDirty: false,
55
+ isSaving: false,
56
+ isSavingDraft: false,
57
+ userCan: {
58
+ publish: true,
59
+ },
60
+ },
61
+ 2: {
62
+ id: 2,
63
+ title: 'Document 2',
64
+ type: {
65
+ value: 'wp-page',
66
+ label: 'WP-PAGE',
67
+ },
68
+ status: {
69
+ value: 'publish',
70
+ label: 'PUBLISH',
71
+ },
72
+ isDirty: false,
73
+ isSaving: false,
74
+ isSavingDraft: false,
75
+ userCan: {
76
+ publish: true,
77
+ },
78
+ },
79
+ } );
80
+ } );
81
+
82
+ it.each( [
83
+ {
84
+ type: 'V1 load',
85
+ dispatchEvent: () => dispatchV1ReadyEvent(),
86
+ },
87
+ {
88
+ type: 'document open',
89
+ dispatchEvent: () => dispatchCommandAfter( 'editor/documents/open' ),
90
+ },
91
+ ] )( 'should sync active document on $type', ( { dispatchEvent } ) => {
92
+ // Arrange.
93
+ mockV1DocumentsManager( [
94
+ makeMockV1Document( { id: 1 } ),
95
+ makeMockV1Document( { id: 2 } ),
96
+ ], 2 );
97
+
98
+ // Act.
99
+ dispatchEvent();
100
+
101
+ // Assert.
102
+ const currentDocument = selectActiveDocument( store.getState() );
103
+
104
+ expect( currentDocument ).toEqual( {
105
+ id: 2,
106
+ title: 'Document 2',
107
+ type: {
108
+ value: 'wp-page',
109
+ label: 'WP-PAGE',
110
+ },
111
+ status: {
112
+ value: 'publish',
113
+ label: 'PUBLISH',
114
+ },
115
+ isDirty: false,
116
+ isSaving: false,
117
+ isSavingDraft: false,
118
+ userCan: {
119
+ publish: true,
120
+ },
121
+ } );
122
+ } );
123
+
124
+ it( 'should sync saving state of a document on V1 load', () => {
125
+ // Arrange.
126
+ const mockDocument = makeMockV1Document();
127
+
128
+ mockV1DocumentsManager( [
129
+ {
130
+ ...mockDocument,
131
+ editor: {
132
+ ...mockDocument.editor,
133
+ isSaving: true,
134
+ },
135
+ },
136
+ ] );
137
+
138
+ // Act.
139
+ dispatchV1ReadyEvent();
140
+
141
+ // Assert.
142
+ expect( selectActiveDocument( store.getState() )?.isSaving ).toBe( true );
143
+ } );
144
+
145
+ it( 'should sync saving state of a document on save', () => {
146
+ // Arrange.
147
+ mockV1DocumentsManager( [
148
+ makeMockV1Document(),
149
+ ] );
150
+
151
+ // Populate the documents state.
152
+ dispatchV1ReadyEvent();
153
+
154
+ // Assert - Default state.
155
+ expect( selectActiveDocument( store.getState() )?.isSaving ).toBe( false );
156
+
157
+ // Act.
158
+ dispatchCommandBefore( 'document/save/save' );
159
+
160
+ // Assert - On save start.
161
+ expect( selectActiveDocument( store.getState() )?.isSaving ).toBe( true );
162
+ expect( selectActiveDocument( store.getState() )?.isSavingDraft ).toBe( false );
163
+
164
+ // Act.
165
+ dispatchCommandAfter( 'document/save/save' );
166
+
167
+ // Assert - On save end.
168
+ expect( selectActiveDocument( store.getState() )?.isSaving ).toBe( false );
169
+ expect( selectActiveDocument( store.getState() )?.isSavingDraft ).toBe( false );
170
+ } );
171
+
172
+ it( 'should sync draft saving state of a document on save', () => {
173
+ // Arrange.
174
+ mockV1DocumentsManager( [
175
+ makeMockV1Document(),
176
+ ] );
177
+
178
+ // Populate the documents state.
179
+ dispatchV1ReadyEvent();
180
+
181
+ // Assert - Default state.
182
+ expect( selectActiveDocument( store.getState() )?.isSavingDraft ).toBe( false );
183
+
184
+ // Act.
185
+ dispatchCommandBefore( 'document/save/save', {
186
+ status: 'autosave',
187
+ } );
188
+
189
+ // Assert - On save start.
190
+ expect( selectActiveDocument( store.getState() )?.isSaving ).toBe( false );
191
+ expect( selectActiveDocument( store.getState() )?.isSavingDraft ).toBe( true );
192
+
193
+ // Act.
194
+ dispatchCommandAfter( 'document/save/save', {
195
+ status: 'autosave',
196
+ } );
197
+
198
+ // Assert - On save end.
199
+ expect( selectActiveDocument( store.getState() )?.isSaving ).toBe( false );
200
+ expect( selectActiveDocument( store.getState() )?.isSavingDraft ).toBe( false );
201
+ } );
202
+
203
+ it( 'should sync dirty state of a document when it has an autosave', () => {
204
+ // Arrange.
205
+ const mockDocument = makeMockV1Document( { id: 1 } );
206
+
207
+ mockV1DocumentsManager( [ {
208
+ ...mockDocument,
209
+ config: {
210
+ ...mockDocument.config,
211
+ revisions: {
212
+ current_id: 2,
213
+ },
214
+ },
215
+ } ] );
216
+
217
+ // Act.
218
+ dispatchV1ReadyEvent();
219
+
220
+ // Assert.
221
+ expect( selectActiveDocument( store.getState() )?.isDirty ).toBe( true );
222
+ } );
223
+
224
+ it( 'should sync dirty state of a document on document change', () => {
225
+ // Arrange.
226
+ const mockDocument = makeMockV1Document();
227
+
228
+ mockV1DocumentsManager( [
229
+ mockDocument,
230
+ ] );
231
+
232
+ // Populate the documents state.
233
+ dispatchV1ReadyEvent();
234
+
235
+ // Mock a change.
236
+ mockDocument.editor.isChanged = true;
237
+
238
+ // Assert - Default state.
239
+ expect( selectActiveDocument( store.getState() )?.isDirty ).toBe( false );
240
+
241
+ // Act.
242
+ dispatchCommandAfter( 'document/save/set-is-modified' );
243
+
244
+ // Assert - After change.
245
+ expect( selectActiveDocument( store.getState() )?.isDirty ).toBe( true );
246
+
247
+ // Emulate a save / undo action that flips the `isChanged` back to `false`.
248
+ mockDocument.editor.isChanged = false;
249
+
250
+ dispatchCommandAfter( 'document/save/set-is-modified' );
251
+
252
+ // Assert - After change.
253
+ expect( selectActiveDocument( store.getState() )?.isDirty ).toBe( false );
254
+ } );
255
+
256
+ it( 'should update the document when finish saving', () => {
257
+ // Arrange.
258
+ mockV1DocumentsManager( [
259
+ makeMockV1Document( {
260
+ id: 1,
261
+ status: 'draft',
262
+ title: 'test',
263
+ } ),
264
+ ] );
265
+
266
+ // Populate the documents state.
267
+ dispatchV1ReadyEvent();
268
+
269
+ // Mock a change.
270
+ mockV1DocumentsManager( [
271
+ makeMockV1Document( {
272
+ id: 1,
273
+ status: 'publish',
274
+ title: 'test title changed',
275
+ } ),
276
+ ] );
277
+
278
+ // Assert.
279
+ expect( selectActiveDocument( store.getState() )?.title ).toBe( 'test' );
280
+ expect( selectActiveDocument( store.getState() )?.status.value ).toBe( 'draft' );
281
+
282
+ // Act.
283
+ dispatchCommandAfter( 'document/save/save' );
284
+
285
+ // Assert.
286
+ expect( selectActiveDocument( store.getState() )?.title ).toBe( 'test title changed' );
287
+ expect( selectActiveDocument( store.getState() )?.status.value ).toBe( 'publish' );
288
+ } );
289
+ } );
290
+
291
+ function mockV1DocumentsManager( documentsArray: V1Document[], current = 1 ) {
292
+ ( window as unknown as WindowWithOptionalElementor ).elementor = {
293
+ documents: makeDocumentsManager( documentsArray, current ),
294
+ };
295
+ }
@@ -0,0 +1,98 @@
1
+ import { V1Document } from '../../types';
2
+
3
+ export function dispatchCommandBefore( command: string, args: object = {} ) {
4
+ window.dispatchEvent( new CustomEvent( 'elementor/commands/run/before', {
5
+ detail: {
6
+ command,
7
+ args,
8
+ },
9
+ } ) );
10
+ }
11
+
12
+ export function dispatchCommandAfter( command: string, args: object = {} ) {
13
+ window.dispatchEvent( new CustomEvent( 'elementor/commands/run/after', {
14
+ detail: {
15
+ command,
16
+ args,
17
+ },
18
+ } ) );
19
+ }
20
+
21
+ export function dispatchWindowEvent( event: string ) {
22
+ window.dispatchEvent( new CustomEvent( event ) );
23
+ }
24
+
25
+ export function dispatchV1ReadyEvent() {
26
+ dispatchWindowEvent( 'elementor/initialized' );
27
+ }
28
+
29
+ export function makeDocumentsManager( documentsArray: V1Document[], current = 1, initial = current ) {
30
+ const documents = documentsArray.reduce( ( acc: Record<number, V1Document>, document ) => {
31
+ acc[ document.id ] = document;
32
+
33
+ return acc;
34
+ }, {} );
35
+
36
+ return {
37
+ documents,
38
+ getCurrentId() {
39
+ return current;
40
+ },
41
+ getInitialId() {
42
+ return initial;
43
+ },
44
+ getCurrent() {
45
+ return this.documents[ this.getCurrentId() ];
46
+ },
47
+ };
48
+ }
49
+
50
+ export function makeMockV1Document( {
51
+ id = 1,
52
+ title = 'Document ' + id,
53
+ status = 'publish',
54
+ type = 'wp-page',
55
+ }: {
56
+ id?: number,
57
+ status?: string,
58
+ title?: string,
59
+ type?: string,
60
+ } = {} ): V1Document {
61
+ return {
62
+ id,
63
+ config: {
64
+ type,
65
+ user: {
66
+ can_publish: true,
67
+ },
68
+ revisions: {
69
+ current_id: id,
70
+ },
71
+ panel: {
72
+ title: type.toUpperCase(),
73
+ },
74
+ status: {
75
+ label: status.toUpperCase(),
76
+ value: status,
77
+ },
78
+ },
79
+ editor: {
80
+ isChanged: false,
81
+ isSaving: false,
82
+ },
83
+ container: {
84
+ settings: makeV1Settings( {
85
+ post_title: title,
86
+ } ),
87
+ },
88
+ };
89
+ }
90
+
91
+ // Mock Backbone's settings model.
92
+ function makeV1Settings<T extends object>( settings: T ) {
93
+ return {
94
+ get( key: keyof T ) {
95
+ return settings[ key ];
96
+ },
97
+ } as V1Document['container']['settings'];
98
+ }