UNPKG

@eclipse-emfcloud/model-manager

Version:

Command-based model editing with undo/redo.

298 lines 12.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.jsonTransform = exports.createModelPatchCommand = exports.createModelUpdaterCommand = exports.createModelUpdaterCommandWithResult = exports.MultiPatchCommand = exports.PatchCommand = void 0; // ***************************************************************************** // Copyright (C) 2023-2024 STMicroelectronics. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: MIT License which is // available at https://opensource.org/licenses/MIT. // // SPDX-License-Identifier: EPL-2.0 OR MIT // ***************************************************************************** const fast_json_patch_1 = require("fast-json-patch"); const model_logger_1 = require("@eclipse-emfcloud/model-logger"); const cloneDeep_1 = __importDefault(require("lodash/cloneDeep")); const core_1 = require("../core"); const logger = (0, model_logger_1.getLogger)('model-manager/patch-command'); /** * A {@link SimpleCommand} that executes a Json Patch on a Document. * * @template K the type of model ID used by the Model Manager */ class PatchCommand { /** * * @param label The label of this command. * @param model The document to patch. This document will be modified when this command is executed/undone/redone. * @param patch The JSON Patch to apply on the document or (preferred) a function that updates a working copy of the model directly. * @param [options] The options, if any, to tune the behaviour of the command */ constructor(label, modelId, patch, options = {}) { this.label = label; this.modelId = modelId; this.state = 'ready'; if (typeof patch === 'function') { this.modelUpdater = patch; } else { // Create a defensive copy of the patch now because we only use it later const myPatch = (0, cloneDeep_1.default)(patch); this.modelUpdater = (model) => this.applyPatchWithOptions(model, myPatch); } this.options = { preconditionsMode: 'strict', canExecute: () => true, ...options, }; } /** * Query whether any preconditions that I may have for viable execution are met. * On a `true` result, I guarantee that I can effect my changes on the model correctly and completely. * Otherwise, it is an error to attempt to {@link execute} me. * * @param model the model on which I am to be executed * @param reasonCallback an optional call-back function that will be given the reason * why the command cannot be executed, in the case that it is not executable * @returns whether I am able to be executed */ async canExecute(model, reasonCallback) { const okOrReason = !this.inState('ready') ? 'already executed' : await this.options.canExecute(model, this.modelId); if (okOrReason !== true) { reasonCallback?.(okOrReason); return false; } return okOrReason; } canUndo(_model) { return this.inState('executed') && this.undoPatch !== undefined; } canRedo(_model) { return this.inState('undone') && this.redoPatch !== undefined; } async execute(model) { // Don't need to check the return result because we throw if not executable await this.canExecute(model, (reason) => { throw new Error(`PatchCommand '${this.label}' cannot be executed: ${reason}`); }); const initialDocument = (0, cloneDeep_1.default)(model); await this.modelUpdater(model, this.modelId); // The modelUpdater might have modified the model in an incompatible manner. // Transform the model to be JSON compatible to avoid problems with // undefined values in properties and arrays. (0, exports.jsonTransform)(model); this.redoPatch = (0, fast_json_patch_1.compare)(initialDocument, model, true); this.undoPatch = (0, fast_json_patch_1.compare)(model, initialDocument, true); this.state = 'executed'; return (0, cloneDeep_1.default)(this.redoPatch); } undo(model) { if (!this.canUndo(model) || this.undoPatch === undefined) { throw new Error(`PatchCommand ${this.label} cannot be undone`); } // Maybe the model updater didn't make any changes, so check // whether the patch has anything to do if (this.undoPatch.length > 0) { this.applyPatchWithOptions(model, this.undoPatch); } this.state = 'undone'; return (0, cloneDeep_1.default)(this.undoPatch); } redo(model) { if (!this.canRedo(model) || this.redoPatch === undefined) { throw new Error(`PatchCommand ${this.label} cannot be redone`); } // Maybe the model updater didn't make any changes, so check // whether the patch has anything to do if (this.redoPatch.length > 0) { this.applyPatchWithOptions(model, this.redoPatch); } this.state = 'executed'; return (0, cloneDeep_1.default)(this.redoPatch); } /** * Query whether I am in some `state`. * * @param state a state to query * @returns whether I am in the given `state` */ inState(state) { return this.state === state; } /** * Apply the given `patch` to a `model` with accounting for the preconditions mode. */ applyPatchWithOptions(model, patch) { // Every time we attempt to apply the `patch` we have to clone // that original again; we cannot reuse a clone of it for the retry // in case the first attempt modifies it try { (0, fast_json_patch_1.applyPatch)(model, (0, cloneDeep_1.default)(patch)); } catch (error) { if (this.options.preconditionsMode === 'strict') { // Propagate to the caller throw error; } logger.debug('Inapplicable undo/redo patch. Re-trying without tests.', error); // Try again without the test assertions const noTests = (0, cloneDeep_1.default)(patch).filter((op) => op.op !== 'test'); (0, fast_json_patch_1.applyPatch)(model, noTests); } } } exports.PatchCommand = PatchCommand; class MultiPatchCommand extends core_1.CompoundCommandImpl { constructor(label, ...patches) { super(label, ...patches.map((entry) => new PatchCommand(label, entry.modelId, entry.patch))); } } exports.MultiPatchCommand = MultiPatchCommand; /** * Create a command that invokes a model updater function at the * time of its execution to directly modify a model object, * automatically capturing a JSON Patch for undo/redo. * Upon successful execution, the `result` property of the returned * command is set to the return result of the `modelUpdater` function * that computed the model changes. * * @param label a user-presentable label for the command * @param modelId the ID of the model to be modified * @param modelUpdater a function that will directly modify a working copy of the model * @param [options] options, if any, to tune the behaviour of the command * * @returns the undoable model updater command with a `result` that is `undefined` until the command * has been executed */ const createModelUpdaterCommandWithResult = (label, modelId, modelUpdater, options) => { let returnResult = { status: 'pending' }; const delegate = async (workingCopy, modelId) => { try { returnResult = { status: 'ready', value: await modelUpdater(workingCopy, modelId), }; } catch (e) { // Be sure to re-throw after creating the return-result so that // the calling context of the Command Stack can unwind if (!!e && typeof e === 'object' && 'message' in e && typeof e.message === 'string') { const error = e; returnResult = { status: 'failed', error }; throw e; } else { const error = new Error(String(e)); returnResult = { status: 'failed', error }; throw error; } } }; return new (class extends PatchCommand { get result() { return returnResult; } })(label, modelId, delegate, options); }; exports.createModelUpdaterCommandWithResult = createModelUpdaterCommandWithResult; /** * Create a command that invokes a model updater function at the * time of its execution to directly modify a model object, * automatically capturing a JSON Patch for undo/redo. * * @param label a user-presentable label for the command * @param modelId the ID of the model to be modified * @param modelUpdater a function that will directly modify a working copy of the model * @param [options] options, if any, to tune the behaviour of the command * * @returns the undoable model updater command */ const createModelUpdaterCommand = (label, modelId, modelUpdater, options) => { return new PatchCommand(label, modelId, modelUpdater, options); }; exports.createModelUpdaterCommand = createModelUpdaterCommand; /** * Create a command that invokes a model function at the time of * its execution to compute a JSON Patch to apply to the model. * The computed patch is then used to modify the model. * * @param label a user-presentable label for the command * @param modelId the ID of the model to be modified * @param patchFunction a function that will compute the patch to apply * @param @param [options] options, if any, to tune the behaviour of the command * * @returns the undoable model patch command */ const createModelPatchCommand = (label, modelId, patchFunction, options) => { return (0, exports.createModelUpdaterCommand)(label, modelId, async (workingCopy, theModelId) => { const patch = await patchFunction(workingCopy, theModelId); (0, fast_json_patch_1.applyPatch)(workingCopy, (0, cloneDeep_1.default)(patch)); }, options); }; exports.createModelPatchCommand = createModelPatchCommand; /** * Transforms an arbitrary Javascript object in place to be JSON compatible. * The goal is to behave similar to `JSON.parse(JSON.stringify(obj))`. * * The following transformations are applied: * - Removes properties whose value is `undefined` * - Converts `undefined` array values to `null` * * Will throw an error for recursive objects. */ const jsonTransform = (obj) => { const visited = new Set(); const traverse = (current) => { if (visited.has(current)) { throw new Error("model can't be processed because it has recursive references"); } if (current && typeof current === 'object') { visited.add(current); if (Array.isArray(current)) { current.forEach((value, index) => { if (value === undefined) { current[index] = null; } else { (0, exports.jsonTransform)(value); } }); visited.delete(current); // A transformation of an object to another in place can't be properly typed, // therefore we manually confirm that we did our job return current; } for (const key in current) { const value = current[key]; if (value === undefined) { delete current[key]; } else { // A transformation of an object to another in place can't be properly typed, // therefore pretend that nothing is changed type wise so that Typescript is satisfied current[key] = traverse(value); } } visited.delete(current); } // A transformation of an object to another in place can't be properly typed, // therefore we manually confirm that we did our job return current; }; return traverse(obj); }; exports.jsonTransform = jsonTransform; //# sourceMappingURL=patch-command.js.map