From 40dc0819c5242dfcd339936d4017ee9025bf2679 Mon Sep 17 00:00:00 2001 From: Christoph Daum Date: Tue, 14 Apr 2026 21:06:50 +0200 Subject: [PATCH 1/6] test(metabox): update tests for create new button Update render_select and render_input tests to expect the new "create new translation" link and the required additional WordPress function mocks. --- tests/phpunit/TestMslsMetaBox.php | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/TestMslsMetaBox.php b/tests/phpunit/TestMslsMetaBox.php index 78397c089..f9c189d93 100644 --- a/tests/phpunit/TestMslsMetaBox.php +++ b/tests/phpunit/TestMslsMetaBox.php @@ -160,17 +160,19 @@ public function test_render_select_not_hierarchical(): void { Functions\expect( 'get_post_type' )->once()->andReturn( 'page' ); Functions\expect( 'get_option' )->once()->andReturn( array() ); + Functions\expect( 'get_blog_option' )->once()->andReturn( '' ); + Functions\expect( 'get_post_status' )->once()->andReturn( 'draft' ); Functions\expect( 'wp_nonce_field' )->once()->andReturn( 'nonce_field' ); Functions\expect( 'switch_to_blog' )->once(); Functions\expect( 'restore_current_blog' )->once(); - Functions\expect( 'add_query_arg' )->once()->andReturn( 'query_args' ); + Functions\expect( 'add_query_arg' )->times( 3 )->andReturn( 'query_args' ); Functions\expect( 'get_post_type_object' )->once()->andReturn( $wp_post_type ); Functions\expect( 'get_post_stati' )->once()->andReturn( array( 'draft', 'public', 'private' ) ); Functions\expect( 'get_posts' )->once()->andReturn( array() ); - Functions\expect( 'get_current_blog_id' )->once()->andReturn( 1 ); - Functions\expect( 'get_admin_url' )->once()->andReturn( 'admin-url-empty' ); + Functions\expect( 'get_current_blog_id' )->times( 3 )->andReturn( 1 ); + Functions\expect( 'get_admin_url' )->twice()->andReturn( 'admin-url-empty' ); - $expected = ''; + $expected = ''; $this->expectOutputString( $expected ); $this->MslsMetaBoxFactory()->render_select(); @@ -192,6 +194,8 @@ public function test_render_select_hierarchical(): void { Functions\expect( 'get_post_type' )->once()->andReturn( 'page' ); Functions\expect( 'get_option' )->once()->andReturn( array( 'de_DE' => 42 ) ); + Functions\expect( 'get_blog_option' )->once()->andReturn( '' ); + Functions\expect( 'get_post_status' )->once()->andReturn( 'draft' ); Functions\expect( 'wp_nonce_field' )->once()->andReturn( 'nonce_field' ); Functions\expect( 'switch_to_blog' )->once(); Functions\expect( 'restore_current_blog' )->once(); @@ -199,6 +203,7 @@ public function test_render_select_hierarchical(): void { Functions\expect( 'get_post_type_object' )->once()->andReturn( $wp_post_type ); Functions\expect( 'wp_dropdown_pages' )->once()->andReturn( '' ); Functions\expect( 'get_edit_post_link' )->once()->andReturn( 'edit-post-link' ); + Functions\expect( 'get_current_blog_id' )->once()->andReturn( 1 ); $expected = ''; $this->expectOutputString( $expected ); @@ -208,15 +213,15 @@ public function test_render_select_hierarchical(): void { public static function render_input_provider(): array { return array( - array( array( 'de_DE' => 42 ), 1, 0, 0, 1, '' ), - array( array( 'en_US' => 17 ), 0, 1, 1, 0, '' ), + array( array( 'de_DE' => 42 ), 1, 2, 1, 1, 1, '' ), + array( array( 'en_US' => 17 ), 0, 3, 2, 0, 2, '' ), ); } /** * @dataProvider render_input_provider */ - public function test_render_input( $option, $the_title_times, $current_blog_id_times, $admin_url_times, $edit_post_link_times, $expected ) { + public function test_render_input( $option, $the_title_times, $current_blog_id_times, $admin_url_times, $edit_post_link_times, $add_query_arg_times, $expected ) { global $post; $post = \Mockery::mock( 'WP_Post' ); @@ -232,11 +237,14 @@ public function test_render_input( $option, $the_title_times, $current_blog_id_t Functions\expect( 'restore_current_blog' )->once(); Functions\expect( 'get_post_type' )->once()->andReturn( 'page' ); Functions\expect( 'get_option' )->once()->andReturn( $option ); + Functions\expect( 'get_blog_option' )->once()->andReturn( '' ); + Functions\expect( 'get_post_status' )->once()->andReturn( 'draft' ); Functions\expect( 'wp_nonce_field' )->once()->andReturn( 'nonce_field' ); Functions\expect( 'get_the_title' )->times( $the_title_times )->andReturn( 'Test' ); Functions\expect( 'get_current_blog_id' )->times( $current_blog_id_times )->andReturn( 1 ); Functions\expect( 'get_admin_url' )->times( $admin_url_times )->andReturn( 'admin-url-empty' ); Functions\expect( 'get_edit_post_link' )->times( $edit_post_link_times )->andReturn( 'edit-post-link' ); + Functions\expect( 'add_query_arg' )->times( $add_query_arg_times )->andReturn( 'query_args' ); $this->expectOutputString( $expected ); From 8432b30f8753b15e7c3d7e158974002c54637daf Mon Sep 17 00:00:00 2001 From: Christoph Daum Date: Tue, 14 Apr 2026 21:06:55 +0200 Subject: [PATCH 2/6] feat(metabox): add create new translation button Add a "+" button to the editor metabox that opens post-new.php with msls_id and msls_lang params in a new tab. Visible when no post is linked and the current post has been saved. Closes #606 --- includes/MslsMetaBox.php | 65 +++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 8 deletions(-) diff --git a/includes/MslsMetaBox.php b/includes/MslsMetaBox.php index 3430774c9..bd0cc1949 100644 --- a/includes/MslsMetaBox.php +++ b/includes/MslsMetaBox.php @@ -161,8 +161,10 @@ public function render_select(): void { if ( $blogs ) { global $post; - $type = get_post_type( $post->ID ); - $mydata = new MslsOptionsPost( $post->ID ); + $type = get_post_type( $post->ID ); + $mydata = new MslsOptionsPost( $post->ID ); + $origin_language = MslsBlogCollection::get_blog_language(); + $is_saved = 'auto-draft' !== get_post_status( $post ); $this->maybe_set_linked_post( $mydata ); @@ -177,7 +179,11 @@ public function render_select(): void { $language = $blog->get_language(); $icon_type = $this->options->get_icon_type(); - $icon = MslsAdminIcon::create( $type )->set_language( $language )->set_icon_type( $icon_type ); + $icon = MslsAdminIcon::create( $type ) + ->set_language( $language ) + ->set_icon_type( $icon_type ) + ->set_id( $post->ID ) + ->set_origin_language( $origin_language ); if ( $mydata->has_value( $language ) ) { $icon->set_href( (int) $mydata->$language ); @@ -215,11 +221,17 @@ public function render_select(): void { ); } + $create_new = ''; + if ( $is_saved && ! $mydata->has_value( $language ) ) { + $create_new = $this->get_create_new_link( $icon, $language ); + } + $lis .= sprintf( - '
  • %3$s
  • ', + '
  • %3$s%4$s
  • ', esc_attr( $language ), $icon, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $selects, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $create_new, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped esc_attr( $icon_type ) ); @@ -292,8 +304,10 @@ public function render_input(): void { if ( $blogs ) { global $post; - $post_type = get_post_type( $post->ID ); - $my_data = new MslsOptionsPost( $post->ID ); + $post_type = get_post_type( $post->ID ); + $my_data = new MslsOptionsPost( $post->ID ); + $origin_language = MslsBlogCollection::get_blog_language(); + $is_saved = 'auto-draft' !== get_post_status( $post ); $this->maybe_set_linked_post( $my_data ); @@ -307,7 +321,11 @@ public function render_input(): void { $language = $blog->get_language(); $icon_type = $this->options->get_icon_type(); - $icon = MslsAdminIcon::create()->set_language( $language )->set_icon_type( $icon_type ); + $icon = MslsAdminIcon::create() + ->set_language( $language ) + ->set_icon_type( $icon_type ) + ->set_id( $post->ID ) + ->set_origin_language( $origin_language ); $value = ''; $title = ''; @@ -317,13 +335,20 @@ public function render_input(): void { $title = get_the_title( $value ); } + $create_new = ''; + if ( $is_saved ) { + $display = $my_data->has_value( $language ) ? ' style="display:none"' : ''; + $create_new = $this->get_create_new_link( $icon, $language, $display ); + } + $items .= sprintf( - '
  • ', + '
  • %6$s
  • ', $blog->userblog_id, $icon, $language, $value, $title, + $create_new, esc_attr( $icon_type ) ); @@ -352,6 +377,30 @@ public function render_input(): void { } } + /** + * @param MslsAdminIcon $icon + * @param string $language + * @param string $extra_attrs + * + * @return string + */ + private function get_create_new_link( MslsAdminIcon $icon, string $language, string $extra_attrs = '' ): string { + $href = $icon->get_edit_new(); + + /* translators: %s: language code */ + $title = sprintf( + __( 'Create a new translation in the %s-blog', 'multisite-language-switcher' ), + $language + ); + + return sprintf( + '', + esc_url( $href ), + esc_attr( $title ), + $extra_attrs + ); + } + /** * Set * From 59834c71e3fcdc5ec3f68e6269a85767924df358 Mon Sep 17 00:00:00 2001 From: Christoph Daum Date: Tue, 14 Apr 2026 21:06:59 +0200 Subject: [PATCH 3/6] feat(js): toggle create new button on autocomplete Hide the create new button when a post is selected via autocomplete and show it again when the field is cleared. --- src/msls.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/msls.js b/src/msls.js index a98c90c87..59f70388c 100644 --- a/src/msls.js +++ b/src/msls.js @@ -36,11 +36,13 @@ jQuery( document ).ready( select: function ( event, ui ) { $( event.target ).val( ui.item.label ); hid_field.val( ui.item.value ); + $( event.target ).siblings( '.msls-create-new' ).hide(); return false; }, change: function ( event, ui ) { if ( ! $( event.target ).val() ) { hid_field.val( '' ); + $( event.target ).siblings( '.msls-create-new' ).show(); } else if ( mslsinput.id === hid_field.val() && mslsinput.title !== $( event.target ).val() From 53045101c77882b6fd82ef301af6cc945502ace9 Mon Sep 17 00:00:00 2001 From: Christoph Daum Date: Tue, 14 Apr 2026 21:11:12 +0200 Subject: [PATCH 4/6] style(metabox): fix create new button styling Remove underline and add proper color to the create new translation button in the metabox. --- assets/css/msls.css | 2 +- assets/css/msls.less | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/assets/css/msls.css b/assets/css/msls.css index 1a5af2ab5..4e2d90cea 100644 --- a/assets/css/msls.css +++ b/assets/css/msls.css @@ -1 +1 @@ -div#msls.postbox label{margin-right:6px}div#msls.postbox input.msls_title,div#msls.postbox select{width:100%}select.msls-translations{width:226px}#msls.postbox .inside li{display:flex;align-items:center}#msls.postbox .inside li label{display:flex}#msls.postbox .inside li input.msls_title,#msls.postbox .inside li select{flex-grow:1}#msls-content-import .button-primary{margin:1em auto}.flag-icon{width:1.3333em!important;height:1em!important;vertical-align:middle;overflow:hidden;line-height:1!important;color:transparent}.msls-icon-wrapper{display:inline-flex;justify-content:center;align-items:center;text-align:center}.msls-icon-wrapper.flag{min-width:36px}.msls-icon-wrapper.label{min-width:48px}label .msls-icon-wrapper{text-align:left}#wpadminbar * .language-badge,#wpadminbar .language-badge,.language-badge{display:inline-block;min-width:32px;height:auto;padding:4px 6px;white-space:nowrap;font-size:10px;line-height:1;text-align:center;background-color:currentColor;border-radius:9px;user-select:none}#wpadminbar * .language-badge>span,#wpadminbar .language-badge>span,.language-badge>span{display:inline-block;vertical-align:top;margin:0 1px;font-size:10px;font-weight:600;line-height:1;text-transform:uppercase;color:#fff;text-align:center}#wpadminbar * .language-badge>span:nth-child(2),#wpadminbar .language-badge>span:nth-child(2),.language-badge>span:nth-child(2){opacity:.5}.column-mslscol .language-badge{margin:0 1px!important}.column-mslscol{width:56px}#wpadminbar * .language-badge,#wpadminbar .language-badge{position:relative;top:-1px;padding-top:3px;padding-bottom:3px;background-color:transparent;border:1px currentColor solid}#wpadminbar * .language-badge>span,#wpadminbar .language-badge>span{color:currentColor} \ No newline at end of file +div#msls.postbox label{margin-right:6px}div#msls.postbox input.msls_title,div#msls.postbox select{width:100%}select.msls-translations{width:226px}#msls.postbox .inside li{display:flex;align-items:center}#msls.postbox .inside li label{display:flex}#msls.postbox .inside li input.msls_title,#msls.postbox .inside li select{flex-grow:1}#msls.postbox .inside li .msls-create-new{text-decoration:none;margin-left:4px;color:#2271b1}#msls.postbox .inside li .msls-create-new:hover{color:#135e96}#msls-content-import .button-primary{margin:1em auto}.flag-icon{width:1.3333em!important;height:1em!important;vertical-align:middle;overflow:hidden;line-height:1!important;color:transparent}.msls-icon-wrapper{display:inline-flex;justify-content:center;align-items:center;text-align:center}.msls-icon-wrapper.flag{min-width:36px}.msls-icon-wrapper.label{min-width:48px}label .msls-icon-wrapper{text-align:left}#wpadminbar * .language-badge,#wpadminbar .language-badge,.language-badge{display:inline-block;min-width:32px;height:auto;padding:4px 6px;white-space:nowrap;font-size:10px;line-height:1;text-align:center;background-color:currentColor;border-radius:9px;user-select:none}#wpadminbar * .language-badge>span,#wpadminbar .language-badge>span,.language-badge>span{display:inline-block;vertical-align:top;margin:0 1px;font-size:10px;font-weight:600;line-height:1;text-transform:uppercase;color:#fff;text-align:center}#wpadminbar * .language-badge>span:nth-child(2),#wpadminbar .language-badge>span:nth-child(2),.language-badge>span:nth-child(2){opacity:.5}.column-mslscol .language-badge{margin:0 1px!important}.column-mslscol{width:56px}#wpadminbar * .language-badge,#wpadminbar .language-badge{position:relative;top:-1px;padding-top:3px;padding-bottom:3px;background-color:transparent;border:1px currentColor solid}#wpadminbar * .language-badge>span,#wpadminbar .language-badge>span{color:currentColor} \ No newline at end of file diff --git a/assets/css/msls.less b/assets/css/msls.less index e10339aa1..657a72960 100644 --- a/assets/css/msls.less +++ b/assets/css/msls.less @@ -24,6 +24,14 @@ select.msls-translations { input.msls_title, select { flex-grow: 1; } + .msls-create-new { + text-decoration: none; + margin-left: 4px; + color: #2271b1; + &:hover { + color: #135e96; + } + } } } } From ed14c9aa93d2be11606ea499de0a6e28ca1f2b2e Mon Sep 17 00:00:00 2001 From: Christoph Daum Date: Tue, 14 Apr 2026 21:31:05 +0200 Subject: [PATCH 5/6] fix(metabox): move CSS classes from for to class attr The label for attribute was polluted with CSS classes which is invalid HTML. Move msls-icon-wrapper and icon type to a proper class attribute. --- includes/MslsMetaBox.php | 4 ++-- tests/phpunit/TestMslsMetaBox.php | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/includes/MslsMetaBox.php b/includes/MslsMetaBox.php index bd0cc1949..115acbba2 100644 --- a/includes/MslsMetaBox.php +++ b/includes/MslsMetaBox.php @@ -227,7 +227,7 @@ public function render_select(): void { } $lis .= sprintf( - '
  • %3$s%4$s
  • ', + '
  • %3$s%4$s
  • ', esc_attr( $language ), $icon, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped $selects, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped @@ -342,7 +342,7 @@ public function render_input(): void { } $items .= sprintf( - '
  • %6$s
  • ', + '
  • %6$s
  • ', $blog->userblog_id, $icon, $language, diff --git a/tests/phpunit/TestMslsMetaBox.php b/tests/phpunit/TestMslsMetaBox.php index f9c189d93..688cc70a8 100644 --- a/tests/phpunit/TestMslsMetaBox.php +++ b/tests/phpunit/TestMslsMetaBox.php @@ -172,7 +172,7 @@ public function test_render_select_not_hierarchical(): void { Functions\expect( 'get_current_blog_id' )->times( 3 )->andReturn( 1 ); Functions\expect( 'get_admin_url' )->twice()->andReturn( 'admin-url-empty' ); - $expected = ''; + $expected = ''; $this->expectOutputString( $expected ); $this->MslsMetaBoxFactory()->render_select(); @@ -205,7 +205,7 @@ public function test_render_select_hierarchical(): void { Functions\expect( 'get_edit_post_link' )->once()->andReturn( 'edit-post-link' ); Functions\expect( 'get_current_blog_id' )->once()->andReturn( 1 ); - $expected = ''; + $expected = ''; $this->expectOutputString( $expected ); $this->MslsMetaBoxFactory()->render_select(); @@ -213,8 +213,8 @@ public function test_render_select_hierarchical(): void { public static function render_input_provider(): array { return array( - array( array( 'de_DE' => 42 ), 1, 2, 1, 1, 1, '' ), - array( array( 'en_US' => 17 ), 0, 3, 2, 0, 2, '' ), + array( array( 'de_DE' => 42 ), 1, 2, 1, 1, 1, '' ), + array( array( 'en_US' => 17 ), 0, 3, 2, 0, 2, '' ), ); } From 4a886f0eaf44db1db14e06172e6e3a599beeaf62 Mon Sep 17 00:00:00 2001 From: Christoph Daum Date: Tue, 14 Apr 2026 21:39:03 +0200 Subject: [PATCH 6/6] fix(metabox): move translators comment above __() The WordPress plugin checker requires the translators comment on the line directly above the __() call. --- includes/MslsMetaBox.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/includes/MslsMetaBox.php b/includes/MslsMetaBox.php index 115acbba2..03fd22fb0 100644 --- a/includes/MslsMetaBox.php +++ b/includes/MslsMetaBox.php @@ -387,8 +387,8 @@ public function render_input(): void { private function get_create_new_link( MslsAdminIcon $icon, string $language, string $extra_attrs = '' ): string { $href = $icon->get_edit_new(); - /* translators: %s: language code */ $title = sprintf( + /* translators: %s: language code */ __( 'Create a new translation in the %s-blog', 'multisite-language-switcher' ), $language );