Skip to content

choose configuration profile UI (WP-1022) - #638

Merged
vsolovei-smartling merged 15 commits into
masterfrom
WP-1022-choose-profile-on-request
Oct 10, 2026
Merged

vsolovei-smartling merged 15 commits into
masterfrom
WP-1022-choose-profile-on-request

Conversation

@vsolovei-smartling

Copy link
Copy Markdown
Contributor

No description provided.

vsolovei-smartling and others added 9 commits October 6, 2026 11:49
First step of letting a user choose which Smartling profile/project a
translation request goes to, instead of relying on whichever profile
is flagged "active": the request model itself now requires the caller
to say which profile it's for, validated the same way the existing
required job/source fields are.

Updated every constructor/fromArray() call site (TestRunController,
and the test suites exercising UserTranslationRequest/
ContentRelationsDiscoveryService/ContentRelationsHandler) to pass one.

Note: ContentRelationsDiscoveryService::createSubmissions() doesn't
read getProfileId() yet - it still resolves the active profile
unconditionally. That wiring, plus the frontend profile selector that
will actually populate this field in real AJAX requests, are separate
steps in the same ticket.
createSubmissions() always resolved the profile via
getSingleSettingsProfile($curBlogId) - whichever one happens to be
flagged active - ignoring UserTranslationRequest::getProfileId()
entirely.

Now resolves the requested profile explicitly (validating it exists,
belongs to the current blog, and is active), falling back to today's
active-profile behavior only if the requested id is stale (profile
deactivated/deleted client-side after the page loaded) rather than
hard-failing the whole request.

This is the single resolution point both the bulk and non-bulk
branches already share, so the profile-stamping added for WP-1021
(createBatch(), setConfigurationProfileId() at enqueue time) now
automatically uses the user's explicit choice with no further changes
needed at those call sites.
list-jobs/create-job previously always resolved the blog's active
profile via getSingleSettingsProfile(), ignoring any profileId the
client may have selected. Mirror the fallback behavior already added
to ContentRelationsDiscoveryService: use the requested profile when it
exists, belongs to the current blog, and is active; otherwise fall
back to the active profile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Surface all active profiles for the current blog (not just the first
one) to the React job wizard shared by the post/taxonomy edit screen
and bulk submit page, each with its own target-locale set. Add a
profile dropdown (shown only when more than one profile applies),
remember the chosen profile per-blog in localStorage, and send its id
with list-jobs/create-job/smartling-create-submissions so the request
is routed to the profile the user picked instead of whichever one
happens to be flagged active. Switching profiles reconciles the
selected target locales and refreshes the existing-jobs list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The per-post "Smartling Widget" (side metabox) only showed a link to a
target placeholder post once its row had been rendered server-side on
page load, so after queuing a new upload from the job wizard the user
had to manually reload the page to get a link to the freshly created
placeholder. Add an AJAX endpoint that re-renders that metabox's
markup on demand, and have the job wizard poll it with a short backoff
after a successful (non bulk-submit) upload, swapping in the refreshed
widget until every requested target locale has an edit link or the
poll window runs out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…1022)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… client (WP-1022)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@sl-mmuradov sl-mmuradov left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, centralizing the lookup in resolveRequestedProfile() with a strict check (exists, same blog, active) is the right approach, and keeping error details server-side is good. A few things block merging, though:

Blocking

  1. Related content still uses the default profile. SubmissionManager::getSubmissionEntity() always stamps getSingleSettingsProfile(), even on existing submissions. Related content created during upload or download (referenced posts and terms, images, downloadTranslation) therefore goes to profile A while the parent and job are on B, and existing B submissions are re-stamped to A. This defeats the main goal of the ticket for any content with relations.
  2. Target blogs aren't validated against the chosen profile in createSubmissions, create-job or instant translation. The result is an empty Smartling locale and a failure later on.
  3. The widget refresh breaks the side widget. The outerHTML swap drops the Download and checkbox handlers. The AJAX action is registered by every post-type controller, so only the first post type renders correct submissions. Taxonomy screens poll with a term id as postId.
  4. ContentRelationsHandler reports every SmartlingDbException as "Invalid translation profile", which hides real DB errors.

Should fix

  • Re-stamping in-progress submissions under a different profile leaves them pointing at the wrong project. Block or warn.
  • The wizard leaves the old profile's jobs selectable after a switch, doesn't clear its error, and re-fires all relation requests.
  • The side widget merges locales from all profiles, but its legacy upload path uses only the first profile.

Scope question: the ticket asks for a per-blog default profile. Right now it's "first active by id" plus localStorage. Is that a follow-up ticket?

Tests: please add tests for ajaxRefreshWidgetHandler, the job proxy's profile 400 path, the handler's exception mapping, related-content stamping when the requested profile isn't the default, and Playwright coverage for the dropdown, the reset on switch and the widget refresh.

Inline comments have details and suggested fixes.

// them with the profile this request's batch is being created under (below), so
// UploadJob never has to guess at a profile for them later.
// New submissions built from this template bypass getSubmissionEntity(), stamp them with the requested profile.
$submissionTemplateArray[SubmissionEntity::FIELD_CONFIGURATION_PROFILE_ID] = $profile->getId();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Parent submissions are stamped with the requested profile here, but related content created later still gets the default profile. SubmissionManager::getSubmissionEntity() → stampConfigurationProfile() → getSingleSettingsProfile() (SubmissionManager.php ~489) runs for new and existing submissions. It's reached from TranslationHelper::tryPrepareRelatedContent() via ReferencedContentProcessor (referenced posts/terms in meta), SmartlingCoreExportApi::sendAttachmentForTranslation() (Gutenberg/Elementor images) and SmartlingCoreDownloadTrait::downloadTranslation().

With profile B chosen, children get profile A's credentials but B's job (JobEntityWithBatchUid::fromJob($submission->getJobInfo())), and existing B submissions are silently re-stamped to A. This was harmless while "active" meant "requested". Now that several profiles can be active, it isn't. Suggest: pass the parent's profile id into related-content creation, and in getSubmissionEntity() stamp only when getConfigurationProfileId() === null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. SubmissionManager::getSubmissionEntity() now only stamps the source blog's default profile when none is set yet; an explicit profile id always wins. Threaded that through TranslationHelper (prepareSubmission/prepareSubmissionEntity/tryPrepareRelatedContent/getExistingSubmissionOrCreateNew), and ReferencedContentProcessor::processFieldPreTranslation() now passes the parent submission's own profile id.

Checked sendAttachmentForTranslation() and SmartlingCoreDownloadTrait::downloadTranslation(): neither has any caller anywhere in the codebase currently (grepped inc/) - the only live path creating related-content submissions is ReferencedContentProcessor during upload serialization. Added the optional profile id param to sendAttachmentForTranslation() too for signature consistency, but didn't invent a call site for downloadTranslation() since nothing reaches it today.

(5847c8d, tests in SubmissionManagerTest/ReferencedContentProcessorTest)

{
$curBlogId = $this->wordpressProxy->get_current_blog_id();
$profile = $this->settingsManager->getSingleSettingsProfile($curBlogId);
$profile = $this->settingsManager->resolveRequestedProfile($request->getProfileId(), $curBlogId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] targetBlogIds aren't checked against the resolved profile's enabled target locales. A blog outside profile B (stale tab, crafted request, or a locale picked in the side widget, which now merges all profiles) is accepted. getSmartlingLocaleIdBySettingsProfile() then returns '', so the upload fails later, away from the request. Can we reject it here with a 400? The same applies to create-job (ContentEditJobController.php:161) and instant translation (InstantTranslationController.php:58).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added SettingsManager::assertTargetBlogIdsBelongToProfile(), called from createSubmissions() (covers both the bulk and non-bulk paths), ContentEditJobController's create-job case, InstantTranslationController::handleRequestTranslation(), and the legacy widget's ajaxUploadHandler(). All four now reject with a 400 before doing any work instead of silently producing an empty Smartling locale.

(5847c8d)

Comment thread js/app.js Outdated
if (!widget) {
return;
}
widget.outerHTML = response.html;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] widget.outerHTML = response.html destroys the handlers smartling-connector-admin.js bound at page load. #smartling-download is bound directly (:196), and the checkbox handler is delegated from the widget node itself (:54). After the first refresh, Download does nothing and checkbox value syncing stops until the page is reloaded. Options: replace only the inner content and re-init the handlers, or move those handlers to $(document).on(...) delegation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the live bug: the Download button's click handler in smartling-connector-admin.js is now delegated from document instead of bound directly to #smartling-download, so it survives the outerHTML swap.

Checked the checkbox-sync handler you mentioned (setCheckboxValue, bound in init()): init() only runs if ($(this.selectors.form).length > 0), and #smartling-form isn't rendered anywhere in the codebase (grepped inc/ and js/) - so that delegation was already dead code before this change, unrelated to the refresh. Left it alone rather than fixing an unrelated pre-existing gap in this PR.

(ab8892e)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: the delegated-binding fix here was itself only a patch on a feature that kept causing problems (this, the multi-post-type collision, the taxonomy postId mismatch, and a nonce/referer duplication bug you found in the next round). Removed the whole widget auto-refresh instead of continuing to patch it - the Download button binding is back to the original direct $(selector).on(...), since there's no more outerHTML replacement for it to survive.

(50715fc)

add_action('save_post', [$this, 'save']); // old logic 2 be refactored
add_action('wp_ajax_' . 'smartling_force_download_handler', [$this, 'ajaxDownloadHandler']);
add_action('wp_ajax_' . 'smartling_upload_handler', [$this, 'ajaxUploadHandler']);
add_action('wp_ajax_' . 'smartling_refresh_post_widget', [$this, 'ajaxRefreshWidgetHandler']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] CustomPostType::registerWidgetHandler() (CustomPostType.php:63) creates one controller per post type, and each registers this same wp_ajax_smartling_refresh_post_widget. The first registered controller handles every request and wp_send_json ends the request. Its preView() then filters submissions by its own servedContentType, so on a page or CPT the refreshed widget has no statuses. allReady never becomes true, so it polls all 5 times. Suggest registering the action once and using $post->post_type, or checking $post->post_type === $this->servedContentType and returning early so the matching controller can answer.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as you suggested: ajaxRefreshWidgetHandler() now returns early (without responding) when $post->post_type !== $this->servedContentType, letting the matching post type's instance answer instead.

(ab8892e)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: removed ajaxRefreshWidgetHandler() and the smartling_refresh_post_widget action entirely rather than continuing to patch the auto-refresh feature (see the reply on the nonce-duplication comment from the latest round for why). This collision no longer applies since the endpoint doesn't exist anymore.

(50715fc)

Comment thread js/app.js Outdated
throw new Error(submissionResponse.message?.global || 'Failed to add content to upload queue.');
}
setSuccess('Content successfully added to upload queue.');
if (!isBulkSubmitPage) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] !isBulkSubmitPage is also true on taxonomy term edit screens, which render the wizard (ContentEditJobController::box for WP_Term) and a #smartling-post-widget (taxonomy-based-content-type.php:61). The term id goes out as postId, so get_post(<termId>) either swaps in an unrelated post's widget or returns a 404 on every poll. Please limit polling to the post base type, for example by passing data-base-type to the wizard.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added data-base-type to the #smartling-app container (post/taxonomy, from the same $baseType already computed in ContentEditJob.php) and gated the refresh call on baseType === 'post'. Bulk submit is unaffected (already gated on isBulkSubmitPage, and is always post-type content).

(ab8892e)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: moot now - removed the widget auto-refresh feature entirely (the data-base-type gating along with it), so there's no more polling to misfire on taxonomy screens.

(50715fc)

try {
$this->service->createSubmissions(UserTranslationRequest::fromArray($data));
$this->returnResponse(['status' => BaseAjaxServiceAbstract::RESPONSE_SUCCESS]);
} catch (SmartlingDbException $e) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] SmartlingDbException is also thrown by DB, Queue, TaxonomyEntityStd, GravityFormsFormHandler, SmartlingCore and others, all reachable from createSubmissions(). Real DB or content errors will now show as "Invalid translation profile" and the original message is lost. It's also misleading when no profileId is sent and the blog has no active profile. Suggest a dedicated exception for profile resolution, or resolving the profile before this try block like the other two controllers do.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SettingsManager::resolveRequestedProfile() now throws SmartlingHumanReadableException instead of SmartlingDbException, and createSubmissionsHandler() catches that specifically (same pattern actionHandler() already used) before falling through to the generic Exception branch - so an unrelated SmartlingDbException now keeps its own message instead of being relabeled as an invalid profile.

(5847c8d, tests in ContentRelationsHandlerTest)

// that method, so it would otherwise keep whatever profile (or none) it had.
// Resubmitting an existing submission is an explicit new translation request, so restamp it with the
// profile it was requested with.
$submission->setConfigurationProfileId($profile->getId());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] (also applies to :110 and InstantTranslationController.php:323) Re-stamping an existing submission unconditionally means content that is IN_PROGRESS in project A and resubmitted under B loses its link to A's file and job. Status checks and download then go to B. Should we block or warn when the stored profile differs and the submission isn't completed or failed yet?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a warning (not a block, since this is a resubmission the user explicitly asked for) when an existing submission is still IN_PROGRESS under a different profile - logs the submission id and old/new profile ids before the restamp. Same in InstantTranslationController::getOrCreateSubmission(). Happy to make this hard-block instead if that's the preference.

(5847c8d)

Comment thread js/app.js
action: 'smartling_job_api_proxy',
_wpnonce: nonce,
innerAction: 'list-jobs',
params: { profileId: selectedProfileId }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] On a profile switch the old job list stays selectable until the new one arrives (and stays forever if the request fails). selectedJob is cleared, but the user can still pick one of A's jobs and submit with profileId=B, which fails at createBatch. Suggest setJobs([]); setLoading(true); setError(''); at the start of the effect. Also, setError('Failed to load jobs') (:111) is never cleared after a later successful load.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied as suggested: setJobs([]); setLoading(true); setError(''); at the start of the effect.

(ab8892e)

Comment thread js/app.js
const [activeTab, setActiveTab] = useState('new');
const [selectedProfileId, setSelectedProfileId] = useState(() => {
const stored = getStoredProfileId(blogId);
return (stored !== null && profiles.some(p => p.id === stored)) ? stored : profiles[0]?.id;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] The ticket says "One profile can be marked default for a blog; the user can override it." Here the default is profiles[0] (DB id order) overridden by per-browser localStorage. The server fallback is also "first active by id", as are cron (JobAbstract::getActiveProfile()), test run and the legacy widget upload. Is an admin-configurable default planned as a follow-up ticket?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a deliberate scope decision made with the ticket owner before implementation, not an oversight: an admin-configurable per-blog default was discussed and intentionally dropped in favor of 'first active by id' plus a per-browser localStorage override, to avoid data-model and profile-management-UI changes for what's expected to be an edge case (most blogs only ever have one active profile). An admin-configurable default would be a reasonable follow-up ticket if it turns out to matter in practice.

*/
$locales = $data['profile']->getTargetLocales();
$locales = [];
foreach ($data['profiles'] ?? [$data['profile']] as $profile) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] The side widget now lists locales from all profiles (first one wins per blog), but its legacy upload path ajaxUploadHandler() still uses ArrayHelper::first($this->getProfiles()) (PostBasedWidgetControllerStd.php:220). Choosing a blog that only belongs to profile 2 uploads through profile 1 with an empty Smartling locale. The "settings" link in the empty state (:150) also points only at the first profile.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed both. ajaxUploadHandler() now searches the blog's active profiles for the one whose enabled target locales actually cover every selected target blog (rejecting with a clear error if none does), instead of always using the first active one. The empty-state link now points at the profile list page instead of one specific profile's edit screen.

(5847c8d)

vsolovei-smartling and others added 2 commits October 8, 2026 19:13
…-1022)

Several bugs surfaced once more than one profile can be active for a
blog:

- Related content created while processing a submission (referenced
  posts/terms, images) always got the source blog's default active
  profile via SubmissionManager::getSubmissionEntity(), even when the
  parent submission had been explicitly stamped with a different,
  user-requested one - silently sending it to the wrong Smartling
  project. getSubmissionEntity() now only falls back to the default
  when nothing is stamped yet, and an explicit profile id (the
  parent's) always wins. Threaded through
  TranslationHelper/ReferencedContentProcessor/SmartlingCoreExportApi
  and the legacy per-post widget's upload handler, which also now
  picks the profile that actually covers the selected target blogs
  instead of always using the first active one.
- Target blog ids were never checked against the resolved profile's
  enabled locales in createSubmissions(), create-job, instant
  translation, or the legacy widget - a blog outside the profile
  produced an empty Smartling locale and failed later, away from the
  request. SettingsManager::assertTargetBlogIdsBelongToProfile()
  rejects this up front with a 400.
- SettingsManager::resolveRequestedProfile() now throws
  SmartlingHumanReadableException instead of the broader
  SmartlingDbException, so ContentRelationsHandler can map profile
  resolution failures to a clean 400 without mislabeling unrelated DB
  errors as invalid profile errors.
- Resubmitting a submission that is still IN_PROGRESS under a
  different profile now logs a warning, since it loses its link to
  the previous profile's file/job.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Swapping in the refreshed widget via outerHTML replaced the DOM node
  smartling-connector-admin.js had bound its Download click handler
  to, silently breaking the button after the first refresh. Delegate
  that binding from document instead, so it survives the node being
  replaced.
- The per-post-type widget controller that registers
  smartling_refresh_post_widget is instantiated once per post type, so
  every instance was handling every request and rendering its own
  content type's (usually empty) submissions for any other type's
  post. Each instance now returns early when the requested post isn't
  its own served content type, letting the matching one respond.
- The job wizard also renders on taxonomy term edit screens, where
  !isBulkSubmitPage is true but there is no post behind the term id -
  the refresh call was sending a term id as postId. Gate it on a new
  data-base-type attribute so it only fires for actual posts.
- On a profile switch, the previous profile's jobs stayed selectable
  (and a prior load error stayed visible) until the new list arrived,
  or forever on failure. Clear both up front instead of waiting for
  the response.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@vsolovei-smartling

Copy link
Copy Markdown
Contributor Author

Addressed all four blocking issues and the three "should fix" items (replies inline on each comment; commits 5847c8d + ab8892e).

Summary:

  • Related content created while processing a submission now inherits the parent submission's own profile instead of always falling back to the blog's default active profile.
  • Target blog ids are now validated against the resolved profile's enabled locales in createSubmissions(), create-job, instant translation, and the legacy widget's upload handler - all reject with a 400 instead of silently producing an empty Smartling locale.
  • Widget refresh: the Download button handler is now delegated from document so it survives being replaced, ajaxRefreshWidgetHandler() now defers to the controller instance whose post type actually matches, and the refresh call is gated to post base type so it doesn't misfire with a term id on taxonomy screens.
  • ContentRelationsHandler now maps profile-resolution failures (SmartlingHumanReadableException) to their own key/message/code instead of mislabeling any SmartlingDbException as an invalid profile.
  • Resubmitting a still-IN_PROGRESS submission under a different profile now logs a warning instead of silently losing its link to the previous project.
  • A profile switch now clears the stale job list and any previous load error immediately, instead of leaving the old list selectable until (or unless) the new one arrives.
  • The legacy widget's upload path now resolves the profile that actually covers the selected target blogs, and its empty-state settings link points at the profile list rather than one arbitrary profile.

Unit suite: 751/751 passing.

Tests added: SettingsManager::resolveRequestedProfile/assertTargetBlogIdsBelongToProfile, SubmissionManager's stamp-only-if-unset behavior, ReferencedContentProcessor's profile propagation, ContentRelationsHandler's exception mapping.

Not covered by new tests: the ajaxRefreshWidgetHandler() post-type-mismatch fix and the create-job 400 path - both are AJAX handlers that are hard to unit test in isolation here (no WP_Post stub in this suite, and ContentEditJobController's handler is an inline closure); the shared SettingsManager logic they call is covered directly. Didn't add Playwright coverage for the dropdown/reset-on-switch/widget-refresh flows in this pass either - open to doing that here or as a fast-follow.

$locales = [];
foreach ($data['profiles'] ?? [$data['profile']] as $profile) {
foreach ($profile->getTargetLocales() as $locale) {
$locales[$locale->getBlogId()] ??= $locale;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] ??= keeps the first profile's entry for each blog, even when that entry is disabled. Every profile stores a TargetLocale for every network blog, with an enabled flag (ConfigurationProfileFormController.php:239-256). So profile A's disabled entry for blog 3 wins over profile B's enabled one, and the isEnabled() filter below then drops it. In practice the side widget still shows only the first profile's enabled locales, so the multi-profile merge does nothing. Suggest merging only enabled locales:

foreach ($profile->getTargetLocales() as $locale) {
    if ($locale->isEnabled()) {
        $locales[$locale->getBlogId()] ??= $locale;
    }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed - merge now only considers enabled locales:

if ($locale->isEnabled()) {
    $locales[$locale->getBlogId()] ??= $locale;
}

(50715fc)

}

ob_start();
$this->preView($post);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] preView() calls wp_nonce_field(self::WIDGET_NAME, self::CONNECTOR_NONCE) (:535) before the view prints #smartling-post-widget. That makes response.html the nonce input, plus a _wp_http_referer input, plus the widget. Because app.js:50 replaces only the widget's outerHTML, each poll (up to 5) adds another #smartling_connector_nonce and another _wp_http_referer input to the post edit form. The _wp_http_referer value is /wp-admin/admin-ajax.php, and because it comes later in the DOM it overrides the form's own referer on submit. Suggest rendering only the view here (pull the $this->view([...]) part out of preView()), or parsing the response in JS and swapping in only its #smartling-post-widget node.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, and it's a symptom of a bigger problem - the whole auto-refresh approach was fragile in ways that kept surfacing new issues (this one, the multi-post-type collision, the taxonomy postId mismatch). Rather than patch this one too, removed the refresh feature entirely: ajaxRefreshWidgetHandler(), the smartling_refresh_post_widget AJAX action, the frontend poller, and the plumbing that only existed to support it. The widget now only re-renders on normal page load, same as before this PR touched it.

(50715fc)

if ($continue) {
$targetBlogIds = array_map('intval', $data['blogs']);
$matchedProfile = null;
foreach ($this->getProfiles() as $candidateProfile) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Choosing "the first active profile that covers all selected blogs" ignores which project $data['job']['id'] belongs to. If both A and B cover the selected blogs and the job is B's, this picks A. The submissions then get A's project id with B's job uid, and createBatch fails later. Also, nothing in js/ calls smartling_upload_handler any more, so this guessing code only serves external callers. Suggest accepting an explicit profileId and reusing resolveRequestedProfile() + assertTargetBlogIdsBelongToProfile() like the other three entry points do, instead of inferring it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as suggested. ajaxUploadHandler() now resolves the profile via resolveRequestedProfile() (accepting an explicit profileId from $data, falling back to the blog's single active profile when none is sent) and validates target blogs with assertTargetBlogIdsBelongToProfile(), same as the other three entry points - no more inferring from which blogs happen to be covered.

(50715fc)

Comment thread js/app.js
};
}, [adminUrl, nonce, selectedProfileId]);

const handleProfileChange = (val) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] This is still open from the previous round ("re-fires all relation requests"). loadRelations depends on locales (:163), so a profile switch re-runs the depth-1 effect, and at depth 2 it also re-fetches every l1Relations entry. l1Relations/l2Relations are appended, never reset, so relations found for the old profile's target blogs stay listed and selectable, and totalRequests keeps growing, which skews the progress bar. Suggest resetting l1Relations, l2Relations, selectedRelations, pendingRequests and totalRequests here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. handleProfileChange now also resets l1Relations, l2Relations, selectedRelations, pendingRequests and totalRequests, so the depth effect's re-fetch (triggered by loadRelations's identity changing with the new locales) starts clean instead of appending to the old profile's entries.

(50715fc)

…elation staleness (WP-1022)

The post-upload widget auto-refresh (smartling_refresh_post_widget,
refreshDownloadWidgetUntilReady) caused more problems than it solved -
duplicate nonce/referer inputs accumulating in the post edit form on
each poll (overriding the real referer on submit), a handler-binding
footgun from swapping the widget via outerHTML, and the multi-post-type
AJAX registration collision. Removed it entirely rather than patching
further: the ajaxRefreshWidgetHandler endpoint, its frontend poller,
the data-base-type plumbing that only existed to gate it, and the
Download button's delegated-from-document binding that only existed to
survive the outerHTML swap.

Also, from the latest review round:
- post-based-content-type.php's multi-profile locale merge used `??=`
  before checking isEnabled(), so a disabled entry from the first
  profile could shadow an enabled one from a later profile. Now only
  enabled locales are merged.
- The legacy widget's ajaxUploadHandler() inferred the profile by
  searching for one that happens to cover the selected blogs, which
  ignores which project the job actually belongs to. It now resolves
  via resolveRequestedProfile() (accepting an explicit profileId) and
  validates target blogs with assertTargetBlogIdsBelongToProfile(),
  consistent with the other three entry points.
- Switching profiles in the job wizard left relations fetched for the
  old profile's target locales in place, so the new fetch appended
  instead of replacing and the progress bar's totalRequests kept
  growing. handleProfileChange now resets l1Relations, l2Relations,
  selectedRelations, pendingRequests and totalRequests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@vsolovei-smartling

Copy link
Copy Markdown
Contributor Author

Addressed the latest review round (commit 50715fc, replies inline):

  • Removed the widget auto-refresh feature entirely rather than continuing to patch it - it kept surfacing new problems (the outerHTML handler-binding bug, the multi-post-type AJAX collision, the taxonomy postId mismatch, and now a duplicate nonce/referer-input bug from polling). ajaxRefreshWidgetHandler(), the smartling_refresh_post_widget action, the frontend poller, and all the plumbing that only existed to support it are gone. The side widget now only renders on normal page load, same as before this PR touched it. Posted follow-ups on the three earlier threads whose fixes this supersedes.
  • Fixed the multi-profile locale merge in post-based-content-type.php to only consider enabled locales (a disabled entry from one profile was shadowing an enabled one from another).
  • ajaxUploadHandler()'s legacy profile inference now resolves via resolveRequestedProfile()/assertTargetBlogIdsBelongToProfile() like the other three entry points, instead of guessing from which blogs happen to be covered.
  • handleProfileChange now resets relation-tracking state (l1Relations, l2Relations, selectedRelations, pendingRequests, totalRequests) so switching profiles doesn't append to stale relations from the previous one.

Unit suite: 751/751 passing.

@vsolovei-smartling
vsolovei-smartling merged commit b9acab6 into master Oct 10, 2026
3 checks passed
@vsolovei-smartling
vsolovei-smartling deleted the WP-1022-choose-profile-on-request branch October 10, 2026 09:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants