mirror of https://github.com/artf/grapesjs.git
committed by
GitHub
80 changed files with 3302 additions and 941 deletions
@ -0,0 +1,13 @@ |
|||
export interface DataSourcesConfig { |
|||
/** |
|||
* If true, data source providers will be autoloaded on project load. |
|||
* @default false |
|||
*/ |
|||
autoloadProviders?: boolean; |
|||
} |
|||
|
|||
const config: () => DataSourcesConfig = () => ({ |
|||
autoloadProviders: false, |
|||
}); |
|||
|
|||
export default config; |
|||
@ -0,0 +1,124 @@ |
|||
import { DataCollectionStateMap } from '../../data_sources/model/data_collection/types'; |
|||
import DataResolverListener from '../../data_sources/model/DataResolverListener'; |
|||
import DataVariable, { DataVariableProps, DataVariableType } from '../../data_sources/model/DataVariable'; |
|||
import Components from '../../dom_components/model/Components'; |
|||
import Component from '../../dom_components/model/Component'; |
|||
import { ObjectAny } from '../../common'; |
|||
import DataSource from './DataSource'; |
|||
import { isArray } from 'underscore'; |
|||
|
|||
export type DataVariableMap = Record<string, DataVariableProps>; |
|||
|
|||
export type DataSourceRecords = DataVariableProps[] | DataVariableMap; |
|||
|
|||
export default class ComponentWithCollectionsState<DataResolverType> extends Component { |
|||
collectionsStateMap: DataCollectionStateMap = {}; |
|||
dataSourceWatcher?: DataResolverListener; |
|||
|
|||
constructor(props: any, opt: any) { |
|||
super(props, opt); |
|||
this.listenToPropsChange(); |
|||
} |
|||
|
|||
onCollectionsStateMapUpdate(collectionsStateMap: DataCollectionStateMap) { |
|||
this.collectionsStateMap = collectionsStateMap; |
|||
this.dataResolverWatchers?.onCollectionsStateMapUpdate?.(); |
|||
|
|||
this.components().forEach((cmp) => { |
|||
cmp.onCollectionsStateMapUpdate?.(collectionsStateMap); |
|||
}); |
|||
} |
|||
|
|||
syncOnComponentChange(model: Component, collection: Components, opts: any) { |
|||
const prev = this.collectionsStateMap; |
|||
this.collectionsStateMap = {}; |
|||
super.syncOnComponentChange(model, collection, opts); |
|||
this.collectionsStateMap = prev; |
|||
this.onCollectionsStateMapUpdate(prev); |
|||
} |
|||
|
|||
setDataResolver(dataResolver: DataResolverType | undefined) { |
|||
return this.set('dataResolver', dataResolver); |
|||
} |
|||
|
|||
getDataResolver() { |
|||
return this.dataResolverProps; |
|||
} |
|||
|
|||
get dataResolverProps(): DataResolverType | undefined { |
|||
return this.get('dataResolver'); |
|||
} |
|||
|
|||
protected listenToDataSource() { |
|||
const path = this.dataResolverPath; |
|||
if (!path) return; |
|||
|
|||
const { em, collectionsStateMap } = this; |
|||
this.dataSourceWatcher?.destroy(); |
|||
this.dataSourceWatcher = new DataResolverListener({ |
|||
em, |
|||
resolver: new DataVariable({ type: DataVariableType, path }, { em, collectionsStateMap }), |
|||
onUpdate: () => this.onDataSourceChange(), |
|||
}); |
|||
} |
|||
|
|||
protected listenToPropsChange() { |
|||
this.on(`change:dataResolver`, () => { |
|||
this.listenToDataSource(); |
|||
}); |
|||
|
|||
this.listenToDataSource(); |
|||
} |
|||
|
|||
protected get dataSourceProps(): DataVariableProps | undefined { |
|||
return this.get('dataResolver'); |
|||
} |
|||
|
|||
protected get dataResolverPath(): string | undefined { |
|||
return this.dataSourceProps?.path; |
|||
} |
|||
|
|||
protected onDataSourceChange() { |
|||
this.onCollectionsStateMapUpdate(this.collectionsStateMap); |
|||
} |
|||
|
|||
protected getDataSourceItems(): DataSourceRecords { |
|||
const dataSourceProps = this.dataSourceProps; |
|||
if (!dataSourceProps) return []; |
|||
const items = this.listDataSourceItems(dataSourceProps); |
|||
if (items && isArray(items)) { |
|||
return items; |
|||
} |
|||
|
|||
const clone = { ...items }; |
|||
return clone; |
|||
} |
|||
|
|||
protected listDataSourceItems(dataSource: DataSource | DataVariableProps): DataSourceRecords { |
|||
const path = dataSource instanceof DataSource ? dataSource.get('id')! : dataSource.path; |
|||
if (!path) return []; |
|||
let value = this.em.DataSources.getValue(path, []); |
|||
|
|||
const isDatasourceId = path.split('.').length === 1; |
|||
if (isDatasourceId) { |
|||
value = Object.entries(value).map(([_, value]) => value); |
|||
} |
|||
|
|||
return value; |
|||
} |
|||
|
|||
protected getItemKey(items: DataVariableProps[] | { [x: string]: DataVariableProps }, index: number) { |
|||
return isArray(items) ? index : Object.keys(items)[index]; |
|||
} |
|||
|
|||
private removePropsListeners() { |
|||
this.off(`change:dataResolver`); |
|||
this.dataSourceWatcher?.destroy(); |
|||
this.dataSourceWatcher = undefined; |
|||
} |
|||
|
|||
destroy(options?: ObjectAny): false | JQueryXHR { |
|||
this.removePropsListeners(); |
|||
return super.destroy(options); |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export const keyRootData = '__rootData'; |
|||
@ -0,0 +1,28 @@ |
|||
/**{START_EVENTS}*/ |
|||
export enum KeymapsEvents { |
|||
/** |
|||
* @event `keymap:add` New keymap added. The new keymap object is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('keymap:add', (keymap) => { ... }); |
|||
*/ |
|||
add = 'keymap:add', |
|||
|
|||
/** |
|||
* @event `keymap:remove` Keymap removed. The removed keymap object is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('keymap:remove', (keymap) => { ... }); |
|||
*/ |
|||
remove = 'keymap:remove', |
|||
|
|||
/** |
|||
* @event `keymap:emit` Some keymap emitted. The keymapId, shortcutUsed, and Event are passed as arguments to the callback. |
|||
* @example |
|||
* editor.on('keymap:emit', (keymapId, shortcutUsed, event) => { ... }); |
|||
*/ |
|||
emit = 'keymap:emit', |
|||
emitId = 'keymap:emit:', |
|||
} |
|||
/**{END_EVENTS}*/ |
|||
|
|||
// need this to avoid the TS documentation generator to break
|
|||
export default KeymapsEvents; |
|||
@ -0,0 +1,27 @@ |
|||
/**{START_EVENTS}*/ |
|||
export enum ModalEvents { |
|||
/** |
|||
* @event `modal:open` Modal is opened |
|||
* @example |
|||
* editor.on('modal:open', () => { ... }); |
|||
*/ |
|||
open = 'modal:open', |
|||
|
|||
/** |
|||
* @event `modal:close` Modal is closed |
|||
* @example |
|||
* editor.on('modal:close', () => { ... }); |
|||
*/ |
|||
close = 'modal:close', |
|||
|
|||
/** |
|||
* @event `modal` Event triggered on any change related to the modal. An object containing all the available data about the triggered event is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('modal', ({ open, title, content, ... }) => { ... }); |
|||
*/ |
|||
all = 'modal', |
|||
} |
|||
/**{END_EVENTS}*/ |
|||
|
|||
// need this to avoid the TS documentation generator to break
|
|||
export default ModalEvents; |
|||
@ -0,0 +1,39 @@ |
|||
import Component from '../dom_components/model/Component'; |
|||
|
|||
export interface LayerData { |
|||
name: string; |
|||
open: boolean; |
|||
selected: boolean; |
|||
hovered: boolean; |
|||
visible: boolean; |
|||
locked: boolean; |
|||
components: Component[]; |
|||
} |
|||
|
|||
/**{START_EVENTS}*/ |
|||
export enum LayerEvents { |
|||
/** |
|||
* @event `layer:root` Root layer changed. The new root component is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('layer:root', (component) => { ... }); |
|||
*/ |
|||
root = 'layer:root', |
|||
|
|||
/** |
|||
* @event `layer:component` Component layer is updated. The updated component is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('layer:component', (component, opts) => { ... }); |
|||
*/ |
|||
component = 'layer:component', |
|||
|
|||
/** |
|||
* @event `layer:custom` Custom layer event. Object with container and root is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('layer:custom', ({ container, root }) => { ... }); |
|||
*/ |
|||
custom = 'layer:custom', |
|||
} |
|||
/**{END_EVENTS}*/ |
|||
|
|||
// need this to avoid the TS documentation generator to break
|
|||
export default LayerEvents; |
|||
@ -0,0 +1,39 @@ |
|||
import ComponentTextView from '../dom_components/view/ComponentTextView'; |
|||
|
|||
export interface ModelRTE { |
|||
currentView?: ComponentTextView; |
|||
} |
|||
|
|||
export type RichTextEditorEvent = `${RichTextEditorEvents}`; |
|||
|
|||
export interface RteDisableResult { |
|||
forceSync?: boolean; |
|||
} |
|||
|
|||
/**{START_EVENTS}*/ |
|||
export enum RichTextEditorEvents { |
|||
/** |
|||
* @event `rte:enable` RTE enabled. The view, on which RTE is enabled, and the RTE instance are passed as arguments. |
|||
* @example |
|||
* editor.on('rte:enable', (view, rte) => { ... }); |
|||
*/ |
|||
enable = 'rte:enable', |
|||
|
|||
/** |
|||
* @event `rte:disable` RTE disabled. The view, on which RTE is disabled, and the RTE instance are passed as arguments. |
|||
* @example |
|||
* editor.on('rte:disable', (view, rte) => { ... }); |
|||
*/ |
|||
disable = 'rte:disable', |
|||
|
|||
/** |
|||
* @event `rte:custom` Custom RTE event. Object with enabled status, container, and actions is passed as an argument. |
|||
* @example |
|||
* editor.on('rte:custom', ({ enabled, container, actions }) => { ... }); |
|||
*/ |
|||
custom = 'rte:custom', |
|||
} |
|||
/**{END_EVENTS}*/ |
|||
|
|||
// need this to avoid the TS documentation generator to break
|
|||
export default RichTextEditorEvents; |
|||
@ -0,0 +1,57 @@ |
|||
/**{START_EVENTS}*/ |
|||
export enum SelectorEvents { |
|||
/** |
|||
* @event `selector:add` Selector added. The Selector is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('selector:add', (selector) => { ... }); |
|||
*/ |
|||
add = 'selector:add', |
|||
|
|||
/** |
|||
* @event `selector:remove` Selector removed. The Selector is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('selector:remove', (selector) => { ... }); |
|||
*/ |
|||
remove = 'selector:remove', |
|||
|
|||
/** |
|||
* @event `selector:remove:before` Before selector remove. The Selector is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('selector:remove:before', (selector) => { ... }); |
|||
*/ |
|||
removeBefore = 'selector:remove:before', |
|||
|
|||
/** |
|||
* @event `selector:update` Selector updated. The Selector and the object containing changes are passed as arguments to the callback. |
|||
* @example |
|||
* editor.on('selector:update', (selector, changes) => { ... }); |
|||
*/ |
|||
update = 'selector:update', |
|||
|
|||
/** |
|||
* @event `selector:state` States changed. An object containing all the available data about the triggered event is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('selector:state', (state) => { ... }); |
|||
*/ |
|||
state = 'selector:state', |
|||
|
|||
/** |
|||
* @event `selector:custom` Custom selector event. An object containing states, selected selectors, and container is passed as an argument. |
|||
* @example |
|||
* editor.on('selector:custom', ({ states, selected, container }) => { ... }); |
|||
*/ |
|||
custom = 'selector:custom', |
|||
|
|||
/** |
|||
* @event `selector` Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('selector', ({ event, selector, changes, ... }) => { ... }); |
|||
*/ |
|||
all = 'selector', |
|||
} |
|||
/**{END_EVENTS}*/ |
|||
|
|||
export type SelectorStringObject = string | { name?: string; label?: string; type?: number }; |
|||
|
|||
// need this to avoid the TS documentation generator to break
|
|||
export default SelectorEvents; |
|||
@ -0,0 +1,88 @@ |
|||
import StyleManager from '.'; |
|||
import StyleableModel from '../domain_abstract/model/StyleableModel'; |
|||
import { PropertyNumberProps } from './model/PropertyNumber'; |
|||
import { PropertySelectProps } from './model/PropertySelect'; |
|||
import { PropertyStackProps } from './model/PropertyStack'; |
|||
|
|||
export type PropertyTypes = PropertyStackProps | PropertySelectProps | PropertyNumberProps; |
|||
|
|||
export type StyleTarget = StyleableModel; |
|||
|
|||
export type StyleModuleParam<T extends keyof StyleManager, N extends number> = Parameters<StyleManager[T]>[N]; |
|||
|
|||
/**{START_EVENTS}*/ |
|||
export enum StyleManagerEvents { |
|||
/** |
|||
* @event `style:sector:add` Sector added. The Sector is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('style:sector:add', (sector) => { ... }); |
|||
*/ |
|||
sectorAdd = 'style:sector:add', |
|||
|
|||
/** |
|||
* @event `style:sector:remove` Sector removed. The Sector is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('style:sector:remove', (sector) => { ... }); |
|||
*/ |
|||
sectorRemove = 'style:sector:remove', |
|||
|
|||
/** |
|||
* @event `style:sector:update` Sector updated. The Sector and the object containing changes are passed as arguments to the callback. |
|||
* @example |
|||
* editor.on('style:sector:update', (sector, changes) => { ... }); |
|||
*/ |
|||
sectorUpdate = 'style:sector:update', |
|||
|
|||
/** |
|||
* @event `style:property:add` Property added. The Property is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('style:property:add', (property) => { ... }); |
|||
*/ |
|||
propertyAdd = 'style:property:add', |
|||
|
|||
/** |
|||
* @event `style:property:remove` Property removed. The Property is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('style:property:remove', (property) => { ... }); |
|||
*/ |
|||
propertyRemove = 'style:property:remove', |
|||
|
|||
/** |
|||
* @event `style:property:update` Property updated. The Property and the object containing changes are passed as arguments to the callback. |
|||
* @example |
|||
* editor.on('style:property:update', (property, changes) => { ... }); |
|||
*/ |
|||
propertyUpdate = 'style:property:update', |
|||
|
|||
/** |
|||
* @event `style:target` Target selection changed. The target (or null in case the target is deselected) is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('style:target', (target) => { ... }); |
|||
*/ |
|||
target = 'style:target', |
|||
|
|||
/** |
|||
* @event `style:layer:select` Layer selected. Object containing layer data is passed as an argument. |
|||
* @example |
|||
* editor.on('style:layer:select', (data) => { ... }); |
|||
*/ |
|||
layerSelect = 'style:layer:select', |
|||
|
|||
/** |
|||
* @event `style:custom` Custom style event. Object containing all custom data is passed as an argument. |
|||
* @example |
|||
* editor.on('style:custom', ({ container }) => { ... }); |
|||
*/ |
|||
custom = 'style:custom', |
|||
|
|||
/** |
|||
* @event `style` Catch-all event for all the events mentioned above. An object containing all the available data about the triggered event is passed as an argument to the callback. |
|||
* @example |
|||
* editor.on('style', ({ event, sector, property, ... }) => { ... }); |
|||
*/ |
|||
all = 'style', |
|||
} |
|||
/**{END_EVENTS}*/ |
|||
|
|||
// need this to avoid the TS documentation generator to break
|
|||
export default StyleManagerEvents; |
|||
@ -0,0 +1,295 @@ |
|||
import { DataSourceManager } from '../../../../src'; |
|||
import DataSource from '../../../../src/data_sources/model/DataSource'; |
|||
import { |
|||
DataFieldPrimitiveType, |
|||
DataFieldSchemaNumber, |
|||
DataFieldSchemaString, |
|||
DataRecordProps, |
|||
DataSourceProviderResult, |
|||
} from '../../../../src/data_sources/types'; |
|||
import Editor from '../../../../src/editor/model/Editor'; |
|||
import { setupTestEditor } from '../../../common'; |
|||
|
|||
interface TestRecord extends DataRecordProps { |
|||
name?: string; |
|||
age?: number; |
|||
} |
|||
|
|||
const serializeRecords = (records: any[]) => JSON.parse(JSON.stringify(records)); |
|||
|
|||
describe('DataSource', () => { |
|||
let em: Editor; |
|||
let editor: Editor['Editor']; |
|||
let dsm: DataSourceManager; |
|||
let ds: DataSource<TestRecord>; |
|||
const categoryRecords = [ |
|||
{ id: 'cat1', uid: 'cat1-uid', name: 'Category 1' }, |
|||
{ id: 'cat2', uid: 'cat2-uid', name: 'Category 2' }, |
|||
{ id: 'cat3', uid: 'cat3-uid', name: 'Category 3' }, |
|||
]; |
|||
const userRecords = [ |
|||
{ id: 'user1', username: 'user_one' }, |
|||
{ id: 'user2', username: 'user_two' }, |
|||
{ id: 'user3', username: 'user_three' }, |
|||
]; |
|||
const blogRecords = [ |
|||
{ id: 'blog1', title: 'First Blog', author: 'user1', categories: ['cat1-uid'] }, |
|||
{ id: 'blog2', title: 'Second Blog', author: 'user2' }, |
|||
{ id: 'blog3', title: 'Third Blog', categories: ['cat1-uid', 'cat3-uid'] }, |
|||
]; |
|||
|
|||
const addTestDataSource = (records?: TestRecord[]) => { |
|||
return dsm.add<TestRecord>({ id: 'test', records: records || [{ id: 'user1', age: 30 }] }); |
|||
}; |
|||
|
|||
beforeEach(() => { |
|||
({ em, dsm, editor } = setupTestEditor()); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
em.destroy(); |
|||
}); |
|||
|
|||
describe('Schema', () => { |
|||
const schemaName: DataFieldSchemaString = { |
|||
type: DataFieldPrimitiveType.string, |
|||
label: 'Name', |
|||
}; |
|||
const schemaAge: DataFieldSchemaNumber = { |
|||
type: DataFieldPrimitiveType.number, |
|||
label: 'Age', |
|||
default: 18, |
|||
}; |
|||
|
|||
beforeEach(() => { |
|||
ds = addTestDataSource(); |
|||
}); |
|||
|
|||
test('Initialize with empty schema', () => { |
|||
expect(ds.schema).toEqual({}); |
|||
}); |
|||
|
|||
test('Add and update schema', () => { |
|||
const schemaNameDef: typeof ds.schema = { name: schemaName }; |
|||
const schemaAgeDef: typeof ds.schema = { age: schemaAge }; |
|||
ds.upSchema(schemaNameDef); |
|||
ds.upSchema(schemaAgeDef); |
|||
expect(ds.schema).toEqual({ ...schemaNameDef, ...schemaAgeDef }); |
|||
}); |
|||
|
|||
test('Should update existing field schema', () => { |
|||
ds.upSchema({ name: schemaName }); |
|||
|
|||
const updatedSchema: typeof ds.schema = { |
|||
name: { |
|||
...schemaName, |
|||
description: 'User name field', |
|||
}, |
|||
}; |
|||
ds.upSchema(updatedSchema); |
|||
expect(ds.schema).toEqual(updatedSchema); |
|||
}); |
|||
|
|||
test('Should get field schema', () => { |
|||
ds.upSchema({ |
|||
name: schemaName, |
|||
age: schemaAge, |
|||
}); |
|||
expect(ds.getSchemaField('name')).toEqual(schemaName); |
|||
expect(ds.getSchemaField('age')).toEqual(schemaAge); |
|||
expect(ds.getSchemaField('nonExistentField')).toBeUndefined(); |
|||
}); |
|||
|
|||
describe('Relations', () => { |
|||
beforeEach(() => { |
|||
dsm.add({ |
|||
id: 'categories', |
|||
records: categoryRecords, |
|||
}); |
|||
dsm.add({ |
|||
id: 'users', |
|||
records: userRecords, |
|||
}); |
|||
dsm.add({ |
|||
id: 'blogs', |
|||
records: blogRecords, |
|||
schema: { |
|||
title: { |
|||
type: DataFieldPrimitiveType.string, |
|||
}, |
|||
author: { |
|||
type: DataFieldPrimitiveType.relation, |
|||
target: 'users', |
|||
targetField: 'id', |
|||
}, |
|||
}, |
|||
}); |
|||
}); |
|||
|
|||
test('return default values', () => { |
|||
const blogsDS = dsm.get('blogs'); |
|||
expect(serializeRecords(blogsDS.getRecords())).toEqual(blogRecords); |
|||
}); |
|||
|
|||
test('return 1:1 resolved values', () => { |
|||
const blogsDS = dsm.get('blogs'); |
|||
const records = blogsDS.getResolvedRecords(); |
|||
expect(records).toEqual([ |
|||
{ ...blogRecords[0], author: userRecords[0] }, |
|||
{ ...blogRecords[1], author: userRecords[1] }, |
|||
blogRecords[2], |
|||
]); |
|||
}); |
|||
|
|||
test('return 1:many resolved values', () => { |
|||
const blogsDS = dsm.get('blogs'); |
|||
blogsDS.upSchema({ |
|||
categories: { |
|||
type: DataFieldPrimitiveType.relation, |
|||
target: 'categories', |
|||
targetField: 'uid', |
|||
isMany: true, |
|||
}, |
|||
}); |
|||
const records = blogsDS.getResolvedRecords(); |
|||
expect(records).toEqual([ |
|||
{ ...blogRecords[0], author: userRecords[0], categories: [categoryRecords[0]] }, |
|||
{ ...blogRecords[1], author: userRecords[1] }, |
|||
{ ...blogRecords[2], categories: [categoryRecords[0], categoryRecords[2]] }, |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('Providers', () => { |
|||
const testApiUrl = 'https://api.example.com/data'; |
|||
const testHeaders = { 'Content-Type': 'application/json' }; |
|||
const getMockSchema = () => ({ |
|||
author: { |
|||
type: DataFieldPrimitiveType.relation, |
|||
target: 'users', |
|||
targetField: 'id', |
|||
}, |
|||
}); |
|||
const getMockProviderResponse: () => DataSourceProviderResult = () => ({ |
|||
records: blogRecords, |
|||
schema: getMockSchema(), |
|||
}); |
|||
const getProviderBlogsGet = () => ({ url: testApiUrl, headers: testHeaders }); |
|||
const addBlogsWithProvider = () => { |
|||
return dsm.add({ |
|||
id: 'blogs', |
|||
name: 'My blogs', |
|||
provider: { |
|||
get: getProviderBlogsGet(), |
|||
}, |
|||
}); |
|||
}; |
|||
|
|||
beforeEach(() => { |
|||
jest.spyOn(global, 'fetch').mockImplementation(() => |
|||
Promise.resolve({ |
|||
ok: true, |
|||
json: () => Promise.resolve(getMockProviderResponse()), |
|||
} as Response), |
|||
); |
|||
|
|||
dsm.add({ |
|||
id: 'categories', |
|||
records: categoryRecords, |
|||
}); |
|||
dsm.add({ |
|||
id: 'users', |
|||
records: userRecords, |
|||
}); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
jest.restoreAllMocks(); |
|||
}); |
|||
|
|||
test('loadProvider', async () => { |
|||
const ds = addBlogsWithProvider(); |
|||
await ds.loadProvider(); |
|||
|
|||
expect(fetch).toHaveBeenCalledWith(testApiUrl, { headers: testHeaders }); |
|||
expect(ds.schema).toEqual(getMockSchema()); |
|||
expect(ds.getResolvedRecords()).toEqual([ |
|||
{ ...blogRecords[0], author: userRecords[0] }, |
|||
{ ...blogRecords[1], author: userRecords[1] }, |
|||
blogRecords[2], |
|||
]); |
|||
}); |
|||
|
|||
test('loadProvider with failed fetch', async () => { |
|||
jest.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error')); |
|||
|
|||
em.config.log = false; |
|||
const ds = addBlogsWithProvider(); |
|||
await ds.loadProvider(); |
|||
|
|||
expect(fetch).toHaveBeenCalledWith(testApiUrl, { headers: testHeaders }); |
|||
expect(ds.schema).toEqual({}); |
|||
expect(ds.getRecords().length).toBe(0); |
|||
}); |
|||
|
|||
test('records loaded from the provider are not persisted', async () => { |
|||
const ds = addBlogsWithProvider(); |
|||
const eventLoad = jest.fn(); |
|||
em.on(dsm.events.providerLoad, eventLoad); |
|||
|
|||
await ds.loadProvider(); |
|||
|
|||
expect(editor.getProjectData().dataSources).toEqual([ |
|||
{ id: 'categories', records: categoryRecords }, |
|||
{ id: 'users', records: userRecords }, |
|||
{ |
|||
id: 'blogs', |
|||
name: 'My blogs', |
|||
schema: getMockSchema(), |
|||
provider: { get: getProviderBlogsGet() }, |
|||
}, |
|||
]); |
|||
expect(eventLoad).toHaveBeenCalledTimes(1); |
|||
expect(eventLoad).toHaveBeenCalledWith({ |
|||
dataSource: ds, |
|||
result: getMockProviderResponse(), |
|||
}); |
|||
}); |
|||
|
|||
test('load providers on project load', (done) => { |
|||
dsm.getConfig().autoloadProviders = true; |
|||
|
|||
editor.on(dsm.events.providerLoadAll, () => { |
|||
expect(dsm.get('blogs').getResolvedRecords()).toEqual([ |
|||
{ ...blogRecords[0], author: userRecords[0] }, |
|||
{ ...blogRecords[1], author: userRecords[1] }, |
|||
blogRecords[2], |
|||
]); |
|||
|
|||
expect(editor.getProjectData().dataSources).toEqual([ |
|||
{ id: 'categories', records: categoryRecords }, |
|||
{ id: 'users', records: userRecords }, |
|||
{ |
|||
id: 'blogs', |
|||
schema: getMockSchema(), |
|||
provider: { get: testApiUrl }, |
|||
}, |
|||
]); |
|||
|
|||
done(); |
|||
}); |
|||
|
|||
editor.loadProjectData({ |
|||
dataSources: [ |
|||
{ id: 'categories', records: categoryRecords }, |
|||
{ id: 'users', records: userRecords }, |
|||
{ |
|||
id: 'blogs', |
|||
provider: { get: testApiUrl }, |
|||
}, |
|||
], |
|||
}); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -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