Browse Source

Merge branch 'dev' into GRA-164-adds-sitemap-to-grapesjs

GRA-164-adds-sitemap-to-grapesjs
markdanial 3 months ago
committed by GitHub
parent
commit
6341ac43c0
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 58
      docs/api.mjs
  2. 2
      docs/api/block_manager.md
  3. 8
      docs/api/canvas.md
  4. 7
      docs/api/component.md
  5. 44
      docs/api/datasource.md
  6. 43
      docs/api/datasources.md
  7. 33
      docs/api/device_manager.md
  8. 12
      docs/api/editor.md
  9. 27
      docs/api/keymaps.md
  10. 20
      docs/api/layer_manager.md
  11. 19
      docs/api/modal_dialog.md
  12. 12
      docs/api/parser.md
  13. 25
      docs/api/rich_text_editor.md
  14. 54
      docs/api/selector_manager.md
  15. 78
      docs/api/style_manager.md
  16. 1
      package.json
  17. 4
      packages/core/README.md
  18. 2
      packages/core/package.json
  19. 46
      packages/core/src/abstract/Module.ts
  20. 4
      packages/core/src/asset_manager/config/config.ts
  21. 7
      packages/core/src/asset_manager/index.ts
  22. 17
      packages/core/src/asset_manager/types.ts
  23. 13
      packages/core/src/asset_manager/view/FileUploader.ts
  24. 7
      packages/core/src/block_manager/types.ts
  25. 134
      packages/core/src/css_composer/index.ts
  26. 32
      packages/core/src/css_composer/model/CssRule.ts
  27. 13
      packages/core/src/data_sources/config/config.ts
  28. 77
      packages/core/src/data_sources/index.ts
  29. 124
      packages/core/src/data_sources/model/ComponentWithCollectionsState.ts
  30. 17
      packages/core/src/data_sources/model/DataResolverListener.ts
  31. 142
      packages/core/src/data_sources/model/DataSource.ts
  32. 36
      packages/core/src/data_sources/model/DataVariable.ts
  33. 4
      packages/core/src/data_sources/model/conditional_variables/DataCondition.ts
  34. 213
      packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts
  35. 7
      packages/core/src/data_sources/model/data_collection/types.ts
  36. 137
      packages/core/src/data_sources/types.ts
  37. 4
      packages/core/src/data_sources/utils.ts
  38. 7
      packages/core/src/device_manager/index.ts
  39. 1
      packages/core/src/dom_components/constants.ts
  40. 175
      packages/core/src/dom_components/model/Component.ts
  41. 95
      packages/core/src/dom_components/model/ComponentWrapper.ts
  42. 61
      packages/core/src/dom_components/model/Components.ts
  43. 215
      packages/core/src/dom_components/model/ModelDataResolverWatchers.ts
  44. 40
      packages/core/src/dom_components/model/ModelResolverWatcher.ts
  45. 59
      packages/core/src/dom_components/model/SymbolUtils.ts
  46. 10
      packages/core/src/dom_components/model/types.ts
  47. 9
      packages/core/src/dom_components/view/ComponentImageView.ts
  48. 4
      packages/core/src/dom_components/view/ComponentTextView.ts
  49. 7
      packages/core/src/dom_components/view/ComponentView.ts
  50. 10
      packages/core/src/dom_components/view/ComponentsView.ts
  51. 135
      packages/core/src/domain_abstract/model/StyleableModel.ts
  52. 73
      packages/core/src/editor/index.ts
  53. 27
      packages/core/src/editor/model/Editor.ts
  54. 47
      packages/core/src/editor/types.ts
  55. 33
      packages/core/src/keymaps/index.ts
  56. 28
      packages/core/src/keymaps/types.ts
  57. 25
      packages/core/src/modal_dialog/index.ts
  58. 27
      packages/core/src/modal_dialog/types.ts
  59. 46
      packages/core/src/navigator/index.ts
  60. 39
      packages/core/src/navigator/types.ts
  61. 48
      packages/core/src/rich_text_editor/index.ts
  62. 39
      packages/core/src/rich_text_editor/types.ts
  63. 88
      packages/core/src/selector_manager/index.ts
  64. 57
      packages/core/src/selector_manager/types.ts
  65. 106
      packages/core/src/style_manager/index.ts
  66. 88
      packages/core/src/style_manager/types.ts
  67. 2
      packages/core/src/trait_manager/model/Trait.ts
  68. 22
      packages/core/src/utils/Droppable.ts
  69. 42
      packages/core/src/utils/mixins.ts
  70. 24
      packages/core/test/common.ts
  71. 0
      packages/core/test/specs/data_sources/dynamic_values/styles.ts
  72. 0
      packages/core/test/specs/data_sources/dynamic_values/traits.ts
  73. 107
      packages/core/test/specs/data_sources/index.ts
  74. 295
      packages/core/test/specs/data_sources/model/DataSource.ts
  75. 5
      packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts
  76. 4
      packages/core/test/specs/dom_components/index.ts
  77. 30
      packages/core/test/specs/dom_components/model/Component.ts
  78. 96
      packages/core/test/specs/dom_components/model/ComponentWrapper.ts
  79. 248
      packages/core/test/specs/undo_manager/datasources.ts
  80. 286
      packages/core/test/specs/undo_manager/index.ts

58
docs/api.mjs

@ -97,33 +97,43 @@ async function generateDocs() {
throw `File not found '${filePath}'`;
}
return build([filePath], { shallow: true })
.then((cm) => formats.md(cm /*{ markdownToc: true }*/))
.then(async (output) => {
let addLogs = [];
let result = output
.replace(/\*\*\\\[/g, '**[')
.replace(/\*\*\(\\\[/g, '**([')
.replace(/<\\\[/g, '<[')
.replace(/<\(\\\[/g, '<([')
.replace(/\| \\\[/g, '| [')
.replace(/\\n```js/g, '```js')
.replace(/docsjs\./g, '')
.replace('**Extends ModuleModel**', '')
.replace('**Extends Model**', '');
try {
return build([filePath], { shallow: true })
.then((cm) => formats.md(cm /*{ markdownToc: true }*/))
.then(async (output) => {
let addLogs = [];
let result = output
.replace(/\*\*\\\[/g, '**[')
.replace(/\*\*\(\\\[/g, '**([')
.replace(/<\\\[/g, '<[')
.replace(/<\(\\\[/g, '<([')
.replace(/\| \\\[/g, '| [')
.replace(/\\n```js/g, '```js')
.replace(/docsjs\./g, '')
.replace('**Extends ModuleModel**', '')
.replace('**Extends Model**', '');
// Search for module event documentation
if (result.indexOf(REPLACE_EVENTS) >= 0) {
const eventsMd = await getEventsMdFromTypes(filePath);
if (eventsMd && result.indexOf(REPLACE_EVENTS) >= 0) {
addLogs.push('replaced events');
// Search for module event documentation
if (result.indexOf(REPLACE_EVENTS) >= 0) {
try {
const eventsMd = await getEventsMdFromTypes(filePath);
if (eventsMd && result.indexOf(REPLACE_EVENTS) >= 0) {
addLogs.push('replaced events');
}
result = eventsMd ? result.replace(REPLACE_EVENTS, `## Available Events\n${eventsMd}`) : result;
} catch (err) {
console.error(`Failed getting events: ${file[0]}`);
throw err;
}
}
result = eventsMd ? result.replace(REPLACE_EVENTS, `## Available Events\n${eventsMd}`) : result;
}
writeFileSync(`${docRoot}/api/${file[1]}`, result);
log('Created', file[1], addLogs.length ? `(${addLogs.join(', ')})` : '');
});
writeFileSync(`${docRoot}/api/${file[1]}`, result);
log('Created', file[1], addLogs.length ? `(${addLogs.join(', ')})` : '');
});
} catch (err) {
console.error(`Build failed: ${file[0]}`);
throw err;
}
}),
);

2
docs/api/block_manager.md

@ -84,6 +84,8 @@ editor.on('block:custom', ({ container, blocks, ... }) => { ... });
editor.on('block', ({ event, model, ... }) => { ... });
```
* BlocksEventCallback
[Block]: block.html
[Component]: component.html

8
docs/api/canvas.md

@ -122,6 +122,14 @@ editor.on('canvas:frame:load:body', ({ window }) => {
});
```
* `canvas:frame:unload` Frame is unloading from the canvas.
```javascript
editor.on('canvas:frame:unload', ({ frame }) => {
console.log('Unloading frame', frame);
});
```
[Component]: component.html
[Frame]: frame.html

7
docs/api/component.md

@ -137,7 +137,7 @@ By setting override to specific properties, changes of those properties will be
### Parameters
* `value` **([Boolean][3] | [String][1] | [Array][5]<[String][1]>)**&#x20;
* `options` **DynamicWatchersOptions** (optional, default `{}`)
* `options` **DataWatchersOptions** (optional, default `{}`)
### Examples
@ -335,8 +335,7 @@ Get the style of the component
### Parameters
* `options` **any** (optional, default `{}`)
* `optsAdd` **any** (optional, default `{}`)
* `opts` **GetComponentStyleOpts?**&#x20;
Returns **[Object][2]**&#x20;
@ -363,7 +362,7 @@ Return all component's attributes
### Parameters
* `opts` **{noClass: [boolean][3]?, noStyle: [boolean][3]?}** (optional, default `{}`)
* `opts` **{noClass: [boolean][3]?, noStyle: [boolean][3]?, skipResolve: [boolean][3]?}** (optional, default `{}`)
Returns **[Object][2]**&#x20;

44
docs/api/datasource.md

@ -31,6 +31,44 @@ dataSource.addRecord({ id: 'id3', name: 'value3' });
* `props` **DataSourceProps** Properties to initialize the data source.
* `opts` **DataSourceOptions** Options to initialize the data source.
### hasProvider
Indicates if the data source has a provider for records.
### getResolvedRecords
Retrieves all records from the data source with resolved relations based on the schema.
### upSchema
Update the schema.
#### Parameters
* `schema` **Partial\<any>**&#x20;
* `opts` **SetOptions?**&#x20;
#### Examples
```javascript
dataSource.upSchema({ name: { type: 'string' } });
```
### getSchemaField
Get schema field definition.
#### Parameters
* `fieldKey` **any**&#x20;
#### Examples
```javascript
const fieldSchema = dataSource.getSchemaField('name');
fieldSchema.type; // 'string'
```
## defaults
Returns the default properties for the data source.
@ -55,6 +93,12 @@ Retrieves the collection of records associated with this data source.
Returns **DataRecords\<DRProps>** The collection of data records.
## records
Retrieves the collection of records associated with this data source.
Returns **DataRecords\<DRProps>** The collection of data records.
## em
Retrieves the editor model associated with this data source.

43
docs/api/datasources.md

@ -44,12 +44,26 @@ editor.on('data:path', ({ dataSource, dataRecord, path }) => {
editor.on('data:pathSource:SOURCE_ID', ({ dataSource, dataRecord, path }) => { ... });
```
* `data:provider:load` Data source provider load.
```javascript
editor.on('data:provider:load', ({ dataSource, result }) => { ... });
```
* `data:provider:loadAll` Load of all data source providers (eg. on project load).
```javascript
editor.on('data:provider:loadAll', () => { ... });
```
* `data` Catch-all event for all the events mentioned above.
```javascript
editor.on('data', ({ event, model, ... }) => { ... });
```
* DataSourcesEventCallback
## Methods
* [add][1] - Add a new data source.
@ -101,15 +115,32 @@ Returns **[DataSource]** Data source.
## getValue
Get value from data sources by key
Get value from data sources by path.
### Parameters
* `key` **[String][7]** Path to value.
* `defValue` **any**&#x20;
* `path` **[String][7]** Path to value.
* `defValue` **any** Default value if the path is not found.
Returns **any** const value = dsm.getValue('ds\_id.record\_id.propName', 'defaultValue');
## setValue
Set value in data sources by path.
### Parameters
* `path` **[String][7]** Path to value in format 'dataSourceId.recordId.propName'
* `value` **any** Value to set
### Examples
```javascript
dsm.setValue('ds_id.record_id.propName', 'new value');
```
Returns **[Boolean][8]** Returns true if the value was set successfully
## remove
Remove data source.
@ -152,7 +183,7 @@ data record, and optional property path.
Store data sources to a JSON object.
Returns **[Array][8]** Stored data sources.
Returns **[Array][9]** Stored data sources.
## load
@ -178,4 +209,6 @@ Returns **[Object][6]** Loaded data sources.
[7]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String
[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array
[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean
[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array

33
docs/api/device_manager.md

@ -19,12 +19,35 @@ const deviceManager = editor.Devices;
```
## Available Events
* `device:add` New device added to the collection. The `Device` is passed as an argument.
* `device:add` - Added new device. The [Device] is passed as an argument to the callback
* `device:remove` - Device removed. The [Device] is passed as an argument to the callback
* `device:select` - New device selected. The newly selected [Device] and the previous one, are passed as arguments to the callback
* `device:update` - Device updated. The updated [Device] and the object containing changes are passed as arguments to the callback
* `device` - Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback
```javascript
editor.on('device:add', (device) => { ... });
```
* `device:remove` Device removed from the collection. The `Device` is passed as an argument.
```javascript
editor.on('device:remove', (device) => { ... });
```
* `device:select` A new device is selected. The `Device` is passed as an argument.
```javascript
editor.on('device:select', (device) => { ... });
```
* `device:update` Device updated. The `Device` and the object containing changes are passed as arguments.
```javascript
editor.on('device:update', (device) => { ... });
```
* `device` Catch-all event for all the events mentioned above.
```javascript
editor.on('device', ({ event, model, ... }) => { ... });
```
## Methods

12
docs/api/editor.md

@ -42,6 +42,15 @@ editor.on('load', () => { ... });
editor.on('project:load', ({ project, initial }) => { ... });
```
* `project:loaded` Similar to `project:load`, but triggers only if the project is loaded successfully.
```javascript
editor.on('project:loaded', ({ project, initial }) => { ... });
// Loading an empty project, won't trigger this event.
editor.loadProjectData({});
```
* `project:get` Event triggered on request of the project data. This can be used to extend the project with custom data.
```javascript
@ -516,6 +525,7 @@ Load data from the JSON project
### Parameters
* `data` **[Object][16]** Project to load
* `options` **[Object][16]?** Custom options that could be passed to the project load events. (optional, default `{}`)
### Examples
@ -722,7 +732,7 @@ Trigger event
### Parameters
* `event` **[string][18]** Event to trigger
* `args` **...[Array][19]\<any>**&#x20;
* `args` **...any**&#x20;
Returns **this**&#x20;

27
docs/api/keymaps.md

@ -19,23 +19,30 @@ const editor = grapesjs.init({
})
```
Once the editor is instantiated you can use its API and listen to its events. Before using these methods, you should get the module from the instance.
Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance.
```js
// Listen to events
editor.on('keymap:add', () => { ... });
// Use the API
const keymaps = editor.Keymaps;
keymaps.add(...);
```
## Available Events
* `keymap:add` New keymap added. The new keymap object is passed as an argument to the callback.
```javascript
editor.on('keymap:add', (keymap) => { ... });
```
* `keymap:remove` Keymap removed. The removed keymap object is passed as an argument to the callback.
* `keymap:add` - New keymap added. The new keyamp object is passed as an argument
* `keymap:remove` - Keymap removed. The removed keyamp object is passed as an argument
* `keymap:emit` - Some keymap emitted, in arguments you get keymapId, shortcutUsed, Event
* `keymap:emit:{keymapId}` - `keymapId` emitted, in arguments you get keymapId, shortcutUsed, Event
```javascript
editor.on('keymap:remove', (keymap) => { ... });
```
* `keymap:emit` Some keymap emitted. The keymapId, shortcutUsed, and Event are passed as arguments to the callback.
```javascript
editor.on('keymap:emit', (keymapId, shortcutUsed, event) => { ... });
```
## Methods

20
docs/api/layer_manager.md

@ -13,16 +13,30 @@ const editor = grapesjs.init({
})
```
Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance
Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance.
```js
const layers = editor.Layers;
```
## Available Events
* `layer:root` Root layer changed. The new root component is passed as an argument to the callback.
* `layer:root` - Root layer changed. The new root component is passed as an argument to the callback.
* `layer:component` - Component layer is updated. The updated component is passed as an argument to the callback.
```javascript
editor.on('layer:root', (component) => { ... });
```
* `layer:component` Component layer is updated. The updated component is passed as an argument to the callback.
```javascript
editor.on('layer:component', (component, opts) => { ... });
```
* `layer:custom` Custom layer event. Object with container and root is passed as an argument to the callback.
```javascript
editor.on('layer:custom', ({ container, root }) => { ... });
```
## Methods

19
docs/api/modal_dialog.md

@ -19,10 +19,23 @@ const modal = editor.Modal;
```
## Available Events
* `modal:open` Modal is opened
* `modal:open` - Modal is opened
* `modal:close` - Modal is closed
* `modal` - Event triggered on any change related to the modal. An object containing all the available data about the triggered event is passed as an argument to the callback.
```javascript
editor.on('modal:open', () => { ... });
```
* `modal:close` Modal is closed
```javascript
editor.on('modal:close', () => { ... });
```
* `modal` Event triggered on any change related to the modal. An object containing all the available data about the triggered event is passed as an argument to the callback.
```javascript
editor.on('modal', ({ open, title, content, ... }) => { ... });
```
## Methods

12
docs/api/parser.md

@ -81,6 +81,12 @@ Parse HTML string and return the object containing the Component Definition
* `options.htmlType` **[String][6]?** [HTML mime type][7] to parse
* `options.allowScripts` **[Boolean][8]** Allow `<script>` tags (optional, default `false`)
* `options.allowUnsafeAttr` **[Boolean][8]** Allow unsafe HTML attributes (eg. `on*` inline event handlers) (optional, default `false`)
* `options.allowUnsafeAttrValue` **[Boolean][8]** Allow unsafe HTML attribute values (eg. `src="javascript:..."`) (optional, default `false`)
* `options.keepEmptyTextNodes` **[Boolean][8]** Keep whitespaces regardless of whether they are meaningful (optional, default `false`)
* `options.asDocument` **[Boolean][8]?** Treat the HTML string as document
* `options.detectDocument` **([Boolean][8] | [Function][9])?** Indicate if or how to detect if the HTML string should be treated as document
* `options.preParser` **[Function][9]?** How to pre-process the HTML string before parsing
* `options.convertDataGjsAttributesHyphens` **[Boolean][8]** Convert `data-gjs-*` attributes from hyphenated to camelCase (eg. `data-gjs-my-component` to `data-gjs-myComponent`) (optional, default `false`)
### Examples
@ -113,7 +119,7 @@ const res = Parser.parseCss('.cls { color: red }');
// [{ ... }]
```
Returns **[Array][9]<[Object][5]>** Array containing the result
Returns **[Array][10]<[Object][5]>** Array containing the result
[1]: https://github.com/GrapesJS/grapesjs/blob/master/src/parser/config/config.ts
@ -131,4 +137,6 @@ Returns **[Array][9]<[Object][5]>** Array containing the result
[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean
[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array
[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function
[10]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array

25
docs/api/rich_text_editor.md

@ -15,21 +15,30 @@ const editor = grapesjs.init({
})
```
Once the editor is instantiated you can use its API and listen to its events. Before using these methods, you should get the module from the instance.
Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance.
```js
// Listen to events
editor.on('rte:enable', () => { ... });
// Use the API
const rte = editor.RichTextEditor;
rte.add(...);
```
## Available Events
* `rte:enable` RTE enabled. The view, on which RTE is enabled, and the RTE instance are passed as arguments.
```javascript
editor.on('rte:enable', (view, rte) => { ... });
```
* `rte:disable` RTE disabled. The view, on which RTE is disabled, and the RTE instance are passed as arguments.
* `rte:enable` - RTE enabled. The view, on which RTE is enabled, is passed as an argument
* `rte:disable` - RTE disabled. The view, on which RTE is disabled, is passed as an argument
```javascript
editor.on('rte:disable', (view, rte) => { ... });
```
* `rte:custom` Custom RTE event. Object with enabled status, container, and actions is passed as an argument.
```javascript
editor.on('rte:custom', ({ enabled, container, actions }) => { ... });
```
## Methods

54
docs/api/selector_manager.md

@ -35,24 +35,56 @@ const editor = grapesjs.init({
})
```
Once the editor is instantiated you can use its API and listen to its events. Before using these methods, you should get the module from the instance.
Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance.
```js
// Listen to events
editor.on('selector:add', (selector) => { ... });
// Use the API
const sm = editor.Selectors;
sm.add(...);
```
## Available Events
* `selector:add` Selector added. The Selector is passed as an argument to the callback.
```javascript
editor.on('selector:add', (selector) => { ... });
```
* `selector:remove` Selector removed. The Selector is passed as an argument to the callback.
```javascript
editor.on('selector:remove', (selector) => { ... });
```
* `selector:remove:before` Before selector remove. The Selector is passed as an argument to the callback.
```javascript
editor.on('selector:remove:before', (selector) => { ... });
```
* `selector:update` Selector updated. The Selector and the object containing changes are passed as arguments to the callback.
```javascript
editor.on('selector:update', (selector, changes) => { ... });
```
* `selector:state` States changed. An object containing all the available data about the triggered event is passed as an argument to the callback.
```javascript
editor.on('selector:state', (state) => { ... });
```
* `selector:custom` Custom selector event. An object containing states, selected selectors, and container is passed as an argument.
```javascript
editor.on('selector:custom', ({ states, selected, container }) => { ... });
```
* `selector` Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback.
```javascript
editor.on('selector', ({ event, selector, changes, ... }) => { ... });
```
* `selector:add` - Selector added. The [Selector] is passed as an argument to the callback.
* `selector:remove` - Selector removed. The [Selector] is passed as an argument to the callback.
* `selector:update` - Selector updated. The [Selector] and the object containing changes are passed as arguments to the callback.
* `selector:state` - States changed. An object containing all the available data about the triggered event is passed as an argument to the callback.
* `selector` - Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback.
* SelectorStringObject
## Methods

78
docs/api/style_manager.md

@ -13,32 +13,72 @@ const editor = grapesjs.init({
})
```
Once the editor is instantiated you can use its API and listen to its events. Before using these methods, you should get the module from the instance.
Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance.
```js
// Listen to events
editor.on('style:sector:add', (sector) => { ... });
// Use the API
const styleManager = editor.StyleManager;
styleManager.addSector(...);
```
## Available Events
* `style:sector:add` Sector added. The Sector is passed as an argument to the callback.
```javascript
editor.on('style:sector:add', (sector) => { ... });
```
* `style:sector:remove` Sector removed. The Sector is passed as an argument to the callback.
```javascript
editor.on('style:sector:remove', (sector) => { ... });
```
* `style:sector:update` Sector updated. The Sector and the object containing changes are passed as arguments to the callback.
* `style:sector:add` - Sector added. The [Sector] is passed as an argument to the callback.
* `style:sector:remove` - Sector removed. The [Sector] is passed as an argument to the callback.
* `style:sector:update` - Sector updated. The [Sector] and the object containing changes are passed as arguments to the callback.
* `style:property:add` - Property added. The [Property] is passed as an argument to the callback.
* `style:property:remove` - Property removed. The [Property] is passed as an argument to the callback.
* `style:property:update` - Property updated. The [Property] and the object containing changes are passed as arguments to the callback.
* `style:target` - Target selection changed. The target (or `null` in case the target is deselected) is passed as an argument to the callback.
<!--
* `styleManager:update:target` - The target (Component or CSSRule) is changed
* `styleManager:change` - Triggered on style property change from new selected component, the view of the property is passed as an argument to the callback
* `styleManager:change:{propertyName}` - As above but for a specific style property
-->
```javascript
editor.on('style:sector:update', (sector, changes) => { ... });
```
* `style:property:add` Property added. The Property is passed as an argument to the callback.
```javascript
editor.on('style:property:add', (property) => { ... });
```
* `style:property:remove` Property removed. The Property is passed as an argument to the callback.
```javascript
editor.on('style:property:remove', (property) => { ... });
```
* `style:property:update` Property updated. The Property and the object containing changes are passed as arguments to the callback.
```javascript
editor.on('style:property:update', (property, changes) => { ... });
```
* `style:target` Target selection changed. The target (or null in case the target is deselected) is passed as an argument to the callback.
```javascript
editor.on('style:target', (target) => { ... });
```
* `style:layer:select` Layer selected. Object containing layer data is passed as an argument.
```javascript
editor.on('style:layer:select', (data) => { ... });
```
* `style:custom` Custom style event. Object containing all custom data is passed as an argument.
```javascript
editor.on('style:custom', ({ container }) => { ... });
```
* `style` Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback.
```javascript
editor.on('style', ({ event, sector, property, ... }) => { ... });
```
## Methods

1
package.json

@ -10,6 +10,7 @@
"docs:api": "pnpm --filter @grapesjs/docs docs:api",
"lint": "eslint .",
"build": "pnpm -r run build",
"check": "pnpm run lint && pnpm run format:check && pnpm run ts:check",
"ts:check": "pnpm --filter grapesjs ts:check",
"clean": "find . -type d \\( -name \"node_modules\" -o -name \"build\" -o -name \"dist\" \\) -exec rm -rf {} + && rm ./pnpm-lock.yaml",
"format": "prettier . --write --ignore-path .prettierignore",

4
packages/core/README.md

@ -33,7 +33,7 @@ Newsletter Demo - http://grapesjs.com/demo-newsletter-editor.html<br/>
- [Plugins](#plugins)
- [Support](#support)
- [Changelog](https://github.com/GrapesJS/grapesjs/releases)
- [Contributing](https://github.com/GrapesJS/grapesjs/blob/master/CONTRIBUTING.md)
- [Contributing](https://github.com/GrapesJS/grapesjs/blob/dev/CONTRIBUTING.md)
- [License](#license)
## Features
@ -87,7 +87,7 @@ For a more practical example I'd suggest looking up the code inside this demo: h
## Development
Follow the [Contributing Guide](https://github.com/GrapesJS/grapesjs/blob/master/CONTRIBUTING.md).
Follow the [Contributing Guide](https://github.com/GrapesJS/grapesjs/blob/dev/CONTRIBUTING.md).
## Documentation

2
packages/core/package.json

@ -1,7 +1,7 @@
{
"name": "grapesjs",
"description": "Free and Open Source Web Builder Framework",
"version": "0.22.12",
"version": "0.22.14-rc.2",
"author": "Artur Arseniev",
"license": "BSD-3-Clause",
"homepage": "http://grapesjs.com",

46
packages/core/src/abstract/Module.ts

@ -130,6 +130,7 @@ export abstract class ItemManagerModule<
all: TCollection;
view?: View;
events!: Record<string, string>;
protected _itemCache = new Map<string, Model>();
constructor(
em: EditorModel,
@ -207,6 +208,51 @@ export abstract class ItemManagerModule<
}, {} as any);
}
protected _makeCacheKey(m: Model) {
return '';
}
protected _cacheItem(item: Model) {
const key = this._makeCacheKey(item);
key && this._itemCache.set(key, item);
}
protected _uncacheItem(item: Model) {
const key = this._makeCacheKey(item);
key && this._itemCache.delete(key);
}
protected _clearItemCache() {
this._itemCache.clear();
}
protected _onItemsResetCache(collection: Collection) {
this._clearItemCache();
collection.each((item: Model) => this._cacheItem(item));
}
protected _onItemKeyChange(item: Model) {
let oldKey: string | undefined;
for (const [key, cachedItem] of (this._itemCache as any).entries()) {
if (cachedItem === item) {
oldKey = key;
break;
}
}
if (oldKey) {
this._itemCache.delete(oldKey);
}
this._cacheItem(item);
}
protected _setupCacheListeners() {
this.em.listenTo(this.all, 'add', this._cacheItem.bind(this));
this.em.listenTo(this.all, 'remove', this._uncacheItem.bind(this));
this.em.listenTo(this.all, 'reset', this._onItemsResetCache.bind(this));
}
__initListen(opts: any = {}) {
const { all, em, events } = this;
all &&

4
packages/core/src/asset_manager/config/config.ts

@ -1,3 +1,5 @@
import { UploadFileFn } from '../types';
export interface AssetManagerConfig {
/**
* Default assets.
@ -83,7 +85,7 @@ export interface AssetManagerConfig {
* // ...send somewhere
* }
*/
uploadFile?: (ev: DragEvent) => void;
uploadFile?: UploadFileFn;
/**
* In the absence of 'uploadFile' or 'upload' assets will be embedded as Base64.
* @default true

7
packages/core/src/asset_manager/index.ts

@ -40,13 +40,10 @@ import { ProjectData } from '../storage_manager';
import defConfig, { AssetManagerConfig } from './config/config';
import Asset from './model/Asset';
import Assets from './model/Assets';
import AssetsEvents, { AssetOpenOptions } from './types';
import AssetsEvents, { AssetAddInput, AssetOpenOptions, AssetProps } from './types';
import AssetsView from './view/AssetsView';
import FileUploaderView from './view/FileUploader';
// TODO
type AssetProps = Record<string, any>;
const assetCmd = 'open-assets';
export default class AssetManager extends ItemManagerModule<AssetManagerConfig, Assets> {
@ -153,7 +150,7 @@ export default class AssetManager extends ItemManagerModule<AssetManagerConfig,
* });
* assetManager.add([{ src: 'img2.jpg' }, { src: 'img2.png' }]);
*/
add(asset: string | AssetProps | (string | AssetProps)[], opts: AddOptions = {}) {
add(asset: AssetAddInput | AssetAddInput[], opts: AddOptions = {}) {
// Put the model at the beginning
if (typeof opts.at == 'undefined') {
opts.at = 0;

17
packages/core/src/asset_manager/types.ts

@ -1,7 +1,10 @@
import ComponentView from '../dom_components/view/ComponentView';
import Asset from './model/Asset';
export type AssetEvent = `${AssetsEvents}`;
export type AssetAddInput = string | AssetProps | Asset;
export interface AssetOpenOptions {
select?: (asset: Asset, complete: boolean) => void;
types?: string[];
@ -9,6 +12,20 @@ export interface AssetOpenOptions {
target?: any;
}
export interface AssetProps {
src: string;
[key: string]: unknown;
}
export interface UploadFileOptions {
componentView?: ComponentView;
file?: File;
}
export type UploadFileClb = (result: { data: (AssetProps | string)[] }) => void;
export type UploadFileFn = (ev: DragEvent, clb?: UploadFileClb, opts?: UploadFileOptions) => Promise<void> | undefined;
/**{START_EVENTS}*/
export enum AssetsEvents {
/**

13
packages/core/src/asset_manager/view/FileUploader.ts

@ -4,6 +4,7 @@ import EditorModel from '../../editor/model/Editor';
import fetch from '../../utils/fetch';
import html from '../../utils/html';
import { AssetManagerConfig } from '../config/config';
import { UploadFileClb, UploadFileOptions } from '../types';
type FileUploaderTemplateProps = {
pfx: string;
@ -53,7 +54,7 @@ export default class FileUploaderView extends View {
constructor(opts: any = {}) {
super(opts);
this.options = opts;
const c = opts.config || {};
const c = (opts.config || {}) as AssetManagerConfig & { pStylePrefix?: string; disableUpload?: boolean };
this.module = opts.module;
this.config = c;
// @ts-ignore
@ -113,7 +114,7 @@ export default class FileUploaderView extends View {
* @param {string} text Response text
* @private
*/
onUploadResponse(text: string, clb?: (json: any) => void) {
onUploadResponse(text: string, clb?: UploadFileClb) {
const { module, config, target } = this;
let json;
try {
@ -138,9 +139,9 @@ export default class FileUploaderView extends View {
* @return {Promise}
* @private
* */
uploadFile(e: DragEvent, clb?: () => void) {
// @ts-ignore
const files = e.dataTransfer ? e.dataTransfer.files : e.target.files;
uploadFile(e: DragEvent, clb?: UploadFileClb, opts?: UploadFileOptions) {
opts; // Options are not used here but can be used by the custom uploadFile function
const files = e.dataTransfer ? e.dataTransfer.files : ((e.target as any)?.files as FileList);
const { config } = this;
const { beforeUpload } = config;
@ -293,7 +294,7 @@ export default class FileUploaderView extends View {
return this;
}
static embedAsBase64(e: DragEvent, clb?: () => void) {
static embedAsBase64(e: DragEvent, clb?: UploadFileClb) {
// List files dropped
// @ts-ignore
const files = e.dataTransfer ? e.dataTransfer.files : e.target.files;

7
packages/core/src/block_manager/types.ts

@ -1,4 +1,5 @@
import { ItemsByCategory } from '../abstract/ModuleCategory';
import { AddOptions, RemoveOptions } from '../common';
import Block from './model/Block';
export interface BlocksByCategory extends ItemsByCategory<Block> {}
@ -77,5 +78,11 @@ export enum BlocksEvents {
}
/**{END_EVENTS}*/
export interface BlocksEventCallback {
[BlocksEvents.add]: [Block, AddOptions];
[BlocksEvents.remove]: [Block, RemoveOptions];
[BlocksEvents.update]: [Block, AddOptions];
}
// need this to avoid the TS documentation generator to break
export default BlocksEvents;

134
packages/core/src/css_composer/index.ts

@ -87,7 +87,7 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
Selectors = Selectors;
storageKey = 'styles';
protected _itemCache = new Map<string, CssRule>();
/**
* Initializes module. Automatically called with a new instance of the editor
* @param {Object} config Configurations
@ -104,6 +104,34 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
config.rules = this.em.config.style || config.rules || '';
this.rules = new CssRules([], config);
this._setupCacheListeners();
}
protected override _setupCacheListeners() {
super._setupCacheListeners();
this.em.listenTo(this.rules, 'change:selectors change:state change:mediaText', this._onItemKeyChange.bind(this));
}
protected _makeCacheKey(rule: CssRule) {
const atRuleKey = rule.getAtRule();
const selectorsKey = rule.selectorsToString();
return `${atRuleKey}__${selectorsKey}`;
}
_makeCacheKeyFromProps(ruleProps: CssRuleProperties) {
const { atRuleType = '', mediaText = '', state = '', selectorsAdd = '', selectors = [] } = ruleProps;
const selectorsStr = selectors.map((selector) => (isString(selector) ? selector : selector.toString())).join('');
const selectorsRes = [];
selectorsStr && selectorsRes.push(`${selectorsStr}${state ? `:${state}` : ''}`);
selectorsAdd && selectorsRes.push(selectorsAdd);
const selectorsKey = selectorsRes.join(', ');
const typeStr = atRuleType ? `@${atRuleType}` : mediaText ? '@media' : '';
const atRuleKey = typeStr + (mediaText && typeStr ? ` ${mediaText}` : '');
return `${atRuleKey}__${selectorsKey}`;
}
/**
@ -112,6 +140,7 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
*/
onLoad() {
this.rules.add(this.config.rules, { silent: true });
this._onItemsResetCache(this.rules as any);
}
/**
@ -134,6 +163,28 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
});
}
/**
* Find a rule in the collection by its properties.
* @private
*/
_findRule(
selectors: any,
state?: string,
width?: string,
ruleProps?: Omit<CssRuleProperties, 'selectors'>,
): CssRule | null {
let slc = selectors;
if (isString(selectors)) {
const sm = this.em.Selectors;
const singleSel = selectors.split(',')[0].trim();
const node = this.em.Parser.parserCss.checkNode({ selectors: singleSel } as any)[0];
slc = sm.get(node.selectors as string[]);
}
const rule = this.rules.find((r) => r.compare(slc, state, width, ruleProps)) || null;
return rule;
}
/**
* Add new rule to the collection, if not yet exists with the same selectors
* @param {Array<Selector>} selectors Array of selectors
@ -157,27 +208,38 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
const s = state || '';
const w = width || '';
const opt = { ...opts } as CssRuleProperties;
let rule = this.get(selectors, s, w, opt);
const key = this._makeCacheKeyFromProps({
state: s,
mediaText: w,
...opt,
selectors: Array.isArray(selectors) ? selectors : [selectors],
});
const cached = this._itemCache.get(key);
if (cached && cached.config && !cached.config.singleAtRule) {
return cached;
}
let rule = this._findRule(selectors, s, w, opt);
// do not create rules that were found before
// unless this is a single at-rule, for which multiple declarations
// make sense (e.g. multiple `@font-type`s)
if (rule && rule.config && !rule.config.singleAtRule) {
return rule;
} else {
opt.state = s;
opt.mediaText = w;
opt.selectors = [];
// #4727: Prevent updating atRuleType if already defined
if (w && !opt.atRuleType) {
opt.atRuleType = 'media';
}
rule = new CssRule(opt, this.config);
// @ts-ignore
rule.get('selectors').add(selectors, addOpts);
this.rules.add(rule, addOpts);
this._cacheItem(rule);
return rule;
}
opt.state = s;
opt.mediaText = w;
opt.selectors = [];
if (w && !opt.atRuleType) opt.atRuleType = 'media';
rule = new CssRule(opt, this.config);
// @ts-ignore
rule.get('selectors').add(selectors, addOpts);
this.rules.add(rule, addOpts);
this._cacheItem(rule);
return rule;
}
/**
@ -199,20 +261,24 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
* color: '#000',
* });
* */
get(
selectors: any,
state?: string,
width?: string,
ruleProps?: Omit<CssRuleProperties, 'selectors'>,
): CssRule | undefined {
let slc = selectors;
if (isString(selectors)) {
const sm = this.em.Selectors;
const singleSel = selectors.split(',')[0].trim();
const node = this.em.Parser.parserCss.checkNode({ selectors: singleSel } as any)[0];
slc = sm.get(node.selectors as string[]);
get(selectors: any, state?: string, width?: string, ruleProps?: Omit<CssRuleProperties, 'selectors'>) {
const key = this._makeCacheKeyFromProps({
...ruleProps,
selectors: Array.isArray(selectors) ? selectors : [selectors],
state,
width,
mediaText: width,
});
const cached = this._itemCache.get(key);
if (cached) return cached;
const rule = this._findRule(selectors, state, width, ruleProps);
if (rule) {
this._cacheItem(rule);
}
return this.rules.find((rule) => rule.compare(slc, state, width, ruleProps)) || null;
return rule;
}
getAll() {
@ -485,8 +551,9 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
*/
remove(rule: string | CssRule, opts?: any) {
const toRemove = isString(rule) ? this.getRules(rule) : rule;
const result = this.getAll().remove(toRemove, opts);
return isArray(result) ? result : [result];
const arr = Array.isArray(toRemove) ? toRemove : [toRemove];
const result = this.getAll().remove(arr, opts);
return Array.isArray(result) ? result : [result];
}
/**
@ -494,6 +561,7 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
* @return {this}
*/
clear(opts = {}) {
this._clearItemCache();
this.getAll().reset([], opts);
return this;
}

32
packages/core/src/css_composer/model/CssRule.ts

@ -1,5 +1,5 @@
import { isEmpty, forEach, isString, isArray } from 'underscore';
import { Model, ObjectAny } from '../../common';
import { ObjectAny, ObjectHash } from '../../common';
import StyleableModel, { StyleProps } from '../../domain_abstract/model/StyleableModel';
import Selectors from '../../selector_manager/model/Selectors';
import { getMediaLength } from '../../code_manager/model/CssGenerator';
@ -16,7 +16,7 @@ export interface ToCssOptions {
}
/** @private */
export interface CssRuleProperties {
export interface CssRuleProperties extends ObjectHash {
/**
* Array of selectors
*/
@ -125,8 +125,8 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
this.opt = opt;
this.em = opt.em;
this.ensureSelectors(null, null, {});
this.setStyle(this.get('style'), { skipWatcherUpdates: true });
this.on('change', this.__onChange);
this.setStyle(this.get('style'));
}
__onChange(m: CssRule, opts: any) {
@ -135,12 +135,10 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
changed && !isEmptyObj(changed) && em?.changesUp(opts);
}
clone(): CssRule {
const opts = { ...this.opt };
const attr = { ...this.attributes };
attr.selectors = this.get('selectors')!.map((s) => s.clone() as Selector);
// @ts-ignore
return new this.constructor(attr, opts);
clone(): typeof this {
const selectors = this.get('selectors')!.map((s) => s.clone() as Selector);
return super.clone({ selectors });
}
ensureSelectors(m: any, c: any, opts: any) {
@ -177,9 +175,12 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
* cssRule.getAtRule(); // "@media (min-width: 500px)"
*/
getAtRule() {
const type = this.get('atRuleType');
const condition = this.get('mediaText');
// Avoid breaks with the last condition
return CssRule.getAtRuleFromProps(this.attributes);
}
static getAtRuleFromProps(cssRuleProps: Partial<CssRuleProperties>) {
const type = cssRuleProps.atRuleType;
const condition = cssRuleProps.mediaText;
const typeStr = type ? `@${type}` : condition ? '@media' : '';
return typeStr + (condition && typeStr ? ` ${condition}` : '');
@ -307,9 +308,8 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
return result;
}
toJSON(...args: any) {
const obj = Model.prototype.toJSON.apply(this, args);
toJSON(opts?: ObjectAny) {
const obj = super.toJSON(opts);
if (this.em?.getConfig().avoidDefaults) {
const defaults = this.defaults();
@ -326,7 +326,7 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
if (isEmpty(obj.style)) delete obj.style;
}
return { ...obj, style: this.dataResolverWatchers.getStylesDefsOrValues(obj.style) };
return obj;
}
/**

13
packages/core/src/data_sources/config/config.ts

@ -0,0 +1,13 @@
export interface DataSourcesConfig {
/**
* If true, data source providers will be autoloaded on project load.
* @default false
*/
autoloadProviders?: boolean;
}
const config: () => DataSourcesConfig = () => ({
autoloadProviders: false,
});
export default config;

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

@ -21,23 +21,25 @@
* @module DataSources
*/
import { isEmpty } from 'underscore';
import { ItemManagerModule, ModuleConfig } from '../abstract/Module';
import { AddOptions, collectionEvents, ObjectAny, RemoveOptions } from '../common';
import EditorModel from '../editor/model/Editor';
import { get, stringToPath } from '../utils/mixins';
import { get, set, stringToPath } from '../utils/mixins';
import defConfig, { DataSourcesConfig } from './config/config';
import DataRecord from './model/DataRecord';
import DataSource from './model/DataSource';
import DataSources from './model/DataSources';
import { DataSourcesEvents, DataSourceProps, DataRecordProps } from './types';
import { Events } from 'backbone';
export default class DataSourceManager extends ItemManagerModule<ModuleConfig, DataSources> {
export default class DataSourceManager extends ItemManagerModule<DataSourcesConfig & ModuleConfig, DataSources> {
storageKey = 'dataSources';
events = DataSourcesEvents;
destroy(): void {}
constructor(em: EditorModel) {
super(em, 'DataSources', new DataSources([], em), DataSourcesEvents);
super(em, 'DataSources', new DataSources([], em), DataSourcesEvents, defConfig());
Object.assign(this, Events); // Mixin Backbone.Events
}
@ -73,14 +75,36 @@ export default class DataSourceManager extends ItemManagerModule<ModuleConfig, D
}
/**
* Get value from data sources by key
* @param {String} key Path to value.
* @param {any} defValue
* Get value from data sources by path.
* @param {String} path Path to value.
* @param {any} defValue Default value if the path is not found.
* @returns {any}
* const value = dsm.getValue('ds_id.record_id.propName', 'defaultValue');
*/
getValue(key: string | string[], defValue: any) {
return get(this.getContext(), key, defValue);
getValue(path: string | string[], defValue?: any) {
return get(this.getContext(), path, defValue);
}
/**
* Set value in data sources by path.
* @param {String} path Path to value in format 'dataSourceId.recordId.propName'
* @param {any} value Value to set
* @returns {Boolean} Returns true if the value was set successfully
* @example
* dsm.setValue('ds_id.record_id.propName', 'new value');
*/
setValue(path: string, value: any) {
const [ds, record, propPath] = this.fromPath(path);
if (record && (propPath || propPath === '')) {
let attrs = { ...record.attributes };
if (set(attrs, propPath || '', value)) {
record.set(attrs);
return true;
}
}
return false;
}
private getContext() {
@ -88,7 +112,9 @@ export default class DataSourceManager extends ItemManagerModule<ModuleConfig, D
acc[ds.id] = ds.records.reduce((accR, dr, i) => {
const dataRecord = dr;
accR[dataRecord.id || i] = dataRecord.attributes;
const attributes = { ...dataRecord.attributes };
delete attributes.__p;
accR[dataRecord.id || i] = attributes;
return accR;
}, {} as ObjectAny);
@ -140,15 +166,16 @@ export default class DataSourceManager extends ItemManagerModule<ModuleConfig, D
* @returns {Array} Stored data sources.
*/
store() {
const data: any[] = [];
const data: DataSourceProps[] = [];
this.all.forEach((dataSource) => {
const skipFromStorage = dataSource.get('skipFromStorage');
const { skipFromStorage, transformers, records, schema, ...rest } = dataSource.attributes;
if (!skipFromStorage) {
data.push({
id: dataSource.id,
name: dataSource.get('name' as any),
records: dataSource.records.toJSON(),
skipFromStorage,
...rest,
id: rest.id!,
schema: !isEmpty(schema) ? schema : undefined,
records: !rest.provider ? records : undefined,
});
}
});
@ -162,11 +189,29 @@ export default class DataSourceManager extends ItemManagerModule<ModuleConfig, D
* @returns {Object} Loaded data sources.
*/
load(data: any) {
return this.loadProjectData(data);
const { config, all, events, em } = this;
const result = this.loadProjectData(data);
if (config.autoloadProviders) {
const dsWithProviders = all.filter((ds) => ds.hasProvider);
if (!!dsWithProviders.length) {
const loadProviders = async () => {
em.trigger(events.providerLoadAllBefore);
const providersToLoad = dsWithProviders.map((ds) => ds.loadProvider());
await Promise.all(providersToLoad);
em.trigger(events.providerLoadAll);
};
loadProviders();
}
}
return result;
}
postLoad() {
const { em, all } = this;
em.listenTo(all, collectionEvents, (m, c, o) => em.changesUp(o || c));
this.em.UndoManager.add(all);
}
}

124
packages/core/src/data_sources/model/ComponentWithCollectionsState.ts

@ -0,0 +1,124 @@
import { DataCollectionStateMap } from '../../data_sources/model/data_collection/types';
import DataResolverListener from '../../data_sources/model/DataResolverListener';
import DataVariable, { DataVariableProps, DataVariableType } from '../../data_sources/model/DataVariable';
import Components from '../../dom_components/model/Components';
import Component from '../../dom_components/model/Component';
import { ObjectAny } from '../../common';
import DataSource from './DataSource';
import { isArray } from 'underscore';
export type DataVariableMap = Record<string, DataVariableProps>;
export type DataSourceRecords = DataVariableProps[] | DataVariableMap;
export default class ComponentWithCollectionsState<DataResolverType> extends Component {
collectionsStateMap: DataCollectionStateMap = {};
dataSourceWatcher?: DataResolverListener;
constructor(props: any, opt: any) {
super(props, opt);
this.listenToPropsChange();
}
onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) {
this.collectionsStateMap = collectionsStateMap;
this.dataResolverWatchers?.onCollectionsStateMapUpdate?.();
this.components().forEach((cmp) => {
cmp.onCollectionsStateMapUpdate?.(collectionsStateMap);
});
}
syncOnComponentChange(model: Component, collection: Components, opts: any) {
const prev = this.collectionsStateMap;
this.collectionsStateMap = {};
super.syncOnComponentChange(model, collection, opts);
this.collectionsStateMap = prev;
this.onCollectionsStateMapUpdate(prev);
}
setDataResolver(dataResolver: DataResolverType | undefined) {
return this.set('dataResolver', dataResolver);
}
getDataResolver() {
return this.dataResolverProps;
}
get dataResolverProps(): DataResolverType | undefined {
return this.get('dataResolver');
}
protected listenToDataSource() {
const path = this.dataResolverPath;
if (!path) return;
const { em, collectionsStateMap } = this;
this.dataSourceWatcher?.destroy();
this.dataSourceWatcher = new DataResolverListener({
em,
resolver: new DataVariable({ type: DataVariableType, path }, { em, collectionsStateMap }),
onUpdate: () => this.onDataSourceChange(),
});
}
protected listenToPropsChange() {
this.on(`change:dataResolver`, () => {
this.listenToDataSource();
});
this.listenToDataSource();
}
protected get dataSourceProps(): DataVariableProps | undefined {
return this.get('dataResolver');
}
protected get dataResolverPath(): string | undefined {
return this.dataSourceProps?.path;
}
protected onDataSourceChange() {
this.onCollectionsStateMapUpdate(this.collectionsStateMap);
}
protected getDataSourceItems(): DataSourceRecords {
const dataSourceProps = this.dataSourceProps;
if (!dataSourceProps) return [];
const items = this.listDataSourceItems(dataSourceProps);
if (items && isArray(items)) {
return items;
}
const clone = { ...items };
return clone;
}
protected listDataSourceItems(dataSource: DataSource | DataVariableProps): DataSourceRecords {
const path = dataSource instanceof DataSource ? dataSource.get('id')! : dataSource.path;
if (!path) return [];
let value = this.em.DataSources.getValue(path, []);
const isDatasourceId = path.split('.').length === 1;
if (isDatasourceId) {
value = Object.entries(value).map(([_, value]) => value);
}
return value;
}
protected getItemKey(items: DataVariableProps[] | { [x: string]: DataVariableProps }, index: number) {
return isArray(items) ? index : Object.keys(items)[index];
}
private removePropsListeners() {
this.off(`change:dataResolver`);
this.dataSourceWatcher?.destroy();
this.dataSourceWatcher = undefined;
}
destroy(options?: ObjectAny): false | JQueryXHR {
this.removePropsListeners();
return super.destroy(options);
}
}

17
packages/core/src/data_sources/model/DataResolverListener.ts

@ -17,7 +17,7 @@ export interface DataResolverListenerProps {
}
interface ListenerWithCallback extends DataSourceListener {
callback: () => void;
callback: (opts?: any) => void;
}
export default class DataResolverListener {
@ -39,7 +39,11 @@ export default class DataResolverListener {
this.onUpdate(value);
};
private createListener(obj: any, event: string, callback: () => void = this.onChange): ListenerWithCallback {
private createListener(
obj: any,
event: string,
callback: (opts?: any) => void = this.onChange,
): ListenerWithCallback {
return { obj, event, callback };
}
@ -98,6 +102,15 @@ export default class DataResolverListener {
dataListeners.push(
this.createListener(em.DataSources.all, 'add remove reset', onChangeAndRewatch),
this.createListener(em, `${DataSourcesEvents.path}:${normPath}`),
this.createListener(em, DataSourcesEvents.path, ({ path: eventPath }: { path: string }) => {
if (
// Skip same path as it's already handled be the listener above
eventPath !== path &&
eventPath.startsWith(path)
) {
this.onChange();
}
}),
);
return dataListeners;

142
packages/core/src/data_sources/model/DataSource.ts

@ -29,9 +29,26 @@
* @extends {Model<DataSourceProps>}
*/
import { AddOptions, collectionEvents, CombinedModelConstructorOptions, Model, RemoveOptions } from '../../common';
import { isString } from 'underscore';
import {
AddOptions,
collectionEvents,
CombinedModelConstructorOptions,
Model,
RemoveOptions,
SetOptions,
} from '../../common';
import EditorModel from '../../editor/model/Editor';
import { DataSourceTransformers, DataSourceType, DataSourceProps, DataRecordProps } from '../types';
import {
DataFieldPrimitiveType,
DataFieldSchemaRelation,
DataRecordProps,
DataSourceProps,
DataSourceProviderResult,
DataSourceTransformers,
DataSourceType,
} from '../types';
import { DEF_DATA_FIELD_ID } from '../utils';
import DataRecord from './DataRecord';
import DataRecords from './DataRecords';
import DataSources from './DataSources';
@ -68,6 +85,7 @@ export default class DataSource<DRProps extends DataRecordProps = DataRecordProp
constructor(props: DataSourceProps<DRProps>, opts: DataSourceOptions) {
super(
{
schema: {},
...props,
records: [],
} as unknown as DataSourceType<DRProps>,
@ -94,6 +112,16 @@ export default class DataSource<DRProps extends DataRecordProps = DataRecordProp
return this.attributes.records as NonNullable<DataRecords<DRProps>>;
}
/**
* Retrieves the collection of records associated with this data source.
*
* @returns {DataRecords<DRProps>} The collection of data records.
* @name records
*/
get schema() {
return this.attributes.schema!;
}
/**
* Retrieves the editor model associated with this data source.
*
@ -104,6 +132,13 @@ export default class DataSource<DRProps extends DataRecordProps = DataRecordProp
return (this.collection as unknown as DataSources).em;
}
/**
* Indicates if the data source has a provider for records.
*/
get hasProvider() {
return !!this.attributes.provider;
}
/**
* Handles the `add` event for records in the data source.
* This method triggers a change event on the newly added record.
@ -135,8 +170,8 @@ export default class DataSource<DRProps extends DataRecordProps = DataRecordProp
* @returns {DataRecord<DRProps> | undefined} The data record, or `undefined` if no record is found with the given ID.
* @name getRecord
*/
getRecord(id: string | number) {
return this.records.get(id) as DataRecord | undefined;
getRecord(id: string | number): DataRecord | undefined {
return this.records.get(id);
}
/**
@ -150,6 +185,86 @@ export default class DataSource<DRProps extends DataRecordProps = DataRecordProp
return [...this.records.models].map((record) => this.getRecord(record.id)!);
}
/**
* Retrieves all records from the data source with resolved relations based on the schema.
*/
getResolvedRecords() {
const schemaEntries = Object.entries(this.schema);
const records = this.getRecords().map((record) => {
const result = { ...record.attributes };
if (schemaEntries.length === 0) return result;
schemaEntries.forEach(([fieldName, schema]) => {
const fieldSchema = schema as DataFieldSchemaRelation;
if (fieldSchema?.type === DataFieldPrimitiveType.relation && fieldSchema.target) {
const relationValue = result[fieldName];
if (relationValue) {
const targetDs = this.em.DataSources.get(fieldSchema.target);
if (targetDs) {
const targetRecords = targetDs.records;
const targetField = fieldSchema.targetField || DEF_DATA_FIELD_ID;
if (fieldSchema.isMany) {
const relationValues = Array.isArray(relationValue) ? relationValue : [relationValue];
const relatedRecords = targetRecords.filter((r) => relationValues.includes(r.attributes[targetField]));
result[fieldName] = relatedRecords.map((r) => ({ ...r.attributes }));
} else {
const relatedRecord = targetDs.records.find((r) => r.attributes[targetField] === relationValue);
if (relatedRecord) {
result[fieldName] = { ...relatedRecord.attributes };
}
}
}
}
}
});
return result;
});
return records;
}
async loadProvider() {
const { attributes, em } = this;
const { provider } = attributes;
if (!provider) return;
if (isString(provider)) {
// TODO: implement providers as plugins (later)
return;
}
const providerGet = isString(provider.get) ? { url: provider.get } : provider.get;
const { url, method, headers, body } = providerGet;
const fetchProvider = async () => {
const dataSource = this;
try {
em.trigger(em.DataSources.events.providerLoadBefore, { dataSource });
const response = await fetch(url, { method, headers, body });
if (!response.ok) throw new Error(await response.text());
const result: DataSourceProviderResult = await response.json();
if (result?.records) this.setRecords(result.records as any);
if (result?.schema) this.upSchema(result.schema);
em.trigger(em.DataSources.events.providerLoad, { result, dataSource });
} catch (error: any) {
em.logError(error.message);
em.trigger(em.DataSources.events.providerLoadError, { dataSource, error });
}
};
await fetchProvider();
}
/**
* Removes a record from the data source by its ID.
*
@ -182,6 +297,25 @@ export default class DataSource<DRProps extends DataRecordProps = DataRecordProp
});
}
/**
* Update the schema.
* @example
* dataSource.upSchema({ name: { type: 'string' } });
*/
upSchema(schema: Partial<typeof this.schema>, opts?: SetOptions) {
this.set('schema', { ...this.schema, ...schema }, opts);
}
/**
* Get schema field definition.
* @example
* const fieldSchema = dataSource.getSchemaField('name');
* fieldSchema.type; // 'string'
*/
getSchemaField(fieldKey: keyof DRProps) {
return this.schema[fieldKey];
}
private handleChanges(m: any, c: any, o: any) {
this.em.changesUp(o || c);
}

36
packages/core/src/data_sources/model/DataVariable.ts

@ -1,7 +1,13 @@
import { Model } from '../../common';
import { keyRootData } from '../../dom_components/constants';
import EditorModel from '../../editor/model/Editor';
import { isDataVariable } from '../utils';
import { DataCollectionStateMap, DataCollectionState, DataCollectionStateType } from './data_collection/types';
import {
DataCollectionStateMap,
DataCollectionState,
DataCollectionStateType,
RootDataType,
} from './data_collection/types';
export const DataVariableType = 'data-variable' as const;
@ -134,36 +140,44 @@ export default class DataVariable extends Model<DataVariableProps> {
);
}
private resolveCollectionVariable(): unknown {
private resolveCollectionVariable() {
const { em, collectionsStateMap } = this;
return DataVariable.resolveCollectionVariable(this.attributes, { em, collectionsStateMap });
}
static resolveCollectionVariable(
dataResolverProps: {
params: {
collectionId?: string;
variableType?: DataCollectionStateType;
path?: string;
defaultValue?: string;
},
opts: DataVariableOptions,
): unknown {
const { collectionId = '', variableType, path, defaultValue = '' } = dataResolverProps;
const { em, collectionsStateMap } = opts;
ctx: DataVariableOptions,
) {
const { collectionId = '', variableType, path, defaultValue = '' } = params;
const { em, collectionsStateMap } = ctx;
if (!collectionsStateMap) return defaultValue;
const collectionItem = collectionsStateMap[collectionId];
if (!collectionItem) return defaultValue;
if (collectionId === keyRootData) {
const root = collectionItem as RootDataType;
return path ? root?.[path as keyof RootDataType] : root;
}
if (!variableType) {
em.logError(`Missing collection variable type for collection: ${collectionId}`);
return defaultValue;
}
return variableType === 'currentItem'
? DataVariable.resolveCurrentItem(collectionItem, path, collectionId, em)
: collectionItem[variableType];
if (variableType === 'currentItem') {
return DataVariable.resolveCurrentItem(collectionItem as DataCollectionState, path, collectionId, em);
}
const state = collectionItem as DataCollectionState;
return state[variableType] ?? defaultValue;
}
private static resolveCurrentItem(
@ -171,7 +185,7 @@ export default class DataVariable extends Model<DataVariableProps> {
path: string | undefined,
collectionId: string,
em: EditorModel,
): unknown {
) {
const currentItem = collectionItem.currentItem;
if (!currentItem) {
em.logError(`Current item is missing for collection: ${collectionId}`);

4
packages/core/src/data_sources/model/conditional_variables/DataCondition.ts

@ -103,14 +103,14 @@ export class DataCondition extends Model<DataConditionProps> {
return this._conditionEvaluator.evaluate();
}
getDataValue(skipDynamicValueResolution: boolean = false): any {
getDataValue(skipResolve: boolean = false): any {
const { em, collectionsStateMap } = this;
const options = { em, collectionsStateMap };
const ifTrue = this.getIfTrue();
const ifFalse = this.getIfFalse();
const isConditionTrue = this.isTrue();
if (skipDynamicValueResolution) {
if (skipResolve) {
return isConditionTrue ? ifTrue : ifFalse;
}

213
packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts

@ -1,13 +1,11 @@
import { isArray } from 'underscore';
import { isArray, size } from 'underscore';
import { ObjectAny } from '../../../common';
import Component, { keySymbol } from '../../../dom_components/model/Component';
import { ComponentAddType, ComponentDefinitionDefined, ComponentOptions } from '../../../dom_components/model/types';
import EditorModel from '../../../editor/model/Editor';
import { isObject, toLowerCase } from '../../../utils/mixins';
import { toLowerCase } from '../../../utils/mixins';
import DataResolverListener from '../DataResolverListener';
import DataSource from '../DataSource';
import DataVariable, { DataVariableProps, DataVariableType } from '../DataVariable';
import { isDataVariable } from '../../utils';
import { DataVariableProps } from '../DataVariable';
import { DataCollectionItemType, DataCollectionType, keyCollectionDefinition } from './constants';
import {
ComponentDataCollectionProps,
@ -16,14 +14,12 @@ import {
DataCollectionStateMap,
} from './types';
import { detachSymbolInstance, getSymbolInstances } from '../../../dom_components/model/SymbolUtils';
import { updateFromWatcher } from '../../../dom_components/model/ModelDataResolverWatchers';
import { ModelDestroyOptions } from 'backbone';
import Components from '../../../dom_components/model/Components';
import { keyDataValues, updateFromWatcher } from '../../../dom_components/model/ModelDataResolverWatchers';
import ComponentWithCollectionsState, { DataVariableMap } from '../ComponentWithCollectionsState';
const AvoidStoreOptions = { avoidStore: true, partial: true };
type DataVariableMap = Record<string, DataVariableProps>;
export default class ComponentDataCollection extends Component {
export default class ComponentDataCollection extends ComponentWithCollectionsState<DataCollectionProps> {
dataSourceWatcher?: DataResolverListener;
get defaults(): ComponentDefinitionDefined {
@ -55,10 +51,6 @@ export default class ComponentDataCollection extends Component {
return cmp;
}
getDataResolver() {
return this.get('dataResolver');
}
getItemsCount() {
const items = this.getDataSourceItems();
const itemsCount = getLength(items);
@ -91,10 +83,6 @@ export default class ComponentDataCollection extends Component {
return this.firstChild.components();
}
setDataResolver(props: DataCollectionProps) {
return this.set('dataResolver', props);
}
setCollectionId(collectionId: string) {
this.updateCollectionConfig({ collectionId });
}
@ -123,59 +111,81 @@ export default class ComponentDataCollection extends Component {
this.firstChild.components(content);
}
private get firstChild() {
return this.components().at(0);
}
onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) {
super.onCollectionsStateMapUpdate(collectionsStateMap);
private updateCollectionConfig(updates: Partial<DataCollectionProps>): void {
this.set(keyCollectionDefinition, {
...this.dataResolver,
...updates,
const items = this.getDataSourceItems();
const { startIndex } = this.resolveCollectionConfig(items);
const cmps = this.components();
cmps.forEach((cmp, index) => {
const key = this.getItemKey(items, startIndex + index);
const collectionsStateMap = this.getCollectionsStateMapForItem(items, key);
cmp.onCollectionsStateMapUpdate(collectionsStateMap);
});
}
private getDataSourceItems() {
const items = getDataSourceItems(this.dataResolver.dataSource, this.em);
if (isArray(items)) {
return items;
}
protected stopSyncComponentCollectionState() {
this.stopListening(this.components(), 'add remove reset', this.syncOnComponentChange);
this.onCollectionsStateMapUpdate({});
}
protected setCollectionStateMapAndPropagate(cmp: Component, collectionsStateMap: DataCollectionStateMap) {
cmp.setSymbolOverride(['locked', 'layerable', keyDataValues]);
cmp.syncComponentsCollectionState();
cmp.onCollectionsStateMapUpdate(collectionsStateMap);
}
const clone = { ...items };
delete clone['__p'];
return clone;
protected onDataSourceChange() {
this.rebuildChildrenFromCollection();
}
private get dataResolver() {
return (this.get(keyCollectionDefinition) || {}) as DataCollectionProps;
protected listenToPropsChange() {
this.on(`change:${keyCollectionDefinition}`, () => {
this.rebuildChildrenFromCollection();
this.listenToDataSource();
});
this.listenToDataSource();
}
private get collectionDataSource() {
protected get dataSourceProps(): DataVariableProps | undefined {
return this.dataResolver.dataSource;
}
private listenToDataSource() {
const { em } = this;
const path = this.collectionDataSource?.path;
if (!path) return;
this.dataSourceWatcher = new DataResolverListener({
em,
resolver: new DataVariable(
{ type: DataVariableType, path },
{ em, collectionsStateMap: this.collectionsStateMap },
),
onUpdate: this.rebuildChildrenFromCollection,
protected get dataResolver(): DataCollectionProps {
return this.get(keyCollectionDefinition) || {};
}
private get firstChild() {
return this.components().at(0);
}
private updateCollectionConfig(updates: Partial<DataCollectionProps>): void {
this.set(keyCollectionDefinition, {
...this.dataResolver,
...updates,
});
}
private rebuildChildrenFromCollection() {
this.components().reset(this.getCollectionItems(), updateFromWatcher as any);
const items = this.getDataSourceItems();
const { totalItems } = this.resolveCollectionConfig(items);
if (totalItems === this.components().length) {
this.onCollectionsStateMapUpdate(this.collectionsStateMap);
return;
}
const collectionItems = this.getCollectionItems(items as any);
this.components().reset(collectionItems, updateFromWatcher as any);
}
private getCollectionItems() {
private getCollectionItems(items?: any[]) {
const firstChild = this.ensureFirstChild();
const displayStyle = firstChild.getStyle()['display'];
const isDisplayNoneOrMissing = !displayStyle || displayStyle === 'none';
const resolvedDisplay = isDisplayNoneOrMissing ? '' : displayStyle;
// TODO: Move to component view
firstChild.addStyle({ display: 'none' }, AvoidStoreOptions);
const components: Component[] = [firstChild];
@ -186,36 +196,33 @@ export default class ComponentDataCollection extends Component {
}
const collectionId = this.collectionId;
const items = this.getDataSourceItems();
const { startIndex, endIndex } = this.resolveCollectionConfig(items);
const dataItems = items ?? this.getDataSourceItems();
const { startIndex, endIndex } = this.resolveCollectionConfig(dataItems);
const isDuplicatedId = this.hasDuplicateCollectionId();
if (isDuplicatedId) {
this.em.logError(
`The collection ID "${collectionId}" already exists in the parent collection state. Overriding it is not allowed.`,
);
return components;
}
for (let index = startIndex; index <= endIndex; index++) {
const isFirstItem = index === startIndex;
const key = isArray(items) ? index : Object.keys(items)[index];
const collectionsStateMap = this.getCollectionsStateMapForItem(items, key);
const key = this.getItemKey(dataItems, index);
const collectionsStateMap = this.getCollectionsStateMapForItem(dataItems, key);
if (isFirstItem) {
getSymbolInstances(firstChild)?.forEach((cmp) => detachSymbolInstance(cmp));
setCollectionStateMapAndPropagate(firstChild, collectionsStateMap);
this.setCollectionStateMapAndPropagate(firstChild, collectionsStateMap);
// TODO: Move to component view
firstChild.addStyle({ display: resolvedDisplay }, AvoidStoreOptions);
continue;
}
const instance = firstChild!.clone({ symbol: true, symbolInv: true });
const instance = firstChild.clone({ symbol: true, symbolInv: true });
instance.set({ locked: true, layerable: false }, AvoidStoreOptions);
setCollectionStateMapAndPropagate(instance, collectionsStateMap);
this.setCollectionStateMapAndPropagate(instance, collectionsStateMap);
components.push(instance);
}
@ -287,49 +294,8 @@ export default class ComponentDataCollection extends Component {
);
}
private listenToPropsChange() {
this.on(`change:${keyCollectionDefinition}`, () => {
this.rebuildChildrenFromCollection();
this.listenToDataSource();
});
this.listenToDataSource();
}
private removePropsListeners() {
this.off(`change:${keyCollectionDefinition}`);
this.dataSourceWatcher?.destroy();
}
onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) {
this.collectionsStateMap = collectionsStateMap;
this.dataResolverWatchers.onCollectionsStateMapUpdate();
const items = this.getDataSourceItems();
const { startIndex } = this.resolveCollectionConfig(items);
const cmps = this.components();
cmps.forEach((cmp, index) => {
const collectionsStateMap = this.getCollectionsStateMapForItem(items, startIndex + index);
cmp.onCollectionsStateMapUpdate(collectionsStateMap);
});
}
stopSyncComponentCollectionState() {
this.stopListening(this.components(), 'add remove reset', this.syncOnComponentChange);
this.onCollectionsStateMapUpdate({});
}
syncOnComponentChange(model: Component, collection: Components, opts: any) {
const collectionsStateMap = this.collectionsStateMap;
// Avoid assigning wrong collectionsStateMap value to children components
this.collectionsStateMap = {};
super.syncOnComponentChange(model, collection, opts);
this.collectionsStateMap = collectionsStateMap;
this.onCollectionsStateMapUpdate(collectionsStateMap);
}
private get collectionId() {
return this.getDataResolver().collectionId as string;
return this.dataResolverProps?.collectionId ?? '';
}
static isComponent(el: HTMLElement) {
@ -345,23 +311,12 @@ export default class ComponentDataCollection extends Component {
const firstChild = this.firstChild as any;
return { ...json, components: [firstChild] };
}
destroy(options?: ModelDestroyOptions | undefined): false | JQueryXHR {
this.removePropsListeners();
return super.destroy(options);
}
}
function getLength(items: DataVariableProps[] | object) {
return isArray(items) ? items.length : Object.keys(items).length;
}
function setCollectionStateMapAndPropagate(cmp: Component, collectionsStateMap: DataCollectionStateMap) {
cmp.setSymbolOverride(['locked', 'layerable']);
cmp.syncComponentsCollectionState();
cmp.onCollectionsStateMapUpdate(collectionsStateMap);
}
function logErrorIfMissing(property: any, propertyPath: string, em: EditorModel) {
if (!property) {
em.logError(`The "${propertyPath}" property is required in the collection definition.`);
@ -390,37 +345,3 @@ function validateCollectionDef(dataResolver: DataCollectionProps, em: EditorMode
return true;
}
function getDataSourceItems(
dataSource: DataCollectionDataSource,
em: EditorModel,
): DataVariableProps[] | DataVariableMap {
switch (true) {
case isObject(dataSource) && dataSource instanceof DataSource: {
const id = dataSource.get('id')!;
return listDataSourceVariables(id, em);
}
case isDataVariable(dataSource): {
const path = dataSource.path;
if (!path) return [];
const isDataSourceId = path.split('.').length === 1;
if (isDataSourceId) {
return listDataSourceVariables(path, em);
} else {
return em.DataSources.getValue(path, []);
}
}
default:
return [];
}
}
function listDataSourceVariables(dataSource_id: string, em: EditorModel): DataVariableProps[] {
const records = em.DataSources.getValue(dataSource_id, []);
const keys = Object.keys(records);
return keys.map((key) => ({
type: DataVariableType,
path: dataSource_id + '.' + key,
}));
}

7
packages/core/src/data_sources/model/data_collection/types.ts

@ -1,6 +1,8 @@
import { DataCollectionType, keyCollectionDefinition } from './constants';
import { ComponentDefinition } from '../../../dom_components/model/types';
import { DataVariableProps } from '../DataVariable';
import { keyRootData } from '../../../dom_components/constants';
import { ObjectAny } from '../../../common';
export type DataCollectionDataSource = DataVariableProps;
@ -26,8 +28,11 @@ export interface DataCollectionState {
[DataCollectionStateType.remainingItems]: number;
}
export type RootDataType = Array<ObjectAny> | ObjectAny;
export interface DataCollectionStateMap {
[key: string]: DataCollectionState;
[key: string]: DataCollectionState | RootDataType | undefined;
rootData?: RootDataType;
}
export interface ComponentDataCollectionProps extends ComponentDefinition {

137
packages/core/src/data_sources/types.ts

@ -1,8 +1,9 @@
import { Model, Collection, ObjectAny } from '../common';
import { AddOptions, Collection, Model, ObjectAny, RemoveOptions, SetOptions } from '../common';
import DataRecord from './model/DataRecord';
import DataRecords from './model/DataRecords';
import DataSource from './model/DataSource';
import DataVariable, { DataVariableProps } from './model/DataVariable';
import { DataConditionProps, DataCondition } from './model/conditional_variables/DataCondition';
import { DataCondition, DataConditionProps } from './model/conditional_variables/DataCondition';
export type DataResolver = DataVariable | DataCondition;
export type DataResolverProps = DataVariableProps | DataConditionProps;
@ -46,12 +47,107 @@ interface BaseDataSource {
* If true will store the data source in the GrapesJS project.json file.
*/
skipFromStorage?: boolean;
[key: string]: unknown;
}
export enum DataFieldPrimitiveType {
string = 'string',
number = 'number',
boolean = 'boolean',
date = 'date',
json = 'json',
relation = 'relation',
}
export interface DataFieldSchemaBase<T = unknown> {
default?: T;
description?: string;
label?: string;
[key: string]: unknown;
// order?: number;
// primary?: boolean;
// required?: boolean;
// unique?: boolean;
// validate?: (value: T, record: Record<string, any>) => boolean;
}
export interface DataFieldSchemaString extends DataFieldSchemaBase<string> {
type: DataFieldPrimitiveType.string;
enum?: string[];
}
export interface DataFieldSchemaNumber extends DataFieldSchemaBase<number> {
type: DataFieldPrimitiveType.number;
}
export interface DataFieldSchemaBoolean extends DataFieldSchemaBase<boolean> {
type: DataFieldPrimitiveType.boolean;
}
export interface DataFieldSchemaDate extends DataFieldSchemaBase<Date> {
type: DataFieldPrimitiveType.date;
}
export interface DataFieldSchemaJSON extends DataFieldSchemaBase<any> {
type: DataFieldPrimitiveType.json;
}
export interface DataFieldSchemaRelation extends DataFieldSchemaBase {
type: DataFieldPrimitiveType.relation;
/**
* The target data source ID
*/
target: string;
/**
* The target field in the data source
*/
targetField?: string;
/**
* If true, the relation is one-to-many
*/
isMany?: boolean;
}
export type DataFieldSchemas =
| DataFieldSchemaString
| DataFieldSchemaNumber
| DataFieldSchemaBoolean
| DataFieldSchemaDate
| DataFieldSchemaJSON
| DataFieldSchemaRelation;
export type DataSourceSchema<DR extends DataRecordProps = DataRecordProps> = {
[K in keyof DR]?: DataFieldSchemas;
};
export interface DataSourceProviderMethodProps {
url: string;
method?: string;
headers?: HeadersInit;
body?: BodyInit;
}
export interface DataSourceProviderDefinitionProps {
get: string | DataSourceProviderMethodProps;
}
export interface DataSourceProviderResult {
records?: DataRecordProps[];
schema?: DataSourceSchema;
}
export type DataSourceProviderProp = string | DataSourceProviderDefinitionProps;
export interface DataSourceType<DR extends DataRecordProps> extends BaseDataSource {
records: DataRecords<DR>;
schema: DataSourceSchema<DR>;
provider?: DataSourceProviderProp;
}
export interface DataSourceProps<DR extends DataRecordProps> extends BaseDataSource {
export interface DataSourceProps<DR extends DataRecordProps = DataRecordProps> extends BaseDataSource {
records?: DataRecords<DR> | DataRecord<DR>[] | DR[];
schema?: DataSourceSchema<DR>;
provider?: DataSourceProviderProp;
}
export type RecordPropsType<T> = T extends DataRecord<infer U> ? U : never;
export interface DataSourceTransformers {
@ -80,6 +176,8 @@ export type DeepPartialDot<T> = {
: never;
};
export type DataSourceEvent = `${DataSourcesEvents}`;
/**{START_EVENTS}*/
export enum DataSourcesEvents {
/**
@ -122,6 +220,23 @@ export enum DataSourcesEvents {
*/
pathSource = 'data:pathSource:',
/**
* @event `data:provider:load` Data source provider load.
* @example
* editor.on('data:provider:load', ({ dataSource, result }) => { ... });
*/
providerLoad = 'data:provider:load',
providerLoadBefore = 'data:provider:load:before',
providerLoadError = 'data:provider:load:error',
/**
* @event `data:provider:loadAll` Load of all data source providers (eg. on project load).
* @example
* editor.on('data:provider:loadAll', () => { ... });
*/
providerLoadAll = 'data:provider:loadAll',
providerLoadAllBefore = 'data:provider:loadAll:before',
/**
* @event `data` Catch-all event for all the events mentioned above.
* @example
@ -131,5 +246,21 @@ export enum DataSourcesEvents {
}
/**{END_EVENTS}*/
export interface DataSourcesEventCallback {
[DataSourcesEvents.add]: [DataSource, AddOptions];
[DataSourcesEvents.remove]: [DataSource, RemoveOptions];
[DataSourcesEvents.update]: [DataSource, AddOptions];
[DataSourcesEvents.path]: [{ dataSource: DataSource; dataRecord: DataRecord; path: string; options: SetOptions }];
[DataSourcesEvents.pathSource]: [
{ dataSource: DataSource; dataRecord: DataRecord; path: string; options: SetOptions },
];
[DataSourcesEvents.providerLoad]: [{ dataSource: DataSource; result: DataSourceProviderResult }];
[DataSourcesEvents.providerLoadBefore]: [{ dataSource: DataSource }];
[DataSourcesEvents.providerLoadError]: [{ dataSource: DataSource; error: Error }];
[DataSourcesEvents.providerLoadAll]: [];
[DataSourcesEvents.providerLoadAllBefore]: [];
[DataSourcesEvents.all]: [{ event: DataSourceEvent; model?: Model; options: ObjectAny }];
}
// need this to avoid the TS documentation generator to break
export default DataSourcesEvents;

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

@ -10,6 +10,8 @@ import { DataConditionIfFalseType, DataConditionIfTrueType } from './model/condi
import { getSymbolMain } from '../dom_components/model/SymbolUtils';
import Component from '../dom_components/model/Component';
export const DEF_DATA_FIELD_ID = 'id';
export function isDataResolverProps(value: any): value is DataResolverProps {
return typeof value === 'object' && [DataVariableType, DataConditionType].includes(value?.type);
}
@ -49,7 +51,7 @@ export function getDataResolverInstance(
break;
}
default:
options.em?.logWarning(`Unsupported dynamic type: ${type}`);
options.em?.logWarning(`Unsupported resolver type: ${type}`);
return;
}

7
packages/core/src/device_manager/index.ts

@ -13,12 +13,7 @@
* ```js
* const deviceManager = editor.Devices;
* ```
* ## Available Events
* * `device:add` - Added new device. The [Device] is passed as an argument to the callback
* * `device:remove` - Device removed. The [Device] is passed as an argument to the callback
* * `device:select` - New device selected. The newly selected [Device] and the previous one, are passed as arguments to the callback
* * `device:update` - Device updated. The updated [Device] and the object containing changes are passed as arguments to the callback
* * `device` - Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback
* {REPLACE_EVENTS}
*
* ## Methods
* * [add](#add)

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

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

175
packages/core/src/dom_components/model/Component.ts

@ -11,8 +11,20 @@ import {
bindAll,
keys,
} from 'underscore';
import { shallowDiff, capitalize, isEmptyObj, isObject, toLowerCase } from '../../utils/mixins';
import StyleableModel, { StyleProps, UpdateStyleOptions } from '../../domain_abstract/model/StyleableModel';
import {
shallowDiff,
capitalize,
isEmptyObj,
isObject,
toLowerCase,
escapeAltQuoteAttrValue,
escapeAttrValue,
} from '../../utils/mixins';
import StyleableModel, {
GetStyleOpts,
StyleProps,
UpdateStyleOptions,
} from '../../domain_abstract/model/StyleableModel';
import { Model, ModelDestroyOptions } from 'backbone';
import Components from './Components';
import Selector from '../../selector_manager/model/Selector';
@ -52,14 +64,14 @@ import {
updateSymbolProps,
getSymbolsToUpdate,
} from './SymbolUtils';
import { ModelDataResolverWatchers } from './ModelDataResolverWatchers';
import { DynamicWatchersOptions } from './ModelResolverWatcher';
import { DataWatchersOptions } from './ModelResolverWatcher';
import { DataCollectionStateMap } from '../../data_sources/model/data_collection/types';
import { checkAndGetSyncableCollectionItemId } from '../../data_sources/utils';
import { keyRootData } from '../constants';
export interface IComponent extends ExtractMethods<Component> {}
export interface SetAttrOptions extends SetOptions, UpdateStyleOptions, DynamicWatchersOptions {}
export interface ComponentSetOptions extends SetOptions, DynamicWatchersOptions {}
export interface SetAttrOptions extends SetOptions, UpdateStyleOptions, DataWatchersOptions {}
export interface ComponentSetOptions extends SetOptions, DataWatchersOptions {}
const escapeRegExp = (str: string) => {
return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
@ -74,6 +86,10 @@ export const keySymbolOvrd = '__symbol_ovrd';
export const keyUpdate = ComponentsEvents.update;
export const keyUpdateInside = ComponentsEvents.updateInside;
type GetComponentStyleOpts = GetStyleOpts & {
inline?: boolean;
};
/**
* The Component object represents a single node of our template structure, so when you update its properties the changes are
* immediately reflected on the canvas and in the code to export (indeed, when you ask to export the code we just go through all
@ -256,7 +272,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
views!: ComponentView[];
view?: ComponentView;
viewLayer?: ItemView;
rule?: CssRule;
rule?: CssRule | null;
prevColl?: Components;
__hasUm?: boolean;
__symbReady?: boolean;
@ -294,12 +310,12 @@ export default class Component extends StyleableModel<ComponentProperties> {
this.opt = opt;
this.em = em!;
this.config = opt.config || {};
const dynamicAttributes = this.dataResolverWatchers.getDynamicAttributesDefs();
this.setAttributes({
const defaultAttrs = {
...(result(this, 'defaults').attributes || {}),
...(this.get('attributes') || {}),
...dynamicAttributes,
});
};
const attrs = this.dataResolverWatchers.getValueOrResolver('attributes', defaultAttrs);
this.setAttributes(attrs);
this.ccid = Component.createId(this, opt);
this.preInit();
this.initClasses();
@ -343,34 +359,9 @@ export default class Component extends StyleableModel<ComponentProperties> {
}
}
set<A extends string>(
keyOrAttributes: A | Partial<ComponentProperties>,
valueOrOptions?: ComponentProperties[A] | ComponentSetOptions,
optionsOrUndefined?: ComponentSetOptions,
): this {
let attributes: Partial<ComponentProperties>;
let options: ComponentSetOptions & {
dataResolverWatchers?: ModelDataResolverWatchers;
} = { skipWatcherUpdates: false, fromDataSource: false };
if (typeof keyOrAttributes === 'object') {
attributes = keyOrAttributes;
options = valueOrOptions || (options as ComponentSetOptions);
} else if (typeof keyOrAttributes === 'string') {
attributes = { [keyOrAttributes as string]: valueOrOptions };
options = optionsOrUndefined || options;
} else {
attributes = {};
options = optionsOrUndefined || options;
}
this.dataResolverWatchers = this.dataResolverWatchers || options.dataResolverWatchers;
const evaluatedProps = this.dataResolverWatchers.addProps(attributes, options);
return super.set(evaluatedProps, options);
}
onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) {
this.collectionsStateMap = collectionsStateMap;
this.dataResolverWatchers.onCollectionsStateMapUpdate();
super.onCollectionsStateMapUpdate(collectionsStateMap);
this._getStyleRule()?.onCollectionsStateMapUpdate(collectionsStateMap);
const cmps = this.components();
cmps.forEach((cmp) => cmp.onCollectionsStateMapUpdate(collectionsStateMap));
@ -382,13 +373,13 @@ export default class Component extends StyleableModel<ComponentProperties> {
this.components().forEach((cmp) => cmp.syncComponentsCollectionState());
}
stopSyncComponentCollectionState() {
protected stopSyncComponentCollectionState() {
this.stopListening(this.components(), 'add remove reset', this.syncOnComponentChange);
this.collectionsStateMap = {};
this.components().forEach((cmp) => cmp.stopSyncComponentCollectionState());
}
syncOnComponentChange(model: Component, collection: Components, opts: any) {
protected syncOnComponentChange(model: Component, collection: Components, opts: any) {
if (!this.collectionsStateMap || !Object.keys(this.collectionsStateMap).length) return;
const options = opts || collection || {};
@ -445,9 +436,9 @@ export default class Component extends StyleableModel<ComponentProperties> {
}
}
__onStyleChange(newStyles: StyleProps) {
const { em } = this;
if (!em) return;
__onStyleChange(newStyles: StyleProps, opts?: UpdateStyleOptions) {
const { collectionsStateMap, em } = this;
if (!em || opts?.noEvent) return;
const styleKeys = keys(newStyles);
const pros = { style: newStyles };
@ -455,13 +446,14 @@ export default class Component extends StyleableModel<ComponentProperties> {
this.emitWithEditor(ComponentsEvents.styleUpdate, this, pros);
styleKeys.forEach((key) => this.emitWithEditor(`${ComponentsEvents.styleUpdateProperty}${key}`, this, pros));
const collectionsStateMap = this.collectionsStateMap;
const allParentCollectionIds = Object.keys(collectionsStateMap);
if (!allParentCollectionIds.length) return;
const parentCollectionIds = Object.keys(collectionsStateMap).filter((key) => key !== keyRootData);
const isAtInitialPosition = allParentCollectionIds.every(
(key) => collectionsStateMap[key].currentIndex === collectionsStateMap[key].startIndex,
);
if (parentCollectionIds.length === 0) return;
const isAtInitialPosition = parentCollectionIds.every((id) => {
const collection = collectionsStateMap[id] as DataCollectionStateMap;
return collection.currentIndex === collection.startIndex;
});
if (!isAtInitialPosition) return;
const componentsToUpdate = getSymbolsToUpdate(this);
@ -469,12 +461,10 @@ export default class Component extends StyleableModel<ComponentProperties> {
const componentCollectionsState = component.collectionsStateMap;
const componentParentCollectionIds = Object.keys(componentCollectionsState);
const isChildOfOriginalCollections = componentParentCollectionIds.every((id) =>
allParentCollectionIds.includes(id),
);
const isChildOfOriginalCollections = componentParentCollectionIds.every((id) => parentCollectionIds.includes(id));
if (isChildOfOriginalCollections) {
component.addStyle(newStyles);
component.addStyle({ ...newStyles }, { noEvent: true });
}
});
}
@ -572,7 +562,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
* @example
* component.setSymbolOverride(['children', 'classes']);
*/
setSymbolOverride(value: boolean | string | string[], options: DynamicWatchersOptions = {}) {
setSymbolOverride(value: boolean | string | string[], options: DataWatchersOptions = {}) {
this.set(
{
[keySymbolOvrd]: (isString(value) ? [value] : value) ?? 0,
@ -773,11 +763,13 @@ export default class Component extends StyleableModel<ComponentProperties> {
* component.addAttributes({ 'data-key': 'value' });
*/
addAttributes(attrs: ObjectAny, opts: SetAttrOptions = {}) {
const dynamicAttributes = this.dataResolverWatchers.getDynamicAttributesDefs();
const previousAttrs = this.dataResolverWatchers.getValueOrResolver(
'attributes',
this.getAttributes({ noClass: true, noStyle: true }),
);
return this.setAttributes(
{
...this.getAttributes({ noClass: true, noStyle: true }),
...dynamicAttributes,
...previousAttrs,
...attrs,
},
opts,
@ -795,7 +787,6 @@ export default class Component extends StyleableModel<ComponentProperties> {
*/
removeAttributes(attrs: string | string[] = [], opts: SetOptions = {}) {
const attrArr = Array.isArray(attrs) ? attrs : [attrs];
this.dataResolverWatchers.removeAttributes(attrArr);
const compAttr = this.getAttributes();
attrArr.map((i) => delete compAttr[i]);
@ -806,21 +797,26 @@ export default class Component extends StyleableModel<ComponentProperties> {
* Get the style of the component
* @return {Object}
*/
getStyle(options: any = {}, optsAdd: any = {}) {
getStyle(opts?: GetComponentStyleOpts): StyleProps;
getStyle(prop: '' | undefined, opts?: GetComponentStyleOpts): StyleProps;
getStyle(
prop?: keyof StyleProps | '' | ObjectAny,
opts?: GetComponentStyleOpts,
): StyleProps | StyleProps[keyof StyleProps] | undefined {
const { em } = this;
const isOptionsString = isString(options);
const prop = isOptionsString ? options : '';
const opts = isOptionsString || options === '' ? optsAdd : options;
const skipResolve = !!opts?.skipResolve;
const isPropString = isString(prop);
const resolvedProp = isPropString ? prop : '';
const resolvedOpts = isPropString ? opts : prop;
const skipResolve = !!resolvedOpts?.skipResolve;
if (avoidInline(em) && !opts.inline) {
if (avoidInline(em) && !resolvedOpts?.inline) {
const state = em.get('state');
const cc = em.Css;
const rule = cc.getIdRule(this.getId(), { state, ...opts });
const rule = cc.getIdRule(this.getId(), { state, ...resolvedOpts });
this.rule = rule;
if (rule) {
return rule.getStyle(prop, { skipResolve });
return rule.getStyle(resolvedProp, { skipResolve });
}
// Return empty style if no rule have been found. We cannot return inline style with the next return
@ -828,7 +824,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
return {};
}
return super.getStyle.call(this, prop, { skipResolve });
return super.getStyle.call(this, resolvedProp, { skipResolve });
}
/**
@ -844,7 +840,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 as any) };
prop = { ...(style as any), ...prop };
const state = em.get('state');
const cc = em.Css;
const propOrig = this.getStyle({ ...opts, skipResolve: true });
@ -857,7 +853,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
}
if (!opt.temporary) {
this.__onStyleChange(opts.addStyle || prop);
this.__onStyleChange(opts.addStyle || prop, opts);
}
return prop;
@ -870,11 +866,10 @@ export default class Component extends StyleableModel<ComponentProperties> {
getAttributes(opts: { noClass?: boolean; noStyle?: boolean; skipResolve?: boolean } = {}) {
const { em } = this;
const classes: string[] = [];
const dynamicValues = opts.skipResolve ? this.dataResolverWatchers.getDynamicAttributesDefs() : {};
const attributes = {
...this.get('attributes'),
...dynamicValues,
};
const resolvedAttrs = { ...this.get('attributes')! };
const attributes = opts?.skipResolve
? this.dataResolverWatchers.getValueOrResolver('attributes', resolvedAttrs)
: resolvedAttrs;
const sm = em?.Selectors;
const id = this.getId();
@ -1043,12 +1038,8 @@ export default class Component extends StyleableModel<ComponentProperties> {
if (name && value) attrs[name] = value;
}
});
const dynamicAttributes = this.dataResolverWatchers.getDynamicAttributesDefs();
traits.length &&
this.setAttributes({
...attrs,
...dynamicAttributes,
});
const resolvedAttributes = this.dataResolverWatchers.getValueOrResolver('attributes', attrs);
traits.length && this.setAttributes(resolvedAttributes);
this.on(event, this.initTraits);
changed && em && em.trigger('component:toggled');
return this;
@ -1132,7 +1123,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
return coll as any;
} else {
coll.reset(undefined, opts);
return components ? this.append(components, opts) : ([] as any);
return (components ? this.append(components, opts) : []) as any;
}
}
@ -1390,16 +1381,10 @@ export default class Component extends StyleableModel<ComponentProperties> {
* @ts-ignore */
clone(opt: { symbol?: boolean; symbolInv?: boolean } = {}): this {
const em = this.em;
const attr = {
...this.attributes,
...this.dataResolverWatchers.getDynamicPropsDefs(),
};
const attr = this.dataResolverWatchers.getProps(this.attributes);
const opts = { ...this.opt };
const id = this.getId();
const cssc = em?.Css;
attr.attributes = {
...(attr.attributes ? this.dataResolverWatchers.getAttributesDefsOrValues(attr.attributes) : undefined),
};
// @ts-ignore
attr.components = [];
// @ts-ignore
@ -1620,9 +1605,9 @@ export default class Component extends StyleableModel<ComponentProperties> {
} else {
let valueRes = '';
if (opts.altQuoteAttr && isString(val) && val.indexOf('"') >= 0) {
valueRes = `'${val.replace(/'/g, '&apos;')}'`;
valueRes = `'${escapeAltQuoteAttrValue(val)}'`;
} else {
const value = isString(val) ? val.replace(/"/g, '&quot;') : val;
const value = isString(val) ? escapeAttrValue(val) : val;
valueRes = `"${value}"`;
}
@ -1656,9 +1641,7 @@ export default class Component extends StyleableModel<ComponentProperties> {
* @private
*/
toJSON(opts: ObjectAny = {}): ComponentDefinition {
let obj = Model.prototype.toJSON.call(this, opts);
obj = { ...obj, ...this.dataResolverWatchers.getDynamicPropsDefs() };
obj.attributes = this.dataResolverWatchers.getAttributesDefsOrValues(this.getAttributes());
let obj = super.toJSON(opts, { attributes: this.getAttributes() });
delete obj.dataResolverWatchers;
delete obj.attributes.class;
delete obj.toolbar;
@ -2036,8 +2019,10 @@ export default class Component extends StyleableModel<ComponentProperties> {
(isObject(inlineStyle) && Object.keys(inlineStyle).length > 0);
if (avoidInline(this.em) && hasInlineStyle) {
this.addStyle(inlineStyle);
this.set('style', '');
this.addStyle(
isObject(inlineStyle) ? this.dataResolverWatchers.getValueOrResolver('styles', inlineStyle) : inlineStyle,
{ avoidStore: true, noUndo: true },
);
}
}

95
packages/core/src/dom_components/model/ComponentWrapper.ts

@ -2,9 +2,23 @@ import { isUndefined } from 'underscore';
import { attrToString } from '../../utils/dom';
import Component from './Component';
import ComponentHead, { type as typeHead } from './ComponentHead';
import { ToHTMLOptions } from './types';
import { ComponentOptions, ComponentProperties, ToHTMLOptions } from './types';
import Components from './Components';
import DataResolverListener from '../../data_sources/model/DataResolverListener';
import { DataVariableProps } from '../../data_sources/model/DataVariable';
import { DataCollectionStateMap } from '../../data_sources/model/data_collection/types';
import ComponentWithCollectionsState, {
DataSourceRecords,
} from '../../data_sources/model/ComponentWithCollectionsState';
import { keyRootData } from '../constants';
type ResolverCurrentItemType = string | number;
export default class ComponentWrapper extends ComponentWithCollectionsState<DataVariableProps> {
dataSourceWatcher?: DataResolverListener;
private _resolverCurrentItem?: ResolverCurrentItemType;
private _isWatchingCollectionStateMap = false;
export default class ComponentWrapper extends Component {
get defaults() {
return {
// @ts-ignore
@ -30,6 +44,16 @@ export default class ComponentWrapper extends Component {
};
}
constructor(props: ComponentProperties = {}, opt: ComponentOptions) {
super(props, opt);
const hasDataResolver = this.dataResolverProps;
if (hasDataResolver) {
this.onDataSourceChange();
this.syncComponentsCollectionState();
}
}
preInit() {
const { opt, attributes: props } = this;
const cmp = this.em?.Components;
@ -78,6 +102,73 @@ export default class ComponentWrapper extends Component {
return asDoc ? `${doctype}<html${docElAttrStr}>${headStr}${body}</html>` : body;
}
onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) {
const { head } = this;
super.onCollectionsStateMapUpdate(collectionsStateMap);
head.onCollectionsStateMapUpdate(collectionsStateMap);
}
syncComponentsCollectionState() {
super.syncComponentsCollectionState();
this.head.syncComponentsCollectionState();
}
syncOnComponentChange(model: Component, collection: Components, opts: any) {
const collectionsStateMap: any = this.getCollectionsStateMap();
this.collectionsStateMap = collectionsStateMap;
super.syncOnComponentChange(model, collection, opts);
this.onCollectionsStateMapUpdate(collectionsStateMap);
}
get resolverCurrentItem(): ResolverCurrentItemType | undefined {
return this._resolverCurrentItem;
}
set resolverCurrentItem(value: ResolverCurrentItemType) {
this._resolverCurrentItem = value;
this.onCollectionsStateMapUpdate(this.getCollectionsStateMap());
}
protected onDataSourceChange() {
this.onCollectionsStateMapUpdate(this.getCollectionsStateMap());
}
protected listenToPropsChange() {
this.on(`change:dataResolver`, (_, value) => {
const hasResolver = !isUndefined(value);
if (hasResolver && !this._isWatchingCollectionStateMap) {
this._isWatchingCollectionStateMap = true;
this.syncComponentsCollectionState();
this.onCollectionsStateMapUpdate(this.getCollectionsStateMap());
this.listenToDataSource();
} else if (!hasResolver && this._isWatchingCollectionStateMap) {
this._isWatchingCollectionStateMap = false;
this.stopSyncComponentCollectionState();
}
});
this.listenToDataSource();
}
private getCollectionsStateMap(): DataCollectionStateMap {
const { dataResolverPath: dataSourcePath, resolverCurrentItem } = this;
if (!dataSourcePath) {
return {};
}
const allItems = this.getDataSourceItems();
const selectedItems = !isUndefined(resolverCurrentItem)
? allItems[resolverCurrentItem as keyof DataSourceRecords]
: allItems;
return {
[keyRootData]: selectedItems,
} as DataCollectionStateMap;
}
__postAdd() {
const um = this.em?.UndoManager;
!this.__hasUm && um?.add(this);

61
packages/core/src/dom_components/model/Components.ts

@ -1,5 +1,5 @@
import { isEmpty, isArray, isString, isFunction, each, includes, extend, flatten, keys } from 'underscore';
import Component from './Component';
import Component, { SetAttrOptions } from './Component';
import { AddOptions, Collection } from '../../common';
import { DomComponentsConfig } from '../config/config';
import EditorModel from '../../editor/model/Editor';
@ -18,6 +18,21 @@ import ComponentWrapper from './ComponentWrapper';
import { ComponentsEvents, ParseStringOptions } from '../types';
import { isSymbolInstance, isSymbolRoot, updateSymbolComps } from './SymbolUtils';
export interface ResetCommonUpdateProps {
component: Component;
item: ComponentDefinitionDefined;
options: SetAttrOptions;
}
export interface ResetFromStringOptions {
visitedCmps?: Record<string, Component[]>;
keepIds?: string[];
updateOptions?: {
onAttributes?: (props: ResetCommonUpdateProps & { attributes: Record<string, any> }) => void;
onStyle?: (props: ResetCommonUpdateProps & { style: Record<string, any> }) => void;
};
}
export const getComponentIds = (cmp?: Component | Component[] | Components, res: string[] = []) => {
if (!cmp) return [];
const cmps = (isArray(cmp) || isFunction((cmp as Components).map) ? cmp : [cmp]) as Component[];
@ -35,6 +50,7 @@ const getComponentsFromDefs = (
) => {
opts.visitedCmps = opts.visitedCmps || {};
const { visitedCmps } = opts;
const updateOptions = (opts.updateOptions as ResetFromStringOptions['updateOptions']) || {};
const itms = isArray(items) ? items : [items];
return itms.map((item) => {
@ -50,10 +66,21 @@ const getComponentsFromDefs = (
// Update the component if exists already
if (all[id]) {
result = all[id] as any;
const cmp = result as unknown as Component;
tagName && cmp.set({ tagName }, { ...opts, silent: true });
keys(restAttr).length && cmp.addAttributes(restAttr, { ...opts });
keys(style).length && cmp.addStyle(style, opts);
const { onAttributes, onStyle } = updateOptions;
const component = result as unknown as Component;
tagName && component.set({ tagName }, { ...opts, silent: true });
if (onAttributes) {
onAttributes({ item, component, attributes: restAttr, options: opts });
} else if (keys(restAttr).length) {
component.addAttributes(restAttr, { ...opts });
}
if (onStyle) {
onStyle({ item, component, style, options: opts });
} else if (keys(style).length) {
component.addStyle(style, opts);
}
}
} else {
// Found another component with the same ID, treat it as a new component
@ -131,14 +158,15 @@ Component> {
models.each((model) => this.onAdd(model));
}
resetFromString(input = '', opts: { visitedCmps?: Record<string, Component[]>; keepIds?: string[] } = {}) {
resetFromString(input = '', opts: ResetFromStringOptions = {}) {
opts.keepIds = getComponentIds(this);
const { domc, em, parent } = this;
const cssc = em?.Css;
const allByID = domc?.allById() || {};
const parsed = this.parseString(input, opts);
const newCmps = getComponentsFromDefs(parsed, allByID, opts);
const { visitedCmps = {} } = opts;
const fromDefOpts = { skipViewUpdate: true, ...opts };
const newCmps = getComponentsFromDefs(parsed, allByID, fromDefOpts);
const { visitedCmps = {} } = fromDefOpts;
// Clone styles for duplicated components
Object.keys(visitedCmps).forEach((id) => {
@ -391,15 +419,18 @@ Component> {
onAdd(model: Component, c?: any, opts: { temporary?: boolean } = {}) {
const { domc, em } = this;
const style = model.getStyle();
const avoidInline = em && em.getConfig().avoidInlineStyle;
const avoidInline = em.config.avoidInlineStyle;
domc && domc.Component.ensureInList(model);
if (!isEmpty(style) && !avoidInline && em && em.getConfig().forceClass && !opts.temporary) {
const name = model.cid;
em.Css.setClassRule(name, style);
model.setStyle({});
model.addClass(name);
if (!avoidInline && em.config.forceClass && !opts.temporary) {
const style = model.getStyle();
if (!isEmpty(style)) {
const name = model.cid;
em.Css.setClassRule(name, style);
model.setStyle({});
model.addClass(name);
}
}
model.__postAdd({ recursive: true });

215
packages/core/src/dom_components/model/ModelDataResolverWatchers.ts

@ -1,74 +1,120 @@
import { ObjectAny } from '../../common';
import StyleableModel from '../../domain_abstract/model/StyleableModel';
import {
ModelResolverWatcher as ModelResolverWatcher,
ModelResolverWatcher,
ModelResolverWatcherOptions,
DynamicWatchersOptions,
DataWatchersOptions,
WatchableModel,
} from './ModelResolverWatcher';
import { getSymbolsToUpdate } from './SymbolUtils';
import Component from './Component';
import { StyleableModelProperties } from '../../domain_abstract/model/StyleableModel';
import { isEmpty, isObject } from 'underscore';
export const updateFromWatcher = { fromDataSource: true, avoidStore: true };
export const keyDataValues = '__data_values';
export class ModelDataResolverWatchers {
private propertyWatcher: ModelResolverWatcher;
private attributeWatcher: ModelResolverWatcher;
private styleWatcher: ModelResolverWatcher;
export class ModelDataResolverWatchers<T extends StyleableModelProperties> {
private propertyWatcher: ModelResolverWatcher<T>;
private attributeWatcher: ModelResolverWatcher<T>;
private styleWatcher: ModelResolverWatcher<T>;
constructor(
private model: StyleableModel | undefined,
options: ModelResolverWatcherOptions,
private model: WatchableModel<T>,
private options: ModelResolverWatcherOptions,
) {
this.propertyWatcher = new ModelResolverWatcher(model, this.onPropertyUpdate, options);
this.attributeWatcher = new ModelResolverWatcher(model, this.onAttributeUpdate, options);
this.styleWatcher = new ModelResolverWatcher(model, this.onStyleUpdate, options);
}
private onPropertyUpdate(component: StyleableModel | undefined, key: string, value: any) {
component?.set(key, value, updateFromWatcher);
}
private onAttributeUpdate(component: StyleableModel | undefined, key: string, value: any) {
(component as any)?.addAttributes({ [key]: value }, updateFromWatcher);
}
private onStyleUpdate(component: StyleableModel | undefined, key: string, value: any) {
component?.addStyle({ [key]: value }, { ...updateFromWatcher, partial: true, avoidStore: true });
}
bindModel(model: StyleableModel) {
bindModel(model: WatchableModel<T>) {
this.model = model;
this.propertyWatcher.bindModel(model);
this.attributeWatcher.bindModel(model);
this.styleWatcher.bindModel(model);
this.watchers.forEach((watcher) => watcher.bindModel(model));
this.updateSymbolOverride();
}
addProps(props: ObjectAny, options: DynamicWatchersOptions = {}) {
const excludedFromEvaluation = ['components', 'dataResolver'];
addProps(props: ObjectAny, options: DataWatchersOptions = {}) {
const dataValues = props[keyDataValues] ?? {};
const evaluatedProps = Object.fromEntries(
Object.entries(props).map(([key, value]) =>
excludedFromEvaluation.includes(key)
? [key, value] // Return excluded keys as they are
: [key, this.propertyWatcher.addDynamicValues({ [key]: value }, options)[key]],
),
);
const filteredProps = this.filterProps(props);
const evaluatedProps = {
...props,
...this.propertyWatcher.addDataValues({ ...filteredProps, ...dataValues.props }, options),
};
if (props.attributes) {
const evaluatedAttributes = this.attributeWatcher.setDynamicValues(props.attributes, options);
evaluatedProps['attributes'] = evaluatedAttributes;
if (this.shouldProcessProp('attributes', props, dataValues)) {
evaluatedProps.attributes = this.processAttributes(props, dataValues, options);
}
if (this.shouldProcessProp('style', props, dataValues)) {
evaluatedProps.style = this.processStyles(props, dataValues, options);
}
const skipOverrideUpdates = options.skipWatcherUpdates || options.fromDataSource;
if (!skipOverrideUpdates) {
this.updateSymbolOverride();
evaluatedProps[keyDataValues] = {
props: this.propertyWatcher.getAllDataResolvers(),
style: this.styleWatcher.getAllDataResolvers(),
attributes: this.attributeWatcher.getAllDataResolvers(),
};
}
return evaluatedProps;
}
setStyles(styles: ObjectAny, options: DynamicWatchersOptions = {}) {
return this.styleWatcher.setDynamicValues(styles, options);
getProps(data: ObjectAny): ObjectAny {
const resolvedProps = this.getValueOrResolver('props', data);
const result = {
...resolvedProps,
};
delete result[keyDataValues];
if (!isEmpty(data.attributes)) {
result.attributes = this.getValueOrResolver('attributes', data.attributes);
}
if (isObject(data.style) && !isEmpty(data.style)) {
result.style = this.getValueOrResolver('styles', data.style);
}
return result;
}
/**
* Resolves properties, styles, or attributes to their final values or returns the data resolvers.
* - If `data` is `null` or `undefined`, the method returns an object containing all data resolvers for the specified `target`.
*/
getValueOrResolver(target: 'props' | 'styles' | 'attributes', data?: ObjectAny) {
let watcher;
switch (target) {
case 'props':
watcher = this.propertyWatcher;
break;
case 'styles':
watcher = this.styleWatcher;
break;
case 'attributes':
watcher = this.attributeWatcher;
break;
default: {
const { em } = this.options;
em?.logError(`Invalid target '${target}'. Must be 'props', 'styles', or 'attributes'.`);
return {};
}
}
if (!data) {
return watcher.getAllDataResolvers();
}
return watcher.getValuesOrResolver(data);
}
removeAttributes(attributes: string[]) {
this.attributeWatcher.removeListeners(attributes);
this.updateSymbolOverride();
}
/**
@ -79,20 +125,57 @@ export class ModelDataResolverWatchers {
this.styleWatcher.destroy();
}
removeAttributes(attributes: string[]) {
this.attributeWatcher.removeListeners(attributes);
this.updateSymbolOverride();
onCollectionsStateMapUpdate() {
this.watchers.forEach((watcher) => watcher.onCollectionsStateMapUpdate());
}
destroy() {
this.watchers.forEach((watcher) => watcher.destroy());
}
private get watchers() {
return [this.propertyWatcher, this.styleWatcher, this.attributeWatcher];
}
private isComponent(model: any): model is Component {
return model instanceof Component;
}
private onPropertyUpdate = (model: WatchableModel<T>, key: string, value: any) => {
model?.set(key, value, updateFromWatcher);
};
private onAttributeUpdate = (model: WatchableModel<T>, key: string, value: any) => {
if (!this.isComponent(model)) return;
model?.addAttributes({ [key]: value }, updateFromWatcher);
};
private onStyleUpdate = (model: WatchableModel<T>, key: string, value: any) => {
model?.addStyle({ [key]: value }, { ...updateFromWatcher, partial: true, avoidStore: true });
};
private shouldProcessProp(key: 'attributes' | 'style', newProps: ObjectAny, dataValues: ObjectAny): boolean {
const watcher = key === 'attributes' ? this.attributeWatcher : this.styleWatcher;
const dataSubProps = dataValues[key];
const hasNewValues = !!newProps[key];
const hasExistingDataValues = dataSubProps && Object.keys(dataSubProps).length > 0;
const hasApplicableWatchers = dataSubProps && Object.keys(watcher.getAllDataResolvers()).length > 0;
return hasNewValues || hasExistingDataValues || hasApplicableWatchers;
}
private updateSymbolOverride() {
const model = this.model as any;
const model = this.model;
if (!this.isComponent(model)) return;
const isCollectionItem = !!Object.keys(model?.collectionsStateMap ?? {}).length;
if (!this.model || !isCollectionItem) return;
if (!isCollectionItem) return;
const keys = this.propertyWatcher.getValuesResolvingFromCollections();
const attributesKeys = this.attributeWatcher.getValuesResolvingFromCollections();
const combinedKeys = ['locked', 'layerable', ...keys];
const combinedKeys = ['locked', 'layerable', keyDataValues, ...keys];
const haveOverridenAttributes = Object.keys(attributesKeys).length;
if (haveOverridenAttributes) combinedKeys.push('attributes');
@ -103,39 +186,25 @@ export class ModelDataResolverWatchers {
model.setSymbolOverride(combinedKeys, { fromDataSource: true });
}
onCollectionsStateMapUpdate() {
this.propertyWatcher.onCollectionsStateMapUpdate();
this.attributeWatcher.onCollectionsStateMapUpdate();
this.styleWatcher.onCollectionsStateMapUpdate();
}
getDynamicPropsDefs() {
return this.propertyWatcher.getAllSerializableValues();
}
getDynamicAttributesDefs() {
return this.attributeWatcher.getAllSerializableValues();
}
getDynamicStylesDefs() {
return this.styleWatcher.getAllSerializableValues();
}
private filterProps(props: ObjectAny) {
const excludedFromEvaluation = ['components', 'dataResolver', keyDataValues];
const filteredProps = Object.fromEntries(
Object.entries(props).filter(([key]) => !excludedFromEvaluation.includes(key)),
);
getPropsDefsOrValues(props: ObjectAny) {
return this.propertyWatcher.getSerializableValues(props);
return filteredProps;
}
getAttributesDefsOrValues(attributes: ObjectAny) {
return this.attributeWatcher.getSerializableValues(attributes);
private processAttributes(baseValue: ObjectAny, dataValues: ObjectAny, options: DataWatchersOptions = {}) {
return this.attributeWatcher.setDataValues({ ...baseValue.attributes, ...(dataValues.attributes ?? {}) }, options);
}
getStylesDefsOrValues(styles: ObjectAny) {
return this.styleWatcher.getSerializableValues(styles);
}
private processStyles(baseValue: ObjectAny | string, dataValues: ObjectAny, options: DataWatchersOptions = {}) {
if (typeof baseValue === 'string') {
this.styleWatcher.removeListeners();
return baseValue;
}
destroy() {
this.propertyWatcher.destroy();
this.attributeWatcher.destroy();
this.styleWatcher.destroy();
return this.styleWatcher.setDataValues({ ...baseValue.style, ...(dataValues.style ?? {}) }, options);
}
}

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

@ -1,11 +1,10 @@
import { ObjectAny } from '../../common';
import { ObjectAny, ObjectHash } from '../../common';
import DataResolverListener from '../../data_sources/model/DataResolverListener';
import { getDataResolverInstance, getDataResolverInstanceValue, isDataResolverProps } from '../../data_sources/utils';
import StyleableModel from '../../domain_abstract/model/StyleableModel';
import EditorModel from '../../editor/model/Editor';
import Component from './Component';
export interface DynamicWatchersOptions {
export interface DataWatchersOptions {
skipWatcherUpdates?: boolean;
fromDataSource?: boolean;
}
@ -14,35 +13,35 @@ export interface ModelResolverWatcherOptions {
em: EditorModel;
}
type NewType = StyleableModel | undefined;
type UpdateFn = (component: NewType, key: string, value: any) => void;
export type WatchableModel<T extends ObjectHash> = StyleableModel<T> | undefined;
export type UpdateFn<T extends ObjectHash> = (component: WatchableModel<T>, key: string, value: any) => void;
export class ModelResolverWatcher {
export class ModelResolverWatcher<T extends ObjectHash> {
private em: EditorModel;
private resolverListeners: Record<string, DataResolverListener> = {};
constructor(
private model: NewType,
private updateFn: UpdateFn,
private model: WatchableModel<T>,
private updateFn: UpdateFn<T>,
options: ModelResolverWatcherOptions,
) {
this.em = options.em;
}
bindModel(model: StyleableModel) {
bindModel(model: WatchableModel<T>) {
this.model = model;
}
setDynamicValues(values: ObjectAny | undefined, options: DynamicWatchersOptions = {}) {
setDataValues(values: ObjectAny | undefined, options: DataWatchersOptions = {}) {
const shouldSkipWatcherUpdates = options.skipWatcherUpdates || options.fromDataSource;
if (!shouldSkipWatcherUpdates) {
this.removeListeners();
}
return this.addDynamicValues(values, options);
return this.addDataValues(values, options);
}
addDynamicValues(values: ObjectAny | undefined, options: DynamicWatchersOptions = {}) {
addDataValues(values: ObjectAny | undefined, options: DataWatchersOptions = {}) {
if (!values) return {};
const evaluatedValues = this.evaluateValues(values);
@ -57,12 +56,9 @@ export class ModelResolverWatcher {
onCollectionsStateMapUpdate() {
const resolvesFromCollections = this.getValuesResolvingFromCollections();
if (!resolvesFromCollections.length) return;
resolvesFromCollections.forEach((key) =>
this.resolverListeners[key].resolver.updateCollectionsStateMap(this.collectionsStateMap),
);
const evaluatedValues = this.addDynamicValues(
this.getSerializableValues(Object.fromEntries(resolvesFromCollections.map((key) => [key, null]))),
const evaluatedValues = this.addDataValues(
this.getValuesOrResolver(Object.fromEntries(resolvesFromCollections.map((key) => [key, '']))),
);
Object.entries(evaluatedValues).forEach(([key, value]) => this.updateFn(this.model, key, value));
@ -70,8 +66,8 @@ export class ModelResolverWatcher {
private get collectionsStateMap() {
const component = this.model;
if (component instanceof Component) return component.collectionsStateMap;
return {};
return component?.collectionsStateMap ?? {};
}
private updateListeners(values: { [key: string]: any }) {
@ -133,9 +129,9 @@ export class ModelResolverWatcher {
return propsKeys;
}
getSerializableValues(values: ObjectAny | undefined) {
getValuesOrResolver(values: ObjectAny) {
if (!values) return {};
const serializableValues = { ...values };
const serializableValues: ObjectAny = { ...values };
const propsKeys = Object.keys(serializableValues);
for (let index = 0; index < propsKeys.length; index++) {
@ -149,7 +145,7 @@ export class ModelResolverWatcher {
return serializableValues;
}
getAllSerializableValues() {
getAllDataResolvers() {
const serializableValues: ObjectAny = {};
const propsKeys = Object.keys(this.resolverListeners);

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

@ -131,57 +131,66 @@ export const logSymbol = (symb: Component, type: string, toUp: Component[], opts
};
export const updateSymbolProps = (symbol: Component, opts: SymbolToUpOptions = {}): void => {
const changed = symbol.dataResolverWatchers.getPropsDefsOrValues({ ...symbol.changedAttributes() });
const attrs = symbol.dataResolverWatchers.getAttributesDefsOrValues({ ...changed.attributes });
const changedAttributes = symbol.changedAttributes();
if (!changedAttributes) return;
cleanChangedProperties(changed, attrs);
let resolvedProps = symbol.dataResolverWatchers.getProps(changedAttributes);
cleanChangedProperties(resolvedProps);
if (!isEmptyObj(changed)) {
if (!isEmptyObj(resolvedProps)) {
const toUpdate = getSymbolsToUpdate(symbol, opts);
// Filter properties to propagate
filterPropertiesForPropagation(changed, symbol);
resolvedProps = filterPropertiesForPropagation(resolvedProps, symbol);
logSymbol(symbol, 'props', toUpdate, { opts, changed });
logSymbol(symbol, 'props', toUpdate, { opts, changed: resolvedProps });
// Update child symbols
toUpdate.forEach((child) => {
const propsToUpdate = { ...changed };
filterPropertiesForPropagation(propsToUpdate, child);
const propsToUpdate = filterPropertiesForPropagation(resolvedProps, child);
child.set(propsToUpdate, { fromInstance: symbol, ...opts });
});
}
};
const cleanChangedProperties = (changed: Record<string, any>, attrs: Record<string, any>): void => {
const keysToDelete = ['status', 'open', keySymbols, keySymbol, keySymbolOvrd, 'attributes'];
const cleanChangedProperties = (changed: Record<string, any>): void => {
const keysToDelete = ['status', 'open', keySymbols, keySymbol, keySymbolOvrd];
keysToDelete.forEach((key) => delete changed[key]);
delete attrs.id;
if (!isEmptyObj(attrs)) {
changed.attributes = attrs;
}
delete changed.attributes?.id;
isEmptyObj(changed.attributes ?? {}) && delete changed.attributes;
};
const filterPropertiesForPropagation = (props: Record<string, any>, component: Component): void => {
const filterPropertiesForPropagation = (props: Record<string, any>, component: Component) => {
const filteredProps = { ...props };
keys(props).forEach((prop) => {
if (!shouldPropagateProperty(props, prop, component)) {
delete props[prop];
delete filteredProps[prop];
}
});
return filteredProps;
};
const shouldPropagateProperty = (props: Record<string, any>, prop: string, component: Component): boolean => {
const isCollectionVariableDefinition = (() => {
if (prop === 'attributes') {
const attributes = props['attributes'];
return Object.values(attributes).some((attr: any) => !!attr?.collectionId);
}
const hasCollectionId = (obj: Record<string, any> | undefined): boolean => {
if (!obj) return false;
return Object.values(obj).some((val: any) => Boolean(val?.collectionId));
};
return !!props[prop]?.collectionId;
})();
const isCollectionVariableDefinition = (props: Record<string, any>, prop: string): boolean => {
switch (prop) {
case 'attributes':
case 'style':
return hasCollectionId(props[prop]);
default:
return Boolean(props[prop]?.collectionId);
}
};
const shouldPropagateProperty = (props: Record<string, any>, prop: string, component: Component): boolean => {
const isCollectionVar = isCollectionVariableDefinition(props, prop);
return !isSymbolOverride(component, prop) || isCollectionVariableDefinition;
return !isSymbolOverride(component, prop) || isCollectionVar;
};
export const updateSymbolCls = (symbol: Component, opts: any = {}) => {

10
packages/core/src/dom_components/model/types.ts

@ -1,4 +1,4 @@
import { DynamicWatchersOptions } from './ModelResolverWatcher';
import { DataWatchersOptions } from './ModelResolverWatcher';
import Frame from '../../canvas/model/Frame';
import { AddOptions, Nullable, OptionAsDocument } from '../../common';
import EditorModel from '../../editor/model/Editor';
@ -19,7 +19,11 @@ export type DraggableDroppableFn = (source: Component, target: Component, index?
export interface AddComponentsOption extends AddOptions, OptionAsDocument {}
export interface ResetComponentsOptions extends AddComponentsOption {
export interface UpdateComponentsOptions extends AddComponentsOption {
skipViewUpdate?: boolean;
}
export interface ResetComponentsOptions extends UpdateComponentsOptions {
previousModels?: Component[];
keepIds?: string[];
skipDomReset?: boolean;
@ -253,7 +257,7 @@ export interface ComponentProperties {
[key: string]: any;
}
export interface SymbolToUpOptions extends DynamicWatchersOptions {
export interface SymbolToUpOptions extends DataWatchersOptions {
changed?: string;
fromInstance?: boolean;
noPropagate?: boolean;

9
packages/core/src/dom_components/view/ComponentImageView.ts

@ -40,14 +40,17 @@ export default class ComponentImageView<TComp extends ComponentImage = Component
const fu = em.Assets.FileUploader();
fu?.uploadFile(
{
// @ts-ignore
dataTransfer: { files: [file] },
},
(res: any) => {
} as unknown as DragEvent,
(res) => {
const obj = res && res.data && res.data[0];
const src = obj && (isString(obj) ? obj : obj.src);
src && model.set({ src });
},
{
componentView: this,
file,
},
);
model.set('file', '');
}

4
packages/core/src/dom_components/view/ComponentTextView.ts

@ -248,9 +248,11 @@ export default class ComponentTextView<TComp extends ComponentText = ComponentTe
mixins.off(elDocs, 'mousedown', this.onDisable as any);
mixins[method](elDocs, 'mousedown', this.onDisable as any);
em[method]('toolbar:run:before', this.onDisable);
if (model) {
const rteEvents = em.RichTextEditor.events;
model[method]('removed', this.onDisable);
model.trigger(`rte:${enable ? 'enable' : 'disable'}`);
model.trigger(enable ? rteEvents.enable : rteEvents.disable);
}
// @ts-ignore Avoid closing edit mode on component click

7
packages/core/src/dom_components/view/ComponentView.ts

@ -11,7 +11,7 @@ import { setViewEl } from '../../utils/mixins';
import { DomComponentsConfig } from '../config/config';
import Component, { avoidInline } from '../model/Component';
import Components from '../model/Components';
import { ComponentOptions } from '../model/types';
import { ComponentOptions, UpdateComponentsOptions } from '../model/types';
import ComponentsView from './ComponentsView';
import { ComponentsEvents } from '../types';
@ -315,7 +315,7 @@ TComp> {
* @private
* */
updateClasses() {
const str = this.model.classes.pluck('name').join(' ');
const str = this.model.classes.pluck?.('name').join(' ') || '';
this.setAttribute('class', str);
// Regenerate status class
@ -570,7 +570,8 @@ TComp> {
}
}
renderAttributes() {
renderAttributes(m?: any, v?: any, opts: UpdateComponentsOptions = {}) {
if (opts.skipViewUpdate) return;
this.updateAttributes();
this.updateClasses();
}

10
packages/core/src/dom_components/view/ComponentsView.ts

@ -1,14 +1,14 @@
import { isUndefined } from 'underscore';
import { removeEl } from '../../utils/dom';
import FrameView from '../../canvas/view/FrameView';
import { View } from '../../common';
import { DomComponentsConfig } from '../config/config';
import EditorModel from '../../editor/model/Editor';
import { removeEl } from '../../utils/dom';
import { DomComponentsConfig } from '../config/config';
import Component from '../model/Component';
import ComponentView from './ComponentView';
import FrameView from '../../canvas/view/FrameView';
import Components from '../model/Components';
import { ResetComponentsOptions } from '../model/types';
import { ComponentsEvents } from '../types';
import ComponentView from './ComponentView';
export default class ComponentsView extends View {
opts!: any;
@ -127,6 +127,8 @@ export default class ComponentsView extends View {
}
resetChildren(models: Components, opts: ResetComponentsOptions = {}) {
if (opts.skipViewUpdate) return;
const { previousModels } = opts;
if (!opts.skipDomReset) {
this.parentEl!.innerHTML = '';

135
packages/core/src/domain_abstract/model/StyleableModel.ts

@ -1,22 +1,22 @@
import { isArray, isString, keys } from 'underscore';
import { isArray, isObject, isString, keys } from 'underscore';
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';
import EditorModel from '../../editor/model/Editor';
import { DataVariableProps } from '../../data_sources/model/DataVariable';
import CssRuleView from '../../css_composer/view/CssRuleView';
import ComponentView from '../../dom_components/view/ComponentView';
import Frame from '../../canvas/model/Frame';
import { DataConditionProps } from '../../data_sources/model/conditional_variables/DataCondition';
import { ToCssOptions } from '../../css_composer/model/CssRule';
import { ModelDataResolverWatchers } from '../../dom_components/model/ModelDataResolverWatchers';
import { DataCollectionStateMap } from '../../data_sources/model/data_collection/types';
import { DynamicWatchersOptions } from '../../dom_components/model/ModelResolverWatcher';
import { DataWatchersOptions } from '../../dom_components/model/ModelResolverWatcher';
import { DataResolverProps } from '../../data_sources/types';
import { _StringKey } from 'backbone';
export type StyleProps = Record<string, string | string[] | DataVariableProps | DataConditionProps>;
export type StyleProps = Record<string, string | string[] | DataResolverProps>;
export interface UpdateStyleOptions extends SetOptions, DynamicWatchersOptions {
export interface UpdateStyleOptions extends SetOptions, DataWatchersOptions {
partial?: boolean;
addStyle?: StyleProps;
inline?: boolean;
@ -31,20 +31,76 @@ export const getLastStyleValue = (value: string | string[]) => {
return isArray(value) ? value[value.length - 1] : value;
};
export default class StyleableModel<T extends ObjectHash = any> extends Model<T, UpdateStyleOptions> {
export interface StyleableModelProperties extends ObjectHash {
selectors?: any;
style?: StyleProps | string;
}
export interface GetStyleOpts {
skipResolve?: boolean;
}
type WithDataResolvers<T> = {
[P in keyof T]?: T[P] | DataResolverProps;
};
export default class StyleableModel<T extends StyleableModelProperties = any> extends Model<T, UpdateStyleOptions> {
em?: EditorModel;
views: StyleableView[] = [];
dataResolverWatchers: ModelDataResolverWatchers;
dataResolverWatchers: ModelDataResolverWatchers<T>;
collectionsStateMap: DataCollectionStateMap = {};
opt: { em?: EditorModel };
constructor(attributes: T, options: { em?: EditorModel } = {}) {
const em = options.em!;
const dataResolverWatchers = new ModelDataResolverWatchers(undefined, { em });
const dataResolverWatchers = new ModelDataResolverWatchers<T>(undefined, { em });
super(attributes, { ...options, dataResolverWatchers });
dataResolverWatchers.bindModel(this);
dataResolverWatchers.setStyles(this.get('style')!);
this.dataResolverWatchers = dataResolverWatchers;
this.em = options.em;
this.opt = options;
}
get<A extends _StringKey<T>>(attributeName: A, opts?: { skipResolve?: boolean }): T[A] | undefined {
if (opts?.skipResolve) return this.dataResolverWatchers.getValueOrResolver('props')[attributeName];
return super.get(attributeName);
}
set<A extends keyof T>(
keyOrAttributes: A,
valueOrOptions?: T[A] | DataResolverProps,
optionsOrUndefined?: UpdateStyleOptions,
): this;
set(keyOrAttributes: WithDataResolvers<T>, options?: UpdateStyleOptions): this;
set<A extends keyof T>(
keyOrAttributes: WithDataResolvers<T>,
valueOrOptions?: T[A] | DataResolverProps | UpdateStyleOptions,
optionsOrUndefined?: UpdateStyleOptions,
): this {
const defaultOptions: UpdateStyleOptions = {
skipWatcherUpdates: false,
fromDataSource: false,
};
let attributes: WithDataResolvers<T>;
let options: UpdateStyleOptions & { dataResolverWatchers?: ModelDataResolverWatchers<T> };
if (typeof keyOrAttributes === 'object') {
attributes = keyOrAttributes;
options = (valueOrOptions as UpdateStyleOptions) || defaultOptions;
} else if (typeof keyOrAttributes === 'string') {
attributes = { [keyOrAttributes]: valueOrOptions } as Partial<T>;
options = optionsOrUndefined || defaultOptions;
} else {
attributes = {};
options = defaultOptions;
}
this.dataResolverWatchers = this.dataResolverWatchers ?? options.dataResolverWatchers;
const evaluatedValues = this.dataResolverWatchers.addProps(attributes, options) as Partial<T>;
return super.set(evaluatedValues, options);
}
/**
@ -69,15 +125,31 @@ export default class StyleableModel<T extends ObjectHash = any> extends Model<T,
* Get style object
* @return {Object}
*/
getStyle(prop?: string | ObjectAny, opts: { skipResolve?: boolean } = {}): StyleProps {
const style: ObjectAny = { ...(this.get('style') || {}) };
delete style.__p;
getStyle(opts?: GetStyleOpts): StyleProps;
getStyle(prop: '' | undefined, opts?: GetStyleOpts): StyleProps;
getStyle<K extends keyof StyleProps>(prop: K, opts?: GetStyleOpts): StyleProps[K] | undefined;
getStyle(
prop?: keyof StyleProps | '' | ObjectAny,
opts: GetStyleOpts = {},
): StyleProps | StyleProps[keyof StyleProps] | undefined {
const rawStyle = this.get('style');
const parsedStyle: StyleProps = isString(rawStyle)
? this.parseStyle(rawStyle)
: isObject(rawStyle)
? { ...rawStyle }
: {};
delete parsedStyle.__p;
const shouldReturnFull = !prop || prop === '' || isObject(prop);
if (!opts.skipResolve) {
return prop && isString(prop) ? { ...style }[prop] : { ...style };
return shouldReturnFull ? parsedStyle : parsedStyle[prop];
}
const result: ObjectAny = { ...style, ...this.dataResolverWatchers.getDynamicStylesDefs() };
return prop && isString(prop) ? result[prop] : result;
const unresolvedStyles: StyleProps = this.dataResolverWatchers.getValueOrResolver('styles', parsedStyle);
return shouldReturnFull ? unresolvedStyles : unresolvedStyles[prop];
}
/**
@ -110,9 +182,9 @@ export default class StyleableModel<T extends ObjectHash = any> extends Model<T,
return;
}
});
newStyle = this.dataResolverWatchers.setStyles(newStyle, opts);
this.set('style', newStyle, opts as any);
const resolvedProps = this.dataResolverWatchers.addProps({ style: newStyle }, opts) as Partial<T>;
this.set(resolvedProps, opts as any);
newStyle = resolvedProps['style']! as StyleProps;
const changedKeys = Object.keys(shallowDiff(propOrig, propNew));
const diff: ObjectAny = changedKeys.reduce((acc, key) => {
@ -199,7 +271,7 @@ export default class StyleableModel<T extends ObjectHash = any> extends Model<T,
*/
styleToString(opts: ToCssOptions = {}) {
const result: string[] = [];
const style = opts.style || this.getStyle(opts);
const style = opts.style || (this.getStyle(opts as any) as StyleProps);
const imp = opts.important;
for (let prop in style) {
@ -229,4 +301,27 @@ export default class StyleableModel<T extends ObjectHash = any> extends Model<T,
// @ts-ignore
return this.selectorsToString ? this.selectorsToString(opts) : this.getSelectors().getFullString();
}
onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) {
this.collectionsStateMap = collectionsStateMap;
this.dataResolverWatchers.onCollectionsStateMapUpdate();
}
clone(attributes?: Partial<T>, opts?: any): typeof this {
const props = this.dataResolverWatchers.getProps(this.attributes);
const mergedProps = { ...props, ...attributes };
const mergedOpts = { ...this.opt, ...opts };
const ClassConstructor = this.constructor as new (attributes: any, opts?: any) => typeof this;
return new ClassConstructor(mergedProps, mergedOpts);
}
toJSON(opts?: ObjectAny, attributes?: Partial<T>) {
if (opts?.fromUndo) return { ...super.toJSON(opts) };
const mergedProps = { ...this.attributes, ...attributes };
const obj = this.dataResolverWatchers.getProps(mergedProps);
return obj;
}
}

73
packages/core/src/editor/index.ts

@ -44,43 +44,49 @@
*/
import { IBaseModule } from '../abstract/Module';
import AssetManager from '../asset_manager';
import { AssetEvent } from '../asset_manager/types';
import BlockManager, { BlockEvent } from '../block_manager';
import CanvasModule, { CanvasEvent } from '../canvas';
import BlockManager from '../block_manager';
import CanvasModule from '../canvas';
import CodeManagerModule from '../code_manager';
import CommandsModule, { CommandEvent } from '../commands';
import { AddOptions, EventHandler, LiteralUnion } from '../common';
import CommandsModule from '../commands';
import { AddOptions, EventHandler } 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 ComponentManager from '../dom_components';
import Component from '../dom_components/model/Component';
import Components from '../dom_components/model/Components';
import ComponentWrapper from '../dom_components/model/ComponentWrapper';
import { AddComponentsOption, ComponentAdd, DragMode } from '../dom_components/model/types';
import StyleableModel from '../domain_abstract/model/StyleableModel';
import I18nModule from '../i18n';
import KeymapsModule, { KeymapEvent } from '../keymaps';
import ModalModule, { ModalEvent } from '../modal_dialog';
import KeymapsModule from '../keymaps';
import ModalModule from '../modal_dialog';
import LayerManager from '../navigator';
import PageManager from '../pages';
import PanelManager from '../panels';
import ParserModule from '../parser';
import { CustomParserCss } from '../parser/config/config';
import RichTextEditorModule, { RichTextEditorEvent } from '../rich_text_editor';
import RichTextEditorModule from '../rich_text_editor';
import { CustomRTE } from '../rich_text_editor/config/config';
import SelectorManager, { SelectorEvent } from '../selector_manager';
import StorageManager, { ProjectData, StorageEvent, StorageOptions } from '../storage_manager';
import StyleManager, { StyleManagerEvent } from '../style_manager';
import SelectorManager from '../selector_manager';
import StorageManager, { ProjectData, StorageOptions } from '../storage_manager';
import StyleManager from '../style_manager';
import TraitManager from '../trait_manager';
import UndoManagerModule from '../undo_manager';
import UtilsModule from '../utils';
import html from '../utils/html';
import defConfig, { EditorConfig, EditorConfigKeys } from './config/config';
import EditorModel, { EditorLoadOptions } from './model/Editor';
import { EditorEvents } from './types';
import {
EditorConfigType,
EditorEvent,
EditorEventCallbacks,
EditorEventHandler,
EditorEvents,
EditorModelParam,
} from './types';
import EditorView from './view/EditorView';
export type ParsedRule = {
@ -90,28 +96,6 @@ export type ParsedRule = {
params?: string;
};
type GeneralEvent = 'canvasScroll' | 'undo' | 'redo' | 'load' | 'update';
type EditorBuiltInEvents =
| ComponentEvent
| BlockEvent
| AssetEvent
| KeymapEvent
| StyleManagerEvent
| StorageEvent
| CanvasEvent
| SelectorEvent
| RichTextEditorEvent
| ModalEvent
| CommandEvent
| GeneralEvent;
type EditorEvent = LiteralUnion<EditorBuiltInEvents, string>;
type EditorConfigType = EditorConfig & { pStylePrefix?: string };
type EditorModelParam<T extends keyof EditorModel, N extends number> = Parameters<EditorModel[T]>[N];
export type EditorParam<T extends keyof Editor, N extends number> = Parameters<Editor[T]>[N];
export default class Editor implements IBaseModule<EditorConfig> {
@ -733,8 +717,8 @@ export default class Editor implements IBaseModule<EditorConfig> {
* @param {Function} callback Callback function
* @return {this}
*/
on(event: EditorEvent, callback: EventHandler) {
this.em.on(event, callback);
on<E extends EditorEvent>(event: E, callback: EditorEventHandler<E>) {
this.em.on(event as string, callback);
return this;
}
@ -744,8 +728,8 @@ export default class Editor implements IBaseModule<EditorConfig> {
* @param {Function} callback Callback function
* @return {this}
*/
once(event: EditorEvent, callback: EventHandler) {
this.em.once(event, callback);
once<E extends EditorEvent>(event: E, callback: EditorEventHandler<E>) {
this.em.once(event as string, callback);
return this;
}
@ -755,8 +739,8 @@ export default class Editor implements IBaseModule<EditorConfig> {
* @param {Function} callback Callback function
* @return {this}
*/
off(event: EditorEvent, callback: EventHandler) {
this.em.off(event, callback);
off<E extends EditorEvent>(event: E, callback: EditorEventHandler<E>) {
this.em.off(event as string, callback);
return this;
}
@ -765,8 +749,11 @@ export default class Editor implements IBaseModule<EditorConfig> {
* @param {string} event Event to trigger
* @return {this}
*/
trigger(event: EditorEvent, ...args: any[]) {
this.em.trigger.apply(this.em, [event, ...args]);
trigger<E extends EditorEvent>(
event: E,
...args: E extends keyof EditorEventCallbacks ? EditorEventCallbacks[E] : any[]
) {
this.em.trigger.apply(this.em, [event as string, ...args]);
return this;
}

27
packages/core/src/editor/model/Editor.ts

@ -76,6 +76,7 @@ const storableDeps: (new (em: EditorModel) => IModule & IStorableModule)[] = [
CssComposer,
PageManager,
ComponentManager,
SelectorManager,
];
Extender({ $ });
@ -119,6 +120,7 @@ export default class EditorModel extends Model {
destroyed = false;
_config: InitEditorConfig;
_storageTimeout?: ReturnType<typeof setTimeout>;
_isStoring: boolean = false;
attrsOrig: any;
timedInterval?: ReturnType<typeof setTimeout>;
updateItr?: ReturnType<typeof setTimeout>;
@ -279,7 +281,7 @@ export default class EditorModel extends Model {
this.on('change:componentHovered', this.componentHovered, this);
this.on('change:changesCount', this.updateChanges, this);
this.on('change:readyLoad change:readyCanvas', this._checkReady, this);
toLog.forEach((e) => this.listenLog(e));
toLog.forEach((e) => this.listenLog(e as keyof typeof logs));
// Deprecations
[{ from: 'change:selectedComponent', to: 'component:toggled' }].forEach((event) => {
@ -302,9 +304,12 @@ export default class EditorModel extends Model {
return this.config.el;
}
listenLog(event: string) {
//@ts-ignore
this.listenTo(this, `log:${event}`, logs[event]);
listenLog(event: keyof typeof logs) {
this.listenTo(this, `log:${event}`, (...args) => {
if (!this.config.log) return;
const logFn = logs[event];
logFn?.(...args);
});
}
get config() {
@ -842,7 +847,7 @@ export default class EditorModel extends Model {
const keepUnusedStyles = !isUndefined(opts.keepUnusedStyles) ? opts.keepUnusedStyles : config.keepUnusedStyles;
const cssc = this.Css;
const wrp = opts.component || this.Components.getComponent();
const protCss = !avoidProt ? config.protectedCss! : '';
const protCss = !avoidProt ? config.protectedCss || '' : '';
const css =
wrp &&
this.CodeManager.getCode(wrp, 'css', {
@ -869,9 +874,19 @@ export default class EditorModel extends Model {
* @public
*/
async store<T extends StorageOptions>(options?: T) {
if (this._isStoring) return;
this._isStoring = true;
// We use a 1ms timeout to defer the cleanup to the next tick of the event loop.
// This prevents a race condition where a store operation, like 'sync:content',
// might increase the dirty count before it can be properly cleared.
setTimeout(() => {
this.clearDirtyCount();
}, 1);
const data = this.storeData();
await this.Storage.store(data, options);
this.clearDirtyCount();
setTimeout(() => {
this._isStoring = false;
}, 1);
return data;
}

47
packages/core/src/editor/types.ts

@ -1,3 +1,50 @@
import { AssetEvent } from '../asset_manager/types';
import { BlockEvent } from '../block_manager';
import { BlocksEventCallback } from '../block_manager/types';
import { CanvasEvent } from '../canvas';
import { CommandEvent } from '../commands';
import { LiteralUnion } from '../common';
import { DataSourceEvent, DataSourcesEventCallback } from '../data_sources/types';
import { ComponentEvent } from '../dom_components';
import { KeymapEvent } from '../keymaps';
import { ModalEvent } from '../modal_dialog';
import { RichTextEditorEvent } from '../rich_text_editor';
import { SelectorEvent } from '../selector_manager';
import { StyleManagerEvent } from '../style_manager';
import { EditorConfig } from './config/config';
import EditorModel from './model/Editor';
type GeneralEvent = 'canvasScroll' | 'undo' | 'redo' | 'load' | 'update';
type EditorBuiltInEvents =
| DataSourceEvent
| ComponentEvent
| BlockEvent
| AssetEvent
| KeymapEvent
| StyleManagerEvent
| StorageEvent
| CanvasEvent
| SelectorEvent
| RichTextEditorEvent
| ModalEvent
| CommandEvent
| GeneralEvent;
export type EditorEvent = LiteralUnion<EditorBuiltInEvents, string>;
export type EditorConfigType = EditorConfig & { pStylePrefix?: string };
export type EditorModelParam<T extends keyof EditorModel, N extends number> = Parameters<EditorModel[T]>[N];
export interface EditorEventCallbacks extends BlocksEventCallback, DataSourcesEventCallback {
[key: string]: any[];
}
export type EditorEventHandler<E extends EditorEvent> = E extends keyof EditorEventCallbacks
? (...args: EditorEventCallbacks[E]) => void
: (...args: any[]) => void;
/**{START_EVENTS}*/
export enum EditorEvents {
/**

33
packages/core/src/keymaps/index.ts

@ -15,22 +15,13 @@
* })
* ```
*
* Once the editor is instantiated you can use its API and listen to its events. Before using these methods, you should get the module from the instance.
* Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance.
*
* ```js
* // Listen to events
* editor.on('keymap:add', () => { ... });
*
* // Use the API
* const keymaps = editor.Keymaps;
* keymaps.add(...);
* ```
*
* ## Available Events
* * `keymap:add` - New keymap added. The new keyamp object is passed as an argument
* * `keymap:remove` - Keymap removed. The removed keyamp object is passed as an argument
* * `keymap:emit` - Some keymap emitted, in arguments you get keymapId, shortcutUsed, Event
* * `keymap:emit:{keymapId}` - `keymapId` emitted, in arguments you get keymapId, shortcutUsed, Event
* {REPLACE_EVENTS}
*
* ## Methods
* * [getConfig](#getconfig)
@ -44,19 +35,21 @@
*/
import { isFunction, isString } from 'underscore';
import { hasWin } from '../utils/mixins';
import keymaster from '../utils/keymaster';
import { Module } from '../abstract';
import EditorModel from '../editor/model/Editor';
import keymaster from '../utils/keymaster';
import { hasWin } from '../utils/mixins';
import defConfig, { Keymap, KeymapOptions, KeymapsConfig } from './config';
import { KeymapsEvents } from './types';
export type KeymapEvent = 'keymap:add' | 'keymap:remove' | 'keymap:emit' | `keymap:emit:${string}`;
export type KeymapEvent = `${KeymapsEvents}`;
hasWin() && keymaster.init(window);
export default class KeymapsModule extends Module<KeymapsConfig & { name?: string }> {
keymaster: any = keymaster;
keymaps: Record<string, Keymap>;
events = KeymapsEvents;
constructor(em: EditorModel) {
super(em, 'Keymaps', defConfig());
@ -106,7 +99,7 @@ export default class KeymapsModule extends Module<KeymapsConfig & { name?: strin
* })
*/
add(id: Keymap['id'], keys: Keymap['keys'], handler: Keymap['handler'], opts: KeymapOptions = {}) {
const { em } = this;
const { em, events } = this;
const cmd = em.Commands;
const editor = em.getEditor();
const canvas = em.Canvas;
@ -125,13 +118,13 @@ export default class KeymapsModule extends Module<KeymapsConfig & { name?: strin
opts.prevent && canvas.getCanvasView()?.preventDefault(e);
isFunction(handlerRes) ? handlerRes(editor, 0, opt) : cmd.runCommand(handlerRes, opt);
const args = [id, h.shortcut, e];
em.trigger('keymap:emit', ...args);
em.trigger(`keymap:emit:${id}`, ...args);
em.trigger(events.emit, ...args);
em.trigger(`${events.emitId}${id}`, ...args);
}
},
undefined,
);
em.trigger('keymap:add', keymap);
em.trigger(events.add, keymap);
return keymap;
}
@ -167,7 +160,7 @@ export default class KeymapsModule extends Module<KeymapsConfig & { name?: strin
* // -> {keys, handler};
*/
remove(id: string) {
const { em } = this;
const { em, events } = this;
const keymap = this.get(id);
if (keymap) {
@ -176,7 +169,7 @@ export default class KeymapsModule extends Module<KeymapsConfig & { name?: strin
// @ts-ignore
keymaster.unbind(k.trim());
});
em?.trigger('keymap:remove', keymap);
em?.trigger(events.remove, keymap);
return keymap;
}
}

28
packages/core/src/keymaps/types.ts

@ -0,0 +1,28 @@
/**{START_EVENTS}*/
export enum KeymapsEvents {
/**
* @event `keymap:add` New keymap added. The new keymap object is passed as an argument to the callback.
* @example
* editor.on('keymap:add', (keymap) => { ... });
*/
add = 'keymap:add',
/**
* @event `keymap:remove` Keymap removed. The removed keymap object is passed as an argument to the callback.
* @example
* editor.on('keymap:remove', (keymap) => { ... });
*/
remove = 'keymap:remove',
/**
* @event `keymap:emit` Some keymap emitted. The keymapId, shortcutUsed, and Event are passed as arguments to the callback.
* @example
* editor.on('keymap:emit', (keymapId, shortcutUsed, event) => { ... });
*/
emit = 'keymap:emit',
emitId = 'keymap:emit:',
}
/**{END_EVENTS}*/
// need this to avoid the TS documentation generator to break
export default KeymapsEvents;

25
packages/core/src/modal_dialog/index.ts

@ -14,10 +14,7 @@
* const modal = editor.Modal;
* ```
*
* ## Available Events
* * `modal:open` - Modal is opened
* * `modal:close` - Modal is closed
* * `modal` - Event triggered on any change related to the modal. An object containing all the available data about the triggered event is passed as an argument to the callback.
* {REPLACE_EVENTS}
*
* ## Methods
* * [open](#open)
@ -35,18 +32,20 @@
import { debounce, isFunction, isString } from 'underscore';
import { Module } from '../abstract';
import EditorView from '../editor/view/EditorView';
import { EventHandler } from '../common';
import EditorModel from '../editor/model/Editor';
import EditorView from '../editor/view/EditorView';
import { createText } from '../utils/dom';
import defConfig, { ModalConfig } from './config/config';
import ModalM from './model/Modal';
import { ModalEvents } from './types';
import ModalView from './view/ModalView';
import { EventHandler } from '../common';
export type ModalEvent = 'modal:open' | 'modal:close' | 'modal';
export type ModalEvent = `${ModalEvents}`;
export default class ModalModule extends Module<ModalConfig> {
modal?: ModalView;
events = ModalEvents;
/**
* Initialize module. Automatically called with a new instance of the editor
@ -55,10 +54,10 @@ export default class ModalModule extends Module<ModalConfig> {
*/
constructor(em: EditorModel) {
super(em, 'Modal', defConfig());
const { events } = this;
this.model = new ModalM(this);
this.model.on('change:open', (m: ModalM, enable: boolean) => {
em.trigger(`modal:${enable ? 'open' : 'close'}`);
em.trigger(enable ? events.open : events.close);
});
this.model.on(
'change',
@ -67,7 +66,7 @@ export default class ModalModule extends Module<ModalConfig> {
const { custom } = this.config;
//@ts-ignore
isFunction(custom) && custom(data);
em.trigger('modal', data);
em.trigger(events.all, data);
}, 0),
);
@ -142,7 +141,8 @@ export default class ModalModule extends Module<ModalConfig> {
* });
*/
onceClose(clb: EventHandler) {
this.em.once('modal:close', clb);
const { em, events } = this;
em.once(events.close, clb);
return this;
}
@ -157,7 +157,8 @@ export default class ModalModule extends Module<ModalConfig> {
* });
*/
onceOpen(clb: EventHandler) {
this.em.once('modal:open', clb);
const { em, events } = this;
em.once(events.open, clb);
return this;
}

27
packages/core/src/modal_dialog/types.ts

@ -0,0 +1,27 @@
/**{START_EVENTS}*/
export enum ModalEvents {
/**
* @event `modal:open` Modal is opened
* @example
* editor.on('modal:open', () => { ... });
*/
open = 'modal:open',
/**
* @event `modal:close` Modal is closed
* @example
* editor.on('modal:close', () => { ... });
*/
close = 'modal:close',
/**
* @event `modal` Event triggered on any change related to the modal. An object containing all the available data about the triggered event is passed as an argument to the callback.
* @example
* editor.on('modal', ({ open, title, content, ... }) => { ... });
*/
all = 'modal',
}
/**{END_EVENTS}*/
// need this to avoid the TS documentation generator to break
export default ModalEvents;

46
packages/core/src/navigator/index.ts

@ -9,15 +9,13 @@
* })
* ```
*
* Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance
* Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance.
*
* ```js
* const layers = editor.Layers;
* ```
*
* ## Available Events
* * `layer:root` - Root layer changed. The new root component is passed as an argument to the callback.
* * `layer:component` - Component layer is updated. The updated component is passed as an argument to the callback.
* {REPLACE_EVENTS}
*
* ## Methods
* * [setRoot](#setroot)
@ -39,38 +37,18 @@
* @module Layers
*/
import { isString, bindAll } from 'underscore';
import { bindAll, isString } from 'underscore';
import { ModuleModel } from '../abstract';
import Module from '../abstract/Module';
import Component from '../dom_components/model/Component';
import { ComponentsEvents } from '../dom_components/types';
import EditorModel from '../editor/model/Editor';
import { hasWin, isComponent, isDef } from '../utils/mixins';
import defConfig, { LayerManagerConfig } from './config/config';
import { LayerData, LayerEvents } from './types';
import View from './view/ItemView';
import { ComponentsEvents } from '../dom_components/types';
interface LayerData {
name: string;
open: boolean;
selected: boolean;
hovered: boolean;
visible: boolean;
locked: boolean;
components: Component[];
}
export const evAll = 'layer';
export const evPfx = `${evAll}:`;
export const evRoot = `${evPfx}root`;
export const evComponent = `${evPfx}component`;
export const evCustom = `${evPfx}custom`;
const events = {
all: evAll,
root: evRoot,
component: evComponent,
custom: evCustom,
};
export type LayerEvent = `${LayerEvents}`;
const styleOpts = { mediaText: '' };
@ -88,7 +66,7 @@ export default class LayerManager extends Module<LayerManagerConfig> {
view?: View;
events = events;
events = LayerEvents;
constructor(em: EditorModel) {
super(em, 'LayerManager', defConfig());
@ -184,7 +162,7 @@ export default class LayerManager extends Module<LayerManagerConfig> {
*/
setVisible(component: Component, value: boolean) {
const prevDspKey = '__prev-display';
const style: any = component.getStyle(styleOpts);
const style: any = component.getStyle(styleOpts as any);
const { display } = style;
if (value) {
@ -211,7 +189,7 @@ export default class LayerManager extends Module<LayerManagerConfig> {
* @returns {Boolean}
*/
isVisible(component: Component): boolean {
return !isStyleHidden(component.getStyle(styleOpts));
return !isStyleHidden(component.getStyle(styleOpts as any));
}
/**
@ -358,9 +336,10 @@ export default class LayerManager extends Module<LayerManagerConfig> {
}
__onRootChange() {
const { em, events } = this;
const root = this.getRoot();
this.view?.setRoot(root);
this.em.trigger(evRoot, root);
em.trigger(events.root, root);
this.__trgCustom();
}
@ -390,6 +369,7 @@ export default class LayerManager extends Module<LayerManagerConfig> {
}
updateLayer(component: Component, opts?: any) {
this.em.trigger(evComponent, component, opts);
const { em, events } = this;
em.trigger(events.component, component, opts);
}
}

39
packages/core/src/navigator/types.ts

@ -0,0 +1,39 @@
import Component from '../dom_components/model/Component';
export interface LayerData {
name: string;
open: boolean;
selected: boolean;
hovered: boolean;
visible: boolean;
locked: boolean;
components: Component[];
}
/**{START_EVENTS}*/
export enum LayerEvents {
/**
* @event `layer:root` Root layer changed. The new root component is passed as an argument to the callback.
* @example
* editor.on('layer:root', (component) => { ... });
*/
root = 'layer:root',
/**
* @event `layer:component` Component layer is updated. The updated component is passed as an argument to the callback.
* @example
* editor.on('layer:component', (component, opts) => { ... });
*/
component = 'layer:component',
/**
* @event `layer:custom` Custom layer event. Object with container and root is passed as an argument to the callback.
* @example
* editor.on('layer:custom', ({ container, root }) => { ... });
*/
custom = 'layer:custom',
}
/**{END_EVENTS}*/
// need this to avoid the TS documentation generator to break
export default LayerEvents;

48
packages/core/src/rich_text_editor/index.ts

@ -11,20 +11,13 @@
* })
* ```
*
* Once the editor is instantiated you can use its API and listen to its events. Before using these methods, you should get the module from the instance.
* Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance.
*
* ```js
* // Listen to events
* editor.on('rte:enable', () => { ... });
*
* // Use the API
* const rte = editor.RichTextEditor;
* rte.add(...);
* ```
*
* ## Available Events
* * `rte:enable` - RTE enabled. The view, on which RTE is enabled, is passed as an argument
* * `rte:disable` - RTE disabled. The view, on which RTE is disabled, is passed as an argument
* {REPLACE_EVENTS}
*
* ## Methods
* * [add](#add)
@ -39,38 +32,21 @@
import { debounce, isFunction, isString } from 'underscore';
import { Module } from '../abstract';
import CanvasEvents from '../canvas/types';
import { Debounced, DisableOptions, Model } from '../common';
import { ComponentsEvents } from '../dom_components/types';
import ComponentTextView from '../dom_components/view/ComponentTextView';
import EditorModel from '../editor/model/Editor';
import { createEl, cx, on, removeEl } from '../utils/dom';
import { hasWin, isDef } from '../utils/mixins';
import defConfig, { CustomRTE, CustomRteOptions, RichTextEditorConfig } from './config/config';
import RichTextEditor, { RichTextEditorAction } from './model/RichTextEditor';
import CanvasEvents from '../canvas/types';
import { ComponentsEvents } from '../dom_components/types';
import ComponentTextView from '../dom_components/view/ComponentTextView';
import { ModelRTE, RichTextEditorEvents, RteDisableResult } from './types';
export type RichTextEditorEvent = 'rte:enable' | 'rte:disable' | 'rte:custom';
export type { RichTextEditorEvent, RteDisableResult } from './types';
const eventsUp = `${CanvasEvents.refresh} frame:scroll ${ComponentsEvents.update}`;
export const evEnable = 'rte:enable';
export const evDisable = 'rte:disable';
export const evCustom = 'rte:custom';
const events = {
enable: evEnable,
disable: evDisable,
custom: evCustom,
};
interface ModelRTE {
currentView?: ComponentTextView;
}
export interface RteDisableResult {
forceSync?: boolean;
}
export default class RichTextEditorModule extends Module<RichTextEditorConfig & { pStylePrefix?: string }> {
pfx: string;
toolbar!: HTMLElement;
@ -81,7 +57,7 @@ export default class RichTextEditorModule extends Module<RichTextEditorConfig &
customRte?: CustomRTE;
model: Model<ModelRTE>;
__dbdTrgCustom: Debounced;
events = events;
events = RichTextEditorEvents;
/**
* Get configuration object
@ -362,7 +338,7 @@ export default class RichTextEditorModule extends Module<RichTextEditorConfig &
* */
async enable(view: ComponentTextView, rte: RichTextEditor, opts: CustomRteOptions) {
this.lastEl = view.el;
const { customRte, em } = this;
const { customRte, em, events } = this;
const el = view.getChildrenContainer();
this.toolbar.style.display = '';
@ -372,7 +348,7 @@ export default class RichTextEditorModule extends Module<RichTextEditorConfig &
setTimeout(this.updatePosition.bind(this), 0);
em.off(eventsUp, this.updatePosition, this);
em.on(eventsUp, this.updatePosition, this);
em.trigger('rte:enable', view, rteInst);
em.trigger(events.enable, view, rteInst);
}
this.model.set({ currentView: view });
@ -407,7 +383,7 @@ export default class RichTextEditorModule extends Module<RichTextEditorConfig &
* */
async disable(view: ComponentTextView, rte?: RichTextEditor, opts: DisableOptions = {}) {
let result: RteDisableResult = {};
const { em } = this;
const { em, events } = this;
const customRte = this.customRte;
if (customRte) {
@ -423,7 +399,7 @@ export default class RichTextEditorModule extends Module<RichTextEditorConfig &
if (em) {
em.off(eventsUp, this.updatePosition, this);
!opts.fromMove && em.trigger('rte:disable', view, rte);
!opts.fromMove && em.trigger(events.disable, view, rte);
}
this.model.unset('currentView');

39
packages/core/src/rich_text_editor/types.ts

@ -0,0 +1,39 @@
import ComponentTextView from '../dom_components/view/ComponentTextView';
export interface ModelRTE {
currentView?: ComponentTextView;
}
export type RichTextEditorEvent = `${RichTextEditorEvents}`;
export interface RteDisableResult {
forceSync?: boolean;
}
/**{START_EVENTS}*/
export enum RichTextEditorEvents {
/**
* @event `rte:enable` RTE enabled. The view, on which RTE is enabled, and the RTE instance are passed as arguments.
* @example
* editor.on('rte:enable', (view, rte) => { ... });
*/
enable = 'rte:enable',
/**
* @event `rte:disable` RTE disabled. The view, on which RTE is disabled, and the RTE instance are passed as arguments.
* @example
* editor.on('rte:disable', (view, rte) => { ... });
*/
disable = 'rte:disable',
/**
* @event `rte:custom` Custom RTE event. Object with enabled status, container, and actions is passed as an argument.
* @example
* editor.on('rte:custom', ({ enabled, container, actions }) => { ... });
*/
custom = 'rte:custom',
}
/**{END_EVENTS}*/
// need this to avoid the TS documentation generator to break
export default RichTextEditorEvents;

88
packages/core/src/selector_manager/index.ts

@ -29,23 +29,13 @@
* })
* ```
*
* Once the editor is instantiated you can use its API and listen to its events. Before using these methods, you should get the module from the instance.
* Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance.
*
* ```js
* // Listen to events
* editor.on('selector:add', (selector) => { ... });
*
* // Use the API
* const sm = editor.Selectors;
* sm.add(...);
* ```
*
* ## Available Events
* * `selector:add` - Selector added. The [Selector] is passed as an argument to the callback.
* * `selector:remove` - Selector removed. The [Selector] is passed as an argument to the callback.
* * `selector:update` - Selector updated. The [Selector] and the object containing changes are passed as arguments to the callback.
* * `selector:state` - States changed. An object containing all the available data about the triggered event is passed as an argument to the callback.
* * `selector` - Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback.
* {REPLACE_EVENTS}
*
* ## Methods
* * [getConfig](#getconfig)
@ -73,47 +63,27 @@
* @module Selectors
*/
import { isString, debounce, isObject, isArray, bindAll } from 'underscore';
import { bindAll, debounce, isArray, isObject, isString } from 'underscore';
import { ItemManagerModule } from '../abstract/Module';
import { Collection, Debounced, Model, RemoveOptions, SetOptions } from '../common';
import Component from '../dom_components/model/Component';
import { ComponentsEvents } from '../dom_components/types';
import StyleableModel from '../domain_abstract/model/StyleableModel';
import EditorModel from '../editor/model/Editor';
import { StyleModuleParam } from '../style_manager';
import { isComponent, isRule } from '../utils/mixins';
import { Model, Collection, RemoveOptions, SetOptions, Debounced } from '../common';
import defConfig, { SelectorManagerConfig } from './config/config';
import Selector from './model/Selector';
import Selectors from './model/Selectors';
import State from './model/State';
import { SelectorEvents, SelectorStringObject } from './types';
import ClassTagsView from './view/ClassTagsView';
import EditorModel from '../editor/model/Editor';
import Component from '../dom_components/model/Component';
import { ItemManagerModule } from '../abstract/Module';
import { StyleModuleParam } from '../style_manager';
import StyleableModel from '../domain_abstract/model/StyleableModel';
import { ComponentsEvents } from '../dom_components/types';
export type SelectorEvent = 'selector:add' | 'selector:remove' | 'selector:update' | 'selector:state' | 'selector';
export type SelectorEvent = `${SelectorEvents}`;
const isId = (str: string) => isString(str) && str[0] == '#';
const isClass = (str: string) => isString(str) && str[0] == '.';
export const evAll = 'selector';
export const evPfx = `${evAll}:`;
export const evAdd = `${evPfx}add`;
export const evUpdate = `${evPfx}update`;
export const evRemove = `${evPfx}remove`;
export const evRemoveBefore = `${evRemove}:before`;
export const evCustom = `${evPfx}custom`;
export const evState = `${evPfx}state`;
const selectorEvents = {
all: evAll,
update: evUpdate,
add: evAdd,
remove: evRemove,
removeBefore: evRemoveBefore,
state: evState,
custom: evCustom,
};
type SelectorStringObject = string | { name?: string; label?: string; type?: number };
export default class SelectorManager extends ItemManagerModule<SelectorManagerConfig & { pStylePrefix?: string }> {
Selector = Selector;
@ -124,11 +94,10 @@ export default class SelectorManager extends ItemManagerModule<SelectorManagerCo
selectorTags?: ClassTagsView;
selected: Selectors;
all: Selectors;
events!: typeof selectorEvents;
events = SelectorEvents;
storageKey = '';
__update: Debounced;
__ctn?: HTMLElement;
/**
* Get configuration object
* @name getConfig
@ -137,13 +106,12 @@ export default class SelectorManager extends ItemManagerModule<SelectorManagerCo
*/
constructor(em: EditorModel) {
super(em, 'SelectorManager', new Selectors([]), selectorEvents, defConfig(), { skipListen: true });
super(em, 'SelectorManager', new Selectors([]), SelectorEvents, defConfig(), { skipListen: true });
bindAll(this, '__updateSelectedByComponents');
const { config } = this;
const { config, events } = this;
const ppfx = config.pStylePrefix;
if (ppfx) config.stylePrefix = ppfx + config.stylePrefix;
// Global selectors container
this.all = new Selectors(config.selectors);
this.selected = new Selectors([], { em, config });
this.states = new Collection<State>(
@ -154,9 +122,9 @@ export default class SelectorManager extends ItemManagerModule<SelectorManagerCo
this.__update = debounce(() => this.__trgCustom(), 0);
this.__initListen({
collections: [this.states, this.selected],
propagate: [{ entity: this.states, event: this.events.state }],
propagate: [{ entity: this.states, event: events.state }],
});
em.on('change:state', (m, value) => em.trigger(evState, value));
em.on('change:state', (m, value) => em.trigger(events.state, value));
this.model.on('change:cFirst', (m, value) => em.trigger('selector:type', value));
const eventCmpUpdateCls = `${ComponentsEvents.update}:classes`;
em.on(`component:toggled ${eventCmpUpdateCls}`, this.__updateSelectedByComponents);
@ -183,14 +151,6 @@ export default class SelectorManager extends ItemManagerModule<SelectorManagerCo
};
}
// postLoad() {
// this.__postLoad();
// const { em, model } = this;
// const um = em.get('UndoManager');
// um && um.add(model);
// um && um.add(this.pages);
// },
postRender() {
this.__appendTo();
this.__trgCustom();
@ -230,10 +190,8 @@ export default class SelectorManager extends ItemManagerModule<SelectorManagerCo
props.name = this.escapeName(props.label);
}
const cname = props.name;
const config = this.getConfig();
const { all, em } = this;
const selector = cname ? (this.get(cname, props.type) as Selector) : all.where(props)[0];
const { all, em, config } = this;
const selector = all.get(props);
if (!selector) {
const selModel = props instanceof Selector ? props : new Selector(props, { ...cOpts, config, em });
@ -251,7 +209,7 @@ export default class SelectorManager extends ItemManagerModule<SelectorManagerCo
name = name.substr(1);
}
return this.all.where({ name, type })[0];
return this.all.get({ name, type } as any);
}
/**
@ -292,7 +250,7 @@ export default class SelectorManager extends ItemManagerModule<SelectorManagerCo
const added: Selector[] = [];
if (isString(classes)) {
classes = classes.trim().split(' ');
classes = classes.trim().split(' ').filter(Boolean);
}
classes.forEach((name) => added.push(this.addSelector(name) as Selector));
@ -552,6 +510,10 @@ export default class SelectorManager extends ItemManagerModule<SelectorManagerCo
this.selectorTags = undefined;
}
// Need for the IStorableModule to run the clenup
load() {}
store() {}
/**
* Get common selectors from the current selection.
* @return {Array<Selector>}

57
packages/core/src/selector_manager/types.ts

@ -0,0 +1,57 @@
/**{START_EVENTS}*/
export enum SelectorEvents {
/**
* @event `selector:add` Selector added. The Selector is passed as an argument to the callback.
* @example
* editor.on('selector:add', (selector) => { ... });
*/
add = 'selector:add',
/**
* @event `selector:remove` Selector removed. The Selector is passed as an argument to the callback.
* @example
* editor.on('selector:remove', (selector) => { ... });
*/
remove = 'selector:remove',
/**
* @event `selector:remove:before` Before selector remove. The Selector is passed as an argument to the callback.
* @example
* editor.on('selector:remove:before', (selector) => { ... });
*/
removeBefore = 'selector:remove:before',
/**
* @event `selector:update` Selector updated. The Selector and the object containing changes are passed as arguments to the callback.
* @example
* editor.on('selector:update', (selector, changes) => { ... });
*/
update = 'selector:update',
/**
* @event `selector:state` States changed. An object containing all the available data about the triggered event is passed as an argument to the callback.
* @example
* editor.on('selector:state', (state) => { ... });
*/
state = 'selector:state',
/**
* @event `selector:custom` Custom selector event. An object containing states, selected selectors, and container is passed as an argument.
* @example
* editor.on('selector:custom', ({ states, selected, container }) => { ... });
*/
custom = 'selector:custom',
/**
* @event `selector` Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback.
* @example
* editor.on('selector', ({ event, selector, changes, ... }) => { ... });
*/
all = 'selector',
}
/**{END_EVENTS}*/
export type SelectorStringObject = string | { name?: string; label?: string; type?: number };
// need this to avoid the TS documentation generator to break
export default SelectorEvents;

106
packages/core/src/style_manager/index.ts

@ -9,29 +9,13 @@
* })
* ```
*
* Once the editor is instantiated you can use its API and listen to its events. Before using these methods, you should get the module from the instance.
* Once the editor is instantiated you can use its API. Before using these methods you should get the module from the instance.
*
* ```js
* // Listen to events
* editor.on('style:sector:add', (sector) => { ... });
*
* // Use the API
* const styleManager = editor.StyleManager;
* styleManager.addSector(...);
* ```
* ## Available Events
* * `style:sector:add` - Sector added. The [Sector] is passed as an argument to the callback.
* * `style:sector:remove` - Sector removed. The [Sector] is passed as an argument to the callback.
* * `style:sector:update` - Sector updated. The [Sector] and the object containing changes are passed as arguments to the callback.
* * `style:property:add` - Property added. The [Property] is passed as an argument to the callback.
* * `style:property:remove` - Property removed. The [Property] is passed as an argument to the callback.
* * `style:property:update` - Property updated. The [Property] and the object containing changes are passed as arguments to the callback.
* * `style:target` - Target selection changed. The target (or `null` in case the target is deselected) is passed as an argument to the callback.
* <!--
* * `styleManager:update:target` - The target (Component or CSSRule) is changed
* * `styleManager:change` - Triggered on style property change from new selected component, the view of the property is passed as an argument to the callback
* * `styleManager:change:{propertyName}` - As above but for a specific style property
* -->
*
* {REPLACE_EVENTS}
*
* ## Methods
* * [getConfig](#getconfig)
@ -63,71 +47,32 @@
* @module docsjs.StyleManager
*/
import { isUndefined, isArray, isString, debounce, bindAll } from 'underscore';
import { isComponent } from '../utils/mixins';
import { bindAll, debounce, isArray, isString, isUndefined } from 'underscore';
import { ItemManagerModule } from '../abstract/Module';
import { AddOptions, Debounced, Model } from '../common';
import CssRule from '../css_composer/model/CssRule';
import Component from '../dom_components/model/Component';
import { ComponentsEvents } from '../dom_components/types';
import StyleableModel, { StyleProps } from '../domain_abstract/model/StyleableModel';
import EditorModel from '../editor/model/Editor';
import { isComponent } from '../utils/mixins';
import defConfig, { StyleManagerConfig } from './config/config';
import Sector, { SectorProperties } from './model/Sector';
import Sectors from './model/Sectors';
import Properties from './model/Properties';
import PropertyFactory from './model/PropertyFactory';
import SectorsView from './view/SectorsView';
import { ItemManagerModule } from '../abstract/Module';
import EditorModel from '../editor/model/Editor';
import Property, { PropertyProps } from './model/Property';
import Component from '../dom_components/model/Component';
import CssRule from '../css_composer/model/CssRule';
import StyleableModel, { StyleProps } from '../domain_abstract/model/StyleableModel';
import { CustomPropertyView } from './view/PropertyView';
import { PropertySelectProps } from './model/PropertySelect';
import { PropertyNumberProps } from './model/PropertyNumber';
import PropertyStack, { PropertyStackProps } from './model/PropertyStack';
import PropertyComposite from './model/PropertyComposite';
import { ComponentsEvents } from '../dom_components/types';
import PropertyFactory from './model/PropertyFactory';
import PropertyStack from './model/PropertyStack';
import Sector, { SectorProperties } from './model/Sector';
import Sectors from './model/Sectors';
import { PropertyTypes, StyleManagerEvents, StyleTarget } from './types';
import { CustomPropertyView } from './view/PropertyView';
import SectorsView from './view/SectorsView';
export type PropertyTypes = PropertyStackProps | PropertySelectProps | PropertyNumberProps;
export type StyleManagerEvent =
| 'style:sector:add'
| 'style:sector:remove'
| 'style:sector:update'
| 'style:property:add'
| 'style:property:remove'
| 'style:property:update'
| 'style:target';
export type StyleTarget = StyleableModel;
export const evAll = 'style';
export const evPfx = `${evAll}:`;
export const evSector = `${evPfx}sector`;
export const evSectorAdd = `${evSector}:add`;
export const evSectorRemove = `${evSector}:remove`;
export const evSectorUpdate = `${evSector}:update`;
export const evProp = `${evPfx}property`;
export const evPropAdd = `${evProp}:add`;
export const evPropRemove = `${evProp}:remove`;
export const evPropUp = `${evProp}:update`;
export const evLayerSelect = `${evPfx}layer:select`;
export const evTarget = `${evPfx}target`;
export const evCustom = `${evPfx}custom`;
export type StyleModuleParam<T extends keyof StyleManager, N extends number> = Parameters<StyleManager[T]>[N];
export type { PropertyTypes, StyleModuleParam, StyleTarget } from './types';
const propDef = (value: any) => value || value === 0;
export type StyleManagerEvent = `${StyleManagerEvents}`;
const stylesEvents = {
all: evAll,
sectorAdd: evSectorAdd,
sectorRemove: evSectorRemove,
sectorUpdate: evSectorUpdate,
propertyAdd: evPropAdd,
propertyRemove: evPropRemove,
propertyUpdate: evPropUp,
layerSelect: evLayerSelect,
target: evTarget,
custom: evCustom,
};
const propDef = (value: any) => value || value === 0;
export default class StyleManager extends ItemManagerModule<
StyleManagerConfig,
@ -137,7 +82,7 @@ export default class StyleManager extends ItemManagerModule<
builtIn: PropertyFactory;
upAll: Debounced;
properties: typeof Properties;
events!: typeof stylesEvents;
events = StyleManagerEvents;
sectors: Sectors;
SectView!: SectorsView;
Sector = Sector;
@ -157,8 +102,9 @@ export default class StyleManager extends ItemManagerModule<
* @private
*/
constructor(em: EditorModel) {
super(em, 'StyleManager', new Sectors([], { em }), stylesEvents, defConfig());
super(em, 'StyleManager', new Sectors([], { em }), StyleManagerEvents, defConfig());
bindAll(this, '__clearStateTarget');
const { events } = this;
const c = this.config;
const ppfx = c.pStylePrefix;
if (ppfx) c.stylePrefix = ppfx + c.stylePrefix;
@ -185,10 +131,10 @@ export default class StyleManager extends ItemManagerModule<
// Triggers only custom event
const trgCustom = debounce(() => this.__trgCustom(), 0);
model.listenTo(em, `${evLayerSelect} ${evTarget}`, trgCustom);
model.listenTo(em, `${events.layerSelect} ${events.target}`, trgCustom);
// Other listeners
model.on('change:lastTarget', () => em.trigger(evTarget, this.getSelected()));
model.on('change:lastTarget', () => em.trigger(events.target, this.getSelected()));
}
__upSel() {

88
packages/core/src/style_manager/types.ts

@ -0,0 +1,88 @@
import StyleManager from '.';
import StyleableModel from '../domain_abstract/model/StyleableModel';
import { PropertyNumberProps } from './model/PropertyNumber';
import { PropertySelectProps } from './model/PropertySelect';
import { PropertyStackProps } from './model/PropertyStack';
export type PropertyTypes = PropertyStackProps | PropertySelectProps | PropertyNumberProps;
export type StyleTarget = StyleableModel;
export type StyleModuleParam<T extends keyof StyleManager, N extends number> = Parameters<StyleManager[T]>[N];
/**{START_EVENTS}*/
export enum StyleManagerEvents {
/**
* @event `style:sector:add` Sector added. The Sector is passed as an argument to the callback.
* @example
* editor.on('style:sector:add', (sector) => { ... });
*/
sectorAdd = 'style:sector:add',
/**
* @event `style:sector:remove` Sector removed. The Sector is passed as an argument to the callback.
* @example
* editor.on('style:sector:remove', (sector) => { ... });
*/
sectorRemove = 'style:sector:remove',
/**
* @event `style:sector:update` Sector updated. The Sector and the object containing changes are passed as arguments to the callback.
* @example
* editor.on('style:sector:update', (sector, changes) => { ... });
*/
sectorUpdate = 'style:sector:update',
/**
* @event `style:property:add` Property added. The Property is passed as an argument to the callback.
* @example
* editor.on('style:property:add', (property) => { ... });
*/
propertyAdd = 'style:property:add',
/**
* @event `style:property:remove` Property removed. The Property is passed as an argument to the callback.
* @example
* editor.on('style:property:remove', (property) => { ... });
*/
propertyRemove = 'style:property:remove',
/**
* @event `style:property:update` Property updated. The Property and the object containing changes are passed as arguments to the callback.
* @example
* editor.on('style:property:update', (property, changes) => { ... });
*/
propertyUpdate = 'style:property:update',
/**
* @event `style:target` Target selection changed. The target (or null in case the target is deselected) is passed as an argument to the callback.
* @example
* editor.on('style:target', (target) => { ... });
*/
target = 'style:target',
/**
* @event `style:layer:select` Layer selected. Object containing layer data is passed as an argument.
* @example
* editor.on('style:layer:select', (data) => { ... });
*/
layerSelect = 'style:layer:select',
/**
* @event `style:custom` Custom style event. Object containing all custom data is passed as an argument.
* @example
* editor.on('style:custom', ({ container }) => { ... });
*/
custom = 'style:custom',
/**
* @event `style` Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback.
* @example
* editor.on('style', ({ event, sector, property, ... }) => { ... });
*/
all = 'style',
}
/**{END_EVENTS}*/
// need this to avoid the TS documentation generator to break
export default StyleManagerEvents;

2
packages/core/src/trait_manager/model/Trait.ts

@ -275,7 +275,7 @@ export default class Trait extends Model<TraitProperties> {
});
} else if (this.changeProp) {
value = component.get(name);
if (skipResolve) value = component.dataResolverWatchers.getPropsDefsOrValues({ [name]: value })[name];
if (skipResolve) value = component.dataResolverWatchers.getValueOrResolver('props', { [name]: value })[name];
} else {
value = component.getAttributes({ skipResolve })[name];
}

22
packages/core/src/utils/Droppable.ts

@ -1,12 +1,12 @@
import { bindAll, indexOf } from 'underscore';
import CanvasModule from '../canvas';
import { ObjectStrings } from '../common';
import Component from '../dom_components/model/Component';
import EditorModel from '../editor/model/Editor';
import { getDocumentScroll, off, on } from './dom';
import { DragDirection, DragSource } from './sorter/types';
import CanvasNewComponentNode from './sorter/CanvasNewComponentNode';
import ComponentSorter from './sorter/ComponentSorter';
import Component from '../dom_components/model/Component';
import { DragDirection, DraggableContent, DragSource } from './sorter/types';
// TODO move in sorter
type SorterOptions = {
@ -233,7 +233,7 @@ export default class Droppable {
handleDrop(ev: Event | DragEvent) {
ev.preventDefault();
const dt = (ev as DragEvent).dataTransfer;
const content = this.getContentByData(dt).content;
const content = this.getContentByData(dt!).content || '';
if (this.draggedNode) {
this.draggedNode.content = content;
}
@ -241,12 +241,12 @@ export default class Droppable {
this.endDrop(!content, ev);
}
getContentByData(dt: any) {
getContentByData(dt?: DataTransfer) {
const em = this.em;
const types = dt && dt.types;
const files = (dt && dt.files) || [];
const types = dt?.types || [];
const files = dt?.files || [];
const dragSource: DragSource<Component> = em.get('dragSource');
let content = dt && dt.getData('text');
let content: DraggableContent['content'] = dt?.getData('text') || '';
if (files.length) {
content = [];
@ -280,9 +280,13 @@ export default class Droppable {
content = `<div>${content}</div>`;
}
const result = { content };
const result = {
content,
setContent(content: DraggableContent['content']) {
result.content = content;
},
};
em.trigger('canvas:dragdata', dt, result);
return result;
}
}

42
packages/core/src/utils/mixins.ts

@ -25,7 +25,7 @@ function castPath(value: string | string[], object: ObjectAny) {
return object.hasOwnProperty(value) ? [value] : stringToPath(value);
}
export const get = (object: ObjectAny, path: string | string[], def: any) => {
export const get = (object: ObjectAny, path: string | string[], def?: any) => {
const paths = castPath(path, object);
const length = paths.length;
let index = 0;
@ -36,6 +36,38 @@ export const get = (object: ObjectAny, path: string | string[], def: any) => {
return (index && index == length ? object : undefined) ?? def;
};
export const set = (object: ObjectAny, path: string | string[], value: any): boolean => {
if (!isObject(object)) return false;
const paths = castPath(path, object);
const length = paths.length;
if (length === 0) return false;
if (length === 1) {
object[paths[0]] = value;
return true;
}
const parentPath = paths.slice(0, -1);
const lastKey = paths[length - 1];
const parent = get(object, parentPath);
if (parent) {
if (Array.isArray(parent)) {
const index = +lastKey;
if (!isNaN(index)) {
parent[index] = value;
return true;
}
} else if (isObject(parent)) {
(parent as ObjectAny)[lastKey] = value;
return true;
}
}
return false;
};
export const serialize = (obj: ObjectAny) => JSON.parse(JSON.stringify(obj));
export const isBultInMethod = (key: string) => isFunction(obj[key]);
@ -192,6 +224,14 @@ export const escapeNodeContent = (str = '') => {
return `${str}`.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
};
export const escapeAttrValue = (str = '') => {
return `${str}`.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
};
export const escapeAltQuoteAttrValue = (str = '') => {
return `${str}`.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/'/g, '&apos;');
};
export const deepMerge = (...args: ObjectAny[]) => {
const target = { ...args[0] };

24
packages/core/test/common.ts

@ -1,4 +1,4 @@
import { DataSourceManager } from '../src';
import { DataSource } from '../src';
import CanvasEvents from '../src/canvas/types';
import { ObjectAny } from '../src/common';
import {
@ -14,7 +14,17 @@ import EditorModel from '../src/editor/model/Editor';
export const DEFAULT_CMPS = 3;
export function setupTestEditor(opts?: { withCanvas?: boolean; config?: Partial<EditorConfig> }) {
document.body.innerHTML = '<div id="fixtures"></div> <div id="canvas-wrp"></div> <div id="editor"></div>';
document.body.innerHTML = '';
const fixtures = document.createElement('div');
fixtures.id = 'fixtures';
const canvasWrapEl = document.createElement('div');
canvasWrapEl.id = 'canvas-wrp';
const editorEl = document.createElement('div');
editorEl.id = 'editor';
document.body.appendChild(fixtures);
document.body.appendChild(canvasWrapEl);
document.body.appendChild(editorEl);
const editor = new Editor({
mediaCondition: 'max-width',
el: document.body.querySelector('#editor') as HTMLElement,
@ -23,6 +33,7 @@ export function setupTestEditor(opts?: { withCanvas?: boolean; config?: Partial<
});
const em = editor.getModel();
const dsm = em.DataSources;
const um = em.UndoManager;
const { Pages, Components, Canvas } = em;
Pages.onLoad();
const cmpRoot = Components.getWrapper()!;
@ -32,9 +43,6 @@ export function setupTestEditor(opts?: { withCanvas?: boolean; config?: Partial<
config: { ...cmpRoot.config, em },
});
wrapperEl.render();
const fixtures = document.body.querySelector('#fixtures')!;
fixtures.appendChild(wrapperEl.el);
const canvasWrapEl = document.body.querySelector('#canvas-wrp')!;
/**
* When trying to render the canvas, seems like jest gets stuck in a loop of iframe.onload (FrameView.ts)
@ -48,10 +56,14 @@ export function setupTestEditor(opts?: { withCanvas?: boolean; config?: Partial<
el.onload = null;
});
// Enable undo manager
editor.UndoManager.postLoad();
editor.CssComposer.postLoad();
editor.DataSources.postLoad();
editor.Components.postLoad();
editor.Pages.postLoad();
}
return { editor, em, dsm, cmpRoot, fixtures: fixtures as HTMLElement };
return { editor, em, dsm, um, cmpRoot, fixtures };
}
export function fixJsDom(editor: Editor) {

0
packages/core/test/specs/data_sources/model/StyleDataVariable.ts → packages/core/test/specs/data_sources/dynamic_values/styles.ts

0
packages/core/test/specs/data_sources/model/TraitDataVariable.ts → packages/core/test/specs/data_sources/dynamic_values/traits.ts

107
packages/core/test/specs/data_sources/index.ts

@ -1,12 +1,12 @@
import DataSourceManager from '../../../src/data_sources';
import { DataSourceProps } from '../../../src/data_sources/types';
import { setupTestEditor } from '../../common';
import EditorModel from '../../../src/editor/model/Editor';
import { setupTestEditor } from '../../common';
describe('DataSourceManager', () => {
let em: EditorModel;
let dsm: DataSourceManager;
type Record = { id: string; name: string };
type Record = { id: string; name: string; metadata?: any };
const dsTest: DataSourceProps<Record> = {
id: 'ds1',
records: [
@ -35,7 +35,7 @@ describe('DataSourceManager', () => {
em.on(dsm.events.add, eventAdd);
const ds = addDataSource();
expect(dsm.getAll().length).toBe(1);
expect(eventAdd).toBeCalledTimes(1);
expect(eventAdd).toHaveBeenCalledTimes(1);
expect(ds.getRecords().length).toBe(3);
});
@ -50,7 +50,104 @@ describe('DataSourceManager', () => {
const ds = addDataSource();
dsm.remove('ds1');
expect(dsm.getAll().length).toBe(0);
expect(event).toBeCalledTimes(1);
expect(event).toBeCalledWith(ds, expect.any(Object));
expect(event).toHaveBeenCalledTimes(1);
expect(event).toHaveBeenCalledWith(ds, expect.any(Object));
});
describe('getValue', () => {
test('get value from records', () => {
const ds = addDataSource();
const testPath = ds.getRecord('id2')?.getPath('name') || '';
expect(dsm.getValue(testPath)).toBe('Name2');
expect(dsm.getValue(`ds1.id1.name`)).toBe('Name1');
expect(dsm.getValue(`ds1[id1]name`)).toBe('Name1');
expect(dsm.getValue(`ds1.non-existing.name`)).toBeUndefined();
expect(dsm.getValue(`ds1.non-existing.name`, 'Default name')).toBe('Default name');
expect(dsm.getValue(`ds1.id1.nonExisting`)).toBeUndefined();
expect(dsm.getValue('non-existing-ds.id1.name')).toBeUndefined();
});
test('with nested values', () => {
const ds = addDataSource();
const address = { city: 'CityName' };
const roles = ['admin', 'user'];
ds.addRecord({
id: 'id4',
name: 'Name4',
metadata: { address, roles },
});
expect(dsm.getValue(`ds1.id4.metadata.address`)).toEqual(address);
expect(dsm.getValue(`ds1.id4.metadata.address.city`)).toEqual(address.city);
expect(dsm.getValue(`ds1.id4.metadata.roles`)).toEqual(roles);
expect(dsm.getValue(`ds1.id4.metadata.roles[1]`)).toEqual(roles[1]);
});
});
describe('setValue', () => {
test('set value in existing record', () => {
addDataSource();
expect(dsm.setValue('ds1.id1.name', 'Name1 Up')).toBe(true);
expect(dsm.getValue('ds1.id1.name')).toBe('Name1 Up');
expect(dsm.setValue('ds1.id1.newField', 'new field value')).toBe(true);
expect(dsm.getValue('ds1.id1.newField')).toBe('new field value');
expect(dsm.setValue('non-existing-ds.id1.name', 'New Name')).toBe(false);
expect(dsm.setValue('non-existing-ds.none.name', 'New Name')).toBe(false);
expect(dsm.setValue('invalid-path', 'New Name')).toBe(false);
});
test('set nested values', () => {
const ds = addDataSource();
const address = { city: 'CityName' };
const roles = ['admin', 'user', 'member'];
const newObj = { newValue: '1' };
ds.addRecord({
id: 'id4',
name: 'Name4',
metadata: { address, roles },
});
// Check object updates
expect(dsm.setValue('ds1.id4.metadata.address.city', 'NewCity')).toBe(true);
expect(dsm.getValue('ds1.id4.metadata.address.city')).toBe('NewCity');
expect(dsm.setValue('ds1.id4.metadata.newObj', newObj)).toBe(true);
expect(dsm.getValue('ds1.id4.metadata.newObj')).toEqual(newObj);
// Check array updates
expect(dsm.setValue('ds1.id4.metadata.roles[1]', 'editor')).toBe(true);
expect(dsm.getValue('ds1.id4.metadata')).toEqual({
newObj: { newValue: '1' },
address: { city: 'NewCity' },
roles: ['admin', 'editor', 'member'],
});
// Set entirely new nested object
const newAddress = { city: 'AnotherCity', country: 'SomeCountry' };
expect(dsm.setValue('ds1.id4.metadata.address', newAddress)).toBe(true);
expect(dsm.getValue('ds1.id4.metadata.address')).toEqual(newAddress);
const newRoles = ['editor', 'viewer'];
expect(dsm.setValue('ds1.id4.metadata.roles', newRoles)).toBe(true);
expect(dsm.getValue('ds1.id4.metadata.roles')).toEqual(newRoles);
expect(dsm.getValue('ds1.id4.metadata')).toEqual({
newObj: { newValue: '1' },
address: { city: 'AnotherCity', country: 'SomeCountry' },
roles: ['editor', 'viewer'],
});
// Set completely new nested structure
const newMetadata = { tags: ['tag1', 'tag2'], settings: { theme: 'dark' } };
expect(dsm.setValue('ds1.id4.metadata', newMetadata)).toBe(true);
expect(dsm.getValue('ds1.id4.metadata')).toEqual(newMetadata);
expect(dsm.getValue('ds1.id4.metadata.settings.theme')).toBe('dark');
expect(dsm.getValue('ds1.id4.metadata')).toEqual({
tags: ['tag1', 'tag2'],
settings: { theme: 'dark' },
});
});
});
});

295
packages/core/test/specs/data_sources/model/DataSource.ts

@ -0,0 +1,295 @@
import { DataSourceManager } from '../../../../src';
import DataSource from '../../../../src/data_sources/model/DataSource';
import {
DataFieldPrimitiveType,
DataFieldSchemaNumber,
DataFieldSchemaString,
DataRecordProps,
DataSourceProviderResult,
} from '../../../../src/data_sources/types';
import Editor from '../../../../src/editor/model/Editor';
import { setupTestEditor } from '../../../common';
interface TestRecord extends DataRecordProps {
name?: string;
age?: number;
}
const serializeRecords = (records: any[]) => JSON.parse(JSON.stringify(records));
describe('DataSource', () => {
let em: Editor;
let editor: Editor['Editor'];
let dsm: DataSourceManager;
let ds: DataSource<TestRecord>;
const categoryRecords = [
{ id: 'cat1', uid: 'cat1-uid', name: 'Category 1' },
{ id: 'cat2', uid: 'cat2-uid', name: 'Category 2' },
{ id: 'cat3', uid: 'cat3-uid', name: 'Category 3' },
];
const userRecords = [
{ id: 'user1', username: 'user_one' },
{ id: 'user2', username: 'user_two' },
{ id: 'user3', username: 'user_three' },
];
const blogRecords = [
{ id: 'blog1', title: 'First Blog', author: 'user1', categories: ['cat1-uid'] },
{ id: 'blog2', title: 'Second Blog', author: 'user2' },
{ id: 'blog3', title: 'Third Blog', categories: ['cat1-uid', 'cat3-uid'] },
];
const addTestDataSource = (records?: TestRecord[]) => {
return dsm.add<TestRecord>({ id: 'test', records: records || [{ id: 'user1', age: 30 }] });
};
beforeEach(() => {
({ em, dsm, editor } = setupTestEditor());
});
afterEach(() => {
em.destroy();
});
describe('Schema', () => {
const schemaName: DataFieldSchemaString = {
type: DataFieldPrimitiveType.string,
label: 'Name',
};
const schemaAge: DataFieldSchemaNumber = {
type: DataFieldPrimitiveType.number,
label: 'Age',
default: 18,
};
beforeEach(() => {
ds = addTestDataSource();
});
test('Initialize with empty schema', () => {
expect(ds.schema).toEqual({});
});
test('Add and update schema', () => {
const schemaNameDef: typeof ds.schema = { name: schemaName };
const schemaAgeDef: typeof ds.schema = { age: schemaAge };
ds.upSchema(schemaNameDef);
ds.upSchema(schemaAgeDef);
expect(ds.schema).toEqual({ ...schemaNameDef, ...schemaAgeDef });
});
test('Should update existing field schema', () => {
ds.upSchema({ name: schemaName });
const updatedSchema: typeof ds.schema = {
name: {
...schemaName,
description: 'User name field',
},
};
ds.upSchema(updatedSchema);
expect(ds.schema).toEqual(updatedSchema);
});
test('Should get field schema', () => {
ds.upSchema({
name: schemaName,
age: schemaAge,
});
expect(ds.getSchemaField('name')).toEqual(schemaName);
expect(ds.getSchemaField('age')).toEqual(schemaAge);
expect(ds.getSchemaField('nonExistentField')).toBeUndefined();
});
describe('Relations', () => {
beforeEach(() => {
dsm.add({
id: 'categories',
records: categoryRecords,
});
dsm.add({
id: 'users',
records: userRecords,
});
dsm.add({
id: 'blogs',
records: blogRecords,
schema: {
title: {
type: DataFieldPrimitiveType.string,
},
author: {
type: DataFieldPrimitiveType.relation,
target: 'users',
targetField: 'id',
},
},
});
});
test('return default values', () => {
const blogsDS = dsm.get('blogs');
expect(serializeRecords(blogsDS.getRecords())).toEqual(blogRecords);
});
test('return 1:1 resolved values', () => {
const blogsDS = dsm.get('blogs');
const records = blogsDS.getResolvedRecords();
expect(records).toEqual([
{ ...blogRecords[0], author: userRecords[0] },
{ ...blogRecords[1], author: userRecords[1] },
blogRecords[2],
]);
});
test('return 1:many resolved values', () => {
const blogsDS = dsm.get('blogs');
blogsDS.upSchema({
categories: {
type: DataFieldPrimitiveType.relation,
target: 'categories',
targetField: 'uid',
isMany: true,
},
});
const records = blogsDS.getResolvedRecords();
expect(records).toEqual([
{ ...blogRecords[0], author: userRecords[0], categories: [categoryRecords[0]] },
{ ...blogRecords[1], author: userRecords[1] },
{ ...blogRecords[2], categories: [categoryRecords[0], categoryRecords[2]] },
]);
});
});
});
describe('Providers', () => {
const testApiUrl = 'https://api.example.com/data';
const testHeaders = { 'Content-Type': 'application/json' };
const getMockSchema = () => ({
author: {
type: DataFieldPrimitiveType.relation,
target: 'users',
targetField: 'id',
},
});
const getMockProviderResponse: () => DataSourceProviderResult = () => ({
records: blogRecords,
schema: getMockSchema(),
});
const getProviderBlogsGet = () => ({ url: testApiUrl, headers: testHeaders });
const addBlogsWithProvider = () => {
return dsm.add({
id: 'blogs',
name: 'My blogs',
provider: {
get: getProviderBlogsGet(),
},
});
};
beforeEach(() => {
jest.spyOn(global, 'fetch').mockImplementation(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(getMockProviderResponse()),
} as Response),
);
dsm.add({
id: 'categories',
records: categoryRecords,
});
dsm.add({
id: 'users',
records: userRecords,
});
});
afterEach(() => {
jest.restoreAllMocks();
});
test('loadProvider', async () => {
const ds = addBlogsWithProvider();
await ds.loadProvider();
expect(fetch).toHaveBeenCalledWith(testApiUrl, { headers: testHeaders });
expect(ds.schema).toEqual(getMockSchema());
expect(ds.getResolvedRecords()).toEqual([
{ ...blogRecords[0], author: userRecords[0] },
{ ...blogRecords[1], author: userRecords[1] },
blogRecords[2],
]);
});
test('loadProvider with failed fetch', async () => {
jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'));
em.config.log = false;
const ds = addBlogsWithProvider();
await ds.loadProvider();
expect(fetch).toHaveBeenCalledWith(testApiUrl, { headers: testHeaders });
expect(ds.schema).toEqual({});
expect(ds.getRecords().length).toBe(0);
});
test('records loaded from the provider are not persisted', async () => {
const ds = addBlogsWithProvider();
const eventLoad = jest.fn();
em.on(dsm.events.providerLoad, eventLoad);
await ds.loadProvider();
expect(editor.getProjectData().dataSources).toEqual([
{ id: 'categories', records: categoryRecords },
{ id: 'users', records: userRecords },
{
id: 'blogs',
name: 'My blogs',
schema: getMockSchema(),
provider: { get: getProviderBlogsGet() },
},
]);
expect(eventLoad).toHaveBeenCalledTimes(1);
expect(eventLoad).toHaveBeenCalledWith({
dataSource: ds,
result: getMockProviderResponse(),
});
});
test('load providers on project load', (done) => {
dsm.getConfig().autoloadProviders = true;
editor.on(dsm.events.providerLoadAll, () => {
expect(dsm.get('blogs').getResolvedRecords()).toEqual([
{ ...blogRecords[0], author: userRecords[0] },
{ ...blogRecords[1], author: userRecords[1] },
blogRecords[2],
]);
expect(editor.getProjectData().dataSources).toEqual([
{ id: 'categories', records: categoryRecords },
{ id: 'users', records: userRecords },
{
id: 'blogs',
schema: getMockSchema(),
provider: { get: testApiUrl },
},
]);
done();
});
editor.loadProjectData({
dataSources: [
{ id: 'categories', records: categoryRecords },
{ id: 'users', records: userRecords },
{
id: 'blogs',
provider: { get: testApiUrl },
},
],
});
});
});
});

5
packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts

@ -256,7 +256,6 @@ describe('Collection component', () => {
test('Updating the value to a different collection variable', async () => {
firstChild.set('name', {
// @ts-ignore
type: DataVariableType,
variableType: DataCollectionStateType.currentItem,
collectionId: 'my_collection',
@ -275,7 +274,6 @@ describe('Collection component', () => {
expect(secondChild.get('name')).toBe('new_value_14');
firstGrandchild.set('name', {
// @ts-ignore
type: DataVariableType,
variableType: DataCollectionStateType.currentItem,
collectionId: 'my_collection',
@ -293,7 +291,6 @@ describe('Collection component', () => {
test('Updating the value to a different dynamic variable', async () => {
firstChild.set('name', {
// @ts-ignore
type: DataVariableType,
path: 'my_data_source_id.user2.user',
});
@ -306,8 +303,8 @@ describe('Collection component', () => {
expect(secondChild.get('name')).toBe('new_value');
expect(thirdChild.get('name')).toBe('new_value');
// @ts-ignore
firstGrandchild.set('name', {
// @ts-ignore
type: DataVariableType,
path: 'my_data_source_id.user2.user',
});

4
packages/core/test/specs/dom_components/index.ts

@ -264,9 +264,9 @@ describe('DOM Components', () => {
#${id} { background-color: red }
</style>`) as Component;
const rule = cc.getAll().at(0);
expect(rule.toCSS()).toEqual(`#${id}{background-color:red;color:red;padding:50px 100px;}`);
expect(rule.toCSS()).toEqual(`#${id}{color:red;padding:50px 100px;background-color:red;}`);
obj.getComponents().first().addStyle({ margin: '10px' });
const css = `#${id}{background-color:red;color:red;padding:50px 100px;margin:10px;}`;
const css = `#${id}{color:red;padding:50px 100px;background-color:red;margin:10px;}`;
expect(rule.toCSS()).toEqual(css);
setTimeout(() => {

30
packages/core/test/specs/dom_components/model/Component.ts

@ -155,7 +155,7 @@ describe('Component', () => {
obj.set({
bool: true,
removable: false,
string: 'st\'ri"ng',
string: 'st\'ri"ng&<>',
array: [1, 'string', true],
object: { a: 1, b: 'string', c: true },
null: null,
@ -164,12 +164,12 @@ describe('Component', () => {
zero: 0,
_private: 'value',
});
let resStr = "st'ri&quot;ng";
let resStr = "st'ri&quot;ng&amp;&lt;&gt;";
let resArr = '[1,&quot;string&quot;,true]';
let resObj = '{&quot;a&quot;:1,&quot;b&quot;:&quot;string&quot;,&quot;c&quot;:true}';
let res = `<div data-gjs-removable="false" data-gjs-bool="true" data-gjs-string="${resStr}" data-gjs-array="${resArr}" data-gjs-object="${resObj}" data-gjs-empty="" data-gjs-zero="0"></div>`;
expect(obj.toHTML({ withProps: true })).toEqual(res);
resStr = 'st&apos;ri"ng';
resStr = 'st&apos;ri"ng&amp;&lt;&gt;';
resArr = '[1,"string",true]';
resObj = '{"a":1,"b":"string","c":true}';
res = `<div data-gjs-removable="false" data-gjs-bool="true" data-gjs-string='${resStr}' data-gjs-array='${resArr}' data-gjs-object='${resObj}' data-gjs-empty="" data-gjs-zero="0"></div>`;
@ -436,6 +436,30 @@ describe('Component', () => {
expect(comp1.getId()).toEqual(comp1Id);
});
test('Ensure duplicated component clones also the rules', () => {
const idName = 'test';
const cmp = dcomp.addComponent(`
<div>
<div id="${idName}">Comp 1</div>
</div>
<style>
#test { color: red; }
@media (max-width: 992px) {
#test { color: blue; }
}
</style>
`) as Component;
expect(em.getCss()).toBe('#test{color:red;}@media (max-width: 992px){#test{color:blue;}}');
cmp.components().resetFromString(`
<div id="${idName}">Comp 1</div>
<div id="${idName}">Comp 2</div>
`);
const newId = cmp.components().at(1).getId();
expect(em.getCss()).toBe(
`#test{color:red;}#${newId}{color:red;}@media (max-width: 992px){#test{color:blue;}#${newId}{color:blue;}}`,
);
});
test('Ability to stop/change propagation chain', () => {
obj.append({
removable: false,

96
packages/core/test/specs/dom_components/model/ComponentWrapper.ts

@ -1,6 +1,12 @@
import { DataSourceManager, DataSource, DataRecord } from '../../../../src';
import { DataVariableProps, DataVariableType } from '../../../../src/data_sources/model/DataVariable';
import Component from '../../../../src/dom_components/model/Component';
import ComponentHead from '../../../../src/dom_components/model/ComponentHead';
import ComponentWrapper from '../../../../src/dom_components/model/ComponentWrapper';
import { keyRootData } from '../../../../src/dom_components/constants';
import Editor from '../../../../src/editor';
import EditorModel from '../../../../src/editor/model/Editor';
import { setupTestEditor } from '../../../common';
describe('ComponentWrapper', () => {
let em: Editor;
@ -33,4 +39,94 @@ describe('ComponentWrapper', () => {
expect(newPageComponent?.head.cid).not.toEqual(originalComponent?.head.cid);
});
});
describe('ComponentWrapper with DataResolver', () => {
let em: EditorModel;
let dsm: DataSourceManager;
let blogDataSource: DataSource;
let wrapper: ComponentWrapper;
let firstRecord: DataRecord;
const firstBlog = { id: 'blog1', title: 'How to Test Components' };
const blogsData = [
firstBlog,
{ id: 'blog2', title: 'Refactoring for Clarity' },
{ id: 'blog3', title: 'Async Patterns in TS' },
];
const productsById = {
product1: { title: 'Laptop' },
product2: { title: 'Smartphone' },
};
beforeEach(() => {
({ em, dsm } = setupTestEditor());
wrapper = em.getWrapper() as ComponentWrapper;
blogDataSource = dsm.add({
id: 'contentDataSource',
records: [
{
id: 'blogs',
data: blogsData,
},
{
id: 'productsById',
data: productsById,
},
],
});
firstRecord = em.DataSources.get('contentDataSource').getRecord('blogs')!;
});
afterEach(() => {
em.destroy();
});
const createDataResolver = (path: string): DataVariableProps => ({
type: DataVariableType,
path,
});
const appendChildWithTitle = (path: string = 'title') =>
wrapper.append({
type: 'default',
title: {
type: 'data-variable',
collectionId: keyRootData,
path,
},
})[0];
test('children reflect resolved value from dataResolver', () => {
wrapper.setDataResolver(createDataResolver('contentDataSource.blogs.data'));
wrapper.resolverCurrentItem = 0;
const child = appendChildWithTitle();
expect(child.get('title')).toBe(blogsData[0].title);
firstRecord.set('data', [{ id: 'blog1', title: 'New Blog Title' }]);
expect(child.get('title')).toBe('New Blog Title');
});
test('children update collectionStateMap on wrapper.setDataResolver', () => {
const child = appendChildWithTitle();
wrapper.setDataResolver(createDataResolver('contentDataSource.blogs.data'));
wrapper.resolverCurrentItem = 0;
expect(child.get('title')).toBe(blogsData[0].title);
firstRecord.set('data', [{ id: 'blog1', title: 'Updated Title' }]);
expect(child.get('title')).toBe('Updated Title');
});
test('wrapper should handle objects as collection state', () => {
wrapper.setDataResolver(createDataResolver('contentDataSource.productsById.data'));
wrapper.resolverCurrentItem = 'product1';
const child = appendChildWithTitle('title');
expect(child.get('title')).toBe(productsById.product1.title);
});
});
});

248
packages/core/test/specs/undo_manager/datasources.ts

@ -0,0 +1,248 @@
import { Component, DataSourceManager, Editor } from '../../../src';
import { DataConditionType } from '../../../src/data_sources/model/conditional_variables/DataCondition';
import { StringOperation } from '../../../src/data_sources/model/conditional_variables/operators/StringOperator';
import { DataVariableType } from '../../../src/data_sources/model/DataVariable';
import UndoManager from '../../../src/undo_manager';
import { setupTestEditor } from '../../common';
describe('Undo Manager with Data Binding', () => {
let editor: Editor;
let um: UndoManager;
let wrapper: Component;
let dsm: DataSourceManager;
const makeColorVar = () => ({
type: DataVariableType,
path: 'ds1.rec1.color',
});
const makeTitleVar = () => ({
type: DataVariableType,
path: 'ds1.rec1.title',
});
const makeContentVar = () => ({
type: DataVariableType,
path: 'ds1.rec1.content',
});
beforeEach(() => {
({ editor, um, dsm } = setupTestEditor({ withCanvas: true }));
wrapper = editor.getWrapper()!;
dsm.add({
id: 'ds1',
records: [{ id: 'rec1', color: 'red', title: 'Initial Title', content: 'Initial Content' }],
});
jest.useFakeTimers();
});
afterEach(() => {
editor.destroy();
});
describe('Initial State with Data Binding', () => {
it('should correctly initialize with a component having data-bound properties', () => {
const component = wrapper.append({
style: { color: makeColorVar() },
attributes: { title: makeTitleVar() },
content: makeContentVar(),
})[0];
expect(um.getStackGroup()).toHaveLength(1);
um.undo();
um.redo();
expect(component.getStyle().color).toBe('red');
expect(component.getAttributes().title).toBe('Initial Title');
expect(component.get('content')).toBe('Initial Content');
expect(um.getStackGroup()).toHaveLength(1);
});
});
describe('Core Undo/Redo on Component Data Binding', () => {
describe('Styles', () => {
it('should undo and redo the assignment of a data value to a style', () => {
const component = wrapper.append({
content: makeContentVar(),
style: { color: 'blue', 'font-size': '12px' },
})[0];
jest.runAllTimers();
um.clear();
component.setStyle({ color: makeColorVar() });
expect(component.getStyle().color).toBe('red');
expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar());
um.undo();
expect(component.getStyle().color).toBe('blue');
expect(component.getStyle({ skipResolve: true }).color).toBe('blue');
um.redo();
expect(component.getStyle().color).toBe('red');
expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar());
});
it('should handle binding with a data-condition value', () => {
const component = wrapper.append({ content: 'some content', style: { color: 'blue' } })[0];
const conditionVar = {
type: DataConditionType,
condition: { left: makeTitleVar(), operator: StringOperation.contains, right: 'Initial' },
ifTrue: 'green',
ifFalse: 'purple',
};
jest.runAllTimers();
um.clear();
component.addStyle({ color: conditionVar });
expect(component.getStyle().color).toBe('green');
um.undo();
expect(component.getStyle().color).toBe('blue');
um.redo();
expect(component.getStyle().color).toBe('green');
});
});
describe('Attributes', () => {
it('should undo and redo the assignment of a data value to an attribute', () => {
const component = wrapper.append({ attributes: { title: 'Static Title' } })[0];
jest.runAllTimers();
um.clear();
component.setAttributes({ title: makeTitleVar() });
expect(component.getAttributes().title).toBe('Initial Title');
um.undo();
expect(component.getAttributes().title).toBe('Static Title');
um.redo();
expect(component.getAttributes().title).toBe('Initial Title');
});
});
describe('Properties', () => {
it('should undo and redo the assignment of a data value to a property', () => {
const component = wrapper.append({ content: 'Static Content' })[0];
jest.runAllTimers();
um.clear();
component.set({ content: makeContentVar() });
expect(component.get('content')).toBe('Initial Content');
um.undo();
expect(component.get('content')).toBe('Static Content');
um.redo();
expect(component.get('content')).toBe('Initial Content');
});
});
});
describe('Value Overwriting Scenarios', () => {
it('should correctly undo a static style that overwrites a data binding', () => {
const component = wrapper.append({
style: { color: makeColorVar() },
attributes: { title: 'Static Title' },
})[0];
jest.runAllTimers();
um.clear();
component.addStyle({ color: 'green' });
expect(component.getStyle().color).toBe('green');
um.undo();
expect(component.getStyle().color).toBe('red');
expect(component.getAttributes().title).toBe('Static Title');
});
it('should correctly undo a data binding that overwrites a static style', () => {
const component = wrapper.append({ style: { color: 'green' } })[0];
jest.runAllTimers();
um.clear();
component.addStyle({ color: makeColorVar() });
expect(component.getStyle().color).toBe('red');
um.undo();
expect(component.getStyle().color).toBe('green');
});
});
describe('Listeners & Data Source Integrity', () => {
it('should maintain listeners after a binding is restored via undo', () => {
const component = wrapper.append({ style: { color: makeColorVar() } })[0];
jest.runAllTimers();
um.clear();
component.addStyle({ color: 'green' });
expect(component.getStyle().color).toBe('green');
um.undo();
expect(component.getStyle().color).toBe('red');
dsm.get('ds1').getRecord('rec1')!.set('color', 'purple');
expect(component.getStyle().color).toBe('purple');
});
it('should handle undo when the data source has been removed', () => {
const component = wrapper.append({ style: { color: makeColorVar() } })[0];
expect(component.getStyle().color).toBe('red');
jest.runAllTimers();
um.clear();
dsm.remove('ds1');
expect(component.getStyle().color).toBeUndefined();
um.undo();
expect(dsm.get('ds1')).toBeTruthy();
expect(component.getStyle().color).toBe('red');
});
});
describe('Serialization & Cloning', () => {
let component: any;
beforeEach(() => {
component = wrapper.append({
style: { color: makeColorVar() },
attributes: { title: makeTitleVar() },
content: makeContentVar(),
})[0];
});
it('should correctly serialize data bindings in toJSON()', () => {
const json = component.toJSON();
expect(json.attributes.title).toEqual(makeTitleVar());
expect(json.__dynamicProps).toBeUndefined();
});
it('should correctly clone data bindings', () => {
const clone = component.clone();
expect(clone.getStyle('', { skipResolve: true }).color).toEqual(makeColorVar());
expect(clone.getAttributes({ skipResolve: true }).title).toEqual(makeTitleVar());
expect(clone.get('content', { skipResolve: true })).toEqual(makeContentVar());
expect(clone.getStyle().color).toBe('red');
});
it('should ensure a cloned component has an independent undo history', () => {
const clone = component.clone();
wrapper.append(clone);
jest.runAllTimers();
um.clear();
component.addStyle({ color: 'blue' });
expect(um.hasUndo()).toBe(true);
expect(clone.getStyle().color).toBe('red');
um.undo();
expect(component.getStyle().color).toBe('red');
expect(clone.getStyle().color).toBe('red');
});
});
});

286
packages/core/test/specs/undo_manager/index.ts

@ -0,0 +1,286 @@
import UndoManager from '../../../src/undo_manager';
import Editor from '../../../src/editor';
import { setupTestEditor } from '../../common';
describe('Undo Manager', () => {
let editor: Editor;
let um: UndoManager;
let wrapper: any;
beforeEach(() => {
({ editor, um } = setupTestEditor({
withCanvas: true,
}));
wrapper = editor.getWrapper();
um.clear();
});
afterEach(() => {
editor.destroy();
});
test('Initial state is correct', () => {
expect(um.hasUndo()).toBe(false);
expect(um.hasRedo()).toBe(false);
expect(um.getStack()).toHaveLength(0);
});
describe('Component changes', () => {
test('Add component', () => {
expect(wrapper.components()).toHaveLength(0);
wrapper.append('<div></div>');
expect(wrapper.components()).toHaveLength(1);
expect(um.hasUndo()).toBe(true);
um.undo();
expect(wrapper.components()).toHaveLength(0);
expect(um.hasRedo()).toBe(true);
um.redo();
expect(wrapper.components()).toHaveLength(1);
});
test('Remove component', () => {
const comp = wrapper.append('<div></div>')[0];
expect(wrapper.components()).toHaveLength(1);
um.clear();
comp.remove();
expect(wrapper.components()).toHaveLength(0);
expect(um.hasUndo()).toBe(true);
um.undo();
expect(wrapper.components()).toHaveLength(1);
expect(um.hasRedo()).toBe(true);
um.redo();
expect(wrapper.components()).toHaveLength(0);
});
test('Modify component properties', () => {
const comp = wrapper.append({ tagName: 'div', content: 'test' })[0];
um.clear();
comp.set('content', 'test2');
expect(comp.get('content')).toBe('test2');
expect(um.hasUndo()).toBe(true);
um.undo();
expect(comp.get('content')).toBe('test');
um.redo();
expect(comp.get('content')).toBe('test2');
});
test('Modify component style (StyleManager)', () => {
const comp = wrapper.append('<div></div>')[0];
um.clear();
comp.addStyle({ color: 'red' });
expect(comp.getStyle().color).toBe('red');
expect(um.hasUndo()).toBe(true);
um.undo();
expect(comp.getStyle().color).toBeUndefined();
um.redo();
expect(comp.getStyle().color).toBe('red');
});
test('Move component', () => {
wrapper.append('<div>1</div><div>2</div>');
const comp1 = wrapper.components().at(0);
const comp2 = wrapper.components().at(1);
um.clear();
wrapper.append(comp1, { at: 2 });
expect(wrapper.components().at(0)).toBe(comp2);
expect(wrapper.components().at(1)).toBe(comp1);
expect(um.hasUndo()).toBe(true);
um.undo();
expect(wrapper.components().at(0)).toBe(comp1);
expect(wrapper.components().at(1)).toBe(comp2);
um.redo();
expect(wrapper.components().at(0)).toBe(comp2);
expect(wrapper.components().at(1)).toBe(comp1);
});
test('Grouped component additions are treated as one undo action', () => {
wrapper.append('<div>1</div><div>2</div>');
expect(wrapper.components()).toHaveLength(2);
expect(um.getStackGroup()).toHaveLength(1);
um.undo();
expect(wrapper.components()).toHaveLength(0);
});
});
describe('CSS Rule changes', () => {
test('Add CSS Rule', () => {
editor.Css.addRules('.test { color: red; }');
expect(editor.Css.getRules('.test')).toHaveLength(1);
expect(um.hasUndo()).toBe(true);
um.undo();
expect(editor.Css.getRules('.test')).toHaveLength(0);
um.redo();
expect(editor.Css.getRules('.test')).toHaveLength(1);
expect(editor.Css.getRule('.test')?.getStyle().color).toBe('red');
});
test('Modify CSS Rule', () => {
const rule = editor.Css.addRules('.test { color: red; }')[0];
um.clear();
rule.setStyle({ color: 'blue' });
expect(rule.getStyle().color).toBe('blue');
expect(um.hasUndo()).toBe(true);
um.undo();
expect(rule.getStyle().color).toBe('red');
um.redo();
expect(rule.getStyle().color).toBe('blue');
});
test('Remove CSS Rule', () => {
const rule = editor.Css.addRules('.test { color: red; }')[0];
um.clear();
editor.Css.remove(rule);
expect(editor.Css.getRules('.test')).toHaveLength(0);
expect(um.hasUndo()).toBe(true);
um.undo();
expect(editor.Css.getRules('.test')).toHaveLength(1);
um.redo();
expect(editor.Css.getRules('.test')).toHaveLength(0);
});
});
// TODO: add undo_manager to asset manager
describe.skip('Asset Manager changes', () => {
test('Add asset', () => {
const am = editor.Assets;
expect(am.getAll()).toHaveLength(0);
um.clear();
am.add('path/to/img.jpg');
expect(am.getAll()).toHaveLength(1);
expect(um.hasUndo()).toBe(true);
um.undo();
expect(am.getAll()).toHaveLength(0);
um.redo();
expect(am.getAll()).toHaveLength(1);
expect(am.get('path/to/img.jpg')).toBeTruthy();
});
test('Remove asset', () => {
const am = editor.Assets;
const asset = am.add('path/to/img.jpg');
expect(am.getAll()).toHaveLength(1);
um.clear();
am.remove(asset);
expect(am.getAll()).toHaveLength(0);
expect(um.hasUndo()).toBe(true);
um.undo();
expect(am.getAll()).toHaveLength(1);
um.redo();
expect(am.getAll()).toHaveLength(0);
});
});
// TODO: add undo_manager to editor
describe.skip('Editor states changes', () => {
test('Device change', () => {
editor.Devices.add({ id: 'tablet', name: 'Tablet', width: 'auto' });
um.clear();
editor.setDevice('Tablet');
expect(editor.getDevice()).toBe('Tablet');
expect(um.hasUndo()).toBe(true);
um.undo();
// Default device is an empty string
expect(editor.getDevice()).toBe('');
um.redo();
expect(editor.getDevice()).toBe('Tablet');
});
test('Panel visibility change', () => {
const panel = editor.Panels.getPanel('options')!;
panel.set('visible', true);
um.clear();
panel.set('visible', false);
expect(panel.get('visible')).toBe(false);
expect(um.hasUndo()).toBe(true);
um.undo();
expect(panel.get('visible')).toBe(true);
um.redo();
expect(panel.get('visible')).toBe(false);
});
});
describe('Selection tracking', () => {
test('Change selection', (done) => {
const comp1 = wrapper.append('<div>1</div>')[0];
const comp2 = wrapper.append('<div>2</div>')[0];
um.clear();
editor.select(comp1);
expect(editor.getSelected()).toBe(comp1);
setTimeout(() => {
editor.select(comp2);
expect(editor.getSelected()).toBe(comp2);
expect(um.hasUndo()).toBe(true);
um.undo();
expect(editor.getSelected()).toBe(comp1);
um.redo();
expect(editor.getSelected()).toBe(comp2);
done();
});
});
});
describe('Operations with `noUndo`', () => {
test('Skipping undo for component modification', () => {
const comp = wrapper.append('<div></div>')[0];
um.clear();
comp.set('content', 'no undo content', { noUndo: true });
expect(um.hasUndo()).toBe(false);
wrapper.append('<div>undo this</div>');
expect(um.hasUndo()).toBe(true);
um.undo();
expect(wrapper.components()).toHaveLength(1);
expect(wrapper.components().at(0).get('content')).toBe('no undo content');
});
});
});
Loading…
Cancel
Save