From 12f376b752743950b1e1d024c344217c5d32ff0a Mon Sep 17 00:00:00 2001 From: mohamed yahia Date: Fri, 25 Apr 2025 14:56:43 +0300 Subject: [PATCH] Add an option to convert attributes with hyphens to camel case (#6501) * Add convertDataGjsAttributesHyphens option * up * A few fixes --------- Co-authored-by: Artur Arseniev --- .../conditional_variables/DataCondition.ts | 13 +- .../ComponentDataCollection.ts | 8 +- packages/core/src/parser/config/config.ts | 12 ++ packages/core/src/parser/model/ParserHtml.ts | 22 ++- packages/core/src/utils/dom.ts | 6 + .../test/specs/parser/model/ParserHtml.ts | 161 ++++++++++++++++++ 6 files changed, 200 insertions(+), 22 deletions(-) diff --git a/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts b/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts index d636e27f7..10a3f3ba3 100644 --- a/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts +++ b/packages/core/src/data_sources/model/conditional_variables/DataCondition.ts @@ -1,13 +1,12 @@ import { Model } from '../../../common'; import EditorModel from '../../../editor/model/Editor'; -import DataVariable, { DataVariableProps } from '../DataVariable'; +import { isDataVariable, valueOrResolve } from '../../utils'; +import { DataCollectionStateMap } from '../data_collection/types'; import DataResolverListener from '../DataResolverListener'; -import { valueOrResolve, isDataVariable } from '../../utils'; -import { DataConditionEvaluator, ConditionProps } from './DataConditionEvaluator'; +import DataVariable, { DataVariableProps } from '../DataVariable'; +import { ConditionProps, DataConditionEvaluator } from './DataConditionEvaluator'; import { BooleanOperation } from './operators/BooleanOperator'; import { StringOperation } from './operators/StringOperator'; -import { isUndefined } from 'underscore'; -import { DataCollectionStateMap } from '../data_collection/types'; import { DataConditionSimpleOperation } from './types'; export const DataConditionType = 'data-condition' as const; @@ -59,10 +58,6 @@ export class DataCondition extends Model { } constructor(props: DataConditionProps, opts: DataConditionOptions) { - if (isUndefined(props.condition)) { - opts.em.logError('No condition was provided to a conditional component.'); - } - super(props, opts); this.em = opts.em; this.collectionsStateMap = opts.collectionsStateMap ?? {}; diff --git a/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts b/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts index 10ddd5044..0c06a5372 100644 --- a/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts +++ b/packages/core/src/data_sources/model/data_collection/ComponentDataCollection.ts @@ -29,6 +29,7 @@ export default class ComponentDataCollection extends Component { // @ts-ignore ...super.defaults, droppable: false, + dataResolver: {}, type: DataCollectionType, components: [ { @@ -39,8 +40,6 @@ export default class ComponentDataCollection extends Component { } constructor(props: ComponentDataCollectionProps, opt: ComponentOptions) { - const dataResolver = props[keyCollectionDefinition]; - if (opt.forCloning) { return super(props as any, opt) as unknown as ComponentDataCollection; } @@ -48,11 +47,6 @@ export default class ComponentDataCollection extends Component { const em = opt.em; const newProps = { ...props, droppable: false } as any; const cmp: ComponentDataCollection = super(newProps, opt) as unknown as ComponentDataCollection; - if (!dataResolver) { - em.logError('missing collection definition'); - return cmp; - } - this.rebuildChildrenFromCollection = this.rebuildChildrenFromCollection.bind(this); this.listenToPropsChange(); this.rebuildChildrenFromCollection(); diff --git a/packages/core/src/parser/config/config.ts b/packages/core/src/parser/config/config.ts index 2074fd721..f5d9ae467 100644 --- a/packages/core/src/parser/config/config.ts +++ b/packages/core/src/parser/config/config.ts @@ -72,6 +72,17 @@ export interface HTMLParserOptions extends OptionAsDocument { * preParser: htmlString => DOMPurify.sanitize(htmlString) */ preParser?: (input: string, opts: { editor: Editor }) => string; + + /** + * Configures whether `data-gjs-*` attributes should be automatically converted from hyphenated to camelCase. + * + * When `true`: + * - Hyphenated `data-gjs-*` attributes (e.g., `data-gjs-my-component`) are transformed into camelCase (`data-gjs-myComponent`). + * - If `defaults` contains the camelCase version and not the original attribute, the camelCase will be used; otherwise, the original name is kept. + * + * @default false + */ + convertDataGjsAttributesHyphens?: boolean; } export interface ParserConfig { @@ -121,6 +132,7 @@ const config: () => ParserConfig = () => ({ allowUnsafeAttr: false, allowUnsafeAttrValue: false, keepEmptyTextNodes: false, + convertDataGjsAttributesHyphens: false, }, }); diff --git a/packages/core/src/parser/model/ParserHtml.ts b/packages/core/src/parser/model/ParserHtml.ts index d19edd128..35b3d28d4 100644 --- a/packages/core/src/parser/model/ParserHtml.ts +++ b/packages/core/src/parser/model/ParserHtml.ts @@ -1,10 +1,10 @@ -import { each, isArray, isFunction, isUndefined } from 'underscore'; +import { each, isArray, isFunction, isUndefined, result } from 'underscore'; import { ObjectAny, ObjectStrings } from '../../common'; import { ComponentDefinitionDefined, ComponentStackItem } from '../../dom_components/model/types'; import EditorModel from '../../editor/model/Editor'; import { HTMLParseResult, HTMLParserOptions, ParseNodeOptions, ParserConfig } from '../config/config'; import BrowserParserHtml from './BrowserParserHtml'; -import { doctypeToString } from '../../utils/dom'; +import { doctypeToString, processDataGjsAttributeHyphen } from '../../utils/dom'; import { isDef } from '../../utils/mixins'; import { ParserEvents } from '../types'; @@ -125,13 +125,17 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo return result; }, - parseNodeAttr(node: HTMLElement, result?: ComponentDefinitionDefined) { - const model = result || {}; + parseNodeAttr(node: HTMLElement, modelResult?: ComponentDefinitionDefined) { + const model = modelResult || {}; const attrs = node.attributes || []; const attrsLen = attrs.length; + const convertHyphens = !!config?.optionsHtml?.convertDataGjsAttributesHyphens; + const defaults = + (convertHyphens && !!model.type && result(em?.Components.getType(model.type)?.model.prototype, 'defaults')) || + {}; for (let i = 0; i < attrsLen; i++) { - const nodeName = attrs[i].nodeName; + let nodeName = attrs[i].nodeName; let nodeValue: string | boolean = attrs[i].nodeValue!; if (nodeName == 'style') { @@ -142,7 +146,13 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo continue; } else if (nodeName.indexOf(this.modelAttrStart) === 0) { const propsResult = this.getPropAttribute(nodeName, nodeValue); - model[propsResult.name] = propsResult.value; + let resolvedName = propsResult.name; + if (convertHyphens && !(resolvedName in defaults)) { + const transformed = processDataGjsAttributeHyphen(resolvedName); + resolvedName = transformed in defaults ? transformed : resolvedName; + } + + model[resolvedName] = propsResult.value; } else { // @ts-ignore Check for attributes from props (eg. required, disabled) if (nodeValue === '' && node[nodeName] === true) { diff --git a/packages/core/src/utils/dom.ts b/packages/core/src/utils/dom.ts index cea444ad9..e233bdcc9 100644 --- a/packages/core/src/utils/dom.ts +++ b/packages/core/src/utils/dom.ts @@ -247,3 +247,9 @@ export const off = ( els.forEach((el) => el?.removeEventListener(ev, fn as EventListener, opts)); }); }; + +export const processDataGjsAttributeHyphen = (str: string): string => { + const camelCased = str.replace(/-([a-zA-Z0-9])/g, (_, char) => char.toUpperCase()); + + return camelCased; +}; diff --git a/packages/core/test/specs/parser/model/ParserHtml.ts b/packages/core/test/specs/parser/model/ParserHtml.ts index 53314ec60..0f0000e4e 100644 --- a/packages/core/test/specs/parser/model/ParserHtml.ts +++ b/packages/core/test/specs/parser/model/ParserHtml.ts @@ -816,4 +816,165 @@ describe('ParserHtml', () => { }); }); }); + + describe('with convertDataGjsAttributesHyphens OFF (default)', () => { + beforeEach(() => { + em = new Editor({}); + em.Components.addType('test-cmp', { + isComponent: (el) => el.tagName === 'a', + model: { + defaults: { + type: 'default', + testAttr: 'value', + otherAttr: 'value', + }, + }, + }); + + obj = ParserHtml(em, { + textTags: ['br', 'b', 'i', 'u'], + textTypes: ['text', 'textnode', 'comment'], + returnArray: true, + optionsHtml: { convertDataGjsAttributesHyphens: false }, + }); + + obj.compTypes = em.Components.componentTypes; + }); + + test('keeps original attribute names', () => { + const str = ''; + const result = [ + { + tagName: 'a', + type: 'test-cmp', + 'test-attr': 'value1', + 'other-attr': 'value2', + }, + ]; + expect(obj.parse(str).html).toEqual(result); + }); + + test('does not convert data-gjs-data-resolver', () => { + const str = '
'; + const result = [ + { + type: 'data-variable', + tagName: 'div', + 'data-resolver': 'test', + }, + ]; + expect(obj.parse(str).html).toEqual(result); + }); + }); + + describe('with convertDataGjsAttributesHyphens ON', () => { + beforeEach(() => { + em = new Editor({}); + em.Components.addType('test-cmp', { + isComponent: (el) => el.tagName === 'a', + model: { + defaults: { + testAttr: 'value', + otherAttr: 'value', + nullAttr: null, + undefinedAttr: undefined, + 'hyphen-attr': 'value', + duplicatedAttr: 'value', + 'duplicated-attr': 'value', + }, + }, + }); + + obj = ParserHtml(em, { + returnArray: true, + optionsHtml: { convertDataGjsAttributesHyphens: true }, + }); + obj.compTypes = em.Components.componentTypes; + }); + + test('converts hyphenated to camelCase', () => { + const str = ''; + const result = [ + { + tagName: 'a', + type: 'test-cmp', + testAttr: 'value1', + otherAttr: 'value2', + }, + ]; + + expect(obj.parse(str).html).toEqual(result); + }); + + test('handles null/undefined values', () => { + const str = ''; + const result = [ + { + tagName: 'a', + type: 'test-cmp', + nullAttr: 'value', + undefinedAttr: 'some value', + }, + ]; + + expect(obj.parse(str).html).toEqual(result); + }); + + test('converts data-gjs-data-resolver to dataResolver', () => { + const str = ` +
+ `; + const result = [ + { + tagName: 'div', + type: 'data-variable', + dataResolver: { + type: 'data-variable', + path: 'some path', + collectionId: 'someCollectionId', + }, + }, + ]; + expect(obj.parse(str).html).toEqual(result); + }); + + test('handles defaults with original hyphenated', () => { + const str = ''; + const result = [ + { + tagName: 'a', + type: 'test-cmp', + 'hyphen-attr': 'value1', + }, + ]; + expect(obj.parse(str).html).toEqual(result); + }); + + test('handles defaults not containing camelCase or hyphenated', () => { + const str = ''; + const result = [ + { + tagName: 'a', + type: 'test-cmp', + 'new-attr': 'value1', + }, + ]; + expect(obj.parse(str).html).toEqual(result); + }); + + test('handles defaults with hyphenated and camelCase', () => { + const str = ''; + const result = [ + { + tagName: 'a', + type: 'test-cmp', + 'duplicated-attr': 'value1', + }, + ]; + expect(obj.parse(str).html).toEqual(result); + }); + }); });