Skip to content

feat: add post from translation (PoC → refined)#617

Draft
apermo wants to merge 35 commits intolloc:masterfrom
apermo:poc/add-post-from-translation
Draft

feat: add post from translation (PoC → refined)#617
apermo wants to merge 35 commits intolloc:masterfrom
apermo:poc/add-post-from-translation

Conversation

@apermo
Copy link
Copy Markdown
Contributor

@apermo apermo commented Apr 21, 2026

Context

Refines the Add-from-Translation PoC (on apermo:poc/add-post-from-translation) from rough working prototype to production-ready polish. Opening as draft while the commit series lands.

What the PoC does today

  • Adds an Add from Translation button next to Add New on edit.php
  • Thickbox modal with a source-blog dropdown and a list of untranslated posts
  • On select → POST /msls/v1/create-translation → new linked draft + redirect to editor
  • New REST route GET /msls/v1/untranslated-posts (already supports ?search=)

Scope of this PR

Six small, independently-commitable changes — all inside the existing picker modal. No new REST routes: the search refinement reuses the ?search= param on GET /msls/v1/untranslated-posts.

  • Task 1 — Publish-state badge per row. post_status is already in the response; render a colored pill (publish / draft / pending / future / private) with localized labels.
  • Task 2 — "Open in new tab" icon per row. Adds edit_url to the list response (via switch_to_blog() + get_edit_post_link( $id, 'raw' ) — same pattern as create_translation), renders a dashicons-external link. stopPropagation so it does not trigger selection.
  • Task 3 — Search input. Debounced (~250ms) <input type="search"> in the modal header, wired to the existing ?search= param. Term is preserved when switching source blog.
  • Task 4 — Remembered source blog. Per-user meta msls_user_prefs (array, namespaced for future per-user prefs), storing last_source_blog. Restored on next open. No settings UI.
  • Task 5 — Flag strip replaces source-blog <select>. One-click source switching. Uses the existing flag-icon-XX CSS classes from assets/css-flags/ (MslsPlugin.php:125) — no new assets. Active blog marked via aria-current="true" and a visual ring. Each button has an aria-label with the blog description / language name. Falls back to a text label when a blog has no resolvable flag.
  • Task 6 — Polish. Loading skeleton, empty state, error state + retry, Enter-to-confirm, focus trap, aria-labels on the status badge and external-link icon.

Decisions taken

  • Reuse GET /msls/v1/untranslated-posts?search= rather than adding a dedicated autocomplete route. Same permission model; the rich row data (title, date, status) is what the modal renders anyway.
  • Per-user prefs nested under a single msls_user_prefs array user-meta key, mirroring the site-level msls options blob pattern. Avoids msls_* key proliferation as we add more per-user state later.
  • Source switch is one-click (flag strip), not a dropdown. Dropdown requires open → pick → close; a flag strip is a single click on a visible affordance, and flags are the plugin's natural language vocabulary.

UI mock — refined modal

┌─ Add post from translation ─────────────────────────────────── [ × ]─┐
│                                                                      │
│  Source:   DE    EN    ES    FR            [🔍 Search title…     ]  │
│            ══                                                        │
│           active                                                     │
│  ──────────────────────────────────────────────────────────────────  │
│                                                                      │
│  ┌──────────────────────────────────────────────────────────────┐   │
│  │ Hello World — a first post                        ● Publish  │   │
│  │ 2026-03-14                                              [↗]  │   │
│  ├──────────────────────────────────────────────────────────────┤   │
│  │ Draft on the new routing layer                    ● Draft    │   │
│  │ 2026-04-02                                              [↗]  │   │
│  ├──────────────────────────────────────────────────────────────┤   │
│  │ Scheduled launch announcement                     ● Future   │   │
│  │ 2026-05-01                                              [↗]  │   │
│  └──────────────────────────────────────────────────────────────┘   │
│                                                                      │
│                                        [ Cancel ]  [ Create draft ]  │
└──────────────────────────────────────────────────────────────────────┘

DE / EN / ES / FR are the flag-icon rendered flags (labels shown here as text for ASCII legibility). = status dot. [↗] = dashicons-external, target="_blank".

Empty state

│                  No untranslated posts in this blog.                 │
│                  Try another source blog or clear search.            │

Loading state

│  ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░                            │
│  ░░░░░░░░░░                                                          │
│  ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░                                      │
│  ░░░░░░░░░░                                                          │

Out of scope / follow-ups

Tracked for later; explicitly not part of this PR:

  • Site-level default source blog — new field in MslsAdmin Advanced Settings, used as fallback when a user has no recorded msls_user_prefs.last_source_blog yet. Has its own UX questions (hide if only two blogs, interaction with sort_by_description, fallback ordering) and deserves its own review.
  • Modal migration from Thickbox to @wordpress/components <Modal> — React-based, built-in a11y (focus trap, aria-*), modern WP admin look. wp-scripts + React infrastructure is already present (src/msls-widget-block/), but mounting on edit.php (classic admin, not the block editor) needs its own design pass. Out of scope here so Thickbox investment stays minimal.
  • Overflow handling for the flag strip on sites with many blogs (horizontal scroll vs. secondary "more" popover) — not needed for the typical 2–5 blog setup; revisit if real installs exceed that.

Verification

Tested inside a Bedrock-based WP multisite (PHP 8.3, WP 6.7). Golden-path + edge cases for each task above; composer test (phpcs) and npm run build are both clean.

Test plan

  • Flag strip renders one flag per other blog; current blog excluded
  • Clicking a flag switches source in one click and reloads the list
  • Active flag visibly marked (ring) and announced via aria-current
  • Status badges render for publish / draft / pending / future / private
  • External-link icon opens editor in new tab; click does not select the row
  • Search filters list; term preserved across blog switch; clearing restores list
  • Last-picked blog is remembered across modal reopens (per user)
  • Empty state shown when source blog has no untranslated posts
  • Error state with retry shown on REST failure
  • Keyboard: Esc closes, Enter confirms, Arrow keys cycle flags, focus trap inside modal
  • Screen reader announces status + external-link + flag affordances

apermo added 17 commits April 20, 2026 17:45
Update render_select and render_input tests to expect
the new create/edit action element and the additional
WordPress function mocks.
Add an action button next to each language row in the
editor metabox. Shows dashicons-plus when no translation
is linked (Quick Create button or classic post-new.php
link) and dashicons-external when linked (edit link
opening in a new tab). Only visible for saved posts.

Closes lloc#606
Hide create button and show edit link when a post is
selected via autocomplete. Reverse when the field
is cleared.
After Quick Create succeeds in the metabox, use
dashicons-external instead of dashicons-edit and
open the link in a new tab.
Add styles for create-new and edit-link buttons in
the metabox. Move Quick Create button reset and
loading animation into the LESS source.
When get_edit_post_link returns null (post does not
exist on the target blog or stale link), show the
create button instead of an empty-href edit link.
Add the created post's title to the REST response so
the metabox input/select can display it immediately.
Set the autocomplete text input and select option
text to the new post's title after a successful
Quick Create call.
The plugin checker requires the translators comment
on the line directly above the __() call.
Introduces msls_quick_create_capability filter so integrations can
override the default read_post/edit_posts check. Receives the default
result plus source/target IDs and a 'read'|'create' context so a single
handler can cover both sites of the check.

Default behavior is preserved: without a filter, the existing
read_post + edit_posts checks still gate the endpoint.

Also exposes user_can_read_source() / user_can_create_target() helpers
to be reused by the forthcoming untranslated-posts listing endpoint.
Adds two cases: filter can upgrade a failing read_post check to allowed
(the POC use case for translators without a source-blog account), and
filter can downgrade a passing default check to denied.
Adds GET /msls/v1/untranslated-posts which returns source-blog posts of
the given type that do not yet have a translation in the target blog.
Backs the upcoming 'Add Post from Translation' modal on edit.php.

Reuses TranslatedPostIdQuery to skip posts already linked to the target
language. Capability gated via the same msls_quick_create_capability
filter (context 'read' on source, 'create' on target), with source_post_id
0 since no single post is being targeted. Default source read cap is the
generic 'read' so filter authors can broaden or tighten per use case.

Response is filterable via msls_untranslated_posts for integrations that
want to add custom fields (e.g., preview URL, featured image).
Adds coverage for the new /untranslated-posts endpoint: permission
callback rejects without source 'read' cap (and filter can override it),
listing returns items with id/title/status/date, and unknown post_type
on the source blog produces a WP_Error.
Bootstraps MslsPostListActions on load-edit.php, enqueueing a picker
script and printing a Thickbox modal template in the footer. The modal
offers a source-blog select populated from blogs where MSLS is active;
the post list and the draft-creation call are handled by the script.

Only mounts when MSLS is not excluded for the current blog and a valid
post_type is in the request; silently no-ops when there are no other
active MSLS blogs.
Bootstraps the 'Add from Translation' page-title-action button on
edit.php by appending it next to core's 'Add New'. Reads config from the
localized mslsTranslationPicker object, opens a Thickbox modal with the
source-blog select, fetches untranslated posts from
/msls/v1/untranslated-posts on blog change, and calls
/msls/v1/create-translation on click before redirecting to the returned
edit_url.

Keyboard: list items are role=button with Enter/Space handling so the
modal is operable without a mouse. Empty state and error state are
surfaced through aria-live status text.
TestMslsOptionsTax registers a Mockery stub of WP_Error that sticks in
PHP's class table and lacks get_error_code(). Test-order dependent, so
keep the assertion restricted to the type check.
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces an 'Add from Translation' feature for post list screens, allowing users to create translations from other blogs via a modal and a new REST API endpoint. It also enhances the metabox UI with direct create and edit links. Feedback was provided to optimize the render_modal method in MslsPostListActions.php by avoiding redundant calls to get_source_blogs().

Comment thread includes/MslsPostListActions.php Outdated
Comment on lines +120 to +135
if ( empty( $this->get_source_blogs() ) ) {
return;
}
?>
<div id="<?php echo esc_attr( self::INLINE_ID ); ?>" style="display:none;">
<div class="msls-tp">
<p class="msls-tp-blog-row">
<label for="msls-tp-blog"><?php esc_html_e( 'Source blog', 'multisite-language-switcher' ); ?></label>
<select id="msls-tp-blog">
<option value=""><?php esc_html_e( 'Select a source blog…', 'multisite-language-switcher' ); ?></option>
<?php foreach ( $this->get_source_blogs() as $blog ) : ?>
<option value="<?php echo esc_attr( (string) $blog['blog_id'] ); ?>">
<?php echo esc_html( $blog['description'] ); ?>
</option>
<?php endforeach; ?>
</select>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The get_source_blogs() method is called twice within render_modal(), and it was already called once during enqueue(). Since this method iterates over the entire blog collection and performs plugin activity checks, it is inefficient to call it repeatedly in the same request. Consider storing the result in a local variable within render_modal() or, better yet, caching it in a private property of the class.

public function render_modal(): void {
		$blogs = $this->get_source_blogs();
		if ( empty( $blogs ) ) {
			return;
		}
		?>
		<div id="<?php echo esc_attr( self::INLINE_ID ); ?>" style="display:none;">
			<div class="msls-tp">
				<p class="msls-tp-blog-row">
					<label for="msls-tp-blog"><?php esc_html_e( 'Source blog', 'multisite-language-switcher' ); ?></label>
					<select id="msls-tp-blog">
						<option value=""><?php esc_html_e( 'Select a source blog…', 'multisite-language-switcher' ); ?></option>
						<?php foreach ( $blogs as $blog ) : ?>
							<option value="<?php echo esc_attr( (string) $blog['blog_id'] ); ?>">
								<?php echo esc_html( $blog['description'] ); ?>
							</option>
						<?php endforeach; ?>

apermo added 12 commits April 21, 2026 15:08
Adds each item's permalink so the picker can show a 'view original'
affordance per row. Useful before creating a draft to sanity-check the
source is the right one — especially when the user's capability check
has been relaxed via msls_quick_create_capability.
Hooks remember_source_blog to msls_quick_create_after_insert so the blog
id a user just created from is stored in user meta. Reads back via
get_last_source_blog_id(), which the picker uses to pre-select the most
recent source next time the modal opens.

Only writes on successful create (not on modal open/cancel) and only for
logged-in users.
Localization payload now includes target blog description/language and
each source blog's alpha2 code, plus the last source blog id the user
picked (from get_last_source_blog_id). Strings for search, status
labels, banner text and 'view original' are exposed for JS.

Modal template gains a target-context banner, a language prefix on each
dropdown option (flags don't render reliably inside native <option>),
and a hidden search input that JS reveals after a source blog is
chosen.
- Target banner is rendered server-side; JS only manages the dynamic
  chunks.
- Source-blog select shows '[de] Description' labels; native <option>
  cannot render flag icons reliably cross-browser.
- Search input is hidden until a source blog is picked; 300ms debounce
  drives a fresh GET /untranslated-posts request with ?search=. Request
  tokens drop stale responses when the user types quickly.
- Loading shows 3 skeleton rows instead of a plain text message.
- List rows now carry: title, language chip, translatable status badge,
  date, external 'view original' link and a primary 'Add from Translation'
  button (replaces click-on-row for clearer affordance + button-level
  loading state).
- Auto-select: if the user has a remembered last source blog that still
  exists, pre-pick it on open and fetch the list; otherwise pre-pick when
  there's only one source blog available.
Adds styles for the target-context banner, search input, status badges
(publish/draft/pending/future), language chip, per-row view link, and a
3-line skeleton loader with a gradient pulse animation. Badges and chips
use MSLS-neutral tokens to match the admin palette.
Registers a hidden admin page at admin.php?page=msls-translation-picker
&post_type=... which replaces the Thickbox modal. The page renders a
target-context banner, a GET filter form (source blog + search), and a
WP_List_Table rendering untranslated source-blog posts server-side with
paging.

Per-row 'Create draft' button and a 'Create drafts for selected' bulk
action are exposed; JS handles the actual REST calls so the page stays
responsive during creation.
Replaces the modal-plumbing class with a tiny button-injector. The
button is now a plain page-title-action link pointing to
MslsTranslationPickerPage. Removes the inline Thickbox template, the
large localized payload, and the modal-specific strings — all of which
moved to the dedicated page.

Guards with current_user_can('edit_posts') so users without create
rights do not see the button (the endpoint still 403s independently).
Drops all modal/Thickbox plumbing (button injection, blog select change,
skeletons, auto-select). The page renders the list server-side, so the
script is scoped to two concerns:

1. Per-row 'Create draft' button — POSTs to create-translation, flips
   the button into a loading state, and redirects to edit_url on success.
2. Bulk 'Create drafts for selected' — intercepts the submit, iterates
   the selected ids sequentially with an inline progress notice, and
   fades out successfully-created rows at the end. Per-row buttons still
   flip to loading while their request is in flight.
Drops modal-specific styles (list container, skeleton, item-primary
stack layout, Thickbox-sized max-heights). Keeps the reusable bits
(status badges, language chip) global, scopes the rest under .msls-tp-page
so the admin page gets banner, filter form and WP_List_Table tweaks
(column widths, per-row view link, create-button loading spinner).
apermo added 6 commits April 21, 2026 15:58
The source-blog select required an extra 'Apply' click after each
change. Replaces it with a row of clickable flag buttons — one per
MSLS-active source blog — using MslsAdminIcon's existing flag-icon
rendering. Clicking a flag navigates to the page with msls_source set,
preserving the current search query.

The active blog is visually highlighted (filled primary style, ARIA
aria-selected=true) so the current source is obvious at a glance. The
search form still posts with a hidden msls_source input so entering a
query keeps the selected source.
Registers 'Add from Translation' under each MSLS-supported post type's
admin menu (edit.php / edit.php?post_type=X) instead of a single hidden
slug. The sidebar now stays expanded on 'Posts' (or 'Pages', etc.) with
the entry listed right below 'All Posts'.

Page slugs are scoped per post type (msls-translation-picker-<type>)
because WordPress enforces globally unique submenu slugs. The render
callback derives the post type from the slug so a single handler serves
all entries.

Also adds a '← Back to all posts' link above the page title linking
back to the post type's edit.php, so users can leave the picker without
relying on the sidebar.
Passes position=6 to add_submenu_page so the entry sits between 'All
Posts' (5) and 'Add New' (10) in the Posts/Pages/CPT submenu instead of
being appended at the bottom.

Adds a CSS rule targeting our specific menu link to bump its left
padding, which visually marks it as a sub-action of 'All Posts' without
changing WordPress's native two-level menu structure.
add_submenu_page position=6 didn't land in the right slot (collision
with another plugin, or post-insert reshuffling). Replaces it with an
explicit late-priority admin_menu reorder that walks the parent's
submenu array, pulls our entry out, and re-inserts it directly after
the parent's first item (the 'All …' entry core always registers
first).
If the current blog has just one MSLS-active sibling, the 'pick a
source' step adds no value — the list is already unambiguous. Treats
that single source as selected when the URL has no msls_source set, so
the user lands directly on the list of untranslated posts.

The source flag for that blog still renders in the active state, so the
UI truthfully reflects which blog is driving the list.
Matches core edit.php's column set: shows Author and every taxonomy
registered on the source blog with show_admin_column=true (Categories
and Tags for posts, plus any CPT taxonomies that opt in). Terms are
fetched inside switch_to_blog so they reflect the source post's
categorization, not an accidental lookup on the target blog.

Taxonomy list is cached per request; get_columns and prepare_items
share the same underlying WP_Taxonomy objects so header labels and
rendered data can never drift.
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.

1 participant