@stackbit/cms-sanity
Version:
Stackbit Sanity CMS Interface
651 lines (620 loc) • 23.3 kB
text/typescript
import _ from 'lodash';
import { SanityDocument } from '@sanity/client';
import * as StackbitTypes from '@stackbit/types';
import { DocumentHistory, DocumentHistoryMap } from './sanity-api-client';
import { SchemaContext, ModelWithContext, ModelContext } from './sanity-schema-converter';
import { getItemTypeForListItem, isLocalizedModelField, resolvedFieldType } from './utils';
export interface SanitySchedule {
author: string;
action: ScheduleAction;
createdAt: string;
dataset: string;
description: string;
documents: {
documentId: string;
documentType?: string;
}[];
executeAt: string | null;
executedAt?: string;
id: string;
name: string;
projectId: string;
state: ScheduleState;
stateReason: string;
}
export type ScheduleAction = 'publish' | 'unpublish';
export type ScheduleState = 'cancelled' | 'scheduled' | 'succeeded';
export type DocumentContext = {
publishedDocument?: SanityDocument;
draftDocument?: SanityDocument;
};
export type AssetContext = DocumentContext;
export type ContextualDocument = StackbitTypes.Document<DocumentContext>;
export type ContextualAsset = StackbitTypes.Asset<AssetContext>;
export const DRAFT_ID_PREFIX = 'drafts.';
export type GetModelByName = StackbitTypes.Cache<SchemaContext, DocumentContext, AssetContext, ModelContext>['getModelByName'];
export type ConvertDocumentsOptions = {
documents: SanityDocument[];
getModelByName: GetModelByName;
documentsHistory?: DocumentHistoryMap;
studioUrl?: string;
};
export function getPureObjectId(objectId: string) {
return objectId.replace(/^drafts\./, '');
}
export function getDraftObjectId(objectId: string) {
return isDraftId(objectId) ? objectId : `${DRAFT_ID_PREFIX}${objectId}`;
}
export function isDraftId(objectId: string) {
return objectId && objectId.startsWith(DRAFT_ID_PREFIX);
}
export function convertAndFilterScheduledActions(sanitySchedules: SanitySchedule[]): StackbitTypes.ScheduledAction[] {
const allScheduledActions = sanitySchedules.map((schedule: SanitySchedule) => convertScheduledAction(schedule));
return filterScheduledActions(allScheduledActions);
}
export function convertScheduledAction(sanitySchedule: SanitySchedule): StackbitTypes.ScheduledAction {
return {
id: sanitySchedule.id,
name: sanitySchedule.name,
state: getScheduledActionState(sanitySchedule),
action: sanitySchedule.action,
createdAt: sanitySchedule.createdAt,
createdBy: sanitySchedule.author,
executeAt: sanitySchedule.executeAt || sanitySchedule.executedAt || '',
documentIds: sanitySchedule.documents.map((doc: { documentId: string }) => doc.documentId)
};
}
export function filterScheduledActions(scheduledActions: StackbitTypes.ScheduledAction[]) {
const cutoffDate = new Date();
cutoffDate.setMonth(cutoffDate.getMonth() - 1);
const cutoffDateStr = cutoffDate.toISOString();
return scheduledActions.filter(
(scheduledAction: StackbitTypes.ScheduledAction) => scheduledAction.state !== 'cancelled' && scheduledAction.executeAt.localeCompare(cutoffDateStr) > 0
);
}
export function convertDocuments({ documents, getModelByName, documentsHistory, studioUrl }: ConvertDocumentsOptions): ContextualDocument[] {
const publishedDocuments = _.keyBy(
documents.filter((document) => !isDraftId(document._id)),
'_id'
);
const draftDocuments = _.keyBy(
documents.filter((document) => isDraftId(document._id)),
(document) => getPureObjectId(document._id)
);
return _.uniq([..._.keys(publishedDocuments), ..._.keys(draftDocuments)])
.map((documentId) => {
const docId = draftDocuments[documentId]?._id;
const draftDocumentHistory = docId ? documentsHistory?.[docId] : undefined;
return convertDocument({
publishedDocument: publishedDocuments[documentId],
draftDocument: draftDocuments[documentId],
getModelByName,
documentHistory: draftDocumentHistory,
studioUrl
});
})
.filter((document): document is ContextualDocument => !!document);
}
export type ConvertDocumentOptions = {
publishedDocument?: SanityDocument;
draftDocument?: SanityDocument;
getModelByName: GetModelByName;
documentHistory?: DocumentHistory[];
studioUrl?: string;
};
function convertDocument({
publishedDocument,
draftDocument,
getModelByName,
documentHistory,
studioUrl
}: ConvertDocumentOptions): ContextualDocument | undefined {
const document = draftDocument || publishedDocument;
if (!document) {
return;
}
const model = getModelByName(document._type);
if (!model) {
return;
}
const pureObjectId = getPureObjectId(document._id);
const manageUrl = studioUrl ? `${studioUrl}/desk/${model.name};${pureObjectId}` : '';
return {
type: 'document',
id: pureObjectId,
manageUrl,
modelName: model.name,
...commonFields({ publishedDocument, draftDocument, documentHistory }),
fields: convertFields({
object: document,
modelFields: model.fields,
rootModel: model,
modelFieldPath: [],
getModelByName
}),
context: {
draftDocument,
publishedDocument
}
};
}
export type ConvertAssetsOptions = {
assets: SanityDocument[];
documentsHistory: DocumentHistoryMap;
};
export function convertAssets({ assets, documentsHistory }: ConvertAssetsOptions): ContextualAsset[] {
const publishedDocuments = _.keyBy(
assets.filter((document) => !isDraftId(document._id)),
'_id'
);
const draftDocuments = _.keyBy(
assets.filter((document) => isDraftId(document._id)),
(document) => getPureObjectId(document._id)
);
return _.uniq([..._.keys(publishedDocuments), ..._.keys(draftDocuments)]).map((documentId): ContextualAsset => {
const publishedDocument = publishedDocuments[documentId];
const draftDocument = draftDocuments[documentId];
const document = (draftDocument || publishedDocument)!;
const pureObjectId = getPureObjectId(document._id);
const draftDocId = draftDocument?._id;
const draftDocumentHistory = draftDocId ? documentsHistory[draftDocId] : undefined;
return {
type: 'asset',
id: pureObjectId,
manageUrl: document.url,
...commonFields({ draftDocument, publishedDocument, documentHistory: draftDocumentHistory }),
fields: {
title: {
type: 'string',
value: document.originalFilename
},
file: {
type: 'assetFile',
url: document.url,
fileName: document.originalFilename,
contentType: document.mimeType,
dimensions: document.metadata?.dimensions
}
},
context: {
publishedDocument,
draftDocument
}
};
});
}
export type ConvertFieldsOptions = {
object: Record<string, any>;
modelFields?: StackbitTypes.Field[];
rootModel: ModelWithContext;
modelFieldPath: string[];
getModelByName: GetModelByName;
};
function convertFields({ object, modelFields, rootModel, modelFieldPath, getModelByName }: ConvertFieldsOptions): Record<string, StackbitTypes.DocumentField> {
const fieldsByName = _.keyBy(modelFields, 'name');
const result: Record<string, StackbitTypes.DocumentField> = {};
for (const fieldName in fieldsByName) {
const value = object[fieldName];
const modelField = fieldsByName[fieldName];
if (!modelField) {
continue;
}
const field = convertFieldType({
value,
modelField,
rootModel,
modelFieldPath: modelFieldPath.concat(fieldName),
getModelByName
});
if (field) {
result[fieldName] = field;
}
}
return result;
}
/**
* Converts Sanity's document or object field value, or list item value, to
* Stackbit's DocumentField or DocumentListFieldItems.
*
* The convertFieldType has two overloads:
* When modelField is Field, it returns DocumentField or undefined.
* When modelField is FieldListItems it returns DocumentListFieldItems or undefined.
*/
function convertFieldType(options: {
value: any;
modelField: StackbitTypes.Field;
rootModel: ModelWithContext;
modelFieldPath: string[];
getModelByName: GetModelByName;
}): StackbitTypes.DocumentField | undefined;
function convertFieldType(options: {
value: any;
modelField: StackbitTypes.FieldListItems;
rootModel: ModelWithContext;
modelFieldPath: string[];
getModelByName: GetModelByName;
}): StackbitTypes.DocumentListFieldItems | undefined;
function convertFieldType({
value,
modelField,
rootModel,
modelFieldPath,
getModelByName
}: {
value: any;
modelField: StackbitTypes.Field | StackbitTypes.FieldListItems;
rootModel: ModelWithContext;
modelFieldPath: string[];
getModelByName: GetModelByName;
}): StackbitTypes.DocumentField | undefined {
switch (modelField.type) {
case 'string':
case 'text':
case 'html':
case 'url':
case 'boolean':
case 'number':
case 'date':
case 'datetime':
case 'enum':
case 'json':
case 'style':
case 'markdown': {
return createDocumentField({
fieldValue: value,
modelField: modelField,
documentFieldBaseProps: { type: modelField.type },
documentFieldSpecificProps: (value: any) => {
if (_.isUndefined(value)) {
return undefined;
}
return { value };
}
});
}
case 'cross-reference': {
return undefined;
}
case 'list': {
return createDocumentField({
fieldValue: value,
modelField: modelField,
documentFieldBaseProps: { type: 'list' },
documentFieldSpecificProps: (value: any) => {
return {
items: _.reduce(
value,
(accum: StackbitTypes.DocumentListFieldItems[], item) => {
const itemModel = getItemTypeForListItem(item, modelField);
if (!itemModel) {
return accum;
}
const documentField = convertFieldType({
value: item,
modelField: itemModel,
rootModel,
modelFieldPath: modelFieldPath.concat('items'),
getModelByName
});
if (!documentField) {
return accum;
}
return accum.concat(documentField);
},
[]
)
};
}
});
}
case 'object': {
return createDocumentField({
fieldValue: value,
modelField: modelField,
documentFieldBaseProps: { type: 'object' },
documentFieldSpecificProps: (value: any) => {
if (!value) {
return undefined;
}
return {
fields: convertFields({
object: value,
modelFields: modelField.fields,
rootModel,
modelFieldPath,
getModelByName
})
};
}
});
}
case 'model': {
return createDocumentField({
fieldValue: value,
modelField: modelField,
documentFieldBaseProps: { type: 'model' },
documentFieldSpecificProps: (value: any) => {
if (!value) {
return undefined;
}
const modelName = resolvedFieldType({ sanityFieldType: value._type, model: rootModel, modelFieldPath });
const model = getModelByName(modelName);
if (!model) {
return undefined;
}
return {
modelName: model.name,
fields: convertFields({
object: value,
modelFields: model.fields,
rootModel: model,
modelFieldPath: [],
getModelByName
})
};
}
});
}
case 'reference': {
return createDocumentField({
fieldValue: value,
modelField: modelField,
documentFieldBaseProps: {
type: 'reference',
refType: 'document'
},
documentFieldSpecificProps: (value: any) => {
if (!value) {
return undefined;
}
let refId = _.get(value, '_ref', null);
if (refId) {
refId = refId.replace(/^drafts\./, '');
}
return {
refId
};
}
});
}
case 'color': {
return createDocumentField({
fieldValue: value,
modelField: modelField,
documentFieldBaseProps: { type: 'color' },
documentFieldSpecificProps: (value: any) => {
if (!value) {
return undefined;
}
return {
value: _.get(value, 'hex')
};
}
});
}
case 'richText': {
return createDocumentField({
fieldValue: value,
modelField: modelField,
documentFieldBaseProps: { type: 'richText' },
documentFieldSpecificProps: (value: any) => {
if (!value) {
return undefined;
}
return {
value: value,
hint: flattenRichText(value).substring(0, 200)
};
}
});
}
case 'image': {
if (modelField.source && ['cloudinary', 'aprimo', 'bynder'].includes(modelField.source)) {
return createDocumentField({
fieldValue: value,
modelField: modelField,
documentFieldBaseProps: {
type: 'image',
source: modelField.source
},
documentFieldSpecificProps: (value: any) => {
if (!value) {
return undefined;
}
return {
sourceData: value,
...(modelField.source === 'bynder'
? {
fields: {
title: {
type: 'string',
value: value?.name
},
url: {
type: 'string',
value: value?.previewImg
}
}
}
: null)
};
}
});
}
return createDocumentField({
fieldValue: value,
// The modelField.type and documentField.type should match.
// However, in the case of the modelField.type === 'image',
// it is OK to have the documentField.type === 'reference'
// and documentField.type === 'asset.
modelField: modelField as unknown as StackbitTypes.FieldReference,
documentFieldBaseProps: {
type: 'reference',
refType: 'asset'
},
documentFieldSpecificProps: (value: any) => {
if (!value) {
return undefined;
}
return {
refId: _.get(value, 'asset._ref')
};
}
});
}
case 'file': {
return createDocumentField({
fieldValue: value,
// The modelField.type and documentField.type should match.
// However, in the case of the modelField.type === 'file',
// it is OK to have the documentField.type === 'reference'
// and documentField.type === 'asset.
modelField: modelField as unknown as StackbitTypes.FieldReference,
documentFieldBaseProps: {
type: 'reference',
refType: 'asset'
},
documentFieldSpecificProps: (value: any) => {
if (!value) {
return undefined;
}
return {
refId: _.get(value, 'asset._ref')
};
}
});
}
case 'slug': {
return createDocumentField({
fieldValue: value,
modelField: modelField,
documentFieldBaseProps: { type: 'slug' },
documentFieldSpecificProps: (value: any) => {
if (!value) {
return undefined;
}
return {
value: _.get(value, 'current')
};
}
});
}
default: {
const _exhaustiveCheck: never = modelField;
return _exhaustiveCheck;
}
}
}
type CommonFieldsType = {
documentHistory?: DocumentHistory[];
draftDocument?: SanityDocument;
publishedDocument?: SanityDocument;
};
function commonFields({ draftDocument, publishedDocument, documentHistory }: CommonFieldsType): {
status: StackbitTypes.Document['status'];
createdAt: string;
updatedAt: string;
updatedBy: string[];
} {
const document = draftDocument ?? publishedDocument;
if (!document) {
throw new Error('No draft or published document found');
}
const isDraft = isDraftId(document._id);
let status: StackbitTypes.Document['status'];
if (isDraft && !publishedDocument) {
status = 'added';
} else {
status = isDraft ? 'modified' : 'published';
}
const updatedByList = documentHistory?.map(({ author }: DocumentHistory) => author);
return {
status,
createdAt: document._createdAt,
// createdBy: createdByEmail,
updatedAt: document._updatedAt,
updatedBy: _.uniq(updatedByList)
};
}
function createDocumentField<
Type extends StackbitTypes.FieldType,
BaseProps extends StackbitTypes.DocumentFieldBasePropsForType<Type>,
SpecificProps extends StackbitTypes.DocumentFieldSpecificPropsForType<Type>
>({
fieldValue,
modelField,
documentFieldBaseProps,
documentFieldSpecificProps
}: {
fieldValue: any;
modelField: StackbitTypes.FieldForType<Type> | StackbitTypes.FieldListItemsForType<Type>;
documentFieldBaseProps: BaseProps;
documentFieldSpecificProps: (value: any) => SpecificProps | undefined;
}): (BaseProps & (SpecificProps | { localized: true; locales: Record<string, { locale: string } & SpecificProps> })) | undefined {
if (isLocalizedModelField(modelField)) {
if (!Array.isArray(fieldValue)) {
return undefined;
}
return {
...documentFieldBaseProps,
localized: true,
locales: _.reduce(
fieldValue,
(accum: Record<string, { locale: string } & SpecificProps>, localizedItem: { _key: string; value: any }) => {
if (localizedItem === null || typeof localizedItem === 'undefined') {
return accum;
}
const fieldSpecificProps = documentFieldSpecificProps(localizedItem.value);
if (!fieldSpecificProps) {
return accum;
}
accum[localizedItem._key] = {
locale: localizedItem._key,
...fieldSpecificProps
};
return accum;
},
{}
)
};
}
const fieldSpecificProps = documentFieldSpecificProps(fieldValue);
if (!fieldSpecificProps) {
return undefined;
}
return {
...documentFieldBaseProps,
...fieldSpecificProps
};
}
function flattenRichText(richTextArray: any) {
return _.reduce(
richTextArray,
(accum, node) => {
if (_.get(node, '_type') !== 'block') {
return accum;
}
const children: any = _.get(node, 'children', []);
return (
accum +
_.reduce(
children,
(accum, node) => {
if (_.get(node, '_type') !== 'span') {
return accum;
}
return accum + _.get(node, 'text', '');
},
''
)
);
},
''
);
}
function getScheduledActionState(sanitySchedule: SanitySchedule): StackbitTypes.ScheduledActionState {
if (sanitySchedule.state === 'cancelled' && sanitySchedule.stateReason !== 'cancelled by user') {
return 'failed';
}
return sanitySchedule.state;
}