@eclipse-emfcloud/model-manager
Version:
Command-based model editing with undo/redo.
298 lines • 12.9 kB
JavaScript
;
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