diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index ddfa1f317..045fff3d0 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -103,6 +103,9 @@ module.exports = { ['/api/keymaps', 'Keymaps'], ['/api/undo_manager', 'Undo Manager'], ['/api/parser', 'Parser'], + ['/api/data_source_manager', 'Data Source Manager'], + ['/api/datasource', `${subDivider}DataSource`], + ['/api/datarecord', `${subDivider}DataRecord`], ], '/': [ '', @@ -126,6 +129,7 @@ module.exports = { ['/modules/Storage', 'Storage Manager'], ['/modules/Modal', 'Modal'], ['/modules/Plugins', 'Plugins'], + ['/modules/DataSources', 'Data Sources'], ], }, { diff --git a/docs/api.mjs b/docs/api.mjs index 7f7aa5f6f..3bbd09ef1 100644 --- a/docs/api.mjs +++ b/docs/api.mjs @@ -87,6 +87,9 @@ async function generateDocs() { ['pages/index.ts', 'pages.md'], ['pages/model/Page.ts', 'page.md'], ['parser/index.ts', 'parser.md'], + ['data_sources/index.ts', 'data_source_manager.md'], + ['data_sources/model/DataSource.ts', 'datasource.md'], + ['data_sources/model/DataRecord.ts', 'datarecord.md'], ].map(async (file) => { const filePath = `${srcRoot}/${file[0]}`; diff --git a/docs/api/data_source_manager.md b/docs/api/data_source_manager.md new file mode 100644 index 000000000..e1cac5e88 --- /dev/null +++ b/docs/api/data_source_manager.md @@ -0,0 +1,141 @@ + + +## DataSources + +This module manages data sources within the editor. +You can initialize the module with the editor by passing an instance of `EditorModel`. + +```js +const editor = new EditorModel(); +const dsm = new DataSourceManager(editor); +``` + +Once the editor is instantiated, you can use the following API to manage data sources: + +```js +const dsm = editor.DataSources; +``` + +* [add][1] - Add a new data source. +* [get][2] - Retrieve a data source by its ID. +* [getAll][3] - Retrieve all data sources. +* [remove][4] - Remove a data source by its ID. +* [clear][5] - Remove all data sources. + +Example of adding a data source: + +```js +const ds = dsm.add({ + id: 'my_data_source_id', + records: [ + { id: 'id1', name: 'value1' }, + { id: 'id2', name: 'value2' } + ] +}); +``` + +### Parameters + +* `em` **EditorModel** Editor model. + +## add + +Add new data source. + +### Parameters + +* `props` **[Object][6]** Data source properties. +* `opts` **AddOptions** (optional, default `{}`) + +### Examples + +```javascript +const ds = dsm.add({ + id: 'my_data_source_id', + records: [ + { id: 'id1', name: 'value1' }, + { id: 'id2', name: 'value2' } + ] +}); +``` + +Returns **[DataSource]** Added data source. + +## get + +Get data source. + +### Parameters + +* `id` **[String][7]** Data source id. + +### Examples + +```javascript +const ds = dsm.get('my_data_source_id'); +``` + +Returns **[DataSource]** Data source. + +## getValue + +Get value from data sources by key + +### Parameters + +* `key` **[String][7]** Path to value. +* `defValue` **any** + +Returns **any** const value = dsm.getValue('ds\_id.record\_id.propName', 'defaultValue'); + +## remove + +Remove data source. + +### Parameters + +* `id` **([String][7] | [DataSource])** Id of the data source. +* `opts` **RemoveOptions?** + +### Examples + +```javascript +const removed = dsm.remove('DS_ID'); +``` + +Returns **[DataSource]** Removed data source. + +## fromPath + +Retrieve a data source, data record, and optional property path based on a string path. +This method parses a string path to identify and retrieve the corresponding data source +and data record. If a property path is included in the input, it will also be returned. +The method is useful for accessing nested data within data sources. + +### Parameters + +* `path` **[String][7]** The string path in the format 'dataSourceId.recordId.property'. + +### Examples + +```javascript +const [dataSource, dataRecord, propPath] = dsm.fromPath('my_data_source_id.record_id.myProp'); +// e.g., [DataSource, DataRecord, 'myProp'] +``` + +Returns **[DataSource?, DataRecord?, [String][7]?]** An array containing the data source, +data record, and optional property path. + +[1]: #add + +[2]: #get + +[3]: #getall + +[4]: #remove + +[5]: #clear + +[6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object + +[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String diff --git a/docs/api/datarecord.md b/docs/api/datarecord.md new file mode 100644 index 000000000..94f88375d --- /dev/null +++ b/docs/api/datarecord.md @@ -0,0 +1,115 @@ + + +## DataRecord + +The `DataRecord` class represents a single record within a data source. +It extends the base `Model` class and provides additional methods and properties specific to data records. +Each `DataRecord` is associated with a `DataSource` and can trigger events when its properties change. + +### DataRecord API + +* [getPath][1] +* [getPaths][2] +* [set][3] + +### Example of Usage + +```js +const record = new DataRecord({ id: 'record1', name: 'value1' }, { collection: dataRecords }); +const path = record.getPath(); // e.g., 'SOURCE_ID.record1' +record.set('name', 'newValue'); +``` + +### Parameters + +* `props` **DataRecordProps** Properties to initialize the data record. +* `opts` **[Object][4]** Options for initializing the data record. + +## getPath + +Get the path of the record. +The path is a string that represents the location of the record within the data source. +Optionally, include a property name to create a more specific path. + +### Parameters + +* `prop` **[String][5]?** Optional property name to include in the path. +* `opts` **[Object][4]?** Options for path generation. + + * `opts.useIndex` **[Boolean][6]?** Whether to use the index of the record in the path. + +### Examples + +```javascript +const pathRecord = record.getPath(); +// e.g., 'SOURCE_ID.record1' +const pathRecord2 = record.getPath('myProp'); +// e.g., 'SOURCE_ID.record1.myProp' +``` + +Returns **[String][5]** The path of the record. + +## getPaths + +Get both ID-based and index-based paths of the record. +Returns an array containing the paths using both ID and index. + +### Parameters + +* `prop` **[String][5]?** Optional property name to include in the paths. + +### Examples + +```javascript +const paths = record.getPaths(); +// e.g., ['SOURCE_ID.record1', 'SOURCE_ID.0'] +``` + +Returns **[Array][7]<[String][5]>** An array of paths. + +## triggerChange + +Trigger a change event for the record. +Optionally, include a property name to trigger a change event for a specific property. + +### Parameters + +* `prop` **[String][5]?** Optional property name to trigger a change event for a specific property. + +## set + +Set a property on the record, optionally using transformers. +If transformers are defined for the record, they will be applied to the value before setting it. + +### Parameters + +* `attributeName` **([String][5] | [Object][4])** The name of the attribute to set, or an object of key-value pairs. +* `value` **any?** The value to set for the attribute. +* `options` **[Object][4]?** Options to apply when setting the attribute. + + * `options.avoidTransformers` **[Boolean][6]?** If true, transformers will not be applied. + +### Examples + +```javascript +record.set('name', 'newValue'); +// Sets 'name' property to 'newValue' +``` + +Returns **[DataRecord][8]** The instance of the DataRecord. + +[1]: #getpath + +[2]: #getpaths + +[3]: #set + +[4]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object + +[5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String + +[6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean + +[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array + +[8]: #datarecord diff --git a/docs/api/datasource.md b/docs/api/datasource.md new file mode 100644 index 000000000..d330d9b44 --- /dev/null +++ b/docs/api/datasource.md @@ -0,0 +1,129 @@ + + +## DataSource + +The `DataSource` class represents a data source within the editor. +It manages a collection of data records and provides methods to interact with them. +The `DataSource` can be extended with transformers to modify records during add, read, and delete operations. + +### DataSource API + +* [addRecord][1] +* [getRecord][2] +* [getRecords][3] +* [removeRecord][4] + +### Example of Usage + +```js +const dataSource = new DataSource({ + records: [ + { id: 'id1', name: 'value1' }, + { id: 'id2', name: 'value2' } + ], +}, { em: editor }); + +dataSource.addRecord({ id: 'id3', name: 'value3' }); +``` + +### Parameters + +* `props` **DataSourceProps** Properties to initialize the data source. +* `opts` **DataSourceOptions** Options to initialize the data source. + +## defaults + +Returns the default properties for the data source. +These include an empty array of records and an empty object of transformers. + +Returns **[Object][5]** The default attributes for the data source. + +## constructor + +Initializes a new instance of the `DataSource` class. +It sets up the transformers and initializes the collection of records. +If the `records` property is not an instance of `DataRecords`, it will be converted into one. + +### Parameters + +* `props` **DataSourceProps** Properties to initialize the data source. +* `opts` **DataSourceOptions** Options to initialize the data source. + +## records + +Retrieves the collection of records associated with this data source. + +Returns **DataRecords** The collection of data records. + +## em + +Retrieves the editor model associated with this data source. + +Returns **EditorModel** The editor model. + +## addRecord + +Adds a new record to the data source. + +### Parameters + +* `record` **DataRecordProps** The properties of the record to add. +* `opts` **AddOptions?** Options to apply when adding the record. + +Returns **DataRecord** The added data record. + +## getRecord + +Retrieves a record from the data source by its ID. + +### Parameters + +* `id` **([string][6] | [number][7])** The ID of the record to retrieve. + +Returns **(DataRecord | [undefined][8])** The data record, or `undefined` if no record is found with the given ID. + +## getRecords + +Retrieves all records from the data source. +Each record is processed with the `getRecord` method to apply any read transformers. + +Returns **[Array][9]<(DataRecord | [undefined][8])>** An array of data records. + +## removeRecord + +Removes a record from the data source by its ID. + +### Parameters + +* `id` **([string][6] | [number][7])** The ID of the record to remove. +* `opts` **RemoveOptions?** Options to apply when removing the record. + +Returns **(DataRecord | [undefined][8])** The removed data record, or `undefined` if no record is found with the given ID. + +## setRecords + +Replaces the existing records in the data source with a new set of records. + +### Parameters + +* `records` **[Array][9]\** An array of data record properties to set. + +Returns **[Array][9]\** An array of the added data records. + +[1]: #addrecord + +[2]: #getrecord + +[3]: #getrecords + +[4]: #removerecord + +[5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object + +[6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String + +[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number + +[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/undefined + +[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array diff --git a/docs/modules/DataSources.md b/docs/modules/DataSources.md new file mode 100644 index 000000000..7414c54f5 --- /dev/null +++ b/docs/modules/DataSources.md @@ -0,0 +1,210 @@ +--- +title: Data Sources +--- + +# DataSources + +## Overview + +**DataSources** are a powerful feature in GrapesJS that allow you to manage and inject data into your components, styles, and traits programmatically. They help you bind dynamic data to your design elements and keep your user interface synchronized with underlying data models. + +### Key Concepts + +1. **DataSource**: A static object with records that can be used throughout GrapesJS. +2. **ComponentDataVariable**: A type of data variable that can be used within components to inject dynamic values. +3. **StyleDataVariable**: A data variable used to bind CSS properties to values in your DataSource. +4. **TraitDataVariable**: A data variable used in component traits to bind data to various UI elements. +5. **Transformers**: Methods for validating and transforming data records in a DataSource. + +## Creating and Adding DataSources + +To start using DataSources, you need to create them and add them to GrapesJS. + +**Example: Creating and Adding a DataSource** + +```ts +const editor = grapesjs.init({ + container: '#gjs', +}); + +const datasource = { + id: 'my-datasource', + records: [ + { id: 'id1', content: 'Hello World' }, + { id: 'id2', color: 'red' }, + ], +}; + +editor.DataSources.add(datasource); +``` + +## Using DataSources with Components + +You can reference DataSources within your components to dynamically inject data. + +**Example: Using DataSources with Components** + +```ts +editor.addComponents([ + { + tagName: 'h1', + type: 'text', + components: [ + { + type: 'data-variable', + defaultValue: 'default', + path: 'my-datasource.id1.content', + }, + ], + }, +]); +``` + +In this example, the `h1` component will display "Hello World" by fetching the content from the DataSource with the path `my-datasource.id1.content`. + +## Using DataSources with Styles + +DataSources can also be used to bind data to CSS properties. + +**Example: Using DataSources with Styles** + +```ts +editor.addComponents([ + { + tagName: 'h1', + type: 'text', + components: [ + { + type: 'data-variable', + defaultValue: 'default', + path: 'my-datasource.id1.content', + }, + ], + style: { + color: { + type: 'data-variable', + defaultValue: 'red', + path: 'my-datasource.id2.color', + }, + }, + }, +]); +``` + +Here, the `h1` component's color will be set to red, as specified in the DataSource at `my-datasource.id2.color`. + +## Using DataSources with Traits + +Traits are used to bind DataSource values to component properties, such as input fields. + +**Example: Using DataSources with Traits** + +```ts +const datasource = { + id: 'my-datasource', + records: [{ id: 'id1', value: 'I Love Grapes' }], +}; +editor.DataSources.add(datasource); + +editor.addComponents([ + { + tagName: 'input', + traits: [ + 'name', + 'type', + { + type: 'text', + label: 'Value', + name: 'value', + value: { + type: 'data-variable', + defaultValue: 'default', + path: 'my-datasource.id1.value', + }, + }, + ], + }, +]); +``` + +In this case, the value of the input field is bound to the DataSource value at `my-datasource.id1.value`. + +## DataSource Transformers + +Transformers in DataSources allow you to customize how data is processed during various stages of interaction with the data. The primary transformer functions include: + +### 1. `onRecordSetValue` + +This transformer is invoked when a record's property is added or updated. It provides an opportunity to validate or transform the new value. + +#### Example Usage + +```javascript +const testDataSource = { + id: 'test-data-source', + records: [], + transformers: { + onRecordSetValue: ({ id, key, value }) => { + if (key !== 'content') { + return value; + } + if (typeof value !== 'string') { + throw new Error('Value must be a string'); + } + return value.toUpperCase(); + }, + }, +}; +``` + +In this example, the `onRecordSetValue` transformer ensures that the `content` property is always an uppercase string. + +## Benefits of Using DataSources + +DataSources are integrated with GrapesJS's runtime and BackboneJS models, enabling dynamic updates and synchronization between your data and UI components. This allows you to: + +1. **Inject Configuration**: Manage and inject configuration settings dynamically. +2. **Manage Global Themes**: Apply and update global styling themes. +3. **Mock & Test**: Use DataSources for testing and mocking data during development. +4. **Integrate with Third-Party Services**: Connect and synchronize with external data sources and services. + +**Example: Using DataSources to Manage a Counter** + +```ts +const datasource = { + id: 'my-datasource', + records: [{ id: 'id1', counter: 0 }], +}; + +editor.DataSources.add(datasource); + +editor.addComponents([ + { + tagName: 'span', + type: 'text', + components: [ + { + type: 'data-variable', + defaultValue: 'default', + path: 'my-datasource.id1.counter', + }, + ], + }, +]); + +const ds = editor.DataSources.get('my-datasource'); +setInterval(() => { + console.log('Incrementing counter'); + const counterRecord = ds.getRecord('id1'); + counterRecord.set({ counter: counterRecord.get('counter') + 1 }); +}, 1000); +``` + +In this example, a counter is dynamically updated and displayed in the UI, demonstrating the real-time synchronization capabilities of DataSources. + +**Examples of How DataSources Could Be Used:** + +1. Injecting configuration +2. Managing global themes +3. Mocking & testing +4. Third-party integrations diff --git a/src/abstract/Module.ts b/src/abstract/Module.ts index 3a77e19c0..e0e4f4cd0 100644 --- a/src/abstract/Module.ts +++ b/src/abstract/Module.ts @@ -125,7 +125,7 @@ export abstract class ItemManagerModule< TCollection extends Collection = Collection, > extends Module { cls: any[] = []; - protected all: TCollection; + all: TCollection; view?: View; constructor( diff --git a/src/common/index.ts b/src/common/index.ts index 943d4ad02..93a15345f 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -6,7 +6,7 @@ interface NOOP {} export type Debounced = Function & { cancel(): void }; -export type SetOptions = Backbone.ModelSetOptions & { avoidStore?: boolean }; +export type SetOptions = Backbone.ModelSetOptions & { avoidStore?: boolean; avoidTransformers?: boolean }; export type AddOptions = Backbone.AddOptions & { temporary?: boolean; action?: string }; diff --git a/src/data_sources/index.ts b/src/data_sources/index.ts new file mode 100644 index 000000000..37b3e9360 --- /dev/null +++ b/src/data_sources/index.ts @@ -0,0 +1,151 @@ +/** + * This module manages data sources within the editor. + * You can initialize the module with the editor by passing an instance of `EditorModel`. + * + * ```js + * const editor = new EditorModel(); + * const dsm = new DataSourceManager(editor); + * ``` + * + * Once the editor is instantiated, you can use the following API to manage data sources: + * + * ```js + * const dsm = editor.DataSources; + * ``` + * + * * [add](#add) - Add a new data source. + * * [get](#get) - Retrieve a data source by its ID. + * * [getAll](#getall) - Retrieve all data sources. + * * [remove](#remove) - Remove a data source by its ID. + * * [clear](#clear) - Remove all data sources. + * + * Example of adding a data source: + * + * ```js + * const ds = dsm.add({ + * id: 'my_data_source_id', + * records: [ + * { id: 'id1', name: 'value1' }, + * { id: 'id2', name: 'value2' } + * ] + * }); + * ``` + * + * @module DataSources + * @param {EditorModel} em - Editor model. + */ + +import { ItemManagerModule, ModuleConfig } from '../abstract/Module'; +import { AddOptions, ObjectAny, RemoveOptions } from '../common'; +import EditorModel from '../editor/model/Editor'; +import { get, stringToPath } from '../utils/mixins'; +import DataRecord from './model/DataRecord'; +import DataSource from './model/DataSource'; +import DataSources from './model/DataSources'; +import { DataSourcesEvents, DataSourceProps } from './types'; +import { Events } from 'backbone'; + +export default class DataSourceManager extends ItemManagerModule { + storageKey = ''; + events = DataSourcesEvents; + destroy(): void {} + + constructor(em: EditorModel) { + super(em, 'DataSources', new DataSources([], em), DataSourcesEvents); + Object.assign(this, Events); // Mixin Backbone.Events + } + + /** + * Add new data source. + * @param {Object} props Data source properties. + * @returns {[DataSource]} Added data source. + * @example + * const ds = dsm.add({ + * id: 'my_data_source_id', + * records: [ + * { id: 'id1', name: 'value1' }, + * { id: 'id2', name: 'value2' } + * ] + * }); + */ + add(props: DataSourceProps, opts: AddOptions = {}) { + const { all } = this; + props.id = props.id || this._createId(); + return all.add(props, opts); + } + + /** + * Get data source. + * @param {String} id Data source id. + * @returns {[DataSource]} Data source. + * @example + * const ds = dsm.get('my_data_source_id'); + */ + get(id: string) { + return this.all.get(id); + } + + /** + * Get value from data sources by key + * @param {String} key Path to value. + * @param {any} defValue + * @returns {any} + * const value = dsm.getValue('ds_id.record_id.propName', 'defaultValue'); + */ + getValue(key: string | string[], defValue: any) { + return get(this.getContext(), key, defValue); + } + + private getContext() { + return this.all.reduce((acc, ds) => { + acc[ds.id] = ds.records.reduce((accR, dr, i) => { + const dataRecord = dr; + + accR[i] = dataRecord.attributes; + accR[dataRecord.id || i] = dataRecord.attributes; + + return accR; + }, {} as ObjectAny); + return acc; + }, {} as ObjectAny); + } + + /** + * Remove data source. + * @param {String|[DataSource]} id Id of the data source. + * @returns {[DataSource]} Removed data source. + * @example + * const removed = dsm.remove('DS_ID'); + */ + remove(id: string | DataSource, opts?: RemoveOptions) { + return this.__remove(id, opts); + } + + /** + * Retrieve a data source, data record, and optional property path based on a string path. + * This method parses a string path to identify and retrieve the corresponding data source + * and data record. If a property path is included in the input, it will also be returned. + * The method is useful for accessing nested data within data sources. + * + * @param {String} path - The string path in the format 'dataSourceId.recordId.property'. + * @returns {[DataSource?, DataRecord?, String?]} - An array containing the data source, + * data record, and optional property path. + * @example + * const [dataSource, dataRecord, propPath] = dsm.fromPath('my_data_source_id.record_id.myProp'); + * // e.g., [DataSource, DataRecord, 'myProp'] + */ + fromPath(path: string) { + const result: [DataSource?, DataRecord?, string?] = []; + const [dsId, drId, ...resPath] = stringToPath(path || ''); + const dataSource = this.get(dsId); + const dataRecord = dataSource?.records.get(drId); + dataSource && result.push(dataSource); + + if (dataRecord) { + result.push(dataRecord); + resPath.length && result.push(resPath.join('.')); + } + + return result; + } +} diff --git a/src/data_sources/model/ComponentDataVariable.ts b/src/data_sources/model/ComponentDataVariable.ts new file mode 100644 index 000000000..e75d57a60 --- /dev/null +++ b/src/data_sources/model/ComponentDataVariable.ts @@ -0,0 +1,31 @@ +import Component from '../../dom_components/model/Component'; +import { ToHTMLOptions } from '../../dom_components/model/types'; +import { toLowerCase } from '../../utils/mixins'; +import { DataVariableType } from './DataVariable'; + +export default class ComponentDataVariable extends Component { + get defaults() { + return { + // @ts-ignore + ...super.defaults, + type: DataVariableType, + path: '', + defaultValue: '', + }; + } + + getDataValue() { + const { path, defaultValue } = this.attributes; + return this.em.DataSources.getValue(path, defaultValue); + } + + getInnerHTML(opts: ToHTMLOptions) { + const val = this.getDataValue(); + + return val; + } + + static isComponent(el: HTMLElement) { + return toLowerCase(el.tagName) === DataVariableType; + } +} diff --git a/src/data_sources/model/DataRecord.ts b/src/data_sources/model/DataRecord.ts new file mode 100644 index 000000000..bf64dbfcf --- /dev/null +++ b/src/data_sources/model/DataRecord.ts @@ -0,0 +1,166 @@ +/** + * The `DataRecord` class represents a single record within a data source. + * It extends the base `Model` class and provides additional methods and properties specific to data records. + * Each `DataRecord` is associated with a `DataSource` and can trigger events when its properties change. + * + * ### DataRecord API + * + * * [getPath](#getpath) + * * [getPaths](#getpaths) + * * [set](#set) + * + * ### Example of Usage + * + * ```js + * const record = new DataRecord({ id: 'record1', name: 'value1' }, { collection: dataRecords }); + * const path = record.getPath(); // e.g., 'SOURCE_ID.record1' + * record.set('name', 'newValue'); + * ``` + * + * @module DataRecord + * @param {DataRecordProps} props - Properties to initialize the data record. + * @param {Object} opts - Options for initializing the data record. + * @extends {Model} + */ + +import { keys } from 'underscore'; +import { Model, SetOptions } from '../../common'; +import { DataRecordProps, DataSourcesEvents } from '../types'; +import DataRecords from './DataRecords'; +import DataSource from './DataSource'; +import EditorModel from '../../editor/model/Editor'; +import { _StringKey } from 'backbone'; + +export default class DataRecord extends Model { + constructor(props: T, opts = {}) { + super(props, opts); + this.on('change', this.handleChange); + } + + get cl() { + return this.collection as unknown as DataRecords; + } + + get dataSource(): DataSource { + return this.cl.dataSource; + } + + get em(): EditorModel { + return this.dataSource.em; + } + + get index(): number { + return this.cl.indexOf(this); + } + + /** + * Handles changes to the record's attributes. + * This method triggers a change event for each property that has been altered. + * + * @private + * @name handleChange + */ + handleChange() { + const changed = this.changedAttributes(); + keys(changed).forEach((prop) => this.triggerChange(prop)); + } + + /** + * Get the path of the record. + * The path is a string that represents the location of the record within the data source. + * Optionally, include a property name to create a more specific path. + * + * @param {String} [prop] - Optional property name to include in the path. + * @param {Object} [opts] - Options for path generation. + * @param {Boolean} [opts.useIndex] - Whether to use the index of the record in the path. + * @returns {String} - The path of the record. + * @name getPath + * @example + * const pathRecord = record.getPath(); + * // e.g., 'SOURCE_ID.record1' + * const pathRecord2 = record.getPath('myProp'); + * // e.g., 'SOURCE_ID.record1.myProp' + */ + getPath(prop?: string, opts: { useIndex?: boolean } = {}) { + const { dataSource, id, index } = this; + const dsId = dataSource.id; + const suffix = prop ? `.${prop}` : ''; + return `${dsId}.${opts.useIndex ? index : id}${suffix}`; + } + + /** + * Get both ID-based and index-based paths of the record. + * Returns an array containing the paths using both ID and index. + * + * @param {String} [prop] - Optional property name to include in the paths. + * @returns {Array} - An array of paths. + * @name getPaths + * @example + * const paths = record.getPaths(); + * // e.g., ['SOURCE_ID.record1', 'SOURCE_ID.0'] + */ + getPaths(prop?: string) { + return [this.getPath(prop), this.getPath(prop, { useIndex: true })]; + } + + /** + * Trigger a change event for the record. + * Optionally, include a property name to trigger a change event for a specific property. + * + * @param {String} [prop] - Optional property name to trigger a change event for a specific property. + * @name triggerChange + */ + triggerChange(prop?: string) { + const { dataSource, em } = this; + const data = { dataSource, dataRecord: this }; + const paths = this.getPaths(prop); + paths.forEach((path) => em.trigger(`${DataSourcesEvents.path}:${path}`, { ...data, path })); + } + + /** + * Set a property on the record, optionally using transformers. + * If transformers are defined for the record, they will be applied to the value before setting it. + * + * @param {String|Object} attributeName - The name of the attribute to set, or an object of key-value pairs. + * @param {any} [value] - The value to set for the attribute. + * @param {Object} [options] - Options to apply when setting the attribute. + * @param {Boolean} [options.avoidTransformers] - If true, transformers will not be applied. + * @returns {DataRecord} - The instance of the DataRecord. + * @name set + * @example + * record.set('name', 'newValue'); + * // Sets 'name' property to 'newValue' + */ + set>( + attributeName: Partial | A, + value?: SetOptions | T[A] | undefined, + options?: SetOptions | undefined, + ): this; + set(attributeName: unknown, value?: unknown, options?: SetOptions): DataRecord { + const onRecordSetValue = this.dataSource?.transformers?.onRecordSetValue; + + const applySet = (key: string, val: unknown) => { + const newValue = + options?.avoidTransformers || !onRecordSetValue + ? val + : onRecordSetValue({ + id: this.id, + key, + value: val, + }); + + super.set(key, newValue, options); + }; + + if (typeof attributeName === 'object' && attributeName !== null) { + const attributes = attributeName as Partial; + for (const [key, val] of Object.entries(attributes)) { + applySet(key, val); + } + } else { + applySet(attributeName as string, value); + } + + return this; + } +} diff --git a/src/data_sources/model/DataRecords.ts b/src/data_sources/model/DataRecords.ts new file mode 100644 index 000000000..87a172dae --- /dev/null +++ b/src/data_sources/model/DataRecords.ts @@ -0,0 +1,15 @@ +import { Collection } from '../../common'; +import { DataRecordProps } from '../types'; +import DataRecord from './DataRecord'; +import DataSource from './DataSource'; + +export default class DataRecords extends Collection { + dataSource: DataSource; + + constructor(models: DataRecord[] | DataRecordProps[], options: { dataSource: DataSource }) { + super(models, options); + this.dataSource = options.dataSource; + } +} + +DataRecords.prototype.model = DataRecord; diff --git a/src/data_sources/model/DataSource.ts b/src/data_sources/model/DataSource.ts new file mode 100644 index 000000000..37543901b --- /dev/null +++ b/src/data_sources/model/DataSource.ts @@ -0,0 +1,172 @@ +/** + * The `DataSource` class represents a data source within the editor. + * It manages a collection of data records and provides methods to interact with them. + * The `DataSource` can be extended with transformers to modify records during add, read, and delete operations. + * + * ### DataSource API + * + * * [addRecord](#addrecord) + * * [getRecord](#getrecord) + * * [getRecords](#getrecords) + * * [removeRecord](#removerecord) + * + * ### Example of Usage + * + * ```js + * const dataSource = new DataSource({ + * records: [ + * { id: 'id1', name: 'value1' }, + * { id: 'id2', name: 'value2' } + * ], + * }, { em: editor }); + * + * dataSource.addRecord({ id: 'id3', name: 'value3' }); + * ``` + * + * @module DataSource + * @param {DataSourceProps} props - Properties to initialize the data source. + * @param {DataSourceOptions} opts - Options to initialize the data source. + * @extends {Model} + */ + +import { AddOptions, CombinedModelConstructorOptions, Model, RemoveOptions } from '../../common'; +import EditorModel from '../../editor/model/Editor'; +import { DataRecordProps, DataSourceProps, DataSourceTransformers } from '../types'; +import DataRecord from './DataRecord'; +import DataRecords from './DataRecords'; +import DataSources from './DataSources'; + +interface DataSourceOptions extends CombinedModelConstructorOptions<{ em: EditorModel }, DataSource> {} + +export default class DataSource extends Model { + transformers: DataSourceTransformers; + + /** + * Returns the default properties for the data source. + * These include an empty array of records and an empty object of transformers. + * + * @returns {Object} The default attributes for the data source. + * @name defaults + */ + defaults() { + return { + records: [], + transformers: {}, + }; + } + + /** + * Initializes a new instance of the `DataSource` class. + * It sets up the transformers and initializes the collection of records. + * If the `records` property is not an instance of `DataRecords`, it will be converted into one. + * + * @param {DataSourceProps} props - Properties to initialize the data source. + * @param {DataSourceOptions} opts - Options to initialize the data source. + * @name constructor + */ + constructor(props: DataSourceProps, opts: DataSourceOptions) { + super(props, opts); + const { records, transformers } = props; + this.transformers = transformers || {}; + + if (!(records instanceof DataRecords)) { + this.set({ records: new DataRecords(records!, { dataSource: this }) }); + } + + this.listenTo(this.records, 'add', this.onAdd); + } + + /** + * Retrieves the collection of records associated with this data source. + * + * @returns {DataRecords} The collection of data records. + * @name records + */ + get records() { + return this.attributes.records as DataRecords; + } + + /** + * Retrieves the editor model associated with this data source. + * + * @returns {EditorModel} The editor model. + * @name em + */ + get em() { + return (this.collection as unknown as DataSources).em; + } + + /** + * Handles the `add` event for records in the data source. + * This method triggers a change event on the newly added record. + * + * @param {DataRecord} dr - The data record that was added. + * @private + * @name onAdd + */ + onAdd(dr: DataRecord) { + dr.triggerChange(); + } + + /** + * Adds a new record to the data source. + * + * @param {DataRecordProps} record - The properties of the record to add. + * @param {AddOptions} [opts] - Options to apply when adding the record. + * @returns {DataRecord} The added data record. + * @name addRecord + */ + addRecord(record: DataRecordProps, opts?: AddOptions) { + return this.records.add(record, opts); + } + + /** + * Retrieves a record from the data source by its ID. + * + * @param {string | number} id - The ID of the record to retrieve. + * @returns {DataRecord | undefined} The data record, or `undefined` if no record is found with the given ID. + * @name getRecord + */ + getRecord(id: string | number): DataRecord | undefined { + const record = this.records.get(id); + return record; + } + + /** + * Retrieves all records from the data source. + * Each record is processed with the `getRecord` method to apply any read transformers. + * + * @returns {Array} An array of data records. + * @name getRecords + */ + getRecords() { + return [...this.records.models].map((record) => this.getRecord(record.id)); + } + + /** + * Removes a record from the data source by its ID. + * + * @param {string | number} id - The ID of the record to remove. + * @param {RemoveOptions} [opts] - Options to apply when removing the record. + * @returns {DataRecord | undefined} The removed data record, or `undefined` if no record is found with the given ID. + * @name removeRecord + */ + removeRecord(id: string | number, opts?: RemoveOptions): DataRecord | undefined { + return this.records.remove(id, opts); + } + + /** + * Replaces the existing records in the data source with a new set of records. + * + * @param {Array} records - An array of data record properties to set. + * @returns {Array} An array of the added data records. + * @name setRecords + */ + setRecords(records: Array) { + this.records.reset([], { silent: true }); + + records.forEach((record) => { + this.records.add(record); + }); + } +} diff --git a/src/data_sources/model/DataSources.ts b/src/data_sources/model/DataSources.ts new file mode 100644 index 000000000..4f7478762 --- /dev/null +++ b/src/data_sources/model/DataSources.ts @@ -0,0 +1,18 @@ +import { Collection } from '../../common'; +import EditorModel from '../../editor/model/Editor'; +import { DataSourceProps } from '../types'; +import DataSource from './DataSource'; + +export default class DataSources extends Collection { + em: EditorModel; + + constructor(models: DataSource[] | DataSourceProps[], em: EditorModel) { + super(models, em); + this.em = em; + + // @ts-ignore We need to inject `em` for pages created on reset from the Storage load + this.model = (props: DataSourceProps, opts = {}) => { + return new DataSource(props, { ...opts, em }); + }; + } +} diff --git a/src/data_sources/model/DataVariable.ts b/src/data_sources/model/DataVariable.ts new file mode 100644 index 000000000..fce884565 --- /dev/null +++ b/src/data_sources/model/DataVariable.ts @@ -0,0 +1,44 @@ +import { Model } from '../../common'; +import EditorModel from '../../editor/model/Editor'; +import { stringToPath } from '../../utils/mixins'; + +export const DataVariableType = 'data-variable'; + +export default class DataVariable extends Model { + em?: EditorModel; + + defaults() { + return { + type: DataVariableType, + defaultValue: '', + path: '', + }; + } + + constructor(attrs: any, options: any) { + super(attrs, options); + this.em = options.em; + this.listenToDataSource(); + } + + listenToDataSource() { + const { path } = this.attributes; + const resolvedPath = stringToPath(path).join('.'); + + if (this.em) { + this.listenTo(this.em.DataSources, `change:${resolvedPath}`, this.onDataSourceChange); + } + } + + onDataSourceChange() { + const newValue = this.getDataValue(); + this.set({ value: newValue }); + } + + getDataValue() { + const { path, defaultValue } = this.attributes; + const val = this.em?.DataSources?.getValue?.(path, defaultValue); + + return val; + } +} diff --git a/src/data_sources/model/DataVariableListenerManager.ts b/src/data_sources/model/DataVariableListenerManager.ts new file mode 100644 index 000000000..271648e61 --- /dev/null +++ b/src/data_sources/model/DataVariableListenerManager.ts @@ -0,0 +1,62 @@ +import { DataSourcesEvents, DataVariableListener } from '../../data_sources/types'; +import { stringToPath } from '../../utils/mixins'; +import { Model } from '../../common'; +import EditorModel from '../../editor/model/Editor'; +import DataVariable from './DataVariable'; +import ComponentView from '../../dom_components/view/ComponentView'; +import ComponentDataVariable from './ComponentDataVariable'; + +export interface DataVariableListenerManagerOptions { + model: Model | ComponentView; + em: EditorModel; + dataVariable: DataVariable | ComponentDataVariable; + updateValueFromDataVariable: (value: any) => void; +} + +export default class DataVariableListenerManager { + private dataListeners: DataVariableListener[] = []; + private em: EditorModel; + private model: Model | ComponentView; + private dataVariable: DataVariable | ComponentDataVariable; + private updateValueFromDataVariable: (value: any) => void; + + constructor(options: DataVariableListenerManagerOptions) { + this.em = options.em; + this.model = options.model; + this.dataVariable = options.dataVariable; + this.updateValueFromDataVariable = options.updateValueFromDataVariable; + + this.listenToDataVariable(); + } + + listenToDataVariable() { + const { em, dataVariable, model, updateValueFromDataVariable } = this; + const { path } = dataVariable.attributes; + const normPath = stringToPath(path || '').join('.'); + const prevListeners = this.dataListeners || []; + const [ds, dr] = this.em.DataSources.fromPath(path); + + prevListeners.forEach((ls) => model.stopListening(ls.obj, ls.event, updateValueFromDataVariable)); + + const dataListeners: DataVariableListener[] = []; + ds && dataListeners.push({ obj: ds.records, event: 'add remove reset' }); + dr && dataListeners.push({ obj: dr, event: 'change' }); + dataListeners.push({ obj: dataVariable, event: 'change:value' }); + dataListeners.push({ obj: em, event: `${DataSourcesEvents.path}:${normPath}` }); + dataListeners.push( + { obj: dataVariable, event: 'change:path change:value' }, + { obj: em.DataSources.all, event: 'add remove reset' }, + { obj: em, event: `${DataSourcesEvents.path}:${normPath}` }, + ); + + dataListeners.forEach((ls) => + model.listenTo(ls.obj, ls.event, () => { + const value = dataVariable.getDataValue(); + + updateValueFromDataVariable(value); + }), + ); + + this.dataListeners = dataListeners; + } +} diff --git a/src/data_sources/model/StyleDataVariable.ts b/src/data_sources/model/StyleDataVariable.ts new file mode 100644 index 000000000..bec65ba17 --- /dev/null +++ b/src/data_sources/model/StyleDataVariable.ts @@ -0,0 +1,9 @@ +import DataVariable from './DataVariable'; + +export default class StyleDataVariable extends DataVariable { + defaults() { + return { + ...super.defaults(), + }; + } +} diff --git a/src/data_sources/model/TraitDataVariable.ts b/src/data_sources/model/TraitDataVariable.ts new file mode 100644 index 000000000..b8aedc1f5 --- /dev/null +++ b/src/data_sources/model/TraitDataVariable.ts @@ -0,0 +1,16 @@ +import DataVariable from './DataVariable'; +import Trait from '../../trait_manager/model/Trait'; + +export default class TraitDataVariable extends DataVariable { + trait?: Trait; + + constructor(attrs: any, options: any) { + super(attrs, options); + this.trait = options.trait; + } + + onDataSourceChange() { + const newValue = this.getDataValue(); + this.trait?.setTargetValue(newValue); + } +} diff --git a/src/data_sources/types.ts b/src/data_sources/types.ts new file mode 100644 index 000000000..8b3c9dfed --- /dev/null +++ b/src/data_sources/types.ts @@ -0,0 +1,78 @@ +import { ObjectAny } from '../common'; +import DataRecord from './model/DataRecord'; +import DataRecords from './model/DataRecords'; + +export interface DataRecordProps extends ObjectAny { + /** + * Record id. + */ + id: string; +} + +export interface DataVariableListener { + obj: any; + event: string; +} + +export interface DataSourceProps { + /** + * DataSource id. + */ + id: string; + + /** + * DataSource records. + */ + records?: DataRecords | DataRecord[] | DataRecordProps[]; + + /** + * DataSource validation and transformation factories. + */ + + transformers?: DataSourceTransformers; +} + +export interface DataSourceTransformers { + onRecordSetValue?: (args: { id: string | number; key: string; value: any }) => any; +} + +/**{START_EVENTS}*/ +export enum DataSourcesEvents { + /** + * @event `data:add` Added new data source. + * @example + * editor.on('data:add', (dataSource) => { ... }); + */ + add = 'data:add', + addBefore = 'data:add:before', + + /** + * @event `data:remove` Data source removed. + * @example + * editor.on('data:remove', (dataSource) => { ... }); + */ + remove = 'data:remove', + removeBefore = 'data:remove:before', + + /** + * @event `data:update` Data source updated. + * @example + * editor.on('data:update', (dataSource, changes) => { ... }); + */ + update = 'data:update', + + /** + * @event `data:path` Data record path update. + * @example + * editor.on('data:path:SOURCE_ID:RECORD_ID:PROP_NAME', ({ dataSource, dataRecord, path }) => { ... }); + */ + path = 'data:path', + + /** + * @event `data` Catch-all event for all the events mentioned above. + * @example + * editor.on('data', ({ event, model, ... }) => { ... }); + */ + all = 'data', +} +/**{END_EVENTS}*/ diff --git a/src/data_sources/view/ComponentDataVariableView.ts b/src/data_sources/view/ComponentDataVariableView.ts new file mode 100644 index 000000000..385b6ed8f --- /dev/null +++ b/src/data_sources/view/ComponentDataVariableView.ts @@ -0,0 +1,24 @@ +import ComponentView from '../../dom_components/view/ComponentView'; +import ComponentDataVariable from '../model/ComponentDataVariable'; +import DataVariableListenerManager from '../model/DataVariableListenerManager'; + +export default class ComponentDataVariableView extends ComponentView { + dataVariableListener?: DataVariableListenerManager; + + initialize(opt = {}) { + super.initialize(opt); + this.dataVariableListener = new DataVariableListenerManager({ + model: this, + em: this.em!, + dataVariable: this.model, + updateValueFromDataVariable: () => this.postRender(), + }); + } + + postRender() { + const { model, el, em } = this; + const { path, defaultValue } = model.attributes; + el.innerHTML = em.DataSources.getValue(path, defaultValue); + super.postRender(); + } +} diff --git a/src/dom_components/index.ts b/src/dom_components/index.ts index f30e2476c..930166876 100644 --- a/src/dom_components/index.ts +++ b/src/dom_components/index.ts @@ -114,6 +114,9 @@ import { import { ComponentsEvents, SymbolInfo } from './types'; import Symbols from './model/Symbols'; import { BlockProperties } from '../block_manager/model/Block'; +import ComponentDataVariable from '../data_sources/model/ComponentDataVariable'; +import ComponentDataVariableView from '../data_sources/view/ComponentDataVariableView'; +import { DataVariableType } from '../data_sources/model/DataVariable'; export type ComponentEvent = | 'component:create' @@ -179,6 +182,11 @@ export interface CanMoveResult { export default class ComponentManager extends ItemManagerModule { componentTypes: ComponentStackItem[] = [ + { + id: DataVariableType, + model: ComponentDataVariable, + view: ComponentDataVariableView, + }, { id: 'cell', model: ComponentTableCell, diff --git a/src/dom_components/model/Component.ts b/src/dom_components/model/Component.ts index d450d34c9..071225cd9 100644 --- a/src/dom_components/model/Component.ts +++ b/src/dom_components/model/Component.ts @@ -51,6 +51,7 @@ import { updateSymbolComps, updateSymbolProps, } from './SymbolUtils'; +import TraitDataVariable from '../../data_sources/model/TraitDataVariable'; export interface IComponent extends ExtractMethods {} @@ -713,7 +714,7 @@ export default class Component extends StyleableModel { if (avoidInline(em) && !opt.temporary && !opts.inline) { const style = this.get('style') || {}; prop = isString(prop) ? this.parseStyle(prop) : prop; - prop = { ...prop, ...style }; + prop = { ...prop, ...(style as any) }; const state = em.get('state'); const cc = em.Css; const propOrig = this.getStyle(opts); @@ -759,6 +760,14 @@ export default class Component extends StyleableModel { } } + const attrDataVariable = this.get('attributes-data-variable'); + if (attrDataVariable) { + Object.entries(attrDataVariable).forEach(([key, value]) => { + const dataVariable = value instanceof TraitDataVariable ? value : new TraitDataVariable(value, { em }); + attributes[key] = dataVariable.getDataValue(); + }); + } + // Check if we need an ID on the component if (!has(attributes, 'id')) { let addId = false; @@ -897,15 +906,24 @@ export default class Component extends StyleableModel { this.off(event, this.initTraits); this.__loadTraits(); const attrs = { ...this.get('attributes') }; + const traitDataVariableAttr: ObjectAny = {}; const traits = this.traits; traits.each((trait) => { - if (!trait.changeProp) { - const name = trait.getName(); - const value = trait.getInitValue(); + const name = trait.getName(); + const value = trait.getInitValue(); + + if (trait.changeProp) { + this.set(name, value); + } else { if (name && value) attrs[name] = value; } + + if (trait.dataVariable) { + traitDataVariableAttr[name] = trait.dataVariable; + } }); traits.length && this.set('attributes', attrs); + Object.keys(traitDataVariableAttr).length && this.set('attributes-data-variable', traitDataVariableAttr); this.on(event, this.initTraits); changed && em && em.trigger('component:toggled'); return this; @@ -947,7 +965,7 @@ export default class Component extends StyleableModel { * // append at specific index (eg. at the beginning) * someComponent.append(otherComponent, { at: 0 }); */ - append(components: ComponentAdd, opts: AddOptions = {}): Component[] { + append(components: ComponentAdd, opts: AddOptions = {}): T[] { const compArr = isArray(components) ? [...components] : [components]; const toAppend = compArr.map((comp) => { if (isString(comp)) { @@ -962,7 +980,7 @@ export default class Component extends StyleableModel { action: ActionLabelComponents.add, ...opts, }); - return isArray(result) ? result : [result]; + return result as T[]; } /** diff --git a/src/dom_components/model/types.ts b/src/dom_components/model/types.ts index 4a603f125..8364d00d4 100644 --- a/src/dom_components/model/types.ts +++ b/src/dom_components/model/types.ts @@ -11,6 +11,7 @@ import Component from './Component'; import Components from './Components'; import { ToolbarButtonProps } from './ToolbarButton'; import { ParseNodeOptions } from '../../parser/config/config'; +import { DataVariableType } from '../../data_sources/model/DataVariable'; export type DragMode = 'translate' | 'absolute' | ''; @@ -183,7 +184,7 @@ export interface ComponentProperties { * Component default style, eg. `{ width: '100px', height: '100px', 'background-color': 'red' }` * @default {} */ - style?: any; + style?: string | Record; /** * Component related styles, eg. `.my-component-class { color: red }` * @default '' diff --git a/src/domain_abstract/model/StyleableModel.ts b/src/domain_abstract/model/StyleableModel.ts index a4b035200..05d4579c3 100644 --- a/src/domain_abstract/model/StyleableModel.ts +++ b/src/domain_abstract/model/StyleableModel.ts @@ -3,8 +3,21 @@ import { Model, ObjectAny, ObjectHash, SetOptions } from '../../common'; import ParserHtml from '../../parser/model/ParserHtml'; import Selectors from '../../selector_manager/model/Selectors'; import { shallowDiff } from '../../utils/mixins'; - -export type StyleProps = Record; +import EditorModel from '../../editor/model/Editor'; +import StyleDataVariable from '../../data_sources/model/StyleDataVariable'; +import { DataVariableType } from '../../data_sources/model/DataVariable'; +import DataVariableListenerManager from '../../data_sources/model/DataVariableListenerManager'; + +export type StyleProps = Record< + string, + | string + | string[] + | { + type: typeof DataVariableType; + defaultValue: string; + path: string; + } +>; export type UpdateStyleOptions = SetOptions & { partial?: boolean; @@ -20,8 +33,11 @@ export const getLastStyleValue = (value: string | string[]) => { }; export default class StyleableModel extends Model { + em?: EditorModel; + dataVariableListeners: Record = {}; + /** - * Forward style string to `parseStyle` to be parse to an object + * Parse style string to an object * @param {string} str * @returns */ @@ -30,8 +46,7 @@ export default class StyleableModel extends Model } /** - * To trigger the style change event on models I have to - * pass a new object instance + * Trigger style change event with a new object instance * @param {Object} prop * @return {Object} */ @@ -46,6 +61,11 @@ export default class StyleableModel extends Model getStyle(prop?: string | ObjectAny): StyleProps { const style = this.get('style') || {}; const result: ObjectAny = { ...style }; + if (this.em) { + const resolvedStyle = this.resolveDataVariables({ ...result }); + // @ts-ignore + return prop && isString(prop) ? resolvedStyle[prop] : resolvedStyle; + } return prop && isString(prop) ? result[prop] : result; } @@ -71,20 +91,34 @@ export default class StyleableModel extends Model const propNew = { ...prop }; const newStyle = { ...propNew }; - // Remove empty style properties - keys(newStyle).forEach((prop) => { - if (newStyle[prop] === '') { - delete newStyle[prop]; + + keys(newStyle).forEach((key) => { + // Remove empty style properties + if (newStyle[key] === '') { + delete newStyle[key]; + return; + } + + const styleValue = newStyle[key]; + if (typeof styleValue === 'object' && styleValue.type === DataVariableType) { + const styleDataVariable = new StyleDataVariable(styleValue, { em: this.em }); + newStyle[key] = styleDataVariable; + this.manageDataVariableListener(styleDataVariable, key); } }); + this.set('style', newStyle, opts as any); - const diff = shallowDiff(propOrig, propNew); + + const diff = shallowDiff(propOrig, newStyle); // Delete the property used for partial updates delete diff.__p; + keys(diff).forEach((pr) => { - // @ts-ignore const { em } = this; - if (opts.noEvent) return; + if (opts.noEvent) { + return; + } + this.trigger(`change:style:${pr}`); if (em) { em.trigger('styleable:change', this, pr, opts); @@ -92,7 +126,61 @@ export default class StyleableModel extends Model } }); - return propNew; + return newStyle; + } + + /** + * Manage DataVariableListenerManager for a style property + */ + manageDataVariableListener(dataVar: StyleDataVariable, styleProp: string) { + if (this.dataVariableListeners[styleProp]) { + this.dataVariableListeners[styleProp].listenToDataVariable(); + } else { + this.dataVariableListeners[styleProp] = new DataVariableListenerManager({ + model: this, + em: this.em!, + dataVariable: dataVar, + updateValueFromDataVariable: (newValue: string) => this.updateStyleProp(styleProp, newValue), + }); + } + } + + /** + * Update a specific style property + */ + updateStyleProp(prop: string, value: string) { + const style = this.getStyle(); + style[prop] = value; + this.setStyle(style, { noEvent: true }); + this.trigger(`change:style:${prop}`); + } + + /** + * Resolve data variables to their actual values + */ + resolveDataVariables(style: StyleProps): StyleProps { + const resolvedStyle = { ...style }; + keys(resolvedStyle).forEach((key) => { + const styleValue = resolvedStyle[key]; + + if (typeof styleValue === 'string' || Array.isArray(styleValue)) { + return; + } + + if ( + typeof styleValue === 'object' && + styleValue.type === DataVariableType && + !(styleValue instanceof StyleDataVariable) + ) { + const dataVar = new StyleDataVariable(styleValue, { em: this.em }); + resolvedStyle[key] = dataVar.getDataValue(); + } + + if (styleValue instanceof StyleDataVariable) { + resolvedStyle[key] = styleValue.getDataValue(); + } + }); + return resolvedStyle; } /** @@ -147,7 +235,7 @@ export default class StyleableModel extends Model const value = style[prop]; const values = isArray(value) ? (value as string[]) : [value]; - values.forEach((val: string) => { + (values as string[]).forEach((val: string) => { const value = `${val}${important ? ' !important' : ''}`; value && result.push(`${prop}:${value};`); }); @@ -164,9 +252,4 @@ export default class StyleableModel extends Model // @ts-ignore return this.selectorsToString ? this.selectorsToString(opts) : this.getSelectors().getFullString(); } - - // @ts-ignore - // _validate(attr, opts) { - // return true; - // } } diff --git a/src/editor/index.ts b/src/editor/index.ts index 03fb7ade0..0afa32535 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -65,6 +65,7 @@ import { AddOptions, EventHandler, LiteralUnion } from '../common'; import CssComposer from '../css_composer'; import CssRule from '../css_composer/model/CssRule'; import CssRules from '../css_composer/model/CssRules'; +import DataSourceManager from '../data_sources'; import DeviceManager from '../device_manager'; import ComponentManager, { ComponentEvent } from '../dom_components'; import Component from '../dom_components/model/Component'; @@ -241,6 +242,9 @@ export default class Editor implements IBaseModule { get DeviceManager(): DeviceManager { return this.em.Devices; } + get DataSources(): DataSourceManager { + return this.em.DataSources; + } get EditorModel() { return this.em; diff --git a/src/editor/model/Editor.ts b/src/editor/model/Editor.ts index 3a58617a1..aec546bbf 100644 --- a/src/editor/model/Editor.ts +++ b/src/editor/model/Editor.ts @@ -3,7 +3,7 @@ import Backbone from 'backbone'; import $ from '../../utils/cash-dom'; import Extender from '../../utils/extender'; import { hasWin, isEmptyObj, wait } from '../../utils/mixins'; -import { AddOptions, Model, ObjectAny } from '../../common'; +import { AddOptions, Model, Collection, ObjectAny } from '../../common'; import Selected from './Selected'; import FrameView from '../../canvas/view/FrameView'; import Editor from '..'; @@ -42,6 +42,7 @@ import CssRules from '../../css_composer/model/CssRules'; import { ComponentAdd, DragMode } from '../../dom_components/model/types'; import ComponentWrapper from '../../dom_components/model/ComponentWrapper'; import { CanvasSpotBuiltInTypes } from '../../canvas/model/CanvasSpot'; +import DataSourceManager from '../../data_sources'; Backbone.$ = $; @@ -64,6 +65,7 @@ const deps: (new (em: EditorModel) => IModule)[] = [ CanvasModule, CommandsModule, BlockManager, + DataSourceManager, ]; const storableDeps: (new (em: EditorModel) => IModule & IStorableModule)[] = [ AssetManager, @@ -104,6 +106,8 @@ export default class EditorModel extends Model { }; } + Model = Model; + Collection = Collection; __skip = false; defaultRunning = false; destroyed = false; @@ -226,6 +230,10 @@ export default class EditorModel extends Model { return this.get('StyleManager'); } + get DataSources(): DataSourceManager { + return this.get('DataSources'); + } + constructor(conf: EditorConfig = {}) { super(); this._config = conf; diff --git a/src/index.ts b/src/index.ts index c0980b75c..40c710e47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -140,5 +140,10 @@ export type { default as Sector } from './style_manager/model/Sector'; export type { default as Sectors } from './style_manager/model/Sectors'; export type { default as Trait } from './trait_manager/model/Trait'; export type { default as Traits } from './trait_manager/model/Traits'; +export type { default as DataSourceManager } from './data_sources'; +export type { default as DataSources } from './data_sources/model/DataSources'; +export type { default as DataSource } from './data_sources/model/DataSource'; +export type { default as DataRecord } from './data_sources/model/DataRecord'; +export type { default as DataRecords } from './data_sources/model/DataRecords'; export default grapesjs; diff --git a/src/style_manager/index.ts b/src/style_manager/index.ts index 7293b700c..331568607 100644 --- a/src/style_manager/index.ts +++ b/src/style_manager/index.ts @@ -395,6 +395,7 @@ export default class StyleManager extends ItemManagerModule< if (isString(target)) { const rule = cssc.getRule(target) || cssc.setRule(target); !isUndefined(stylable) && rule.set({ stylable }); + // @ts-ignore model = rule; } @@ -652,6 +653,7 @@ export default class StyleManager extends ItemManagerModule< .reverse(); // Slice removes rules not related to the current device + // @ts-ignore result = all.slice(all.indexOf(target as CssRule) + 1); } diff --git a/src/style_manager/model/PropertyComposite.ts b/src/style_manager/model/PropertyComposite.ts index 6a828c2a5..53699e7e9 100644 --- a/src/style_manager/model/PropertyComposite.ts +++ b/src/style_manager/model/PropertyComposite.ts @@ -5,6 +5,7 @@ import Properties from './Properties'; import Property, { OptionsStyle, OptionsUpdate, PropertyProps } from './Property'; import { PropertyNumberProps } from './PropertyNumber'; import { PropertySelectProps } from './PropertySelect'; +import { DataVariableType } from '../../data_sources/model/DataVariable'; export const isNumberType = (type: string) => type === 'integer' || type === 'number'; @@ -279,7 +280,7 @@ export default class PropertyComposite = PropertyC const result = this.getStyleFromProps()[this.getName()] || ''; - return getLastStyleValue(result); + return getLastStyleValue(result as string); } __getJoin() { @@ -303,7 +304,9 @@ export default class PropertyComposite = PropertyC } __splitStyleName(style: StyleProps, name: string, sep: string | RegExp) { - return this.__splitValue(style[name] || '', sep); + const value = style[name]; + + return this.__splitValue((value as string) || '', sep); } __getSplitValue(value: string | string[] = '', { byName }: OptionByName = {}) { @@ -343,7 +346,9 @@ export default class PropertyComposite = PropertyC if (!fromStyle) { // Get props from the main property - result = this.__getSplitValue(style[name] || '', { byName }); + const value = style[name]; + + result = this.__getSplitValue((value as string) || '', { byName }); // Get props from the inner properties props.forEach((prop) => { diff --git a/src/trait_manager/model/Trait.ts b/src/trait_manager/model/Trait.ts index f5dd1c278..c03779305 100644 --- a/src/trait_manager/model/Trait.ts +++ b/src/trait_manager/model/Trait.ts @@ -7,6 +7,10 @@ import { isDef } from '../../utils/mixins'; import TraitsEvents, { TraitGetValueOptions, TraitOption, TraitProperties, TraitSetValueOptions } from '../types'; import TraitView from '../view/TraitView'; import Traits from './Traits'; +import { DataVariableListener } from '../../data_sources/types'; +import TraitDataVariable from '../../data_sources/model/TraitDataVariable'; +import { DataVariableType } from '../../data_sources/model/DataVariable'; +import DataVariableListenerManager from '../../data_sources/model/DataVariableListenerManager'; /** * @property {String} id Trait id, eg. `my-trait-id`. @@ -26,6 +30,9 @@ export default class Trait extends Model { em: EditorModel; view?: TraitView; el?: HTMLElement; + dataListeners: DataVariableListener[] = []; + dataVariable?: TraitDataVariable; + dataVariableListener?: DataVariableListenerManager; defaults() { return { @@ -51,6 +58,23 @@ export default class Trait extends Model { this.setTarget(target); } this.em = em; + + if ( + this.attributes.value && + typeof this.attributes.value === 'object' && + this.attributes.value.type === DataVariableType + ) { + this.dataVariable = new TraitDataVariable(this.attributes.value, { em: this.em, trait: this }); + + const dv = this.dataVariable.getDataValue(); + this.set({ value: dv }); + this.dataVariableListener = new DataVariableListenerManager({ + model: this, + em: this.em, + dataVariable: this.dataVariable, + updateValueFromDataVariable: this.updateValueFromDataVariable.bind(this), + }); + } } get parent() { @@ -85,6 +109,11 @@ export default class Trait extends Model { } } + updateValueFromDataVariable(value: string) { + this.setTargetValue(value); + this.trigger('change:value'); + } + /** * Get the trait id. * @returns {String} @@ -130,6 +159,12 @@ export default class Trait extends Model { * @returns {any} */ getValue(opts?: TraitGetValueOptions) { + if (this.dataVariable) { + const dValue = this.dataVariable.getDataValue(); + + return dValue; + } + return this.getTargetValue(opts); } diff --git a/src/trait_manager/view/TraitView.ts b/src/trait_manager/view/TraitView.ts index d608dfcec..7490d8217 100644 --- a/src/trait_manager/view/TraitView.ts +++ b/src/trait_manager/view/TraitView.ts @@ -123,7 +123,7 @@ export default class TraitView extends View { this.postUpdate(); } else { const val = this.getValueForTarget(); - model.setTargetValue(val, opts); + model?.setTargetValue(val, opts); } } diff --git a/src/utils/mixins.ts b/src/utils/mixins.ts index 55fdc629a..f1fa6775f 100644 --- a/src/utils/mixins.ts +++ b/src/utils/mixins.ts @@ -7,6 +7,35 @@ import { ObjectAny } from '../common'; const obj: ObjectAny = {}; +const reEscapeChar = /\\(\\)?/g; +const rePropName = /[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g; + +export const stringToPath = function (string: string) { + const result = []; + if (string.charCodeAt(0) === 46 /* . */) result.push(''); + string.replace(rePropName, (match: string, number, quote, subString) => { + result.push(quote ? subString.replace(reEscapeChar, '$1') : number || match); + return ''; + }); + return result; +}; + +function castPath(value: string | string[], object: ObjectAny) { + if (isArray(value)) return value; + return object.hasOwnProperty(value) ? [value] : stringToPath(value); +} + +export const get = (object: ObjectAny, path: string | string[], def: any) => { + const paths = castPath(path, object); + const length = paths.length; + let index = 0; + + while (object != null && index < length) { + object = object[`${paths[index++]}`]; + } + return (index && index == length ? object : undefined) ?? def; +}; + export const isBultInMethod = (key: string) => isFunction(obj[key]); export const normalizeKey = (key: string) => (isBultInMethod(key) ? `_${key}` : key); diff --git a/test/specs/data_sources/__snapshots__/serialization.ts.snap b/test/specs/data_sources/__snapshots__/serialization.ts.snap new file mode 100644 index 000000000..97242902f --- /dev/null +++ b/test/specs/data_sources/__snapshots__/serialization.ts.snap @@ -0,0 +1,164 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataSource Serialization .getProjectData ComponentDataVariable 1`] = ` +{ + "assets": [], + "pages": [ + { + "frames": [ + { + "component": { + "components": [ + { + "components": [ + { + "defaultValue": "default", + "path": "component-serialization.id1.content", + "type": "data-variable", + }, + ], + "tagName": "h1", + "type": "text", + }, + ], + "docEl": { + "tagName": "html", + }, + "head": { + "type": "head", + }, + "stylable": [ + "background", + "background-color", + "background-image", + "background-repeat", + "background-attachment", + "background-position", + "background-size", + ], + "type": "wrapper", + }, + "id": "data-variable-id", + }, + ], + "id": "data-variable-id", + "type": "main", + }, + ], + "styles": [], + "symbols": [], +} +`; + +exports[`DataSource Serialization .getProjectData StyleDataVariable 1`] = ` +{ + "assets": [], + "pages": [ + { + "frames": [ + { + "component": { + "components": [ + { + "attributes": { + "id": "data-variable-id", + }, + "content": "Hello World", + "tagName": "h1", + "type": "text", + }, + ], + "docEl": { + "tagName": "html", + }, + "head": { + "type": "head", + }, + "stylable": [ + "background", + "background-color", + "background-image", + "background-repeat", + "background-attachment", + "background-position", + "background-size", + ], + "type": "wrapper", + }, + "id": "data-variable-id", + }, + ], + "id": "data-variable-id", + "type": "main", + }, + ], + "styles": [ + { + "selectors": [ + "data-variable-id", + ], + "style": { + "color": { + "defaultValue": "black", + "path": "colors-data.id1.color", + "type": "data-variable", + }, + }, + }, + ], + "symbols": [], +} +`; + +exports[`DataSource Serialization .getProjectData TraitDataVariable 1`] = ` +{ + "assets": [], + "pages": [ + { + "frames": [ + { + "component": { + "components": [ + { + "attributes": { + "value": "test-value", + }, + "attributes-data-variable": { + "value": { + "defaultValue": "default", + "path": "test-input.id1.value", + "type": "data-variable", + }, + }, + "tagName": "input", + "void": true, + }, + ], + "docEl": { + "tagName": "html", + }, + "head": { + "type": "head", + }, + "stylable": [ + "background", + "background-color", + "background-image", + "background-repeat", + "background-attachment", + "background-position", + "background-size", + ], + "type": "wrapper", + }, + "id": "data-variable-id", + }, + ], + "id": "data-variable-id", + "type": "main", + }, + ], + "styles": [], + "symbols": [], +} +`; diff --git a/test/specs/data_sources/index.ts b/test/specs/data_sources/index.ts new file mode 100644 index 000000000..e8c0f5d83 --- /dev/null +++ b/test/specs/data_sources/index.ts @@ -0,0 +1,73 @@ +import Editor from '../../../src/editor/model/Editor'; +import DataSourceManager from '../../../src/data_sources'; +import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper'; +import { DataSourceProps } from '../../../src/data_sources/types'; + +describe('DataSourceManager', () => { + let em: Editor; + let dsm: DataSourceManager; + let fixtures: HTMLElement; + let cmpRoot: ComponentWrapper; + const dsTest: DataSourceProps = { + id: 'ds1', + records: [ + { id: 'id1', name: 'Name1' }, + { id: 'id2', name: 'Name2' }, + { id: 'id3', name: 'Name3' }, + ], + }; + + const addDataSource = () => dsm.add(dsTest); + + beforeEach(() => { + em = new Editor({ + mediaCondition: 'max-width', + avoidInlineStyle: true, + }); + dsm = em.DataSources; + document.body.innerHTML = '
'; + const { Pages, Components } = em; + Pages.onLoad(); + cmpRoot = Components.getWrapper()!; + const View = Components.getType('wrapper')!.view; + const wrapperEl = new View({ + model: cmpRoot, + config: { ...cmpRoot.config, em }, + }); + wrapperEl.render(); + fixtures = document.body.querySelector('#fixtures')!; + fixtures.appendChild(wrapperEl.el); + }); + + afterEach(() => { + em.destroy(); + }); + + test('DataSourceManager exists', () => { + expect(dsm).toBeTruthy(); + }); + + test('add DataSource with records', () => { + const eventAdd = jest.fn(); + em.on(dsm.events.add, eventAdd); + const ds = addDataSource(); + expect(dsm.getAll().length).toBe(1); + expect(eventAdd).toBeCalledTimes(1); + expect(ds.getRecords().length).toBe(3); + }); + + test('get added DataSource', () => { + const ds = addDataSource(); + expect(dsm.get(dsTest.id)).toBe(ds); + }); + + test('remove DataSource', () => { + const event = jest.fn(); + em.on(dsm.events.remove, event); + const ds = addDataSource(); + dsm.remove('ds1'); + expect(dsm.getAll().length).toBe(0); + expect(event).toBeCalledTimes(1); + expect(event).toBeCalledWith(ds, expect.any(Object)); + }); +}); diff --git a/test/specs/data_sources/model/ComponentDataVariable.ts b/test/specs/data_sources/model/ComponentDataVariable.ts new file mode 100644 index 000000000..b6e1a0b1b --- /dev/null +++ b/test/specs/data_sources/model/ComponentDataVariable.ts @@ -0,0 +1,298 @@ +import Editor from '../../../../src/editor/model/Editor'; +import DataSourceManager from '../../../../src/data_sources'; +import ComponentWrapper from '../../../../src/dom_components/model/ComponentWrapper'; +import { DataVariableType } from '../../../../src/data_sources/model/DataVariable'; +import { DataSourceProps } from '../../../../src/data_sources/types'; + +describe('ComponentDataVariable', () => { + let em: Editor; + let dsm: DataSourceManager; + let fixtures: HTMLElement; + let cmpRoot: ComponentWrapper; + + beforeEach(() => { + em = new Editor({ + mediaCondition: 'max-width', + avoidInlineStyle: true, + }); + dsm = em.DataSources; + document.body.innerHTML = '
'; + const { Pages, Components } = em; + Pages.onLoad(); + cmpRoot = Components.getWrapper()!; + const View = Components.getType('wrapper')!.view; + const wrapperEl = new View({ + model: cmpRoot, + config: { ...cmpRoot.config, em }, + }); + wrapperEl.render(); + fixtures = document.body.querySelector('#fixtures')!; + fixtures.appendChild(wrapperEl.el); + }); + + afterEach(() => { + em.destroy(); + }); + + test('component initializes with data-variable content', () => { + const dataSource: DataSourceProps = { + id: 'ds1', + records: [{ id: 'id1', name: 'Name1' }], + }; + dsm.add(dataSource); + + const cmp = cmpRoot.append({ + tagName: 'div', + type: 'default', + components: [ + { + type: DataVariableType, + defaultValue: 'default', + path: 'ds1.id1.name', + }, + ], + })[0]; + + expect(cmp.getEl()?.innerHTML).toContain('Name1'); + }); + + test('component updates on data-variable change', () => { + const dataSource: DataSourceProps = { + id: 'ds2', + records: [{ id: 'id1', name: 'Name1' }], + }; + dsm.add(dataSource); + + const cmp = cmpRoot.append({ + tagName: 'div', + type: 'default', + components: [ + { + type: DataVariableType, + defaultValue: 'default', + path: 'ds2.id1.name', + }, + ], + })[0]; + + expect(cmp.getEl()?.innerHTML).toContain('Name1'); + + const ds = dsm.get('ds2'); + ds.getRecord('id1')?.set({ name: 'Name1-UP' }); + + expect(cmp.getEl()?.innerHTML).toContain('Name1-UP'); + }); + + test("component uses default value if data source doesn't exist", () => { + const cmp = cmpRoot.append({ + tagName: 'div', + type: 'default', + components: [ + { + type: DataVariableType, + defaultValue: 'default', + path: 'unknown.id1.name', + }, + ], + })[0]; + + expect(cmp.getEl()?.innerHTML).toContain('default'); + }); + + test('component updates on data source reset', () => { + const dataSource: DataSourceProps = { + id: 'ds3', + records: [{ id: 'id1', name: 'Name1' }], + }; + dsm.add(dataSource); + + const cmp = cmpRoot.append({ + tagName: 'div', + type: 'default', + components: [ + { + type: DataVariableType, + defaultValue: 'default', + path: 'ds3.id1.name', + }, + ], + })[0]; + + expect(cmp.getEl()?.innerHTML).toContain('Name1'); + + dsm.all.reset(); + expect(cmp.getEl()?.innerHTML).toContain('default'); + }); + + test('component updates on data source setRecords', () => { + const dataSource: DataSourceProps = { + id: 'component-setRecords', + records: [{ id: 'id1', name: 'init name' }], + }; + dsm.add(dataSource); + + const cmp = cmpRoot.append({ + tagName: 'div', + type: 'default', + components: [ + { + type: DataVariableType, + defaultValue: 'default', + path: `${dataSource.id}.id1.name`, + }, + ], + })[0]; + + expect(cmp.getEl()?.innerHTML).toContain('init name'); + + const ds = dsm.get(dataSource.id); + ds.setRecords([{ id: 'id1', name: 'updated name' }]); + + expect(cmp.getEl()?.innerHTML).toContain('updated name'); + }); + + test('component updates on record removal', () => { + const dataSource: DataSourceProps = { + id: 'ds4', + records: [{ id: 'id1', name: 'Name1' }], + }; + dsm.add(dataSource); + + const cmp = cmpRoot.append({ + tagName: 'div', + type: 'default', + components: [ + { + type: DataVariableType, + defaultValue: 'default', + path: 'ds4.id1.name', + }, + ], + })[0]; + + expect(cmp.getEl()?.innerHTML).toContain('Name1'); + + const ds = dsm.get('ds4'); + ds.removeRecord('id1'); + + expect(cmp.getEl()?.innerHTML).toContain('default'); + }); + + test('component initializes and updates with data-variable for nested object', () => { + const dataSource: DataSourceProps = { + id: 'dsNestedObject', + records: [ + { + id: 'id1', + nestedObject: { + name: 'NestedName1', + }, + }, + ], + }; + dsm.add(dataSource); + + const cmp = cmpRoot.append({ + tagName: 'div', + type: 'default', + components: [ + { + type: DataVariableType, + defaultValue: 'default', + path: 'dsNestedObject.id1.nestedObject.name', + }, + ], + })[0]; + + expect(cmp.getEl()?.innerHTML).toContain('NestedName1'); + + const ds = dsm.get('dsNestedObject'); + ds.getRecord('id1')?.set({ nestedObject: { name: 'NestedName1-UP' } }); + + expect(cmp.getEl()?.innerHTML).toContain('NestedName1-UP'); + }); + + test('component initializes and updates with data-variable for nested object inside an array', () => { + const dataSource: DataSourceProps = { + id: 'dsNestedArray', + records: [ + { + id: 'id1', + items: [ + { + id: 'item1', + nestedObject: { + name: 'NestedItemName1', + }, + }, + ], + }, + ], + }; + dsm.add(dataSource); + + const cmp = cmpRoot.append({ + tagName: 'div', + type: 'default', + components: [ + { + type: DataVariableType, + defaultValue: 'default', + path: 'dsNestedArray.id1.items.0.nestedObject.name', + }, + ], + })[0]; + + expect(cmp.getEl()?.innerHTML).toContain('NestedItemName1'); + + const ds = dsm.get('dsNestedArray'); + ds.getRecord('id1')?.set({ + items: [ + { + id: 'item1', + nestedObject: { name: 'NestedItemName1-UP' }, + }, + ], + }); + + expect(cmp.getEl()?.innerHTML).toContain('NestedItemName1-UP'); + }); + + test('component initalizes and updates data on datarecord set object', () => { + const dataSource: DataSourceProps = { + id: 'setObject', + records: [{ id: 'id1', content: 'Hello World', color: 'red' }], + }; + dsm.add(dataSource); + + const cmp = cmpRoot.append({ + tagName: 'h1', + type: 'text', + components: [ + { + type: DataVariableType, + defaultValue: 'default', + path: `${dataSource.id}.id1.content`, + }, + ], + style: { + color: { + type: DataVariableType, + defaultValue: 'black', + path: `${dataSource.id}.id1.color`, + }, + }, + })[0]; + + const style = cmp.getStyle(); + expect(style).toHaveProperty('color', 'red'); + expect(cmp.getEl()?.innerHTML).toContain('Hello World'); + + const ds = dsm.get('setObject'); + ds.getRecord('id1')?.set({ content: 'Hello World UP', color: 'blue' }); + + const updatedStyle = cmp.getStyle(); + expect(updatedStyle).toHaveProperty('color', 'blue'); + expect(cmp.getEl()?.innerHTML).toContain('Hello World UP'); + }); +}); diff --git a/test/specs/data_sources/model/StyleDataVariable.ts b/test/specs/data_sources/model/StyleDataVariable.ts new file mode 100644 index 000000000..c9dd11177 --- /dev/null +++ b/test/specs/data_sources/model/StyleDataVariable.ts @@ -0,0 +1,175 @@ +import Editor from '../../../../src/editor/model/Editor'; +import DataSourceManager from '../../../../src/data_sources'; +import ComponentWrapper from '../../../../src/dom_components/model/ComponentWrapper'; +import { DataVariableType } from '../../../../src/data_sources/model/DataVariable'; +import { DataSourceProps } from '../../../../src/data_sources/types'; + +describe('StyleDataVariable', () => { + let em: Editor; + let dsm: DataSourceManager; + let fixtures: HTMLElement; + let cmpRoot: ComponentWrapper; + + beforeEach(() => { + em = new Editor({ + mediaCondition: 'max-width', + avoidInlineStyle: true, + }); + dsm = em.DataSources; + document.body.innerHTML = '
'; + const { Pages, Components } = em; + Pages.onLoad(); + cmpRoot = Components.getWrapper()!; + const View = Components.getType('wrapper')!.view; + const wrapperEl = new View({ + model: cmpRoot, + config: { ...cmpRoot.config, em }, + }); + wrapperEl.render(); + fixtures = document.body.querySelector('#fixtures')!; + fixtures.appendChild(wrapperEl.el); + }); + + afterEach(() => { + em.destroy(); + }); + + test('component initializes with data-variable style', () => { + const styleDataSource: DataSourceProps = { + id: 'colors-data', + records: [{ id: 'id1', color: 'red' }], + }; + dsm.add(styleDataSource); + + const cmp = cmpRoot.append({ + tagName: 'h1', + type: 'text', + content: 'Hello World', + style: { + color: { + type: DataVariableType, + defaultValue: 'black', + path: 'colors-data.id1.color', + }, + }, + })[0]; + + const style = cmp.getStyle(); + expect(style).toHaveProperty('color', 'red'); + }); + + test('component updates on style change', () => { + const styleDataSource: DataSourceProps = { + id: 'colors-data', + records: [{ id: 'id1', color: 'red' }], + }; + dsm.add(styleDataSource); + + const cmp = cmpRoot.append({ + tagName: 'h1', + type: 'text', + content: 'Hello World', + style: { + color: { + type: DataVariableType, + defaultValue: 'black', + path: 'colors-data.id1.color', + }, + }, + })[0]; + + const style = cmp.getStyle(); + expect(style).toHaveProperty('color', 'red'); + + const colorsDatasource = dsm.get('colors-data'); + colorsDatasource.getRecord('id1')?.set({ color: 'blue' }); + + const updatedStyle = cmp.getStyle(); + expect(updatedStyle).toHaveProperty('color', 'blue'); + }); + + test('component updates to defaultValue on record removal', () => { + const styleDataSource: DataSourceProps = { + id: 'colors-data-removal', + records: [{ id: 'id1', color: 'red' }], + }; + dsm.add(styleDataSource); + + const cmp = cmpRoot.append({ + tagName: 'h1', + type: 'text', + content: 'Hello World', + style: { + color: { + type: DataVariableType, + defaultValue: 'black', + path: `${styleDataSource.id}.id1.color`, + }, + }, + })[0]; + + const style = cmp.getStyle(); + expect(style).toHaveProperty('color', 'red'); + + const colorsDatasource = dsm.get(styleDataSource.id); + colorsDatasource.removeRecord('id1'); + + const updatedStyle = cmp.getStyle(); + expect(updatedStyle).toHaveProperty('color', 'black'); + }); + + test("should use default value if data source doesn't exist", () => { + const cmp = cmpRoot.append({ + tagName: 'h1', + type: 'text', + content: 'Hello World', + style: { + color: { + type: DataVariableType, + defaultValue: 'black', + path: 'unknown.id1.color', + }, + }, + })[0]; + + const style = cmp.getStyle(); + expect(style).toHaveProperty('color', 'black'); + }); + + test('component initializes and updates with data-variable style for nested object', () => { + const styleDataSource: DataSourceProps = { + id: 'style-data', + records: [ + { + id: 'id1', + nestedObject: { + color: 'red', + }, + }, + ], + }; + dsm.add(styleDataSource); + + const cmp = cmpRoot.append({ + tagName: 'h1', + type: 'text', + content: 'Hello World', + style: { + color: { + type: DataVariableType, + defaultValue: 'black', + path: 'style-data.id1.nestedObject.color', + }, + }, + })[0]; + + const style = cmp.getStyle(); + expect(style).toHaveProperty('color', 'red'); + + const ds = dsm.get('style-data'); + ds.getRecord('id1')?.set({ nestedObject: { color: 'blue' } }); + + const updatedStyle = cmp.getStyle(); + expect(updatedStyle).toHaveProperty('color', 'blue'); + }); +}); diff --git a/test/specs/data_sources/model/TraitDataVariable.ts b/test/specs/data_sources/model/TraitDataVariable.ts new file mode 100644 index 000000000..d660aada0 --- /dev/null +++ b/test/specs/data_sources/model/TraitDataVariable.ts @@ -0,0 +1,361 @@ +import Editor from '../../../../src/editor/model/Editor'; +import DataSourceManager from '../../../../src/data_sources'; +import ComponentWrapper from '../../../../src/dom_components/model/ComponentWrapper'; +import { DataVariableType } from '../../../../src/data_sources/model/DataVariable'; +import { DataSourceProps } from '../../../../src/data_sources/types'; + +describe('TraitDataVariable', () => { + let em: Editor; + let dsm: DataSourceManager; + let fixtures: HTMLElement; + let cmpRoot: ComponentWrapper; + + beforeEach(() => { + em = new Editor({ + mediaCondition: 'max-width', + avoidInlineStyle: true, + }); + dsm = em.DataSources; + document.body.innerHTML = '
'; + const { Pages, Components } = em; + Pages.onLoad(); + cmpRoot = Components.getWrapper()!; + const View = Components.getType('wrapper')!.view; + const wrapperEl = new View({ + model: cmpRoot, + config: { ...cmpRoot.config, em }, + }); + wrapperEl.render(); + fixtures = document.body.querySelector('#fixtures')!; + fixtures.appendChild(wrapperEl.el); + }); + + afterEach(() => { + em.destroy(); + }); + + describe('text input component', () => { + test('component initializes data-variable value', () => { + const inputDataSource: DataSourceProps = { + id: 'test-input', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(inputDataSource); + + const cmp = cmpRoot.append({ + tagName: 'input', + traits: [ + 'name', + { + type: 'text', + label: 'Value', + name: 'value', + value: { + type: DataVariableType, + defaultValue: 'default', + path: `${inputDataSource.id}.id1.value`, + }, + }, + ], + })[0]; + + const input = cmp.getEl(); + expect(input?.getAttribute('value')).toBe('test-value'); + expect(cmp?.getAttributes().value).toBe('test-value'); + }); + + test('component initializes data-variable placeholder', () => { + const inputDataSource: DataSourceProps = { + id: 'test-input', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(inputDataSource); + + const cmp = cmpRoot.append({ + tagName: 'input', + traits: [ + 'name', + { + type: 'text', + label: 'Placeholder', + name: 'placeholder', + value: { + type: DataVariableType, + defaultValue: 'default', + path: `${inputDataSource.id}.id1.value`, + }, + }, + ], + })[0]; + + const input = cmp.getEl(); + expect(input?.getAttribute('placeholder')).toBe('test-value'); + expect(cmp?.getAttributes().placeholder).toBe('test-value'); + + const testDs = dsm.get(inputDataSource.id); + testDs.getRecord('id1')?.set({ value: 'new-value' }); + + expect(input?.getAttribute('placeholder')).toBe('new-value'); + expect(cmp?.getAttributes().placeholder).toBe('new-value'); + }); + + test('component updates to defaultValue on record removal', () => { + const inputDataSource: DataSourceProps = { + id: 'test-input-removal', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(inputDataSource); + + const cmp = cmpRoot.append({ + tagName: 'input', + traits: [ + 'name', + { + type: 'text', + label: 'Value', + name: 'value', + value: { + type: DataVariableType, + defaultValue: 'default', + path: `${inputDataSource.id}.id1.value`, + }, + }, + ], + })[0]; + + const input = cmp.getEl(); + expect(input?.getAttribute('value')).toBe('test-value'); + expect(cmp?.getAttributes().value).toBe('test-value'); + + const testDs = dsm.get(inputDataSource.id); + testDs.removeRecord('id1'); + + expect(input?.getAttribute('value')).toBe('default'); + expect(cmp?.getAttributes().value).toBe('default'); + }); + + test('component updates with data-variable value', () => { + const inputDataSource: DataSourceProps = { + id: 'test-input', + records: [{ id: 'id1', value: 'test-value' }], + }; + dsm.add(inputDataSource); + + const cmp = cmpRoot.append({ + tagName: 'input', + traits: [ + 'name', + 'type', + { + type: 'text', + label: 'Value', + name: 'value', + value: { + type: DataVariableType, + defaultValue: 'default', + path: `${inputDataSource.id}.id1.value`, + }, + }, + ], + })[0]; + + const input = cmp.getEl(); + expect(input?.getAttribute('value')).toBe('test-value'); + expect(cmp?.getAttributes().value).toBe('test-value'); + + const testDs = dsm.get(inputDataSource.id); + testDs.getRecord('id1')?.set({ value: 'new-value' }); + + expect(input?.getAttribute('value')).toBe('new-value'); + expect(cmp?.getAttributes().value).toBe('new-value'); + }); + + test('component initializes data-variable value for nested object', () => { + const inputDataSource: DataSourceProps = { + id: 'nested-input-data', + records: [ + { + id: 'id1', + nestedObject: { + value: 'nested-value', + }, + }, + ], + }; + dsm.add(inputDataSource); + + const cmp = cmpRoot.append({ + tagName: 'input', + traits: [ + 'name', + { + type: 'text', + label: 'Value', + name: 'value', + value: { + type: DataVariableType, + defaultValue: 'default', + path: 'nested-input-data.id1.nestedObject.value', + }, + }, + ], + })[0]; + + const input = cmp.getEl(); + expect(input?.getAttribute('value')).toBe('nested-value'); + expect(cmp?.getAttributes().value).toBe('nested-value'); + }); + }); + + describe('checkbox input component', () => { + test('component initializes and updates data-variable value', () => { + const inputDataSource: DataSourceProps = { + id: 'test-checkbox-datasource', + records: [{ id: 'id1', value: 'true' }], + }; + dsm.add(inputDataSource); + + const cmp = cmpRoot.append({ + type: 'checkbox', + tagName: 'input', + attributes: { type: 'checkbox', name: 'my-checkbox' }, + traits: [ + { + type: 'checkbox', + label: 'Checked', + name: 'checked', + value: { + type: 'data-variable', + defaultValue: 'false', + path: `${inputDataSource.id}.id1.value`, + }, + valueTrue: 'true', + valueFalse: 'false', + }, + ], + })[0]; + + const input = cmp.getEl() as HTMLInputElement; + expect(input?.checked).toBe(true); + expect(input?.getAttribute('checked')).toBe('true'); + + const testDs = dsm.get(inputDataSource.id); + testDs.getRecord('id1')?.set({ value: 'false' }); + + expect(input?.getAttribute('checked')).toBe('false'); + // Not syncing - related to + // https://github.com/GrapesJS/grapesjs/discussions/5868 + // https://github.com/GrapesJS/grapesjs/discussions/4415 + // https://github.com/GrapesJS/grapesjs/pull/6095 + // expect(input?.checked).toBe(false); + }); + }); + + describe('image component', () => { + test('component initializes and updates data-variable value', () => { + const inputDataSource: DataSourceProps = { + id: 'test-image-datasource', + records: [{ id: 'id1', value: 'url-to-cat-image' }], + }; + dsm.add(inputDataSource); + + const cmp = cmpRoot.append({ + type: 'image', + tagName: 'img', + traits: [ + { + type: 'text', + name: 'src', + value: { + type: 'data-variable', + defaultValue: 'default', + path: `${inputDataSource.id}.id1.value`, + }, + }, + ], + })[0]; + + const img = cmp.getEl() as HTMLImageElement; + expect(img?.getAttribute('src')).toBe('url-to-cat-image'); + expect(cmp?.getAttributes().src).toBe('url-to-cat-image'); + + const testDs = dsm.get(inputDataSource.id); + testDs.getRecord('id1')?.set({ value: 'url-to-dog-image' }); + + expect(img?.getAttribute('src')).toBe('url-to-dog-image'); + expect(cmp?.getAttributes().src).toBe('url-to-dog-image'); + }); + }); + + describe('link component', () => { + test('component initializes and updates data-variable value', () => { + const inputDataSource: DataSourceProps = { + id: 'test-link-datasource', + records: [{ id: 'id1', value: 'url-to-cat-image' }], + }; + dsm.add(inputDataSource); + + const cmp = cmpRoot.append({ + type: 'link', + tagName: 'a', + traits: [ + { + type: 'text', + name: 'href', + value: { + type: 'data-variable', + defaultValue: 'default', + path: `${inputDataSource.id}.id1.value`, + }, + }, + ], + components: [{ tagName: 'span', content: 'Link' }], + })[0]; + + const link = cmp.getEl() as HTMLLinkElement; + expect(link?.href).toBe('http://localhost/url-to-cat-image'); + + const testDs = dsm.get(inputDataSource.id); + testDs.getRecord('id1')?.set({ value: 'url-to-dog-image' }); + + expect(link?.href).toBe('http://localhost/url-to-dog-image'); + expect(cmp?.getAttributes().href).toBe('url-to-dog-image'); + }); + }); + + describe('changeProp', () => { + test('component initializes and updates data-variable value using changeProp', () => { + const inputDataSource: DataSourceProps = { + id: 'test-change-prop-datasource', + records: [{ id: 'id1', value: 'I love grapes' }], + }; + dsm.add(inputDataSource); + + const cmp = cmpRoot.append({ + tagName: 'div', + type: 'default', + traits: [ + { + name: 'test-change-prop', + type: 'text', + changeProp: true, + value: { + type: DataVariableType, + defaultValue: 'default', + path: `${inputDataSource.id}.id1.value`, + }, + }, + ], + })[0]; + + let property = cmp.get('test-change-prop'); + expect(property).toBe('I love grapes'); + + const testDs = dsm.get(inputDataSource.id); + testDs.getRecord('id1')?.set({ value: 'I really love grapes' }); + + property = cmp.get('test-change-prop'); + expect(property).toBe('I really love grapes'); + }); + }); +}); diff --git a/test/specs/data_sources/serialization.ts b/test/specs/data_sources/serialization.ts new file mode 100644 index 000000000..6c60179a5 --- /dev/null +++ b/test/specs/data_sources/serialization.ts @@ -0,0 +1,393 @@ +import Editor from '../../../src/editor'; +import DataSourceManager from '../../../src/data_sources'; +import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper'; +import { DataVariableType } from '../../../src/data_sources/model/DataVariable'; +import EditorModel from '../../../src/editor/model/Editor'; +import { ProjectData } from '../../../src/storage_manager'; +import { DataSourceProps } from '../../../src/data_sources/types'; + +// Filter out the unique ids and selectors replaced with 'data-variable-id' +// Makes the snapshot more stable +function filterObjectForSnapshot(obj: any, parentKey: string = ''): any { + const result: any = {}; + + for (const key in obj) { + if (key === 'id') { + result[key] = 'data-variable-id'; + continue; + } + + if (key === 'selectors') { + result[key] = obj[key].map(() => 'data-variable-id'); + continue; + } + + if (typeof obj[key] === 'object' && obj[key] !== null) { + if (Array.isArray(obj[key])) { + result[key] = obj[key].map((item: any) => + typeof item === 'object' ? filterObjectForSnapshot(item, key) : item, + ); + } else { + result[key] = filterObjectForSnapshot(obj[key], key); + } + } else { + result[key] = obj[key]; + } + } + + return result; +} + +describe('DataSource Serialization', () => { + let editor: Editor; + let em: EditorModel; + let dsm: DataSourceManager; + let fixtures: HTMLElement; + let cmpRoot: ComponentWrapper; + const componentDataSource: DataSourceProps = { + id: 'component-serialization', + records: [ + { id: 'id1', content: 'Hello World' }, + { id: 'id2', color: 'red' }, + ], + }; + const styleDataSource: DataSourceProps = { + id: 'colors-data', + records: [{ id: 'id1', color: 'red' }], + }; + const traitDataSource: DataSourceProps = { + id: 'test-input', + records: [{ id: 'id1', value: 'test-value' }], + }; + + beforeEach(() => { + editor = new Editor({ + mediaCondition: 'max-width', + avoidInlineStyle: true, + }); + em = editor.getModel(); + dsm = em.DataSources; + document.body.innerHTML = '
'; + const { Pages, Components } = em; + Pages.onLoad(); + cmpRoot = Components.getWrapper()!; + const View = Components.getType('wrapper')!.view; + const wrapperEl = new View({ + model: cmpRoot, + config: { ...cmpRoot.config, em }, + }); + wrapperEl.render(); + fixtures = document.body.querySelector('#fixtures')!; + fixtures.appendChild(wrapperEl.el); + + dsm.add(componentDataSource); + dsm.add(styleDataSource); + dsm.add(traitDataSource); + }); + + afterEach(() => { + em.destroy(); + }); + + test('component .getHtml', () => { + const cmp = cmpRoot.append({ + tagName: 'h1', + type: 'text', + components: [ + { + type: DataVariableType, + defaultValue: 'default', + path: `${componentDataSource.id}.id1.content`, + }, + ], + })[0]; + + const el = cmp.getEl(); + expect(el?.innerHTML).toContain('Hello World'); + + const html = em.getHtml(); + expect(html).toMatchInlineSnapshot('"

Hello World

"'); + }); + + describe('.getProjectData', () => { + test('ComponentDataVariable', () => { + const dataVariable = { + type: DataVariableType, + defaultValue: 'default', + path: `${componentDataSource.id}.id1.content`, + }; + + cmpRoot.append({ + tagName: 'h1', + type: 'text', + components: [dataVariable], + })[0]; + + const projectData = editor.getProjectData(); + const page = projectData.pages[0]; + const frame = page.frames[0]; + const component = frame.component.components[0]; + expect(component.components[0]).toEqual(dataVariable); + + const snapshot = filterObjectForSnapshot(projectData); + expect(snapshot).toMatchSnapshot(``); + }); + + test('StyleDataVariable', () => { + const dataVariable = { + type: DataVariableType, + defaultValue: 'black', + path: 'colors-data.id1.color', + }; + + cmpRoot.append({ + tagName: 'h1', + type: 'text', + content: 'Hello World', + style: { + color: dataVariable, + }, + })[0]; + + const projectData = editor.getProjectData(); + const page = projectData.pages[0]; + const frame = page.frames[0]; + const component = frame.component.components[0]; + const componentId = component.attributes.id; + expect(componentId).toBeDefined(); + + const styleSelector = projectData.styles.find((style: any) => style.selectors[0] === `#${componentId}`); + expect(styleSelector.style).toEqual({ + color: dataVariable, + }); + + const snapshot = filterObjectForSnapshot(projectData); + expect(snapshot).toMatchSnapshot(``); + }); + + test('TraitDataVariable', () => { + const dataVariable = { + type: DataVariableType, + defaultValue: 'default', + path: `${traitDataSource.id}.id1.value`, + }; + + cmpRoot.append({ + tagName: 'input', + traits: [ + 'name', + { + type: 'text', + label: 'Value', + name: 'value', + value: dataVariable, + }, + ], + })[0]; + + const projectData = editor.getProjectData(); + const page = projectData.pages[0]; + const frame = page.frames[0]; + const component = frame.component.components[0]; + expect(component).toHaveProperty('attributes-data-variable'); + expect(component['attributes-data-variable']).toEqual({ + value: dataVariable, + }); + expect(component.attributes).toEqual({ + value: 'test-value', + }); + + const snapshot = filterObjectForSnapshot(projectData); + expect(snapshot).toMatchSnapshot(``); + }); + }); + + describe('.loadProjectData', () => { + test('ComponentDataVariable', () => { + const componentProjectData: ProjectData = { + assets: [], + pages: [ + { + frames: [ + { + component: { + components: [ + { + components: [ + { + path: 'component-serialization.id1.content', + type: 'data-variable', + value: 'default', + }, + ], + tagName: 'h1', + type: 'text', + }, + ], + docEl: { + tagName: 'html', + }, + head: { + type: 'head', + }, + stylable: [ + 'background', + 'background-color', + 'background-image', + 'background-repeat', + 'background-attachment', + 'background-position', + 'background-size', + ], + type: 'wrapper', + }, + id: 'data-variable-id', + }, + ], + id: 'data-variable-id', + type: 'main', + }, + ], + styles: [], + symbols: [], + }; + + editor.loadProjectData(componentProjectData); + const components = editor.getComponents(); + + const component = components.models[0]; + const html = component.toHTML(); + expect(html).toContain('Hello World'); + }); + + test('StyleDataVariable', () => { + const componentProjectData: ProjectData = { + assets: [], + pages: [ + { + frames: [ + { + component: { + components: [ + { + attributes: { + id: 'selectorid', + }, + content: 'Hello World', + tagName: 'h1', + type: 'text', + }, + ], + docEl: { + tagName: 'html', + }, + head: { + type: 'head', + }, + stylable: [ + 'background', + 'background-color', + 'background-image', + 'background-repeat', + 'background-attachment', + 'background-position', + 'background-size', + ], + type: 'wrapper', + }, + id: 'componentid', + }, + ], + id: 'frameid', + type: 'main', + }, + ], + styles: [ + { + selectors: ['#selectorid'], + style: { + color: { + path: 'colors-data.id1.color', + type: 'data-variable', + defaultValue: 'black', + }, + }, + }, + ], + symbols: [], + }; + + editor.loadProjectData(componentProjectData); + + const components = editor.getComponents(); + const component = components.models[0]; + const style = component.getStyle(); + + expect(style).toEqual({ + color: 'red', + }); + }); + + test('TraitDataVariable', () => { + const componentProjectData: ProjectData = { + assets: [], + pages: [ + { + frames: [ + { + component: { + components: [ + { + attributes: { + value: 'default', + }, + 'attributes-data-variable': { + value: { + path: 'test-input.id1.value', + type: 'data-variable', + defaultValue: 'default', + }, + }, + tagName: 'input', + void: true, + }, + ], + docEl: { + tagName: 'html', + }, + head: { + type: 'head', + }, + stylable: [ + 'background', + 'background-color', + 'background-image', + 'background-repeat', + 'background-attachment', + 'background-position', + 'background-size', + ], + type: 'wrapper', + }, + id: 'frameid', + }, + ], + id: 'pageid', + type: 'main', + }, + ], + styles: [], + symbols: [], + }; + + editor.loadProjectData(componentProjectData); + + const components = editor.getComponents(); + const component = components.models[0]; + const value = component.getAttributes(); + expect(value).toEqual({ + value: 'test-value', + }); + }); + }); +}); diff --git a/test/specs/data_sources/transformers.ts b/test/specs/data_sources/transformers.ts new file mode 100644 index 000000000..a8c780fe1 --- /dev/null +++ b/test/specs/data_sources/transformers.ts @@ -0,0 +1,121 @@ +import Editor from '../../../src/editor/model/Editor'; +import DataSourceManager from '../../../src/data_sources'; +import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper'; +import { DataVariableType } from '../../../src/data_sources/model/DataVariable'; +import { DataSourceProps } from '../../../src/data_sources/types'; + +describe('DataSource Transformers', () => { + let em: Editor; + let dsm: DataSourceManager; + let fixtures: HTMLElement; + let cmpRoot: ComponentWrapper; + + beforeEach(() => { + em = new Editor({ + mediaCondition: 'max-width', + avoidInlineStyle: true, + }); + dsm = em.DataSources; + document.body.innerHTML = '
'; + const { Pages, Components } = em; + Pages.onLoad(); + cmpRoot = Components.getWrapper()!; + const View = Components.getType('wrapper')!.view; + const wrapperEl = new View({ + model: cmpRoot, + config: { ...cmpRoot.config, em }, + }); + wrapperEl.render(); + fixtures = document.body.querySelector('#fixtures')!; + fixtures.appendChild(wrapperEl.el); + }); + + afterEach(() => { + em.destroy(); + }); + + test('should assert that onRecordSetValue is called when adding a record', () => { + const testDataSource: DataSourceProps = { + id: 'test-data-source', + records: [], + transformers: { + onRecordSetValue: ({ key, value }) => { + if (key !== 'content') { + return value; + } + + return (value as string).toUpperCase(); + }, + }, + }; + dsm.add(testDataSource); + + const cmp = cmpRoot.append({ + tagName: 'h1', + type: 'text', + components: [ + { + type: DataVariableType, + defaultValue: 'default', + path: 'test-data-source.id1.content', + }, + ], + })[0]; + + const ds = dsm.get('test-data-source'); + ds.addRecord({ id: 'id1', content: 'i love grapes' }); + + const el = cmp.getEl(); + expect(el?.innerHTML).toContain('I LOVE GRAPES'); + + const result = ds.getRecord('id1')?.get('content'); + expect(result).toBe('I LOVE GRAPES'); + }); + + test('should assert that onRecordSetValue is called when setting a value on a record', () => { + const testDataSource: DataSourceProps = { + id: 'test-data-source', + records: [], + transformers: { + onRecordSetValue: ({ id, key, value }) => { + if (key !== 'content') { + return value; + } + + if (typeof value !== 'string') { + throw new Error('Value must be a string'); + } + + return value.toUpperCase(); + }, + }, + }; + dsm.add(testDataSource); + + const cmp = cmpRoot.append({ + tagName: 'h1', + type: 'text', + components: [ + { + type: DataVariableType, + defaultValue: 'default', + path: 'test-data-source.id1.content', + }, + ], + })[0]; + + const ds = dsm.get('test-data-source'); + const dr = ds.addRecord({ id: 'id1', content: 'i love grapes' }); + + expect(() => dr.set('content', 123)).toThrowError('Value must be a string'); + expect(() => dr.set({ content: 123 })).toThrowError('Value must be a string'); + + dr.set({ content: 'I LOVE GRAPES' }); + + const el = cmp.getEl(); + expect(el?.innerHTML).toContain('I LOVE GRAPES'); + + const result = ds.getRecord('id1')?.get('content'); + expect(result).toBe('I LOVE GRAPES'); + }); +});