@stackbit/utils
Version:
Stackbit utilities
688 lines (633 loc) • 28.2 kB
text/typescript
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');
});
});