From 889350a558cd31ec8b6b5435e4a8a7ed4710052a Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Thu, 2 Apr 2026 14:47:05 +0400 Subject: [PATCH] Start data source import policy implementation --- packages/core/src/css_composer/index.ts | 2 + .../core/src/data_sources/config/config.ts | 9 + packages/core/src/data_sources/types.ts | 22 ++ .../src/dom_components/model/Components.ts | 23 +- .../model/ModelDataResolverWatchers.ts | 6 +- .../model/ModelResolverWatcher.ts | 84 +++++- packages/core/src/dom_components/types.ts | 2 + packages/core/src/editor/config/config.ts | 6 + packages/core/src/index.ts | 7 + .../test/specs/data_sources/import_policy.ts | 277 ++++++++++++++++++ 10 files changed, 424 insertions(+), 14 deletions(-) create mode 100644 packages/core/test/specs/data_sources/import_policy.ts diff --git a/packages/core/src/css_composer/index.ts b/packages/core/src/css_composer/index.ts index 19914f253..99d18a831 100644 --- a/packages/core/src/css_composer/index.ts +++ b/packages/core/src/css_composer/index.ts @@ -298,9 +298,11 @@ export default class CssComposer extends ItemManagerModule = {}, 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]; diff --git a/packages/core/src/data_sources/config/config.ts b/packages/core/src/data_sources/config/config.ts index 7a85ccbb6..76bcd8c2f 100644 --- a/packages/core/src/data_sources/config/config.ts +++ b/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; diff --git a/packages/core/src/data_sources/types.ts b/packages/core/src/data_sources/types.ts index 7545c0510..9c271bf2c 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 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 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..d76ac7ec0 100644 --- a/packages/core/src/dom_components/model/Components.ts +++ b/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', }); } 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..ccf9fba63 100644 --- a/packages/core/src/dom_components/model/ModelResolverWatcher.ts +++ b/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 { constructor( private model: WatchableModel, private updateFn: UpdateFn, + private kind: DataSourcePropertyKind, options: ModelResolverWatcherOptions, ) { this.em = options.em; @@ -33,6 +42,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,6 +53,7 @@ export class ModelResolverWatcher { 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 { 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 diff --git a/packages/core/src/dom_components/types.ts b/packages/core/src/dom_components/types.ts index 3df7798b1..2aee68209 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 { 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 { 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..33b09aa2d 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 { + DataSourceImportSource, + DataSourcePropertyAction, + DataSourcePropertyContext, + DataSourcePropertyHandler, + DataSourcePropertyKind, +} from './data_sources/types'; export default grapesjs; diff --git a/packages/core/test/specs/data_sources/import_policy.ts b/packages/core/test/specs/data_sources/import_policy.ts new file mode 100644 index 000000000..edd0dc1ca --- /dev/null +++ b/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 = {}) => { + ({ 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 = '
Imported
', + ) => { + 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('
'); + + 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'); + }); +});