UNPKG

@stackbit/utils

Version:
332 lines (320 loc) 13.3 kB
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] }); } } }; }