diff --git a/docs/.vuepress/components/DemoViewer.vue b/docs/.vuepress/components/DemoViewer.vue index 7f830b299..08e1f47af 100644 --- a/docs/.vuepress/components/DemoViewer.vue +++ b/docs/.vuepress/components/DemoViewer.vue @@ -26,12 +26,17 @@ export default { type: Boolean, default: false, }, + show: { + type: Boolean, + default: false, + }, }, computed: { src() { - const { value, user, darkcode } = this; + const { value, user, darkcode, show } = this; + const tabs = show ? 'result,js,html,css' : 'js,html,css,result'; const dcStr = darkcode ? '/dark/?menuColor=fff&fontColor=333&accentColor=e67891' : ''; - return `//jsfiddle.net/${user}/${value}/embedded/js,html,css,result${dcStr}`; + return `//jsfiddle.net/${user}/${value}/embedded/${tabs}${dcStr}`; } } } diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index 53df6bffe..59563977b 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -125,6 +125,7 @@ module.exports = { title: 'Guides', collapsable: false, children: [ + ['/guides/Symbols', 'Symbols'], ['/guides/Replace-Rich-Text-Editor', 'Replace Rich Text Editor'], ['/guides/Custom-CSS-parser', 'Use Custom CSS Parser'], ] diff --git a/docs/.vuepress/public/symbols-model.svg b/docs/.vuepress/public/symbols-model.svg new file mode 100644 index 000000000..82cfeb9a7 --- /dev/null +++ b/docs/.vuepress/public/symbols-model.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/docs/guides/Symbols.md b/docs/guides/Symbols.md new file mode 100644 index 000000000..ea066ba21 --- /dev/null +++ b/docs/guides/Symbols.md @@ -0,0 +1,306 @@ +--- +title: Symbols +--- +# Symbols + +::: warning +This feature is released as a beta from GrapesJS v0.21.11 + + +To get a better understanding of the content in this guide we recommend reading [Components] first +::: + +Symbols are a special type of [Component] that allows you to easily reuse common elements across your project. They are particularly useful for components that appear multiple times in your project and need to remain consistent. By using Symbols, you can easily update these components in one place and have the changes reflected everywhere they are used. + +[[toc]] + + +## Concept + +A Symbol created from a component retains the same shape and uses the same [Components API], but it includes a reference to other related Symbols. When you create a new Symbol from a Component, it creates a Main Symbol, and the original component becomes an Instance Symbol. + +When you reuse the Symbol elsewhere, it creates new Instance Symbols. Any updates made to the Main Symbol are automatically replicated in all Instance Symbols, ensuring consistency throughout your project. + +Below is a simple representation of the connection between Main and Instance Symbols. + + + +::: warning Note +This feature operates at a low level, meaning there is no built-in UI for creating and managing symbols. Developers need to implement their own UI to interact with this feature. Below you'll find an example of implementation. +::: + + +## Programmatic usage + +Let's see how to work with and manage Symbols in your project. + + + +### Create symbol + +Create a new Symbol from any component in your project: + +```js +const anyComponent = editor.getSelected(); +const symbolMain = editor.Components.addSymbol(anyComponent); +``` + +This will transform `anyComponent` to an Instance and the returned `symbolMain` will be the Main Symbol. GrapesJS keeps track of Main Symbols separately in your project JSON, and they will be automatically reconnected when you reload the project. + +The `addSymbol` method also handles the creation of Instances. If you call it again by passing `symbolMain` or `anyComponent`, it will create a new Instance of `symbolMain`. + +```js +const secondInstance = editor.Components.addSymbol(symbolMain); +``` + +Now, `symbolMain` references two instances of its shape. + +To get all the available Symbols in your project, use `getSymbols`: + +```js +const symbols = editor.Components.getSymbols(); +const symbolMain = symbols[0]; +``` + + + +### Symbol details + +Once you have Symbols in your project, you might need to know when a Component is a Symbol and get details about it. Use the `getSymbolInfo` method for this: + +```js +// Details from the Main Symbol +const symbolMainInfo = editor.Components.getSymbolInfo(symbolMain); + +symbolMainInfo.isSymbol; // true; It's a Symbol +symbolMainInfo.isRoot; // true; It's the root of the Symbol +symbolMainInfo.isMain; // true; It's the Main Symbol +symbolMainInfo.isInstance; // false; It's not the Instance Symbol +symbolMainInfo.main; // symbolMainInfo; Reference to the Main Symbol +symbolMainInfo.instances; // [anyComponent, secondInstance]; Reference to Instance Symbols +symbolMainInfo.relatives; // [anyComponent, secondInstance]; Relative Symbols + +// Details from the Instance Symbol +const secondInstanceInfo = editor.Components.getSymbolInfo(secondInstance); + +symbolMainInfo.isSymbol; // true; It's a Symbol +symbolMainInfo.isRoot; // true; It's the root of the Symbol +symbolMainInfo.isMain; // false; It's not the Main Symbol +symbolMainInfo.isInstance; // true; It's the Instance Symbol +symbolMainInfo.main; // symbolMainInfo; Reference to the Main Symbol +symbolMainInfo.instances; // [anyComponent, secondInstance]; Reference to Instance Symbols +symbolMainInfo.relatives; // [anyComponent, symbolMain]; Relative Symbols +``` + + + +### Overrides + +When you update a Symbol's properties, changes are propagated to all related Symbols. To avoid propagating specific properties, you can specify at the component level which properties to skip: + +```js +anyComponent.set('my-property', true); +secondInstance.get('my-property'); // true; change propagated + +anyComponent.setSymbolOverride(['my-property']); +// Get current override value: anyComponent.getSymbolOverride(); + +anyComponent.set('my-property', false); +secondInstance.get('my-property'); // true; change didn't propagate +``` + + + +### Detach symbol + +Once you have Symbol instances you might need to disconnect one to create a new custom shape with other components inside, in that case you can use `detachSymbol`. + +```js +editor.Components.detachSymbol(anyComponent); + +const info = editor.Components.getSymbolInfo(anyComponent); +info.isSymbol; // false; Not a Symbol anymore + +const infoMain = editor.Components.getSymbolInfo(symbolMain); +infoMain.instances; // [secondInstance]; Removed the reference +``` + + + +### Remove symbol + +To remove a Main Symbol and detach all related instances: + +```js +const symbolMain = editor.Components.getSymbols()[0]; +symbolMain.remove(); +``` + + + + +## Events + +The editor triggers several symbol-related events that you can leverage for your integration: + + +* `symbol:main:add` Added new root main symbol. +```js +editor.on('symbol:main:add', ({ component }) => { ... }); +``` + +* `symbol:main:update` Root main symbol updated. +```js +editor.on('symbol:main:update', ({ component }) => { ... }); +``` + +* `symbol:main:remove` Root main symbol removed. +```js +editor.on('symbol:main:remove', ({ component }) => { ... }); +``` + +* `symbol:main` Catch-all event related to root main symbol updates. +```js +editor.on('symbol:main', ({ event, component }) => { ... }); +``` + +* `symbol:instance:add` Added new root instance symbol. +```js +editor.on('symbol:instance:add', ({ component }) => { ... }); +``` + +* `symbol:instance:remove` Root instance symbol removed. +```js +editor.on('symbol:instance:remove', ({ component }) => { ... }); +``` + +* `symbol:instance` Catch-all event related to root instance symbol updates. +```js +editor.on('symbol:instance', ({ event, component }) => { ... }); +``` + +* `symbol` Catch-all event for any symbol update (main or instance). +```js +editor.on('symbol', () => { ... }); +``` + + + + +## Example + +Below is a basic UI implementation leveraging the Symbols API: + + + + + + + +[Component]: +[Components]: +[Components API]: \ No newline at end of file diff --git a/src/canvas/model/CanvasSpots.ts b/src/canvas/model/CanvasSpots.ts index 7869faecd..4838640b1 100644 --- a/src/canvas/model/CanvasSpots.ts +++ b/src/canvas/model/CanvasSpots.ts @@ -4,6 +4,7 @@ import { ModuleCollection } from '../../abstract'; import { Debounced, ObjectAny } from '../../common'; import EditorModel from '../../editor/model/Editor'; import CanvasSpot, { CanvasSpotProps } from './CanvasSpot'; +import { ComponentsEvents } from '../../dom_components/types'; export default class CanvasSpots extends ModuleCollection { refreshDbn: Debounced; @@ -15,7 +16,8 @@ export default class CanvasSpots extends ModuleCollection { this.on('remove', this.onRemove); const { em } = this; this.refreshDbn = debounce(() => this.refresh(), 0); - const evToRefreshDbn = 'component:resize styleable:change component:input component:update frame:updated undo redo'; + + const evToRefreshDbn = `component:resize styleable:change component:input ${ComponentsEvents.update} frame:updated undo redo`; this.listenTo(em, evToRefreshDbn, () => this.refreshDbn()); } diff --git a/src/commands/view/SelectComponent.ts b/src/commands/view/SelectComponent.ts index 41cad4f84..2613691eb 100644 --- a/src/commands/view/SelectComponent.ts +++ b/src/commands/view/SelectComponent.ts @@ -7,6 +7,7 @@ import { getComponentModel, getComponentView, getUnitFromValue, getViewEl, hasWi import { CommandObject } from './CommandAbstract'; import { CanvasSpotBuiltInTypes } from '../../canvas/model/CanvasSpot'; import { ResizerOptions } from '../../utils/Resizer'; +import { ComponentsEvents } from '../../dom_components/types'; let showOffsets: boolean; /** @@ -79,6 +80,7 @@ export default { const { parentNode } = em.getContainer()!; const method = enable ? 'on' : 'off'; const methods = { on, off }; + const eventCmpUpdate = ComponentsEvents.update; !listenToEl.length && parentNode && listenToEl.push(parentNode as HTMLElement); const trigger = (win: Window, body: HTMLBodyElement) => { methods[method](body, 'mouseover', this.onHover); @@ -89,10 +91,10 @@ export default { }; methods[method](window, 'resize', this.onFrameUpdated); methods[method](listenToEl, 'scroll', this.onContainerChange); - em[method]('component:toggled component:update undo redo', this.onSelect, this); + em[method](`component:toggled ${eventCmpUpdate} undo redo`, this.onSelect, this); em[method]('change:componentHovered', this.onHovered, this); em[method]('component:resize styleable:change component:input', this.updateGlobalPos, this); - em[method]('component:update:toolbar', this._upToolbar, this); + em[method](`${eventCmpUpdate}:toolbar`, this._upToolbar, this); em[method]('frame:updated', this.onFrameUpdated, this); em[method]('canvas:updateTools', this.onFrameUpdated, this); em[method](em.Canvas.events.refresh, this.updateAttached, this); diff --git a/src/dom_components/index.ts b/src/dom_components/index.ts index faf587d5d..af41491cb 100644 --- a/src/dom_components/index.ts +++ b/src/dom_components/index.ts @@ -53,7 +53,7 @@ * * @module Components */ -import { debounce, isArray, isEmpty, isFunction, isString, result } from 'underscore'; +import { debounce, isArray, isEmpty, isFunction, isString, isSymbol, result } from 'underscore'; import { ItemManagerModule } from '../abstract/Module'; import { AddOptions, ObjectAny } from '../common'; import EditorModel from '../editor/model/Editor'; @@ -102,6 +102,17 @@ import ComponentView, { IComponentView } from './view/ComponentView'; import ComponentWrapperView from './view/ComponentWrapperView'; import ComponentsView from './view/ComponentsView'; import ComponentHead, { type as typeHead } from './model/ComponentHead'; +import { + getSymbolMain, + getSymbolInstances, + getSymbolsToUpdate, + isSymbolMain, + isSymbolInstance, + detachSymbolInstance, + isSymbolRoot, +} from './model/SymbolUtils'; +import { ComponentsEvents, SymbolInfo } from './types'; +import Symbols from './model/Symbols'; export type ComponentEvent = | 'component:create' @@ -292,8 +303,11 @@ export default class ComponentManager extends ItemManagerModule { let wrapper = this.getWrapper()!; @@ -350,10 +372,16 @@ export default class ComponentManager extends ItemManagerModule} + * @example + * const symbols = cmp.getSymbols(); + * // [Component, Component, ...] + * // Removing the main symbol will detach all the relative instances. + * symbols[0].remove(); + */ + getSymbols() { + return [...this.symbols.models]; + } + + /** + * Detach symbol instance from the main one. + * The passed symbol instance will become a regular component. + * @param {[Component]} component The component symbol to detach. + * @example + * const cmpInstance = editor.getSelected(); + * // cmp.getSymbolInfo(cmpInstance).isInstance === true; + * cmp.detachSymbol(cmpInstance); + * // cmp.getSymbolInfo(cmpInstance).isInstance === false; + */ + detachSymbol(component: Component) { + if (isSymbolInstance(component)) { + detachSymbolInstance(component); + } + } + + /** + * Get info about the symbol. + * @param {[Component]} component Component symbol from which to get the info. + * @returns {Object} Object containing symbol info. + * @example + * cmp.getSymbolInfo(editor.getSelected()); + * // > { isSymbol: true, isMain: false, isInstance: true, ... } + */ + getSymbolInfo(component: Component, opts: { withChanges?: string } = {}): SymbolInfo { + const isMain = isSymbolMain(component); + const mainRef = getSymbolMain(component); + const isInstance = !!mainRef; + const instances = (isMain ? getSymbolInstances(component) : getSymbolInstances(mainRef)) || []; + const main = mainRef || (isMain ? component : undefined); + const relatives = getSymbolsToUpdate(component, { changed: opts.withChanges }); + const isSymbol = isMain || isInstance; + const isRoot = isSymbol && isSymbolRoot(component); + + return { + isSymbol, + isMain, + isInstance, + isRoot, + main, + instances: instances, + relatives: relatives || [], + }; + } + /** * Check if a component can be moved inside another one. * @param {[Component]} target The target component is the one that is supposed to receive the source one. diff --git a/src/dom_components/model/Component.ts b/src/dom_components/model/Component.ts index aca0c7d94..f86cb1435 100644 --- a/src/dom_components/model/Component.ts +++ b/src/dom_components/model/Component.ts @@ -40,6 +40,17 @@ import { ToolbarButtonProps } from './ToolbarButton'; import { TraitProperties } from '../../trait_manager/types'; import { ActionLabelComponents, ComponentsEvents } from '../types'; import ItemView from '../../navigator/view/ItemView'; +import { + getSymbolMain, + getSymbolInstances, + initSymbol, + isSymbol, + isSymbolMain, + isSymbolRoot, + updateSymbolCls, + updateSymbolComps, + updateSymbolProps, +} from './SymbolUtils'; export interface IComponent extends ExtractMethods {} @@ -55,8 +66,8 @@ export const eventDrag = 'component:drag'; export const keySymbols = '__symbols'; export const keySymbol = '__symbol'; export const keySymbolOvrd = '__symbol_ovrd'; -export const keyUpdate = 'component:update'; -export const keyUpdateInside = `${keyUpdate}-inside`; +export const keyUpdate = ComponentsEvents.update; +export const keyUpdateInside = ComponentsEvents.updateInside; /** * The Component object represents a single node of our template structure, so when you update its properties the changes are @@ -301,7 +312,7 @@ export default class Component extends StyleableModel { this.__postAdd(); this.init(); - this.__isSymbolOrInst() && this.__initSymb(); + isSymbol(this) && initSymbol(this); em?.trigger(ComponentsEvents.create, this, opt); } } @@ -370,6 +381,23 @@ export default class Component extends StyleableModel { this.emitUpdate('toolbar'); } + __getAllById() { + const { em } = this; + return em ? em.Components.allById() : {}; + } + + __upSymbProps(m: any, opts: SymbolToUpOptions = {}) { + updateSymbolProps(this, opts); + } + + __upSymbCls(m: any, c: any, opts = {}) { + updateSymbolCls(this, opts); + } + + __upSymbComps(m: Component, c: Components, o: any) { + updateSymbolComps(this, m, c, o); + } + /** * Check component's type * @param {string} type Component type @@ -417,6 +445,26 @@ export default class Component extends StyleableModel { return this.get('dmode') || ''; } + /** + * Set symbol override. + * By setting override to `true`, none of its property changes will be propagated to relative symbols. + * By setting override to specific properties, changes of those properties will be skipped from propagation. + * @param {Boolean|String|Array} value + * @example + * component.setSymbolOverride(['children', 'classes']); + */ + setSymbolOverride(value?: boolean | string | string[]) { + this.set(keySymbolOvrd, (isString(value) ? [value] : value) ?? 0); + } + + /** + * Get symbol override value. + * @returns {Boolean|Array} + */ + getSymbolOverride(): boolean | string[] | undefined { + return this.get(keySymbolOvrd); + } + /** * Find inner components by query string. * **ATTENTION**: this method works only with already rendered component @@ -708,8 +756,7 @@ export default class Component extends StyleableModel { if ( // Symbols should always have an id - this.__getSymbol() || - this.__getSymbols() || + isSymbol(this) || // Components with script should always have an id this.get('script-export') || this.get('script') @@ -792,254 +839,6 @@ export default class Component extends StyleableModel { return classStr ? classStr.split(' ') : []; } - __logSymbol(type: string, toUp: Component[], opts: any = {}) { - const symbol = this.__getSymbol(); - const symbols = this.__getSymbols(); - if (!symbol && !symbols) return; - this.em.log(type, { model: this, toUp, context: 'symbols', opts }); - } - - __initSymb() { - if (this.__symbReady) return; - this.on('change', this.__upSymbProps); - this.__symbReady = true; - } - - __isSymbol() { - return isArray(this.get(keySymbols)); - } - - __isSymbolOrInst() { - return !!(this.__isSymbol() || this.get(keySymbol)); - } - - __isSymbolTop() { - const parent = this.parent(); - const symb = this.__isSymbolOrInst(); - return symb && (!parent || (parent && !parent.__isSymbol() && !parent.__getSymbol())); - } - - __isSymbolNested() { - if (!this.__isSymbolOrInst() || this.__isSymbolTop()) return false; - const symbTopSelf = (this.__isSymbol() ? this : this.__getSymbol())!.__getSymbTop(); - const symbTop = this.__getSymbTop(); - const symbTopMain = symbTop.__isSymbol() ? symbTop : symbTop.__getSymbol(); - return symbTopMain !== symbTopSelf; - } - - __getAllById() { - const { em } = this; - return em ? em.Components.allById() : {}; - } - - __getSymbol(): Component | undefined { - let symb = this.get(keySymbol); - if (symb && isString(symb)) { - const ref = this.__getAllById()[symb]; - if (ref) { - symb = ref; - this.set(keySymbol, ref); - } else { - symb = 0; - } - } - return symb; - } - - __getSymbols(): Component[] | undefined { - let symbs = this.get(keySymbols); - if (symbs && isArray(symbs)) { - symbs.forEach((symb, idx) => { - if (symb && isString(symb)) { - symbs[idx] = this.__getAllById()[symb]; - } - }); - symbs = symbs.filter(symb => symb && !isString(symb)); - } - return symbs; - } - - __isSymbOvrd(prop = '') { - const ovrd = this.get(keySymbolOvrd); - const [prp] = prop.split(':'); - const props = prop !== prp ? [prop, prp] : [prop]; - return ovrd === true || (isArray(ovrd) && props.some(p => ovrd.indexOf(p) >= 0)); - } - - __getSymbToUp(opts: SymbolToUpOptions = {}) { - let result: Component[] = []; - const { changed } = opts; - - if ( - opts.fromInstance || - opts.noPropagate || - opts.fromUndo || - // Avoid updating others if the current component has override - (changed && this.__isSymbOvrd(changed)) - ) { - return result; - } - - const symbols = this.__getSymbols() || []; - const symbol = this.__getSymbol(); - const all = symbol ? [symbol, ...(symbol.__getSymbols() || [])] : symbols; - result = all - .filter(s => s !== this) - // Avoid updating those with override - .filter(s => !(changed && s.__isSymbOvrd(changed))); - - return result; - } - - __getSymbTop(opts?: any) { - let result: Component = this; - let parent = this.parent(opts); - - while (parent && (parent.__isSymbol() || parent.__getSymbol())) { - result = parent; - parent = parent.parent(opts); - } - - return result; - } - - __upSymbProps(m: any, opts: SymbolToUpOptions = {}) { - const changed = this.changedAttributes() || {}; - const attrs = changed.attributes || {}; - delete changed.status; - delete changed.open; - delete changed[keySymbols]; - delete changed[keySymbol]; - delete changed[keySymbolOvrd]; - delete changed.attributes; - delete attrs.id; - if (!isEmptyObj(attrs)) changed.attributes = attrs; - if (!isEmptyObj(changed)) { - const toUp = this.__getSymbToUp(opts); - // Avoid propagating overrides to other symbols - keys(changed).map(prop => { - if (this.__isSymbOvrd(prop)) delete changed[prop]; - }); - - this.__logSymbol('props', toUp, { opts, changed }); - toUp.forEach(child => { - const propsChanged = { ...changed }; - // Avoid updating those with override - keys(propsChanged).map(prop => { - if (child.__isSymbOvrd(prop)) delete propsChanged[prop]; - }); - child.set(propsChanged, { fromInstance: this, ...opts }); - }); - } - } - - __upSymbCls(m: any, c: any, opts = {}) { - const toUp = this.__getSymbToUp(opts); - this.__logSymbol('classes', toUp, { opts }); - toUp.forEach(child => { - // @ts-ignore This will propagate the change up to __upSymbProps - child.set('classes', this.get('classes'), { fromInstance: this }); - }); - this.__changesUp(opts); - } - - __upSymbComps(m: Component, c: Components, o: any) { - const optUp = o || c || {}; - const { fromInstance, fromUndo } = optUp; - const toUpOpts = { fromInstance, fromUndo }; - const isTemp = m.opt.temporary; - - // Reset - if (!o) { - const toUp = this.__getSymbToUp({ - ...toUpOpts, - changed: 'components:reset', - }); - // @ts-ignore - const cmps = m.models as Component[]; - this.__logSymbol('reset', toUp, { components: cmps }); - toUp.forEach(symb => { - const newMods = cmps.map(mod => mod.clone({ symbol: true })); - // @ts-ignore - symb.components().reset(newMods, { fromInstance: this, ...c }); - }); - // Add - } else if (o.add) { - let addedInstances: Component[] = []; - const isMainSymb = !!this.__getSymbols(); - const toUp = this.__getSymbToUp({ - ...toUpOpts, - changed: 'components:add', - }); - if (toUp.length) { - const addSymb = m.__getSymbol(); - addedInstances = (addSymb ? addSymb.__getSymbols() : m.__getSymbols()) || []; - addedInstances = [...addedInstances]; - addedInstances.push(addSymb ? addSymb : m); - } - !isTemp && - this.__logSymbol('add', toUp, { - opts: o, - addedInstances: addedInstances.map(c => c.cid), - added: m.cid, - }); - // Here, before appending a new symbol, I have to ensure there are no previously - // created symbols (eg. used mainly when drag components around) - toUp.forEach(symb => { - const symbTop = symb.__getSymbTop(); - const symbPrev = addedInstances.filter(addedInst => { - const addedTop = addedInst.__getSymbTop({ prev: 1 }); - return symbTop && addedTop && addedTop === symbTop; - })[0]; - const toAppend = symbPrev || m.clone({ symbol: true, symbolInv: isMainSymb }); - symb.append(toAppend, { fromInstance: this, ...o }); - }); - // Remove - } else { - // Remove instance reference from the symbol - const symb = m.__getSymbol(); - symb && - !o.temporary && - symb.set( - keySymbols, - symb.__getSymbols()!.filter(i => i !== m) - ); - - // Propagate remove only if the component is an inner symbol - if (!m.__isSymbolTop()) { - const changed = 'components:remove'; - const { index } = o; - const parent = m.parent(); - const opts = { fromInstance: m, ...o }; - const isSymbNested = m.__isSymbolNested(); - let toUpFn = (symb: Component) => { - const symbPrnt = symb.parent(); - symbPrnt && !symbPrnt.__isSymbOvrd(changed) && symb.remove(opts); - }; - // Check if the parent allows the removing - let toUp = !parent?.__isSymbOvrd(changed) ? m.__getSymbToUp(toUpOpts) : []; - - if (isSymbNested) { - toUp = parent?.__getSymbToUp({ ...toUpOpts, changed })!; - toUpFn = symb => { - const toRemove = symb.components().at(index); - toRemove && toRemove.remove({ fromInstance: parent, ...opts }); - }; - } - - !isTemp && - this.__logSymbol('remove', toUp, { - opts: o, - removed: m.cid, - isSymbNested, - }); - toUp.forEach(toUpFn); - } - } - - this.__changesUp(optUp); - } - initClasses(m?: any, c?: any, opts: any = {}) { const event = 'change:classes'; const { class: attrCls, ...restAttr } = this.get('attributes') || {}; @@ -1432,7 +1231,8 @@ export default class Component extends StyleableModel { * Override original clone method * @private */ - clone(opt: { symbol?: boolean; symbolInv?: boolean } = {}) { + /** @ts-ignore */ + clone(opt: { symbol?: boolean; symbolInv?: boolean } = {}): this { const em = this.em; const attr = { ...this.attributes }; const opts = { ...this.opt }; @@ -1447,7 +1247,7 @@ export default class Component extends StyleableModel { // @ts-ignore attr.traits = []; - if (this.__isSymbolTop()) { + if (isSymbolRoot(this)) { opt.symbol = true; } @@ -1483,32 +1283,32 @@ export default class Component extends StyleableModel { // Symbols // If I clone an inner symbol, I have to reset it cloned.set(keySymbols, 0); - const symbol = this.__getSymbol(); - const symbols = this.__getSymbols(); + const symbol = getSymbolMain(this); + const symbols = getSymbolInstances(this); if (!opt.symbol && (symbol || symbols)) { cloned.set(keySymbol, 0); cloned.set(keySymbols, 0); } else if (symbol) { // Contains already a reference to a symbol - symbol.set(keySymbols, [...symbol.__getSymbols()!, cloned]); - cloned.__initSymb(); + symbol.set(keySymbols, [...getSymbolInstances(symbol)!, cloned]); + initSymbol(cloned); } else if (opt.symbol) { // Request to create a symbol - if (this.__isSymbol()) { + if (isSymbolMain(this)) { // Already a symbol, cloned should be an instance this.set(keySymbols, [...symbols!, cloned]); cloned.set(keySymbol, this); - cloned.__initSymb(); + initSymbol(cloned); } else if (opt.symbolInv) { // Inverted, cloned is the instance, the origin is the main symbol this.set(keySymbols, [cloned]); cloned.set(keySymbol, this); - [this, cloned].map(i => i.__initSymb()); + [this, cloned].map(i => initSymbol(i)); } else { // Cloned becomes the main symbol cloned.set(keySymbols, [this]); - [this, cloned].map(i => i.__initSymb()); + [this, cloned].map(i => initSymbol(i)); this.set(keySymbol, cloned); } } diff --git a/src/dom_components/model/Components.ts b/src/dom_components/model/Components.ts index 5a41fb330..79ec7a944 100644 --- a/src/dom_components/model/Components.ts +++ b/src/dom_components/model/Components.ts @@ -16,6 +16,7 @@ import { import ComponentText from './ComponentText'; import ComponentWrapper from './ComponentWrapper'; import { ComponentsEvents } from '../types'; +import { isSymbolInstance, isSymbolRoot } from './SymbolUtils'; export const getComponentIds = (cmp?: Component | Component[] | Components, res: string[] = []) => { if (!cmp) return []; @@ -110,6 +111,10 @@ Component> { this.domc = opt.domc || em?.Components; } + get events() { + return this.domc?.events!; + } + resetChildren(models: Components, opts: { previousModels?: Component[]; keepIds?: string[] } = {}) { const coll = this; const prev = opts.previousModels || []; @@ -189,12 +194,14 @@ Component> { sels.remove(rulesRemoved.map(rule => rule.getSelectors().at(0))); if (!removed.opt.temporary) { - em.Commands.run('core:component-style-clear', { - target: removed, - }); + em.Commands.run('core:component-style-clear', { target: removed }); removed.removed(); removed.trigger('removed'); em.trigger(ComponentsEvents.remove, removed); + + if (domc && isSymbolInstance(removed) && isSymbolRoot(removed)) { + domc.symbols.__trgEvent(domc.events.symbolInstanceRemove, { component: removed }, true); + } } const inner = removed.components(); @@ -380,6 +387,10 @@ Component> { model.components().forEach(comp => triggerAdd(comp)); }; triggerAdd(model); + + if (domc && isSymbolInstance(model) && isSymbolRoot(model)) { + domc.symbols.__trgEvent(domc.events.symbolInstanceAdd, { component: model }, true); + } } } } diff --git a/src/dom_components/model/SymbolUtils.ts b/src/dom_components/model/SymbolUtils.ts new file mode 100644 index 000000000..830482c6b --- /dev/null +++ b/src/dom_components/model/SymbolUtils.ts @@ -0,0 +1,271 @@ +import { isArray, isString, keys } from 'underscore'; +import Component, { keySymbol, keySymbolOvrd, keySymbols } from './Component'; +import { SymbolToUpOptions } from './types'; +import { isEmptyObj } from '../../utils/mixins'; +import Components from './Components'; + +export const isSymbolMain = (cmp: Component) => isArray(cmp.get(keySymbols)); + +export const isSymbolInstance = (cmp: Component) => !!cmp.get(keySymbol); + +export const isSymbol = (cmp: Component) => !!(isSymbolMain(cmp) || isSymbolInstance(cmp)); + +export const isSymbolRoot = (symbol: Component) => { + const parent = symbol.parent(); + return isSymbol(symbol) && (!parent || !isSymbol(parent)); +}; + +export const isSymbolNested = (symbol: Component) => { + if (!isSymbol(symbol)) return false; + const symbTopSelf = getSymbolTop(isSymbolMain(symbol) ? symbol : getSymbolMain(symbol)!); + const symbTop = getSymbolTop(symbol); + const symbTopMain = isSymbolMain(symbTop) ? symbTop : getSymbolMain(symbTop); + return symbTopMain !== symbTopSelf; +}; + +export const initSymbol = (symbol: Component) => { + if (symbol.__symbReady) return; + symbol.on('change', symbol.__upSymbProps); + symbol.__symbReady = true; +}; + +export const getSymbolMain = (symbol: Component): Component | undefined => { + let result = symbol.get(keySymbol); + + if (result && isString(result)) { + const ref = symbol.__getAllById()[result]; + if (ref) { + result = ref; + symbol.set(keySymbol, ref); + } else { + result = 0; + } + } + + return result || undefined; +}; + +export const getSymbolInstances = (symbol?: Component): Component[] | undefined => { + let symbs = symbol?.get(keySymbols); + + if (symbs && isArray(symbs)) { + symbs.forEach((symb, idx) => { + if (symb && isString(symb)) { + symbs[idx] = symbol!.__getAllById()[symb]; + } + }); + symbs = symbs.filter(symb => symb && !isString(symb)); + } + + return symbs || undefined; +}; + +export const isSymbolOverride = (symbol?: Component, prop = '') => { + const ovrd = symbol?.get(keySymbolOvrd); + const [prp] = prop.split(':'); + const props = prop !== prp ? [prop, prp] : [prop]; + return ovrd === true || (isArray(ovrd) && props.some(p => ovrd.indexOf(p) >= 0)); +}; + +export const getSymbolsToUpdate = (symb: Component, opts: SymbolToUpOptions = {}) => { + let result: Component[] = []; + const { changed } = opts; + + if ( + opts.fromInstance || + opts.noPropagate || + opts.fromUndo || + // Avoid updating others if the current component has override + (changed && isSymbolOverride(symb, changed)) + ) { + return result; + } + + const symbols = getSymbolInstances(symb) || []; + const symbol = getSymbolMain(symb); + const all = symbol ? [symbol, ...(getSymbolInstances(symbol) || [])] : symbols; + result = all + .filter(s => s !== symb) + // Avoid updating those with override + .filter(s => !(changed && isSymbolOverride(s, changed))); + + return result; +}; + +export const getSymbolTop = (symbol: Component, opts?: any) => { + let result = symbol; + let parent = symbol.parent(opts); + + // while (parent && (isSymbolMain(parent) || getSymbol(parent))) { + while (parent && isSymbol(parent)) { + result = parent; + parent = parent.parent(opts); + } + + return result; +}; + +export const detachSymbolInstance = (symbol: Component, opts: { skipRefs?: boolean } = {}) => { + const symbolMain = getSymbolMain(symbol); + const symbs = symbolMain && getSymbolInstances(symbolMain); + !opts.skipRefs && + symbs && + symbolMain.set( + keySymbols, + symbs.filter(s => s !== symbol) + ); + symbol.set(keySymbol, 0); + symbol.components().forEach(s => detachSymbolInstance(s, opts)); +}; + +export const logSymbol = (symb: Component, type: string, toUp: Component[], opts: any = {}) => { + const symbol = getSymbolMain(symb); + const symbols = getSymbolInstances(symb); + + if (!symbol && !symbols) { + return; + } + + symb.em.log(type, { model: symb, toUp, context: 'symbols', opts }); +}; + +export const updateSymbolProps = (symbol: Component, opts: SymbolToUpOptions = {}) => { + const changed = symbol.changedAttributes() || {}; + const attrs = changed.attributes || {}; + delete changed.status; + delete changed.open; + delete changed[keySymbols]; + delete changed[keySymbol]; + delete changed[keySymbolOvrd]; + delete changed.attributes; + delete attrs.id; + + if (!isEmptyObj(attrs)) { + changed.attributes = attrs; + } + + if (!isEmptyObj(changed)) { + const toUp = getSymbolsToUpdate(symbol, opts); + // Avoid propagating overrides to other symbols + keys(changed).map(prop => { + if (isSymbolOverride(symbol, prop)) delete changed[prop]; + }); + + logSymbol(symbol, 'props', toUp, { opts, changed }); + toUp.forEach(child => { + const propsChanged = { ...changed }; + // Avoid updating those with override + keys(propsChanged).map(prop => { + if (isSymbolOverride(child, prop)) delete propsChanged[prop]; + }); + child.set(propsChanged, { fromInstance: symbol, ...opts }); + }); + } +}; + +export const updateSymbolCls = (symbol: Component, opts: any = {}) => { + const toUp = getSymbolsToUpdate(symbol, opts); + logSymbol(symbol, 'classes', toUp, { opts }); + toUp.forEach(child => { + // @ts-ignore This will propagate the change up to __upSymbProps + child.set('classes', symbol.get('classes'), { fromInstance: symbol }); + }); + symbol.__changesUp(opts); +}; + +export const updateSymbolComps = (symbol: Component, m: Component, c: Components, o: any) => { + const optUp = o || c || {}; + const { fromInstance, fromUndo } = optUp; + const toUpOpts = { fromInstance, fromUndo }; + const isTemp = m.opt.temporary; + + // Reset + if (!o) { + const toUp = getSymbolsToUpdate(symbol, { + ...toUpOpts, + changed: 'components:reset', + }); + // @ts-ignore + const cmps = m.models as Component[]; + logSymbol(symbol, 'reset', toUp, { components: cmps }); + toUp.forEach(symb => { + const newMods = cmps.map(mod => mod.clone({ symbol: true })); + // @ts-ignore + symb.components().reset(newMods, { fromInstance: symbol, ...c }); + }); + // Add + } else if (o.add) { + let addedInstances: Component[] = []; + const isMainSymb = !!getSymbolInstances(symbol); + const toUp = getSymbolsToUpdate(symbol, { + ...toUpOpts, + changed: 'components:add', + }); + if (toUp.length) { + const addSymb = getSymbolMain(m); + addedInstances = (addSymb ? getSymbolInstances(addSymb) : getSymbolInstances(m)) || []; + addedInstances = [...addedInstances]; + addedInstances.push(addSymb ? addSymb : m); + } + !isTemp && + logSymbol(symbol, 'add', toUp, { + opts: o, + addedInstances: addedInstances.map(c => c.cid), + added: m.cid, + }); + // Here, before appending a new symbol, I have to ensure there are no previously + // created symbols (eg. used mainly when drag components around) + toUp.forEach(symb => { + const symbTop = getSymbolTop(symb); + const symbPrev = addedInstances.filter(addedInst => { + const addedTop = getSymbolTop(addedInst, { prev: 1 }); + return symbTop && addedTop && addedTop === symbTop; + })[0]; + const toAppend = symbPrev || m.clone({ symbol: true, symbolInv: isMainSymb }); + symb.append(toAppend, { fromInstance: symbol, ...o }); + }); + // Remove + } else { + // Remove instance reference from the symbol + const symb = getSymbolMain(m); + symb && + !o.temporary && + symb.set( + keySymbols, + getSymbolInstances(symb)!.filter(i => i !== m) + ); + + // Propagate remove only if the component is an inner symbol + if (!isSymbolRoot(m)) { + const changed = 'components:remove'; + const { index } = o; + const parent = m.parent(); + const opts = { fromInstance: m, ...o }; + const isSymbNested = isSymbolRoot(m); + let toUpFn = (symb: Component) => { + const symbPrnt = symb.parent(); + symbPrnt && !isSymbolOverride(symbPrnt, changed) && symb.remove(opts); + }; + // Check if the parent allows the removing + let toUp = !isSymbolOverride(parent, changed) ? getSymbolsToUpdate(m, toUpOpts) : []; + + if (isSymbNested) { + toUp = parent! && getSymbolsToUpdate(parent, { ...toUpOpts, changed })!; + toUpFn = symb => { + const toRemove = symb.components().at(index); + toRemove && toRemove.remove({ fromInstance: parent, ...opts }); + }; + } + + !isTemp && + logSymbol(symbol, 'remove', toUp, { + opts: o, + removed: m.cid, + isSymbNested, + }); + toUp.forEach(toUpFn); + } + } + + symbol.__changesUp(optUp); +}; diff --git a/src/dom_components/model/Symbols.ts b/src/dom_components/model/Symbols.ts new file mode 100644 index 000000000..463b07c72 --- /dev/null +++ b/src/dom_components/model/Symbols.ts @@ -0,0 +1,56 @@ +import { debounce } from 'underscore'; +import { Debounced, ObjectAny } from '../../common'; +import Component from './Component'; +import Components from './Components'; +import { detachSymbolInstance, getSymbolInstances } from './SymbolUtils'; + +interface PropsComponentUpdate { + component: Component; + changed: ObjectAny; + options: ObjectAny; +} + +export default class Symbols extends Components { + refreshDbn: Debounced; + + constructor(...args: ConstructorParameters) { + super(...args); + this.refreshDbn = debounce(() => this.refresh(), 0); + const { events } = this; + this.on(events.update, this.onUpdate); + this.on(events.updateInside, this.onUpdateDeep); + } + + removeChildren(component: Component, coll?: Components, opts: any = {}) { + super.removeChildren(component, coll, opts); + getSymbolInstances(component)?.forEach(i => detachSymbolInstance(i, { skipRefs: true })); + this.__trgEvent(this.events.symbolMainRemove, { component }); + } + + onAdd(...args: Parameters) { + super.onAdd(...args); + const [component] = args; + this.__trgEvent(this.events.symbolMainAdd, { component }); + } + + onUpdate(props: PropsComponentUpdate) { + this.__trgEvent(this.events.symbolMainUpdate, props); + } + + onUpdateDeep(props: PropsComponentUpdate) { + this.__trgEvent(this.events.symbolMainUpdateDeep, props); + } + + refresh() { + const { em, events } = this; + em.trigger(events.symbol); + } + + __trgEvent(event: string, props: ObjectAny, isInstance = false) { + const { em, events } = this; + const eventType = isInstance ? events.symbolInstance : events.symbolMain; + em.trigger(event, props); + em.trigger(eventType, { ...props, event }); + this.refreshDbn(); + } +} diff --git a/src/dom_components/types.ts b/src/dom_components/types.ts index e8f3c0819..fbca11658 100644 --- a/src/dom_components/types.ts +++ b/src/dom_components/types.ts @@ -1,9 +1,21 @@ +import Component from './model/Component'; + export enum ActionLabelComponents { remove = 'component:remove', add = 'component:add', move = 'component:move', } +export interface SymbolInfo { + isSymbol: boolean; + isMain: boolean; + isInstance: boolean; + isRoot: boolean; + main?: Component; + instances: Component[]; + relatives: Component[]; +} + export enum ComponentsEvents { /** * @event `component:add` New component added. @@ -26,4 +38,69 @@ export enum ComponentsEvents { * editor.on('component:create', (component) => { ... }); */ create = 'component:create', + + /** + * @event `component:update` Component is updated, the component is passed as an argument to the callback. + * @example + * editor.on('component:update', (component) => { ... }); + */ + update = 'component:update', + updateInside = 'component:update-inside', + + /** + * @event `symbol:main:add` Added new main symbol. + * @example + * editor.on('symbol:main:add', ({ component }) => { ... }); + */ + symbolMainAdd = 'symbol:main:add', + + /** + * @event `symbol:main:update` Main symbol updated. + * @example + * editor.on('symbol:main:update', ({ component }) => { ... }); + */ + symbolMainUpdate = 'symbol:main:update', + symbolMainUpdateDeep = 'symbol:main:update-deep', + + /** + * @event `symbol:main:remove` Main symbol removed. + * @example + * editor.on('symbol:main:remove', ({ component }) => { ... }); + */ + symbolMainRemove = 'symbol:main:remove', + + /** + * @event `symbol:main` Catch-all event related to main symbol updates. + * @example + * editor.on('symbol:main', ({ event, component }) => { ... }); + */ + symbolMain = 'symbol:main', + + /** + * @event `symbol:instance:add` Added new root instance symbol. + * @example + * editor.on('symbol:instance:add', ({ component }) => { ... }); + */ + symbolInstanceAdd = 'symbol:instance:add', + + /** + * @event `symbol:instance:remove` Root instance symbol removed. + * @example + * editor.on('symbol:instance:remove', ({ component }) => { ... }); + */ + symbolInstanceRemove = 'symbol:instance:remove', + + /** + * @event `symbol:instance` Catch-all event related to instance symbol updates. + * @example + * editor.on('symbol:instance', ({ event, component }) => { ... }); + */ + symbolInstance = 'symbol:instance', + + /** + * @event `symbol` Catch-all event for any symbol update (main or instance). + * @example + * editor.on('symbol', () => { ... }); + */ + symbol = 'symbol', } diff --git a/src/dom_components/view/ComponentView.ts b/src/dom_components/view/ComponentView.ts index c67b552d5..ccf2a9264 100644 --- a/src/dom_components/view/ComponentView.ts +++ b/src/dom_components/view/ComponentView.ts @@ -293,8 +293,9 @@ TComp> { const { model, em } = this; if (avoidInline(em) && !opts.inline) { + // Move inline styles to CSSRule const styleOpts = this.__cmpStyleOpts; - const style = model.getStyle(styleOpts); + const style = model.getStyle({ inline: true, ...styleOpts }); !isEmpty(style) && model.setStyle(style, styleOpts); } else { this.setAttribute('style', model.styleToString(opts)); diff --git a/src/navigator/index.ts b/src/navigator/index.ts index 76c7ff471..38ffa2d5a 100644 --- a/src/navigator/index.ts +++ b/src/navigator/index.ts @@ -47,6 +47,7 @@ import EditorModel from '../editor/model/Editor'; import { hasWin, isComponent, isDef } from '../utils/mixins'; import defaults, { LayerManagerConfig } from './config/config'; import View from './view/ItemView'; +import { ComponentsEvents } from '../dom_components/types'; interface LayerData { name: string; @@ -74,7 +75,7 @@ const events = { const styleOpts = { mediaText: '' }; const propsToListen = ['open', 'status', 'locked', 'custom-name', 'components', 'classes'] - .map(p => `component:update:${p}`) + .map(p => `${ComponentsEvents.update}:${p}`) .join(' '); const isStyleHidden = (style: any = {}) => { diff --git a/src/rich_text_editor/index.ts b/src/rich_text_editor/index.ts index 54cd110c3..5aaac9f53 100644 --- a/src/rich_text_editor/index.ts +++ b/src/rich_text_editor/index.ts @@ -47,10 +47,11 @@ import { hasWin, isDef } from '../utils/mixins'; import defaults, { CustomRTE, RichTextEditorConfig } from './config/config'; import RichTextEditor, { RichTextEditorAction } from './model/RichTextEditor'; import CanvasEvents from '../canvas/types'; +import { ComponentsEvents } from '../dom_components/types'; export type RichTextEditorEvent = 'rte:enable' | 'rte:disable' | 'rte:custom'; -const eventsUp = `${CanvasEvents.refresh} frame:scroll component:update`; +const eventsUp = `${CanvasEvents.refresh} frame:scroll ${ComponentsEvents.update}`; export const evEnable = 'rte:enable'; export const evDisable = 'rte:disable'; diff --git a/src/selector_manager/index.ts b/src/selector_manager/index.ts index 7bf587984..25c13ca43 100644 --- a/src/selector_manager/index.ts +++ b/src/selector_manager/index.ts @@ -87,6 +87,7 @@ import { ItemManagerModule } from '../abstract/Module'; import { StyleModuleParam } from '../style_manager'; import StyleableModel from '../domain_abstract/model/StyleableModel'; import CssRule from '../css_composer/model/CssRule'; +import { ComponentsEvents } from '../dom_components/types'; export type SelectorEvent = 'selector:add' | 'selector:remove' | 'selector:update' | 'selector:state' | 'selector'; @@ -158,9 +159,9 @@ export default class SelectorManager extends ItemManagerModule em.trigger(evState, value)); this.model.on('change:cFirst', (m, value) => em.trigger('selector:type', value)); - em.on('component:toggled component:update:classes', this.__updateSelectedByComponents); - const listenTo = - 'component:toggled component:update:classes change:device styleManager:update selector:state selector:type style:target'; + const eventCmpUpdateCls = `${ComponentsEvents.update}:classes`; + em.on(`component:toggled ${eventCmpUpdateCls}`, this.__updateSelectedByComponents); + const listenTo = `component:toggled ${eventCmpUpdateCls} change:device styleManager:update selector:state selector:type style:target`; this.model.listenTo(em, listenTo, () => this.__update()); } diff --git a/src/selector_manager/view/ClassTagsView.ts b/src/selector_manager/view/ClassTagsView.ts index 050752304..9b92cc917 100644 --- a/src/selector_manager/view/ClassTagsView.ts +++ b/src/selector_manager/view/ClassTagsView.ts @@ -9,6 +9,7 @@ import Component from '../../dom_components/model/Component'; import Selector from '../model/Selector'; import Selectors from '../model/Selectors'; import CssRule from '../../css_composer/model/CssRule'; +import { ComponentsEvents } from '../../dom_components/types'; export default class ClassTagsView extends View { template({ labelInfo, labelHead, iconSync, iconAdd, pfx, ppfx }: any) { @@ -83,9 +84,10 @@ export default class ClassTagsView extends View { this.em = em; this.componentChanged = debounce(this.componentChanged.bind(this), 0); this.checkSync = debounce(this.checkSync.bind(this), 0); - const evClsUp = 'component:update:classes'; + const eventCmpUpdate = ComponentsEvents.update; + const evClsUp = `${eventCmpUpdate}:classes`; const toList = `component:toggled ${evClsUp}`; - const toListCls = `${evClsUp} component:update:attributes:id change:state`; + const toListCls = `${evClsUp} ${eventCmpUpdate}:attributes:id change:state`; this.listenTo(em, toList, this.componentChanged); this.listenTo(em, 'styleManager:update', this.componentChanged); this.listenTo(em, toListCls, this.__handleStateChange); diff --git a/src/style_manager/index.ts b/src/style_manager/index.ts index 38044baca..9bb5f2825 100644 --- a/src/style_manager/index.ts +++ b/src/style_manager/index.ts @@ -83,6 +83,7 @@ import { PropertySelectProps } from './model/PropertySelect'; import { PropertyNumberProps } from './model/PropertyNumber'; import PropertyStack, { PropertyStackProps } from './model/PropertyStack'; import PropertyComposite from './model/PropertyComposite'; +import { ComponentsEvents } from '../dom_components/types'; export type PropertyTypes = PropertyStackProps | PropertySelectProps | PropertyNumberProps; @@ -168,7 +169,8 @@ export default class StyleManager extends ItemManagerModule< this.model = model; // Triggers for the selection refresh and properties - const ev = 'component:toggled component:update:classes change:state change:device frame:resized selector:type'; + const eventCmpUpdate = ComponentsEvents.update; + const ev = `component:toggled ${eventCmpUpdate}:classes change:state change:device frame:resized selector:type`; this.upAll = debounce(() => this.__upSel(), 0); model.listenTo(em, ev, this.upAll as any); // Clear state target on any component selection change, without debounce (#4208) diff --git a/test/specs/dom_components/model/Component.ts b/test/specs/dom_components/model/Component.ts index 6a966ad11..2ab804178 100644 --- a/test/specs/dom_components/model/Component.ts +++ b/test/specs/dom_components/model/Component.ts @@ -50,7 +50,7 @@ describe('Component', () => { test('Clones correctly with traits', () => { obj.traits.at(0).set('value', 'testTitle'); var cloned = obj.clone(); - cloned.set('stylable', 0); + cloned.set('stylable', false); cloned.traits.at(0).set('value', 'testTitle2'); expect(obj.traits.at(0).get('value')).toEqual('testTitle'); expect(obj.get('stylable')).toEqual(true); diff --git a/test/specs/dom_components/model/Symbols.ts b/test/specs/dom_components/model/Symbols.ts index 2965334f2..c83609950 100644 --- a/test/specs/dom_components/model/Symbols.ts +++ b/test/specs/dom_components/model/Symbols.ts @@ -1,14 +1,28 @@ import Editor from '../../../../src/editor'; -import Component, { keySymbol, keySymbols, keySymbolOvrd } from '../../../../src/dom_components/model/Component'; +import Component, { keySymbol, keySymbols } from '../../../../src/dom_components/model/Component'; +import { isSymbolNested } from '../../../../src/dom_components/model/SymbolUtils'; describe('Symbols', () => { let editor: Editor; let wrapper: NonNullable>; + let cmps: Editor['Components']; + let um: Editor['UndoManager']; - const createSymbol = (comp: Component): Component => { - const symbol = comp.clone({ symbol: true }); - comp.parent()?.append(symbol, { at: comp.index() + 1 }); - return symbol; + const getSymbols = () => cmps.getSymbols(); + + const createSymbol = (component: Component) => cmps.addSymbol(component)!; + + const detachSymbol = (component: Component) => cmps.detachSymbol(component); + + const getSymbolInfo = ((comp, opts) => { + const result = cmps.getSymbolInfo(comp, opts); + // @ts-ignore skip for now from check + delete result.isRoot; + return result; + }) as Editor['Components']['getSymbolInfo']; + + const setSymbolOverride = (comp: Component, value: Parameters[0]) => { + comp.setSymbolOverride(value); }; const duplicate = (comp: Component): Component => { @@ -43,10 +57,9 @@ describe('Symbols', () => { return attr; }, }); - const getUm = (cmp: Component) => cmp.em.get('UndoManager'); const getInnerComp = (cmp: Component, i = 0) => cmp.components().at(i); - const getFirstInnSymbol = (cmp: Component) => getInnerComp(cmp).__getSymbol(); - const getInnSymbol = (cmp: Component, i = 0) => getInnerComp(cmp, i).__getSymbol(); + const getFirstInnSymbol = (cmp: Component) => getSymbolInfo(getInnerComp(cmp)).main; + const getInnSymbol = (cmp: Component, i = 0) => getSymbolInfo(getInnerComp(cmp, i)).main; const basicSymbUpdate = (cFrom: Component, cTo: Component) => { const rand = (Math.random() + 1).toString(36).slice(-7); const newAttr = { class: `cls-${rand}`, [`myattr-${rand}`]: `val-${rand}` }; @@ -58,66 +71,182 @@ describe('Symbols', () => { expect(toHTML(cFrom)).toBe(toHTML(cTo)); }; - beforeAll(() => { + beforeEach(() => { editor = new Editor(); - editor.getModel().get('PageManager').onLoad(); + editor.Components.postLoad(); + editor.Pages.onLoad(); wrapper = editor.getWrapper()!; + cmps = editor.Components; + um = editor.UndoManager; + editor.UndoManager.clear(); }); - afterAll(() => { - editor.destroy(); - }); - - beforeEach(() => {}); - afterEach(() => { - wrapper.components().reset(); + editor.destroy(); }); test("Simple clone doesn't create any symbol", () => { const comp = wrapper.append(simpleComp)[0]; const cloned = comp.clone(); [comp, cloned].forEach(item => { - expect(item.__getSymbol()).toBeFalsy(); - expect(item.__getSymbols()).toBeFalsy(); + expect(getSymbolInfo(item).isSymbol).toBeFalsy(); }); }); test('Create symbol from a component', () => { + expect(getSymbols()).toEqual([]); const comp = wrapper.append(simpleComp)[0]; + + expect(getSymbolInfo(comp)).toEqual({ + isSymbol: false, + isMain: false, + isInstance: false, + main: undefined, + instances: [], + relatives: [], + }); + const symbol = createSymbol(comp); - const symbs = symbol.__getSymbols(); - expect(symbol.__isSymbol()).toBe(true); - expect(comp.__getSymbol()).toBe(symbol); - expect(symbs?.length).toBe(1); - expect(symbs?.[0]).toBe(comp); + + expect(getSymbolInfo(symbol)).toEqual({ + isSymbol: true, + isMain: true, + isInstance: false, + main: symbol, + instances: [comp], + relatives: [comp], + }); + expect(getSymbolInfo(comp)).toEqual({ + isSymbol: true, + isMain: false, + isInstance: true, + main: symbol, + instances: [comp], + relatives: [symbol], + }); + expect(toHTML(comp)).toBe(toHTML(symbol)); + expect(getSymbols()).toEqual([symbol]); + // Symbols should have an id + expect(symbol.getAttributes().id).toEqual(symbol.getId()); + expect(comp.getAttributes().id).toEqual(comp.getId()); }); test('Create 1 symbol and clone the instance for another one', () => { const comp = wrapper.append(simpleComp)[0]; const symbol = createSymbol(comp); const comp2 = createSymbol(comp); - const symbs = symbol.__getSymbols(); - expect(symbs?.length).toBe(2); - expect(symbs?.[0]).toBe(comp); - expect(symbs?.[1]).toBe(comp2); - expect(comp2.__getSymbol()).toBe(symbol); + + const commonInfo = { + isSymbol: true, + main: symbol, + instances: [comp, comp2], + }; + + expect(getSymbolInfo(symbol)).toEqual({ + ...commonInfo, + isMain: true, + isInstance: false, + relatives: commonInfo.instances, + }); + expect(getSymbolInfo(comp)).toEqual({ + ...commonInfo, + isMain: false, + isInstance: true, + relatives: [symbol, comp2], + }); + expect(getSymbolInfo(comp2)).toEqual({ + ...commonInfo, + isMain: false, + isInstance: true, + relatives: [symbol, comp], + }); + expect(toHTML(comp2)).toBe(toHTML(symbol)); + expect(getSymbols()).toEqual([symbol]); }); test('Create 1 symbol and clone it to have another instance', () => { const comp = wrapper.append(simpleComp)[0]; const symbol = createSymbol(comp); const comp2 = createSymbol(symbol); - const symbs = symbol.__getSymbols(); - expect(symbs?.length).toBe(2); - expect(symbs?.[0]).toBe(comp); - expect(symbs?.[1]).toBe(comp2); - expect(comp2.__getSymbol()).toBe(symbol); + + const commonInfo = { + isSymbol: true, + main: symbol, + instances: [comp, comp2], + }; + + expect(getSymbolInfo(symbol)).toEqual({ + ...commonInfo, + isMain: true, + isInstance: false, + relatives: commonInfo.instances, + }); + expect(getSymbolInfo(comp)).toEqual({ + ...commonInfo, + isMain: false, + isInstance: true, + relatives: [symbol, comp2], + }); + expect(getSymbolInfo(comp2)).toEqual({ + ...commonInfo, + isMain: false, + isInstance: true, + relatives: [symbol, comp], + }); + expect(toHTML(comp2)).toBe(toHTML(symbol)); }); + test('When symbol is removed, all instances are detached', () => { + const comp = wrapper.append(compMultipleNodes)[0]; + const symbol = createSymbol(comp); + + [comp, ...comp.components().models].forEach(i => { + expect(getSymbolInfo(i).isInstance).toBe(true); + }); + + symbol.remove(); + + [comp, ...comp.components().models].forEach(i => { + expect(getSymbolInfo(i)).toEqual({ + isSymbol: false, + isMain: false, + isInstance: false, + instances: [], + relatives: [], + }); + }); + }); + + test('Detach symbol instance', () => { + const comp = wrapper.append(compMultipleNodes)[0]; + const symbol = createSymbol(comp); + const comp2 = createSymbol(comp); + + detachSymbol(comp); + + [comp, ...comp.components().models].forEach(i => { + expect(getSymbolInfo(i)).toEqual({ + isSymbol: false, + isMain: false, + isInstance: false, + instances: [], + relatives: [], + }); + }); + + expect(getSymbolInfo(comp2)).toEqual({ + isSymbol: true, + isMain: false, + isInstance: true, + main: symbol, + instances: [comp2], + relatives: [symbol], + }); + }); + test('Symbols and instances are correctly serialized', () => { const comp = wrapper.append(simpleComp)[0]; const symbol = createSymbol(comp); @@ -157,22 +286,131 @@ describe('Symbols', () => { attributes: { id: idSymb }, }; const [comp, symbol] = wrapper.append([defComp, defSymb]); - expect(comp.__getSymbol()).toBe(symbol); + + const commonInfo = { + isSymbol: true, + main: symbol, + instances: [comp], + }; + + expect(getSymbolInfo(symbol)).toEqual({ + ...commonInfo, + isMain: true, + isInstance: false, + relatives: commonInfo.instances, + }); + expect(getSymbolInfo(comp)).toEqual({ + ...commonInfo, + isMain: false, + isInstance: true, + relatives: [symbol], + }); + expect(comp.get(keySymbol)).toBe(symbol); - expect(symbol.__getSymbols()?.[0]).toBe(comp); expect(symbol.get(keySymbols)[0]).toBe(comp); basicSymbUpdate(comp, symbol); basicSymbUpdate(symbol, comp); }); + test('Symbols are properly stored in project data', () => { + expect(editor.getProjectData().symbols).toEqual([]); + const comp = wrapper.append(simpleComp)[0]; + const symbol = createSymbol(comp); + const symbolsJSON = [JSON.parse(JSON.stringify(symbol))]; + expect(editor.getProjectData().symbols).toEqual(symbolsJSON); + // Check post remove + symbol.remove(); + expect(editor.getProjectData().symbols).toEqual([]); + }); + + test('Symbols are properly loaded from project data', () => { + const idComp = 'c1'; + const idSymb = 's1'; + const projectData = { + symbols: [ + { + ...simpleCompDef, + [keySymbols]: [idComp], + attributes: { id: idSymb }, + }, + ], + pages: [ + { + id: 'page-1', + frames: [ + { + component: { + type: 'wrapper', + components: [ + { + ...simpleCompDef, + [keySymbol]: idSymb, + attributes: { id: idComp }, + }, + ], + }, + id: 'wrap-1', + }, + ], + }, + ], + }; + editor.loadProjectData(projectData); + const symbols = getSymbols(); + const symbol = symbols[0]; + const comp = cmps.getWrapper()!.components().at(0); + + const commonInfo = { + isSymbol: true, + main: symbol, + instances: [comp], + }; + + expect(getSymbolInfo(symbol)).toEqual({ + ...commonInfo, + isMain: true, + isInstance: false, + relatives: commonInfo.instances, + }); + expect(getSymbolInfo(comp)).toEqual({ + ...commonInfo, + isMain: false, + isInstance: true, + relatives: [symbol], + }); + + const symbolsJSON = JSON.parse(JSON.stringify(symbols)); + expect(editor.getProjectData().symbols).toEqual(symbolsJSON); + }); + test("Removing one instance doesn't affect others", () => { const comp = wrapper.append(simpleComp)[0]; const symbol = createSymbol(comp); const comp2 = createSymbol(comp); - expect(wrapper.components().length).toBe(3); - comp.remove(); + wrapper.append(comp2); + expect(wrapper.components().length).toBe(2); - expect(comp2.__getSymbol()).toBe(symbol); + comp.remove(); + expect(wrapper.components().models).toEqual([comp2]); + + const commonInfo = { + isSymbol: true, + main: symbol, + instances: [comp2], + }; + + expect(getSymbolInfo(symbol)).toEqual({ + ...commonInfo, + isMain: true, + isInstance: false, + relatives: [comp2], + }); + expect(getSymbolInfo(comp2)).toEqual({ + ...commonInfo, + isMain: false, + isInstance: true, + relatives: [symbol], + }); }); test('New component added to an instance is correctly propogated to all others', () => { @@ -185,21 +423,48 @@ describe('Symbols', () => { const allInst = [comp, comp2, comp3]; const all = [...allInst, symbol]; all.forEach(cmp => expect(cmp.components().length).toBe(compLen)); - expect(wrapper.components().length).toBe(4); + wrapper.append([comp2, comp3]); + expect(wrapper.components().length).toBe(3); // Append new component to one of the instances - const added = comp3.append(simpleComp, { at: 0 })[0]; + const comp3Added = comp3.append(simpleComp, { at: 0 })[0]; // The append should be propagated all.forEach(cmp => expect(cmp.components().length).toBe(compLen + 1)); // The new added component became part of the symbol instance - const addedSymb = added.__getSymbol(); const symbAdded = symbol.components().at(0); - expect(addedSymb).toBe(symbAdded); - allInst.forEach(cmp => expect(cmp.components().at(0).__getSymbol()).toBe(symbAdded)); - // The new main Symbol should keep the track of all instances - expect(symbAdded.__getSymbols()?.length).toBe(allInst.length); + const compAdded = comp.components().at(0); + const comp2Added = comp2.components().at(0); + const commonInfo = { + isSymbol: true, + main: symbAdded, + instances: [comp3Added, compAdded, comp2Added], // comp3 was edited first + }; + expect(getSymbolInfo(symbAdded)).toEqual({ + ...commonInfo, + isMain: true, + isInstance: false, + relatives: commonInfo.instances, + }); + expect(getSymbolInfo(compAdded)).toEqual({ + ...commonInfo, + isMain: false, + isInstance: true, + relatives: [symbAdded, comp3Added, comp2Added], + }); + expect(getSymbolInfo(comp2Added)).toEqual({ + ...commonInfo, + isMain: false, + isInstance: true, + relatives: [symbAdded, comp3Added, compAdded], + }); + expect(getSymbolInfo(comp3Added)).toEqual({ + ...commonInfo, + isMain: false, + isInstance: true, + relatives: [symbAdded, compAdded, comp2Added], + }); }); - describe('Creating 3 symbols in the wrapper', () => { + describe('Creating multiple symbols', () => { beforeEach(() => { comp = wrapper.append(compMultipleNodes)[0]; compInitChild = comp.components().length; @@ -208,6 +473,8 @@ describe('Symbols', () => { const comp3 = createSymbol(comp); allInst = [comp, comp2, comp3]; all = [...allInst, symbol]; + wrapper.append([comp2, comp3]); + editor.UndoManager.clear(); }); afterEach(() => { @@ -215,7 +482,7 @@ describe('Symbols', () => { }); test('The wrapper contains all the symbols', () => { - expect(wrapper.components().length).toBe(all.length); + expect(wrapper.components().length).toBe(allInst.length); }); test('All the symbols contain the same amount of children', () => { @@ -223,18 +490,18 @@ describe('Symbols', () => { }); test('Removing one instance, will remove the reference from the symbol', () => { - expect(symbol.__getSymbols()?.length).toBe(allInst.length); + expect(getSymbolInfo(symbol).instances.length).toBe(allInst.length); allInst[2].remove(); - expect(symbol.__getSymbols()?.length).toBe(allInst.length - 1); + expect(getSymbolInfo(symbol).instances.length).toBe(allInst.length - 1); }); test('Removing one instance, works with UndoManager', done => { setTimeout(() => { // This will commit the undo - const um = getUm(comp); allInst[0].remove(); um.undo(); - expect(symbol.__getSymbols()?.length).toBe(allInst.length); + expect(wrapper.components().length).toBe(allInst.length); + expect(getSymbolInfo(symbol).instances.length).toBe(allInst.length); done(); }); }); @@ -243,7 +510,15 @@ describe('Symbols', () => { const added = symbol.append(simpleComp, { at: 0 })[0]; all.forEach(cmp => expect(cmp.components().length).toBe(compInitChild + 1)); // Check symbol references - expect(added.__getSymbols()?.length).toBe(allInst.length); + const addedInstances = allInst.map(cmp => cmp.components().at(0)); + expect(getSymbolInfo(added)).toEqual({ + isSymbol: true, + isMain: true, + isInstance: false, + main: added, + instances: addedInstances, + relatives: addedInstances, + }); allInst.forEach(cmp => expect(getFirstInnSymbol(cmp)).toBe(added)); }); @@ -251,14 +526,21 @@ describe('Symbols', () => { const added = comp.append(simpleComp, { at: 0 })[0]; all.forEach(cmp => expect(cmp.components().length).toBe(compInitChild + 1)); // Check symbol references - const addSymb = added.__getSymbol(); - expect(symbol.components().at(0)).toBe(addSymb); + const addSymb = symbol.components().at(0); + const addedInstances = allInst.map(cmp => cmp.components().at(0)); + expect(getSymbolInfo(added)).toEqual({ + isSymbol: true, + isMain: false, + isInstance: true, + main: addSymb, + instances: addedInstances, + relatives: [addSymb, ...addedInstances.filter(s => s !== added)], + }); allInst.forEach(cmp => expect(getFirstInnSymbol(cmp)).toBe(addSymb)); }); test('Adding a new component to an instance of the symbol, works correctly with Undo Manager', () => { const added = comp.append(simpleComp, { at: 0 })[0]; - const um = getUm(added); um.undo(); all.forEach(cmp => expect(cmp.components().length).toBe(compInitChild)); um.redo(); @@ -266,8 +548,16 @@ describe('Symbols', () => { um.redo(); // check multiple undo/redo all.forEach(cmp => expect(cmp.components().length).toBe(compInitChild + 1)); // Check symbol references - const addSymbs = added.__getSymbol()?.__getSymbols(); - expect(addSymbs?.length).toBe(allInst.length); + const addSymb = symbol.components().at(0); + const addedInstances = allInst.map(cmp => cmp.components().at(0)); + expect(getSymbolInfo(added)).toEqual({ + isSymbol: true, + isMain: false, + isInstance: true, + main: addSymb, + instances: addedInstances, + relatives: [addSymb, ...addedInstances.filter(s => s !== added)], + }); }); test('Moving a new added component in the instance, will propagate the action in all symbols', () => { @@ -277,25 +567,51 @@ describe('Symbols', () => { added.move(comp, { at: 0 }); expect(added.index()).toBe(0); // extra checks expect(added.parent()).toBe(comp); - const symbRef = added.__getSymbol(); + + const addSymb = symbol.components().at(0); + const addedInstances = allInst.map(cmp => cmp.components().at(0)); + const commonInfo = { + isSymbol: true, + main: addSymb, + instances: addedInstances, + }; + expect(getSymbolInfo(added)).toEqual({ + ...commonInfo, + isMain: false, + isInstance: true, + relatives: [addSymb, ...addedInstances.filter(s => s !== added)], + }); + expect(getSymbolInfo(addSymb)).toEqual({ + ...commonInfo, + isMain: true, + isInstance: false, + relatives: addedInstances, + }); + // All symbols still have the same amount of components all.forEach(cmp => expect(cmp.components().length).toBe(newChildLen)); // All instances refer to the same symbol - allInst.forEach(cmp => expect(getFirstInnSymbol(cmp)).toBe(symbRef)); - // The moved symbol contains all its instances - expect(getInnerComp(symbol).__getSymbols()?.length).toBe(allInst.length); + allInst.forEach(cmp => expect(getFirstInnSymbol(cmp)).toBe(addSymb)); }); test('Moving a new added component in the symbol, will propagate the action in all instances', () => { - const added = symbol.append(simpleComp)[0]; + const addSymb = symbol.append(simpleComp)[0]; const newChildLen = compInitChild + 1; - added.move(symbol, { at: 0 }); + addSymb.move(symbol, { at: 0 }); // All symbols still have the same amount of components all.forEach(cmp => expect(cmp.components().length).toBe(newChildLen)); // All instances refer to the same symbol - allInst.forEach(cmp => expect(getFirstInnSymbol(cmp)).toBe(added)); + allInst.forEach(cmp => expect(getFirstInnSymbol(cmp)).toBe(addSymb)); // The moved symbol contains all its instances - expect(added.__getSymbols()?.length).toBe(allInst.length); + const addedInstances = allInst.map(cmp => cmp.components().at(0)); + expect(getSymbolInfo(addSymb)).toEqual({ + isSymbol: true, + isMain: true, + isInstance: false, + main: addSymb, + instances: addedInstances, + relatives: addedInstances, + }); }); test('Adding a class, reflects changes to all symbols', () => { @@ -352,14 +668,28 @@ describe('Symbols', () => { const clonedSymb = symbol.components().at(1); const newLen = comp.components().length; expect(newLen).toBe(compInitChild + 1); - expect(cloned.__getSymbol()).toBe(clonedSymb); // All symbols have the same amount of components all.forEach(cmp => expect(cmp.components().length).toBe(newLen)); // All instances refer to the same symbol allInst.forEach(cmp => expect(getInnSymbol(cmp, 1)).toBe(clonedSymb)); - // Symbol contains the reference of instances - const innerSymb = allInst.map(i => getInnerComp(i, 1)); - expect(clonedSymb.__getSymbols()).toEqual(innerSymb); + + const commonInfo = { + isSymbol: true, + main: clonedSymb, + instances: allInst.map(cmp => cmp.components().at(1)), + }; + expect(getSymbolInfo(cloned)).toEqual({ + ...commonInfo, + isMain: false, + isInstance: true, + relatives: [clonedSymb, ...commonInfo.instances.filter(i => i !== cloned)], + }); + expect(getSymbolInfo(clonedSymb)).toEqual({ + ...commonInfo, + isMain: true, + isInstance: false, + relatives: commonInfo.instances, + }); }); test('Cloning a component in a symbol, reflects changes to all instances', () => { @@ -368,35 +698,51 @@ describe('Symbols', () => { const newLen = symbol.components().length; // As above expect(newLen).toBe(compInitChild + 1); - expect(cloned.__getSymbol()).toBe(clonedSymb); all.forEach(cmp => expect(cmp.components().length).toBe(newLen)); allInst.forEach(cmp => expect(getInnSymbol(cmp, 1)).toBe(clonedSymb)); - const innerSymb = allInst.map(i => getInnerComp(i, 1)); - expect(clonedSymb.__getSymbols()).toEqual(innerSymb); + + const commonInfo = { + isSymbol: true, + main: clonedSymb, + instances: allInst.map(cmp => cmp.components().at(1)), + }; + expect(getSymbolInfo(cloned)).toEqual({ + ...commonInfo, + isMain: false, + isInstance: true, + relatives: [clonedSymb, ...commonInfo.instances.filter(i => i !== cloned)], + }); + expect(getSymbolInfo(clonedSymb)).toEqual({ + ...commonInfo, + isMain: true, + isInstance: false, + relatives: commonInfo.instances, + }); }); describe('Symbols override', () => { - test('Symbol with override returns correctly instances to update', () => { - expect(symbol.__getSymbToUp().length).toBe(allInst.length); + test('Symbol with override returns correctly relatives to update', () => { + expect(getSymbolInfo(symbol).relatives).toEqual(allInst); // With override as `true`, it will return empty array with any 'changed' - symbol.set(keySymbolOvrd, true); - expect(symbol.__getSymbToUp({ changed: 'anything' }).length).toBe(0); + setSymbolOverride(symbol, true); + expect(getSymbolInfo(symbol, { withChanges: 'anything' }).relatives).toEqual([]); // With override as an array with props, changed option will count - symbol.set(keySymbolOvrd, ['components']); - expect(symbol.__getSymbToUp({ changed: 'anything' }).length).toBe(allInst.length); - symbol.set(keySymbolOvrd, ['components']); - expect(symbol.__getSymbToUp({ changed: 'components' }).length).toBe(0); - expect(symbol.__getSymbToUp({ changed: 'components:reset' }).length).toBe(0); + setSymbolOverride(symbol, ['components']); + expect(getSymbolInfo(symbol, { withChanges: 'anything' }).relatives).toEqual(allInst); + expect(getSymbolInfo(symbol, { withChanges: 'components' }).relatives).toEqual([]); + expect(getSymbolInfo(symbol, { withChanges: 'components:reset' }).relatives).toEqual([]); // Support also overrides with type of actions - symbol.set(keySymbolOvrd, ['components:change']); // specific change - expect(symbol.__getSymbToUp({ changed: 'components' }).length).toBe(allInst.length); - expect(symbol.__getSymbToUp({ changed: 'components:change' }).length).toBe(0); + // symbol.set(keySymbolOvrd, ['components:change']); // specific change + setSymbolOverride(symbol, 'components:change'); + expect(getSymbolInfo(symbol, { withChanges: 'components' }).relatives).toEqual(allInst); + expect(getSymbolInfo(symbol, { withChanges: 'components:change' }).relatives).toEqual([]); + expect(getSymbolInfo(symbol, { withChanges: 'components:reset' }).relatives).toEqual(allInst); }); - test('Symbol is not propagating props data if override is set', () => { + test('Symbol is not propagating props changes if override is set', () => { const propKey = 'someprop'; const propValue = 'somevalue'; - symbol.set(keySymbolOvrd, true); + setSymbolOverride(symbol, true); // Single prop update symbol.set(propKey, propValue); allInst.forEach(cmp => expect(cmp.get(propKey)).toBeFalsy()); @@ -406,8 +752,11 @@ describe('Symbols', () => { expect(cmp.get('prop1')).toBeFalsy(); expect(cmp.get('prop2')).toBeFalsy(); }); + }); + + test('Symbol is propagating properly props changes not indicated in override', () => { // Override applied on specific properties - symbol.set(keySymbolOvrd, ['prop1']); + setSymbolOverride(symbol, 'prop1'); symbol.set({ prop1: 'value1-2', prop2: 'value2-2' }); allInst.forEach(cmp => { expect(cmp.get('prop1')).toBeFalsy(); @@ -415,10 +764,10 @@ describe('Symbols', () => { }); }); - test('On symbol props update, those having override are ignored', () => { + test('On symbol update propagation, those having override are ignored', () => { const propKey = 'someprop'; const propValue = 'somevalue'; - comp.set(keySymbolOvrd, true); + setSymbolOverride(comp, true); symbol.set(propKey, propValue); // All symbols are updated except the one with override all.forEach(cmp => { @@ -428,7 +777,7 @@ describe('Symbols', () => { expect(cmp.get(propKey)).toBe(propValue); } }); - comp.set(keySymbolOvrd, ['prop1']); + setSymbolOverride(comp, ['prop1']); symbol.set({ prop1: 'value1', prop2: 'value2' }); // Only the overrided property is ignored all.forEach(cmp => { @@ -443,7 +792,7 @@ describe('Symbols', () => { }); test('Symbol is not propagating components data if override is set', () => { - symbol.set(keySymbolOvrd, ['components']); + setSymbolOverride(symbol, ['components']); const innCompsLen = symbol.components().length; all.forEach(cmp => expect(cmp.components().length).toBe(innCompsLen)); symbol.components('Test text'); @@ -458,7 +807,7 @@ describe('Symbols', () => { }); test('Symbol is not removing components data if override is set', () => { - symbol.set(keySymbolOvrd, ['components']); + setSymbolOverride(symbol, ['components']); const innCompsLen = symbol.components().length; symbol.components().at(0).remove(); expect(symbol.components().length).toBe(innCompsLen - 1); @@ -466,14 +815,14 @@ describe('Symbols', () => { }); test('Symbol is not propagating remove on instances with ovverride', () => { - comp.set(keySymbolOvrd, ['components']); + setSymbolOverride(comp, ['components']); const innCompsLen = symbol.components().length; symbol.components().at(0).remove(); all.forEach(cmp => expect(cmp.components().length).toBe(cmp === comp ? innCompsLen : innCompsLen - 1)); }); test('On symbol components update, those having override are ignored', () => { - comp.set(keySymbolOvrd, ['components']); + setSymbolOverride(comp, ['components']); const innCompsLen = comp.components().length; // Check reset action symbol.components('Test text'); @@ -503,14 +852,18 @@ describe('Symbols', () => { }); describe('Nested symbols', () => { + let comp2: Component; + let comp3: Component; + beforeEach(() => { comp = wrapper.append(compMultipleNodes)[0]; compInitChild = comp.components().length; symbol = createSymbol(comp); - const comp2 = createSymbol(comp); - const comp3 = createSymbol(comp); + comp2 = createSymbol(comp); + comp3 = createSymbol(comp); allInst = [comp, comp2, comp3]; - all = [...allInst, symbol]; + all = [symbol, ...allInst]; + wrapper.append([comp2, comp3]); // Second symbol secComp = wrapper.append(simpleComp2)[0]; secSymbol = createSymbol(secComp); @@ -521,51 +874,79 @@ describe('Symbols', () => { }); test('Second symbol created properly', () => { - const symbs = secSymbol.__getSymbols()!; - expect(secSymbol.__isSymbol()).toBe(true); - expect(secComp.__getSymbol()).toBe(secSymbol); - expect(symbs.length).toBe(1); - expect(symbs[0]).toBe(secComp); + expect(getSymbolInfo(secSymbol)).toEqual({ + isSymbol: true, + isMain: true, + isInstance: false, + main: secSymbol, + instances: [secComp], + relatives: [secComp], + }); expect(toHTML(secComp)).toBe(toHTML(secSymbol)); }); test('Adding the instance, of the second symbol, inside the first symbol, propagates correctly to all first instances', () => { const added = symbol.append(secComp)[0]; - expect(added.__isSymbolNested()).toBe(true); - // The added component is still the second instance expect(added).toBe(secComp); - // The added component still has the reference to the second symbol - expect(added.__getSymbol()).toBe(secSymbol); - // The main second symbol now has the reference to all its instances - const secInstans = secSymbol.__getSymbols()!; - expect(secInstans.length).toBe(all.length); + + const allAdded = all.map(s => s.components().last()); + expect(getSymbolInfo(secComp)).toEqual({ + isSymbol: true, + isMain: false, + isInstance: true, + main: secSymbol, + instances: allAdded, + relatives: [secSymbol, ...allAdded.filter(s => s !== secComp)], + }); + + expect(isSymbolNested(added)).toBe(true); // All instances still refer to the second symbol - secInstans.forEach(secInst => expect(secInst.__getSymbol()).toBe(secSymbol)); + allAdded.forEach(secInst => expect(getSymbolInfo(secInst).main).toBe(secSymbol)); }); test('Adding the instance, of the second symbol, inside one of the first instances, propagates correctly to all first symbols', () => { const added = comp.append(secComp)[0]; - // The added component is still the second instance expect(added).toBe(secComp); - // The added component still has the reference to the second symbol - expect(added.__getSymbol()).toBe(secSymbol); - // The main second symbol now has the reference to all its instances - const secInstans = secSymbol.__getSymbols()!; - expect(secInstans.length).toBe(all.length); + + const allAdded = [comp, symbol, comp2, comp3].map(s => s.components().last()); + expect(getSymbolInfo(secComp)).toEqual({ + isSymbol: true, + isMain: false, + isInstance: true, + main: secSymbol, + instances: allAdded, + relatives: [secSymbol, ...allAdded.filter(s => s !== secComp)], + }); + // All instances still refer to the second symbol - secInstans.forEach(secInst => expect(secInst.__getSymbol()).toBe(secSymbol)); + allAdded.forEach(s => expect(getSymbolInfo(s).main).toBe(secSymbol)); }); test('Adding the instance, of the second symbol, inside one of the first instances, and then removing it, will not affect second instances outside', () => { const secComp2 = createSymbol(secComp); const added = comp.append(secComp)[0]; - expect(secComp2.__isSymbolNested()).toBe(false); - const secInstans = secSymbol.__getSymbols()!; - expect(secInstans.length).toBe(all.length + 1); // + 1 is secComp2 + const allAdded = [comp, symbol, comp2, comp3].map(s => s.components().last()); + allAdded.splice(1, 0, secComp2); + expect(getSymbolInfo(secComp2)).toEqual({ + isSymbol: true, + isMain: false, + isInstance: true, + main: secSymbol, + instances: allAdded, + relatives: [secSymbol, ...allAdded.filter(s => s !== secComp2)], + }); + expect(isSymbolNested(secComp2)).toBe(false); // Remove the second instance, added in one of the first instances added.remove(); - // All first symbols will remove their copy and only the secComp2 will remain - expect(secSymbol.__getSymbols()?.length).toBe(1); + // Only the secComp2 will remain + expect(getSymbolInfo(secSymbol)).toEqual({ + isSymbol: true, + isMain: true, + isInstance: false, + main: secSymbol, + instances: [secComp2], + relatives: [secComp2], + }); // First symbols has the previous number of components inside all.forEach(s => expect(s.components().length).toBe(compInitChild)); }); @@ -574,14 +955,24 @@ describe('Symbols', () => { const added = comp.append(secComp)[0]; expect(added.parent()).toBe(comp); // extra checks expect(added.index()).toBe(compInitChild); - const secInstansArr = secSymbol.__getSymbols()?.map(i => i.cid) || []; + const secInstansArr = getSymbolInfo(secSymbol).instances.map(i => i.cid); expect(secInstansArr.length).toBe(all.length); added.move(comp, { at: 0 }); // After the move, the symbol still have the same references - const secInstansArr2 = secSymbol.__getSymbols()?.map(i => i.cid); + const secInstansArr2 = getSymbolInfo(secSymbol).instances.map(i => i.cid); expect(secInstansArr2).toEqual(secInstansArr); // All second instances refer to the same second symbol all.forEach(c => expect(getFirstInnSymbol(c)).toBe(secSymbol)); + + const allAdded = [comp, symbol, comp2, comp3].map(s => s.components().first()); + expect(getSymbolInfo(secSymbol)).toEqual({ + isSymbol: true, + isMain: true, + isInstance: false, + main: secSymbol, + instances: allAdded, + relatives: allAdded, + }); }); }); });