@elementor/editor-canvas 4.2.0-946 → 4.2.0-beta2

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.
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-946",
4
+ "version": "4.2.0-beta2",
5
5
  "private": false,
6
6
  "author": "Elementor Team",
7
7
  "homepage": "https://elementor.com/",
@@ -37,26 +37,27 @@
37
37
  "react-dom": "^18.3.1"
38
38
  },
39
39
  "dependencies": {
40
- "@elementor/editor": "4.2.0-946",
40
+ "@elementor/editor": "4.2.0-beta2",
41
41
  "dompurify": "^3.2.6",
42
- "@elementor/editor-controls": "4.2.0-946",
43
- "@elementor/editor-documents": "4.2.0-946",
44
- "@elementor/editor-elements": "4.2.0-946",
45
- "@elementor/editor-interactions": "4.2.0-946",
46
- "@elementor/editor-mcp": "4.2.0-946",
47
- "@elementor/editor-notifications": "4.2.0-946",
48
- "@elementor/editor-props": "4.2.0-946",
49
- "@elementor/editor-responsive": "4.2.0-946",
50
- "@elementor/editor-styles": "4.2.0-946",
51
- "@elementor/editor-styles-repository": "4.2.0-946",
52
- "@elementor/editor-ui": "4.2.0-946",
53
- "@elementor/editor-v1-adapters": "4.2.0-946",
54
- "@elementor/http-client": "4.2.0-946",
55
- "@elementor/schema": "4.2.0-946",
56
- "@elementor/twing": "4.2.0-946",
42
+ "@elementor/editor-controls": "4.2.0-beta2",
43
+ "@elementor/editor-documents": "4.2.0-beta2",
44
+ "@elementor/editor-elements": "4.2.0-beta2",
45
+ "@elementor/editor-interactions": "4.2.0-beta2",
46
+ "@elementor/editor-mcp": "4.2.0-beta2",
47
+ "@elementor/editor-notifications": "4.2.0-beta2",
48
+ "@elementor/editor-props": "4.2.0-beta2",
49
+ "@elementor/editor-responsive": "4.2.0-beta2",
50
+ "@elementor/editor-styles": "4.2.0-beta2",
51
+ "@elementor/editor-styles-repository": "4.2.0-beta2",
52
+ "@elementor/editor-ui": "4.2.0-beta2",
53
+ "@elementor/editor-v1-adapters": "4.2.0-beta2",
54
+ "@elementor/events": "4.2.0-beta2",
55
+ "@elementor/http-client": "4.2.0-beta2",
56
+ "@elementor/schema": "4.2.0-beta2",
57
+ "@elementor/twing": "4.2.0-beta2",
57
58
  "@elementor/ui": "1.37.5",
58
- "@elementor/utils": "4.2.0-946",
59
- "@elementor/wp-media": "4.2.0-946",
59
+ "@elementor/utils": "4.2.0-beta2",
60
+ "@elementor/wp-media": "4.2.0-beta2",
60
61
  "@floating-ui/react": "^0.27.5",
61
62
  "@wordpress/i18n": "^5.13.0"
62
63
  },
@@ -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 );
@@ -8,9 +8,13 @@ import {
8
8
  } from '@elementor/editor-v1-adapters';
9
9
 
10
10
  import type { ElementOverlayConfig } from '../types/element-overlay';
11
- import { GridOutlineOverlay } from './grid-outline';
11
+ import { GridEmptyCellPositioner, GridOutlineOverlay } from './grid-outline';
12
12
  import { OutlineOverlay } from './outline-overlay';
13
13
 
14
+ const hasGridStyleDisplay = ( element: HTMLElement ): boolean => {
15
+ return element.computedStyleMap().get( 'display' )?.toString() === 'grid';
16
+ };
17
+
14
18
  const ELEMENTS_DATA_ATTR = 'atomic';
15
19
 
16
20
  const overlayRegistry: ElementOverlayConfig[] = [
@@ -18,9 +22,13 @@ const overlayRegistry: ElementOverlayConfig[] = [
18
22
  component: OutlineOverlay,
19
23
  shouldRender: () => true,
20
24
  },
25
+ {
26
+ component: GridEmptyCellPositioner,
27
+ shouldRender: ( { element } ) => hasGridStyleDisplay( element ),
28
+ },
21
29
  {
22
30
  component: GridOutlineOverlay,
23
- shouldRender: ( { element, isSelected } ) => isSelected && element.dataset.eType === 'e-grid',
31
+ shouldRender: ( { isSelected, element } ) => isSelected && hasGridStyleDisplay( element ),
24
32
  },
25
33
  ];
26
34
 
@@ -37,18 +45,19 @@ export function ElementsOverlays() {
37
45
  return null;
38
46
  }
39
47
 
40
- return elements.map( ( { id, domElement, isGlobal } ) => {
48
+ return elements.map( ( { id, domElement, isGlobal, widgetType } ) => {
41
49
  const isSelected = selected.element?.id === id;
42
50
 
43
51
  return overlayRegistry.map(
44
52
  ( { shouldRender, component: Overlay }, index ) =>
45
- shouldRender( { id, element: domElement, isSelected } ) && (
53
+ shouldRender( { id, element: domElement, isSelected, widgetType } ) && (
46
54
  <Overlay
47
55
  key={ `${ id }-${ index }` }
48
56
  id={ id }
49
57
  element={ domElement }
50
58
  isSelected={ isSelected }
51
59
  isGlobal={ isGlobal }
60
+ widgetType={ widgetType }
52
61
  />
53
62
  )
54
63
  );
@@ -59,18 +68,20 @@ type ElementData = {
59
68
  id: string;
60
69
  domElement: HTMLElement;
61
70
  isGlobal: boolean;
71
+ widgetType: string | undefined;
62
72
  };
63
73
 
64
- function useElementsDom() {
74
+ function useElementsDom(): ElementData[] {
65
75
  return useListenTo(
66
76
  [ windowEvent( 'elementor/editor/element-rendered' ), windowEvent( 'elementor/editor/element-destroyed' ) ],
67
- () => {
77
+ (): ElementData[] => {
68
78
  return getElements()
69
79
  .filter( ( el ) => isV4Element( el.view?.el?.dataset ) )
70
80
  .map( ( element ) => ( {
71
81
  id: element.id,
72
82
  domElement: element.view?.getDomElement?.()?.get?.( 0 ),
73
83
  isGlobal: element.model.get( 'isGlobal' ) ?? false,
84
+ widgetType: element.model.get( 'widgetType' ),
74
85
  } ) )
75
86
  .filter( ( item ): item is ElementData => !! item.domElement );
76
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
 
@@ -0,0 +1,46 @@
1
+ import { useEffect } from 'react';
2
+
3
+ import { useElementRect } from '../../hooks/use-element-rect';
4
+ import { useGridTracks } from '../../hooks/use-grid-tracks';
5
+ import type { ElementOverlayProps } from '../../types/element-overlay';
6
+ import { findFirstEmptyCell } from '../../utils/find-first-empty-cell';
7
+
8
+ const CSS_VAR_ROW = '--e-grid-empty-cell-row';
9
+ const CSS_VAR_COL = '--e-grid-empty-cell-col';
10
+ const CSS_VAR_VISIBILITY = '--e-grid-empty-cell-visibility';
11
+
12
+ const clearGridEmptyCellStyles = ( target: HTMLElement ): void => {
13
+ target.style.removeProperty( CSS_VAR_ROW );
14
+ target.style.removeProperty( CSS_VAR_COL );
15
+ target.style.removeProperty( CSS_VAR_VISIBILITY );
16
+ };
17
+
18
+ export const GridEmptyCellPositioner = ( { element }: ElementOverlayProps ): null => {
19
+ const rect = useElementRect( element );
20
+ const tracks = useGridTracks( element, rect );
21
+
22
+ useEffect( () => {
23
+ if ( ! element ) {
24
+ return;
25
+ }
26
+
27
+ const firstEmpty = findFirstEmptyCell( element, tracks.columns.length, tracks.rows.length );
28
+
29
+ if ( ! firstEmpty ) {
30
+ element.style.removeProperty( CSS_VAR_ROW );
31
+ element.style.removeProperty( CSS_VAR_COL );
32
+ element.style.setProperty( CSS_VAR_VISIBILITY, 'hidden' );
33
+ return () => clearGridEmptyCellStyles( element );
34
+ }
35
+
36
+ element.style.setProperty( CSS_VAR_ROW, String( firstEmpty.row + 1 ) );
37
+ element.style.setProperty( CSS_VAR_COL, String( firstEmpty.col + 1 ) );
38
+ element.style.setProperty( CSS_VAR_VISIBILITY, 'visible' );
39
+
40
+ return () => {
41
+ clearGridEmptyCellStyles( element );
42
+ };
43
+ }, [ element, tracks ] );
44
+
45
+ return null;
46
+ };
@@ -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 } );
@@ -54,6 +54,14 @@ const renderLines = ( tracks: GridTracks, width: number, height: number ) => {
54
54
  ];
55
55
  };
56
56
 
57
+ const isDragActiveFromDom = ( element: HTMLElement | null ): boolean => {
58
+ return Boolean(
59
+ element?.querySelector(
60
+ '.e-dragging-over, .elementor-dragging-on-child, .elementor-draggable-over, .elementor-widget-placeholder, .elementor-sortable-placeholder'
61
+ )
62
+ );
63
+ };
64
+
57
65
  export function GridOutline( { element, tracks, width, height }: Props ) {
58
66
  const cells = useMemo( () => computeCellRects( tracks, width, height ), [ tracks, width, height ] );
59
67
  const hasGap = tracks.columnGap > 0 || tracks.rowGap > 0;
@@ -76,7 +84,9 @@ export function GridOutline( { element, tracks, width, height }: Props ) {
76
84
  >
77
85
  { hasGap ? renderCells( cells, tracks.borderColor ) : renderLines( tracks, width, height ) }
78
86
  </svg>
79
- { emptyCellRect && <FirstEmptyCell rect={ emptyCellRect } color={ tracks.borderColor } /> }
87
+ { emptyCellRect && ! isDragActiveFromDom( element ) && (
88
+ <FirstEmptyCell rect={ emptyCellRect } color={ tracks.borderColor } />
89
+ ) }
80
90
  </>
81
91
  );
82
92
  }
@@ -1 +1,2 @@
1
+ export { GridEmptyCellPositioner } from './grid-empty-cell-positioner';
1
2
  export { GridOutlineOverlay } from './grid-outline-overlay';
@@ -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 &&
@@ -11,6 +11,11 @@ import {
11
11
  } from '@elementor/editor-elements';
12
12
  import { type z } from '@elementor/schema';
13
13
 
14
+ import {
15
+ collectEmptyMessageErrors,
16
+ collectFormAncestorErrors,
17
+ collectSubmitButtonErrors,
18
+ } from '../form-structure/utils';
14
19
  import { doUpdateElementProperty } from '../mcp/utils/do-update-element-property';
15
20
  import { mergeCustomCssText } from '../mcp/utils/merge-custom-css';
16
21
  import { RequiredChildrenEnforcer } from './utils/required-children-enforcer';
@@ -294,6 +299,12 @@ export class CompositionBuilder {
294
299
  throw new Error( `Invalid element structure:\n${ childTypeErrors.join( '\n' ) }` );
295
300
  }
296
301
 
302
+ const formErrors = [
303
+ ...collectFormAncestorErrors( this.xml ),
304
+ ...collectSubmitButtonErrors( this.xml ),
305
+ ...collectEmptyMessageErrors( this.xml ),
306
+ ];
307
+
297
308
  const children = Array.from( this.xml.children );
298
309
  for ( const childNode of children ) {
299
310
  const modelTree = this.buildModelTree( childNode, widgetsCache );
@@ -332,6 +343,7 @@ export class CompositionBuilder {
332
343
  return {
333
344
  configErrors,
334
345
  styleErrors,
346
+ formErrors,
335
347
  rootContainers: [ ...this.rootContainers ],
336
348
  };
337
349
  }
@@ -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;