diff --git a/docs/modules/Storage.md b/docs/modules/Storage.md index 0fc5fe0fa..40d948707 100644 --- a/docs/modules/Storage.md +++ b/docs/modules/Storage.md @@ -4,70 +4,183 @@ title: 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 -This guide requires GrapesJS v0.14.15 or higher +This guide requires GrapesJS v0.19.* or higher ::: [[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 const editor = grapesjs.init({ ... // Default configurations storageManager: { - id: 'gjs-', // Prefix identifier that will be used on parameters - type: 'local', // Type of the storage - autosave: true, // Store data automatically - autoload: true, // Autoload stored data on init - stepsBeforeSave: 1, // If autosave enabled, indicates how many changes are necessary before store method is triggered + type: 'local', // Storage type. Available: local | remote + autosave: true, // Store data automatically + 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 + // ... + // 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 -... -storageManager: { type: null }, +// Get current project data +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. +::: + + + + + + +## 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 -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": "
Initial content
"}]} } ]}' > 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 +const projectID = 1; +const projectEndpoint = `http://localhost:3000/projects/${projectID}`; + const editor = grapesjs.init({ ... storageManager: { type: 'remote', stepsBeforeSave: 3, - urlStore: 'http://endpoint/store-template/some-id-123', - urlLoad: 'http://endpoint/load-template/some-id-123', - // For custom parameters/headers on requests - params: { _some_token: '....' }, - headers: { Authorization: 'Basic ...' }, + options: { + remote: { + urlLoad: projectEndpoint, + urlStore: projectEndpoint, + // 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. +::: + +
+ +### 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 + + -## 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 -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 -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)). -[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. +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). ```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({ ... - storageManager: { type: 'simple-storage' }, -}); - -// Here our `simple-storage` implementation -const SimpleStorage = {}; - -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]; + plugins: [sessionStoragePlugin], + storageManager: { + type: 'session', + options: { + session: { key: 'myKey' } } - // Might be called inside some async method - clb(); - } + }, }); ``` @@ -211,90 +258,191 @@ editor.StorageManager.add('simple-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: -```js -const sm = editor.StorageManager; +Among other needs, you might need to use existing storages to combine them in a more complex use case. +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: -sm.add('local-remote', { - store(data, clb, clbErr) { - const remote = sm.get('remote'); - const local = sm.get('local'); - // ... - remote.store(data, clb, err => { - // eg. some error on remote side, store it locally - local.store(data, clb, clbError); - }); +```js +const { Storage } = editor; + +Storage.add('remote-local', { + async store(data) { + const remoteStorage = Storage.get('remote'); + + 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 -editor.StorageManager.add('local', { - // New logic for the local storage - load() { - // ... +editor.Storage.add('remote', { + async load() { + return await axios.get(`projects/${projectId}`); }, - store() { - // ... + async store(data) { + return await axios.patch(`projects/${projectId}`, { data }); }, }); ``` -### Examples + -## 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 -editor.on('storage:error', (err) => { - alert(`Error: ${err}`); -}); +// Get the data before initializing the editor (eg. printed on server-side). +const projectData = {...}; +// ... +grapesjs.init({ + // ... + // If projectData is not defined we might want to load some initial data for the project. + projectData: projectData || { + pages: [ + { + component: ` +
Initial content
+ + ` + } + ] + }, + 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 -editor.on('storage:start:store', (objectToStore) => { - if (needToAddExtraParam) { - objectToStore.customHtml = `
...${editor.getHtml()}...
`; - } -}); +grapesjs.init({ + // ... + 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 -editor.on('storage:end:load', (resultObject) => { - if (resultObject.hasSomeKey) { - // do stuff - } -}); + +### Inline project data + +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. + +```html +
+ + +
+ +
+ + ``` +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]: [grapesjs-firestore]: [localStorage API]: -[IndexedDB]: +[IndexedDB API]: +[json-server]: diff --git a/index.d.ts b/index.d.ts index 7e2799603..700332f00 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3588,43 +3588,28 @@ declare module grapesjs { * Add new storage * @example * storageManager.add('local2', { - * load: function(keys, clb, clbErr) { - * var res = {}; - * for (var i = 0, len = keys.length; i < len; i++){ - * var v = localStorage.getItem(keys[i]); - * if(v) res[keys[i]] = v; - * } - * clb(res); // might be called inside some async method - * // In case of errors... - * // clbErr('Went something wrong'); + * async load(storageOptions) { + * // ... + * }, + * async store(data, storageOptions) { + * // ... * }, - * 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 storage - Storage wrapper * @param storage.load - Load method * @param storage.store - Store method */ - add( - id: string, - storage: { - load: (...params: any[]) => any; - store: (...params: any[]) => any; - } - ): this; + add(id: string, storage: IStorage): this; /** * Returns storage by id * @param id - Storage ID */ - get(id: string): any | null; + get(id: string): IStorage | null; /** * Returns all storages */ - getStorages(): any[]; + getStorages(): Record; /** * Returns current storage type */ @@ -3635,30 +3620,41 @@ declare module grapesjs { */ 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 - * storageManager.store({item1: value1, item2: value2}); - * @param data - Data in key-value format, eg. {item1: value1, item2: value2} - * @param clb - Callback function + * const data = editor.getProjectData(); + * await storageManager.store(data); */ - store(data: any, clb: (...params: any[]) => any): any | null; + store(data: ProjectData, options: StorageOptions): Promise; /** * Load resource from the current storage by keys + * @param options Storage options. + * @returns Loaded data. * @example - * storageManager.load(['item1', 'item2'], res => { - * // res -> {item1: value1, item2: value2} - * }); - * storageManager.load('item1', res => { - * // res -> {item1: value1} - * }); - * @param keys - Keys to load - * @param clb - Callback function - */ - load(keys: string | string[], clb: (...params: any[]) => any): void; + * const data = await storageManager.load(); + * editor.loadProjectData(data); + * */ + load(options: StorageOptions): Promise; /** * Get current storage */ - getCurrentStorage(): Storage; + getCurrentStorage(): IStorage; + } + + interface ProjectData { + + } + + interface StorageOptions { + + } + + interface IStorage { + load: (options: StorageOptions) => Promise; + store: (data: ProjectData, options: StorageOptions) => Promise; } /** diff --git a/package.json b/package.json index b0085108c..fbd30eeba 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "grapesjs", "description": "Free and Open Source Web Builder Framework", - "version": "0.18.4", + "version": "0.19.0", "author": "Artur Arseniev", "license": "BSD-3-Clause", "homepage": "http://grapesjs.com", @@ -29,6 +29,7 @@ "documentation": "^13.2.5", "eslint": "^7.32.0", "grapesjs-cli": "^3.0.0", + "html-entities": "^1.4.0", "husky": "^2.7.0", "jest": "^24.9.0", "lint-staged": "^8.2.1", diff --git a/src/asset_manager/index.js b/src/asset_manager/index.js index bd0c939ef..756402ffe 100644 --- a/src/asset_manager/index.js +++ b/src/asset_manager/index.js @@ -280,47 +280,12 @@ export default () => { return this.__remove(asset, opts); }, - /** - * Store assets data to the selected storage - * @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; + store() { + return this.getProjectData(); }, - /** - * Load data from the passed object. - * 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; + load(data) { + return this.loadProjectData(data); }, /** diff --git a/src/canvas/model/Frame.js b/src/canvas/model/Frame.js index 65895c19a..e0f27779c 100644 --- a/src/canvas/model/Frame.js +++ b/src/canvas/model/Frame.js @@ -26,7 +26,7 @@ export default class Frame extends Model { component: '', styles: '', _undo: true, - _undoexc: ['changesCount'] + _undoexc: ['changesCount'], }; } @@ -125,12 +125,7 @@ export default class Frame extends Model { getHeadByAttr(attr, value, tag) { const head = this.getHead(); - return head.filter( - item => - item.attributes && - item.attributes[attr] == value && - (!tag || tag === item.tag) - )[0]; + return head.filter(item => item.attributes && item.attributes[attr] == value && (!tag || tag === item.tag))[0]; } removeHeadByAttr(attr, value, tag) { @@ -151,8 +146,8 @@ export default class Frame extends Model { tag, attributes: { href, - rel: 'stylesheet' - } + rel: 'stylesheet', + }, }); } @@ -165,7 +160,7 @@ export default class Frame extends Model { !this.getHeadByAttr('src', src, tag) && this.addHeadItem({ tag, - attributes: { src } + attributes: { src }, }); } @@ -184,17 +179,8 @@ export default class Frame extends Model { toJSON(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'); - 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; delete obj.styles; delete obj.changesCount; diff --git a/src/common/module.js b/src/common/module.js index 8794a0542..1594b8de9 100644 --- a/src/common/module.js +++ b/src/common/module.js @@ -1,11 +1,51 @@ import { isString, isElement } from 'underscore'; -import { createId } from 'utils/mixins'; +import { createId, deepMerge, isDef } from 'utils/mixins'; export default { 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) { const res = this.config || {}; return name ? res[name] : res; @@ -23,10 +63,7 @@ export default { }, __initConfig(def = {}, conf = {}) { - this.config = { - ...def, - ...conf, - }; + this.config = deepMerge(def, conf); this.em = this.config.em; this.cls = []; }, @@ -82,8 +119,8 @@ export default { __onAllEvent() {}, - __logWarn(str) { - this.em.logWarning(`[${this.name}]: ${str}`); + __logWarn(str, opts) { + this.em.logWarning(`[${this.name}]: ${str}`, opts); }, _createId(len = 16) { diff --git a/src/css_composer/index.js b/src/css_composer/index.js index 939c7ae2e..eb375c12e 100644 --- a/src/css_composer/index.js +++ b/src/css_composer/index.js @@ -35,6 +35,7 @@ import CssRules from './model/CssRules'; import CssRulesView from './view/CssRulesView'; import Selectors from 'selector_manager/model/Selectors'; import Selector from 'selector_manager/model/Selector'; +import Module from 'common/module'; export default () => { let em; @@ -42,6 +43,8 @@ export default () => { var rules, rulesView; return { + ...Module, + Selectors, /** @@ -51,23 +54,12 @@ export default () => { */ name: 'CssComposer', + storageKey: 'styles', + getConfig() { 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 * @param {Object} config Configurations @@ -108,55 +100,12 @@ export default () => { um && um.add(this.getAll()); }, - /** - * Load data from the passed object, if the object is empty will try to fetch them - * 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; + store() { + return this.getProjectData(); }, - /** - * Store data to the selected storage - * @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; + load(data) { + return this.loadProjectData(data); }, /** diff --git a/src/css_composer/model/CssRule.js b/src/css_composer/model/CssRule.js index 72301873f..f90a515c0 100644 --- a/src/css_composer/model/CssRule.js +++ b/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.style)) delete obj.style; } diff --git a/src/dom_components/config/config.js b/src/dom_components/config/config.js index cc895ee7c..b882cb42b 100644 --- a/src/dom_components/config/config.js +++ b/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) 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. * It might be useful to transform custom objects (es. some framework specific JSX) to GrapesJS component one. @@ -53,6 +46,6 @@ export default { 'param', 'source', 'track', - 'wbr' - ] + 'wbr', + ], }; diff --git a/src/dom_components/index.js b/src/dom_components/index.js index d508b20dc..7674e8dd2 100644 --- a/src/dom_components/index.js +++ b/src/dom_components/index.js @@ -96,6 +96,7 @@ import ComponentTextView from './view/ComponentTextView'; import ComponentWrapper from './model/ComponentWrapper'; import ComponentFrame from './model/ComponentFrame'; import ComponentFrameView from './view/ComponentFrameView'; +import Module from 'common/module'; export default () => { var c = {}; @@ -207,6 +208,8 @@ export default () => { ]; return { + ...Module, + Component, Components, @@ -224,6 +227,8 @@ export default () => { */ name: 'DomComponents', + storageKey: 'components', + /** * Returns config * @return {Object} Config object @@ -233,19 +238,6 @@ export default () => { 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 * inside 'domComponents' field @@ -280,97 +272,26 @@ export default () => { em.listenTo(selected, 'remove', (sel, c, opts) => this.selectRemove(selected.getComponent(sel), opts)); } - if (em.get('hasPages')) { - c.components = ''; - } - return this; }, - /** - * On load callback - * @private - */ - onLoad() { - c.components && this.setComponents(c.components, { silent: 1 }); - }, - - /** - * Load components from the passed object, if the object is empty will try to fetch them - * 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); + load(data) { + return this.loadProjectData(data, { + onResult: result => { + const wrapper = this.getWrapper(); + if (isArray(result)) { + result.length && wrapper.components(result); + } else { + const { components = [], ...rest } = result; + wrapper.set(rest); + wrapper.components(components); } - } - } 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 components on the selected storage - * @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; + store() { + return {}; }, /** diff --git a/src/dom_components/model/Component.js b/src/dom_components/model/Component.js index eb6853247..316f81648 100644 --- a/src/dom_components/model/Component.js +++ b/src/dom_components/model/Component.js @@ -1677,7 +1677,11 @@ export default class Component extends Model.extend(Styleable) { const coll = this.collection; const remove = () => { 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 }; [this, em].map(i => i.trigger('component:remove:before', this, remove, rmOpts)); diff --git a/src/editor/config/config.js b/src/editor/config/config.js index 538b77bee..926df187c 100644 --- a/src/editor/config/config.js +++ b/src/editor/config/config.js @@ -2,15 +2,29 @@ export default { // Style prefix stylePrefix: 'gjs-', - // HTML string or object of components + /** + * HTML string or object of components + * @deprecated Rely on `projectData` option + */ components: '', - // CSS string or object of rules + /** + * CSS string or object of rules + * @deprecated Rely on `projectData` option + */ style: '', - // If true, will fetch HTML and CSS from selected container + /** + * If true, will fetch HTML and CSS from selected container + * @deprecated + */ fromElement: 0, + /** + * Initial project data + */ + projectData: null, + // Show an alert before unload the page with unsaved changes noticeOnUnload: true, diff --git a/src/editor/index.js b/src/editor/index.js index d5bd7f393..e86d896ba 100644 --- a/src/editor/index.js +++ b/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; }, @@ -430,64 +421,53 @@ export default (config = {}, opts = {}) => { }, /** - * Store data to the current storage - * @param {Function} clb Callback function - * @return {Object} Stored data + * Store data to the current storage. + * This will reset the counter of changes (`editor.getDirtyCount()`). + * @param {Object} [options] Storage options. + * @returns {Object} Stored data. + * @example + * const storedData = await editor.store(); */ - store(clb) { - return em.store(clb); + async store(options) { + return await em.store(options); }, /** - * Get the JSON project data, which could be stored and loaded back with `editor.loadProject(json)` - * @returns {Object} + * Load data from the current storage. + * @param {Object} [options] Storage options. + * @returns {Object} Loaded data. * @example - * @private - * console.log(editor.getProject()); - * // { pages: [...], styles: [...], ... } + * const data = await editor.load(); */ - getProject() { - return em.storeData(); + async load(options) { + 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} * @example - * console.log(editor.storeData()); + * console.log(editor.getProjectData()); * // { pages: [...], styles: [...], ... } */ - storeData() { + getProjectData() { 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 * @param {Object} data Project to load * @example - * @private - * editor.loadProject({ pages: [...], styles: [...], ... }) + * editor.loadProjectData({ pages: [...], styles: [...], ... }) */ - loadProject(data) { + loadProjectData(data) { return em.loadData(data); }, - /** - * Load data from the JSON data object - * @param {Object} data Data to load - * @return {Object} Loaded object - * @example - * editor.loadData({ pages: [...], styles: [...], ... }) - */ + storeData() { + return em.storeData(); + }, + loadData(data) { return em.loadData(data); }, @@ -510,6 +490,13 @@ export default (config = {}, opts = {}) => { return em.getDirtyCount(); }, + /** + * Reset the counter of changes. + */ + clearDirtyCount() { + return em.clearDirtyCount(); + }, + /** * Update editor dimension offsets * diff --git a/src/editor/model/Editor.js b/src/editor/model/Editor.js index 7de4ce250..35275aa18 100644 --- a/src/editor/model/Editor.js +++ b/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 $ from 'utils/cash-dom'; import Extender from 'utils/extender'; @@ -66,21 +66,23 @@ export default class EditorModel extends Model { }; } - initialize(c = {}) { - this.config = c; - this.set('Config', c); + initialize(conf = {}) { + this.config = conf; + const { config } = this; + this.set('Config', config); this.set('modules', []); this.set('toLoad', []); this.set('storables', []); this.set('selected', new Selected()); - this.set('dmode', c.dragMode); - this.set('hasPages', !!c.pageManager); - const el = c.el; - const log = c.log; + this.set('dmode', config.dragMode); + const { el, log } = config; const toLog = log === true ? keys(logs) : isArray(log) ? log : []; bindAll(this, 'initBaseColorPicker'); - if (el && c.fromElement) this.config.components = el.innerHTML; + if (el && config.fromElement) { + config.components = el.innerHTML; + } + this.attrsOrig = el ? toArray(el.attributes).reduce((res, next) => { 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 deps.forEach(name => this.loadModule(name)); 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 - * @param {Function} clb * @private */ - loadOnStart(clb = null) { - const sm = this.get('StorageManager'); - + loadOnStart() { // In `onLoad`, the module will try to load the data from its configurations. this.get('toLoad').forEach(mdl => mdl.onLoad()); @@ -147,14 +151,23 @@ export default class EditorModel extends Model { const modules = this.get('modules'); modules.forEach(mdl => mdl.postLoad && mdl.postLoad(this)); this.set('readyLoad', 1); - clb && clb(); }; - if (sm && sm.canAutoload()) { - this.load(postLoad); - } else { - setTimeout(postLoad); - } + // Defer for storage load events. + setTimeout(async () => { + const projectData = this.getConfig('projectData'); + + if (projectData) { + this.loadData(projectData); + } else if (this.get('StorageManager').canAutoload()) { + try { + await this.load(); + } catch (error) { + this.logError(error); + } + } + postLoad(); + }); // Create shallow editor. // Here we can create components/styles without altering/triggering the main EditorModel @@ -175,7 +188,7 @@ export default class EditorModel extends Model { */ updateChanges() { const stm = this.get('StorageManager'); - const changes = this.get('changesCount'); + const changes = this.getDirtyCount(); updateItr && clearTimeout(updateItr); updateItr = setTimeout(() => this.trigger('update')); @@ -184,7 +197,7 @@ export default class EditorModel extends Model { } 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 cfgParent = !isUndefined(config[name]) ? config[name] : config[Mod.name]; const cfg = cfgParent === true ? {} : cfgParent || {}; - const sm = this.get('StorageManager'); cfg.pStylePrefix = config.pStylePrefix || ''; if (!isUndefined(cfgParent) && !cfgParent) { cfg._disable = 1; } - if (Mod.storageKey && Mod.store && Mod.load && sm) { - cfg.stm = sm; - // DomComponents should be load before CSS Composer - const mth = name == 'domComponents' ? 'unshift' : 'push'; + if (Mod.storageKey && Mod.store && Mod.load) { + // Components should be loaded before CSS due to reset + const mth = ['domComponents', 'pageManager'].indexOf(name) >= 0 ? 'unshift' : 'push'; this.get('storables')[mth](Mod); } @@ -259,7 +270,7 @@ export default class EditorModel extends Model { timedInterval && clearTimeout(timedInterval); timedInterval = setTimeout(() => { - const curr = this.get('changesCount') || 0; + const curr = this.getDirtyCount() || 0; const { unset, ...opts } = opt; this.set('changesCount', curr + 1, opts); }, 0); @@ -606,23 +617,24 @@ export default class EditorModel extends Model { } /** - * Store data to the current storage - * @param {Function} clb Callback function - * @return {Object} Stored data + * Store data to the current storage. * @private */ - store(clb) { - const sm = this.get('StorageManager'); - if (!sm) return; - - const store = this.storeData(); - sm.store(store, res => { - clb && clb(res, store); - this.set('changesCount', 0); - this.trigger('storage:store', store); - }); + async store(options) { + const data = this.storeData(); + await this.get('StorageManager').store(data, options); + this.clearDirtyCount(); + return data; + } - 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() { @@ -634,59 +646,12 @@ export default class EditorModel extends Model { this.get('storables').forEach(m => { result = { ...result, ...m.store(1) }; }); - return 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); - }); + return JSON.parse(JSON.stringify(result)); } loadData(data = {}) { - const sm = this.get('StorageManager'); - const result = sm.__clearKeys(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)); - }); + this.get('storables').forEach(module => module.load(data)); + return data; } /** @@ -793,6 +758,10 @@ export default class EditorModel extends Model { return this.get('changesCount'); } + clearDirtyCount() { + return this.set('changesCount', 0); + } + getZoomDecimal() { return this.get('Canvas').getZoomDecimal(); } diff --git a/src/editor/view/EditorView.js b/src/editor/view/EditorView.js index a9c1af266..98f8e877c 100644 --- a/src/editor/view/EditorView.js +++ b/src/editor/view/EditorView.js @@ -7,22 +7,23 @@ const $ = Backbone.$; export default class EditorView extends View { initialize() { const { model } = this; + const { Panels, UndoManager } = model.attributes; model.view = this; - this.conf = model.config; - this.pn = model.get('Panels'); - this.cv = model.get('Canvas'); model.once('change:ready', () => { - this.pn.active(); - this.pn.disableButtons(); + Panels.active(); + Panels.disableButtons(); + UndoManager.clear(); setTimeout(() => { model.trigger('load', model.get('Editor')); - model.set('changesCount', 0); + model.clearDirtyCount(); }); }); } 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 contEl = $(conf.el || `body ${conf.container}`); 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.height) contEl.css('height', conf.height); - $el.append(this.cv.render()); - $el.append(this.pn.render()); + $el.append(Canvas.render()); + $el.append(Panels.render()); // Load shallow editor 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`); contEl.addClass(`${pfx}editor-cont`).empty().append($el); + modules.forEach(md => md.postRender && md.postRender(this)); return this; } diff --git a/src/i18n/locale/en.js b/src/i18n/locale/en.js index 2d6659016..f2594f298 100644 --- a/src/i18n/locale/en.js +++ b/src/i18n/locale/en.js @@ -160,4 +160,7 @@ export default { }, }, }, + storageManager: { + recover: 'Do you want to recover unsaved changes?', + }, }; diff --git a/src/pages/index.js b/src/pages/index.js index 65fb5e0d2..829348f46 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -46,7 +46,7 @@ import { isString, bindAll, unique, flatten } from 'underscore'; import { createId } from 'utils/mixins'; -import { Model } from 'backbone'; +import { Model, Module } from 'common'; import Pages from './model/Pages'; import Page from './model/Page'; @@ -64,6 +64,8 @@ const typeMain = 'main'; export default () => { return { + ...Module, + name: 'PageManager', storageKey: 'pages', @@ -80,7 +82,7 @@ export default () => { add: evPageAdd, addBefore: evPageAddBefore, remove: evPageRemove, - removeBefore: evPageRemoveBefore + removeBefore: evPageRemoveBefore, }, /** @@ -96,6 +98,7 @@ export default () => { this.em = em; const pages = new Pages([], cnf); this.pages = pages; + this.all = pages; const model = new Model({ _undo: true }); this.model = model; pages.on('add', (p, c, o) => em.trigger(evPageAdd, p, o)); @@ -119,9 +122,7 @@ export default () => { const { pages } = this; const opt = { silent: true }; pages.add(this.config.pages || [], opt); - const mainPage = !pages.length - ? this.add({ type: typeMain }, opt) - : this.getMain(); + const mainPage = !pages.length ? this.add({ type: typeMain }, opt) : this.getMain(); this.select(mainPage, opt); }, @@ -228,13 +229,7 @@ export default () => { */ getAllWrappers() { const pages = this.getAll(); - return unique( - flatten( - pages.map(page => - page.getAllFrames().map(frame => frame.getComponent()) - ) - ) - ); + return unique(flatten(pages.map(page => page.getAllFrames().map(frame => frame.getComponent())))); }, getAllMap() { @@ -280,28 +275,12 @@ export default () => { ['selected', 'config', 'em', 'pages', 'model'].map(i => (this[i] = 0)); }, - store(noStore) { - if (!this.em.get('hasPages')) return {}; - const obj = {}; - const cnf = this.config; - obj[this.storageKey] = JSON.stringify(this.getAll()); - if (!noStore && cnf.stm) cnf.stm.store(obj); - return obj; + store() { + return this.getProjectData(); }, - load(data = {}) { - const key = this.storageKey; - 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; + load(data) { + return this.loadProjectData(data, { all: this.pages, reset: true }); }, _createId() { @@ -315,6 +294,6 @@ export default () => { } while (pagesMap[id]); return id; - } + }, }; }; diff --git a/src/storage_manager/config/config.js b/src/storage_manager/config/config.js index 8d2c43bba..38eb52448 100644 --- a/src/storage_manager/config/config.js +++ b/src/storage_manager/config/config.js @@ -1,67 +1,93 @@ export default { // Prefix identifier that will be used inside storing and loading + // @deprecated id: 'gjs-', + // Default storage type. Available: local | remote + type: 'local', + // Enable/Disable autosaving autosave: true, - // Indicates if load data inside editor after init + // Enable/Disable autoload of data on editor init 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) // need to be done before save. Useful with remoteStorage to reduce remote calls stepsBeforeSave: 1, - //Enable/Disable components model (JSON format) - storeComponents: true, - - //Enable/Disable styles model (JSON format) - storeStyles: true, - - //Enable/Disable saving HTML template - storeHtml: true, - - //Enable/Disable saving CSS template - storeCss: true, - - // ONLY FOR LOCAL STORAGE - // If enabled, checks if browser supports Local Storage - checkLocal: true, - - // ONLY FOR REMOTE STORAGE - // Custom parameters to pass with the remote storage request, eg. csrf token - params: {}, - - // Custom headers for the remote storage request - headers: {}, - - // Endpoint where to save all stuff - urlStore: '', - - // Endpoint where to fetch data - urlLoad: '', - - //Callback before request - beforeSend(jqXHR, settings) {}, - - //Callback after request - onComplete(jqXHR, status) {}, - - // set contentType paramater of $.ajax - // true: application/json; charset=utf-8' - // false: 'x-www-form-urlencoded' - contentTypeJson: true, - - credentials: 'include', - - // Pass custom options to fetch API (remote storage) - // You can pass a simple object: { someOption: 'someValue' } - // or a function which returns and object to add: - // currentOpts => { - // return currentOpts.method === 'post' ? { method: 'patch' } : {}; - // } - fetchOptions: '', + /** + * Callback triggered before the store call (can be asynchronous). + * This can be used to enrich the project data to store. + */ + onStore: data => data, + + /** + * Callback triggered after the load call (can be asynchronous). + */ + onLoad: data => data, + + // Default storage options + options: { + local: { + key: 'gjsProject', + + // If enabled, checks if browser supports LocalStorage + checkLocal: true, + }, + remote: { + // Custom headers + headers: {}, + + // Endpoint URL where to store data project + urlStore: '', + + // Endpoint URL where to load data project + urlLoad: '', + + // set contentType paramater of $.ajax + // true: application/json; charset=utf-8' + // false: 'x-www-form-urlencoded' + contentTypeJson: true, + + // Pass custom options to fetch API (remote storage) + // You can pass a simple object: { someOption: 'someValue' } + // or a function which returns and object to add: + // currentOpts => { + // return currentOpts.method === 'POST' ? { method: 'PATCH' } : {}; + // } + fetchOptions: '', + + credentials: 'include', + + /** + * The remote storage sends the project data as a body of the request. + * You can use this method to update the body before the store call in order to align + * with your API requirements. + */ + 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, + }, + }, }; diff --git a/src/storage_manager/index.js b/src/storage_manager/index.js index fd39e8d7a..2a402b5ab 100644 --- a/src/storage_manager/index.js +++ b/src/storage_manager/index.js @@ -42,6 +42,7 @@ * * [getCurrent](#getcurrent) * * [getCurrentStorage](#getcurrentstorage) * * [setCurrent](#setcurrent) + * * [getStorageOptions](#getstorageoptions) * * [add](#add) * * [get](#get) * * [store](#store) @@ -53,249 +54,269 @@ import defaults from './config/config'; import LocalStorage from './model/LocalStorage'; import RemoteStorage from './model/RemoteStorage'; +import Module from 'common/module'; +import { isEmpty, isFunction } from 'underscore'; const eventStart = 'storage:start'; const eventAfter = 'storage:after'; const eventEnd = 'storage:end'; const eventError = 'storage:error'; -export default () => { - var c = {}; - let em; - var storages = {}; - var defaultStorages = {}; +const STORAGE_LOCAL = 'local'; +const STORAGE_REMOTE = 'remote'; +export default () => { return { + ...Module, + + name: 'StorageManager', + /** - * Name of the module - * @type {String} - * @private + * Get configuration object + * @name getConfig + * @function + * @return {Object} */ - name: 'StorageManager', /** * Initialize module. Automatically called with a new instance of the editor * @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 - * @example - * ... - * { - * autosave: false, - * type: 'remote', - * } - * ... */ init(config = {}) { - c = { ...defaults, ...config }; - em = c.em; + this.__initConfig(defaults, config); + const c = this.getConfig(); if (c._disable) c.type = 0; - defaultStorages.remote = new RemoteStorage(c); - defaultStorages.local = new LocalStorage(c); - c.currentStorage = c.type; - this.loadDefaultProviders().setCurrent(c.type); + this.storages = {}; + this.add(STORAGE_LOCAL, new LocalStorage(c)); + this.add(STORAGE_REMOTE, new RemoteStorage(c)); + this.setCurrent(c.type); return this; }, /** - * Get configuration object - * @return {Object} - * */ - getConfig() { - return c; - }, - - /** - * Checks if autosave is enabled - * @return {Boolean} + * Check if autosave is enabled. + * @returns {Boolean} * */ isAutosave() { - return !!c.autosave; + return !!this.getConfig().autosave; }, /** - * Set autosave value - * @param {Boolean} v - * @return {this} + * Set autosave value. + * @param {Boolean} value * */ - setAutosave(v) { - c.autosave = !!v; + setAutosave(value) { + this.getConfig().autosave = !!value; return this; }, /** - * Returns number of steps required before trigger autosave - * @return {number} + * Returns number of steps required before trigger autosave. + * @returns {Number} * */ getStepsBeforeSave() { - return c.stepsBeforeSave; + return this.getConfig().stepsBeforeSave; }, /** - * Set steps required before trigger autosave - * @param {number} v - * @return {this} + * Set steps required before trigger autosave. + * @param {Number} value * */ - setStepsBeforeSave(v) { - c.stepsBeforeSave = v; + setStepsBeforeSave(value) { + this.getConfig().stepsBeforeSave = value; return this; }, /** - * Add new storage - * @param {string} id Storage ID - * @param {Object} storage Storage wrapper - * @param {Function} storage.load Load method + * Add new storage. + * @param {String} type Storage type + * @param {Object} storage Storage definition + * @param {Function} storage.load Load method * @param {Function} storage.store Store method - * @return {this} * @example * storageManager.add('local2', { - * load: function(keys, clb, clbErr) { - * var res = {}; - * for (var i = 0, len = keys.length; i < len; i++){ - * var v = localStorage.getItem(keys[i]); - * if(v) res[keys[i]] = v; - * } - * clb(res); // might be called inside some async method - * // In case of errors... - * // clbErr('Went something wrong'); + * async load(storageOptions) { + * // ... + * }, + * async store(data, storageOptions) { + * // ... * }, - * 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) { - storages[id] = storage; + add(type, storage) { + this.storages[type] = storage; return this; }, /** - * Returns storage by id - * @param {string} id Storage ID - * @return {Object|null} + * Return storage by type. + * @param {String} type Storage type + * @returns {Object|null} * */ - get(id) { - return storages[id] || null; + get(type) { + return this.storages[type] || null; }, /** - * Returns all storages - * @return {Array} + * Get all storages. + * @returns {Object} * */ getStorages() { - return storages; + return this.storages; }, /** - * Returns current storage type - * @return {string} + * Get current storage type. + * @returns {String} * */ getCurrent() { - return c.currentStorage; + return this.getConfig().currentStorage; }, /** - * Set current storage type - * @param {string} id Storage ID - * @return {this} + * Set current storage type. + * @param {String} type Storage type * */ - setCurrent(id) { - c.currentStorage = id; + setCurrent(type) { + this.getConfig().currentStorage = type; return this; }, + getCurrentStorage() { + return this.get(this.getCurrent()); + }, + /** - * Store key-value resources in the current storage - * @param {Object} data Data in key-value format, eg. {item1: value1, item2: value2} - * @param {Function} clb Callback function - * @return {Object|null} - * @example - * storageManager.store({item1: value1, item2: value2}); + * Get storage options by type. + * @param {String} type Storage type + * @returns {Object} * */ - store(data, clb) { - const st = this.get(this.getCurrent()); - const toStore = {}; - this.onStart('store', data); + getStorageOptions(type) { + return this.getCurrentOptons(type); + }, - 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 - ? st.store( - toStore, - res => { - this.onAfter('store', res); - clb && clb(res); - this.onEnd('store', res); - }, - err => { - this.onError('store', err); - } - ) - : null; + return data; }, /** * Load resource from the current storage by keys - * @param {string|Array} keys Keys to load - * @param {Function} clb Callback function + * @param {Object} [options] Storage options. + * @returns {Object} Loaded data. * @example - * storageManager.load(['item1', 'item2'], res => { - * // res -> {item1: value1, item2: value2} - * }); - * storageManager.load('item1', res => { - * // res -> {item1: value1} - * }); + * const data = await storageManager.load(); + * editor.loadProjectData(data); * */ - load(keys, clb) { - const st = this.get(this.getCurrent()); - const keysF = []; - let result = {}; + async load(options = {}) { + const st = this.getCurrentStorage(); + const opts = { ...this.getCurrentOptons(), ...options }; + 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]; - this.onStart('load', keys); + return result || {}; + }, + + __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++) { - keysF.push(c.id + keys[i]); + getRecovery() { + 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) { - st.load( - keysF, - res => { - result = this.__clearKeys(res); - this.onAfter('load', result); - clb && clb(result); - this.onEnd('load', result); - }, - err => { - clb && clb(result); - this.onError('load', err); - } - ); - } else { - clb && clb(result); + try { + const editor = this.em?.getEditor(); + + if (data) { + let toStore = (onStore && (await onStore(data, editor))) || data; + toStore = (opts.onStore && (await opts.onStore(toStore, editor))) || toStore; + await storage.store(toStore, opts); + result = data; + } else { + result = await storage.load(opts); + result = this.__clearKeys(result); + result = (opts.onLoad && (await opts.onLoad(result, editor))) || result; + result = (onLoad && (await onLoad(result, editor))) || result; + } + this.onAfter(ev, 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 = {}) { + const config = this.getConfig(); + const reg = new RegExp(`^${config.id}`); const result = {}; - const reg = new RegExp('^' + c.id + ''); for (let itemKey in data) { const itemKeyR = itemKey.replace(reg, ''); @@ -305,22 +326,10 @@ export default () => { return result; }, - /** - * Load default storages - * @return {this} - * @private - * */ - loadDefaultProviders() { - for (var id in defaultStorages) this.add(id, defaultStorages[id]); - return this; - }, - - /** - * Get current storage - * @return {Storage} - * */ - getCurrentStorage() { - return this.get(this.getCurrent()); + getCurrentOptons(type) { + const config = this.getConfig(); + const current = type || this.getCurrent(); + return config.options[current] || {}; }, /** @@ -328,6 +337,7 @@ export default () => { * @private */ onStart(ctx, data) { + const { em } = this; if (em) { em.trigger(eventStart); ctx && em.trigger(`${eventStart}:${ctx}`, data); @@ -339,9 +349,11 @@ export default () => { * @private */ onAfter(ctx, data) { + const { em } = this; if (em) { 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 */ onEnd(ctx, data) { + const { em } = this; if (em) { em.trigger(eventEnd); ctx && em.trigger(`${eventEnd}:${ctx}`, data); @@ -361,6 +374,7 @@ export default () => { * @private */ onError(ctx, data) { + const { em } = this; if (em) { em.trigger(eventError, data); ctx && em.trigger(`${eventError}:${ctx}`, data); @@ -379,7 +393,8 @@ export default () => { }, destroy() { - [c, em, storages, defaultStorages].forEach(i => (i = {})); + this.__destroy(); + this.storages = {}; }, }; }; diff --git a/src/storage_manager/model/LocalStorage.js b/src/storage_manager/model/LocalStorage.js index 3bec5a11b..dd70a6235 100644 --- a/src/storage_manager/model/LocalStorage.js +++ b/src/storage_manager/model/LocalStorage.js @@ -1,62 +1,28 @@ -import { Model } from 'backbone'; import { hasWin } from 'utils/mixins'; -export default Model.extend({ - defaults: { - checkLocal: true - }, - - /** - * @private - */ - store(data, clb = () => {}) { - if (this.hasLocal()) { - for (let key in data) localStorage.setItem(key, data[key]); +export default class LocalStorage { + async store(data, opts = {}) { + if (this.hasLocal(opts, true)) { + localStorage.setItem(opts.key, JSON.stringify(data)); } + } - clb && clb(); - }, - - /** - * @private - */ - load(keys, clb = () => {}) { - const result = {}; + async load(opts = {}) { + let result = {}; - if (this.hasLocal()) { - for (let i = 0, len = keys.length; i < len; i++) { - const value = localStorage.getItem(keys[i]); - if (value) result[keys[i]] = value; - } + if (this.hasLocal(opts, true)) { + result = JSON.parse(localStorage.getItem(opts.key) || '{}'); } - clb && clb(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)) { - win && console.warn("Your browser doesn't support localStorage"); + hasLocal(opts = {}, thr) { + if (opts.checkLocal && (!hasWin() || !localStorage)) { + if (thr) throw new Error('localStorage not available'); return false; } return true; } -}); +} diff --git a/src/storage_manager/model/RemoteStorage.js b/src/storage_manager/model/RemoteStorage.js index 0555628b0..d4c6b4f0a 100644 --- a/src/storage_manager/model/RemoteStorage.js +++ b/src/storage_manager/model/RemoteStorage.js @@ -1,146 +1,66 @@ -import Backbone from 'backbone'; import fetch from 'utils/fetch'; -import { isUndefined, isFunction } from 'underscore'; +import { isUndefined, isFunction, isString } from 'underscore'; -export default Backbone.Model.extend({ - fetch, - - 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]; - } +export default class RemoteStorage { + async store(data, opts = {}) { + await this.request(opts.urlStore, this.__props(opts, data), opts); + } - 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) { - this.request(this.get('urlLoad'), { method: 'get' }, clb, clbErr); - }, + request(url, props = {}, opts = {}) { + 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; + }); + } - /** - * Execute remote request - * @param {string} url Url - * @param {Object} [opts={}] Options - * @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'); + __props(opts = {}, data) { + const typeJson = opts.contentTypeJson; + const headers = opts.headers || {}; + const fetchOpts = opts.fetchOptions || {}; const reqHead = 'X-Requested-With'; const typeHead = 'Content-Type'; - const bodyObj = opts.body || {}; - let fetchOptions; let body; - for (let param in params) { - bodyObj[param] = params[param]; - } - if (isUndefined(headers[reqHead])) { 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) { headers[typeHead] = 'application/json; charset=utf-8'; } - if (typeJson) { - body = JSON.stringify(bodyObj); - } else { - body = new FormData(); + if (data) { + if (typeJson) { + body = JSON.stringify(data); + } else { + body = new FormData(); - for (let bodyKey in bodyObj) { - body.append(bodyKey, bodyObj[bodyKey]); + for (let key in data) { + body.append(key, data[key]); + } } } - fetchOptions = { - method: opts.method || 'post', - credentials: this.get('credentials'), - headers - }; - // Body should only be included on POST method - if (fetchOptions.method === 'post') { - fetchOptions.body = body; - } - - const fetchOpts = this.get('fetchOptions') || {}; - const addOpts = isFunction(fetchOpts) - ? fetchOpts(fetchOptions) - : fetchOptions; + const result = { + method: body ? 'POST' : 'GET', + credentials: opts.credentials, + headers, + body, + }; - this.onStart(); - this.fetch(url, { - ...fetchOptions, - ...(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)); + return { + ...result, + ...(isFunction(fetchOpts) ? fetchOpts(result) : fetchOpts), + }; } -}); +} diff --git a/src/utils/mixins.js b/src/utils/mixins.js index f1dd4b354..eea59a0a1 100644 --- a/src/utils/mixins.js +++ b/src/utils/mixins.js @@ -180,6 +180,27 @@ export const escape = (str = '') => { .replace(/`/g, '`'); }; +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 * @param {HTMLElement|Component} el Component or HTML element diff --git a/test/setup.js b/test/setup.js index ad21f2765..760e78209 100644 --- a/test/setup.js +++ b/test/setup.js @@ -1,3 +1,4 @@ +import 'regenerator-runtime/runtime'; import 'whatwg-fetch'; import _ from 'underscore'; import sinon from 'sinon'; @@ -11,7 +12,7 @@ const localStorage = { }, removeItem(key, value) { delete this[key]; - } + }, }; global._ = _; diff --git a/test/specs/asset_manager/index.js b/test/specs/asset_manager/index.js index d4a0aa8f0..94b598da4 100644 --- a/test/specs/asset_manager/index.js +++ b/test/specs/asset_manager/index.js @@ -1,21 +1,9 @@ -import StorageManager from 'storage_manager'; import AssetManager from 'asset_manager'; describe('Asset Manager', () => { describe('Main', () => { - var obj; - var imgObj; - - var storage; - var storageId = 'testStorage'; - var storageMock = { - store(data) { - storage = data; - }, - load(keys) { - return storage; - } - }; + let obj; + let imgObj; beforeEach(() => { document.body.innerHTML = '
'; @@ -23,7 +11,7 @@ describe('Asset Manager', () => { type: 'image', src: 'path/to/image', width: 101, - height: 102 + height: 102, }; obj = new AssetManager(); obj.init(); @@ -81,37 +69,5 @@ describe('Asset Manager', () => { obj.add(imgObj); expect(obj.render()).toBeTruthy(); }); - - describe('With storage', () => { - var storageManager; - - beforeEach(() => { - document.body.innerHTML = '
'; - 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'); - }); - }); }); }); diff --git a/test/specs/css_composer/index.js b/test/specs/css_composer/index.js index 20dc78af5..bc38698ee 100644 --- a/test/specs/css_composer/index.js +++ b/test/specs/css_composer/index.js @@ -14,14 +14,14 @@ describe('Css Composer', () => { }, getCacheLoad() { return storagMock.load(); - } + }, }; var setSmConfig = () => { config.stm = storagMock; config.stm.getConfig = () => ({ storeCss: 1, - storeStyles: 1 + storeStyles: 1, }); }; var setEm = () => { @@ -49,19 +49,13 @@ describe('Css Composer', () => { }); test('storageKey returns array', () => { - expect(obj.storageKey() instanceof Array).toEqual(true); - }); - - test('storageKey returns correct composition', () => { - setSmConfig(); - expect(obj.storageKey()).toEqual(['css', 'styles']); + expect(obj.storageKey).toEqual('styles'); }); test('Store data', () => { setSmConfig(); setEm(); - var expected = { css: 'testCss', styles: '[]' }; - expect(obj.store(1)).toEqual(expected); + expect(JSON.parse(JSON.stringify(obj.store()))).toEqual({ styles: [] }); }); test('Rules are empty', () => { @@ -87,14 +81,7 @@ describe('Css Composer', () => { var sel = new obj.Selectors([{ name: 'test1' }]); var rule = obj.add(sel.models); expect(obj.getAll().length).toEqual(1); - expect( - obj - .getAll() - .at(0) - .get('selectors') - .at(0) - .get('name') - ).toEqual('test1'); + expect(obj.getAll().at(0).get('selectors').at(0).get('name')).toEqual('test1'); }); test('Returns correct rule with the same selector', () => { @@ -175,12 +162,8 @@ describe('Css Composer', () => { const rule = obj.getIdRule(name); expect(rule.selectorsToString()).toEqual(`#${name}`); expect(rule.styleToString()).toEqual(`color:red;`); - expect(rule.styleToString({ important: 1 })).toEqual( - `color:red !important;` - ); - expect(rule.styleToString({ important: ['color'] })).toEqual( - `color:red !important;` - ); + expect(rule.styleToString({ important: 1 })).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()', () => { @@ -273,26 +256,24 @@ describe('Css Composer', () => { style: { color: 'blue' }, opts: { atRuleType: 'media', - atRuleParams: 'screen and (min-width: 480px)' - } + atRuleParams: 'screen and (min-width: 480px)', + }, }, { selector: '.class1:hover', style: { color: 'red' }, opts: { atRuleType: 'media', - atRuleParams: 'screen and (min-width: 480px)' - } - } + atRuleParams: 'screen and (min-width: 480px)', + }, + }, ]; toTest.forEach(test => { const { selector, style, opts } = test; const result = obj.setRule(selector, style, opts); expect(obj.getAll().length).toEqual(1); const rule = obj.getRule(selector, opts); - expect(rule.getAtRule()).toEqual( - `@${opts.atRuleType} ${opts.atRuleParams}` - ); + expect(rule.getAtRule()).toEqual(`@${opts.atRuleType} ${opts.atRuleParams}`); expect(rule.selectorsToString()).toEqual(selector); expect(rule.getStyle()).toEqual(style); }); @@ -308,15 +289,14 @@ describe('Css Composer', () => { { selector: '.class4, .class1 .class2', style: { color: 'blue' }, - opt: { atRuleType: 'media', atRuleParams: '(min-width: 480px)' } - } + opt: { atRuleType: 'media', atRuleParams: '(min-width: 480px)' }, + }, ]; toTest.forEach(test => { const { selector, style, opt = {} } = test; obj.setRule(selector, style, opt); const rule = obj.getRule(selector, opt); - const atRule = `${opt.atRuleType || ''} ${opt.atRuleParams || - ''}`.trim(); + const atRule = `${opt.atRuleType || ''} ${opt.atRuleParams || ''}`.trim(); expect(rule.getAtRule()).toEqual(atRule ? `@${atRule}` : ''); expect(rule.selectorsToString()).toEqual(selector); expect(rule.getStyle()).toEqual(style); @@ -356,7 +336,7 @@ describe('Css Composer', () => { `.test-rule{color:red;}`, `.test-rule:hover{color:blue;}`, `@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(''); obj.addCollection(cssString); @@ -388,9 +368,7 @@ describe('Css Composer', () => { expect(obj.get('.test-rule2', 'hover')).toBe(rule2); expect(rule3.get('mediaText')).toBe('(max-width: 992px)'); expect(obj.get('.test-rule3', null, '(max-width: 992px)')).toBe(rule3); - expect(obj.get('.test-rule4', 'hover', '(max-width: 992px)')).toBe( - rule4 - ); + expect(obj.get('.test-rule4', 'hover', '(max-width: 992px)')).toBe(rule4); }); }); }); diff --git a/test/specs/dom_components/index.js b/test/specs/dom_components/index.js index 6249acddc..39d9eab95 100644 --- a/test/specs/dom_components/index.js +++ b/test/specs/dom_components/index.js @@ -65,35 +65,6 @@ describe('DOM Components', () => { 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', () => { setSmConfig(); setEm(); diff --git a/test/specs/grapesjs/index.js b/test/specs/grapesjs/index.js index 70a1da93d..8b3496f0c 100644 --- a/test/specs/grapesjs/index.js +++ b/test/specs/grapesjs/index.js @@ -12,12 +12,11 @@ describe('GrapesJS', () => { var storage; var storageId = 'testStorage'; var storageMock = { - store(data, clb) { + store(data) { storage = data; - clb(); }, - load(keys, clb) { - return clb(storage); + load() { + return storage; }, }; diff --git a/test/specs/storage_manager/index.js b/test/specs/storage_manager/index.js index f657f25eb..7672e1e36 100644 --- a/test/specs/storage_manager/index.js +++ b/test/specs/storage_manager/index.js @@ -48,12 +48,13 @@ describe('Storage Manager', () => { expect(obj.getCurrent()).toEqual('remote'); }); - test('Store do not execute if empty', () => { - expect(obj.store({ item: 'test' })).toBeUndefined(); + test('Store is executed', async () => { + const spy = jest.spyOn(obj, '__exec'); + await obj.store({ item: 'test' }); + expect(spy).toBeCalledTimes(1); }); test('Load default storages ', () => { - obj.loadDefaultProviders(); expect(obj.get('local')).toBeTruthy(); expect(obj.get('remote')).toBeTruthy(); expect(obj.get('test')).toBeFalsy(); diff --git a/test/specs/storage_manager/model/Models.js b/test/specs/storage_manager/model/Models.js index 6a99805a5..81b89a18c 100644 --- a/test/specs/storage_manager/model/Models.js +++ b/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'; describe('LocalStorage', () => { - var obj; - var itemName = 'testItem'; - var data = { + let obj; + let data = { item1: 'value1', - item2: 'value2' + item2: 'value2', }; beforeEach(() => { @@ -17,141 +16,84 @@ describe('LocalStorage', () => { obj = null; }); - test('Store and load items', () => { - obj.store(data); - var result = obj.load(['item1', 'item2']); + test('Store and load items', async () => { + await obj.store(data); + const result = await obj.load(); 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', () => { - var obj; - var itemName = 'testItem'; - var endpointStore = 'testStoreEndpoint'; - var endpointLoad = 'testLoadEndpoint'; - var params = { test: 'testValue' }; - var storageOptions; - var data; - var mockResponse = (body = {}) => { + let obj; + let data; + let defaultOpts = { + urlStore: '/store', + urlLoad: '/load', + credentials: true, + headers: { 'X-Requested-With': 'XMLHttpRequest' }, + }; + let mockResponse = (body = {}) => { return new window.Response(JSON.stringify(body), { status: 200, - headers: { 'Content-type': 'application/json' } + headers: { 'Content-type': 'application/json' }, }); }; beforeEach(() => { data = { item1: 'value1', - item2: 'value2' - }; - storageOptions = { - urlStore: endpointStore, - urlLoad: endpointLoad, - params + item2: 'value2', }; - obj = new RemoteStorage(storageOptions); - sinon - .stub(obj, 'fetch') - .returns(Promise.resolve(mockResponse({ data: 1 }))); + obj = new RemoteStorage(); + obj.request = jest.fn(() => Promise.resolve(mockResponse({ data: 1 }))); }); afterEach(() => { - obj.fetch.restore(); + obj.request.mockRestore(); obj = null; }); - test('Store data', () => { - obj.store(data); - const callResult = obj.fetch; - expect(callResult.called).toEqual(true); - expect(callResult.firstCall.args[0]).toEqual(endpointStore); - }); - - test('Load data', () => { - obj.load(['item1', 'item2']); - const callResult = obj.fetch; - expect(callResult.called).toEqual(true); - 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', async () => { + await obj.store(data, defaultOpts); + const { calls } = obj.request.mock; + expect(calls.length).toBe(1); + expect(calls[0][0]).toBe(defaultOpts.urlStore); + // expect(obj.request).toBeCalledWith(opts.urlStore, defaultOpts, opts); + const { body, ...args } = calls[0][1]; + expect(args).toEqual({ + method: 'POST', + headers: defaultOpts.headers, + credentials: defaultOpts.credentials, }); }); - test("Store data with credentials option as 'include' by default", () => { - obj.store(data); - const callResult = obj.fetch; - expect(callResult.called).toEqual(true); - expect(callResult.firstCall.args[1]).toMatchObject({ - credentials: 'include' + test('Load data', async () => { + await obj.load(defaultOpts); + const { calls } = obj.request.mock; + expect(obj.request).toBeCalledTimes(1); + expect(calls[0][0]).toBe(defaultOpts.urlLoad); + expect(calls[0][1]).toEqual({ + method: 'GET', + body: undefined, + headers: defaultOpts.headers, + credentials: defaultOpts.credentials, }); }); - test('Store data with credentials option as false ', () => { - obj = new RemoteStorage({ ...storageOptions, credentials: false }); - sinon - .stub(obj, 'fetch') - .returns(Promise.resolve(mockResponse({ data: 1 }))); - - 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', async () => { + const customOpts = { customOpt: 'customValue' }; + await obj.load({ + ...defaultOpts, + fetchOptions: () => customOpts, }); - }); - test('Load data with custom fetch options as function', () => { - const customOpts = { customOpt: 'customValue' }; - obj = new RemoteStorage({ - ...storageOptions, - fetchOptions: () => { - return customOpts; - } + expect(obj.request).toBeCalledTimes(1); + expect(obj.request.mock.calls[0][1]).toEqual({ + method: 'GET', + body: undefined, + headers: defaultOpts.headers, + 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); }); });