Browse Source

Feature/data sources (#6018)

pull/6098/head
Daniel Starns 1 year ago
committed by GitHub
parent
commit
c9aec5cbe4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      docs/.vuepress/config.js
  2. 3
      docs/api.mjs
  3. 141
      docs/api/data_source_manager.md
  4. 115
      docs/api/datarecord.md
  5. 129
      docs/api/datasource.md
  6. 210
      docs/modules/DataSources.md
  7. 2
      src/abstract/Module.ts
  8. 2
      src/common/index.ts
  9. 151
      src/data_sources/index.ts
  10. 31
      src/data_sources/model/ComponentDataVariable.ts
  11. 166
      src/data_sources/model/DataRecord.ts
  12. 15
      src/data_sources/model/DataRecords.ts
  13. 172
      src/data_sources/model/DataSource.ts
  14. 18
      src/data_sources/model/DataSources.ts
  15. 44
      src/data_sources/model/DataVariable.ts
  16. 62
      src/data_sources/model/DataVariableListenerManager.ts
  17. 9
      src/data_sources/model/StyleDataVariable.ts
  18. 16
      src/data_sources/model/TraitDataVariable.ts
  19. 78
      src/data_sources/types.ts
  20. 24
      src/data_sources/view/ComponentDataVariableView.ts
  21. 8
      src/dom_components/index.ts
  22. 30
      src/dom_components/model/Component.ts
  23. 3
      src/dom_components/model/types.ts
  24. 121
      src/domain_abstract/model/StyleableModel.ts
  25. 4
      src/editor/index.ts
  26. 10
      src/editor/model/Editor.ts
  27. 5
      src/index.ts
  28. 2
      src/style_manager/index.ts
  29. 11
      src/style_manager/model/PropertyComposite.ts
  30. 35
      src/trait_manager/model/Trait.ts
  31. 2
      src/trait_manager/view/TraitView.ts
  32. 29
      src/utils/mixins.ts
  33. 164
      test/specs/data_sources/__snapshots__/serialization.ts.snap
  34. 73
      test/specs/data_sources/index.ts
  35. 298
      test/specs/data_sources/model/ComponentDataVariable.ts
  36. 175
      test/specs/data_sources/model/StyleDataVariable.ts
  37. 361
      test/specs/data_sources/model/TraitDataVariable.ts
  38. 393
      test/specs/data_sources/serialization.ts
  39. 121
      test/specs/data_sources/transformers.ts

4
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'],
],
},
{

3
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]}`;

141
docs/api/data_source_manager.md

@ -0,0 +1,141 @@
<!-- Generated by documentation.js. Update this documentation by updating the source code. -->
## 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**&#x20;
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?**&#x20;
### 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

115
docs/api/datarecord.md

@ -0,0 +1,115 @@
<!-- Generated by documentation.js. Update this documentation by updating the source code. -->
## 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

129
docs/api/datasource.md

@ -0,0 +1,129 @@
<!-- Generated by documentation.js. Update this documentation by updating the source code. -->
## 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]\<DataRecordProps>** An array of data record properties to set.
Returns **[Array][9]\<DataRecord>** 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

210
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

2
src/abstract/Module.ts

@ -125,7 +125,7 @@ export abstract class ItemManagerModule<
TCollection extends Collection = Collection,
> extends Module<TConf> {
cls: any[] = [];
protected all: TCollection;
all: TCollection;
view?: View;
constructor(

2
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 };

151
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<ModuleConfig, DataSources> {
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;
}
}

31
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;
}
}

166
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<DataRecordProps>}
*/
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<T extends DataRecordProps = DataRecordProps> extends Model<T> {
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<String>} - 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<A extends _StringKey<T>>(
attributeName: Partial<T> | 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<T>;
for (const [key, val] of Object.entries(attributes)) {
applySet(key, val);
}
} else {
applySet(attributeName as string, value);
}
return this;
}
}

15
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<DataRecord> {
dataSource: DataSource;
constructor(models: DataRecord[] | DataRecordProps[], options: { dataSource: DataSource }) {
super(models, options);
this.dataSource = options.dataSource;
}
}
DataRecords.prototype.model = DataRecord;

172
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<DataSourceProps>}
*/
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<DataSourceProps> {
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<DataRecord | undefined>} 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<DataRecordProps>} records - An array of data record properties to set.
* @returns {Array<DataRecord>} An array of the added data records.
* @name setRecords
*/
setRecords(records: Array<DataRecordProps>) {
this.records.reset([], { silent: true });
records.forEach((record) => {
this.records.add(record);
});
}
}

18
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<DataSource> {
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 });
};
}
}

44
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;
}
}

62
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;
}
}

9
src/data_sources/model/StyleDataVariable.ts

@ -0,0 +1,9 @@
import DataVariable from './DataVariable';
export default class StyleDataVariable extends DataVariable {
defaults() {
return {
...super.defaults(),
};
}
}

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

78
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}*/

24
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<ComponentDataVariable> {
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();
}
}

8
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<DomComponentsConfig, any> {
componentTypes: ComponentStackItem[] = [
{
id: DataVariableType,
model: ComponentDataVariable,
view: ComponentDataVariableView,
},
{
id: 'cell',
model: ComponentTableCell,

30
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<Component> {}
@ -713,7 +714,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
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<ComponentProperties> {
}
}
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<ComponentProperties> {
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<ComponentProperties> {
* // append at specific index (eg. at the beginning)
* someComponent.append(otherComponent, { at: 0 });
*/
append(components: ComponentAdd, opts: AddOptions = {}): Component[] {
append<T extends Component = Component>(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<ComponentProperties> {
action: ActionLabelComponents.add,
...opts,
});
return isArray(result) ? result : [result];
return result as T[];
}
/**

3
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<string, any | { type: typeof DataVariableType; path: string; value: string }>;
/**
* Component related styles, eg. `.my-component-class { color: red }`
* @default ''

121
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<string, string | string[]>;
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<T extends ObjectHash = any> extends Model<T> {
em?: EditorModel;
dataVariableListeners: Record<string, DataVariableListenerManager> = {};
/**
* 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<T extends ObjectHash = any> extends Model<T>
}
/**
* 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<T extends ObjectHash = any> extends Model<T>
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<T extends ObjectHash = any> extends Model<T>
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<T extends ObjectHash = any> extends Model<T>
}
});
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<T extends ObjectHash = any> extends Model<T>
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<T extends ObjectHash = any> extends Model<T>
// @ts-ignore
return this.selectorsToString ? this.selectorsToString(opts) : this.getSelectors().getFullString();
}
// @ts-ignore
// _validate(attr, opts) {
// return true;
// }
}

4
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<EditorConfig> {
get DeviceManager(): DeviceManager {
return this.em.Devices;
}
get DataSources(): DataSourceManager {
return this.em.DataSources;
}
get EditorModel() {
return this.em;

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

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

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

11
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<T extends Record<string, any> = PropertyC
const result = this.getStyleFromProps()[this.getName()] || '';
return getLastStyleValue(result);
return getLastStyleValue(result as string);
}
__getJoin() {
@ -303,7 +304,9 @@ export default class PropertyComposite<T extends Record<string, any> = 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<T extends Record<string, any> = 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) => {

35
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<TraitProperties> {
em: EditorModel;
view?: TraitView;
el?: HTMLElement;
dataListeners: DataVariableListener[] = [];
dataVariable?: TraitDataVariable;
dataVariableListener?: DataVariableListenerManager;
defaults() {
return {
@ -51,6 +58,23 @@ export default class Trait extends Model<TraitProperties> {
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<TraitProperties> {
}
}
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<TraitProperties> {
* @returns {any}
*/
getValue(opts?: TraitGetValueOptions) {
if (this.dataVariable) {
const dValue = this.dataVariable.getDataValue();
return dValue;
}
return this.getTargetValue(opts);
}

2
src/trait_manager/view/TraitView.ts

@ -123,7 +123,7 @@ export default class TraitView extends View<Trait> {
this.postUpdate();
} else {
const val = this.getValueForTarget();
model.setTargetValue(val, opts);
model?.setTargetValue(val, opts);
}
}

29
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);

164
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": [],
}
`;

73
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 = '<div id="fixtures"></div>';
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));
});
});

298
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 = '<div id="fixtures"></div>';
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');
});
});

175
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 = '<div id="fixtures"></div>';
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');
});
});

361
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 = '<div id="fixtures"></div>';
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');
});
});
});

393
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 = '<div id="fixtures"></div>';
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('"<body><h1><div>Hello World</div></h1></body>"');
});
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',
});
});
});
});

121
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 = '<div id="fixtures"></div>';
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');
});
});
Loading…
Cancel
Save