import _ from 'lodash'; import path from 'path'; import crypto from 'crypto'; import fse from 'fs-extra'; import type { Asset, Assets, User, ContentChanges, ContentSourceInterface, Document, Field, FieldListItems, FieldObjectProps, FieldSpecificProps, InitOptions, Logger, Schema, Model, Locale, UpdateOperation, UpdateOperationField, ValidationError, Version, Cache } from '@stackbit/types'; import * as stackbitUtils from '@stackbit/types'; import { DocumentContext, AssetContext, convertAsset, convertDocument, generateDocumentId, getFilePathFromSlugContext, isPrefixedAssetId, assetIdToAssetValue, isVirtualSlug, createVirtualSlugFieldName, getFieldNameFromVirtualSlug, getAssetDir, replaceAssetIdsWithAssetValues } from './content-converter'; import { momentDateToken, extractTokensFromString, forEachFieldInDocument, getFileData, processMarkdownImagePaths, readFilesFromDirectory, saveBase64Data, saveFileData, saveFromUrl } from './utils'; import { mapListItemsPropsOrSelfSpecificProps, mapModelFieldsRecursively, isModelField, isReferenceField, isPageModel, isDataModel, assignLabelFieldIfNeeded, SUPPORTED_FILE_EXTENSIONS, ASSET_FILE_EXTENSIONS } from '@stackbit/sdk'; import { readDirRecursivelyWithExtensions } from '@stackbit/utils'; export type FileSystemContentSourceOptions = { /** * The root path of the project. Used to resolve relative paths in the * `contentDirs` option. */ rootPath: string; /** * Array of content directories. Each directory is searched recursively * for content files. */ contentDirs: string[]; /** * Array of model objects used at this content source's schema. */ models: Model[]; /** * Array of locale objects to enable field-level localization. * One of the objects must be defined as `default: true`. */ locales?: Locale[]; /** * Assets loading and behavior configuration. */ assetsConfig?: Assets; /** * Are files saved with a unique stable id */ useFileIds?: boolean; /** * The key used to store the file id in the file data */ fileIdKey?: string; /** * Set the file id on the file data when the file is loaded * This is useful when we want to migrate from using the file path as the id to using a unique stable id * Runs only in local dev mode */ setFileIdsOnStart?: boolean; /** * Run file id migration in dry run mode * This is useful when we want to see what files will be affected by the migration * Runs only in local dev mode */ setFileIdsOnStartDryRun?: boolean; /** @deprecated */ pagesDir?: string; /** @deprecated */ dataDir?: string; }; let didMigrationRun = false; export class FileSystemContentSource implements ContentSourceInterface { private rootPath: string; private contentDirs: string[]; private models: Model[]; private locales: Locale[]; private assetsConfig?: Assets; private fileIdKey?: string; private setFileIdsOnStart?: boolean; private setFileIdsOnStartDryRun?: boolean; private localDev!: boolean; private logger!: Logger; private userLogger!: Logger; private cache!: Cache; constructor(options: FileSystemContentSourceOptions) { this.rootPath = options.rootPath; this.contentDirs = options.contentDirs; this.models = options.models; this.locales = options.locales ?? []; this.assetsConfig = options.assetsConfig; if (options.useFileIds) { this.fileIdKey = options.fileIdKey ?? 'id'; } this.setFileIdsOnStart = options.setFileIdsOnStart; this.setFileIdsOnStartDryRun = options.setFileIdsOnStartDryRun; } async getVersion(): Promise { return stackbitUtils.getVersion({ packageJsonPath: path.join(__dirname, '../package.json') }); } getContentSourceType(): string { return 'fs'; } getProjectId(): string { // generate unique id based on content dirs return crypto.createHash('md5').update(this.contentDirs.join('.')).digest('hex').slice(0, 8); } getProjectEnvironment(): string { return ''; } getProjectManageUrl(): string { return ''; } async init(options: InitOptions): Promise { this.logger = options.logger.createLogger({ label: 'cms-fs' }); this.userLogger = options.userLogger.createLogger({ label: 'cms-fs' }); this.cache = options.cache; this.localDev = options.localDev; if (!this.assetsConfig) { this.userLogger.warn('No `assetsConfig` option provided, asset handling will be turned off.'); } else { if (this.assetsConfig.referenceType === 'static' && !this.assetsConfig.staticDir) { this.userLogger.error( 'assetsConfig.referenceType is set to "static", but no assetsConfig.staticDir was set. Please specify assetsConfig.staticDir.' ); } else if (this.assetsConfig.referenceType === 'relative' && !this.assetsConfig.assetsDir) { this.userLogger.error( 'assetsConfig.referenceType is set to "relative", but no assetsConfig.assetsDir was set. Please specify assetsConfig.assetsDir.' ); } } } async migrateToFileIds(): Promise { if (!this.fileIdKey) { this.userLogger.warn('setFileIdsOnStart is set to true but `useFileIds` is set to false. Skipping conversion...'); return; } if (!this.localDev) { this.userLogger.warn('setFileIdsOnStart is set to true but this is not a local dev environment. Skipping conversion...'); return; } this.userLogger.info('Running file id migration', { dryRun: this.setFileIdsOnStartDryRun }); const filePathToId: Record = {}; for (const contentDir of this.contentDirs) { const contentDirPath = path.join(this.rootPath, contentDir); await readFilesFromDirectory(contentDirPath, this.logger, async (filePath, fullFilePath, data) => { if (!data[this.fileIdKey!]) { const documentId = generateDocumentId(); this.userLogger.info(`→ Updating file ${filePath} with new id ${documentId}`); data[this.fileIdKey!] = documentId; filePathToId[filePath] = documentId; if (!this.setFileIdsOnStartDryRun) { await saveFileData(fullFilePath, data); } } }); } const documents = await this.getDocuments(); for (const document of documents) { const fullFilePath = path.join(this.rootPath, document.context.filePath); let data: any; await forEachFieldInDocument(document, async (field, fieldPath) => { if (field.type === 'reference' && !field.localized && field.refType === 'document' && field.refId && filePathToId[field.refId]) { const newRefId = filePathToId[field.refId]; this.userLogger.info(`Updating reference field ${fieldPath} to new id ${newRefId}`); // load the file data if we haven't already if (!this.setFileIdsOnStartDryRun && !data) { try { data = await getFileData(fullFilePath); } catch (err) { this.logger?.warn('Error loading file for conversion: ' + document.context.filePath, err); return; } } if (data) { _.set(data, fieldPath, newRefId); } } }); // the existence of data signals that we have changes to save if (data) { this.userLogger.info(`Updating file ${document.context.filePath} with updated references`); await saveFileData(fullFilePath, data); } } if (this.setFileIdsOnStartDryRun) { this.userLogger.info('File id migration dry run complete'); } else { this.userLogger.info('File id migration complete'); } } async destroy(): Promise {} async reset(): Promise {} async onFilesChange?({ updatedFiles }: { updatedFiles: string[]; }): Promise<{ invalidateSchema?: boolean; contentChanges?: ContentChanges }> { const documents: Document[] = []; const deletedDocumentIds: string[] = []; const contentFiles = updatedFiles.filter( (updatedFile) => _.some(this.contentDirs, (contentDir) => updatedFile.startsWith(contentDir)) && SUPPORTED_FILE_EXTENSIONS.includes(path.extname(updatedFile).substring(1).toLowerCase()) ); let existingDocumentsByFilePath: Record> | undefined; for (const filePath of contentFiles) { const fullFilePath = path.join(this.rootPath, filePath); if (!(await fse.pathExists(fullFilePath))) { // calculate existing documents only if needed and only once if (!existingDocumentsByFilePath) { const existingDocuments = this.cache.getDocuments().filter((document) => contentFiles.includes(document.context.filePath)); existingDocumentsByFilePath = _.keyBy(existingDocuments, (document) => document.context.filePath); } const existingDocument = existingDocumentsByFilePath[filePath]; if (existingDocument) { // When a file is renamed, there will be two updated files, one deleted and one added, if their IDs are the same, don't report it as deleted. // The onFilesChange may be called for both files at once, or for each file separately! // When called for both files at once, and the loop already saw the added file, don't add the document to the deleted documents. const addedNewFileWithSameId = documents.find((document) => document.id === existingDocument.id); if (!addedNewFileWithSameId) { deletedDocumentIds.push(existingDocument.id); } } continue; } let data; try { data = await getFileData(fullFilePath); } catch (err) { this.logger?.warn('Error loading file: ' + fullFilePath, err); continue; } const document = await convertDocument({ filePath, fullFilePath, data, getModelByName: this.cache.getModelByName, assetsConfig: this.assetsConfig, fileIdKey: this.fileIdKey, logger: this.userLogger }); if (!document) { this.logger?.warn('Error converting file: ' + filePath); continue; } // When a file is renamed, there will be two updated files, one deleted and one added, if their IDs are the same, don't report it as deleted. // The onFilesChange may be called for both files at once, or for each file separately! // When called for both files at once, and the loop already seen the deleted file, remove it from the deleted documents. if (deletedDocumentIds.includes(document.id)) { deletedDocumentIds.splice(deletedDocumentIds.indexOf(document.id), 1); } documents.push(document); } const assets: Asset[] = []; const deletedAssetIds: string[] = []; if (this.assetsConfig) { const assetsDir = getAssetDir(this.assetsConfig); const assetFiles = updatedFiles.filter( (updatedFile) => updatedFile.startsWith(assetsDir) && ASSET_FILE_EXTENSIONS.includes(path.extname(updatedFile).substring(1).toLowerCase()) ); for (const assetFilePath of assetFiles) { const absAssetFilePath = path.join(this.rootPath, assetFilePath); if (!(await fse.pathExists(absAssetFilePath))) { deletedAssetIds.push(assetFilePath); continue; } const asset = await convertAsset({ assetFilePath, absProjectDir: this.rootPath, assetsConfig: this.assetsConfig }); assets.push(asset); } } return { invalidateSchema: false, // handled by stackbit.config builder contentChanges: { documents, assets, deletedDocumentIds, deletedAssetIds } }; } startWatchingContentUpdates(): void {} stopWatchingContentUpdates(): void {} async getSchema(): Promise { this.userLogger.debug('getSchema'); const modelGroups: Record = {}; for (const model of this.models) { for (const groupName of model.groups ?? []) { if (!modelGroups[groupName]) { modelGroups[groupName] = {}; } const key = model.type === 'object' ? 'objectModels' : 'documentModels'; if (!modelGroups[groupName]![key]) { modelGroups[groupName]![key] = []; } if (!modelGroups[groupName]![key]!.includes(model.name)) { modelGroups[groupName]![key]!.push(model.name); } } } const models = this.models.map((model) => { let { groups, ...updatedModel } = model; // add markdown content field for pages if (isPageModel(updatedModel)) { const modelFields = updatedModel.fields ?? []; if (!updatedModel.hideContent && !modelFields.find((field) => field.name === 'markdown_content')) { updatedModel.fields = modelFields.concat({ type: 'markdown', name: 'markdown_content', label: 'Content', description: 'Page content' }); } } // Add "virtual" slug fields for all tokens in model.filePath. // For example, if model.filePath === 'posts/{category}/{slug}' // then, the tokens 'category' and 'slug' will be added as required // fields of type 'slug', if fields with same name do not already // exist. The added fields will be prefixed and regarded as "virtual" // fields. Virtual fields will not be saved in files but used for // computing the file names, and effectively the page URLs reflected // by these files. if ((isPageModel(updatedModel) || isDataModel(updatedModel)) && updatedModel.filePath && typeof updatedModel.filePath === 'string') { const tokens = extractTokensFromString(updatedModel.filePath); const existingFieldsByName = _.keyBy(updatedModel.fields ?? [], (field) => field.name); const slugVirtualFields: Field[] = []; for (const token of tokens) { if (existingFieldsByName[token]) { // Make the field that matches the fieldPath token to be // required if it isn't. if (!existingFieldsByName[token]?.required) { existingFieldsByName[token]!.required = true; } continue; } const fieldType = token === momentDateToken ? 'date' : 'slug'; if (this.fileIdKey) { // stableId === fileIdKey. When fileIdKey is used, the // document.id is set to fileIdKey and is used as sitemap's // stableId. In this case, users can safely change the // virtual slug fields which change the file names. The // Studio will be able to keep track of the renamed files // due to constant sitemap's stableId. slugVirtualFields.push({ type: fieldType, name: createVirtualSlugFieldName(token), label: _.startCase(token), required: true }); } else { // stableId === fileName. When fileIdKey is not used, the // document.id is set to the file name and is used as // sitemap's stableId, which is not actually stable. // In this case, user cannot safely change slug fields // as it will rename the files, and the Studio will not // be able to relate the between the old and new file // names of the same page as the sitemap's stableId will // change. Therefore, we set readOnly to be true. slugVirtualFields.push({ type: fieldType, name: createVirtualSlugFieldName(token), label: _.startCase(token), required: true, readOnly: true, description: "You can't change the slug. Contact your developer to enable stableId-based files." }); } } if (slugVirtualFields) { updatedModel.fields = [...slugVirtualFields, ...(updatedModel.fields ?? [])]; } } assignLabelFieldIfNeeded(updatedModel); updatedModel = mapModelFieldsRecursively(updatedModel, (field: Field): Field => { return mapListItemsPropsOrSelfSpecificProps(field, (fieldSpecificProps) => { if ((!isModelField(fieldSpecificProps) && !isReferenceField(fieldSpecificProps)) || !fieldSpecificProps.groups) { return fieldSpecificProps; } const { ...cloned } = fieldSpecificProps; const key = isModelField(fieldSpecificProps) ? 'objectModels' : 'documentModels'; for (const groupName of fieldSpecificProps.groups) { const groupModels = modelGroups[groupName]?.[key] ?? []; if (groupModels) { cloned.models = _.uniq((fieldSpecificProps.models ?? []).concat(...groupModels).concat(...(cloned.models ?? []))); } } delete cloned.groups; return cloned; }); }); return updatedModel; }); return { models, locales: this.locales, context: null }; } async getDocuments(): Promise[]> { // migration should be called at this point of the lifecycle if (this.setFileIdsOnStart && !didMigrationRun) { // make sure we run migration only once even if content source is reloaded didMigrationRun = true; await this.migrateToFileIds(); } const documents: Document[] = []; for (const contentDir of this.contentDirs) { const contentDirPath = path.join(this.rootPath, contentDir); await readFilesFromDirectory(contentDirPath, this.logger, async (filePath, fullFilePath, data) => { const document = await convertDocument({ filePath: path.join(contentDir, filePath), fullFilePath, data, getModelByName: this.cache.getModelByName, assetsConfig: this.assetsConfig, fileIdKey: this.fileIdKey, logger: this.userLogger }); if (!document) { this.logger?.warn('Error converting file to document: ' + filePath); return; } documents.push(document); }); } return documents; } async getAssets(): Promise[]> { if (!this.assetsConfig) { return []; } const assetsDir = getAssetDir(this.assetsConfig); const absAssetsDir = path.join(this.rootPath, assetsDir); const assetFilePaths = await readDirRecursivelyWithExtensions(absAssetsDir, ASSET_FILE_EXTENSIONS); const assets: Asset[] = []; for (const assetFilePath of assetFilePaths) { assets.push( await convertAsset({ assetFilePath: path.join(assetsDir, assetFilePath), absProjectDir: this.rootPath, assetsConfig: this.assetsConfig }) ); } return assets; } async hasAccess(options: { userContext?: User }): Promise<{ hasConnection: boolean; hasPermissions: boolean }> { return { hasConnection: true, hasPermissions: true }; } async createDocument(options: { updateOperationFields: Record; model: Model; locale?: string | undefined; defaultLocaleDocumentId?: string | undefined; userContext?: User; }): Promise<{ documentId: string } & Document> { const { model, locale } = options; let data: any = { type: model.name, ...(this.fileIdKey ? { [this.fileIdKey]: generateDocumentId() } : null) }; const virtualSlugData: any = {}; for (const fieldName in options.updateOperationFields) { const updateOperationField = options.updateOperationFields[fieldName]!; const modelField = _.find(model.fields, (field: Field) => field.name === fieldName); const value = mapUpdateOperationToValue({ updateOperationField, locale, getModelByName: this.cache.getModelByName, modelField, documentDirPath: null, assetsConfig: this.assetsConfig }); if (modelField && isVirtualSlug(modelField.name)) { const token = getFieldNameFromVirtualSlug(modelField.name); virtualSlugData[token] = value; } else { data[fieldName] = value; } } let filePath: string; if (isPageModel(model) || isDataModel(model)) { filePath = (await getFilePathFromSlugContext({ model, context: { ...data, ...virtualSlugData }, contentDir: this.contentDirs[0] ? `${this.contentDirs[0]}/` : '', locale, generateFallback: true })) || ''; if (!filePath) { throw new Error(`Error creating document: failed to generate file path for model '${model.name}'.`); } } else { throw new Error(`Error creating document: cannot create document for model '${model.name}' which is not of type 'page' or 'data'.`); } const fullFilePath = path.join(this.rootPath, filePath); if (await fse.pathExists(fullFilePath)) { throw new Error(`Page already exists at: '${filePath}'.`); } data = replaceAssetIdsWithAssetValues(data, path.dirname(filePath), this.assetsConfig); await saveFileData(fullFilePath, data); const document = await convertDocument({ filePath, fullFilePath, data, getModelByName: this.cache.getModelByName, assetsConfig: this.assetsConfig, fileIdKey: this.fileIdKey, logger: this.userLogger }); if (!document) { throw new Error('Error converting document'); } return { documentId: filePath, ...document }; } async updateDocument(options: { document: Document; operations: UpdateOperation[]; userContext?: User }): Promise { const { document } = options; let locale: string | undefined; let filePath = document.context.filePath; let fullFilePath = path.join(this.rootPath, filePath); const data = await getFileData(fullFilePath); const virtualSlugData: any = Object.entries(document.fields).reduce((result, [fieldName, documentField]) => { if (isVirtualSlug(fieldName)) { const localizedField = stackbitUtils.getLocalizedFieldForLocale(documentField); if (localizedField && 'value' in localizedField) { result[getFieldNameFromVirtualSlug(fieldName)] = localizedField.value; } } return result; }, {} as Record); for (const updateOperation of options.operations) { applyUpdateOp({ updateOperation, data, virtualSlugData: virtualSlugData, getModelByName: this.cache.getModelByName, documentDirPath: path.dirname(filePath), assetsConfig: this.assetsConfig }); if (updateOperation.locale) { locale = updateOperation.locale; } } const model = this.cache.getModelByName(document.modelName); if (!model) { throw new Error(`Error updating document: model not found: ${document.modelName}.`); } if (!isPageModel(model) && !isDataModel(model)) { throw new Error(`Error updating document: cannot update document for model "${model.name}" which is not of type "page" or "data".`); } const newFilePath = await getFilePathFromSlugContext({ model, contentDir: this.contentDirs[0] ? `${this.contentDirs[0]}/` : '', context: { ...data, ...virtualSlugData }, locale }); // check if slug tokens have changed and if so, check if they result in a new file path if (newFilePath && newFilePath !== document.context.filePath) { if (await fse.pathExists(path.join(this.rootPath, newFilePath))) { throw new Error(`Page already exists at: '${newFilePath}'.`); } await fse.unlink(fullFilePath); const containingDir = path.dirname(fullFilePath); if ((await fse.readdir(containingDir)).length === 0) { // delete containing dir if it's empty await fse.remove(containingDir); } filePath = newFilePath; } // recalculate path in case it changed fullFilePath = path.join(this.rootPath, filePath); await saveFileData(fullFilePath, data); return ( (await convertDocument({ filePath, fullFilePath, data, getModelByName: this.cache.getModelByName, assetsConfig: this.assetsConfig, fileIdKey: this.fileIdKey, logger: this.userLogger })) || document ); } async deleteDocument(options: { document: Document; userContext?: User }): Promise { const { document } = options; const filePath = path.join(this.rootPath, document.context.filePath); await fse.unlink(filePath); } async uploadAsset(options: { url?: string | undefined; base64?: string | undefined; fileName: string; mimeType: string; locale?: string | undefined; userContext?: User; }): Promise> { if (!this.assetsConfig) { throw new Error('Error uploading asset: no asset options defined.'); } const { url, base64, fileName } = options; const assetDir = getAssetDir(this.assetsConfig); const absAssetsDir = path.join(this.rootPath, assetDir); const assetFilePath = path.join(this.assetsConfig.uploadDir ?? '', fileName); const absAssetFilePath = path.join(absAssetsDir, assetFilePath); if (base64) { await saveBase64Data(absAssetFilePath, base64); } else if (url) { await saveFromUrl(absAssetFilePath, url); } else { throw new Error('Error uploading asset: no upload data found for asset.'); } return await convertAsset({ assetFilePath: path.join(assetDir, assetFilePath), absProjectDir: this.rootPath, assetsConfig: this.assetsConfig }); } async validateDocuments(options: { documents: Document[]; assets: Asset[]; locale?: string | undefined; userContext?: User; }): Promise<{ errors: ValidationError[] }> { return { errors: [] }; } async publishDocuments(options: { documents: Document[]; assets: Asset[]; userContext?: User }): Promise {} } function mapUpdateOperationToValue({ updateOperationField, locale, getModelByName, modelField, documentDirPath, assetsConfig }: { updateOperationField: UpdateOperationField; locale?: string; getModelByName: Cache['getModelByName']; modelField?: FieldSpecificProps; documentDirPath: string | null; assetsConfig?: Assets; }): any { switch (updateOperationField.type) { case 'object': { const result = {}; _.forEach(updateOperationField.fields, (childUpdateOperationField, fieldName) => { const childModelField = _.find((modelField as FieldObjectProps).fields, (field) => field.name === fieldName); const value = mapUpdateOperationToValue({ updateOperationField: childUpdateOperationField, locale, getModelByName, modelField: childModelField, documentDirPath, assetsConfig }); if (childModelField?.localized && locale) { _.set(result, [fieldName, locale], value); } else { _.set(result, fieldName, value); } }); return result; } case 'model': { const modelName = updateOperationField.modelName; const childModel = getModelByName(modelName); const result = { type: modelName }; _.forEach(updateOperationField.fields, (updateOperationField, fieldName) => { const childModelField = _.find(childModel?.fields, (field) => field.name === fieldName); const value = mapUpdateOperationToValue({ updateOperationField, locale, getModelByName, modelField: childModelField, documentDirPath, assetsConfig }); if (childModelField?.localized && locale) { _.set(result, [fieldName, locale], value); } else { _.set(result, fieldName, value); } }); return result; } case 'list': { const listItemsModel = modelField?.type === 'list' && modelField.items; return updateOperationField.items.map((item) => { let listItemModelField: FieldListItems | undefined; if (_.isArray(listItemsModel)) { listItemModelField = (listItemsModel as FieldListItems[]).find((listItemsModel) => listItemsModel.type === item.type); } else if (listItemsModel) { listItemModelField = listItemsModel; } return mapUpdateOperationToValue({ updateOperationField: item, locale, getModelByName, modelField: listItemModelField, documentDirPath, assetsConfig }); }); } case 'reference': { if (assetsConfig && updateOperationField.refType === 'asset' && isPrefixedAssetId(updateOperationField.refId)) { return assetIdToAssetValue(updateOperationField.refId, documentDirPath, assetsConfig); } return updateOperationField.refId; } case 'markdown': { return processMarkdownImagePaths(updateOperationField.value, (path) => { if (assetsConfig && isPrefixedAssetId(path)) { return assetIdToAssetValue(path, documentDirPath, assetsConfig); } return path; }); } default: return updateOperationField.value; } } function applyUpdateOp({ updateOperation, data, virtualSlugData, getModelByName, documentDirPath, assetsConfig }: { updateOperation: UpdateOperation; data: Record; virtualSlugData: Record; getModelByName: Cache['getModelByName']; documentDirPath: string; assetsConfig?: Assets; }) { const { modelField, locale } = updateOperation; const fieldPath = 'localized' in modelField && modelField.localized && locale ? updateOperation.fieldPath.concat(locale) : updateOperation.fieldPath; switch (updateOperation.opType) { case 'set': { const { field } = updateOperation; const value = mapUpdateOperationToValue({ updateOperationField: field, getModelByName, modelField, documentDirPath, assetsConfig }); if ('name' in modelField && isVirtualSlug(modelField.name)) { const token = getFieldNameFromVirtualSlug(modelField.name); _.set(virtualSlugData, token, value); } else { _.set(data, fieldPath, value); } break; } case 'unset': { _.unset(data, fieldPath); break; } case 'insert': { const { item, index } = updateOperation; const value = mapUpdateOperationToValue({ updateOperationField: item, getModelByName, modelField, documentDirPath, assetsConfig }); const arr = [..._.get(data, fieldPath, [])]; arr.splice(index ?? 0, 0, value); _.set(data, fieldPath, arr); break; } case 'remove': { const { index } = updateOperation; const arr = [..._.get(data, fieldPath, [])]; arr.splice(index, 1); _.set(data, fieldPath, arr); break; } case 'reorder': { const { order } = updateOperation; const arr = [..._.get(data, fieldPath, [])]; const newArr = order.map((newIndex) => arr[newIndex]); _.set(data, fieldPath, newArr); break; } } return data; }