diff --git a/packages/core/src/data_sources/model/DataVariableListenerManager.ts b/packages/core/src/data_sources/model/DataVariableListenerManager.ts index d10fbd43d..ccd119aa9 100644 --- a/packages/core/src/data_sources/model/DataVariableListenerManager.ts +++ b/packages/core/src/data_sources/model/DataVariableListenerManager.ts @@ -3,13 +3,11 @@ import { stringToPath } from '../../utils/mixins'; import { Model } from '../../common'; import EditorModel from '../../editor/model/Editor'; import DataVariable, { DataVariableType } from './DataVariable'; -import ComponentView from '../../dom_components/view/ComponentView'; import { DynamicValue } from '../types'; import { DataCondition, ConditionalVariableType } from './conditional_variables/DataCondition'; import ComponentDataVariable from './ComponentDataVariable'; export interface DynamicVariableListenerManagerOptions { - model: Model | ComponentView; em: EditorModel; dataVariable: DynamicValue; updateValueFromDataVariable: (value: any) => void; @@ -18,13 +16,12 @@ export interface DynamicVariableListenerManagerOptions { export default class DynamicVariableListenerManager { private dataListeners: DataVariableListener[] = []; private em: EditorModel; - private model: Model | ComponentView; - private dynamicVariable: DynamicValue; + dynamicVariable: DynamicValue; private updateValueFromDynamicVariable: (value: any) => void; + private model = new Model(); constructor(options: DynamicVariableListenerManagerOptions) { this.em = options.em; - this.model = options.model; this.dynamicVariable = options.dataVariable; this.updateValueFromDynamicVariable = options.updateValueFromDataVariable; @@ -37,7 +34,7 @@ export default class DynamicVariableListenerManager { }; listenToDynamicVariable() { - const { em, dynamicVariable, model } = this; + const { em, dynamicVariable } = this; this.removeListeners(); // @ts-ignore @@ -51,7 +48,7 @@ export default class DynamicVariableListenerManager { dataListeners = this.listenToConditionalVariable(dynamicVariable as DataCondition, em); break; } - dataListeners.forEach((ls) => model.listenTo(ls.obj, ls.event, this.onChange)); + dataListeners.forEach((ls) => this.model.listenTo(ls.obj, ls.event, this.onChange)); this.dataListeners = dataListeners; } @@ -81,8 +78,7 @@ export default class DynamicVariableListenerManager { } private removeListeners() { - const { model } = this; - this.dataListeners.forEach((ls) => model.stopListening(ls.obj, ls.event, this.onChange)); + this.dataListeners.forEach((ls) => this.model.stopListening(ls.obj, ls.event, this.onChange)); this.dataListeners = []; } 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 d5a481645..6208a318f 100644 --- a/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts +++ b/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts @@ -94,7 +94,6 @@ export class DataCondition extends Model { dataVariables.forEach((variable) => { const variableInstance = new DataVariable(variable, { em: this.em }); const listener = new DynamicVariableListenerManager({ - model: this as any, em: this.em!, dataVariable: variableInstance, updateValueFromDataVariable: (() => { diff --git a/packages/core/src/data_sources/model/utils.ts b/packages/core/src/data_sources/model/utils.ts index fe835cb68..85451ca7f 100644 --- a/packages/core/src/data_sources/model/utils.ts +++ b/packages/core/src/data_sources/model/utils.ts @@ -4,7 +4,7 @@ import { ConditionalVariableType, DataCondition } from './conditional_variables/ import DataVariable, { DataVariableType } from './DataVariable'; export function isDynamicValueDefinition(value: any): value is DynamicValueDefinition { - return typeof value === 'object' && [DataVariableType, ConditionalVariableType].includes(value.type); + return typeof value === 'object' && [DataVariableType, ConditionalVariableType].includes(value?.type); } export function isDynamicValue(value: any): value is DynamicValue { @@ -22,3 +22,29 @@ export function isDataCondition(variable: any) { export function evaluateVariable(variable: any, em: EditorModel) { return isDataVariable(variable) ? new DataVariable(variable, { em }).getDataValue() : variable; } + +export function getDynamicValueInstance(valueDefinition: DynamicValueDefinition, em: EditorModel): DynamicValue { + const dynamicType = valueDefinition.type; + let dynamicVariable: DynamicValue; + + switch (dynamicType) { + case DataVariableType: + dynamicVariable = new DataVariable(valueDefinition, { em: em }); + break; + case ConditionalVariableType: { + const { condition, ifTrue, ifFalse } = valueDefinition; + dynamicVariable = new DataCondition(condition, ifTrue, ifFalse, { em: em }); + break; + } + default: + throw new Error(`Unsupported dynamic type: ${dynamicType}`); + } + + return dynamicVariable; +} + +export function evaluateDynamicValueDefinition(valueDefinition: DynamicValueDefinition, em: EditorModel) { + const dynamicVariable = getDynamicValueInstance(valueDefinition, em); + + return { variable: dynamicVariable, value: dynamicVariable.getDataValue() }; +} diff --git a/packages/core/src/data_sources/view/ComponentDataVariableView.ts b/packages/core/src/data_sources/view/ComponentDataVariableView.ts index f1bba3708..1d53474d3 100644 --- a/packages/core/src/data_sources/view/ComponentDataVariableView.ts +++ b/packages/core/src/data_sources/view/ComponentDataVariableView.ts @@ -8,7 +8,6 @@ export default class ComponentDataVariableView extends ComponentView this.postRender(), diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index fe93d6931..caa45bd98 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/packages/core/src/dom_components/model/Component.ts @@ -13,7 +13,7 @@ import { } from 'underscore'; import { shallowDiff, capitalize, isEmptyObj, isObject, toLowerCase } from '../../utils/mixins'; import StyleableModel, { StyleProps, UpdateStyleOptions } from '../../domain_abstract/model/StyleableModel'; -import { Model } from 'backbone'; +import { Model, ModelDestroyOptions } from 'backbone'; import Components from './Components'; import Selector from '../../selector_manager/model/Selector'; import Selectors from '../../selector_manager/model/Selectors'; @@ -51,14 +51,17 @@ import { updateSymbolComps, updateSymbolProps, } from './SymbolUtils'; -import TraitDataVariable from '../../data_sources/model/TraitDataVariable'; -import { ConditionalVariableType, DataCondition } from '../../data_sources/model/conditional_variables/DataCondition'; -import { isDynamicValue, isDynamicValueDefinition } from '../../data_sources/model/utils'; +import { ComponentDynamicValueWatcher } from './ComponentDynamicValueWatcher'; +import { DynamicValueWatcher } from './DynamicValueWatcher'; import { DynamicValueDefinition } from '../../data_sources/types'; export interface IComponent extends ExtractMethods {} - -export interface SetAttrOptions extends SetOptions, UpdateStyleOptions {} +export interface DynamicWatchersOptions { + skipWatcherUpdates?: boolean; + fromDataSource?: boolean; +} +export interface SetAttrOptions extends SetOptions, UpdateStyleOptions, DynamicWatchersOptions {} +export interface ComponentSetOptions extends SetOptions, DynamicWatchersOptions {} const escapeRegExp = (str: string) => { return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); @@ -72,7 +75,6 @@ export const keySymbol = '__symbol'; export const keySymbolOvrd = '__symbol_ovrd'; export const keyUpdate = ComponentsEvents.update; export const keyUpdateInside = ComponentsEvents.updateInside; -export const dynamicAttrKey = 'attributes-dynamic-value'; /** * The Component object represents a single node of our template structure, so when you update its properties the changes are @@ -260,9 +262,13 @@ export default class Component extends StyleableModel { * @private * @ts-ignore */ collection!: Components; + componentDVListener: ComponentDynamicValueWatcher; constructor(props: ComponentProperties = {}, opt: ComponentOptions) { super(props, opt); + this.componentDVListener = new ComponentDynamicValueWatcher(this, opt.em); + this.componentDVListener.addProps(props); + bindAll(this, '__upSymbProps', '__upSymbCls', '__upSymbComps'); const em = opt.em; @@ -289,7 +295,7 @@ export default class Component extends StyleableModel { this.opt = opt; this.em = em!; this.config = opt.config || {}; - this.set('attributes', { + this.setAttributes({ ...(result(this, 'defaults').attributes || {}), ...(this.get('attributes') || {}), }); @@ -331,6 +337,36 @@ export default class Component extends StyleableModel { } } + set( + keyOrAttributes: A | Partial, + valueOrOptions?: ComponentProperties[A] | ComponentSetOptions, + optionsOrUndefined?: ComponentSetOptions, + ): this { + let attributes: Partial; + let options: ComponentSetOptions = { 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; + } + + // @ts-ignore + const em = this.em || options.em; + const evaluatedAttributes = DynamicValueWatcher.getStaticValues(attributes, em); + + const shouldSkipWatcherUpdates = options.skipWatcherUpdates || options.fromDataSource; + if (!shouldSkipWatcherUpdates) { + this.componentDVListener?.addProps(attributes); + } + + return super.set(evaluatedAttributes, options); + } + __postAdd(opts: { recursive?: boolean } = {}) { const { em } = this; const um = em?.UndoManager; @@ -648,8 +684,16 @@ export default class Component extends StyleableModel { * @example * component.setAttributes({ id: 'test', 'data-key': 'value' }); */ - setAttributes(attrs: ObjectAny, opts: SetAttrOptions = {}) { - this.set('attributes', { ...attrs }, opts); + setAttributes(attrs: ObjectAny, opts: SetAttrOptions = { skipWatcherUpdates: false, fromDataSource: false }) { + // @ts-ignore + const em = this.em || opts.em; + const evaluatedAttributes = DynamicValueWatcher.getStaticValues(attrs, em); + const shouldSkipWatcherUpdates = opts.skipWatcherUpdates || opts.fromDataSource; + if (!shouldSkipWatcherUpdates) { + this.componentDVListener.setAttributes(attrs); + } + this.set('attributes', { ...evaluatedAttributes }, opts); + return this; } @@ -662,9 +706,11 @@ export default class Component extends StyleableModel { * component.addAttributes({ 'data-key': 'value' }); */ addAttributes(attrs: ObjectAny, opts: SetAttrOptions = {}) { + const dynamicAttributes = this.componentDVListener.getDynamicAttributesDefs(); return this.setAttributes( { ...this.getAttributes({ noClass: true }), + ...dynamicAttributes, ...attrs, }, opts, @@ -682,6 +728,8 @@ export default class Component extends StyleableModel { */ removeAttributes(attrs: string | string[] = [], opts: SetOptions = {}) { const attrArr = Array.isArray(attrs) ? attrs : [attrs]; + this.componentDVListener.removeAttributes(attrArr); + const compAttr = this.getAttributes(); attrArr.map((i) => delete compAttr[i]); return this.setAttributes(compAttr, opts); @@ -773,29 +821,6 @@ export default class Component extends StyleableModel { } } - const attrDataVariable = this.get(dynamicAttrKey) as { - [key: string]: TraitDataVariable | DynamicValueDefinition; - }; - if (attrDataVariable) { - Object.entries(attrDataVariable).forEach(([key, value]) => { - let dataVariable: TraitDataVariable | DataCondition; - if (isDynamicValue(value)) { - dataVariable = value; - } else if (isDynamicValueDefinition(value)) { - const type = value.type; - - if (type === ConditionalVariableType) { - const { condition, ifTrue, ifFalse } = value; - dataVariable = new DataCondition(condition, ifTrue, ifFalse, { em }); - } else { - dataVariable = new TraitDataVariable(value, { em }); - } - } - - attributes[key] = dataVariable!.getDataValue(); - }); - } - // Check if we need an ID on the component if (!has(attributes, 'id')) { let addId = false; @@ -934,7 +959,6 @@ export default class Component extends StyleableModel { this.off(event, this.initTraits); this.__loadTraits(); const attrs = { ...this.get('attributes') }; - const traitDynamicValueAttr: ObjectAny = {}; const traits = this.traits; traits.each((trait) => { const name = trait.getName(); @@ -945,13 +969,13 @@ export default class Component extends StyleableModel { } else { if (name && value) attrs[name] = value; } - - if (trait.dynamicVariable) { - traitDynamicValueAttr[name] = trait.dynamicVariable; - } }); - traits.length && this.set('attributes', attrs); - Object.keys(traitDynamicValueAttr).length && this.set(dynamicAttrKey, traitDynamicValueAttr); + const dynamicAttributes = this.componentDVListener.getDynamicAttributesDefs(); + traits.length && + this.setAttributes({ + ...attrs, + ...dynamicAttributes, + }); this.on(event, this.initTraits); changed && em && em.trigger('component:toggled'); return this; @@ -1147,7 +1171,6 @@ export default class Component extends StyleableModel { traits.setTarget(this); if (traitsI.length) { - traitsI.forEach((tr) => tr.attributes && delete tr.attributes.value); traits.add(traitsI); } @@ -1294,12 +1317,15 @@ export default class Component extends StyleableModel { * @ts-ignore */ clone(opt: { symbol?: boolean; symbolInv?: boolean } = {}): this { const em = this.em; - const attr = { ...this.attributes }; + const attr = { + ...this.componentDVListener.getPropsDefsOrValues(this.attributes), + }; const opts = { ...this.opt }; const id = this.getId(); const cssc = em?.Css; - attr.attributes = { ...attr.attributes }; - delete attr.attributes.id; + attr.attributes = { + ...(attr.attributes ? this.componentDVListener.getAttributesDefsOrValues(attr.attributes) : undefined), + }; // @ts-ignore attr.components = []; // @ts-ignore @@ -1554,8 +1580,10 @@ export default class Component extends StyleableModel { * @private */ toJSON(opts: ObjectAny = {}): ComponentDefinition { - const obj = Model.prototype.toJSON.call(this, opts); - obj.attributes = this.getAttributes(); + let obj = Model.prototype.toJSON.call(this, opts); + obj = { ...obj, ...this.componentDVListener.getDynamicPropsDefs() }; + obj.attributes = this.componentDVListener.getAttributesDefsOrValues(this.getAttributes()); + delete obj.componentDVListener; delete obj.attributes.class; delete obj.toolbar; delete obj.traits; @@ -1789,6 +1817,11 @@ export default class Component extends StyleableModel { return this; } + destroy(options?: ModelDestroyOptions | undefined): false | JQueryXHR { + this.componentDVListener.destroy(); + return super.destroy(options); + } + /** * Move the component to another destination component * @param {Component} component Destination component (so the current one will be appended as a child) diff --git a/packages/core/src/dom_components/model/ComponentDynamicValueWatcher.ts b/packages/core/src/dom_components/model/ComponentDynamicValueWatcher.ts new file mode 100644 index 000000000..911417833 --- /dev/null +++ b/packages/core/src/dom_components/model/ComponentDynamicValueWatcher.ts @@ -0,0 +1,66 @@ +import { ObjectAny } from '../../common'; +import EditorModel from '../../editor/model/Editor'; +import Component from './Component'; +import { DynamicValueWatcher } from './DynamicValueWatcher'; + +export class ComponentDynamicValueWatcher { + private propertyWatcher: DynamicValueWatcher; + private attributeWatcher: DynamicValueWatcher; + + constructor( + private component: Component, + em: EditorModel, + ) { + this.propertyWatcher = new DynamicValueWatcher(this.createPropertyUpdater(), em); + this.attributeWatcher = new DynamicValueWatcher(this.createAttributeUpdater(), em); + } + + private createPropertyUpdater() { + return (key: string, value: any) => { + this.component.set(key, value, { fromDataSource: true, avoidStore: true }); + }; + } + + private createAttributeUpdater() { + return (key: string, value: any) => { + this.component.addAttributes({ [key]: value }, { fromDataSource: true, avoidStore: true }); + }; + } + + addProps(props: ObjectAny) { + this.propertyWatcher.addDynamicValues(props); + } + + addAttributes(attributes: ObjectAny) { + this.attributeWatcher.addDynamicValues(attributes); + } + + setAttributes(attributes: ObjectAny) { + this.attributeWatcher.setDynamicValues(attributes); + } + + removeAttributes(attributes: string[]) { + this.attributeWatcher.removeListeners(attributes); + } + + getDynamicPropsDefs() { + return this.propertyWatcher.getAllSerializableValues(); + } + + getDynamicAttributesDefs() { + return this.attributeWatcher.getAllSerializableValues(); + } + + getAttributesDefsOrValues(attributes: ObjectAny) { + return this.attributeWatcher.getSerializableValues(attributes); + } + + getPropsDefsOrValues(props: ObjectAny) { + return this.propertyWatcher.getSerializableValues(props); + } + + destroy() { + this.propertyWatcher.removeListeners(); + this.attributeWatcher.removeListeners(); + } +} diff --git a/packages/core/src/dom_components/model/DynamicValueWatcher.ts b/packages/core/src/dom_components/model/DynamicValueWatcher.ts new file mode 100644 index 000000000..88af44dad --- /dev/null +++ b/packages/core/src/dom_components/model/DynamicValueWatcher.ts @@ -0,0 +1,117 @@ +import { ObjectAny } from '../../common'; +import DynamicVariableListenerManager from '../../data_sources/model/DataVariableListenerManager'; +import { evaluateDynamicValueDefinition, isDynamicValueDefinition } from '../../data_sources/model/utils'; +import { DynamicValue } from '../../data_sources/types'; +import EditorModel from '../../editor/model/Editor'; + +export class DynamicValueWatcher { + dynamicVariableListeners: { [key: string]: DynamicVariableListenerManager } = {}; + constructor( + private updateFn: (key: string, value: any) => void, + private em: EditorModel, + ) {} + + static getStaticValues(values: ObjectAny | undefined, em: EditorModel): ObjectAny { + if (!values) return {}; + const evaluatedValues: ObjectAny = { ...values }; + const propsKeys = Object.keys(values); + + for (const key of propsKeys) { + const valueDefinition = values[key]; + if (!isDynamicValueDefinition(valueDefinition)) continue; + + const { value } = evaluateDynamicValueDefinition(valueDefinition, em); + evaluatedValues[key] = value; + } + + return evaluatedValues; + } + + static areStaticValues(values: ObjectAny | undefined) { + if (!values) return true; + return Object.keys(values).every((key) => { + return !isDynamicValueDefinition(values[key]); + }); + } + + setDynamicValues(values: ObjectAny | undefined) { + this.removeListeners(); + return this.addDynamicValues(values); + } + + addDynamicValues(values: ObjectAny | undefined) { + if (!values) return {}; + this.removeListeners(Object.keys(values)); + const dynamicProps = this.getDynamicValues(values); + const propsKeys = Object.keys(dynamicProps); + for (let index = 0; index < propsKeys.length; index++) { + const key = propsKeys[index]; + this.dynamicVariableListeners[key] = new DynamicVariableListenerManager({ + em: this.em, + dataVariable: dynamicProps[key], + updateValueFromDataVariable: (value: any) => { + this.updateFn.bind(this)(key, value); + }, + }); + } + + return dynamicProps; + } + + private getDynamicValues(values: ObjectAny) { + const dynamicValues: { + [key: string]: DynamicValue; + } = {}; + const propsKeys = Object.keys(values); + for (let index = 0; index < propsKeys.length; index++) { + const key = propsKeys[index]; + if (!isDynamicValueDefinition(values[key])) { + continue; + } + const { variable } = evaluateDynamicValueDefinition(values[key], this.em); + dynamicValues[key] = variable; + } + + return dynamicValues; + } + + /** + * removes listeners to stop watching for changes, + * if keys argument is omitted, remove all listeners + * @argument keys + */ + removeListeners(keys?: string[]) { + const propsKeys = keys ? keys : Object.keys(this.dynamicVariableListeners); + propsKeys.forEach((key) => { + if (this.dynamicVariableListeners[key]) { + this.dynamicVariableListeners[key].destroy(); + delete this.dynamicVariableListeners[key]; + } + }); + } + + getSerializableValues(values: ObjectAny | undefined) { + if (!values) return {}; + const serializableValues = { ...values }; + const propsKeys = Object.keys(serializableValues); + for (let index = 0; index < propsKeys.length; index++) { + const key = propsKeys[index]; + if (this.dynamicVariableListeners[key]) { + serializableValues[key] = this.dynamicVariableListeners[key].dynamicVariable.toJSON(); + } + } + + return serializableValues; + } + + getAllSerializableValues() { + const serializableValues: ObjectAny = {}; + const propsKeys = Object.keys(this.dynamicVariableListeners); + for (let index = 0; index < propsKeys.length; index++) { + const key = propsKeys[index]; + serializableValues[key] = this.dynamicVariableListeners[key].dynamicVariable.toJSON(); + } + + return serializableValues; + } +} diff --git a/packages/core/src/dom_components/model/types.ts b/packages/core/src/dom_components/model/types.ts index 10df3a2d1..79b452b6b 100644 --- a/packages/core/src/dom_components/model/types.ts +++ b/packages/core/src/dom_components/model/types.ts @@ -11,7 +11,7 @@ import Component from './Component'; import Components from './Components'; import { ToolbarButtonProps } from './ToolbarButton'; import { ParseNodeOptions } from '../../parser/config/config'; -import { DataVariableType } from '../../data_sources/model/DataVariable'; +import { DynamicValueDefinition } from '../../data_sources/types'; export type DragMode = 'translate' | 'absolute' | ''; @@ -190,7 +190,7 @@ export interface ComponentProperties { * Component default style, eg. `{ width: '100px', height: '100px', 'background-color': 'red' }` * @default {} */ - style?: string | Record; + style?: string | Record; /** * Component related styles, eg. `.my-component-class { color: red }` * @default '' diff --git a/packages/core/src/domain_abstract/model/StyleableModel.ts b/packages/core/src/domain_abstract/model/StyleableModel.ts index 4e3641728..5621b4d67 100644 --- a/packages/core/src/domain_abstract/model/StyleableModel.ts +++ b/packages/core/src/domain_abstract/model/StyleableModel.ts @@ -168,7 +168,6 @@ export default class StyleableModel extends Model this.dynamicVariableListeners[styleProp].listenToDynamicVariable(); } else { this.dynamicVariableListeners[styleProp] = new DynamicVariableListenerManager({ - model: this, em: this.em!, dataVariable: dataVar, updateValueFromDataVariable: () => this.updateView(), diff --git a/packages/core/src/trait_manager/model/Trait.ts b/packages/core/src/trait_manager/model/Trait.ts index 185bbce1d..497a0741d 100644 --- a/packages/core/src/trait_manager/model/Trait.ts +++ b/packages/core/src/trait_manager/model/Trait.ts @@ -9,9 +9,7 @@ import TraitsEvents, { TraitGetValueOptions, TraitOption, TraitProperties, Trait import TraitView from '../view/TraitView'; import Traits from './Traits'; import TraitDataVariable from '../../data_sources/model/TraitDataVariable'; -import { DataVariableType } from '../../data_sources/model/DataVariable'; import DynamicVariableListenerManager from '../../data_sources/model/DataVariableListenerManager'; -import { isDynamicValueDefinition } from '../../data_sources/model/utils'; /** * @property {String} id Trait id, eg. `my-trait-id`. @@ -58,31 +56,6 @@ export default class Trait extends Model { this.setTarget(target); } this.em = em; - - if (isDynamicValueDefinition(this.attributes.value)) { - const dataType = this.attributes.value.type; - switch (dataType) { - case DataVariableType: - this.dynamicVariable = new TraitDataVariable(this.attributes.value, { em: this.em, trait: this }); - break; - case ConditionalVariableType: { - const { condition, ifTrue, ifFalse } = this.attributes.value; - this.dynamicVariable = new DataCondition(condition, ifTrue, ifFalse, { em: this.em }); - break; - } - default: - return; - } - - const dv = this.dynamicVariable.getDataValue(); - this.set({ value: dv }); - this.dynamicVariableListener = new DynamicVariableListenerManager({ - model: this, - em: this.em, - dataVariable: this.dynamicVariable, - updateValueFromDataVariable: this.updateValueFromDataVariable.bind(this), - }); - } } get parent() { @@ -117,11 +90,6 @@ export default class Trait extends Model { } } - updateValueFromDataVariable(value: string) { - this.setValue(value); - this.trigger('change:value'); - } - /** * Get the trait id. * @returns {String} @@ -167,12 +135,6 @@ export default class Trait extends Model { * @returns {any} */ getValue(opts?: TraitGetValueOptions) { - if (this.dynamicVariable) { - const dValue = this.dynamicVariable.getDataValue(); - - return dValue; - } - return this.getTargetValue(opts); } diff --git a/packages/core/test/specs/data_sources/__snapshots__/serialization.ts.snap b/packages/core/test/specs/data_sources/__snapshots__/serialization.ts.snap index cdb56af48..6e141da55 100644 --- a/packages/core/test/specs/data_sources/__snapshots__/serialization.ts.snap +++ b/packages/core/test/specs/data_sources/__snapshots__/serialization.ts.snap @@ -51,7 +51,7 @@ exports[`DataSource Serialization .getProjectData ComponentDataVariable 1`] = ` } `; -exports[`DataSource Serialization .getProjectData StyleDataVariable 1`] = ` +exports[`DataSource Serialization .getProjectData Dynamic Attributes 1`] = ` { "assets": [], "dataSources": [], @@ -63,11 +63,14 @@ exports[`DataSource Serialization .getProjectData StyleDataVariable 1`] = ` "components": [ { "attributes": { - "id": "data-variable-id", + "dynamicAttribute": { + "defaultValue": "default", + "path": "test-input.id1.value", + "type": "data-variable", + }, }, - "content": "Hello World", - "tagName": "h1", - "type": "text", + "tagName": "input", + "void": true, }, ], "docEl": { @@ -94,25 +97,66 @@ exports[`DataSource Serialization .getProjectData StyleDataVariable 1`] = ` "type": "main", }, ], - "styles": [ + "styles": [], + "symbols": [], +} +`; + +exports[`DataSource Serialization .getProjectData Dynamic Props 1`] = ` +{ + "assets": [], + "dataSources": [], + "pages": [ { - "selectors": [ - "data-variable-id", - ], - "style": { - "color": { - "defaultValue": "black", - "path": "colors-data.id1.color", - "type": "data-variable", + "frames": [ + { + "component": { + "components": [ + { + "content": { + "defaultValue": "default", + "path": "test-input.id1.value", + "type": "data-variable", + }, + "customProp": { + "defaultValue": "default", + "path": "test-input.id1.value", + "type": "data-variable", + }, + "tagName": "input", + "void": true, + }, + ], + "docEl": { + "tagName": "html", + }, + "head": { + "type": "head", + }, + "stylable": [ + "background", + "background-color", + "background-image", + "background-repeat", + "background-attachment", + "background-position", + "background-size", + ], + "type": "wrapper", + }, + "id": "data-variable-id", }, - }, + ], + "id": "data-variable-id", + "type": "main", }, ], + "styles": [], "symbols": [], } `; -exports[`DataSource Serialization .getProjectData TraitDataVariable 1`] = ` +exports[`DataSource Serialization .getProjectData StyleDataVariable 1`] = ` { "assets": [], "dataSources": [], @@ -124,17 +168,11 @@ exports[`DataSource Serialization .getProjectData TraitDataVariable 1`] = ` "components": [ { "attributes": { - "value": "test-value", - }, - "attributes-dynamic-value": { - "value": { - "defaultValue": "default", - "path": "test-input.id1.value", - "type": "data-variable", - }, + "id": "data-variable-id", }, - "tagName": "input", - "void": true, + "content": "Hello World", + "tagName": "h1", + "type": "text", }, ], "docEl": { @@ -161,7 +199,20 @@ exports[`DataSource Serialization .getProjectData TraitDataVariable 1`] = ` "type": "main", }, ], - "styles": [], + "styles": [ + { + "selectors": [ + "data-variable-id", + ], + "style": { + "color": { + "defaultValue": "black", + "path": "colors-data.id1.color", + "type": "data-variable", + }, + }, + }, + ], "symbols": [], } `; diff --git a/packages/core/test/specs/data_sources/dynamic_values/attributes.ts b/packages/core/test/specs/data_sources/dynamic_values/attributes.ts new file mode 100644 index 000000000..61d1ae65d --- /dev/null +++ b/packages/core/test/specs/data_sources/dynamic_values/attributes.ts @@ -0,0 +1,259 @@ +import Editor from '../../../../src/editor/model/Editor'; +import DataSourceManager from '../../../../src/data_sources'; +import ComponentWrapper from '../../../../src/dom_components/model/ComponentWrapper'; +import { DataVariableType } from '../../../../src/data_sources/model/DataVariable'; +import { setupTestEditor } from '../../../common'; +import { Component } from '../../../../src'; + +const staticAttributeValue = 'some tiltle'; +describe('Dynamic Attributes', () => { + let em: Editor; + let dsm: DataSourceManager; + let cmpRoot: ComponentWrapper; + const staticAttributes = { + staticAttribute: staticAttributeValue, + }; + + beforeEach(() => { + ({ em, dsm, cmpRoot } = setupTestEditor()); + }); + + afterEach(() => { + em.destroy(); + }); + + test('static and dynamic attributes', () => { + const inputDataSource = { + id: 'ds_id', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(inputDataSource); + + const attributes = { + ...staticAttributes, + dynamicAttribute: { + type: DataVariableType, + defaultValue: 'default', + path: 'ds_id.id1.value', + }, + }; + const cmp = cmpRoot.append({ + tagName: 'input', + attributes, + })[0]; + + testAttribute(cmp, 'dynamicAttribute', 'test-value'); + testStaticAttributes(cmp); + }); + + test('dynamic attributes should listen to change', () => { + const dataSource = { + id: 'ds_id', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(dataSource); + + const attributes = { + ...staticAttributes, + dynamicAttribute: { + type: DataVariableType, + defaultValue: 'default', + path: 'ds_id.id1.value', + }, + }; + const cmp = cmpRoot.append({ + tagName: 'input', + attributes, + })[0]; + + testAttribute(cmp, 'dynamicAttribute', 'test-value'); + testStaticAttributes(cmp); + + changeDataSourceValue(dsm, 'id1'); + testAttribute(cmp, 'dynamicAttribute', 'changed-value'); + }); + + test('(Component.setAttributes) dynamic attributes should listen to the latest dynamic value', () => { + const dataSource = { + id: 'ds_id', + records: [ + { id: 'id1', value: 'test-value' }, + { id: 'id2', value: 'second-test-value' }, + ], + }; + dsm.add(dataSource); + + const attributes = { + ...staticAttributes, + dynamicAttribute: { + type: DataVariableType, + defaultValue: 'default', + path: 'ds_id.id1.value', + }, + }; + const cmp = cmpRoot.append({ + tagName: 'input', + attributes, + })[0]; + + cmp.setAttributes({ dynamicAttribute: 'some-static-value' }); + testAttribute(cmp, 'dynamicAttribute', 'some-static-value'); + + cmp.setAttributes({ + dynamicAttribute: { + type: DataVariableType, + defaultValue: 'default', + path: 'ds_id.id2.value', + }, + }); + changeDataSourceValue(dsm, 'id1'); + testAttribute(cmp, 'dynamicAttribute', 'second-test-value'); + + changeDataSourceValue(dsm, 'id2'); + testAttribute(cmp, 'dynamicAttribute', 'changed-value'); + }); + + test('(Component.addAttributes) dynamic attributes should listen to the latest dynamic value', () => { + const dataSource = { + id: 'ds_id', + records: [ + { id: 'id1', value: 'test-value' }, + { id: 'id2', value: 'second-test-value' }, + ], + }; + dsm.add(dataSource); + + const attributes = { + ...staticAttributes, + dynamicAttribute: { + type: DataVariableType, + defaultValue: 'default', + path: 'ds_id.id1.value', + }, + }; + const cmp = cmpRoot.append({ + tagName: 'input', + attributes, + })[0]; + + cmp.addAttributes({ dynamicAttribute: 'some-static-value' }); + testAttribute(cmp, 'dynamicAttribute', 'some-static-value'); + + cmp.addAttributes({ + dynamicAttribute: { + type: DataVariableType, + defaultValue: 'default', + path: 'ds_id.id2.value', + }, + }); + changeDataSourceValue(dsm, 'id1'); + testAttribute(cmp, 'dynamicAttribute', 'second-test-value'); + + changeDataSourceValue(dsm, 'id2'); + testAttribute(cmp, 'dynamicAttribute', 'changed-value'); + }); + + test('dynamic attributes should stop listening to change if the value changed to static', () => { + const dataSource = { + id: 'ds_id', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(dataSource); + + const attributes = { + ...staticAttributes, + dynamicAttribute: { + type: DataVariableType, + defaultValue: 'default', + path: 'ds_id.id1.value', + }, + }; + const cmp = cmpRoot.append({ + tagName: 'input', + attributes, + })[0]; + + testAttribute(cmp, 'dynamicAttribute', 'test-value'); + testStaticAttributes(cmp); + + cmp.setAttributes({ + dynamicAttribute: 'static-value', + }); + changeDataSourceValue(dsm, 'id1'); + testAttribute(cmp, 'dynamicAttribute', 'static-value'); + }); + + test('dynamic attributes should start listening to change if the value changed to dynamic value', () => { + const dataSource = { + id: 'ds_id', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(dataSource); + + const attributes = { + ...staticAttributes, + dynamicAttribute: 'static-value', + }; + const cmp = cmpRoot.append({ + tagName: 'input', + attributes, + })[0]; + + cmp.setAttributes({ + dynamicAttribute: { + type: DataVariableType, + defaultValue: 'default', + path: 'ds_id.id1.value', + }, + }); + testAttribute(cmp, 'dynamicAttribute', 'test-value'); + changeDataSourceValue(dsm, 'id1'); + testAttribute(cmp, 'dynamicAttribute', 'changed-value'); + }); + + test('dynamic attributes should stop listening to change if the attribute was removed', () => { + const dataSource = { + id: 'ds_id', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(dataSource); + + const attributes = { + ...staticAttributes, + dynamicAttribute: { + type: DataVariableType, + defaultValue: 'default', + path: 'ds_id.id1.value', + }, + }; + const cmp = cmpRoot.append({ + tagName: 'input', + attributes, + })[0]; + + testAttribute(cmp, 'dynamicAttribute', 'test-value'); + testStaticAttributes(cmp); + + cmp.removeAttributes('dynamicAttribute'); + changeDataSourceValue(dsm, 'id1'); + expect(cmp?.getAttributes()['dynamicAttribute']).toBe(undefined); + const input = cmp.getEl(); + expect(input?.getAttribute('dynamicAttribute')).toBe(null); + }); +}); + +function changeDataSourceValue(dsm: DataSourceManager, id: string) { + dsm.get('ds_id').getRecord(id)?.set('value', 'intermediate-value1'); + dsm.get('ds_id').getRecord(id)?.set('value', 'intermediate-value2'); + dsm.get('ds_id').getRecord(id)?.set('value', 'changed-value'); +} + +function testStaticAttributes(cmp: Component) { + testAttribute(cmp, 'staticAttribute', staticAttributeValue); +} + +function testAttribute(cmp: Component, attribute: string, value: string) { + expect(cmp?.getAttributes()[attribute]).toBe(value); + const input = cmp.getEl(); + expect(input?.getAttribute(attribute)).toBe(value); +} diff --git a/packages/core/test/specs/data_sources/dynamic_values/props.ts b/packages/core/test/specs/data_sources/dynamic_values/props.ts new file mode 100644 index 000000000..7a9d9c739 --- /dev/null +++ b/packages/core/test/specs/data_sources/dynamic_values/props.ts @@ -0,0 +1,147 @@ +import Editor from '../../../../src/editor/model/Editor'; +import DataSourceManager from '../../../../src/data_sources'; +import ComponentWrapper from '../../../../src/dom_components/model/ComponentWrapper'; +import { DataVariableType } from '../../../../src/data_sources/model/DataVariable'; +import { setupTestEditor } from '../../../common'; + +describe('Component Dynamic Properties', () => { + let em: Editor; + let dsm: DataSourceManager; + let cmpRoot: ComponentWrapper; + + beforeEach(() => { + ({ em, dsm, cmpRoot } = setupTestEditor()); + }); + + afterEach(() => { + em.destroy(); + }); + + test('set static and dynamic properties', () => { + const dataSource = { + id: 'ds_id', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(dataSource); + + const properties = { + custom_property: 'static-value', + content: { + type: DataVariableType, + path: 'ds_id.id1.value', + defaultValue: 'default', + }, + }; + + const cmp = cmpRoot.append({ + tagName: 'div', + ...properties, + })[0]; + + expect(cmp.get('custom_property')).toBe('static-value'); + expect(cmp.get('content')).toBe('test-value'); + }); + + test('dynamic properties respond to data changes', () => { + const dataSource = { + id: 'ds_id', + records: [{ id: 'id1', value: 'initial-value' }], + }; + dsm.add(dataSource); + + const cmp = cmpRoot.append({ + tagName: 'div', + content: { + type: DataVariableType, + path: 'ds_id.id1.value', + defaultValue: 'default', + }, + })[0]; + + expect(cmp.get('content')).toBe('initial-value'); + dsm.get('ds_id').getRecord('id1')?.set('value', 'updated-value'); + expect(cmp.get('content')).toBe('updated-value'); + }); + + test('setting static values stops dynamic updates', () => { + const dataSource = { + id: 'ds_id', + records: [{ id: 'id1', value: 'dynamic-value' }], + }; + dsm.add(dataSource); + + const dataVariable = { + type: DataVariableType, + path: 'ds_id.id1.value', + defaultValue: 'default', + }; + const cmp = cmpRoot.append({ + tagName: 'div', + content: dataVariable, + })[0]; + + cmp.set('content', 'static-value'); + dsm.get('ds_id').getRecord('id1')?.set('value', 'new-dynamic-value'); + expect(cmp.get('content')).toBe('static-value'); + + // @ts-ignore + cmp.set({ content: dataVariable }); + expect(cmp.get('content')).toBe('new-dynamic-value'); + }); + + test('updating to a new dynamic value listens to the new dynamic value only', () => { + const dataSource = { + id: 'ds_id', + records: [ + { id: 'id1', value: 'dynamic-value1' }, + { id: 'id2', value: 'dynamic-value2' }, + ], + }; + dsm.add(dataSource); + + const cmp = cmpRoot.append({ + tagName: 'div', + content: { + type: DataVariableType, + path: 'ds_id.id1.value', + defaultValue: 'default', + }, + })[0]; + + cmp.set({ + content: { + type: DataVariableType, + path: 'ds_id.id2.value', + defaultValue: 'default', + } as any, + }); + dsm.get('ds_id').getRecord('id1')?.set('value', 'new-dynamic-value1'); + expect(cmp.get('content')).toBe('dynamic-value2'); + dsm.get('ds_id').getRecord('id2')?.set('value', 'new-dynamic-value2'); + expect(cmp.get('content')).toBe('new-dynamic-value2'); + }); + + test('unset properties stops dynamic updates', () => { + const dataSource = { + id: 'ds_id', + records: [ + { id: 'id1', value: 'dynamic-value1' }, + { id: 'id2', value: 'dynamic-value2' }, + ], + }; + dsm.add(dataSource); + + const cmp = cmpRoot.append({ + tagName: 'div', + custom_property: { + type: DataVariableType, + path: 'ds_id.id1.value', + defaultValue: 'default', + }, + })[0]; + + cmp.unset('custom_property'); + dsm.get('ds_id').getRecord('id1')?.set('value', 'new-dynamic-value'); + expect(cmp.get('custom_property')).toBeUndefined(); + }); +}); diff --git a/packages/core/test/specs/data_sources/model/TraitDataVariable.ts b/packages/core/test/specs/data_sources/model/TraitDataVariable.ts index 3cf542a40..f2ec24e05 100644 --- a/packages/core/test/specs/data_sources/model/TraitDataVariable.ts +++ b/packages/core/test/specs/data_sources/model/TraitDataVariable.ts @@ -2,7 +2,6 @@ import Editor from '../../../../src/editor/model/Editor'; import DataSourceManager from '../../../../src/data_sources'; import ComponentWrapper from '../../../../src/dom_components/model/ComponentWrapper'; import { DataVariableType } from '../../../../src/data_sources/model/DataVariable'; -import { DataSourceProps } from '../../../../src/data_sources/types'; import { setupTestEditor } from '../../../common'; describe('TraitDataVariable', () => { @@ -18,346 +17,144 @@ describe('TraitDataVariable', () => { em.destroy(); }); - describe('text input component', () => { - test('component initializes data-variable value', () => { - const inputDataSource = { - id: 'test-input', - records: [{ id: 'id1', value: 'test-value' }], - }; - dsm.add(inputDataSource); - - const cmp = cmpRoot.append({ - tagName: 'input', - traits: [ - 'name', - { - type: 'text', - label: 'Value', - name: 'value', - value: { - type: DataVariableType, - defaultValue: 'default', - path: `${inputDataSource.id}.id1.value`, - }, - }, - ], - })[0]; - - const input = cmp.getEl(); - expect(input?.getAttribute('value')).toBe('test-value'); - expect(cmp?.getAttributes().value).toBe('test-value'); - }); - - test('component initializes data-variable placeholder', () => { - const inputDataSource = { - id: 'test-input', - records: [{ id: 'id1', value: 'test-value' }], - }; - dsm.add(inputDataSource); - - const cmp = cmpRoot.append({ - tagName: 'input', - traits: [ - 'name', - { - type: 'text', - label: 'Placeholder', - name: 'placeholder', - value: { - type: DataVariableType, - defaultValue: 'default', - path: `${inputDataSource.id}.id1.value`, - }, - }, - ], - })[0]; - - const input = cmp.getEl(); - expect(input?.getAttribute('placeholder')).toBe('test-value'); - expect(cmp?.getAttributes().placeholder).toBe('test-value'); - - const testDs = dsm.get(inputDataSource.id); - testDs.getRecord('id1')?.set({ value: 'new-value' }); - - expect(input?.getAttribute('placeholder')).toBe('new-value'); - expect(cmp?.getAttributes().placeholder).toBe('new-value'); - }); - - test('component updates to defaultValue on record removal', () => { - const inputDataSource = { - id: 'test-input-removal', - records: [{ id: 'id1', value: 'test-value' }], - }; - dsm.add(inputDataSource); - - const cmp = cmpRoot.append({ - tagName: 'input', - traits: [ - 'name', - { - type: 'text', - label: 'Value', - name: 'value', - value: { - type: DataVariableType, - defaultValue: 'default', - path: `${inputDataSource.id}.id1.value`, - }, + test('set component attribute to trait value if component has no value for the attribute', () => { + const inputDataSource = { + id: 'test-input', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(inputDataSource); + + const cmp = cmpRoot.append({ + tagName: 'input', + traits: [ + 'name', + { + type: 'text', + label: 'Value', + name: 'value', + value: { + type: DataVariableType, + defaultValue: 'default', + path: `${inputDataSource.id}.id1.value`, }, - ], - })[0]; - - const input = cmp.getEl(); - expect(input?.getAttribute('value')).toBe('test-value'); - expect(cmp?.getAttributes().value).toBe('test-value'); - - const testDs = dsm.get(inputDataSource.id); - testDs.removeRecord('id1'); - - expect(input?.getAttribute('value')).toBe('default'); - expect(cmp?.getAttributes().value).toBe('default'); - }); - - test('component updates with data-variable value', () => { - const inputDataSource = { - id: 'test-input', - records: [{ id: 'id1', value: 'test-value' }], - }; - dsm.add(inputDataSource); - - const cmp = cmpRoot.append({ - tagName: 'input', - traits: [ - 'name', - 'type', - { - type: 'text', - label: 'Value', - name: 'value', - value: { - type: DataVariableType, - defaultValue: 'default', - path: `${inputDataSource.id}.id1.value`, - }, - }, - ], - })[0]; - - const input = cmp.getEl(); - expect(input?.getAttribute('value')).toBe('test-value'); - expect(cmp?.getAttributes().value).toBe('test-value'); + }, + ], + })[0]; - const testDs = dsm.get(inputDataSource.id); - testDs.getRecord('id1')?.set({ value: 'new-value' }); + const input = cmp.getEl(); + expect(input?.getAttribute('value')).toBe('test-value'); + expect(cmp?.getAttributes().value).toBe('test-value'); - expect(input?.getAttribute('value')).toBe('new-value'); - expect(cmp?.getAttributes().value).toBe('new-value'); - }); + const testDs = dsm.get(inputDataSource.id); + testDs.getRecord('id1')?.set({ value: 'new-value' }); - test('component initializes data-variable value for nested object', () => { - const inputDataSource = { - id: 'nested-input-data', - records: [ - { - id: 'id1', - nestedObject: { - value: 'nested-value', - }, - }, - ], - }; - dsm.add(inputDataSource); - - const cmp = cmpRoot.append({ - tagName: 'input', - traits: [ - 'name', - { - type: 'text', - label: 'Value', - name: 'value', - value: { - type: DataVariableType, - defaultValue: 'default', - path: 'nested-input-data.id1.nestedObject.value', - }, - }, - ], - })[0]; - - const input = cmp.getEl(); - expect(input?.getAttribute('value')).toBe('nested-value'); - expect(cmp?.getAttributes().value).toBe('nested-value'); - }); + expect(input?.getAttribute('value')).toBe('new-value'); + expect(cmp?.getAttributes().value).toBe('new-value'); }); - describe('checkbox input component', () => { - test('component initializes and updates data-variable value', () => { - const inputDataSource = { - id: 'test-checkbox-datasource', - records: [{ id: 'id1', value: 'true' }], - }; - dsm.add(inputDataSource); - - const cmp = cmpRoot.append({ - tagName: 'input', - attributes: { type: 'checkbox', name: 'my-checkbox' }, - traits: [ - { - type: 'checkbox', - label: 'Checked', - name: 'checked', - value: { - type: 'data-variable', - defaultValue: 'false', - path: `${inputDataSource.id}.id1.value`, - }, - valueTrue: 'true', - valueFalse: 'false', + test('set component prop to trait value if component has no value for the prop', () => { + const inputDataSource = { + id: 'test-input', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(inputDataSource); + + const cmp = cmpRoot.append({ + tagName: 'input', + traits: [ + 'name', + { + type: 'text', + label: 'Value', + name: 'value', + changeProp: true, + value: { + type: DataVariableType, + defaultValue: 'default', + path: `${inputDataSource.id}.id1.value`, }, - ], - })[0]; + }, + ], + })[0]; - const input = cmp.getEl() as HTMLInputElement; - expect(input?.checked).toBe(true); - expect(input?.getAttribute('checked')).toBe('true'); + expect(cmp?.get('value')).toBe('test-value'); - const testDs = dsm.get(inputDataSource.id); - testDs.getRecord('id1')?.set({ value: 'false' }); + const testDs = dsm.get(inputDataSource.id); + testDs.getRecord('id1')?.set({ value: 'new-value' }); - expect(input?.getAttribute('checked')).toBe('false'); - // Not syncing - related to - // https://github.com/GrapesJS/grapesjs/discussions/5868 - // https://github.com/GrapesJS/grapesjs/discussions/4415 - // https://github.com/GrapesJS/grapesjs/pull/6095 - // expect(input?.checked).toBe(false); - }); + expect(cmp?.get('value')).toBe('new-value'); }); - describe('image component', () => { - test('component initializes and updates data-variable value', () => { - const inputDataSource = { - id: 'test-image-datasource', - records: [{ id: 'id1', value: 'url-to-cat-image' }], - }; - dsm.add(inputDataSource); - - const cmp = cmpRoot.append({ - type: 'image', - tagName: 'img', - traits: [ - { - type: 'text', - name: 'src', - value: { - type: 'data-variable', - defaultValue: 'default', - path: `${inputDataSource.id}.id1.value`, - }, + test('should keep component prop if component already has a value for the prop', () => { + const inputDataSource = { + id: 'test-input', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(inputDataSource); + + const cmp = cmpRoot.append({ + tagName: 'input', + attributes: { + value: 'existing-value', + }, + traits: [ + 'name', + { + type: 'text', + label: 'Value', + name: 'value', + changeProp: true, + value: { + type: DataVariableType, + defaultValue: 'default', + path: `${inputDataSource.id}.id1.value`, }, - ], - })[0]; + }, + ], + })[0]; - const img = cmp.getEl() as HTMLImageElement; - expect(img?.getAttribute('src')).toBe('url-to-cat-image'); - expect(cmp?.getAttributes().src).toBe('url-to-cat-image'); + const input = cmp.getEl(); + expect(input?.getAttribute('value')).toBe('existing-value'); + expect(cmp?.getAttributes().value).toBe('existing-value'); - const testDs = dsm.get(inputDataSource.id); - testDs.getRecord('id1')?.set({ value: 'url-to-dog-image' }); + const testDs = dsm.get(inputDataSource.id); + testDs.getRecord('id1')?.set({ value: 'new-value' }); - expect(img?.getAttribute('src')).toBe('url-to-dog-image'); - expect(cmp?.getAttributes().src).toBe('url-to-dog-image'); - }); + expect(input?.getAttribute('value')).toBe('existing-value'); + expect(cmp?.getAttributes().value).toBe('existing-value'); }); - describe('link component', () => { - test('component initializes and updates data-variable value', () => { - const inputDataSource = { - id: 'test-link-datasource', - records: [{ id: 'id1', value: 'url-to-cat-image' }], - }; - dsm.add(inputDataSource); - - const cmp = cmpRoot.append({ - type: 'link', - tagName: 'a', - traits: [ - { - type: 'text', - name: 'href', - value: { - type: 'data-variable', - defaultValue: 'default', - path: `${inputDataSource.id}.id1.value`, - }, + test('should keep component prop if component already has a value for the prop', () => { + const inputDataSource = { + id: 'test-input', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(inputDataSource); + + const cmp = cmpRoot.append({ + tagName: 'input', + value: 'existing-value', + traits: [ + 'name', + { + type: 'text', + label: 'Value', + name: 'value', + changeProp: true, + value: { + type: DataVariableType, + defaultValue: 'default', + path: `${inputDataSource.id}.id1.value`, }, - ], - components: [{ tagName: 'span', content: 'Link' }], - })[0]; + }, + ], + })[0]; - const link = cmp.getEl() as HTMLLinkElement; - expect(link?.href).toBe('http://localhost/url-to-cat-image'); - expect(cmp?.getAttributes().href).toBe('url-to-cat-image'); + expect(cmp?.get('value')).toBe('existing-value'); - const testDs = dsm.get(inputDataSource.id); - testDs.getRecord('id1')?.set({ value: 'url-to-dog-image' }); - - expect(link?.href).toBe('http://localhost/url-to-dog-image'); - expect(cmp?.getAttributes().href).toBe('url-to-dog-image'); - }); - }); - - describe('changeProp', () => { - test('component initializes and updates data-variable value using changeProp', () => { - const inputDataSource = { - id: 'test-change-prop-datasource', - records: [{ id: 'id1', value: 'I love grapes' }], - }; - dsm.add(inputDataSource); - - const cmp = cmpRoot.append({ - tagName: 'div', - type: 'default', - traits: [ - { - name: 'test-change-prop', - type: 'text', - changeProp: true, - value: { - type: DataVariableType, - defaultValue: 'default', - path: `${inputDataSource.id}.id1.value`, - }, - }, - ], - })[0]; - - let property = cmp.get('test-change-prop'); - expect(property).toBe('I love grapes'); - - const testDs = dsm.get(inputDataSource.id); - testDs.getRecord('id1')?.set({ value: 'I really love grapes' }); - - property = cmp.get('test-change-prop'); - expect(property).toBe('I really love grapes'); - }); - - test('should cover when changeProp trait value is not set', () => { - const cmp = cmpRoot.append({ - tagName: 'div', - type: 'default', - 'test-change-prop': 'initial-value', - traits: [ - { - name: 'test-change-prop', - type: 'text', - changeProp: true, - }, - ], - })[0]; + const testDs = dsm.get(inputDataSource.id); + testDs.getRecord('id1')?.set({ value: 'new-value' }); - let property = cmp.get('test-change-prop'); - expect(property).toBe('initial-value'); - }); + expect(cmp?.get('value')).toBe('existing-value'); }); }); diff --git a/packages/core/test/specs/data_sources/model/conditional_variables/ConditionalTraits.ts b/packages/core/test/specs/data_sources/model/conditional_variables/ConditionalTraits.ts index 9d31b9e31..a9907207a 100644 --- a/packages/core/test/specs/data_sources/model/conditional_variables/ConditionalTraits.ts +++ b/packages/core/test/specs/data_sources/model/conditional_variables/ConditionalTraits.ts @@ -1,16 +1,11 @@ import { DataSourceManager, Editor } from '../../../../../src'; -import { DataVariableType } from '../../../../../src/data_sources/model/DataVariable'; -import { MissingConditionError } from '../../../../../src/data_sources/model/conditional_variables/DataCondition'; import { ConditionalVariableType } from '../../../../../src/data_sources/model/conditional_variables/DataCondition'; -import { GenericOperation } from '../../../../../src/data_sources/model/conditional_variables/operators/GenericOperator'; import { NumberOperation } from '../../../../../src/data_sources/model/conditional_variables/operators/NumberOperator'; -import { DataSourceProps } from '../../../../../src/data_sources/types'; -import Component, { dynamicAttrKey } from '../../../../../src/dom_components/model/Component'; import ComponentWrapper from '../../../../../src/dom_components/model/ComponentWrapper'; import EditorModel from '../../../../../src/editor/model/Editor'; -import { filterObjectForSnapshot, setupTestEditor } from '../../../../common'; +import { setupTestEditor } from '../../../../common'; -describe('TraitConditionalVariable', () => { +describe('conditional traits', () => { let editor: Editor; let em: EditorModel; let dsm: DataSourceManager; @@ -23,15 +18,21 @@ describe('TraitConditionalVariable', () => { afterEach(() => { em.destroy(); }); + test('set component attribute to trait value if component has no value for the attribute', () => { + const inputDataSource = { + id: 'test-input', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(inputDataSource); - it('should add a trait with a condition evaluating to a string', () => { - const component = cmpRoot.append({ - tagName: 'h1', - type: 'text', + const cmp = cmpRoot.append({ + tagName: 'input', traits: [ + 'name', { type: 'text', - name: 'title', + label: 'Value', + name: 'value', value: { type: ConditionalVariableType, condition: { @@ -39,217 +40,115 @@ describe('TraitConditionalVariable', () => { operator: NumberOperation.greaterThan, right: -1, }, - ifTrue: 'Some title', + ifTrue: 'test-value', }, }, ], })[0]; - testComponentAttr(component, 'title', 'Some title'); + const input = cmp.getEl(); + expect(input?.getAttribute('value')).toBe('test-value'); + expect(cmp?.getAttributes().value).toBe('test-value'); }); - it('should add a trait with a data-source condition', () => { - const dataSource = { - id: 'ds1', - records: [{ id: 'left_id', left: 'Name1' }], + test('set component prop to trait value if component has no value for the prop', () => { + const inputDataSource = { + id: 'test-input', + records: [{ id: 'id1', value: 'test-value' }], }; - dsm.add(dataSource); + dsm.add(inputDataSource); - const component = cmpRoot.append({ - tagName: 'h1', - type: 'text', + const cmp = cmpRoot.append({ + tagName: 'input', traits: [ + 'name', { type: 'text', - name: 'title', + label: 'Value', + name: 'value', + changeProp: true, value: { type: ConditionalVariableType, condition: { - left: { - type: DataVariableType, - path: 'ds1.left_id.left', - }, - operator: GenericOperation.equals, - right: 'Name1', + left: 0, + operator: NumberOperation.greaterThan, + right: -1, }, - ifTrue: 'Valid name', - ifFalse: 'Invalid name', + ifTrue: 'test-value', }, }, ], })[0]; - testComponentAttr(component, 'title', 'Valid name'); + expect(cmp?.get('value')).toBe('test-value'); }); - it('should change trait value with changing data-source value', () => { - const dataSource = { - id: 'ds1', - records: [{ id: 'left_id', left: 'Name1' }], + test('should keep component prop if component already has a value for the prop', () => { + const inputDataSource = { + id: 'test-input', + records: [{ id: 'id1', value: 'test-value' }], }; - dsm.add(dataSource); + dsm.add(inputDataSource); - const component = cmpRoot.append({ - tagName: 'h1', - type: 'text', + const cmp = cmpRoot.append({ + tagName: 'input', + attributes: { + value: 'existing-value', + }, traits: [ + 'name', { type: 'text', - name: 'title', + label: 'Value', + name: 'value', + changeProp: true, value: { type: ConditionalVariableType, condition: { - left: { - type: DataVariableType, - path: 'ds1.left_id.left', - }, - operator: GenericOperation.equals, - right: 'Name1', - }, - ifTrue: 'Correct name', - ifFalse: 'Incorrect name', - }, - }, - ], - })[0]; - - testComponentAttr(component, 'title', 'Correct name'); - dsm.get('ds1').getRecord('left_id')?.set('left', 'Different name'); - testComponentAttr(component, 'title', 'Incorrect name'); - }); - - it('should throw an error if no condition is passed in trait', () => { - expect(() => { - cmpRoot.append({ - tagName: 'h1', - type: 'text', - traits: [ - { - type: 'text', - name: 'invalidTrait', - value: { - type: ConditionalVariableType, + left: 0, + operator: NumberOperation.greaterThan, + right: -1, }, + ifTrue: 'existing-value', }, - ], - }); - }).toThrow(MissingConditionError); - }); - - it('should store traits with conditional values correctly', () => { - const conditionalTrait = { - type: ConditionalVariableType, - condition: { - left: 0, - operator: NumberOperation.greaterThan, - right: -1, - }, - ifTrue: 'Positive', - }; - cmpRoot.append({ - tagName: 'h1', - type: 'text', - traits: [ - { - type: 'text', - name: 'dynamicTrait', - value: conditionalTrait, }, ], })[0]; - const projectData = editor.getProjectData(); - const snapshot = filterObjectForSnapshot(projectData); - expect(snapshot).toMatchSnapshot(``); - const page = projectData.pages[0]; - const frame = page.frames[0]; - const storedComponent = frame.component.components[0]; - - expect(storedComponent[dynamicAttrKey]).toEqual({ - dynamicTrait: conditionalTrait, - }); + const input = cmp.getEl(); + expect(input?.getAttribute('value')).toBe('existing-value'); + expect(cmp?.getAttributes().value).toBe('existing-value'); }); - it('should load traits with conditional values correctly', () => { - const projectData = { - pages: [ - { - frames: [ - { - component: { - components: [ - { - attributes: { - dynamicTrait: 'Default', - }, - [dynamicAttrKey]: { - dynamicTrait: { - condition: { - left: 0, - operator: '>', - right: -1, - }, - ifTrue: 'Positive', - type: 'conditional-variable', - }, - }, - type: 'text', - }, - ], - type: 'wrapper', - }, - }, - ], - type: 'main', - }, - ], + test('should keep component prop if component already has a value for the prop', () => { + const inputDataSource = { + id: 'test-input', + records: [{ id: 'id1', value: 'test-value' }], }; + dsm.add(inputDataSource); - editor.loadProjectData(projectData); - const components = editor.getComponents(); - const component = components.models[0]; - expect(component.getAttributes()).toEqual({ dynamicTrait: 'Positive' }); - }); - - it('should be property on the component with `changeProp:true`', () => { - const dataSource = { - id: 'ds1', - records: [{ id: 'left_id', left: 'Name1' }], - }; - dsm.add(dataSource); - - const component = cmpRoot.append({ - tagName: 'h1', - type: 'text', + const cmp = cmpRoot.append({ + tagName: 'input', + value: 'existing-value', traits: [ + 'name', { type: 'text', - name: 'title', + label: 'Value', + name: 'value', changeProp: true, value: { type: ConditionalVariableType, condition: { - left: { - type: DataVariableType, - path: 'ds1.left_id.left', - }, - operator: GenericOperation.equals, - right: 'Name1', + left: 0, + operator: NumberOperation.greaterThan, + right: -1, }, - ifTrue: 'Correct name', - ifFalse: 'Incorrect name', + ifTrue: 'existing-value', }, }, ], })[0]; - - // TODO: make dynamic values not to change the attributes if `changeProp:true` - // expect(component.getView()?.el.getAttribute('title')).toBeNull(); - expect(component.get('title')).toBe('Correct name'); - - dsm.get('ds1').getRecord('left_id')?.set('left', 'Different name'); - // expect(component.getView()?.el.getAttribute('title')).toBeNull(); - expect(component.get('title')).toBe('Incorrect name'); }); it('should handle objects as traits (other than dynamic values)', () => { @@ -274,10 +173,3 @@ describe('TraitConditionalVariable', () => { expect(component.getAttributes().title).toEqual(traitValue); }); }); - -function testComponentAttr(component: Component, trait: string, value: string) { - expect(component).toBeDefined(); - expect(component.getTrait(trait).get('value')).toBe(value); - expect(component.getAttributes()[trait]).toBe(value); - expect(component.getView()?.el.getAttribute(trait)).toBe(value); -} diff --git a/packages/core/test/specs/data_sources/model/conditional_variables/__snapshots__/ConditionalTraits.ts.snap b/packages/core/test/specs/data_sources/model/conditional_variables/__snapshots__/ConditionalTraits.ts.snap deleted file mode 100644 index 34066fa8c..000000000 --- a/packages/core/test/specs/data_sources/model/conditional_variables/__snapshots__/ConditionalTraits.ts.snap +++ /dev/null @@ -1,59 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TraitConditionalVariable should store traits with conditional values correctly 1`] = ` -{ - "assets": [], - "dataSources": [], - "pages": [ - { - "frames": [ - { - "component": { - "components": [ - { - "attributes": { - "dynamicTrait": "Positive", - }, - "attributes-dynamic-value": { - "dynamicTrait": { - "condition": { - "left": 0, - "operator": ">", - "right": -1, - }, - "ifTrue": "Positive", - "type": "conditional-variable", - }, - }, - "tagName": "h1", - "type": "text", - }, - ], - "docEl": { - "tagName": "html", - }, - "head": { - "type": "head", - }, - "stylable": [ - "background", - "background-color", - "background-image", - "background-repeat", - "background-attachment", - "background-position", - "background-size", - ], - "type": "wrapper", - }, - "id": "data-variable-id", - }, - ], - "id": "data-variable-id", - "type": "main", - }, - ], - "styles": [], - "symbols": [], -} -`; diff --git a/packages/core/test/specs/data_sources/serialization.ts b/packages/core/test/specs/data_sources/serialization.ts index 3cf20005f..57c88471e 100644 --- a/packages/core/test/specs/data_sources/serialization.ts +++ b/packages/core/test/specs/data_sources/serialization.ts @@ -4,10 +4,7 @@ import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper import { DataVariableType } from '../../../src/data_sources/model/DataVariable'; import EditorModel from '../../../src/editor/model/Editor'; import { ProjectData } from '../../../src/storage_manager'; -import { DataSourceProps } from '../../../src/data_sources/types'; import { filterObjectForSnapshot, setupTestEditor } from '../../common'; -import { dynamicAttrKey } from '../../../src/dom_components/model/Component'; - describe('DataSource Serialization', () => { let editor: Editor; let em: EditorModel; @@ -31,6 +28,11 @@ describe('DataSource Serialization', () => { records: [{ id: 'id1', value: 'test-value' }], skipFromStorage: true, }; + const propsDataSource = { + id: 'test-input', + records: [{ id: 'id1', value: 'test-value' }], + skipFromStorage: true, + }; beforeEach(() => { ({ editor, em, dsm, cmpRoot } = setupTestEditor()); @@ -65,6 +67,54 @@ describe('DataSource Serialization', () => { }); describe('.getProjectData', () => { + test('Dynamic Props', () => { + const dataVariable = { + type: DataVariableType, + defaultValue: 'default', + path: `${propsDataSource.id}.id1.value`, + }; + + cmpRoot.append({ + tagName: 'input', + content: dataVariable, + customProp: dataVariable, + })[0]; + + const projectData = editor.getProjectData(); + const page = projectData.pages[0]; + const frame = page.frames[0]; + const component = frame.component.components[0]; + expect(component['content']).toEqual(dataVariable); + expect(component['customProp']).toEqual(dataVariable); + + const snapshot = filterObjectForSnapshot(projectData); + expect(snapshot).toMatchSnapshot(``); + }); + + test('Dynamic Attributes', () => { + const dataVariable = { + type: DataVariableType, + defaultValue: 'default', + path: `${propsDataSource.id}.id1.value`, + }; + + cmpRoot.append({ + tagName: 'input', + attributes: { + dynamicAttribute: dataVariable, + }, + })[0]; + + const projectData = editor.getProjectData(); + const page = projectData.pages[0]; + const frame = page.frames[0]; + const component = frame.component.components[0]; + expect(component['attributes']['dynamicAttribute']).toEqual(dataVariable); + + const snapshot = filterObjectForSnapshot(projectData); + expect(snapshot).toMatchSnapshot(``); + }); + test('ComponentDataVariable', () => { const dataVariable = { type: DataVariableType, @@ -119,46 +169,79 @@ describe('DataSource Serialization', () => { const snapshot = filterObjectForSnapshot(projectData); expect(snapshot).toMatchSnapshot(``); }); + }); - test('TraitDataVariable', () => { + describe('.loadProjectData', () => { + test('Dynamic Props', () => { const dataVariable = { type: DataVariableType, defaultValue: 'default', - path: `${traitDataSource.id}.id1.value`, + path: `${propsDataSource.id}.id1.value`, }; - cmpRoot.append({ - tagName: 'input', - traits: [ - 'name', + const componentProjectData: ProjectData = { + assets: [], + pages: [ { - type: 'text', - label: 'Value', - name: 'value', - value: dataVariable, + frames: [ + { + component: { + components: [ + { + content: dataVariable, + customProp: dataVariable, + tagName: 'input', + void: true, + }, + ], + docEl: { + tagName: 'html', + }, + head: { + type: 'head', + }, + stylable: [ + 'background', + 'background-color', + 'background-image', + 'background-repeat', + 'background-attachment', + 'background-position', + 'background-size', + ], + type: 'wrapper', + }, + id: 'frameid', + }, + ], + id: 'pageid', + type: 'main', }, ], - })[0]; + styles: [], + symbols: [], + dataSources: [propsDataSource], + }; - const projectData = editor.getProjectData(); - const page = projectData.pages[0]; - const frame = page.frames[0]; - const component = frame.component.components[0]; - expect(component).toHaveProperty(dynamicAttrKey); - expect(component[dynamicAttrKey]).toEqual({ - value: dataVariable, - }); - expect(component.attributes).toEqual({ - value: 'test-value', - }); + editor.loadProjectData(componentProjectData); - const snapshot = filterObjectForSnapshot(projectData); - expect(snapshot).toMatchSnapshot(``); + const components = editor.getComponents(); + const component = components.models[0]; + expect(component.get('content')).toEqual('test-value'); + expect(component.get('customProp')).toEqual('test-value'); + + dsm.get(propsDataSource.id).getRecord('id1')?.set('value', 'updated-value'); + expect(component.get('content')).toEqual('updated-value'); + expect(component.get('customProp')).toEqual('updated-value'); }); - }); - describe('.loadProjectData', () => { - test('ComponentDataVariable', () => { + test('Dynamic Attributes', () => { + const dataVariable = { + type: DataVariableType, + defaultValue: 'default', + path: `${propsDataSource.id}.id1.value`, + }; + const componentProjectData: ProjectData = { assets: [], pages: [ @@ -168,15 +251,11 @@ describe('DataSource Serialization', () => { component: { components: [ { - components: [ - { - path: 'component-serialization.id1.content', - type: 'data-variable', - value: 'default', - }, - ], - tagName: 'h1', - type: 'text', + attributes: { + dynamicAttribute: dataVariable, + }, + tagName: 'input', + void: true, }, ], docEl: { @@ -196,27 +275,29 @@ describe('DataSource Serialization', () => { ], type: 'wrapper', }, - id: 'data-variable-id', + id: 'frameid', }, ], - id: 'data-variable-id', + id: 'pageid', type: 'main', }, ], styles: [], symbols: [], - dataSources: [componentDataSource], + dataSources: [propsDataSource], }; editor.loadProjectData(componentProjectData); + const components = editor.getComponents(); + const component = components.at(0); + expect(component.getAttributes()['dynamicAttribute']).toEqual('test-value'); - const component = components.models[0]; - const html = component.toHTML(); - expect(html).toContain('Hello World'); + dsm.get(propsDataSource.id).getRecord('id1')?.set('value', 'updated-value'); + expect(component.getAttributes()['dynamicAttribute']).toEqual('updated-value'); }); - test('StyleDataVariable', () => { + test('ComponentDataVariable', () => { const componentProjectData: ProjectData = { assets: [], pages: [ @@ -226,10 +307,13 @@ describe('DataSource Serialization', () => { component: { components: [ { - attributes: { - id: 'selectorid', - }, - content: 'Hello World', + components: [ + { + path: 'component-serialization.id1.content', + type: 'data-variable', + value: 'default', + }, + ], tagName: 'h1', type: 'text', }, @@ -251,41 +335,27 @@ describe('DataSource Serialization', () => { ], type: 'wrapper', }, - id: 'componentid', + id: 'data-variable-id', }, ], - id: 'frameid', + id: 'data-variable-id', type: 'main', }, ], - styles: [ - { - selectors: ['#selectorid'], - style: { - color: { - path: 'colors-data.id1.color', - type: 'data-variable', - defaultValue: 'black', - }, - }, - }, - ], + styles: [], symbols: [], - dataSources: [styleDataSource], + dataSources: [componentDataSource], }; editor.loadProjectData(componentProjectData); - const components = editor.getComponents(); - const component = components.models[0]; - const style = component.getStyle(); - expect(style).toEqual({ - color: 'red', - }); + const component = components.models[0]; + const html = component.toHTML(); + expect(html).toContain('Hello World'); }); - test('TraitDataVariable', () => { + test('StyleDataVariable', () => { const componentProjectData: ProjectData = { assets: [], pages: [ @@ -296,17 +366,11 @@ describe('DataSource Serialization', () => { components: [ { attributes: { - value: 'default', - }, - [dynamicAttrKey]: { - value: { - path: 'test-input.id1.value', - type: 'data-variable', - defaultValue: 'default', - }, + id: 'selectorid', }, - tagName: 'input', - void: true, + content: 'Hello World', + tagName: 'h1', + type: 'text', }, ], docEl: { @@ -326,25 +390,37 @@ describe('DataSource Serialization', () => { ], type: 'wrapper', }, - id: 'frameid', + id: 'componentid', }, ], - id: 'pageid', + id: 'frameid', type: 'main', }, ], - styles: [], + styles: [ + { + selectors: ['#selectorid'], + style: { + color: { + path: 'colors-data.id1.color', + type: 'data-variable', + defaultValue: 'black', + }, + }, + }, + ], symbols: [], - dataSources: [traitDataSource], + dataSources: [styleDataSource], }; editor.loadProjectData(componentProjectData); const components = editor.getComponents(); const component = components.models[0]; - const value = component.getAttributes(); - expect(value).toEqual({ - value: 'test-value', + const style = component.getStyle(); + + expect(style).toEqual({ + color: 'red', }); }); });