Browse Source

Merge pull request #4223 from artf/up-storage-manager

Refactor Storage Manager
pull/4224/head
Artur Arseniev 5 years ago
committed by GitHub
parent
commit
c31162b788
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 466
      docs/modules/Storage.md
  2. 74
      index.d.ts
  3. 3
      package.json
  4. 43
      src/asset_manager/index.js
  5. 24
      src/canvas/model/Frame.js
  6. 51
      src/common/module.js
  7. 69
      src/css_composer/index.js
  8. 3
      src/css_composer/model/CssRule.js
  9. 11
      src/dom_components/config/config.js
  10. 117
      src/dom_components/index.js
  11. 6
      src/dom_components/model/Component.js
  12. 20
      src/editor/config/config.js
  13. 73
      src/editor/index.js
  14. 151
      src/editor/model/Editor.js
  15. 20
      src/editor/view/EditorView.js
  16. 3
      src/i18n/locale/en.js
  17. 45
      src/pages/index.js
  18. 130
      src/storage_manager/config/config.js
  19. 357
      src/storage_manager/index.js
  20. 62
      src/storage_manager/model/LocalStorage.js
  21. 166
      src/storage_manager/model/RemoteStorage.js
  22. 21
      src/utils/mixins.js
  23. 3
      test/setup.js
  24. 50
      test/specs/asset_manager/index.js
  25. 58
      test/specs/css_composer/index.js
  26. 29
      test/specs/dom_components/index.js
  27. 7
      test/specs/grapesjs/index.js
  28. 7
      test/specs/storage_manager/index.js
  29. 164
      test/specs/storage_manager/model/Models.js

466
docs/modules/Storage.md

@ -4,70 +4,183 @@ title: Storage Manager
# Storage Manager # Storage Manager
The aim of this guide is to show how to setup correctly your storage configuration for common usages of the editor and explain also some additional advanced settings The Storage Manager is a built-in module that allows the persistence of your project data.
::: warning ::: warning
This guide requires GrapesJS v0.14.15 or higher This guide requires GrapesJS v0.19.* or higher
::: :::
[[toc]] [[toc]]
## Basic configuration ## Configuration
To change the default configurations you have to pass the `storageManager` property with the main configuration object.
The storage manager is a built-in module implemented inside GrapesJS which allows the persistence of your data. By default, GrapesJS saves the data locally by using the built-in `LocalStorage` which just leverages [localStorage API].
You can initialize the editor with different storage configurations via `storageManager` option:
```js ```js
const editor = grapesjs.init({ const editor = grapesjs.init({
... ...
// Default configurations // Default configurations
storageManager: { storageManager: {
id: 'gjs-', // Prefix identifier that will be used on parameters type: 'local', // Storage type. Available: local | remote
type: 'local', // Type of the storage autosave: true, // Store data automatically
autosave: true, // Store data automatically autoload: true, // Autoload stored data on init
autoload: true, // Autoload stored data on init stepsBeforeSave: 1, // If autosave is enabled, indicates how many changes are necessary before the store method is triggered
stepsBeforeSave: 1, // If autosave enabled, indicates how many changes are necessary before store method is triggered // ...
// Default storage options
options: {
local: {/* ... */},
remote: {/* ... */},
}
}, },
}); });
``` ```
The `id` option is used to prevent collisions (quite common with localStorage) in case of multiple editors on the same page, therefore you will see parameters passed like `{ 'gjs-components': '...', 'gjs-styles': '...', }`
If you need to disable the storage manager you can pass any empty `type`: In case you don't need any persistence, you can disable the module in this way:
```js
const editor = grapesjs.init({
...
storageManager: false,
});
```
Check the full list of available options here: [Storage Manager Config](https://github.com/artf/grapesjs/blob/master/src/storage_manager/config/config.js)
## Project data
The project data is a JSON object containing all the necessary information (styles, pages, etc.) about your project in the editor and is the one used in the storage manager methods in order to store and load your project (locally or remotely in your DB/file).
::: tip
You can get the current state of the data and load it manually in this way:
```js ```js
... // Get current project data
storageManager: { type: null }, const projectData = editor.getProjectData();
// ...
// Load project data
editor.loadProjectData(projectData);
``` ```
:::
::: danger
You should only rely on the JSON project data in order to load your project properly in the editor.
The editor is able to parse and use HTML/CSS code, you can use it as part of your project initialization but never rely on it as a persitance layer in the load of projects as many information could be stripped off.
:::
<!-- If necessary, the JSON can be also enriched with your data of choice, but as the data schema might differ in time we highly recommend to store them in your domain specific keys-->
## Storage strategy
Project data are automatically stored every time the amount of changes (`editor.getDirtyCount()`) reaches the number of steps before save (`editor.Storage.getStepsBeforeSave()`). On any successful store of the data, the counter of changes is reset (`editor.clearDirtyCount()`).
::: tip
When necessary, you can always trigger store/load manually.
```js
// Store data
const storedProjectData = await editor.store();
// Load data
const loadedProjectData = await editor.load();
```
:::
For all other available options check directly the [configuration source file](https://github.com/artf/grapesjs/blob/dev/src/storage_manager/config/config.js).
## Setup local storage
By default, GrapesJS saves the data locally by using the built-in `local` storage which leverages [localStorage API].
The only option you might probably care for the local storage is the `key` used to store the data. If the user loads different projects in your application, you might probably need to differentiate the local storage by the ID of the project (the ID here is intended to be part of your application domain).
```js
// Get your project ID (eg. taken from the route)
const projectId = getProjectId();
const editor = grapesjs.init({
...
storageManager: {
type: 'local',
options: {
local: { key: `gjsProject-${projectId}` }
}
},
});
```
## Setup remote storage ## Setup remote storage
Switching up the remote storage is very simple, it's just a matter of specifying your endpoints for storing and loading, which generally might be also the same (if you rely on HTTP methods). Most commonly the data of the project might be saved remotely on your server (DB, file, etc.) therefore you need to setup your server-side API calls in order to store/load project data.
For a sake of simplicity we can setup a fake REST API server by relying on [json-server].
```sh
mkdir my-server
cd my-server
npm init
npm i json-server
echo '{"projects": [ {"id": 1, "data": {"assets": [], "styles": [], "pages": [{"component": "<div>Initial content</div>"}]} } ]}' > db.json
npx json-server --watch db.json
```
This will start up a local server with one single project available on `http://localhost:3000/projects/1`. The data will be updated on the `db.json` file.
Here below an example of how you would configure a `remote` storage in GrapesJS.
```js ```js
const projectID = 1;
const projectEndpoint = `http://localhost:3000/projects/${projectID}`;
const editor = grapesjs.init({ const editor = grapesjs.init({
... ...
storageManager: { storageManager: {
type: 'remote', type: 'remote',
stepsBeforeSave: 3, stepsBeforeSave: 3,
urlStore: 'http://endpoint/store-template/some-id-123', options: {
urlLoad: 'http://endpoint/load-template/some-id-123', remote: {
// For custom parameters/headers on requests urlLoad: projectEndpoint,
params: { _some_token: '....' }, urlStore: projectEndpoint,
headers: { Authorization: 'Basic ...' }, // The `remote` storage uses the POST method when stores data but
// the json-server API requires PATCH.
fetchOptions: opts => (opts.method === 'POST' ? { method: 'PATCH' } : {}),
// As the API stores projects in this format `{id: 1, data: projectData }`,
// we have to properly update the body before the store and extract the
// project data from the response result.
onStore: data => ({ id: projectID, data }),
onLoad: result => result.data,
}
}
} }
}); });
``` ```
As you can see we've left some default option unchanged, increased changes necessary for autosave triggering and passed remote endpoints.
::: danger
Be sure to configure properly [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) on your server API. The [json-server] is not intended to be used in production and therefore enables all of them automatically.
:::
<div id="setup-the-server"></div>
### Server setup
Server configuration might differ case to case so usually, it's up to you to know how to configure it properly.
The default remote storage follows a simple REST API approach with project data exchanged as a JSON (`Content-Type: application/json`).
* On **load** (`GET` method), the JSON project data are expected to be returned directly in the response. As from example above, you can use `options.remote.onLoad` to extract the project data if the response contains other metadata.
* On **store** (`POST` method), the editor doesn't expect any particular result but only a valid response from the server (status code `200`).
## Store and load templates
<!-- ## Store and load templates
Even without a fully working endpoint, you can see what is sent from the editor by triggering the store and looking in the network panel of the inspector. GrapesJS sends mainly 4 types of parameters and it prefixes them with the `gjs-` key (you can disable it via `storageManager.id`). From the parameters, you will get the final result in 'gjs-html' and 'gjs-css' and this is what actually your end-users will gonna see on the final template/page. The other two, 'gjs-components' and 'gjs-styles', are a JSON representation of your template and therefore those should be used for the template editing. **So be careful**, GrapesJS is able to start from any HTML/CSS but use this approach only for importing already existent HTML templates, once the user starts editing, rely always on JSON objects because the HTML doesn't contain information about your components. You can achieve it in a pretty straightforward way and if you load your page by server-side you don't even need to load asynchronously your data (so you can turn off the `autoload`). Even without a fully working endpoint, you can see what is sent from the editor by triggering the store and looking in the network panel of the inspector. GrapesJS sends mainly 4 types of parameters and it prefixes them with the `gjs-` key (you can disable it via `storageManager.id`). From the parameters, you will get the final result in 'gjs-html' and 'gjs-css' and this is what actually your end-users will gonna see on the final template/page. The other two, 'gjs-components' and 'gjs-styles', are a JSON representation of your template and therefore those should be used for the template editing. **So be careful**, GrapesJS is able to start from any HTML/CSS but use this approach only for importing already existent HTML templates, once the user starts editing, rely always on JSON objects because the HTML doesn't contain information about your components. You can achieve it in a pretty straightforward way and if you load your page by server-side you don't even need to load asynchronously your data (so you can turn off the `autoload`).
@ -97,113 +210,47 @@ const editor = grapesjs.init({
autoload: false, autoload: false,
}, },
}); });
``` ``` -->
If for any reason you need to get the data from the remote storage you can trigger the load, at any time, manually
```js
editor.load(res => console.log('Load callback'));
```
Similarly, you have the same control over the storing. By default, the `autosave` is enabled and is triggered by how many changes are made to the template (change it via `stepsBeforeSave` option). As before, you can disable this behavior and trigger it manually when you need it
```js
...
const editor = grapesjs.init({
...
storageManager: {
...
autosave: false,
},
});
// Call load somewhere
editor.store(res => console.log('Store callback'));
```
If you need to check changes which yet need to be stored you can use `editor.getDirtyCount()`. At any, successful, store of the editor, it resets the count.
## Setup the server
Server configuration might differ for any use case so generally, it's something up to you on how to make it work, but usually, the flow is pretty straightforward. Create two endpoints, one for storing (eg. `mydomain.com/store-page/123`) and the other one for loading (eg. `mydomain.com/load-page/123`), you can also create just one and distinguish them via HTTP methods (eg. `mydomain.com/page/123`, via GET you load the template, with POST you store it).
When you **store**, the editor doesn't expect any particular result but only a valid response from the server (status code 200).
When you **load** the template, return a JSON object with the data you have (don't forget to include the `id` prefix if it's used)
```js
{
// `gjs-` is the id prefix
'gjs-components': [{ tagName: 'div', ... }, {...}, ...],
'gjs-styles': [{...}, {...}, ...],
}
```
Be sure to have a correct `Content-Type` response header, eg. in PHP you would do something like this:
```php
header('Content-Type: application/json');
echo json_encode([
'gjs-components': [...],
'gjs-styles': [...],
]);
```
## Storage API ## Storage API
The Storage module has also its own [set of API](https://github.com/artf/grapesjs/wiki/API-Storage-Manager) that allows you to extend and add new functionalities. The Storage Manager module has also its own [set of APIs](/api/storage_manager.html) that allows you to extend and add new functionalities.
### Define new storage ### Define new storage
One of the most useful methods of API is the possibility to add new storages. You might think, we have the `local` and `remote` storages, what else do we need, right? Well, let's take as an example the `local` one. As you already know, it relies on [localStorage API] which is really cool and easy to use but one of his specs might be a big limit, by default it has a limited amount of MB to use per site (something around 5MB-10MB, depends on the browser implementation). As an alternative, we can make use of [IndexedDB] which is also quite [well supported](https://caniuse.com/#search=indexedDB) and allows more space usage (each browser implements its own rules, for a better understanding on how browser storage limits work, check [here](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Browser_storage_limits_and_eviction_criteria)). Defining a new storage is a matter of passing of two asyncronous methods to the `editor.Storage.add` API. For a sake of simplicity, the example below illustrates the API usage for defining the `session` storage by using [sessionStorage API](https://developer.mozilla.org/en-US/docs/Web/API/Window/sessionStorage).
[IndexedDB configuration](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API/Using_IndexedDB) might be too much verbose for this guide so we decided to create the [grapesjs-indexeddb] plugin, so you can check its source and see how it's implemented. For this guide we are going to see something much simpler but with the same flow, it'll be just a simple javascript object which stores key-value data, not persistent at all but the concept is the same.
```js ```js
const sessionStoragePlugin = (editor) => {
// As sessionStorage is not an asynchronous API,
// the `async` keyword could be skipped
editor.Storage.add('session', {
async load(options = {}) {
return JSON.parse(sessionStorage.getItem(options.key));
},
async store(data, options = {}) {
sessionStorage.setItem(options.key, JSON.stringify(data));
}
});
};
const editor = grapesjs.init({ const editor = grapesjs.init({
... ...
storageManager: { type: 'simple-storage' }, plugins: [sessionStoragePlugin],
}); storageManager: {
type: 'session',
// Here our `simple-storage` implementation options: {
const SimpleStorage = {}; session: { key: 'myKey' }
editor.StorageManager.add('simple-storage', {
/**
* Load the data
* @param {Array} keys Array containing values to load, eg, ['gjs-components', 'gjs-styles', ...]
* @param {Function} clb Callback function to call when the load is ended
* @param {Function} clbErr Callback function to call in case of errors
*/
load(keys, clb, clbErr) {
const result = {};
keys.forEach(key => {
const value = SimpleStorage[key];
if (value) {
result[key] = value;
}
});
// Might be called inside some async method
clb(result);
},
/**
* Store the data
* @param {Object} data Data object to store
* @param {Function} clb Callback function to call when the load is ended
* @param {Function} clbErr Callback function to call in case of errors
*/
store(data, clb, clbErr) {
for (let key in data) {
SimpleStorage[key] = data[key];
} }
// Might be called inside some async method },
clb();
}
}); });
``` ```
@ -211,90 +258,191 @@ editor.StorageManager.add('simple-storage', {
### Extend storage ### Extend storage
Among other needs, you might need to use existing storages to create more complex uses. For example, let's say we would like to mix the local and remote storages inside another one. This is how it would look like: Among other needs, you might need to use existing storages to combine them in a more complex use case.
```js For example, let's say we would like to mix the local and remote storages inside another one. This is how it would look like:
const sm = editor.StorageManager;
sm.add('local-remote', { ```js
store(data, clb, clbErr) { const { Storage } = editor;
const remote = sm.get('remote');
const local = sm.get('local'); Storage.add('remote-local', {
// ... async store(data) {
remote.store(data, clb, err => { const remoteStorage = Storage.get('remote');
// eg. some error on remote side, store it locally
local.store(data, clb, clbError); try {
}); await remoteStorage.store(data, Storage.getStorageOptions('remote'));
} catch (err) {
// On remote error, store data locally
const localStorage = Storage.get('local');
await localStorage.store(data, Storage.getStorageOptions('local'));
}
}, },
load(keys, clb, clbErr) { async load() {
// ... // ...
}, },
}); });
``` ```
If you need to completely replace the storage, just use the same id in `add` method ### Replace storage
You can also replace already defined storages with other implementations by passing the same storage type in the `Storage.add` method. You can switch, for example, the default `local`, which relies on [localStorage API], with something more scalable like [IndexedDB API].
It might also be possible that you're already using some HTTP client library (eg. [axios](https://github.com/axios/axios)) which handles for you all the necessary HTTP headers in your application (CSRF token, session data, etc.), so you can simply replace the default `remote` storage wiht your implemenation of choice without caring about the default configurations.
```js ```js
editor.StorageManager.add('local', { editor.Storage.add('remote', {
// New logic for the local storage async load() {
load() { return await axios.get(`projects/${projectId}`);
// ...
}, },
store() { async store(data) {
// ... return await axios.patch(`projects/${projectId}`, { data });
}, },
}); });
``` ```
### Examples <!-- ### Examples
Here you can find some of the plugins extending the Storage Manager Here you can find some of the plugins extending the Storage Manager
* [grapesjs-indexeddb] - Storage wrapper for IndexedDB * [grapesjs-indexeddb] - Storage wrapper for IndexedDB
* [grapesjs-firestore] - Storage wrapper for [Cloud Firestore](https://firebase.google.com/docs/firestore) * [grapesjs-firestore] - Storage wrapper for [Cloud Firestore](https://firebase.google.com/docs/firestore) -->
## Events ## Common use cases
Another way to extend storage capabilities is to make use of GrapesJS's event hooks, you can check [here](https://github.com/artf/grapesjs/wiki/API-Editor#storages) the list of all available events for the Storage module. Let's see some of the cases where you might want to use them: ### Skip initial load
In case you're using the `remote` storage, you might probably want to skip the initial remote call by loading the project instantly. In that case, you can specify the `projectData` on initialization.
* Loading animation on storage requests
```js
editor.on('storage:start', startLoading);
editor.on('storage:end', endLoading);
```
* Error handling
```js ```js
editor.on('storage:error', (err) => { // Get the data before initializing the editor (eg. printed on server-side).
alert(`Error: ${err}`); const projectData = {...};
}); // ...
grapesjs.init({
// ...
// If projectData is not defined we might want to load some initial data for the project.
projectData: projectData || {
pages: [
{
component: `
<div class="test">Initial content</div>
<style>.test { color: red }</style>
`
}
]
},
storageManager: {
type: 'remote',
// ...
},
})
``` ```
* Extend parameters to store In case `projectData` is defined, the initial storage load will be automatically skipped.
### HTML code with project data
The project data doesn't contain HTML/CSS of your pages as its main purpose is to collect only the strictly necessary information.
In case you have a strict requirement to execute also other logic connected to the store of your project data (eg. deploy HTML/CSS result to the stage environment) you can enrich your remote calls by using the `onStore` option in the remote configuration.
```js ```js
editor.on('storage:start:store', (objectToStore) => { grapesjs.init({
if (needToAddExtraParam) { // ...
objectToStore.customHtml = `<div>...${editor.getHtml()}...</div>`; storageManager: {
} type: 'remote',
}); options: {
remote: {
// Enrich the store call
onStore: (data, editor) => {
const pagesHtml = editor.Pages.getAll().map(page => {
const component = page.getMainComponent();
return {
html: editor.getHtml({ component }),
css: editor.getCss({ component })
}
});
return { id: projectID, data, pagesHtml };
},
}
},
},
})
``` ```
* Do stuff post load
```js ### Inline project data
editor.on('storage:end:load', (resultObject) => {
if (resultObject.hasSomeKey) { In might be a case where the editor is not connected to any storage but simply read/write the data in inputs placed in a form. For such a case you can create an inline storage.
// do stuff
} ```html
}); <form id="my-form">
<input id="project-html" type="hidden"/>
<input id="project-data" type="hidden" value='{"pages": [{"component": "<div>Initial content</div>"}]}'/>
<div id="gjs"></div>
<button type="submit">Submit</button>
</form>
<script>
// Show data on submit
document.getElementById('my-form').addEventListener('submit', event => {
event.preventDefault();
const projectDataEl = document.getElementById('project-data');
const projectHtmlEl = document.getElementById('project-html');
alert(`HTML: ${projectHtmlEl.value}\n------\nDATA: ${projectDataEl.value}`)
});
// Inline storage
const inlineStorage = (editor) => {
const projectDataEl = document.getElementById('project-data');
const projectHtmlEl = document.getElementById('project-html');
editor.Storage.add('inline', {
load() {
return JSON.parse(projectDataEl.value || '{}');
},
store(data) {
const component = editor.Pages.getSelected().getMainComponent();
projectDataEl.value = JSON.stringify(data);
projectHtmlEl.value = `<html>
<head>
<style>${editor.getCss({ component })}</style>
</head>
${editor.getHtml({ component })}
<html>`;
}
});
};
// Init editor
grapesjs.init({
container: '#gjs',
height: '500px',
plugins: [inlineStorage],
storageManager: { type: 'inline' },
});
</script>
``` ```
In the example above we're relying on two hidden inputs, one for containing the project data and the another one for the HTML/CSS.
## Events
For a complete list of available events, you can check it [here](/api/storage_manager.html#available-events).
[grapesjs-indexeddb]: <https://github.com/artf/grapesjs-indexeddb> [grapesjs-indexeddb]: <https://github.com/artf/grapesjs-indexeddb>
[grapesjs-firestore]: <https://github.com/artf/grapesjs-firestore> [grapesjs-firestore]: <https://github.com/artf/grapesjs-firestore>
[localStorage API]: <https://developer.mozilla.org/it/docs/Web/API/Window/localStorage> [localStorage API]: <https://developer.mozilla.org/it/docs/Web/API/Window/localStorage>
[IndexedDB]: <https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API> [IndexedDB API]: <https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API>
[json-server]: <https://github.com/typicode/json-server>

74
index.d.ts

@ -3588,43 +3588,28 @@ declare module grapesjs {
* Add new storage * Add new storage
* @example * @example
* storageManager.add('local2', { * storageManager.add('local2', {
* load: function(keys, clb, clbErr) { * async load(storageOptions) {
* var res = {}; * // ...
* for (var i = 0, len = keys.length; i < len; i++){ * },
* var v = localStorage.getItem(keys[i]); * async store(data, storageOptions) {
* if(v) res[keys[i]] = v; * // ...
* }
* clb(res); // might be called inside some async method
* // In case of errors...
* // clbErr('Went something wrong');
* }, * },
* store: function(data, clb, clbErr) {
* for(var key in data)
* localStorage.setItem(key, data[key]);
* clb(); // might be called inside some async method
* }
* }); * });
* @param id - Storage ID * @param id - Storage ID
* @param storage - Storage wrapper * @param storage - Storage wrapper
* @param storage.load - Load method * @param storage.load - Load method
* @param storage.store - Store method * @param storage.store - Store method
*/ */
add( add(id: string, storage: IStorage): this;
id: string,
storage: {
load: (...params: any[]) => any;
store: (...params: any[]) => any;
}
): this;
/** /**
* Returns storage by id * Returns storage by id
* @param id - Storage ID * @param id - Storage ID
*/ */
get(id: string): any | null; get(id: string): IStorage | null;
/** /**
* Returns all storages * Returns all storages
*/ */
getStorages(): any[]; getStorages(): Record<string, IStorage>;
/** /**
* Returns current storage type * Returns current storage type
*/ */
@ -3635,30 +3620,41 @@ declare module grapesjs {
*/ */
setCurrent(id: string): this; setCurrent(id: string): this;
/** /**
* Store key-value resources in the current storage * Store data in the current storage.
* @param data Project data.
* @param options Storage options.
* @returns Stored data.
* @example * @example
* storageManager.store({item1: value1, item2: value2}); * const data = editor.getProjectData();
* @param data - Data in key-value format, eg. {item1: value1, item2: value2} * await storageManager.store(data);
* @param clb - Callback function
*/ */
store(data: any, clb: (...params: any[]) => any): any | null; store(data: ProjectData, options: StorageOptions): Promise<ProjectData>;
/** /**
* Load resource from the current storage by keys * Load resource from the current storage by keys
* @param options Storage options.
* @returns Loaded data.
* @example * @example
* storageManager.load(['item1', 'item2'], res => { * const data = await storageManager.load();
* // res -> {item1: value1, item2: value2} * editor.loadProjectData(data);
* }); * */
* storageManager.load('item1', res => { load(options: StorageOptions): Promise<ProjectData>;
* // res -> {item1: value1}
* });
* @param keys - Keys to load
* @param clb - Callback function
*/
load(keys: string | string[], clb: (...params: any[]) => any): void;
/** /**
* Get current storage * Get current storage
*/ */
getCurrentStorage(): Storage; getCurrentStorage(): IStorage;
}
interface ProjectData {
}
interface StorageOptions {
}
interface IStorage {
load: (options: StorageOptions) => Promise<ProjectData>;
store: (data: ProjectData, options: StorageOptions) => Promise<ProjectData>;
} }
/** /**

3
package.json

@ -1,7 +1,7 @@
{ {
"name": "grapesjs", "name": "grapesjs",
"description": "Free and Open Source Web Builder Framework", "description": "Free and Open Source Web Builder Framework",
"version": "0.18.4", "version": "0.19.0",
"author": "Artur Arseniev", "author": "Artur Arseniev",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"homepage": "http://grapesjs.com", "homepage": "http://grapesjs.com",
@ -29,6 +29,7 @@
"documentation": "^13.2.5", "documentation": "^13.2.5",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"grapesjs-cli": "^3.0.0", "grapesjs-cli": "^3.0.0",
"html-entities": "^1.4.0",
"husky": "^2.7.0", "husky": "^2.7.0",
"jest": "^24.9.0", "jest": "^24.9.0",
"lint-staged": "^8.2.1", "lint-staged": "^8.2.1",

43
src/asset_manager/index.js

@ -280,47 +280,12 @@ export default () => {
return this.__remove(asset, opts); return this.__remove(asset, opts);
}, },
/** store() {
* Store assets data to the selected storage return this.getProjectData();
* @param {Boolean} noStore If true, won't store
* @returns {Object} Data to store
* @example
* var assets = assetManager.store();
*/
store(noStore) {
const obj = {};
const assets = JSON.stringify(this.getAll().toJSON());
obj[this.storageKey] = assets;
if (!noStore && c.stm) c.stm.store(obj);
return obj;
}, },
/** load(data) {
* Load data from the passed object. return this.loadProjectData(data);
* The fetched data will be added to the collection.
* @param {Object} data Object of data to load
* @returns {Object} Loaded assets
* @example
* var assets = assetManager.load({
* assets: [...]
* })
*
*/
load(data = {}) {
const name = this.storageKey;
let assets = data[name] || [];
if (typeof assets == 'string') {
try {
assets = JSON.parse(data[name]);
} catch (err) {}
}
if (assets && assets.length) {
this.getAll().reset(assets);
}
return assets;
}, },
/** /**

24
src/canvas/model/Frame.js

@ -26,7 +26,7 @@ export default class Frame extends Model {
component: '', component: '',
styles: '', styles: '',
_undo: true, _undo: true,
_undoexc: ['changesCount'] _undoexc: ['changesCount'],
}; };
} }
@ -125,12 +125,7 @@ export default class Frame extends Model {
getHeadByAttr(attr, value, tag) { getHeadByAttr(attr, value, tag) {
const head = this.getHead(); const head = this.getHead();
return head.filter( return head.filter(item => item.attributes && item.attributes[attr] == value && (!tag || tag === item.tag))[0];
item =>
item.attributes &&
item.attributes[attr] == value &&
(!tag || tag === item.tag)
)[0];
} }
removeHeadByAttr(attr, value, tag) { removeHeadByAttr(attr, value, tag) {
@ -151,8 +146,8 @@ export default class Frame extends Model {
tag, tag,
attributes: { attributes: {
href, href,
rel: 'stylesheet' rel: 'stylesheet',
} },
}); });
} }
@ -165,7 +160,7 @@ export default class Frame extends Model {
!this.getHeadByAttr('src', src, tag) && !this.getHeadByAttr('src', src, tag) &&
this.addHeadItem({ this.addHeadItem({
tag, tag,
attributes: { src } attributes: { src },
}); });
} }
@ -184,17 +179,8 @@ export default class Frame extends Model {
toJSON(opts = {}) { toJSON(opts = {}) {
const obj = Model.prototype.toJSON.call(this, opts); const obj = Model.prototype.toJSON.call(this, opts);
const { em } = this;
const sm = em && em.get('StorageManager');
const smc = sm && sm.getConfig();
const defaults = result(this, 'defaults'); const defaults = result(this, 'defaults');
if (smc && !opts.fromUndo) {
const opts = { component: this.getComponent() };
if (smc.storeHtml) obj.html = em.getHtml(opts);
if (smc.storeCss) obj.css = em.getCss(opts);
}
if (opts.fromUndo) delete obj.component; if (opts.fromUndo) delete obj.component;
delete obj.styles; delete obj.styles;
delete obj.changesCount; delete obj.changesCount;

51
src/common/module.js

@ -1,11 +1,51 @@
import { isString, isElement } from 'underscore'; import { isString, isElement } from 'underscore';
import { createId } from 'utils/mixins'; import { createId, deepMerge, isDef } from 'utils/mixins';
export default { export default {
getConfig(name) { getConfig(name) {
return this.__getConfig(name); return this.__getConfig(name);
}, },
getProjectData(data) {
const obj = {};
const key = this.storageKey;
if (key) {
obj[key] = data || this.getAll();
}
return obj;
},
loadProjectData(data = {}, { all, onResult, reset } = {}) {
const key = this.storageKey;
const opts = { action: 'load' };
const coll = all || this.getAll();
let result = data[key];
if (typeof result == 'string') {
try {
result = JSON.parse(result);
} catch (err) {
this.__logWarn('Data parsing failed', { input: result });
}
}
reset && result && coll.reset(null, opts);
if (onResult) {
result && onResult(result, opts);
} else if (result && isDef(result.length)) {
coll.reset(result, opts);
}
return result;
},
clear(opts = {}) {
const { all } = this;
all && all.reset(null, opts);
return this;
},
__getConfig(name) { __getConfig(name) {
const res = this.config || {}; const res = this.config || {};
return name ? res[name] : res; return name ? res[name] : res;
@ -23,10 +63,7 @@ export default {
}, },
__initConfig(def = {}, conf = {}) { __initConfig(def = {}, conf = {}) {
this.config = { this.config = deepMerge(def, conf);
...def,
...conf,
};
this.em = this.config.em; this.em = this.config.em;
this.cls = []; this.cls = [];
}, },
@ -82,8 +119,8 @@ export default {
__onAllEvent() {}, __onAllEvent() {},
__logWarn(str) { __logWarn(str, opts) {
this.em.logWarning(`[${this.name}]: ${str}`); this.em.logWarning(`[${this.name}]: ${str}`, opts);
}, },
_createId(len = 16) { _createId(len = 16) {

69
src/css_composer/index.js

@ -35,6 +35,7 @@ import CssRules from './model/CssRules';
import CssRulesView from './view/CssRulesView'; import CssRulesView from './view/CssRulesView';
import Selectors from 'selector_manager/model/Selectors'; import Selectors from 'selector_manager/model/Selectors';
import Selector from 'selector_manager/model/Selector'; import Selector from 'selector_manager/model/Selector';
import Module from 'common/module';
export default () => { export default () => {
let em; let em;
@ -42,6 +43,8 @@ export default () => {
var rules, rulesView; var rules, rulesView;
return { return {
...Module,
Selectors, Selectors,
/** /**
@ -51,23 +54,12 @@ export default () => {
*/ */
name: 'CssComposer', name: 'CssComposer',
storageKey: 'styles',
getConfig() { getConfig() {
return c; return c;
}, },
/**
* Mandatory for the storage manager
* @type {String}
* @private
*/
storageKey() {
var keys = [];
var smc = (c.stm && c.stm.getConfig()) || {};
if (smc.storeCss) keys.push('css');
if (smc.storeStyles) keys.push('styles');
return keys;
},
/** /**
* Initializes module. Automatically called with a new instance of the editor * Initializes module. Automatically called with a new instance of the editor
* @param {Object} config Configurations * @param {Object} config Configurations
@ -108,55 +100,12 @@ export default () => {
um && um.add(this.getAll()); um && um.add(this.getAll());
}, },
/** store() {
* Load data from the passed object, if the object is empty will try to fetch them return this.getProjectData();
* autonomously from the storage manager.
* The fetched data will be added to the collection
* @param {Object} data Object of data to load
* @return {Object} Loaded rules
* @private
*/
load(data) {
var d = data || '';
if (!d && c.stm) {
d = c.em.getCacheLoad();
}
var obj = d.styles || '';
if (d.styles) {
try {
obj = JSON.parse(d.styles);
} catch (err) {}
} else if (d.css) {
obj = c.em.get('Parser').parseCss(d.css);
}
if (isArray(obj)) {
obj.length && rules.reset(obj);
} else if (obj) {
rules.reset(obj);
}
return obj;
}, },
/** load(data) {
* Store data to the selected storage return this.loadProjectData(data);
* @param {Boolean} noStore If true, won't store
* @return {Object} Data to store
* @private
*/
store(noStore) {
if (!c.stm) return;
const obj = {};
const keys = this.storageKey();
const hasPages = em && em.get('hasPages');
if (keys.indexOf('css') >= 0 && !hasPages) obj.css = c.em.getCss();
if (keys.indexOf('styles') >= 0) obj.styles = JSON.stringify(rules);
if (!noStore) c.stm.store(obj);
return obj;
}, },
/** /**

3
src/css_composer/model/CssRule.js

@ -236,6 +236,9 @@ export default class CssRule extends Model.extend(Styleable) {
} }
}); });
// Delete the property used for partial updates
delete obj.style.__p;
if (isEmpty(obj.selectors)) delete obj.selectors; if (isEmpty(obj.selectors)) delete obj.selectors;
if (isEmpty(obj.style)) delete obj.style; if (isEmpty(obj.style)) delete obj.style;
} }

11
src/dom_components/config/config.js

@ -7,13 +7,6 @@ export default {
// If the component is draggable you can drag the component itself (not only from the toolbar) // If the component is draggable you can drag the component itself (not only from the toolbar)
draggableComponents: 1, draggableComponents: 1,
// Generally, if you don't edit the wrapper in the editor, like
// custom attributes, you don't need the wrapper stored in your JSON
// structure, but in case you need it you can use this option.
// If you have `config.avoidInlineStyle` disabled the wrapper will be stored
// as we need to store inlined style.
storeWrapper: 0,
/** /**
* You can setup a custom component definition processor before adding it into the editor. * You can setup a custom component definition processor before adding it into the editor.
* It might be useful to transform custom objects (es. some framework specific JSX) to GrapesJS component one. * It might be useful to transform custom objects (es. some framework specific JSX) to GrapesJS component one.
@ -53,6 +46,6 @@ export default {
'param', 'param',
'source', 'source',
'track', 'track',
'wbr' 'wbr',
] ],
}; };

117
src/dom_components/index.js

@ -96,6 +96,7 @@ import ComponentTextView from './view/ComponentTextView';
import ComponentWrapper from './model/ComponentWrapper'; import ComponentWrapper from './model/ComponentWrapper';
import ComponentFrame from './model/ComponentFrame'; import ComponentFrame from './model/ComponentFrame';
import ComponentFrameView from './view/ComponentFrameView'; import ComponentFrameView from './view/ComponentFrameView';
import Module from 'common/module';
export default () => { export default () => {
var c = {}; var c = {};
@ -207,6 +208,8 @@ export default () => {
]; ];
return { return {
...Module,
Component, Component,
Components, Components,
@ -224,6 +227,8 @@ export default () => {
*/ */
name: 'DomComponents', name: 'DomComponents',
storageKey: 'components',
/** /**
* Returns config * Returns config
* @return {Object} Config object * @return {Object} Config object
@ -233,19 +238,6 @@ export default () => {
return c; return c;
}, },
/**
* Mandatory for the storage manager
* @type {String}
* @private
*/
storageKey() {
var keys = [];
var smc = (c.stm && c.stm.getConfig()) || {};
if (smc.storeHtml) keys.push('html');
if (smc.storeComponents) keys.push('components');
return keys;
},
/** /**
* Initialize module. Called on a new instance of the editor with configurations passed * Initialize module. Called on a new instance of the editor with configurations passed
* inside 'domComponents' field * inside 'domComponents' field
@ -280,97 +272,26 @@ export default () => {
em.listenTo(selected, 'remove', (sel, c, opts) => this.selectRemove(selected.getComponent(sel), opts)); em.listenTo(selected, 'remove', (sel, c, opts) => this.selectRemove(selected.getComponent(sel), opts));
} }
if (em.get('hasPages')) {
c.components = '';
}
return this; return this;
}, },
/** load(data) {
* On load callback return this.loadProjectData(data, {
* @private onResult: result => {
*/ const wrapper = this.getWrapper();
onLoad() { if (isArray(result)) {
c.components && this.setComponents(c.components, { silent: 1 }); result.length && wrapper.components(result);
}, } else {
const { components = [], ...rest } = result;
/** wrapper.set(rest);
* Load components from the passed object, if the object is empty will try to fetch them wrapper.components(components);
* autonomously from the selected storage
* The fetched data will be added to the collection
* @param {Object} data Object of data to load
* @return {Object} Loaded data
*/
load(data = '') {
const { em } = this;
let result = '';
if (!data && c.stm) {
data = c.em.getCacheLoad();
}
const { components, html } = data;
if (components) {
if (isObject(components) || isArray(components)) {
result = components;
} else {
try {
result = JSON.parse(components);
} catch (err) {
em && em.logError(err);
} }
} },
} else if (html) { });
result = html;
}
const isObj = result && result.constructor === Object;
if ((result && result.length) || isObj) {
this.clear();
// If the result is an object I consider it the wrapper
if (isObj) {
this.getWrapper().set(result);
} else {
this.getComponents().add(result);
}
}
return result;
}, },
/** store() {
* Store components on the selected storage return {};
* @param {Boolean} noStore If true, won't store
* @return {Object} Data to store
*/
store(noStore) {
if (!c.stm || this.em.get('hasPages')) {
return {};
}
var obj = {};
var keys = this.storageKey();
if (keys.indexOf('html') >= 0) {
obj.html = c.em.getHtml();
}
if (keys.indexOf('components') >= 0) {
// const storeWrap = (em && !em.getConfig('avoidInlineStyle')) || c.storeWrapper;
const storeWrap = c.storeWrapper;
const toStore = storeWrap ? this.getWrapper() : this.getComponents();
obj.components = JSON.stringify(toStore);
}
if (!noStore) {
c.stm.store(obj);
}
return obj;
}, },
/** /**

6
src/dom_components/model/Component.js

@ -1677,7 +1677,11 @@ export default class Component extends Model.extend(Styleable) {
const coll = this.collection; const coll = this.collection;
const remove = () => { const remove = () => {
coll && coll.remove(this, { ...opts, action: 'remove-component' }); coll && coll.remove(this, { ...opts, action: 'remove-component' });
opts.root && this.components(''); // Component without parent
if (!coll) {
this.components('', opts);
this.components().removeChildren(this, null, opts);
}
}; };
const rmOpts = { ...opts }; const rmOpts = { ...opts };
[this, em].map(i => i.trigger('component:remove:before', this, remove, rmOpts)); [this, em].map(i => i.trigger('component:remove:before', this, remove, rmOpts));

20
src/editor/config/config.js

@ -2,15 +2,29 @@ export default {
// Style prefix // Style prefix
stylePrefix: 'gjs-', stylePrefix: 'gjs-',
// HTML string or object of components /**
* HTML string or object of components
* @deprecated Rely on `projectData` option
*/
components: '', components: '',
// CSS string or object of rules /**
* CSS string or object of rules
* @deprecated Rely on `projectData` option
*/
style: '', style: '',
// If true, will fetch HTML and CSS from selected container /**
* If true, will fetch HTML and CSS from selected container
* @deprecated
*/
fromElement: 0, fromElement: 0,
/**
* Initial project data
*/
projectData: null,
// Show an alert before unload the page with unsaved changes // Show an alert before unload the page with unsaved changes
noticeOnUnload: true, noticeOnUnload: true,

73
src/editor/index.js

@ -133,15 +133,6 @@ export default (config = {}, opts = {}) => {
} }
}); });
// Do post render stuff after the iframe is loaded otherwise it'll
// be empty during tests
em.once('change:ready', () => {
this.UndoManager.clear();
em.get('modules').forEach(module => {
module.postRender && module.postRender(editorView);
});
});
return this; return this;
}, },
@ -430,64 +421,53 @@ export default (config = {}, opts = {}) => {
}, },
/** /**
* Store data to the current storage * Store data to the current storage.
* @param {Function} clb Callback function * This will reset the counter of changes (`editor.getDirtyCount()`).
* @return {Object} Stored data * @param {Object} [options] Storage options.
* @returns {Object} Stored data.
* @example
* const storedData = await editor.store();
*/ */
store(clb) { async store(options) {
return em.store(clb); return await em.store(options);
}, },
/** /**
* Get the JSON project data, which could be stored and loaded back with `editor.loadProject(json)` * Load data from the current storage.
* @returns {Object} * @param {Object} [options] Storage options.
* @returns {Object} Loaded data.
* @example * @example
* @private * const data = await editor.load();
* console.log(editor.getProject());
* // { pages: [...], styles: [...], ... }
*/ */
getProject() { async load(options) {
return em.storeData(); return await em.load(options);
}, },
/** /**
* Get the JSON data object, which could be stored and loaded back with `editor.loadData(json)` * Get the JSON project data, which could be stored and loaded back with `editor.loadProjectData(json)`
* @returns {Object} * @returns {Object}
* @example * @example
* console.log(editor.storeData()); * console.log(editor.getProjectData());
* // { pages: [...], styles: [...], ... } * // { pages: [...], styles: [...], ... }
*/ */
storeData() { getProjectData() {
return em.storeData(); return em.storeData();
}, },
/**
* Load data from the current storage
* @param {Function} clb Callback function
* @return {Object} Stored data
*/
load(clb) {
return em.load(clb);
},
/** /**
* Load data from the JSON project * Load data from the JSON project
* @param {Object} data Project to load * @param {Object} data Project to load
* @example * @example
* @private * editor.loadProjectData({ pages: [...], styles: [...], ... })
* editor.loadProject({ pages: [...], styles: [...], ... })
*/ */
loadProject(data) { loadProjectData(data) {
return em.loadData(data); return em.loadData(data);
}, },
/** storeData() {
* Load data from the JSON data object return em.storeData();
* @param {Object} data Data to load },
* @return {Object} Loaded object
* @example
* editor.loadData({ pages: [...], styles: [...], ... })
*/
loadData(data) { loadData(data) {
return em.loadData(data); return em.loadData(data);
}, },
@ -510,6 +490,13 @@ export default (config = {}, opts = {}) => {
return em.getDirtyCount(); return em.getDirtyCount();
}, },
/**
* Reset the counter of changes.
*/
clearDirtyCount() {
return em.clearDirtyCount();
},
/** /**
* Update editor dimension offsets * Update editor dimension offsets
* *

151
src/editor/model/Editor.js

@ -1,4 +1,4 @@
import { isUndefined, isFunction, isArray, contains, toArray, keys, bindAll } from 'underscore'; import { isUndefined, isArray, contains, toArray, keys, bindAll } from 'underscore';
import Backbone from 'backbone'; import Backbone from 'backbone';
import $ from 'utils/cash-dom'; import $ from 'utils/cash-dom';
import Extender from 'utils/extender'; import Extender from 'utils/extender';
@ -66,21 +66,23 @@ export default class EditorModel extends Model {
}; };
} }
initialize(c = {}) { initialize(conf = {}) {
this.config = c; this.config = conf;
this.set('Config', c); const { config } = this;
this.set('Config', config);
this.set('modules', []); this.set('modules', []);
this.set('toLoad', []); this.set('toLoad', []);
this.set('storables', []); this.set('storables', []);
this.set('selected', new Selected()); this.set('selected', new Selected());
this.set('dmode', c.dragMode); this.set('dmode', config.dragMode);
this.set('hasPages', !!c.pageManager); const { el, log } = config;
const el = c.el;
const log = c.log;
const toLog = log === true ? keys(logs) : isArray(log) ? log : []; const toLog = log === true ? keys(logs) : isArray(log) ? log : [];
bindAll(this, 'initBaseColorPicker'); bindAll(this, 'initBaseColorPicker');
if (el && c.fromElement) this.config.components = el.innerHTML; if (el && config.fromElement) {
config.components = el.innerHTML;
}
this.attrsOrig = el this.attrsOrig = el
? toArray(el.attributes).reduce((res, next) => { ? toArray(el.attributes).reduce((res, next) => {
res[next.nodeName] = next.nodeValue; res[next.nodeName] = next.nodeValue;
@ -88,6 +90,11 @@ export default class EditorModel extends Model {
}, {}) }, {})
: ''; : '';
// Move components to pages
if (config.components && !config.pageManager) {
config.pageManager = { pages: [{ component: config.components }] };
}
// Load modules // Load modules
deps.forEach(name => this.loadModule(name)); deps.forEach(name => this.loadModule(name));
this.on('change:componentHovered', this.componentHovered, this); this.on('change:componentHovered', this.componentHovered, this);
@ -133,12 +140,9 @@ export default class EditorModel extends Model {
/** /**
* Should be called once all modules and plugins are loaded * Should be called once all modules and plugins are loaded
* @param {Function} clb
* @private * @private
*/ */
loadOnStart(clb = null) { loadOnStart() {
const sm = this.get('StorageManager');
// In `onLoad`, the module will try to load the data from its configurations. // In `onLoad`, the module will try to load the data from its configurations.
this.get('toLoad').forEach(mdl => mdl.onLoad()); this.get('toLoad').forEach(mdl => mdl.onLoad());
@ -147,14 +151,23 @@ export default class EditorModel extends Model {
const modules = this.get('modules'); const modules = this.get('modules');
modules.forEach(mdl => mdl.postLoad && mdl.postLoad(this)); modules.forEach(mdl => mdl.postLoad && mdl.postLoad(this));
this.set('readyLoad', 1); this.set('readyLoad', 1);
clb && clb();
}; };
if (sm && sm.canAutoload()) { // Defer for storage load events.
this.load(postLoad); setTimeout(async () => {
} else { const projectData = this.getConfig('projectData');
setTimeout(postLoad);
} if (projectData) {
this.loadData(projectData);
} else if (this.get('StorageManager').canAutoload()) {
try {
await this.load();
} catch (error) {
this.logError(error);
}
}
postLoad();
});
// Create shallow editor. // Create shallow editor.
// Here we can create components/styles without altering/triggering the main EditorModel // Here we can create components/styles without altering/triggering the main EditorModel
@ -175,7 +188,7 @@ export default class EditorModel extends Model {
*/ */
updateChanges() { updateChanges() {
const stm = this.get('StorageManager'); const stm = this.get('StorageManager');
const changes = this.get('changesCount'); const changes = this.getDirtyCount();
updateItr && clearTimeout(updateItr); updateItr && clearTimeout(updateItr);
updateItr = setTimeout(() => this.trigger('update')); updateItr = setTimeout(() => this.trigger('update'));
@ -184,7 +197,7 @@ export default class EditorModel extends Model {
} }
if (stm.isAutosave() && changes >= stm.getStepsBeforeSave()) { if (stm.isAutosave() && changes >= stm.getStepsBeforeSave()) {
this.store(); this.store().catch(err => this.logError(err));
} }
} }
@ -201,17 +214,15 @@ export default class EditorModel extends Model {
const name = Mod.name.charAt(0).toLowerCase() + Mod.name.slice(1); const name = Mod.name.charAt(0).toLowerCase() + Mod.name.slice(1);
const cfgParent = !isUndefined(config[name]) ? config[name] : config[Mod.name]; const cfgParent = !isUndefined(config[name]) ? config[name] : config[Mod.name];
const cfg = cfgParent === true ? {} : cfgParent || {}; const cfg = cfgParent === true ? {} : cfgParent || {};
const sm = this.get('StorageManager');
cfg.pStylePrefix = config.pStylePrefix || ''; cfg.pStylePrefix = config.pStylePrefix || '';
if (!isUndefined(cfgParent) && !cfgParent) { if (!isUndefined(cfgParent) && !cfgParent) {
cfg._disable = 1; cfg._disable = 1;
} }
if (Mod.storageKey && Mod.store && Mod.load && sm) { if (Mod.storageKey && Mod.store && Mod.load) {
cfg.stm = sm; // Components should be loaded before CSS due to reset
// DomComponents should be load before CSS Composer const mth = ['domComponents', 'pageManager'].indexOf(name) >= 0 ? 'unshift' : 'push';
const mth = name == 'domComponents' ? 'unshift' : 'push';
this.get('storables')[mth](Mod); this.get('storables')[mth](Mod);
} }
@ -259,7 +270,7 @@ export default class EditorModel extends Model {
timedInterval && clearTimeout(timedInterval); timedInterval && clearTimeout(timedInterval);
timedInterval = setTimeout(() => { timedInterval = setTimeout(() => {
const curr = this.get('changesCount') || 0; const curr = this.getDirtyCount() || 0;
const { unset, ...opts } = opt; const { unset, ...opts } = opt;
this.set('changesCount', curr + 1, opts); this.set('changesCount', curr + 1, opts);
}, 0); }, 0);
@ -606,23 +617,24 @@ export default class EditorModel extends Model {
} }
/** /**
* Store data to the current storage * Store data to the current storage.
* @param {Function} clb Callback function
* @return {Object} Stored data
* @private * @private
*/ */
store(clb) { async store(options) {
const sm = this.get('StorageManager'); const data = this.storeData();
if (!sm) return; await this.get('StorageManager').store(data, options);
this.clearDirtyCount();
const store = this.storeData(); return data;
sm.store(store, res => { }
clb && clb(res, store);
this.set('changesCount', 0);
this.trigger('storage:store', store);
});
return store; /**
* Load data from the current storage.
* @private
*/
async load(options) {
const result = await this.get('StorageManager').load(options);
this.loadData(result);
return result;
} }
storeData() { storeData() {
@ -634,59 +646,12 @@ export default class EditorModel extends Model {
this.get('storables').forEach(m => { this.get('storables').forEach(m => {
result = { ...result, ...m.store(1) }; result = { ...result, ...m.store(1) };
}); });
return result; return JSON.parse(JSON.stringify(result));
}
/**
* Load data from the current storage
* @param {Function} clb Callback function
* @private
*/
load(clb = null) {
this.getCacheLoad(1, res => {
this.loadData(res);
clb && clb(res);
});
} }
loadData(data = {}) { loadData(data = {}) {
const sm = this.get('StorageManager'); this.get('storables').forEach(module => module.load(data));
const result = sm.__clearKeys(data); return data;
this.get('storables').forEach(module => {
module.load(result);
module.postLoad && module.postLoad(this);
});
return result;
}
/**
* Returns cached load
* @param {Boolean} force Force to reload
* @param {Function} clb Callback function
* @return {Object}
* @private
*/
getCacheLoad(force, clb) {
if (this.cacheLoad && !force) return this.cacheLoad;
const sm = this.get('StorageManager');
const load = [];
if (!sm) return {};
this.get('storables').forEach(m => {
let key = m.storageKey;
key = isFunction(key) ? key() : key;
const keys = isArray(key) ? key : [key];
keys.forEach(k => load.push(k));
});
sm.load(load, res => {
this.cacheLoad = res;
clb && clb(res);
setTimeout(() => this.trigger('storage:load', res));
});
} }
/** /**
@ -793,6 +758,10 @@ export default class EditorModel extends Model {
return this.get('changesCount'); return this.get('changesCount');
} }
clearDirtyCount() {
return this.set('changesCount', 0);
}
getZoomDecimal() { getZoomDecimal() {
return this.get('Canvas').getZoomDecimal(); return this.get('Canvas').getZoomDecimal();
} }

20
src/editor/view/EditorView.js

@ -7,22 +7,23 @@ const $ = Backbone.$;
export default class EditorView extends View { export default class EditorView extends View {
initialize() { initialize() {
const { model } = this; const { model } = this;
const { Panels, UndoManager } = model.attributes;
model.view = this; model.view = this;
this.conf = model.config;
this.pn = model.get('Panels');
this.cv = model.get('Canvas');
model.once('change:ready', () => { model.once('change:ready', () => {
this.pn.active(); Panels.active();
this.pn.disableButtons(); Panels.disableButtons();
UndoManager.clear();
setTimeout(() => { setTimeout(() => {
model.trigger('load', model.get('Editor')); model.trigger('load', model.get('Editor'));
model.set('changesCount', 0); model.clearDirtyCount();
}); });
}); });
} }
render() { render() {
const { $el, conf, model } = this; const { $el, model } = this;
const { Panels, Canvas, modules } = model.attributes;
const conf = model.getConfig();
const pfx = conf.stylePrefix; const pfx = conf.stylePrefix;
const contEl = $(conf.el || `body ${conf.container}`); const contEl = $(conf.el || `body ${conf.container}`);
appendStyles(conf.cssIcons, { unique: 1, prepand: 1 }); appendStyles(conf.cssIcons, { unique: 1, prepand: 1 });
@ -31,8 +32,8 @@ export default class EditorView extends View {
if (conf.width) contEl.css('width', conf.width); if (conf.width) contEl.css('width', conf.width);
if (conf.height) contEl.css('height', conf.height); if (conf.height) contEl.css('height', conf.height);
$el.append(this.cv.render()); $el.append(Canvas.render());
$el.append(this.pn.render()); $el.append(Panels.render());
// Load shallow editor // Load shallow editor
const shallow = model.get('shallow'); const shallow = model.get('shallow');
@ -42,6 +43,7 @@ export default class EditorView extends View {
$el.attr('class', `${pfx}editor ${pfx}one-bg ${pfx}two-color`); $el.attr('class', `${pfx}editor ${pfx}one-bg ${pfx}two-color`);
contEl.addClass(`${pfx}editor-cont`).empty().append($el); contEl.addClass(`${pfx}editor-cont`).empty().append($el);
modules.forEach(md => md.postRender && md.postRender(this));
return this; return this;
} }

3
src/i18n/locale/en.js

@ -160,4 +160,7 @@ export default {
}, },
}, },
}, },
storageManager: {
recover: 'Do you want to recover unsaved changes?',
},
}; };

45
src/pages/index.js

@ -46,7 +46,7 @@
import { isString, bindAll, unique, flatten } from 'underscore'; import { isString, bindAll, unique, flatten } from 'underscore';
import { createId } from 'utils/mixins'; import { createId } from 'utils/mixins';
import { Model } from 'backbone'; import { Model, Module } from 'common';
import Pages from './model/Pages'; import Pages from './model/Pages';
import Page from './model/Page'; import Page from './model/Page';
@ -64,6 +64,8 @@ const typeMain = 'main';
export default () => { export default () => {
return { return {
...Module,
name: 'PageManager', name: 'PageManager',
storageKey: 'pages', storageKey: 'pages',
@ -80,7 +82,7 @@ export default () => {
add: evPageAdd, add: evPageAdd,
addBefore: evPageAddBefore, addBefore: evPageAddBefore,
remove: evPageRemove, remove: evPageRemove,
removeBefore: evPageRemoveBefore removeBefore: evPageRemoveBefore,
}, },
/** /**
@ -96,6 +98,7 @@ export default () => {
this.em = em; this.em = em;
const pages = new Pages([], cnf); const pages = new Pages([], cnf);
this.pages = pages; this.pages = pages;
this.all = pages;
const model = new Model({ _undo: true }); const model = new Model({ _undo: true });
this.model = model; this.model = model;
pages.on('add', (p, c, o) => em.trigger(evPageAdd, p, o)); pages.on('add', (p, c, o) => em.trigger(evPageAdd, p, o));
@ -119,9 +122,7 @@ export default () => {
const { pages } = this; const { pages } = this;
const opt = { silent: true }; const opt = { silent: true };
pages.add(this.config.pages || [], opt); pages.add(this.config.pages || [], opt);
const mainPage = !pages.length const mainPage = !pages.length ? this.add({ type: typeMain }, opt) : this.getMain();
? this.add({ type: typeMain }, opt)
: this.getMain();
this.select(mainPage, opt); this.select(mainPage, opt);
}, },
@ -228,13 +229,7 @@ export default () => {
*/ */
getAllWrappers() { getAllWrappers() {
const pages = this.getAll(); const pages = this.getAll();
return unique( return unique(flatten(pages.map(page => page.getAllFrames().map(frame => frame.getComponent()))));
flatten(
pages.map(page =>
page.getAllFrames().map(frame => frame.getComponent())
)
)
);
}, },
getAllMap() { getAllMap() {
@ -280,28 +275,12 @@ export default () => {
['selected', 'config', 'em', 'pages', 'model'].map(i => (this[i] = 0)); ['selected', 'config', 'em', 'pages', 'model'].map(i => (this[i] = 0));
}, },
store(noStore) { store() {
if (!this.em.get('hasPages')) return {}; return this.getProjectData();
const obj = {};
const cnf = this.config;
obj[this.storageKey] = JSON.stringify(this.getAll());
if (!noStore && cnf.stm) cnf.stm.store(obj);
return obj;
}, },
load(data = {}) { load(data) {
const key = this.storageKey; return this.loadProjectData(data, { all: this.pages, reset: true });
let res = data[key] || [];
if (typeof res == 'string') {
try {
res = JSON.parse(data[key]);
} catch (err) {}
}
res && res.length && this.pages.reset(res);
return res;
}, },
_createId() { _createId() {
@ -315,6 +294,6 @@ export default () => {
} while (pagesMap[id]); } while (pagesMap[id]);
return id; return id;
} },
}; };
}; };

130
src/storage_manager/config/config.js

@ -1,67 +1,93 @@
export default { export default {
// Prefix identifier that will be used inside storing and loading // Prefix identifier that will be used inside storing and loading
// @deprecated
id: 'gjs-', id: 'gjs-',
// Default storage type. Available: local | remote
type: 'local',
// Enable/Disable autosaving // Enable/Disable autosaving
autosave: true, autosave: true,
// Indicates if load data inside editor after init // Enable/Disable autoload of data on editor init
autoload: true, autoload: true,
// Indicates which storage to use. Available: local | remote /**
type: 'local', * In case the `remote` storage is selected, and this options is enabled, the project
* will be stored on the `local` storage in case the remote one fails.
* The local data are cleared on every sucessful remote save. When the remote storage
* fails (eg. network issue) and the editor is reloaded, a dialog with the possibility to
* recovery previous data will be shown.
* @example
* // Enable recovery with default confirm dialog
* recovery: true,
* // Enable recovery with a custom dialog
* recovery: (accept, cancel, editor) => {
* confirm('Recover data?') ? accept() : cancel();
* },
*/
recovery: false,
// If autosave enabled, indicates how many steps (general changes to structure) // If autosave enabled, indicates how many steps (general changes to structure)
// need to be done before save. Useful with remoteStorage to reduce remote calls // need to be done before save. Useful with remoteStorage to reduce remote calls
stepsBeforeSave: 1, stepsBeforeSave: 1,
//Enable/Disable components model (JSON format) /**
storeComponents: true, * Callback triggered before the store call (can be asynchronous).
* This can be used to enrich the project data to store.
//Enable/Disable styles model (JSON format) */
storeStyles: true, onStore: data => data,
//Enable/Disable saving HTML template /**
storeHtml: true, * Callback triggered after the load call (can be asynchronous).
*/
//Enable/Disable saving CSS template onLoad: data => data,
storeCss: true,
// Default storage options
// ONLY FOR LOCAL STORAGE options: {
// If enabled, checks if browser supports Local Storage local: {
checkLocal: true, key: 'gjsProject',
// ONLY FOR REMOTE STORAGE // If enabled, checks if browser supports LocalStorage
// Custom parameters to pass with the remote storage request, eg. csrf token checkLocal: true,
params: {}, },
remote: {
// Custom headers for the remote storage request // Custom headers
headers: {}, headers: {},
// Endpoint where to save all stuff // Endpoint URL where to store data project
urlStore: '', urlStore: '',
// Endpoint where to fetch data // Endpoint URL where to load data project
urlLoad: '', urlLoad: '',
//Callback before request // set contentType paramater of $.ajax
beforeSend(jqXHR, settings) {}, // true: application/json; charset=utf-8'
// false: 'x-www-form-urlencoded'
//Callback after request contentTypeJson: true,
onComplete(jqXHR, status) {},
// Pass custom options to fetch API (remote storage)
// set contentType paramater of $.ajax // You can pass a simple object: { someOption: 'someValue' }
// true: application/json; charset=utf-8' // or a function which returns and object to add:
// false: 'x-www-form-urlencoded' // currentOpts => {
contentTypeJson: true, // return currentOpts.method === 'POST' ? { method: 'PATCH' } : {};
// }
credentials: 'include', fetchOptions: '',
// Pass custom options to fetch API (remote storage) credentials: 'include',
// You can pass a simple object: { someOption: 'someValue' }
// or a function which returns and object to add: /**
// currentOpts => { * The remote storage sends the project data as a body of the request.
// return currentOpts.method === 'post' ? { method: 'patch' } : {}; * You can use this method to update the body before the store call in order to align
// } * with your API requirements.
fetchOptions: '', */
onStore: data => data,
/**
* The remote storage loads the project data directly from the request response.
* You can use this method to properly extract the project data from the response.
*/
onLoad: result => result,
},
},
}; };

357
src/storage_manager/index.js

@ -42,6 +42,7 @@
* * [getCurrent](#getcurrent) * * [getCurrent](#getcurrent)
* * [getCurrentStorage](#getcurrentstorage) * * [getCurrentStorage](#getcurrentstorage)
* * [setCurrent](#setcurrent) * * [setCurrent](#setcurrent)
* * [getStorageOptions](#getstorageoptions)
* * [add](#add) * * [add](#add)
* * [get](#get) * * [get](#get)
* * [store](#store) * * [store](#store)
@ -53,249 +54,269 @@
import defaults from './config/config'; import defaults from './config/config';
import LocalStorage from './model/LocalStorage'; import LocalStorage from './model/LocalStorage';
import RemoteStorage from './model/RemoteStorage'; import RemoteStorage from './model/RemoteStorage';
import Module from 'common/module';
import { isEmpty, isFunction } from 'underscore';
const eventStart = 'storage:start'; const eventStart = 'storage:start';
const eventAfter = 'storage:after'; const eventAfter = 'storage:after';
const eventEnd = 'storage:end'; const eventEnd = 'storage:end';
const eventError = 'storage:error'; const eventError = 'storage:error';
export default () => { const STORAGE_LOCAL = 'local';
var c = {}; const STORAGE_REMOTE = 'remote';
let em;
var storages = {};
var defaultStorages = {};
export default () => {
return { return {
...Module,
name: 'StorageManager',
/** /**
* Name of the module * Get configuration object
* @type {String} * @name getConfig
* @private * @function
* @return {Object}
*/ */
name: 'StorageManager',
/** /**
* Initialize module. Automatically called with a new instance of the editor * Initialize module. Automatically called with a new instance of the editor
* @param {Object} config Configurations * @param {Object} config Configurations
* @param {string} [config.id='gjs-'] The prefix for the fields, useful to differentiate storing/loading
* with multiple editors on the same page. For example, in local storage, the item of HTML will be saved like 'gjs-html'
* @param {Boolean} [config.autosave=true] Indicates if autosave mode is enabled, works in conjunction with stepsBeforeSave
* @param {number} [config.stepsBeforeSave=1] If autosave enabled, indicates how many steps/changes are necessary
* before autosave is triggered
* @param {string} [config.type='local'] Default storage type. Available: 'local' | 'remote' | ''(do not store)
* @private * @private
* @example
* ...
* {
* autosave: false,
* type: 'remote',
* }
* ...
*/ */
init(config = {}) { init(config = {}) {
c = { ...defaults, ...config }; this.__initConfig(defaults, config);
em = c.em; const c = this.getConfig();
if (c._disable) c.type = 0; if (c._disable) c.type = 0;
defaultStorages.remote = new RemoteStorage(c); this.storages = {};
defaultStorages.local = new LocalStorage(c); this.add(STORAGE_LOCAL, new LocalStorage(c));
c.currentStorage = c.type; this.add(STORAGE_REMOTE, new RemoteStorage(c));
this.loadDefaultProviders().setCurrent(c.type); this.setCurrent(c.type);
return this; return this;
}, },
/** /**
* Get configuration object * Check if autosave is enabled.
* @return {Object} * @returns {Boolean}
* */
getConfig() {
return c;
},
/**
* Checks if autosave is enabled
* @return {Boolean}
* */ * */
isAutosave() { isAutosave() {
return !!c.autosave; return !!this.getConfig().autosave;
}, },
/** /**
* Set autosave value * Set autosave value.
* @param {Boolean} v * @param {Boolean} value
* @return {this}
* */ * */
setAutosave(v) { setAutosave(value) {
c.autosave = !!v; this.getConfig().autosave = !!value;
return this; return this;
}, },
/** /**
* Returns number of steps required before trigger autosave * Returns number of steps required before trigger autosave.
* @return {number} * @returns {Number}
* */ * */
getStepsBeforeSave() { getStepsBeforeSave() {
return c.stepsBeforeSave; return this.getConfig().stepsBeforeSave;
}, },
/** /**
* Set steps required before trigger autosave * Set steps required before trigger autosave.
* @param {number} v * @param {Number} value
* @return {this}
* */ * */
setStepsBeforeSave(v) { setStepsBeforeSave(value) {
c.stepsBeforeSave = v; this.getConfig().stepsBeforeSave = value;
return this; return this;
}, },
/** /**
* Add new storage * Add new storage.
* @param {string} id Storage ID * @param {String} type Storage type
* @param {Object} storage Storage wrapper * @param {Object} storage Storage definition
* @param {Function} storage.load Load method * @param {Function} storage.load Load method
* @param {Function} storage.store Store method * @param {Function} storage.store Store method
* @return {this}
* @example * @example
* storageManager.add('local2', { * storageManager.add('local2', {
* load: function(keys, clb, clbErr) { * async load(storageOptions) {
* var res = {}; * // ...
* for (var i = 0, len = keys.length; i < len; i++){ * },
* var v = localStorage.getItem(keys[i]); * async store(data, storageOptions) {
* if(v) res[keys[i]] = v; * // ...
* }
* clb(res); // might be called inside some async method
* // In case of errors...
* // clbErr('Went something wrong');
* }, * },
* store: function(data, clb, clbErr) {
* for(var key in data)
* localStorage.setItem(key, data[key]);
* clb(); // might be called inside some async method
* }
* }); * });
* */ * */
add(id, storage) { add(type, storage) {
storages[id] = storage; this.storages[type] = storage;
return this; return this;
}, },
/** /**
* Returns storage by id * Return storage by type.
* @param {string} id Storage ID * @param {String} type Storage type
* @return {Object|null} * @returns {Object|null}
* */ * */
get(id) { get(type) {
return storages[id] || null; return this.storages[type] || null;
}, },
/** /**
* Returns all storages * Get all storages.
* @return {Array} * @returns {Object}
* */ * */
getStorages() { getStorages() {
return storages; return this.storages;
}, },
/** /**
* Returns current storage type * Get current storage type.
* @return {string} * @returns {String}
* */ * */
getCurrent() { getCurrent() {
return c.currentStorage; return this.getConfig().currentStorage;
}, },
/** /**
* Set current storage type * Set current storage type.
* @param {string} id Storage ID * @param {String} type Storage type
* @return {this}
* */ * */
setCurrent(id) { setCurrent(type) {
c.currentStorage = id; this.getConfig().currentStorage = type;
return this; return this;
}, },
getCurrentStorage() {
return this.get(this.getCurrent());
},
/** /**
* Store key-value resources in the current storage * Get storage options by type.
* @param {Object} data Data in key-value format, eg. {item1: value1, item2: value2} * @param {String} type Storage type
* @param {Function} clb Callback function * @returns {Object}
* @return {Object|null}
* @example
* storageManager.store({item1: value1, item2: value2});
* */ * */
store(data, clb) { getStorageOptions(type) {
const st = this.get(this.getCurrent()); return this.getCurrentOptons(type);
const toStore = {}; },
this.onStart('store', data);
for (let key in data) { /**
toStore[c.id + key] = data[key]; * Store data in the current storage.
* @param {Object} data Project data.
* @param {Object} [options] Storage options.
* @returns {Object} Stored data.
* @example
* const data = editor.getProjectData();
* await storageManager.store(data);
* */
async store(data, options = {}) {
const st = this.getCurrentStorage();
const opts = { ...this.getCurrentOptons(), ...options };
const recovery = this.getRecoveryStorage();
const recoveryOpts = this.getCurrentOptons(STORAGE_LOCAL);
try {
await this.__exec(st, opts, data);
recovery && (await this.__exec(recovery, recoveryOpts, {}));
} catch (error) {
if (recovery) {
await this.__exec(recovery, recoveryOpts, data);
} else {
throw error;
}
} }
return st return data;
? st.store(
toStore,
res => {
this.onAfter('store', res);
clb && clb(res);
this.onEnd('store', res);
},
err => {
this.onError('store', err);
}
)
: null;
}, },
/** /**
* Load resource from the current storage by keys * Load resource from the current storage by keys
* @param {string|Array<string>} keys Keys to load * @param {Object} [options] Storage options.
* @param {Function} clb Callback function * @returns {Object} Loaded data.
* @example * @example
* storageManager.load(['item1', 'item2'], res => { * const data = await storageManager.load();
* // res -> {item1: value1, item2: value2} * editor.loadProjectData(data);
* });
* storageManager.load('item1', res => {
* // res -> {item1: value1}
* });
* */ * */
load(keys, clb) { async load(options = {}) {
const st = this.get(this.getCurrent()); const st = this.getCurrentStorage();
const keysF = []; const opts = { ...this.getCurrentOptons(), ...options };
let result = {}; const recoveryStorage = this.getRecoveryStorage();
let result;
if (recoveryStorage) {
const recoveryData = await this.__exec(recoveryStorage, this.getCurrentOptons(STORAGE_LOCAL));
if (!isEmpty(recoveryData)) {
try {
await this.__askRecovery();
result = recoveryData;
} catch (error) {}
}
}
if (!result) {
result = await this.__exec(st, opts);
}
if (typeof keys === 'string') keys = [keys]; return result || {};
this.onStart('load', keys); },
__askRecovery() {
const { em } = this;
const recovery = this.getRecovery();
return new Promise((res, rej) => {
if (isFunction(recovery)) {
recovery(res, rej, em?.getEditor());
} else {
confirm(em?.t('storageManager.recover')) ? res() : rej();
}
});
},
for (var i = 0, len = keys.length; i < len; i++) { getRecovery() {
keysF.push(c.id + keys[i]); return this.getConfig().recovery;
},
getRecoveryStorage() {
const recovery = this.getRecovery();
return recovery && this.getCurrent() === STORAGE_REMOTE && this.get(STORAGE_LOCAL);
},
async __exec(storage, opts, data) {
const ev = data ? 'store' : 'load';
const { onStore, onLoad } = this.getConfig();
let result;
this.onStart(ev, data);
if (!storage) {
return data || {};
} }
if (st) { try {
st.load( const editor = this.em?.getEditor();
keysF,
res => { if (data) {
result = this.__clearKeys(res); let toStore = (onStore && (await onStore(data, editor))) || data;
this.onAfter('load', result); toStore = (opts.onStore && (await opts.onStore(toStore, editor))) || toStore;
clb && clb(result); await storage.store(toStore, opts);
this.onEnd('load', result); result = data;
}, } else {
err => { result = await storage.load(opts);
clb && clb(result); result = this.__clearKeys(result);
this.onError('load', err); result = (opts.onLoad && (await opts.onLoad(result, editor))) || result;
} result = (onLoad && (await onLoad(result, editor))) || result;
); }
} else { this.onAfter(ev, result);
clb && clb(result); this.onEnd(ev, result);
} catch (error) {
this.onError(ev, error);
throw error;
} }
return result;
}, },
/**
* Restore key names
* @param {Object} data
* @returns {Object}
* @private
*/
__clearKeys(data = {}) { __clearKeys(data = {}) {
const config = this.getConfig();
const reg = new RegExp(`^${config.id}`);
const result = {}; const result = {};
const reg = new RegExp('^' + c.id + '');
for (let itemKey in data) { for (let itemKey in data) {
const itemKeyR = itemKey.replace(reg, ''); const itemKeyR = itemKey.replace(reg, '');
@ -305,22 +326,10 @@ export default () => {
return result; return result;
}, },
/** getCurrentOptons(type) {
* Load default storages const config = this.getConfig();
* @return {this} const current = type || this.getCurrent();
* @private return config.options[current] || {};
* */
loadDefaultProviders() {
for (var id in defaultStorages) this.add(id, defaultStorages[id]);
return this;
},
/**
* Get current storage
* @return {Storage}
* */
getCurrentStorage() {
return this.get(this.getCurrent());
}, },
/** /**
@ -328,6 +337,7 @@ export default () => {
* @private * @private
*/ */
onStart(ctx, data) { onStart(ctx, data) {
const { em } = this;
if (em) { if (em) {
em.trigger(eventStart); em.trigger(eventStart);
ctx && em.trigger(`${eventStart}:${ctx}`, data); ctx && em.trigger(`${eventStart}:${ctx}`, data);
@ -339,9 +349,11 @@ export default () => {
* @private * @private
*/ */
onAfter(ctx, data) { onAfter(ctx, data) {
const { em } = this;
if (em) { if (em) {
em.trigger(eventAfter); em.trigger(eventAfter);
ctx && em.trigger(`${eventAfter}:${ctx}`, data); em.trigger(`${eventAfter}:${ctx}`, data);
em.trigger(`storage:${ctx}`, data);
} }
}, },
@ -350,6 +362,7 @@ export default () => {
* @private * @private
*/ */
onEnd(ctx, data) { onEnd(ctx, data) {
const { em } = this;
if (em) { if (em) {
em.trigger(eventEnd); em.trigger(eventEnd);
ctx && em.trigger(`${eventEnd}:${ctx}`, data); ctx && em.trigger(`${eventEnd}:${ctx}`, data);
@ -361,6 +374,7 @@ export default () => {
* @private * @private
*/ */
onError(ctx, data) { onError(ctx, data) {
const { em } = this;
if (em) { if (em) {
em.trigger(eventError, data); em.trigger(eventError, data);
ctx && em.trigger(`${eventError}:${ctx}`, data); ctx && em.trigger(`${eventError}:${ctx}`, data);
@ -379,7 +393,8 @@ export default () => {
}, },
destroy() { destroy() {
[c, em, storages, defaultStorages].forEach(i => (i = {})); this.__destroy();
this.storages = {};
}, },
}; };
}; };

62
src/storage_manager/model/LocalStorage.js

@ -1,62 +1,28 @@
import { Model } from 'backbone';
import { hasWin } from 'utils/mixins'; import { hasWin } from 'utils/mixins';
export default Model.extend({ export default class LocalStorage {
defaults: { async store(data, opts = {}) {
checkLocal: true if (this.hasLocal(opts, true)) {
}, localStorage.setItem(opts.key, JSON.stringify(data));
/**
* @private
*/
store(data, clb = () => {}) {
if (this.hasLocal()) {
for (let key in data) localStorage.setItem(key, data[key]);
} }
}
clb && clb(); async load(opts = {}) {
}, let result = {};
/**
* @private
*/
load(keys, clb = () => {}) {
const result = {};
if (this.hasLocal()) { if (this.hasLocal(opts, true)) {
for (let i = 0, len = keys.length; i < len; i++) { result = JSON.parse(localStorage.getItem(opts.key) || '{}');
const value = localStorage.getItem(keys[i]);
if (value) result[keys[i]] = value;
}
} }
clb && clb(result);
return result; return result;
}, }
/**
* @private
*/
remove(keys) {
if (!this.hasLocal()) return;
for (let i = 0, len = keys.length; i < len; i++)
localStorage.removeItem(keys[i]);
},
/**
* Check storage environment
* @private
* */
hasLocal() {
const win = hasWin();
if (this.get('checkLocal') && (!win || !localStorage)) { hasLocal(opts = {}, thr) {
win && console.warn("Your browser doesn't support localStorage"); if (opts.checkLocal && (!hasWin() || !localStorage)) {
if (thr) throw new Error('localStorage not available');
return false; return false;
} }
return true; return true;
} }
}); }

166
src/storage_manager/model/RemoteStorage.js

@ -1,146 +1,66 @@
import Backbone from 'backbone';
import fetch from 'utils/fetch'; import fetch from 'utils/fetch';
import { isUndefined, isFunction } from 'underscore'; import { isUndefined, isFunction, isString } from 'underscore';
export default Backbone.Model.extend({ export default class RemoteStorage {
fetch, async store(data, opts = {}) {
await this.request(opts.urlStore, this.__props(opts, data), opts);
defaults: { }
urlStore: '',
urlLoad: '',
params: {},
beforeSend() {},
onComplete() {},
contentTypeJson: false,
credentials: 'include',
fetchOptions: ''
},
/**
* Triggered before the request is started
* @private
*/
onStart() {
const em = this.get('em');
const before = this.get('beforeSend');
before && before();
},
/**
* Triggered on request error
* @param {Object} err Error
* @param {Function} [clbErr] Error callback
* @private
*/
onError(err, clbErr) {
if (clbErr) {
clbErr(err);
} else {
const em = this.get('em');
console.error(err);
em && em.trigger('storage:error', err);
}
},
/**
* Triggered on request response
* @param {string} text Response text
* @private
*/
onResponse(text, clb) {
const em = this.get('em');
const complete = this.get('onComplete');
const typeJson = this.get('contentTypeJson');
const parsable = text && typeof text === 'string';
const res = typeJson && parsable ? JSON.parse(text) : text;
complete && complete(res);
clb && clb(res);
em && em.trigger('storage:response', res);
},
store(data, clb, clbErr) {
const body = {};
for (let key in data) {
body[key] = data[key];
}
this.request(this.get('urlStore'), { body }, clb, clbErr); async load(opts) {
}, return await this.request(opts.urlLoad, this.__props(opts), opts);
}
load(keys, clb, clbErr) { request(url, props = {}, opts = {}) {
this.request(this.get('urlLoad'), { method: 'get' }, clb, clbErr); return fetch(url, props)
}, .then(res => {
const result = res.text();
const isOk = ((res.status / 200) | 0) === 1;
return isOk ? result : result.then(Promise.reject);
})
.then(text => {
const parsable = text && isString(text);
return opts.contentTypeJson && parsable ? JSON.parse(text) : text;
});
}
/** __props(opts = {}, data) {
* Execute remote request const typeJson = opts.contentTypeJson;
* @param {string} url Url const headers = opts.headers || {};
* @param {Object} [opts={}] Options const fetchOpts = opts.fetchOptions || {};
* @param {Function} [clb=null] Callback
* @param {Function} [clbErr=null] Error callback
* @private
*/
request(url, opts = {}, clb = null, clbErr = null) {
const typeJson = this.get('contentTypeJson');
const headers = this.get('headers') || {};
const params = this.get('params');
const reqHead = 'X-Requested-With'; const reqHead = 'X-Requested-With';
const typeHead = 'Content-Type'; const typeHead = 'Content-Type';
const bodyObj = opts.body || {};
let fetchOptions;
let body; let body;
for (let param in params) {
bodyObj[param] = params[param];
}
if (isUndefined(headers[reqHead])) { if (isUndefined(headers[reqHead])) {
headers[reqHead] = 'XMLHttpRequest'; headers[reqHead] = 'XMLHttpRequest';
} }
// With `fetch`, have to send FormData without any 'Content-Type'
// https://stackoverflow.com/questions/39280438/fetch-missing-boundary-in-multipart-form-data-post
if (isUndefined(headers[typeHead]) && typeJson) { if (isUndefined(headers[typeHead]) && typeJson) {
headers[typeHead] = 'application/json; charset=utf-8'; headers[typeHead] = 'application/json; charset=utf-8';
} }
if (typeJson) { if (data) {
body = JSON.stringify(bodyObj); if (typeJson) {
} else { body = JSON.stringify(data);
body = new FormData(); } else {
body = new FormData();
for (let bodyKey in bodyObj) { for (let key in data) {
body.append(bodyKey, bodyObj[bodyKey]); body.append(key, data[key]);
}
} }
} }
fetchOptions = {
method: opts.method || 'post',
credentials: this.get('credentials'),
headers
};
// Body should only be included on POST method const result = {
if (fetchOptions.method === 'post') { method: body ? 'POST' : 'GET',
fetchOptions.body = body; credentials: opts.credentials,
} headers,
body,
const fetchOpts = this.get('fetchOptions') || {}; };
const addOpts = isFunction(fetchOpts)
? fetchOpts(fetchOptions)
: fetchOptions;
this.onStart(); return {
this.fetch(url, { ...result,
...fetchOptions, ...(isFunction(fetchOpts) ? fetchOpts(result) : fetchOpts),
...(addOpts || {}) };
})
.then(res =>
((res.status / 200) | 0) == 1
? res.text()
: res.text().then(text => Promise.reject(text))
)
.then(text => this.onResponse(text, clb))
.catch(err => this.onError(err, clbErr));
} }
}); }

21
src/utils/mixins.js

@ -180,6 +180,27 @@ export const escape = (str = '') => {
.replace(/`/g, '&#96;'); .replace(/`/g, '&#96;');
}; };
export const deepMerge = (...args) => {
const target = { ...args[0] };
for (let i = 1; i < args.length; i++) {
const source = { ...args[i] };
for (let key in source) {
const targValue = target[key];
const srcValue = source[key];
if (isObject(targValue) && isObject(srcValue)) {
target[key] = deepMerge(targValue, srcValue);
} else {
target[key] = srcValue;
}
}
}
return target;
};
/** /**
* Ensure to fetch the model from the input argument * Ensure to fetch the model from the input argument
* @param {HTMLElement|Component} el Component or HTML element * @param {HTMLElement|Component} el Component or HTML element

3
test/setup.js

@ -1,3 +1,4 @@
import 'regenerator-runtime/runtime';
import 'whatwg-fetch'; import 'whatwg-fetch';
import _ from 'underscore'; import _ from 'underscore';
import sinon from 'sinon'; import sinon from 'sinon';
@ -11,7 +12,7 @@ const localStorage = {
}, },
removeItem(key, value) { removeItem(key, value) {
delete this[key]; delete this[key];
} },
}; };
global._ = _; global._ = _;

50
test/specs/asset_manager/index.js

@ -1,21 +1,9 @@
import StorageManager from 'storage_manager';
import AssetManager from 'asset_manager'; import AssetManager from 'asset_manager';
describe('Asset Manager', () => { describe('Asset Manager', () => {
describe('Main', () => { describe('Main', () => {
var obj; let obj;
var imgObj; let imgObj;
var storage;
var storageId = 'testStorage';
var storageMock = {
store(data) {
storage = data;
},
load(keys) {
return storage;
}
};
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = '<div id="asset-c"></div>'; document.body.innerHTML = '<div id="asset-c"></div>';
@ -23,7 +11,7 @@ describe('Asset Manager', () => {
type: 'image', type: 'image',
src: 'path/to/image', src: 'path/to/image',
width: 101, width: 101,
height: 102 height: 102,
}; };
obj = new AssetManager(); obj = new AssetManager();
obj.init(); obj.init();
@ -81,37 +69,5 @@ describe('Asset Manager', () => {
obj.add(imgObj); obj.add(imgObj);
expect(obj.render()).toBeTruthy(); expect(obj.render()).toBeTruthy();
}); });
describe('With storage', () => {
var storageManager;
beforeEach(() => {
document.body.innerHTML = '<div id="asset-c"></div>';
storageManager = new StorageManager().init({
autoload: 0,
type: storageId
});
obj = new AssetManager().init({
stm: storageManager
});
storageManager.add(storageId, storageMock);
document.body.querySelector('#asset-c').appendChild(obj.render());
});
afterEach(() => {
storageManager = null;
});
test('Store and load data', () => {
obj.add(imgObj);
obj.store();
obj.remove(imgObj.src);
obj.load({ assets: storage['gjs-assets'] });
var asset = obj.get(imgObj.src);
expect(asset.get('width')).toEqual(imgObj.width);
expect(asset.get('height')).toEqual(imgObj.height);
expect(asset.get('type')).toEqual('image');
});
});
}); });
}); });

58
test/specs/css_composer/index.js

@ -14,14 +14,14 @@ describe('Css Composer', () => {
}, },
getCacheLoad() { getCacheLoad() {
return storagMock.load(); return storagMock.load();
} },
}; };
var setSmConfig = () => { var setSmConfig = () => {
config.stm = storagMock; config.stm = storagMock;
config.stm.getConfig = () => ({ config.stm.getConfig = () => ({
storeCss: 1, storeCss: 1,
storeStyles: 1 storeStyles: 1,
}); });
}; };
var setEm = () => { var setEm = () => {
@ -49,19 +49,13 @@ describe('Css Composer', () => {
}); });
test('storageKey returns array', () => { test('storageKey returns array', () => {
expect(obj.storageKey() instanceof Array).toEqual(true); expect(obj.storageKey).toEqual('styles');
});
test('storageKey returns correct composition', () => {
setSmConfig();
expect(obj.storageKey()).toEqual(['css', 'styles']);
}); });
test('Store data', () => { test('Store data', () => {
setSmConfig(); setSmConfig();
setEm(); setEm();
var expected = { css: 'testCss', styles: '[]' }; expect(JSON.parse(JSON.stringify(obj.store()))).toEqual({ styles: [] });
expect(obj.store(1)).toEqual(expected);
}); });
test('Rules are empty', () => { test('Rules are empty', () => {
@ -87,14 +81,7 @@ describe('Css Composer', () => {
var sel = new obj.Selectors([{ name: 'test1' }]); var sel = new obj.Selectors([{ name: 'test1' }]);
var rule = obj.add(sel.models); var rule = obj.add(sel.models);
expect(obj.getAll().length).toEqual(1); expect(obj.getAll().length).toEqual(1);
expect( expect(obj.getAll().at(0).get('selectors').at(0).get('name')).toEqual('test1');
obj
.getAll()
.at(0)
.get('selectors')
.at(0)
.get('name')
).toEqual('test1');
}); });
test('Returns correct rule with the same selector', () => { test('Returns correct rule with the same selector', () => {
@ -175,12 +162,8 @@ describe('Css Composer', () => {
const rule = obj.getIdRule(name); const rule = obj.getIdRule(name);
expect(rule.selectorsToString()).toEqual(`#${name}`); expect(rule.selectorsToString()).toEqual(`#${name}`);
expect(rule.styleToString()).toEqual(`color:red;`); expect(rule.styleToString()).toEqual(`color:red;`);
expect(rule.styleToString({ important: 1 })).toEqual( expect(rule.styleToString({ important: 1 })).toEqual(`color:red !important;`);
`color:red !important;` expect(rule.styleToString({ important: ['color'] })).toEqual(`color:red !important;`);
);
expect(rule.styleToString({ important: ['color'] })).toEqual(
`color:red !important;`
);
}); });
test('Create a rule with id selector and state by using setIdRule()', () => { test('Create a rule with id selector and state by using setIdRule()', () => {
@ -273,26 +256,24 @@ describe('Css Composer', () => {
style: { color: 'blue' }, style: { color: 'blue' },
opts: { opts: {
atRuleType: 'media', atRuleType: 'media',
atRuleParams: 'screen and (min-width: 480px)' atRuleParams: 'screen and (min-width: 480px)',
} },
}, },
{ {
selector: '.class1:hover', selector: '.class1:hover',
style: { color: 'red' }, style: { color: 'red' },
opts: { opts: {
atRuleType: 'media', atRuleType: 'media',
atRuleParams: 'screen and (min-width: 480px)' atRuleParams: 'screen and (min-width: 480px)',
} },
} },
]; ];
toTest.forEach(test => { toTest.forEach(test => {
const { selector, style, opts } = test; const { selector, style, opts } = test;
const result = obj.setRule(selector, style, opts); const result = obj.setRule(selector, style, opts);
expect(obj.getAll().length).toEqual(1); expect(obj.getAll().length).toEqual(1);
const rule = obj.getRule(selector, opts); const rule = obj.getRule(selector, opts);
expect(rule.getAtRule()).toEqual( expect(rule.getAtRule()).toEqual(`@${opts.atRuleType} ${opts.atRuleParams}`);
`@${opts.atRuleType} ${opts.atRuleParams}`
);
expect(rule.selectorsToString()).toEqual(selector); expect(rule.selectorsToString()).toEqual(selector);
expect(rule.getStyle()).toEqual(style); expect(rule.getStyle()).toEqual(style);
}); });
@ -308,15 +289,14 @@ describe('Css Composer', () => {
{ {
selector: '.class4, .class1 .class2', selector: '.class4, .class1 .class2',
style: { color: 'blue' }, style: { color: 'blue' },
opt: { atRuleType: 'media', atRuleParams: '(min-width: 480px)' } opt: { atRuleType: 'media', atRuleParams: '(min-width: 480px)' },
} },
]; ];
toTest.forEach(test => { toTest.forEach(test => {
const { selector, style, opt = {} } = test; const { selector, style, opt = {} } = test;
obj.setRule(selector, style, opt); obj.setRule(selector, style, opt);
const rule = obj.getRule(selector, opt); const rule = obj.getRule(selector, opt);
const atRule = `${opt.atRuleType || ''} ${opt.atRuleParams || const atRule = `${opt.atRuleType || ''} ${opt.atRuleParams || ''}`.trim();
''}`.trim();
expect(rule.getAtRule()).toEqual(atRule ? `@${atRule}` : ''); expect(rule.getAtRule()).toEqual(atRule ? `@${atRule}` : '');
expect(rule.selectorsToString()).toEqual(selector); expect(rule.selectorsToString()).toEqual(selector);
expect(rule.getStyle()).toEqual(style); expect(rule.getStyle()).toEqual(style);
@ -356,7 +336,7 @@ describe('Css Composer', () => {
`.test-rule{color:red;}`, `.test-rule{color:red;}`,
`.test-rule:hover{color:blue;}`, `.test-rule:hover{color:blue;}`,
`@media (max-width: 992px){.test-rule{color:darkred;}}`, `@media (max-width: 992px){.test-rule{color:darkred;}}`,
`@media (max-width: 992px){.test-rule:hover{color:darkblue;}}` `@media (max-width: 992px){.test-rule:hover{color:darkblue;}}`,
]; ];
const cssString = cssRules.join(''); const cssString = cssRules.join('');
obj.addCollection(cssString); obj.addCollection(cssString);
@ -388,9 +368,7 @@ describe('Css Composer', () => {
expect(obj.get('.test-rule2', 'hover')).toBe(rule2); expect(obj.get('.test-rule2', 'hover')).toBe(rule2);
expect(rule3.get('mediaText')).toBe('(max-width: 992px)'); expect(rule3.get('mediaText')).toBe('(max-width: 992px)');
expect(obj.get('.test-rule3', null, '(max-width: 992px)')).toBe(rule3); expect(obj.get('.test-rule3', null, '(max-width: 992px)')).toBe(rule3);
expect(obj.get('.test-rule4', 'hover', '(max-width: 992px)')).toBe( expect(obj.get('.test-rule4', 'hover', '(max-width: 992px)')).toBe(rule4);
rule4
);
}); });
}); });
}); });

29
test/specs/dom_components/index.js

@ -65,35 +65,6 @@ describe('DOM Components', () => {
expect(DomComponents).toBeTruthy(); expect(DomComponents).toBeTruthy();
}); });
test('storageKey returns array', () => {
expect(obj.storageKey() instanceof Array).toEqual(true);
});
test('storageKey returns correct composition', () => {
config.stm = {
getConfig() {
return {
storeHtml: 1,
storeComponents: 1,
};
},
};
expect(obj.storageKey()).toEqual(['html', 'components']);
});
test('Store data', () => {
setSmConfig();
(em.getHtml = () => {
return 'testHtml';
}),
(obj = em.get('DomComponents').init(config));
var expected = {
html: 'testHtml',
components: JSON.stringify(obj.getWrapper()),
};
expect(obj.store(1)).toEqual(expected);
});
test.skip('Store and load data', () => { test.skip('Store and load data', () => {
setSmConfig(); setSmConfig();
setEm(); setEm();

7
test/specs/grapesjs/index.js

@ -12,12 +12,11 @@ describe('GrapesJS', () => {
var storage; var storage;
var storageId = 'testStorage'; var storageId = 'testStorage';
var storageMock = { var storageMock = {
store(data, clb) { store(data) {
storage = data; storage = data;
clb();
}, },
load(keys, clb) { load() {
return clb(storage); return storage;
}, },
}; };

7
test/specs/storage_manager/index.js

@ -48,12 +48,13 @@ describe('Storage Manager', () => {
expect(obj.getCurrent()).toEqual('remote'); expect(obj.getCurrent()).toEqual('remote');
}); });
test('Store do not execute if empty', () => { test('Store is executed', async () => {
expect(obj.store({ item: 'test' })).toBeUndefined(); const spy = jest.spyOn(obj, '__exec');
await obj.store({ item: 'test' });
expect(spy).toBeCalledTimes(1);
}); });
test('Load default storages ', () => { test('Load default storages ', () => {
obj.loadDefaultProviders();
expect(obj.get('local')).toBeTruthy(); expect(obj.get('local')).toBeTruthy();
expect(obj.get('remote')).toBeTruthy(); expect(obj.get('remote')).toBeTruthy();
expect(obj.get('test')).toBeFalsy(); expect(obj.get('test')).toBeFalsy();

164
test/specs/storage_manager/model/Models.js

@ -2,11 +2,10 @@ import LocalStorage from 'storage_manager/model/LocalStorage';
import RemoteStorage from 'storage_manager/model/RemoteStorage'; import RemoteStorage from 'storage_manager/model/RemoteStorage';
describe('LocalStorage', () => { describe('LocalStorage', () => {
var obj; let obj;
var itemName = 'testItem'; let data = {
var data = {
item1: 'value1', item1: 'value1',
item2: 'value2' item2: 'value2',
}; };
beforeEach(() => { beforeEach(() => {
@ -17,141 +16,84 @@ describe('LocalStorage', () => {
obj = null; obj = null;
}); });
test('Store and load items', () => { test('Store and load items', async () => {
obj.store(data); await obj.store(data);
var result = obj.load(['item1', 'item2']); const result = await obj.load();
expect(result).toEqual(data); expect(result).toEqual(data);
}); });
test('Store, update and load items', () => {
obj.store(data);
obj.store({ item3: 'value3' });
obj.store({ item2: 'value22' });
var result = obj.load(['item1', 'item2', 'item3']);
expect(result).toEqual({
item1: 'value1',
item2: 'value22',
item3: 'value3'
});
});
test('Remove items', () => {
var items = ['item1', 'item2', 'item3'];
obj.store(data);
obj.remove(items);
expect(obj.load(items)).toEqual({});
});
}); });
describe('RemoteStorage', () => { describe('RemoteStorage', () => {
var obj; let obj;
var itemName = 'testItem'; let data;
var endpointStore = 'testStoreEndpoint'; let defaultOpts = {
var endpointLoad = 'testLoadEndpoint'; urlStore: '/store',
var params = { test: 'testValue' }; urlLoad: '/load',
var storageOptions; credentials: true,
var data; headers: { 'X-Requested-With': 'XMLHttpRequest' },
var mockResponse = (body = {}) => { };
let mockResponse = (body = {}) => {
return new window.Response(JSON.stringify(body), { return new window.Response(JSON.stringify(body), {
status: 200, status: 200,
headers: { 'Content-type': 'application/json' } headers: { 'Content-type': 'application/json' },
}); });
}; };
beforeEach(() => { beforeEach(() => {
data = { data = {
item1: 'value1', item1: 'value1',
item2: 'value2' item2: 'value2',
};
storageOptions = {
urlStore: endpointStore,
urlLoad: endpointLoad,
params
}; };
obj = new RemoteStorage(storageOptions); obj = new RemoteStorage();
sinon obj.request = jest.fn(() => Promise.resolve(mockResponse({ data: 1 })));
.stub(obj, 'fetch')
.returns(Promise.resolve(mockResponse({ data: 1 })));
}); });
afterEach(() => { afterEach(() => {
obj.fetch.restore(); obj.request.mockRestore();
obj = null; obj = null;
}); });
test('Store data', () => { test('Store data', async () => {
obj.store(data); await obj.store(data, defaultOpts);
const callResult = obj.fetch; const { calls } = obj.request.mock;
expect(callResult.called).toEqual(true); expect(calls.length).toBe(1);
expect(callResult.firstCall.args[0]).toEqual(endpointStore); expect(calls[0][0]).toBe(defaultOpts.urlStore);
}); // expect(obj.request).toBeCalledWith(opts.urlStore, defaultOpts, opts);
const { body, ...args } = calls[0][1];
test('Load data', () => { expect(args).toEqual({
obj.load(['item1', 'item2']); method: 'POST',
const callResult = obj.fetch; headers: defaultOpts.headers,
expect(callResult.called).toEqual(true); credentials: defaultOpts.credentials,
expect(callResult.firstCall.args[0]).toEqual(endpointLoad);
});
test("Load data with credentials option as 'include' by default", () => {
obj.load(['item1', 'item2']);
const callResult = obj.fetch;
expect(callResult.called).toEqual(true);
expect(callResult.firstCall.args[1]).toMatchObject({
credentials: 'include'
}); });
}); });
test("Store data with credentials option as 'include' by default", () => { test('Load data', async () => {
obj.store(data); await obj.load(defaultOpts);
const callResult = obj.fetch; const { calls } = obj.request.mock;
expect(callResult.called).toEqual(true); expect(obj.request).toBeCalledTimes(1);
expect(callResult.firstCall.args[1]).toMatchObject({ expect(calls[0][0]).toBe(defaultOpts.urlLoad);
credentials: 'include' expect(calls[0][1]).toEqual({
method: 'GET',
body: undefined,
headers: defaultOpts.headers,
credentials: defaultOpts.credentials,
}); });
}); });
test('Store data with credentials option as false ', () => { test('Load data with custom fetch options', async () => {
obj = new RemoteStorage({ ...storageOptions, credentials: false }); const customOpts = { customOpt: 'customValue' };
sinon await obj.load({
.stub(obj, 'fetch') ...defaultOpts,
.returns(Promise.resolve(mockResponse({ data: 1 }))); fetchOptions: () => customOpts,
obj.store(data);
const callResult = obj.fetch;
expect(callResult.called).toEqual(true);
expect(callResult.firstCall.args[1]).toMatchObject({
credentials: false
});
});
test('Load data with credentials option as false', () => {
obj = new RemoteStorage({ ...storageOptions, credentials: false });
sinon
.stub(obj, 'fetch')
.returns(Promise.resolve(mockResponse({ data: 1 })));
obj.load(['item1', 'item2']);
const callResult = obj.fetch;
expect(callResult.called).toEqual(true);
expect(callResult.firstCall.args[1]).toMatchObject({
credentials: false
}); });
});
test('Load data with custom fetch options as function', () => { expect(obj.request).toBeCalledTimes(1);
const customOpts = { customOpt: 'customValue' }; expect(obj.request.mock.calls[0][1]).toEqual({
obj = new RemoteStorage({ method: 'GET',
...storageOptions, body: undefined,
fetchOptions: () => { headers: defaultOpts.headers,
return customOpts; credentials: defaultOpts.credentials,
} ...customOpts,
}); });
sinon
.stub(obj, 'fetch')
.returns(Promise.resolve(mockResponse({ data: 1 })));
obj.load(['item1', 'item2']);
const callResult = obj.fetch;
expect(callResult.called).toEqual(true);
expect(callResult.firstCall.args[1]).toMatchObject(customOpts);
}); });
}); });

Loading…
Cancel
Save