@elementor/editor-canvas 4.2.0-beta1 → 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.
@@ -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
+ }
@@ -84,8 +84,11 @@ export const initBuildCompositionsTool = ( reg: MCPRegistryEntry ) => {
84
84
  compositionBuilder.setStylesConfig( stylesConfig );
85
85
  compositionBuilder.setCustomCSS( customCSS );
86
86
 
87
- const { configErrors, rootContainers: generatedRootContainers } =
88
- await compositionBuilder.build( targetContainer );
87
+ const {
88
+ configErrors,
89
+ formErrors,
90
+ rootContainers: generatedRootContainers,
91
+ } = await compositionBuilder.build( targetContainer );
89
92
 
90
93
  rootContainers.push( ...generatedRootContainers );
91
94
  generatedXML = new XMLSerializer().serializeToString( compositionBuilder.getXML() );
@@ -105,6 +108,10 @@ export const initBuildCompositionsTool = ( reg: MCPRegistryEntry ) => {
105
108
  if ( configErrors.length ) {
106
109
  errors.push( ...configErrors.map( ( msg ) => new Error( msg ) ) );
107
110
  }
111
+
112
+ if ( formErrors.length ) {
113
+ errors.push( ...formErrors.map( ( msg ) => new Error( msg ) ) );
114
+ }
108
115
  } catch ( error ) {
109
116
  errors.push( error as Error );
110
117
  }
@@ -5,12 +5,14 @@ export type ElementOverlayProps = {
5
5
  id: string;
6
6
  isSelected: boolean;
7
7
  isGlobal?: boolean;
8
+ widgetType?: string;
8
9
  };
9
10
 
10
11
  export type OverlayFilterArgs = {
11
12
  id: string;
12
13
  element: HTMLElement;
13
14
  isSelected: boolean;
15
+ widgetType?: string;
14
16
  };
15
17
 
16
18
  export type ElementOverlayConfig = {
@@ -0,0 +1,39 @@
1
+ import { shouldUseSmallerOutlineOffset, THIN_ELEMENT_MAX_HEIGHT_PX } from '../outline-offset-utils';
2
+
3
+ describe( 'shouldUseSmallerOutlineOffset', () => {
4
+ it( 'returns true for thin elements like dividers', () => {
5
+ // Arrange.
6
+ const element = document.createElement( 'hr' );
7
+ Object.defineProperty( element, 'offsetHeight', { value: THIN_ELEMENT_MAX_HEIGHT_PX } );
8
+
9
+ // Act.
10
+ const result = shouldUseSmallerOutlineOffset( element );
11
+
12
+ // Assert.
13
+ expect( result ).toBe( true );
14
+ } );
15
+
16
+ it( 'returns true for atomic form input widgets', () => {
17
+ // Arrange.
18
+ const element = document.createElement( 'input' );
19
+ Object.defineProperty( element, 'offsetHeight', { value: 36 } );
20
+
21
+ // Act.
22
+ const result = shouldUseSmallerOutlineOffset( element, 'e-form-input' );
23
+
24
+ // Assert.
25
+ expect( result ).toBe( true );
26
+ } );
27
+
28
+ it( 'returns false for other atomic widgets', () => {
29
+ // Arrange.
30
+ const element = document.createElement( 'div' );
31
+ Object.defineProperty( element, 'offsetHeight', { value: 100 } );
32
+
33
+ // Act.
34
+ const result = shouldUseSmallerOutlineOffset( element, 'e-heading' );
35
+
36
+ // Assert.
37
+ expect( result ).toBe( false );
38
+ } );
39
+ } );
@@ -0,0 +1,11 @@
1
+ export const THIN_ELEMENT_MAX_HEIGHT_PX = 1;
2
+
3
+ export const SMALLER_OUTLINE_OFFSET_WIDGET_TYPES = new Set( [ 'e-form-input' ] );
4
+
5
+ export function shouldUseSmallerOutlineOffset( element: HTMLElement, widgetType?: string ): boolean {
6
+ if ( element.offsetHeight <= THIN_ELEMENT_MAX_HEIGHT_PX ) {
7
+ return true;
8
+ }
9
+
10
+ return widgetType !== undefined && SMALLER_OUTLINE_OFFSET_WIDGET_TYPES.has( widgetType );
11
+ }