@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/dist/index.d.mts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.js +406 -242
- package/dist/index.mjs +336 -173
- package/package.json +20 -19
- package/src/__tests__/parse-xml.ts +2 -0
- package/src/components/__tests__/elements-overlays.test.tsx +7 -0
- package/src/components/elements-overlays.tsx +17 -6
- package/src/components/grid-outline/__tests__/grid-outline-overlay.test.tsx +13 -15
- package/src/components/grid-outline/grid-empty-cell-positioner.tsx +46 -0
- package/src/components/grid-outline/grid-outline-overlay.tsx +3 -4
- package/src/components/grid-outline/grid-outline.tsx +11 -1
- package/src/components/grid-outline/index.ts +1 -0
- package/src/components/outline-overlay.tsx +9 -2
- package/src/composition-builder/composition-builder.ts +12 -0
- package/src/form-structure/__tests__/form-structure-utils.test.ts +218 -0
- package/src/form-structure/enforce-form-ancestor-commands.ts +11 -4
- package/src/form-structure/utils.ts +47 -0
- package/src/index.ts +1 -0
- package/src/mcp/tools/build-composition/tool.ts +58 -2
- package/src/mcp/tools/configure-element/tool.ts +3 -0
- package/src/types/element-overlay.ts +2 -0
- package/src/utils/__tests__/outline-offset-utils.test.ts +39 -0
- package/src/utils/outline-offset-utils.ts +11 -0
- package/src/utils/tracking.ts +14 -0
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-
|
|
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-
|
|
40
|
+
"@elementor/editor": "4.2.0-beta2",
|
|
41
41
|
"dompurify": "^3.2.6",
|
|
42
|
-
"@elementor/editor-controls": "4.2.0-
|
|
43
|
-
"@elementor/editor-documents": "4.2.0-
|
|
44
|
-
"@elementor/editor-elements": "4.2.0-
|
|
45
|
-
"@elementor/editor-interactions": "4.2.0-
|
|
46
|
-
"@elementor/editor-mcp": "4.2.0-
|
|
47
|
-
"@elementor/editor-notifications": "4.2.0-
|
|
48
|
-
"@elementor/editor-props": "4.2.0-
|
|
49
|
-
"@elementor/editor-responsive": "4.2.0-
|
|
50
|
-
"@elementor/editor-styles": "4.2.0-
|
|
51
|
-
"@elementor/editor-styles-repository": "4.2.0-
|
|
52
|
-
"@elementor/editor-ui": "4.2.0-
|
|
53
|
-
"@elementor/editor-v1-adapters": "4.2.0-
|
|
54
|
-
"@elementor/
|
|
55
|
-
"@elementor/
|
|
56
|
-
"@elementor/
|
|
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-
|
|
59
|
-
"@elementor/wp-media": "4.2.0-
|
|
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
|
},
|
|
@@ -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: ( {
|
|
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 {
|
|
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(
|
|
38
|
-
jest.mocked(
|
|
39
|
-
|
|
40
|
-
|
|
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(
|
|
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
|
|
101
|
-
mockGridOutlineSetting(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
|
16
|
-
const enabled =
|
|
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 &&
|
|
87
|
+
{ emptyCellRect && ! isDragActiveFromDom( element ) && (
|
|
88
|
+
<FirstEmptyCell rect={ emptyCellRect } color={ tracks.borderColor } />
|
|
89
|
+
) }
|
|
80
90
|
</>
|
|
81
91
|
);
|
|
82
92
|
}
|
|
@@ -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 = ( {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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 (
|
|
72
|
+
if ( hasLooseFormFields && ! isWithinForm( target ) && ! movedContainersIncludeAtomicFormRoot( containers ) ) {
|
|
66
73
|
handleBlockedFormField();
|
|
67
74
|
|
|
68
75
|
return true;
|