diff --git a/composer.json b/composer.json index 2467c479..0469cf58 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "smartling/wordpress-connector", "license": "GPL-2.0-or-later", - "version": "5.7.0", + "version": "5.7.3", "description": "", "type": "wordpress-plugin", "repositories": [ diff --git a/inc/Smartling/Helpers/WordpressContentTypeHelper.php b/inc/Smartling/Helpers/WordpressContentTypeHelper.php index b1eaff96..5f2ee768 100644 --- a/inc/Smartling/Helpers/WordpressContentTypeHelper.php +++ b/inc/Smartling/Helpers/WordpressContentTypeHelper.php @@ -9,10 +9,6 @@ use Smartling\Exception\SmartlingDirectRunRuntimeException; use Smartling\Submissions\SubmissionEntity; -/** - * Class WordpressContentTypeHelper - * @package Smartling\Helpers - */ class WordpressContentTypeHelper { /** @@ -144,39 +140,51 @@ public static function getBaseTypeByContentType($contentType) return $ctHandler->getBaseType(); } - public static function getEditUrl(SubmissionEntity $submission) + public static function getTargetEditUrl(SubmissionEntity $submission) { - /** - * @var ContentTypeAbstract $ctHandler - */ - $ctHandler = static::getContentTypeManager()->getHandler($submission->getContentType()); + return static::buildEditUrl($submission, $submission->getTargetBlogId(), $submission->getTargetId()); + } + + public static function getSourceEditUrl(SubmissionEntity $submission): string + { + return static::buildEditUrl($submission, $submission->getSourceBlogId(), $submission->getSourceId()); + } + + private static function buildEditUrl(SubmissionEntity $submission, int $blogId, int $contentId): string + { + try { + $ctHandler = static::getContentTypeManager()->getHandler($submission->getContentType()); + } catch (\Exception $e) { + Bootstrap::getLogger()->warning(sprintf('%s (submissionId=%d)', $e->getMessage(), $submission->getId())); + + return ''; + } if ($ctHandler instanceof ContentTypeAbstract) { - $tail = ''; switch ($ctHandler->getBaseType()) { case 'post': - $tail = vsprintf('/post.php?post=%s&action=edit', [$submission->getTargetId()]); + $tail = sprintf('/post.php?post=%s&action=edit', $contentId); break; case 'taxonomy': - $tail = sprintf('/term.php?taxonomy=%s&tag_ID=%s', $submission->getContentType(), $submission->getTargetId()); + $tail = sprintf('/term.php?taxonomy=%s&tag_ID=%s', $submission->getContentType(), $contentId); break; default: return ''; } - return get_admin_url($submission->getTargetBlogId(), $tail); - } else { - Bootstrap::getLogger()->warning( - vsprintf( - 'Requested edit URI for unknown content-type \'%s\'', - [ - $submission->getContentType(), - ] - ) - ); - - return ''; + return get_admin_url($blogId, $tail); } + Bootstrap::getLogger()->warning( + vsprintf( + 'Requested edit URI for unknown content-type \'%s\'', + [ + $submission->getContentType(), + ] + ) + ); + + return ''; + } -} \ No newline at end of file +} diff --git a/inc/Smartling/WP/Controller/ConfigurationProfileFormController.php b/inc/Smartling/WP/Controller/ConfigurationProfileFormController.php index 81835595..511755a7 100644 --- a/inc/Smartling/WP/Controller/ConfigurationProfileFormController.php +++ b/inc/Smartling/WP/Controller/ConfigurationProfileFormController.php @@ -230,18 +230,23 @@ public function save(): void $profile->setSourceLocale($locale); } + $sourceBlogId = $profile->getSourceLocale()->getBlogId(); + $usedTargetLocales = []; if (array_key_exists('targetLocales', $settings)) { $locales = []; - foreach ($settings['targetLocales'] as $blogId => $settings) { + foreach ($settings['targetLocales'] as $blogId => $targetLocaleSettings) { + if ((int)$blogId === $sourceBlogId) { + continue; + } try { $tLocale = new TargetLocale(); $tLocale->setBlogId($blogId); $tLocale->setLabel($this->siteHelper->getBlogLabelById($this->localizationPluginProxy, $blogId)); - $enabled = 'on' === $settings['enabled']; - $tLocale->setEnabled(array_key_exists('enabled', $settings) && $enabled); - $smartlingLocale = array_key_exists('target', $settings) ? $settings['target'] : -1; + $enabled = 'on' === $targetLocaleSettings['enabled']; + $tLocale->setEnabled(array_key_exists('enabled', $targetLocaleSettings) && $enabled); + $smartlingLocale = array_key_exists('target', $targetLocaleSettings) ? $targetLocaleSettings['target'] : -1; $tLocale->setSmartlingLocale($smartlingLocale); if ($smartlingLocale !== -1 && $enabled) { $usedTargetLocales[] = $smartlingLocale; @@ -269,7 +274,8 @@ protected function renderLocales( string $displayName, int $blogId, string $smartlingName, - bool $enabled, + bool $checked, + bool $disabled, ): string { $parts = []; @@ -279,24 +285,34 @@ protected function renderLocales( 'name' => sprintf('smartling_settings[targetLocales][%s][enabled]', $blogId), ]; - if (true === $enabled) { + if (true === $checked) { $checkboxProperties['checked'] = 'checked'; } + if (true === $disabled) { + $checkboxProperties['disabled'] = 'disabled'; + } + $parts[] = HtmlTagGeneratorHelper::tag('input', '', $checkboxProperties); $parts[] = HtmlTagGeneratorHelper::tag('span', htmlspecialchars($displayName)); $parts = [ HtmlTagGeneratorHelper::tag('label', implode('', $parts), ['class' => 'radio-label']), ]; + $targetLocaleProperties = [ + 'name' => sprintf('smartling_settings[targetLocales][%s][target]', $blogId), + ]; + + if (true === $disabled) { + $targetLocaleProperties['disabled'] = 'disabled'; + } + if (0 === count($locales)) { $sLocale = HtmlTagGeneratorHelper::tag( 'input', '', - [ - 'name' => sprintf('smartling_settings[targetLocales][%s][target]', $blogId), - 'type' => 'text', - ]); + $targetLocaleProperties + ['type' => 'text'], + ); } else { $sLocale = HtmlTagGeneratorHelper::tag( 'select', @@ -304,9 +320,8 @@ protected function renderLocales( $smartlingName, $locales ), - [ - 'name' => sprintf('smartling_settings[targetLocales][%s][target]', $blogId), - ]); + $targetLocaleProperties, + ); } $parts = [ diff --git a/inc/Smartling/WP/Table/SubmissionTableWidget.php b/inc/Smartling/WP/Table/SubmissionTableWidget.php index f9354669..a8175b51 100644 --- a/inc/Smartling/WP/Table/SubmissionTableWidget.php +++ b/inc/Smartling/WP/Table/SubmissionTableWidget.php @@ -135,6 +135,24 @@ public function column_cb($item): string ); } + public static function buildSourceTitleCell(string $escapedTitle, string $sourceEditUrl): string + { + if ($sourceEditUrl === '') { + return $escapedTitle; + } + + return HtmlTagGeneratorHelper::tag('a', $escapedTitle, ['href' => $sourceEditUrl]); + } + + public static function buildTargetLocaleCell(string $blogLabel, string $targetEditUrl): string + { + if ($targetEditUrl === '') { + return $blogLabel; + } + + return HtmlTagGeneratorHelper::tag('a', $blogLabel, ['href' => $targetEditUrl]); + } + public function get_columns(): array { $columns = $this->submissionManager->getColumnsLabels(); @@ -398,7 +416,10 @@ public function prepare_items(): void $fileName = htmlentities($row[SubmissionEntity::FIELD_FILE_URI]); $row[SubmissionEntity::FIELD_FILE_URI] = $fileName; - $row[SubmissionEntity::FIELD_SOURCE_TITLE] = htmlentities($row[SubmissionEntity::FIELD_SOURCE_TITLE]); + $row[SubmissionEntity::FIELD_SOURCE_TITLE] = static::buildSourceTitleCell( + htmlentities($row[SubmissionEntity::FIELD_SOURCE_TITLE]), + 0 !== $element->getSourceId() ? WordpressContentTypeHelper::getSourceEditUrl($element) : '' + ); $row[SubmissionEntity::FIELD_CONTENT_TYPE] = WordpressContentTypeHelper::getLocalizedContentType($row[SubmissionEntity::FIELD_CONTENT_TYPE]); $row[SubmissionEntity::FIELD_SUBMISSION_DATE] = $this->sqlToReadableDate($row[SubmissionEntity::FIELD_SUBMISSION_DATE]); $row[SubmissionEntity::FIELD_APPLIED_DATE] = $this->sqlToReadableDate($row[SubmissionEntity::FIELD_APPLIED_DATE]); @@ -407,7 +428,10 @@ public function prepare_items(): void } catch (BlogNotFoundException $e) { $blogLabel = "*blog id {$row[SubmissionEntity::FIELD_TARGET_BLOG_ID]} not found*"; } - $row[SubmissionEntity::FIELD_TARGET_LOCALE] = $blogLabel; + $row[SubmissionEntity::FIELD_TARGET_LOCALE] = static::buildTargetLocaleCell( + htmlentities($blogLabel), + 0 !== $element->getTargetId() ? WordpressContentTypeHelper::getTargetEditUrl($element) : '' + ); $row[SubmissionEntity::VIRTUAL_FIELD_JOB_LINK] = $jobInfo->getJobName() === '' ? '' : "getProjectUid()}/account-jobs/?filename=$fileName\">" . esc_html($jobInfo->getJobName()) . ''; $flagBlockParts = []; diff --git a/inc/Smartling/WP/View/ConfigurationProfileForm.php b/inc/Smartling/WP/View/ConfigurationProfileForm.php index 542b4cee..ae1ac4e7 100644 --- a/inc/Smartling/WP/View/ConfigurationProfileForm.php +++ b/inc/Smartling/WP/View/ConfigurationProfileForm.php @@ -473,13 +473,8 @@ getTargetLocales(); $supportedLocales = $this->api->getSupportedLocales($profile); + $currentSourceBlogId = $profile->getSourceLocale()->getBlogId(); foreach ($locales as $blogId => $label) { - if ($blogId === $profile->getSourceLocale() - ->getBlogId() - ) { - continue; - } - $smartlingLocale = ''; $enabled = false; @@ -490,10 +485,12 @@ break; } } + + $isSourceLocaleRow = $blogId === $currentSourceBlogId; ?> - - renderLocales($supportedLocales, $label, $blogId, $smartlingLocale, $enabled) ?> + + renderLocales($supportedLocales, $label, $blogId, $smartlingLocale, $enabled, $isSourceLocaleRow) ?> getIsCloned() || 1 === $item->hasLocks()); if (0 !== (int) $item->getTargetId()) { - $editUrl = WordpressContentTypeHelper::getEditUrl($item); + $editUrl = WordpressContentTypeHelper::getTargetEditUrl($item); } /** diff --git a/inc/Smartling/WP/View/taxonomy-based-content-type.php b/inc/Smartling/WP/View/taxonomy-based-content-type.php index 081800c5..91eead4b 100644 --- a/inc/Smartling/WP/View/taxonomy-based-content-type.php +++ b/inc/Smartling/WP/View/taxonomy-based-content-type.php @@ -97,7 +97,9 @@ $percent = $item->getCompletionPercentage(); $status = $item->getStatusColor(); $statusFlags = $item->getStatusFlags(); - $editUrl = WordpressContentTypeHelper::getEditUrl($item); + if (0 !== $item->getTargetId()) { + $editUrl = WordpressContentTypeHelper::getTargetEditUrl($item); + } $enabled = !(1 === $item->getIsCloned() || 1 === $item->getIsLocked()); break; } diff --git a/js/configuration-profile-form.js b/js/configuration-profile-form.js index ef049c8f..6305cd5e 100644 --- a/js/configuration-profile-form.js +++ b/js/configuration-profile-form.js @@ -11,6 +11,22 @@ }); $('#smartling-configuration-profile-form').validate() } + + const syncTargetLocaleRows = function (sourceLocaleSelect) { + const sourceBlogId = String(sourceLocaleSelect.value); + $('#target-locale-block tr.target-locale-row').each(function () { + const row = $(this); + const isSource = String(row.data('blog-id')) === sourceBlogId; + row.toggleClass('hidden', isSource); + row.find('input, select').prop('disabled', isSource); + }); + }; + + $('#default-locales, #default-locales-new').each(function () { + syncTargetLocaleRows(this); + }).on('change', function () { + syncTargetLocaleRows(this); + }); $('a.toggleExpert').on('click', function () { $('.toggleExpert').removeClass('hidden'); $('a.toggleExpert').addClass('hidden'); diff --git a/readme.txt b/readme.txt index 87132a16..7975f286 100755 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: translation, localization, multilingual, internationalization, smartling Requires at least: 5.5 Tested up to: 7.0 Requires PHP: 8.0 -Stable tag: 5.7.0 +Stable tag: 5.7.3 License: GPLv2 or later Translate content in WordPress quickly and seamlessly with Smartling, the industry-leading Translation Management System. @@ -62,6 +62,10 @@ Additional information on the Smartling Connector for WordPress can be found [he 3. Track translation status within WordPress from the Submissions Board. View overall progress of submitted translation requests as well as resend updated content. == Changelog == += 5.7.3 = +* Fixed possible misconfiguration where target locales were being saved as a duplicate of a newly changed source locale +* Added links to the source and target content (if available) on the Translation Progress screen + = 5.7.0 = * Reworked upload queue, added a live-refreshing upload queue count with visual feedback on change diff --git a/smartling-connector.php b/smartling-connector.php index bddaadd5..81d70c2f 100755 --- a/smartling-connector.php +++ b/smartling-connector.php @@ -11,7 +11,7 @@ * Plugin Name: Smartling Connector * Plugin URI: https://www.smartling.com/products/automate/integrations/wordpress/ * Description: Integrate your WordPress site with Smartling to upload your content and download translations. - * Version: 5.7.0 + * Version: 5.7.3 * Author: Smartling * Author URI: https://www.smartling.com * License: GPL-2.0+ diff --git a/tests/Smartling/Helpers/WordpressContentTypeHelperTest.php b/tests/Smartling/Helpers/WordpressContentTypeHelperTest.php new file mode 100644 index 00000000..8ae74483 --- /dev/null +++ b/tests/Smartling/Helpers/WordpressContentTypeHelperTest.php @@ -0,0 +1,186 @@ +originalManager = Bootstrap::getContainer()->get('content-type-descriptor-manager'); + } + + protected function tearDown(): void + { + Bootstrap::getContainer()->set('content-type-descriptor-manager', $this->originalManager); + parent::tearDown(); + } + + private function registerContentTypeHandler(?string $baseType): void + { + $manager = $this->createMock(ContentTypeManager::class); + + if ($baseType === null) { + // A handler that implements the interface but not ContentTypeAbstract, + // matching the "unknown/unregistered content type" branch in production code. + $manager->method('getHandler')->willReturn($this->createMock(ContentTypeInterface::class)); + } else { + $handler = $this->createMock(ContentTypeAbstract::class); + $handler->method('getBaseType')->willReturn($baseType); + $manager->method('getHandler')->willReturn($handler); + } + + Bootstrap::getContainer()->set('content-type-descriptor-manager', $manager); + } + + /** + * ContentTypeManager::getHandler() throws (rather than returning some sentinel + * value) when the requested content type was never registered as a descriptor - + * e.g. a historical submission row whose custom-post-type integration has since + * been removed/deactivated. buildEditUrl() must not let that exception escape. + */ + private function registerThrowingContentTypeManager(): void + { + $manager = $this->createMock(ContentTypeManager::class); + $manager->method('getHandler')->willThrowException( + new SmartlingInvalidFactoryArgumentException("Requested descriptor for 'sovos_product' that doesn't exists.") + ); + + Bootstrap::getContainer()->set('content-type-descriptor-manager', $manager); + } + + public function testGetSourceEditUrlReturnsEmptyStringWhenContentTypeManagerThrows(): void + { + $this->registerThrowingContentTypeManager(); + + $submission = (new SubmissionEntity()) + ->setContentType('sovos_product') + ->setSourceBlogId(1) + ->setSourceId(1); + + $this->assertSame('', WordpressContentTypeHelper::getSourceEditUrl($submission)); + } + + public function testGetEditUrlReturnsEmptyStringWhenContentTypeManagerThrows(): void + { + $this->registerThrowingContentTypeManager(); + + $submission = (new SubmissionEntity()) + ->setContentType('sovos_product') + ->setTargetBlogId(1) + ->setTargetId(1); + + $this->assertSame('', WordpressContentTypeHelper::getTargetEditUrl($submission)); + } + + public function testGetSourceEditUrlBuildsPostEditLink(): void + { + $this->registerContentTypeHandler('post'); + + $submission = (new SubmissionEntity()) + ->setContentType('post') + ->setSourceBlogId(5) + ->setSourceId(42); + + $this->assertSame( + 'https://blog-5.test/post.php?post=42&action=edit', + WordpressContentTypeHelper::getSourceEditUrl($submission) + ); + } + + public function testGetSourceEditUrlBuildsAttachmentEditLinkUsingPostPhp(): void + { + // Attachments share the 'post' base type, so they must resolve through the + // same post.php edit screen as regular posts, not upload.php. + $this->registerContentTypeHandler('post'); + + $submission = (new SubmissionEntity()) + ->setContentType('attachment') + ->setSourceBlogId(5) + ->setSourceId(99); + + $this->assertSame( + 'https://blog-5.test/post.php?post=99&action=edit', + WordpressContentTypeHelper::getSourceEditUrl($submission) + ); + } + + public function testGetSourceEditUrlBuildsTaxonomyEditLink(): void + { + $this->registerContentTypeHandler('taxonomy'); + + $submission = (new SubmissionEntity()) + ->setContentType('category') + ->setSourceBlogId(7) + ->setSourceId(13); + + $this->assertSame( + 'https://blog-7.test/term.php?taxonomy=category&tag_ID=13', + WordpressContentTypeHelper::getSourceEditUrl($submission) + ); + } + + public function testGetSourceEditUrlReturnsEmptyStringForUnsupportedBaseType(): void + { + $this->registerContentTypeHandler('virtual'); + + $submission = (new SubmissionEntity()) + ->setContentType('menu') + ->setSourceBlogId(1) + ->setSourceId(1); + + $this->assertSame('', WordpressContentTypeHelper::getSourceEditUrl($submission)); + } + + public function testGetSourceEditUrlReturnsEmptyStringForUnknownContentType(): void + { + $this->registerContentTypeHandler(null); + + $submission = (new SubmissionEntity()) + ->setContentType('does-not-exist') + ->setSourceBlogId(1) + ->setSourceId(1); + + $this->assertSame('', WordpressContentTypeHelper::getSourceEditUrl($submission)); + } + + public function testGetEditUrlStillBuildsTargetEditLink(): void + { + // Regression check: refactoring getEditUrl() to share code with + // getSourceEditUrl() must not change its existing target-link behavior. + $this->registerContentTypeHandler('post'); + + $submission = (new SubmissionEntity()) + ->setContentType('post') + ->setTargetBlogId(9) + ->setTargetId(101); + + $this->assertSame( + 'https://blog-9.test/post.php?post=101&action=edit', + WordpressContentTypeHelper::getTargetEditUrl($submission) + ); + } + } +} diff --git a/tests/Smartling/WP/Controller/ConfigurationProfileFormControllerTest.php b/tests/Smartling/WP/Controller/ConfigurationProfileFormControllerTest.php new file mode 100644 index 00000000..35040051 --- /dev/null +++ b/tests/Smartling/WP/Controller/ConfigurationProfileFormControllerTest.php @@ -0,0 +1,165 @@ +storedRequest = $_REQUEST; + } + + protected function tearDown(): void + { + parent::tearDown(); + $_REQUEST = $this->storedRequest; + } + + private function createController(SettingsManager $settingsManager, SiteHelper $siteHelper): ConfigurationProfileFormController + { + return new ConfigurationProfileFormController( + $this->createMock(ApiWrapperInterface::class), + $this->createMock(LocalizationPluginProxyInterface::class), + $this->createMock(PluginInfo::class), + $settingsManager, + $siteHelper, + $this->createMock(SubmissionManager::class), + $this->createMock(Cache::class), + ); + } + + public function testSaveRemovesTargetLocaleMatchingNewSourceLocale(): void + { + $profile = new ConfigurationProfileEntity(); + $profile->setId(1); + + $siteHelper = $this->createMock(SiteHelper::class); + $siteHelper->method('getBlogLabelById')->willReturnCallback( + static fn(LocalizationPluginProxyInterface $proxy, int $blogId) => 'blog-' . $blogId + ); + + $settingsManager = $this->createMock(SettingsManager::class); + $settingsManager->method('getEntityById')->with(1)->willReturn([$profile]); + $settingsManager->expects($this->once()) + ->method('storeEntity') + ->with($this->callback(function (ConfigurationProfileEntity $savedProfile) { + $targetBlogIds = array_map(static fn($locale) => $locale->getBlogId(), $savedProfile->getTargetLocales()); + // Target locale for blogId 3 (== new source locale) must be dropped, + // while the unrelated target locale for blogId 4 must survive. + return $savedProfile->getSourceLocale()->getBlogId() === 3 + && !in_array(3, $targetBlogIds, true) + && in_array(4, $targetBlogIds, true) + && count($targetBlogIds) === 1; + })) + ->willReturnArgument(0); + + $controller = $this->createController($settingsManager, $siteHelper); + + $_REQUEST['smartling_settings'] = [ + 'id' => 1, + 'defaultLocale' => '3', + 'targetLocales' => [ + 3 => ['enabled' => 'on', 'target' => 'fr-FR'], + 4 => ['enabled' => 'on', 'target' => 'de-DE'], + ], + ]; + + $controller->save(); + } + + public function testSaveKeepsNonCollidingTargetLocalesAndStillDetectsDuplicateSmartlingLocales(): void + { + $profile = new ConfigurationProfileEntity(); + $profile->setId(1); + + $siteHelper = $this->createMock(SiteHelper::class); + $siteHelper->method('getBlogLabelById')->willReturnCallback( + static fn(LocalizationPluginProxyInterface $proxy, int $blogId) => 'blog-' . $blogId + ); + + $settingsManager = $this->createMock(SettingsManager::class); + $settingsManager->method('getEntityById')->with(1)->willReturn([$profile]); + // Duplicate Smartling locale codes among the remaining (non-source) targets + // must still block the save, same as before this change. + $settingsManager->expects($this->never())->method('storeEntity'); + + $controller = $this->createController($settingsManager, $siteHelper); + + $_REQUEST['smartling_settings'] = [ + 'id' => 1, + 'defaultLocale' => '3', + 'targetLocales' => [ + 3 => ['enabled' => 'on', 'target' => 'fr-FR'], + 4 => ['enabled' => 'on', 'target' => 'de-DE'], + 5 => ['enabled' => 'on', 'target' => 'de-DE'], + ], + ]; + + $controller->save(); + } + + public function testRenderLocalesDisablesInputsForSourceLocaleRow(): void + { + $controller = $this->createController( + $this->createMock(SettingsManager::class), + $this->createMock(SiteHelper::class), + ); + + $method = new \ReflectionMethod(ConfigurationProfileFormController::class, 'renderLocales'); + // PHP 8.0 (this project's target) still requires setAccessible() to invoke a + // protected method via reflection; it only became a no-op starting PHP 8.1. + $method->setAccessible(true); + + $html = $method->invoke($controller, ['en-US' => 'English'], 'French', 3, 'fr-FR', true, true); + + $this->assertStringContainsString('disabled="disabled"', $html); + } + + public function testRenderLocalesDoesNotDisableInputsForRegularRow(): void + { + $controller = $this->createController( + $this->createMock(SettingsManager::class), + $this->createMock(SiteHelper::class), + ); + + $method = new \ReflectionMethod(ConfigurationProfileFormController::class, 'renderLocales'); + $method->setAccessible(true); + + $html = $method->invoke($controller, ['en-US' => 'English'], 'French', 3, 'fr-FR', true, false); + + $this->assertStringNotContainsString('disabled="disabled"', $html); + } + } +} diff --git a/tests/Smartling/WP/Table/SubmissionTableWidgetTest.php b/tests/Smartling/WP/Table/SubmissionTableWidgetTest.php index 67cc2ced..6503d2c7 100644 --- a/tests/Smartling/WP/Table/SubmissionTableWidgetTest.php +++ b/tests/Smartling/WP/Table/SubmissionTableWidgetTest.php @@ -113,6 +113,48 @@ public function testProcessBulkActionIgnoresNonArraySubmissionPayload(): void $x->processBulkAction(); } + /** + * The Title column must link to the source content's WP edit screen when a source + * edit URL is available. + */ + public function testBuildSourceTitleCellWrapsTitleInLinkWhenUrlAvailable(): void + { + $this->assertSame( + 'My Title', + SubmissionTableWidget::buildSourceTitleCell('My Title', 'https://example.com/wp-admin/post.php?post=1&action=edit') + ); + } + + /** + * With no source edit URL (e.g. unsupported content type), the Title column must + * fall back to plain text instead of rendering a dead/empty link. + */ + public function testBuildSourceTitleCellReturnsPlainTextWhenUrlMissing(): void + { + $this->assertSame('My Title', SubmissionTableWidget::buildSourceTitleCell('My Title', '')); + } + + /** + * The Locale column must link to the target content's WP edit screen when a target + * edit URL is available (e.g. translation already applied). + */ + public function testBuildTargetLocaleCellWrapsLabelInLinkWhenUrlAvailable(): void + { + $this->assertSame( + 'German', + SubmissionTableWidget::buildTargetLocaleCell('German', 'https://de.example.com/wp-admin/post.php?post=2&action=edit') + ); + } + + /** + * With no target edit URL (e.g. translation not yet applied, target_id === 0), the + * Locale column must fall back to plain text instead of rendering a dead link. + */ + public function testBuildTargetLocaleCellReturnsPlainTextWhenUrlMissing(): void + { + $this->assertSame('German', SubmissionTableWidget::buildTargetLocaleCell('German', '')); + } + private function buildWidget( ApiWrapperInterface $apiWrapper, SettingsManager $settingsManager,