Browse Source

Data Binding Fixes (#6565)

* Fix saving dynamic values for cssRules

* Allow using collectionStateMap for styleable models

* Fix bug with undo manager

* refactor resolverWatcher

* Allow CssRules to use collection variable

* Fix undoManager issue for binding data to a component

* update returned value for adding props

* Revert a change to fix tests

* move test files

* Add tests for undomanager with datasources

* update clone and toJSON for cssRule

* Refactor Model data resolver

* update test setup function

* Refactor datasources logic to styleable model

* Add clone and update toJSON in styleableModel

* Refactor CssRule to use styleableModel methods

* Add undomanager to datasources

* refactor component class to use styleableModel methods

* update unit tests for undo manager

* Refactor data resolver watcher

* Fix undoManager in test enviroment

* Remove destroy test editor

* Update Data resolver watchers

* Remove setTimeout from undo manager unit tests

* Fix Selection tracking tests

* Fix missing id in `component.toJSON()`

* Fix styles as string for cssRules

* Fix CssRule type

* Add string style support for ModelResolver.getProps()

* Cleanup ( rename dynamic to data resolver )

* Use fake timers in undo manager tests

* Remove checking duplicated object in undomanager registry

* Fix typescript checks

* Fix lint
pull/6604/head
mohamed yahia 5 months ago
committed by GitHub
parent
commit
45659ece43
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 23
      packages/core/src/css_composer/model/CssRule.ts
  2. 1
      packages/core/src/data_sources/index.ts
  3. 4
      packages/core/src/data_sources/model/conditional_variables/DataCondition.ts
  4. 7
      packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts
  5. 2
      packages/core/src/data_sources/utils.ts
  6. 125
      packages/core/src/dom_components/model/Component.ts
  7. 211
      packages/core/src/dom_components/model/ModelDataResolverWatchers.ts
  8. 37
      packages/core/src/dom_components/model/ModelResolverWatcher.ts
  9. 33
      packages/core/src/dom_components/model/SymbolUtils.ts
  10. 4
      packages/core/src/dom_components/model/types.ts
  11. 135
      packages/core/src/domain_abstract/model/StyleableModel.ts
  12. 4
      packages/core/src/navigator/index.ts
  13. 2
      packages/core/src/trait_manager/model/Trait.ts
  14. 24
      packages/core/test/common.ts
  15. 0
      packages/core/test/specs/data_sources/dynamic_values/styles.ts
  16. 0
      packages/core/test/specs/data_sources/dynamic_values/traits.ts
  17. 5
      packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts
  18. 4
      packages/core/test/specs/dom_components/index.ts
  19. 248
      packages/core/test/specs/undo_manager/datasources.ts
  20. 286
      packages/core/test/specs/undo_manager/index.ts

23
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<CssRuleProperties> {
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<CssRuleProperties> {
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<CssRuleProperties> {
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<CssRuleProperties> {
if (isEmpty(obj.style)) delete obj.style;
}
return { ...obj, style: this.dataResolverWatchers.getStylesDefsOrValues(obj.style) };
return obj;
}
/**

1
packages/core/src/data_sources/index.ts

@ -168,5 +168,6 @@ export default class DataSourceManager extends ItemManagerModule<ModuleConfig, D
postLoad() {
const { em, all } = this;
em.listenTo(all, collectionEvents, (m, c, o) => em.changesUp(o || c));
this.em.UndoManager.add(all);
}
}

4
packages/core/src/data_sources/model/conditional_variables/DataCondition.ts

@ -103,14 +103,14 @@ export class DataCondition extends Model<DataConditionProps> {
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;
}

7
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);
}

2
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;
}

125
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<Component> {}
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<ComponentProperties> {
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<ComponentProperties> {
}
}
set<A extends string>(
keyOrAttributes: A | Partial<ComponentProperties>,
valueOrOptions?: ComponentProperties[A] | ComponentSetOptions,
optionsOrUndefined?: ComponentSetOptions,
): this {
let attributes: Partial<ComponentProperties>;
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<ComponentProperties> {
* @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<ComponentProperties> {
* 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<ComponentProperties> {
*/
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<ComponentProperties> {
* 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<ComponentProperties> {
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<ComponentProperties> {
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<ComponentProperties> {
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<ComponentProperties> {
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<ComponentProperties> {
* @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<ComponentProperties> {
* @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<ComponentProperties> {
(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 },
);
}
}

211
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<T extends StyleableModelProperties> {
private propertyWatcher: ModelResolverWatcher<T>;
private attributeWatcher: ModelResolverWatcher<T>;
private styleWatcher: ModelResolverWatcher<T>;
constructor(
private model: StyleableModel | undefined,
options: ModelResolverWatcherOptions,
private model: WatchableModel<T>,
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);
bindModel(model: WatchableModel<T>) {
this.model = model;
this.watchers.forEach((watcher) => watcher.bindModel(model));
this.updateSymbolOverride();
}
private onAttributeUpdate(component: StyleableModel | undefined, key: string, value: any) {
(component as any)?.addAttributes({ [key]: value }, updateFromWatcher);
addProps(props: ObjectAny, options: DataWatchersOptions = {}) {
const dataValues = props[keyDataValues] ?? {};
const filteredProps = this.filterProps(props);
const evaluatedProps = {
...props,
...this.propertyWatcher.addDataValues({ ...filteredProps, ...dataValues.props }, options),
};
if (this.shouldProcessProp('attributes', props, dataValues)) {
evaluatedProps.attributes = this.processAttributes(props, dataValues, options);
}
private onStyleUpdate(component: StyleableModel | undefined, key: string, value: any) {
component?.addStyle({ [key]: value }, { ...updateFromWatcher, partial: true, avoidStore: true });
if (this.shouldProcessProp('style', props, dataValues)) {
evaluatedProps.style = this.processStyles(props, dataValues, options);
}
bindModel(model: StyleableModel) {
this.model = model;
this.propertyWatcher.bindModel(model);
this.attributeWatcher.bindModel(model);
this.styleWatcher.bindModel(model);
const skipOverrideUpdates = options.skipWatcherUpdates || options.fromDataSource;
if (!skipOverrideUpdates) {
this.updateSymbolOverride();
evaluatedProps[keyDataValues] = {
props: this.propertyWatcher.getAllDataResolvers(),
style: this.styleWatcher.getAllDataResolvers(),
attributes: this.attributeWatcher.getAllDataResolvers(),
};
}
addProps(props: ObjectAny, options: DynamicWatchersOptions = {}) {
const excludedFromEvaluation = ['components', 'dataResolver'];
return evaluatedProps;
}
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]],
),
);
getProps(data: ObjectAny): ObjectAny {
const resolvedProps = this.getValueOrResolver('props', data);
const result = {
...resolvedProps,
};
delete result[keyDataValues];
if (props.attributes) {
const evaluatedAttributes = this.attributeWatcher.setDynamicValues(props.attributes, options);
evaluatedProps['attributes'] = evaluatedAttributes;
if (!isEmpty(data.attributes)) {
result.attributes = this.getValueOrResolver('attributes', data.attributes);
}
const skipOverrideUpdates = options.skipWatcherUpdates || options.fromDataSource;
if (!skipOverrideUpdates) {
this.updateSymbolOverride();
if (isObject(data.style) && !isEmpty(data.style)) {
result.style = this.getValueOrResolver('styles', data.style);
}
return evaluatedProps;
return result;
}
setStyles(styles: ObjectAny, options: DynamicWatchersOptions = {}) {
return this.styleWatcher.setDynamicValues(styles, options);
/**
* 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<T>, key: string, value: any) => {
model?.set(key, value, updateFromWatcher);
};
private onAttributeUpdate = (model: WatchableModel<T>, key: string, value: any) => {
if (!this.isComponent(model)) return;
model?.addAttributes({ [key]: value }, updateFromWatcher);
};
private onStyleUpdate = (model: WatchableModel<T>, 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);
}
}

37
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<T extends ObjectHash> = StyleableModel<T> | undefined;
export type UpdateFn<T extends ObjectHash> = (component: WatchableModel<T>, key: string, value: any) => void;
export class ModelResolverWatcher {
export class ModelResolverWatcher<T extends ObjectHash> {
private em: EditorModel;
private resolverListeners: Record<string, DataResolverListener> = {};
constructor(
private model: NewType,
private updateFn: UpdateFn,
private model: WatchableModel<T>,
private updateFn: UpdateFn<T>,
options: ModelResolverWatcherOptions,
) {
this.em = options.em;
}
bindModel(model: StyleableModel) {
bindModel(model: WatchableModel<T>) {
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);

33
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<string, any>, attrs: Record<string, any>): void => {
const keysToDelete = ['status', 'open', keySymbols, keySymbol, keySymbolOvrd, 'attributes'];
const cleanChangedProperties = (changed: Record<string, any>): 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<string, any>, component: Component): void => {
const filterPropertiesForPropagation = (props: Record<string, any>, 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<string, any>, prop: string, component: Component): boolean => {

4
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;

135
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<string, string | string[] | DataVariableProps | DataConditionProps>;
export type StyleProps = Record<string, string | string[] | DataResolverProps>;
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<T extends ObjectHash = any> extends Model<T, UpdateStyleOptions> {
export interface StyleableModelProperties extends ObjectHash {
selectors?: any;
style?: StyleProps | string;
}
export interface GetStyleOpts {
skipResolve?: boolean;
}
type WithDataResolvers<T> = {
[P in keyof T]?: T[P] | DataResolverProps;
};
export default class StyleableModel<T extends StyleableModelProperties = any> extends Model<T, UpdateStyleOptions> {
em?: EditorModel;
views: StyleableView[] = [];
dataResolverWatchers: ModelDataResolverWatchers;
dataResolverWatchers: ModelDataResolverWatchers<T>;
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<T>(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<A extends _StringKey<T>>(attributeName: A, opts?: { skipResolve?: boolean }): T[A] | undefined {
if (opts?.skipResolve) return this.dataResolverWatchers.getValueOrResolver('props')[attributeName];
return super.get(attributeName);
}
set<A extends keyof T>(
keyOrAttributes: A,
valueOrOptions?: T[A] | DataResolverProps,
optionsOrUndefined?: UpdateStyleOptions,
): this;
set(keyOrAttributes: WithDataResolvers<T>, options?: UpdateStyleOptions): this;
set<A extends keyof T>(
keyOrAttributes: WithDataResolvers<T>,
valueOrOptions?: T[A] | DataResolverProps | UpdateStyleOptions,
optionsOrUndefined?: UpdateStyleOptions,
): this {
const defaultOptions: UpdateStyleOptions = {
skipWatcherUpdates: false,
fromDataSource: false,
};
let attributes: WithDataResolvers<T>;
let options: UpdateStyleOptions & { dataResolverWatchers?: ModelDataResolverWatchers<T> };
if (typeof keyOrAttributes === 'object') {
attributes = keyOrAttributes;
options = (valueOrOptions as UpdateStyleOptions) || defaultOptions;
} else if (typeof keyOrAttributes === 'string') {
attributes = { [keyOrAttributes]: valueOrOptions } as Partial<T>;
options = optionsOrUndefined || defaultOptions;
} else {
attributes = {};
options = defaultOptions;
}
this.dataResolverWatchers = this.dataResolverWatchers ?? options.dataResolverWatchers;
const evaluatedValues = this.dataResolverWatchers.addProps(attributes, options) as Partial<T>;
return super.set(evaluatedValues, options);
}
/**
@ -69,15 +125,31 @@ export default class StyleableModel<T extends ObjectHash = any> extends Model<T,
* Get style object
* @return {Object}
*/
getStyle(prop?: string | ObjectAny, opts: { skipResolve?: boolean } = {}): StyleProps {
const style: ObjectAny = { ...(this.get('style') || {}) };
delete style.__p;
getStyle(opts?: GetStyleOpts): StyleProps;
getStyle(prop: '' | undefined, opts?: GetStyleOpts): StyleProps;
getStyle<K extends keyof StyleProps>(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<T extends ObjectHash = any> extends Model<T,
return;
}
});
newStyle = this.dataResolverWatchers.setStyles(newStyle, opts);
this.set('style', newStyle, opts as any);
const resolvedProps = this.dataResolverWatchers.addProps({ style: newStyle }, opts) as Partial<T>;
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<T extends ObjectHash = any> extends Model<T,
*/
styleToString(opts: ToCssOptions = {}) {
const result: string[] = [];
const style = opts.style || this.getStyle(opts);
const style = opts.style || (this.getStyle(opts as any) as StyleProps);
const imp = opts.important;
for (let prop in style) {
@ -229,4 +301,27 @@ export default class StyleableModel<T extends ObjectHash = any> extends Model<T,
// @ts-ignore
return this.selectorsToString ? this.selectorsToString(opts) : this.getSelectors().getFullString();
}
onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) {
this.collectionsStateMap = collectionsStateMap;
this.dataResolverWatchers.onCollectionsStateMapUpdate();
}
clone(attributes?: Partial<T>, 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<T>) {
if (opts?.fromUndo) return { ...super.toJSON(opts) };
const mergedProps = { ...this.attributes, ...attributes };
const obj = this.dataResolverWatchers.getProps(mergedProps);
return obj;
}
}

4
packages/core/src/navigator/index.ts

@ -184,7 +184,7 @@ export default class LayerManager extends Module<LayerManagerConfig> {
*/
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<LayerManagerConfig> {
* @returns {Boolean}
*/
isVisible(component: Component): boolean {
return !isStyleHidden(component.getStyle(styleOpts));
return !isStyleHidden(component.getStyle(styleOpts as any));
}
/**

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

@ -275,7 +275,7 @@ export default class Trait extends Model<TraitProperties> {
});
} 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];
}

24
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<EditorConfig> }) {
document.body.innerHTML = '<div id="fixtures"></div> <div id="canvas-wrp"></div> <div id="editor"></div>';
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) {

0
packages/core/test/specs/data_sources/model/StyleDataVariable.ts → packages/core/test/specs/data_sources/dynamic_values/styles.ts

0
packages/core/test/specs/data_sources/model/TraitDataVariable.ts → packages/core/test/specs/data_sources/dynamic_values/traits.ts

5
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');
firstGrandchild.set('name', {
// @ts-ignore
firstGrandchild.set('name', {
type: DataVariableType,
path: 'my_data_source_id.user2.user',
});

4
packages/core/test/specs/dom_components/index.ts

@ -264,9 +264,9 @@ describe('DOM Components', () => {
#${id} { background-color: red }
</style>`) 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(() => {

248
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');
});
});
});

286
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('<div></div>');
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('<div></div>')[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('<div></div>')[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('<div>1</div><div>2</div>');
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('<div>1</div><div>2</div>');
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('<div>1</div>')[0];
const comp2 = wrapper.append('<div>2</div>')[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('<div></div>')[0];
um.clear();
comp.set('content', 'no undo content', { noUndo: true });
expect(um.hasUndo()).toBe(false);
wrapper.append('<div>undo this</div>');
expect(um.hasUndo()).toBe(true);
um.undo();
expect(wrapper.components()).toHaveLength(1);
expect(wrapper.components().at(0).get('content')).toBe('no undo content');
});
});
});
Loading…
Cancel
Save