UNPKG

@stackbit/utils

Version:
688 lines (633 loc) 28.2 kB
import { describe, expect, test } from '@jest/globals'; import type * as StackbitTypes from '@stackbit/types'; import { createContentSourceWithDrafts } from '../src'; import { createMockedContentSource, createMockInitOptions } from './test-utils/content-source-mock'; const models: StackbitTypes.Model[] = [ { type: 'page', name: 'pageModel', actions: [ { name: 'existing_action', type: 'document', run: async () => {} } ], fields: [ { type: 'string', name: 'title' }, { type: 'enum', name: 'style', options: ['option-a', 'option-b'] } ] }, { type: 'data', name: 'dataModel', fields: [ { type: 'string', name: 'title' } ] }, { type: 'object', name: 'objectModel', fields: [ { type: 'string', name: 'title' } ] } ]; describe('test createContentSourceWithDrafts', () => { test('should override getSchema function and add "save_draft" and "cancel_draft" actions to existing actions of a page model', async () => { const contentSource = createMockedContentSource({ getSchema: async () => { return { models: models, locales: [], context: null }; } }); const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource }); const schema = await contentSourceWithDrafts.getSchema(); const pageModel = schema.models.find((model) => model.name === 'pageModel') as StackbitTypes.PageModel; expect(pageModel.actions).toEqual([ { name: 'existing_action', type: 'document', run: expect.any(Function) }, { name: 'save_draft', label: 'Save', icon: 'check', preferredStyle: 'button-primary', state: expect.any(Function), run: expect.any(Function) }, { name: 'cancel_draft', label: 'Cancel', icon: 'xmark', preferredStyle: 'button-secondary', state: expect.any(Function), run: expect.any(Function) } ]); }); test('should override getSchema function and add "save_draft" and "cancel_draft" actions to data model', async () => { const contentSource = createMockedContentSource({ getSchema: async () => { return { models: models, locales: [], context: null }; } }); const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource }); const schema = await contentSourceWithDrafts.getSchema(); const dataModel = schema.models.find((model) => model.name === 'dataModel') as StackbitTypes.DataModel; expect(dataModel.actions).toEqual([ { name: 'save_draft', label: 'Save', icon: 'check', preferredStyle: 'button-primary', state: expect.any(Function), run: expect.any(Function) }, { name: 'cancel_draft', label: 'Cancel', icon: 'xmark', preferredStyle: 'button-secondary', state: expect.any(Function), run: expect.any(Function) } ]); }); test('no actions should be added to object models', async () => { const contentSource = createMockedContentSource({ getSchema: async () => { return { models: models, locales: [], context: null }; } }); const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource }); const schema = await contentSourceWithDrafts.getSchema(); const objectModel = schema.models.find((model) => model.name === 'objectModel') as StackbitTypes.ObjectModel; expect(objectModel).toEqual({ type: 'object', name: 'objectModel', fields: [ { type: 'string', name: 'title' } ] }); }); test('should concatenate update operations, and call updateDocument of the wrapped content source when "save_draft" is invoked', async () => { const contentSource = createMockedContentSource({ getSchema: async () => { return { models: models, locales: [], context: null }; }, updateDocument: async () => {} }); // existing document const document: StackbitTypes.Document = { type: 'document', modelName: 'pageModel', id: 'doc-1', status: 'modified', manageUrl: '', createdAt: '2024-01-18T10:00:00.000Z', updatedAt: '2024-01-18T14:00:00.000Z', context: null, fields: {} }; const initOptionsMock = createMockInitOptions(); initOptionsMock.cache.getModelByName.mockImplementation((modelName) => models.find((model) => model.name === modelName)); // Create content source with drafts wrapping the mocked content source const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource }); await contentSourceWithDrafts.init(initOptionsMock); const schema = await contentSourceWithDrafts.getSchema(); const pageModel = schema.models.find((model) => model.name === 'pageModel')! as StackbitTypes.PageModel; const saveDraftAction = pageModel.actions!.find((action) => action.name === 'save_draft')! as StackbitTypes.CustomActionDocument; // The "save" action state should be disabled because there are no cached changes. let saveActionState = await saveDraftAction.state!({ document: document } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions); expect(saveActionState).toEqual('disabled'); // Call updateDocument on the content source with drafts passing an // update operation with updates to the "title" field. initOptionsMock.cache.getDocumentById.mockImplementationOnce(() => document); const firstOperation: StackbitTypes.UpdateOperation = { opType: 'set', modelField: models.find((model) => model.name === 'pageModel')!.fields!.find((field) => field.name === 'title')!, fieldPath: ['title'], field: { type: 'string', value: 'new title' } }; await contentSourceWithDrafts.updateDocument({ document, operations: [firstOperation] }); // The content source with drafts should call the cache.updateContent // and pass the updated document. let expectedDocument: StackbitTypes.Document = { ...document, fields: { title: { type: 'string', value: 'new title' } } }; expect(initOptionsMock.cache.updateContent).toBeCalledTimes(1); expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(1, { documents: [expectedDocument] }); // Call updateDocument on the content source with drafts again passing // an update operation with updates to the "style" field. // The document passed to updateDocument should include updates to the // previously updated "title" field. const secondOperation: StackbitTypes.UpdateOperation = { opType: 'set', modelField: models.find((model) => model.name === 'pageModel')!.fields!.find((field) => field.name === 'style')!, fieldPath: ['style'], field: { type: 'enum', value: 'option-b' } }; await contentSourceWithDrafts.updateDocument({ document: expectedDocument, operations: [secondOperation] }); // The content source with drafts should call the cache.updateContent // again and pass the updated document with both fields. expectedDocument = { ...document, fields: { title: { type: 'string', value: 'new title' }, style: { type: 'enum', value: 'option-b' } } }; expect(initOptionsMock.cache.updateContent).toBeCalledTimes(2); expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(2, { documents: [expectedDocument] }); // The updateDocument of the wrapped content source should not be called. expect(contentSource.updateDocument).not.toBeCalled(); // The "save" action state should be enabled because there are cached changes. saveActionState = await saveDraftAction.state!({ document: expectedDocument } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions); expect(saveActionState).toEqual('enabled'); // Call "save" action run function. await saveDraftAction.run({ document: expectedDocument } as StackbitTypes.CustomActionRunCommonOptions & StackbitTypes.CustomActionDocumentRunOptions); // The updateDocument of the wrapped content source should be called with // the cached operations and the original document. expect(contentSource.updateDocument).toBeCalledTimes(1); expect(contentSource.updateDocument).toHaveBeenNthCalledWith(1, { document: document, operations: [firstOperation, secondOperation] }); // The "save" action state should be disabled again because all cached // changes were saved. saveActionState = await saveDraftAction.state!({ document: expectedDocument } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions); expect(saveActionState).toEqual('disabled'); }); test('should concatenate update operations, and clear them when "cancel_draft" is invoked', async () => { const contentSource = createMockedContentSource({ getSchema: async () => { return { models: models, locales: [], context: null }; } }); // existing document const document: StackbitTypes.Document = { type: 'document', modelName: 'pageModel', id: 'doc-1', status: 'modified', manageUrl: '', createdAt: '2024-01-18T10:00:00.000Z', updatedAt: '2024-01-18T14:00:00.000Z', context: null, fields: {} }; const initOptionsMock = createMockInitOptions(); initOptionsMock.cache.getModelByName.mockImplementation((modelName) => models.find((model) => model.name === modelName)); // Create content source with drafts wrapping the mocked content source const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource }); await contentSourceWithDrafts.init(initOptionsMock); const schema = await contentSourceWithDrafts.getSchema(); const pageModel = schema.models.find((model) => model.name === 'pageModel')! as StackbitTypes.PageModel; const cancelDraftAction = pageModel.actions!.find((action) => action.name === 'cancel_draft')! as StackbitTypes.CustomActionDocument; // The "cancel" action state should be disabled because there are no cached changes. let cancelActionState = await cancelDraftAction.state!({ document: document } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions); expect(cancelActionState).toEqual('disabled'); // Call updateDocument on the content source with drafts passing an // update operation with updates to the "title" field. initOptionsMock.cache.getDocumentById.mockImplementationOnce(() => document); await contentSourceWithDrafts.updateDocument({ document, operations: [ { opType: 'set', modelField: models.find((model) => model.name === 'pageModel')!.fields!.find((field) => field.name === 'title')!, fieldPath: ['title'], field: { type: 'string', value: 'new title' } } ] }); // The content source with drafts should call the cache.updateContent // again and pass the updated document with both fields. const expectedDocument: StackbitTypes.Document = { ...document, fields: { title: { type: 'string', value: 'new title' } } }; expect(initOptionsMock.cache.updateContent).toBeCalledTimes(1); expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(1, { documents: [expectedDocument] }); // The "cancel" action state should be enabled because there are cached changes. cancelActionState = await cancelDraftAction.state!({ document: expectedDocument } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions); expect(cancelActionState).toEqual('enabled'); // Call "cancel" action run function. await cancelDraftAction.run({ document: expectedDocument } as StackbitTypes.CustomActionRunCommonOptions & StackbitTypes.CustomActionDocumentRunOptions); // The "cancel" action state should be disabled because we cancelled cached changes. cancelActionState = await cancelDraftAction.state!({ document } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions); expect(cancelActionState).toEqual('disabled'); // The updateDocument of the wrapped content source should not be called. expect(contentSource.updateDocument).not.toBeCalled(); }); test('should override getDocuments and update documents with cached operations when wrapped content source calls getDocuments', async () => { // existing document const document: StackbitTypes.Document = { type: 'document', modelName: 'pageModel', id: 'doc-1', status: 'modified', manageUrl: '', createdAt: '2024-01-18T10:00:00.000Z', updatedAt: '2024-01-18T14:00:00.000Z', context: null, fields: {} }; const contentSource = createMockedContentSource({ getSchema: async () => { return { models: models, locales: [], context: null }; }, getDocuments: async () => { return [document]; } }); const initOptionsMock = createMockInitOptions(); initOptionsMock.cache.getModelByName.mockImplementation((modelName) => models.find((model) => model.name === modelName)); // Create content source with drafts wrapping the mocked content source const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource }); await contentSourceWithDrafts.init(initOptionsMock); // Call updateDocument on the content source with drafts passing an // update operation with updates to the "title" field. initOptionsMock.cache.getDocumentById.mockImplementationOnce(() => document); const operation: StackbitTypes.UpdateOperation = { opType: 'set', modelField: models.find((model) => model.name === 'pageModel')!.fields!.find((field) => field.name === 'title')!, fieldPath: ['title'], field: { type: 'string', value: 'new title' } }; await contentSourceWithDrafts.updateDocument({ document, operations: [operation] }); // The content source with drafts should call the cache.updateContent // and pass the updated document with the updated "title" field. const expectedDocument: StackbitTypes.Document = { ...document, fields: { title: { type: 'string', value: 'new title' } } }; expect(initOptionsMock.cache.updateContent).toBeCalledTimes(1); expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(1, { documents: [expectedDocument] }); // Stackbit calls getDocuments on the wrapped content source. const documents = await contentSourceWithDrafts!.getDocuments(); // The wrapped content source getDocuments should be called once expect(contentSource.getDocuments).toHaveBeenCalledTimes(1); // The returned documents should include cached changes expect(documents).toEqual([expectedDocument]); }); test('should override cache.updateContent and update documents with cached operations when wrapped content source calls cache.updateContent', async () => { let contentSourceCache: StackbitTypes.Cache; const contentSource = createMockedContentSource({ init: async (options: StackbitTypes.InitOptions): Promise<void> => { contentSourceCache = options.cache; }, getSchema: async () => { return { models: models, locales: [], context: null }; }, updateDocument: async () => {} }); // existing document const document: StackbitTypes.Document = { type: 'document', modelName: 'pageModel', id: 'doc-1', status: 'modified', manageUrl: '', createdAt: '2024-01-18T10:00:00.000Z', updatedAt: '2024-01-18T14:00:00.000Z', context: null, fields: {} }; const initOptionsMock = createMockInitOptions(); initOptionsMock.cache.getModelByName.mockImplementation((modelName) => models.find((model) => model.name === modelName)); // Create content source with drafts wrapping the mocked content source const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource }); await contentSourceWithDrafts.init(initOptionsMock); const schema = await contentSourceWithDrafts.getSchema(); const pageModel = schema.models.find((model) => model.name === 'pageModel')! as StackbitTypes.PageModel; const saveDraftAction = pageModel.actions!.find((action) => action.name === 'save_draft')! as StackbitTypes.CustomActionDocument; // Call updateDocument on the content source with drafts passing an // update operation with updates to the "title" field. initOptionsMock.cache.getDocumentById.mockImplementationOnce(() => document); const operation: StackbitTypes.UpdateOperation = { opType: 'set', modelField: models.find((model) => model.name === 'pageModel')!.fields!.find((field) => field.name === 'title')!, fieldPath: ['title'], field: { type: 'string', value: 'new title' } }; await contentSourceWithDrafts.updateDocument({ document, operations: [operation] }); // The content source with drafts should call the cache.updateContent // and pass the updated document with the updated "title" field. let expectedDocument: StackbitTypes.Document = { ...document, fields: { title: { type: 'string', value: 'new title' } } }; expect(initOptionsMock.cache.updateContent).toBeCalledTimes(1); expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(1, { documents: [expectedDocument] }); // The wrapped content source calls cache.updateContent updating a // different "style" field. await contentSourceCache!.updateContent({ documents: [ { ...document, fields: { style: { type: 'enum', value: 'option-b' } } } ] }); // The content source with drafts should pass the document with the // updated "style" field and also add the cached "title" field. expectedDocument = { ...document, fields: { title: { type: 'string', value: 'new title' }, style: { type: 'enum', value: 'option-b' } } }; expect(initOptionsMock.cache.updateContent).toBeCalledTimes(2); expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(2, { documents: [expectedDocument] }); // The "save" action state should be enabled because there are cached changes let saveActionState = await saveDraftAction.state!({ document: expectedDocument } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions); expect(saveActionState).toEqual('enabled'); // Call "save" action run function await saveDraftAction.run({ document: expectedDocument } as StackbitTypes.CustomActionRunCommonOptions & StackbitTypes.CustomActionDocumentRunOptions); // The updateDocument of the wrapped content source should be called with // the cached operations and the original document. expect(contentSource.updateDocument).toBeCalledTimes(1); expect(contentSource.updateDocument).toHaveBeenNthCalledWith(1, { document: document, operations: [operation] }); // The "save" action state should be disabled because cached changes are saved saveActionState = await saveDraftAction.state!({ document: expectedDocument } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions); expect(saveActionState).toEqual('disabled'); }); test('should override cache.updateContent and delete cached documents with cached operations when wrapped content source calls cache.updateContent with deleted documents', async () => { let contentSourceCache: StackbitTypes.Cache; const contentSource = createMockedContentSource({ init: async (options: StackbitTypes.InitOptions): Promise<void> => { contentSourceCache = options.cache; }, getSchema: async () => { return { models: models, locales: [], context: null }; } }); // existing document const document: StackbitTypes.Document = { type: 'document', modelName: 'pageModel', id: 'doc-1', status: 'modified', manageUrl: '', createdAt: '2024-01-18T10:00:00.000Z', updatedAt: '2024-01-18T14:00:00.000Z', context: null, fields: {} }; const initOptionsMock = createMockInitOptions(); initOptionsMock.cache.getModelByName.mockImplementation((modelName) => models.find((model) => model.name === modelName)); // Create content source with drafts wrapping the mocked content source const contentSourceWithDrafts = createContentSourceWithDrafts({ contentSource }); await contentSourceWithDrafts.init(initOptionsMock); const schema = await contentSourceWithDrafts.getSchema(); const pageModel = schema.models.find((model) => model.name === 'pageModel')! as StackbitTypes.PageModel; const saveDraftAction = pageModel.actions!.find((action) => action.name === 'save_draft')! as StackbitTypes.CustomActionDocument; // Call updateDocument on the content source with drafts passing an // update operation with updates to the "title" field. initOptionsMock.cache.getDocumentById.mockImplementationOnce(() => document); await contentSourceWithDrafts.updateDocument({ document, operations: [ { opType: 'set', modelField: models.find((model) => model.name === 'pageModel')!.fields!.find((field) => field.name === 'title')!, fieldPath: ['title'], field: { type: 'string', value: 'new title' } } ] }); // The content source with drafts should call the cache.updateContent // and pass the updated document. const expectedDocument: StackbitTypes.Document = { ...document, fields: { title: { type: 'string', value: 'new title' } } }; expect(initOptionsMock.cache.updateContent).toBeCalledTimes(1); expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(1, { documents: [expectedDocument] }); // The "save" action state should be enabled because there are cached changes. let saveActionState = await saveDraftAction.state!({ document: expectedDocument } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions); expect(saveActionState).toEqual('enabled'); // Wrapped content source calls updateContent with deleted document ID await contentSourceCache!.updateContent({ deletedDocumentIds: [document.id] }); // The content source with drafts should call cache.updateContent and // propagate the deleted document ID expect(initOptionsMock.cache.updateContent).toBeCalledTimes(2); expect(initOptionsMock.cache.updateContent).toHaveBeenNthCalledWith(2, { deletedDocumentIds: [document.id] }); // The "save" action state should be disabled because the document with // cached changes was deleted. saveActionState = await saveDraftAction.state!({ document: expectedDocument } as StackbitTypes.CustomActionStateCommonOptions & StackbitTypes.CustomActionDocumentStateOptions); expect(saveActionState).toEqual('disabled'); }); });