diff --git a/packages/core/src/css_composer/index.ts b/packages/core/src/css_composer/index.ts index 19914f253..89a486246 100644 --- a/packages/core/src/css_composer/index.ts +++ b/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['style']; +export interface AddCollectionOptions extends UpdateStyleOptions { + extend?: boolean | number; + avoidUpdateStyle?: boolean; + dataBindingImportPolicy?: DataBindingImportPolicy; +} + export default class CssComposer extends ItemManagerModule { classes = { CssRule, @@ -295,12 +302,14 @@ export default class CssComposer extends ItemManagerModule} * @private */ - addCollection(data: string | CssRuleJSON[], opts: Record = {}, 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]; diff --git a/packages/core/src/data_sources/config/config.ts b/packages/core/src/data_sources/config/config.ts index 7a85ccbb6..f6f38c792 100644 --- a/packages/core/src/data_sources/config/config.ts +++ b/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; diff --git a/packages/core/src/data_sources/types.ts b/packages/core/src/data_sources/types.ts index 7545c0510..bc0d47aba 100644 --- a/packages/core/src/data_sources/types.ts +++ b/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 extends object ? { [K in keyof T]: K extends string diff --git a/packages/core/src/dom_components/model/Components.ts b/packages/core/src/dom_components/model/Components.ts index 74f6273b3..9492f3265 100644 --- a/packages/core/src/dom_components/model/Components.ts +++ b/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; keepIds?: string[]; + dataBindingImportPolicy?: DataBindingImportPolicy; updateOptions?: { onAttributes?: (props: ResetCommonUpdateProps & { attributes: Record }) => void; onStyle?: (props: ResetCommonUpdateProps & { style: Record }) => 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', }); } diff --git a/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts b/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts index 7dd31ce9e..4748da4b3 100644 --- a/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts +++ b/packages/core/src/dom_components/model/ModelDataResolverWatchers.ts @@ -22,9 +22,9 @@ export class ModelDataResolverWatchers { private model: WatchableModel, 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) { diff --git a/packages/core/src/dom_components/model/ModelResolverWatcher.ts b/packages/core/src/dom_components/model/ModelResolverWatcher.ts index 0834721ee..2f9ad8848 100644 --- a/packages/core/src/dom_components/model/ModelResolverWatcher.ts +++ b/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 { constructor( private model: WatchableModel, private updateFn: UpdateFn, + private kind: DataBindingKind, options: ModelResolverWatcherOptions, ) { this.em = options.em; @@ -33,6 +43,7 @@ export class ModelResolverWatcher { } 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 { 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 { 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 diff --git a/packages/core/src/dom_components/types.ts b/packages/core/src/dom_components/types.ts index 3df7798b1..e9df3bc85 100644 --- a/packages/core/src/dom_components/types.ts +++ b/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 { diff --git a/packages/core/src/editor/config/config.ts b/packages/core/src/editor/config/config.ts index 6a3f81cdc..ebd83c217 100644 --- a/packages/core/src/editor/config/config.ts +++ b/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; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c7f532747..49098d19b 100644 --- a/packages/core/src/index.ts +++ b/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; diff --git a/packages/core/test/specs/data_sources/dataBindingImportPolicy.ts b/packages/core/test/specs/data_sources/dataBindingImportPolicy.ts new file mode 100644 index 000000000..bf8807bee --- /dev/null +++ b/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 = {}) => { + ({ 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 = '
Imported
', + 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, + 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('
'); + + 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'); + }); +});