Browse Source

Merge branch 'path-manager' of github.newgen:IhorKaleniuk666/grapesjs into path-manager-any

pull/6691/head
IhorKaleniuk666 2 weeks ago
parent
commit
393f2d9cbd
  1. 5
      packages/core/src/block_manager/model/Block.ts
  2. 37
      packages/core/src/block_manager/model/Blocks.ts
  3. 5
      packages/core/src/device_manager/model/Device.ts
  4. 8
      packages/core/src/device_manager/model/Devices.ts
  5. 156
      packages/core/src/dom_components/model/Component.ts
  6. 181
      packages/core/src/dom_components/model/Components.ts
  7. 22
      packages/core/src/domain_abstract/model/StyleableModel.ts
  8. 35
      packages/core/src/patch_manager/CollectionWithPatches.ts
  9. 148
      packages/core/src/patch_manager/ModelWithPatches.ts
  10. 47
      packages/core/src/patch_manager/index.ts
  11. 91
      packages/core/src/patch_manager/registry.ts
  12. 6
      packages/core/src/trait_manager/model/Trait.ts
  13. 3
      packages/core/src/trait_manager/model/Traits.ts
  14. 4
      packages/core/src/utils/fractionalIndex.ts
  15. 252
      packages/core/test/specs/patch_manager/components.js
  16. 51
      packages/core/test/specs/patch_manager/model/ModelWithPatches.js
  17. 33
      packages/core/test/specs/patch_manager/registry.js

5
packages/core/src/block_manager/model/Block.ts

@ -1,4 +1,4 @@
import ModelWithPatches from '../../patch_manager/ModelWithPatches';
import { Model } from '../../common';
import { isFunction } from 'underscore';
import Editor from '../../editor';
import Category, { CategoryProperties } from '../../abstract/ModuleCategory';
@ -74,8 +74,7 @@ export interface BlockProperties extends DraggableContent {
*
* @module docsjs.Block
*/
export default class Block extends ModelWithPatches<BlockProperties> {
patchObjectType = 'block';
export default class Block extends Model<BlockProperties> {
defaults() {
return {
label: '',

37
packages/core/src/block_manager/model/Blocks.ts

@ -1,21 +1,48 @@
import { CollectionWithCategories } from '../../abstract/CollectionWithCategories';
import { isString } from 'underscore';
import { Collection } from '../../common';
import Categories from '../../abstract/ModuleCategories';
import Category from '../../abstract/ModuleCategory';
import { isObject } from '../../utils/mixins';
import EditorModel from '../../editor/model/Editor';
import Block from './Block';
export default class Blocks extends CollectionWithCategories<Block> {
const CATEGORY_KEY = 'category';
export default class Blocks extends Collection<Block> {
em: EditorModel;
patchObjectType = 'blocks';
constructor(coll: any[], options: { em: EditorModel }) {
super(coll, { ...options, patchObjectType: 'blocks', collectionId: 'global' } as any);
super(coll, options);
this.em = options.em;
this.on('add', this.handleAdd);
}
getCategories() {
getCategories(): Categories {
return this.em.Blocks.getCategories();
}
initCategory(model: Block) {
let category = (model as any).get(CATEGORY_KEY);
const isDefined = category instanceof Category;
// Ensure the category exists and it's not already initialized
if (category && !isDefined) {
if (isString(category)) {
category = { id: category, label: category };
} else if (isObject(category) && !category.id) {
category.id = category.label;
}
const catModel = this.getCategories().add(category);
(model as any).set(CATEGORY_KEY, catModel as any, { silent: true });
return catModel;
} else if (isDefined) {
const catModel = category as unknown as Category;
this.getCategories().add(catModel);
return catModel;
}
}
handleAdd(model: Block) {
this.initCategory(model);
}

5
packages/core/src/device_manager/model/Device.ts

@ -1,4 +1,4 @@
import ModelWithPatches from '../../patch_manager/ModelWithPatches';
import { Model } from '../../common';
/** @private */
export interface DeviceProperties {
@ -43,8 +43,7 @@ export interface DeviceProperties {
* @property {String} [widthMedia=''] The width which will be used in media queries, If empty the width will be used
* @property {Number} [priority=null] Setup the order of media queries
*/
export default class Device extends ModelWithPatches<DeviceProperties> {
patchObjectType = 'device';
export default class Device extends Model<DeviceProperties> {
defaults() {
return {
name: '',

8
packages/core/src/device_manager/model/Devices.ts

@ -1,11 +1,9 @@
import CollectionWithPatches from '../../patch_manager/CollectionWithPatches';
import { Collection } from '../../common';
import Device from './Device';
export default class Devices extends CollectionWithPatches<Device> {
patchObjectType = 'devices';
export default class Devices extends Collection<Device> {
constructor(models?: any, opts: any = {}) {
super(models, { ...opts, patchObjectType: 'devices', collectionId: opts.collectionId || 'global' } as any);
super(models, opts);
}
}

156
packages/core/src/dom_components/model/Component.ts

@ -1,4 +1,5 @@
import { Model, ModelDestroyOptions } from 'backbone';
import PatchManager, { type PatchPath } from '../../patch_manager';
import {
bindAll,
forEach,
@ -158,7 +159,22 @@ type GetComponentStyleOpts = GetStyleOpts & {
* @module docsjs.Component
*/
export default class Component extends StyleableModel<ComponentProperties> {
patchObjectType = 'component';
patchObjectType = 'components';
protected getPatchExcludedPaths(): PatchPath[] {
return [
['toolbar'],
['traits'],
['status'],
['open'],
['delegate'],
['_undoexc'],
['dataResolverWatchers'],
['attributes', 'class'],
// Structural changes are tracked via `componentsOrder`
['components'],
];
}
/**
* @private
* @ts-ignore */
@ -293,75 +309,84 @@ export default class Component extends StyleableModel<ComponentProperties> {
bindAll(this, '__upSymbProps', '__upSymbCls', '__upSymbComps', 'syncOnComponentChange');
// Propagate properties from parent if indicated
const parent = this.parent();
const parentAttr = parent?.attributes;
const propagate = this.get('propagate');
propagate && this.set('propagate', isArray(propagate) ? propagate : [propagate]);
if (parentAttr && parentAttr.propagate && !propagate) {
const newAttr: Partial<ComponentProperties> = {};
const toPropagate = parentAttr.propagate;
toPropagate.forEach((prop) => (newAttr[prop] = parent.get(prop as string)));
newAttr.propagate = toPropagate;
this.set({ ...newAttr, ...props });
}
const pm = em && ((em as any).Patches as PatchManager | undefined);
const init = () => {
// Propagate properties from parent if indicated
const parent = this.parent();
const parentAttr = parent?.attributes;
const propagate = this.get('propagate');
propagate && this.set('propagate', isArray(propagate) ? propagate : [propagate]);
if (parentAttr && parentAttr.propagate && !propagate) {
const newAttr: Partial<ComponentProperties> = {};
const toPropagate = parentAttr.propagate;
toPropagate.forEach((prop) => (newAttr[prop] = parent.get(prop as string)));
newAttr.propagate = toPropagate;
this.set({ ...newAttr, ...props });
}
// Check void elements
if (opt && opt.config && opt.config.voidElements!.indexOf(this.get('tagName')!) >= 0) {
this.set('void', true);
}
// Check void elements
if (opt && opt.config && opt.config.voidElements!.indexOf(this.get('tagName')!) >= 0) {
this.set('void', true);
}
opt.em = em;
this.opt = opt;
this.em = em!;
this.config = opt.config || {};
const defaultAttrs = {
...(result(this, 'defaults').attributes || {}),
...(this.get('attributes') || {}),
};
const attrs = this.dataResolverWatchers.getValueOrResolver('attributes', defaultAttrs);
this.setAttributes(attrs);
this.ccid = Component.createId(this, opt as any);
this.preInit();
this.initClasses();
this.initComponents();
this.initTraits();
this.initToolbar();
this.initScriptProps();
this.listenTo(this, 'change:script', this.scriptUpdated);
this.listenTo(this, 'change:tagName', this.tagUpdated);
this.listenTo(this, 'change:attributes', this.attrUpdated);
this.listenTo(this, 'change:attributes:id', this._idUpdated);
this.on('change:toolbar', this.__emitUpdateTlb);
this.on('change', this.__onChange);
this.on(keyUpdateInside, this.__propToParent);
this.set('status', '');
this.views = [];
// Register global updates for collection properties
['classes', 'traits', 'components'].forEach((name) => {
const events = `add remove reset ${name !== 'components' ? 'change' : ''}`;
this.listenTo(this.get(name), events.trim(), (...args) => this.emitUpdate(name, ...args));
});
opt.em = em;
this.opt = opt;
this.em = em!;
this.config = opt.config || {};
const defaultAttrs = {
...(result(this, 'defaults').attributes || {}),
...(this.get('attributes') || {}),
};
const attrs = this.dataResolverWatchers.getValueOrResolver('attributes', defaultAttrs);
this.setAttributes(attrs);
this.ccid = Component.createId(this, opt as any);
this.preInit();
this.initClasses();
this.initComponents();
this.initTraits();
this.initToolbar();
this.initScriptProps();
this.listenTo(this, 'change:script', this.scriptUpdated);
this.listenTo(this, 'change:tagName', this.tagUpdated);
this.listenTo(this, 'change:attributes', this.attrUpdated);
this.listenTo(this, 'change:attributes:id', this._idUpdated);
this.on('change:toolbar', this.__emitUpdateTlb);
this.on('change', this.__onChange);
this.on(keyUpdateInside, this.__propToParent);
this.set('status', '');
this.views = [];
// Register global updates for collection properties
['classes', 'traits', 'components'].forEach((name) => {
const events = `add remove reset ${name !== 'components' ? 'change' : ''}`;
this.listenTo(this.get(name), events.trim(), (...args) => this.emitUpdate(name, ...args));
});
if (!opt.temporary) {
// Add component styles
const cssc = em && em.Css;
const { styles, type } = this.attributes;
if (styles && cssc) {
cssc.addCollection(styles, { avoidUpdateStyle: true }, { group: `cmp:${type}` });
if (!opt.temporary) {
// Add component styles
const cssc = em && em.Css;
const { styles, type } = this.attributes;
if (styles && cssc) {
cssc.addCollection(styles, { avoidUpdateStyle: true }, { group: `cmp:${type}` });
}
this._moveInlineStyleToRule();
this.__postAdd();
this.init();
isSymbol(this) && initSymbol(this);
em?.trigger(ComponentsEvents.create, this, opt);
}
this._moveInlineStyleToRule();
this.__postAdd();
this.init();
isSymbol(this) && initSymbol(this);
em?.trigger(ComponentsEvents.create, this, opt);
}
if (avoidInline(em)) {
this.dataResolverWatchers.disableStyles();
}
};
if (avoidInline(em)) {
this.dataResolverWatchers.disableStyles();
if (pm?.isEnabled) {
pm.withSuppressedTracking(init);
} else {
init();
}
}
@ -1018,8 +1043,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
// Have to add components after the init, otherwise the parent
// is not visible
const comps = new Components([], this.opt);
comps.parent = this;
comps.setCollectionId(this.getId() || this.cid);
comps.setParent(this);
const components = this.get('components');
const addChild = !this.opt.avoidChildren;
this.set('components', comps);

181
packages/core/src/dom_components/model/Components.ts

@ -6,6 +6,8 @@ import EditorModel from '../../editor/model/Editor';
import ComponentManager from '..';
import CssRule from '../../css_composer/model/CssRule';
import CollectionWithPatches from '../../patch_manager/CollectionWithPatches';
import { generateKeyBetween, generateNKeysBetween } from '../../utils/fractionalIndex';
import { serialize } from '../../utils/mixins';
import {
ComponentAdd,
@ -123,6 +125,22 @@ interface AddComponentOptions extends AddOptions {
keepIds?: string[];
}
const isValidPatchUid = (uid: any): uid is string | number => {
if (typeof uid === 'string') return uid !== '';
return typeof uid === 'number';
};
const isOrderMap = (value: any): value is Record<string, any> =>
value != null && typeof value === 'object' && !Array.isArray(value);
const getOrderKeyByUid = (orderMap: Record<string, any>, uid: string | number) => {
const entries = Object.entries(orderMap);
const match = entries.find(([, value]) => value === uid);
return match ? match[0] : undefined;
};
const TEMP_MOVE_FLAG = '__patchTempMove';
export default class Components extends CollectionWithPatches</**
* Keep this format to avoid errors in TS bundler */
/** @ts-ignore */
@ -134,11 +152,13 @@ Component> {
parent?: Component;
constructor(models: any, opt: ComponentsOptions) {
super(models, { ...opt, patchObjectType: 'components', collectionId: opt.collectionId });
super(models, { ...opt, patchObjectType: 'components', trackOrder: false } as any);
this.opt = opt;
this.listenTo(this, 'add', this.onAdd);
this.listenTo(this, 'remove', this.handlePatchRemove);
this.listenTo(this, 'remove', this.removeChildren);
this.listenTo(this, 'reset', this.resetChildren);
this.listenTo(this, 'add', this.handlePatchAdd);
const { em, config } = opt;
this.config = config;
this.em = em;
@ -149,6 +169,165 @@ Component> {
return this.domc?.events!;
}
setParent(parent: Component) {
this.parent = parent;
this.stopListening(parent, 'change:componentsOrder', this.handleComponentsOrderChange);
this.listenTo(parent, 'change:componentsOrder', this.handleComponentsOrderChange);
this.patchManager && this.ensureParentOrderMap();
}
protected handleComponentsOrderChange(_model: Component, value: any, opts: any = {}) {
if (opts.fromComponents) return;
if (!isOrderMap(value)) return;
const ordered = Object.entries(value)
.sort(([a], [b]) => a.localeCompare(b))
.map(([, uid]) => uid);
const byUid = new Map<string | number, Component>();
this.models.forEach((model) => {
const uid = (model as any).get?.('uid');
if (isValidPatchUid(uid)) {
byUid.set(uid, model);
}
});
const nextModels: Component[] = [];
ordered.forEach((uid) => {
const model = byUid.get(uid);
model && nextModels.push(model);
});
// Append leftovers (eg. models without uid/order entry) keeping current order.
const included = new Set(nextModels.map((m) => m.cid));
this.models.forEach((model) => {
if (!included.has(model.cid)) {
nextModels.push(model);
}
});
if (!nextModels.length) return;
this.models.splice(0, this.models.length, ...nextModels);
this.trigger('sort', this, { fromPatches: true });
}
protected ensureModelUid(model: Component): string | number | undefined {
const uid = (model as any).get?.('uid');
if (isValidPatchUid(uid)) return uid;
const pm = this.patchManager;
if (!pm) return;
(model as any).set?.({}, { silent: true });
const nextUid = (model as any).get?.('uid');
return isValidPatchUid(nextUid) ? nextUid : undefined;
}
protected ensureParentOrderMap(excludeUid?: string | number): Record<string, any> {
const parent = this.parent;
if (!parent) return {};
const current = parent.get('componentsOrder');
if (isOrderMap(current)) return current;
if (!this.patchManager) return {};
const models = this.models.filter((model) => {
const uid = this.ensureModelUid(model);
return uid !== excludeUid;
});
const uids = models.map((model) => this.ensureModelUid(model)).filter(isValidPatchUid);
const keys = generateNKeysBetween(null, null, uids.length);
const map: Record<string, any> = {};
uids.forEach((uid, index) => {
map[keys[index]] = uid;
});
// Initialize without recording a patch (it's a derived structure).
(parent as any).attributes.componentsOrder = map;
return map;
}
protected handlePatchAdd(model: Component, _collection: any, opts: any = {}) {
const pm = this.patchManager;
const parent = this.parent;
if (!pm || !parent) return;
const uid = this.ensureModelUid(model);
const parentUid = this.ensureModelUid(parent as any);
if (!isValidPatchUid(uid) || !isValidPatchUid(parentUid)) return;
const isTempMove = !!(model as any)[TEMP_MOVE_FLAG];
if (isTempMove) {
delete (model as any)[TEMP_MOVE_FLAG];
}
if (!isTempMove && !opts.fromUndo) {
const patch = pm.createOrGetCurrentPatch();
const attrPrefix = [this.patchObjectType as string, uid, 'attributes'];
const isAttrPatch = (p: any) => {
const { path, from } = p || {};
const startsWith = (value?: any[]) => attrPrefix.every((seg, index) => value?.[index] === seg);
return startsWith(path) || startsWith(from);
};
patch.changes = patch.changes.filter((p: any) => !isAttrPatch(p));
patch.reverseChanges = patch.reverseChanges.filter((p: any) => !isAttrPatch(p));
patch.changes.push({
op: 'add',
path: [this.patchObjectType as string, uid],
value: { attributes: serialize(model.toJSON()) },
});
patch.reverseChanges.unshift({ op: 'remove', path: [this.patchObjectType as string, uid] });
}
const baseMap = this.ensureParentOrderMap(uid);
const cleanMap = Object.fromEntries(Object.entries(baseMap).filter(([, value]) => value !== uid));
const index = this.indexOf(model);
const prevModel = index > 0 ? this.at(index - 1) : undefined;
const nextModel = index < this.length - 1 ? this.at(index + 1) : undefined;
const prevUid = prevModel ? this.ensureModelUid(prevModel) : undefined;
const nextUid = nextModel ? this.ensureModelUid(nextModel) : undefined;
const prevKey = prevUid != null ? getOrderKeyByUid(cleanMap, prevUid) : undefined;
const nextKey = nextUid != null ? getOrderKeyByUid(cleanMap, nextUid) : undefined;
const newKey = generateKeyBetween(prevKey ?? null, nextKey ?? null);
const nextMap = { ...cleanMap, [newKey]: uid };
parent.set('componentsOrder', nextMap, { ...opts, fromComponents: true });
}
protected handlePatchRemove(model: Component, _collection: any, opts: any = {}) {
const pm = this.patchManager;
const parent = this.parent;
if (!pm || !parent) return;
const uid = this.ensureModelUid(model);
const parentUid = this.ensureModelUid(parent as any);
if (!isValidPatchUid(uid) || !isValidPatchUid(parentUid)) return;
if (opts.temporary) {
(model as any)[TEMP_MOVE_FLAG] = true;
}
const currentMap = this.ensureParentOrderMap();
const orderKey = isOrderMap(currentMap) ? getOrderKeyByUid(currentMap, uid) : undefined;
if (orderKey) {
const { [orderKey]: _removed, ...rest } = currentMap;
parent.set('componentsOrder', rest, { ...opts, fromComponents: true });
}
const isTemp = opts.temporary || opts.fromUndo;
if (isTemp) return;
const patch = pm.createOrGetCurrentPatch();
patch.changes.push({ op: 'remove', path: [this.patchObjectType as string, uid] });
patch.reverseChanges.unshift({
op: 'add',
path: [this.patchObjectType as string, uid],
value: { attributes: serialize(model.toJSON()) },
});
}
resetChildren(models: Components, opts: { previousModels?: Component[]; keepIds?: string[] } = {}) {
const coll = this;
const prev = opts.previousModels || [];

22
packages/core/src/domain_abstract/model/StyleableModel.ts

@ -2,7 +2,7 @@ import { isArray, isObject, isString, keys } from 'underscore';
import { ObjectAny, ObjectHash, SetOptions } from '../../common';
import ParserHtml from '../../parser/model/ParserHtml';
import Selectors from '../../selector_manager/model/Selectors';
import { shallowDiff } from '../../utils/mixins';
import { createId, shallowDiff } from '../../utils/mixins';
import EditorModel from '../../editor/model/Editor';
import CssRuleView from '../../css_composer/view/CssRuleView';
import ComponentView from '../../dom_components/view/ComponentView';
@ -45,10 +45,17 @@ type WithDataResolvers<T> = {
[P in keyof T]?: T[P] | DataResolverProps;
};
export default class StyleableModel<T extends StyleableModelProperties = any> extends ModelWithPatches<
T,
UpdateStyleOptions
> {
const isValidPatchUid = (uid: any): uid is string | number => {
if (typeof uid === 'string') return uid !== '';
return typeof uid === 'number';
};
const createStableUid = () => {
const randomUUID = typeof crypto !== 'undefined' && (crypto as any).randomUUID;
return typeof randomUUID === 'function' ? randomUUID.call(crypto) : createId();
};
export default class StyleableModel<T extends StyleableModelProperties = any> extends ModelWithPatches<T, UpdateStyleOptions> {
em?: EditorModel;
views: StyleableView[] = [];
dataResolverWatchers: ModelDataResolverWatchers<T>;
@ -316,6 +323,11 @@ export default class StyleableModel<T extends StyleableModelProperties = any> ex
const mergedProps = { ...props, ...attributes };
const mergedOpts = { ...this.opt, ...opts };
const uid = (mergedProps as any).uid;
if (isValidPatchUid(uid)) {
(mergedProps as any).uid = createStableUid();
}
const ClassConstructor = this.constructor as new (attributes: any, opts?: any) => typeof this;
return new ClassConstructor(mergedProps, mergedOpts);

35
packages/core/src/patch_manager/CollectionWithPatches.ts

@ -1,5 +1,5 @@
import { generateNKeysBetween } from '../utils/fractionalIndex';
import { Collection, Model, AddOptions } from '../common';
import { AddOptions, Collection, Model } from '../common';
import EditorModel from '../editor/model/Editor';
import PatchManager, { PatchChangeProps, PatchPath } from './index';
@ -7,6 +7,7 @@ export interface CollectionWithPatchesOptions extends AddOptions {
em?: EditorModel;
collectionId?: string;
patchObjectType?: string;
trackOrder?: boolean;
}
export type FractionalEntry<T extends Model = Model> = {
@ -30,6 +31,7 @@ export default class CollectionWithPatches<T extends Model = Model> extends Coll
private pendingRemovals: Record<string, PendingRemoval> = {};
private suppressSortRebuild = false;
private isResetting = false;
private trackOrder = true;
constructor(models?: any, options: CollectionWithPatchesOptions = {}) {
const nextOptions = { ...options };
@ -37,20 +39,26 @@ export default class CollectionWithPatches<T extends Model = Model> extends Coll
this.em = nextOptions.em;
this.collectionId = nextOptions.collectionId;
this.patchObjectType = nextOptions.patchObjectType;
this.on('sort', this.handleSort, this);
this.rebuildFractionalMap(false);
this.trackOrder = nextOptions.trackOrder !== false;
if (this.trackOrder) {
this.on('sort', this.handleSort, this);
this.rebuildFractionalMap(false);
}
// Ensure tracking/registry works for apply(external) in enabled mode.
Promise.resolve().then(() => {
const pm = this.patchManager;
if (pm?.isEnabled) {
const id = this.getPatchCollectionId();
if (pm?.isEnabled && this.patchObjectType && id != null) {
pm.trackCollection?.(this as any);
}
});
}
get patchManager(): PatchManager | undefined {
return this.em?.Patches;
const pm = (this.em as any)?.Patches as PatchManager | undefined;
return pm?.isEnabled && this.patchObjectType ? pm : undefined;
}
setCollectionId(id: string) {
@ -61,7 +69,7 @@ export default class CollectionWithPatches<T extends Model = Model> extends Coll
add(models: Array<T | {}>, options?: CollectionWithPatchesOptions): T[];
add(models: any, options?: CollectionWithPatchesOptions): any {
const result = super.add(models, this.withEmOptions(options) as any);
!this.isResetting && this.assignKeysForMissingModels();
this.trackOrder && !this.isResetting && this.assignKeysForMissingModels();
return result as any;
}
@ -69,6 +77,8 @@ export default class CollectionWithPatches<T extends Model = Model> extends Coll
remove(models: Array<T | {}>, options?: any): T[];
remove(models: any, options?: any): any {
const removed = super.remove(models, options as any);
if (!this.trackOrder) return removed;
const removedModels = Array.isArray(removed) ? removed : removed ? [removed] : [];
removedModels.forEach((model) => {
const id = this.getModelId(model as any);
@ -96,9 +106,11 @@ export default class CollectionWithPatches<T extends Model = Model> extends Coll
this.isResetting = true;
try {
const result = super.reset(models, this.withEmOptions(options) as any);
this.fractionalMap = {};
this.pendingRemovals = {};
this.rebuildFractionalMap();
if (this.trackOrder) {
this.fractionalMap = {};
this.pendingRemovals = {};
this.rebuildFractionalMap();
}
return result;
} finally {
this.isResetting = false;
@ -106,12 +118,13 @@ export default class CollectionWithPatches<T extends Model = Model> extends Coll
}
protected handleSort(_collection?: any, options: any = {}) {
if (!this.trackOrder) return;
if (this.suppressSortRebuild || options?.fromPatches) return;
this.rebuildFractionalMap();
}
protected getPatchCollectionId(): string | undefined {
return this.collectionId || (this as any).cid;
return this.collectionId;
}
protected withEmOptions(options?: CollectionWithPatchesOptions) {
@ -123,6 +136,7 @@ export default class CollectionWithPatches<T extends Model = Model> extends Coll
}
protected rebuildFractionalMap(record: boolean = true) {
if (!this.trackOrder) return;
const ids = this.models.map((model) => this.getModelId(model)).filter(Boolean);
const keys = ids.length ? generateNKeysBetween(null, null, ids.length) : [];
const prevMap = { ...this.fractionalMap };
@ -148,6 +162,7 @@ export default class CollectionWithPatches<T extends Model = Model> extends Coll
}
protected assignKeysForMissingModels() {
if (!this.trackOrder) return;
let idx = 0;
const models = this.models;

148
packages/core/src/patch_manager/ModelWithPatches.ts

@ -1,7 +1,7 @@
import { enablePatches, produceWithPatches } from 'immer';
import EditorModel from '../editor/model/Editor';
import { Model, ObjectHash, SetOptions } from '../common';
import { serialize } from '../utils/mixins';
import { createId, serialize } from '../utils/mixins';
import PatchManager, { PatchChangeProps, PatchPath } from './index';
enablePatches();
@ -35,6 +35,39 @@ const normalizePatchPaths = (patches: PatchChangeProps[], prefix: PatchPath): Pa
}));
const syncDraftToState = (draft: any, target: any) => {
const isObject = (value: any): value is Record<string, any> =>
value != null && typeof value === 'object' && !Array.isArray(value);
if (Array.isArray(draft) && Array.isArray(target)) {
if (draft.length > target.length) {
draft.splice(target.length, draft.length - target.length);
}
for (let i = 0; i < target.length; i++) {
const draftValue = draft[i];
const targetValue = target[i];
if (Array.isArray(draftValue) && Array.isArray(targetValue)) {
syncDraftToState(draftValue, targetValue);
} else if (isObject(draftValue) && isObject(targetValue)) {
syncDraftToState(draftValue, targetValue);
} else if (draftValue !== targetValue) {
draft[i] = targetValue;
}
}
// Add new entries (after syncing shared indexes).
for (let i = draft.length; i < target.length; i++) {
draft.push(target[i]);
}
return;
}
if (!isObject(draft) || !isObject(target)) {
return;
}
Object.keys(draft).forEach((key) => {
if (!(key in target)) {
delete draft[key];
@ -42,7 +75,51 @@ const syncDraftToState = (draft: any, target: any) => {
});
Object.keys(target).forEach((key) => {
draft[key] = target[key];
const draftValue = draft[key];
const targetValue = target[key];
if (Array.isArray(draftValue) && Array.isArray(targetValue)) {
syncDraftToState(draftValue, targetValue);
} else if (isObject(draftValue) && isObject(targetValue)) {
syncDraftToState(draftValue, targetValue);
} else if (draftValue !== targetValue) {
draft[key] = targetValue;
}
});
};
const isValidPatchUid = (uid: any): uid is string | number => {
if (typeof uid === 'string') return uid !== '';
return typeof uid === 'number';
};
const createStableUid = () => {
const randomUUID = typeof crypto !== 'undefined' && (crypto as any).randomUUID;
return typeof randomUUID === 'function' ? randomUUID.call(crypto) : createId();
};
const stripUid = <T extends ObjectHash>(attrs: Partial<T>): Partial<T> => {
const attrsAny = attrs as any;
if (attrsAny && typeof attrsAny === 'object' && 'uid' in attrsAny) {
const { uid: _uid, ...rest } = attrsAny;
return rest as Partial<T>;
}
return attrs;
};
const isPatchPathExcluded = (path: PatchPath, exclusions: PatchPath[]) =>
exclusions.some((excludedPath) =>
excludedPath.every((excludedSeg, index) => path[index] === excludedSeg),
);
const filterExcludedPatches = (patches: PatchChangeProps[], exclusions: PatchPath[]) => {
if (!exclusions.length || !patches.length) return patches;
return patches.filter((patch) => {
const { path, from } = patch;
if (isPatchPathExcluded(path, exclusions)) return false;
if (from && isPatchPathExcluded(from, exclusions)) return false;
return true;
});
};
@ -62,6 +139,10 @@ export default class ModelWithPatches<T extends ObjectHash = any, S = SetOptions
});
}
protected getPatchExcludedPaths(): PatchPath[] {
return [];
}
protected get patchManager(): PatchManager | undefined {
const pm = (this.em as any)?.Patches as PatchManager | undefined;
if (pm?.isEnabled && this.patchObjectType) {
@ -72,37 +153,64 @@ export default class ModelWithPatches<T extends ObjectHash = any, S = SetOptions
}
protected getPatchObjectId(): string | number | undefined {
const withGetId = this as any;
if (typeof withGetId.getId === 'function') {
const stableId = withGetId.getId();
const valid = typeof stableId === 'string' ? stableId !== '' : typeof stableId === 'number';
if (valid) return stableId;
}
const id = (this as any).id ?? (this as any).get?.('id');
return id ?? (this as any).cid;
return this.get('uid' as any);
}
clone(): this {
const attrs = serialize(this.attributes || {}) as any;
attrs.uid = createStableUid();
return new (this.constructor as any)(attrs);
}
set(...args: any[]): this {
const { attrs: rawAttrs, opts } = normalizeSetArgs<T>(args);
const existingUid = this.get('uid' as any) as string | number | undefined;
const hasExistingUid = isValidPatchUid(existingUid);
// UID is immutable: ignore any attempt to change/unset it via public `set`
const immutableAttrs = hasExistingUid ? stripUid(rawAttrs) : rawAttrs;
const pm = this.patchManager;
const objectId = this.getPatchObjectId();
if (!pm || !objectId) {
return (super.set as any).apply(this, args);
if (!pm) {
return super.set(immutableAttrs as any, opts as any);
}
// Never accept UID mutations via public `set` while tracking patches
const attrsNoUid = stripUid(immutableAttrs);
const beforeState = serialize(this.attributes || {}) as any;
const stateUid = beforeState.uid;
const uid = isValidPatchUid(stateUid) ? stateUid : hasExistingUid ? existingUid : pm.createId();
beforeState.uid = uid;
// Ensure UID exists before applying changes, but do not record it in patches
if (!hasExistingUid && isValidPatchUid(uid)) {
super.set({ uid } as any, { silent: true } as any);
}
const { attrs, opts } = normalizeSetArgs<T>(args);
const beforeState = serialize(this.attributes || {});
const result = super.set(attrs as any, opts as any);
if (!isValidPatchUid(uid)) {
return super.set(attrsNoUid as any, opts as any);
}
const result = super.set(attrsNoUid as any, opts as any);
const afterState = serialize(this.attributes || {});
(afterState as any).uid = uid;
const [, patches, inversePatches] = produceWithPatches<any>(beforeState, (draft: any) => {
syncDraftToState(draft, afterState);
});
if (patches.length || inversePatches.length) {
const prefix: PatchPath = [this.patchObjectType as string, objectId, 'attributes'];
const excludedPaths = this.getPatchExcludedPaths();
const nextPatches = filterExcludedPatches(patches, excludedPaths);
const nextInversePatches = filterExcludedPatches(inversePatches, excludedPaths);
if (nextPatches.length || nextInversePatches.length) {
const prefix: PatchPath = [this.patchObjectType as string, uid, 'attributes'];
const activePatch = pm.createOrGetCurrentPatch();
activePatch.changes.push(...normalizePatchPaths(patches, prefix));
activePatch.reverseChanges.push(...normalizePatchPaths(inversePatches, prefix));
activePatch.changes.push(...normalizePatchPaths(nextPatches, prefix));
// Reverse changes should be applied in reverse order.
activePatch.reverseChanges.unshift(...normalizePatchPaths(nextInversePatches, prefix));
}
return result;

47
packages/core/src/patch_manager/index.ts

@ -49,6 +49,11 @@ const createPatchId = () => {
return typeof randomUUID === 'function' ? randomUUID.call(crypto) : createId();
};
const isValidPatchUid = (uid: any): uid is string | number => {
if (typeof uid === 'string') return uid !== '';
return typeof uid === 'number';
};
export default class PatchManager {
isEnabled: boolean;
private emitter?: PatchEventEmitter;
@ -71,10 +76,9 @@ export default class PatchManager {
trackModel(model: any): void {
if (!model) return;
const type = model.patchObjectType;
const idFromGetId = typeof model.getId === 'function' ? model.getId() : undefined;
const hasGetId = typeof idFromGetId === 'string' ? idFromGetId !== '' : typeof idFromGetId === 'number';
const id = hasGetId ? idFromGetId : (model.id ?? model.get?.('id') ?? model.cid);
if (!type || id == null) return;
const id =
typeof model.getPatchObjectId === 'function' ? model.getPatchObjectId() : (model.get?.('uid') ?? model.uid);
if (!type || !isValidPatchUid(id)) return;
const idStr = String(id);
this.trackedModels[type] = this.trackedModels[type] || {};
this.trackedModels[type][idStr] = model;
@ -83,10 +87,9 @@ export default class PatchManager {
untrackModel(model: any): void {
if (!model) return;
const type = model.patchObjectType;
const idFromGetId = typeof model.getId === 'function' ? model.getId() : undefined;
const hasGetId = typeof idFromGetId === 'string' ? idFromGetId !== '' : typeof idFromGetId === 'number';
const id = hasGetId ? idFromGetId : (model.id ?? model.get?.('id') ?? model.cid);
if (!type || id == null) return;
const id =
typeof model.getPatchObjectId === 'function' ? model.getPatchObjectId() : (model.get?.('uid') ?? model.uid);
if (!type || !isValidPatchUid(id)) return;
const idStr = String(id);
this.trackedModels[type] && delete this.trackedModels[type][idStr];
}
@ -94,13 +97,9 @@ export default class PatchManager {
trackCollection(collection: any): void {
if (!collection) return;
const type = collection.patchObjectType;
const idFromGetter =
typeof collection.getPatchCollectionId === 'function' ? collection.getPatchCollectionId() : undefined;
const hasGetterId = typeof idFromGetter === 'string' ? idFromGetter !== '' : typeof idFromGetter === 'number';
const id = hasGetterId
? idFromGetter
: (collection.collectionId ?? collection.id ?? collection.get?.('id') ?? collection.cid);
if (!type || id == null) return;
const id =
typeof collection.getPatchCollectionId === 'function' ? collection.getPatchCollectionId() : collection.collectionId;
if (!type || !isValidPatchUid(id)) return;
const idStr = String(id);
this.trackedCollections[type] = this.trackedCollections[type] || {};
this.trackedCollections[type][idStr] = collection;
@ -109,17 +108,17 @@ export default class PatchManager {
untrackCollection(collection: any): void {
if (!collection) return;
const type = collection.patchObjectType;
const idFromGetter =
typeof collection.getPatchCollectionId === 'function' ? collection.getPatchCollectionId() : undefined;
const hasGetterId = typeof idFromGetter === 'string' ? idFromGetter !== '' : typeof idFromGetter === 'number';
const id = hasGetterId
? idFromGetter
: (collection.collectionId ?? collection.id ?? collection.get?.('id') ?? collection.cid);
if (!type || id == null) return;
const id =
typeof collection.getPatchCollectionId === 'function' ? collection.getPatchCollectionId() : collection.collectionId;
if (!type || !isValidPatchUid(id)) return;
const idStr = String(id);
this.trackedCollections[type] && delete this.trackedCollections[type][idStr];
}
createId(): string {
return createPatchId();
}
createOrGetCurrentPatch(): PatchProps {
if (!this.shouldRecord()) {
return this.createVoidPatch();
@ -294,7 +293,7 @@ export default class PatchManager {
});
}
private withSuppressedTracking<T>(cb: () => T): T {
withSuppressedTracking<T>(cb: () => T): T {
const prevSuppress = this.suppressTracking;
this.suppressTracking = true;
@ -344,4 +343,6 @@ export default class PatchManager {
this.emitter?.trigger?.(event, payload);
}
}
export { default as CollectionWithPatches } from './CollectionWithPatches';
export { PatchObjectsRegistry, createRegistryApplyPatchHandler, type PatchUid } from './registry';

91
packages/core/src/patch_manager/registry.ts

@ -0,0 +1,91 @@
import { applyPatches } from 'immer';
import { serialize } from '../utils/mixins';
import type { PatchApplyHandler, PatchApplyOptions, PatchChangeProps, PatchPath } from './index';
export type PatchUid = string | number;
export class PatchObjectsRegistry<T = any> {
private byType: Record<string, Map<PatchUid, T>> = {};
register(type: string, uid: PatchUid, obj: T): void {
if (!this.byType[type]) {
this.byType[type] = new Map();
}
this.byType[type].set(uid, obj);
}
unregister(type: string, uid: PatchUid): void {
this.byType[type]?.delete(uid);
}
get(type: string, uid: PatchUid): T | undefined {
return this.byType[type]?.get(uid);
}
clear(type?: string): void {
if (type) {
delete this.byType[type];
return;
}
this.byType = {};
}
}
type PatchGroup = {
type: string;
uid: PatchUid;
patches: PatchChangeProps[];
};
const getPatchGroupKey = (type: string, uid: PatchUid) => `${type}::${uid}`;
const stripPrefix = (path: PatchPath, prefixLen: number): PatchPath => path.slice(prefixLen);
const normalizeForApply = (patch: PatchChangeProps): PatchChangeProps => {
const prefixLen = 3; // [type, uid, 'attributes', ...]
return {
...patch,
path: stripPrefix(patch.path, prefixLen),
...(patch.from ? { from: stripPrefix(patch.from, prefixLen) } : {}),
};
};
const syncModelToState = (model: any, state: any, options?: PatchApplyOptions) => {
const current = model.attributes || {};
Object.keys(current).forEach((key) => {
if (!(key in state)) {
model.unset(key, options as any);
}
});
model.set(state, options as any);
};
export const createRegistryApplyPatchHandler = (registry: PatchObjectsRegistry): PatchApplyHandler => {
return (changes: PatchChangeProps[], options?: PatchApplyOptions) => {
const groups = new Map<string, PatchGroup>();
changes.forEach((patch) => {
const [type, uid, scope] = patch.path;
if (typeof type !== 'string' || (typeof uid !== 'string' && typeof uid !== 'number')) return;
if (scope !== 'attributes') return;
const key = getPatchGroupKey(type, uid);
const group = groups.get(key) || { type, uid, patches: [] };
group.patches.push(patch);
groups.set(key, group);
});
groups.forEach(({ type, uid, patches }) => {
const model = registry.get(type, uid);
if (!model) return;
const baseState = serialize(model.attributes || {});
const nextState = applyPatches(baseState, patches.map(normalizeForApply) as any);
syncModelToState(model, nextState, options);
});
};
};

6
packages/core/src/trait_manager/model/Trait.ts

@ -1,13 +1,12 @@
import { isString, isUndefined } from 'underscore';
import Category from '../../abstract/ModuleCategory';
import { LocaleOptions, SetOptions } from '../../common';
import { LocaleOptions, SetOptions, Model } from '../../common';
import Component from '../../dom_components/model/Component';
import EditorModel from '../../editor/model/Editor';
import { isDef } from '../../utils/mixins';
import TraitsEvents, { TraitGetValueOptions, TraitOption, TraitProperties, TraitSetValueOptions } from '../types';
import TraitView from '../view/TraitView';
import Traits from './Traits';
import ModelWithPatches from '../../patch_manager/ModelWithPatches';
/**
* @property {String} id Trait id, eg. `my-trait-id`.
@ -22,8 +21,7 @@ import ModelWithPatches from '../../patch_manager/ModelWithPatches';
* @module docsjs.Trait
*
*/
export default class Trait extends ModelWithPatches<TraitProperties> {
patchObjectType = 'trait';
export default class Trait extends Model<TraitProperties> {
target!: Component;
em: EditorModel;
view?: TraitView;

3
packages/core/src/trait_manager/model/Traits.ts

@ -14,10 +14,9 @@ export default class Traits extends CollectionWithCategories<Trait> {
target!: Component;
tf: TraitFactory;
categories = new Categories();
patchObjectType = 'traits';
constructor(coll: TraitProperties[], options: { em: EditorModel; collectionId?: string }) {
super(coll, { ...options, patchObjectType: 'traits', collectionId: options.collectionId || 'global' } as any);
super(coll, options as any);
const { em } = options;
this.em = em;
this.categories = new Categories([], {

4
packages/core/src/utils/fractionalIndex.ts

@ -1,5 +1,5 @@
// License: CC0 (no rights reserved).
// See https://github.com/rocicorp/fractional-indexing
// Based on rocicorp/fractional-indexing (CC0)
// https://github.com/rocicorp/fractional-indexing
export const BASE_62_DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';

252
packages/core/test/specs/patch_manager/components.js

@ -0,0 +1,252 @@
import { applyPatches } from 'immer';
import Component from 'dom_components/model/Component';
import Editor from 'editor/model/Editor';
import PatchManager from 'patch_manager';
import { generateKeyBetween, generateNKeysBetween } from 'utils/fractionalIndex';
import { serialize } from 'utils/mixins';
const flush = () => Promise.resolve();
const getUpdatePatches = (events) => events.filter((e) => e.event === 'patch:update').map((e) => e.payload);
const initState = (models) => ({
components: Object.fromEntries(
models.map((model) => {
const attributes = serialize(model.toJSON());
delete attributes.components;
return [model.get('uid'), { attributes }];
}),
),
});
describe('Patch tracking: nested Components order', () => {
let em;
let compOpts;
beforeEach(() => {
em = new Editor({ avoidDefaults: true, avoidInlineStyle: true });
em.Pages.onLoad();
const domc = em.Components;
compOpts = { em, componentTypes: domc.componentTypes, domc };
});
afterEach(() => {
em.destroyAll();
});
test('Does not create patches for non-storable props (toolbar/traits/status)', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
});
const cmp = new Component({}, compOpts);
em.set('Patches', pm);
events.length = 0;
cmp.set('toolbar', [{ command: 'tlb-move' }]);
cmp.set('traits', [{ type: 'text', name: 'title' }]);
cmp.set('status', 'selected');
await flush();
expect(getUpdatePatches(events)).toHaveLength(0);
});
test('Add child: records component add + order-map add patches', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
});
pm.createId = () => 'child-1';
const parent = new Component({}, compOpts);
parent.set('uid', 'parent');
em.set('Patches', pm);
parent.components().setParent(parent);
events.length = 0;
parent.append({ tagName: 'div' });
await flush();
const patches = getUpdatePatches(events);
expect(patches).toHaveLength(1);
const patch = patches[0];
expect(patch.changes).toHaveLength(2);
expect(patch.reverseChanges).toHaveLength(2);
expect(patch.changes[0]).toMatchObject({
op: 'add',
path: ['components', 'child-1'],
});
expect(patch.changes[0].value.attributes.uid).toBe('child-1');
expect(patch.changes[1]).toEqual({
op: 'add',
path: ['components', 'parent', 'attributes', 'componentsOrder', generateKeyBetween(null, null)],
value: 'child-1',
});
// Undo order must remove map entry first, then the component object.
expect(patch.reverseChanges[0]).toEqual({
op: 'remove',
path: ['components', 'parent', 'attributes', 'componentsOrder', generateKeyBetween(null, null)],
});
expect(patch.reverseChanges[1]).toEqual({
op: 'remove',
path: ['components', 'child-1'],
});
});
test('Remove child: records order-map remove + component remove patches', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
});
pm.createId = () => 'child-1';
const parent = new Component({}, compOpts);
parent.set('uid', 'parent');
em.set('Patches', pm);
parent.components().setParent(parent);
const [child] = parent.append({ tagName: 'div' });
await flush();
events.length = 0;
parent.components().remove(child);
await flush();
const patches = getUpdatePatches(events);
expect(patches).toHaveLength(1);
const patch = patches[0];
expect(patch.changes).toHaveLength(2);
expect(patch.reverseChanges).toHaveLength(2);
const orderKey = generateKeyBetween(null, null);
expect(patch.changes[0]).toEqual({
op: 'remove',
path: ['components', 'parent', 'attributes', 'componentsOrder', orderKey],
});
expect(patch.changes[1]).toEqual({
op: 'remove',
path: ['components', 'child-1'],
});
// Undo order must re-add the component object first, then restore the order map.
expect(patch.reverseChanges[0]).toMatchObject({
op: 'add',
path: ['components', 'child-1'],
});
expect(patch.reverseChanges[0].value.attributes.uid).toBe('child-1');
expect(patch.reverseChanges[1]).toEqual({
op: 'add',
path: ['components', 'parent', 'attributes', 'componentsOrder', orderKey],
value: 'child-1',
});
});
test('Reorder within same parent updates only componentsOrder (no array index moves)', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
});
const parent = new Component({}, compOpts);
parent.set('uid', 'parent');
const [c1, c2, c3] = parent.append([{ tagName: 'div' }, { tagName: 'span' }, { tagName: 'p' }]);
c1.set('uid', 'c1');
c2.set('uid', 'c2');
c3.set('uid', 'c3');
em.set('Patches', pm);
parent.components().setParent(parent);
events.length = 0;
// Move c1 to the end (temporary remove + re-add).
parent.components().remove(c1, { temporary: true });
parent.components().add(c1, { at: 2 });
await flush();
const patches = getUpdatePatches(events);
expect(patches).toHaveLength(1);
const patch = patches[0];
expect(patch.changes).toHaveLength(2);
expect(patch.reverseChanges).toHaveLength(2);
const [k1, k2, k3] = generateNKeysBetween(null, null, 3);
const newKey = generateKeyBetween(k3, null);
expect(patch.changes[0]).toEqual({
op: 'remove',
path: ['components', 'parent', 'attributes', 'componentsOrder', k1],
});
expect(patch.changes[1]).toEqual({
op: 'add',
path: ['components', 'parent', 'attributes', 'componentsOrder', newKey],
value: 'c1',
});
// Regression: no patches for `attributes.components` array indices.
const hasComponentsArrayPatch = patch.changes.some((ch) => {
const p = ch.path || [];
for (let i = 0; i < p.length - 1; i++) {
if (p[i] === 'attributes' && p[i + 1] === 'components') return true;
}
return false;
});
expect(hasComponentsArrayPatch).toBe(false);
});
test('Move between parents updates order maps and is undo/redo deterministic', async () => {
const events = [];
let state;
const pm = new PatchManager({
enabled: true,
emitter: { trigger: (event, payload) => events.push({ event, payload }) },
applyPatch: (changes) => {
state = applyPatches(state, changes);
},
});
const parentA = new Component({}, compOpts);
const parentB = new Component({}, compOpts);
parentA.set('uid', 'parentA');
parentB.set('uid', 'parentB');
const [child] = parentA.append({ tagName: 'div' });
child.set('uid', 'c1');
em.set('Patches', pm);
parentA.components().setParent(parentA);
parentB.components().setParent(parentB);
state = initState([parentA, parentB, child]);
const before = JSON.parse(JSON.stringify(state));
events.length = 0;
parentA.components().remove(child, { temporary: true });
parentB.components().add(child, { at: 0 });
await flush();
const patches = getUpdatePatches(events);
expect(patches).toHaveLength(1);
const patch = patches[0];
state = applyPatches(state, patch.changes);
const after = JSON.parse(JSON.stringify(state));
pm.undo();
expect(state).toEqual(before);
pm.redo();
expect(state).toEqual(after);
});
});

51
packages/core/test/specs/patch_manager/model/ModelWithPatches.js

@ -10,6 +10,7 @@ describe('ModelWithPatches', () => {
trigger: (event, payload) => events.push({ event, payload }),
},
});
pm.createId = () => 'uid-1';
const model = new ModelWithPatches({ id: 'model-1', foo: 'bar' });
model.em = { Patches: pm };
@ -23,16 +24,17 @@ describe('ModelWithPatches', () => {
expect(events[0].event).toBe(PatchManagerEvents.update);
const patch = events[0].payload;
expect(model.get('uid')).toBe('uid-1');
expect(patch.changes).toHaveLength(1);
expect(patch.reverseChanges).toHaveLength(1);
expect(patch.changes[0]).toMatchObject({
op: 'replace',
path: ['model', 'model-1', 'attributes', 'foo'],
path: ['model', 'uid-1', 'attributes', 'foo'],
value: 'baz',
});
expect(patch.reverseChanges[0]).toMatchObject({
op: 'replace',
path: ['model', 'model-1', 'attributes', 'foo'],
path: ['model', 'uid-1', 'attributes', 'foo'],
value: 'bar',
});
});
@ -71,15 +73,15 @@ describe('ModelWithPatches', () => {
},
});
model = new ModelWithPatches({ id: 'model-3', foo: 'bar' });
model = new ModelWithPatches({ uid: 'uid-3', id: 'model-3', foo: 'bar' });
model.em = { Patches: pm };
model.patchObjectType = 'model';
pm.apply(
{
id: 'patch-3',
changes: [{ op: 'replace', path: ['model', 'model-3', 'attributes', 'foo'], value: 'applied' }],
reverseChanges: [{ op: 'replace', path: ['model', 'model-3', 'attributes', 'foo'], value: 'bar' }],
changes: [{ op: 'replace', path: ['model', 'uid-3', 'attributes', 'foo'], value: 'applied' }],
reverseChanges: [{ op: 'replace', path: ['model', 'uid-3', 'attributes', 'foo'], value: 'bar' }],
},
{ external: true },
);
@ -99,27 +101,46 @@ describe('ModelWithPatches', () => {
},
});
class TrackedModel extends ModelWithPatches {
patchObjectType = 'model';
}
const model = new TrackedModel({ id: 'model-4', foo: 'bar' }, { em: { Patches: pm } });
expect(model.patchObjectType).toBe('model');
expect(model.id || model.get('id')).toBe('model-4');
const model = new ModelWithPatches({ uid: 'uid-4', foo: 'bar' });
model.em = { Patches: pm };
model.patchObjectType = 'model';
pm.trackModel(model);
pm.apply(
{
id: 'patch-4',
changes: [{ op: 'replace', path: ['model', 'model-4', 'attributes', 'foo'], value: 'baz' }],
reverseChanges: [{ op: 'replace', path: ['model', 'model-4', 'attributes', 'foo'], value: 'bar' }],
changes: [{ op: 'replace', path: ['model', 'uid-4', 'attributes', 'foo'], value: 'baz' }],
reverseChanges: [{ op: 'replace', path: ['model', 'uid-4', 'attributes', 'foo'], value: 'bar' }],
},
{ external: true },
);
expect(model.get('foo')).toBe('baz');
await Promise.resolve();
expect(events).toHaveLength(0);
});
test('uid is immutable once set', async () => {
const events = [];
const pm = new PatchManager({
enabled: true,
emitter: {
trigger: (event, payload) => events.push({ event, payload }),
},
});
const model = new ModelWithPatches({ uid: 'uid-4', foo: 'bar' });
model.em = { Patches: pm };
model.patchObjectType = 'model';
model.set('uid', 'uid-changed');
await Promise.resolve();
expect(model.get('uid')).toBe('uid-4');
expect(events).toHaveLength(0);
});
});

33
packages/core/test/specs/patch_manager/registry.js

@ -0,0 +1,33 @@
import PatchManager, { PatchObjectsRegistry, createRegistryApplyPatchHandler } from 'patch_manager';
import ModelWithPatches from 'patch_manager/ModelWithPatches';
describe('PatchObjectsRegistry', () => {
test('apply handler resolves models by uid and applies forward/backward changes', () => {
const registry = new PatchObjectsRegistry();
const pm = new PatchManager({
enabled: true,
applyPatch: createRegistryApplyPatchHandler(registry),
});
const model = new ModelWithPatches({ uid: 'uid-1', foo: 'bar' });
model.em = { Patches: pm };
model.patchObjectType = 'model';
registry.register('model', 'uid-1', model);
const patch = {
id: 'patch-1',
changes: [{ op: 'replace', path: ['model', 'uid-1', 'attributes', 'foo'], value: 'baz' }],
reverseChanges: [{ op: 'replace', path: ['model', 'uid-1', 'attributes', 'foo'], value: 'bar' }],
};
pm.apply(patch);
expect(model.get('foo')).toBe('baz');
pm.undo();
expect(model.get('foo')).toBe('bar');
pm.redo();
expect(model.get('foo')).toBe('baz');
});
});
Loading…
Cancel
Save