diff --git a/CLAUDE.md b/CLAUDE.md index 749e91e5..bfd7dbf4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ This is the **Smartling Connector** WordPress plugin - a translation and localiz - `Submissions/` - Core translation workflow management **Translation Pipeline**: -1. **Upload**: Content serialization → Smartling API upload +1. **Upload**: Content serialization to XML → Smartling API upload 2. **Processing**: Translation occurs in Smartling dashboard 3. **Download**: Completed translations → WordPress content application @@ -90,6 +90,7 @@ This is the **Smartling Connector** WordPress plugin - a translation and localiz ## Development Guidelines ### Code Structure +- PHP language level 8.0 - PSR-0 autoloading with `Smartling\` namespace - Dependency injection throughout the codebase - Extensive use of interfaces for testability diff --git a/inc/Smartling/ContentTypes/ExternalContentJsonRules.php b/inc/Smartling/ContentTypes/ExternalContentJsonRules.php new file mode 100644 index 00000000..980d15e3 --- /dev/null +++ b/inc/Smartling/ContentTypes/ExternalContentJsonRules.php @@ -0,0 +1,281 @@ +rulesManager->loadData(); + foreach ($this->rulesManager->listItems() as $rule) { + if ($rule->getContentType() === '*' || $rule->getContentType() === $contentType) { + return Pluggable::SUPPORTED; + } + } + return Pluggable::NOT_SUPPORTED; + } + + public function getExternalContentTypes(): array + { + return []; + } + + public function getContentFields(SubmissionEntity $submission, bool $raw): array + { + $result = []; + $this->rulesManager->loadData(); + foreach ($this->getRulesByMetaKey($submission->getContentType()) as $metaKey => $rules) { + $json = $this->readMetaJson($submission->getSourceId(), $metaKey); + if ($json === null) { + continue; + } + foreach ($rules as $rule) { + if ($this->parseReplacer($rule->getReplacerId())[0] !== ReplacerFactory::REPLACER_TRANSLATE) { + continue; + } + $matches = $this->safeGet($json, $rule->getPropertyPath()); + foreach ($matches as $index => $value) { + if (is_string($value) && $value !== '') { + $result[$this->buildKey($metaKey, $rule->getPropertyPath(), $index)] = $value; + } + } + } + } + return $result; + } + + public function getRelatedContent(string $contentType, int $contentId): array + { + $result = []; + $this->rulesManager->loadData(); + foreach ($this->getRulesByMetaKey($contentType) as $metaKey => $rules) { + $json = $this->readMetaJson($contentId, $metaKey); + if ($json === null) { + continue; + } + foreach ($rules as $rule) { + [$replacer, $hint] = $this->parseReplacer($rule->getReplacerId()); + if ($replacer !== ReplacerFactory::REPLACER_RELATED) { + continue; + } + $referencedType = $hint !== '' ? $hint : ContentTypeHelper::CONTENT_TYPE_UNKNOWN; + foreach ($this->safeGet($json, $rule->getPropertyPath()) as $value) { + if (is_numeric($value) && (int)$value > 0) { + $result[$referencedType][] = (int)$value; + } + } + } + } + foreach ($result as $type => $ids) { + $result[$type] = array_values(array_unique($ids)); + } + return $result; + } + + public function setContentFields(array $original, array $translation, SubmissionEntity $submission): ?array + { + $translations = $translation[$this->getPluginId()] ?? []; + unset($translation[$this->getPluginId()]); + + $this->rulesManager->loadData(); + $changed = false; + foreach ($this->getRulesByMetaKey($submission->getContentType()) as $metaKey => $rules) { + // Prefer a translation already produced by a prior handler (e.g. Elementor) over the + // source. JsonRules' edits act as a delta on top of bundled handlers rather than + // replacing their work. + $sourceJson = $translation['meta'][$metaKey] ?? $original['meta'][$metaKey] ?? null; + if (!is_string($sourceJson) || $sourceJson === '') { + continue; + } + try { + $jsonObject = new JsonObject($sourceJson); + } catch (\Throwable $e) { + $this->getLogger()->debug("Failed to parse meta $metaKey as JSON: " . $e->getMessage()); + continue; + } + $modified = false; + foreach ($rules as $rule) { + [$replacer] = $this->parseReplacer($rule->getReplacerId()); + if ($replacer === ReplacerFactory::REPLACER_TRANSLATE) { + $modified = $this->applyTranslateRule($jsonObject, $rule, $metaKey, $translations) || $modified; + } elseif ($replacer === ReplacerFactory::REPLACER_RELATED) { + $modified = $this->applyRelatedRule($jsonObject, $rule, $submission) || $modified; + } + } + if ($modified) { + $translation['meta'][$metaKey] = $jsonObject->getJson(JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + $changed = true; + } + } + + return $changed ? $translation : null; + } + + public function removeUntranslatableFieldsForUpload(array $source, SubmissionEntity $submission): array + { + $this->rulesManager->loadData(); + foreach (array_keys($this->getRulesByMetaKey($submission->getContentType())) as $metaKey) { + if (isset($source['meta'][$metaKey])) { + unset($source['meta'][$metaKey]); + } + } + return $source; + } + + /** + * @return array + */ + private function getRulesByMetaKey(string $contentType): array + { + $result = []; + foreach ($this->rulesManager->listItems() as $rule) { + if ($rule->getContentType() !== '*' && $rule->getContentType() !== $contentType) { + continue; + } + $result[$rule->getMetaKey()][] = $rule; + } + return $result; + } + + private function readMetaJson(int $contentId, string $metaKey): ?string + { + $value = $this->wpProxy->getPostMeta($contentId, $metaKey, true); + if (!is_string($value) || $value === '') { + return null; + } + try { + json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + return $value; + } + + private function safeGet(string $json, string $path): array + { + try { + $result = (new JsonObject($json))->get($path); + } catch (\Throwable $e) { + $this->getLogger()->debug("JsonPath get failed for path=$path: " . $e->getMessage()); + return []; + } + if (!is_array($result)) { + return []; + } + return $result; + } + + private function applyTranslateRule(JsonObject $jsonObject, JsonFieldRule $rule, string $metaKey, array $translations): bool + { + $objects = $jsonObject->getJsonObjects($rule->getPropertyPath()); + if ($objects === false) { + return false; + } + if (!is_array($objects)) { + $objects = [$objects]; + } + $changed = false; + foreach ($objects as $index => $node) { + $key = $this->buildKey($metaKey, $rule->getPropertyPath(), $index); + if (array_key_exists($key, $translations)) { + $ref = &$node->getValue(); + $ref = $translations[$key]; + unset($ref); + $changed = true; + } + } + return $changed; + } + + private function applyRelatedRule(JsonObject $jsonObject, JsonFieldRule $rule, SubmissionEntity $submission): bool + { + try { + $replacer = $this->replacerFactory->getReplacer($rule->getReplacerId()); + } catch (\Throwable $e) { + $this->getLogger()->notice("Unable to resolve replacer {$rule->getReplacerId()}: " . $e->getMessage()); + return false; + } + $objects = $jsonObject->getJsonObjects($rule->getPropertyPath()); + if ($objects === false) { + return false; + } + if (!is_array($objects)) { + $objects = [$objects]; + } + $changed = false; + foreach ($objects as $node) { + $ref = &$node->getValue(); + $original = $ref; + if (!is_numeric($original) || (int)$original <= 0) { + unset($ref); + continue; + } + $replaced = $replacer->processAttributeOnDownload($original, $original, $submission); + if ($replaced !== $original) { + $ref = $replaced; + $changed = true; + } + unset($ref); + } + return $changed; + } + + /** + * @return array{0:string,1:string} [replacerId, contentTypeHint] + */ + private function parseReplacer(string $replacerId): array + { + $parts = explode('|', $replacerId, 2); + return [$parts[0], $parts[1] ?? '']; + } + + private function buildKey(string $metaKey, string $path, int $index): string + { + return $metaKey . '|' . $path . '|' . $index; + } +} diff --git a/inc/Smartling/Replacers/ReplacerFactory.php b/inc/Smartling/Replacers/ReplacerFactory.php index 40b42441..09563f58 100644 --- a/inc/Smartling/Replacers/ReplacerFactory.php +++ b/inc/Smartling/Replacers/ReplacerFactory.php @@ -10,6 +10,7 @@ class ReplacerFactory public const REPLACER_COPY = 'copy'; private const REPLACER_EXCLUDE = 'exclude'; public const REPLACER_RELATED = 'related'; + public const REPLACER_TRANSLATE = 'translate'; private const REPLACER_WP_CORE_IMAGE_INNER_HTML = 'coreImage'; /** @@ -23,6 +24,7 @@ public function __construct(SubmissionManager $submissionManager) self::REPLACER_COPY => new CopyReplacer(), self::REPLACER_EXCLUDE => new ExcludeReplacer(), self::REPLACER_RELATED => new ContentIdReplacer($submissionManager), + self::REPLACER_TRANSLATE => new TranslateReplacer(), self::REPLACER_WP_CORE_IMAGE_INNER_HTML => new ImageInnerHtmlReplacer($submissionManager), ]; } diff --git a/inc/Smartling/Replacers/TranslateReplacer.php b/inc/Smartling/Replacers/TranslateReplacer.php new file mode 100644 index 00000000..92b029d0 --- /dev/null +++ b/inc/Smartling/Replacers/TranslateReplacer.php @@ -0,0 +1,11 @@ +mediaAttachmentRulesManager = $mediaAttachmentRulesManager; - $this->replacerFactory = $replacerFactory; + public function __construct( + private MediaAttachmentRulesManager $mediaAttachmentRulesManager, + private ReplacerFactory $replacerFactory, + private JsonFieldRulesManager $jsonFieldRulesManager, + private PluginInfo $pluginInfo, + private WordpressFunctionProxyHelper $wpProxy, + ) { } public function register(): void @@ -35,6 +39,12 @@ public function register(): void (new ShortcodeForm())->register(); (new FilterForm())->register(); (new MediaRuleForm($this->mediaAttachmentRulesManager, $this->replacerFactory))->register(); + (new VisualConfiguratorPage( + $this->jsonFieldRulesManager, + $this->replacerFactory, + $this->pluginInfo, + $this->wpProxy, + ))->register(); }); } } diff --git a/inc/Smartling/Tuner/JsonFieldRule.php b/inc/Smartling/Tuner/JsonFieldRule.php new file mode 100644 index 00000000..4d71f9a9 --- /dev/null +++ b/inc/Smartling/Tuner/JsonFieldRule.php @@ -0,0 +1,60 @@ +contentType; + } + + public function getMetaKey(): string + { + return $this->metaKey; + } + + public function getPropertyPath(): string + { + return $this->propertyPath; + } + + public function getReplacerId(): string + { + return $this->replacerId; + } + + public function toArray(): array + { + return [ + 'contentType' => $this->contentType, + 'metaKey' => $this->metaKey, + 'propertyPath' => $this->propertyPath, + 'replacerId' => $this->replacerId, + ]; + } + + public static function fromArray(array $data): self + { + foreach (['contentType', 'metaKey', 'propertyPath', 'replacerId'] as $key) { + if (!array_key_exists($key, $data)) { + throw new \InvalidArgumentException("Missing key in JsonFieldRule array: $key"); + } + } + + return new self( + (string)$data['contentType'], + (string)$data['metaKey'], + (string)$data['propertyPath'], + (string)$data['replacerId'], + ); + } +} diff --git a/inc/Smartling/Tuner/JsonFieldRulesManager.php b/inc/Smartling/Tuner/JsonFieldRulesManager.php new file mode 100644 index 00000000..8e49492a --- /dev/null +++ b/inc/Smartling/Tuner/JsonFieldRulesManager.php @@ -0,0 +1,42 @@ +state)) { + return ''; + } + do { + $id = uniqid('', true); + } while (array_key_exists($id, $this->state)); + $this->state[$id] = $value; + return $id; + } + + /** + * @return JsonFieldRule[] + */ + public function listItems(): array + { + $result = []; + foreach (parent::listItems() as $id => $item) { + try { + $result[$id] = JsonFieldRule::fromArray($item); + } catch (\InvalidArgumentException) { + // skip malformed entries + } + } + return $result; + } +} diff --git a/inc/Smartling/WP/Controller/VisualConfiguratorPage.php b/inc/Smartling/WP/Controller/VisualConfiguratorPage.php new file mode 100644 index 00000000..8aa56524 --- /dev/null +++ b/inc/Smartling/WP/Controller/VisualConfiguratorPage.php @@ -0,0 +1,182 @@ +wpProxy->add_action('admin_menu', [$this, 'menu']); + $this->wpProxy->add_action('network_admin_menu', [$this, 'menu']); + $this->wpProxy->add_action('admin_enqueue_scripts', [$this, 'enqueue']); + $this->wpProxy->add_action('wp_ajax_' . self::ACTION_LIST_RULES, [$this, 'ajaxListRules']); + $this->wpProxy->add_action('wp_ajax_' . self::ACTION_SAVE_RULE, [$this, 'ajaxSaveRule']); + $this->wpProxy->add_action('wp_ajax_' . self::ACTION_DELETE_RULE, [$this, 'ajaxDeleteRule']); + } + + public function menu(): void + { + add_submenu_page( + AdminPage::SLUG, + 'Smartling Visual Configurator', + 'Visual Configurator', + SmartlingUserCapabilities::SMARTLING_CAPABILITY_PROFILE_CAP, + self::SLUG, + [$this, 'pageHandler'], + ); + } + + public function pageHandler(): void + { + $this->viewData = [ + 'replacerOptions' => $this->replacerFactory->getListForUi(), + ]; + $this->renderScript(); + } + + public function enqueue(string $hook): void + { + if (!str_contains($hook, self::SLUG)) { + return; + } + + $handle = $this->pluginInfo->getName() . 'visual-configurator'; + wp_enqueue_script( + $handle, + $this->pluginInfo->getUrl() . 'js/visual-configurator.js', + ['wp-element', 'wp-components', 'wp-api-fetch', 'jquery'], + $this->pluginInfo->getVersion(), + true, + ); + wp_localize_script($handle, 'smartlingVisualConfigurator', [ + 'ajaxUrl' => admin_url('admin-ajax.php'), + 'restRoot' => rest_url('smartling-connector/v2'), + 'nonce' => wp_create_nonce(self::NONCE_ACTION), + 'restNonce' => wp_create_nonce('wp_rest'), + 'replacerOptions' => $this->replacerFactory->getListForUi(), + 'actions' => [ + 'list' => self::ACTION_LIST_RULES, + 'save' => self::ACTION_SAVE_RULE, + 'delete' => self::ACTION_DELETE_RULE, + ], + ]); + wp_enqueue_style('wp-components'); + } + + public function ajaxListRules(): void + { + $this->verifyNonce(); + $this->rulesManager->loadData(); + $rules = []; + foreach ($this->rulesManager->listItems() as $id => $rule) { + $rules[] = ['id' => $id] + $rule->toArray(); + } + $this->wpProxy->wp_send_json_success(['rules' => $rules]); + } + + public function ajaxSaveRule(): void + { + $this->verifyNonce(); + try { + $payload = $this->readRulePayload(); + } catch (\InvalidArgumentException $e) { + $this->wpProxy->wp_send_json_error(['message' => $e->getMessage()], 400); + return; + } + + $data = (new JsonFieldRule( + $payload['contentType'], + $payload['metaKey'], + $payload['propertyPath'], + $payload['replacerId'], + ))->toArray(); + + $id = isset($_POST['id']) && is_string($_POST['id']) + ? $this->wpProxy->sanitize_text_field($this->wpProxy->wp_unslash($_POST['id'])) + : ''; + + $this->rulesManager->loadData(); + if ($id === '') { + $id = $this->rulesManager->add($data); + if ($id === '') { + $this->wpProxy->wp_send_json_error(['message' => 'Duplicate rule'], 409); + return; + } + } else { + $this->rulesManager->updateItem($id, $data); + } + $this->rulesManager->saveData(); + + $this->wpProxy->wp_send_json_success(['rule' => ['id' => $id] + $data]); + } + + public function ajaxDeleteRule(): void + { + $this->verifyNonce(); + $id = isset($_POST['id']) && is_string($_POST['id']) + ? $this->wpProxy->sanitize_text_field($this->wpProxy->wp_unslash($_POST['id'])) + : ''; + if ($id === '') { + $this->wpProxy->wp_send_json_error(['message' => 'Missing id'], 400); + return; + } + + $this->rulesManager->loadData(); + $this->rulesManager->removeItem($id); + $this->rulesManager->saveData(); + + $this->wpProxy->wp_send_json_success(['id' => $id]); + } + + private function verifyNonce(): void + { + $this->wpProxy->check_ajax_referer(self::NONCE_ACTION, '_wpnonce'); + } + + /** + * @return array{contentType:string,metaKey:string,propertyPath:string,replacerId:string} + */ + private function readRulePayload(): array + { + $get = function (string $key): string { + if (!isset($_POST[$key]) || !is_string($_POST[$key])) { + throw new \InvalidArgumentException("Missing field: $key"); + } + return $this->wpProxy->sanitize_text_field($this->wpProxy->wp_unslash($_POST[$key])); + }; + $payload = [ + 'contentType' => $get('contentType'), + 'metaKey' => $get('metaKey'), + 'propertyPath' => $get('propertyPath'), + 'replacerId' => $get('replacerId'), + ]; + foreach ($payload as $k => $v) { + if ($k !== 'contentType' && $v === '') { + throw new \InvalidArgumentException("Field cannot be empty: $k"); + } + } + return $payload; + } +} diff --git a/inc/Smartling/WP/View/AdminPage.php b/inc/Smartling/WP/View/AdminPage.php index d5ba2d1c..6735e4eb 100644 --- a/inc/Smartling/WP/View/AdminPage.php +++ b/inc/Smartling/WP/View/AdminPage.php @@ -4,6 +4,7 @@ use Smartling\WP\Controller\FilterForm; use Smartling\WP\Controller\MediaRuleForm; use Smartling\WP\Controller\ShortcodeForm; +use Smartling\WP\Controller\VisualConfiguratorPage; use Smartling\WP\Table\LocalizationRulesTableWidget; use Smartling\WP\Table\MediaAttachmentTableWidget; use Smartling\WP\Table\ShortcodeTableClass; @@ -69,4 +70,12 @@ + +

Visual Configurator

+

+

+ + + +

diff --git a/inc/Smartling/WP/View/VisualConfiguratorPage.php b/inc/Smartling/WP/View/VisualConfiguratorPage.php new file mode 100644 index 00000000..8037cdb5 --- /dev/null +++ b/inc/Smartling/WP/View/VisualConfiguratorPage.php @@ -0,0 +1,18 @@ + +
+

+

+ +

+
+ +
diff --git a/inc/config/services.yml b/inc/config/services.yml index d0243488..023faf0e 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -11,6 +11,16 @@ services: arguments: - "%known.attachment.rules%" + json.field.rules.manager: + class: Smartling\Tuner\JsonFieldRulesManager + + content.json.rules: + class: Smartling\ContentTypes\ExternalContentJsonRules + arguments: + - "@json.field.rules.manager" + - "@factory.replacer" + - "@wp.proxy" + persistent.notices.manager: class: Smartling\Helpers\AdminNoticesHelper @@ -261,6 +271,7 @@ services: - ["addHandler", ["@content.elementor4"]] - ["addHandler", ["@content.gravity.forms"]] - ["addHandler", ["@content.yoast"]] + - ["addHandler", ["@content.json.rules"]] manager.job: class: Smartling\Jobs\JobManager @@ -554,6 +565,9 @@ services: arguments: - "@media.attachment.rules.manager" - "@factory.replacer" + - "@json.field.rules.manager" + - "@plugin.info" + - "@wp.proxy" duplicate.submissions.cleaner: class: Smartling\WP\Controller\DuplicateSubmissionsCleaner diff --git a/js/visual-configurator.js b/js/visual-configurator.js new file mode 100644 index 00000000..74c1d46d --- /dev/null +++ b/js/visual-configurator.js @@ -0,0 +1,425 @@ +/* global wp, jQuery, smartlingVisualConfigurator */ +(function () { + const { render, createElement: el, useState, useEffect, useCallback, Fragment } = wp.element; + const { + Button, + Card, + CardBody, + CardHeader, + Notice, + SelectControl, + Spinner, + TextControl, + __experimentalVStack: VStack, + } = wp.components; + + const settings = window.smartlingVisualConfigurator || {}; + const REPLACER_OPTIONS = settings.replacerOptions || {}; + const REFERENCED_CONTENT_TYPES = [ + { label: 'Post-based (attachment)', value: 'attachment' }, + { label: 'Post-based (post)', value: 'postbased' }, + ]; + + function buildReplacerSelect(value, onChange) { + const options = Object.keys(REPLACER_OPTIONS).map((id) => ({ + label: REPLACER_OPTIONS[id], + value: id, + })); + return el(SelectControl, { + label: 'Rule', + value, + options: [{ label: '(none)', value: '' }, ...options], + onChange, + }); + } + + function tryParseJson(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (trimmed === '' || (trimmed[0] !== '{' && trimmed[0] !== '[')) { + return null; + } + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object') return parsed; + } catch (e) { + // not JSON + } + return null; + } + + function joinPath(prefix, segment) { + if (typeof segment === 'number' || /^\d+$/.test(segment)) { + return `${prefix}[${segment}]`; + } + if (/^[A-Za-z_][\w]*$/.test(segment)) { + return prefix === '$' ? `$.${segment}` : `${prefix}.${segment}`; + } + return `${prefix}['${segment.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}']`; + } + + function valueIsLeaf(value) { + return value === null || ['string', 'number', 'boolean'].includes(typeof value); + } + + function formatLeaf(value) { + if (typeof value === 'string') { + const trimmed = value.length > 80 ? `${value.slice(0, 80)}…` : value; + return `"${trimmed}"`; + } + return String(value); + } + + function JsonNode({ value, path, metaKey, contentType, onAddRule, rulesByPath, depth = 0 }) { + const [expanded, setExpanded] = useState(depth < 2); + if (valueIsLeaf(value)) { + const existing = rulesByPath[path]; + const isString = typeof value === 'string'; + const isNumeric = typeof value === 'number' || (isString && /^\d+$/.test(value)); + return el( + 'div', + { className: 'svc-leaf', style: { padding: '4px 0 4px 16px', borderLeft: '1px solid #ddd' } }, + el('code', { style: { color: '#0073aa' } }, path), + ' = ', + el('span', { style: { color: '#444' } }, formatLeaf(value)), + ' ', + existing + ? el( + 'span', + { style: { marginLeft: 8, padding: '0 6px', background: '#eef', borderRadius: 4 } }, + 'rule: ', + existing.replacerId, + ) + : el( + Button, + { + variant: 'link', + onClick: () => onAddRule({ path, metaKey, contentType, value, isString, isNumeric }), + }, + 'Add rule', + ), + ); + } + const entries = Array.isArray(value) + ? value.map((v, i) => [i, v]) + : Object.entries(value); + return el( + 'div', + { style: { paddingLeft: depth === 0 ? 0 : 16 } }, + el( + Button, + { + variant: 'tertiary', + onClick: () => setExpanded(!expanded), + style: { padding: '0 4px' }, + }, + expanded ? '▾' : '▸', + ' ', + Array.isArray(value) ? `array(${entries.length})` : `object(${entries.length})`, + ), + expanded && + el( + 'div', + { style: { borderLeft: '1px solid #ddd', marginLeft: 4 } }, + entries.map(([k, v]) => + el( + 'div', + { key: String(k) }, + valueIsLeaf(v) + ? null + : el('div', { style: { paddingLeft: 16, color: '#666' } }, String(k), ':'), + el(JsonNode, { + key: String(k), + value: v, + path: joinPath(path, k), + metaKey, + contentType, + onAddRule, + rulesByPath, + depth: depth + 1, + }), + ), + ), + ), + ); + } + + function MetaField({ name, value, contentType, onAddRule, rulesByPath }) { + const parsed = tryParseJson(value); + return el( + Card, + { style: { marginBottom: 12 } }, + el(CardHeader, null, el('strong', null, name), parsed ? ' (JSON)' : ' (plain)'), + el( + CardBody, + null, + parsed + ? el(JsonNode, { + value: parsed, + path: '$', + metaKey: name, + contentType, + onAddRule, + rulesByPath, + }) + : el( + 'div', + null, + el('code', { style: { color: '#0073aa' } }, name), + ' = ', + el('span', null, formatLeaf(value)), + ' ', + rulesByPath[name] + ? el( + 'span', + { style: { marginLeft: 8, padding: '0 6px', background: '#eef', borderRadius: 4 } }, + 'rule: ', + rulesByPath[name].replacerId, + ) + : el( + Button, + { + variant: 'link', + onClick: () => + onAddRule({ + path: '', + metaKey: name, + contentType, + value, + isString: typeof value === 'string', + isNumeric: typeof value === 'number' || /^\d+$/.test(String(value)), + }), + }, + 'Add rule', + ), + ), + ), + ); + } + + function RuleEditor({ draft, onCancel, onSave }) { + const [replacerId, setReplacerId] = useState('copy'); + const [refType, setRefType] = useState('attachment'); + if (!draft) return null; + const composedReplacerId = replacerId === 'related' ? `related|${refType}` : replacerId; + return el( + Card, + { style: { marginTop: 12, border: '2px solid #0073aa' } }, + el(CardHeader, null, 'Add rule for ', el('code', null, draft.metaKey + (draft.path ? ' ' + draft.path : ''))), + el( + CardBody, + null, + el(VStack, { spacing: 3 }, + buildReplacerSelect(replacerId, setReplacerId), + replacerId === 'related' + ? el(SelectControl, { + label: 'Referenced content type', + value: refType, + options: REFERENCED_CONTENT_TYPES, + onChange: setRefType, + }) + : null, + el('div', null, + el(Button, { + variant: 'primary', + disabled: !replacerId, + onClick: () => onSave({ + contentType: draft.contentType, + metaKey: draft.metaKey, + propertyPath: draft.path, + replacerId: composedReplacerId, + }), + }, 'Save rule'), + ' ', + el(Button, { variant: 'secondary', onClick: onCancel }, 'Cancel'), + ), + ), + ), + ); + } + + function VisualConfigurator() { + const [contentType, setContentType] = useState('page'); + const [contentId, setContentId] = useState(''); + const [content, setContent] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [rules, setRules] = useState([]); + const [draft, setDraft] = useState(null); + + const refreshRules = useCallback(async () => { + try { + const response = await jQuery.post(settings.ajaxUrl, { + action: settings.actions.list, + _wpnonce: settings.nonce, + }); + if (response && response.success) { + setRules(response.data.rules || []); + } + } catch (e) { + setError('Failed to load rules: ' + (e.message || 'unknown')); + } + }, []); + + useEffect(() => { + refreshRules(); + }, [refreshRules]); + + const loadContent = useCallback(async () => { + if (!contentId) return; + setLoading(true); + setError(''); + setContent(null); + try { + const url = `${settings.restRoot}/assets/${contentType}-${contentId}/raw`; + const response = await fetch(url, { + credentials: 'same-origin', + headers: { 'X-WP-Nonce': settings.restNonce }, + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data = await response.json(); + setContent(data); + } catch (e) { + setError('Failed to load content: ' + (e.message || 'unknown')); + } finally { + setLoading(false); + } + }, [contentType, contentId]); + + const handleSaveRule = useCallback(async (payload) => { + try { + const response = await jQuery.post(settings.ajaxUrl, { + action: settings.actions.save, + _wpnonce: settings.nonce, + ...payload, + }); + if (!response || !response.success) { + setError(response?.data?.message || 'Save failed'); + return; + } + setDraft(null); + await refreshRules(); + } catch (e) { + setError('Save failed: ' + (e.message || 'unknown')); + } + }, [refreshRules]); + + const handleDeleteRule = useCallback(async (id) => { + if (!confirm('Delete this rule?')) return; + try { + await jQuery.post(settings.ajaxUrl, { + action: settings.actions.delete, + _wpnonce: settings.nonce, + id, + }); + await refreshRules(); + } catch (e) { + setError('Delete failed: ' + (e.message || 'unknown')); + } + }, [refreshRules]); + + const rulesByPath = {}; + rules + .filter((r) => r.contentType === contentType || r.contentType === '*') + .forEach((r) => { + const key = r.propertyPath ? `${r.metaKey}|${r.propertyPath}` : r.metaKey; + rulesByPath[r.propertyPath || r.metaKey] = r; + rulesByPath[key] = r; + }); + + return el(Fragment, null, + el(Card, { style: { marginBottom: 16 } }, + el(CardHeader, null, 'Load content sample'), + el(CardBody, null, + el(VStack, { spacing: 3 }, + el(SelectControl, { + label: 'Content type', + value: contentType, + options: [ + { label: 'page', value: 'page' }, + { label: 'post', value: 'post' }, + { label: 'attachment', value: 'attachment' }, + ], + onChange: setContentType, + }), + el(TextControl, { + label: 'Content ID', + value: contentId, + onChange: setContentId, + }), + el(Button, { variant: 'primary', onClick: loadContent, disabled: !contentId }, 'Load'), + ), + ), + ), + error ? el(Notice, { status: 'error', isDismissible: false }, error) : null, + loading ? el(Spinner) : null, + content && el(Card, { style: { marginBottom: 16 } }, + el(CardHeader, null, 'Detected meta fields'), + el(CardBody, null, + Object.entries(content.meta || {}).map(([name, value]) => + el(MetaField, { + key: name, + name, + value, + contentType, + onAddRule: setDraft, + rulesByPath, + }), + ), + ), + ), + el(RuleEditor, { + draft, + onCancel: () => setDraft(null), + onSave: handleSaveRule, + }), + el(Card, null, + el(CardHeader, null, `Saved rules (${rules.length})`), + el(CardBody, null, + rules.length === 0 + ? el('em', null, 'No rules yet.') + : el('table', { className: 'widefat' }, + el('thead', null, + el('tr', null, + el('th', null, 'Content type'), + el('th', null, 'Meta key'), + el('th', null, 'Path'), + el('th', null, 'Rule'), + el('th', null, ''), + ), + ), + el('tbody', null, + rules.map((r) => + el('tr', { key: r.id }, + el('td', null, r.contentType), + el('td', null, el('code', null, r.metaKey)), + el('td', null, el('code', null, r.propertyPath || '(whole field)')), + el('td', null, r.replacerId), + el('td', null, + el(Button, { + variant: 'link', + isDestructive: true, + onClick: () => handleDeleteRule(r.id), + }, 'Delete'), + ), + ), + ), + ), + ), + ), + ), + ); + } + + document.addEventListener('DOMContentLoaded', () => { + const root = document.getElementById('smartling-visual-configurator-root'); + if (!root) return; + if (typeof settings.ajaxUrl === 'undefined') { + root.innerHTML = '

Configurator not configured.

'; + return; + } + render(el(VisualConfigurator), root); + }); +})(); diff --git a/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php b/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php new file mode 100644 index 00000000..f4d898e4 --- /dev/null +++ b/tests/Smartling/ContentTypes/ExternalContentJsonRulesTest.php @@ -0,0 +1,264 @@ +mockRulesManager([ + $this->rule('page', '_elementor_data', '$.title', 'translate'), + ]); + $engine = $this->buildEngine($manager); + + $this->assertSame(Pluggable::SUPPORTED, $engine->getSupportLevel('page')); + } + + public function testGetSupportLevelNotSupportedWhenNoRules(): void + { + $manager = $this->mockRulesManager([]); + $engine = $this->buildEngine($manager); + + $this->assertSame(Pluggable::NOT_SUPPORTED, $engine->getSupportLevel('page')); + } + + public function testGetSupportLevelNotSupportedForUnrelatedContentType(): void + { + $manager = $this->mockRulesManager([ + $this->rule('post', '_elementor_data', '$.title', 'translate'), + ]); + $engine = $this->buildEngine($manager); + + $this->assertSame(Pluggable::NOT_SUPPORTED, $engine->getSupportLevel('page')); + } + + public function testGetSupportLevelSupportedForWildcardRule(): void + { + $manager = $this->mockRulesManager([ + $this->rule('*', '_meta', '$.x', 'copy'), + ]); + + $this->assertSame(Pluggable::SUPPORTED, $this->buildEngine($manager)->getSupportLevel('anytype')); + } + + public function testGetContentFieldsExtractsTranslateStringsOnly(): void + { + $json = json_encode([ + 'elements' => [ + ['settings' => ['title' => 'Hello world', 'id' => 42]], + ['settings' => ['title' => 'Second title', 'id' => 99]], + ], + ]); + $manager = $this->mockRulesManager([ + $this->rule('page', '_elementor_data', '$.elements[*].settings.title', 'translate'), + $this->rule('page', '_elementor_data', '$.elements[*].settings.id', 'related|attachment'), + $this->rule('page', '_elementor_data', '$.foo', 'copy'), + ]); + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $wpProxy->method('getPostMeta')->willReturn($json); + + $engine = $this->buildEngine($manager, $wpProxy); + $submission = $this->submission('page', 100); + + $result = $engine->getContentFields($submission, false); + + $this->assertCount(2, $result); + $this->assertSame('Hello world', $result['_elementor_data|$.elements[*].settings.title|0']); + $this->assertSame('Second title', $result['_elementor_data|$.elements[*].settings.title|1']); + } + + public function testGetRelatedContentExtractsReferenceIdsGroupedByContentType(): void + { + $json = json_encode([ + 'elements' => [ + ['settings' => ['image' => ['id' => 11]]], + ['settings' => ['image' => ['id' => 22]]], + ['settings' => ['image' => ['id' => 11]]], + ], + ]); + $manager = $this->mockRulesManager([ + $this->rule('page', '_elementor_data', '$.elements[*].settings.image.id', 'related|attachment'), + ]); + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $wpProxy->method('getPostMeta')->willReturn($json); + + $result = $this->buildEngine($manager, $wpProxy)->getRelatedContent('page', 100); + + $this->assertArrayHasKey('attachment', $result); + $this->assertSame([11, 22], $result['attachment']); + } + + public function testSetContentFieldsAppliesTranslationsAndIdReplacement(): void + { + $sourceJson = json_encode([ + 'elements' => [ + ['settings' => ['title' => 'Hello', 'image' => ['id' => 11]]], + ['settings' => ['title' => 'World', 'image' => ['id' => 22]]], + ], + ]); + $manager = $this->mockRulesManager([ + $this->rule('page', '_elementor_data', '$.elements[*].settings.title', 'translate'), + $this->rule('page', '_elementor_data', '$.elements[*].settings.image.id', 'related|attachment'), + ]); + $submission = $this->submission('page', 100); + + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->method('findOne')->willReturnCallback(function (array $params) { + $remap = [11 => 110, 22 => 220]; + $sourceId = $params[SubmissionEntity::FIELD_SOURCE_ID] ?? null; + if (!isset($remap[$sourceId])) { + return null; + } + $related = $this->createMock(SubmissionEntity::class); + $related->method('getTargetId')->willReturn($remap[$sourceId]); + $related->method('getId')->willReturn(0); + return $related; + }); + + $replacerFactory = new ReplacerFactory($submissionManager); + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $engine = new ExternalContentJsonRules($manager, $replacerFactory, $wpProxy); + + $translation = [ + ExternalContentJsonRules::PLUGIN_ID => [ + '_elementor_data|$.elements[*].settings.title|0' => 'Hola', + '_elementor_data|$.elements[*].settings.title|1' => 'Mundo', + ], + 'meta' => [], + ]; + $original = ['meta' => ['_elementor_data' => $sourceJson]]; + + $result = $engine->setContentFields($original, $translation, $submission); + + $this->assertIsArray($result); + $this->assertArrayHasKey('meta', $result); + $this->assertArrayHasKey('_elementor_data', $result['meta']); + $this->assertArrayNotHasKey(ExternalContentJsonRules::PLUGIN_ID, $result, 'plugin-id key should be stripped'); + + $decoded = json_decode($result['meta']['_elementor_data'], true); + $this->assertSame('Hola', $decoded['elements'][0]['settings']['title']); + $this->assertSame('Mundo', $decoded['elements'][1]['settings']['title']); + $this->assertSame(110, $decoded['elements'][0]['settings']['image']['id']); + $this->assertSame(220, $decoded['elements'][1]['settings']['image']['id']); + } + + public function testSetContentFieldsPreservesPriorHandlerTranslationsOnSameKey(): void + { + $sourceJson = json_encode([ + 'elements' => [ + ['settings' => ['title' => 'Hello', 'subtitle' => 'World']], + ], + ]); + // Simulates the JSON that an upstream handler (e.g. Elementor) wrote into + // $translation['meta']['_elementor_data'] before JsonRules ran: + // BOTH title AND subtitle already translated. + $priorTranslationJson = json_encode([ + 'elements' => [ + ['settings' => ['title' => 'Elementor-Hola', 'subtitle' => 'Elementor-Mundo']], + ], + ]); + $manager = $this->mockRulesManager([ + // JsonRules user configured a rule only for title, not subtitle. + $this->rule('page', '_elementor_data', '$.elements[*].settings.title', 'translate'), + ]); + $submission = $this->submission('page', 100); + $engine = $this->buildEngine($manager); + + $translation = [ + ExternalContentJsonRules::PLUGIN_ID => [ + '_elementor_data|$.elements[*].settings.title|0' => 'JsonRules-Hola', + ], + 'meta' => [ + '_elementor_data' => $priorTranslationJson, + ], + ]; + $original = ['meta' => ['_elementor_data' => $sourceJson]]; + + $result = $engine->setContentFields($original, $translation, $submission); + + $this->assertIsArray($result); + $decoded = json_decode($result['meta']['_elementor_data'], true); + $this->assertSame( + 'JsonRules-Hola', + $decoded['elements'][0]['settings']['title'], + 'JsonRules rule should overwrite the title', + ); + $this->assertSame( + 'Elementor-Mundo', + $decoded['elements'][0]['settings']['subtitle'], + "Prior handler's translation on a path WITHOUT a JsonRules rule must survive", + ); + } + + public function testRemoveUntranslatableFieldsStripsCoveredMetaKeys(): void + { + $manager = $this->mockRulesManager([ + $this->rule('page', '_elementor_data', '$.x', 'translate'), + ]); + $engine = $this->buildEngine($manager); + + $result = $engine->removeUntranslatableFieldsForUpload([ + 'entity' => ['post_content' => 'preserved'], + 'meta' => ['_elementor_data' => '...', '_other' => 'kept'], + ], $this->submission('page', 100)); + + $this->assertArrayNotHasKey('_elementor_data', $result['meta']); + $this->assertArrayHasKey('_other', $result['meta']); + $this->assertSame('preserved', $result['entity']['post_content']); + } + + private function rule(string $contentType, string $metaKey, string $path, string $replacerId): JsonFieldRule + { + return new JsonFieldRule($contentType, $metaKey, $path, $replacerId); + } + + /** + * @param JsonFieldRule[] $rules + */ + private function mockRulesManager(array $rules): JsonFieldRulesManager|MockObject + { + $mock = $this->createMock(JsonFieldRulesManager::class); + $mock->method('listItems')->willReturn($rules); + return $mock; + } + + private function buildEngine(JsonFieldRulesManager $manager, ?WordpressFunctionProxyHelper $wpProxy = null): ExternalContentJsonRules + { + $submissionManager = $this->createMock(SubmissionManager::class); + return new ExternalContentJsonRules( + $manager, + new ReplacerFactory($submissionManager), + $wpProxy ?? $this->createMock(WordpressFunctionProxyHelper::class), + ); + } + + private function submission(string $contentType, int $sourceId): SubmissionEntity|MockObject + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getContentType')->willReturn($contentType); + $submission->method('getSourceId')->willReturn($sourceId); + $submission->method('getSourceBlogId')->willReturn(1); + $submission->method('getTargetBlogId')->willReturn(2); + $submission->method('getId')->willReturn(0); + return $submission; + } +} diff --git a/tests/Smartling/Replacers/TranslateReplacerTest.php b/tests/Smartling/Replacers/TranslateReplacerTest.php new file mode 100644 index 00000000..e78f1cad --- /dev/null +++ b/tests/Smartling/Replacers/TranslateReplacerTest.php @@ -0,0 +1,24 @@ +assertSame('Translate', (new TranslateReplacer())->getLabel()); + } + + public function testUploadPassesValueThrough(): void + { + $this->assertSame('hello', (new TranslateReplacer())->processAttributeOnUpload('hello')); + } + + public function testDownloadUsesTranslatedValue(): void + { + $this->assertSame('hola', (new TranslateReplacer())->processAttributeOnDownload('hello', 'hola', null)); + } +} diff --git a/tests/Smartling/Tuner/JsonFieldRuleTest.php b/tests/Smartling/Tuner/JsonFieldRuleTest.php new file mode 100644 index 00000000..d79ec32d --- /dev/null +++ b/tests/Smartling/Tuner/JsonFieldRuleTest.php @@ -0,0 +1,37 @@ +assertSame('page', $rule->getContentType()); + $this->assertSame('_elementor_data', $rule->getMetaKey()); + $this->assertSame('$.elements[*].settings.title', $rule->getPropertyPath()); + $this->assertSame('translate', $rule->getReplacerId()); + } + + public function testToArrayAndFromArray(): void + { + $rule = new JsonFieldRule('page', '_elementor_data', '$.x', 'related|attachment'); + $arr = $rule->toArray(); + $this->assertSame([ + 'contentType' => 'page', + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.x', + 'replacerId' => 'related|attachment', + ], $arr); + $this->assertEquals($rule, JsonFieldRule::fromArray($arr)); + } + + public function testFromArrayMissingKeyThrows(): void + { + $this->expectException(\InvalidArgumentException::class); + JsonFieldRule::fromArray(['contentType' => 'page']); + } +} diff --git a/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php b/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php new file mode 100644 index 00000000..34ab6110 --- /dev/null +++ b/tests/Smartling/Tuner/JsonFieldRulesManagerTest.php @@ -0,0 +1,68 @@ +assertSame(JsonFieldRulesManager::STORAGE_KEY, (new JsonFieldRulesManager())->getStorageKey()); + } + + public function testAddAndListItems(): void + { + $m = new JsonFieldRulesManager(); + $id = $m->add([ + 'contentType' => 'page', + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.x', + 'replacerId' => 'copy', + ]); + $this->assertNotSame('', $id); + + $items = $m->listItems(); + $this->assertArrayHasKey($id, $items); + $this->assertInstanceOf(JsonFieldRule::class, $items[$id]); + $this->assertSame('page', $items[$id]->getContentType()); + $this->assertSame('_elementor_data', $items[$id]->getMetaKey()); + $this->assertSame('$.x', $items[$id]->getPropertyPath()); + $this->assertSame('copy', $items[$id]->getReplacerId()); + } + + public function testAddDoesNotDuplicate(): void + { + $m = new JsonFieldRulesManager(); + $data = [ + 'contentType' => 'page', + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.x', + 'replacerId' => 'copy', + ]; + $id1 = $m->add($data); + $id2 = $m->add($data); + $this->assertNotSame('', $id1); + $this->assertSame('', $id2); + $this->assertCount(1, $m->listItems()); + } + + public function testRemoveItem(): void + { + $m = new JsonFieldRulesManager(); + $id = $m->add(['contentType' => 'page', 'metaKey' => '_elementor_data', 'propertyPath' => '$.a', 'replacerId' => 'copy']); + $this->assertCount(1, $m->listItems()); + + $m->removeItem($id); + + $this->assertCount(0, $m->listItems()); + } +} diff --git a/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php b/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php new file mode 100644 index 00000000..aeae6ffd --- /dev/null +++ b/tests/Smartling/WP/Controller/VisualConfiguratorPageTest.php @@ -0,0 +1,168 @@ +createMock(JsonFieldRulesManager::class); + $rulesManager->expects($this->once())->method('loadData'); + $rulesManager->method('listItems')->willReturn([ + 'rule-id-1' => new JsonFieldRule('page', '_elementor_data', '$.title', 'translate'), + ]); + + $wpProxy = $this->createWpProxy(); + $wpProxy->expects($this->once()) + ->method('wp_send_json_success') + ->with($this->callback(function ($payload): bool { + return $payload['rules'][0] === [ + 'id' => 'rule-id-1', + 'contentType' => 'page', + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.title', + 'replacerId' => 'translate', + ]; + })); + + $controller = $this->makeController($rulesManager, $wpProxy); + $controller->ajaxListRules(); + } + + public function testAjaxSaveRuleStoresAndReturnsRule(): void + { + $_POST = [ + 'contentType' => 'page', + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.title', + 'replacerId' => 'translate', + ]; + + $rulesManager = new JsonFieldRulesManager(); + $wpProxy = $this->createWpProxy(); + $wpProxy->method('sanitize_text_field')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->method('wp_unslash')->willReturnCallback(fn(string $v): string => $v); + + $savedRule = null; + $wpProxy->method('wp_send_json_success')->willReturnCallback(function (array $payload) use (&$savedRule) { + $savedRule = $payload['rule']; + }); + + $controller = $this->makeController($rulesManager, $wpProxy); + $controller->ajaxSaveRule(); + + $this->assertIsArray($savedRule); + $this->assertSame('page', $savedRule['contentType']); + $this->assertSame('translate', $savedRule['replacerId']); + $this->assertNotEmpty($savedRule['id']); + $this->assertCount(1, $rulesManager->listItems()); + } + + public function testAjaxSaveRuleRejectsMissingFields(): void + { + $_POST = ['contentType' => 'page']; + + $wpProxy = $this->createWpProxy(); + $wpProxy->method('sanitize_text_field')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->method('wp_unslash')->willReturnCallback(fn(string $v): string => $v); + + $errorCalled = false; + $wpProxy->method('wp_send_json_error')->willReturnCallback(function ($payload, $status = null) use (&$errorCalled) { + $errorCalled = true; + $this->assertSame(400, $status); + $this->assertStringContainsString('Missing', $payload['message']); + }); + + $this->makeController(new JsonFieldRulesManager(), $wpProxy)->ajaxSaveRule(); + + $this->assertTrue($errorCalled); + } + + public function testAjaxSaveRuleRejectsDuplicate(): void + { + $_POST = [ + 'contentType' => 'page', + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.title', + 'replacerId' => 'translate', + ]; + + // The manager's add() returns '' for duplicates; mock that directly so we test the controller's response. + $manager = $this->createMock(JsonFieldRulesManager::class); + $manager->method('add')->willReturn(''); + + $wpProxy = $this->createWpProxy(); + $wpProxy->method('sanitize_text_field')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->method('wp_unslash')->willReturnCallback(fn(string $v): string => $v); + + $errorCalled = false; + $wpProxy->method('wp_send_json_error')->willReturnCallback(function ($payload, $status = null) use (&$errorCalled) { + $errorCalled = true; + $this->assertSame(409, $status); + }); + + $this->makeController($manager, $wpProxy)->ajaxSaveRule(); + + $this->assertTrue($errorCalled); + } + + public function testAjaxDeleteRuleRemovesItem(): void + { + $manager = new JsonFieldRulesManager(); + $id = $manager->add([ + 'contentType' => 'page', + 'metaKey' => '_elementor_data', + 'propertyPath' => '$.title', + 'replacerId' => 'translate', + ]); + $_POST = ['id' => $id]; + + $wpProxy = $this->createWpProxy(); + $wpProxy->method('sanitize_text_field')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->method('wp_unslash')->willReturnCallback(fn(string $v): string => $v); + $wpProxy->expects($this->once())->method('wp_send_json_success'); + + $this->makeController($manager, $wpProxy)->ajaxDeleteRule(); + + $this->assertCount(0, $manager->listItems()); + } + + private function createWpProxy(): WordpressFunctionProxyHelper|MockObject + { + return $this->createMock(WordpressFunctionProxyHelper::class); + } + + private function makeController(JsonFieldRulesManager $manager, WordpressFunctionProxyHelper $wpProxy): VisualConfiguratorPage + { + $pluginInfo = $this->createMock(PluginInfo::class); + $submissionManager = $this->createMock(SubmissionManager::class); + return new VisualConfiguratorPage( + $manager, + new ReplacerFactory($submissionManager), + $pluginInfo, + $wpProxy, + ); + } +}