diff --git a/packages/core/src/data_sources/index.ts b/packages/core/src/data_sources/index.ts index 5e9f6efb16..d54110a755 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 0000000000..13f76fea49 --- /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 cf3d4c9c3d..f2a211904d 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 c7f195fcca..b8dbb1258c 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 096f09d9ef..eed5b87979 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 ea1bf324d6..8a50cc53fb 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 0000000000..6b5aa1bbc1 --- /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 aaf0b90e1f..17f644f767 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 d32080d742..6c89c8c0c8 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 82569aa920..0834721eea 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 4df7300f98..4066749ad7 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 9706bd3898..384650694c 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); + }); + }); });