diff --git a/packages/core/src/css_composer/model/CssRule.ts b/packages/core/src/css_composer/model/CssRule.ts index 9a7be61e2..b8fc42266 100644 --- a/packages/core/src/css_composer/model/CssRule.ts +++ b/packages/core/src/css_composer/model/CssRule.ts @@ -1,5 +1,5 @@ import { isEmpty, forEach, isString, isArray } from 'underscore'; -import { Model, ObjectAny } from '../../common'; +import { ObjectAny, ObjectHash } from '../../common'; import StyleableModel, { StyleProps } from '../../domain_abstract/model/StyleableModel'; import Selectors from '../../selector_manager/model/Selectors'; import { getMediaLength } from '../../code_manager/model/CssGenerator'; @@ -16,7 +16,7 @@ export interface ToCssOptions { } /** @private */ -export interface CssRuleProperties { +export interface CssRuleProperties extends ObjectHash { /** * Array of selectors */ @@ -126,7 +126,7 @@ export default class CssRule extends StyleableModel { this.em = opt.em; this.ensureSelectors(null, null, {}); this.on('change', this.__onChange); - this.setStyle(this.get('style')); + this.setStyle(this.get('style'), { skipWatcherUpdates: true }); } __onChange(m: CssRule, opts: any) { @@ -135,12 +135,10 @@ export default class CssRule extends StyleableModel { changed && !isEmptyObj(changed) && em?.changesUp(opts); } - clone(): CssRule { - const opts = { ...this.opt }; - const attr = { ...this.attributes }; - attr.selectors = this.get('selectors')!.map((s) => s.clone() as Selector); - // @ts-ignore - return new this.constructor(attr, opts); + clone(): typeof this { + const selectors = this.get('selectors')!.map((s) => s.clone() as Selector); + + return super.clone({ selectors }); } ensureSelectors(m: any, c: any, opts: any) { @@ -307,9 +305,8 @@ export default class CssRule extends StyleableModel { return result; } - toJSON(...args: any) { - const obj = Model.prototype.toJSON.apply(this, args); - + toJSON(opts?: ObjectAny) { + const obj = super.toJSON(opts); if (this.em?.getConfig().avoidDefaults) { const defaults = this.defaults(); @@ -326,7 +323,7 @@ export default class CssRule extends StyleableModel { if (isEmpty(obj.style)) delete obj.style; } - return { ...obj, style: this.dataResolverWatchers.getStylesDefsOrValues(obj.style) }; + return obj; } /** diff --git a/packages/core/src/data_sources/index.ts b/packages/core/src/data_sources/index.ts index 63837cdff..5e9f6efb1 100644 --- a/packages/core/src/data_sources/index.ts +++ b/packages/core/src/data_sources/index.ts @@ -168,5 +168,6 @@ export default class DataSourceManager extends ItemManagerModule em.changesUp(o || c)); + this.em.UndoManager.add(all); } } diff --git a/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts b/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts index 10a3f3ba3..ac4946074 100644 --- a/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts +++ b/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts @@ -103,14 +103,14 @@ export class DataCondition extends Model { return this._conditionEvaluator.evaluate(); } - getDataValue(skipDynamicValueResolution: boolean = false): any { + getDataValue(skipResolve: boolean = false): any { const { em, collectionsStateMap } = this; const options = { em, collectionsStateMap }; const ifTrue = this.getIfTrue(); const ifFalse = this.getIfFalse(); const isConditionTrue = this.isTrue(); - if (skipDynamicValueResolution) { + if (skipResolve) { return isConditionTrue ? ifTrue : ifFalse; } diff --git a/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts b/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts index 18895e11e..096f09d9e 100644 --- a/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts +++ b/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts @@ -16,7 +16,7 @@ import { DataCollectionStateMap, } from './types'; import { detachSymbolInstance, getSymbolInstances } from '../../../dom_components/model/SymbolUtils'; -import { updateFromWatcher } from '../../../dom_components/model/ModelDataResolverWatchers'; +import { keyDataValues, updateFromWatcher } from '../../../dom_components/model/ModelDataResolverWatchers'; import { ModelDestroyOptions } from 'backbone'; import Components from '../../../dom_components/model/Components'; @@ -301,8 +301,7 @@ export default class ComponentDataCollection extends Component { } onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) { - this.collectionsStateMap = collectionsStateMap; - this.dataResolverWatchers.onCollectionsStateMapUpdate(); + super.onCollectionsStateMapUpdate(collectionsStateMap); const items = this.getDataSourceItems(); const { startIndex } = this.resolveCollectionConfig(items); @@ -357,7 +356,7 @@ function getLength(items: DataVariableProps[] | object) { } function setCollectionStateMapAndPropagate(cmp: Component, collectionsStateMap: DataCollectionStateMap) { - cmp.setSymbolOverride(['locked', 'layerable']); + cmp.setSymbolOverride(['locked', 'layerable', keyDataValues]); cmp.syncComponentsCollectionState(); cmp.onCollectionsStateMapUpdate(collectionsStateMap); } diff --git a/packages/core/src/data_sources/utils.ts b/packages/core/src/data_sources/utils.ts index 598dc36aa..583309245 100644 --- a/packages/core/src/data_sources/utils.ts +++ b/packages/core/src/data_sources/utils.ts @@ -49,7 +49,7 @@ export function getDataResolverInstance( break; } default: - options.em?.logWarning(`Unsupported dynamic type: ${type}`); + options.em?.logWarning(`Unsupported resolver type: ${type}`); return; } diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index 6f7c2a111..1dae41ba4 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/packages/core/src/dom_components/model/Component.ts @@ -12,7 +12,11 @@ import { keys, } from 'underscore'; import { shallowDiff, capitalize, isEmptyObj, isObject, toLowerCase } from '../../utils/mixins'; -import StyleableModel, { StyleProps, UpdateStyleOptions } from '../../domain_abstract/model/StyleableModel'; +import StyleableModel, { + GetStyleOpts, + StyleProps, + UpdateStyleOptions, +} from '../../domain_abstract/model/StyleableModel'; import { Model, ModelDestroyOptions } from 'backbone'; import Components from './Components'; import Selector from '../../selector_manager/model/Selector'; @@ -52,14 +56,13 @@ import { updateSymbolProps, getSymbolsToUpdate, } from './SymbolUtils'; -import { ModelDataResolverWatchers } from './ModelDataResolverWatchers'; -import { DynamicWatchersOptions } from './ModelResolverWatcher'; +import { DataWatchersOptions } from './ModelResolverWatcher'; import { DataCollectionStateMap } from '../../data_sources/model/data_collection/types'; import { checkAndGetSyncableCollectionItemId } from '../../data_sources/utils'; export interface IComponent extends ExtractMethods {} -export interface SetAttrOptions extends SetOptions, UpdateStyleOptions, DynamicWatchersOptions {} -export interface ComponentSetOptions extends SetOptions, DynamicWatchersOptions {} +export interface SetAttrOptions extends SetOptions, UpdateStyleOptions, DataWatchersOptions {} +export interface ComponentSetOptions extends SetOptions, DataWatchersOptions {} const escapeRegExp = (str: string) => { return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); @@ -74,6 +77,10 @@ export const keySymbolOvrd = '__symbol_ovrd'; export const keyUpdate = ComponentsEvents.update; export const keyUpdateInside = ComponentsEvents.updateInside; +type GetComponentStyleOpts = GetStyleOpts & { + inline?: boolean; +}; + /** * The Component object represents a single node of our template structure, so when you update its properties the changes are * immediately reflected on the canvas and in the code to export (indeed, when you ask to export the code we just go through all @@ -294,12 +301,12 @@ export default class Component extends StyleableModel { this.opt = opt; this.em = em!; this.config = opt.config || {}; - const dynamicAttributes = this.dataResolverWatchers.getDynamicAttributesDefs(); - this.setAttributes({ + const defaultAttrs = { ...(result(this, 'defaults').attributes || {}), ...(this.get('attributes') || {}), - ...dynamicAttributes, - }); + }; + const attrs = this.dataResolverWatchers.getValueOrResolver('attributes', defaultAttrs); + this.setAttributes(attrs); this.ccid = Component.createId(this, opt); this.preInit(); this.initClasses(); @@ -343,34 +350,9 @@ export default class Component extends StyleableModel { } } - set( - keyOrAttributes: A | Partial, - valueOrOptions?: ComponentProperties[A] | ComponentSetOptions, - optionsOrUndefined?: ComponentSetOptions, - ): this { - let attributes: Partial; - let options: ComponentSetOptions & { - dataResolverWatchers?: ModelDataResolverWatchers; - } = { skipWatcherUpdates: false, fromDataSource: false }; - if (typeof keyOrAttributes === 'object') { - attributes = keyOrAttributes; - options = valueOrOptions || (options as ComponentSetOptions); - } else if (typeof keyOrAttributes === 'string') { - attributes = { [keyOrAttributes as string]: valueOrOptions }; - options = optionsOrUndefined || options; - } else { - attributes = {}; - options = optionsOrUndefined || options; - } - - this.dataResolverWatchers = this.dataResolverWatchers || options.dataResolverWatchers; - const evaluatedProps = this.dataResolverWatchers.addProps(attributes, options); - return super.set(evaluatedProps, options); - } - onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) { - this.collectionsStateMap = collectionsStateMap; - this.dataResolverWatchers.onCollectionsStateMapUpdate(); + super.onCollectionsStateMapUpdate(collectionsStateMap); + this._getStyleRule()?.onCollectionsStateMapUpdate(collectionsStateMap); const cmps = this.components(); cmps.forEach((cmp) => cmp.onCollectionsStateMapUpdate(collectionsStateMap)); @@ -572,7 +554,7 @@ export default class Component extends StyleableModel { * @example * component.setSymbolOverride(['children', 'classes']); */ - setSymbolOverride(value: boolean | string | string[], options: DynamicWatchersOptions = {}) { + setSymbolOverride(value: boolean | string | string[], options: DataWatchersOptions = {}) { this.set( { [keySymbolOvrd]: (isString(value) ? [value] : value) ?? 0, @@ -773,11 +755,13 @@ export default class Component extends StyleableModel { * component.addAttributes({ 'data-key': 'value' }); */ addAttributes(attrs: ObjectAny, opts: SetAttrOptions = {}) { - const dynamicAttributes = this.dataResolverWatchers.getDynamicAttributesDefs(); + const previousAttrs = this.dataResolverWatchers.getValueOrResolver( + 'attributes', + this.getAttributes({ noClass: true, noStyle: true }), + ); return this.setAttributes( { - ...this.getAttributes({ noClass: true, noStyle: true }), - ...dynamicAttributes, + ...previousAttrs, ...attrs, }, opts, @@ -795,7 +779,6 @@ export default class Component extends StyleableModel { */ removeAttributes(attrs: string | string[] = [], opts: SetOptions = {}) { const attrArr = Array.isArray(attrs) ? attrs : [attrs]; - this.dataResolverWatchers.removeAttributes(attrArr); const compAttr = this.getAttributes(); attrArr.map((i) => delete compAttr[i]); @@ -806,21 +789,26 @@ export default class Component extends StyleableModel { * Get the style of the component * @return {Object} */ - getStyle(options: any = {}, optsAdd: any = {}) { + getStyle(opts?: GetComponentStyleOpts): StyleProps; + getStyle(prop: '' | undefined, opts?: GetComponentStyleOpts): StyleProps; + getStyle( + prop?: keyof StyleProps | '' | ObjectAny, + opts?: GetComponentStyleOpts, + ): StyleProps | StyleProps[keyof StyleProps] | undefined { const { em } = this; - const isOptionsString = isString(options); - const prop = isOptionsString ? options : ''; - const opts = isOptionsString || options === '' ? optsAdd : options; - const skipResolve = !!opts?.skipResolve; + const isPropString = isString(prop); + const resolvedProp = isPropString ? prop : ''; + const resolvedOpts = isPropString ? opts : prop; + const skipResolve = !!resolvedOpts?.skipResolve; - if (avoidInline(em) && !opts.inline) { + if (avoidInline(em) && !resolvedOpts?.inline) { const state = em.get('state'); const cc = em.Css; - const rule = cc.getIdRule(this.getId(), { state, ...opts }); + const rule = cc.getIdRule(this.getId(), { state, ...resolvedOpts }); this.rule = rule; if (rule) { - return rule.getStyle(prop, { skipResolve }); + return rule.getStyle(resolvedProp, { skipResolve }); } // Return empty style if no rule have been found. We cannot return inline style with the next return @@ -828,7 +816,7 @@ export default class Component extends StyleableModel { return {}; } - return super.getStyle.call(this, prop, { skipResolve }); + return super.getStyle.call(this, resolvedProp, { skipResolve }); } /** @@ -844,7 +832,7 @@ export default class Component extends StyleableModel { if (avoidInline(em) && !opt.temporary && !opts.inline) { const style = this.get('style') || {}; prop = isString(prop) ? this.parseStyle(prop) : prop; - prop = { ...prop, ...(style as any) }; + prop = { ...(style as any), ...prop }; const state = em.get('state'); const cc = em.Css; const propOrig = this.getStyle({ ...opts, skipResolve: true }); @@ -870,11 +858,10 @@ export default class Component extends StyleableModel { getAttributes(opts: { noClass?: boolean; noStyle?: boolean; skipResolve?: boolean } = {}) { const { em } = this; const classes: string[] = []; - const dynamicValues = opts.skipResolve ? this.dataResolverWatchers.getDynamicAttributesDefs() : {}; - const attributes = { - ...this.get('attributes'), - ...dynamicValues, - }; + const resolvedAttrs = { ...this.get('attributes')! }; + const attributes = opts?.skipResolve + ? this.dataResolverWatchers.getValueOrResolver('attributes', resolvedAttrs) + : resolvedAttrs; const sm = em?.Selectors; const id = this.getId(); @@ -1043,12 +1030,8 @@ export default class Component extends StyleableModel { if (name && value) attrs[name] = value; } }); - const dynamicAttributes = this.dataResolverWatchers.getDynamicAttributesDefs(); - traits.length && - this.setAttributes({ - ...attrs, - ...dynamicAttributes, - }); + const resolvedAttributes = this.dataResolverWatchers.getValueOrResolver('attributes', attrs); + traits.length && this.setAttributes(resolvedAttributes); this.on(event, this.initTraits); changed && em && em.trigger('component:toggled'); return this; @@ -1390,16 +1373,10 @@ export default class Component extends StyleableModel { * @ts-ignore */ clone(opt: { symbol?: boolean; symbolInv?: boolean } = {}): this { const em = this.em; - const attr = { - ...this.attributes, - ...this.dataResolverWatchers.getDynamicPropsDefs(), - }; + const attr = this.dataResolverWatchers.getProps(this.attributes); const opts = { ...this.opt }; const id = this.getId(); const cssc = em?.Css; - attr.attributes = { - ...(attr.attributes ? this.dataResolverWatchers.getAttributesDefsOrValues(attr.attributes) : undefined), - }; // @ts-ignore attr.components = []; // @ts-ignore @@ -1656,9 +1633,7 @@ export default class Component extends StyleableModel { * @private */ toJSON(opts: ObjectAny = {}): ComponentDefinition { - let obj = Model.prototype.toJSON.call(this, opts); - obj = { ...obj, ...this.dataResolverWatchers.getDynamicPropsDefs() }; - obj.attributes = this.dataResolverWatchers.getAttributesDefsOrValues(this.getAttributes()); + let obj = super.toJSON(opts, { attributes: this.getAttributes() }); delete obj.dataResolverWatchers; delete obj.attributes.class; delete obj.toolbar; @@ -2036,8 +2011,10 @@ export default class Component extends StyleableModel { (isObject(inlineStyle) && Object.keys(inlineStyle).length > 0); if (avoidInline(this.em) && hasInlineStyle) { - this.addStyle(inlineStyle); - this.set('style', ''); + this.addStyle( + isObject(inlineStyle) ? this.dataResolverWatchers.getValueOrResolver('styles', inlineStyle) : inlineStyle, + { avoidStore: true, noUndo: true }, + ); } } diff --git a/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts b/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts index 25ad6d24d..f6bc69c90 100644 --- a/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts +++ b/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts @@ -1,74 +1,120 @@ import { ObjectAny } from '../../common'; -import StyleableModel from '../../domain_abstract/model/StyleableModel'; import { - ModelResolverWatcher as ModelResolverWatcher, + ModelResolverWatcher, ModelResolverWatcherOptions, - DynamicWatchersOptions, + DataWatchersOptions, + WatchableModel, } from './ModelResolverWatcher'; import { getSymbolsToUpdate } from './SymbolUtils'; +import Component from './Component'; +import { StyleableModelProperties } from '../../domain_abstract/model/StyleableModel'; +import { isEmpty, isObject } from 'underscore'; export const updateFromWatcher = { fromDataSource: true, avoidStore: true }; +export const keyDataValues = '__data_values'; -export class ModelDataResolverWatchers { - private propertyWatcher: ModelResolverWatcher; - private attributeWatcher: ModelResolverWatcher; - private styleWatcher: ModelResolverWatcher; +export class ModelDataResolverWatchers { + private propertyWatcher: ModelResolverWatcher; + private attributeWatcher: ModelResolverWatcher; + private styleWatcher: ModelResolverWatcher; constructor( - private model: StyleableModel | undefined, - options: ModelResolverWatcherOptions, + private model: WatchableModel, + private options: ModelResolverWatcherOptions, ) { this.propertyWatcher = new ModelResolverWatcher(model, this.onPropertyUpdate, options); this.attributeWatcher = new ModelResolverWatcher(model, this.onAttributeUpdate, options); this.styleWatcher = new ModelResolverWatcher(model, this.onStyleUpdate, options); } - private onPropertyUpdate(component: StyleableModel | undefined, key: string, value: any) { - component?.set(key, value, updateFromWatcher); - } - - private onAttributeUpdate(component: StyleableModel | undefined, key: string, value: any) { - (component as any)?.addAttributes({ [key]: value }, updateFromWatcher); - } - - private onStyleUpdate(component: StyleableModel | undefined, key: string, value: any) { - component?.addStyle({ [key]: value }, { ...updateFromWatcher, partial: true, avoidStore: true }); - } - - bindModel(model: StyleableModel) { + bindModel(model: WatchableModel) { this.model = model; - this.propertyWatcher.bindModel(model); - this.attributeWatcher.bindModel(model); - this.styleWatcher.bindModel(model); + this.watchers.forEach((watcher) => watcher.bindModel(model)); this.updateSymbolOverride(); } - addProps(props: ObjectAny, options: DynamicWatchersOptions = {}) { - const excludedFromEvaluation = ['components', 'dataResolver']; + addProps(props: ObjectAny, options: DataWatchersOptions = {}) { + const dataValues = props[keyDataValues] ?? {}; - const evaluatedProps = Object.fromEntries( - Object.entries(props).map(([key, value]) => - excludedFromEvaluation.includes(key) - ? [key, value] // Return excluded keys as they are - : [key, this.propertyWatcher.addDynamicValues({ [key]: value }, options)[key]], - ), - ); + const filteredProps = this.filterProps(props); + const evaluatedProps = { + ...props, + ...this.propertyWatcher.addDataValues({ ...filteredProps, ...dataValues.props }, options), + }; - if (props.attributes) { - const evaluatedAttributes = this.attributeWatcher.setDynamicValues(props.attributes, options); - evaluatedProps['attributes'] = evaluatedAttributes; + if (this.shouldProcessProp('attributes', props, dataValues)) { + evaluatedProps.attributes = this.processAttributes(props, dataValues, options); + } + + if (this.shouldProcessProp('style', props, dataValues)) { + evaluatedProps.style = this.processStyles(props, dataValues, options); } const skipOverrideUpdates = options.skipWatcherUpdates || options.fromDataSource; if (!skipOverrideUpdates) { this.updateSymbolOverride(); + evaluatedProps[keyDataValues] = { + props: this.propertyWatcher.getAllDataResolvers(), + style: this.styleWatcher.getAllDataResolvers(), + attributes: this.attributeWatcher.getAllDataResolvers(), + }; } return evaluatedProps; } - setStyles(styles: ObjectAny, options: DynamicWatchersOptions = {}) { - return this.styleWatcher.setDynamicValues(styles, options); + getProps(data: ObjectAny): ObjectAny { + const resolvedProps = this.getValueOrResolver('props', data); + const result = { + ...resolvedProps, + }; + delete result[keyDataValues]; + + if (!isEmpty(data.attributes)) { + result.attributes = this.getValueOrResolver('attributes', data.attributes); + } + + if (isObject(data.style) && !isEmpty(data.style)) { + result.style = this.getValueOrResolver('styles', data.style); + } + + return result; + } + + /** + * Resolves properties, styles, or attributes to their final values or returns the data resolvers. + * - If `data` is `null` or `undefined`, the method returns an object containing all data resolvers for the specified `target`. + */ + getValueOrResolver(target: 'props' | 'styles' | 'attributes', data?: ObjectAny) { + let watcher; + + switch (target) { + case 'props': + watcher = this.propertyWatcher; + break; + case 'styles': + watcher = this.styleWatcher; + break; + case 'attributes': + watcher = this.attributeWatcher; + break; + default: { + const { em } = this.options; + em?.logError(`Invalid target '${target}'. Must be 'props', 'styles', or 'attributes'.`); + return {}; + } + } + + if (!data) { + return watcher.getAllDataResolvers(); + } + + return watcher.getValuesOrResolver(data); + } + + removeAttributes(attributes: string[]) { + this.attributeWatcher.removeListeners(attributes); + this.updateSymbolOverride(); } /** @@ -79,20 +125,57 @@ export class ModelDataResolverWatchers { this.styleWatcher.destroy(); } - removeAttributes(attributes: string[]) { - this.attributeWatcher.removeListeners(attributes); - this.updateSymbolOverride(); + onCollectionsStateMapUpdate() { + this.watchers.forEach((watcher) => watcher.onCollectionsStateMapUpdate()); + } + + destroy() { + this.watchers.forEach((watcher) => watcher.destroy()); + } + + private get watchers() { + return [this.propertyWatcher, this.styleWatcher, this.attributeWatcher]; + } + + private isComponent(model: any): model is Component { + return model instanceof Component; + } + + private onPropertyUpdate = (model: WatchableModel, key: string, value: any) => { + model?.set(key, value, updateFromWatcher); + }; + + private onAttributeUpdate = (model: WatchableModel, key: string, value: any) => { + if (!this.isComponent(model)) return; + model?.addAttributes({ [key]: value }, updateFromWatcher); + }; + + private onStyleUpdate = (model: WatchableModel, key: string, value: any) => { + model?.addStyle({ [key]: value }, { ...updateFromWatcher, partial: true, avoidStore: true }); + }; + + private shouldProcessProp(key: 'attributes' | 'style', newProps: ObjectAny, dataValues: ObjectAny): boolean { + const watcher = key === 'attributes' ? this.attributeWatcher : this.styleWatcher; + const dataSubProps = dataValues[key]; + + const hasNewValues = !!newProps[key]; + const hasExistingDataValues = dataSubProps && Object.keys(dataSubProps).length > 0; + const hasApplicableWatchers = dataSubProps && Object.keys(watcher.getAllDataResolvers()).length > 0; + + return hasNewValues || hasExistingDataValues || hasApplicableWatchers; } private updateSymbolOverride() { - const model = this.model as any; + const model = this.model; + if (!this.isComponent(model)) return; + const isCollectionItem = !!Object.keys(model?.collectionsStateMap ?? {}).length; - if (!this.model || !isCollectionItem) return; + if (!isCollectionItem) return; const keys = this.propertyWatcher.getValuesResolvingFromCollections(); const attributesKeys = this.attributeWatcher.getValuesResolvingFromCollections(); - const combinedKeys = ['locked', 'layerable', ...keys]; + const combinedKeys = ['locked', 'layerable', keyDataValues, ...keys]; const haveOverridenAttributes = Object.keys(attributesKeys).length; if (haveOverridenAttributes) combinedKeys.push('attributes'); @@ -103,39 +186,25 @@ export class ModelDataResolverWatchers { model.setSymbolOverride(combinedKeys, { fromDataSource: true }); } - onCollectionsStateMapUpdate() { - this.propertyWatcher.onCollectionsStateMapUpdate(); - this.attributeWatcher.onCollectionsStateMapUpdate(); - this.styleWatcher.onCollectionsStateMapUpdate(); - } - - getDynamicPropsDefs() { - return this.propertyWatcher.getAllSerializableValues(); - } - - getDynamicAttributesDefs() { - return this.attributeWatcher.getAllSerializableValues(); - } - - getDynamicStylesDefs() { - return this.styleWatcher.getAllSerializableValues(); - } + private filterProps(props: ObjectAny) { + const excludedFromEvaluation = ['components', 'dataResolver', keyDataValues]; + const filteredProps = Object.fromEntries( + Object.entries(props).filter(([key]) => !excludedFromEvaluation.includes(key)), + ); - getPropsDefsOrValues(props: ObjectAny) { - return this.propertyWatcher.getSerializableValues(props); + return filteredProps; } - getAttributesDefsOrValues(attributes: ObjectAny) { - return this.attributeWatcher.getSerializableValues(attributes); + private processAttributes(baseValue: ObjectAny, dataValues: ObjectAny, options: DataWatchersOptions = {}) { + return this.attributeWatcher.setDataValues({ ...baseValue.attributes, ...(dataValues.attributes ?? {}) }, options); } - getStylesDefsOrValues(styles: ObjectAny) { - return this.styleWatcher.getSerializableValues(styles); - } + private processStyles(baseValue: ObjectAny | string, dataValues: ObjectAny, options: DataWatchersOptions = {}) { + if (typeof baseValue === 'string') { + this.styleWatcher.removeListeners(); + return baseValue; + } - destroy() { - this.propertyWatcher.destroy(); - this.attributeWatcher.destroy(); - this.styleWatcher.destroy(); + return this.styleWatcher.setDataValues({ ...baseValue.style, ...(dataValues.style ?? {}) }, options); } } diff --git a/packages/core/src/dom_components/model/ModelResolverWatcher.ts b/packages/core/src/dom_components/model/ModelResolverWatcher.ts index 34e62ca4d..82569aa92 100644 --- a/packages/core/src/dom_components/model/ModelResolverWatcher.ts +++ b/packages/core/src/dom_components/model/ModelResolverWatcher.ts @@ -1,11 +1,10 @@ -import { ObjectAny } from '../../common'; +import { ObjectAny, ObjectHash } from '../../common'; import DataResolverListener from '../../data_sources/model/DataResolverListener'; import { getDataResolverInstance, getDataResolverInstanceValue, isDataResolverProps } from '../../data_sources/utils'; import StyleableModel from '../../domain_abstract/model/StyleableModel'; import EditorModel from '../../editor/model/Editor'; -import Component from './Component'; -export interface DynamicWatchersOptions { +export interface DataWatchersOptions { skipWatcherUpdates?: boolean; fromDataSource?: boolean; } @@ -14,35 +13,35 @@ export interface ModelResolverWatcherOptions { em: EditorModel; } -type NewType = StyleableModel | undefined; -type UpdateFn = (component: NewType, key: string, value: any) => void; +export type WatchableModel = StyleableModel | undefined; +export type UpdateFn = (component: WatchableModel, key: string, value: any) => void; -export class ModelResolverWatcher { +export class ModelResolverWatcher { private em: EditorModel; private resolverListeners: Record = {}; constructor( - private model: NewType, - private updateFn: UpdateFn, + private model: WatchableModel, + private updateFn: UpdateFn, options: ModelResolverWatcherOptions, ) { this.em = options.em; } - bindModel(model: StyleableModel) { + bindModel(model: WatchableModel) { this.model = model; } - setDynamicValues(values: ObjectAny | undefined, options: DynamicWatchersOptions = {}) { + setDataValues(values: ObjectAny | undefined, options: DataWatchersOptions = {}) { const shouldSkipWatcherUpdates = options.skipWatcherUpdates || options.fromDataSource; if (!shouldSkipWatcherUpdates) { this.removeListeners(); } - return this.addDynamicValues(values, options); + return this.addDataValues(values, options); } - addDynamicValues(values: ObjectAny | undefined, options: DynamicWatchersOptions = {}) { + addDataValues(values: ObjectAny | undefined, options: DataWatchersOptions = {}) { if (!values) return {}; const evaluatedValues = this.evaluateValues(values); @@ -61,8 +60,8 @@ export class ModelResolverWatcher { this.resolverListeners[key].resolver.updateCollectionsStateMap(this.collectionsStateMap), ); - const evaluatedValues = this.addDynamicValues( - this.getSerializableValues(Object.fromEntries(resolvesFromCollections.map((key) => [key, null]))), + const evaluatedValues = this.addDataValues( + this.getValuesOrResolver(Object.fromEntries(resolvesFromCollections.map((key) => [key, '']))), ); Object.entries(evaluatedValues).forEach(([key, value]) => this.updateFn(this.model, key, value)); @@ -70,8 +69,8 @@ export class ModelResolverWatcher { private get collectionsStateMap() { const component = this.model; - if (component instanceof Component) return component.collectionsStateMap; - return {}; + + return component?.collectionsStateMap ?? {}; } private updateListeners(values: { [key: string]: any }) { @@ -133,9 +132,9 @@ export class ModelResolverWatcher { return propsKeys; } - getSerializableValues(values: ObjectAny | undefined) { + getValuesOrResolver(values: ObjectAny) { if (!values) return {}; - const serializableValues = { ...values }; + const serializableValues: ObjectAny = { ...values }; const propsKeys = Object.keys(serializableValues); for (let index = 0; index < propsKeys.length; index++) { @@ -149,7 +148,7 @@ export class ModelResolverWatcher { return serializableValues; } - getAllSerializableValues() { + getAllDataResolvers() { const serializableValues: ObjectAny = {}; const propsKeys = Object.keys(this.resolverListeners); diff --git a/packages/core/src/dom_components/model/SymbolUtils.ts b/packages/core/src/dom_components/model/SymbolUtils.ts index 79f98d3b1..4df7300f9 100644 --- a/packages/core/src/dom_components/model/SymbolUtils.ts +++ b/packages/core/src/dom_components/model/SymbolUtils.ts @@ -131,44 +131,45 @@ export const logSymbol = (symb: Component, type: string, toUp: Component[], opts }; export const updateSymbolProps = (symbol: Component, opts: SymbolToUpOptions = {}): void => { - const changed = symbol.dataResolverWatchers.getPropsDefsOrValues({ ...symbol.changedAttributes() }); - const attrs = symbol.dataResolverWatchers.getAttributesDefsOrValues({ ...changed.attributes }); + const changedAttributes = symbol.changedAttributes(); + if (!changedAttributes) return; - cleanChangedProperties(changed, attrs); + let resolvedProps = symbol.dataResolverWatchers.getProps(changedAttributes); + cleanChangedProperties(resolvedProps); - if (!isEmptyObj(changed)) { + if (!isEmptyObj(resolvedProps)) { const toUpdate = getSymbolsToUpdate(symbol, opts); // Filter properties to propagate - filterPropertiesForPropagation(changed, symbol); + resolvedProps = filterPropertiesForPropagation(resolvedProps, symbol); - logSymbol(symbol, 'props', toUpdate, { opts, changed }); + logSymbol(symbol, 'props', toUpdate, { opts, changed: resolvedProps }); // Update child symbols toUpdate.forEach((child) => { - const propsToUpdate = { ...changed }; - filterPropertiesForPropagation(propsToUpdate, child); + const propsToUpdate = filterPropertiesForPropagation(resolvedProps, child); child.set(propsToUpdate, { fromInstance: symbol, ...opts }); }); } }; -const cleanChangedProperties = (changed: Record, attrs: Record): void => { - const keysToDelete = ['status', 'open', keySymbols, keySymbol, keySymbolOvrd, 'attributes']; +const cleanChangedProperties = (changed: Record): void => { + const keysToDelete = ['status', 'open', keySymbols, keySymbol, keySymbolOvrd]; keysToDelete.forEach((key) => delete changed[key]); - delete attrs.id; - if (!isEmptyObj(attrs)) { - changed.attributes = attrs; - } + delete changed.attributes?.id; + isEmptyObj(changed.attributes ?? {}) && delete changed.attributes; }; -const filterPropertiesForPropagation = (props: Record, component: Component): void => { +const filterPropertiesForPropagation = (props: Record, component: Component) => { + const filteredProps = { ...props }; keys(props).forEach((prop) => { if (!shouldPropagateProperty(props, prop, component)) { - delete props[prop]; + delete filteredProps[prop]; } }); + + return filteredProps; }; const shouldPropagateProperty = (props: Record, prop: string, component: Component): boolean => { diff --git a/packages/core/src/dom_components/model/types.ts b/packages/core/src/dom_components/model/types.ts index 400200410..84e7cc7ce 100644 --- a/packages/core/src/dom_components/model/types.ts +++ b/packages/core/src/dom_components/model/types.ts @@ -1,4 +1,4 @@ -import { DynamicWatchersOptions } from './ModelResolverWatcher'; +import { DataWatchersOptions } from './ModelResolverWatcher'; import Frame from '../../canvas/model/Frame'; import { AddOptions, Nullable, OptionAsDocument } from '../../common'; import EditorModel from '../../editor/model/Editor'; @@ -253,7 +253,7 @@ export interface ComponentProperties { [key: string]: any; } -export interface SymbolToUpOptions extends DynamicWatchersOptions { +export interface SymbolToUpOptions extends DataWatchersOptions { changed?: string; fromInstance?: boolean; noPropagate?: boolean; diff --git a/packages/core/src/domain_abstract/model/StyleableModel.ts b/packages/core/src/domain_abstract/model/StyleableModel.ts index 67474e851..8e3bca8b8 100644 --- a/packages/core/src/domain_abstract/model/StyleableModel.ts +++ b/packages/core/src/domain_abstract/model/StyleableModel.ts @@ -1,22 +1,22 @@ -import { isArray, isString, keys } from 'underscore'; +import { isArray, isObject, isString, keys } from 'underscore'; import { Model, ObjectAny, ObjectHash, SetOptions } from '../../common'; import ParserHtml from '../../parser/model/ParserHtml'; import Selectors from '../../selector_manager/model/Selectors'; import { shallowDiff } from '../../utils/mixins'; import EditorModel from '../../editor/model/Editor'; -import { DataVariableProps } from '../../data_sources/model/DataVariable'; import CssRuleView from '../../css_composer/view/CssRuleView'; import ComponentView from '../../dom_components/view/ComponentView'; import Frame from '../../canvas/model/Frame'; -import { DataConditionProps } from '../../data_sources/model/conditional_variables/DataCondition'; import { ToCssOptions } from '../../css_composer/model/CssRule'; import { ModelDataResolverWatchers } from '../../dom_components/model/ModelDataResolverWatchers'; import { DataCollectionStateMap } from '../../data_sources/model/data_collection/types'; -import { DynamicWatchersOptions } from '../../dom_components/model/ModelResolverWatcher'; +import { DataWatchersOptions } from '../../dom_components/model/ModelResolverWatcher'; +import { DataResolverProps } from '../../data_sources/types'; +import { _StringKey } from 'backbone'; -export type StyleProps = Record; +export type StyleProps = Record; -export interface UpdateStyleOptions extends SetOptions, DynamicWatchersOptions { +export interface UpdateStyleOptions extends SetOptions, DataWatchersOptions { partial?: boolean; addStyle?: StyleProps; inline?: boolean; @@ -31,20 +31,76 @@ export const getLastStyleValue = (value: string | string[]) => { return isArray(value) ? value[value.length - 1] : value; }; -export default class StyleableModel extends Model { +export interface StyleableModelProperties extends ObjectHash { + selectors?: any; + style?: StyleProps | string; +} + +export interface GetStyleOpts { + skipResolve?: boolean; +} + +type WithDataResolvers = { + [P in keyof T]?: T[P] | DataResolverProps; +}; + +export default class StyleableModel extends Model { em?: EditorModel; views: StyleableView[] = []; - dataResolverWatchers: ModelDataResolverWatchers; + dataResolverWatchers: ModelDataResolverWatchers; collectionsStateMap: DataCollectionStateMap = {}; + opt: { em?: EditorModel }; constructor(attributes: T, options: { em?: EditorModel } = {}) { const em = options.em!; - const dataResolverWatchers = new ModelDataResolverWatchers(undefined, { em }); + const dataResolverWatchers = new ModelDataResolverWatchers(undefined, { em }); super(attributes, { ...options, dataResolverWatchers }); dataResolverWatchers.bindModel(this); - dataResolverWatchers.setStyles(this.get('style')!); this.dataResolverWatchers = dataResolverWatchers; this.em = options.em; + this.opt = options; + } + + get>(attributeName: A, opts?: { skipResolve?: boolean }): T[A] | undefined { + if (opts?.skipResolve) return this.dataResolverWatchers.getValueOrResolver('props')[attributeName]; + + return super.get(attributeName); + } + + set( + keyOrAttributes: A, + valueOrOptions?: T[A] | DataResolverProps, + optionsOrUndefined?: UpdateStyleOptions, + ): this; + set(keyOrAttributes: WithDataResolvers, options?: UpdateStyleOptions): this; + set( + keyOrAttributes: WithDataResolvers, + valueOrOptions?: T[A] | DataResolverProps | UpdateStyleOptions, + optionsOrUndefined?: UpdateStyleOptions, + ): this { + const defaultOptions: UpdateStyleOptions = { + skipWatcherUpdates: false, + fromDataSource: false, + }; + + let attributes: WithDataResolvers; + let options: UpdateStyleOptions & { dataResolverWatchers?: ModelDataResolverWatchers }; + + if (typeof keyOrAttributes === 'object') { + attributes = keyOrAttributes; + options = (valueOrOptions as UpdateStyleOptions) || defaultOptions; + } else if (typeof keyOrAttributes === 'string') { + attributes = { [keyOrAttributes]: valueOrOptions } as Partial; + options = optionsOrUndefined || defaultOptions; + } else { + attributes = {}; + options = defaultOptions; + } + + this.dataResolverWatchers = this.dataResolverWatchers ?? options.dataResolverWatchers; + const evaluatedValues = this.dataResolverWatchers.addProps(attributes, options) as Partial; + + return super.set(evaluatedValues, options); } /** @@ -69,15 +125,31 @@ export default class StyleableModel extends Model(prop: K, opts?: GetStyleOpts): StyleProps[K] | undefined; + getStyle( + prop?: keyof StyleProps | '' | ObjectAny, + opts: GetStyleOpts = {}, + ): StyleProps | StyleProps[keyof StyleProps] | undefined { + const rawStyle = this.get('style'); + const parsedStyle: StyleProps = isString(rawStyle) + ? this.parseStyle(rawStyle) + : isObject(rawStyle) + ? { ...rawStyle } + : {}; + + delete parsedStyle.__p; + + const shouldReturnFull = !prop || prop === '' || isObject(prop); + if (!opts.skipResolve) { - return prop && isString(prop) ? { ...style }[prop] : { ...style }; + return shouldReturnFull ? parsedStyle : parsedStyle[prop]; } - const result: ObjectAny = { ...style, ...this.dataResolverWatchers.getDynamicStylesDefs() }; - return prop && isString(prop) ? result[prop] : result; + const unresolvedStyles: StyleProps = this.dataResolverWatchers.getValueOrResolver('styles', parsedStyle); + + return shouldReturnFull ? unresolvedStyles : unresolvedStyles[prop]; } /** @@ -110,9 +182,9 @@ export default class StyleableModel extends Model; + this.set(resolvedProps, opts as any); + newStyle = resolvedProps['style']! as StyleProps; const changedKeys = Object.keys(shallowDiff(propOrig, propNew)); const diff: ObjectAny = changedKeys.reduce((acc, key) => { @@ -199,7 +271,7 @@ export default class StyleableModel extends Model extends Model, opts?: any): typeof this { + const props = this.dataResolverWatchers.getProps(this.attributes); + const mergedProps = { ...props, ...attributes }; + const mergedOpts = { ...this.opt, ...opts }; + + const ClassConstructor = this.constructor as new (attributes: any, opts?: any) => typeof this; + + return new ClassConstructor(mergedProps, mergedOpts); + } + + toJSON(opts?: ObjectAny, attributes?: Partial) { + if (opts?.fromUndo) return { ...super.toJSON(opts) }; + const mergedProps = { ...this.attributes, ...attributes }; + const obj = this.dataResolverWatchers.getProps(mergedProps); + + return obj; + } } diff --git a/packages/core/src/navigator/index.ts b/packages/core/src/navigator/index.ts index d2e49adda..163ab9168 100644 --- a/packages/core/src/navigator/index.ts +++ b/packages/core/src/navigator/index.ts @@ -184,7 +184,7 @@ export default class LayerManager extends Module { */ setVisible(component: Component, value: boolean) { const prevDspKey = '__prev-display'; - const style: any = component.getStyle(styleOpts); + const style: any = component.getStyle(styleOpts as any); const { display } = style; if (value) { @@ -211,7 +211,7 @@ export default class LayerManager extends Module { * @returns {Boolean} */ isVisible(component: Component): boolean { - return !isStyleHidden(component.getStyle(styleOpts)); + return !isStyleHidden(component.getStyle(styleOpts as any)); } /** diff --git a/packages/core/src/trait_manager/model/Trait.ts b/packages/core/src/trait_manager/model/Trait.ts index e0069d87f..2c5622902 100644 --- a/packages/core/src/trait_manager/model/Trait.ts +++ b/packages/core/src/trait_manager/model/Trait.ts @@ -275,7 +275,7 @@ export default class Trait extends Model { }); } else if (this.changeProp) { value = component.get(name); - if (skipResolve) value = component.dataResolverWatchers.getPropsDefsOrValues({ [name]: value })[name]; + if (skipResolve) value = component.dataResolverWatchers.getValueOrResolver('props', { [name]: value })[name]; } else { value = component.getAttributes({ skipResolve })[name]; } diff --git a/packages/core/test/common.ts b/packages/core/test/common.ts index e14d11cf1..385db4899 100644 --- a/packages/core/test/common.ts +++ b/packages/core/test/common.ts @@ -1,4 +1,4 @@ -import { DataSourceManager } from '../src'; +import { DataSource } from '../src'; import CanvasEvents from '../src/canvas/types'; import { ObjectAny } from '../src/common'; import { @@ -14,7 +14,17 @@ import EditorModel from '../src/editor/model/Editor'; export const DEFAULT_CMPS = 3; export function setupTestEditor(opts?: { withCanvas?: boolean; config?: Partial }) { - document.body.innerHTML = '
'; + document.body.innerHTML = ''; + const fixtures = document.createElement('div'); + fixtures.id = 'fixtures'; + const canvasWrapEl = document.createElement('div'); + canvasWrapEl.id = 'canvas-wrp'; + const editorEl = document.createElement('div'); + editorEl.id = 'editor'; + document.body.appendChild(fixtures); + document.body.appendChild(canvasWrapEl); + document.body.appendChild(editorEl); + const editor = new Editor({ mediaCondition: 'max-width', el: document.body.querySelector('#editor') as HTMLElement, @@ -23,6 +33,7 @@ export function setupTestEditor(opts?: { withCanvas?: boolean; config?: Partial< }); const em = editor.getModel(); const dsm = em.DataSources; + const um = em.UndoManager; const { Pages, Components, Canvas } = em; Pages.onLoad(); const cmpRoot = Components.getWrapper()!; @@ -32,9 +43,6 @@ export function setupTestEditor(opts?: { withCanvas?: boolean; config?: Partial< config: { ...cmpRoot.config, em }, }); wrapperEl.render(); - const fixtures = document.body.querySelector('#fixtures')!; - fixtures.appendChild(wrapperEl.el); - const canvasWrapEl = document.body.querySelector('#canvas-wrp')!; /** * When trying to render the canvas, seems like jest gets stuck in a loop of iframe.onload (FrameView.ts) @@ -48,10 +56,14 @@ export function setupTestEditor(opts?: { withCanvas?: boolean; config?: Partial< el.onload = null; }); // Enable undo manager + editor.UndoManager.postLoad(); + editor.CssComposer.postLoad(); + editor.DataSources.postLoad(); + editor.Components.postLoad(); editor.Pages.postLoad(); } - return { editor, em, dsm, cmpRoot, fixtures: fixtures as HTMLElement }; + return { editor, em, dsm, um, cmpRoot, fixtures }; } export function fixJsDom(editor: Editor) { diff --git a/packages/core/test/specs/data_sources/model/StyleDataVariable.ts b/packages/core/test/specs/data_sources/dynamic_values/styles.ts similarity index 100% rename from packages/core/test/specs/data_sources/model/StyleDataVariable.ts rename to packages/core/test/specs/data_sources/dynamic_values/styles.ts diff --git a/packages/core/test/specs/data_sources/model/TraitDataVariable.ts b/packages/core/test/specs/data_sources/dynamic_values/traits.ts similarity index 100% rename from packages/core/test/specs/data_sources/model/TraitDataVariable.ts rename to packages/core/test/specs/data_sources/dynamic_values/traits.ts diff --git a/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts b/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts index 7fd5f4c37..41169940a 100644 --- a/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts +++ b/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts @@ -256,7 +256,6 @@ describe('Collection component', () => { test('Updating the value to a different collection variable', async () => { firstChild.set('name', { - // @ts-ignore type: DataVariableType, variableType: DataCollectionStateType.currentItem, collectionId: 'my_collection', @@ -275,7 +274,6 @@ describe('Collection component', () => { expect(secondChild.get('name')).toBe('new_value_14'); firstGrandchild.set('name', { - // @ts-ignore type: DataVariableType, variableType: DataCollectionStateType.currentItem, collectionId: 'my_collection', @@ -293,7 +291,6 @@ describe('Collection component', () => { test('Updating the value to a different dynamic variable', async () => { firstChild.set('name', { - // @ts-ignore type: DataVariableType, path: 'my_data_source_id.user2.user', }); @@ -306,8 +303,8 @@ describe('Collection component', () => { expect(secondChild.get('name')).toBe('new_value'); expect(thirdChild.get('name')).toBe('new_value'); + // @ts-ignore firstGrandchild.set('name', { - // @ts-ignore type: DataVariableType, path: 'my_data_source_id.user2.user', }); diff --git a/packages/core/test/specs/dom_components/index.ts b/packages/core/test/specs/dom_components/index.ts index 825350821..e06edd390 100644 --- a/packages/core/test/specs/dom_components/index.ts +++ b/packages/core/test/specs/dom_components/index.ts @@ -264,9 +264,9 @@ describe('DOM Components', () => { #${id} { background-color: red } `) as Component; const rule = cc.getAll().at(0); - expect(rule.toCSS()).toEqual(`#${id}{background-color:red;color:red;padding:50px 100px;}`); + expect(rule.toCSS()).toEqual(`#${id}{color:red;padding:50px 100px;background-color:red;}`); obj.getComponents().first().addStyle({ margin: '10px' }); - const css = `#${id}{background-color:red;color:red;padding:50px 100px;margin:10px;}`; + const css = `#${id}{color:red;padding:50px 100px;background-color:red;margin:10px;}`; expect(rule.toCSS()).toEqual(css); setTimeout(() => { diff --git a/packages/core/test/specs/undo_manager/datasources.ts b/packages/core/test/specs/undo_manager/datasources.ts new file mode 100644 index 000000000..9388ca726 --- /dev/null +++ b/packages/core/test/specs/undo_manager/datasources.ts @@ -0,0 +1,248 @@ +import { Component, DataSourceManager, Editor } from '../../../src'; +import { DataConditionType } from '../../../src/data_sources/model/conditional_variables/DataCondition'; +import { StringOperation } from '../../../src/data_sources/model/conditional_variables/operators/StringOperator'; +import { DataVariableType } from '../../../src/data_sources/model/DataVariable'; +import UndoManager from '../../../src/undo_manager'; +import { setupTestEditor } from '../../common'; + +describe('Undo Manager with Data Binding', () => { + let editor: Editor; + let um: UndoManager; + let wrapper: Component; + let dsm: DataSourceManager; + + const makeColorVar = () => ({ + type: DataVariableType, + path: 'ds1.rec1.color', + }); + const makeTitleVar = () => ({ + type: DataVariableType, + path: 'ds1.rec1.title', + }); + const makeContentVar = () => ({ + type: DataVariableType, + path: 'ds1.rec1.content', + }); + + beforeEach(() => { + ({ editor, um, dsm } = setupTestEditor({ withCanvas: true })); + wrapper = editor.getWrapper()!; + dsm.add({ + id: 'ds1', + records: [{ id: 'rec1', color: 'red', title: 'Initial Title', content: 'Initial Content' }], + }); + jest.useFakeTimers(); + }); + + afterEach(() => { + editor.destroy(); + }); + + describe('Initial State with Data Binding', () => { + it('should correctly initialize with a component having data-bound properties', () => { + const component = wrapper.append({ + style: { color: makeColorVar() }, + attributes: { title: makeTitleVar() }, + content: makeContentVar(), + })[0]; + + expect(um.getStackGroup()).toHaveLength(1); + um.undo(); + um.redo(); + expect(component.getStyle().color).toBe('red'); + expect(component.getAttributes().title).toBe('Initial Title'); + expect(component.get('content')).toBe('Initial Content'); + expect(um.getStackGroup()).toHaveLength(1); + }); + }); + + describe('Core Undo/Redo on Component Data Binding', () => { + describe('Styles', () => { + it('should undo and redo the assignment of a data value to a style', () => { + const component = wrapper.append({ + content: makeContentVar(), + style: { color: 'blue', 'font-size': '12px' }, + })[0]; + + jest.runAllTimers(); + um.clear(); + component.setStyle({ color: makeColorVar() }); + expect(component.getStyle().color).toBe('red'); + expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar()); + + um.undo(); + expect(component.getStyle().color).toBe('blue'); + expect(component.getStyle({ skipResolve: true }).color).toBe('blue'); + + um.redo(); + expect(component.getStyle().color).toBe('red'); + expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar()); + }); + + it('should handle binding with a data-condition value', () => { + const component = wrapper.append({ content: 'some content', style: { color: 'blue' } })[0]; + const conditionVar = { + type: DataConditionType, + condition: { left: makeTitleVar(), operator: StringOperation.contains, right: 'Initial' }, + ifTrue: 'green', + ifFalse: 'purple', + }; + + jest.runAllTimers(); + um.clear(); + + component.addStyle({ color: conditionVar }); + expect(component.getStyle().color).toBe('green'); + + um.undo(); + expect(component.getStyle().color).toBe('blue'); + + um.redo(); + expect(component.getStyle().color).toBe('green'); + }); + }); + + describe('Attributes', () => { + it('should undo and redo the assignment of a data value to an attribute', () => { + const component = wrapper.append({ attributes: { title: 'Static Title' } })[0]; + + jest.runAllTimers(); + um.clear(); + + component.setAttributes({ title: makeTitleVar() }); + expect(component.getAttributes().title).toBe('Initial Title'); + + um.undo(); + expect(component.getAttributes().title).toBe('Static Title'); + + um.redo(); + expect(component.getAttributes().title).toBe('Initial Title'); + }); + }); + + describe('Properties', () => { + it('should undo and redo the assignment of a data value to a property', () => { + const component = wrapper.append({ content: 'Static Content' })[0]; + + jest.runAllTimers(); + um.clear(); + + component.set({ content: makeContentVar() }); + expect(component.get('content')).toBe('Initial Content'); + + um.undo(); + expect(component.get('content')).toBe('Static Content'); + + um.redo(); + expect(component.get('content')).toBe('Initial Content'); + }); + }); + }); + + describe('Value Overwriting Scenarios', () => { + it('should correctly undo a static style that overwrites a data binding', () => { + const component = wrapper.append({ + style: { color: makeColorVar() }, + attributes: { title: 'Static Title' }, + })[0]; + + jest.runAllTimers(); + um.clear(); + + component.addStyle({ color: 'green' }); + expect(component.getStyle().color).toBe('green'); + + um.undo(); + expect(component.getStyle().color).toBe('red'); + expect(component.getAttributes().title).toBe('Static Title'); + }); + + it('should correctly undo a data binding that overwrites a static style', () => { + const component = wrapper.append({ style: { color: 'green' } })[0]; + + jest.runAllTimers(); + um.clear(); + + component.addStyle({ color: makeColorVar() }); + expect(component.getStyle().color).toBe('red'); + + um.undo(); + expect(component.getStyle().color).toBe('green'); + }); + }); + + describe('Listeners & Data Source Integrity', () => { + it('should maintain listeners after a binding is restored via undo', () => { + const component = wrapper.append({ style: { color: makeColorVar() } })[0]; + + jest.runAllTimers(); + um.clear(); + + component.addStyle({ color: 'green' }); + expect(component.getStyle().color).toBe('green'); + + um.undo(); + expect(component.getStyle().color).toBe('red'); + + dsm.get('ds1').getRecord('rec1')!.set('color', 'purple'); + expect(component.getStyle().color).toBe('purple'); + }); + + it('should handle undo when the data source has been removed', () => { + const component = wrapper.append({ style: { color: makeColorVar() } })[0]; + expect(component.getStyle().color).toBe('red'); + + jest.runAllTimers(); + um.clear(); + + dsm.remove('ds1'); + expect(component.getStyle().color).toBeUndefined(); + + um.undo(); + expect(dsm.get('ds1')).toBeTruthy(); + expect(component.getStyle().color).toBe('red'); + }); + }); + + describe('Serialization & Cloning', () => { + let component: any; + + beforeEach(() => { + component = wrapper.append({ + style: { color: makeColorVar() }, + attributes: { title: makeTitleVar() }, + content: makeContentVar(), + })[0]; + }); + + it('should correctly serialize data bindings in toJSON()', () => { + const json = component.toJSON(); + expect(json.attributes.title).toEqual(makeTitleVar()); + expect(json.__dynamicProps).toBeUndefined(); + }); + + it('should correctly clone data bindings', () => { + const clone = component.clone(); + expect(clone.getStyle('', { skipResolve: true }).color).toEqual(makeColorVar()); + expect(clone.getAttributes({ skipResolve: true }).title).toEqual(makeTitleVar()); + expect(clone.get('content', { skipResolve: true })).toEqual(makeContentVar()); + expect(clone.getStyle().color).toBe('red'); + }); + + it('should ensure a cloned component has an independent undo history', () => { + const clone = component.clone(); + wrapper.append(clone); + + jest.runAllTimers(); + um.clear(); + + component.addStyle({ color: 'blue' }); + expect(um.hasUndo()).toBe(true); + expect(clone.getStyle().color).toBe('red'); + + um.undo(); + expect(component.getStyle().color).toBe('red'); + expect(clone.getStyle().color).toBe('red'); + }); + }); +}); diff --git a/packages/core/test/specs/undo_manager/index.ts b/packages/core/test/specs/undo_manager/index.ts new file mode 100644 index 000000000..060fcafdb --- /dev/null +++ b/packages/core/test/specs/undo_manager/index.ts @@ -0,0 +1,286 @@ +import UndoManager from '../../../src/undo_manager'; +import Editor from '../../../src/editor'; +import { setupTestEditor } from '../../common'; + +describe('Undo Manager', () => { + let editor: Editor; + let um: UndoManager; + let wrapper: any; + + beforeEach(() => { + ({ editor, um } = setupTestEditor({ + withCanvas: true, + })); + wrapper = editor.getWrapper(); + um.clear(); + }); + + afterEach(() => { + editor.destroy(); + }); + + test('Initial state is correct', () => { + expect(um.hasUndo()).toBe(false); + expect(um.hasRedo()).toBe(false); + expect(um.getStack()).toHaveLength(0); + }); + + describe('Component changes', () => { + test('Add component', () => { + expect(wrapper.components()).toHaveLength(0); + wrapper.append('
'); + expect(wrapper.components()).toHaveLength(1); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(wrapper.components()).toHaveLength(0); + expect(um.hasRedo()).toBe(true); + + um.redo(); + expect(wrapper.components()).toHaveLength(1); + }); + + test('Remove component', () => { + const comp = wrapper.append('
')[0]; + expect(wrapper.components()).toHaveLength(1); + um.clear(); + + comp.remove(); + expect(wrapper.components()).toHaveLength(0); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(wrapper.components()).toHaveLength(1); + expect(um.hasRedo()).toBe(true); + + um.redo(); + expect(wrapper.components()).toHaveLength(0); + }); + + test('Modify component properties', () => { + const comp = wrapper.append({ tagName: 'div', content: 'test' })[0]; + um.clear(); + + comp.set('content', 'test2'); + expect(comp.get('content')).toBe('test2'); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(comp.get('content')).toBe('test'); + + um.redo(); + expect(comp.get('content')).toBe('test2'); + }); + + test('Modify component style (StyleManager)', () => { + const comp = wrapper.append('
')[0]; + + um.clear(); + comp.addStyle({ color: 'red' }); + expect(comp.getStyle().color).toBe('red'); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(comp.getStyle().color).toBeUndefined(); + + um.redo(); + expect(comp.getStyle().color).toBe('red'); + }); + + test('Move component', () => { + wrapper.append('
1
2
'); + const comp1 = wrapper.components().at(0); + const comp2 = wrapper.components().at(1); + + um.clear(); + + wrapper.append(comp1, { at: 2 }); + expect(wrapper.components().at(0)).toBe(comp2); + expect(wrapper.components().at(1)).toBe(comp1); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(wrapper.components().at(0)).toBe(comp1); + expect(wrapper.components().at(1)).toBe(comp2); + + um.redo(); + expect(wrapper.components().at(0)).toBe(comp2); + expect(wrapper.components().at(1)).toBe(comp1); + }); + + test('Grouped component additions are treated as one undo action', () => { + wrapper.append('
1
2
'); + + expect(wrapper.components()).toHaveLength(2); + expect(um.getStackGroup()).toHaveLength(1); + + um.undo(); + expect(wrapper.components()).toHaveLength(0); + }); + }); + + describe('CSS Rule changes', () => { + test('Add CSS Rule', () => { + editor.Css.addRules('.test { color: red; }'); + + expect(editor.Css.getRules('.test')).toHaveLength(1); + + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(editor.Css.getRules('.test')).toHaveLength(0); + + um.redo(); + expect(editor.Css.getRules('.test')).toHaveLength(1); + expect(editor.Css.getRule('.test')?.getStyle().color).toBe('red'); + }); + + test('Modify CSS Rule', () => { + const rule = editor.Css.addRules('.test { color: red; }')[0]; + + um.clear(); + + rule.setStyle({ color: 'blue' }); + expect(rule.getStyle().color).toBe('blue'); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(rule.getStyle().color).toBe('red'); + + um.redo(); + expect(rule.getStyle().color).toBe('blue'); + }); + + test('Remove CSS Rule', () => { + const rule = editor.Css.addRules('.test { color: red; }')[0]; + + um.clear(); + + editor.Css.remove(rule); + expect(editor.Css.getRules('.test')).toHaveLength(0); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(editor.Css.getRules('.test')).toHaveLength(1); + + um.redo(); + expect(editor.Css.getRules('.test')).toHaveLength(0); + }); + }); + + // TODO: add undo_manager to asset manager + describe.skip('Asset Manager changes', () => { + test('Add asset', () => { + const am = editor.Assets; + expect(am.getAll()).toHaveLength(0); + + um.clear(); + + am.add('path/to/img.jpg'); + expect(am.getAll()).toHaveLength(1); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(am.getAll()).toHaveLength(0); + + um.redo(); + expect(am.getAll()).toHaveLength(1); + expect(am.get('path/to/img.jpg')).toBeTruthy(); + }); + + test('Remove asset', () => { + const am = editor.Assets; + const asset = am.add('path/to/img.jpg'); + expect(am.getAll()).toHaveLength(1); + + um.clear(); + + am.remove(asset); + expect(am.getAll()).toHaveLength(0); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(am.getAll()).toHaveLength(1); + + um.redo(); + expect(am.getAll()).toHaveLength(0); + }); + }); + + // TODO: add undo_manager to editor + describe.skip('Editor states changes', () => { + test('Device change', () => { + editor.Devices.add({ id: 'tablet', name: 'Tablet', width: 'auto' }); + + um.clear(); + + editor.setDevice('Tablet'); + expect(editor.getDevice()).toBe('Tablet'); + expect(um.hasUndo()).toBe(true); + + um.undo(); + // Default device is an empty string + expect(editor.getDevice()).toBe(''); + + um.redo(); + expect(editor.getDevice()).toBe('Tablet'); + }); + + test('Panel visibility change', () => { + const panel = editor.Panels.getPanel('options')!; + panel.set('visible', true); + + um.clear(); + + panel.set('visible', false); + expect(panel.get('visible')).toBe(false); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(panel.get('visible')).toBe(true); + + um.redo(); + expect(panel.get('visible')).toBe(false); + }); + }); + + describe('Selection tracking', () => { + test('Change selection', (done) => { + const comp1 = wrapper.append('
1
')[0]; + const comp2 = wrapper.append('
2
')[0]; + + um.clear(); + editor.select(comp1); + expect(editor.getSelected()).toBe(comp1); + + setTimeout(() => { + editor.select(comp2); + expect(editor.getSelected()).toBe(comp2); + expect(um.hasUndo()).toBe(true); + um.undo(); + expect(editor.getSelected()).toBe(comp1); + um.redo(); + expect(editor.getSelected()).toBe(comp2); + done(); + }); + }); + }); + + describe('Operations with `noUndo`', () => { + test('Skipping undo for component modification', () => { + const comp = wrapper.append('
')[0]; + + um.clear(); + + comp.set('content', 'no undo content', { noUndo: true }); + expect(um.hasUndo()).toBe(false); + + wrapper.append('
undo this
'); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(wrapper.components()).toHaveLength(1); + expect(wrapper.components().at(0).get('content')).toBe('no undo content'); + }); + }); +});