From 1c8506eb7b883a82b765b9ea4f26b1a53ce8e320 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Mon, 28 Jul 2025 11:13:04 +0400 Subject: [PATCH 01/17] Improve TS for custom file upload option (#6578) Fix D&D file upload TS --- .../core/src/asset_manager/config/config.ts | 4 +++- packages/core/src/asset_manager/index.ts | 7 ++---- packages/core/src/asset_manager/types.ts | 17 ++++++++++++++ .../src/asset_manager/view/FileUploader.ts | 13 ++++++----- .../dom_components/view/ComponentImageView.ts | 9 +++++--- packages/core/src/utils/Droppable.ts | 22 +++++++++++-------- 6 files changed, 48 insertions(+), 24 deletions(-) diff --git a/packages/core/src/asset_manager/config/config.ts b/packages/core/src/asset_manager/config/config.ts index 34ca1c593..adb15c7c5 100644 --- a/packages/core/src/asset_manager/config/config.ts +++ b/packages/core/src/asset_manager/config/config.ts @@ -1,3 +1,5 @@ +import { UploadFileFn } from '../types'; + export interface AssetManagerConfig { /** * Default assets. @@ -83,7 +85,7 @@ export interface AssetManagerConfig { * // ...send somewhere * } */ - uploadFile?: (ev: DragEvent) => void; + uploadFile?: UploadFileFn; /** * In the absence of 'uploadFile' or 'upload' assets will be embedded as Base64. * @default true diff --git a/packages/core/src/asset_manager/index.ts b/packages/core/src/asset_manager/index.ts index cfee7a7b4..5dd338121 100644 --- a/packages/core/src/asset_manager/index.ts +++ b/packages/core/src/asset_manager/index.ts @@ -40,13 +40,10 @@ import { ProjectData } from '../storage_manager'; import defConfig, { AssetManagerConfig } from './config/config'; import Asset from './model/Asset'; import Assets from './model/Assets'; -import AssetsEvents, { AssetOpenOptions } from './types'; +import AssetsEvents, { AssetAddInput, AssetOpenOptions, AssetProps } from './types'; import AssetsView from './view/AssetsView'; import FileUploaderView from './view/FileUploader'; -// TODO -type AssetProps = Record; - const assetCmd = 'open-assets'; export default class AssetManager extends ItemManagerModule { @@ -153,7 +150,7 @@ export default class AssetManager extends ItemManagerModule void; types?: string[]; @@ -9,6 +12,20 @@ export interface AssetOpenOptions { target?: any; } +export interface AssetProps { + src: string; + [key: string]: unknown; +} + +export interface UploadFileOptions { + componentView?: ComponentView; + file?: File; +} + +export type UploadFileClb = (result: { data: (AssetProps | string)[] }) => void; + +export type UploadFileFn = (ev: DragEvent, clb?: UploadFileClb, opts?: UploadFileOptions) => Promise | undefined; + /**{START_EVENTS}*/ export enum AssetsEvents { /** diff --git a/packages/core/src/asset_manager/view/FileUploader.ts b/packages/core/src/asset_manager/view/FileUploader.ts index e8cef8c58..208298333 100644 --- a/packages/core/src/asset_manager/view/FileUploader.ts +++ b/packages/core/src/asset_manager/view/FileUploader.ts @@ -4,6 +4,7 @@ import EditorModel from '../../editor/model/Editor'; import fetch from '../../utils/fetch'; import html from '../../utils/html'; import { AssetManagerConfig } from '../config/config'; +import { UploadFileClb, UploadFileOptions } from '../types'; type FileUploaderTemplateProps = { pfx: string; @@ -53,7 +54,7 @@ export default class FileUploaderView extends View { constructor(opts: any = {}) { super(opts); this.options = opts; - const c = opts.config || {}; + const c = (opts.config || {}) as AssetManagerConfig & { pStylePrefix?: string; disableUpload?: boolean }; this.module = opts.module; this.config = c; // @ts-ignore @@ -113,7 +114,7 @@ export default class FileUploaderView extends View { * @param {string} text Response text * @private */ - onUploadResponse(text: string, clb?: (json: any) => void) { + onUploadResponse(text: string, clb?: UploadFileClb) { const { module, config, target } = this; let json; try { @@ -138,9 +139,9 @@ export default class FileUploaderView extends View { * @return {Promise} * @private * */ - uploadFile(e: DragEvent, clb?: () => void) { - // @ts-ignore - const files = e.dataTransfer ? e.dataTransfer.files : e.target.files; + uploadFile(e: DragEvent, clb?: UploadFileClb, opts?: UploadFileOptions) { + opts; // Options are not used here but can be used by the custom uploadFile function + const files = e.dataTransfer ? e.dataTransfer.files : ((e.target as any)?.files as FileList); const { config } = this; const { beforeUpload } = config; @@ -293,7 +294,7 @@ export default class FileUploaderView extends View { return this; } - static embedAsBase64(e: DragEvent, clb?: () => void) { + static embedAsBase64(e: DragEvent, clb?: UploadFileClb) { // List files dropped // @ts-ignore const files = e.dataTransfer ? e.dataTransfer.files : e.target.files; diff --git a/packages/core/src/dom_components/view/ComponentImageView.ts b/packages/core/src/dom_components/view/ComponentImageView.ts index 19d9de56b..ada10c3c7 100644 --- a/packages/core/src/dom_components/view/ComponentImageView.ts +++ b/packages/core/src/dom_components/view/ComponentImageView.ts @@ -40,14 +40,17 @@ export default class ComponentImageView { + } as unknown as DragEvent, + (res) => { const obj = res && res.data && res.data[0]; const src = obj && (isString(obj) ? obj : obj.src); src && model.set({ src }); }, + { + componentView: this, + file, + }, ); model.set('file', ''); } diff --git a/packages/core/src/utils/Droppable.ts b/packages/core/src/utils/Droppable.ts index 368503165..5d5a8aac7 100644 --- a/packages/core/src/utils/Droppable.ts +++ b/packages/core/src/utils/Droppable.ts @@ -1,12 +1,12 @@ import { bindAll, indexOf } from 'underscore'; import CanvasModule from '../canvas'; import { ObjectStrings } from '../common'; +import Component from '../dom_components/model/Component'; import EditorModel from '../editor/model/Editor'; import { getDocumentScroll, off, on } from './dom'; -import { DragDirection, DragSource } from './sorter/types'; import CanvasNewComponentNode from './sorter/CanvasNewComponentNode'; import ComponentSorter from './sorter/ComponentSorter'; -import Component from '../dom_components/model/Component'; +import { DragDirection, DraggableContent, DragSource } from './sorter/types'; // TODO move in sorter type SorterOptions = { @@ -233,7 +233,7 @@ export default class Droppable { handleDrop(ev: Event | DragEvent) { ev.preventDefault(); const dt = (ev as DragEvent).dataTransfer; - const content = this.getContentByData(dt).content; + const content = this.getContentByData(dt!).content || ''; if (this.draggedNode) { this.draggedNode.content = content; } @@ -241,12 +241,12 @@ export default class Droppable { this.endDrop(!content, ev); } - getContentByData(dt: any) { + getContentByData(dt?: DataTransfer) { const em = this.em; - const types = dt && dt.types; - const files = (dt && dt.files) || []; + const types = dt?.types || []; + const files = dt?.files || []; const dragSource: DragSource = em.get('dragSource'); - let content = dt && dt.getData('text'); + let content: DraggableContent['content'] = dt?.getData('text') || ''; if (files.length) { content = []; @@ -280,9 +280,13 @@ export default class Droppable { content = `
${content}
`; } - const result = { content }; + const result = { + content, + setContent(content: DraggableContent['content']) { + result.content = content; + }, + }; em.trigger('canvas:dragdata', dt, result); - return result; } } From aa77f729641ce7d96efd8c0b72f56a524b85908b Mon Sep 17 00:00:00 2001 From: markdanial Date: Wed, 30 Jul 2025 03:56:59 -0700 Subject: [PATCH 02/17] Update Contributing link in README.md (#6579) Changes from master to dev in url of contributing doc --- packages/core/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/README.md b/packages/core/README.md index 5c82fde5a..41a8b6417 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -33,7 +33,7 @@ Newsletter Demo - http://grapesjs.com/demo-newsletter-editor.html
- [Plugins](#plugins) - [Support](#support) - [Changelog](https://github.com/GrapesJS/grapesjs/releases) -- [Contributing](https://github.com/GrapesJS/grapesjs/blob/master/CONTRIBUTING.md) +- [Contributing](https://github.com/GrapesJS/grapesjs/blob/dev/CONTRIBUTING.md) - [License](#license) ## Features @@ -87,7 +87,7 @@ For a more practical example I'd suggest looking up the code inside this demo: h ## Development -Follow the [Contributing Guide](https://github.com/GrapesJS/grapesjs/blob/master/CONTRIBUTING.md). +Follow the [Contributing Guide](https://github.com/GrapesJS/grapesjs/blob/dev/CONTRIBUTING.md). ## Documentation From 45659ece4393ec16e90ce5315cad2123721345bb Mon Sep 17 00:00:00 2001 From: mohamed yahia Date: Thu, 28 Aug 2025 20:03:13 +0300 Subject: [PATCH 03/17] Data Binding Fixes (#6565) * Fix saving dynamic values for cssRules * Allow using collectionStateMap for styleable models * Fix bug with undo manager * refactor resolverWatcher * Allow CssRules to use collection variable * Fix undoManager issue for binding data to a component * update returned value for adding props * Revert a change to fix tests * move test files * Add tests for undomanager with datasources * update clone and toJSON for cssRule * Refactor Model data resolver * update test setup function * Refactor datasources logic to styleable model * Add clone and update toJSON in styleableModel * Refactor CssRule to use styleableModel methods * Add undomanager to datasources * refactor component class to use styleableModel methods * update unit tests for undo manager * Refactor data resolver watcher * Fix undoManager in test enviroment * Remove destroy test editor * Update Data resolver watchers * Remove setTimeout from undo manager unit tests * Fix Selection tracking tests * Fix missing id in `component.toJSON()` * Fix styles as string for cssRules * Fix CssRule type * Add string style support for ModelResolver.getProps() * Cleanup ( rename dynamic to data resolver ) * Use fake timers in undo manager tests * Remove checking duplicated object in undomanager registry * Fix typescript checks * Fix lint --- .../core/src/css_composer/model/CssRule.ts | 23 +- packages/core/src/data_sources/index.ts | 1 + .../conditional_variables/DataCondition.ts | 4 +- .../ComponentDataCollection.ts | 7 +- packages/core/src/data_sources/utils.ts | 2 +- .../src/dom_components/model/Component.ts | 125 ++++---- .../model/ModelDataResolverWatchers.ts | 215 ++++++++----- .../model/ModelResolverWatcher.ts | 37 ++- .../src/dom_components/model/SymbolUtils.ts | 33 +- .../core/src/dom_components/model/types.ts | 4 +- .../domain_abstract/model/StyleableModel.ts | 135 +++++++-- packages/core/src/navigator/index.ts | 4 +- .../core/src/trait_manager/model/Trait.ts | 2 +- packages/core/test/common.ts | 24 +- .../styles.ts} | 0 .../traits.ts} | 0 .../ComponentDataCollection.ts | 5 +- .../core/test/specs/dom_components/index.ts | 4 +- .../test/specs/undo_manager/datasources.ts | 248 +++++++++++++++ .../core/test/specs/undo_manager/index.ts | 286 ++++++++++++++++++ 20 files changed, 920 insertions(+), 239 deletions(-) rename packages/core/test/specs/data_sources/{model/StyleDataVariable.ts => dynamic_values/styles.ts} (100%) rename packages/core/test/specs/data_sources/{model/TraitDataVariable.ts => dynamic_values/traits.ts} (100%) create mode 100644 packages/core/test/specs/undo_manager/datasources.ts create mode 100644 packages/core/test/specs/undo_manager/index.ts diff --git a/packages/core/src/css_composer/model/CssRule.ts b/packages/core/src/css_composer/model/CssRule.ts index 9a7be61e2..b8fc42266 100644 --- a/packages/core/src/css_composer/model/CssRule.ts +++ b/packages/core/src/css_composer/model/CssRule.ts @@ -1,5 +1,5 @@ import { isEmpty, forEach, isString, isArray } from 'underscore'; -import { Model, ObjectAny } from '../../common'; +import { ObjectAny, ObjectHash } from '../../common'; import StyleableModel, { StyleProps } from '../../domain_abstract/model/StyleableModel'; import Selectors from '../../selector_manager/model/Selectors'; import { getMediaLength } from '../../code_manager/model/CssGenerator'; @@ -16,7 +16,7 @@ export interface ToCssOptions { } /** @private */ -export interface CssRuleProperties { +export interface CssRuleProperties extends ObjectHash { /** * Array of selectors */ @@ -126,7 +126,7 @@ export default class CssRule extends StyleableModel { this.em = opt.em; this.ensureSelectors(null, null, {}); this.on('change', this.__onChange); - this.setStyle(this.get('style')); + this.setStyle(this.get('style'), { skipWatcherUpdates: true }); } __onChange(m: CssRule, opts: any) { @@ -135,12 +135,10 @@ export default class CssRule extends StyleableModel { changed && !isEmptyObj(changed) && em?.changesUp(opts); } - clone(): CssRule { - const opts = { ...this.opt }; - const attr = { ...this.attributes }; - attr.selectors = this.get('selectors')!.map((s) => s.clone() as Selector); - // @ts-ignore - return new this.constructor(attr, opts); + clone(): typeof this { + const selectors = this.get('selectors')!.map((s) => s.clone() as Selector); + + return super.clone({ selectors }); } ensureSelectors(m: any, c: any, opts: any) { @@ -307,9 +305,8 @@ export default class CssRule extends StyleableModel { return result; } - toJSON(...args: any) { - const obj = Model.prototype.toJSON.apply(this, args); - + toJSON(opts?: ObjectAny) { + const obj = super.toJSON(opts); if (this.em?.getConfig().avoidDefaults) { const defaults = this.defaults(); @@ -326,7 +323,7 @@ export default class CssRule extends StyleableModel { if (isEmpty(obj.style)) delete obj.style; } - return { ...obj, style: this.dataResolverWatchers.getStylesDefsOrValues(obj.style) }; + return obj; } /** diff --git a/packages/core/src/data_sources/index.ts b/packages/core/src/data_sources/index.ts index 63837cdff..5e9f6efb1 100644 --- a/packages/core/src/data_sources/index.ts +++ b/packages/core/src/data_sources/index.ts @@ -168,5 +168,6 @@ export default class DataSourceManager extends ItemManagerModule em.changesUp(o || c)); + this.em.UndoManager.add(all); } } diff --git a/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts b/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts index 10a3f3ba3..ac4946074 100644 --- a/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts +++ b/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts @@ -103,14 +103,14 @@ export class DataCondition extends Model { return this._conditionEvaluator.evaluate(); } - getDataValue(skipDynamicValueResolution: boolean = false): any { + getDataValue(skipResolve: boolean = false): any { const { em, collectionsStateMap } = this; const options = { em, collectionsStateMap }; const ifTrue = this.getIfTrue(); const ifFalse = this.getIfFalse(); const isConditionTrue = this.isTrue(); - if (skipDynamicValueResolution) { + if (skipResolve) { return isConditionTrue ? ifTrue : ifFalse; } diff --git a/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts b/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts index 18895e11e..096f09d9e 100644 --- a/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts +++ b/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts @@ -16,7 +16,7 @@ import { DataCollectionStateMap, } from './types'; import { detachSymbolInstance, getSymbolInstances } from '../../../dom_components/model/SymbolUtils'; -import { updateFromWatcher } from '../../../dom_components/model/ModelDataResolverWatchers'; +import { keyDataValues, updateFromWatcher } from '../../../dom_components/model/ModelDataResolverWatchers'; import { ModelDestroyOptions } from 'backbone'; import Components from '../../../dom_components/model/Components'; @@ -301,8 +301,7 @@ export default class ComponentDataCollection extends Component { } onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) { - this.collectionsStateMap = collectionsStateMap; - this.dataResolverWatchers.onCollectionsStateMapUpdate(); + super.onCollectionsStateMapUpdate(collectionsStateMap); const items = this.getDataSourceItems(); const { startIndex } = this.resolveCollectionConfig(items); @@ -357,7 +356,7 @@ function getLength(items: DataVariableProps[] | object) { } function setCollectionStateMapAndPropagate(cmp: Component, collectionsStateMap: DataCollectionStateMap) { - cmp.setSymbolOverride(['locked', 'layerable']); + cmp.setSymbolOverride(['locked', 'layerable', keyDataValues]); cmp.syncComponentsCollectionState(); cmp.onCollectionsStateMapUpdate(collectionsStateMap); } diff --git a/packages/core/src/data_sources/utils.ts b/packages/core/src/data_sources/utils.ts index 598dc36aa..583309245 100644 --- a/packages/core/src/data_sources/utils.ts +++ b/packages/core/src/data_sources/utils.ts @@ -49,7 +49,7 @@ export function getDataResolverInstance( break; } default: - options.em?.logWarning(`Unsupported dynamic type: ${type}`); + options.em?.logWarning(`Unsupported resolver type: ${type}`); return; } diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index 6f7c2a111..1dae41ba4 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/packages/core/src/dom_components/model/Component.ts @@ -12,7 +12,11 @@ import { keys, } from 'underscore'; import { shallowDiff, capitalize, isEmptyObj, isObject, toLowerCase } from '../../utils/mixins'; -import StyleableModel, { StyleProps, UpdateStyleOptions } from '../../domain_abstract/model/StyleableModel'; +import StyleableModel, { + GetStyleOpts, + StyleProps, + UpdateStyleOptions, +} from '../../domain_abstract/model/StyleableModel'; import { Model, ModelDestroyOptions } from 'backbone'; import Components from './Components'; import Selector from '../../selector_manager/model/Selector'; @@ -52,14 +56,13 @@ import { updateSymbolProps, getSymbolsToUpdate, } from './SymbolUtils'; -import { ModelDataResolverWatchers } from './ModelDataResolverWatchers'; -import { DynamicWatchersOptions } from './ModelResolverWatcher'; +import { DataWatchersOptions } from './ModelResolverWatcher'; import { DataCollectionStateMap } from '../../data_sources/model/data_collection/types'; import { checkAndGetSyncableCollectionItemId } from '../../data_sources/utils'; export interface IComponent extends ExtractMethods {} -export interface SetAttrOptions extends SetOptions, UpdateStyleOptions, DynamicWatchersOptions {} -export interface ComponentSetOptions extends SetOptions, DynamicWatchersOptions {} +export interface SetAttrOptions extends SetOptions, UpdateStyleOptions, DataWatchersOptions {} +export interface ComponentSetOptions extends SetOptions, DataWatchersOptions {} const escapeRegExp = (str: string) => { return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); @@ -74,6 +77,10 @@ export const keySymbolOvrd = '__symbol_ovrd'; export const keyUpdate = ComponentsEvents.update; export const keyUpdateInside = ComponentsEvents.updateInside; +type GetComponentStyleOpts = GetStyleOpts & { + inline?: boolean; +}; + /** * The Component object represents a single node of our template structure, so when you update its properties the changes are * immediately reflected on the canvas and in the code to export (indeed, when you ask to export the code we just go through all @@ -294,12 +301,12 @@ export default class Component extends StyleableModel { this.opt = opt; this.em = em!; this.config = opt.config || {}; - const dynamicAttributes = this.dataResolverWatchers.getDynamicAttributesDefs(); - this.setAttributes({ + const defaultAttrs = { ...(result(this, 'defaults').attributes || {}), ...(this.get('attributes') || {}), - ...dynamicAttributes, - }); + }; + const attrs = this.dataResolverWatchers.getValueOrResolver('attributes', defaultAttrs); + this.setAttributes(attrs); this.ccid = Component.createId(this, opt); this.preInit(); this.initClasses(); @@ -343,34 +350,9 @@ export default class Component extends StyleableModel { } } - set( - keyOrAttributes: A | Partial, - valueOrOptions?: ComponentProperties[A] | ComponentSetOptions, - optionsOrUndefined?: ComponentSetOptions, - ): this { - let attributes: Partial; - let options: ComponentSetOptions & { - dataResolverWatchers?: ModelDataResolverWatchers; - } = { skipWatcherUpdates: false, fromDataSource: false }; - if (typeof keyOrAttributes === 'object') { - attributes = keyOrAttributes; - options = valueOrOptions || (options as ComponentSetOptions); - } else if (typeof keyOrAttributes === 'string') { - attributes = { [keyOrAttributes as string]: valueOrOptions }; - options = optionsOrUndefined || options; - } else { - attributes = {}; - options = optionsOrUndefined || options; - } - - this.dataResolverWatchers = this.dataResolverWatchers || options.dataResolverWatchers; - const evaluatedProps = this.dataResolverWatchers.addProps(attributes, options); - return super.set(evaluatedProps, options); - } - onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) { - this.collectionsStateMap = collectionsStateMap; - this.dataResolverWatchers.onCollectionsStateMapUpdate(); + super.onCollectionsStateMapUpdate(collectionsStateMap); + this._getStyleRule()?.onCollectionsStateMapUpdate(collectionsStateMap); const cmps = this.components(); cmps.forEach((cmp) => cmp.onCollectionsStateMapUpdate(collectionsStateMap)); @@ -572,7 +554,7 @@ export default class Component extends StyleableModel { * @example * component.setSymbolOverride(['children', 'classes']); */ - setSymbolOverride(value: boolean | string | string[], options: DynamicWatchersOptions = {}) { + setSymbolOverride(value: boolean | string | string[], options: DataWatchersOptions = {}) { this.set( { [keySymbolOvrd]: (isString(value) ? [value] : value) ?? 0, @@ -773,11 +755,13 @@ export default class Component extends StyleableModel { * component.addAttributes({ 'data-key': 'value' }); */ addAttributes(attrs: ObjectAny, opts: SetAttrOptions = {}) { - const dynamicAttributes = this.dataResolverWatchers.getDynamicAttributesDefs(); + const previousAttrs = this.dataResolverWatchers.getValueOrResolver( + 'attributes', + this.getAttributes({ noClass: true, noStyle: true }), + ); return this.setAttributes( { - ...this.getAttributes({ noClass: true, noStyle: true }), - ...dynamicAttributes, + ...previousAttrs, ...attrs, }, opts, @@ -795,7 +779,6 @@ export default class Component extends StyleableModel { */ removeAttributes(attrs: string | string[] = [], opts: SetOptions = {}) { const attrArr = Array.isArray(attrs) ? attrs : [attrs]; - this.dataResolverWatchers.removeAttributes(attrArr); const compAttr = this.getAttributes(); attrArr.map((i) => delete compAttr[i]); @@ -806,21 +789,26 @@ export default class Component extends StyleableModel { * Get the style of the component * @return {Object} */ - getStyle(options: any = {}, optsAdd: any = {}) { + getStyle(opts?: GetComponentStyleOpts): StyleProps; + getStyle(prop: '' | undefined, opts?: GetComponentStyleOpts): StyleProps; + getStyle( + prop?: keyof StyleProps | '' | ObjectAny, + opts?: GetComponentStyleOpts, + ): StyleProps | StyleProps[keyof StyleProps] | undefined { const { em } = this; - const isOptionsString = isString(options); - const prop = isOptionsString ? options : ''; - const opts = isOptionsString || options === '' ? optsAdd : options; - const skipResolve = !!opts?.skipResolve; + const isPropString = isString(prop); + const resolvedProp = isPropString ? prop : ''; + const resolvedOpts = isPropString ? opts : prop; + const skipResolve = !!resolvedOpts?.skipResolve; - if (avoidInline(em) && !opts.inline) { + if (avoidInline(em) && !resolvedOpts?.inline) { const state = em.get('state'); const cc = em.Css; - const rule = cc.getIdRule(this.getId(), { state, ...opts }); + const rule = cc.getIdRule(this.getId(), { state, ...resolvedOpts }); this.rule = rule; if (rule) { - return rule.getStyle(prop, { skipResolve }); + return rule.getStyle(resolvedProp, { skipResolve }); } // Return empty style if no rule have been found. We cannot return inline style with the next return @@ -828,7 +816,7 @@ export default class Component extends StyleableModel { return {}; } - return super.getStyle.call(this, prop, { skipResolve }); + return super.getStyle.call(this, resolvedProp, { skipResolve }); } /** @@ -844,7 +832,7 @@ export default class Component extends StyleableModel { if (avoidInline(em) && !opt.temporary && !opts.inline) { const style = this.get('style') || {}; prop = isString(prop) ? this.parseStyle(prop) : prop; - prop = { ...prop, ...(style as any) }; + prop = { ...(style as any), ...prop }; const state = em.get('state'); const cc = em.Css; const propOrig = this.getStyle({ ...opts, skipResolve: true }); @@ -870,11 +858,10 @@ export default class Component extends StyleableModel { getAttributes(opts: { noClass?: boolean; noStyle?: boolean; skipResolve?: boolean } = {}) { const { em } = this; const classes: string[] = []; - const dynamicValues = opts.skipResolve ? this.dataResolverWatchers.getDynamicAttributesDefs() : {}; - const attributes = { - ...this.get('attributes'), - ...dynamicValues, - }; + const resolvedAttrs = { ...this.get('attributes')! }; + const attributes = opts?.skipResolve + ? this.dataResolverWatchers.getValueOrResolver('attributes', resolvedAttrs) + : resolvedAttrs; const sm = em?.Selectors; const id = this.getId(); @@ -1043,12 +1030,8 @@ export default class Component extends StyleableModel { if (name && value) attrs[name] = value; } }); - const dynamicAttributes = this.dataResolverWatchers.getDynamicAttributesDefs(); - traits.length && - this.setAttributes({ - ...attrs, - ...dynamicAttributes, - }); + const resolvedAttributes = this.dataResolverWatchers.getValueOrResolver('attributes', attrs); + traits.length && this.setAttributes(resolvedAttributes); this.on(event, this.initTraits); changed && em && em.trigger('component:toggled'); return this; @@ -1390,16 +1373,10 @@ export default class Component extends StyleableModel { * @ts-ignore */ clone(opt: { symbol?: boolean; symbolInv?: boolean } = {}): this { const em = this.em; - const attr = { - ...this.attributes, - ...this.dataResolverWatchers.getDynamicPropsDefs(), - }; + const attr = this.dataResolverWatchers.getProps(this.attributes); const opts = { ...this.opt }; const id = this.getId(); const cssc = em?.Css; - attr.attributes = { - ...(attr.attributes ? this.dataResolverWatchers.getAttributesDefsOrValues(attr.attributes) : undefined), - }; // @ts-ignore attr.components = []; // @ts-ignore @@ -1656,9 +1633,7 @@ export default class Component extends StyleableModel { * @private */ toJSON(opts: ObjectAny = {}): ComponentDefinition { - let obj = Model.prototype.toJSON.call(this, opts); - obj = { ...obj, ...this.dataResolverWatchers.getDynamicPropsDefs() }; - obj.attributes = this.dataResolverWatchers.getAttributesDefsOrValues(this.getAttributes()); + let obj = super.toJSON(opts, { attributes: this.getAttributes() }); delete obj.dataResolverWatchers; delete obj.attributes.class; delete obj.toolbar; @@ -2036,8 +2011,10 @@ export default class Component extends StyleableModel { (isObject(inlineStyle) && Object.keys(inlineStyle).length > 0); if (avoidInline(this.em) && hasInlineStyle) { - this.addStyle(inlineStyle); - this.set('style', ''); + this.addStyle( + isObject(inlineStyle) ? this.dataResolverWatchers.getValueOrResolver('styles', inlineStyle) : inlineStyle, + { avoidStore: true, noUndo: true }, + ); } } diff --git a/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts b/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts index 25ad6d24d..f6bc69c90 100644 --- a/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts +++ b/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts @@ -1,74 +1,120 @@ import { ObjectAny } from '../../common'; -import StyleableModel from '../../domain_abstract/model/StyleableModel'; import { - ModelResolverWatcher as ModelResolverWatcher, + ModelResolverWatcher, ModelResolverWatcherOptions, - DynamicWatchersOptions, + DataWatchersOptions, + WatchableModel, } from './ModelResolverWatcher'; import { getSymbolsToUpdate } from './SymbolUtils'; +import Component from './Component'; +import { StyleableModelProperties } from '../../domain_abstract/model/StyleableModel'; +import { isEmpty, isObject } from 'underscore'; export const updateFromWatcher = { fromDataSource: true, avoidStore: true }; +export const keyDataValues = '__data_values'; -export class ModelDataResolverWatchers { - private propertyWatcher: ModelResolverWatcher; - private attributeWatcher: ModelResolverWatcher; - private styleWatcher: ModelResolverWatcher; +export class ModelDataResolverWatchers { + private propertyWatcher: ModelResolverWatcher; + private attributeWatcher: ModelResolverWatcher; + private styleWatcher: ModelResolverWatcher; constructor( - private model: StyleableModel | undefined, - options: ModelResolverWatcherOptions, + private model: WatchableModel, + private options: ModelResolverWatcherOptions, ) { this.propertyWatcher = new ModelResolverWatcher(model, this.onPropertyUpdate, options); this.attributeWatcher = new ModelResolverWatcher(model, this.onAttributeUpdate, options); this.styleWatcher = new ModelResolverWatcher(model, this.onStyleUpdate, options); } - private onPropertyUpdate(component: StyleableModel | undefined, key: string, value: any) { - component?.set(key, value, updateFromWatcher); - } - - private onAttributeUpdate(component: StyleableModel | undefined, key: string, value: any) { - (component as any)?.addAttributes({ [key]: value }, updateFromWatcher); - } - - private onStyleUpdate(component: StyleableModel | undefined, key: string, value: any) { - component?.addStyle({ [key]: value }, { ...updateFromWatcher, partial: true, avoidStore: true }); - } - - bindModel(model: StyleableModel) { + bindModel(model: WatchableModel) { this.model = model; - this.propertyWatcher.bindModel(model); - this.attributeWatcher.bindModel(model); - this.styleWatcher.bindModel(model); + this.watchers.forEach((watcher) => watcher.bindModel(model)); this.updateSymbolOverride(); } - addProps(props: ObjectAny, options: DynamicWatchersOptions = {}) { - const excludedFromEvaluation = ['components', 'dataResolver']; + addProps(props: ObjectAny, options: DataWatchersOptions = {}) { + const dataValues = props[keyDataValues] ?? {}; - const evaluatedProps = Object.fromEntries( - Object.entries(props).map(([key, value]) => - excludedFromEvaluation.includes(key) - ? [key, value] // Return excluded keys as they are - : [key, this.propertyWatcher.addDynamicValues({ [key]: value }, options)[key]], - ), - ); + const filteredProps = this.filterProps(props); + const evaluatedProps = { + ...props, + ...this.propertyWatcher.addDataValues({ ...filteredProps, ...dataValues.props }, options), + }; - if (props.attributes) { - const evaluatedAttributes = this.attributeWatcher.setDynamicValues(props.attributes, options); - evaluatedProps['attributes'] = evaluatedAttributes; + if (this.shouldProcessProp('attributes', props, dataValues)) { + evaluatedProps.attributes = this.processAttributes(props, dataValues, options); + } + + if (this.shouldProcessProp('style', props, dataValues)) { + evaluatedProps.style = this.processStyles(props, dataValues, options); } const skipOverrideUpdates = options.skipWatcherUpdates || options.fromDataSource; if (!skipOverrideUpdates) { this.updateSymbolOverride(); + evaluatedProps[keyDataValues] = { + props: this.propertyWatcher.getAllDataResolvers(), + style: this.styleWatcher.getAllDataResolvers(), + attributes: this.attributeWatcher.getAllDataResolvers(), + }; } return evaluatedProps; } - setStyles(styles: ObjectAny, options: DynamicWatchersOptions = {}) { - return this.styleWatcher.setDynamicValues(styles, options); + getProps(data: ObjectAny): ObjectAny { + const resolvedProps = this.getValueOrResolver('props', data); + const result = { + ...resolvedProps, + }; + delete result[keyDataValues]; + + if (!isEmpty(data.attributes)) { + result.attributes = this.getValueOrResolver('attributes', data.attributes); + } + + if (isObject(data.style) && !isEmpty(data.style)) { + result.style = this.getValueOrResolver('styles', data.style); + } + + return result; + } + + /** + * Resolves properties, styles, or attributes to their final values or returns the data resolvers. + * - If `data` is `null` or `undefined`, the method returns an object containing all data resolvers for the specified `target`. + */ + getValueOrResolver(target: 'props' | 'styles' | 'attributes', data?: ObjectAny) { + let watcher; + + switch (target) { + case 'props': + watcher = this.propertyWatcher; + break; + case 'styles': + watcher = this.styleWatcher; + break; + case 'attributes': + watcher = this.attributeWatcher; + break; + default: { + const { em } = this.options; + em?.logError(`Invalid target '${target}'. Must be 'props', 'styles', or 'attributes'.`); + return {}; + } + } + + if (!data) { + return watcher.getAllDataResolvers(); + } + + return watcher.getValuesOrResolver(data); + } + + removeAttributes(attributes: string[]) { + this.attributeWatcher.removeListeners(attributes); + this.updateSymbolOverride(); } /** @@ -79,20 +125,57 @@ export class ModelDataResolverWatchers { this.styleWatcher.destroy(); } - removeAttributes(attributes: string[]) { - this.attributeWatcher.removeListeners(attributes); - this.updateSymbolOverride(); + onCollectionsStateMapUpdate() { + this.watchers.forEach((watcher) => watcher.onCollectionsStateMapUpdate()); + } + + destroy() { + this.watchers.forEach((watcher) => watcher.destroy()); + } + + private get watchers() { + return [this.propertyWatcher, this.styleWatcher, this.attributeWatcher]; + } + + private isComponent(model: any): model is Component { + return model instanceof Component; + } + + private onPropertyUpdate = (model: WatchableModel, key: string, value: any) => { + model?.set(key, value, updateFromWatcher); + }; + + private onAttributeUpdate = (model: WatchableModel, key: string, value: any) => { + if (!this.isComponent(model)) return; + model?.addAttributes({ [key]: value }, updateFromWatcher); + }; + + private onStyleUpdate = (model: WatchableModel, key: string, value: any) => { + model?.addStyle({ [key]: value }, { ...updateFromWatcher, partial: true, avoidStore: true }); + }; + + private shouldProcessProp(key: 'attributes' | 'style', newProps: ObjectAny, dataValues: ObjectAny): boolean { + const watcher = key === 'attributes' ? this.attributeWatcher : this.styleWatcher; + const dataSubProps = dataValues[key]; + + const hasNewValues = !!newProps[key]; + const hasExistingDataValues = dataSubProps && Object.keys(dataSubProps).length > 0; + const hasApplicableWatchers = dataSubProps && Object.keys(watcher.getAllDataResolvers()).length > 0; + + return hasNewValues || hasExistingDataValues || hasApplicableWatchers; } private updateSymbolOverride() { - const model = this.model as any; + const model = this.model; + if (!this.isComponent(model)) return; + const isCollectionItem = !!Object.keys(model?.collectionsStateMap ?? {}).length; - if (!this.model || !isCollectionItem) return; + if (!isCollectionItem) return; const keys = this.propertyWatcher.getValuesResolvingFromCollections(); const attributesKeys = this.attributeWatcher.getValuesResolvingFromCollections(); - const combinedKeys = ['locked', 'layerable', ...keys]; + const combinedKeys = ['locked', 'layerable', keyDataValues, ...keys]; const haveOverridenAttributes = Object.keys(attributesKeys).length; if (haveOverridenAttributes) combinedKeys.push('attributes'); @@ -103,39 +186,25 @@ export class ModelDataResolverWatchers { model.setSymbolOverride(combinedKeys, { fromDataSource: true }); } - onCollectionsStateMapUpdate() { - this.propertyWatcher.onCollectionsStateMapUpdate(); - this.attributeWatcher.onCollectionsStateMapUpdate(); - this.styleWatcher.onCollectionsStateMapUpdate(); - } - - getDynamicPropsDefs() { - return this.propertyWatcher.getAllSerializableValues(); - } - - getDynamicAttributesDefs() { - return this.attributeWatcher.getAllSerializableValues(); - } - - getDynamicStylesDefs() { - return this.styleWatcher.getAllSerializableValues(); - } + private filterProps(props: ObjectAny) { + const excludedFromEvaluation = ['components', 'dataResolver', keyDataValues]; + const filteredProps = Object.fromEntries( + Object.entries(props).filter(([key]) => !excludedFromEvaluation.includes(key)), + ); - getPropsDefsOrValues(props: ObjectAny) { - return this.propertyWatcher.getSerializableValues(props); + return filteredProps; } - getAttributesDefsOrValues(attributes: ObjectAny) { - return this.attributeWatcher.getSerializableValues(attributes); + private processAttributes(baseValue: ObjectAny, dataValues: ObjectAny, options: DataWatchersOptions = {}) { + return this.attributeWatcher.setDataValues({ ...baseValue.attributes, ...(dataValues.attributes ?? {}) }, options); } - getStylesDefsOrValues(styles: ObjectAny) { - return this.styleWatcher.getSerializableValues(styles); - } + private processStyles(baseValue: ObjectAny | string, dataValues: ObjectAny, options: DataWatchersOptions = {}) { + if (typeof baseValue === 'string') { + this.styleWatcher.removeListeners(); + return baseValue; + } - destroy() { - this.propertyWatcher.destroy(); - this.attributeWatcher.destroy(); - this.styleWatcher.destroy(); + return this.styleWatcher.setDataValues({ ...baseValue.style, ...(dataValues.style ?? {}) }, options); } } diff --git a/packages/core/src/dom_components/model/ModelResolverWatcher.ts b/packages/core/src/dom_components/model/ModelResolverWatcher.ts index 34e62ca4d..82569aa92 100644 --- a/packages/core/src/dom_components/model/ModelResolverWatcher.ts +++ b/packages/core/src/dom_components/model/ModelResolverWatcher.ts @@ -1,11 +1,10 @@ -import { ObjectAny } from '../../common'; +import { ObjectAny, ObjectHash } from '../../common'; import DataResolverListener from '../../data_sources/model/DataResolverListener'; import { getDataResolverInstance, getDataResolverInstanceValue, isDataResolverProps } from '../../data_sources/utils'; import StyleableModel from '../../domain_abstract/model/StyleableModel'; import EditorModel from '../../editor/model/Editor'; -import Component from './Component'; -export interface DynamicWatchersOptions { +export interface DataWatchersOptions { skipWatcherUpdates?: boolean; fromDataSource?: boolean; } @@ -14,35 +13,35 @@ export interface ModelResolverWatcherOptions { em: EditorModel; } -type NewType = StyleableModel | undefined; -type UpdateFn = (component: NewType, key: string, value: any) => void; +export type WatchableModel = StyleableModel | undefined; +export type UpdateFn = (component: WatchableModel, key: string, value: any) => void; -export class ModelResolverWatcher { +export class ModelResolverWatcher { private em: EditorModel; private resolverListeners: Record = {}; constructor( - private model: NewType, - private updateFn: UpdateFn, + private model: WatchableModel, + private updateFn: UpdateFn, options: ModelResolverWatcherOptions, ) { this.em = options.em; } - bindModel(model: StyleableModel) { + bindModel(model: WatchableModel) { this.model = model; } - setDynamicValues(values: ObjectAny | undefined, options: DynamicWatchersOptions = {}) { + setDataValues(values: ObjectAny | undefined, options: DataWatchersOptions = {}) { const shouldSkipWatcherUpdates = options.skipWatcherUpdates || options.fromDataSource; if (!shouldSkipWatcherUpdates) { this.removeListeners(); } - return this.addDynamicValues(values, options); + return this.addDataValues(values, options); } - addDynamicValues(values: ObjectAny | undefined, options: DynamicWatchersOptions = {}) { + addDataValues(values: ObjectAny | undefined, options: DataWatchersOptions = {}) { if (!values) return {}; const evaluatedValues = this.evaluateValues(values); @@ -61,8 +60,8 @@ export class ModelResolverWatcher { this.resolverListeners[key].resolver.updateCollectionsStateMap(this.collectionsStateMap), ); - const evaluatedValues = this.addDynamicValues( - this.getSerializableValues(Object.fromEntries(resolvesFromCollections.map((key) => [key, null]))), + const evaluatedValues = this.addDataValues( + this.getValuesOrResolver(Object.fromEntries(resolvesFromCollections.map((key) => [key, '']))), ); Object.entries(evaluatedValues).forEach(([key, value]) => this.updateFn(this.model, key, value)); @@ -70,8 +69,8 @@ export class ModelResolverWatcher { private get collectionsStateMap() { const component = this.model; - if (component instanceof Component) return component.collectionsStateMap; - return {}; + + return component?.collectionsStateMap ?? {}; } private updateListeners(values: { [key: string]: any }) { @@ -133,9 +132,9 @@ export class ModelResolverWatcher { return propsKeys; } - getSerializableValues(values: ObjectAny | undefined) { + getValuesOrResolver(values: ObjectAny) { if (!values) return {}; - const serializableValues = { ...values }; + const serializableValues: ObjectAny = { ...values }; const propsKeys = Object.keys(serializableValues); for (let index = 0; index < propsKeys.length; index++) { @@ -149,7 +148,7 @@ export class ModelResolverWatcher { return serializableValues; } - getAllSerializableValues() { + getAllDataResolvers() { const serializableValues: ObjectAny = {}; const propsKeys = Object.keys(this.resolverListeners); diff --git a/packages/core/src/dom_components/model/SymbolUtils.ts b/packages/core/src/dom_components/model/SymbolUtils.ts index 79f98d3b1..4df7300f9 100644 --- a/packages/core/src/dom_components/model/SymbolUtils.ts +++ b/packages/core/src/dom_components/model/SymbolUtils.ts @@ -131,44 +131,45 @@ export const logSymbol = (symb: Component, type: string, toUp: Component[], opts }; export const updateSymbolProps = (symbol: Component, opts: SymbolToUpOptions = {}): void => { - const changed = symbol.dataResolverWatchers.getPropsDefsOrValues({ ...symbol.changedAttributes() }); - const attrs = symbol.dataResolverWatchers.getAttributesDefsOrValues({ ...changed.attributes }); + const changedAttributes = symbol.changedAttributes(); + if (!changedAttributes) return; - cleanChangedProperties(changed, attrs); + let resolvedProps = symbol.dataResolverWatchers.getProps(changedAttributes); + cleanChangedProperties(resolvedProps); - if (!isEmptyObj(changed)) { + if (!isEmptyObj(resolvedProps)) { const toUpdate = getSymbolsToUpdate(symbol, opts); // Filter properties to propagate - filterPropertiesForPropagation(changed, symbol); + resolvedProps = filterPropertiesForPropagation(resolvedProps, symbol); - logSymbol(symbol, 'props', toUpdate, { opts, changed }); + logSymbol(symbol, 'props', toUpdate, { opts, changed: resolvedProps }); // Update child symbols toUpdate.forEach((child) => { - const propsToUpdate = { ...changed }; - filterPropertiesForPropagation(propsToUpdate, child); + const propsToUpdate = filterPropertiesForPropagation(resolvedProps, child); child.set(propsToUpdate, { fromInstance: symbol, ...opts }); }); } }; -const cleanChangedProperties = (changed: Record, attrs: Record): void => { - const keysToDelete = ['status', 'open', keySymbols, keySymbol, keySymbolOvrd, 'attributes']; +const cleanChangedProperties = (changed: Record): void => { + const keysToDelete = ['status', 'open', keySymbols, keySymbol, keySymbolOvrd]; keysToDelete.forEach((key) => delete changed[key]); - delete attrs.id; - if (!isEmptyObj(attrs)) { - changed.attributes = attrs; - } + delete changed.attributes?.id; + isEmptyObj(changed.attributes ?? {}) && delete changed.attributes; }; -const filterPropertiesForPropagation = (props: Record, component: Component): void => { +const filterPropertiesForPropagation = (props: Record, component: Component) => { + const filteredProps = { ...props }; keys(props).forEach((prop) => { if (!shouldPropagateProperty(props, prop, component)) { - delete props[prop]; + delete filteredProps[prop]; } }); + + return filteredProps; }; const shouldPropagateProperty = (props: Record, prop: string, component: Component): boolean => { diff --git a/packages/core/src/dom_components/model/types.ts b/packages/core/src/dom_components/model/types.ts index 400200410..84e7cc7ce 100644 --- a/packages/core/src/dom_components/model/types.ts +++ b/packages/core/src/dom_components/model/types.ts @@ -1,4 +1,4 @@ -import { DynamicWatchersOptions } from './ModelResolverWatcher'; +import { DataWatchersOptions } from './ModelResolverWatcher'; import Frame from '../../canvas/model/Frame'; import { AddOptions, Nullable, OptionAsDocument } from '../../common'; import EditorModel from '../../editor/model/Editor'; @@ -253,7 +253,7 @@ export interface ComponentProperties { [key: string]: any; } -export interface SymbolToUpOptions extends DynamicWatchersOptions { +export interface SymbolToUpOptions extends DataWatchersOptions { changed?: string; fromInstance?: boolean; noPropagate?: boolean; diff --git a/packages/core/src/domain_abstract/model/StyleableModel.ts b/packages/core/src/domain_abstract/model/StyleableModel.ts index 67474e851..8e3bca8b8 100644 --- a/packages/core/src/domain_abstract/model/StyleableModel.ts +++ b/packages/core/src/domain_abstract/model/StyleableModel.ts @@ -1,22 +1,22 @@ -import { isArray, isString, keys } from 'underscore'; +import { isArray, isObject, isString, keys } from 'underscore'; import { Model, ObjectAny, ObjectHash, SetOptions } from '../../common'; import ParserHtml from '../../parser/model/ParserHtml'; import Selectors from '../../selector_manager/model/Selectors'; import { shallowDiff } from '../../utils/mixins'; import EditorModel from '../../editor/model/Editor'; -import { DataVariableProps } from '../../data_sources/model/DataVariable'; import CssRuleView from '../../css_composer/view/CssRuleView'; import ComponentView from '../../dom_components/view/ComponentView'; import Frame from '../../canvas/model/Frame'; -import { DataConditionProps } from '../../data_sources/model/conditional_variables/DataCondition'; import { ToCssOptions } from '../../css_composer/model/CssRule'; import { ModelDataResolverWatchers } from '../../dom_components/model/ModelDataResolverWatchers'; import { DataCollectionStateMap } from '../../data_sources/model/data_collection/types'; -import { DynamicWatchersOptions } from '../../dom_components/model/ModelResolverWatcher'; +import { DataWatchersOptions } from '../../dom_components/model/ModelResolverWatcher'; +import { DataResolverProps } from '../../data_sources/types'; +import { _StringKey } from 'backbone'; -export type StyleProps = Record; +export type StyleProps = Record; -export interface UpdateStyleOptions extends SetOptions, DynamicWatchersOptions { +export interface UpdateStyleOptions extends SetOptions, DataWatchersOptions { partial?: boolean; addStyle?: StyleProps; inline?: boolean; @@ -31,20 +31,76 @@ export const getLastStyleValue = (value: string | string[]) => { return isArray(value) ? value[value.length - 1] : value; }; -export default class StyleableModel extends Model { +export interface StyleableModelProperties extends ObjectHash { + selectors?: any; + style?: StyleProps | string; +} + +export interface GetStyleOpts { + skipResolve?: boolean; +} + +type WithDataResolvers = { + [P in keyof T]?: T[P] | DataResolverProps; +}; + +export default class StyleableModel extends Model { em?: EditorModel; views: StyleableView[] = []; - dataResolverWatchers: ModelDataResolverWatchers; + dataResolverWatchers: ModelDataResolverWatchers; collectionsStateMap: DataCollectionStateMap = {}; + opt: { em?: EditorModel }; constructor(attributes: T, options: { em?: EditorModel } = {}) { const em = options.em!; - const dataResolverWatchers = new ModelDataResolverWatchers(undefined, { em }); + const dataResolverWatchers = new ModelDataResolverWatchers(undefined, { em }); super(attributes, { ...options, dataResolverWatchers }); dataResolverWatchers.bindModel(this); - dataResolverWatchers.setStyles(this.get('style')!); this.dataResolverWatchers = dataResolverWatchers; this.em = options.em; + this.opt = options; + } + + get>(attributeName: A, opts?: { skipResolve?: boolean }): T[A] | undefined { + if (opts?.skipResolve) return this.dataResolverWatchers.getValueOrResolver('props')[attributeName]; + + return super.get(attributeName); + } + + set( + keyOrAttributes: A, + valueOrOptions?: T[A] | DataResolverProps, + optionsOrUndefined?: UpdateStyleOptions, + ): this; + set(keyOrAttributes: WithDataResolvers, options?: UpdateStyleOptions): this; + set( + keyOrAttributes: WithDataResolvers, + valueOrOptions?: T[A] | DataResolverProps | UpdateStyleOptions, + optionsOrUndefined?: UpdateStyleOptions, + ): this { + const defaultOptions: UpdateStyleOptions = { + skipWatcherUpdates: false, + fromDataSource: false, + }; + + let attributes: WithDataResolvers; + let options: UpdateStyleOptions & { dataResolverWatchers?: ModelDataResolverWatchers }; + + if (typeof keyOrAttributes === 'object') { + attributes = keyOrAttributes; + options = (valueOrOptions as UpdateStyleOptions) || defaultOptions; + } else if (typeof keyOrAttributes === 'string') { + attributes = { [keyOrAttributes]: valueOrOptions } as Partial; + options = optionsOrUndefined || defaultOptions; + } else { + attributes = {}; + options = defaultOptions; + } + + this.dataResolverWatchers = this.dataResolverWatchers ?? options.dataResolverWatchers; + const evaluatedValues = this.dataResolverWatchers.addProps(attributes, options) as Partial; + + return super.set(evaluatedValues, options); } /** @@ -69,15 +125,31 @@ export default class StyleableModel extends Model(prop: K, opts?: GetStyleOpts): StyleProps[K] | undefined; + getStyle( + prop?: keyof StyleProps | '' | ObjectAny, + opts: GetStyleOpts = {}, + ): StyleProps | StyleProps[keyof StyleProps] | undefined { + const rawStyle = this.get('style'); + const parsedStyle: StyleProps = isString(rawStyle) + ? this.parseStyle(rawStyle) + : isObject(rawStyle) + ? { ...rawStyle } + : {}; + + delete parsedStyle.__p; + + const shouldReturnFull = !prop || prop === '' || isObject(prop); + if (!opts.skipResolve) { - return prop && isString(prop) ? { ...style }[prop] : { ...style }; + return shouldReturnFull ? parsedStyle : parsedStyle[prop]; } - const result: ObjectAny = { ...style, ...this.dataResolverWatchers.getDynamicStylesDefs() }; - return prop && isString(prop) ? result[prop] : result; + const unresolvedStyles: StyleProps = this.dataResolverWatchers.getValueOrResolver('styles', parsedStyle); + + return shouldReturnFull ? unresolvedStyles : unresolvedStyles[prop]; } /** @@ -110,9 +182,9 @@ export default class StyleableModel extends Model; + this.set(resolvedProps, opts as any); + newStyle = resolvedProps['style']! as StyleProps; const changedKeys = Object.keys(shallowDiff(propOrig, propNew)); const diff: ObjectAny = changedKeys.reduce((acc, key) => { @@ -199,7 +271,7 @@ export default class StyleableModel extends Model extends Model, opts?: any): typeof this { + const props = this.dataResolverWatchers.getProps(this.attributes); + const mergedProps = { ...props, ...attributes }; + const mergedOpts = { ...this.opt, ...opts }; + + const ClassConstructor = this.constructor as new (attributes: any, opts?: any) => typeof this; + + return new ClassConstructor(mergedProps, mergedOpts); + } + + toJSON(opts?: ObjectAny, attributes?: Partial) { + if (opts?.fromUndo) return { ...super.toJSON(opts) }; + const mergedProps = { ...this.attributes, ...attributes }; + const obj = this.dataResolverWatchers.getProps(mergedProps); + + return obj; + } } diff --git a/packages/core/src/navigator/index.ts b/packages/core/src/navigator/index.ts index d2e49adda..163ab9168 100644 --- a/packages/core/src/navigator/index.ts +++ b/packages/core/src/navigator/index.ts @@ -184,7 +184,7 @@ export default class LayerManager extends Module { */ setVisible(component: Component, value: boolean) { const prevDspKey = '__prev-display'; - const style: any = component.getStyle(styleOpts); + const style: any = component.getStyle(styleOpts as any); const { display } = style; if (value) { @@ -211,7 +211,7 @@ export default class LayerManager extends Module { * @returns {Boolean} */ isVisible(component: Component): boolean { - return !isStyleHidden(component.getStyle(styleOpts)); + return !isStyleHidden(component.getStyle(styleOpts as any)); } /** diff --git a/packages/core/src/trait_manager/model/Trait.ts b/packages/core/src/trait_manager/model/Trait.ts index e0069d87f..2c5622902 100644 --- a/packages/core/src/trait_manager/model/Trait.ts +++ b/packages/core/src/trait_manager/model/Trait.ts @@ -275,7 +275,7 @@ export default class Trait extends Model { }); } else if (this.changeProp) { value = component.get(name); - if (skipResolve) value = component.dataResolverWatchers.getPropsDefsOrValues({ [name]: value })[name]; + if (skipResolve) value = component.dataResolverWatchers.getValueOrResolver('props', { [name]: value })[name]; } else { value = component.getAttributes({ skipResolve })[name]; } diff --git a/packages/core/test/common.ts b/packages/core/test/common.ts index e14d11cf1..385db4899 100644 --- a/packages/core/test/common.ts +++ b/packages/core/test/common.ts @@ -1,4 +1,4 @@ -import { DataSourceManager } from '../src'; +import { DataSource } from '../src'; import CanvasEvents from '../src/canvas/types'; import { ObjectAny } from '../src/common'; import { @@ -14,7 +14,17 @@ import EditorModel from '../src/editor/model/Editor'; export const DEFAULT_CMPS = 3; export function setupTestEditor(opts?: { withCanvas?: boolean; config?: Partial }) { - document.body.innerHTML = '
'; + document.body.innerHTML = ''; + const fixtures = document.createElement('div'); + fixtures.id = 'fixtures'; + const canvasWrapEl = document.createElement('div'); + canvasWrapEl.id = 'canvas-wrp'; + const editorEl = document.createElement('div'); + editorEl.id = 'editor'; + document.body.appendChild(fixtures); + document.body.appendChild(canvasWrapEl); + document.body.appendChild(editorEl); + const editor = new Editor({ mediaCondition: 'max-width', el: document.body.querySelector('#editor') as HTMLElement, @@ -23,6 +33,7 @@ export function setupTestEditor(opts?: { withCanvas?: boolean; config?: Partial< }); const em = editor.getModel(); const dsm = em.DataSources; + const um = em.UndoManager; const { Pages, Components, Canvas } = em; Pages.onLoad(); const cmpRoot = Components.getWrapper()!; @@ -32,9 +43,6 @@ export function setupTestEditor(opts?: { withCanvas?: boolean; config?: Partial< config: { ...cmpRoot.config, em }, }); wrapperEl.render(); - const fixtures = document.body.querySelector('#fixtures')!; - fixtures.appendChild(wrapperEl.el); - const canvasWrapEl = document.body.querySelector('#canvas-wrp')!; /** * When trying to render the canvas, seems like jest gets stuck in a loop of iframe.onload (FrameView.ts) @@ -48,10 +56,14 @@ export function setupTestEditor(opts?: { withCanvas?: boolean; config?: Partial< el.onload = null; }); // Enable undo manager + editor.UndoManager.postLoad(); + editor.CssComposer.postLoad(); + editor.DataSources.postLoad(); + editor.Components.postLoad(); editor.Pages.postLoad(); } - return { editor, em, dsm, cmpRoot, fixtures: fixtures as HTMLElement }; + return { editor, em, dsm, um, cmpRoot, fixtures }; } export function fixJsDom(editor: Editor) { diff --git a/packages/core/test/specs/data_sources/model/StyleDataVariable.ts b/packages/core/test/specs/data_sources/dynamic_values/styles.ts similarity index 100% rename from packages/core/test/specs/data_sources/model/StyleDataVariable.ts rename to packages/core/test/specs/data_sources/dynamic_values/styles.ts diff --git a/packages/core/test/specs/data_sources/model/TraitDataVariable.ts b/packages/core/test/specs/data_sources/dynamic_values/traits.ts similarity index 100% rename from packages/core/test/specs/data_sources/model/TraitDataVariable.ts rename to packages/core/test/specs/data_sources/dynamic_values/traits.ts diff --git a/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts b/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts index 7fd5f4c37..41169940a 100644 --- a/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts +++ b/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts @@ -256,7 +256,6 @@ describe('Collection component', () => { test('Updating the value to a different collection variable', async () => { firstChild.set('name', { - // @ts-ignore type: DataVariableType, variableType: DataCollectionStateType.currentItem, collectionId: 'my_collection', @@ -275,7 +274,6 @@ describe('Collection component', () => { expect(secondChild.get('name')).toBe('new_value_14'); firstGrandchild.set('name', { - // @ts-ignore type: DataVariableType, variableType: DataCollectionStateType.currentItem, collectionId: 'my_collection', @@ -293,7 +291,6 @@ describe('Collection component', () => { test('Updating the value to a different dynamic variable', async () => { firstChild.set('name', { - // @ts-ignore type: DataVariableType, path: 'my_data_source_id.user2.user', }); @@ -306,8 +303,8 @@ describe('Collection component', () => { expect(secondChild.get('name')).toBe('new_value'); expect(thirdChild.get('name')).toBe('new_value'); + // @ts-ignore firstGrandchild.set('name', { - // @ts-ignore type: DataVariableType, path: 'my_data_source_id.user2.user', }); diff --git a/packages/core/test/specs/dom_components/index.ts b/packages/core/test/specs/dom_components/index.ts index 825350821..e06edd390 100644 --- a/packages/core/test/specs/dom_components/index.ts +++ b/packages/core/test/specs/dom_components/index.ts @@ -264,9 +264,9 @@ describe('DOM Components', () => { #${id} { background-color: red } `) as Component; const rule = cc.getAll().at(0); - expect(rule.toCSS()).toEqual(`#${id}{background-color:red;color:red;padding:50px 100px;}`); + expect(rule.toCSS()).toEqual(`#${id}{color:red;padding:50px 100px;background-color:red;}`); obj.getComponents().first().addStyle({ margin: '10px' }); - const css = `#${id}{background-color:red;color:red;padding:50px 100px;margin:10px;}`; + const css = `#${id}{color:red;padding:50px 100px;background-color:red;margin:10px;}`; expect(rule.toCSS()).toEqual(css); setTimeout(() => { diff --git a/packages/core/test/specs/undo_manager/datasources.ts b/packages/core/test/specs/undo_manager/datasources.ts new file mode 100644 index 000000000..9388ca726 --- /dev/null +++ b/packages/core/test/specs/undo_manager/datasources.ts @@ -0,0 +1,248 @@ +import { Component, DataSourceManager, Editor } from '../../../src'; +import { DataConditionType } from '../../../src/data_sources/model/conditional_variables/DataCondition'; +import { StringOperation } from '../../../src/data_sources/model/conditional_variables/operators/StringOperator'; +import { DataVariableType } from '../../../src/data_sources/model/DataVariable'; +import UndoManager from '../../../src/undo_manager'; +import { setupTestEditor } from '../../common'; + +describe('Undo Manager with Data Binding', () => { + let editor: Editor; + let um: UndoManager; + let wrapper: Component; + let dsm: DataSourceManager; + + const makeColorVar = () => ({ + type: DataVariableType, + path: 'ds1.rec1.color', + }); + const makeTitleVar = () => ({ + type: DataVariableType, + path: 'ds1.rec1.title', + }); + const makeContentVar = () => ({ + type: DataVariableType, + path: 'ds1.rec1.content', + }); + + beforeEach(() => { + ({ editor, um, dsm } = setupTestEditor({ withCanvas: true })); + wrapper = editor.getWrapper()!; + dsm.add({ + id: 'ds1', + records: [{ id: 'rec1', color: 'red', title: 'Initial Title', content: 'Initial Content' }], + }); + jest.useFakeTimers(); + }); + + afterEach(() => { + editor.destroy(); + }); + + describe('Initial State with Data Binding', () => { + it('should correctly initialize with a component having data-bound properties', () => { + const component = wrapper.append({ + style: { color: makeColorVar() }, + attributes: { title: makeTitleVar() }, + content: makeContentVar(), + })[0]; + + expect(um.getStackGroup()).toHaveLength(1); + um.undo(); + um.redo(); + expect(component.getStyle().color).toBe('red'); + expect(component.getAttributes().title).toBe('Initial Title'); + expect(component.get('content')).toBe('Initial Content'); + expect(um.getStackGroup()).toHaveLength(1); + }); + }); + + describe('Core Undo/Redo on Component Data Binding', () => { + describe('Styles', () => { + it('should undo and redo the assignment of a data value to a style', () => { + const component = wrapper.append({ + content: makeContentVar(), + style: { color: 'blue', 'font-size': '12px' }, + })[0]; + + jest.runAllTimers(); + um.clear(); + component.setStyle({ color: makeColorVar() }); + expect(component.getStyle().color).toBe('red'); + expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar()); + + um.undo(); + expect(component.getStyle().color).toBe('blue'); + expect(component.getStyle({ skipResolve: true }).color).toBe('blue'); + + um.redo(); + expect(component.getStyle().color).toBe('red'); + expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar()); + }); + + it('should handle binding with a data-condition value', () => { + const component = wrapper.append({ content: 'some content', style: { color: 'blue' } })[0]; + const conditionVar = { + type: DataConditionType, + condition: { left: makeTitleVar(), operator: StringOperation.contains, right: 'Initial' }, + ifTrue: 'green', + ifFalse: 'purple', + }; + + jest.runAllTimers(); + um.clear(); + + component.addStyle({ color: conditionVar }); + expect(component.getStyle().color).toBe('green'); + + um.undo(); + expect(component.getStyle().color).toBe('blue'); + + um.redo(); + expect(component.getStyle().color).toBe('green'); + }); + }); + + describe('Attributes', () => { + it('should undo and redo the assignment of a data value to an attribute', () => { + const component = wrapper.append({ attributes: { title: 'Static Title' } })[0]; + + jest.runAllTimers(); + um.clear(); + + component.setAttributes({ title: makeTitleVar() }); + expect(component.getAttributes().title).toBe('Initial Title'); + + um.undo(); + expect(component.getAttributes().title).toBe('Static Title'); + + um.redo(); + expect(component.getAttributes().title).toBe('Initial Title'); + }); + }); + + describe('Properties', () => { + it('should undo and redo the assignment of a data value to a property', () => { + const component = wrapper.append({ content: 'Static Content' })[0]; + + jest.runAllTimers(); + um.clear(); + + component.set({ content: makeContentVar() }); + expect(component.get('content')).toBe('Initial Content'); + + um.undo(); + expect(component.get('content')).toBe('Static Content'); + + um.redo(); + expect(component.get('content')).toBe('Initial Content'); + }); + }); + }); + + describe('Value Overwriting Scenarios', () => { + it('should correctly undo a static style that overwrites a data binding', () => { + const component = wrapper.append({ + style: { color: makeColorVar() }, + attributes: { title: 'Static Title' }, + })[0]; + + jest.runAllTimers(); + um.clear(); + + component.addStyle({ color: 'green' }); + expect(component.getStyle().color).toBe('green'); + + um.undo(); + expect(component.getStyle().color).toBe('red'); + expect(component.getAttributes().title).toBe('Static Title'); + }); + + it('should correctly undo a data binding that overwrites a static style', () => { + const component = wrapper.append({ style: { color: 'green' } })[0]; + + jest.runAllTimers(); + um.clear(); + + component.addStyle({ color: makeColorVar() }); + expect(component.getStyle().color).toBe('red'); + + um.undo(); + expect(component.getStyle().color).toBe('green'); + }); + }); + + describe('Listeners & Data Source Integrity', () => { + it('should maintain listeners after a binding is restored via undo', () => { + const component = wrapper.append({ style: { color: makeColorVar() } })[0]; + + jest.runAllTimers(); + um.clear(); + + component.addStyle({ color: 'green' }); + expect(component.getStyle().color).toBe('green'); + + um.undo(); + expect(component.getStyle().color).toBe('red'); + + dsm.get('ds1').getRecord('rec1')!.set('color', 'purple'); + expect(component.getStyle().color).toBe('purple'); + }); + + it('should handle undo when the data source has been removed', () => { + const component = wrapper.append({ style: { color: makeColorVar() } })[0]; + expect(component.getStyle().color).toBe('red'); + + jest.runAllTimers(); + um.clear(); + + dsm.remove('ds1'); + expect(component.getStyle().color).toBeUndefined(); + + um.undo(); + expect(dsm.get('ds1')).toBeTruthy(); + expect(component.getStyle().color).toBe('red'); + }); + }); + + describe('Serialization & Cloning', () => { + let component: any; + + beforeEach(() => { + component = wrapper.append({ + style: { color: makeColorVar() }, + attributes: { title: makeTitleVar() }, + content: makeContentVar(), + })[0]; + }); + + it('should correctly serialize data bindings in toJSON()', () => { + const json = component.toJSON(); + expect(json.attributes.title).toEqual(makeTitleVar()); + expect(json.__dynamicProps).toBeUndefined(); + }); + + it('should correctly clone data bindings', () => { + const clone = component.clone(); + expect(clone.getStyle('', { skipResolve: true }).color).toEqual(makeColorVar()); + expect(clone.getAttributes({ skipResolve: true }).title).toEqual(makeTitleVar()); + expect(clone.get('content', { skipResolve: true })).toEqual(makeContentVar()); + expect(clone.getStyle().color).toBe('red'); + }); + + it('should ensure a cloned component has an independent undo history', () => { + const clone = component.clone(); + wrapper.append(clone); + + jest.runAllTimers(); + um.clear(); + + component.addStyle({ color: 'blue' }); + expect(um.hasUndo()).toBe(true); + expect(clone.getStyle().color).toBe('red'); + + um.undo(); + expect(component.getStyle().color).toBe('red'); + expect(clone.getStyle().color).toBe('red'); + }); + }); +}); diff --git a/packages/core/test/specs/undo_manager/index.ts b/packages/core/test/specs/undo_manager/index.ts new file mode 100644 index 000000000..060fcafdb --- /dev/null +++ b/packages/core/test/specs/undo_manager/index.ts @@ -0,0 +1,286 @@ +import UndoManager from '../../../src/undo_manager'; +import Editor from '../../../src/editor'; +import { setupTestEditor } from '../../common'; + +describe('Undo Manager', () => { + let editor: Editor; + let um: UndoManager; + let wrapper: any; + + beforeEach(() => { + ({ editor, um } = setupTestEditor({ + withCanvas: true, + })); + wrapper = editor.getWrapper(); + um.clear(); + }); + + afterEach(() => { + editor.destroy(); + }); + + test('Initial state is correct', () => { + expect(um.hasUndo()).toBe(false); + expect(um.hasRedo()).toBe(false); + expect(um.getStack()).toHaveLength(0); + }); + + describe('Component changes', () => { + test('Add component', () => { + expect(wrapper.components()).toHaveLength(0); + wrapper.append('
'); + expect(wrapper.components()).toHaveLength(1); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(wrapper.components()).toHaveLength(0); + expect(um.hasRedo()).toBe(true); + + um.redo(); + expect(wrapper.components()).toHaveLength(1); + }); + + test('Remove component', () => { + const comp = wrapper.append('
')[0]; + expect(wrapper.components()).toHaveLength(1); + um.clear(); + + comp.remove(); + expect(wrapper.components()).toHaveLength(0); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(wrapper.components()).toHaveLength(1); + expect(um.hasRedo()).toBe(true); + + um.redo(); + expect(wrapper.components()).toHaveLength(0); + }); + + test('Modify component properties', () => { + const comp = wrapper.append({ tagName: 'div', content: 'test' })[0]; + um.clear(); + + comp.set('content', 'test2'); + expect(comp.get('content')).toBe('test2'); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(comp.get('content')).toBe('test'); + + um.redo(); + expect(comp.get('content')).toBe('test2'); + }); + + test('Modify component style (StyleManager)', () => { + const comp = wrapper.append('
')[0]; + + um.clear(); + comp.addStyle({ color: 'red' }); + expect(comp.getStyle().color).toBe('red'); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(comp.getStyle().color).toBeUndefined(); + + um.redo(); + expect(comp.getStyle().color).toBe('red'); + }); + + test('Move component', () => { + wrapper.append('
1
2
'); + const comp1 = wrapper.components().at(0); + const comp2 = wrapper.components().at(1); + + um.clear(); + + wrapper.append(comp1, { at: 2 }); + expect(wrapper.components().at(0)).toBe(comp2); + expect(wrapper.components().at(1)).toBe(comp1); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(wrapper.components().at(0)).toBe(comp1); + expect(wrapper.components().at(1)).toBe(comp2); + + um.redo(); + expect(wrapper.components().at(0)).toBe(comp2); + expect(wrapper.components().at(1)).toBe(comp1); + }); + + test('Grouped component additions are treated as one undo action', () => { + wrapper.append('
1
2
'); + + expect(wrapper.components()).toHaveLength(2); + expect(um.getStackGroup()).toHaveLength(1); + + um.undo(); + expect(wrapper.components()).toHaveLength(0); + }); + }); + + describe('CSS Rule changes', () => { + test('Add CSS Rule', () => { + editor.Css.addRules('.test { color: red; }'); + + expect(editor.Css.getRules('.test')).toHaveLength(1); + + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(editor.Css.getRules('.test')).toHaveLength(0); + + um.redo(); + expect(editor.Css.getRules('.test')).toHaveLength(1); + expect(editor.Css.getRule('.test')?.getStyle().color).toBe('red'); + }); + + test('Modify CSS Rule', () => { + const rule = editor.Css.addRules('.test { color: red; }')[0]; + + um.clear(); + + rule.setStyle({ color: 'blue' }); + expect(rule.getStyle().color).toBe('blue'); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(rule.getStyle().color).toBe('red'); + + um.redo(); + expect(rule.getStyle().color).toBe('blue'); + }); + + test('Remove CSS Rule', () => { + const rule = editor.Css.addRules('.test { color: red; }')[0]; + + um.clear(); + + editor.Css.remove(rule); + expect(editor.Css.getRules('.test')).toHaveLength(0); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(editor.Css.getRules('.test')).toHaveLength(1); + + um.redo(); + expect(editor.Css.getRules('.test')).toHaveLength(0); + }); + }); + + // TODO: add undo_manager to asset manager + describe.skip('Asset Manager changes', () => { + test('Add asset', () => { + const am = editor.Assets; + expect(am.getAll()).toHaveLength(0); + + um.clear(); + + am.add('path/to/img.jpg'); + expect(am.getAll()).toHaveLength(1); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(am.getAll()).toHaveLength(0); + + um.redo(); + expect(am.getAll()).toHaveLength(1); + expect(am.get('path/to/img.jpg')).toBeTruthy(); + }); + + test('Remove asset', () => { + const am = editor.Assets; + const asset = am.add('path/to/img.jpg'); + expect(am.getAll()).toHaveLength(1); + + um.clear(); + + am.remove(asset); + expect(am.getAll()).toHaveLength(0); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(am.getAll()).toHaveLength(1); + + um.redo(); + expect(am.getAll()).toHaveLength(0); + }); + }); + + // TODO: add undo_manager to editor + describe.skip('Editor states changes', () => { + test('Device change', () => { + editor.Devices.add({ id: 'tablet', name: 'Tablet', width: 'auto' }); + + um.clear(); + + editor.setDevice('Tablet'); + expect(editor.getDevice()).toBe('Tablet'); + expect(um.hasUndo()).toBe(true); + + um.undo(); + // Default device is an empty string + expect(editor.getDevice()).toBe(''); + + um.redo(); + expect(editor.getDevice()).toBe('Tablet'); + }); + + test('Panel visibility change', () => { + const panel = editor.Panels.getPanel('options')!; + panel.set('visible', true); + + um.clear(); + + panel.set('visible', false); + expect(panel.get('visible')).toBe(false); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(panel.get('visible')).toBe(true); + + um.redo(); + expect(panel.get('visible')).toBe(false); + }); + }); + + describe('Selection tracking', () => { + test('Change selection', (done) => { + const comp1 = wrapper.append('
1
')[0]; + const comp2 = wrapper.append('
2
')[0]; + + um.clear(); + editor.select(comp1); + expect(editor.getSelected()).toBe(comp1); + + setTimeout(() => { + editor.select(comp2); + expect(editor.getSelected()).toBe(comp2); + expect(um.hasUndo()).toBe(true); + um.undo(); + expect(editor.getSelected()).toBe(comp1); + um.redo(); + expect(editor.getSelected()).toBe(comp2); + done(); + }); + }); + }); + + describe('Operations with `noUndo`', () => { + test('Skipping undo for component modification', () => { + const comp = wrapper.append('
')[0]; + + um.clear(); + + comp.set('content', 'no undo content', { noUndo: true }); + expect(um.hasUndo()).toBe(false); + + wrapper.append('
undo this
'); + expect(um.hasUndo()).toBe(true); + + um.undo(); + expect(wrapper.components()).toHaveLength(1); + expect(wrapper.components().at(0).get('content')).toBe('no undo content'); + }); + }); +}); From 6089fc756cc8f65576a4373b863e1ba4c1a12ef4 Mon Sep 17 00:00:00 2001 From: mohamed yahia Date: Tue, 2 Sep 2025 03:10:16 +0300 Subject: [PATCH 04/17] Add `check` script ( `lint && format:check && ts:check` ) (#6599) Add a single script for checking grapesjs --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 4b69595bf..576418231 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "docs:api": "pnpm --filter @grapesjs/docs docs:api", "lint": "eslint .", "build": "pnpm -r run build", + "check": "pnpm run lint && pnpm run format:check && pnpm run ts:check", "ts:check": "pnpm --filter grapesjs ts:check", "clean": "find . -type d \\( -name \"node_modules\" -o -name \"build\" -o -name \"dist\" \\) -exec rm -rf {} + && rm ./pnpm-lock.yaml", "format": "prettier . --write --ignore-path .prettierignore", From 3b97b7a04397876967b3e498f495764647dd42db Mon Sep 17 00:00:00 2001 From: mohamed yahia Date: Wed, 3 Sep 2025 11:34:56 +0300 Subject: [PATCH 05/17] [#6587]: Fix infinite text nodes on dragging into a symbol (#6600) * [#6587]: Fix infinite text nodes on dragging into a symbol * Fix race condition during storing project data * Update bug fix --- packages/core/src/editor/model/Editor.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/core/src/editor/model/Editor.ts b/packages/core/src/editor/model/Editor.ts index 5fb4ac329..a13d954ad 100644 --- a/packages/core/src/editor/model/Editor.ts +++ b/packages/core/src/editor/model/Editor.ts @@ -119,6 +119,7 @@ export default class EditorModel extends Model { destroyed = false; _config: InitEditorConfig; _storageTimeout?: ReturnType; + _isStoring: boolean = false; attrsOrig: any; timedInterval?: ReturnType; updateItr?: ReturnType; @@ -869,9 +870,19 @@ export default class EditorModel extends Model { * @public */ async store(options?: T) { + if (this._isStoring) return; + this._isStoring = true; + // We use a 1ms timeout to defer the cleanup to the next tick of the event loop. + // This prevents a race condition where a store operation, like 'sync:content', + // might increase the dirty count before it can be properly cleared. + setTimeout(() => { + this.clearDirtyCount(); + }, 1); const data = this.storeData(); await this.Storage.store(data, options); - this.clearDirtyCount(); + setTimeout(() => { + this._isStoring = false; + }, 1); return data; } From 3b1b9faaa674785ef483b4d768931306ea2da3c0 Mon Sep 17 00:00:00 2001 From: "nanto_vi, TOYAMA Nao" Date: Sat, 13 Sep 2025 04:18:38 +0900 Subject: [PATCH 06/17] Escape ampersand, less-than, and greater-than in attribute value (#6604) --- .../core/src/dom_components/model/Component.ts | 14 +++++++++++--- packages/core/src/utils/mixins.ts | 8 ++++++++ .../test/specs/dom_components/model/Component.ts | 6 +++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index 1dae41ba4..aaf0b90e1 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/packages/core/src/dom_components/model/Component.ts @@ -11,7 +11,15 @@ import { bindAll, keys, } from 'underscore'; -import { shallowDiff, capitalize, isEmptyObj, isObject, toLowerCase } from '../../utils/mixins'; +import { + shallowDiff, + capitalize, + isEmptyObj, + isObject, + toLowerCase, + escapeAltQuoteAttrValue, + escapeAttrValue, +} from '../../utils/mixins'; import StyleableModel, { GetStyleOpts, StyleProps, @@ -1597,9 +1605,9 @@ export default class Component extends StyleableModel { } else { let valueRes = ''; if (opts.altQuoteAttr && isString(val) && val.indexOf('"') >= 0) { - valueRes = `'${val.replace(/'/g, ''')}'`; + valueRes = `'${escapeAltQuoteAttrValue(val)}'`; } else { - const value = isString(val) ? val.replace(/"/g, '"') : val; + const value = isString(val) ? escapeAttrValue(val) : val; valueRes = `"${value}"`; } diff --git a/packages/core/src/utils/mixins.ts b/packages/core/src/utils/mixins.ts index 038461210..8426b796d 100644 --- a/packages/core/src/utils/mixins.ts +++ b/packages/core/src/utils/mixins.ts @@ -192,6 +192,14 @@ export const escapeNodeContent = (str = '') => { return `${str}`.replace(/&/g, '&').replace(//g, '>'); }; +export const escapeAttrValue = (str = '') => { + return `${str}`.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +}; + +export const escapeAltQuoteAttrValue = (str = '') => { + return `${str}`.replace(/&/g, '&').replace(//g, '>').replace(/'/g, '''); +}; + export const deepMerge = (...args: ObjectAny[]) => { const target = { ...args[0] }; diff --git a/packages/core/test/specs/dom_components/model/Component.ts b/packages/core/test/specs/dom_components/model/Component.ts index 6069c05af..bbe957416 100644 --- a/packages/core/test/specs/dom_components/model/Component.ts +++ b/packages/core/test/specs/dom_components/model/Component.ts @@ -155,7 +155,7 @@ describe('Component', () => { obj.set({ bool: true, removable: false, - string: 'st\'ri"ng', + string: 'st\'ri"ng&<>', array: [1, 'string', true], object: { a: 1, b: 'string', c: true }, null: null, @@ -164,12 +164,12 @@ describe('Component', () => { zero: 0, _private: 'value', }); - let resStr = "st'ri"ng"; + let resStr = "st'ri"ng&<>"; let resArr = '[1,"string",true]'; let resObj = '{"a":1,"b":"string","c":true}'; let res = `
`; expect(obj.toHTML({ withProps: true })).toEqual(res); - resStr = 'st'ri"ng'; + resStr = 'st'ri"ng&<>'; resArr = '[1,"string",true]'; resObj = '{"a":1,"b":"string","c":true}'; res = `
`; From 26d14708bcf3eb4575a69e392eaef1b37c0e84c9 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Fri, 12 Sep 2025 23:55:57 +0400 Subject: [PATCH 07/17] Improve reset from string (#6607) * Update resetFromString with new options * Fix updateClasses in ComponentView --- .../src/dom_components/model/Components.ts | 39 ++++++++++++++++--- .../src/dom_components/view/ComponentView.ts | 2 +- packages/core/src/selector_manager/index.ts | 2 +- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/core/src/dom_components/model/Components.ts b/packages/core/src/dom_components/model/Components.ts index 23f645f8a..8e6253c02 100644 --- a/packages/core/src/dom_components/model/Components.ts +++ b/packages/core/src/dom_components/model/Components.ts @@ -1,5 +1,5 @@ import { isEmpty, isArray, isString, isFunction, each, includes, extend, flatten, keys } from 'underscore'; -import Component from './Component'; +import Component, { SetAttrOptions } from './Component'; import { AddOptions, Collection } from '../../common'; import { DomComponentsConfig } from '../config/config'; import EditorModel from '../../editor/model/Editor'; @@ -18,6 +18,21 @@ import ComponentWrapper from './ComponentWrapper'; import { ComponentsEvents, ParseStringOptions } from '../types'; import { isSymbolInstance, isSymbolRoot, updateSymbolComps } from './SymbolUtils'; +export interface ResetCommonUpdateProps { + component: Component; + item: ComponentDefinitionDefined; + options: SetAttrOptions; +} + +export interface ResetFromStringOptions { + visitedCmps?: Record; + keepIds?: string[]; + updateOptions?: { + onAttributes?: (props: ResetCommonUpdateProps & { attributes: Record }) => void; + onStyle?: (props: ResetCommonUpdateProps & { style: Record }) => void; + }; +} + export const getComponentIds = (cmp?: Component | Component[] | Components, res: string[] = []) => { if (!cmp) return []; const cmps = (isArray(cmp) || isFunction((cmp as Components).map) ? cmp : [cmp]) as Component[]; @@ -35,6 +50,7 @@ const getComponentsFromDefs = ( ) => { opts.visitedCmps = opts.visitedCmps || {}; const { visitedCmps } = opts; + const updateOptions = (opts.updateOptions as ResetFromStringOptions['updateOptions']) || {}; const itms = isArray(items) ? items : [items]; return itms.map((item) => { @@ -50,10 +66,21 @@ const getComponentsFromDefs = ( // Update the component if exists already if (all[id]) { result = all[id] as any; - const cmp = result as unknown as Component; - tagName && cmp.set({ tagName }, { ...opts, silent: true }); - keys(restAttr).length && cmp.addAttributes(restAttr, { ...opts }); - keys(style).length && cmp.addStyle(style, opts); + const { onAttributes, onStyle } = updateOptions; + const component = result as unknown as Component; + tagName && component.set({ tagName }, { ...opts, silent: true }); + + if (onAttributes) { + onAttributes({ item, component, attributes: restAttr, options: opts }); + } else if (keys(restAttr).length) { + component.addAttributes(restAttr, { ...opts }); + } + + if (onStyle) { + onStyle({ item, component, style, options: opts }); + } else if (keys(style).length) { + component.addStyle(style, opts); + } } } else { // Found another component with the same ID, treat it as a new component @@ -131,7 +158,7 @@ Component> { models.each((model) => this.onAdd(model)); } - resetFromString(input = '', opts: { visitedCmps?: Record; keepIds?: string[] } = {}) { + resetFromString(input = '', opts: ResetFromStringOptions = {}) { opts.keepIds = getComponentIds(this); const { domc, em, parent } = this; const cssc = em?.Css; diff --git a/packages/core/src/dom_components/view/ComponentView.ts b/packages/core/src/dom_components/view/ComponentView.ts index 6658467f1..2e6d2d1a1 100644 --- a/packages/core/src/dom_components/view/ComponentView.ts +++ b/packages/core/src/dom_components/view/ComponentView.ts @@ -315,7 +315,7 @@ TComp> { * @private * */ updateClasses() { - const str = this.model.classes.pluck('name').join(' '); + const str = this.model.classes.pluck?.('name').join(' ') || ''; this.setAttribute('class', str); // Regenerate status class diff --git a/packages/core/src/selector_manager/index.ts b/packages/core/src/selector_manager/index.ts index e8905df40..129cdfec8 100644 --- a/packages/core/src/selector_manager/index.ts +++ b/packages/core/src/selector_manager/index.ts @@ -292,7 +292,7 @@ export default class SelectorManager extends ItemManagerModule added.push(this.addSelector(name) as Selector)); From 753b1731fd46d277240b26a262645594ae050805 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Tue, 16 Sep 2025 14:10:12 +0400 Subject: [PATCH 08/17] Release GrapesJS core latest: v0.22.13 (#6611) --- packages/core/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/package.json b/packages/core/package.json index 281001dd6..8e7dbc065 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "grapesjs", "description": "Free and Open Source Web Builder Framework", - "version": "0.22.12", + "version": "0.22.13", "author": "Artur Arseniev", "license": "BSD-3-Clause", "homepage": "http://grapesjs.com", From bbccd9496ad85c947c862b96c453d76c79753897 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Wed, 1 Oct 2025 13:01:42 +0400 Subject: [PATCH 09/17] Improve resetFromString by adding option to skip view updates (#6617) --- packages/core/src/dom_components/model/Components.ts | 2 +- packages/core/src/dom_components/model/types.ts | 6 +++++- packages/core/src/dom_components/view/ComponentView.ts | 5 +++-- .../core/src/dom_components/view/ComponentsView.ts | 10 ++++++---- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/core/src/dom_components/model/Components.ts b/packages/core/src/dom_components/model/Components.ts index 8e6253c02..10178e0c6 100644 --- a/packages/core/src/dom_components/model/Components.ts +++ b/packages/core/src/dom_components/model/Components.ts @@ -164,7 +164,7 @@ Component> { const cssc = em?.Css; const allByID = domc?.allById() || {}; const parsed = this.parseString(input, opts); - const newCmps = getComponentsFromDefs(parsed, allByID, opts); + const newCmps = getComponentsFromDefs(parsed, allByID, { skipViewUpdate: true, ...opts }); const { visitedCmps = {} } = opts; // Clone styles for duplicated components diff --git a/packages/core/src/dom_components/model/types.ts b/packages/core/src/dom_components/model/types.ts index 84e7cc7ce..ad3b01a15 100644 --- a/packages/core/src/dom_components/model/types.ts +++ b/packages/core/src/dom_components/model/types.ts @@ -19,7 +19,11 @@ export type DraggableDroppableFn = (source: Component, target: Component, index? export interface AddComponentsOption extends AddOptions, OptionAsDocument {} -export interface ResetComponentsOptions extends AddComponentsOption { +export interface UpdateComponentsOptions extends AddComponentsOption { + skipViewUpdate?: boolean; +} + +export interface ResetComponentsOptions extends UpdateComponentsOptions { previousModels?: Component[]; keepIds?: string[]; skipDomReset?: boolean; diff --git a/packages/core/src/dom_components/view/ComponentView.ts b/packages/core/src/dom_components/view/ComponentView.ts index 2e6d2d1a1..0a8341769 100644 --- a/packages/core/src/dom_components/view/ComponentView.ts +++ b/packages/core/src/dom_components/view/ComponentView.ts @@ -11,7 +11,7 @@ import { setViewEl } from '../../utils/mixins'; import { DomComponentsConfig } from '../config/config'; import Component, { avoidInline } from '../model/Component'; import Components from '../model/Components'; -import { ComponentOptions } from '../model/types'; +import { ComponentOptions, UpdateComponentsOptions } from '../model/types'; import ComponentsView from './ComponentsView'; import { ComponentsEvents } from '../types'; @@ -570,7 +570,8 @@ TComp> { } } - renderAttributes() { + renderAttributes(m?: any, v?: any, opts: UpdateComponentsOptions = {}) { + if (opts.skipViewUpdate) return; this.updateAttributes(); this.updateClasses(); } diff --git a/packages/core/src/dom_components/view/ComponentsView.ts b/packages/core/src/dom_components/view/ComponentsView.ts index 7daf853b3..3b0b276fd 100644 --- a/packages/core/src/dom_components/view/ComponentsView.ts +++ b/packages/core/src/dom_components/view/ComponentsView.ts @@ -1,14 +1,14 @@ import { isUndefined } from 'underscore'; -import { removeEl } from '../../utils/dom'; +import FrameView from '../../canvas/view/FrameView'; import { View } from '../../common'; -import { DomComponentsConfig } from '../config/config'; import EditorModel from '../../editor/model/Editor'; +import { removeEl } from '../../utils/dom'; +import { DomComponentsConfig } from '../config/config'; import Component from '../model/Component'; -import ComponentView from './ComponentView'; -import FrameView from '../../canvas/view/FrameView'; import Components from '../model/Components'; import { ResetComponentsOptions } from '../model/types'; import { ComponentsEvents } from '../types'; +import ComponentView from './ComponentView'; export default class ComponentsView extends View { opts!: any; @@ -127,6 +127,8 @@ export default class ComponentsView extends View { } resetChildren(models: Components, opts: ResetComponentsOptions = {}) { + if (opts.skipViewUpdate) return; + const { previousModels } = opts; if (!opts.skipDomReset) { this.parentEl!.innerHTML = ''; From fe88fc88a353ff8a526170565e54a67d60dcba19 Mon Sep 17 00:00:00 2001 From: mohamed yahia Date: Fri, 10 Oct 2025 13:58:09 +0300 Subject: [PATCH 10/17] Pages datasources (#6601) * Add tests for component wrapper * Refactor component data collection * Add data resolver to wrapper component * Fix types * Add collection data source to page * refactor get and set DataResolver to componentWrapper * Rename key to __rootData * add resolverCurrentItem * Make _resolverCurrentItem private * update ComponentWrapper tests * Fix componentWithCollectionsState * remove collectionsStateMap from Page * update component wrapper tests * fix component wrapper tests * return a copy of records for DataSource.getPath * Move all collection listeners to component with collection state * fix style sync in collection items * fix loop issue * update data collection tests * cleanup * update collection statemap on wrapper change * Add object test data for wrapper data resolver * cleanup * up unit test * remove duplicated code * cleanup event path * update test data to better names * improve component data collection performance * cleanup tests and types * fix performance issue for the new wrapper datasource * Undo updating component with datacolection tests * apply comments * Skip same path update --------- Co-authored-by: Artur Arseniev --- packages/core/src/data_sources/index.ts | 4 +- .../model/ComponentWithCollectionsState.ts | 120 ++++++++++ .../model/DataResolverListener.ts | 17 +- .../src/data_sources/model/DataVariable.ts | 36 ++- .../ComponentDataCollection.ts | 210 ++++++------------ .../model/data_collection/types.ts | 7 +- packages/core/src/dom_components/constants.ts | 1 + .../src/dom_components/model/Component.ts | 32 +-- .../dom_components/model/ComponentWrapper.ts | 95 +++++++- .../model/ModelResolverWatcher.ts | 3 - .../src/dom_components/model/SymbolUtils.ts | 26 ++- .../dom_components/model/ComponentWrapper.ts | 96 ++++++++ 12 files changed, 458 insertions(+), 189 deletions(-) create mode 100644 packages/core/src/data_sources/model/ComponentWithCollectionsState.ts create mode 100644 packages/core/src/dom_components/constants.ts diff --git a/packages/core/src/data_sources/index.ts b/packages/core/src/data_sources/index.ts index 5e9f6efb1..d54110a75 100644 --- a/packages/core/src/data_sources/index.ts +++ b/packages/core/src/data_sources/index.ts @@ -88,7 +88,9 @@ export default class DataSourceManager extends ItemManagerModule { const dataRecord = dr; - accR[dataRecord.id || i] = dataRecord.attributes; + const attributes = { ...dataRecord.attributes }; + delete attributes.__p; + accR[dataRecord.id || i] = attributes; return accR; }, {} as ObjectAny); diff --git a/packages/core/src/data_sources/model/ComponentWithCollectionsState.ts b/packages/core/src/data_sources/model/ComponentWithCollectionsState.ts new file mode 100644 index 000000000..13f76fea4 --- /dev/null +++ b/packages/core/src/data_sources/model/ComponentWithCollectionsState.ts @@ -0,0 +1,120 @@ +import { DataCollectionStateMap } from '../../data_sources/model/data_collection/types'; +import DataResolverListener from '../../data_sources/model/DataResolverListener'; +import DataVariable, { DataVariableProps, DataVariableType } from '../../data_sources/model/DataVariable'; +import Components from '../../dom_components/model/Components'; +import Component from '../../dom_components/model/Component'; +import { ObjectAny } from '../../common'; +import DataSource from './DataSource'; +import { isArray } from 'underscore'; + +export type DataVariableMap = Record; + +export type DataSourceRecords = DataVariableProps[] | DataVariableMap; + +export default class ComponentWithCollectionsState extends Component { + collectionsStateMap: DataCollectionStateMap = {}; + dataSourceWatcher?: DataResolverListener; + + constructor(props: any, opt: any) { + super(props, opt); + this.listenToPropsChange(); + } + + onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) { + this.collectionsStateMap = collectionsStateMap; + this.dataResolverWatchers?.onCollectionsStateMapUpdate?.(); + + this.components().forEach((cmp) => { + cmp.onCollectionsStateMapUpdate?.(collectionsStateMap); + }); + } + + syncOnComponentChange(model: Component, collection: Components, opts: any) { + const prev = this.collectionsStateMap; + this.collectionsStateMap = {}; + super.syncOnComponentChange(model, collection, opts); + this.collectionsStateMap = prev; + this.onCollectionsStateMapUpdate(prev); + } + + setDataResolver(dataResolver: DataResolverType | undefined) { + return this.set('dataResolver', dataResolver); + } + + get dataResolverProps(): DataResolverType | undefined { + return this.get('dataResolver'); + } + + protected listenToDataSource() { + const path = this.dataResolverPath; + if (!path) return; + + const { em, collectionsStateMap } = this; + this.dataSourceWatcher?.destroy(); + this.dataSourceWatcher = new DataResolverListener({ + em, + resolver: new DataVariable({ type: DataVariableType, path }, { em, collectionsStateMap }), + onUpdate: () => this.onDataSourceChange(), + }); + } + + protected listenToPropsChange() { + this.on(`change:dataResolver`, () => { + this.listenToDataSource(); + }); + + this.listenToDataSource(); + } + + protected get dataSourceProps(): DataVariableProps | undefined { + return this.get('dataResolver'); + } + + protected get dataResolverPath(): string | undefined { + return this.dataSourceProps?.path; + } + + protected onDataSourceChange() { + this.onCollectionsStateMapUpdate(this.collectionsStateMap); + } + + protected getDataSourceItems(): DataSourceRecords { + const dataSourceProps = this.dataSourceProps; + if (!dataSourceProps) return []; + const items = this.listDataSourceItems(dataSourceProps); + if (items && isArray(items)) { + return items; + } + + const clone = { ...items }; + return clone; + } + + protected listDataSourceItems(dataSource: DataSource | DataVariableProps): DataSourceRecords { + const path = dataSource instanceof DataSource ? dataSource.get('id')! : dataSource.path; + if (!path) return []; + let value = this.em.DataSources.getValue(path, []); + + const isDatasourceId = path.split('.').length === 1; + if (isDatasourceId) { + value = Object.entries(value).map(([_, value]) => value); + } + + return value; + } + + protected getItemKey(items: DataVariableProps[] | { [x: string]: DataVariableProps }, index: number) { + return isArray(items) ? index : Object.keys(items)[index]; + } + + private removePropsListeners() { + this.off(`change:dataResolver`); + this.dataSourceWatcher?.destroy(); + this.dataSourceWatcher = undefined; + } + + destroy(options?: ObjectAny): false | JQueryXHR { + this.removePropsListeners(); + return super.destroy(options); + } +} diff --git a/packages/core/src/data_sources/model/DataResolverListener.ts b/packages/core/src/data_sources/model/DataResolverListener.ts index cf3d4c9c3..f2a211904 100644 --- a/packages/core/src/data_sources/model/DataResolverListener.ts +++ b/packages/core/src/data_sources/model/DataResolverListener.ts @@ -17,7 +17,7 @@ export interface DataResolverListenerProps { } interface ListenerWithCallback extends DataSourceListener { - callback: () => void; + callback: (opts?: any) => void; } export default class DataResolverListener { @@ -39,7 +39,11 @@ export default class DataResolverListener { this.onUpdate(value); }; - private createListener(obj: any, event: string, callback: () => void = this.onChange): ListenerWithCallback { + private createListener( + obj: any, + event: string, + callback: (opts?: any) => void = this.onChange, + ): ListenerWithCallback { return { obj, event, callback }; } @@ -98,6 +102,15 @@ export default class DataResolverListener { dataListeners.push( this.createListener(em.DataSources.all, 'add remove reset', onChangeAndRewatch), this.createListener(em, `${DataSourcesEvents.path}:${normPath}`), + this.createListener(em, DataSourcesEvents.path, ({ path: eventPath }: { path: string }) => { + if ( + // Skip same path as it's already handled be the listener above + eventPath !== path && + eventPath.startsWith(path) + ) { + this.onChange(); + } + }), ); return dataListeners; diff --git a/packages/core/src/data_sources/model/DataVariable.ts b/packages/core/src/data_sources/model/DataVariable.ts index c7f195fcc..b8dbb1258 100644 --- a/packages/core/src/data_sources/model/DataVariable.ts +++ b/packages/core/src/data_sources/model/DataVariable.ts @@ -1,7 +1,13 @@ import { Model } from '../../common'; +import { keyRootData } from '../../dom_components/constants'; import EditorModel from '../../editor/model/Editor'; import { isDataVariable } from '../utils'; -import { DataCollectionStateMap, DataCollectionState, DataCollectionStateType } from './data_collection/types'; +import { + DataCollectionStateMap, + DataCollectionState, + DataCollectionStateType, + RootDataType, +} from './data_collection/types'; export const DataVariableType = 'data-variable' as const; @@ -134,36 +140,44 @@ export default class DataVariable extends Model { ); } - private resolveCollectionVariable(): unknown { + private resolveCollectionVariable() { const { em, collectionsStateMap } = this; return DataVariable.resolveCollectionVariable(this.attributes, { em, collectionsStateMap }); } static resolveCollectionVariable( - dataResolverProps: { + params: { collectionId?: string; variableType?: DataCollectionStateType; path?: string; defaultValue?: string; }, - opts: DataVariableOptions, - ): unknown { - const { collectionId = '', variableType, path, defaultValue = '' } = dataResolverProps; - const { em, collectionsStateMap } = opts; + ctx: DataVariableOptions, + ) { + const { collectionId = '', variableType, path, defaultValue = '' } = params; + const { em, collectionsStateMap } = ctx; if (!collectionsStateMap) return defaultValue; const collectionItem = collectionsStateMap[collectionId]; if (!collectionItem) return defaultValue; + if (collectionId === keyRootData) { + const root = collectionItem as RootDataType; + return path ? root?.[path as keyof RootDataType] : root; + } + if (!variableType) { em.logError(`Missing collection variable type for collection: ${collectionId}`); return defaultValue; } - return variableType === 'currentItem' - ? DataVariable.resolveCurrentItem(collectionItem, path, collectionId, em) - : collectionItem[variableType]; + if (variableType === 'currentItem') { + return DataVariable.resolveCurrentItem(collectionItem as DataCollectionState, path, collectionId, em); + } + + const state = collectionItem as DataCollectionState; + return state[variableType] ?? defaultValue; } private static resolveCurrentItem( @@ -171,7 +185,7 @@ export default class DataVariable extends Model { path: string | undefined, collectionId: string, em: EditorModel, - ): unknown { + ) { const currentItem = collectionItem.currentItem; if (!currentItem) { em.logError(`Current item is missing for collection: ${collectionId}`); diff --git a/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts b/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts index 096f09d9e..eed5b8797 100644 --- a/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts +++ b/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts @@ -1,13 +1,11 @@ -import { isArray } from 'underscore'; +import { isArray, size } from 'underscore'; import { ObjectAny } from '../../../common'; import Component, { keySymbol } from '../../../dom_components/model/Component'; import { ComponentAddType, ComponentDefinitionDefined, ComponentOptions } from '../../../dom_components/model/types'; import EditorModel from '../../../editor/model/Editor'; -import { isObject, toLowerCase } from '../../../utils/mixins'; +import { toLowerCase } from '../../../utils/mixins'; import DataResolverListener from '../DataResolverListener'; -import DataSource from '../DataSource'; -import DataVariable, { DataVariableProps, DataVariableType } from '../DataVariable'; -import { isDataVariable } from '../../utils'; +import { DataVariableProps } from '../DataVariable'; import { DataCollectionItemType, DataCollectionType, keyCollectionDefinition } from './constants'; import { ComponentDataCollectionProps, @@ -17,13 +15,11 @@ import { } from './types'; import { detachSymbolInstance, getSymbolInstances } from '../../../dom_components/model/SymbolUtils'; import { keyDataValues, updateFromWatcher } from '../../../dom_components/model/ModelDataResolverWatchers'; -import { ModelDestroyOptions } from 'backbone'; -import Components from '../../../dom_components/model/Components'; +import ComponentWithCollectionsState, { DataVariableMap } from '../ComponentWithCollectionsState'; const AvoidStoreOptions = { avoidStore: true, partial: true }; -type DataVariableMap = Record; -export default class ComponentDataCollection extends Component { +export default class ComponentDataCollection extends ComponentWithCollectionsState { dataSourceWatcher?: DataResolverListener; get defaults(): ComponentDefinitionDefined { @@ -55,10 +51,6 @@ export default class ComponentDataCollection extends Component { return cmp; } - getDataResolver() { - return this.get('dataResolver'); - } - getItemsCount() { const items = this.getDataSourceItems(); const itemsCount = getLength(items); @@ -91,10 +83,6 @@ export default class ComponentDataCollection extends Component { return this.firstChild.components(); } - setDataResolver(props: DataCollectionProps) { - return this.set('dataResolver', props); - } - setCollectionId(collectionId: string) { this.updateCollectionConfig({ collectionId }); } @@ -123,59 +111,81 @@ export default class ComponentDataCollection extends Component { this.firstChild.components(content); } - private get firstChild() { - return this.components().at(0); - } + onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) { + super.onCollectionsStateMapUpdate(collectionsStateMap); - private updateCollectionConfig(updates: Partial): void { - this.set(keyCollectionDefinition, { - ...this.dataResolver, - ...updates, + const items = this.getDataSourceItems(); + const { startIndex } = this.resolveCollectionConfig(items); + const cmps = this.components(); + cmps.forEach((cmp, index) => { + const key = this.getItemKey(items, startIndex + index); + const collectionsStateMap = this.getCollectionsStateMapForItem(items, key); + cmp.onCollectionsStateMapUpdate(collectionsStateMap); }); } - private getDataSourceItems() { - const items = getDataSourceItems(this.dataResolver.dataSource, this.em); - if (isArray(items)) { - return items; - } + protected stopSyncComponentCollectionState() { + this.stopListening(this.components(), 'add remove reset', this.syncOnComponentChange); + this.onCollectionsStateMapUpdate({}); + } - const clone = { ...items }; - delete clone['__p']; - return clone; + protected setCollectionStateMapAndPropagate(cmp: Component, collectionsStateMap: DataCollectionStateMap) { + cmp.setSymbolOverride(['locked', 'layerable', keyDataValues]); + cmp.syncComponentsCollectionState(); + cmp.onCollectionsStateMapUpdate(collectionsStateMap); } - private get dataResolver() { - return (this.get(keyCollectionDefinition) || {}) as DataCollectionProps; + protected onDataSourceChange() { + this.rebuildChildrenFromCollection(); } - private get collectionDataSource() { + protected listenToPropsChange() { + this.on(`change:${keyCollectionDefinition}`, () => { + this.rebuildChildrenFromCollection(); + this.listenToDataSource(); + }); + + this.listenToDataSource(); + } + + protected get dataSourceProps(): DataVariableProps | undefined { return this.dataResolver.dataSource; } - private listenToDataSource() { - const { em } = this; - const path = this.collectionDataSource?.path; - if (!path) return; - this.dataSourceWatcher = new DataResolverListener({ - em, - resolver: new DataVariable( - { type: DataVariableType, path }, - { em, collectionsStateMap: this.collectionsStateMap }, - ), - onUpdate: this.rebuildChildrenFromCollection, + protected get dataResolver(): DataCollectionProps { + return this.get(keyCollectionDefinition) || {}; + } + + private get firstChild() { + return this.components().at(0); + } + + private updateCollectionConfig(updates: Partial): void { + this.set(keyCollectionDefinition, { + ...this.dataResolver, + ...updates, }); } private rebuildChildrenFromCollection() { - this.components().reset(this.getCollectionItems(), updateFromWatcher as any); + const items = this.getDataSourceItems(); + const { totalItems } = this.resolveCollectionConfig(items); + + if (totalItems === this.components().length) { + this.onCollectionsStateMapUpdate(this.collectionsStateMap); + return; + } + + const collectionItems = this.getCollectionItems(items as any); + this.components().reset(collectionItems, updateFromWatcher as any); } - private getCollectionItems() { + private getCollectionItems(items?: any[]) { const firstChild = this.ensureFirstChild(); const displayStyle = firstChild.getStyle()['display']; const isDisplayNoneOrMissing = !displayStyle || displayStyle === 'none'; const resolvedDisplay = isDisplayNoneOrMissing ? '' : displayStyle; + // TODO: Move to component view firstChild.addStyle({ display: 'none' }, AvoidStoreOptions); const components: Component[] = [firstChild]; @@ -186,36 +196,33 @@ export default class ComponentDataCollection extends Component { } const collectionId = this.collectionId; - const items = this.getDataSourceItems(); - const { startIndex, endIndex } = this.resolveCollectionConfig(items); + const dataItems = items ?? this.getDataSourceItems(); + const { startIndex, endIndex } = this.resolveCollectionConfig(dataItems); const isDuplicatedId = this.hasDuplicateCollectionId(); if (isDuplicatedId) { this.em.logError( `The collection ID "${collectionId}" already exists in the parent collection state. Overriding it is not allowed.`, ); - return components; } for (let index = startIndex; index <= endIndex; index++) { const isFirstItem = index === startIndex; - const key = isArray(items) ? index : Object.keys(items)[index]; - const collectionsStateMap = this.getCollectionsStateMapForItem(items, key); + const key = this.getItemKey(dataItems, index); + const collectionsStateMap = this.getCollectionsStateMapForItem(dataItems, key); if (isFirstItem) { getSymbolInstances(firstChild)?.forEach((cmp) => detachSymbolInstance(cmp)); - - setCollectionStateMapAndPropagate(firstChild, collectionsStateMap); + this.setCollectionStateMapAndPropagate(firstChild, collectionsStateMap); // TODO: Move to component view firstChild.addStyle({ display: resolvedDisplay }, AvoidStoreOptions); - continue; } - const instance = firstChild!.clone({ symbol: true, symbolInv: true }); + const instance = firstChild.clone({ symbol: true, symbolInv: true }); instance.set({ locked: true, layerable: false }, AvoidStoreOptions); - setCollectionStateMapAndPropagate(instance, collectionsStateMap); + this.setCollectionStateMapAndPropagate(instance, collectionsStateMap); components.push(instance); } @@ -287,48 +294,8 @@ export default class ComponentDataCollection extends Component { ); } - private listenToPropsChange() { - this.on(`change:${keyCollectionDefinition}`, () => { - this.rebuildChildrenFromCollection(); - this.listenToDataSource(); - }); - this.listenToDataSource(); - } - - private removePropsListeners() { - this.off(`change:${keyCollectionDefinition}`); - this.dataSourceWatcher?.destroy(); - } - - onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) { - super.onCollectionsStateMapUpdate(collectionsStateMap); - - const items = this.getDataSourceItems(); - const { startIndex } = this.resolveCollectionConfig(items); - const cmps = this.components(); - cmps.forEach((cmp, index) => { - const collectionsStateMap = this.getCollectionsStateMapForItem(items, startIndex + index); - cmp.onCollectionsStateMapUpdate(collectionsStateMap); - }); - } - - stopSyncComponentCollectionState() { - this.stopListening(this.components(), 'add remove reset', this.syncOnComponentChange); - this.onCollectionsStateMapUpdate({}); - } - - syncOnComponentChange(model: Component, collection: Components, opts: any) { - const collectionsStateMap = this.collectionsStateMap; - // Avoid assigning wrong collectionsStateMap value to children components - this.collectionsStateMap = {}; - - super.syncOnComponentChange(model, collection, opts); - this.collectionsStateMap = collectionsStateMap; - this.onCollectionsStateMapUpdate(collectionsStateMap); - } - private get collectionId() { - return this.getDataResolver().collectionId as string; + return this.dataResolverProps?.collectionId ?? ''; } static isComponent(el: HTMLElement) { @@ -344,23 +311,12 @@ export default class ComponentDataCollection extends Component { const firstChild = this.firstChild as any; return { ...json, components: [firstChild] }; } - - destroy(options?: ModelDestroyOptions | undefined): false | JQueryXHR { - this.removePropsListeners(); - return super.destroy(options); - } } function getLength(items: DataVariableProps[] | object) { return isArray(items) ? items.length : Object.keys(items).length; } -function setCollectionStateMapAndPropagate(cmp: Component, collectionsStateMap: DataCollectionStateMap) { - cmp.setSymbolOverride(['locked', 'layerable', keyDataValues]); - cmp.syncComponentsCollectionState(); - cmp.onCollectionsStateMapUpdate(collectionsStateMap); -} - function logErrorIfMissing(property: any, propertyPath: string, em: EditorModel) { if (!property) { em.logError(`The "${propertyPath}" property is required in the collection definition.`); @@ -389,37 +345,3 @@ function validateCollectionDef(dataResolver: DataCollectionProps, em: EditorMode return true; } - -function getDataSourceItems( - dataSource: DataCollectionDataSource, - em: EditorModel, -): DataVariableProps[] | DataVariableMap { - switch (true) { - case isObject(dataSource) && dataSource instanceof DataSource: { - const id = dataSource.get('id')!; - return listDataSourceVariables(id, em); - } - case isDataVariable(dataSource): { - const path = dataSource.path; - if (!path) return []; - const isDataSourceId = path.split('.').length === 1; - if (isDataSourceId) { - return listDataSourceVariables(path, em); - } else { - return em.DataSources.getValue(path, []); - } - } - default: - return []; - } -} - -function listDataSourceVariables(dataSource_id: string, em: EditorModel): DataVariableProps[] { - const records = em.DataSources.getValue(dataSource_id, []); - const keys = Object.keys(records); - - return keys.map((key) => ({ - type: DataVariableType, - path: dataSource_id + '.' + key, - })); -} diff --git a/packages/core/src/data_sources/model/data_collection/types.ts b/packages/core/src/data_sources/model/data_collection/types.ts index ea1bf324d..8a50cc53f 100644 --- a/packages/core/src/data_sources/model/data_collection/types.ts +++ b/packages/core/src/data_sources/model/data_collection/types.ts @@ -1,6 +1,8 @@ import { DataCollectionType, keyCollectionDefinition } from './constants'; import { ComponentDefinition } from '../../../dom_components/model/types'; import { DataVariableProps } from '../DataVariable'; +import { keyRootData } from '../../../dom_components/constants'; +import { ObjectAny } from '../../../common'; export type DataCollectionDataSource = DataVariableProps; @@ -26,8 +28,11 @@ export interface DataCollectionState { [DataCollectionStateType.remainingItems]: number; } +export type RootDataType = Array | ObjectAny; + export interface DataCollectionStateMap { - [key: string]: DataCollectionState; + [key: string]: DataCollectionState | RootDataType | undefined; + rootData?: RootDataType; } export interface ComponentDataCollectionProps extends ComponentDefinition { diff --git a/packages/core/src/dom_components/constants.ts b/packages/core/src/dom_components/constants.ts new file mode 100644 index 000000000..6b5aa1bbc --- /dev/null +++ b/packages/core/src/dom_components/constants.ts @@ -0,0 +1 @@ +export const keyRootData = '__rootData'; diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index aaf0b90e1..17f644f76 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/packages/core/src/dom_components/model/Component.ts @@ -67,6 +67,7 @@ import { import { DataWatchersOptions } from './ModelResolverWatcher'; import { DataCollectionStateMap } from '../../data_sources/model/data_collection/types'; import { checkAndGetSyncableCollectionItemId } from '../../data_sources/utils'; +import { keyRootData } from '../constants'; export interface IComponent extends ExtractMethods {} export interface SetAttrOptions extends SetOptions, UpdateStyleOptions, DataWatchersOptions {} @@ -372,13 +373,13 @@ export default class Component extends StyleableModel { this.components().forEach((cmp) => cmp.syncComponentsCollectionState()); } - stopSyncComponentCollectionState() { + protected stopSyncComponentCollectionState() { this.stopListening(this.components(), 'add remove reset', this.syncOnComponentChange); this.collectionsStateMap = {}; this.components().forEach((cmp) => cmp.stopSyncComponentCollectionState()); } - syncOnComponentChange(model: Component, collection: Components, opts: any) { + protected syncOnComponentChange(model: Component, collection: Components, opts: any) { if (!this.collectionsStateMap || !Object.keys(this.collectionsStateMap).length) return; const options = opts || collection || {}; @@ -435,9 +436,9 @@ export default class Component extends StyleableModel { } } - __onStyleChange(newStyles: StyleProps) { - const { em } = this; - if (!em) return; + __onStyleChange(newStyles: StyleProps, opts?: UpdateStyleOptions) { + const { collectionsStateMap, em } = this; + if (!em || opts?.noEvent) return; const styleKeys = keys(newStyles); const pros = { style: newStyles }; @@ -445,13 +446,14 @@ export default class Component extends StyleableModel { this.emitWithEditor(ComponentsEvents.styleUpdate, this, pros); styleKeys.forEach((key) => this.emitWithEditor(`${ComponentsEvents.styleUpdateProperty}${key}`, this, pros)); - const collectionsStateMap = this.collectionsStateMap; - const allParentCollectionIds = Object.keys(collectionsStateMap); - if (!allParentCollectionIds.length) return; + const parentCollectionIds = Object.keys(collectionsStateMap).filter((key) => key !== keyRootData); - const isAtInitialPosition = allParentCollectionIds.every( - (key) => collectionsStateMap[key].currentIndex === collectionsStateMap[key].startIndex, - ); + if (parentCollectionIds.length === 0) return; + + const isAtInitialPosition = parentCollectionIds.every((id) => { + const collection = collectionsStateMap[id] as DataCollectionStateMap; + return collection.currentIndex === collection.startIndex; + }); if (!isAtInitialPosition) return; const componentsToUpdate = getSymbolsToUpdate(this); @@ -459,12 +461,10 @@ export default class Component extends StyleableModel { const componentCollectionsState = component.collectionsStateMap; const componentParentCollectionIds = Object.keys(componentCollectionsState); - const isChildOfOriginalCollections = componentParentCollectionIds.every((id) => - allParentCollectionIds.includes(id), - ); + const isChildOfOriginalCollections = componentParentCollectionIds.every((id) => parentCollectionIds.includes(id)); if (isChildOfOriginalCollections) { - component.addStyle(newStyles); + component.addStyle({ ...newStyles }, { noEvent: true }); } }); } @@ -853,7 +853,7 @@ export default class Component extends StyleableModel { } if (!opt.temporary) { - this.__onStyleChange(opts.addStyle || prop); + this.__onStyleChange(opts.addStyle || prop, opts); } return prop; diff --git a/packages/core/src/dom_components/model/ComponentWrapper.ts b/packages/core/src/dom_components/model/ComponentWrapper.ts index d32080d74..6c89c8c0c 100644 --- a/packages/core/src/dom_components/model/ComponentWrapper.ts +++ b/packages/core/src/dom_components/model/ComponentWrapper.ts @@ -2,9 +2,23 @@ import { isUndefined } from 'underscore'; import { attrToString } from '../../utils/dom'; import Component from './Component'; import ComponentHead, { type as typeHead } from './ComponentHead'; -import { ToHTMLOptions } from './types'; +import { ComponentOptions, ComponentProperties, ToHTMLOptions } from './types'; +import Components from './Components'; +import DataResolverListener from '../../data_sources/model/DataResolverListener'; +import { DataVariableProps } from '../../data_sources/model/DataVariable'; +import { DataCollectionStateMap } from '../../data_sources/model/data_collection/types'; +import ComponentWithCollectionsState, { + DataSourceRecords, +} from '../../data_sources/model/ComponentWithCollectionsState'; +import { keyRootData } from '../constants'; + +type ResolverCurrentItemType = string | number; + +export default class ComponentWrapper extends ComponentWithCollectionsState { + dataSourceWatcher?: DataResolverListener; + private _resolverCurrentItem?: ResolverCurrentItemType; + private _isWatchingCollectionStateMap = false; -export default class ComponentWrapper extends Component { get defaults() { return { // @ts-ignore @@ -30,6 +44,16 @@ export default class ComponentWrapper extends Component { }; } + constructor(props: ComponentProperties = {}, opt: ComponentOptions) { + super(props, opt); + + const hasDataResolver = this.dataResolverProps; + if (hasDataResolver) { + this.onDataSourceChange(); + this.syncComponentsCollectionState(); + } + } + preInit() { const { opt, attributes: props } = this; const cmp = this.em?.Components; @@ -78,6 +102,73 @@ export default class ComponentWrapper extends Component { return asDoc ? `${doctype}${headStr}${body}` : body; } + onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) { + const { head } = this; + super.onCollectionsStateMapUpdate(collectionsStateMap); + head.onCollectionsStateMapUpdate(collectionsStateMap); + } + + syncComponentsCollectionState() { + super.syncComponentsCollectionState(); + this.head.syncComponentsCollectionState(); + } + + syncOnComponentChange(model: Component, collection: Components, opts: any) { + const collectionsStateMap: any = this.getCollectionsStateMap(); + + this.collectionsStateMap = collectionsStateMap; + super.syncOnComponentChange(model, collection, opts); + this.onCollectionsStateMapUpdate(collectionsStateMap); + } + + get resolverCurrentItem(): ResolverCurrentItemType | undefined { + return this._resolverCurrentItem; + } + + set resolverCurrentItem(value: ResolverCurrentItemType) { + this._resolverCurrentItem = value; + this.onCollectionsStateMapUpdate(this.getCollectionsStateMap()); + } + + protected onDataSourceChange() { + this.onCollectionsStateMapUpdate(this.getCollectionsStateMap()); + } + + protected listenToPropsChange() { + this.on(`change:dataResolver`, (_, value) => { + const hasResolver = !isUndefined(value); + + if (hasResolver && !this._isWatchingCollectionStateMap) { + this._isWatchingCollectionStateMap = true; + this.syncComponentsCollectionState(); + this.onCollectionsStateMapUpdate(this.getCollectionsStateMap()); + this.listenToDataSource(); + } else if (!hasResolver && this._isWatchingCollectionStateMap) { + this._isWatchingCollectionStateMap = false; + this.stopSyncComponentCollectionState(); + } + }); + + this.listenToDataSource(); + } + + private getCollectionsStateMap(): DataCollectionStateMap { + const { dataResolverPath: dataSourcePath, resolverCurrentItem } = this; + + if (!dataSourcePath) { + return {}; + } + + const allItems = this.getDataSourceItems(); + const selectedItems = !isUndefined(resolverCurrentItem) + ? allItems[resolverCurrentItem as keyof DataSourceRecords] + : allItems; + + return { + [keyRootData]: selectedItems, + } as DataCollectionStateMap; + } + __postAdd() { const um = this.em?.UndoManager; !this.__hasUm && um?.add(this); diff --git a/packages/core/src/dom_components/model/ModelResolverWatcher.ts b/packages/core/src/dom_components/model/ModelResolverWatcher.ts index 82569aa92..0834721ee 100644 --- a/packages/core/src/dom_components/model/ModelResolverWatcher.ts +++ b/packages/core/src/dom_components/model/ModelResolverWatcher.ts @@ -56,9 +56,6 @@ export class ModelResolverWatcher { onCollectionsStateMapUpdate() { const resolvesFromCollections = this.getValuesResolvingFromCollections(); if (!resolvesFromCollections.length) return; - resolvesFromCollections.forEach((key) => - this.resolverListeners[key].resolver.updateCollectionsStateMap(this.collectionsStateMap), - ); const evaluatedValues = this.addDataValues( this.getValuesOrResolver(Object.fromEntries(resolvesFromCollections.map((key) => [key, '']))), diff --git a/packages/core/src/dom_components/model/SymbolUtils.ts b/packages/core/src/dom_components/model/SymbolUtils.ts index 4df7300f9..4066749ad 100644 --- a/packages/core/src/dom_components/model/SymbolUtils.ts +++ b/packages/core/src/dom_components/model/SymbolUtils.ts @@ -172,17 +172,25 @@ const filterPropertiesForPropagation = (props: Record, component: C return filteredProps; }; -const shouldPropagateProperty = (props: Record, prop: string, component: Component): boolean => { - const isCollectionVariableDefinition = (() => { - if (prop === 'attributes') { - const attributes = props['attributes']; - return Object.values(attributes).some((attr: any) => !!attr?.collectionId); - } +const hasCollectionId = (obj: Record | undefined): boolean => { + if (!obj) return false; + return Object.values(obj).some((val: any) => Boolean(val?.collectionId)); +}; - return !!props[prop]?.collectionId; - })(); +const isCollectionVariableDefinition = (props: Record, prop: string): boolean => { + switch (prop) { + case 'attributes': + case 'style': + return hasCollectionId(props[prop]); + default: + return Boolean(props[prop]?.collectionId); + } +}; + +const shouldPropagateProperty = (props: Record, prop: string, component: Component): boolean => { + const isCollectionVar = isCollectionVariableDefinition(props, prop); - return !isSymbolOverride(component, prop) || isCollectionVariableDefinition; + return !isSymbolOverride(component, prop) || isCollectionVar; }; export const updateSymbolCls = (symbol: Component, opts: any = {}) => { diff --git a/packages/core/test/specs/dom_components/model/ComponentWrapper.ts b/packages/core/test/specs/dom_components/model/ComponentWrapper.ts index 9706bd389..384650694 100644 --- a/packages/core/test/specs/dom_components/model/ComponentWrapper.ts +++ b/packages/core/test/specs/dom_components/model/ComponentWrapper.ts @@ -1,6 +1,12 @@ +import { DataSourceManager, DataSource, DataRecord } from '../../../../src'; +import { DataVariableProps, DataVariableType } from '../../../../src/data_sources/model/DataVariable'; import Component from '../../../../src/dom_components/model/Component'; import ComponentHead from '../../../../src/dom_components/model/ComponentHead'; +import ComponentWrapper from '../../../../src/dom_components/model/ComponentWrapper'; +import { keyRootData } from '../../../../src/dom_components/constants'; import Editor from '../../../../src/editor'; +import EditorModel from '../../../../src/editor/model/Editor'; +import { setupTestEditor } from '../../../common'; describe('ComponentWrapper', () => { let em: Editor; @@ -33,4 +39,94 @@ describe('ComponentWrapper', () => { expect(newPageComponent?.head.cid).not.toEqual(originalComponent?.head.cid); }); }); + + describe('ComponentWrapper with DataResolver', () => { + let em: EditorModel; + let dsm: DataSourceManager; + let blogDataSource: DataSource; + let wrapper: ComponentWrapper; + let firstRecord: DataRecord; + + const firstBlog = { id: 'blog1', title: 'How to Test Components' }; + const blogsData = [ + firstBlog, + { id: 'blog2', title: 'Refactoring for Clarity' }, + { id: 'blog3', title: 'Async Patterns in TS' }, + ]; + + const productsById = { + product1: { title: 'Laptop' }, + product2: { title: 'Smartphone' }, + }; + + beforeEach(() => { + ({ em, dsm } = setupTestEditor()); + wrapper = em.getWrapper() as ComponentWrapper; + + blogDataSource = dsm.add({ + id: 'contentDataSource', + records: [ + { + id: 'blogs', + data: blogsData, + }, + { + id: 'productsById', + data: productsById, + }, + ], + }); + + firstRecord = em.DataSources.get('contentDataSource').getRecord('blogs')!; + }); + + afterEach(() => { + em.destroy(); + }); + + const createDataResolver = (path: string): DataVariableProps => ({ + type: DataVariableType, + path, + }); + + const appendChildWithTitle = (path: string = 'title') => + wrapper.append({ + type: 'default', + title: { + type: 'data-variable', + collectionId: keyRootData, + path, + }, + })[0]; + + test('children reflect resolved value from dataResolver', () => { + wrapper.setDataResolver(createDataResolver('contentDataSource.blogs.data')); + wrapper.resolverCurrentItem = 0; + const child = appendChildWithTitle(); + + expect(child.get('title')).toBe(blogsData[0].title); + + firstRecord.set('data', [{ id: 'blog1', title: 'New Blog Title' }]); + expect(child.get('title')).toBe('New Blog Title'); + }); + + test('children update collectionStateMap on wrapper.setDataResolver', () => { + const child = appendChildWithTitle(); + wrapper.setDataResolver(createDataResolver('contentDataSource.blogs.data')); + wrapper.resolverCurrentItem = 0; + + expect(child.get('title')).toBe(blogsData[0].title); + + firstRecord.set('data', [{ id: 'blog1', title: 'Updated Title' }]); + expect(child.get('title')).toBe('Updated Title'); + }); + + test('wrapper should handle objects as collection state', () => { + wrapper.setDataResolver(createDataResolver('contentDataSource.productsById.data')); + wrapper.resolverCurrentItem = 'product1'; + const child = appendChildWithTitle('title'); + + expect(child.get('title')).toBe(productsById.product1.title); + }); + }); }); From 83bb01b94be030eefad65de075d3d4096cc54ba6 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Tue, 28 Oct 2025 12:58:40 +0400 Subject: [PATCH 11/17] Data source schema & providers (#6633) * Setup initial schema types * Setup initial schema methods * Add DataSource tests * Up test * Add test getValue * Cleanup * Up test * Add setValue method * Up docs * Check nested arrays * Add a test for nested setValue * Improve setValue for nested values * Improve setValue for nested values * Setup relation tests * Add getResolvedRecords * Resolve one to many relations in DataSource records * Add DataSourceSchema * Up type * Start data source providers * Start test provider * Add tests for loadProvider * Cleanup * Skip records if DataSource provider is set * Load providers on project load * Type keymap events * Move modal events * Move layer events * Move RTE events * Move selector events * Move StyleManager events * Move editor events * Start DataSource callbacks * Add data source callbacks * Update docs * Up device_manager jsdoc * Format --- docs/api.mjs | 58 ++-- docs/api/block_manager.md | 2 + docs/api/canvas.md | 8 + docs/api/component.md | 7 +- docs/api/datasource.md | 44 +++ docs/api/datasources.md | 43 ++- docs/api/device_manager.md | 33 +- docs/api/editor.md | 12 +- docs/api/keymaps.md | 27 +- docs/api/layer_manager.md | 20 +- docs/api/modal_dialog.md | 19 +- docs/api/parser.md | 12 +- docs/api/rich_text_editor.md | 25 +- docs/api/selector_manager.md | 54 +++- docs/api/style_manager.md | 78 +++-- packages/core/src/block_manager/types.ts | 7 + .../core/src/data_sources/config/config.ts | 13 + packages/core/src/data_sources/index.ts | 72 ++++- .../core/src/data_sources/model/DataSource.ts | 142 ++++++++- packages/core/src/data_sources/types.ts | 137 +++++++- packages/core/src/data_sources/utils.ts | 2 + packages/core/src/device_manager/index.ts | 7 +- .../dom_components/view/ComponentTextView.ts | 4 +- packages/core/src/editor/index.ts | 73 ++--- packages/core/src/editor/model/Editor.ts | 11 +- packages/core/src/editor/types.ts | 47 +++ packages/core/src/keymaps/index.ts | 33 +- packages/core/src/keymaps/types.ts | 28 ++ packages/core/src/modal_dialog/index.ts | 25 +- packages/core/src/modal_dialog/types.ts | 27 ++ packages/core/src/navigator/index.ts | 42 +-- packages/core/src/navigator/types.ts | 39 +++ packages/core/src/rich_text_editor/index.ts | 48 +-- packages/core/src/rich_text_editor/types.ts | 39 +++ packages/core/src/selector_manager/index.ts | 64 +--- packages/core/src/selector_manager/types.ts | 57 ++++ packages/core/src/style_manager/index.ts | 106 ++----- packages/core/src/style_manager/types.ts | 88 ++++++ packages/core/src/utils/mixins.ts | 34 +- .../core/test/specs/data_sources/index.ts | 107 ++++++- .../specs/data_sources/model/DataSource.ts | 295 ++++++++++++++++++ 41 files changed, 1586 insertions(+), 403 deletions(-) create mode 100644 packages/core/src/data_sources/config/config.ts create mode 100644 packages/core/src/keymaps/types.ts create mode 100644 packages/core/src/modal_dialog/types.ts create mode 100644 packages/core/src/navigator/types.ts create mode 100644 packages/core/src/rich_text_editor/types.ts create mode 100644 packages/core/src/selector_manager/types.ts create mode 100644 packages/core/src/style_manager/types.ts create mode 100644 packages/core/test/specs/data_sources/model/DataSource.ts diff --git a/docs/api.mjs b/docs/api.mjs index a16dde8d6..6ad7b70e2 100644 --- a/docs/api.mjs +++ b/docs/api.mjs @@ -97,33 +97,43 @@ async function generateDocs() { throw `File not found '${filePath}'`; } - return build([filePath], { shallow: true }) - .then((cm) => formats.md(cm /*{ markdownToc: true }*/)) - .then(async (output) => { - let addLogs = []; - let result = output - .replace(/\*\*\\\[/g, '**[') - .replace(/\*\*\(\\\[/g, '**([') - .replace(/<\\\[/g, '<[') - .replace(/<\(\\\[/g, '<([') - .replace(/\| \\\[/g, '| [') - .replace(/\\n```js/g, '```js') - .replace(/docsjs\./g, '') - .replace('**Extends ModuleModel**', '') - .replace('**Extends Model**', ''); + try { + return build([filePath], { shallow: true }) + .then((cm) => formats.md(cm /*{ markdownToc: true }*/)) + .then(async (output) => { + let addLogs = []; + let result = output + .replace(/\*\*\\\[/g, '**[') + .replace(/\*\*\(\\\[/g, '**([') + .replace(/<\\\[/g, '<[') + .replace(/<\(\\\[/g, '<([') + .replace(/\| \\\[/g, '| [') + .replace(/\\n```js/g, '```js') + .replace(/docsjs\./g, '') + .replace('**Extends ModuleModel**', '') + .replace('**Extends Model**', ''); - // Search for module event documentation - if (result.indexOf(REPLACE_EVENTS) >= 0) { - const eventsMd = await getEventsMdFromTypes(filePath); - if (eventsMd && result.indexOf(REPLACE_EVENTS) >= 0) { - addLogs.push('replaced events'); + // Search for module event documentation + if (result.indexOf(REPLACE_EVENTS) >= 0) { + try { + const eventsMd = await getEventsMdFromTypes(filePath); + if (eventsMd && result.indexOf(REPLACE_EVENTS) >= 0) { + addLogs.push('replaced events'); + } + result = eventsMd ? result.replace(REPLACE_EVENTS, `## Available Events\n${eventsMd}`) : result; + } catch (err) { + console.error(`Failed getting events: ${file[0]}`); + throw err; + } } - result = eventsMd ? result.replace(REPLACE_EVENTS, `## Available Events\n${eventsMd}`) : result; - } - writeFileSync(`${docRoot}/api/${file[1]}`, result); - log('Created', file[1], addLogs.length ? `(${addLogs.join(', ')})` : ''); - }); + writeFileSync(`${docRoot}/api/${file[1]}`, result); + log('Created', file[1], addLogs.length ? `(${addLogs.join(', ')})` : ''); + }); + } catch (err) { + console.error(`Build failed: ${file[0]}`); + throw err; + } }), ); diff --git a/docs/api/block_manager.md b/docs/api/block_manager.md index 6bffe711e..9b149da71 100644 --- a/docs/api/block_manager.md +++ b/docs/api/block_manager.md @@ -84,6 +84,8 @@ editor.on('block:custom', ({ container, blocks, ... }) => { ... }); editor.on('block', ({ event, model, ... }) => { ... }); ``` +* BlocksEventCallback + [Block]: block.html [Component]: component.html diff --git a/docs/api/canvas.md b/docs/api/canvas.md index 415ec65c9..c82ef3a82 100644 --- a/docs/api/canvas.md +++ b/docs/api/canvas.md @@ -122,6 +122,14 @@ editor.on('canvas:frame:load:body', ({ window }) => { }); ``` +* `canvas:frame:unload` Frame is unloading from the canvas. + +```javascript +editor.on('canvas:frame:unload', ({ frame }) => { + console.log('Unloading frame', frame); +}); +``` + [Component]: component.html [Frame]: frame.html diff --git a/docs/api/component.md b/docs/api/component.md index 9dab1b1cc..5d8c281f3 100644 --- a/docs/api/component.md +++ b/docs/api/component.md @@ -137,7 +137,7 @@ By setting override to specific properties, changes of those properties will be ### Parameters * `value` **([Boolean][3] | [String][1] | [Array][5]<[String][1]>)** -* `options` **DynamicWatchersOptions** (optional, default `{}`) +* `options` **DataWatchersOptions** (optional, default `{}`) ### Examples @@ -335,8 +335,7 @@ Get the style of the component ### Parameters -* `options` **any** (optional, default `{}`) -* `optsAdd` **any** (optional, default `{}`) +* `opts` **GetComponentStyleOpts?** Returns **[Object][2]** @@ -363,7 +362,7 @@ Return all component's attributes ### Parameters -* `opts` **{noClass: [boolean][3]?, noStyle: [boolean][3]?}** (optional, default `{}`) +* `opts` **{noClass: [boolean][3]?, noStyle: [boolean][3]?, skipResolve: [boolean][3]?}** (optional, default `{}`) Returns **[Object][2]** diff --git a/docs/api/datasource.md b/docs/api/datasource.md index 920623407..23d094601 100644 --- a/docs/api/datasource.md +++ b/docs/api/datasource.md @@ -31,6 +31,44 @@ dataSource.addRecord({ id: 'id3', name: 'value3' }); * `props` **DataSourceProps** Properties to initialize the data source. * `opts` **DataSourceOptions** Options to initialize the data source. +### hasProvider + +Indicates if the data source has a provider for records. + +### getResolvedRecords + +Retrieves all records from the data source with resolved relations based on the schema. + +### upSchema + +Update the schema. + +#### Parameters + +* `schema` **Partial\** +* `opts` **SetOptions?** + +#### Examples + +```javascript +dataSource.upSchema({ name: { type: 'string' } }); +``` + +### getSchemaField + +Get schema field definition. + +#### Parameters + +* `fieldKey` **any** + +#### Examples + +```javascript +const fieldSchema = dataSource.getSchemaField('name'); +fieldSchema.type; // 'string' +``` + ## defaults Returns the default properties for the data source. @@ -55,6 +93,12 @@ Retrieves the collection of records associated with this data source. Returns **DataRecords\** The collection of data records. +## records + +Retrieves the collection of records associated with this data source. + +Returns **DataRecords\** The collection of data records. + ## em Retrieves the editor model associated with this data source. diff --git a/docs/api/datasources.md b/docs/api/datasources.md index cfe8080c6..108caa2e3 100644 --- a/docs/api/datasources.md +++ b/docs/api/datasources.md @@ -44,12 +44,26 @@ editor.on('data:path', ({ dataSource, dataRecord, path }) => { editor.on('data:pathSource:SOURCE_ID', ({ dataSource, dataRecord, path }) => { ... }); ``` +* `data:provider:load` Data source provider load. + +```javascript +editor.on('data:provider:load', ({ dataSource, result }) => { ... }); +``` + +* `data:provider:loadAll` Load of all data source providers (eg. on project load). + +```javascript +editor.on('data:provider:loadAll', () => { ... }); +``` + * `data` Catch-all event for all the events mentioned above. ```javascript editor.on('data', ({ event, model, ... }) => { ... }); ``` +* DataSourcesEventCallback + ## Methods * [add][1] - Add a new data source. @@ -101,15 +115,32 @@ Returns **[DataSource]** Data source. ## getValue -Get value from data sources by key +Get value from data sources by path. ### Parameters -* `key` **[String][7]** Path to value. -* `defValue` **any** +* `path` **[String][7]** Path to value. +* `defValue` **any** Default value if the path is not found. Returns **any** const value = dsm.getValue('ds\_id.record\_id.propName', 'defaultValue'); +## setValue + +Set value in data sources by path. + +### Parameters + +* `path` **[String][7]** Path to value in format 'dataSourceId.recordId.propName' +* `value` **any** Value to set + +### Examples + +```javascript +dsm.setValue('ds_id.record_id.propName', 'new value'); +``` + +Returns **[Boolean][8]** Returns true if the value was set successfully + ## remove Remove data source. @@ -152,7 +183,7 @@ data record, and optional property path. Store data sources to a JSON object. -Returns **[Array][8]** Stored data sources. +Returns **[Array][9]** Stored data sources. ## load @@ -178,4 +209,6 @@ Returns **[Object][6]** Loaded data sources. [7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String -[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array +[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean + +[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array diff --git a/docs/api/device_manager.md b/docs/api/device_manager.md index c1e283f86..4a6f6e914 100644 --- a/docs/api/device_manager.md +++ b/docs/api/device_manager.md @@ -19,12 +19,35 @@ const deviceManager = editor.Devices; ``` ## Available Events +* `device:add` New device added to the collection. The `Device` is passed as an argument. -* `device:add` - Added new device. The [Device] is passed as an argument to the callback -* `device:remove` - Device removed. The [Device] is passed as an argument to the callback -* `device:select` - New device selected. The newly selected [Device] and the previous one, are passed as arguments to the callback -* `device:update` - Device updated. The updated [Device] and the object containing changes are passed as arguments to the callback -* `device` - Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback +```javascript +editor.on('device:add', (device) => { ... }); +``` + +* `device:remove` Device removed from the collection. The `Device` is passed as an argument. + +```javascript +editor.on('device:remove', (device) => { ... }); +``` + +* `device:select` A new device is selected. The `Device` is passed as an argument. + +```javascript +editor.on('device:select', (device) => { ... }); +``` + +* `device:update` Device updated. The `Device` and the object containing changes are passed as arguments. + +```javascript +editor.on('device:update', (device) => { ... }); +``` + +* `device` Catch-all event for all the events mentioned above. + +```javascript +editor.on('device', ({ event, model, ... }) => { ... }); +``` ## Methods diff --git a/docs/api/editor.md b/docs/api/editor.md index 64a1c8c0d..35b30e20b 100644 --- a/docs/api/editor.md +++ b/docs/api/editor.md @@ -42,6 +42,15 @@ editor.on('load', () => { ... }); editor.on('project:load', ({ project, initial }) => { ... }); ``` +* `project:loaded` Similar to `project:load`, but triggers only if the project is loaded successfully. + +```javascript +editor.on('project:loaded', ({ project, initial }) => { ... }); + +// Loading an empty project, won't trigger this event. +editor.loadProjectData({}); +``` + * `project:get` Event triggered on request of the project data. This can be used to extend the project with custom data. ```javascript @@ -516,6 +525,7 @@ Load data from the JSON project ### Parameters * `data` **[Object][16]** Project to load +* `options` **[Object][16]?** Custom options that could be passed to the project load events. (optional, default `{}`) ### Examples @@ -722,7 +732,7 @@ Trigger event ### Parameters * `event` **[string][18]** Event to trigger -* `args` **...[Array][19]\** +* `args` **...any** Returns **this** diff --git a/docs/api/keymaps.md b/docs/api/keymaps.md index 643feeca9..c1b55b9ec 100644 --- a/docs/api/keymaps.md +++ b/docs/api/keymaps.md @@ -19,23 +19,30 @@ const editor = grapesjs.init({ }) ``` -Once the editor is instantiated you can use its API and listen to its events. Before using these methods, you should get the module from the instance. +Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance. ```js -// Listen to events -editor.on('keymap:add', () => { ... }); - -// Use the API const keymaps = editor.Keymaps; -keymaps.add(...); ``` ## Available Events +* `keymap:add` New keymap added. The new keymap object is passed as an argument to the callback. + +```javascript +editor.on('keymap:add', (keymap) => { ... }); +``` + +* `keymap:remove` Keymap removed. The removed keymap object is passed as an argument to the callback. -* `keymap:add` - New keymap added. The new keyamp object is passed as an argument -* `keymap:remove` - Keymap removed. The removed keyamp object is passed as an argument -* `keymap:emit` - Some keymap emitted, in arguments you get keymapId, shortcutUsed, Event -* `keymap:emit:{keymapId}` - `keymapId` emitted, in arguments you get keymapId, shortcutUsed, Event +```javascript +editor.on('keymap:remove', (keymap) => { ... }); +``` + +* `keymap:emit` Some keymap emitted. The keymapId, shortcutUsed, and Event are passed as arguments to the callback. + +```javascript +editor.on('keymap:emit', (keymapId, shortcutUsed, event) => { ... }); +``` ## Methods diff --git a/docs/api/layer_manager.md b/docs/api/layer_manager.md index 2b879ce07..8ae97605e 100644 --- a/docs/api/layer_manager.md +++ b/docs/api/layer_manager.md @@ -13,16 +13,30 @@ const editor = grapesjs.init({ }) ``` -Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance +Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance. ```js const layers = editor.Layers; ``` ## Available Events +* `layer:root` Root layer changed. The new root component is passed as an argument to the callback. -* `layer:root` - Root layer changed. The new root component is passed as an argument to the callback. -* `layer:component` - Component layer is updated. The updated component is passed as an argument to the callback. +```javascript +editor.on('layer:root', (component) => { ... }); +``` + +* `layer:component` Component layer is updated. The updated component is passed as an argument to the callback. + +```javascript +editor.on('layer:component', (component, opts) => { ... }); +``` + +* `layer:custom` Custom layer event. Object with container and root is passed as an argument to the callback. + +```javascript +editor.on('layer:custom', ({ container, root }) => { ... }); +``` ## Methods diff --git a/docs/api/modal_dialog.md b/docs/api/modal_dialog.md index d82086795..052c6a4ce 100644 --- a/docs/api/modal_dialog.md +++ b/docs/api/modal_dialog.md @@ -19,10 +19,23 @@ const modal = editor.Modal; ``` ## Available Events +* `modal:open` Modal is opened -* `modal:open` - Modal is opened -* `modal:close` - Modal is closed -* `modal` - Event triggered on any change related to the modal. An object containing all the available data about the triggered event is passed as an argument to the callback. +```javascript +editor.on('modal:open', () => { ... }); +``` + +* `modal:close` Modal is closed + +```javascript +editor.on('modal:close', () => { ... }); +``` + +* `modal` Event triggered on any change related to the modal. An object containing all the available data about the triggered event is passed as an argument to the callback. + +```javascript +editor.on('modal', ({ open, title, content, ... }) => { ... }); +``` ## Methods diff --git a/docs/api/parser.md b/docs/api/parser.md index df9d92993..b1fdea016 100644 --- a/docs/api/parser.md +++ b/docs/api/parser.md @@ -81,6 +81,12 @@ Parse HTML string and return the object containing the Component Definition * `options.htmlType` **[String][6]?** [HTML mime type][7] to parse * `options.allowScripts` **[Boolean][8]** Allow `