@stackbit/utils
Version:
Stackbit utilities
290 lines • 12.5 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createContentSourceWithDrafts = void 0;
const update_document_operation_1 = require("./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.
*/
function createContentSourceWithDrafts(options) {
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, prop) {
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];
}
}
});
}
exports.createContentSourceWithDrafts = createContentSourceWithDrafts;
class ContentSourceWithDrafts {
constructor(options) {
/**
* 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.
*/
this.init = async (options) => {
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) => {
// 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) => {
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 (0, update_document_operation_1.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.
*/
this.getSchema = async () => {
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.
*/
this.getDocuments = async (options) => {
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 (0, update_document_operation_1.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.
*/
this.updateDocument = async ({ document, operations }) => {
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 = (0, update_document_operation_1.updateDocumentWithOperations)({
document,
operations,
getModelByName: this.cache.getModelByName
});
this.draftsByDocumentId[document.id] = draft;
await this.cache.updateContent({
documents: [updatedDocument]
}).catch(() => {
// TODO: log error
});
};
this.contentSource = options.contentSource;
this.draftsByDocumentId = {};
}
}
/**
* 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 }) {
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 }) {
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]
});
}
}
};
}
//# sourceMappingURL=content-source-with-drafts.js.map