├── .github └── workflows │ └── php-qa.yml ├── CHANGELOG.md ├── LICENSE ├── composer.json ├── phpunit.xml.dist ├── src ├── AdminNotices.php ├── Bootstrap.php ├── BoxAction.php ├── BoxInfo.php ├── BoxView.php ├── Boxes.php ├── Entity.php ├── Metabox.php ├── MetaboxAuth.php ├── NoopBoxAction.php ├── NoopBoxView.php ├── PostMetabox.php ├── PostMetaboxAuth.php ├── TermMetabox.php └── TermMetaboxAuth.php └── tests ├── boot.php └── src ├── AdminNoticesTest.php ├── BootstrapTest.php ├── BoxInfoTest.php ├── NoopBoxActionTest.php ├── NoopBoxViewTest.php ├── PostMetaboxAuthTest.php ├── TermMetaboxAuthTest.php └── TestCase.php /.github/workflows/php-qa.yml: -------------------------------------------------------------------------------- 1 | name: PHP-QA 2 | 3 | on: [ push ] 4 | 5 | jobs: 6 | lint-php: 7 | strategy: 8 | matrix: 9 | php: ["7.2", "7.3", "7.4", "8.0", "8.1", "8.2"] 10 | uses: inpsyde/reusable-workflows/.github/workflows/lint-php.yml@main 11 | with: 12 | PHP_VERSION: ${{ matrix.php }} 13 | tests-unit-php: 14 | strategy: 15 | matrix: 16 | php: ["7.2", "7.3", "7.4", "8.0", "8.1", "8.2"] 17 | uses: inpsyde/reusable-workflows/.github/workflows/tests-unit-php.yml@main 18 | secrets: 19 | COMPOSER_AUTH_JSON: ${{ secrets.PACKAGIST_AUTH_JSON }} 20 | with: 21 | PHP_VERSION: ${{ matrix.php }} 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.4.4 4 | - Fix PHP 8.1 warnings. 5 | 6 | ## 0.4.3 7 | - Fix: terms MetaBoxes is printing twice in case other plugins use same code structure. 8 | 9 | ## 0.4.2 10 | - Fix: metaBox not saved when other plugins switching between blogs 11 | 12 | ## 0.4.1 13 | - Fix bug of post boxes not saving on new posts. 14 | 15 | ## 0.4.0 16 | - Save post boxes even if post has empty content and WordPress will not proceed in save it (so `"wp_insert_post"` is not triggered). 17 | - Introduce `"metabox-orchestra.save-on-empty-post"` hook to add the possibility to disallow box saving if content is empty. 18 | 19 | ## 0.3.4 20 | - Added `Entity::id()` to obtain a type-safe id of wrapped entity and mark entity as not valid if its id is <= 0. 21 | 22 | ## 0.3.3 23 | - Added three new hooks: before showing boxes and before and after saving them. 24 | - Improved `Entity` object constructor, now accepts an instance of another `Entity`. 25 | 26 | ## 0.3.2 27 | - Prevent recursion when editing post/term object form inside a `BoxAction::save()` method. 28 | 29 | ## 0.3.1 30 | - Fix a bug in Boxes class. 31 | - Update README code sample. 32 | 33 | ## 0.3.0 34 | - Introduce `Entity` object to wrap supported objects for metaboxes (currently `\WP_Post` ad `\WP_Term`) 35 | - **[BREAKING]** `Metabox::create_info()` new requires two arguments, the second being current `Entity`. 36 | 37 | ## 0.2.0 38 | - Added `.travis.yml` 39 | - Added more links to README. 40 | - Added more tests. 41 | 42 | ## 0.1.1 43 | - Minor, no logic change to AdminNotices 44 | - Introduce unit tests 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ### GNU GENERAL PUBLIC LICENSE 2 | 3 | Version 2, June 1991 4 | 5 | Copyright (C) 1989, 1991 Free Software Foundation, Inc. 6 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA 7 | 8 | Everyone is permitted to copy and distribute verbatim copies 9 | of this license document, but changing it is not allowed. 10 | 11 | ### Preamble 12 | 13 | The licenses for most software are designed to take away your freedom 14 | to share and change it. By contrast, the GNU General Public License is 15 | intended to guarantee your freedom to share and change free 16 | software--to make sure the software is free for all its users. This 17 | General Public License applies to most of the Free Software 18 | Foundation's software and to any other program whose authors commit to 19 | using it. (Some other Free Software Foundation software is covered by 20 | the GNU Lesser General Public License instead.) You can apply it to 21 | your programs, too. 22 | 23 | When we speak of free software, we are referring to freedom, not 24 | price. Our General Public Licenses are designed to make sure that you 25 | have the freedom to distribute copies of free software (and charge for 26 | this service if you wish), that you receive source code or can get it 27 | if you want it, that you can change the software or use pieces of it 28 | in new free programs; and that you know you can do these things. 29 | 30 | To protect your rights, we need to make restrictions that forbid 31 | anyone to deny you these rights or to ask you to surrender the rights. 32 | These restrictions translate to certain responsibilities for you if 33 | you distribute copies of the software, or if you modify it. 34 | 35 | For example, if you distribute copies of such a program, whether 36 | gratis or for a fee, you must give the recipients all the rights that 37 | you have. You must make sure that they, too, receive or can get the 38 | source code. And you must show them these terms so they know their 39 | rights. 40 | 41 | We protect your rights with two steps: (1) copyright the software, and 42 | (2) offer you this license which gives you legal permission to copy, 43 | distribute and/or modify the software. 44 | 45 | Also, for each author's protection and ours, we want to make certain 46 | that everyone understands that there is no warranty for this free 47 | software. If the software is modified by someone else and passed on, 48 | we want its recipients to know that what they have is not the 49 | original, so that any problems introduced by others will not reflect 50 | on the original authors' reputations. 51 | 52 | Finally, any free program is threatened constantly by software 53 | patents. We wish to avoid the danger that redistributors of a free 54 | program will individually obtain patent licenses, in effect making the 55 | program proprietary. To prevent this, we have made it clear that any 56 | patent must be licensed for everyone's free use or not licensed at 57 | all. 58 | 59 | The precise terms and conditions for copying, distribution and 60 | modification follow. 61 | 62 | ### TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 63 | 64 | **0.** This License applies to any program or other work which 65 | contains a notice placed by the copyright holder saying it may be 66 | distributed under the terms of this General Public License. The 67 | "Program", below, refers to any such program or work, and a "work 68 | based on the Program" means either the Program or any derivative work 69 | under copyright law: that is to say, a work containing the Program or 70 | a portion of it, either verbatim or with modifications and/or 71 | translated into another language. (Hereinafter, translation is 72 | included without limitation in the term "modification".) Each licensee 73 | is addressed as "you". 74 | 75 | Activities other than copying, distribution and modification are not 76 | covered by this License; they are outside its scope. The act of 77 | running the Program is not restricted, and the output from the Program 78 | is covered only if its contents constitute a work based on the Program 79 | (independent of having been made by running the Program). Whether that 80 | is true depends on what the Program does. 81 | 82 | **1.** You may copy and distribute verbatim copies of the Program's 83 | source code as you receive it, in any medium, provided that you 84 | conspicuously and appropriately publish on each copy an appropriate 85 | copyright notice and disclaimer of warranty; keep intact all the 86 | notices that refer to this License and to the absence of any warranty; 87 | and give any other recipients of the Program a copy of this License 88 | along with the Program. 89 | 90 | You may charge a fee for the physical act of transferring a copy, and 91 | you may at your option offer warranty protection in exchange for a 92 | fee. 93 | 94 | **2.** You may modify your copy or copies of the Program or any 95 | portion of it, thus forming a work based on the Program, and copy and 96 | distribute such modifications or work under the terms of Section 1 97 | above, provided that you also meet all of these conditions: 98 | 99 | 100 | **a)** You must cause the modified files to carry prominent notices 101 | stating that you changed the files and the date of any change. 102 | 103 | 104 | **b)** You must cause any work that you distribute or publish, that in 105 | whole or in part contains or is derived from the Program or any part 106 | thereof, to be licensed as a whole at no charge to all third parties 107 | under the terms of this License. 108 | 109 | 110 | **c)** If the modified program normally reads commands interactively 111 | when run, you must cause it, when started running for such interactive 112 | use in the most ordinary way, to print or display an announcement 113 | including an appropriate copyright notice and a notice that there is 114 | no warranty (or else, saying that you provide a warranty) and that 115 | users may redistribute the program under these conditions, and telling 116 | the user how to view a copy of this License. (Exception: if the 117 | Program itself is interactive but does not normally print such an 118 | announcement, your work based on the Program is not required to print 119 | an announcement.) 120 | 121 | These requirements apply to the modified work as a whole. If 122 | identifiable sections of that work are not derived from the Program, 123 | and can be reasonably considered independent and separate works in 124 | themselves, then this License, and its terms, do not apply to those 125 | sections when you distribute them as separate works. But when you 126 | distribute the same sections as part of a whole which is a work based 127 | on the Program, the distribution of the whole must be on the terms of 128 | this License, whose permissions for other licensees extend to the 129 | entire whole, and thus to each and every part regardless of who wrote 130 | it. 131 | 132 | Thus, it is not the intent of this section to claim rights or contest 133 | your rights to work written entirely by you; rather, the intent is to 134 | exercise the right to control the distribution of derivative or 135 | collective works based on the Program. 136 | 137 | In addition, mere aggregation of another work not based on the Program 138 | with the Program (or with a work based on the Program) on a volume of 139 | a storage or distribution medium does not bring the other work under 140 | the scope of this License. 141 | 142 | **3.** You may copy and distribute the Program (or a work based on it, 143 | under Section 2) in object code or executable form under the terms of 144 | Sections 1 and 2 above provided that you also do one of the following: 145 | 146 | 147 | **a)** Accompany it with the complete corresponding machine-readable 148 | source code, which must be distributed under the terms of Sections 1 149 | and 2 above on a medium customarily used for software interchange; or, 150 | 151 | 152 | **b)** Accompany it with a written offer, valid for at least three 153 | years, to give any third party, for a charge no more than your cost of 154 | physically performing source distribution, a complete machine-readable 155 | copy of the corresponding source code, to be distributed under the 156 | terms of Sections 1 and 2 above on a medium customarily used for 157 | software interchange; or, 158 | 159 | 160 | **c)** Accompany it with the information you received as to the offer 161 | to distribute corresponding source code. (This alternative is allowed 162 | only for noncommercial distribution and only if you received the 163 | program in object code or executable form with such an offer, in 164 | accord with Subsection b above.) 165 | 166 | The source code for a work means the preferred form of the work for 167 | making modifications to it. For an executable work, complete source 168 | code means all the source code for all modules it contains, plus any 169 | associated interface definition files, plus the scripts used to 170 | control compilation and installation of the executable. However, as a 171 | special exception, the source code distributed need not include 172 | anything that is normally distributed (in either source or binary 173 | form) with the major components (compiler, kernel, and so on) of the 174 | operating system on which the executable runs, unless that component 175 | itself accompanies the executable. 176 | 177 | If distribution of executable or object code is made by offering 178 | access to copy from a designated place, then offering equivalent 179 | access to copy the source code from the same place counts as 180 | distribution of the source code, even though third parties are not 181 | compelled to copy the source along with the object code. 182 | 183 | **4.** You may not copy, modify, sublicense, or distribute the Program 184 | except as expressly provided under this License. Any attempt otherwise 185 | to copy, modify, sublicense or distribute the Program is void, and 186 | will automatically terminate your rights under this License. However, 187 | parties who have received copies, or rights, from you under this 188 | License will not have their licenses terminated so long as such 189 | parties remain in full compliance. 190 | 191 | **5.** You are not required to accept this License, since you have not 192 | signed it. However, nothing else grants you permission to modify or 193 | distribute the Program or its derivative works. These actions are 194 | prohibited by law if you do not accept this License. Therefore, by 195 | modifying or distributing the Program (or any work based on the 196 | Program), you indicate your acceptance of this License to do so, and 197 | all its terms and conditions for copying, distributing or modifying 198 | the Program or works based on it. 199 | 200 | **6.** Each time you redistribute the Program (or any work based on 201 | the Program), the recipient automatically receives a license from the 202 | original licensor to copy, distribute or modify the Program subject to 203 | these terms and conditions. You may not impose any further 204 | restrictions on the recipients' exercise of the rights granted herein. 205 | You are not responsible for enforcing compliance by third parties to 206 | this License. 207 | 208 | **7.** If, as a consequence of a court judgment or allegation of 209 | patent infringement or for any other reason (not limited to patent 210 | issues), conditions are imposed on you (whether by court order, 211 | agreement or otherwise) that contradict the conditions of this 212 | License, they do not excuse you from the conditions of this License. 213 | If you cannot distribute so as to satisfy simultaneously your 214 | obligations under this License and any other pertinent obligations, 215 | then as a consequence you may not distribute the Program at all. For 216 | example, if a patent license would not permit royalty-free 217 | redistribution of the Program by all those who receive copies directly 218 | or indirectly through you, then the only way you could satisfy both it 219 | and this License would be to refrain entirely from distribution of the 220 | Program. 221 | 222 | If any portion of this section is held invalid or unenforceable under 223 | any particular circumstance, the balance of the section is intended to 224 | apply and the section as a whole is intended to apply in other 225 | circumstances. 226 | 227 | It is not the purpose of this section to induce you to infringe any 228 | patents or other property right claims or to contest validity of any 229 | such claims; this section has the sole purpose of protecting the 230 | integrity of the free software distribution system, which is 231 | implemented by public license practices. Many people have made 232 | generous contributions to the wide range of software distributed 233 | through that system in reliance on consistent application of that 234 | system; it is up to the author/donor to decide if he or she is willing 235 | to distribute software through any other system and a licensee cannot 236 | impose that choice. 237 | 238 | This section is intended to make thoroughly clear what is believed to 239 | be a consequence of the rest of this License. 240 | 241 | **8.** If the distribution and/or use of the Program is restricted in 242 | certain countries either by patents or by copyrighted interfaces, the 243 | original copyright holder who places the Program under this License 244 | may add an explicit geographical distribution limitation excluding 245 | those countries, so that distribution is permitted only in or among 246 | countries not thus excluded. In such case, this License incorporates 247 | the limitation as if written in the body of this License. 248 | 249 | **9.** The Free Software Foundation may publish revised and/or new 250 | versions of the General Public License from time to time. Such new 251 | versions will be similar in spirit to the present version, but may 252 | differ in detail to address new problems or concerns. 253 | 254 | Each version is given a distinguishing version number. If the Program 255 | specifies a version number of this License which applies to it and 256 | "any later version", you have the option of following the terms and 257 | conditions either of that version or of any later version published by 258 | the Free Software Foundation. If the Program does not specify a 259 | version number of this License, you may choose any version ever 260 | published by the Free Software Foundation. 261 | 262 | **10.** If you wish to incorporate parts of the Program into other 263 | free programs whose distribution conditions are different, write to 264 | the author to ask for permission. For software which is copyrighted by 265 | the Free Software Foundation, write to the Free Software Foundation; 266 | we sometimes make exceptions for this. Our decision will be guided by 267 | the two goals of preserving the free status of all derivatives of our 268 | free software and of promoting the sharing and reuse of software 269 | generally. 270 | 271 | **NO WARRANTY** 272 | 273 | **11.** BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO 274 | WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. 275 | EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR 276 | OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY 277 | KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE 278 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 279 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE 280 | PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME 281 | THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 282 | 283 | **12.** IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN 284 | WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY 285 | AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU 286 | FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR 287 | CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE 288 | PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING 289 | RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A 290 | FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF 291 | SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH 292 | DAMAGES. 293 | 294 | ### END OF TERMS AND CONDITIONS 295 | 296 | ### How to Apply These Terms to Your New Programs 297 | 298 | If you develop a new program, and you want it to be of the greatest 299 | possible use to the public, the best way to achieve this is to make it 300 | free software which everyone can redistribute and change under these 301 | terms. 302 | 303 | To do so, attach the following notices to the program. It is safest to 304 | attach them to the start of each source file to most effectively 305 | convey the exclusion of warranty; and each file should have at least 306 | the "copyright" line and a pointer to where the full notice is found. 307 | 308 | one line to give the program's name and an idea of what it does. 309 | Copyright (C) 2020 Inpsyde GmbH 310 | 311 | This program is free software; you can redistribute it and/or 312 | modify it under the terms of the GNU General Public License 313 | as published by the Free Software Foundation; either version 2 314 | of the License, or (at your option) any later version. 315 | 316 | This program is distributed in the hope that it will be useful, 317 | but WITHOUT ANY WARRANTY; without even the implied warranty of 318 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 319 | GNU General Public License for more details. 320 | 321 | You should have received a copy of the GNU General Public License 322 | along with this program; if not, write to the Free Software 323 | Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 324 | 325 | Also add information on how to contact you by electronic and paper 326 | mail. 327 | 328 | If the program is interactive, make it output a short notice like this 329 | when it starts in an interactive mode: 330 | 331 | Gnomovision version 69, Copyright (C) year name of author 332 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details 333 | type `show w'. This is free software, and you are welcome 334 | to redistribute it under certain conditions; type `show c' 335 | for details. 336 | 337 | The hypothetical commands \`show w' and \`show c' should show the 338 | appropriate parts of the General Public License. Of course, the 339 | commands you use may be called something other than \`show w' and 340 | \`show c'; they could even be mouse-clicks or menu items--whatever 341 | suits your program. 342 | 343 | You should also get your employer (if you work as a programmer) or 344 | your school, if any, to sign a "copyright disclaimer" for the program, 345 | if necessary. Here is a sample; alter the names: 346 | 347 | Yoyodyne, Inc., hereby disclaims all copyright 348 | interest in the program `Gnomovision' 349 | (which makes passes at compilers) written 350 | by James Hacker. 351 | 352 | signature of Ty Coon, 1 April 1989 353 | Ty Coon, President of Vice 354 | 355 | This General Public License does not permit incorporating your program 356 | into proprietary programs. If your program is a subroutine library, 357 | you may consider it more useful to permit linking proprietary 358 | applications with the library. If this is what you want to do, use the 359 | [GNU Lesser General Public 360 | License](https://www.gnu.org/licenses/lgpl.html) instead of this 361 | License. 362 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "inpsyde/metabox-orchestra", 3 | "description": "Package for OOP WordPress metabox orchestration.", 4 | "type": "library", 5 | "license": "GPL-2.0-or-later", 6 | "authors": [ 7 | { 8 | "name": "Inpsyde GmbH", 9 | "email": "hello@inpsyde.com", 10 | "homepage": "http://inpsyde.com", 11 | "role": "Company" 12 | }, 13 | { 14 | "name": "Giuseppe Mazzapica", 15 | "email": "g.mazzapica@inpsyde.com", 16 | "role": "Developer" 17 | }, 18 | { 19 | "name": "Christian Leucht", 20 | "email": "c.leucht@inpsyde.com", 21 | "role": "Developer" 22 | } 23 | ], 24 | "minimum-stability": "stable", 25 | "require": { 26 | "php": ">=7.2", 27 | "brain/nonces": "^1.0" 28 | }, 29 | "require-dev": { 30 | "phpunit/phpunit": "^8 | ^9", 31 | "doctrine/instantiator": "^1.0", 32 | "brain/monkey": "^2" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "MetaboxOrchestra\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "MetaboxOrchestra\\Tests\\": "tests/src/" 42 | } 43 | }, 44 | "config": { 45 | "optimize-autoloader": true 46 | }, 47 | "extra": { 48 | "branch-alias": { 49 | "dev-master": "0.x-dev" 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 16 | tests/src 17 | 18 | 19 | 20 | 21 | src 22 | 23 | tests 24 | vendor 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/AdminNotices.php: -------------------------------------------------------------------------------- 1 | use_default_screen( $default_screen ); 67 | } 68 | 69 | /** 70 | * @param string $message 71 | * @param string $title 72 | * @param string $type 73 | * @param string|null $target_screen 74 | * 75 | * @return AdminNotices 76 | */ 77 | public function add( 78 | string $message, 79 | string $title = '', 80 | string $type = self::ERROR, 81 | string $target_screen = null 82 | ): AdminNotices { 83 | 84 | $user_id = get_current_user_id(); 85 | if ( ! $user_id ) { 86 | return $this; 87 | } 88 | 89 | $target_screen or $target_screen = $this->default_screen ? : get_current_screen()->id; 90 | $now = new \DateTimeImmutable( 'now', new \DateTimeZone( 'UTC' ) ); 91 | 92 | isset( $this->messages[ $target_screen ] ) or $this->messages[ $target_screen ] = []; 93 | isset( $this->messages[ $target_screen ][ $type ] ) or $this->messages[ $target_screen ][ $type ] = []; 94 | isset( $this->messages[ $target_screen ][ $type ] ) or $this->messages[ $target_screen ][ $type ] = []; 95 | $this->messages[ $target_screen ][ $type ][] = [ $message, $title, $now->getTimestamp() ]; 96 | 97 | return $this; 98 | } 99 | 100 | /** 101 | * @return bool 102 | */ 103 | public function do_notices(): bool { 104 | 105 | if ( $this->printed || ! doing_action( 'admin_notices' ) ) { 106 | return FALSE; 107 | } 108 | 109 | $this->printed = TRUE; 110 | 111 | $user_id = get_current_user_id(); 112 | $screen_id = get_current_screen()->id; 113 | $messages = (array) get_user_option( self::OPTION_NAME, $user_id ); 114 | 115 | if ( ! empty( $messages[ $screen_id ] ) ) { 116 | $this->print_messages( (array) $messages[ $screen_id ] ); 117 | unset( $messages[ $screen_id ] ); 118 | 119 | return $messages 120 | ? (bool) update_user_option( $user_id, self::OPTION_NAME, $messages ) 121 | : (bool) delete_user_option( $user_id, self::OPTION_NAME ); 122 | } 123 | 124 | return (bool) delete_user_option( $user_id, self::OPTION_NAME ); 125 | } 126 | 127 | /** 128 | * Store (or delete) messages on shutdown. 129 | * 130 | * @return bool 131 | */ 132 | public function record(): bool { 133 | 134 | if ( $this->recorded || ! doing_action( 'shutdown' ) ) { 135 | return FALSE; 136 | } 137 | 138 | $this->recorded = TRUE; 139 | 140 | $user_id = get_current_user_id(); 141 | if ( $user_id ) { 142 | return $this->messages 143 | ? (bool) update_user_option( $user_id, self::OPTION_NAME, $this->messages ) 144 | : (bool) delete_user_option( $user_id, self::OPTION_NAME ); 145 | } 146 | 147 | return FALSE; 148 | } 149 | 150 | /** 151 | * @param array $messages 152 | */ 153 | private function print_messages( array $messages ) { 154 | 155 | foreach ( $messages as $type => $type_messages ) { 156 | 157 | if ( ! in_array( $type, [ self::ERROR, self::INFO, self::SUCCESS ], TRUE ) ) { 158 | continue; 159 | } 160 | 161 | foreach ( (array) $type_messages as list( $message, $title, $timestamp ) ) { 162 | 163 | $now_ts = ( new \DateTimeImmutable( 'now', new \DateTimeZone( 'UTC' ) ) )->getTimestamp(); 164 | $target_ts = $now_ts - (int) apply_filters( 'metabox-orchestra.notice-ttl', self::DEFAULT_TTL ); 165 | 166 | if ( $target_ts > $timestamp ) { 167 | continue; 168 | } 169 | 170 | ?> 171 |
172 |

173 | 174 |
175 | 176 | 177 | 178 | 179 |

180 |
181 | default_screen = $screen; 194 | 195 | return $this; 196 | } 197 | 198 | } -------------------------------------------------------------------------------- /src/Bootstrap.php: -------------------------------------------------------------------------------- 1 | storage = compact( 'title', 'id', 'context', 'priority' ); 70 | } 71 | 72 | /** 73 | * @return string 74 | */ 75 | public function id(): string { 76 | 77 | return $this->storage[ 'id' ]; 78 | } 79 | 80 | /** 81 | * @return string 82 | */ 83 | public function title(): string { 84 | 85 | return $this->storage[ 'title' ]; 86 | } 87 | 88 | /** 89 | * @return string 90 | */ 91 | public function context(): string { 92 | 93 | return $this->storage[ 'context' ]; 94 | } 95 | 96 | /** 97 | * @return string 98 | */ 99 | public function priority(): string { 100 | 101 | return $this->storage[ 'priority' ]; 102 | } 103 | 104 | /** 105 | * @inheritdoc 106 | */ 107 | public function offsetExists( $offset ): bool { 108 | 109 | return array_key_exists( $offset, $this->meta ); 110 | } 111 | 112 | /** 113 | * @inheritdoc 114 | */ 115 | #[\ReturnTypeWillChange] 116 | public function offsetGet( $offset ) { 117 | 118 | return $this->offsetExists( $offset ) ? $this->meta[ $offset ] : NULL; 119 | } 120 | 121 | /** 122 | * @inheritdoc 123 | */ 124 | public function offsetSet( $offset, $value ): void { 125 | 126 | $this->meta[ $offset ] = $value; 127 | } 128 | 129 | /** 130 | * @inheritdoc 131 | */ 132 | public function offsetUnset( $offset ): void { 133 | 134 | unset( $this->meta[ $offset ] ); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/BoxView.php: -------------------------------------------------------------------------------- 1 | taxonomy ) { 66 | 67 | $instance->init_for_term( $screen->taxonomy ); 68 | add_action( "{$screen->taxonomy}_edit_form", [ $instance, 'print_term_boxes' ] ); 69 | 70 | return; 71 | } 72 | 73 | $instance->init_for_post(); 74 | 75 | }, PHP_INT_MAX ); 76 | } 77 | 78 | /** 79 | * @param Metabox[] $boxes 80 | * 81 | * @return Boxes 82 | */ 83 | public function add_box( Metabox ...$boxes ) { 84 | 85 | if ( $this->locked ) { 86 | throw new \BadMethodCallException( 'Cannot add boxes when controller is locked.' ); 87 | } 88 | 89 | if ( 90 | ! $this->target->valid() 91 | || ! in_array( $this->registering_for, [ Metabox::SAVE, Metabox::SHOW ], true ) 92 | ) { 93 | return $this; 94 | } 95 | 96 | $is_post = $this->target->is( \WP_Post::class ); 97 | $is_term = $this->target->is( \WP_Term::class ); 98 | 99 | foreach ( $boxes as $box ) { 100 | if ( $is_post && $box instanceof PostMetabox ) { 101 | $this->boxes[ $box->create_info( $this->registering_for, $this->target )->id() ] = $box; 102 | } elseif ( $is_term && $box instanceof TermMetabox ) { 103 | $this->boxes[ $box->create_info( $this->registering_for, $this->target )->id() ] = $box; 104 | } 105 | } 106 | 107 | return $this; 108 | } 109 | 110 | /** 111 | * WordPress does not print metaboxes for terms, let's fix this. 112 | * 113 | * @param \WP_Term $term 114 | */ 115 | public function print_term_boxes( \WP_Term $term ) { 116 | 117 | if ( current_filter() !== "{$term->taxonomy}_edit_form" ) { 118 | return; 119 | } 120 | 121 | global $wp_meta_boxes; 122 | if ( empty( $wp_meta_boxes[ "edit-{$term->taxonomy}" ] ) ) { 123 | return; 124 | } 125 | 126 | $script = '!function(J,D){J(function(){J(".meta-box-sortables").sortable();' 127 | . 'J(D).on("click",".termbox-container button.handlediv",function(){' 128 | . 'var D=J(this),t=D.siblings(".inside");t.toggle();var e=t.is(":visible")?"true":"false";' 129 | . 'D.attr("aria-expanded",e)})})}(jQuery,document);'; 130 | wp_enqueue_script( 'jquery-ui-sortable' ); 131 | wp_add_inline_script( 'jquery-ui-sortable', $script ); 132 | 133 | echo '
'; 134 | // WordPress does not print metaboxes for terms, let's fix this 135 | do_meta_boxes( "edit-{$term->taxonomy}", 'side', $term ); 136 | do_meta_boxes( "edit-{$term->taxonomy}", 'normal', $term ); 137 | do_meta_boxes( "edit-{$term->taxonomy}", 'advanced', $term ); 138 | echo '
'; 139 | // remove the term hook after being executed 140 | unset($wp_meta_boxes["edit-{$term->taxonomy}"]); 141 | } 142 | 143 | /** 144 | * @return bool 145 | */ 146 | private function init_for_post(): bool { 147 | 148 | // Show Boxes 149 | add_action( 'add_meta_boxes', function ( $post_type, $post ) { 150 | if ( $post instanceof \WP_Post ) { 151 | 152 | $entity = new Entity( $post ); 153 | 154 | $this->prepare_target( new Entity( $post ), Metabox::SHOW ); 155 | 156 | do_action( self::ACTION_SHOW, $entity ); 157 | 158 | array_walk( $this->boxes, [ $this, 'add_meta_box' ] ); 159 | 160 | $this->release_target(); 161 | } 162 | }, 100, 2 ); 163 | 164 | // Save Boxes even if WordPress says content is empty. 165 | add_filter( 166 | 'wp_insert_post_empty_content', 167 | function ( $empty, array $post_array ) { 168 | 169 | global $post; 170 | if ( ! $empty || ! $post instanceof \WP_Post || ! $post->ID ) { 171 | return $empty; 172 | } 173 | 174 | if ( apply_filters( 'metabox-orchestra.save-on-empty-post', TRUE, $post, $post_array ) ) { 175 | $this->on_post_save( $post ); 176 | } 177 | 178 | return $empty; 179 | }, 180 | PHP_INT_MAX, 181 | 2 182 | ); 183 | 184 | // Save Boxes 185 | add_action( 'wp_insert_post', function ( $post_id, \WP_Post $post ) { 186 | $this->on_post_save( $post ); 187 | }, 100, 2 ); 188 | 189 | return true; 190 | } 191 | 192 | /** 193 | * @param string $taxonomy 194 | * 195 | * @return bool 196 | */ 197 | private function init_for_term( string $taxonomy ): bool { 198 | 199 | // Show Boxes 200 | add_action( "{$taxonomy}_pre_edit_form", function ( \WP_Term $term ) { 201 | 202 | $entity = new Entity( $term ); 203 | 204 | $this->prepare_target( $entity, Metabox::SHOW ); 205 | 206 | do_action( self::ACTION_SHOW, $entity ); 207 | 208 | array_walk( $this->boxes, [ $this, 'add_meta_box' ] ); 209 | 210 | $this->release_target(); 211 | }, 1 ); 212 | 213 | // Save Boxes 214 | add_action( 'edit_term', function ( $term_id, $term_taxonomy_id, $term_taxonomy ) use ( $taxonomy ) { 215 | 216 | // This check allows to edit term object inside BoxAction::save() without recursion. 217 | if ( $this->saving === 'term' ) { 218 | return; 219 | } 220 | 221 | $term = get_term_by( 'term_taxonomy_id', $term_taxonomy_id ); 222 | 223 | if ( 224 | ! $term instanceof \WP_Term 225 | || (int) $term->term_id !== (int) $term_id 226 | || $term->taxonomy !== $term_taxonomy 227 | || $term->taxonomy !== $taxonomy 228 | ) { 229 | return; 230 | } 231 | 232 | $this->saving = 'term'; 233 | 234 | $entity = new Entity( $term ); 235 | 236 | $this->prepare_target( $entity, Metabox::SAVE ); 237 | 238 | do_action( self::ACTION_SAVE, $entity ); 239 | 240 | array_walk( $this->boxes, [ $this, 'save_meta_box' ] ); 241 | 242 | do_action( self::ACTION_SAVED, $entity ); 243 | 244 | $this->release_target(); 245 | 246 | $this->saving = ''; 247 | 248 | }, 100, 3 ); 249 | 250 | return true; 251 | } 252 | 253 | /** 254 | * @param Entity $entity 255 | * @param string $show_or_save 256 | */ 257 | private function prepare_target( Entity $entity, string $show_or_save ) { 258 | 259 | $this->target = $entity; 260 | $this->registering_for = $show_or_save; 261 | $this->boxes = []; 262 | $this->locked = false; 263 | $this->target->valid() and do_action( self::REGISTER_BOXES, $this, $this->target, $show_or_save ); 264 | $this->locked = true; 265 | } 266 | 267 | /** 268 | * @param Metabox|PostMetabox|TermMetabox $box 269 | * @param string $type 270 | * 271 | * @return bool 272 | */ 273 | private function box_enabled( Metabox $box, string $type ): bool { 274 | 275 | if ( ! $this->target->valid() ) { 276 | return false; 277 | } 278 | 279 | $accept = false; 280 | /** @var \WP_Post|\WP_Term $object */ 281 | $object = $this->target->expose(); 282 | switch ( true ) { 283 | case $this->target->is( \WP_Post::class ) && $box instanceof PostMetabox: 284 | $accept = $box->accept_post( $object, $type ); 285 | break; 286 | case $this->target->is( \WP_Term::class ) && $box instanceof TermMetabox: 287 | $accept = $box->accept_term( $object, $type ); 288 | break; 289 | } 290 | 291 | return (bool) apply_filters( 'metabox-orchestra.box-enabled', $accept, $box, $object ); 292 | } 293 | 294 | /** 295 | * @param Metabox|PostMetabox|TermMetabox $box 296 | * @param string $box_id 297 | */ 298 | private function add_meta_box( Metabox $box, string $box_id ) { 299 | 300 | if ( ! $this->box_enabled( $box, Metabox::SHOW ) ) { 301 | return; 302 | } 303 | 304 | $is_post = $this->target->is( \WP_Post::class ); 305 | /** @var \WP_Post|\WP_Term $object */ 306 | $object = $this->target->expose(); 307 | $info = $box->create_info( Metabox::SHOW, $this->target ); 308 | $view = $is_post ? $box->view_for_post( $object ) : $box->view_for_term( $object ); 309 | 310 | $box_suffix = $is_post ? '-postbox' : '-termbox'; 311 | $context = $info->context(); 312 | $screen = $is_post ? null : "edit-{$object->taxonomy}"; 313 | ( $context === BoxInfo::CONTEXT_SIDE && $is_post ) and $screen = $object->post_type; 314 | 315 | add_meta_box( 316 | $box_id . $box_suffix, 317 | $info->title(), 318 | static function ( $object ) use ( $box_id, $view, $box, $info ) { 319 | 320 | $object_id = $object instanceof \WP_Post ? $object->ID : $object->term_id; 321 | 322 | print \Brain\Nonces\formField( new WpNonce( $box_id . "-{$object_id}" ) ); 323 | 324 | do_action( 'metabox-orchestra.inside-box-before', $box, $object, $info ); 325 | 326 | print $view->render( $info ); 327 | 328 | do_action( 'metabox-orchestra.inside-box-after', $box, $object, $info ); 329 | }, 330 | $screen, 331 | $context, 332 | $info->priority() 333 | ); 334 | } 335 | 336 | /** 337 | * @param \WP_Post $post 338 | */ 339 | private function on_post_save( \WP_Post $post ) { 340 | 341 | if ( 342 | wp_is_post_autosave( $post ) 343 | || wp_is_post_revision( $post ) 344 | || ( is_multisite() && ms_is_switched() ) 345 | ) { 346 | return; 347 | } 348 | 349 | // This check allows to edit post object inside BoxAction::save() without recursion. 350 | if ( $this->saving === 'post' ) { 351 | return; 352 | } 353 | 354 | static $saved; 355 | if ( $saved ) { 356 | return; 357 | } 358 | 359 | $saved = true; 360 | 361 | $entity = new Entity( $post ); 362 | 363 | $this->saving = 'post'; 364 | 365 | $this->prepare_target( $entity, Metabox::SAVE ); 366 | 367 | do_action( self::ACTION_SAVE, $entity ); 368 | 369 | array_walk( $this->boxes, [ $this, 'save_meta_box' ] ); 370 | 371 | do_action( self::ACTION_SAVED, $entity ); 372 | 373 | $this->release_target(); 374 | 375 | $this->saving = ''; 376 | } 377 | 378 | /** 379 | * @param Metabox|PostMetabox|TermMetabox $box 380 | * @param string $box_id 381 | */ 382 | private function save_meta_box( Metabox $box, string $box_id ) { 383 | 384 | if ( ! $this->box_enabled( $box, Metabox::SAVE ) ) { 385 | return; 386 | } 387 | 388 | /** @var \WP_Post|\WP_Term $object */ 389 | $object = $this->target->expose(); 390 | $is_post = $object instanceof \WP_Post; 391 | 392 | $screen = $is_post ? $object->post_type : "edit-{$object->taxonomy}"; 393 | $screen = apply_filters( 'metabox-orchestra.box-notices-screen', $screen, $box, $object ); 394 | $object_id = $this->target->id(); 395 | $auth_class = $is_post ? PostMetaboxAuth::class : TermMetaboxAuth::class; 396 | 397 | $notices = AdminNotices::init( (string) $screen ); 398 | 399 | /** @var PostMetaboxAuth|TermMetaboxAuth $auth */ 400 | $nonce = new WpNonce( "{$box_id}-{$object_id}" ); 401 | $auth = new $auth_class( $object, $nonce ); 402 | 403 | if ( ! $auth->authorized() ) { 404 | do_action( 'metabox-orchestra.unauthorized-box-save', $box, $object, $nonce ); 405 | 406 | return; 407 | } 408 | 409 | $action = $is_post ? $box->action_for_post( $object ) : $box->action_for_term( $object ); 410 | $action->save( $notices ); 411 | } 412 | 413 | /** 414 | * Clean up state. 415 | */ 416 | private function release_target() { 417 | $this->target = null; 418 | $this->registering_for = ''; 419 | $this->boxes = []; 420 | $this->locked = false; 421 | } 422 | 423 | } 424 | -------------------------------------------------------------------------------- /src/Entity.php: -------------------------------------------------------------------------------- 1 | entity = $object; 46 | $this->id = (int) $object->ID; 47 | break; 48 | case ( $object instanceof \WP_Term ): 49 | $this->entity = $object; 50 | $this->id = (int) $object->term_id; 51 | break; 52 | case ( $object instanceof Entity ) : 53 | $this->entity = $object->expose(); 54 | $this->id = $object->id(); 55 | break; 56 | } 57 | } 58 | 59 | /** 60 | * @param string $var 61 | * 62 | * @return mixed 63 | */ 64 | public function __get( string $var ) { 65 | 66 | return $this->prop( $var ); 67 | } 68 | 69 | /** 70 | * @return int 71 | */ 72 | public function id(): int { 73 | 74 | return $this->id; 75 | } 76 | 77 | /** 78 | * @return bool 79 | */ 80 | public function valid(): bool { 81 | 82 | return $this->entity && $this->id() > 0; 83 | } 84 | 85 | /** 86 | * @param string $type 87 | * 88 | * @return bool 89 | */ 90 | public function is( string $type ): bool { 91 | 92 | return $this->valid() && is_a( $this->entity, $type ); 93 | } 94 | 95 | /** 96 | * @param string $prop 97 | * @param null $default 98 | * 99 | * @return mixed 100 | */ 101 | public function prop( string $prop, $default = null ) { 102 | 103 | if ( ! $this->valid() ) { 104 | 105 | return $default; 106 | } 107 | 108 | if ( is_array( $this->entity_array ) ) { 109 | 110 | return $this->entity_array[ $prop ] ?? $default; 111 | } 112 | 113 | if ( is_callable( [ $this->entity, 'to_array' ] ) ) { 114 | $this->entity_array = $this->entity->to_array(); 115 | 116 | return $this->entity_array[ $prop ] ?? $default; 117 | } 118 | 119 | $this->entity_array = get_object_vars( $this->entity ); 120 | 121 | return $this->entity_array[ $prop ] ?? $default; 122 | } 123 | 124 | /** 125 | * @return object|null 126 | */ 127 | public function expose() { 128 | 129 | return $this->valid() ? clone $this->entity : null; 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /src/Metabox.php: -------------------------------------------------------------------------------- 1 | post = $post; 39 | $this->nonce = $nonce; 40 | } 41 | 42 | /** 43 | * @return bool 44 | */ 45 | public function authorized(): bool { 46 | 47 | $type = get_post_type_object( $this->post->post_type ); 48 | 49 | if ( ! current_user_can( $type->cap->edit_post, $this->post->ID ) ) { 50 | do_action( 51 | 'metabox-orchestra.error', 52 | new \Error( 'User not allowed to save post.', (int) $this->post->ID ), 53 | [ 54 | 'post' => $this->post->ID, 55 | 'capability' => $type->cap->edit_post, 56 | 'blog' => get_current_blog_id(), 57 | ] 58 | ); 59 | 60 | return FALSE; 61 | } 62 | 63 | if ( is_multisite() && ms_is_switched() ) { 64 | return FALSE; 65 | } 66 | 67 | $context = new ArrayContext( $_POST ); 68 | $valid = $this->nonce->validate( $context ); 69 | 70 | if ( ! $valid ) { 71 | $action = $this->nonce->action(); 72 | do_action( 73 | 'metabox-orchestra.error', 74 | new \Error( 'Nonce did not validated.', (int) $this->post->ID ), 75 | [ 76 | 'post' => $this->post->ID, 77 | 'nonce_action' => $action, 78 | 'nonce_value' => $context->offsetExists( $action ) ? $context[ $action ] : '*not present*', 79 | 'blog' => get_current_blog_id(), 80 | ] 81 | ); 82 | } 83 | 84 | return $valid; 85 | } 86 | } -------------------------------------------------------------------------------- /src/TermMetabox.php: -------------------------------------------------------------------------------- 1 | term = $term; 39 | $this->nonce = $nonce; 40 | } 41 | 42 | /** 43 | * @return bool 44 | */ 45 | public function authorized(): bool { 46 | 47 | $taxonomy = get_taxonomy( $this->term->taxonomy ); 48 | 49 | if ( ! $taxonomy || ! current_user_can( $taxonomy->cap->edit_terms ) ) { 50 | do_action( 51 | 'metabox-orchestra.error', 52 | new \Error( 'User not allowed to save term.', (int) $this->term->term_id ), 53 | [ 54 | 'term' => $this->term->term_id, 55 | 'capability' => $taxonomy ? $taxonomy->capabilitites->edit_terms : '*taxonomy does not exists*', 56 | 'blog' => get_current_blog_id(), 57 | ] 58 | ); 59 | 60 | return FALSE; 61 | } 62 | 63 | if ( is_multisite() && ms_is_switched() ) { 64 | return FALSE; 65 | } 66 | 67 | $context = new ArrayContext( $_POST ); 68 | $valid = $this->nonce->validate( $context ); 69 | 70 | if ( ! $valid ) { 71 | $action = $this->nonce->action(); 72 | do_action( 73 | 'metabox-orchestra.error', 74 | new \Error( 'Nonce did not validated.', (int) $this->term->term_id ), 75 | [ 76 | 'term' => $this->term->term_id, 77 | 'nonce_action' => $action, 78 | 'nonce_value' => $context->offsetExists( $action ) ? $context[ $action ] : '*not present*', 79 | 'blog' => get_current_blog_id(), 80 | ] 81 | ); 82 | } 83 | 84 | return $valid; 85 | } 86 | } -------------------------------------------------------------------------------- /tests/boot.php: -------------------------------------------------------------------------------- 1 | 18 | * @package MetaboxOrchestra 19 | * @license http://opensource.org/licenses/MIT MIT 20 | */ 21 | class AdminNoticesTest extends TestCase { 22 | 23 | public function testAddedNoticesAreRecorded() { 24 | 25 | Functions\when( 'get_current_user_id' )->justReturn( 1 ); 26 | Functions\when( 'get_current_screen' )->justReturn( (object) [ 'id' => 'screen_test' ] ); 27 | 28 | static::assertSame( get_current_screen()->id, 'screen_test' ); 29 | 30 | $notices = new AdminNotices(); 31 | 32 | $notices->add( 'This is an error', 'Error!', AdminNotices::ERROR ); 33 | $notices->add( 'This is a success', 'Success!', AdminNotices::SUCCESS ); 34 | 35 | Functions\expect( 'doing_action' ) 36 | ->with( 'shutdown' ) 37 | ->andReturn( TRUE ); 38 | 39 | Functions\expect( 'update_user_option' ) 40 | ->once() 41 | ->with( 1, AdminNotices::OPTION_NAME, \Mockery::type( 'array' ) ) 42 | ->andReturnUsing( 43 | function ( $id, $option, $messages ) { 44 | 45 | static::assertArrayHasKey( 'screen_test', $messages ); 46 | static::assertArrayHasKey( AdminNotices::ERROR, $messages[ 'screen_test' ] ); 47 | static::assertArrayHasKey( AdminNotices::SUCCESS, $messages[ 'screen_test' ] ); 48 | static::assertIsArray( $messages[ 'screen_test' ][ AdminNotices::ERROR ] ); 49 | static::assertIsArray( $messages[ 'screen_test' ][ AdminNotices::SUCCESS ] ); 50 | static::assertCount( 1, $messages[ 'screen_test' ][ AdminNotices::ERROR ] ); 51 | static::assertCount( 1, $messages[ 'screen_test' ][ AdminNotices::SUCCESS ] ); 52 | 53 | $error = reset( $messages[ 'screen_test' ][ AdminNotices::ERROR ] ); 54 | $success = reset( $messages[ 'screen_test' ][ AdminNotices::SUCCESS ] ); 55 | 56 | static::assertIsArray( $error ); 57 | static::assertIsArray( $success ); 58 | static::assertContains( 'This is an error', $error ); 59 | static::assertContains( 'Error!', $error ); 60 | static::assertContains( 'This is a success', $success ); 61 | static::assertContains( 'Success!', $success ); 62 | } 63 | ); 64 | 65 | $notices->record(); 66 | } 67 | 68 | public function testAddedNoticesArePrinted() { 69 | 70 | Functions\when( 'get_current_user_id' )->justReturn( 123 ); 71 | Functions\when( 'get_current_screen' )->justReturn( (object) [ 'id' => 'screen_test' ] ); 72 | 73 | $notices = new AdminNotices(); 74 | 75 | $notices->add( 'This is an error', 'Error!', AdminNotices::ERROR ); 76 | $notices->add( 'This is a success', 'Success!', AdminNotices::SUCCESS ); 77 | 78 | $to_print = NULL; 79 | 80 | Functions\expect( 'doing_action' ) 81 | ->once() 82 | ->with( 'shutdown' ) 83 | ->andReturn( TRUE ); 84 | Functions\expect( 'update_user_option' ) 85 | ->andReturnUsing( 86 | function ( $id, $option, $messages ) use ( &$to_print ) { 87 | 88 | $to_print = $messages; 89 | 90 | return TRUE; 91 | } 92 | ); 93 | 94 | $notices->record(); 95 | 96 | Functions\expect( 'doing_action' ) 97 | ->once() 98 | ->with( 'admin_notices' ) 99 | ->andReturn( TRUE ); 100 | Functions\expect( 'get_user_option' ) 101 | ->once() 102 | ->with( AdminNotices::OPTION_NAME, 123 ) 103 | ->andReturn( $to_print ); 104 | Functions\expect( 'delete_user_option' ) 105 | ->once() 106 | ->with( 123, AdminNotices::OPTION_NAME ); 107 | Functions\expect( 'esc_html' )->andReturnFirstArg(); 108 | Functions\expect( 'wp_kses_post' )->andReturnFirstArg(); 109 | 110 | ob_start(); 111 | $notices->do_notices(); 112 | $output = ob_get_clean(); 113 | 114 | static::assertStringContainsString( 'notice-' . AdminNotices::ERROR, $output ); 115 | static::assertStringContainsString( 'notice-' . AdminNotices::SUCCESS, $output ); 116 | static::assertStringContainsString( 'Error!', $output ); 117 | static::assertStringContainsString( 'Success!', $output ); 118 | static::assertStringContainsString( 'This is an error', $output ); 119 | static::assertStringContainsString( 'This is a success', $output ); 120 | } 121 | 122 | public function testAddNoUserId() { 123 | 124 | Functions\expect( 'get_current_user_id' ) 125 | ->once() 126 | ->andReturn( FALSE ); 127 | 128 | $testee = new AdminNotices(); 129 | static::assertInstanceOf( AdminNotices::class, $testee->add( '' ) ); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /tests/src/BootstrapTest.php: -------------------------------------------------------------------------------- 1 | justReturn( TRUE ); 20 | Actions\expectAdded( 'admin_menu' ); 21 | 22 | static::assertTrue( Bootstrap::bootstrap() ); 23 | static::assertFalse( Bootstrap::bootstrap() ); 24 | } 25 | 26 | public function testBootstrapInFrontend() { 27 | 28 | Functions\when( 'is_admin' )->justReturn( FALSE ); 29 | 30 | static::assertFalse( Bootstrap::bootstrap() ); 31 | } 32 | } -------------------------------------------------------------------------------- /tests/src/BoxInfoTest.php: -------------------------------------------------------------------------------- 1 | title() ); 18 | static::assertSame( 'foo', $testee->id() ); 19 | static::assertSame( BoxInfo::PRIORITY_ADVANCED, $testee->priority() ); 20 | static::assertSame( BoxInfo::CONTEXT_ADVANCED, $testee->context() ); 21 | } 22 | 23 | public function testConstructorId() { 24 | 25 | Functions\stubs( [ 'sanitize_title_with_dashes' ] ); 26 | $expected = 'unique-id'; 27 | static::assertSame( $expected, ( new BoxInfo( 'foo', $expected ) )->id() ); 28 | } 29 | 30 | /** 31 | * @param string $input 32 | * @param null|string $expected 33 | * 34 | * @dataProvider provideConstructorContext 35 | */ 36 | public function testConstructorContext( $input, $expected = NULL ) { 37 | 38 | $expected = $expected ? : $input; 39 | Functions\stubs( [ 'sanitize_title_with_dashes' ] ); 40 | static::assertSame( 41 | $expected, 42 | ( new BoxInfo( 'foo', '', $input ) )->context() 43 | ); 44 | } 45 | 46 | public function provideConstructorContext() { 47 | 48 | return [ 49 | 'side' => [ BoxInfo::CONTEXT_SIDE ], 50 | 'normal' => [ BoxInfo::CONTEXT_NORMAL ], 51 | 'advanced' => [ BoxInfo::CONTEXT_ADVANCED ], 52 | 'invalid context' => [ 'foo', BoxInfo::CONTEXT_ADVANCED ] 53 | ]; 54 | } 55 | 56 | /** 57 | * @param string $input 58 | * @param null|string $expected 59 | * 60 | * @dataProvider provideConstructorPriority 61 | */ 62 | public function testConstructorPriority( $input, $expected = NULL ) { 63 | 64 | $expected = $expected ? : $input; 65 | Functions\stubs( [ 'sanitize_title_with_dashes' ] ); 66 | static::assertSame( 67 | $expected, 68 | ( new BoxInfo( 'foo', '', '', $input ) )->priority() 69 | ); 70 | } 71 | 72 | public function provideConstructorPriority() { 73 | 74 | return [ 75 | 'high' => [ BoxInfo::PRIORITY_HIGH ], 76 | 'sorted' => [ BoxInfo::PRIORITY_SORTED ], 77 | 'core' => [ BoxInfo::PRIORITY_CORE ], 78 | 'normal' => [ BoxInfo::PRIORITY_NORMAL ], 79 | 'advanced' => [ BoxInfo::PRIORITY_ADVANCED ], 80 | 'invalid priority' => [ 'foo', BoxInfo::PRIORITY_ADVANCED ] 81 | ]; 82 | } 83 | 84 | public function testArrayAccess() { 85 | 86 | Functions\stubs( [ 'sanitize_title_with_dashes' ] ); 87 | $testee = new BoxInfo( '' ); 88 | 89 | $key = 'foo'; 90 | $expected = 'bar'; 91 | 92 | $testee[ $key ] = $expected; 93 | 94 | static::assertSame( $expected, $testee[ $key ] ); 95 | static::assertTrue( isset( $testee[ $key ] ) ); 96 | 97 | unset( $testee[ $key ] ); 98 | 99 | static::assertFalse( isset( $testee[ $key ] ) ); 100 | static::assertNull( $testee[ $key ] ); 101 | } 102 | } -------------------------------------------------------------------------------- /tests/src/NoopBoxActionTest.php: -------------------------------------------------------------------------------- 1 | save( $stub ) ); 23 | } 24 | } -------------------------------------------------------------------------------- /tests/src/NoopBoxViewTest.php: -------------------------------------------------------------------------------- 1 | render( new BoxInfo( '', '', '' ) ) 26 | ); 27 | } 28 | } -------------------------------------------------------------------------------- /tests/src/PostMetaboxAuthTest.php: -------------------------------------------------------------------------------- 1 | 20 | * @package MetaboxOrchestra 21 | * @license http://opensource.org/licenses/MIT MIT 22 | */ 23 | class PostMetaboxAuthTest extends TestCase { 24 | 25 | public function testAuthorizedFailsIfUserCantEditPost() { 26 | 27 | /** @var \stdClass|\WP_Post $post */ 28 | $post = \Mockery::mock( '\WP_Post' ); 29 | $post->ID = 123; 30 | $post->post_type = 'post'; 31 | /** @var \Mockery\MockInterface|NonceInterface $nonce */ 32 | $nonce = \Mockery::mock( NonceInterface::class ); 33 | 34 | Functions\when( 'get_current_blog_id' )->justReturn( 1 ); 35 | 36 | Functions\expect( 'get_post_type_object' ) 37 | ->with( 'post' ) 38 | ->andReturn( (object) [ 'cap' => (object) [ 'edit_post' => 'edit_post' ] ] ); 39 | 40 | Functions\expect( 'current_user_can' ) 41 | ->with( 'edit_post', 123 ) 42 | ->andReturn( FALSE ); 43 | 44 | Actions\expectDone( 'metabox-orchestra.error' ) 45 | ->once() 46 | ->with( \Mockery::type( 'Error' ), \Mockery::type( 'array' ) ); 47 | 48 | $auth = new PostMetaboxAuth( $post, $nonce ); 49 | 50 | static::assertFalse( $auth->authorized() ); 51 | } 52 | 53 | public function testAuthorizedFailsIfMultisiteSwitched() { 54 | 55 | /** @var \stdClass|\WP_Post $post */ 56 | $post = \Mockery::mock( '\WP_Post' ); 57 | $post->ID = 123; 58 | $post->post_type = 'post'; 59 | /** @var \Mockery\MockInterface|NonceInterface $nonce */ 60 | $nonce = \Mockery::mock( NonceInterface::class ); 61 | 62 | Functions\when( 'get_post_type_object' )->justReturn( (object) [ 'cap' => (object) [ 'edit_post' => 'edit_post' ] ] ); 63 | Functions\when( 'current_user_can' )->justReturn( TRUE ); 64 | Functions\when( 'is_multisite' )->justReturn( TRUE ); 65 | Functions\when( 'ms_is_switched' )->justReturn( TRUE ); 66 | 67 | Actions\expectDone( 'metabox-orchestra.error' )->never(); 68 | 69 | $auth = new PostMetaboxAuth( $post, $nonce ); 70 | 71 | static::assertFalse( $auth->authorized() ); 72 | } 73 | 74 | public function testAuthorizedFailsIfNonceFails() { 75 | 76 | /** @var \stdClass|\WP_Post $post */ 77 | $post = \Mockery::mock( '\WP_Post' ); 78 | $post->ID = 123; 79 | $post->post_type = 'post'; 80 | /** @var \Mockery\MockInterface|NonceInterface $nonce */ 81 | $nonce = \Mockery::mock( NonceInterface::class ); 82 | $nonce->shouldReceive( 'validate' )->atLeast()->once()->andReturn( FALSE ); 83 | $nonce->shouldReceive( 'action' )->atLeast()->once()->andReturn( 'test' ); 84 | 85 | Functions\when( 'get_current_blog_id' )->justReturn( 1 ); 86 | Functions\when( 'get_post_type_object' )->justReturn( (object) [ 'cap' => (object) [ 'edit_post' => 'edit_post' ] ] ); 87 | Functions\when( 'current_user_can' )->justReturn( TRUE ); 88 | Functions\when( 'is_multisite' )->justReturn( TRUE ); 89 | Functions\when( 'ms_is_switched' )->justReturn( FALSE ); 90 | 91 | $auth = new PostMetaboxAuth( $post, $nonce ); 92 | 93 | Actions\expectDone( 'metabox-orchestra.error' ) 94 | ->once() 95 | ->with( \Mockery::type( 'Error' ), \Mockery::type( 'array' ) ) 96 | ->whenHappen( function ( \Error $error ) { 97 | static::assertStringContainsString( 'Nonce', $error->getMessage() ); 98 | } ); 99 | 100 | static::assertFalse( $auth->authorized() ); 101 | } 102 | 103 | public function testAuthorizedSuccess() { 104 | 105 | /** @var \stdClass|\WP_Post $post */ 106 | $post = \Mockery::mock( '\WP_Post' ); 107 | $post->ID = 123; 108 | $post->post_type = 'post'; 109 | /** @var \Mockery\MockInterface|NonceInterface $nonce */ 110 | $nonce = \Mockery::mock( NonceInterface::class ); 111 | $nonce->shouldReceive( 'validate' )->atLeast()->once()->andReturn( TRUE ); 112 | 113 | Functions\when( 'get_current_blog_id' )->justReturn( 1 ); 114 | Functions\when( 'get_post_type_object' )->justReturn( (object) [ 'cap' => (object) [ 'edit_post' => 'edit_post' ] ] ); 115 | Functions\when( 'current_user_can' )->justReturn( TRUE ); 116 | Functions\when( 'is_multisite' )->justReturn( TRUE ); 117 | Functions\when( 'ms_is_switched' )->justReturn( FALSE ); 118 | 119 | $auth = new PostMetaboxAuth( $post, $nonce ); 120 | 121 | Actions\expectDone( 'metabox-orchestra.error' )->never(); 122 | 123 | static::assertTrue( $auth->authorized() ); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /tests/src/TermMetaboxAuthTest.php: -------------------------------------------------------------------------------- 1 | taxonomy = NULL; 28 | $wp_term->term_id = NULL; 29 | 30 | /** @var NonceInterface $nonce */ 31 | $nonce = \Mockery::mock( NonceInterface::class ); 32 | 33 | Functions\expect( 'get_taxonomy' ) 34 | ->once() 35 | ->andReturn( FALSE ); 36 | 37 | Functions\expect( 'current_user_can' )->never(); 38 | 39 | Functions\expect( 'get_current_blog_id' ) 40 | ->once() 41 | ->andReturn( NULL ); 42 | 43 | Actions\expectDone( 'metabox-orchestra.error' ) 44 | ->once() 45 | ->with( \Mockery::type( \Error::class ), \Mockery::type( 'array' ) ); 46 | 47 | static::assertFalse( ( new TermMetaboxAuth( $wp_term, $nonce ) )->authorized() ); 48 | } 49 | 50 | public function testAuthorizedCurrentUserCanNot() { 51 | 52 | /** @var \WP_Term $wp_term */ 53 | $wp_term = \Mockery::mock( '\WP_Term' ); 54 | $wp_term->taxonomy = NULL; 55 | $wp_term->term_id = NULL; 56 | 57 | /** @var NonceInterface $nonce */ 58 | $nonce = \Mockery::mock( NonceInterface::class ); 59 | 60 | Functions\expect( 'get_taxonomy' ) 61 | ->once() 62 | ->andReturn( $this->taxonomy() ); 63 | 64 | Functions\expect( 'current_user_can' ) 65 | ->once() 66 | ->andReturn( FALSE ); 67 | 68 | Functions\expect( 'get_current_blog_id' ) 69 | ->once() 70 | ->andReturn( NULL ); 71 | 72 | Actions\expectDone( 'metabox-orchestra.error' ) 73 | ->once() 74 | ->with( \Mockery::type( \Error::class ), \Mockery::type( 'array' ) ); 75 | 76 | static::assertFalse( ( new TermMetaboxAuth( $wp_term, $nonce ) )->authorized() ); 77 | } 78 | 79 | public function testAuthorizedMultisite() { 80 | 81 | /** @var \WP_Term $wp_term */ 82 | $wp_term = \Mockery::mock( '\WP_Term' ); 83 | $wp_term->taxonomy = NULL; 84 | 85 | /** @var NonceInterface $nonce */ 86 | $nonce = \Mockery::mock( NonceInterface::class ); 87 | 88 | Functions\expect( 'get_taxonomy' ) 89 | ->once() 90 | ->andReturn( $this->taxonomy() ); 91 | 92 | Functions\expect( 'current_user_can' ) 93 | ->once() 94 | ->andReturn( TRUE ); 95 | 96 | Functions\expect( 'is_multisite' ) 97 | ->once() 98 | ->andReturn( TRUE ); 99 | 100 | Functions\expect( 'ms_is_switched' ) 101 | ->once() 102 | ->andReturn( TRUE ); 103 | 104 | static::assertFalse( ( new TermMetaboxAuth( $wp_term, $nonce ) )->authorized() ); 105 | } 106 | 107 | public function testAuthorizedInvalidNonce() { 108 | 109 | /** @var \WP_Term $wp_term */ 110 | $wp_term = \Mockery::mock( '\WP_Term' ); 111 | $wp_term->taxonomy = NULL; 112 | $wp_term->term_id = NULL; 113 | 114 | /** @var NonceInterface $nonce */ 115 | $nonce = \Mockery::mock( NonceInterface::class ); 116 | $nonce->shouldReceive( 'validate' ) 117 | ->once() 118 | ->andReturn( FALSE ); 119 | $nonce->shouldReceive( 'action' ) 120 | ->once() 121 | ->andReturn( '' ); 122 | 123 | Functions\expect( 'get_taxonomy' ) 124 | ->once() 125 | ->andReturn( $this->taxonomy() ); 126 | 127 | Functions\expect( 'current_user_can' ) 128 | ->once() 129 | ->andReturn( TRUE ); 130 | 131 | Functions\expect( 'is_multisite' ) 132 | ->once() 133 | ->andReturn( FALSE ); 134 | 135 | Functions\expect( 'ms_is_switched' ) 136 | ->never(); 137 | 138 | Actions\expectDone( 'metabox-orchestra.error' ) 139 | ->once() 140 | ->with( \Mockery::type( \Error::class ), \Mockery::type( 'array' ) ); 141 | 142 | Functions\expect( 'get_current_blog_id' ) 143 | ->once() 144 | ->andReturn( NULL ); 145 | 146 | static::assertFalse( ( new TermMetaboxAuth( $wp_term, $nonce ) )->authorized() ); 147 | } 148 | 149 | public function testAuthorizedValidNonce() { 150 | 151 | /** @var \WP_Term $wp_term */ 152 | $wp_term = \Mockery::mock( '\WP_Term' ); 153 | $wp_term->taxonomy = NULL; 154 | 155 | Functions\expect( 'get_taxonomy' ) 156 | ->once() 157 | ->andReturn( $this->taxonomy() ); 158 | 159 | Functions\expect( 'current_user_can' ) 160 | ->once() 161 | ->andReturn( TRUE ); 162 | 163 | Functions\expect( 'is_multisite' ) 164 | ->once() 165 | ->andReturn( FALSE ); 166 | 167 | Functions\expect( 'ms_is_switched' ) 168 | ->never(); 169 | 170 | /** @var NonceInterface $nonce */ 171 | $nonce = \Mockery::mock( NonceInterface::class ); 172 | $nonce->shouldReceive( 'validate' ) 173 | ->once() 174 | ->andReturn( TRUE ); 175 | 176 | static::assertTrue( ( new TermMetaboxAuth( $wp_term, $nonce ) )->authorized() ); 177 | } 178 | 179 | /** 180 | * Internal function for re-usage as response for get_taxonomy()-mock. 181 | * 182 | * @return \stdClass 183 | */ 184 | private function taxonomy(): \stdClass { 185 | 186 | return (object) [ 187 | 'cap' => (object) [ 'edit_terms' => '' ], 188 | 'capabilitites' => (object) [ 'edit_terms' => '' ] 189 | ]; 190 | } 191 | } -------------------------------------------------------------------------------- /tests/src/TestCase.php: -------------------------------------------------------------------------------- 1 |