@stackbit/utils
Version:
Stackbit utilities
332 lines (320 loc) • 13.3 kB
text/typescript
import type * as StackbitTypes from '@stackbit/types';
import { updateDocumentWithOperations } from './update-document-operation';
/**
* The `createContentSourceWithDrafts()` function enhances the provided content
* source with "Content Drafts" capabilities, enabling editors to modify multiple
* content fields on a page and then save all pending changes into the CMS in a
* single action. This reduces the number of content writes in the CMS.
* For instance, when using this with `GitContentSource`, all pending changes are
* saved in a single commit.
*
* To retrieve draft content in the web framework and reflect changes in Create
* before they are saved to the CMS (e.g., Git), run the web framework with the
* `STACKBIT_API_KEY` environment variable set to the value of the `localId`
* variable printed when running `stackbit dev` locally. Then, pass the
* `STACKBIT_API_KEY` to the `getCSIDocuments()` method from the `@stackbit/utils`
* package to retrieve the draft content from the Create server.
*
* ```javascript
* const result = await getCSIDocuments({
* stackbitApiKey: process.env.STACKBIT_API_KEY
* });
* ```
*
* You can also pass additional data to filter out specific documents by ID:
*
* ```
* {"documentSpecs": [{srcType: "git", srcProjectId: "...", "srcDocumentId": "..."}]}
* ```
*
* For example, after running `stackbit dev`, the command will print:
*
* ```
* info: localId: ab1...9yz
* ```
*
* Use the `localId` value to set the `STACKBIT_API_KEY` environment variable
* when running your web framework:
*
* ```
* STACKBIT_API_KEY=ab1...9yz npm run dev
* ```
*
* Then, use the `STACKBIT_API_KEY` to fetch documents with pending "draft"
* changes and render them on a page.
*
* When the project runs in Create Cloud, a different `STACKBIT_API_KEY` will be
* generated and injected into the environment variables.
*/
export function createContentSourceWithDrafts<ContentSourceType extends StackbitTypes.ContentSourceInterface<any, any, any, any, any>>(options: {
contentSource: ContentSourceType;
}): ContentSourceType {
const contentSource = options.contentSource;
const contentSourceWithDrafts = new ContentSourceWithDrafts({ contentSource });
// Return proxied content source and override some of its methods.
return new Proxy(contentSource, {
get(target: ContentSourceType, prop: keyof StackbitTypes.ContentSourceInterface): any {
switch (prop) {
case 'init':
return contentSourceWithDrafts.init;
case 'getSchema':
return contentSourceWithDrafts.getSchema;
case 'getDocuments':
return contentSourceWithDrafts.getDocuments;
case 'updateDocument':
return contentSourceWithDrafts.updateDocument;
default:
return target[prop];
}
}
});
}
type DraftDocumentMap = Record<
string,
{
origDocument: StackbitTypes.Document;
operations: StackbitTypes.UpdateOperation[];
saving: boolean;
}
>;
class ContentSourceWithDrafts<ContentSourceType extends StackbitTypes.ContentSourceInterface>
implements Pick<StackbitTypes.ContentSourceInterface, 'init' | 'getSchema' | 'getDocuments' | 'updateDocument'>
{
private contentSource: ContentSourceType;
private cache!: StackbitTypes.Cache;
private draftsByDocumentId: DraftDocumentMap;
constructor(options: { contentSource: ContentSourceType }) {
this.contentSource = options.contentSource;
this.draftsByDocumentId = {};
}
/**
* Override the `cache.updateContent` function passed to the content source.
* This override should ensure that when `cache.updateContent` called by the
* wrapped content source, it updates the documents passed in
* `contentChanges.documents` with cached operations, and removes the
* document specified in `contentChanges.deletedDocumentIds` from the drafts
* map.
*/
init = async (options: StackbitTypes.InitOptions<unknown, unknown, unknown, unknown>): Promise<void> => {
this.cache = options.cache;
const newOptions = {
...options,
cache: {
...options.cache,
getDocuments: () => {
// Documents in the cache may contain unsaved changes.
// Map the documents with unsaved changes to the original documents.
const documents = options.cache.getDocuments();
return documents.map((document) => {
const draft = this.draftsByDocumentId[document.id];
if (draft) {
return draft.origDocument;
}
return document;
});
},
getDocumentById: (documentId: string) => {
// Documents in the cache may contain unsaved changes.
// Return the original document if the document in cache has unsaved changes.
const draft = this.draftsByDocumentId[documentId];
if (draft) {
return draft.origDocument;
}
return options.cache.getDocumentById(documentId);
},
updateContent: async (contentChanges: StackbitTypes.ContentChanges) => {
if (Array.isArray(contentChanges.deletedDocumentIds)) {
for (const deletedDocumentId of contentChanges.deletedDocumentIds) {
delete this.draftsByDocumentId[deletedDocumentId];
}
}
// When the wrapped content source calls cache.updateContent(),
// apply the unsaved operations for documents with unsaved content.
if (Array.isArray(contentChanges.documents)) {
contentChanges = {
...contentChanges,
documents: contentChanges.documents.map((document) => {
const draft = this.draftsByDocumentId[document.id];
if (!draft || draft.saving) {
return document;
}
return updateDocumentWithOperations({
document,
operations: draft.operations,
getModelByName: this.cache!.getModelByName
});
})
};
}
await options.cache.updateContent(contentChanges);
}
}
};
return this.contentSource.init(newOptions);
};
/**
* Override the schema returned by the content source, by adding "Save" and
* "Cancel" actions to every "data" and "page" model.
*/
getSchema = async (): ReturnType<StackbitTypes.ContentSourceInterface['getSchema']> => {
const schema = await this.contentSource.getSchema();
return {
...schema,
models: schema.models.map((model) => {
if (model.type === 'data' || model.type === 'page') {
const { actions = [], ...restModel } = model;
model = {
...restModel,
actions: actions.concat(
createSaveAction({
draftsByDocumentId: this.draftsByDocumentId,
contentSource: this.contentSource
}),
createCancelAction({
draftsByDocumentId: this.draftsByDocumentId,
cache: this.cache
})
)
};
}
return model;
})
};
};
/**
* When Stackbit calls `getDocuments` to reload all content source documents,
* update documents that have drafts with cached operations.
*/
getDocuments = async (
options: Parameters<StackbitTypes.ContentSourceInterface['getDocuments']>[0]
): ReturnType<StackbitTypes.ContentSourceInterface['getDocuments']> => {
const getDocumentsResult = await this.contentSource.getDocuments(options);
const isArray = Array.isArray(getDocumentsResult);
const documents = isArray ? getDocumentsResult : getDocumentsResult.documents;
const documentsWithDrafts = documents.map((document) => {
const draft = this.draftsByDocumentId[document.id];
if (!draft) {
return document;
}
return updateDocumentWithOperations({
document,
operations: draft.operations,
getModelByName: this.cache!.getModelByName
});
});
return isArray
? documentsWithDrafts
: {
...getDocumentsResult,
documents: documentsWithDrafts
};
};
/**
* When updating a document, accumulate the update operations and store
* them in `draftsByDocumentId` instead of calling the `updateDocument`
* method of the wrapped content source.
*/
updateDocument = async ({
document,
operations
}: Parameters<StackbitTypes.ContentSourceInterface['updateDocument']>[0]): ReturnType<StackbitTypes.ContentSourceInterface['updateDocument']> => {
let draft = this.draftsByDocumentId[document.id];
if (!draft) {
draft = {
origDocument: this.cache!.getDocumentById(document.id)!,
operations: [],
saving: false
};
}
draft.operations = draft.operations.concat(operations);
const updatedDocument = updateDocumentWithOperations({
document,
operations,
getModelByName: this.cache!.getModelByName
});
this.draftsByDocumentId[document.id] = draft;
await this.cache!.updateContent({
documents: [updatedDocument]
}).catch(() => {
// TODO: log error
});
};
}
/**
* Create a custom action that invokes the `updateDocument` method on the
* wrapped content source with the cached update operations for a specific
* document.
*/
function createSaveAction({
draftsByDocumentId,
contentSource
}: {
draftsByDocumentId: DraftDocumentMap;
contentSource: StackbitTypes.ContentSourceInterface;
}): StackbitTypes.CustomActionDocument {
return {
name: 'save_draft',
label: 'Save',
icon: 'check',
preferredStyle: 'button-primary',
state: async ({ document }) => {
return draftsByDocumentId[document.id] ? 'enabled' : 'disabled';
},
run: async ({ document, currentUser }) => {
const draft = draftsByDocumentId[document.id];
if (!draft) {
return;
}
try {
// Update documents in content-source (CMS)
// Mark the draft as being saved, in case `updateDocument()`
// calls `cache.updateContent()` after updating the document
// with operations, and so the overriden cache won't re-apply
// the same operations again.
draft.saving = true;
await contentSource.updateDocument({
document: draft.origDocument,
operations: draft.operations,
userContext: currentUser
});
delete draftsByDocumentId[document.id];
} catch (error) {
// If the update was unsuccessful, don't delete the draft and
// revert `saving` back to false, so the user won't lose their
// unsaved changes.
draft.saving = false;
throw error;
}
}
};
}
/**
* Create a custom action that discards any cached operations for a specific document.
*/
function createCancelAction({
draftsByDocumentId,
cache
}: {
draftsByDocumentId: DraftDocumentMap;
cache: StackbitTypes.Cache;
}): StackbitTypes.CustomActionDocument {
return {
name: 'cancel_draft',
label: 'Cancel',
icon: 'xmark',
preferredStyle: 'button-secondary',
state: async ({ document }) => {
return draftsByDocumentId[document.id] ? 'enabled' : 'disabled';
},
run: async ({ document, currentUser }) => {
const draft = draftsByDocumentId[document.id];
if (draft) {
delete draftsByDocumentId[document.id];
// update documents in content-store
await cache!.updateContent({
documents: [draft.origDocument]
});
}
}
};
}