diff --git a/src/Integrations/Divi/Compose/Composer.php b/src/Integrations/Divi/Compose/Composer.php index aa0ab04..138bd41 100644 --- a/src/Integrations/Divi/Compose/Composer.php +++ b/src/Integrations/Divi/Compose/Composer.php @@ -256,7 +256,7 @@ final class Composer $violation = $this->rules->violation($parent, $type); if ($violation !== null) { - $this->errors[$path . '.type'] = $violation; + $this->errors[$path . '.type'] = $this->typeFieldCollision($spec, $parent) ?? $violation; return null; } @@ -268,6 +268,42 @@ final class Composer }; } + /** + * Trial feedback: an item whose only possible type has a short-form field named "type" (the + * contact field's field type) – a `type` that is no allowed child was almost certainly meant as + * that field. Explains the collision instead of "unbekanntes Modul divi/". + * + * @param array $spec + */ + private function typeFieldCollision(array $spec, ?Block $parent): ?string + { + $children = $parent === null ? [] : $this->catalog->childrenOf($parent->name()); + if (count($children) !== 1 || ! is_string($spec['type'] ?? null)) { + return null; + } + + $child = ModuleCatalog::shortName($children[0]); + $field = ShortformMap::fields($child)['type'] ?? null; + if ($field === null) { + return null; + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4). + $value = json_encode( + $spec['type'], + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE + ); + + return sprintf( + '`type` ist hier der Knotentyp (%1$s, darf fehlen); das Feldtyp-Feld über attrs setzen: ' + . '{"attrs": {"%2$s": {"desktop": %3$s}}} – oder type weglassen und danach mit lit-divi-update ' + . '{"set": {"type": %3$s}} setzen.', + $child, + (string) $field['path'], + (string) $value + ); + } + /** @param array $spec */ private function typeOf(array $spec, ?Block $parent): ?string { diff --git a/tests/Fixtures/divi/metadata-sample.json b/tests/Fixtures/divi/metadata-sample.json index 0133d13..e9e69b8 100644 --- a/tests/Fixtures/divi/metadata-sample.json +++ b/tests/Fixtures/divi/metadata-sample.json @@ -3530,6 +3530,821 @@ } } } + }, + { + "name": "divi/contact-form", + "title": "Contact Form", + "category": "module", + "childrenName": [ + "divi/contact-field" + ], + "childModuleName": "divi/contact-field", + "attributes": { + "module": { + "settings": { + "meta": { + "meta": [] + }, + "advanced": { + "elements": { + "groupType": "group-item", + "item": { + "groupSlug": "contentElements", + "priority": 5, + "render": true, + "component": { + "type": "group", + "name": "divi/elements", + "props": { + "grouped": false + } + } + } + }, + "html": [], + "link": [], + "loop": [], + "text": { + "component": { + "props": { + "fields": { + "color": { + "render": false + } + } + } + } + }, + "successMessage": { + "groupType": "group-item", + "item": { + "groupSlug": "contentText", + "priority": 10, + "render": true, + "attrName": "module.advanced.successMessage", + "label": "Success Message", + "description": "Type the message you want to display after successful form submission. Leave blank for default", + "category": "configuration", + "features": { + "dynamicContent": { + "type": "text" + }, + "sticky": false, + "responsive": false, + "hover": false, + "preset": "content" + }, + "component": { + "name": "divi/text", + "type": "field" + } + } + } + }, + "decoration": { + "layout": [], + "animation": [], + "attributes": [], + "background": [], + "border": { + "component": { + "props": { + "fieldLabel": "Inputs" + } + } + }, + "boxShadow": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "overflow": [], + "order": [], + "position": [], + "scroll": [], + "sizing": [], + "spacing": [], + "sticky": [], + "transform": [], + "transition": [], + "zIndex": [] + } + } + }, + "title": { + "settings": { + "innerContent": { + "groupType": "group-item", + "item": { + "groupSlug": "contentText", + "priority": 5, + "render": true, + "attrName": "title.innerContent", + "label": "Title", + "description": "Define a title for your contact form.", + "category": "basic_option", + "features": { + "dynamicContent": { + "type": "text" + }, + "sticky": false, + "preset": "content" + }, + "component": { + "name": "divi/text", + "type": "field" + } + } + }, + "decoration": { + "font": { + "priority": 10, + "component": { + "props": { + "groupLabel": "Title Text", + "fieldLabel": "Title", + "dynamicSubgroupHost": true, + "dynamicSubgroupHostLayoutStyle": "moduleLayout", + "fields": { + "headingLevel": { + "render": true + } + } + } + } + } + } + } + }, + "captcha": { + "settings": { + "decoration": { + "font": { + "priority": 15, + "component": { + "props": { + "groupLabel": "Captcha Text", + "fieldLabel": "Captcha", + "dynamicSubgroupHost": true, + "fields": { + "textAlign": { + "render": false + } + } + } + } + } + } + } + }, + "field": { + "settings": { + "decoration": { + "background": [], + "font": [], + "spacing": [] + }, + "advanced": { + "focus": [] + } + } + }, + "button": { + "settings": { + "innerContent": { + "groupType": "group-item", + "item": { + "groupSlug": "contentText", + "priority": 20, + "render": true, + "attrName": "button.innerContent", + "subName": "text", + "label": "Submit Button", + "description": "Define the text of the form submit button.", + "category": "basic_option", + "features": { + "dynamicContent": { + "type": "text" + }, + "sticky": false, + "preset": "content" + }, + "component": { + "name": "divi/text", + "type": "field" + } + } + }, + "decoration": { + "background": [], + "border": [], + "boxShadow": [], + "button": { + "component": { + "props": { + "dynamicSubgroupHost": true + } + } + }, + "font": [], + "sizing": [], + "spacing": [] + } + } + }, + "email": { + "settings": { + "advanced": { + "receiver": { + "groupType": "group-item", + "item": { + "groupSlug": "contentEmail", + "priority": 5, + "render": true, + "attrName": "email.advanced.receiver", + "label": "Email Address", + "description": "Input the email address where messages should be sent.

Note: email delivery and spam prevention are complex processes. We recommend using a delivery service such as Mandrill, SendGrid, or other similar service to ensure the deliverability of messages that are submitted through this form", + "category": "basic_option", + "features": { + "responsive": false, + "hover": false, + "sticky": false, + "preset": "content", + "dynamicContent": { + "type": "text" + } + }, + "component": { + "name": "divi/text", + "type": "field" + } + } + } + }, + "innerContent": { + "groupType": "group-item", + "item": { + "groupSlug": "contentEmail", + "priority": 10, + "render": true, + "attrName": "email.innerContent", + "label": "Message Pattern", + "description": "Here you can define the custom pattern for the email Message. Fields should be included in following format - %%field_id%%. For example if you want to include the field with id = phone and field with id = message, then you can use the following pattern: My message is %%message%% and phone number is %%phone%%. Leave blank for default.", + "category": "configuration", + "features": { + "responsive": false, + "hover": false, + "sticky": false, + "preset": "content", + "dynamicContent": { + "type": "text" + } + }, + "component": { + "name": "divi/textarea", + "type": "field", + "props": { + "processing": "custom_message" + } + } + } + } + } + }, + "redirect": { + "settings": { + "advanced": { + "useRedirect": { + "groupType": "group-item", + "item": { + "groupSlug": "contentRedirect", + "priority": 5, + "render": true, + "attrName": "redirect.advanced.useRedirect", + "label": "Enable Redirect URL", + "description": "Redirect users after successful form submission.", + "category": "configuration", + "features": { + "responsive": false, + "hover": false, + "sticky": false, + "preset": "content" + }, + "component": { + "name": "divi/toggle", + "type": "field" + } + } + } + }, + "innerContent": { + "groupType": "group-item", + "item": { + "groupSlug": "contentRedirect", + "priority": 10, + "render": true, + "attrName": "redirect.innerContent", + "label": "Redirect URL", + "description": "Type the Redirect URL", + "category": "configuration", + "features": { + "responsive": false, + "hover": false, + "sticky": false, + "preset": "content", + "dynamicContent": { + "type": "url" + } + }, + "component": { + "name": "divi/text", + "type": "field" + } + } + } + } + } + } + }, + { + "name": "divi/contact-field", + "title": "Field", + "category": "child-module", + "childrenName": [], + "attributes": { + "module": { + "settings": { + "meta": { + "meta": [] + }, + "advanced": { + "elements": [], + "html": [], + "link": [], + "loop": [], + "text": { + "component": { + "props": { + "fields": { + "orientation": { + "component": { + "props": { + "allowClickToUnset": true, + "renderEmptyStringAsDefaultValue": true + } + } + }, + "color": { + "render": false + }, + "textShadowGroup": { + "render": false + } + } + } + } + } + }, + "decoration": { + "layout": [], + "background": [], + "border": { + "component": { + "props": { + "fieldLabel": "Input" + } + } + }, + "animation": [], + "attributes": [], + "boxShadow": [], + "conditions": [], + "disabledOn": [], + "filters": [], + "interactions": [], + "overflow": [], + "order": [], + "position": [], + "scroll": [], + "sizing": [], + "spacing": [], + "sticky": [], + "transform": [], + "transition": [], + "zIndex": [] + } + } + }, + "field": { + "settings": { + "decoration": { + "background": [], + "font": [] + }, + "advanced": { + "focus": [] + } + } + }, + "fieldTitle": { + "settings": { + "decoration": { + "font": { + "priority": 10, + "component": { + "props": { + "groupLabel": "Title Text", + "fieldLabel": "Title" + } + } + } + } + } + }, + "fieldItem": { + "settings": { + "advanced": { + "id": { + "groupType": "group-item", + "item": { + "groupSlug": "contentText", + "priority": 5, + "render": true, + "attrName": "fieldItem.advanced.id", + "label": "Field ID", + "description": "Define the unique ID of this field. You should use only English characters without special characters and spaces.", + "category": "basic_option", + "features": { + "sticky": false, + "responsive": false, + "hover": false, + "preset": "content", + "dynamicContent": { + "type": "text" + } + }, + "component": { + "name": "divi/text", + "type": "field" + } + } + }, + "type": { + "groupType": "group-item", + "item": { + "groupSlug": "contentFieldOptions", + "priority": 5, + "render": true, + "attrName": "fieldItem.advanced.type", + "label": "Type", + "description": "Choose the type of field", + "category": "basic_option", + "features": { + "sticky": false, + "responsive": false, + "hover": false, + "preset": "content" + }, + "component": { + "name": "divi/select", + "type": "field", + "props": { + "options": { + "input": { + "label": "Input Field" + }, + "email": { + "label": "Email Field" + }, + "text": { + "label": "Textarea" + }, + "checkbox": { + "label": "Checkboxes" + }, + "radio": { + "label": "Radio Buttons" + }, + "select": { + "label": "Select Dropdown" + } + } + } + } + } + }, + "minLength": { + "groupType": "group-item", + "item": { + "groupSlug": "contentFieldOptions", + "priority": 5, + "render": true, + "attrName": "fieldItem.advanced.minLength", + "label": "Minimum Length", + "description": "Leave at 0 to remove restriction", + "category": "basic_option", + "features": { + "sticky": false, + "responsive": false, + "hover": false, + "preset": "content" + }, + "component": { + "name": "divi/range", + "type": "field", + "props": { + "allowedUnits": [], + "defaultUnit": "" + } + } + } + }, + "maxLength": { + "groupType": "group-item", + "item": { + "groupSlug": "contentFieldOptions", + "priority": 5, + "render": true, + "attrName": "fieldItem.advanced.maxLength", + "label": "Maximum Length", + "description": "Leave at 0 to remove restriction", + "category": "basic_option", + "features": { + "sticky": false, + "responsive": false, + "hover": false, + "preset": "content" + }, + "component": { + "name": "divi/range", + "type": "field", + "props": { + "allowedUnits": [], + "defaultUnit": "" + } + } + } + }, + "allowedSymbols": { + "groupType": "group-item", + "item": { + "groupSlug": "contentFieldOptions", + "priority": 5, + "render": true, + "attrName": "fieldItem.advanced.allowedSymbols", + "label": "Allowed Symbols", + "category": "basic_option", + "features": { + "sticky": false, + "responsive": false, + "hover": false, + "preset": "content" + }, + "component": { + "name": "divi/select", + "type": "field", + "props": { + "options": { + "all": { + "label": "All" + }, + "letters": { + "label": "Letters Only (A-Z)" + }, + "numbers": { + "label": "Numbers Only (0-9)" + }, + "alphanumeric": { + "label": "Alphanumeric Only (A-Z, 0-9)" + } + } + } + } + } + }, + "checkboxOptions": { + "groupType": "group-item", + "item": { + "groupSlug": "contentFieldOptions", + "priority": 5, + "render": true, + "attrName": "fieldItem.advanced.checkboxOptions", + "label": "Options", + "category": "basic_option", + "features": { + "sticky": false, + "responsive": false, + "hover": false, + "preset": "content" + }, + "component": { + "name": "divi/sortable-list", + "type": "field", + "props": { + "isCheckbox": true, + "addFirstOnMount": true, + "actions": { + "right": [ + "move", + "link", + "copy", + "delete" + ] + } + } + } + } + }, + "radioOptions": { + "groupType": "group-item", + "item": { + "groupSlug": "contentFieldOptions", + "priority": 5, + "render": true, + "attrName": "fieldItem.advanced.radioOptions", + "label": "Options", + "category": "basic_option", + "features": { + "sticky": false, + "responsive": false, + "hover": false, + "preset": "content" + }, + "component": { + "name": "divi/sortable-list", + "type": "field", + "props": { + "isRadio": true, + "addFirstOnMount": true, + "actions": { + "right": [ + "move", + "link", + "copy", + "delete" + ] + } + } + } + } + }, + "selectOptions": { + "groupType": "group-item", + "item": { + "groupSlug": "contentFieldOptions", + "priority": 5, + "render": true, + "attrName": "fieldItem.advanced.selectOptions", + "label": "Options", + "category": "basic_option", + "features": { + "sticky": false, + "responsive": false, + "hover": false, + "preset": "content" + }, + "component": { + "name": "divi/sortable-list", + "type": "field", + "props": { + "addFirstOnMount": true, + "actions": { + "right": [ + "move", + "copy", + "delete" + ] + } + } + } + } + }, + "required": { + "groupType": "group-item", + "item": { + "groupSlug": "contentFieldOptions", + "priority": 5, + "render": true, + "attrName": "fieldItem.advanced.required", + "label": "Required Field", + "description": "Define whether the field should be required or optional", + "category": "configuration", + "features": { + "sticky": false, + "responsive": false, + "hover": false, + "preset": "content" + }, + "component": { + "name": "divi/toggle", + "type": "field" + } + } + } + }, + "innerContent": { + "groupType": "group-item", + "item": { + "groupSlug": "contentText", + "priority": 10, + "render": true, + "attrName": "fieldItem.innerContent", + "label": "Title", + "description": "Here you can define the content that will be placed within the current tab.", + "category": "basic_option", + "features": { + "sticky": false, + "preset": "content", + "dynamicContent": { + "type": "text" + } + }, + "component": { + "name": "divi/text", + "type": "field" + } + } + } + } + }, + "conditionalLogic": { + "settings": { + "innerContent": { + "groupType": "group-item", + "item": { + "groupSlug": "contentConditionalLogic", + "priority": 10, + "render": true, + "attrName": "conditionalLogic.innerContent", + "label": "Rules", + "category": "layout", + "features": { + "responsive": false, + "hover": false, + "sticky": false, + "preset": "content" + }, + "component": { + "name": "divi/conditions", + "type": "field" + } + } + }, + "advanced": { + "enable": { + "groupType": "group-item", + "item": { + "groupSlug": "contentConditionalLogic", + "priority": 5, + "render": true, + "attrName": "conditionalLogic.advanced.enable", + "label": "Enable", + "description": "Enabling conditional logic makes this field only visible when any or all of the rules below are fulfilled
Note: Only fields with an unique and non-empty field ID can be used", + "category": "layout", + "features": { + "responsive": false, + "hover": false, + "sticky": false, + "preset": "content" + }, + "component": { + "name": "divi/toggle", + "type": "field" + } + } + }, + "relation": { + "groupType": "group-item", + "item": { + "groupSlug": "contentConditionalLogic", + "priority": 5, + "render": true, + "attrName": "conditionalLogic.advanced.relation", + "label": "Relation", + "description": "Choose whether any or all of the rules should be fulfilled", + "category": "layout", + "features": { + "responsive": false, + "hover": false, + "sticky": false, + "preset": "content" + }, + "component": { + "name": "divi/toggle", + "type": "field", + "props": { + "options": { + "off": "Any", + "on": "All" + } + } + } + } + } + } + } + } + } } ], "defaults": { @@ -3972,6 +4787,106 @@ } } } + }, + "divi/contact-form": { + "module": { + "meta": { + "adminLabel": { + "desktop": { + "value": "Contact Form" + } + } + }, + "advanced": { + "spamProtection": { + "desktop": { + "value": { + "enabled": "off", + "useBasicCaptcha": "on" + } + } + } + } + }, + "title": { + "decoration": { + "font": { + "font": { + "desktop": { + "value": { + "headingLevel": "h1" + } + } + } + } + } + }, + "button": { + "innerContent": { + "desktop": { + "value": { + "text": "Submit" + } + } + }, + "decoration": { + "button": { + "desktop": { + "value": { + "icon": { + "enable": "on" + } + } + } + } + } + }, + "redirect": { + "advanced": { + "useRedirect": { + "desktop": { + "value": "off" + } + } + } + } + }, + "divi/contact-field": { + "module": { + "decoration": { + "sizing": { + "desktop": { + "value": { + "flexType": "24_24" + } + } + } + } + }, + "fieldItem": { + "advanced": { + "id": { + "desktop": { + "value": "" + } + }, + "type": { + "desktop": { + "value": "input" + } + }, + "required": { + "desktop": { + "value": "on" + } + } + }, + "innerContent": { + "desktop": { + "value": "New Field" + } + } + } } } } diff --git a/tests/Integration/divi-metadata-fixture.php b/tests/Integration/divi-metadata-fixture.php index 02d1ef7..863464f 100644 --- a/tests/Integration/divi-metadata-fixture.php +++ b/tests/Integration/divi-metadata-fixture.php @@ -18,7 +18,7 @@ if (! class_exists(ModuleRegistration::class)) { $wanted = [ 'section', 'row', 'column', 'text', 'heading', 'button', 'image', 'blurb', 'code', 'accordion', - 'accordion-item', 'fullwidth-header', 'group', + 'accordion-item', 'fullwidth-header', 'group', 'contact-form', 'contact-field', ]; $all = ModuleRegistration::get_all_core_modules_metadata(); $metadata = []; diff --git a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php index c1876d3..bddf7ab 100644 --- a/tests/Unit/Integrations/Divi/Compose/ComposerTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ComposerTest.php @@ -530,4 +530,26 @@ final class ComposerTest extends TestCase self::assertStringContainsString('2 Spalten, aber 3 Modul-Listen', $result['errors']['nodes[0].rows[0].modules']); } + + /** + * Trial feedback: `type` on a contact field is the node type, not the field type. A value that + * is no allowed child gets a dedicated hint instead of "unbekanntes Modul divi/email". + */ + public function testTypeOnAnItemWithATypeFieldExplainsTheCollision(): void + { + $result = ComposeFixture::composer()->compose([['type' => 'section', 'modules' => [ + ['type' => 'contact-form', 'items' => [ + ['title' => 'Name'], + ['type' => 'email', 'title' => 'E-Mail'], + ['type' => 'contact-field', 'title' => 'Nachricht'], + ]], + ]]], null, 'nodes'); + + $message = $result['errors']['nodes[0].modules[0].modules[0][0].items[1].type'] ?? ''; + self::assertStringContainsString('`type` ist hier der Knotentyp', $message); + self::assertStringContainsString('fieldItem.advanced.type', $message); + self::assertStringContainsString('"email"', $message); + self::assertStringNotContainsString('unbekanntes Modul', $message); + self::assertCount(1, $result['errors'], 'the explicit contact-field type is fine'); + } }