From af5a0c5d1d76663873f928d2e27cac644d8b9ba7 Mon Sep 17 00:00:00 2001 From: mohamedsalem401 Date: Mon, 3 Mar 2025 16:32:56 +0200 Subject: [PATCH] Update collections ( add setters and getter, first item editable, sync styles ) --- .../ComponentDataCollection.ts | 286 ++++++++++----- .../model/ComponentDataResolverWatchers.ts | 2 +- ...ComponentDataCollection.getters-setters.ts | 339 ++++++++++++++++++ 3 files changed, 532 insertions(+), 95 deletions(-) create mode 100644 packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.getters-setters.ts 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 1baf6bb62..6ff28d0e2 100644 --- a/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts +++ b/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts @@ -17,7 +17,8 @@ import { DataCollectionState, DataCollectionStateMap, } from './types'; -import { updateFromWatcher } from '../../../dom_components/model/ComponentDataResolverWatchers'; +import { getSymbolsToUpdate } from '../../../dom_components/model/SymbolUtils'; +import { StyleProps, UpdateStyleOptions } from '../../../domain_abstract/model/StyleableModel'; export default class ComponentDataCollection extends Component { constructor(props: ComponentDataCollectionProps, opt: ComponentOptions) { @@ -36,129 +37,199 @@ export default class ComponentDataCollection extends Component { return cmp; } - const parentCollectionStateMap = (props[keyCollectionsStateMap] || {}) as DataCollectionStateMap; - const components: Component[] = getCollectionItems(em, collectionDef, parentCollectionStateMap, opt); - cmp.components(components, opt); + return cmp; + } + + getItemsCount(): number { + const items = this.getDataSourceItems(); + const startIndex = Math.max(0, this.collectionConfig.startIndex || 0); + const configEndIndex = this.getConfigStartIndex() || Number.MAX_VALUE; + const endIndex = Math.min(items.length - 1, configEndIndex); + + return endIndex - startIndex + 1; + } + + getConfigStartIndex() { + return this.collectionConfig.startIndex; + } + + getConfigEndIndex() { + return this.collectionConfig.endIndex; + } + + getComponentDef(): ComponentDefinition { + const componentDef = this.getFirstChildJSON(); + return componentDef; + } + + getDataSource(): DataCollectionDataSource { + const collectionDef = this.collectionDef; + + return collectionDef?.collectionConfig?.dataSource; + } + + getCollectionId(): string { + const collectionDef = this.collectionDef; + + return collectionDef?.collectionConfig?.collectionId; + } + + setComponentDef(componentDef: ComponentDefinition) { + const collectionDef = { ...this.collectionDef }; + collectionDef.componentDef = componentDef; + + this.set(keyCollectionDefinition, collectionDef); + } - if (isDataVariable(this.collectionDataSource)) { - this.watchDataSource(parentCollectionStateMap, opt); + setStartIndex(startIndex: number) { + if (startIndex < 0) { + this.em.logError('Start index should be greater than or equal to 0'); + return; } - return cmp; + const newCollectionConfig = { ...this.collectionConfig }; + newCollectionConfig.startIndex = startIndex; + + const collectionDef = { ...this.collectionDef, collectionConfig: newCollectionConfig }; + this.set(keyCollectionDefinition, collectionDef); } - get collectionConfig() { - return this.get(keyCollectionDefinition).collectionConfig as DataCollectionConfig; + setEndIndex(endIndex: number) { + const collectionConfig = { ...this.collectionConfig }; + collectionConfig.endIndex = endIndex; + + const collectionDef = { ...this.collectionDef, collectionConfig }; + this.set(keyCollectionDefinition, collectionDef); } - get collectionDataSource() { - return this.collectionConfig.dataSource; + setDataSource(dataSource: DataCollectionDataSource) { + const collectionConfig = { ...this.collectionConfig }; + collectionConfig.dataSource = dataSource; + + const collectionDef = { ...this.collectionDef, collectionConfig }; + this.set(keyCollectionDefinition, collectionDef); } - toJSON(opts?: ObjectAny) { - const json = super.toJSON.call(this, opts) as ComponentDataCollectionProps; - json[keyCollectionDefinition].componentDef = this.getComponentDef(); - delete json.components; - delete json.droppable; - return json; + private getDataSourceItems() { + const collectionDef = this.collectionDef; + const collectionConfig = collectionDef?.collectionConfig; + + if (!collectionConfig) { + return []; + } + + const items = getDataSourceItems(collectionConfig.dataSource, this.em); + return items; } - private getComponentDef() { - const firstChild = this.components().at(0); - const firstChildJSON = firstChild ? serialize(firstChild) : this.get(keyCollectionDefinition).componentDef; - delete firstChildJSON?.draggable; - return firstChildJSON; + private getCollectionStateMap() { + return (this.get(keyCollectionsStateMap) || {}) as DataCollectionStateMap; } - private watchDataSource(parentCollectionStateMap: DataCollectionStateMap, opt: ComponentOptions) { - const { em } = this; - const path = this.collectionDataSource?.path; - if (!path) return; - - new DataResolverListener({ - em, - resolver: new DataVariable({ type: DataVariableType, path }, { em }), - onUpdate: () => { - const collectionDef = { ...this.get(keyCollectionDefinition), componentDef: this.getComponentDef() }; - const collectionItems = getCollectionItems(em, collectionDef, parentCollectionStateMap, opt); - this.components().reset(collectionItems, updateFromWatcher as any); - }, - }); + private get collectionDef() { + return (this.get(keyCollectionDefinition) || {}) as DataCollectionProps; } - static isComponent(el: HTMLElement) { - return toLowerCase(el.tagName) === DataCollectionType; + private get collectionConfig() { + return (this.collectionDef?.collectionConfig || {}) as DataCollectionConfig; } -} -function getCollectionItems( - em: EditorModel, - collectionDef: DataCollectionProps, - parentCollectionStateMap: DataCollectionStateMap, - opt: ComponentOptions, -) { - const { componentDef, collectionConfig } = collectionDef; - const result = validateCollectionConfig(collectionConfig, componentDef, em); - if (!result) { - return []; + private get collectionDataSource() { + return this.collectionConfig.dataSource; } - const components: Component[] = []; - const collectionId = collectionConfig.collectionId; - const items = getDataSourceItems(collectionConfig.dataSource, em); - const startIndex = Math.max(0, collectionConfig.startIndex || 0); - const endIndex = Math.min( - items.length - 1, - collectionConfig.endIndex !== undefined ? collectionConfig.endIndex : Number.MAX_VALUE, - ); - const totalItems = endIndex - startIndex + 1; - let symbolMain: Component; - - for (let index = startIndex; index <= endIndex; index++) { - const item = items[index]; - const collectionState: DataCollectionState = { - collectionId, - currentIndex: index, - currentItem: item, - startIndex: startIndex, - endIndex: endIndex, - totalItems: totalItems, - remainingItems: totalItems - (index + 1), - }; + private getFirstChildJSON() { + const firstChild = this.components().at(0); + const firstChildJSON = firstChild ? serialize(firstChild) : this.collectionDef.componentDef; + delete firstChildJSON?.draggable; + delete firstChildJSON?.removable; + + return firstChildJSON; + } - if (parentCollectionStateMap[collectionId]) { - em.logError( - `The collection ID "${collectionId}" already exists in the parent collection state. Overriding it is not allowed.`, - ); + getCollectionItems() { + const { componentDef, collectionConfig } = this.collectionDef; + const result = validateCollectionConfig(collectionConfig, componentDef, this.em); + if (!result) { return []; } - const collectionsStateMap: DataCollectionStateMap = { - ...parentCollectionStateMap, - [collectionId]: collectionState, - }; + const components: Component[] = []; + const collectionId = collectionConfig.collectionId; + const items = this.getDataSourceItems(); + + const startIndex = this.getConfigStartIndex() ?? 0; + const configEndIndex = this.getConfigEndIndex() ?? Number.MAX_VALUE; + const endIndex = Math.min(items.length - 1, configEndIndex); + const totalItems = endIndex - startIndex + 1; + const parentCollectionStateMap = this.getCollectionStateMap(); + + let symbolMain: Component; + for (let index = startIndex; index <= endIndex; index++) { + const item = items[index]; + const isFirstItem = index === startIndex; + const collectionState: DataCollectionState = { + collectionId, + currentIndex: index, + currentItem: item, + startIndex: startIndex, + endIndex: endIndex, + totalItems: totalItems, + remainingItems: totalItems - (index + 1), + }; + + if (parentCollectionStateMap[collectionId]) { + this.em.logError( + `The collection ID "${collectionId}" already exists in the parent collection state. Overriding it is not allowed.`, + ); + + return []; + } + + const collectionsStateMap: DataCollectionStateMap = { + ...parentCollectionStateMap, + [collectionId]: collectionState, + }; + + if (isFirstItem) { + const componentType = (componentDef?.type as string) || 'default'; + let type = this.em.Components.getType(componentType) || this.em.Components.getType('default'); + const Model = type.model; + symbolMain = new Model( + { + ...serialize(componentDef), + draggable: false, + removable: false, + }, + this.opt, + ); + } + + const instance = symbolMain!.clone({ symbol: true }); + !isFirstItem && instance.set('locked', true); + setCollectionStateMapAndPropagate(collectionsStateMap, collectionId)(instance); - if (index === startIndex) { - const componentType = (componentDef?.type as string) || 'default'; - let type = em.Components.getType(componentType) || em.Components.getType('default'); - const Model = type.model; - symbolMain = new Model({ ...serialize(componentDef), draggable: false }, opt); - setCollectionStateMapAndPropagate(collectionsStateMap, collectionId)(symbolMain); + components.push(instance); } - const instance = symbolMain!.clone({ symbol: true }); - setCollectionStateMapAndPropagate(collectionsStateMap, collectionId)(instance); + return components; + } - components.push(instance); + static isComponent(el: HTMLElement) { + return toLowerCase(el.tagName) === DataCollectionType; } - return components; + toJSON(opts?: ObjectAny) { + const json = super.toJSON.call(this, opts) as ComponentDataCollectionProps; + json[keyCollectionDefinition].componentDef = this.getFirstChildJSON(); + delete json.components; + delete json.droppable; + + return json; + } } -function setCollectionStateMapAndPropagate( - collectionsStateMap: DataCollectionStateMap, - collectionId: string | undefined, -) { +function setCollectionStateMapAndPropagate(collectionsStateMap: DataCollectionStateMap, collectionId: string) { return (cmp: Component) => { setCollectionStateMap(collectionsStateMap)(cmp); @@ -178,9 +249,15 @@ function setCollectionStateMapAndPropagate( component.stopListening(component.components(), 'add', addListener); component.off(`change:${keyCollectionsStateMap}`, handleCollectionStateMapChange); const index = component.collectionStateListeners.indexOf(listenerKey); - if (index > -1) { + if (index !== -1) { component.collectionStateListeners.splice(index, 1); } + + const collectionsStateMap = component.get(keyCollectionsStateMap); + component.set(keyCollectionsStateMap, { + ...collectionsStateMap, + [collectionId]: undefined, + }); }; cmp.listenTo(cmps, 'remove', removeListener); @@ -229,6 +306,12 @@ function validateCollectionConfig( } } + const startIndex = collectionConfig?.startIndex; + + if (startIndex !== undefined && (startIndex < 0 || !Number.isInteger(startIndex))) { + em.logError(`Invalid startIndex: ${startIndex}. It must be a non-negative integer.`); + } + return true; } @@ -241,6 +324,21 @@ function setCollectionStateMap(collectionsStateMap: DataCollectionStateMap) { }; cmp.set(keyCollectionsStateMap, updatedCollectionStateMap); cmp.dataResolverWatchers.updateCollectionStateMap(updatedCollectionStateMap); + + const parentCollectionsId = Object.keys(updatedCollectionStateMap); + const isFirstItem = parentCollectionsId.every( + (key) => updatedCollectionStateMap[key].currentIndex === updatedCollectionStateMap[key].startIndex, + ); + + if (isFirstItem) { + cmp.__onStyleChange = (newStyles: StyleProps, opts: UpdateStyleOptions = {}) => { + const cmps = getSymbolsToUpdate(cmp); + + cmps.forEach((cmp) => { + cmp.addStyle(newStyles, opts); + }); + }; + } }; } diff --git a/packages/core/src/dom_components/model/ComponentDataResolverWatchers.ts b/packages/core/src/dom_components/model/ComponentDataResolverWatchers.ts index e0974793d..3b774cae4 100644 --- a/packages/core/src/dom_components/model/ComponentDataResolverWatchers.ts +++ b/packages/core/src/dom_components/model/ComponentDataResolverWatchers.ts @@ -82,7 +82,7 @@ export class ComponentDataResolverWatchers { const keys = this.propertyWatcher.getDynamicValuesOfType(DataCollectionVariableType); const attributesKeys = this.attributeWatcher.getDynamicValuesOfType(DataCollectionVariableType); - const combinedKeys = [keyCollectionsStateMap, ...keys]; + const combinedKeys = [keyCollectionsStateMap, 'locked', ...keys]; const haveOverridenAttributes = Object.keys(attributesKeys).length; if (haveOverridenAttributes) combinedKeys.push('attributes'); diff --git a/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.getters-setters.ts b/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.getters-setters.ts new file mode 100644 index 000000000..8a97e9a9b --- /dev/null +++ b/packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.getters-setters.ts @@ -0,0 +1,339 @@ +import { Component, DataRecord, DataSource, DataSourceManager, Editor } from '../../../../../src'; +import { DataVariableType } from '../../../../../src/data_sources/model/DataVariable'; +import { + DataCollectionType, + DataCollectionVariableType, +} from '../../../../../src/data_sources/model/data_collection/constants'; +import { DataCollectionStateVariableType } from '../../../../../src/data_sources/model/data_collection/types'; +import EditorModel from '../../../../../src/editor/model/Editor'; +import { setupTestEditor } from '../../../../common'; +import ComponentDataCollection from '../../../../../src/data_sources/model/data_collection/ComponentDataCollection'; + +describe('Collection component getters and setters', () => { + let em: EditorModel; + let editor: Editor; + let dsm: DataSourceManager; + let dataSource: DataSource; + let wrapper: Component; + let firstRecord: DataRecord; + let secondRecord: DataRecord; + + beforeEach(() => { + ({ em, editor, dsm } = setupTestEditor()); + wrapper = em.getWrapper()!; + dataSource = dsm.add({ + id: 'my_data_source_id', + records: [ + { id: 'user1', user: 'user1', firstName: 'Name1', age: '12' }, + { id: 'user2', user: 'user2', firstName: 'Name2', age: '14' }, + { id: 'user3', user: 'user3', firstName: 'Name3', age: '16' }, + ], + }); + + firstRecord = dataSource.getRecord('user1')!; + secondRecord = dataSource.getRecord('user2')!; + }); + + afterEach(() => { + em.destroy(); + }); + + describe('Getters', () => { + let cmp: ComponentDataCollection; + + beforeEach(() => { + cmp = wrapper.components({ + type: DataCollectionType, + collectionDef: { + componentDef: { + type: 'default', + components: [ + { + type: 'default', + tagName: 'div', + attributes: { + 'data-user': { + type: DataCollectionVariableType, + variableType: DataCollectionStateVariableType.currentItem, + collectionId: 'my_collection', + path: 'user', + }, + }, + }, + ], + }, + collectionConfig: { + collectionId: 'my_collection', + startIndex: 1, + endIndex: 2, + dataSource: { + type: DataVariableType, + path: 'my_data_source_id', + }, + }, + }, + })[0] as ComponentDataCollection; + }); + + test('getItemsCount should return the correct number of items', () => { + expect(cmp.getItemsCount()).toBe(2); + }); + + test('getConfigStartIndex should return the correct start index', () => { + expect(cmp.getConfigStartIndex()).toBe(1); + }); + + test('getConfigEndIndex should return the correct end index', () => { + expect(cmp.getConfigEndIndex()).toBe(2); + }); + + test('getComponentDef should return the correct component definition', () => { + const componentDef = cmp.getComponentDef(); + expect(componentDef.type).toBe('default'); + expect(componentDef.components).toHaveLength(1); + expect(componentDef.attributes?.['data-user']).toEqual({ + type: DataCollectionVariableType, + variableType: DataCollectionStateVariableType.currentItem, + collectionId: 'my_collection', + path: 'user', + }); + }); + + test('getDataSource should return the correct data source', () => { + const ds = cmp.getDataSource(); + expect(ds).toEqual({ + type: DataVariableType, + path: 'my_data_source_id', + }); + }); + + test('getCollectionId should return the correct collection ID', () => { + expect(cmp.getCollectionId()).toBe('my_collection'); + }); + + test('getItemsCount should return 0 when no records are present', () => { + dataSource.removeRecord('user1'); + dataSource.removeRecord('user2'); + dataSource.removeRecord('user3'); + expect(cmp.getItemsCount()).toBe(0); + }); + + test('getConfigStartIndex should handle zero as a valid start index', () => { + cmp.setStartIndex(0); + + expect(cmp.getConfigStartIndex()).toBe(0); + expect(cmp.getItemsCount()).toBe(3); + }); + + test('getConfigEndIndex should handle zero as a valid end index', () => { + cmp.setEndIndex(0); + expect(cmp.getConfigEndIndex()).toBe(0); + expect(cmp.getItemsCount()).toBe(0); + }); + }); + + describe('Setters', () => { + let cmp: ComponentDataCollection; + + beforeEach(() => { + cmp = wrapper.components({ + type: DataCollectionType, + collectionDef: { + componentDef: { + type: 'default', + components: [ + { + type: 'default', + tagName: 'div', + attributes: { + 'data-user': { + type: DataCollectionVariableType, + variableType: DataCollectionStateVariableType.currentItem, + collectionId: 'my_collection', + path: 'user', + }, + }, + }, + ], + }, + collectionConfig: { + collectionId: 'my_collection', + startIndex: 1, + endIndex: 2, + dataSource: { + type: DataVariableType, + path: 'my_data_source_id', + }, + }, + }, + })[0] as ComponentDataCollection; + }); + + test('setComponentDef should update the component definition and reflect in children', () => { + const newComponentDef = { + type: 'newType', + components: [ + { + type: 'default', + tagName: 'span', + attributes: { + 'data-name': { + type: DataCollectionVariableType, + variableType: DataCollectionStateVariableType.currentItem, + collectionId: 'my_collection', + path: 'firstName', + }, + }, + }, + ], + }; + cmp.setComponentDef(newComponentDef); + + const children = cmp.components(); + expect(children).toHaveLength(2); + expect(children.at(0).get('type')).toBe('newType'); + expect(children.at(0).components().at(0).get('tagName')).toBe('span'); + expect(children.at(0).components().at(0).getAttributes()['data-name']).toBe('Name2'); + }); + + test('setStartIndex should update the start index and reflect in children', () => { + cmp.setStartIndex(0); + expect(cmp.getConfigStartIndex()).toBe(0); + + const children = cmp.components(); + expect(children).toHaveLength(3); + expect(children.at(0).getAttributes()['data-user']).toBe('user1'); + expect(children.at(1).getAttributes()['data-user']).toBe('user2'); + expect(children.at(2).getAttributes()['data-user']).toBe('user3'); + }); + + test('setEndIndex should update the end index and reflect in children', () => { + cmp.setEndIndex(3); + expect(cmp.getConfigEndIndex()).toBe(3); + + const children = cmp.components(); + expect(children).toHaveLength(2); + expect(children.at(0).getAttributes()['data-user']).toBe('user2'); + expect(children.at(1).getAttributes()['data-user']).toBe('user3'); + }); + + test('setDataSource should update the data source and reflect in children', () => { + dsm.add({ + id: 'new_data_source_id', + records: [ + { id: 'user4', user: 'user4', firstName: 'Name4', age: '20' }, + ], + }); + cmp.setDataSource({ + type: DataVariableType, + path: 'new_data_source_id', + }); + + const children = cmp.components(); + expect(children).toHaveLength(1); // Only one record in the new data source + expect(children.at(0).getAttributes()['data-user']).toBe('user4'); + }); + + test('setStartIndex with zero should include the first record', () => { + cmp.setStartIndex(0); + const children = cmp.components(); + expect(children).toHaveLength(3); // Includes all records + expect(children.at(0).getAttributes()['data-user']).toBe('user1'); + }); + + test('setEndIndex with zero should result in no children', () => { + cmp.setEndIndex(0); + const children = cmp.components(); + expect(children).toHaveLength(0); // No records included + }); + + test('setDataSource with an empty data source should result in no children', () => { + const emptyDataSource = dsm.add({ + id: 'empty_data_source_id', + records: [], + }); + cmp.setDataSource({ + type: DataVariableType, + path: 'empty_data_source_id', + }); + + const children = cmp.components(); + expect(children).toHaveLength(0); // No records in the data source + }); + }); + + describe('Impact on HTML output', () => { + let cmp: ComponentDataCollection; + + beforeEach(() => { + cmp = wrapper.components({ + type: DataCollectionType, + collectionDef: { + componentDef: { + type: 'default', + components: [ + { + type: 'default', + tagName: 'div', + attributes: { + 'data-user': { + type: DataCollectionVariableType, + variableType: DataCollectionStateVariableType.currentItem, + collectionId: 'my_collection', + path: 'user', + }, + }, + }, + ], + }, + collectionConfig: { + collectionId: 'my_collection', + startIndex: 1, + endIndex: 2, + dataSource: { + type: DataVariableType, + path: 'my_data_source_id', + }, + }, + }, + })[0] as ComponentDataCollection; + }); + + test('HTML output should reflect changes in startIndex', () => { + cmp.setStartIndex(0); + const html = cmp.toHTML(); + expect(html).toContain('data-user="user1"'); + expect(html).toContain('data-user="user2"'); + expect(html).toContain('data-user="user3"'); + }); + + test('HTML output should reflect changes in endIndex', () => { + cmp.setEndIndex(3); + const html = cmp.toHTML(); + expect(html).toContain('data-user="user2"'); + expect(html).toContain('data-user="user3"'); + }); + + test('HTML output should reflect changes in dataSource', () => { + dsm.add({ + id: 'new_data_source_id', + records: [ + { id: 'user4', user: 'user4', firstName: 'Name4', age: '20' }, + ], + }); + cmp.setDataSource({ + type: DataVariableType, + path: 'new_data_source_id', + }); + + const html = cmp.toHTML(); + expect(html).toContain('data-user="user4"'); + }); + + test('HTML output should be empty when endIndex is zero', () => { + cmp.setEndIndex(0); + const html = cmp.toHTML(); + expect(html).not.toContain('data-user'); + }); + }); +}); \ No newline at end of file