Browse Source

Data source import policy (#6738)

* Start data source import policy implementation

* Fix TS

* Update applyImportPolicy

* Up TS naming

* Up test file

* Improve applyImportPolicy

* Add more use cases to data binding import policy tests

* Add support per-call dataBindingImportPolicy

* Up JSDoc

* Fix tests

* Format
fix-6678-youtube-attribute
Artur Arseniev 6 months ago
committed by GitHub
parent
commit
86d471ce0a
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 11
      packages/core/src/css_composer/index.ts
  2. 23
      packages/core/src/data_sources/config/config.ts
  3. 22
      packages/core/src/data_sources/types.ts
  4. 25
      packages/core/src/dom_components/model/Components.ts
  5. 6
      packages/core/src/dom_components/model/ModelDataResolverWatchers.ts
  6. 94
      packages/core/src/dom_components/model/ModelResolverWatcher.ts
  7. 3
      packages/core/src/dom_components/types.ts
  8. 6
      packages/core/src/editor/config/config.ts
  9. 7
      packages/core/src/index.ts
  10. 467
      packages/core/test/specs/data_sources/dataBindingImportPolicy.ts

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

@ -42,6 +42,7 @@ import { ObjectAny, PrevToNewIdMap } from '../common';
import { UpdateStyleOptions } from '../domain_abstract/model/StyleableModel';
import { CssEvents } from './types';
import CssRuleView from './view/CssRuleView';
import type { DataBindingImportPolicy } from '../data_sources/types';
/** @private */
interface RuleOptions {
@ -73,6 +74,12 @@ export interface GetSetRuleOptions extends UpdateStyleOptions {
type CssRuleStyle = Required<CssRuleProperties>['style'];
export interface AddCollectionOptions extends UpdateStyleOptions {
extend?: boolean | number;
avoidUpdateStyle?: boolean;
dataBindingImportPolicy?: DataBindingImportPolicy;
}
export default class CssComposer extends ItemManagerModule<CssComposerConfig & { pStylePrefix?: string }> {
classes = {
CssRule,
@ -295,12 +302,14 @@ export default class CssComposer extends ItemManagerModule<CssComposerConfig & {
* @return {Array<Model>}
* @private
*/
addCollection(data: string | CssRuleJSON[], opts: Record<string, any> = {}, props = {}) {
addCollection(data: string | CssRuleJSON[], opts: AddCollectionOptions = {}, props = {}) {
const { em } = this;
const result: CssRule[] = [];
const parsedImportOpts: AddCollectionOptions = { ...opts, parsedImportSource: 'css' as const };
if (isString(data)) {
data = em.Parser.parseCss(data);
opts = parsedImportOpts;
}
const d = data instanceof Array ? data : [data];

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

@ -1,13 +1,36 @@
import type { DataBindingImportPolicy } from '../types';
export interface DataSourcesConfig {
/**
* If true, data source providers will be autoloaded on project load.
* @default false
*/
autoloadProviders?: boolean;
/**
* Controls how parsed HTML/CSS string imports interact with existing data-bound
* component properties, attributes, and styles.
*
* This applies when a string import tries to write a static value over an existing
* data binding, for example via `components().resetFromString(...)` or
* `Css.addCollection('...')`.
*
* Available options:
* - `'overwrite'`: replace the existing binding with the imported static value.
* - `'skip'`: ignore the imported static value and keep the current binding.
* - `'update'`: write the imported static value into the bound data source and keep the binding.
* - `(context) => action`: decide per imported key based on the binding context.
*
* This value acts as the global default and can be overridden per import call with
* the `dataBindingImportPolicy` option.
* @default 'overwrite'
*/
dataBindingImportPolicy?: DataBindingImportPolicy;
}
const config: () => DataSourcesConfig = () => ({
autoloadProviders: false,
dataBindingImportPolicy: '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 DataBindingImportSource = 'html' | 'css';
export type DataBindingKind = 'property' | 'attribute' | 'style';
export type DataBindingImportAction = 'overwrite' | 'update' | 'skip';
export interface DataBindingImportContext {
target: StyleableModel;
kind: DataBindingKind;
source: DataBindingImportSource;
key: string;
value: any;
resolvedValue: any;
resolver: DataResolverProps;
path?: string;
}
export type DataBindingImportPolicy =
| DataBindingImportAction
| ((context: DataBindingImportContext) => DataBindingImportAction);
type DotSeparatedKeys<T> = T extends object
? {
[K in keyof T]: K extends string

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

@ -17,6 +17,7 @@ import ComponentText from './ComponentText';
import ComponentWrapper from './ComponentWrapper';
import { ComponentsEvents, ParseStringOptions } from '../types';
import { isSymbolInstance, isSymbolRoot, updateSymbolComps } from './SymbolUtils';
import type { DataBindingImportPolicy } from '../../data_sources/types';
export interface ResetCommonUpdateProps {
component: Component;
@ -27,6 +28,7 @@ export interface ResetCommonUpdateProps {
export interface ResetFromStringOptions {
visitedCmps?: Record<string, ComponentDefinitionDefined[]>;
keepIds?: string[];
dataBindingImportPolicy?: DataBindingImportPolicy;
updateOptions?: {
onAttributes?: (props: ResetCommonUpdateProps & { attributes: Record<string, any> }) => void;
onStyle?: (props: ResetCommonUpdateProps & { style: Record<string, any> }) => void;
@ -68,18 +70,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 +292,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 +309,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>) {

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

@ -1,12 +1,21 @@
import { ObjectAny, ObjectHash } from '../../common';
import DataResolverListener from '../../data_sources/model/DataResolverListener';
import {
DataBindingImportContext,
DataBindingImportPolicy,
DataBindingImportSource,
DataBindingKind,
} 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?: DataBindingImportSource;
dataBindingImportPolicy?: DataBindingImportPolicy;
}
export interface ModelResolverWatcherOptions {
@ -23,6 +32,7 @@ export class ModelResolverWatcher<T extends ObjectHash> {
constructor(
private model: WatchableModel<T>,
private updateFn: UpdateFn<T>,
private kind: DataBindingKind,
options: ModelResolverWatcherOptions,
) {
this.em = options.em;
@ -33,6 +43,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,11 +54,13 @@ export class ModelResolverWatcher<T extends ObjectHash> {
addDataValues(values: ObjectAny | undefined, options: DataWatchersOptions = {}) {
if (!values) return {};
const evaluatedValues = this.evaluateValues(values);
const nextValues = this.applyImportPolicy(values, options);
if (!nextValues) return {};
const evaluatedValues = this.evaluateValues(nextValues);
const shouldSkipWatcherUpdates = options.skipWatcherUpdates || options.fromDataSource;
if (!shouldSkipWatcherUpdates) {
this.updateListeners(values);
this.updateListeners(nextValues);
}
return evaluatedValues;
@ -111,6 +124,81 @@ export class ModelResolverWatcher<T extends ObjectHash> {
return evaluatedValues;
}
private applyImportPolicy(values: ObjectAny | undefined, options: DataWatchersOptions = {}) {
const { parsedImportSource } = options;
const dataBindingImportPolicy =
options.dataBindingImportPolicy ?? this.em?.DataSources.config.dataBindingImportPolicy;
if (!values || !parsedImportSource || dataBindingImportPolicy === 'overwrite') return values;
const nextValues = { ...values };
const source = 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: DataBindingImportContext = {
target: this.model as StyleableModel,
kind: this.kind,
source,
key,
value: incomingValue,
resolvedValue: resolverListener.resolver.getDataValue(),
resolver,
path,
};
const action = this.resolveImportAction(dataBindingImportPolicy, 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: DataBindingImportPolicy | undefined, context: DataBindingImportContext) {
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: DataBindingImportSource, 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

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

@ -13,6 +13,7 @@ import type {
ComponentResizeEventStartProps,
ComponentResizeEventUpdateProps,
} from '../commands/view/Resize';
import type { DataBindingImportPolicy, DataBindingImportSource } 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,8 @@ export interface SymbolInfo {
export interface ParseStringOptions extends AddOptions, OptionAsDocument, WithHTMLParserOptions {
keepIds?: string[];
cloneRules?: boolean;
parsedImportSource?: DataBindingImportSource;
dataBindingImportPolicy?: DataBindingImportPolicy;
}
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 {
DataBindingImportAction,
DataBindingImportContext,
DataBindingImportPolicy,
DataBindingImportSource,
DataBindingKind,
} from './data_sources/types';
export default grapesjs;

467
packages/core/test/specs/data_sources/dataBindingImportPolicy.ts

@ -0,0 +1,467 @@
import type { CssRule, DataBindingImportContext, 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 makeTagNameVar = () => ({
type: DataVariableType,
path: 'records.rec1.tagName',
});
const makeColorVar = () => ({
type: DataVariableType,
path: 'records.rec1.color',
});
const makeKeepPropVar = () => ({
type: DataVariableType,
path: 'records.rec1.keepProp',
});
const makeKeepAttrVar = () => ({
type: DataVariableType,
path: 'records.rec1.keepAttr',
});
const makeBorderColorVar = () => ({
type: DataVariableType,
path: 'records.rec1.borderColor',
});
const makeContentVar = () => ({
type: DataVariableType,
path: 'records.rec1.content',
});
const makeConditionVar = () => ({
type: DataConditionType,
condition: {
left: makeTitleVar(),
operator: StringOperation.contains,
right: 'Initial',
},
ifTrue: 'red',
ifFalse: 'blue',
});
type BaseRecord = {
id: string;
tagName: string;
title: string;
color: string;
keepProp: string;
keepAttr: string;
borderColor: string;
content: string;
mutable?: boolean;
};
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: BaseRecord = {
id: 'rec1',
tagName: 'div',
title: 'Initial Title',
color: 'red',
keepProp: 'Initial Keep Prop',
keepAttr: 'Initial Keep Attr',
borderColor: 'orange',
content: 'Dynamic Content',
},
) => {
dsm.add({
id: 'records',
records: [record],
});
};
const createBoundComponent = () => {
return cmpRoot.append({
tagName: makeTagNameVar(),
staticProp: 'staticValue',
dsPropToKeep: makeKeepPropVar(),
attributes: {
id: 'bound-cmp',
'data-attr': makeTitleVar(),
'data-static': 'staticAttr',
'data-keep': makeKeepAttrVar(),
},
style: {
color: makeColorVar(),
'background-color': 'black',
'border-color': makeBorderColorVar(),
},
})[0];
};
const importStaticHtml = (
html = '<section id="bound-cmp" data-attr="Imported Title" style="color: green;">Imported</section>',
opts = {},
) => {
cmpRoot.components().resetFromString(html, opts);
};
const createBoundRule = () => {
return em.Css.addCollection([
{
selectors: ['.bound-rule'],
style: {
color: makeColorVar(),
'background-color': 'black',
'border-color': makeBorderColorVar(),
},
},
])[0] as CssRule;
};
const importStaticCss = (css = '.bound-rule { color: green; }', opts = {}) => {
em.Css.addCollection(css, { extend: 1, ...opts });
};
const expectUntouchedComponentValues = (
component: ReturnType<typeof createBoundComponent>,
values = {
keepProp: 'Initial Keep Prop',
keepAttr: 'Initial Keep Attr',
borderColor: 'orange',
},
) => {
expect(component.get('staticProp')).toBe('staticValue');
expect(component.get('dsPropToKeep', { skipResolve: true })).toEqual(makeKeepPropVar());
expect(component.get('dsPropToKeep')).toBe(values.keepProp);
expect(component.getAttributes({ skipResolve: true })['data-static']).toBe('staticAttr');
expect(component.getAttributes({ skipResolve: true })['data-keep']).toEqual(makeKeepAttrVar());
expect(component.getAttributes()['data-keep']).toBe(values.keepAttr);
expect(component.getStyle({ skipResolve: true })['background-color']).toBe('black');
expect(component.getStyle({ skipResolve: true })['border-color']).toEqual(makeBorderColorVar());
expect(component.getStyle()['border-color']).toBe(values.borderColor);
};
const expectUntouchedRuleValues = (
rule: CssRule,
values = {
borderColor: 'orange',
},
) => {
expect(rule.getStyle('', { skipResolve: true })['background-color']).toBe('black');
expect(rule.getStyle('', { skipResolve: true })['border-color']).toEqual(makeBorderColorVar());
expect(rule.getStyle()['border-color']).toBe(values.borderColor);
};
afterEach(() => {
editor?.destroy();
});
test('overwrites bound component values on parsed HTML import by default', () => {
init();
addBaseDataSource();
const component = createBoundComponent();
importStaticHtml();
expect(component.get('tagName')).toBe('section');
expect(component.get('tagName', { skipResolve: true })).toBeUndefined();
expect(component.getAttributes({ skipResolve: true })['data-attr']).toBe('Imported Title');
expect(component.getStyle({ skipResolve: true }).color).toBe('green');
expectUntouchedComponentValues(component);
dsm.get('records').getRecord('rec1')?.set({
tagName: 'article',
title: 'Changed Title',
color: 'purple',
keepProp: 'Changed Keep Prop',
keepAttr: 'Changed Keep Attr',
borderColor: 'yellow',
});
expect(component.get('tagName')).toBe('section');
expect(component.getAttributes()['data-attr']).toBe('Imported Title');
expect(component.getStyle().color).toBe('green');
expectUntouchedComponentValues(component, {
keepProp: 'Changed Keep Prop',
keepAttr: 'Changed Keep Attr',
borderColor: 'yellow',
});
});
test('skips static HTML updates and preserves existing bindings', () => {
init({
dataSources: { dataBindingImportPolicy: 'skip' },
});
addBaseDataSource();
const component = createBoundComponent();
importStaticHtml();
expect(component.get('tagName', { skipResolve: true })).toEqual(makeTagNameVar());
expect(component.get('tagName')).toBe('div');
expect(component.getAttributes({ skipResolve: true })['data-attr']).toEqual(makeTitleVar());
expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar());
expectUntouchedComponentValues(component);
expect(dsm.getValue('records.rec1.tagName')).toBe('div');
expect(dsm.getValue('records.rec1.title')).toBe('Initial Title');
expect(dsm.getValue('records.rec1.color')).toBe('red');
dsm.get('records').getRecord('rec1')?.set({
tagName: 'article',
title: 'Changed Title',
color: 'purple',
keepProp: 'Changed Keep Prop',
keepAttr: 'Changed Keep Attr',
borderColor: 'yellow',
});
expect(component.get('tagName')).toBe('article');
expect(component.getAttributes()['data-attr']).toBe('Changed Title');
expect(component.getStyle().color).toBe('purple');
expectUntouchedComponentValues(component, {
keepProp: 'Changed Keep Prop',
keepAttr: 'Changed Keep Attr',
borderColor: 'yellow',
});
});
test('updates datasource values and keeps bindings on parsed HTML import', () => {
init({
dataSources: { dataBindingImportPolicy: 'update' },
});
addBaseDataSource();
const component = createBoundComponent();
importStaticHtml();
expect(dsm.getValue('records.rec1.tagName')).toBe('section');
expect(dsm.getValue('records.rec1.title')).toBe('Imported Title');
expect(dsm.getValue('records.rec1.color')).toBe('green');
expect(component.get('tagName', { skipResolve: true })).toEqual(makeTagNameVar());
expect(component.get('tagName')).toBe('section');
expect(component.getAttributes({ skipResolve: true })['data-attr']).toEqual(makeTitleVar());
expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar());
expectUntouchedComponentValues(component);
dsm.get('records').getRecord('rec1')?.set({
tagName: 'article',
title: 'Changed Again',
color: 'orange',
keepProp: 'Changed Keep Prop',
keepAttr: 'Changed Keep Attr',
borderColor: 'yellow',
});
expect(component.get('tagName')).toBe('article');
expect(component.getAttributes()['data-attr']).toBe('Changed Again');
expect(component.getStyle().color).toBe('orange');
expectUntouchedComponentValues(component, {
keepProp: 'Changed Keep Prop',
keepAttr: 'Changed Keep Attr',
borderColor: 'yellow',
});
});
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');
expectUntouchedRuleValues(rule);
dsm.get('records').getRecord('rec1')?.set({ color: 'orange', borderColor: 'yellow' });
expect(rule.getStyle().color).toBe('green');
expectUntouchedRuleValues(rule, { borderColor: 'yellow' });
});
test('skips static CSS updates and preserves existing rule bindings', () => {
init({
dataSources: { dataBindingImportPolicy: 'skip' },
});
addBaseDataSource();
const rule = createBoundRule();
importStaticCss();
expect(dsm.getValue('records.rec1.color')).toBe('red');
expect(rule.getStyle('', { skipResolve: true }).color).toEqual(makeColorVar());
expectUntouchedRuleValues(rule);
dsm.get('records').getRecord('rec1')?.set({ color: 'orange', borderColor: 'yellow' });
expect(rule.getStyle().color).toBe('orange');
expectUntouchedRuleValues(rule, { borderColor: 'yellow' });
});
test('applies policy to parsed CSS string imports for existing rules', () => {
init({
dataSources: { dataBindingImportPolicy: 'update' },
});
addBaseDataSource();
const rule = createBoundRule();
importStaticCss();
expect(dsm.getValue('records.rec1.color')).toBe('green');
expect(rule.getStyle('', { skipResolve: true }).color).toEqual(makeColorVar());
expectUntouchedRuleValues(rule);
dsm.get('records').getRecord('rec1')?.set({ color: 'orange', borderColor: 'yellow' });
expect(rule.getStyle().color).toBe('orange');
expectUntouchedRuleValues(rule, { borderColor: 'yellow' });
});
test('supports callback policies per key and kind', () => {
init({
dataSources: {
dataBindingImportPolicy: ({ key, kind, source }: DataBindingImportContext) => {
if (source === 'html' && kind === 'attribute' && key === 'data-attr') {
return 'skip';
}
return 'update';
},
},
});
addBaseDataSource();
const component = createBoundComponent();
importStaticHtml();
expect(dsm.getValue('records.rec1.tagName')).toBe('section');
expect(dsm.getValue('records.rec1.title')).toBe('Initial Title');
expect(dsm.getValue('records.rec1.color')).toBe('green');
expect(component.get('tagName', { skipResolve: true })).toEqual(makeTagNameVar());
expect(component.get('tagName')).toBe('section');
expect(component.getAttributes({ skipResolve: true })['data-attr']).toEqual(makeTitleVar());
expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar());
expectUntouchedComponentValues(component);
});
test('supports per-call override on parsed HTML import', () => {
init({
dataSources: { dataBindingImportPolicy: 'skip' },
});
addBaseDataSource();
const component = createBoundComponent();
importStaticHtml(undefined, { dataBindingImportPolicy: 'update' });
expect(dsm.getValue('records.rec1.tagName')).toBe('section');
expect(dsm.getValue('records.rec1.title')).toBe('Imported Title');
expect(dsm.getValue('records.rec1.color')).toBe('green');
expect(component.get('tagName', { skipResolve: true })).toEqual(makeTagNameVar());
expect(component.get('tagName')).toBe('section');
expect(component.getAttributes({ skipResolve: true })['data-attr']).toEqual(makeTitleVar());
expect(component.getStyle({ skipResolve: true }).color).toEqual(makeColorVar());
});
test('supports per-call override on parsed CSS import', () => {
init({
dataSources: { dataBindingImportPolicy: 'skip' },
});
addBaseDataSource();
const rule = createBoundRule();
importStaticCss(undefined, { dataBindingImportPolicy: 'update' });
expect(dsm.getValue('records.rec1.color')).toBe('green');
expect(rule.getStyle('', { skipResolve: true }).color).toEqual(makeColorVar());
expect(rule.getStyle().color).toBe('green');
expectUntouchedRuleValues(rule);
});
test('keeps bindings and warns when update cannot write data-condition values', () => {
init({
dataSources: { dataBindingImportPolicy: '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: { dataBindingImportPolicy: 'update' },
});
addBaseDataSource({
id: 'rec1',
tagName: 'div',
title: 'Initial Title',
color: 'red',
keepProp: 'Initial Keep Prop',
keepAttr: 'Initial Keep Attr',
borderColor: 'orange',
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: { dataBindingImportPolicy: '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