@elementor/editor-canvas 4.2.0-beta1 → 4.3.0-1000

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/index.d.mts +1 -1
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +484 -1282
  4. package/dist/index.mjs +350 -1161
  5. package/package.json +22 -22
  6. package/src/__tests__/parse-xml.ts +2 -0
  7. package/src/components/__tests__/elements-overlays.test.tsx +7 -0
  8. package/src/components/elements-overlays.tsx +13 -10
  9. package/src/components/grid-outline/__tests__/grid-outline-overlay.test.tsx +13 -15
  10. package/src/components/grid-outline/grid-outline-overlay.tsx +3 -4
  11. package/src/components/outline-overlay.tsx +9 -2
  12. package/src/form-structure/__tests__/form-structure-utils.test.ts +218 -0
  13. package/src/form-structure/enforce-form-ancestor-commands.ts +11 -4
  14. package/src/form-structure/utils.ts +47 -0
  15. package/src/index.ts +1 -1
  16. package/src/legacy/__tests__/init-legacy-views.test.ts +1 -1
  17. package/src/mcp/canvas-mcp.ts +4 -2
  18. package/src/mcp/resources/__tests__/available-widgets-resource.test.ts +76 -0
  19. package/src/mcp/resources/__tests__/dynamic-tags-resource.test.ts +27 -37
  20. package/src/mcp/resources/__tests__/widgets-schema-resource.test.ts +79 -76
  21. package/src/mcp/resources/available-widgets-resource.ts +31 -40
  22. package/src/mcp/resources/best-practices-resource.ts +34 -0
  23. package/src/mcp/resources/dynamic-tags-resource.ts +28 -29
  24. package/src/mcp/resources/widgets-schema-resource.ts +34 -177
  25. package/src/mcp/tools/build-composition/tool.ts +108 -212
  26. package/src/sync/element-added-event.ts +8 -0
  27. package/src/types/element-overlay.ts +2 -0
  28. package/src/utils/__tests__/grid-outline-utils.test.ts +39 -0
  29. package/src/utils/__tests__/outline-offset-utils.test.ts +39 -0
  30. package/src/utils/grid-outline-utils.ts +13 -2
  31. package/src/utils/outline-offset-utils.ts +11 -0
  32. package/src/composition-builder/__tests__/composition-builder.test.ts +0 -352
  33. package/src/composition-builder/composition-builder.ts +0 -338
  34. package/src/composition-builder/utils/__tests__/required-children-enforcer.test.ts +0 -79
  35. package/src/composition-builder/utils/__tests__/required-default-child-tags.test.ts +0 -35
  36. package/src/composition-builder/utils/required-children-enforcer.ts +0 -56
  37. package/src/composition-builder/utils/required-default-child-tags.ts +0 -22
  38. package/src/mcp/resources/best-practices.ts +0 -159
  39. package/src/mcp/resources/build-llm-guidance.ts +0 -110
  40. package/src/mcp/tools/build-composition/__tests__/xml-leaf-wrapper.test.ts +0 -117
  41. package/src/mcp/tools/build-composition/prompt.ts +0 -167
  42. package/src/mcp/tools/build-composition/schema.ts +0 -42
  43. package/src/mcp/tools/build-composition/xml-leaf-wrapper.ts +0 -68
  44. package/src/mcp/utils/__tests__/get-composition-target-container.test.ts +0 -59
  45. package/src/mcp/utils/get-composition-target-container.ts +0 -15
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elementor/editor-canvas",
3
3
  "description": "Elementor Editor Canvas",
4
- "version": "4.2.0-beta1",
4
+ "version": "4.3.0-1000",
5
5
  "private": false,
6
6
  "author": "Elementor Team",
7
7
  "homepage": "https://elementor.com/",
@@ -37,29 +37,29 @@
37
37
  "react-dom": "^18.3.1"
38
38
  },
39
39
  "dependencies": {
40
- "@elementor/editor": "4.2.0-beta1",
41
- "dompurify": "^3.2.6",
42
- "@elementor/editor-controls": "4.2.0-beta1",
43
- "@elementor/editor-documents": "4.2.0-beta1",
44
- "@elementor/editor-elements": "4.2.0-beta1",
45
- "@elementor/editor-interactions": "4.2.0-beta1",
46
- "@elementor/editor-mcp": "4.2.0-beta1",
47
- "@elementor/editor-notifications": "4.2.0-beta1",
48
- "@elementor/editor-props": "4.2.0-beta1",
49
- "@elementor/editor-responsive": "4.2.0-beta1",
50
- "@elementor/editor-styles": "4.2.0-beta1",
51
- "@elementor/editor-styles-repository": "4.2.0-beta1",
52
- "@elementor/editor-ui": "4.2.0-beta1",
53
- "@elementor/editor-v1-adapters": "4.2.0-beta1",
54
- "@elementor/events": "4.2.0-beta1",
55
- "@elementor/http-client": "4.2.0-beta1",
56
- "@elementor/schema": "4.2.0-beta1",
57
- "@elementor/twing": "4.2.0-beta1",
40
+ "@elementor/editor": "4.3.0-1000",
41
+ "@elementor/editor-controls": "4.3.0-1000",
42
+ "@elementor/editor-documents": "4.3.0-1000",
43
+ "@elementor/editor-elements": "4.3.0-1000",
44
+ "@elementor/editor-interactions": "4.3.0-1000",
45
+ "@elementor/editor-mcp": "4.3.0-1000",
46
+ "@elementor/editor-notifications": "4.3.0-1000",
47
+ "@elementor/editor-props": "4.3.0-1000",
48
+ "@elementor/editor-responsive": "4.3.0-1000",
49
+ "@elementor/editor-styles": "4.3.0-1000",
50
+ "@elementor/editor-styles-repository": "4.3.0-1000",
51
+ "@elementor/editor-ui": "4.3.0-1000",
52
+ "@elementor/editor-v1-adapters": "4.3.0-1000",
53
+ "@elementor/events": "4.3.0-1000",
54
+ "@elementor/http-client": "4.3.0-1000",
55
+ "@elementor/schema": "4.3.0-1000",
56
+ "@elementor/twing": "4.3.0-1000",
58
57
  "@elementor/ui": "1.37.5",
59
- "@elementor/utils": "4.2.0-beta1",
60
- "@elementor/wp-media": "4.2.0-beta1",
58
+ "@elementor/utils": "4.3.0-1000",
59
+ "@elementor/wp-media": "4.3.0-1000",
61
60
  "@floating-ui/react": "^0.27.5",
62
- "@wordpress/i18n": "^5.13.0"
61
+ "@wordpress/i18n": "^5.13.0",
62
+ "dompurify": "^3.2.6"
63
63
  },
64
64
  "devDependencies": {
65
65
  "tsup": "^8.3.5"
@@ -0,0 +1,2 @@
1
+ export const parseXml = ( xml: string ): Document =>
2
+ new DOMParser().parseFromString( `<root>${ xml }</root>`, 'application/xml' );
@@ -19,6 +19,10 @@ jest.mock( '@elementor/editor-v1-adapters', () => ( {
19
19
  isExperimentActive: jest.fn(),
20
20
  } ) );
21
21
 
22
+ function mockGridComputedStyle( element: HTMLElement ) {
23
+ element.computedStyleMap = jest.fn().mockReturnValue( new Map( [ [ 'display', { toString: () => 'grid' } ] ] ) );
24
+ }
25
+
22
26
  describe( '<ElementsOverlays />', () => {
23
27
  beforeEach( () => {
24
28
  const documentEl = createDOMElement( { tag: 'div' } );
@@ -26,6 +30,9 @@ describe( '<ElementsOverlays />', () => {
26
30
  const atomic1El = createDOMElement( { tag: 'div', attrs: { 'data-atomic': '', id: '10' } } );
27
31
  const atomic2El = createDOMElement( { tag: 'div', attrs: { 'data-atomic': '', id: '20' } } );
28
32
 
33
+ mockGridComputedStyle( atomic1El );
34
+ mockGridComputedStyle( atomic2El );
35
+
29
36
  jest.mocked( useEditMode ).mockReturnValue( 'edit' );
30
37
  jest.mocked( useIsRouteActive ).mockReturnValue( false );
31
38
  jest.mocked( isExperimentActive ).mockReturnValue( true );
@@ -11,11 +11,11 @@ import type { ElementOverlayConfig } from '../types/element-overlay';
11
11
  import { GridEmptyCellPositioner, GridOutlineOverlay } from './grid-outline';
12
12
  import { OutlineOverlay } from './outline-overlay';
13
13
 
14
- const ELEMENTS_DATA_ATTR = 'atomic';
15
- const E_GRID_TYPE = 'e-grid';
14
+ const hasGridStyleDisplay = ( element: HTMLElement ): boolean => {
15
+ return element.computedStyleMap().get( 'display' )?.toString() === 'grid';
16
+ };
16
17
 
17
- const isGridElement = ( element: HTMLElement ): boolean =>
18
- [ element.dataset.eType, element.dataset.element_type ].includes( E_GRID_TYPE );
18
+ const ELEMENTS_DATA_ATTR = 'atomic';
19
19
 
20
20
  const overlayRegistry: ElementOverlayConfig[] = [
21
21
  {
@@ -24,11 +24,11 @@ const overlayRegistry: ElementOverlayConfig[] = [
24
24
  },
25
25
  {
26
26
  component: GridEmptyCellPositioner,
27
- shouldRender: ( { element } ) => isGridElement( element ),
27
+ shouldRender: ( { element } ) => hasGridStyleDisplay( element ),
28
28
  },
29
29
  {
30
30
  component: GridOutlineOverlay,
31
- shouldRender: ( { element, isSelected } ) => isSelected && isGridElement( element ),
31
+ shouldRender: ( { isSelected, element } ) => isSelected && hasGridStyleDisplay( element ),
32
32
  },
33
33
  ];
34
34
 
@@ -45,18 +45,19 @@ export function ElementsOverlays() {
45
45
  return null;
46
46
  }
47
47
 
48
- return elements.map( ( { id, domElement, isGlobal } ) => {
48
+ return elements.map( ( { id, domElement, isGlobal, widgetType } ) => {
49
49
  const isSelected = selected.element?.id === id;
50
50
 
51
51
  return overlayRegistry.map(
52
52
  ( { shouldRender, component: Overlay }, index ) =>
53
- shouldRender( { id, element: domElement, isSelected } ) && (
53
+ shouldRender( { id, element: domElement, isSelected, widgetType } ) && (
54
54
  <Overlay
55
55
  key={ `${ id }-${ index }` }
56
56
  id={ id }
57
57
  element={ domElement }
58
58
  isSelected={ isSelected }
59
59
  isGlobal={ isGlobal }
60
+ widgetType={ widgetType }
60
61
  />
61
62
  )
62
63
  );
@@ -67,18 +68,20 @@ type ElementData = {
67
68
  id: string;
68
69
  domElement: HTMLElement;
69
70
  isGlobal: boolean;
71
+ widgetType: string | undefined;
70
72
  };
71
73
 
72
- function useElementsDom() {
74
+ function useElementsDom(): ElementData[] {
73
75
  return useListenTo(
74
76
  [ windowEvent( 'elementor/editor/element-rendered' ), windowEvent( 'elementor/editor/element-destroyed' ) ],
75
- () => {
77
+ (): ElementData[] => {
76
78
  return getElements()
77
79
  .filter( ( el ) => isV4Element( el.view?.el?.dataset ) )
78
80
  .map( ( element ) => ( {
79
81
  id: element.id,
80
82
  domElement: element.view?.getDomElement?.()?.get?.( 0 ),
81
83
  isGlobal: element.model.get( 'isGlobal' ) ?? false,
84
+ widgetType: element.model.get( 'widgetType' ),
82
85
  } ) )
83
86
  .filter( ( item ): item is ElementData => !! item.domElement );
84
87
  }
@@ -1,6 +1,6 @@
1
1
  import * as React from 'react';
2
2
  import { createDOMElement, renderWithTheme } from 'test-utils';
3
- import { useSelectedElementSettings } from '@elementor/editor-elements';
3
+ import { useElementEditorSettings } from '@elementor/editor-elements';
4
4
  import { screen } from '@testing-library/react';
5
5
 
6
6
  import { useElementRect } from '../../../hooks/use-element-rect';
@@ -34,12 +34,10 @@ const EMPTY_TRACKS: GridTracks = {
34
34
  borderColor: '',
35
35
  };
36
36
 
37
- function mockGridOutlineSetting( value: { $$type: 'boolean'; value: boolean } | null ) {
38
- jest.mocked( useSelectedElementSettings ).mockReturnValue( {
39
- element: { id: ID, type: 'e-grid' },
40
- elementType: {} as never,
41
- settings: { grid_outline: value },
42
- } );
37
+ function mockGridOutlineSetting( gridOutline: boolean | undefined ) {
38
+ jest.mocked( useElementEditorSettings ).mockReturnValue(
39
+ gridOutline === undefined ? {} : { grid_outline: gridOutline }
40
+ );
43
41
  }
44
42
 
45
43
  function mockFloating() {
@@ -87,7 +85,7 @@ describe( '<GridOutlineOverlay />', () => {
87
85
  } );
88
86
 
89
87
  it( 'renders the outline svg with the element id when the setting is enabled', () => {
90
- mockGridOutlineSetting( { $$type: 'boolean', value: true } );
88
+ mockGridOutlineSetting( true );
91
89
 
92
90
  renderOverlay();
93
91
 
@@ -97,8 +95,8 @@ describe( '<GridOutlineOverlay />', () => {
97
95
  expect( overlay.querySelector( 'svg' ) ).toBeInTheDocument();
98
96
  } );
99
97
 
100
- it( 'treats a null setting as default-on and renders the outline', () => {
101
- mockGridOutlineSetting( null );
98
+ it( 'treats an unset setting as default-on and renders the outline', () => {
99
+ mockGridOutlineSetting( undefined );
102
100
 
103
101
  renderOverlay();
104
102
 
@@ -106,7 +104,7 @@ describe( '<GridOutlineOverlay />', () => {
106
104
  } );
107
105
 
108
106
  it( 'renders nothing when the setting is explicitly disabled', () => {
109
- mockGridOutlineSetting( { $$type: 'boolean', value: false } );
107
+ mockGridOutlineSetting( false );
110
108
 
111
109
  renderOverlay();
112
110
 
@@ -114,7 +112,7 @@ describe( '<GridOutlineOverlay />', () => {
114
112
  } );
115
113
 
116
114
  it( 'renders nothing while tracks have not resolved yet', () => {
117
- mockGridOutlineSetting( { $$type: 'boolean', value: true } );
115
+ mockGridOutlineSetting( true );
118
116
  jest.mocked( useGridTracks ).mockReturnValue( EMPTY_TRACKS );
119
117
 
120
118
  renderOverlay();
@@ -123,7 +121,7 @@ describe( '<GridOutlineOverlay />', () => {
123
121
  } );
124
122
 
125
123
  it( 'renders one rect per cell when the grid has a gap', () => {
126
- mockGridOutlineSetting( null );
124
+ mockGridOutlineSetting( undefined );
127
125
  jest.mocked( useGridTracks ).mockReturnValue( {
128
126
  ...NON_EMPTY_TRACKS,
129
127
  columns: [ 100, 100, 100 ],
@@ -140,7 +138,7 @@ describe( '<GridOutlineOverlay />', () => {
140
138
  } );
141
139
 
142
140
  it( 'renders boundary lines when the grid has no gap', () => {
143
- mockGridOutlineSetting( null );
141
+ mockGridOutlineSetting( undefined );
144
142
  jest.mocked( useGridTracks ).mockReturnValue( {
145
143
  ...NON_EMPTY_TRACKS,
146
144
  columns: [ 100, 100, 100 ],
@@ -157,7 +155,7 @@ describe( '<GridOutlineOverlay />', () => {
157
155
  } );
158
156
 
159
157
  it( 'mounts inside the canvas wrapper portal', () => {
160
- mockGridOutlineSetting( null );
158
+ mockGridOutlineSetting( undefined );
161
159
 
162
160
  renderOverlay();
163
161
 
@@ -1,6 +1,5 @@
1
1
  import * as React from 'react';
2
- import { useSelectedElementSettings } from '@elementor/editor-elements';
3
- import { booleanPropTypeUtil } from '@elementor/editor-props';
2
+ import { useElementEditorSettings } from '@elementor/editor-elements';
4
3
  import { Box } from '@elementor/ui';
5
4
  import { FloatingPortal } from '@floating-ui/react';
6
5
 
@@ -12,8 +11,8 @@ import { CANVAS_WRAPPER_ID } from '../outline-overlay';
12
11
  import { GridOutline } from './grid-outline';
13
12
 
14
13
  export const GridOutlineOverlay = ( { element, id, isSelected }: ElementOverlayProps ): React.ReactElement | null => {
15
- const { settings } = useSelectedElementSettings();
16
- const enabled = booleanPropTypeUtil.extract( settings?.grid_outline );
14
+ const settings = useElementEditorSettings( id );
15
+ const enabled = settings?.grid_outline;
17
16
  const rect = useElementRect( element );
18
17
  const tracks = useGridTracks( element, rect );
19
18
  const { floating } = useFloatingOnElement( { element, isSelected } );
@@ -6,6 +6,7 @@ import { useBindReactPropsToElement } from '../hooks/use-bind-react-props-to-ele
6
6
  import { useFloatingOnElement } from '../hooks/use-floating-on-element';
7
7
  import { useHasOverlapping } from '../hooks/use-has-overlapping';
8
8
  import type { ElementOverlayProps } from '../types/element-overlay';
9
+ import { shouldUseSmallerOutlineOffset } from '../utils/outline-offset-utils';
9
10
 
10
11
  export const CANVAS_WRAPPER_ID = 'elementor-preview-responsive-wrapper';
11
12
 
@@ -25,13 +26,19 @@ const OverlayBox = styled( Box, {
25
26
  } )
26
27
  );
27
28
 
28
- export const OutlineOverlay = ( { element, isSelected, id, isGlobal = false }: Props ): React.ReactElement | false => {
29
+ export const OutlineOverlay = ( {
30
+ element,
31
+ isSelected,
32
+ id,
33
+ isGlobal = false,
34
+ widgetType,
35
+ }: Props ): React.ReactElement | false => {
29
36
  const { context, floating, isVisible } = useFloatingOnElement( { element, isSelected } );
30
37
  const { getFloatingProps, getReferenceProps } = useInteractions( [ useHover( context ) ] );
31
38
  const hasOverlapping = useHasOverlapping();
32
39
 
33
40
  useBindReactPropsToElement( element, getReferenceProps );
34
- const isSmallerOffset = element.offsetHeight <= 1;
41
+ const isSmallerOffset = shouldUseSmallerOutlineOffset( element, widgetType );
35
42
 
36
43
  return (
37
44
  isVisible &&
@@ -1,7 +1,11 @@
1
1
  import type { V1Element } from '@elementor/editor-elements';
2
2
 
3
+ import { parseXml } from '../../__tests__/parse-xml';
3
4
  import {
4
5
  clipboardRootsAreAtomicForms,
6
+ collectEmptyMessageErrors,
7
+ collectFormAncestorErrors,
8
+ collectSubmitButtonErrors,
5
9
  FORM_ELEMENT_TYPE,
6
10
  getClipboardElementType,
7
11
  movedContainersIncludeAtomicFormRoot,
@@ -67,4 +71,218 @@ describe( 'form-structure utils', () => {
67
71
  expect( clipboardRootsAreAtomicForms( [] ) ).toBe( false );
68
72
  } );
69
73
  } );
74
+
75
+ describe( 'collectFormAncestorErrors', () => {
76
+ it( 'returns no errors when form field is a direct child of e-form', () => {
77
+ const xml = parseXml( `
78
+ <e-form>
79
+ <e-form-input />
80
+ </e-form>
81
+ ` );
82
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 0 );
83
+ } );
84
+ it( 'returns no errors when form field is a deep descendant of e-form (valid)', () => {
85
+ const xml = parseXml( `
86
+ <e-form>
87
+ <e-flexbox>
88
+ <e-form-input />
89
+ </e-flexbox>
90
+ </e-form>
91
+ ` );
92
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 0 );
93
+ } );
94
+ it( 'returns no errors when e-form wraps multiple containers with fields (valid)', () => {
95
+ const xml = parseXml( `
96
+ <e-flexbox>
97
+ <e-form>
98
+ <e-form-input />
99
+ </e-form>
100
+ </e-flexbox>
101
+ ` );
102
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 0 );
103
+ } );
104
+ it( 'returns an error when form field has no e-form ancestor (invalid)', () => {
105
+ const xml = parseXml( `
106
+ <e-flexbox>
107
+ <e-flexbox>
108
+ <e-form-input />
109
+ </e-flexbox>
110
+ </e-flexbox>
111
+ ` );
112
+ const errors = collectFormAncestorErrors( xml );
113
+ expect( errors ).toHaveLength( 1 );
114
+ expect( errors[ 0 ] ).toContain( 'e-form-input' );
115
+ expect( errors[ 0 ] ).toContain( 'e-form' );
116
+ } );
117
+ it( 'returns an error for each orphaned form field', () => {
118
+ const xml = parseXml( `
119
+ <e-flexbox>
120
+ <e-form-input />
121
+ <e-form-label />
122
+ </e-flexbox>
123
+ ` );
124
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 2 );
125
+ } );
126
+ it( 'includes configuration-id in the error message when present', () => {
127
+ const xml = parseXml( `
128
+ <e-flexbox>
129
+ <e-form-input configuration-id="my-input" />
130
+ </e-flexbox>
131
+ ` );
132
+ const errors = collectFormAncestorErrors( xml );
133
+ expect( errors[ 0 ] ).toContain( 'configuration-id="my-input"' );
134
+ } );
135
+ it( 'returns no errors when there are no form fields at all', () => {
136
+ const xml = parseXml( `
137
+ <e-flexbox>
138
+ <e-heading />
139
+ </e-flexbox>
140
+ ` );
141
+ expect( collectFormAncestorErrors( xml ) ).toHaveLength( 0 );
142
+ } );
143
+ } );
144
+
145
+ describe( 'collectSubmitButtonErrors', () => {
146
+ it( 'returns no errors when e-form has exactly one submit button', () => {
147
+ const xml = parseXml( `
148
+ <e-form>
149
+ <e-form-input />
150
+ <e-form-submit-button />
151
+ </e-form>
152
+ ` );
153
+ expect( collectSubmitButtonErrors( xml ) ).toHaveLength( 0 );
154
+ } );
155
+
156
+ it( 'returns no errors when submit button is nested inside a container within e-form', () => {
157
+ const xml = parseXml( `
158
+ <e-form>
159
+ <e-flexbox>
160
+ <e-form-submit-button />
161
+ </e-flexbox>
162
+ </e-form>
163
+ ` );
164
+ expect( collectSubmitButtonErrors( xml ) ).toHaveLength( 0 );
165
+ } );
166
+
167
+ it( 'returns an error when e-form has no submit button', () => {
168
+ const xml = parseXml( `
169
+ <e-form>
170
+ <e-form-input />
171
+ </e-form>
172
+ ` );
173
+ const errors = collectSubmitButtonErrors( xml );
174
+ expect( errors ).toHaveLength( 1 );
175
+ expect( errors[ 0 ] ).toContain( 'e-form-submit-button' );
176
+ } );
177
+
178
+ it( 'returns an error when e-form has more than one submit button', () => {
179
+ const xml = parseXml( `
180
+ <e-form>
181
+ <e-form-submit-button />
182
+ <e-form-submit-button />
183
+ </e-form>
184
+ ` );
185
+ const errors = collectSubmitButtonErrors( xml );
186
+ expect( errors ).toHaveLength( 1 );
187
+ expect( errors[ 0 ] ).toContain( '2' );
188
+ } );
189
+
190
+ it( 'returns one error per e-form that is missing a submit button', () => {
191
+ const xml = parseXml( `
192
+ <e-form>
193
+ <e-form-input />
194
+ </e-form>
195
+ <e-form>
196
+ <e-form-input />
197
+ </e-form>
198
+ ` );
199
+ expect( collectSubmitButtonErrors( xml ) ).toHaveLength( 2 );
200
+ } );
201
+
202
+ it( 'returns no errors when there are no e-form elements', () => {
203
+ const xml = parseXml( `
204
+ <e-flexbox>
205
+ <e-heading />
206
+ </e-flexbox>
207
+ ` );
208
+ expect( collectSubmitButtonErrors( xml ) ).toHaveLength( 0 );
209
+ } );
210
+ } );
211
+
212
+ describe( 'collectEmptyMessageErrors', () => {
213
+ it( 'returns no errors when success message has children', () => {
214
+ const xml = parseXml( `
215
+ <e-form>
216
+ <e-form-success-message>
217
+ <e-atomic-paragraph />
218
+ </e-form-success-message>
219
+ </e-form>
220
+ ` );
221
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 0 );
222
+ } );
223
+
224
+ it( 'returns no errors when error message has children', () => {
225
+ const xml = parseXml( `
226
+ <e-form>
227
+ <e-form-error-message>
228
+ <e-atomic-paragraph />
229
+ </e-form-error-message>
230
+ </e-form>
231
+ ` );
232
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 0 );
233
+ } );
234
+
235
+ it( 'returns an error when success message is empty', () => {
236
+ const xml = parseXml( `
237
+ <e-form>
238
+ <e-form-success-message />
239
+ </e-form>
240
+ ` );
241
+ const errors = collectEmptyMessageErrors( xml );
242
+ expect( errors ).toHaveLength( 1 );
243
+ expect( errors[ 0 ] ).toContain( 'e-form-success-message' );
244
+ } );
245
+
246
+ it( 'returns an error when error message is empty', () => {
247
+ const xml = parseXml( `
248
+ <e-form>
249
+ <e-form-error-message />
250
+ </e-form>
251
+ ` );
252
+ const errors = collectEmptyMessageErrors( xml );
253
+ expect( errors ).toHaveLength( 1 );
254
+ expect( errors[ 0 ] ).toContain( 'e-form-error-message' );
255
+ } );
256
+
257
+ it( 'returns two errors when both success and error messages are empty', () => {
258
+ const xml = parseXml( `
259
+ <e-form>
260
+ <e-form-success-message />
261
+ <e-form-error-message />
262
+ </e-form>
263
+ ` );
264
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 2 );
265
+ } );
266
+
267
+ it( 'returns no errors when there are no message elements', () => {
268
+ const xml = parseXml( `
269
+ <e-form>
270
+ <e-form-input />
271
+ <e-form-submit-button />
272
+ </e-form>
273
+ ` );
274
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 0 );
275
+ } );
276
+
277
+ it( 'returns no errors when message element is outside e-form but has children', () => {
278
+ const xml = parseXml( `
279
+ <e-flexbox>
280
+ <e-form-success-message>
281
+ <e-atomic-paragraph />
282
+ </e-form-success-message>
283
+ </e-flexbox>
284
+ ` );
285
+ expect( collectEmptyMessageErrors( xml ) ).toHaveLength( 0 );
286
+ } );
287
+ } );
70
288
  } );
@@ -5,9 +5,11 @@ import { __ } from '@wordpress/i18n';
5
5
  import {
6
6
  clipboardRootsAreAtomicForms,
7
7
  type CreateArgs,
8
+ FORM_ELEMENT_TYPE,
8
9
  FORM_FIELD_ELEMENT_TYPES,
9
10
  getArgsElementType,
10
11
  hasClipboardElementTypes,
12
+ hasElementType,
11
13
  hasElementTypes,
12
14
  isWithinForm,
13
15
  type MoveArgs,
@@ -46,7 +48,9 @@ function blockFormFieldCreate( args: CreateArgs ): boolean {
46
48
  return false;
47
49
  }
48
50
 
49
- if ( ! isWithinForm( args.container ) ) {
51
+ const containers = args.containers ?? [ args.container ];
52
+
53
+ if ( containers.some( ( container ) => ! isWithinForm( container ) ) ) {
50
54
  handleBlockedFormField();
51
55
 
52
56
  return true;
@@ -58,11 +62,14 @@ function blockFormFieldCreate( args: CreateArgs ): boolean {
58
62
  function blockFormFieldMove( args: MoveArgs ): boolean {
59
63
  const { containers = [ args.container ], target } = args;
60
64
 
61
- const hasFormFieldElement = containers.some( ( container ) =>
62
- container ? hasElementTypes( container, FORM_FIELD_ELEMENT_TYPES ) : false
65
+ // Form fields must not be outside a form, but can be moved while inside a form or the container IS a form [ED-23858]
66
+ const hasLooseFormFields = containers.some( ( container ) =>
67
+ container
68
+ ? ! hasElementType( container, FORM_ELEMENT_TYPE ) && hasElementTypes( container, FORM_FIELD_ELEMENT_TYPES )
69
+ : false
63
70
  );
64
71
 
65
- if ( hasFormFieldElement && ! isWithinForm( target ) && ! movedContainersIncludeAtomicFormRoot( containers ) ) {
72
+ if ( hasLooseFormFields && ! isWithinForm( target ) && ! movedContainersIncludeAtomicFormRoot( containers ) ) {
66
73
  handleBlockedFormField();
67
74
 
68
75
  return true;
@@ -7,6 +7,7 @@ type Model = {
7
7
 
8
8
  export type CreateArgs = {
9
9
  container?: V1Element;
10
+ containers?: V1Element[];
10
11
  model?: Model;
11
12
  };
12
13
 
@@ -114,3 +115,49 @@ export function clipboardRootsAreAtomicForms( elements: ClipboardElement[] ): bo
114
115
 
115
116
  return elements.every( ( el ) => getClipboardElementType( el ) === FORM_ELEMENT_TYPE );
116
117
  }
118
+
119
+ export function hasFormAncestor( node: Element ): boolean {
120
+ return node.closest( FORM_ELEMENT_TYPE ) !== null;
121
+ }
122
+
123
+ export function collectFormAncestorErrors( xml: Document ): string[] {
124
+ const errors: string[] = [];
125
+ for ( const node of xml.querySelectorAll( '*' ) ) {
126
+ if ( ! FORM_FIELD_ELEMENT_TYPES.has( node.tagName.toLowerCase() ) ) {
127
+ continue;
128
+ }
129
+ if ( hasFormAncestor( node ) ) {
130
+ continue;
131
+ }
132
+ const id = node.getAttribute( 'configuration-id' );
133
+ errors.push(
134
+ `<${ node.tagName }${
135
+ id ? ` configuration-id="${ id }"` : ''
136
+ }> must be nested inside <e-form> (any ancestor depth is allowed).`
137
+ );
138
+ }
139
+ return errors;
140
+ }
141
+
142
+ export function collectSubmitButtonErrors( xml: Document ): string[] {
143
+ const errors: string[] = [];
144
+ for ( const form of xml.querySelectorAll( 'e-form' ) ) {
145
+ const submitButtons = form.querySelectorAll( 'e-form-submit-button' );
146
+ if ( submitButtons.length === 0 ) {
147
+ errors.push( `<e-form> has no <e-form-submit-button>.` );
148
+ } else if ( submitButtons.length > 1 ) {
149
+ errors.push( `<e-form> has ${ submitButtons.length } submit buttons — only 1 is allowed.` );
150
+ }
151
+ }
152
+ return errors;
153
+ }
154
+
155
+ export function collectEmptyMessageErrors( xml: Document ): string[] {
156
+ const errors: string[] = [];
157
+ for ( const node of xml.querySelectorAll( 'e-form-success-message, e-form-error-message' ) ) {
158
+ if ( node.children.length === 0 ) {
159
+ errors.push( `<${ node.tagName }> must have at least one child element (e.g. <e-atomic-paragraph>).` );
160
+ }
161
+ }
162
+ return errors;
163
+ }
package/src/index.ts CHANGED
@@ -44,4 +44,4 @@ export { UnknownStyleTypeError, UnknownStyleStateError } from './renderers/error
44
44
  export { useCanvasDocument } from './hooks/use-canvas-document';
45
45
  export { useEscapeOnCanvas } from './hooks/use-escape-on-canvas';
46
46
  export { doAfterRender } from './utils/after-render';
47
- export { ELEMENT_ADDED_EVENT, type ElementAddedEvent } from './mcp/tools/build-composition/tool';
47
+ export { ELEMENT_ADDED_EVENT, type ElementAddedEvent } from './sync/element-added-event';
@@ -45,7 +45,7 @@ describe( 'initLegacyViews', () => {
45
45
  mockLegacyElementor();
46
46
  } );
47
47
 
48
- it( 'should register a late element type via the legacy manager without leaking the state', () => {
48
+ it( 'should register a late element type via the legacy manager without leaking state', () => {
49
49
  // Arrange
50
50
  const elementsManager = createMockElementsManager();
51
51
  attachMockElementsManager( elementsManager );
@@ -2,6 +2,7 @@ import { type MCPRegistryEntry } from '@elementor/editor-mcp';
2
2
  import { Schema } from '@elementor/editor-props';
3
3
 
4
4
  import { initAvailableWidgetsResource } from './resources/available-widgets-resource';
5
+ import { initBestPracticesResource } from './resources/best-practices-resource';
5
6
  import { initBreakpointsResource } from './resources/breakpoints-resource';
6
7
  import { initDocumentStructureResource } from './resources/document-structure-resource';
7
8
  import { initDynamicTagsResource } from './resources/dynamic-tags-resource';
@@ -9,7 +10,7 @@ import { initEditorStateResource } from './resources/editor-state-resource';
9
10
  import { initGeneralContextResource } from './resources/general-context-resource';
10
11
  import { initSelectedElementResource } from './resources/selected-element-resource';
11
12
  import { initWidgetsSchemaResource } from './resources/widgets-schema-resource';
12
- import { initBuildCompositionsTool } from './tools/build-composition/tool';
13
+ import { initBuildCompositionTool } from './tools/build-composition/tool';
13
14
  import { initConfigureElementTool } from './tools/configure-element/tool';
14
15
  import { initGetElementConfigTool } from './tools/get-element-config/tool';
15
16
  import { getDynamicTagNamesByCategories } from './utils/resolve-dynamic-tag';
@@ -26,8 +27,9 @@ export const initCanvasMcp = ( reg: MCPRegistryEntry ) => {
26
27
  initSelectedElementResource( reg );
27
28
  initEditorStateResource( reg );
28
29
  initGeneralContextResource( reg );
29
- initBuildCompositionsTool( reg );
30
+ initBestPracticesResource( reg );
30
31
  initGetElementConfigTool( reg );
31
32
  initConfigureElementTool( reg );
33
+ initBuildCompositionTool( reg );
32
34
  initBreakpointsResource( reg );
33
35
  };