Browse Source

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 <artur.catch@hotmail.it>
pull/6627/head
mohamed yahia 4 months ago
committed by GitHub
parent
commit
fe88fc88a3
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      packages/core/src/data_sources/index.ts
  2. 120
      packages/core/src/data_sources/model/ComponentWithCollectionsState.ts
  3. 17
      packages/core/src/data_sources/model/DataResolverListener.ts
  4. 36
      packages/core/src/data_sources/model/DataVariable.ts
  5. 210
      packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts
  6. 7
      packages/core/src/data_sources/model/data_collection/types.ts
  7. 1
      packages/core/src/dom_components/constants.ts
  8. 32
      packages/core/src/dom_components/model/Component.ts
  9. 95
      packages/core/src/dom_components/model/ComponentWrapper.ts
  10. 3
      packages/core/src/dom_components/model/ModelResolverWatcher.ts
  11. 26
      packages/core/src/dom_components/model/SymbolUtils.ts
  12. 96
      packages/core/test/specs/dom_components/model/ComponentWrapper.ts

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

@ -88,7 +88,9 @@ export default class DataSourceManager extends ItemManagerModule<ModuleConfig, D
acc[ds.id] = ds.records.reduce((accR, dr, i) => {
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);

120
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<string, DataVariableProps>;
export type DataSourceRecords = DataVariableProps[] | DataVariableMap;
export default class ComponentWithCollectionsState<DataResolverType> 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);
}
}

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

36
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<DataVariableProps> {
);
}
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<DataVariableProps> {
path: string | undefined,
collectionId: string,
em: EditorModel,
): unknown {
) {
const currentItem = collectionItem.currentItem;
if (!currentItem) {
em.logError(`Current item is missing for collection: ${collectionId}`);

210
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<string, DataVariableProps>;
export default class ComponentDataCollection extends Component {
export default class ComponentDataCollection extends ComponentWithCollectionsState<DataCollectionProps> {
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<DataCollectionProps>): 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<DataCollectionProps>): 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,
}));
}

7
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> | ObjectAny;
export interface DataCollectionStateMap {
[key: string]: DataCollectionState;
[key: string]: DataCollectionState | RootDataType | undefined;
rootData?: RootDataType;
}
export interface ComponentDataCollectionProps extends ComponentDefinition {

1
packages/core/src/dom_components/constants.ts

@ -0,0 +1 @@
export const keyRootData = '__rootData';

32
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<Component> {}
export interface SetAttrOptions extends SetOptions, UpdateStyleOptions, DataWatchersOptions {}
@ -372,13 +373,13 @@ export default class Component extends StyleableModel<ComponentProperties> {
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<ComponentProperties> {
}
}
__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<ComponentProperties> {
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<ComponentProperties> {
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<ComponentProperties> {
}
if (!opt.temporary) {
this.__onStyleChange(opts.addStyle || prop);
this.__onStyleChange(opts.addStyle || prop, opts);
}
return prop;

95
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<DataVariableProps> {
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}<html${docElAttrStr}>${headStr}${body}</html>` : 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);

3
packages/core/src/dom_components/model/ModelResolverWatcher.ts

@ -56,9 +56,6 @@ export class ModelResolverWatcher<T extends ObjectHash> {
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, '']))),

26
packages/core/src/dom_components/model/SymbolUtils.ts

@ -172,17 +172,25 @@ const filterPropertiesForPropagation = (props: Record<string, any>, component: C
return filteredProps;
};
const shouldPropagateProperty = (props: Record<string, any>, 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<string, any> | undefined): boolean => {
if (!obj) return false;
return Object.values(obj).some((val: any) => Boolean(val?.collectionId));
};
return !!props[prop]?.collectionId;
})();
const isCollectionVariableDefinition = (props: Record<string, any>, prop: string): boolean => {
switch (prop) {
case 'attributes':
case 'style':
return hasCollectionId(props[prop]);
default:
return Boolean(props[prop]?.collectionId);
}
};
const shouldPropagateProperty = (props: Record<string, any>, 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 = {}) => {

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

Loading…
Cancel
Save