@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
text/typescript
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()
})
})