Browse Source

Initial support for nested CSS rules (#6760)

* Add support for nested CSS rules

* Update undo test

* Cleanup resolutions and TS config

* Update jest

* Normalize tests for the latest jsdom version

* Parse nested CSS rules

* Add hidden test for @page at-rule

* Fix VuePress build

* Up CI nodejs
pull/6761/head
Artur Arseniev 3 months ago
committed by GitHub
parent
commit
2ea1d66618
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      .github/actions/setup-project/action.yml
  2. 2
      .github/workflows/publish.yml
  3. 4
      docs/api/components.md
  4. 1
      docs/api/editor.md
  5. 1
      docs/api/parser.md
  6. 17
      package.json
  7. 2
      packages/core/jest.config.js
  8. 8
      packages/core/package.json
  9. 11
      packages/core/src/code_manager/model/CssGenerator.ts
  10. 144
      packages/core/src/css_composer/model/CssRule.ts
  11. 6
      packages/core/src/css_composer/model/CssRules.ts
  12. 76
      packages/core/src/domain_abstract/model/StyleableModel.ts
  13. 1
      packages/core/src/editor/index.ts
  14. 36
      packages/core/src/parser/model/BrowserParserCss.ts
  15. 5
      packages/core/test/setup.js
  16. 2
      packages/core/test/specs/asset_manager/view/AssetImageView.ts
  17. 2
      packages/core/test/specs/asset_manager/view/AssetsView.ts
  18. 20
      packages/core/test/specs/block_manager/index.ts
  19. 18
      packages/core/test/specs/canvas/index.ts
  20. 2
      packages/core/test/specs/css_composer/index.ts
  21. 34
      packages/core/test/specs/css_composer/model/CssModels.ts
  22. 2
      packages/core/test/specs/css_composer/view/CssRulesView.ts
  23. 2
      packages/core/test/specs/data_sources/__snapshots__/jsonplaceholder.ts.snap
  24. 2
      packages/core/test/specs/data_sources/__snapshots__/serialization.ts.snap
  25. 2
      packages/core/test/specs/data_sources/__snapshots__/storage.ts.snap
  26. 2
      packages/core/test/specs/data_sources/model/data_collection/__snapshots__/ComponentDataCollection.ts.snap
  27. 2
      packages/core/test/specs/data_sources/model/data_collection/__snapshots__/ComponentDataCollectionWithDataVariable.ts.snap
  28. 2
      packages/core/test/specs/data_sources/model/data_collection/__snapshots__/nestedComponentDataCollections.ts.snap
  29. 4
      packages/core/test/specs/data_sources/transformers.ts
  30. 20
      packages/core/test/specs/device_manager/index.js
  31. 46
      packages/core/test/specs/dom_components/index.ts
  32. 2
      packages/core/test/specs/dom_components/model/Component.ts
  33. 2
      packages/core/test/specs/dom_components/view/ComponentsView.ts
  34. 137
      packages/core/test/specs/editor/telemetry.ts
  35. 139
      packages/core/test/specs/grapesjs/index.ts
  36. 6
      packages/core/test/specs/i18n/index.ts
  37. 6
      packages/core/test/specs/keymaps/index.js
  38. 8
      packages/core/test/specs/pages/index.ts
  39. 4
      packages/core/test/specs/panels/index.ts
  40. 4
      packages/core/test/specs/panels/view/ButtonView.ts
  41. 2
      packages/core/test/specs/panels/view/ButtonsView.ts
  42. 2
      packages/core/test/specs/panels/view/PanelsView.ts
  43. 116
      packages/core/test/specs/parser/model/ParserCss.ts
  44. 41
      packages/core/test/specs/parser/model/ParserHtml.ts
  45. 4
      packages/core/test/specs/selector_manager/e2e/ClassManager.ts
  46. 20
      packages/core/test/specs/selector_manager/index.ts
  47. 2
      packages/core/test/specs/selector_manager/view/ClassTagView.ts
  48. 10
      packages/core/test/specs/selector_manager/view/ClassTagsView.ts
  49. 2
      packages/core/test/specs/storage_manager/index.ts
  50. 6
      packages/core/test/specs/storage_manager/model/Models.js
  51. 2
      packages/core/test/specs/style_manager/model/Properties.ts
  52. 1
      packages/core/tsconfig.json
  53. 2438
      pnpm-lock.yaml

2
.github/actions/setup-project/action.yml

@ -9,7 +9,7 @@ inputs:
node-version:
description: 'The version of Node.js to use for building the project.'
required: false
default: '20.16.0'
default: '22.22.2'
runs:
using: composite

2
.github/workflows/publish.yml

@ -12,7 +12,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20.x'
node-version: '22.x'
registry-url: 'https://registry.npmjs.org'
cache: 'yarn'
- run: yarn --frozen-lockfile

4
docs/api/components.md

@ -27,8 +27,8 @@ cmp.addType(...);
* `component:create` - Component is created (only the model, is not yet mounted in the canvas), called after the init() method
* `component:mount` - Component is mounted to an element and rendered in canvas
* `component:add` - Triggered when a new component is added to the editor, the model is passed as an argument to the callback
* `component:remove` - Triggered when a component is removed, the model is passed as an argument to the callback
* `component:add` - Triggered when a component is added to the editor. The callback receives the model and the options object. This can also be triggered on component moves and clones, so you can check `options.action` (`add-component`, `move-component`, `clone-component`) to distinguish the case
* `component:remove` - Triggered when a component is removed from the editor. This can also happen as part of a component move
* `component:remove:before` - Triggered before the remove of the component, the model, remove function (if aborted via options, with this function you can complete the remove) and options (use options.abort = true to prevent remove), are passed as arguments to the callback
* `component:clone` - Triggered when a component is cloned, the new model is passed as an argument to the callback
* `component:update` - Triggered when a component is updated (moved, styled, etc.), the model is passed as an argument to the callback

1
docs/api/editor.md

@ -176,6 +176,7 @@ Returns CSS built inside canvas
* `opts.onlyMatched` **[Boolean][17]** Return only rules matched by the passed component. (optional, default `false`)
* `opts.keepUnusedStyles` **[Boolean][17]** Force keep all defined rules. Toggle on in case output looks different inside/outside of the editor. (optional, default `false`)
* `opts.allowEmpty` **[Boolean][17]** Include rules with empty style declarations. (optional, default `false`)
* `opts.withNested` **[Boolean][17]** Include nested CSS rules. (optional, default `false`)
Returns **([String][18] | [Array][19]\<CssRule>)** CSS string or array of CssRules

1
docs/api/parser.md

@ -87,6 +87,7 @@ Parse HTML string and return the object containing the Component Definition
* `options.detectDocument` **([Boolean][8] | [Function][9])?** Indicate if or how to detect if the HTML string should be treated as document
* `options.preParser` **[Function][9]?** How to pre-process the HTML string before parsing
* `options.convertDataGjsAttributesHyphens` **[Boolean][8]** Convert `data-gjs-*` attributes from hyphenated to camelCase (eg. `data-gjs-my-component` to `data-gjs-myComponent`) (optional, default `false`)
* `options.convertAttributeValues` **([Boolean][8] | [Array][10]<[String][6]> | [Function][9])** Convert regular HTML attribute values using the same parser used by `data-gjs-*` attributes (optional, default `false`)
### Examples

17
package.json

@ -34,8 +34,8 @@
"@babel/preset-typescript": "7.24.7",
"@babel/runtime": "7.25.6",
"babel-loader": "9.1.3",
"@jest/globals": "29.7.0",
"@types/jest": "29.5.12",
"@jest/globals": "30.4.1",
"@types/jest": "30.0.0",
"@types/node": "22.4.1",
"@types/underscore": "^1.11.15",
"@typescript-eslint/eslint-plugin": "8.10.0",
@ -52,14 +52,23 @@
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-promise": "7.1.0",
"eslint-plugin-react-hooks": "4.6.2",
"jest": "29.7.0",
"jest": "30.4.1",
"prettier": "3.3.3",
"ts-jest": "29.2.4",
"ts-loader": "9.5.2",
"ts-node": "10.9.2",
"typescript": "5.5.4"
},
"pnpm": {
"overrides": {
"jest-environment-jsdom>jsdom": "27.4.0"
},
"packageExtensions": {
"@vuepress/shared-utils@1.9.10": {
"dependencies": {
"lru-cache": "5.1.1"
}
}
},
"peerDependencyRules": {
"ignoreMissing": [
"@babel/*",

2
packages/core/jest.config.js

@ -1,4 +1,4 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
/** @type {import('jest').Config} */
module.exports = {
testEnvironment: 'jsdom',
moduleFileExtensions: ['js', 'ts'],

8
packages/core/package.json

@ -43,18 +43,14 @@
"@types/markdown-it": "14.1.2",
"@types/pretty": "^2.0.3",
"grapesjs-cli": "workspace:^",
"jest-environment-jsdom": "29.7.0",
"jsdom": "24.1.1",
"jest-environment-jsdom": "30.4.1",
"jsdom": "27.4.0",
"npm-run-all": "4.1.5",
"postcss": "8",
"pretty": "2.0.0",
"sass": "1.80.3",
"whatwg-fetch": "3.6.20"
},
"resolutions": {
"backbone-undo/backbone": "1.3.3",
"backbone-undo/underscore": "1.13.1"
},
"keywords": [
"grapes",
"grapesjs",

11
packages/core/src/code_manager/model/CssGenerator.ts

@ -34,6 +34,11 @@ export type CssGeneratorBuildOptions = {
* Include rules with empty style declarations.
*/
allowEmpty?: boolean;
/**
* Include nested CSS rules.
*/
withNested?: boolean;
rules?: CssRule[];
clearStyles?: boolean;
};
@ -105,6 +110,8 @@ export default class CssGenerator extends Model {
}
rules.forEach((rule) => {
if (rule.isNested()) return;
const atRule = rule.getAtRule();
if (atRule) {
@ -132,6 +139,8 @@ export default class CssGenerator extends Model {
const mRules = item.value;
mRules.forEach((rule) => {
if (rule.isNested()) return;
const ruleStr = this.buildFromRule(rule, dump, opts);
if (rule.get('singleAtRule')) {
@ -177,7 +186,7 @@ export default class CssGenerator extends Model {
});
if ((selectorStrNoAdd && found) || selectorsAdd || singleAtRule || !model) {
const block = rule.getDeclaration({ allowEmpty: opts.allowEmpty });
const block = rule.getDeclaration({ allowEmpty: opts.allowEmpty, withNested: opts.withNested });
block && (opts.json ? (result = rule) : (result += block));
} else {
dump.push(rule);

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

@ -1,6 +1,10 @@
import { isEmpty, forEach, isString, isArray } from 'underscore';
import { isEmpty, forEach, isString, isArray, isObject } from 'underscore';
import { ObjectAny, ObjectHash } from '../../common';
import StyleableModel, { StyleProps } from '../../domain_abstract/model/StyleableModel';
import StyleableModel, {
GetStyleOpts,
StyleProps,
UpdateStyleOptions,
} from '../../domain_abstract/model/StyleableModel';
import Selectors from '../../selector_manager/model/Selectors';
import { getMediaLength } from '../../code_manager/model/CssGenerator';
import { isEmptyObj, hasWin } from '../../utils/mixins';
@ -11,10 +15,15 @@ import CssRuleView from '../view/CssRuleView';
export interface ToCssOptions {
important?: boolean | string[];
allowEmpty?: boolean;
withNested?: boolean;
style?: StyleProps;
inline?: boolean;
}
type ToCssOptionsInternal = ToCssOptions & {
nested?: boolean;
};
/** @private */
export interface CssRuleProperties extends ObjectHash {
/**
@ -80,6 +89,8 @@ export interface CssRuleJSON extends Omit<CssRuleProperties, 'selectors'> {
// @ts-ignore
const { CSS } = hasWin() ? window : {};
const isNestedStyleKey = (key: string) => /^(@|&|[.#:[>+~*])/.test(key);
/**
* @typedef CssRule
* @property {Array<Selector>} selectors Array of selectors
@ -101,6 +112,8 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
em?: EditorModel;
opt: any;
views: CssRuleView[] = [];
parentRule?: CssRule;
nestedStyleKey?: string;
defaults() {
return {
@ -135,6 +148,114 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
changed && !isEmptyObj(changed) && em?.changesUp(options, { rule, changed, options });
}
isNested() {
return !!this.parentRule;
}
protected __isNestedStyleValue(value: unknown): value is CssRule {
return value instanceof CssRule && value.parentRule === this;
}
protected __attachNestedRule(rule: CssRule, key: string, opts: UpdateStyleOptions = {}) {
rule.parentRule = this;
rule.nestedStyleKey = key;
rule.em = rule.em || this.em;
const rules = this.em?.Css.getAll();
if (rules && !rules.get(rule)) {
rules.add(rule, opts);
}
return rule;
}
protected __detachNestedRule(rule: CssRule, opts: UpdateStyleOptions = {}) {
if (rule.parentRule !== this) return;
rule.parentRule = undefined;
rule.nestedStyleKey = undefined;
this.em?.Css.getAll().remove(rule, opts);
}
protected __createNestedRule(key: string, value: unknown, opts: UpdateStyleOptions = {}) {
const rule =
value instanceof CssRule
? value
: new CssRule(
{
style: value as ObjectAny,
} as CssRuleProperties,
{ em: this.em },
);
return this.__attachNestedRule(rule, key, opts);
}
protected __normalizeStyle(style: ObjectAny, opts: UpdateStyleOptions = {}) {
const result = { ...style };
Object.keys(result).forEach((key) => {
const value = result[key];
const isNested = isNestedStyleKey(key) && (value instanceof CssRule || (isObject(value) && !isArray(value)));
if (isNested) {
result[key] = this.__createNestedRule(key, value, opts);
}
});
return result;
}
protected __getStyleForExtend() {
return this.getStyle('', { skipResolve: true, withNested: true });
}
protected __getStyleForUpdate(opts: UpdateStyleOptions = {}) {
return this.getStyle('', { skipResolve: true, withNested: true });
}
protected __onStyleUpdate(prevStyle: StyleProps, opts: UpdateStyleOptions = {}) {
const nextStyle = this.getStyle('', { withNested: true, skipResolve: true });
Object.keys(prevStyle).forEach((key) => {
const prevRule = prevStyle[key];
if (this.__isNestedStyleValue(prevRule) && nextStyle[key] !== prevRule) {
this.__detachNestedRule(prevRule, opts);
}
});
}
protected __getStyleResult(style: StyleProps, prop: keyof StyleProps | '' | undefined, opts: GetStyleOpts = {}) {
if (opts.withNested) {
return super.__getStyleResult(style, prop, opts);
}
const result = super.__getStyleResult(style, prop, opts);
if (prop && prop !== '') {
return this.__isNestedStyleValue(result) ? undefined : result;
}
const styleResult = { ...(result as StyleProps) };
Object.keys(styleResult).forEach((key) => {
if (this.__isNestedStyleValue(styleResult[key])) {
delete styleResult[key];
}
});
return styleResult;
}
protected __stylePropToString(prop: string, value: StyleProps[keyof StyleProps], opts: ToCssOptions = {}) {
const nestedRule = this.__isNestedStyleValue(value) && value;
if (nestedRule) {
return opts.withNested ? nestedRule.getDeclaration({ ...opts, nested: true } as ToCssOptionsInternal) : '';
}
return super.__stylePropToString(prop, value, opts);
}
clone(): typeof this {
const selectors = this.get('selectors')!.map((s) => s.clone() as Selector);
@ -224,12 +345,17 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
*/
getDeclaration(opts: ToCssOptions = {}) {
let result = '';
const optsInternal = opts as ToCssOptionsInternal;
const { important } = this.attributes;
const selectors = this.selectorsToString(opts);
const style = this.styleToString({ important, ...opts });
const singleAtRule = this.get('singleAtRule');
const nestedStyleKey = optsInternal.nested && this.nestedStyleKey;
const hasStyle = style || opts.allowEmpty;
if ((selectors || singleAtRule) && (style || opts.allowEmpty)) {
if (nestedStyleKey && hasStyle) {
result = `${nestedStyleKey}{${style}}`;
} else if ((selectors || singleAtRule) && hasStyle) {
result = singleAtRule ? style : `${selectors}{${style}}`;
}
@ -310,6 +436,18 @@ export default class CssRule extends StyleableModel<CssRuleProperties> {
toJSON(opts?: ObjectAny) {
const obj = super.toJSON(opts);
const style = this.getStyle('', { withNested: true, skipResolve: true });
const styleJson = { ...style };
Object.keys(styleJson).forEach((key) => {
const rule = styleJson[key];
if (this.__isNestedStyleValue(rule)) {
styleJson[key] = rule.toJSON({ ...opts, nested: true }).style || {};
}
});
obj.style = styleJson;
if (this.em?.getConfig().avoidDefaults) {
const defaults = this.defaults();

6
packages/core/src/css_composer/model/CssRules.ts

@ -18,8 +18,10 @@ export default class CssRules extends Collection<CssRule> {
}
toJSON(opts?: any) {
const result = Collection.prototype.toJSON.call(this, opts);
return result.filter((rule: CssRuleProperties) => rule.style && !rule.shallow);
return this.models
.filter((rule) => !rule.isNested())
.map((rule) => rule.toJSON(opts))
.filter((rule: CssRuleProperties) => rule.style && !rule.shallow);
}
onAdd(model: CssRule, c: CssRules, o: any) {

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

@ -14,7 +14,7 @@ import { DataWatchersOptions } from '../../dom_components/model/ModelResolverWat
import { DataResolverProps } from '../../data_sources/types';
import { _StringKey } from 'backbone';
export type StyleProps = Record<string, string | string[] | DataResolverProps>;
export type StyleProps = Record<string, string | string[] | DataResolverProps | ObjectAny>;
export interface UpdateStyleOptions extends SetOptions, DataWatchersOptions {
partial?: boolean;
@ -38,6 +38,7 @@ export interface StyleableModelProperties extends ObjectHash {
export interface GetStyleOpts {
skipResolve?: boolean;
withNested?: boolean;
}
type WithDataResolvers<T> = {
@ -122,7 +123,30 @@ export default class StyleableModel<T extends StyleableModelProperties = any> ex
* @return {Object}
*/
extendStyle(prop: ObjectAny): ObjectAny {
return { ...this.getStyle('', { skipResolve: true }), ...prop };
return { ...this.__getStyleForExtend(), ...prop };
}
protected __getStyleForExtend() {
return this.getStyle('', { skipResolve: true });
}
protected __getStyleForUpdate(opts: UpdateStyleOptions = {}) {
return this.getStyle('', { skipResolve: true });
}
protected __normalizeStyle(style: ObjectAny, opts: UpdateStyleOptions = {}) {
return style;
}
protected __onStyleUpdate(propOrig: StyleProps, opts: UpdateStyleOptions = {}) {}
protected __getStyleResult(
style: StyleProps,
prop: keyof StyleProps | '' | undefined,
opts: GetStyleOpts = {},
): StyleProps | StyleProps[keyof StyleProps] | undefined {
const shouldReturnFull = !prop || prop === '';
return shouldReturnFull ? style : style[prop];
}
/**
@ -136,6 +160,9 @@ export default class StyleableModel<T extends StyleableModelProperties = any> ex
prop?: keyof StyleProps | '' | ObjectAny,
opts: GetStyleOpts = {},
): StyleProps | StyleProps[keyof StyleProps] | undefined {
const isPropObject = isObject(prop);
const resolvedProp = isPropObject ? '' : prop;
const resolvedOpts = isPropObject ? (prop as GetStyleOpts) : opts;
const rawStyle = this.get('style');
const parsedStyle: StyleProps = isString(rawStyle)
? this.parseStyle(rawStyle)
@ -145,15 +172,13 @@ export default class StyleableModel<T extends StyleableModelProperties = any> ex
delete parsedStyle.__p;
const shouldReturnFull = !prop || prop === '' || isObject(prop);
if (!opts.skipResolve) {
return shouldReturnFull ? parsedStyle : parsedStyle[prop];
if (!resolvedOpts.skipResolve) {
return this.__getStyleResult(parsedStyle, resolvedProp, resolvedOpts);
}
const unresolvedStyles: StyleProps = this.dataResolverWatchers.getValueOrResolver('styles', parsedStyle);
return shouldReturnFull ? unresolvedStyles : unresolvedStyles[prop];
return this.__getStyleResult(unresolvedStyles, resolvedProp, resolvedOpts);
}
/**
@ -167,7 +192,9 @@ export default class StyleableModel<T extends StyleableModelProperties = any> ex
prop = this.parseStyle(prop);
}
const propOrig = this.getStyle('', { skipResolve: true });
prop = this.__normalizeStyle(prop, opts);
const propOrig = this.__getStyleForUpdate(opts);
if (opts.partial || opts.avoidStore) {
opts.avoidStore = true;
@ -213,6 +240,8 @@ export default class StyleableModel<T extends StyleableModelProperties = any> ex
}
});
this.__onStyleUpdate(propOrig, opts);
return newStyle;
}
@ -275,29 +304,40 @@ export default class StyleableModel<T extends StyleableModelProperties = any> ex
* @return {String}
*/
styleToString(opts: ToCssOptions = {}) {
const style = opts.style || (this.getStyle('', opts as any) as StyleProps);
return this.__styleToString(style, opts);
}
protected __styleToString(style: StyleProps, opts: ToCssOptions = {}) {
const result: string[] = [];
const style = opts.style || (this.getStyle(opts as any) as StyleProps);
const imp = opts.important;
for (let prop in style) {
const important = isArray(imp) ? imp.indexOf(prop) >= 0 : imp;
const firstChars = prop.substring(0, 2);
const isPrivate = firstChars === '__';
if (isPrivate) continue;
const value = style[prop];
const values = isArray(value) ? (value as string[]) : [value];
(values as string[]).forEach((val: string) => {
const value = `${val}${important ? ' !important' : ''}`;
value && result.push(`${prop}:${value};`);
});
const value = this.__stylePropToString(prop, style[prop], opts);
value && result.push(value);
}
return result.join('');
}
protected __stylePropToString(prop: string, value: StyleProps[keyof StyleProps], opts: ToCssOptions = {}) {
const result: string[] = [];
const imp = opts.important;
const important = isArray(imp) ? imp.indexOf(prop) >= 0 : imp;
const values = isArray(value) ? (value as string[]) : [value];
(values as string[]).forEach((val: string) => {
const value = `${val}${important ? ' !important' : ''}`;
value && result.push(`${prop}:${value};`);
});
return result.join('');
}
getSelectors() {
return (this.get('selectors') || this.get('classes')) as Selectors;
}

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

@ -257,6 +257,7 @@ export default class Editor implements IBaseModule<EditorConfig> {
* @param {Boolean} [opts.onlyMatched=false] Return only rules matched by the passed component.
* @param {Boolean} [opts.keepUnusedStyles=false] Force keep all defined rules. Toggle on in case output looks different inside/outside of the editor.
* @param {Boolean} [opts.allowEmpty=false] Include rules with empty style declarations.
* @param {Boolean} [opts.withNested=false] Include nested CSS rules.
* @returns {String|Array<CssRule>} CSS string or array of CssRules
*/
getCss(opts?: EditorModelParam<'getCss', 0>) {

36
packages/core/src/parser/model/BrowserParserCss.ts

@ -109,6 +109,40 @@ export const parseStyle = (node: CSSStyleRule) => {
return style;
};
const getNestedRuleKey = (node: CSSRule) => {
const selectorText = (node as CSSStyleRule).selectorText?.trim();
if (selectorText) {
// CSSOM serializes nested relative selectors with the implied nesting
// selector inserted (eg. `& .child`), while the nested style object keeps
// only the original nested key (eg. `.child`).
return selectorText.replace(/^&(?:\s+)?/, '').trim();
}
const { cssText = '' } = node;
const blockIndex = cssText.indexOf('{');
return blockIndex >= 0 ? cssText.slice(0, blockIndex).trim() : '';
};
export const parseRuleStyle = (node: CSSStyleRule | CSSRule) => {
const style = parseStyle(node as CSSStyleRule) as Record<string, any>;
const nestedNodes = (node as CSSStyleRule).cssRules || [];
// Nested CSS rules stay attached to the parent declaration block in the
// parsed output, eg. `{ color: 'green', '.child': { color: 'red' } }`.
for (let i = 0, len = nestedNodes.length; i < len; i++) {
const nestedNode = nestedNodes[i];
const nestedKey = getNestedRuleKey(nestedNode);
if (!nestedKey) continue;
style[nestedKey] = parseRuleStyle(nestedNode);
}
return style;
};
/**
* Get the condition when possible
* @param {CSSRule} node
@ -196,7 +230,7 @@ export const parseNode = (el: CSSStyleSheet | CSSRule) => {
if (!sels && !isSingleAtRule) continue;
const style = parseStyle(node as CSSStyleRule);
const style = parseRuleStyle(node);
const selsParsed = parseSelector(sels);
const selsAdd = selsParsed.add;
const selsArr: string[][] = selsParsed.result;

5
packages/core/test/setup.js

@ -18,4 +18,7 @@ global._ = _;
global.__GJS_VERSION__ = '';
global.grapesjs = require('./../src').default;
global.$ = global.grapesjs.$;
global.localStorage = localStorage;
Object.defineProperty(global, 'localStorage', {
value: localStorage,
configurable: true,
});

2
packages/core/test/specs/asset_manager/view/AssetImageView.ts

@ -58,6 +58,6 @@ describe('AssetImageView', () => {
const fn = jest.fn();
obj.model.on('remove', fn);
obj.onRemove({ stopImmediatePropagation() {} } as any);
expect(fn).toBeCalledTimes(1);
expect(fn).toHaveBeenCalledTimes(1);
});
});

2
packages/core/test/specs/asset_manager/view/AssetsView.ts

@ -34,7 +34,7 @@ describe('AssetsView', () => {
test('Add new asset', () => {
const spy = jest.spyOn(obj, 'addAsset');
coll.add({ src: 'test' });
expect(spy).toBeCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(1);
});
test('Render new asset', () => {

20
packages/core/test/specs/block_manager/index.ts

@ -80,9 +80,9 @@ describe('BlockManager', () => {
editor.on(obj.events.add, eventAdd);
editor.on(obj.events.all, eventAll);
const added = obj.add(idTest, optsTest);
expect(eventAdd).toBeCalledTimes(1);
expect(eventAdd).toBeCalledWith(added, expect.anything());
expect(eventAll).toBeCalled();
expect(eventAdd).toHaveBeenCalledTimes(1);
expect(eventAdd).toHaveBeenCalledWith(added, expect.anything());
expect(eventAll).toHaveBeenCalled();
});
test('Remove triggers proper events', () => {
@ -95,10 +95,10 @@ describe('BlockManager', () => {
editor.on(obj.events.all, eventAll);
const removed = obj.remove(idTest);
expect(obj.getAll().length).toBe(0);
expect(eventBfRm).toBeCalledTimes(1);
expect(eventRm).toBeCalledTimes(1);
expect(eventRm).toBeCalledWith(removed, expect.anything());
expect(eventAll).toBeCalled();
expect(eventBfRm).toHaveBeenCalledTimes(1);
expect(eventRm).toHaveBeenCalledTimes(1);
expect(eventRm).toHaveBeenCalledWith(removed, expect.anything());
expect(eventAll).toHaveBeenCalled();
});
test('Update triggers proper events', () => {
@ -109,9 +109,9 @@ describe('BlockManager', () => {
editor.on(obj.events.update, eventUp);
editor.on(obj.events.all, eventAll);
added.set(newProps);
expect(eventUp).toBeCalledTimes(1);
expect(eventUp).toBeCalledWith(added, newProps, expect.anything());
expect(eventAll).toBeCalled();
expect(eventUp).toHaveBeenCalledTimes(1);
expect(eventUp).toHaveBeenCalledWith(added, newProps, expect.anything());
expect(eventAll).toHaveBeenCalled();
});
});
});

18
packages/core/test/specs/canvas/index.ts

@ -139,10 +139,10 @@ describe('Canvas', () => {
em.on(canvas.events.spotAdd, eventAdd);
em.on(canvas.events.spot, eventAll);
const spot = canvas.addSpot({ type: Select });
expect(eventAdd).toBeCalledTimes(1);
expect(eventAdd).toBeCalledWith({ spot });
expect(eventAdd).toHaveBeenCalledTimes(1);
expect(eventAdd).toHaveBeenCalledWith({ spot });
setTimeout(() => {
expect(eventAll).toBeCalledTimes(1);
expect(eventAll).toHaveBeenCalledTimes(1);
done();
});
});
@ -155,10 +155,10 @@ describe('Canvas', () => {
const spot = canvas.addSpot({ id: 'spot1', type: Select });
canvas.addSpot({ id: 'spot1', type: Target });
expect(eventUpdate).toBeCalledTimes(1);
expect(eventUpdate).toBeCalledWith({ spot });
expect(eventUpdate).toHaveBeenCalledTimes(1);
expect(eventUpdate).toHaveBeenCalledWith({ spot });
setTimeout(() => {
expect(eventAll).toBeCalledTimes(1);
expect(eventAll).toHaveBeenCalledTimes(1);
done();
});
});
@ -170,10 +170,10 @@ describe('Canvas', () => {
em.on(canvas.events.spot, eventAll);
const spot = canvas.addSpot({ type: Select });
canvas.removeSpots();
expect(eventRemove).toBeCalledTimes(1);
expect(eventRemove).toBeCalledWith({ spot });
expect(eventRemove).toHaveBeenCalledTimes(1);
expect(eventRemove).toHaveBeenCalledWith({ spot });
setTimeout(() => {
expect(eventAll).toBeCalledTimes(1);
expect(eventAll).toHaveBeenCalledTimes(1);
done();
});
});

2
packages/core/test/specs/css_composer/index.ts

@ -198,7 +198,7 @@ describe('Css Composer', () => {
const name = 'rule-test';
const selClass = `.${name}`;
const selId = `#${name}`;
const decl = '{colore:red;}';
const decl = '{color:red;}';
all.add(`${selClass}${decl} ${selId}${decl}`);
expect(all.length).toBe(2);
const ruleClass = all.at(0);

34
packages/core/test/specs/css_composer/model/CssModels.ts

@ -67,6 +67,27 @@ describe('CssRule', () => {
expect(obj.toCSS()).toEqual('.test1{color:red;}');
});
test('toCSS with nested selector rule', () => {
obj.getSelectors().add({ name: 'test1' });
obj.setStyle({
color: 'green',
'.bar': {
color: 'red',
},
});
const nested = obj.getStyle('.bar', { withNested: true }) as CssRule;
expect(nested).toBeInstanceOf(CssRule);
expect(nested.parentRule).toBe(obj);
expect(nested.nestedStyleKey).toBe('.bar');
expect(nested.get('selectorsAdd')).toBe('');
expect(obj.getStyle()).toEqual({ color: 'green' });
expect(obj.getStyle({ withNested: true })['.bar']).toBe(nested);
expect(obj.getStyle('.bar')).toBeUndefined();
expect(obj.toCSS()).toEqual('.test1{color:green;}');
expect(obj.toCSS({ withNested: true })).toEqual('.test1{color:green;.bar{color:red;}}');
});
test('toCSS wraps correctly inside media rule', () => {
const media = '(max-width: 768px)';
obj.set('atRuleType', 'media');
@ -90,6 +111,19 @@ describe('CssRule', () => {
expect(obj.toCSS()).toEqual('@font-face{font-family:Sans;}');
});
test('toCSS with nested @page margin at-rule', () => {
obj.set('atRuleType', 'page');
obj.set('singleAtRule', true);
obj.setStyle({
margin: '2cm',
'@bottom-center': {
content: '"x"',
},
});
expect(obj.toCSS()).toEqual('@page{margin:2cm;}');
expect(obj.toCSS({ withNested: true })).toEqual('@page{margin:2cm;@bottom-center{content:"x";}}');
});
test('toCSS with a generic at-rule and condition', () => {
obj.set('atRuleType', 'font-face');
obj.set('mediaText', 'some-condition');

2
packages/core/test/specs/css_composer/view/CssRulesView.ts

@ -121,7 +121,7 @@ describe('CssRulesView', () => {
test('Add new rule', () => {
const spy = jest.spyOn(obj, 'addToCollection');
obj.collection.add({});
expect(spy).toBeCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(1);
});
test('Add correctly rules with different media queries', () => {

2
packages/core/test/specs/data_sources/__snapshots__/jsonplaceholder.ts.snap

@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`JsonPlaceholder Usage should render a list of comments from jsonplaceholder api 1`] = `
"<body>

2
packages/core/test/specs/data_sources/__snapshots__/serialization.ts.snap

@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`DataSource Serialization .getProjectData ComponentDataVariable 1`] = `
{

2
packages/core/test/specs/data_sources/__snapshots__/storage.ts.snap

@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`DataSource Storage .getProjectData ComponentDataVariable 1`] = `
{

2
packages/core/test/specs/data_sources/model/data_collection/__snapshots__/ComponentDataCollection.ts.snap

@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Collection component Serialization Saving: Collection with grandchildren 1`] = `
{

2
packages/core/test/specs/data_sources/model/data_collection/__snapshots__/ComponentDataCollectionWithDataVariable.ts.snap

@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Collection variable components Serialization Saving: Collection with collection variable component ( no grandchildren ) 1`] = `
{

2
packages/core/test/specs/data_sources/model/data_collection/__snapshots__/nestedComponentDataCollections.ts.snap

@ -1,4 +1,4 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`Collection component Nested collections are correctly serialized 1`] = `
{

4
packages/core/test/specs/data_sources/transformers.ts

@ -92,8 +92,8 @@ describe('DataSource Transformers', () => {
const ds = dsm.get('test-data-source');
const dr = ds.addRecord({ id: 'id1', content: 'i love grapes' });
expect(() => dr.set('content', 123)).toThrowError('Value must be a string');
expect(() => dr.set({ content: 123 })).toThrowError('Value must be a string');
expect(() => dr.set('content', 123)).toThrow('Value must be a string');
expect(() => dr.set({ content: 123 })).toThrow('Value must be a string');
dr.set({ content: 'I LOVE GRAPES' });

20
packages/core/test/specs/device_manager/index.js

@ -45,8 +45,8 @@ describe('DeviceManager', () => {
em.on(obj.events.add, eventFn);
em.on(obj.events.all, eventFnAll);
obj.add(testNameDevice, testWidthDevice);
expect(eventFn).toBeCalledTimes(1);
expect(eventFnAll).toBeCalled();
expect(eventFn).toHaveBeenCalledTimes(1);
expect(eventFnAll).toHaveBeenCalled();
});
test('Added device has correct data', () => {
@ -117,8 +117,8 @@ describe('DeviceManager', () => {
expect(all.length).toEqual(0);
expect(model).toBe(removed);
// Check for events
expect(eventFn).toBeCalledTimes(1);
expect(eventFnAll).toBeCalled();
expect(eventFn).toHaveBeenCalledTimes(1);
expect(eventFnAll).toHaveBeenCalled();
});
test('Update device', () => {
@ -128,8 +128,8 @@ describe('DeviceManager', () => {
const up = { name: 'Test' };
const opts = { myopts: 1 };
model.set(up, opts);
expect(event).toBeCalledTimes(1);
expect(event).toBeCalledWith(model, up, opts);
expect(event).toHaveBeenCalledTimes(1);
expect(event).toHaveBeenCalledWith(model, up, opts);
});
test('Select device', () => {
@ -144,19 +144,19 @@ describe('DeviceManager', () => {
obj.select(model);
expect(em.get('device')).toBe('dev-1');
expect(obj.getSelected()).toBe(model);
expect(event).toBeCalledTimes(1);
expect(eventAll).toBeCalled();
expect(event).toHaveBeenCalledTimes(1);
expect(eventAll).toHaveBeenCalled();
// Select from the manager with id
obj.select('dev-2');
expect(em.get('device')).toBe('dev-2');
expect(obj.getSelected()).toBe(model2);
expect(event).toBeCalledTimes(2);
expect(event).toHaveBeenCalledTimes(2);
// Select from the editor
em.set('device', 'dev-1');
expect(obj.getSelected()).toBe(model);
expect(event).toBeCalledTimes(3);
expect(event).toHaveBeenCalledTimes(3);
});
test('Render devices', () => {

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

@ -7,6 +7,7 @@ import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper
import { flattenHTML, setupTestEditor } from '../../common';
import { ProjectData } from '../../../src/storage_manager';
import { CanMoveReason } from '../../../src/dom_components';
import { wait } from '../../../src/utils/mixins';
describe('DOM Components', () => {
describe('Main', () => {
@ -254,7 +255,7 @@ describe('DOM Components', () => {
expect(comp.get('editable')).toBe(1);
});
test('Remove and undo component with styles', (done) => {
test('Remove and undo component with styles', async () => {
const id = 'idtest2';
const um = em.UndoManager;
const cc = em.Css;
@ -265,27 +266,28 @@ describe('DOM Components', () => {
</style>`) as Component;
const rule = cc.getAll().at(0);
expect(rule.toCSS()).toEqual(`#${id}{color:red;padding:50px 100px;background-color:red;}`);
await wait(); // flush noUndo inline-style move
obj.getComponents().first().addStyle({ margin: '10px' });
const css = `#${id}{color:red;padding:50px 100px;background-color:red;margin:10px;}`;
expect(rule.toCSS()).toEqual(css);
setTimeout(() => {
// Undo is committed now
component.remove();
expect(obj.getComponents().length).toBe(0);
expect(cc.getAll().length).toBe(0);
um.undo();
await wait(); // separate style change from remove undo-group
expect(obj.getComponents().length).toBe(1);
expect(cc.getAll().length).toBe(1);
expect(obj.getComponents().at(0)).toBe(component);
expect(cc.getAll().at(0)).toBe(rule);
component.remove();
expect(obj.getComponents().length).toBe(0);
expect(cc.getAll().length).toBe(0);
um.undo();
expect(em.getHtml({ component })).toEqual(`<div id="${id}">Text</div>`);
expect(rule.toCSS()).toEqual(css);
expect(obj.getComponents().length).toBe(1);
expect(cc.getAll().length).toBe(1);
expect(obj.getComponents().at(0)).toBe(component);
expect(cc.getAll().at(0)).toBe(rule);
done();
}, 20);
expect(em.getHtml({ component })).toEqual(`<div id="${id}">Text</div>`);
expect(rule.toCSS()).toEqual(css);
});
describe('Custom components with styles', () => {
@ -370,7 +372,12 @@ describe('DOM Components', () => {
const row = obj.addComponent({ type: rowId }) as Component;
expect(em.Css.getRule('.gjs-test-row')?.getStyle()).toEqual({ display: 'flex', gap: '16px' });
expect(em.Css.getRule('.gjs-test-column')?.getStyle()).toEqual({ flex: '1' });
expect(em.Css.getRule('.gjs-test-column')?.getStyle()).toEqual({
flex: '1 1 0%',
'flex-basis': '0%',
'flex-grow': '1',
'flex-shrink': '1',
});
expect(em.Css.getAll().length).toBe(2);
row.remove();
@ -383,7 +390,12 @@ describe('DOM Components', () => {
expect(rowAgain.get('type')).toBe(rowId);
expect(em.Css.getAll().length).toBe(2);
expect(em.Css.getRule('.gjs-test-row')?.getStyle()).toEqual({ display: 'flex', gap: '16px' });
expect(em.Css.getRule('.gjs-test-column')?.getStyle()).toEqual({ flex: '1' });
expect(em.Css.getRule('.gjs-test-column')?.getStyle()).toEqual({
flex: '1 1 0%',
'flex-basis': '0%',
'flex-grow': '1',
'flex-shrink': '1',
});
});
test('Custom style is not updated if already exists', () => {

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

@ -545,7 +545,7 @@ describe('Component', () => {
},
});
expect(() => new ExtendedComponent({}, compOpts)).not.toThrowError();
expect(() => new ExtendedComponent({}, compOpts)).not.toThrow();
});
});

2
packages/core/test/specs/dom_components/view/ComponentsView.ts

@ -38,7 +38,7 @@ describe('ComponentsView', () => {
test('Add new component', () => {
const addSpy = jest.spyOn(view, 'addToCollection');
view.collection.add({});
expect(addSpy).toBeCalledTimes(1);
expect(addSpy).toHaveBeenCalledTimes(1);
});
test('Render new component', () => {

137
packages/core/test/specs/editor/telemetry.ts

@ -1,20 +1,16 @@
import grapesjs from '../../../src';
import { EditorConfig } from '../../../src/editor/config/config';
import EditorView from '../../../src/editor/view/EditorView';
import { fixJsDom, fixJsDomIframe, waitEditorEvent } from '../../common';
import * as hostUtil from '../../../src/utils/host-name';
jest.mock('../../../src/utils/host-name');
const grapesjs = require('../../../src').default;
describe('Editor telemetry', () => {
const version = '1.0.0';
let fixture: HTMLElement;
let editorName = '';
let htmlString = '';
let config: Partial<EditorConfig>;
let cssString = '';
let documentEl = '';
let originalFetch: typeof fetch;
let originalWindowFetch: typeof window.fetch;
let fetchMock: jest.Mock;
const initTestEditor = (config: Partial<EditorConfig>) => {
@ -28,18 +24,27 @@ describe('Editor telemetry', () => {
return editor;
};
const getSendTelemetryData = (hostname = 'example.com') => {
jest.resetModules();
let sendTelemetryData: any;
jest.isolateModules(() => {
jest.doMock('../../../src/utils/host-name', () => ({
getHostName: jest.fn(() => hostname),
}));
sendTelemetryData = require('../../../src/editor/view/EditorView').default.prototype.sendTelemetryData;
});
return sendTelemetryData;
};
beforeAll(() => {
jest.spyOn(hostUtil, 'getHostName').mockReturnValue('example.com');
editorName = 'editor-fixture';
});
beforeEach(() => {
const initHtml = '<div class="test1"></div><div class="test2"></div>';
htmlString = `<body>${initHtml}</body>`;
cssString = '.test2{color:red}.test3{color:blue}';
documentEl = '<style>' + cssString + '</style>' + initHtml;
config = {
container: '#' + editorName,
container: `#${editorName}`,
storageManager: {
autoload: false,
autosave: false,
@ -47,11 +52,12 @@ describe('Editor telemetry', () => {
},
};
document.body.innerHTML = `<div id="fixtures"><div id="${editorName}"></div></div>`;
fixture = document.body.querySelector(`#${editorName}`)!;
originalFetch = global.fetch;
originalWindowFetch = window.fetch;
fetchMock = jest.fn(() => Promise.resolve({ ok: true }));
global.fetch = fetchMock;
Object.defineProperty(global, 'fetch', { value: fetchMock, configurable: true, writable: true });
Object.defineProperty(window, 'fetch', { value: fetchMock, configurable: true, writable: true });
const sessionStorageMock = {
getItem: jest.fn(),
@ -59,24 +65,22 @@ describe('Editor telemetry', () => {
removeItem: jest.fn(),
};
Object.defineProperty(window, 'sessionStorage', { value: sessionStorageMock });
Object.defineProperty(window, 'location', {
value: {
hostname: 'example.com',
},
});
Object.defineProperty(window, 'sessionStorage', { value: sessionStorageMock, configurable: true });
Object.defineProperty(global, 'sessionStorage', { value: sessionStorageMock, configurable: true });
console.log = jest.fn();
console.error = jest.fn();
});
afterEach(() => {
global.fetch = originalFetch;
jest.resetAllMocks();
Object.defineProperty(global, 'fetch', { value: originalFetch, configurable: true, writable: true });
Object.defineProperty(window, 'fetch', { value: originalWindowFetch, configurable: true, writable: true });
jest.clearAllMocks();
jest.dontMock('../../../src/utils/host-name');
});
test('Telemetry is sent when enabled', async () => {
test('Telemetry hook is invoked when enabled', async () => {
const spy = jest.spyOn(EditorView.prototype as any, 'sendTelemetryData').mockResolvedValue(undefined);
const editor = initTestEditor({
...config,
telemetry: true,
@ -84,49 +88,51 @@ describe('Editor telemetry', () => {
await waitEditorEvent(editor, 'load');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toContain('/api/gjs/telemetry/collect');
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({
domain: expect.any(String),
version: expect.any(String),
});
expect(spy).toHaveBeenCalledTimes(1);
});
test('Telemetry is not sent when disabled', async () => {
test('Telemetry hook is not invoked when disabled', async () => {
const spy = jest.spyOn(EditorView.prototype as any, 'sendTelemetryData').mockResolvedValue(undefined);
const editor = initTestEditor({
...config,
telemetry: false,
});
await waitEditorEvent(editor, 'load');
expect(fetchMock).not.toHaveBeenCalled();
expect(spy).not.toHaveBeenCalled();
});
test('Telemetry is not sent twice in the same session', async () => {
window.sessionStorage.getItem = jest.fn(() => 'true');
test('Telemetry data is sent and session key stored', async () => {
const sendTelemetryData = getSendTelemetryData();
const trigger = jest.fn();
const editor = initTestEditor({
...config,
telemetry: true,
await sendTelemetryData.call({
model: { version },
trigger,
});
await waitEditorEvent(editor, 'load');
expect(fetchMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[0][0]).toContain('/api/gjs/telemetry/collect');
expect(fetchMock.mock.calls[0][1].method).toBe('POST');
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toMatchObject({
domain: 'example.com',
version,
});
expect(sessionStorage.setItem).toHaveBeenCalledWith(`gjs_telemetry_sent_${version}`, 'true');
expect(trigger).toHaveBeenCalledTimes(1);
});
test('Telemetry handles fetch errors gracefully', async () => {
fetchMock.mockRejectedValueOnce(new Error('Network error'));
test('Telemetry is not sent twice in the same session', async () => {
sessionStorage.getItem = jest.fn(() => 'true');
const sendTelemetryData = getSendTelemetryData();
const editor = initTestEditor({
...config,
telemetry: true,
await sendTelemetryData.call({
model: { version },
trigger: jest.fn(),
});
await waitEditorEvent(editor, 'load');
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(console.log).not.toHaveBeenCalled();
expect(console.error).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});
test('Telemetry cleans up old version keys', async () => {
@ -138,21 +144,36 @@ describe('Editor telemetry', () => {
'gjs_telemetry_sent_0.9.1': 'true',
other_key: 'true',
};
Object.defineProperty(window, 'sessionStorage', { value: sessionStorageMock });
Object.defineProperty(window, 'sessionStorage', { value: sessionStorageMock, configurable: true });
Object.defineProperty(global, 'sessionStorage', { value: sessionStorageMock, configurable: true });
Object.defineProperty(sessionStorageMock, 'length', { value: 3 });
fetchMock.mockResolvedValueOnce({ ok: true });
const sendTelemetryData = getSendTelemetryData();
const editor = initTestEditor({
...config,
telemetry: true,
await sendTelemetryData.call({
model: { version },
trigger: jest.fn(),
});
await waitEditorEvent(editor, 'load');
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(sessionStorageMock.setItem).toHaveBeenCalledWith(`gjs_telemetry_sent_${version}`, 'true');
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('gjs_telemetry_sent_0.9.0');
expect(sessionStorageMock.removeItem).toHaveBeenCalledWith('gjs_telemetry_sent_0.9.1');
expect(sessionStorageMock.removeItem).not.toHaveBeenCalledWith('other_key');
}, 10000);
});
test('Telemetry send can fail without noisy logging', async () => {
const sendTelemetryData = getSendTelemetryData();
fetchMock.mockRejectedValueOnce(new Error('Network error'));
await expect(
sendTelemetryData.call({
model: { version },
trigger: jest.fn(),
}),
).rejects.toThrow('Network error');
expect(console.log).not.toHaveBeenCalled();
expect(console.error).not.toHaveBeenCalled();
});
});

139
packages/core/test/specs/grapesjs/index.ts

@ -1,4 +1,5 @@
import grapesjs, { Component, Editor, usePlugin } from '../../../src';
import CssRule from '../../../src/css_composer/model/CssRule';
import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper';
import { EditorConfig } from '../../../src/editor/config/config';
import type { Plugin } from '../../../src/plugin_manager';
@ -334,6 +335,144 @@ describe('GrapesJS', () => {
expect(editor.getCss({ allowEmpty: true, keepUnusedStyles: true })).toEqual(`${protCss}.test-empty{}`);
});
test('Allow nested css rules option for getCSS method', () => {
config.components = '<div class="foo"></div><div class="baz"></div>';
const editor = grapesjs.init(config);
const rule = editor.Css.setRule('.foo', {
color: 'green',
'.bar': {
color: 'red',
},
});
const rule2 = editor.Css.setRule('.baz', {
color: 'blue',
'.bar': {
color: 'yellow',
},
});
const nested = rule.getStyle('.bar', { withNested: true }) as CssRule;
const nested2 = rule2.getStyle('.bar', { withNested: true }) as CssRule;
const protCss = editor.getConfig().protectedCss;
expect(editor.Css.getAll().length).toEqual(4);
expect(nested.isNested()).toBe(true);
expect(nested2.isNested()).toBe(true);
expect(nested).not.toBe(nested2);
expect(editor.getCss()).toEqual(`${protCss}.foo{color:green;}.baz{color:blue;}`);
expect(editor.getCss({ withNested: true })).toEqual(
`${protCss}.foo{color:green;.bar{color:red;}}.baz{color:blue;.bar{color:yellow;}}`,
);
});
test('Nested css rules option from config optsCss', () => {
config.components = '<div class="foo"></div>';
config.optsCss = { withNested: true };
const editor = grapesjs.init(config);
editor.Css.setRule('.foo', {
color: 'green',
'.bar': {
color: 'red',
},
});
const protCss = editor.getConfig().protectedCss;
expect(editor.getCss()).toEqual(`${protCss}.foo{color:green;.bar{color:red;}}`);
});
test('Allow multiple nested css rules for the same parent rule', () => {
config.components = '<div class="foo"></div>';
const editor = grapesjs.init(config);
const rule = editor.Css.setRule('.foo', {
color: 'green',
'.bar': {
color: 'red',
},
'.baz': {
color: 'blue',
},
});
const nestedBar = rule.getStyle('.bar', { withNested: true }) as CssRule;
const nestedBaz = rule.getStyle('.baz', { withNested: true }) as CssRule;
const protCss = editor.getConfig().protectedCss;
expect(editor.Css.getAll().length).toEqual(3);
expect(nestedBar.isNested()).toBe(true);
expect(nestedBaz.isNested()).toBe(true);
expect(nestedBar).not.toBe(nestedBaz);
expect(editor.getCss({ withNested: true })).toEqual(
`${protCss}.foo{color:green;.bar{color:red;}.baz{color:blue;}}`,
);
});
test('Allow deep nested css rules', () => {
config.components = '<div class="foo"></div>';
const editor = grapesjs.init(config);
const rule = editor.Css.setRule('.foo', {
color: 'green',
'.bar': {
color: 'red',
'.baz': {
color: 'blue',
},
},
});
const nestedBar = rule.getStyle('.bar', { withNested: true }) as CssRule;
const nestedBaz = nestedBar.getStyle('.baz', { withNested: true }) as CssRule;
const protCss = editor.getConfig().protectedCss;
expect(editor.Css.getAll().length).toEqual(3);
expect(nestedBar.isNested()).toBe(true);
expect(nestedBaz.isNested()).toBe(true);
expect(nestedBaz.parentRule).toBe(nestedBar);
expect(editor.getCss({ withNested: true })).toEqual(
`${protCss}.foo{color:green;.bar{color:red;.baz{color:blue;}}}`,
);
});
test('Nested css rules are stored under parent style', () => {
config.components = '<div class="foo"></div>';
const editor = grapesjs.init(config);
editor.Css.setRule('.foo', {
color: 'green',
'.bar': {
color: 'red',
},
});
const projectData = editor.getProjectData();
expect(projectData.styles).toHaveLength(1);
expect(projectData.styles[0].style).toEqual({
color: 'green',
'.bar': {
color: 'red',
},
});
const reloaded = grapesjs.init({ ...config, container: document.createElement('div') });
reloaded.loadProjectData(projectData);
expect(reloaded.Css.getAll().length).toEqual(2);
expect(reloaded.getCss({ withNested: true })).toEqual(
`${reloaded.getConfig().protectedCss}.foo{color:green;.bar{color:red;}}`,
);
});
test('Nested css rules are removed when parent style is replaced', () => {
config.components = '<div class="foo"></div>';
const editor = grapesjs.init(config);
const rule = editor.Css.setRule('.foo', {
color: 'green',
'.bar': {
color: 'red',
},
});
const protCss = editor.getConfig().protectedCss;
rule.setStyle({ color: 'blue' });
expect(editor.Css.getAll().length).toEqual(1);
expect(editor.getCss({ withNested: true })).toEqual(`${protCss}.foo{color:blue;}`);
});
test('Keep unused css classes/selectors option for media rules', () => {
cssString =
'.test2{color:red}.test3{color:blue} @media only screen and (max-width: 620px) { .notused { color: red; } } ';

6
packages/core/test/specs/i18n/index.ts

@ -199,9 +199,9 @@ describe('I18n', () => {
em.on('i18n:locale', handlerLocale);
obj.addMessages({ en: { msg1: 'Msg 1', msg2: 'Msg 2' } });
obj.setLocale('it');
expect(handlerAdd).toBeCalledTimes(1);
expect(handlerUpdate).toBeCalledTimes(1);
expect(handlerLocale).toBeCalledTimes(1);
expect(handlerAdd).toHaveBeenCalledTimes(1);
expect(handlerUpdate).toHaveBeenCalledTimes(1);
expect(handlerLocale).toHaveBeenCalledTimes(1);
});
});
});

6
packages/core/test/specs/keymaps/index.js

@ -74,7 +74,7 @@ describe('Keymaps', () => {
});
document.dispatchEvent(keyboardEvent);
expect(handler.callRun).toBeCalled();
expect(handler.callRun).toHaveBeenCalled();
});
});
@ -96,7 +96,7 @@ describe('Keymaps', () => {
});
document.dispatchEvent(keyboardEvent);
expect(handler.callRun).toBeCalledTimes(0);
expect(handler.callRun).toHaveBeenCalledTimes(0);
});
it('Should run the handler if checked as force', () => {
@ -112,7 +112,7 @@ describe('Keymaps', () => {
});
document.dispatchEvent(keyboardEvent);
expect(handler.callRun).toBeCalled();
expect(handler.callRun).toHaveBeenCalled();
});
});
});

8
packages/core/test/specs/pages/index.ts

@ -192,7 +192,7 @@ describe('Managing pages', () => {
em.on(pm.events.add, eventAdd);
pm.add({});
expect(pm.getAll().length).toBe(2);
expect(eventAdd).toBeCalledTimes(1);
expect(eventAdd).toHaveBeenCalledTimes(1);
});
test('Abort add page', () => {
@ -218,7 +218,7 @@ describe('Managing pages', () => {
const page = pm.add({})!;
pm.remove(`${page.id}`);
expect(pm.getAll().length).toBe(1);
expect(eventRm).toBeCalledTimes(1);
expect(eventRm).toHaveBeenCalledTimes(1);
});
test('Abort remove page', () => {
@ -247,8 +247,8 @@ describe('Managing pages', () => {
const up = { name: 'Test' };
const opts = { myopts: 1 };
page.set(up, opts);
expect(event).toBeCalledTimes(1);
expect(event).toBeCalledWith(page, up, opts);
expect(event).toHaveBeenCalledTimes(1);
expect(event).toHaveBeenCalledWith(page, up, opts);
});
test('Prevent duplicate ids in components and styles', () => {

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

@ -82,7 +82,7 @@ describe('Panels', () => {
const btn = obj.addButton('test', { id: 'btn', active: true });
btn?.on('updateActive', fn);
obj.active();
expect(fn).toBeCalledTimes(1);
expect(fn).toHaveBeenCalledTimes(1);
});
test('Disable correctly buttons flagged as disabled', () => {
@ -91,7 +91,7 @@ describe('Panels', () => {
const btn = obj.addButton('test', { id: 'btn', disable: true });
btn?.on('change:disable', fn);
obj.disableButtons();
expect(fn).toBeCalledTimes(1);
expect(fn).toHaveBeenCalledTimes(1);
});
test("Can't remove button to non existent panel", () => {

4
packages/core/test/specs/panels/view/ButtonView.ts

@ -72,14 +72,14 @@ describe('ButtonView', () => {
const spy = jest.spyOn(view, 'toggleActive' as any);
model.set('disable', true, { silent: true });
view.clicked();
expect(spy).toBeCalledTimes(0);
expect(spy).toHaveBeenCalledTimes(0);
});
test('Enable the click action when button is enable', () => {
const spy = jest.spyOn(view, 'toggleActive' as any);
model.set('disable', false, { silent: true });
view.clicked();
expect(spy).toBeCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(1);
});
test('Renders correctly', () => {

2
packages/core/test/specs/panels/view/ButtonsView.ts

@ -28,7 +28,7 @@ describe('ButtonsView', () => {
test('Add new button', () => {
const spy = jest.spyOn(view, 'addToCollection' as any);
view.collection.add([{}]);
expect(spy).toBeCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(1);
});
test('Render new button', () => {

2
packages/core/test/specs/panels/view/PanelsView.ts

@ -28,7 +28,7 @@ describe('PanelsView', () => {
test('Add new panel', () => {
const spy = jest.spyOn(view, 'addToCollection' as any);
view.collection.add([{}]);
expect(spy).toBeCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(1);
});
test('Render new panel', () => {

116
packages/core/test/specs/parser/model/ParserCss.ts

@ -84,10 +84,10 @@ describe('ParserCss', () => {
});
test('Parse rule with more selectors', () => {
var str = ' .test1.test2 {color:red; test: value}';
var str = ' .test1.test2 {color:red; --test:value}';
var result = {
selectors: ['test1', 'test2'],
style: { color: 'red', test: 'value' },
style: { color: 'red', '--test': 'value' },
};
expect(obj.parse(str)).toEqual([result]);
});
@ -184,7 +184,80 @@ describe('ParserCss', () => {
expect(obj.parse(str)).toEqual([result]);
});
// Phantom doesn't find 'node.conditionText' so will skip it
test('Parse nested selector rules', () => {
const str = `.foo {
color: green;
.bar {
color: red;
.baz {
color: blue;
}
}
}`;
expect(obj.parse(str)).toEqual([
{
selectors: ['foo'],
style: {
color: 'green',
'.bar': {
color: 'red',
'.baz': {
color: 'blue',
},
},
},
},
]);
});
test('Parse nested selector rules inside media query', () => {
const str = `@media (max-width: 992px) {
.foo {
color: green;
.bar {
color: red;
}
}
}`;
expect(obj.parse(str)).toEqual([
{
atRuleType: 'media',
selectors: ['foo'],
style: {
color: 'green',
'.bar': {
color: 'red',
},
},
mediaText: '(max-width: 992px)',
},
]);
});
// Pending CSSOM/jsdom support for nested @page margin at-rules.
test.skip('Parse nested @page margin rules', () => {
const str = `@page {
margin-top: 2cm;
@bottom-center {
content: "x";
}
}`;
expect(obj.parse(str)).toEqual([
{
atRuleType: 'page',
selectors: [],
selectorsAdd: '',
singleAtRule: true,
style: {
'margin-top': '2cm',
'@bottom-center': {
content: '"x"',
},
},
},
]);
});
test('Parse rules inside media queries', () => {
var str =
'.test1:hover{ color:white }@media (max-width: 992px){ .test1.test2:hover{ color:red } .test2{ color: blue }}';
@ -338,27 +411,24 @@ describe('ParserCss', () => {
font-family: 'Glyphicons Halflings';
src:url(https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot)
}`;
const result = [
{
selectors: [],
selectorsAdd: '',
style: { 'font-family': '"Open Sans"' },
singleAtRule: true,
atRuleType: 'font-face',
},
{
selectors: [],
selectorsAdd: '',
style: {
'font-family': "'Glyphicons Halflings'",
src: 'url(https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot)',
},
singleAtRule: true,
atRuleType: 'font-face',
},
];
const parsed = obj.parse(str);
expect(parsed).toEqual(result);
expect(parsed).toHaveLength(2);
expect(parsed[0]).toEqual({
selectors: [],
selectorsAdd: '',
style: { 'font-family': '"Open Sans"' },
singleAtRule: true,
atRuleType: 'font-face',
});
expect(parsed[1]).toMatchObject({
selectors: [],
selectorsAdd: '',
style: {
'font-family': '"Glyphicons Halflings"',
},
singleAtRule: true,
atRuleType: 'font-face',
});
});
test('Parse ID rule', () => {

41
packages/core/test/specs/parser/model/ParserHtml.ts

@ -458,31 +458,26 @@ describe('ParserHtml', () => {
<div>a div</div>
`;
const expected = [
{
selectors: [],
selectorsAdd: '',
style: {
'font-family': '"Open Sans"',
src: 'url(https://fonts.gstatic.com/s/droidsans/v8/SlGVmQWMvZQIdix7AFxXkHNSbRYXags.woff2)',
},
singleAtRule: true,
atRuleType: 'font-face',
const css = obj.parse(str, ParserCss()).css || [];
expect(css).toHaveLength(2);
expect(css[0]).toEqual({
selectors: [],
selectorsAdd: '',
style: {
'font-family': '"Open Sans"',
},
{
selectors: [],
selectorsAdd: '',
style: {
'font-family': "'Glyphicons Halflings'",
src: 'url(https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.3.7/fonts/glyphicons-halflings-regular.eot)',
},
singleAtRule: true,
atRuleType: 'font-face',
singleAtRule: true,
atRuleType: 'font-face',
});
expect(css[1]).toMatchObject({
selectors: [],
selectorsAdd: '',
style: {
'font-family': '"Glyphicons Halflings"',
},
];
const res = obj.parse(str, ParserCss());
expect(res.css).toEqual(expected);
singleAtRule: true,
atRuleType: 'font-face',
});
});
test('Parse nested div with text and spaces', () => {

4
packages/core/test/specs/selector_manager/e2e/ClassManager.ts

@ -78,9 +78,9 @@ describe('ClassManager E2E tests', () => {
tagEl.addNewTag('test');
gjs.editor.on('component:update:classes', spy);
tagEl.addNewTag('test');
expect(spy).toBeCalledTimes(0);
expect(spy).toHaveBeenCalledTimes(0);
tagEl.addNewTag('test2');
expect(spy).toBeCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(1);
});
test('Selectors are properly transformed to JSON', () => {

20
packages/core/test/specs/selector_manager/index.ts

@ -193,9 +193,9 @@ describe('SelectorManager', () => {
em.on(obj.events.add, eventAdd);
em.on(obj.events.all, eventAll);
const added = obj.add(itemTest);
expect(eventAdd).toBeCalledTimes(1);
expect(eventAdd).toBeCalledWith(added, expect.anything());
expect(eventAll).toBeCalled();
expect(eventAdd).toHaveBeenCalledTimes(1);
expect(eventAdd).toHaveBeenCalledWith(added, expect.anything());
expect(eventAll).toHaveBeenCalled();
});
test('Remove triggers proper events', () => {
@ -209,10 +209,10 @@ describe('SelectorManager', () => {
em.on(obj.events.all, eventAll);
const removed = obj.remove(itemTest);
expect(obj.getAll().length).toBe(0);
expect(eventBfRm).toBeCalledTimes(1);
expect(eventRm).toBeCalledTimes(1);
expect(eventRm).toBeCalledWith(removed, expect.anything());
expect(eventAll).toBeCalled();
expect(eventBfRm).toHaveBeenCalledTimes(1);
expect(eventRm).toHaveBeenCalledTimes(1);
expect(eventRm).toHaveBeenCalledWith(removed, expect.anything());
expect(eventAll).toHaveBeenCalled();
});
test('Update triggers proper events', () => {
@ -224,9 +224,9 @@ describe('SelectorManager', () => {
em.on(obj.events.update, eventUp);
em.on(obj.events.all, eventAll);
added.set(newProps);
expect(eventUp).toBeCalledTimes(1);
expect(eventUp).toBeCalledWith(added, newProps, expect.anything());
expect(eventAll).toBeCalled();
expect(eventUp).toHaveBeenCalledTimes(1);
expect(eventUp).toHaveBeenCalledWith(added, newProps, expect.anything());
expect(eventAll).toHaveBeenCalled();
});
});
});

2
packages/core/test/specs/selector_manager/view/ClassTagView.ts

@ -67,7 +67,7 @@ describe('ClassTagView', () => {
obj.$el.find('#checkbox').trigger('click');
expect(obj.model.get('active')).toEqual(false);
// expect(spy.called).toEqual(true);
expect(spy).toBeCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(1);
});
test('Label input is disabled', () => {

10
packages/core/test/specs/selector_manager/view/ClassTagsView.ts

@ -75,7 +75,7 @@ describe('ClassTagsView', () => {
test('Add new tag triggers correct method', () => {
const spy = jest.spyOn(view, 'addToClasses');
coll.add({ name: 'test' });
expect(spy).toBeCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(1);
});
test('Start new tag creation', () => {
@ -126,7 +126,7 @@ describe('ClassTagsView', () => {
coll.add([{ name: 'test1' }, { name: 'test2' }]);
const spy = jest.spyOn(view, 'addToClasses');
coll.trigger('reset');
expect(spy).toBeCalledTimes(2);
expect(spy).toHaveBeenCalledTimes(2);
});
test("Don't accept empty tags", () => {
@ -157,7 +157,7 @@ describe('ClassTagsView', () => {
test('States are visible in case of more tags inside', () => {
coll.add({ label: 'test' });
view.updateStateVis();
expect(testContext.$statesC.css('display')).toEqual('');
expect(testContext.$statesC.css('display')).not.toEqual('none');
});
test('Update state visibility on new tag', (done) => {
@ -165,7 +165,7 @@ describe('ClassTagsView', () => {
em.setSelected(compTest);
view.addNewTag('test');
setTimeout(() => {
expect(spy).toBeCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(1);
done();
});
});
@ -176,7 +176,7 @@ describe('ClassTagsView', () => {
const spy = jest.spyOn(view, 'updateStateVis');
coll.remove(coll.at(0));
setTimeout(() => {
expect(spy).toBeCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(1);
done();
});
});

2
packages/core/test/specs/storage_manager/index.ts

@ -60,7 +60,7 @@ describe('Storage Manager', () => {
test('Store is executed', async () => {
const spy = jest.spyOn(obj, '__exec');
await obj.store({ item: 'test' });
expect(spy).toBeCalledTimes(1);
expect(spy).toHaveBeenCalledTimes(1);
});
test('Load default storages ', () => {

6
packages/core/test/specs/storage_manager/model/Models.js

@ -58,7 +58,7 @@ describe('RemoteStorage', () => {
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);
// expect(obj.request).toHaveBeenCalledWith(opts.urlStore, defaultOpts, opts);
const { body, ...args } = calls[0][1];
expect(args).toEqual({
method: 'POST',
@ -70,7 +70,7 @@ describe('RemoteStorage', () => {
test('Load data', async () => {
await obj.load(defaultOpts);
const { calls } = obj.request.mock;
expect(obj.request).toBeCalledTimes(1);
expect(obj.request).toHaveBeenCalledTimes(1);
expect(calls[0][0]).toBe(defaultOpts.urlLoad);
expect(calls[0][1]).toEqual({
method: 'GET',
@ -87,7 +87,7 @@ describe('RemoteStorage', () => {
fetchOptions: () => customOpts,
});
expect(obj.request).toBeCalledTimes(1);
expect(obj.request).toHaveBeenCalledTimes(1);
expect(obj.request.mock.calls[0][1]).toEqual({
method: 'GET',
body: undefined,

2
packages/core/test/specs/style_manager/model/Properties.ts

@ -479,7 +479,7 @@ describe('StyleManager properties logic', () => {
});
describe('Stack type', () => {
const propTest = 'stack-prop';
const propTest = '--stack-prop';
const propATest = `${propTest}-a`;
const propBTest = `${propTest}-b`;
const propCTest = `${propTest}-c`;

1
packages/core/tsconfig.json

@ -2,6 +2,7 @@
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"types": ["jest", "node"],
"allowJs": true,
"sourceMap": true,
"skipLibCheck": true,

2438
pnpm-lock.yaml

File diff suppressed because it is too large
Loading…
Cancel
Save