mirror of https://github.com/artf/grapesjs.git
Browse Source
* Fix saving dynamic values for cssRules * Allow using collectionStateMap for styleable models * Fix bug with undo manager * refactor resolverWatcher * Allow CssRules to use collection variable * Fix undoManager issue for binding data to a component * update returned value for adding props * Revert a change to fix tests * move test files * Add tests for undomanager with datasources * update clone and toJSON for cssRule * Refactor Model data resolver * update test setup function * Refactor datasources logic to styleable model * Add clone and update toJSON in styleableModel * Refactor CssRule to use styleableModel methods * Add undomanager to datasources * refactor component class to use styleableModel methods * update unit tests for undo manager * Refactor data resolver watcher * Fix undoManager in test enviroment * Remove destroy test editor * Update Data resolver watchers * Remove setTimeout from undo manager unit tests * Fix Selection tracking tests * Fix missing id in `component.toJSON()` * Fix styles as string for cssRules * Fix CssRule type * Add string style support for ModelResolver.getProps() * Cleanup ( rename dynamic to data resolver ) * Use fake timers in undo manager tests * Remove checking duplicated object in undomanager registry * Fix typescript checks * Fix lintpull/6604/head
committed by
GitHub
20 changed files with 920 additions and 239 deletions
@ -0,0 +1,248 @@ |
|||
import { Component, DataSourceManager, Editor } from '../../../src'; |
|||
import { DataConditionType } from '../../../src/data_sources/model/conditional_variables/DataCondition'; |
|||
import { StringOperation } from '../../../src/data_sources/model/conditional_variables/operators/StringOperator'; |
|||
import { DataVariableType } from '../../../src/data_sources/model/DataVariable'; |
|||
import UndoManager from '../../../src/undo_manager'; |
|||
import { setupTestEditor } from '../../common'; |
|||
|
|||
describe('Undo Manager with Data Binding', () => { |
|||
let editor: Editor; |
|||
let um: UndoManager; |
|||
let wrapper: Component; |
|||
let dsm: DataSourceManager; |
|||
|
|||
const makeColorVar = () => ({ |
|||
type: DataVariableType, |
|||
path: 'ds1.rec1.color', |
|||
}); |
|||
const makeTitleVar = () => ({ |
|||
type: DataVariableType, |
|||
path: 'ds1.rec1.title', |
|||
}); |
|||
const makeContentVar = () => ({ |
|||
type: DataVariableType, |
|||
path: 'ds1.rec1.content', |
|||
}); |
|||
|
|||
beforeEach(() => { |
|||
({ editor, um, dsm } = setupTestEditor({ withCanvas: true })); |
|||
wrapper = editor.getWrapper()!; |
|||
dsm.add({ |
|||
id: 'ds1', |
|||
records: [{ id: 'rec1', color: 'red', title: 'Initial Title', content: 'Initial Content' }], |
|||
}); |
|||
jest.useFakeTimers(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
editor.destroy(); |
|||
}); |
|||
|
|||
describe('Initial State with Data Binding', () => { |
|||
it('should correctly initialize with a component having data-bound properties', () => { |
|||
const component = wrapper.append({ |
|||
style: { color: makeColorVar() }, |
|||
attributes: { title: makeTitleVar() }, |
|||
content: makeContentVar(), |
|||
})[0]; |
|||
|
|||
expect(um.getStackGroup()).toHaveLength(1); |
|||
um.undo(); |
|||
um.redo(); |
|||
expect(component.getStyle().color).toBe('red'); |
|||
expect(component.getAttributes().title).toBe('Initial Title'); |
|||
expect(component.get('content')).toBe('Initial Content'); |
|||
expect(um.getStackGroup()).toHaveLength(1); |
|||
}); |
|||
}); |
|||
|
|||
describe('Core Undo/Redo on Component Data Binding', () => { |
|||
describe('Styles', () => { |
|||
it('should undo and redo the assignment of a data value to a style', () => { |
|||
const component = wrapper.append({ |
|||
content: makeContentVar(), |
|||
style: { color: 'blue', 'font-size': '12px' }, |
|||
})[0]; |
|||
|
|||
jest.runAllTimers(); |
|||
um.clear(); |
|||
component.setStyle({ color: makeColorVar() }); |
|||
expect(component.getStyle().color).toBe('red'); |
|||
expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar()); |
|||
|
|||
um.undo(); |
|||
expect(component.getStyle().color).toBe('blue'); |
|||
expect(component.getStyle({ skipResolve: true }).color).toBe('blue'); |
|||
|
|||
um.redo(); |
|||
expect(component.getStyle().color).toBe('red'); |
|||
expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar()); |
|||
}); |
|||
|
|||
it('should handle binding with a data-condition value', () => { |
|||
const component = wrapper.append({ content: 'some content', style: { color: 'blue' } })[0]; |
|||
const conditionVar = { |
|||
type: DataConditionType, |
|||
condition: { left: makeTitleVar(), operator: StringOperation.contains, right: 'Initial' }, |
|||
ifTrue: 'green', |
|||
ifFalse: 'purple', |
|||
}; |
|||
|
|||
jest.runAllTimers(); |
|||
um.clear(); |
|||
|
|||
component.addStyle({ color: conditionVar }); |
|||
expect(component.getStyle().color).toBe('green'); |
|||
|
|||
um.undo(); |
|||
expect(component.getStyle().color).toBe('blue'); |
|||
|
|||
um.redo(); |
|||
expect(component.getStyle().color).toBe('green'); |
|||
}); |
|||
}); |
|||
|
|||
describe('Attributes', () => { |
|||
it('should undo and redo the assignment of a data value to an attribute', () => { |
|||
const component = wrapper.append({ attributes: { title: 'Static Title' } })[0]; |
|||
|
|||
jest.runAllTimers(); |
|||
um.clear(); |
|||
|
|||
component.setAttributes({ title: makeTitleVar() }); |
|||
expect(component.getAttributes().title).toBe('Initial Title'); |
|||
|
|||
um.undo(); |
|||
expect(component.getAttributes().title).toBe('Static Title'); |
|||
|
|||
um.redo(); |
|||
expect(component.getAttributes().title).toBe('Initial Title'); |
|||
}); |
|||
}); |
|||
|
|||
describe('Properties', () => { |
|||
it('should undo and redo the assignment of a data value to a property', () => { |
|||
const component = wrapper.append({ content: 'Static Content' })[0]; |
|||
|
|||
jest.runAllTimers(); |
|||
um.clear(); |
|||
|
|||
component.set({ content: makeContentVar() }); |
|||
expect(component.get('content')).toBe('Initial Content'); |
|||
|
|||
um.undo(); |
|||
expect(component.get('content')).toBe('Static Content'); |
|||
|
|||
um.redo(); |
|||
expect(component.get('content')).toBe('Initial Content'); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('Value Overwriting Scenarios', () => { |
|||
it('should correctly undo a static style that overwrites a data binding', () => { |
|||
const component = wrapper.append({ |
|||
style: { color: makeColorVar() }, |
|||
attributes: { title: 'Static Title' }, |
|||
})[0]; |
|||
|
|||
jest.runAllTimers(); |
|||
um.clear(); |
|||
|
|||
component.addStyle({ color: 'green' }); |
|||
expect(component.getStyle().color).toBe('green'); |
|||
|
|||
um.undo(); |
|||
expect(component.getStyle().color).toBe('red'); |
|||
expect(component.getAttributes().title).toBe('Static Title'); |
|||
}); |
|||
|
|||
it('should correctly undo a data binding that overwrites a static style', () => { |
|||
const component = wrapper.append({ style: { color: 'green' } })[0]; |
|||
|
|||
jest.runAllTimers(); |
|||
um.clear(); |
|||
|
|||
component.addStyle({ color: makeColorVar() }); |
|||
expect(component.getStyle().color).toBe('red'); |
|||
|
|||
um.undo(); |
|||
expect(component.getStyle().color).toBe('green'); |
|||
}); |
|||
}); |
|||
|
|||
describe('Listeners & Data Source Integrity', () => { |
|||
it('should maintain listeners after a binding is restored via undo', () => { |
|||
const component = wrapper.append({ style: { color: makeColorVar() } })[0]; |
|||
|
|||
jest.runAllTimers(); |
|||
um.clear(); |
|||
|
|||
component.addStyle({ color: 'green' }); |
|||
expect(component.getStyle().color).toBe('green'); |
|||
|
|||
um.undo(); |
|||
expect(component.getStyle().color).toBe('red'); |
|||
|
|||
dsm.get('ds1').getRecord('rec1')!.set('color', 'purple'); |
|||
expect(component.getStyle().color).toBe('purple'); |
|||
}); |
|||
|
|||
it('should handle undo when the data source has been removed', () => { |
|||
const component = wrapper.append({ style: { color: makeColorVar() } })[0]; |
|||
expect(component.getStyle().color).toBe('red'); |
|||
|
|||
jest.runAllTimers(); |
|||
um.clear(); |
|||
|
|||
dsm.remove('ds1'); |
|||
expect(component.getStyle().color).toBeUndefined(); |
|||
|
|||
um.undo(); |
|||
expect(dsm.get('ds1')).toBeTruthy(); |
|||
expect(component.getStyle().color).toBe('red'); |
|||
}); |
|||
}); |
|||
|
|||
describe('Serialization & Cloning', () => { |
|||
let component: any; |
|||
|
|||
beforeEach(() => { |
|||
component = wrapper.append({ |
|||
style: { color: makeColorVar() }, |
|||
attributes: { title: makeTitleVar() }, |
|||
content: makeContentVar(), |
|||
})[0]; |
|||
}); |
|||
|
|||
it('should correctly serialize data bindings in toJSON()', () => { |
|||
const json = component.toJSON(); |
|||
expect(json.attributes.title).toEqual(makeTitleVar()); |
|||
expect(json.__dynamicProps).toBeUndefined(); |
|||
}); |
|||
|
|||
it('should correctly clone data bindings', () => { |
|||
const clone = component.clone(); |
|||
expect(clone.getStyle('', { skipResolve: true }).color).toEqual(makeColorVar()); |
|||
expect(clone.getAttributes({ skipResolve: true }).title).toEqual(makeTitleVar()); |
|||
expect(clone.get('content', { skipResolve: true })).toEqual(makeContentVar()); |
|||
expect(clone.getStyle().color).toBe('red'); |
|||
}); |
|||
|
|||
it('should ensure a cloned component has an independent undo history', () => { |
|||
const clone = component.clone(); |
|||
wrapper.append(clone); |
|||
|
|||
jest.runAllTimers(); |
|||
um.clear(); |
|||
|
|||
component.addStyle({ color: 'blue' }); |
|||
expect(um.hasUndo()).toBe(true); |
|||
expect(clone.getStyle().color).toBe('red'); |
|||
|
|||
um.undo(); |
|||
expect(component.getStyle().color).toBe('red'); |
|||
expect(clone.getStyle().color).toBe('red'); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,286 @@ |
|||
import UndoManager from '../../../src/undo_manager'; |
|||
import Editor from '../../../src/editor'; |
|||
import { setupTestEditor } from '../../common'; |
|||
|
|||
describe('Undo Manager', () => { |
|||
let editor: Editor; |
|||
let um: UndoManager; |
|||
let wrapper: any; |
|||
|
|||
beforeEach(() => { |
|||
({ editor, um } = setupTestEditor({ |
|||
withCanvas: true, |
|||
})); |
|||
wrapper = editor.getWrapper(); |
|||
um.clear(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
editor.destroy(); |
|||
}); |
|||
|
|||
test('Initial state is correct', () => { |
|||
expect(um.hasUndo()).toBe(false); |
|||
expect(um.hasRedo()).toBe(false); |
|||
expect(um.getStack()).toHaveLength(0); |
|||
}); |
|||
|
|||
describe('Component changes', () => { |
|||
test('Add component', () => { |
|||
expect(wrapper.components()).toHaveLength(0); |
|||
wrapper.append('<div></div>'); |
|||
expect(wrapper.components()).toHaveLength(1); |
|||
expect(um.hasUndo()).toBe(true); |
|||
|
|||
um.undo(); |
|||
expect(wrapper.components()).toHaveLength(0); |
|||
expect(um.hasRedo()).toBe(true); |
|||
|
|||
um.redo(); |
|||
expect(wrapper.components()).toHaveLength(1); |
|||
}); |
|||
|
|||
test('Remove component', () => { |
|||
const comp = wrapper.append('<div></div>')[0]; |
|||
expect(wrapper.components()).toHaveLength(1); |
|||
um.clear(); |
|||
|
|||
comp.remove(); |
|||
expect(wrapper.components()).toHaveLength(0); |
|||
expect(um.hasUndo()).toBe(true); |
|||
|
|||
um.undo(); |
|||
expect(wrapper.components()).toHaveLength(1); |
|||
expect(um.hasRedo()).toBe(true); |
|||
|
|||
um.redo(); |
|||
expect(wrapper.components()).toHaveLength(0); |
|||
}); |
|||
|
|||
test('Modify component properties', () => { |
|||
const comp = wrapper.append({ tagName: 'div', content: 'test' })[0]; |
|||
um.clear(); |
|||
|
|||
comp.set('content', 'test2'); |
|||
expect(comp.get('content')).toBe('test2'); |
|||
expect(um.hasUndo()).toBe(true); |
|||
|
|||
um.undo(); |
|||
expect(comp.get('content')).toBe('test'); |
|||
|
|||
um.redo(); |
|||
expect(comp.get('content')).toBe('test2'); |
|||
}); |
|||
|
|||
test('Modify component style (StyleManager)', () => { |
|||
const comp = wrapper.append('<div></div>')[0]; |
|||
|
|||
um.clear(); |
|||
comp.addStyle({ color: 'red' }); |
|||
expect(comp.getStyle().color).toBe('red'); |
|||
expect(um.hasUndo()).toBe(true); |
|||
|
|||
um.undo(); |
|||
expect(comp.getStyle().color).toBeUndefined(); |
|||
|
|||
um.redo(); |
|||
expect(comp.getStyle().color).toBe('red'); |
|||
}); |
|||
|
|||
test('Move component', () => { |
|||
wrapper.append('<div>1</div><div>2</div>'); |
|||
const comp1 = wrapper.components().at(0); |
|||
const comp2 = wrapper.components().at(1); |
|||
|
|||
um.clear(); |
|||
|
|||
wrapper.append(comp1, { at: 2 }); |
|||
expect(wrapper.components().at(0)).toBe(comp2); |
|||
expect(wrapper.components().at(1)).toBe(comp1); |
|||
expect(um.hasUndo()).toBe(true); |
|||
|
|||
um.undo(); |
|||
expect(wrapper.components().at(0)).toBe(comp1); |
|||
expect(wrapper.components().at(1)).toBe(comp2); |
|||
|
|||
um.redo(); |
|||
expect(wrapper.components().at(0)).toBe(comp2); |
|||
expect(wrapper.components().at(1)).toBe(comp1); |
|||
}); |
|||
|
|||
test('Grouped component additions are treated as one undo action', () => { |
|||
wrapper.append('<div>1</div><div>2</div>'); |
|||
|
|||
expect(wrapper.components()).toHaveLength(2); |
|||
expect(um.getStackGroup()).toHaveLength(1); |
|||
|
|||
um.undo(); |
|||
expect(wrapper.components()).toHaveLength(0); |
|||
}); |
|||
}); |
|||
|
|||
describe('CSS Rule changes', () => { |
|||
test('Add CSS Rule', () => { |
|||
editor.Css.addRules('.test { color: red; }'); |
|||
|
|||
expect(editor.Css.getRules('.test')).toHaveLength(1); |
|||
|
|||
expect(um.hasUndo()).toBe(true); |
|||
|
|||
um.undo(); |
|||
expect(editor.Css.getRules('.test')).toHaveLength(0); |
|||
|
|||
um.redo(); |
|||
expect(editor.Css.getRules('.test')).toHaveLength(1); |
|||
expect(editor.Css.getRule('.test')?.getStyle().color).toBe('red'); |
|||
}); |
|||
|
|||
test('Modify CSS Rule', () => { |
|||
const rule = editor.Css.addRules('.test { color: red; }')[0]; |
|||
|
|||
um.clear(); |
|||
|
|||
rule.setStyle({ color: 'blue' }); |
|||
expect(rule.getStyle().color).toBe('blue'); |
|||
expect(um.hasUndo()).toBe(true); |
|||
|
|||
um.undo(); |
|||
expect(rule.getStyle().color).toBe('red'); |
|||
|
|||
um.redo(); |
|||
expect(rule.getStyle().color).toBe('blue'); |
|||
}); |
|||
|
|||
test('Remove CSS Rule', () => { |
|||
const rule = editor.Css.addRules('.test { color: red; }')[0]; |
|||
|
|||
um.clear(); |
|||
|
|||
editor.Css.remove(rule); |
|||
expect(editor.Css.getRules('.test')).toHaveLength(0); |
|||
expect(um.hasUndo()).toBe(true); |
|||
|
|||
um.undo(); |
|||
expect(editor.Css.getRules('.test')).toHaveLength(1); |
|||
|
|||
um.redo(); |
|||
expect(editor.Css.getRules('.test')).toHaveLength(0); |
|||
}); |
|||
}); |
|||
|
|||
// TODO: add undo_manager to asset manager
|
|||
describe.skip('Asset Manager changes', () => { |
|||
test('Add asset', () => { |
|||
const am = editor.Assets; |
|||
expect(am.getAll()).toHaveLength(0); |
|||
|
|||
um.clear(); |
|||
|
|||
am.add('path/to/img.jpg'); |
|||
expect(am.getAll()).toHaveLength(1); |
|||
expect(um.hasUndo()).toBe(true); |
|||
|
|||
um.undo(); |
|||
expect(am.getAll()).toHaveLength(0); |
|||
|
|||
um.redo(); |
|||
expect(am.getAll()).toHaveLength(1); |
|||
expect(am.get('path/to/img.jpg')).toBeTruthy(); |
|||
}); |
|||
|
|||
test('Remove asset', () => { |
|||
const am = editor.Assets; |
|||
const asset = am.add('path/to/img.jpg'); |
|||
expect(am.getAll()).toHaveLength(1); |
|||
|
|||
um.clear(); |
|||
|
|||
am.remove(asset); |
|||
expect(am.getAll()).toHaveLength(0); |
|||
expect(um.hasUndo()).toBe(true); |
|||
|
|||
um.undo(); |
|||
expect(am.getAll()).toHaveLength(1); |
|||
|
|||
um.redo(); |
|||
expect(am.getAll()).toHaveLength(0); |
|||
}); |
|||
}); |
|||
|
|||
// TODO: add undo_manager to editor
|
|||
describe.skip('Editor states changes', () => { |
|||
test('Device change', () => { |
|||
editor.Devices.add({ id: 'tablet', name: 'Tablet', width: 'auto' }); |
|||
|
|||
um.clear(); |
|||
|
|||
editor.setDevice('Tablet'); |
|||
expect(editor.getDevice()).toBe('Tablet'); |
|||
expect(um.hasUndo()).toBe(true); |
|||
|
|||
um.undo(); |
|||
// Default device is an empty string
|
|||
expect(editor.getDevice()).toBe(''); |
|||
|
|||
um.redo(); |
|||
expect(editor.getDevice()).toBe('Tablet'); |
|||
}); |
|||
|
|||
test('Panel visibility change', () => { |
|||
const panel = editor.Panels.getPanel('options')!; |
|||
panel.set('visible', true); |
|||
|
|||
um.clear(); |
|||
|
|||
panel.set('visible', false); |
|||
expect(panel.get('visible')).toBe(false); |
|||
expect(um.hasUndo()).toBe(true); |
|||
|
|||
um.undo(); |
|||
expect(panel.get('visible')).toBe(true); |
|||
|
|||
um.redo(); |
|||
expect(panel.get('visible')).toBe(false); |
|||
}); |
|||
}); |
|||
|
|||
describe('Selection tracking', () => { |
|||
test('Change selection', (done) => { |
|||
const comp1 = wrapper.append('<div>1</div>')[0]; |
|||
const comp2 = wrapper.append('<div>2</div>')[0]; |
|||
|
|||
um.clear(); |
|||
editor.select(comp1); |
|||
expect(editor.getSelected()).toBe(comp1); |
|||
|
|||
setTimeout(() => { |
|||
editor.select(comp2); |
|||
expect(editor.getSelected()).toBe(comp2); |
|||
expect(um.hasUndo()).toBe(true); |
|||
um.undo(); |
|||
expect(editor.getSelected()).toBe(comp1); |
|||
um.redo(); |
|||
expect(editor.getSelected()).toBe(comp2); |
|||
done(); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('Operations with `noUndo`', () => { |
|||
test('Skipping undo for component modification', () => { |
|||
const comp = wrapper.append('<div></div>')[0]; |
|||
|
|||
um.clear(); |
|||
|
|||
comp.set('content', 'no undo content', { noUndo: true }); |
|||
expect(um.hasUndo()).toBe(false); |
|||
|
|||
wrapper.append('<div>undo this</div>'); |
|||
expect(um.hasUndo()).toBe(true); |
|||
|
|||
um.undo(); |
|||
expect(wrapper.components()).toHaveLength(1); |
|||
expect(wrapper.components().at(0).get('content')).toBe('no undo content'); |
|||
}); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue