add Elementor 4 support (WP-1000)#614
Conversation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PavelLoparev
left a comment
There was a problem hiding this comment.
Review summary
Mostly a clean refactor + v4 surface area, but one 🔴 correctness regression in ElementAbstract::setRelations that I think will fail an existing test (see inline comment). After that's reverted, the main thing worth a second look is the service-ID rename in services.yml.
On the service-ID rename (content.elementor → content.elementor3)
The class rename (ExternalContentElementor → ExternalContentElementorAbstract + a 16-line ExternalContentElementor3 shell) is the right call — standard "extract abstract base, keep concrete name for the original behavior" pattern, and the alternative (using the concrete class as both the v3 handler and the parent of v4) would muddle responsibilities.
The service ID rename in inc/config/services.yml is the part I'd push back on:
- Service IDs are part of the plugin's DI contract. Anything that resolves
@content.elementoror@elementor.factoryby string — other Smartling plugins, customer customizations, e2e tests, hook callbacks reading from the container — silently breaks withServiceNotFoundException. - The benefit is purely cosmetic symmetry with
content.elementor4. - The class name and the service ID don't have to match. DI is exactly the layer where they decouple.
Suggested: keep content.elementor / elementor.factory pointing at the new *3 classes, and only add the content.elementor4 / elementor.factory4 siblings. If you want the suffix in code too, use a deprecated alias rather than a hard rename:
content.elementor3:
class: Smartling\ContentTypes\ExternalContentElementor3
arguments: [...]
content.elementor:
alias: content.elementor3
deprecated: 'Use content.elementor3'Cross-cutting
ElementFactory4 extends ElementFactory3— shared mutable state via inheritance; both register into the same$this->elementsmap keyed bygetType(). A future v4 element reusing a v3 type name would silently overwrite. Prefer composition: v4 holds a v3 factory and consults it as a fallback. (No collision today.)testMixedElementorVersionsInSamePage— the name implies a real scenario, but in production both handlers are version-gated bygetSupportLevel(), so they can't both process one submission. Misleading test name.- No rollback path if v4 causes an incident — only fix is a hotfix removing
content.elementor4fromservices.yml. Consider a feature flag (constant or option) so customers can opt out without a code deploy. - Coverage gap in
Elements4/*:extractTypedValuehandles onlystringandhtml-v3. Any other$$typeElementor 4 might use silently drops out of translation. Worth confirming against Elementor v4's settings schema. - Stale docs —
docs/ELEMENTOR_DEVELOPMENT.md:171,279still reference the removedExternalContentElementorclass. composer.json—clue/graph-composer: "*"has no usage in the repo. Either remove it or pin + document.
Assessment
Ready to merge? No — with fixes.
Reasoning: The inverted is_string($contentType) check in ElementAbstract::setRelations is a regression affecting every v3 widget with related content (Image, Gallery, Posts, MegaMenu, NestedAccordion, etc.) and breaks an existing test. After that's reverted, reconsider the service-ID rename (or add a deprecated alias) and clean up composer.json. Everything else is polish.
🤖 AI-assisted review posted via /sl-core-dev:claude-pr-review-local (Claude Opus 4.7). Findings verified by hand against the diff; please push back on anything that misreads context.
| } | ||
|
|
||
| if ($contentType === false) { | ||
| if (is_string($contentType)) { |
There was a problem hiding this comment.
🔴 critical — inverted check, likely a copy-paste/refactor mistake
- if ($contentType === false) {
+ if (is_string($contentType)) {
$contentType = ContentTypeHelper::CONTENT_TYPE_UNKNOWN;
}The original guard said "if we couldn't determine the content type, default to UNKNOWN." The new code does the opposite: whenever a valid content type is returned, it gets overwritten with CONTENT_TYPE_UNKNOWN. Downstream, getTargetId() then omits FIELD_CONTENT_TYPE from the submission lookup, so relation mapping can match the wrong submission across content types. This breaks every v3 widget with related content (Image, Gallery, Posts, MegaMenu, NestedAccordion, etc.) and EImage in v4. ExternalContentElementor3Test::testSetContentFieldsRelationsChange should fail in CI on this.
Revert:
if ($contentType === false) {
$contentType = ContentTypeHelper::CONTENT_TYPE_UNKNOWN;
}Or, if the intent was to widen the guard for non-string returns (e.g. null, array):
if (!is_string($contentType) || $contentType === '') {
$contentType = ContentTypeHelper::CONTENT_TYPE_UNKNOWN;
}| }, | ||
| "require-dev": { | ||
| "phpunit/phpunit": "~9", | ||
| "clue/graph-composer": "*", |
There was a problem hiding this comment.
🟡 clue/graph-composer: "*" has no reference in the repo (no script, no doc, no Makefile). Unversioned * in require-dev is a supply-chain risk even for the dev environment. Either remove or pin to a specific version with a comment on what it's used for.
|
|
||
| namespace Smartling\ContentTypes\Elementor; | ||
|
|
||
| class ElementFactory4 extends ElementFactory3 { |
There was a problem hiding this comment.
🟡 ElementFactory4 extends ElementFactory3 shares mutable state — loadElements() mutates $this->elements keyed by getType() from both directories. If a future v4 element reuses a v3 type name, the later registration silently wins. No collision today, but consider composition (v4 holds a v3 factory and falls back to it) so the two registries stay independent.
There was a problem hiding this comment.
No issue, future elements should supersede previous ones
| } | ||
| $this->raw['elements'] = $this->elements; | ||
|
|
||
| foreach ($info->getOwnRelatedContent($this->id) as $path => $content) { |
There was a problem hiding this comment.
🟡 ElementAbstract::setTargetContent includes assert($content instanceof Content); before the same call; the v4 path doesn't. Minor consistency gap — if getOwnRelatedContent() is ever extended to return non-Content items, this is a defense-in-depth gap.
There was a problem hiding this comment.
Removed assertion
|
|
||
| namespace Smartling\ContentTypes; | ||
|
|
||
| class ExternalContentElementor3 extends ExternalContentElementorAbstract |
There was a problem hiding this comment.
🔵 ExternalContentElementor3 and ExternalContentElementor4 are both 16-line shells whose only content is getMinVersion()/getMaxVersion(). Consider making the version range data-driven (constructor arg, or a protected property override on the abstract) so a future v5 needs only a services.yml entry, not a new file.
There was a problem hiding this comment.
Consistent with other external content, won't change
|
|
||
| class EImage extends ElementAbstract4 | ||
| { | ||
| private const IMAGE_ID_PATH = 'image/value/src/value/id/value'; |
There was a problem hiding this comment.
🟣 IMAGE_ID_PATH = 'image/value/src/value/id/value' — what about the URL (image/value/src/value/url/value) and alt text? In v3 the connector also translates image.alt. Are these intentionally ignored, or a follow-up?
There was a problem hiding this comment.
Looks like there's no support for them yet in the atomic editor, and I can't guess what keys they will use
| public function getTranslatableStrings(): array | ||
| { | ||
| $result = []; | ||
| foreach (['placeholder'] as $key) { |
There was a problem hiding this comment.
🔵 Only placeholder is extracted here (same for EFormTextarea). Elementor's form widget typically also stores help text and validation messages. Worth checking the v4 form widget settings schema and adding the remaining translatable keys.
There was a problem hiding this comment.
Once again, these will probably appear somewhere in settings at some point of time, but I can't predict where
| use Smartling\Submissions\SubmissionEntity; | ||
| use Smartling\Submissions\SubmissionManager; | ||
|
|
||
| class ExternalContentElementor4Test extends TestCase |
There was a problem hiding this comment.
🟣 Tests construct the handler directly and bypass getSupportLevel()/getMinVersion()/getMaxVersion(), so they don't validate that the v4 handler is actually selected for Elementor 4.x and rejected for 3.x. Consider a testCanHandle-style test asserting v3 returns SUPPORTED for Version=3.21.0 and NOT_SUPPORTED for 4.0.0, and vice-versa for the v4 handler.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
No description provided.