Browse Source

Start data source import policy implementation

data-source-import-policy
Artur Arseniev 2 months ago
parent
commit
889350a558
  1. 2
      packages/core/src/css_composer/index.ts
  2. 9
      packages/core/src/data_sources/config/config.ts
  3. 22
      packages/core/src/data_sources/types.ts
  4. 23
      packages/core/src/dom_components/model/Components.ts
  5. 6
      packages/core/src/dom_components/model/ModelDataResolverWatchers.ts
  6. 84
      packages/core/src/dom_components/model/ModelResolverWatcher.ts
  7. 2
      packages/core/src/dom_components/types.ts
  8. 6
      packages/core/src/editor/config/config.ts
  9. 7
      packages/core/src/index.ts
  10. 277
      packages/core/test/specs/data_sources/import_policy.ts

2
packages/core/src/css_composer/index.ts

@ -298,9 +298,11 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
addCollection(data: string | CssRuleJSON[], opts: Record<string, any> = {}, props = {}) {
const { em } = this;
const result: CssRule[] = [];
const parsedImportOpts = { parsedImportSource: 'css', ...opts };
if (isString(data)) {
data = em.Parser.parseCss(data);
opts = parsedImportOpts;
}
const d = data instanceof Array ? data : [data];

9
packages/core/src/data_sources/config/config.ts

@ -1,13 +1,22 @@
import type { DataSourcePropertyHandler } from '../types';
export interface DataSourcesConfig {
/**
* If true, data source providers will be autoloaded on project load.
* @default false
*/
autoloadProviders?: boolean;
/**
* Controls how parsed static HTML/CSS updates interact with existing data source bindings.
* @default 'overwrite'
*/
onDataSourceProperty?: DataSourcePropertyHandler;
}
const config: () => DataSourcesConfig = () => ({
autoloadProviders: false,
onDataSourceProperty: 'overwrite',
});
export default config;

22
packages/core/src/data_sources/types.ts

@ -1,4 +1,5 @@
import { AddOptions, Collection, Model, ObjectAny, RemoveOptions, SetOptions } from '../common';
import type StyleableModel from '../domain_abstract/model/StyleableModel';
import DataRecord from './model/DataRecord';
import DataRecords from './model/DataRecords';
import DataSource from './model/DataSource';
@ -167,6 +168,27 @@ export interface DataSourceTransformers {
onRecordSetValue?: (args: { id: string | number; key: string; value: any }) => any;
}
export type DataSourceImportSource = 'html' | 'css';
export type DataSourcePropertyKind = 'property' | 'attribute' | 'style';
export type DataSourcePropertyAction = 'overwrite' | 'update' | 'skip';
export interface DataSourcePropertyContext {
target: StyleableModel;
kind: DataSourcePropertyKind;
source: DataSourceImportSource;
key: string;
value: any;
resolvedValue: any;
resolver: DataResolverProps;
path?: string;
}
export type DataSourcePropertyHandler =
| DataSourcePropertyAction
| ((context: DataSourcePropertyContext) => DataSourcePropertyAction);
type DotSeparatedKeys<T> = T extends object
? {
[K in keyof T]: K extends string

23
packages/core/src/dom_components/model/Components.ts

@ -68,18 +68,19 @@ const getComponentsFromDefs = (
result = all[id] as any;
const { onAttributes, onStyle } = updateOptions;
const component = result as unknown as Component;
tagName && component.set({ tagName }, { ...opts, silent: true });
const htmlImportOpts = { ...opts, parsedImportSource: 'html' as const };
tagName && component.set({ tagName }, { ...htmlImportOpts, silent: true });
if (onAttributes) {
onAttributes({ item, component, attributes: restAttr, options: opts });
onAttributes({ item, component, attributes: restAttr, options: htmlImportOpts });
} else if (keys(restAttr).length) {
component.addAttributes(restAttr, { ...opts });
component.addAttributes(restAttr, htmlImportOpts);
}
if (onStyle) {
onStyle({ item, component, style, options: opts });
onStyle({ item, component, style, options: htmlImportOpts });
} else if (keys(style).length) {
component.addStyle(style, opts);
component.addStyle(style, htmlImportOpts);
}
}
} else {
@ -289,11 +290,12 @@ Component> {
const { components: bodyCmps = [], ...restBody } = (parsed.html as ComponentDefinitionDefined) || {};
const { components: headCmps, ...restHead } = parsed.head || {};
components = bodyCmps!;
root.set(restBody as any, opt);
root.head.set(restHead as any, opt);
root.head.components(headCmps, opt);
root.docEl.set(parsed.root as any, opt);
root.set({ doctype: parsed.doctype });
const htmlImportOpts = { ...opt, parsedImportSource: 'html' as const };
root.set(restBody as any, htmlImportOpts);
root.head.set(restHead as any, htmlImportOpts);
root.head.components(headCmps, htmlImportOpts);
root.docEl.set(parsed.root as any, htmlImportOpts);
root.set({ doctype: parsed.doctype }, htmlImportOpts);
}
// We need this to avoid duplicate IDs
@ -305,6 +307,7 @@ Component> {
cssc.addCollection(parsed.css, {
...optsToPass,
extend: 1,
parsedImportSource: 'css',
});
}

6
packages/core/src/dom_components/model/ModelDataResolverWatchers.ts

@ -22,9 +22,9 @@ export class ModelDataResolverWatchers<T extends StyleableModelProperties> {
private model: WatchableModel<T>,
private options: ModelResolverWatcherOptions,
) {
this.propertyWatcher = new ModelResolverWatcher(model, this.onPropertyUpdate, options);
this.attributeWatcher = new ModelResolverWatcher(model, this.onAttributeUpdate, options);
this.styleWatcher = new ModelResolverWatcher(model, this.onStyleUpdate, options);
this.propertyWatcher = new ModelResolverWatcher(model, this.onPropertyUpdate, 'property', options);
this.attributeWatcher = new ModelResolverWatcher(model, this.onAttributeUpdate, 'attribute', options);
this.styleWatcher = new ModelResolverWatcher(model, this.onStyleUpdate, 'style', options);
}
bindModel(model: WatchableModel<T>) {

84
packages/core/src/dom_components/model/ModelResolverWatcher.ts

@ -1,12 +1,20 @@
import { ObjectAny, ObjectHash } from '../../common';
import DataResolverListener from '../../data_sources/model/DataResolverListener';
import {
DataSourceImportSource,
DataSourcePropertyContext,
DataSourcePropertyHandler,
DataSourcePropertyKind,
} from '../../data_sources/types';
import { getDataResolverInstance, getDataResolverInstanceValue, isDataResolverProps } from '../../data_sources/utils';
import StyleableModel from '../../domain_abstract/model/StyleableModel';
import type StyleableModel from '../../domain_abstract/model/StyleableModel';
import EditorModel from '../../editor/model/Editor';
import { isFunction } from 'underscore';
export interface DataWatchersOptions {
skipWatcherUpdates?: boolean;
fromDataSource?: boolean;
parsedImportSource?: DataSourceImportSource;
}
export interface ModelResolverWatcherOptions {
@ -23,6 +31,7 @@ export class ModelResolverWatcher<T extends ObjectHash> {
constructor(
private model: WatchableModel<T>,
private updateFn: UpdateFn<T>,
private kind: DataSourcePropertyKind,
options: ModelResolverWatcherOptions,
) {
this.em = options.em;
@ -33,6 +42,7 @@ export class ModelResolverWatcher<T extends ObjectHash> {
}
setDataValues(values: ObjectAny | undefined, options: DataWatchersOptions = {}) {
values = this.applyImportPolicy(values, options);
const shouldSkipWatcherUpdates = options.skipWatcherUpdates || options.fromDataSource;
if (!shouldSkipWatcherUpdates) {
this.removeListeners();
@ -43,6 +53,7 @@ export class ModelResolverWatcher<T extends ObjectHash> {
addDataValues(values: ObjectAny | undefined, options: DataWatchersOptions = {}) {
if (!values) return {};
values = this.applyImportPolicy(values, options);
const evaluatedValues = this.evaluateValues(values);
const shouldSkipWatcherUpdates = options.skipWatcherUpdates || options.fromDataSource;
@ -111,6 +122,77 @@ export class ModelResolverWatcher<T extends ObjectHash> {
return evaluatedValues;
}
private applyImportPolicy(values: ObjectAny | undefined, options: DataWatchersOptions = {}) {
if (!values || !options.parsedImportSource) return values;
const nextValues = { ...values };
const source = options.parsedImportSource;
Object.keys(nextValues).forEach((key) => {
const resolverListener = this.resolverListeners[key];
const incomingValue = nextValues[key];
if (!resolverListener || isDataResolverProps(incomingValue)) {
return;
}
const resolver = resolverListener.resolver.toJSON();
const path = 'path' in resolver ? resolver.path : undefined;
const context: DataSourcePropertyContext = {
target: this.model as StyleableModel,
kind: this.kind,
source,
key,
value: incomingValue,
resolvedValue: resolverListener.resolver.getDataValue(),
resolver,
path,
};
const action = this.resolveImportAction(this.em.DataSources.getConfig('onDataSourceProperty'), context);
if (action === 'overwrite') {
return;
}
if (action === 'update') {
const updated = this.tryUpdateDataSource(path, incomingValue);
if (!updated) {
this.warnImportFallback(key, source, path);
}
}
nextValues[key] = resolver;
});
return nextValues;
}
private resolveImportAction(handler: DataSourcePropertyHandler | undefined, context: DataSourcePropertyContext) {
const action = isFunction(handler) ? handler(context) : handler;
return action === 'skip' || action === 'update' || action === 'overwrite' ? action : 'overwrite';
}
private tryUpdateDataSource(path: string | undefined, value: any) {
if (!path) {
return false;
}
try {
return this.em.DataSources.setValue(path, value);
} catch (error) {
return false;
}
}
private warnImportFallback(key: string, source: DataSourceImportSource, path?: string) {
this.em.logWarning(
`[DataSources]: Failed to update the data source bound to "${key}" during ${source} import; keeping the existing binding.`,
{ key, source, path },
);
}
/**
* removes listeners to stop watching for changes,
* if keys argument is omitted, remove all listeners

2
packages/core/src/dom_components/types.ts

@ -13,6 +13,7 @@ import type {
ComponentResizeEventStartProps,
ComponentResizeEventUpdateProps,
} from '../commands/view/Resize';
import type { DataSourceImportSource } from '../data_sources/types';
import type { StyleProps } from '../domain_abstract/model/StyleableModel';
import type Selector from '../selector_manager/model/Selector';
import type Component from './model/Component';
@ -39,6 +40,7 @@ export interface SymbolInfo {
export interface ParseStringOptions extends AddOptions, OptionAsDocument, WithHTMLParserOptions {
keepIds?: string[];
cloneRules?: boolean;
parsedImportSource?: DataSourceImportSource;
}
export enum ComponentsEvents {

6
packages/core/src/editor/config/config.ts

@ -23,6 +23,7 @@ import { DomComponentsConfig } from '../../dom_components/config/config';
import { HTMLGeneratorBuildOptions } from '../../code_manager/model/HtmlGenerator';
import { CssGeneratorBuildOptions } from '../../code_manager/model/CssGenerator';
import { ObjectAny } from '../../common';
import type { DataSourcesConfig } from '../../data_sources/config/config';
import { ColorPickerOptions } from '../../utils/ColorPicker';
export interface EditorConfig {
@ -401,6 +402,11 @@ export interface EditorConfig {
*/
parser?: ParserConfig;
/**
* Configurations for Data Sources.
*/
dataSources?: DataSourcesConfig;
/** Texts **/
textViewCode?: string;

7
packages/core/src/index.ts

@ -159,5 +159,12 @@ export type {
DataConditionProps,
ExpressionProps,
} from './data_sources/model/conditional_variables/DataCondition';
export type {
DataSourceImportSource,
DataSourcePropertyAction,
DataSourcePropertyContext,
DataSourcePropertyHandler,
DataSourcePropertyKind,
} from './data_sources/types';
export default grapesjs;

277
packages/core/test/specs/data_sources/import_policy.ts

@ -0,0 +1,277 @@
import type { CssRule, DataSourcePropertyContext, Editor } from '../../../src';
import type DataSourceManager from '../../../src/data_sources';
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 type EditorModel from '../../../src/editor/model/Editor';
import type { EditorConfig } from '../../../src/editor/config/config';
import type ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper';
import { setupTestEditor } from '../../common';
const makeTitleVar = () => ({
type: DataVariableType,
path: 'records.rec1.title',
});
const makeColorVar = () => ({
type: DataVariableType,
path: 'records.rec1.color',
});
const makeContentVar = () => ({
type: DataVariableType,
path: 'records.rec1.content',
});
const makeConditionVar = () => ({
type: DataConditionType,
condition: {
left: makeTitleVar(),
operator: StringOperation.contains,
right: 'Initial',
},
ifTrue: 'red',
ifFalse: 'blue',
});
describe('Data source import policy', () => {
let editor: Editor;
let em: EditorModel;
let dsm: DataSourceManager;
let cmpRoot: ComponentWrapper;
const init = (config: Partial<EditorConfig> = {}) => {
({ editor, em, dsm, cmpRoot } = setupTestEditor({ config }));
};
const addBaseDataSource = (
record = { id: 'rec1', title: 'Initial Title', color: 'red', content: 'Dynamic Content' },
) => {
dsm.add({
id: 'records',
records: [record],
});
};
const createBoundComponent = () => {
return cmpRoot.append({
tagName: 'div',
attributes: { id: 'bound-cmp', 'data-attr': makeTitleVar() },
style: { color: makeColorVar() },
})[0];
};
const importStaticHtml = (
html = '<div id="bound-cmp" data-attr="Imported Title" style="color: green;">Imported</div>',
) => {
cmpRoot.components().resetFromString(html);
};
const createBoundRule = () => {
return em.Css.addCollection([
{
selectors: ['.bound-rule'],
style: { color: makeColorVar() },
},
])[0] as CssRule;
};
const importStaticCss = (css = '.bound-rule { color: green; }') => {
em.Css.addCollection(css);
};
afterEach(() => {
editor?.destroy();
});
test('overwrites bound component values on parsed HTML import by default', () => {
init();
addBaseDataSource();
const component = createBoundComponent();
importStaticHtml();
expect(component.getAttributes({ skipResolve: true })['data-attr']).toBe('Imported Title');
expect(component.getStyle({ skipResolve: true }).color).toBe('green');
dsm.get('records').getRecord('rec1')?.set({ title: 'Changed Title', color: 'purple' });
expect(component.getAttributes()['data-attr']).toBe('Imported Title');
expect(component.getStyle().color).toBe('green');
});
test('skips static HTML updates and preserves existing bindings', () => {
init({
dataSources: { onDataSourceProperty: 'skip' },
});
addBaseDataSource();
const component = createBoundComponent();
importStaticHtml();
expect(component.getAttributes({ skipResolve: true })['data-attr']).toEqual(makeTitleVar());
expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar());
expect(dsm.getValue('records.rec1.title')).toBe('Initial Title');
expect(dsm.getValue('records.rec1.color')).toBe('red');
dsm.get('records').getRecord('rec1')?.set({ title: 'Changed Title', color: 'purple' });
expect(component.getAttributes()['data-attr']).toBe('Changed Title');
expect(component.getStyle().color).toBe('purple');
});
test('updates datasource values and keeps bindings on parsed HTML import', () => {
init({
dataSources: { onDataSourceProperty: 'update' },
});
addBaseDataSource();
const component = createBoundComponent();
importStaticHtml();
expect(dsm.getValue('records.rec1.title')).toBe('Imported Title');
expect(dsm.getValue('records.rec1.color')).toBe('green');
expect(component.getAttributes({ skipResolve: true })['data-attr']).toEqual(makeTitleVar());
expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar());
dsm.get('records').getRecord('rec1')?.set({ title: 'Changed Again', color: 'orange' });
expect(component.getAttributes()['data-attr']).toBe('Changed Again');
expect(component.getStyle().color).toBe('orange');
});
test('overwrites bound rule values on parsed CSS string import by default', () => {
init();
addBaseDataSource();
const rule = createBoundRule();
importStaticCss();
expect(rule.getStyle('', { skipResolve: true }).color).toBe('green');
dsm.get('records').getRecord('rec1')?.set({ color: 'orange' });
expect(rule.getStyle().color).toBe('green');
});
test('skips static CSS updates and preserves existing rule bindings', () => {
init({
dataSources: { onDataSourceProperty: 'skip' },
});
addBaseDataSource();
const rule = createBoundRule();
importStaticCss();
expect(dsm.getValue('records.rec1.color')).toBe('red');
expect(rule.getStyle('', { skipResolve: true }).color).toEqual(makeColorVar());
dsm.get('records').getRecord('rec1')?.set({ color: 'orange' });
expect(rule.getStyle().color).toBe('orange');
});
test('applies policy to parsed CSS string imports for existing rules', () => {
init({
dataSources: { onDataSourceProperty: 'update' },
});
addBaseDataSource();
const rule = createBoundRule();
importStaticCss();
expect(dsm.getValue('records.rec1.color')).toBe('green');
expect(rule.getStyle('', { skipResolve: true }).color).toEqual(makeColorVar());
dsm.get('records').getRecord('rec1')?.set({ color: 'orange' });
expect(rule.getStyle().color).toBe('orange');
});
test('supports callback policies per key and kind', () => {
init({
dataSources: {
onDataSourceProperty: ({ key, kind, source }: DataSourcePropertyContext) => {
if (source === 'html' && kind === 'attribute' && key === 'data-attr') {
return 'skip';
}
return 'update';
},
},
});
addBaseDataSource();
const component = createBoundComponent();
importStaticHtml();
expect(dsm.getValue('records.rec1.title')).toBe('Initial Title');
expect(dsm.getValue('records.rec1.color')).toBe('green');
expect(component.getAttributes({ skipResolve: true })['data-attr']).toEqual(makeTitleVar());
expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar());
});
test('keeps bindings and warns when update cannot write data-condition values', () => {
init({
dataSources: { onDataSourceProperty: 'update' },
});
addBaseDataSource();
const warningSpy = jest.spyOn(em, 'logWarning');
const component = cmpRoot.append({
tagName: 'div',
attributes: { id: 'bound-cmp' },
style: { color: makeConditionVar() },
})[0];
cmpRoot.components().resetFromString('<div id="bound-cmp" style="color: black;"></div>');
expect(component.getStyle({ skipResolve: true }).color).toEqual(makeConditionVar());
expect(component.getStyle().color).toBe('red');
expect(warningSpy).toHaveBeenCalled();
dsm.get('records').getRecord('rec1')?.set({ title: 'No Match' });
expect(component.getStyle().color).toBe('blue');
});
test('keeps bindings and warns when datasource updates fail', () => {
init({
dataSources: { onDataSourceProperty: 'update' },
});
addBaseDataSource({ id: 'rec1', title: 'Initial Title', color: 'red', content: 'Dynamic Content', mutable: false });
const warningSpy = jest.spyOn(em, 'logWarning');
const rule = createBoundRule();
importStaticCss();
expect(rule.getStyle('', { skipResolve: true }).color).toEqual(makeColorVar());
expect(rule.getStyle().color).toBe('red');
expect(warningSpy).toHaveBeenCalled();
});
test('does not change direct setter overwrite behavior', () => {
init({
dataSources: { onDataSourceProperty: 'skip' },
});
addBaseDataSource();
const component = createBoundComponent();
component.set('content', makeContentVar());
const rule = createBoundRule();
component.addAttributes({ 'data-attr': 'Static Title' });
component.addStyle({ color: 'green' });
component.set('content', 'Static Content');
rule.addStyle({ color: 'blue' });
dsm.get('records').getRecord('rec1')?.set({ title: 'Changed Title', color: 'orange', content: 'Changed Content' });
expect(component.getAttributes({ skipResolve: true })['data-attr']).toBe('Static Title');
expect(component.getStyle({ skipResolve: true }).color).toBe('green');
expect(component.get('content', { skipResolve: true })).toBeUndefined();
expect(rule.getStyle('', { skipResolve: true }).color).toBe('blue');
expect(component.getAttributes()['data-attr']).toBe('Static Title');
expect(component.getStyle().color).toBe('green');
expect(component.get('content')).toBe('Static Content');
expect(rule.getStyle().color).toBe('blue');
});
});
Loading…
Cancel
Save