UNPKG

@silexlabs/grapesjs-advanced-selector

Version:

A GrapesJS plugin for managing advanced CSS selectors with a visual interface

1,078 lines (946 loc) 34.2 kB
import { CssRule, Editor, Selector } from 'grapesjs' import { Component } from 'grapesjs' import { getSuggestionsMain, getSuggestionsRelated, getComponentSelector, getSelectors, renameSelector } from './GrapesJsSelectors' import { ClassSelector, IdSelector, SimpleSelectorType, TAGS, TagSelector } from './SimpleSelector' import { Operator, OperatorType } from './Operator' describe('getSelectors', () => { let editorMock: Editor let componentMock: Component test('should return html and body when component tag is body', () => { componentMock = { tagName: 'BODY', view: { el: { matches: jest.fn((sel: string) => sel === 'BODY[data-attr]'), } } } as unknown as Component editorMock = { getSelectedAll: jest.fn(() => [componentMock]), CssComposer: { getRules: jest.fn(() => [ { getSelectorsString: jest.fn(() => 'html.test-class'), getStyle: jest.fn(() => ({ background: 'red' })), getAtRule: jest.fn(), get: jest.fn(), clone: function() { return this }, }, { getSelectorsString: jest.fn(() => 'BODY[data-attr]'), getStyle: jest.fn(() => ({ background: 'red' })), getAtRule: jest.fn(), get: jest.fn(), clone: function() { return this }, }, ]) } } as unknown as Editor const selectors = getSelectors(editorMock) expect(selectors).toContainEqual({ mainSelector: { selectors: [ { active: true, type: 'tag', value: 'html' }, { active: true, type: 'class', value: 'test-class' } ] } }) expect(selectors).toContainEqual({ mainSelector: { selectors: [ { active: true, type: 'tag', value: 'BODY' }, { active: true, attributeValue: '', type: 'attribute', value: 'data-attr' } ] } }) }) // test('should return complex selectors for matching rules', () => { // componentMock = { // view: { // el: { // matches: jest.fn(), // tagName: 'DIV', // } // } // } as unknown as Component // const ruleMock = { // clone: jest.fn().mockReturnThis(), // getStyle: jest.fn().mockReturnValue({ color: 'red' }), // get: jest.fn(), // getSelectorsString: jest.fn().mockReturnValue('div.red') // } // componentMock.view.el.matches.mockReturnValue(true) // editorMock.getSelectedAll.mockReturnValue([componentMock]) // editorMock.CssComposer.getRules.mockReturnValue([ruleMock]) // const selectors = getSelectors(editorMock) // expect(selectors).toHaveLength(1) // expect(selectors[0]).toEqual(expect.objectContaining({ type: 'CLASS', value: 'red', active: true })) // }) // test('should log warning on rule with state', () => { // componentMock = { // view: { // el: { // matches: jest.fn(), // tagName: 'DIV', // } // } // } as unknown as Component // const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => { }) // const ruleMock = { // clone: jest.fn().mockReturnThis(), // getStyle: jest.fn().mockReturnValue({ color: 'red' }), // get: jest.fn().mockReturnValue('state'), // getSelectorsString: jest.fn().mockReturnValue('div.state') // } // componentMock.view.el.matches.mockReturnValue(true) // editorMock.getSelectedAll.mockReturnValue([componentMock]) // editorMock.CssComposer.getRules.mockReturnValue([ruleMock]) // getSelectors(editorMock) // expect(warnSpy).toHaveBeenCalledWith('Rule has a state', expect.any(Object)) // warnSpy.mockRestore() // }) test('Component with selector', () => { const mockComponent = { get: jest.fn().mockReturnValue({ mainSelector: { selectors: [ { type: SimpleSelectorType.CLASS, value: 'existing-class', active: true }, ], }, }), getClasses: jest.fn().mockReturnValue(['other-class']), setClass: jest.fn(), addClass: jest.fn(), set: jest.fn(), } as unknown as Component const result = getComponentSelector(mockComponent) expect(result.mainSelector.selectors).toContainEqual({ type: SimpleSelectorType.CLASS, value: 'existing-class', active: true }) expect(mockComponent.setClass).not.toHaveBeenCalled() expect(mockComponent.addClass).not.toHaveBeenCalled() }) test('component without selector should return the classes (getClasses)', () => { const mockComponent = { get: jest.fn().mockReturnValue(undefined), getClasses: jest.fn().mockReturnValue(['class-one', 'class-two']), } as unknown as Component const result = getComponentSelector(mockComponent) expect(result.mainSelector?.selectors).toBeDefined() expect(result.mainSelector.selectors).toHaveLength(2) expect(result.mainSelector.selectors).toContainEqual({ type: SimpleSelectorType.CLASS, value: 'class-one', active: false }) expect(result.mainSelector.selectors).toContainEqual({ type: SimpleSelectorType.CLASS, value: 'class-two', active: false }) expect(mockComponent.getClasses).toHaveBeenCalled() }) }) describe('getSuggestionsMain', () => { test('Element with ID and class', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), // Mock empty CSS rules to avoid errors }, } as unknown as Editor // Mock Component const mockComponent = { getAttributes: () => ({ 'data-role': 'button' }), getId: () => 'uniqueId', getClasses: () => ['container'], get tagName() { return 'div' }, } as unknown as Component const selectedComponents = [mockComponent] const suggestions = getSuggestionsMain(mockEditor, selectedComponents, { mainSelector: { selectors: [ { type: SimpleSelectorType.ID, value: 'uniqueId', active: true } as IdSelector, { type: SimpleSelectorType.CLASS, value: 'container', active: true } as ClassSelector ], }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'div', active: true }, { type: SimpleSelectorType.ATTRIBUTE, value: 'data-role', operator: '=', attributeValue: 'button', active: true }, ]) }) test('Element without class or ID', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Component without class or ID const mockComponent = { getAttributes: () => ({ 'data-custom': 'value' }), getId: () => '', getClasses: () => [], get tagName() { return 'span' }, } as unknown as Component const selectedComponents = [mockComponent] const suggestions = getSuggestionsMain(mockEditor, selectedComponents, { mainSelector: { selectors: [] }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'span', active: true }, { type: SimpleSelectorType.ATTRIBUTE, value: 'data-custom', operator: '=', attributeValue: 'value', active: true }, ]) }) test('Element with multiple classes', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Component with multiple classes const mockComponent = { getAttributes: () => ({}), // No attributes getId: () => '', getClasses: () => ['box', 'shadow', 'rounded'], get tagName() { return 'div' }, } as unknown as Component const selectedComponents = [mockComponent] const suggestions = getSuggestionsMain(mockEditor, selectedComponents, { mainSelector: { selectors: [ { type: SimpleSelectorType.CLASS, value: 'box', active: true } as ClassSelector, { type: SimpleSelectorType.CLASS, value: 'shadow', active: true } as ClassSelector, { type: SimpleSelectorType.CLASS, value: 'rounded', active: true } as ClassSelector, ], }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'div', active: true }, ]) }) test('Element with class and attributes', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Component with a class and attributes const mockComponent = { getAttributes: () => ({ type: 'submit', 'aria-disabled': 'true', }), getId: () => '', getClasses: () => ['btn'], get tagName() { return 'button' }, } as unknown as Component const selectedComponents = [mockComponent] const suggestions = getSuggestionsMain(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.CLASS, value: 'btn', active: true } as ClassSelector], }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'button', active: true }, { type: SimpleSelectorType.ATTRIBUTE, value: 'type', operator: '=', attributeValue: 'submit', active: true }, { type: SimpleSelectorType.ATTRIBUTE, value: 'aria-disabled', operator: '=', attributeValue: 'true', active: true }, ]) }) test('Element with ID only', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Component with an ID and an attribute const mockComponent = { getAttributes: () => ({ 'data-visible': 'true', }), getId: () => 'main-section', getClasses: () => [], get tagName() { return 'section' }, } as unknown as Component const selectedComponents = [mockComponent] const suggestions = getSuggestionsMain(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.ID, value: 'main-section', active: true } as IdSelector], }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'section', active: true }, { type: SimpleSelectorType.ATTRIBUTE, value: 'data-visible', operator: '=', attributeValue: 'true', active: true }, ]) }) test('Element with a custom tag', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Component with a custom tag and an attribute const mockComponent = { getAttributes: () => ({ 'data-theme': 'dark', }), getId: () => '', getClasses: () => [], get tagName() { return 'my-component' }, } as unknown as Component const selectedComponents = [mockComponent] const suggestions = getSuggestionsMain(mockEditor, selectedComponents, { mainSelector: { selectors: [], }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'my-component', active: true }, { type: SimpleSelectorType.ATTRIBUTE, value: 'data-theme', operator: '=', attributeValue: 'dark', active: true }, ]) }) test('Element with a custom tag and empty mainSelector', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Component with a custom tag and an attribute const mockComponent = { getAttributes: () => ({ 'data-theme': 'dark', }), getId: () => '', getClasses: () => [], get tagName() { return 'my-component' }, } as unknown as Component const selectedComponents = [mockComponent] const suggestions = getSuggestionsMain(mockEditor, selectedComponents, { mainSelector: { selectors: [], // Empty mainSelector }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'my-component', active: true }, { type: SimpleSelectorType.ATTRIBUTE, value: 'data-theme', operator: '=', attributeValue: 'dark', active: true }, ]) }) test('Suggest `body` tag', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Selected Component with body tag const mockSelectedComponent = { getId: () => '', getClasses: () => [], getAttributes: () => [], get tagName() { return 'body' }, } as unknown as Component const selectedComponents = [mockSelectedComponent] const suggestions = getSuggestionsMain(mockEditor, selectedComponents, { mainSelector: { selectors: [] }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'body', active: true }, { type: SimpleSelectorType.TAG, value: 'html', active: true }, ]) }) }) describe('getSuggestionsRelated', () => { test('Direct child selector (`>` operator)', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Parent Component (div) const mockParentComponent = { getId: () => '', getClasses: () => [], get tagName() { return 'div' }, components: () => [], } as unknown as Component // Mock Child Component (p) const mockChildComponent = { getId: () => '', getClasses: () => ['text'], get tagName() { return 'p' }, parent: () => mockParentComponent, } as unknown as Component const selectedComponents = [mockChildComponent] const suggestions = getSuggestionsRelated(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.CLASS, value: 'text', active: true } as ClassSelector] }, operator: { type: OperatorType.CHILD, isCombinator: true } as Operator, relatedSelector: { selectors: [] }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'div', active: true }, ]) }) test('Descendant selector (` ` operator)', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Grandparent Component (section) const mockGrandparentComponent = { getId: () => '', getClasses: () => [], get tagName() { return 'section' }, parent: () => null, } as unknown as Component // Mock Parent Component (div) const mockParentComponent = { getId: () => '', getClasses: () => [], get tagName() { return 'div' }, parent: () => mockGrandparentComponent, } as unknown as Component // Mock Child Component (span) const mockChildComponent = { getId: () => '', getClasses: () => ['highlight'], get tagName() { return 'span' }, parent: () => mockParentComponent, } as unknown as Component const selectedComponents = [mockChildComponent] const suggestions = getSuggestionsRelated(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.CLASS, value: 'highlight', active: true } as ClassSelector] }, operator: { type: OperatorType.DESCENDANT, isCombinator: true } as Operator, relatedSelector: { selectors: [] }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'div', active: true }, { type: SimpleSelectorType.TAG, value: 'section', active: true }, ]) }) test('Adjacent sibling selector (`+` operator)', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Parent Component (div) const mockParentComponent = { getId: () => '', getClasses: () => [], get tagName() { return 'div' }, components: () => [mockPreviousSibling, mockSelectedComponent, mockNextSibling], } as unknown as Component // Mock Previous Sibling Component (h2) const mockPreviousSibling = { getId: () => '', getClasses: () => ['prev'], get tagName() { return 'h2' }, parent: () => mockParentComponent, } as unknown as Component // Mock Selected Component (p) const mockSelectedComponent = { getId: () => '', getClasses: () => ['selected'], get tagName() { return 'p' }, parent: () => mockParentComponent, prev: () => mockPreviousSibling, } as unknown as Component const mockNextSibling = { ...mockSelectedComponent, getClasses: () => ['other'], } const selectedComponents = [mockSelectedComponent] const suggestions = getSuggestionsRelated(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.CLASS, value: 'description', active: true } as ClassSelector] }, operator: { type: OperatorType.ADJACENT, isCombinator: true } as Operator, relatedSelector: { selectors: [] }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'h2', active: true }, { type: SimpleSelectorType.CLASS, value: 'prev', active: true }, ]) }) test('General sibling selector (`~` operator)', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Previous Sibling Components (h2 and div) const mockPreviousSiblingComponent1 = { getId: () => '', getClasses: () => [], get tagName() { return 'h2' }, } as unknown as Component const mockPreviousSiblingComponent2 = { getId: () => '', getClasses: () => [], get tagName() { return 'div' }, } as unknown as Component // Mock Target Component (p) const mockTargetComponent = { getId: () => '', getClasses: () => ['text'], get tagName() { return 'p' }, parent: () => ({ components: () => [mockPreviousSiblingComponent1, mockPreviousSiblingComponent2, mockTargetComponent], // Mock siblings }), } as unknown as Component const selectedComponents = [mockTargetComponent] const suggestions = getSuggestionsRelated(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.CLASS, value: 'text', active: true } as ClassSelector] }, operator: { type: OperatorType.GENERAL_SIBLING, isCombinator: true } as Operator, relatedSelector: { selectors: [] }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'h2', active: true }, { type: SimpleSelectorType.TAG, value: 'div', active: true }, ]) }) test('`:has()` pseudo-class selector', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Child Components (button and span) const mockChildComponent1 = { getId: () => '', getClasses: () => [], get tagName() { return 'button' }, components: () => [], } as unknown as Component const mockChildComponent2 = { getId: () => '', getClasses: () => [], get tagName() { return 'span' }, components: () => [], } as unknown as Component // Mock Parent Component (div) const mockParentComponent = { getId: () => '', getClasses: () => ['container'], get tagName() { return 'div' }, components: () => [mockChildComponent1, mockChildComponent2], // Mock children } as unknown as Component const selectedComponents = [mockParentComponent] const suggestions = getSuggestionsRelated(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.CLASS, value: 'container', active: true } as ClassSelector] }, operator: { type: OperatorType.HAS, isCombinator: false } as Operator, relatedSelector: { selectors: [] }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'button', active: true }, { type: SimpleSelectorType.TAG, value: 'span', active: true }, ]) }) test('`:not()` pseudo-class selector', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([{ at: jest.fn().mockReturnValue({ remove: jest.fn() }), // Mock existing CSS rule getSelectors: jest.fn().mockReturnValue([{ get: jest.fn((attr) => { switch (attr) { case 'selectors': return [{ type: SimpleSelectorType.CLASS, value: 'text', active: true }] case 'private': return false case 'type': return 1 case 'name': return 'sibling1' default: throw new Error(`Unknown attribute: ${attr}`) } }), } as Partial<Selector>]), // Mock existing CSS rule } as Partial<CssRule>]), // Mock existing CSS rule }, } as unknown as Editor // Mock Sibling Components (h2 and div) const mockSiblingComponent1 = { getId: () => '', getClasses: () => [], get tagName() { return 'h2' }, } as unknown as Component const mockSiblingComponent2 = { getId: () => '', getClasses: () => ['sibling1'], // useless here but feels more real get tagName() { return 'div' }, } as unknown as Component // Mock Selected Component (p) const mockSelectedComponent = { getId: () => '', getClasses: () => ['text'], get tagName() { return 'p' }, parent: () => ({ components: () => [mockSiblingComponent1, mockSelectedComponent, mockSiblingComponent2], // Mock siblings }), } as unknown as Component const selectedComponents = [mockSelectedComponent] const suggestions = getSuggestionsRelated(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.CLASS, value: 'text', active: true } as ClassSelector] }, operator: { type: OperatorType.NOT, isCombinator: false } as Operator, relatedSelector: { selectors: [] }, }) expect(suggestions).toEqual([ ...TAGS.map(tag => ({ type: SimpleSelectorType.TAG, value: tag, active: true })), { type: SimpleSelectorType.CLASS, value: 'sibling1', active: true }, ]) }) test('No operator (direct selection)', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Selected Component (button) const mockSelectedComponent = { getId: () => '', getClasses: () => ['a-class'], get tagName() { return 'body' }, } as unknown as Component const selectedComponents = [mockSelectedComponent] const suggestions = getSuggestionsRelated(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.CLASS, value: 'a-class', active: true } as ClassSelector] }, operator: undefined, // No operator relatedSelector: { selectors: [] }, }) expect(suggestions).toEqual([]) // No suggestions should be returned }) test('`:is()` pseudo-class selector', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor const mockTargetComponent = { getId: () => 'main', getClasses: () => ['target'], get tagName() { return 'p' }, parent: () => [], } as unknown as Component const selectedComponents = [mockTargetComponent] // Without the target class in related, it should be suggested expect(getSuggestionsRelated(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.CLASS, value: 'target', active: false } as ClassSelector] }, operator: { type: OperatorType.IS, isCombinator: false } as Operator, relatedSelector: { selectors: [] }, })).toEqual([ { type: SimpleSelectorType.TAG, value: 'p', active: true }, { type: SimpleSelectorType.CLASS, value: 'target', active: true }, ]) // With the class on related, it should not be suggested expect(getSuggestionsRelated(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.ID, value: 'main', active: true } as IdSelector] }, operator: { type: OperatorType.IS, isCombinator: false } as Operator, relatedSelector: { selectors: [{ type: SimpleSelectorType.CLASS, value: 'target', active: true } as ClassSelector] }, })).toEqual([ { type: SimpleSelectorType.TAG, value: 'p', active: true }, ]) }) test('`:where()` pseudo-class selector', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Parent Component (nav) const mockParentComponent = { getId: () => '', getClasses: () => ['navbar'], get tagName() { return 'nav' }, parent: () => null, } as unknown as Component // Mock Target Component (ul) const mockTargetComponent = { getId: () => '', getClasses: () => ['menu'], get tagName() { return 'ul' }, parent: () => mockParentComponent, } as unknown as Component const selectedComponents = [mockTargetComponent] const suggestions = getSuggestionsRelated(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.CLASS, value: 'menu', active: true } as ClassSelector] }, operator: { type: OperatorType.WHERE, isCombinator: false } as Operator, relatedSelector: { selectors: [] }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'ul', active: true }, { type: SimpleSelectorType.CLASS, value: 'menu', active: true }, ]) }) test('Complex nested structure with multiple relations', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Grandparent Component (section) const mockGrandparentComponent = { getId: () => '', getClasses: () => ['grandparent'], get tagName() { return 'section' }, parent: () => undefined, } as Component // Mock Parent Component (article) const mockParentComponent = { getId: () => '', getClasses: () => ['parent'], get tagName() { return 'article' }, parent: () => mockGrandparentComponent, } as Component // Mock Previous Sibling (div) const mockSiblingComponent = { getId: () => '', getClasses: () => ['sibling1'], get tagName() { return 'div' }, } as Component // Mock Target Component (p) const mockTargetComponent = { getId: () => '', getClasses: () => ['target'], get tagName() { return 'p' }, parent: () => mockParentComponent, } as Component // The parent component has both the target and a sibling // @ts-expect-error GrapesJs type is too complex mockParentComponent.components = () => [mockSiblingComponent as Component, mockTargetComponent as Component] as Component[] const selectedComponents = [mockTargetComponent] const suggestions = getSuggestionsRelated(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.CLASS, value: 'target', active: true } as ClassSelector] }, operator: { type: OperatorType.DESCENDANT, isCombinator: true } as Operator, relatedSelector: { selectors: [] }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'article', active: true }, { type: SimpleSelectorType.TAG, value: 'section', active: true }, { type: SimpleSelectorType.CLASS, value: 'parent', active: true }, { type: SimpleSelectorType.CLASS, value: 'grandparent', active: true }, ]) }) test('`:is()` pseudo-class on a div', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([ { at: jest.fn().mockReturnValue({ remove: jest.fn() }), // Mock existing CSS rule getSelectors: jest.fn().mockReturnValue([ { get: jest.fn((attr) => { switch (attr) { case 'selectors': return [{ type: SimpleSelectorType.TAG, value: 'body', active: true }] case 'private': return false case 'type': return 1 case 'name': return 'sibling1' default: throw new Error(`Unknown attribute: ${attr}`) } }), } as Partial<Selector>, ]), // Mock existing CSS rule } as Partial<CssRule>, ]), }, } as unknown as Editor // Mock Selected Component (button) const mockSelectedComponent = { getId: () => '', getClasses: () => ['a-class'], get tagName() { return 'div' }, } as unknown as Component const selectedComponents = [mockSelectedComponent] expect(getSuggestionsRelated(mockEditor, selectedComponents, { mainSelector: { selectors: [{ type: SimpleSelectorType.TAG, value: 'div', active: true } as TagSelector] }, operator: { type: OperatorType.IS, isCombinator: false } as Operator, relatedSelector: { selectors: [] }, })) .toEqual([{ type: SimpleSelectorType.TAG, value: 'div', active: true, }, { type: SimpleSelectorType.CLASS, value: 'a-class', active: true, }]) }) test('Suggest `html` tag when `body` is suggested', () => { // Mock GrapesJS Editor const mockEditor = { CssComposer: { getAll: jest.fn().mockReturnValue([]), }, } as unknown as Editor // Mock Selected Component with body tag const mockParentComponent = { getId: () => '', getClasses: () => [], getAttributes: () => [], parent: () => undefined, get tagName() { return 'body' }, } as unknown as Component const mockSelectedComponent = { getId: () => '', getClasses: () => [], getAttributes: () => [], parent: () => mockParentComponent, get tagName() { return 'div' }, } as unknown as Component const selectedComponents = [mockSelectedComponent] const suggestions = getSuggestionsRelated(mockEditor, selectedComponents, { mainSelector: { selectors: [] }, operator: { type: OperatorType.DESCENDANT, isCombinator: true } as Operator, relatedSelector: { selectors: [] }, }) expect(suggestions).toEqual([ { type: SimpleSelectorType.TAG, value: 'body', active: true }, { type: SimpleSelectorType.TAG, value: 'html', active: true }, ]) }) }) describe('CSS Class Renaming Issue', () => { test('renameSelector should only affect selected components, not all components with the same class', () => { // Mock components that both have the same class const selectedComponent = { getId: () => 'selected-comp', getClasses: () => ['shared-class'], get: () => ({ mainSelector: { selectors: [ { type: SimpleSelectorType.CLASS, value: 'shared-class', active: true } ] } }), set: jest.fn(), setClass: jest.fn() } as unknown as Component const otherComponent = { getId: () => 'other-comp', getClasses: () => ['shared-class'], get: () => ({ mainSelector: { selectors: [ { type: SimpleSelectorType.CLASS, value: 'shared-class', active: true } ] } }), set: jest.fn(), setClass: jest.fn() } as unknown as Component // Mock editor that finds both components when searching for the class const mockEditor = { getSelectedAll: jest.fn(() => [selectedComponent]), // Only selectedComponent is selected SelectorManager: { get: jest.fn(() => null), // Return null for new class name to trigger creation add: jest.fn(() => ({ name: 'new-class-name', type: 1 })) // Mock adding new selector }, Pages: { getAll: jest.fn(() => [{ getMainComponent: jest.fn(() => ({ find: jest.fn(() => [selectedComponent, otherComponent]) // Both components found by class selector })) }]) } } as unknown as Editor const oldSelector: ClassSelector = { type: SimpleSelectorType.CLASS, value: 'shared-class', active: true } const newSelector: ClassSelector = { type: SimpleSelectorType.CLASS, value: 'new-class-name', active: true } // Call renameSelector renameSelector(mockEditor, oldSelector, newSelector) // Verify that otherComponent was NOT affected (setClass should not be called) expect(otherComponent.setClass).not.toHaveBeenCalled() }) })