feat: add post from translation (PoC → refined)#617
feat: add post from translation (PoC → refined)#617apermo wants to merge 35 commits intolloc:masterfrom
Conversation
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.
There was a problem hiding this comment.
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().
| 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> |
There was a problem hiding this comment.
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; ?>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).
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.
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
edit.phpPOST /msls/v1/create-translation→ new linked draft + redirect to editorGET /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 onGET /msls/v1/untranslated-posts.post_statusis already in the response; render a colored pill (publish / draft / pending / future / private) with localized labels.edit_urlto the list response (viaswitch_to_blog()+get_edit_post_link( $id, 'raw' )— same pattern ascreate_translation), renders a dashicons-external link.stopPropagationso it does not trigger selection.<input type="search">in the modal header, wired to the existing?search=param. Term is preserved when switching source blog.msls_user_prefs(array, namespaced for future per-user prefs), storinglast_source_blog. Restored on next open. No settings UI.<select>. One-click source switching. Uses the existingflag-icon-XXCSS classes fromassets/css-flags/(MslsPlugin.php:125) — no new assets. Active blog marked viaaria-current="true"and a visual ring. Each button has anaria-labelwith the blog description / language name. Falls back to a text label when a blog has no resolvable flag.aria-labels on the status badge and external-link icon.Decisions taken
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.msls_user_prefsarray user-meta key, mirroring the site-levelmslsoptions blob pattern. Avoidsmsls_*key proliferation as we add more per-user state later.UI mock — refined modal
DE / EN / ES / FRare theflag-iconrendered flags (labels shown here as text for ASCII legibility).●= status dot.[↗]= dashicons-external,target="_blank".Empty state
Loading state
Out of scope / follow-ups
Tracked for later; explicitly not part of this PR:
MslsAdminAdvanced Settings, used as fallback when a user has no recordedmsls_user_prefs.last_source_blogyet. Has its own UX questions (hide if only two blogs, interaction withsort_by_description, fallback ordering) and deserves its own review.@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 onedit.php(classic admin, not the block editor) needs its own design pass. Out of scope here so Thickbox investment stays minimal.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) andnpm run buildare both clean.Test plan
aria-currentpublish/draft/pending/future/private