Browse Source

Add keepAttributeIdsCrossPages option

IhorKaleniuk666-fix/id-conflict
Artur Arseniev 2 months ago
parent
commit
6c3f0ab0f4
  1. 8
      packages/core/src/dom_components/config/config.ts
  2. 72
      packages/core/src/dom_components/model/Component.ts
  3. 1
      packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts
  4. 183
      packages/core/test/specs/pages/pages-same-id-on-different-pages.ts

8
packages/core/src/dom_components/config/config.ts

@ -60,6 +60,14 @@ export interface DomComponentsConfig {
* work properly (eg. Web Components). * work properly (eg. Web Components).
*/ */
useFrameDoc?: boolean; useFrameDoc?: boolean;
/**
* Experimental!
* By default, the editor ensures unique attribute IDs for all components inside the project, no matter of the page.
* With this option enabled, the editor will keep same attributes IDs for components across different pages.
* This allows multiple components cross pages (eg. <footer id="footer"/>) to share the same ID (eg. to keep the same styling).
*/
keepAttributeIdsCrossPages?: boolean;
} }
const config: () => DomComponentsConfig = () => ({ const config: () => DomComponentsConfig = () => ({

72
packages/core/src/dom_components/model/Component.ts

@ -72,6 +72,11 @@ import {
export interface IComponent extends ExtractMethods<Component> {} export interface IComponent extends ExtractMethods<Component> {}
export interface SetAttrOptions extends SetOptions, UpdateStyleOptions, DataWatchersOptions {} export interface SetAttrOptions extends SetOptions, UpdateStyleOptions, DataWatchersOptions {}
export interface ComponentSetOptions extends SetOptions, DataWatchersOptions {} export interface ComponentSetOptions extends SetOptions, DataWatchersOptions {}
export interface CheckIdOptions {
keepIds?: string[];
idMap?: PrevToNewIdMap;
updatedIds?: Record<string, ComponentDefinitionDefined[]>;
}
const escapeRegExp = (str: string) => { const escapeRegExp = (str: string) => {
return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&'); return str.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&');
@ -2062,81 +2067,72 @@ export default class Component extends StyleableModel<ComponentProperties> {
static ensureInList(model: Component) { static ensureInList(model: Component) {
const list = Component.getList(model); const list = Component.getList(model);
const id = model.getId(); const propId = model.id as string | undefined;
const id = propId || model.getId();
const current = list[id]; const current = list[id];
if (!current) { if (!current) {
list[id] = model; list[id] = model;
} else if (current !== model) { } else if (current !== model) {
const keepIdsCrossPages = model.em?.Components.config.keepAttributeIdsCrossPages;
const currentPage = current.page; const currentPage = current.page;
const modelPage = model.page; const modelPage = model.page;
const samePage = !!currentPage && !!modelPage && currentPage === modelPage; const samePage = !!currentPage && !!modelPage && currentPage === modelPage;
if (samePage) {
const nextId = Component.getIncrementId(id, list); const nextId = Component.getIncrementId(id, list);
if (samePage || !keepIdsCrossPages) {
model.setId(nextId); model.setId(nextId);
list[nextId] = model;
} else { } else {
const baseId = (model as any).ccid || id; model.set({ id: nextId });
let nextId = baseId;
while (list[nextId] && list[nextId] !== model) {
nextId = Component.getIncrementId(nextId, list);
} }
(model as any).ccid = nextId;
list[nextId] = model; list[nextId] = model;
} }
}
model.components().forEach((i) => Component.ensureInList(i)); model.components().forEach((i) => Component.ensureInList(i));
} }
static createId( static createId(model: Component, opts: CheckIdOptions = {}) {
model: Component,
opts: {
keepIds?: string[];
idMap?: PrevToNewIdMap;
updatedIds?: Record<string, ComponentDefinitionDefined[]>;
} = {},
) {
const list = Component.getList(model); const list = Component.getList(model);
const keepIdsCrossPages = model.em?.Components.config.keepAttributeIdsCrossPages;
const { idMap = {} } = opts; const { idMap = {} } = opts;
const attrs = model.get('attributes') || {}; const attrs = model.get('attributes') || {};
const attrId = attrs.id as string | undefined; const attrId = attrs.id as string | undefined;
const propId = model.id as string | undefined;
const currentId = propId ?? attrId;
let nextId: string; let nextId: string;
if (attrId) { if (propId) {
nextId = Component.getIncrementId(propId, list, opts);
if (nextId !== propId) {
model.set({ id: nextId });
}
} else if (attrId) {
const existing = list[attrId] as Component | undefined; const existing = list[attrId] as Component | undefined;
if (!existing || existing === model) { if (!existing || existing === model) {
nextId = attrId; nextId = attrId;
if (!list[nextId]) {
list[nextId] = model;
}
} else { } else {
const existingPage = existing.page; const existingPage = existing.page;
const newPage = model.page; const newPage = model.page;
const samePage = !!existingPage && !!newPage && existingPage === newPage; const samePage = !!existingPage && !!newPage && existingPage === newPage;
if (samePage) {
nextId = Component.getIncrementId(attrId, list, opts); nextId = Component.getIncrementId(attrId, list, opts);
if (samePage || !keepIdsCrossPages) {
model.setId(nextId); model.setId(nextId);
if (attrId !== nextId) {
idMap[attrId] = nextId;
}
list[nextId] = model;
} else { } else {
nextId = Component.getIncrementId(attrId, list, opts); model.set({ id: nextId });
list[nextId] = model;
} }
} }
} else { } else {
nextId = Component.getNewId(list); nextId = Component.getNewId(list);
list[nextId] = model;
} }
if (!!currentId && currentId !== nextId) {
idMap[currentId] = nextId;
}
list[nextId] = model;
return nextId; return nextId;
} }
@ -2169,20 +2165,14 @@ export default class Component extends StyleableModel<ComponentProperties> {
} }
static getList(model: Component) { static getList(model: Component) {
const { em } = model; return model.em?.Components?.componentsById ?? {};
const dm = em?.Components;
return dm?.componentsById ?? {};
} }
static checkId( static checkId(
components: ComponentDefinitionDefined | ComponentDefinitionDefined[], components: ComponentDefinitionDefined | ComponentDefinitionDefined[],
styles: CssRuleJSON[] = [], styles: CssRuleJSON[] = [],
list: ObjectAny = {}, list: ObjectAny = {},
opts: { opts: CheckIdOptions = {},
keepIds?: string[];
idMap?: PrevToNewIdMap;
updatedIds?: Record<string, ComponentDefinitionDefined[]>;
} = {},
) { ) {
opts.updatedIds = opts.updatedIds || {}; opts.updatedIds = opts.updatedIds || {};
const comps = isArray(components) ? components : [components]; const comps = isArray(components) ? components : [components];

1
packages/core/test/specs/data_sources/model/data_collection/ComponentDataCollection.ts

@ -104,7 +104,6 @@ describe('Collection component', () => {
expect(cmp.getInnerHTML()).toBe(innerHTML); expect(cmp.getInnerHTML()).toBe(innerHTML);
expect(cmp.toHTML()).toBe(`<${tagName} id="${cmp.getId()}">${innerHTML}</${tagName}>`); expect(cmp.toHTML()).toBe(`<${tagName} id="${cmp.getId()}">${innerHTML}</${tagName}>`);
expect(cmp.getEl()?.innerHTML).toBe(innerHTML); expect(cmp.getEl()?.innerHTML).toBe(innerHTML);
expect(JSON.parse(JSON.stringify(cmp.toJSON()))).toEqual({ expect(JSON.parse(JSON.stringify(cmp.toJSON()))).toEqual({
tagName: cmp.tagName, tagName: cmp.tagName,
dataResolver: cmp.get('dataResolver'), dataResolver: cmp.get('dataResolver'),

183
packages/core/test/specs/pages/pages-same-id-on-different-pages.ts

@ -1,28 +1,73 @@
import Editor from '../../../src/editor'; import Editor from '../../../src/editor';
import EditorModel from '../../../src/editor/model/Editor'; import EditorModel from '../../../src/editor/model/Editor';
import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper'; import ComponentWrapper from '../../../src/dom_components/model/ComponentWrapper';
import { setupTestEditor } from '../../common';
describe('Pages with same component ids across pages', () => { describe('Pages with same component ids across pages', () => {
let editor: Editor; let editor: Editor;
let em: EditorModel; let em: EditorModel;
let pm: Editor['Pages']; let pm: Editor['Pages'];
let domc: Editor['Components']; let domc: Editor['Components'];
const getTitle = (wrapper: ComponentWrapper) => wrapper.components().at(0)!.components().at(0)!; const rootDefaultProps = {
type: 'wrapper',
head: { type: 'head' },
docEl: { tagName: 'html' },
stylable: [
'background',
'background-color',
'background-image',
'background-repeat',
'background-attachment',
'background-position',
'background-size',
],
};
beforeAll(() => { const getTitle = (wrapper: ComponentWrapper) => wrapper.components().at(0);
editor = new Editor({
pageManager: { const getRootComponent = ({ idBody = 'body', idTitle = 'main-title', contentTitle = 'A' } = {}) => ({
pages: [
{
id: 'p1',
frames: [
{
component: {
type: 'wrapper', type: 'wrapper',
attributes: { id: 'body' }, attributes: { id: idBody },
components: [ components: [
{ {
tagName: 'section', tagName: 'h1',
type: 'text',
attributes: { id: idTitle },
components: [{ type: 'textnode', content: contentTitle }],
},
],
});
beforeEach(() => {
({ editor } = setupTestEditor());
em = editor.getModel();
pm = em.Pages;
domc = em.Components;
});
afterEach(() => {
editor.destroy();
});
test('Default behavior with pages having components with same ids are incremented', () => {
editor.Pages.add({
id: 'page1',
frames: [{ component: getRootComponent() }],
});
editor.Pages.add({
id: 'page2',
frames: [{ component: getRootComponent({ contentTitle: 'B' }) }],
});
const root1 = pm.get('page1')!.getMainComponent();
const root2 = pm.get('page2')!.getMainComponent();
expect(editor.getHtml({ component: root1 })).toBe('<body id="body"><h1 id="main-title">A</h1></body>');
expect(editor.getHtml({ component: root2 })).toBe('<body id="body-2"><h1 id="main-title-2">B</h1></body>');
expect(JSON.parse(JSON.stringify(root1))).toEqual({
...rootDefaultProps,
attributes: { id: 'body' },
components: [ components: [
{ {
tagName: 'h1', tagName: 'h1',
@ -31,81 +76,83 @@ describe('Pages with same component ids across pages', () => {
components: [{ type: 'textnode', content: 'A' }], components: [{ type: 'textnode', content: 'A' }],
}, },
], ],
}, });
],
}, expect(JSON.parse(JSON.stringify(root2))).toEqual({
}, ...rootDefaultProps,
], attributes: { id: 'body-2' },
},
{
id: 'p2',
frames: [
{
component: {
type: 'wrapper',
attributes: { id: 'body' },
components: [
{
tagName: 'section',
components: [ components: [
{ {
tagName: 'h1', tagName: 'h1',
type: 'text', type: 'text',
attributes: { id: 'main-title' }, attributes: { id: 'main-title-2' },
components: [{ type: 'textnode', content: 'B' }], components: [{ type: 'textnode', content: 'B' }],
}, },
], ],
},
],
},
},
],
},
],
},
});
em = editor.getModel();
pm = em.Pages;
domc = em.Components;
pm.onLoad();
}); });
afterAll(() => {
editor.destroy();
}); });
test('Handles pages with components having the same id across pages', () => { test('Handles pages with components having the same id across pages', () => {
const pages = pm.getAll(); editor.Components.config.keepAttributeIdsCrossPages = true;
expect(pages.length).toBe(2); editor.Pages.add({
id: 'page1',
const p1 = pages[0]; frames: [{ component: getRootComponent() }],
const p2 = pages[1]; });
editor.Pages.add({
const w1 = p1.getMainComponent(); id: 'page2',
const w2 = p2.getMainComponent(); frames: [{ component: getRootComponent({ contentTitle: 'B' }) }],
});
const page1 = pm.get('page1')!;
const page2 = pm.get('page2')!;
const root1 = page1.getMainComponent();
const root2 = page2.getMainComponent();
expect(w1.getId()).toBe('body'); expect(root1.getId()).toBe('body');
expect(w2.getId()).toBe('body'); expect(root2.getId()).toBe('body');
const t1 = getTitle(w1); const title1 = getTitle(root1);
const t2 = getTitle(w2); const title2 = getTitle(root2);
// IDs should be preserved per page but stored uniquely in the shared map // IDs should be preserved per page but stored uniquely in the shared map
expect(t1.getId()).toBe('main-title'); expect(title1.getId()).toBe('main-title');
expect(t2.getId()).toBe('main-title'); expect(title2.getId()).toBe('main-title');
const all = domc.allById(); const all = domc.allById();
expect(all['body']).toBe(w1); expect(all['body']).toBe(root1);
expect(all['body-2']).toBe(w2); expect(all['body-2']).toBe(root2);
expect(all['main-title']).toBe(t1); expect(all['main-title']).toBe(title1);
expect(all['main-title-2']).toBe(t2); expect(all['main-title-2']).toBe(title2);
expect(editor.getHtml({ component: root1 })).toBe('<body id="body"><h1 id="main-title">A</h1></body>');
expect(editor.getHtml({ component: root2 })).toBe('<body id="body"><h1 id="main-title">B</h1></body>');
const html1 = editor.getHtml({ component: w1 }); expect(JSON.parse(JSON.stringify(root1))).toEqual({
const html2 = editor.getHtml({ component: w2 }); ...rootDefaultProps,
attributes: { id: 'body' },
components: [
{
tagName: 'h1',
type: 'text',
attributes: { id: 'main-title' },
components: [{ type: 'textnode', content: 'A' }],
},
],
});
expect(html1).toContain('A'); expect(JSON.parse(JSON.stringify(root2))).toEqual({
expect(html2).toContain('B'); ...rootDefaultProps,
id: 'body-2',
attributes: { id: 'body' },
components: [
{
id: 'main-title-2',
tagName: 'h1',
type: 'text',
attributes: { id: 'main-title' },
components: [{ type: 'textnode', content: 'B' }],
},
],
});
}); });
}); });

Loading…
Cancel
Save