├── .github └── workflows │ ├── php.yml │ └── release.yml ├── .gitignore ├── .releaserc ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── composer.json ├── composer.lock ├── grumphp.yml ├── meta-schema-draft4.json ├── package.json ├── phpunit.xml ├── src ├── ArrayToStdClass.php ├── Drupal │ ├── FormGeneratorDrupal.php │ ├── FormValidatorDrupal.php │ └── UserInputCleaner.php ├── FormGeneratorInterface.php ├── JsonSchemaFormValidator.php ├── RecursiveTypeCaster.php └── RenderArrayValidator.php └── tests └── src ├── ArrayToStdClassTest.php ├── Drupal └── SchemaForms │ └── FormGeneratorDrupalTest.php └── RenderArrayValidatorTest.php /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP Composer 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Validate composer.json and composer.lock 21 | run: composer validate --strict 22 | 23 | - name: Cache Composer packages 24 | id: composer-cache 25 | uses: actions/cache@v3 26 | with: 27 | path: vendor 28 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-php- 31 | 32 | - name: Install dependencies 33 | run: composer install --prefer-dist --no-progress 34 | 35 | - name: Run test suite 36 | run: composer run-script test 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | tag: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v3 12 | 13 | - name: Create npm deps 14 | run: yarn init --yes && yarn add --dev @semantic-release/commit-analyzer @semantic-release/release-notes-generator @semantic-release/github @semantic-release/git conventional-changelog-conventionalcommits 15 | 16 | - name: Tag a new release 17 | uses: cycjimmy/semantic-release-action@v2 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /build 3 | /.phpunit.result.cache 4 | .*.swp 5 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "master" 4 | ], 5 | "plugins": [ 6 | [ 7 | "@semantic-release/commit-analyzer", 8 | { 9 | "preset": "conventionalcommits" 10 | } 11 | ], 12 | "@semantic-release/release-notes-generator", 13 | "@semantic-release/github", 14 | "@semantic-release/git", 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at mateu@mateuaguilo.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Schema Forms 2 | Schema forms is a project that aims to generate form structures for the different PHP frameworks based on the data definitions in JSON Schema. 3 | 4 | Frameworks supported: 5 | - Drupal. 6 | 7 | # Usage 8 | Given the following JSON Schema defining the different properties of a user object. 9 | ```json 10 | { 11 | "title": "A registration form", 12 | "description": "A simple form example.", 13 | "type": "object", 14 | "required": [ 15 | "firstName", 16 | "lastName" 17 | ], 18 | "properties": { 19 | "firstName": { 20 | "type": "string", 21 | "title": "First name", 22 | "default": "Chuck" 23 | }, 24 | "lastName": { 25 | "type": "string", 26 | "title": "Last name" 27 | }, 28 | "age": { 29 | "type": "integer", 30 | "title": "Age", 31 | "description": "(earthian year)" 32 | }, 33 | "bio": { 34 | "type": "string", 35 | "title": "Bio" 36 | }, 37 | "password": { 38 | "type": "string", 39 | "title": "Password", 40 | "minLength": 3, 41 | "description": "The key to get in." 42 | }, 43 | "telephone": { 44 | "type": "string", 45 | "title": "Telephone", 46 | "minLength": 10 47 | } 48 | } 49 | ``` 50 | 51 | And the following UI JSON Schema refining the form generation process: 52 | ```json 53 | { 54 | "firstName": { 55 | "ui:autofocus": true, 56 | "ui:emptyValue": "" 57 | }, 58 | "age": { 59 | "ui:widget": "updown", 60 | "ui:title": "Age of person" 61 | }, 62 | "bio": { 63 | "ui:widget": "textarea" 64 | }, 65 | "password": { 66 | "ui:widget": "password", 67 | "ui:help": "Hint: Make it strong!" 68 | }, 69 | "date": { 70 | "ui:widget": "alt-datetime" 71 | }, 72 | "telephone": { 73 | "ui:options": { 74 | "inputType": "tel" 75 | } 76 | } 77 | } 78 | ``` 79 | 80 | Execute this PHP code: 81 | ```php 82 | use SchemaForms\Drupal\FormGeneratorDrupal; 83 | $generator = new FormGeneratorDrupal(); 84 | $context = new Context(['ui_hints => $ui_schema_data]); 85 | $actual_form = $generator->transform($schema_data, $context); 86 | // It generates the following Drupal Form API form: 87 | [ 88 | 'firstName' => [ 89 | '#type' => 'textfield', 90 | '#title' => 'First name', 91 | '#required' => TRUE, 92 | ], 93 | 'lastName' => [ 94 | '#type' => 'textfield', 95 | '#title' => 'Last name', 96 | '#required' => TRUE, 97 | ], 98 | 'age' => [ 99 | '#type' => 'number', 100 | '#title' => 'Age of person', 101 | '#description' => '(earthian year)' 102 | ], 103 | 'bio' => [ 104 | '#type' => 'textarea', 105 | '#title' => 'Bio', 106 | ], 107 | 'password' => [ 108 | '#type' => 'password', 109 | '#title' => 'Password', 110 | '#description' => 'Hint: Make it strong!' 111 | ], 112 | 'telephone' => [ 113 | '#type' => 'telephone', 114 | '#title' => 'Telephone', 115 | ], 116 | ]; 117 | ``` 118 | 119 | ### UI Schema Data 120 | Based on the shape of the data described by the JSON Schema, this library can generate a form. 121 | However, there are multiple ways to generate a form for the same shape of data. The UI schema data 122 | allows you to control the form elements and inputs that will collect the data in the appropriate way. 123 | 124 | Supported UI controls are: 125 | 126 | - `$ui_form_data['ui:title']` 127 | 128 | Controls the label associated to the input element. Defaults to the element's `title` property in the JSON Schema. 129 | - `$ui_form_data['ui:help']` 130 | 131 | Adds a hint to the input element. Defaults to the element's `description` property in the JSON Schema. 132 | - `$ui_form_data['ui:placeholder']` 133 | 134 | Adds a placeholder text to the input. 135 | - `$ui_form_data['ui:widget']` 136 | 137 | Lets you use al alternative input element. For instance, it lets you use ``, or use ``, among others. 139 | - `$ui_form_data['ui:enabled']` 140 | 141 | If 0 the form element will be rendered as non-interactive. 142 | - `$ui_form_data['ui:visible']` 143 | 144 | If 0 the form element will not be rendered. 145 | - `$ui_form_data['ui:enum']` 146 | 147 | Lets you define how the options for selects and radios are populated. By default, the enum information in the schema 148 | defines the options. This might not be enough, or even possible. 149 | - `$ui_form_data['ui:enum']['labels']['mappings']` 150 | 151 | An object defining the label for each key. Ex: `{"uuid1": "Super duper product"}`. 152 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "e0ipso/schema-forms", 3 | "description": "Creates form definitions from JSON Schema property definitions and display configuration.", 4 | "license": "GPL-2.0-or-later", 5 | "type": "library", 6 | "authors": [ 7 | { 8 | "name": "Mateu Aguiló Bosch", 9 | "email": "mateu@mateuaguilo.com" 10 | } 11 | ], 12 | "require": { 13 | "php": ">=7.4", 14 | "ext-json": "*", 15 | "e0ipso/shaper": "^1.2", 16 | "justinrainbow/json-schema": "^5.2", 17 | "symfony/polyfill-php81": "^1.26" 18 | }, 19 | "require-dev": { 20 | "drupal/coder": "^8.3", 21 | "drupal/core-dev": "^9.4", 22 | "drupal/core-recommended": "^9.4", 23 | "enlightn/security-checker": "^1.7", 24 | "ergebnis/composer-normalize": "^2.28", 25 | "nikic/php-parser": "^4.10", 26 | "php-parallel-lint/php-parallel-lint": "^1.3", 27 | "phpro/grumphp-shim": "^1.13", 28 | "phpunit/phpunit": "^9.5", 29 | "povils/phpmnd": "^2.4", 30 | "sebastian/phpcpd": "^6.0", 31 | "squizlabs/php_codesniffer": "^3.6" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "SchemaForms\\": "src", 36 | "SchemaForms\\Tests\\": "tests/src" 37 | } 38 | }, 39 | "config": { 40 | "allow-plugins": { 41 | "dealerdirect/phpcodesniffer-composer-installer": true, 42 | "ergebnis/composer-normalize": true, 43 | "phpro/grumphp-shim": true 44 | } 45 | }, 46 | "scripts": { 47 | "test": "php vendor/bin/grumphp.phar run" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /grumphp.yml: -------------------------------------------------------------------------------- 1 | grumphp: 2 | additional_info: "\nVisit https://github.com/e0ipso/schema-forms-php#readme for documentation!\n" 3 | hooks_preset: local 4 | stop_on_failure: false 5 | ignore_unstaged_changes: false 6 | hide_circumvention_tip: false 7 | process_timeout: 60 8 | tasks: 9 | git_commit_message: 10 | enforce_capitalized_subject: false 11 | type_scope_conventions: 12 | - types: ['build', 'ci', 'chore', 'docs', 'feat', 'fix', 'perf', 'refactor', 'revert', 'style', 'test'] 13 | - scopes: ['schema', 'form', 'drupal', 'api'] 14 | max_subject_width: 50 15 | enforce_no_subject_punctuations: true 16 | enforce_no_subject_trailing_period: true 17 | git_branch_name: 18 | whitelist: 19 | - '/(build|ci|chore|docs|feat|fix|perf|refactor|revert|style|test)\/[a-z0-9-]+/' 20 | - master 21 | - main 22 | phpunit: ~ 23 | phpcs: 24 | standard: 25 | - vendor/drupal/coder/coder_sniffer/Drupal 26 | - vendor/drupal/coder/coder_sniffer/DrupalPractice 27 | tab_width: 2 28 | ignore_patterns: ['.github', '.gitlab', 'bower_components', 'node_modules', 'vendor', '/tests/.*'] 29 | triggered_by: ['php', 'module', 'inc', 'install', 'test', 'profile', 'theme'] 30 | phpcpd: 31 | fuzzy: true 32 | phpmnd: 33 | hint: true 34 | triggered_by: ['php', 'module'] 35 | securitychecker_enlightn: 36 | lockfile: ./composer.lock 37 | run_always: false 38 | phplint: 39 | exclude: [] 40 | jobs: ~ 41 | short_open_tag: false 42 | ignore_patterns: [] 43 | triggered_by: ['php', 'module', 'inc', 'install', 'profile', 'theme'] 44 | yamllint: ~ 45 | composer: ~ 46 | composer_normalize: ~ 47 | jsonlint: ~ 48 | phpparser: 49 | visitors: 50 | no_exit_statements: ~ 51 | never_use_else: ~ 52 | forbidden_function_calls: 53 | blacklist: [var_dump, kint] 54 | metadata: 55 | priority: 100000 56 | -------------------------------------------------------------------------------- /meta-schema-draft4.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "http://json-schema.org/draft-04/schema#", 3 | "$schema": "http://json-schema.org/draft-04/schema#", 4 | "description": "Core schema meta-schema", 5 | "definitions": { 6 | "schemaArray": { 7 | "type": "array", 8 | "minItems": 1, 9 | "items": { "$ref": "#" } 10 | }, 11 | "positiveInteger": { 12 | "type": "integer", 13 | "minimum": 0 14 | }, 15 | "positiveIntegerDefault0": { 16 | "allOf": [ { "$ref": "#/definitions/positiveInteger" }, { "default": 0 } ] 17 | }, 18 | "simpleTypes": { 19 | "enum": [ "array", "boolean", "integer", "null", "number", "object", "string" ] 20 | }, 21 | "stringArray": { 22 | "type": "array", 23 | "items": { "type": "string" }, 24 | "minItems": 1, 25 | "uniqueItems": true 26 | } 27 | }, 28 | "type": "object", 29 | "properties": { 30 | "id": { 31 | "type": "string" 32 | }, 33 | "$schema": { 34 | "type": "string" 35 | }, 36 | "title": { 37 | "type": "string" 38 | }, 39 | "description": { 40 | "type": "string" 41 | }, 42 | "default": {}, 43 | "multipleOf": { 44 | "type": "number", 45 | "minimum": 0, 46 | "exclusiveMinimum": true 47 | }, 48 | "maximum": { 49 | "type": "number" 50 | }, 51 | "exclusiveMaximum": { 52 | "type": "boolean", 53 | "default": false 54 | }, 55 | "minimum": { 56 | "type": "number" 57 | }, 58 | "exclusiveMinimum": { 59 | "type": "boolean", 60 | "default": false 61 | }, 62 | "maxLength": { "$ref": "#/definitions/positiveInteger" }, 63 | "minLength": { "$ref": "#/definitions/positiveIntegerDefault0" }, 64 | "pattern": { 65 | "type": "string", 66 | "format": "regex" 67 | }, 68 | "additionalItems": { 69 | "anyOf": [ 70 | { "type": "boolean" }, 71 | { "$ref": "#" } 72 | ], 73 | "default": {} 74 | }, 75 | "items": { 76 | "anyOf": [ 77 | { "$ref": "#" }, 78 | { "$ref": "#/definitions/schemaArray" } 79 | ], 80 | "default": {} 81 | }, 82 | "maxItems": { "$ref": "#/definitions/positiveInteger" }, 83 | "minItems": { "$ref": "#/definitions/positiveIntegerDefault0" }, 84 | "uniqueItems": { 85 | "type": "boolean", 86 | "default": false 87 | }, 88 | "maxProperties": { "$ref": "#/definitions/positiveInteger" }, 89 | "minProperties": { "$ref": "#/definitions/positiveIntegerDefault0" }, 90 | "required": { "$ref": "#/definitions/stringArray" }, 91 | "additionalProperties": { 92 | "anyOf": [ 93 | { "type": "boolean" }, 94 | { "$ref": "#" } 95 | ], 96 | "default": {} 97 | }, 98 | "definitions": { 99 | "type": "object", 100 | "additionalProperties": { "$ref": "#" }, 101 | "default": {} 102 | }, 103 | "properties": { 104 | "type": "object", 105 | "additionalProperties": { "$ref": "#" }, 106 | "default": {} 107 | }, 108 | "patternProperties": { 109 | "type": "object", 110 | "additionalProperties": { "$ref": "#" }, 111 | "default": {} 112 | }, 113 | "dependencies": { 114 | "type": "object", 115 | "additionalProperties": { 116 | "anyOf": [ 117 | { "$ref": "#" }, 118 | { "$ref": "#/definitions/stringArray" } 119 | ] 120 | } 121 | }, 122 | "enum": { 123 | "type": "array", 124 | "minItems": 1, 125 | "uniqueItems": true 126 | }, 127 | "type": { 128 | "anyOf": [ 129 | { "$ref": "#/definitions/simpleTypes" }, 130 | { 131 | "type": "array", 132 | "items": { "$ref": "#/definitions/simpleTypes" }, 133 | "minItems": 1, 134 | "uniqueItems": true 135 | } 136 | ] 137 | }, 138 | "format": { "type": "string" }, 139 | "allOf": { "$ref": "#/definitions/schemaArray" }, 140 | "anyOf": { "$ref": "#/definitions/schemaArray" }, 141 | "oneOf": { "$ref": "#/definitions/schemaArray" }, 142 | "not": { "$ref": "#" } 143 | }, 144 | "dependencies": { 145 | "exclusiveMaximum": [ "maximum" ], 146 | "exclusiveMinimum": [ "minimum" ] 147 | }, 148 | "default": {} 149 | } 150 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "schema-forms-php", 3 | "version": "1.0.0", 4 | "repository": "https://github.com/e0ipso/schema-forms-php", 5 | "license": "GPL-2.0-or-later", 6 | "devDependencies": { 7 | "@semantic-release/commit-analyzer": "^9.0.2", 8 | "@semantic-release/git": "^10.0.1", 9 | "@semantic-release/github": "^8.0.6", 10 | "@semantic-release/release-notes-generator": "^10.0.3", 11 | "conventional-changelog-conventionalcommits": "^5.0.0" 12 | }, 13 | "main": "index.js" 14 | } 15 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src 6 | 7 | 8 | 9 | 10 | 11 | 12 | tests 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/ArrayToStdClass.php: -------------------------------------------------------------------------------- 1 | getSchema($data); 34 | $props = $schema->properties; 35 | // Store the schema for the validation callbacks to access. 36 | $element = ['#type' => 'container', '#json_schema' => $schema]; 37 | $ui_hints = $context['ui_hints'] ?? []; 38 | $current_input = $context['current_input'] ?? []; 39 | foreach ((array) $props as $key => $prop) { 40 | // If there is UI context, grab it and pass it along. 41 | $ui_schema_data = $ui_hints[$key] ?? []; 42 | $element[$key] = $this->doTransformOneField($prop, $key, [$key], $ui_schema_data, $form_state, $current_input[$key] ?? NULL); 43 | } 44 | return $this->addValidationRules($element, $context); 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | */ 50 | public function getInputValidator() { 51 | // Validate the JSON-Schema input. 52 | return new JsonSchemaFormValidator(new Validator()); 53 | } 54 | 55 | /** 56 | * {@inheritdoc} 57 | */ 58 | public function getOutputValidator() { 59 | return new RenderArrayValidator(); 60 | } 61 | 62 | /** 63 | * Validation callback against the schema. 64 | * 65 | * @param array $element 66 | * The element. 67 | * @param \Drupal\Core\Form\FormStateInterface $form_state 68 | * The form state. 69 | */ 70 | public function validateWithSchema(array &$element, FormStateInterface $form_state): void { 71 | $schema = $element['#json_schema'] ?? []; 72 | // If the schema is empty do not perform additional validation. 73 | if (!empty($schema)) { 74 | FormValidatorDrupal::typeCastRecursive($element, $form_state, $schema); 75 | FormValidatorDrupal::validateWithSchema($element, $form_state, $schema); 76 | } 77 | } 78 | 79 | /** 80 | * Creates a Drupal form element from a property definition. 81 | * 82 | * @param mixed $json_schema 83 | * The JSON Schema for the element. 84 | * @param string $machine_name 85 | * The machine name for the form element. Used for fallback metadata. 86 | * @param array $prop_parents 87 | * The parents. 88 | * @param array $ui_schema_data 89 | * The schema for the UI refinements. 90 | * @param \Drupal\Core\Form\FormStateInterface $form_state 91 | * The form state. 92 | * @param mixed $current_input 93 | * The current input. 94 | * 95 | * @return array 96 | * The form element. 97 | */ 98 | private function doTransformOneField($json_schema, string $machine_name, array $prop_parents, array $ui_schema_data, FormStateInterface $form_state, $current_input): array { 99 | $form_element = $this->scaffoldFormElement($json_schema, $machine_name, $ui_schema_data, $current_input); 100 | $form_element['#prop_parents'] = $prop_parents; 101 | if (!empty($json_schema->const)) { 102 | unset($form_element['#type']); 103 | $form_element['#markup'] = $json_schema->const; 104 | // Not much more to do with constants. 105 | return $form_element; 106 | } 107 | $type = $this->guessSchemaType($json_schema, $ui_schema_data); 108 | if ($type === 'string' && !empty($json_schema->format) && $json_schema->format === 'email') { 109 | $form_element = $this->transformEmail($form_element); 110 | } 111 | $label_mappings = $ui_schema_data['ui:enum']['labels']['mappings'] ?? []; 112 | $label_mappings = (array) $label_mappings; 113 | if (!empty($json_schema->enum)) { 114 | $form_element = $this->transformRadios($ui_schema_data['ui:widget'] ?? NULL, $form_element, $json_schema, $label_mappings); 115 | } 116 | if ($type === 'array') { 117 | $form_element = empty($json_schema->items->enum) 118 | ? $this->transformMultivalue($prop_parents, $machine_name, $form_state, $current_input, $form_element, $json_schema, $ui_schema_data) 119 | : $this->transformCheckboxes($ui_schema_data['ui:widget'] ?? NULL, $form_element, $json_schema, $label_mappings); 120 | } 121 | if ($type === 'object') { 122 | $form_element = $this->transformNested($json_schema, $form_element, $prop_parents, $ui_schema_data, $form_state, $current_input); 123 | } 124 | 125 | $enabled = (bool) ($ui_schema_data['ui:enabled'] ?? TRUE); 126 | $form_element['#disabled'] = !$enabled; 127 | $visible = (bool) ($ui_schema_data['ui:visible'] ?? TRUE); 128 | $form_element['#visible'] = $visible; 129 | return $form_element; 130 | } 131 | 132 | /** 133 | * After-build handler for field elements in a form. 134 | * 135 | * This stores the final location of the field within the form structure so 136 | * that flagErrors() can assign validation errors to the right form element. 137 | * 138 | * @param array $element 139 | * The element. 140 | * @param \Drupal\Core\Form\FormStateInterface $form_state 141 | * The form state. 142 | * 143 | * @return array 144 | * The form element. 145 | */ 146 | public static function multiValueAfterBuild(array $element, FormStateInterface $form_state) { 147 | if ($form_state->isProcessingInput()) { 148 | $element_parents = $element['#parents']; 149 | $data = $form_state->getValue($element_parents); 150 | $clean_data = UserInputCleaner::cleanUserInput($data); 151 | $form_state->setValue($element_parents, $clean_data); 152 | } 153 | 154 | $prop_parents = $element['#prop_parents']; 155 | $prop_name = $element['#prop_name']; 156 | 157 | $prop_state = static::getPropFormState($prop_parents, $prop_name, $form_state); 158 | $prop_state['array_parents'] = $element['#array_parents']; 159 | static::setPropFormState($prop_parents, $prop_name, $form_state, $prop_state); 160 | 161 | return $element; 162 | } 163 | 164 | /** 165 | * Submission handler for the "Add another item" button. 166 | * 167 | * @param array $form 168 | * The form. 169 | * @param \Drupal\Core\Form\FormStateInterface $form_state 170 | * The form state. 171 | */ 172 | public static function removeOneSubmit(array $form, FormStateInterface $form_state): void { 173 | $button = $form_state->getTriggeringElement(); 174 | $delta = $button['#delta'] ?? NULL; 175 | if (is_null($delta)) { 176 | return; 177 | } 178 | 179 | // Go two levels up in the form, to the container. 180 | $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -2)); 181 | $prop_name = $element['#prop_name']; 182 | $form_parents = $button['#parents']; 183 | // Pop the last element 'remove_one' to address the element container. 184 | array_pop($form_parents); 185 | 186 | // Decrement the items count. 187 | $prop_state = static::getPropFormState($element['#prop_parents'] ?? [], $prop_name, $form_state); 188 | // If the index is set, then remove it. 189 | if (isset($prop_state['items_indices'][$delta])) { 190 | unset($prop_state['items_indices'][$delta]); 191 | static::setPropFormState( 192 | $element['#prop_parents'] ?? [], 193 | $prop_name, 194 | $form_state, 195 | $prop_state 196 | ); 197 | } 198 | 199 | $form_state->setRebuild(); 200 | } 201 | 202 | /** 203 | * Ajax callback for the "Add another item" button. 204 | * 205 | * This returns the new page content to replace the page content made obsolete 206 | * by the form submission. 207 | * 208 | * @param array $form 209 | * The form. 210 | * @param \Drupal\Core\Form\FormStateInterface $form_state 211 | * The form state. 212 | * 213 | * @return array|mixed|void 214 | * The element. 215 | */ 216 | public static function removeOneAjax(array $form, FormStateInterface $form_state) { 217 | $button = $form_state->getTriggeringElement(); 218 | $delta = $button['#delta'] ?? NULL; 219 | if (is_null($delta)) { 220 | return NULL; 221 | } 222 | return [ 223 | '#prefix' => '
', 224 | '#markup' => new translatableMarkup('- Deleted -'), 225 | '#suffix' => '
', 226 | ]; 227 | } 228 | 229 | /** 230 | * Submission handler for the "Add another item" button. 231 | * 232 | * @param array $form 233 | * The form. 234 | * @param \Drupal\Core\Form\FormStateInterface $form_state 235 | * The form state. 236 | */ 237 | public static function addMoreSubmit(array $form, FormStateInterface $form_state): void { 238 | $button = $form_state->getTriggeringElement(); 239 | 240 | // Go one level up in the form, to the widgets container. 241 | $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1)); 242 | $prop_name = $element['#prop_name']; 243 | $prop_parents = $element['#prop_parents']; 244 | 245 | // Increment the items count. 246 | $prop_state = static::getPropFormState($prop_parents, $prop_name, $form_state); 247 | $next_index = empty($prop_state['items_indices']) 248 | ? 0 249 | : end($prop_state['items_indices']) + 1; 250 | $prop_state['items_indices'][] = $next_index; 251 | static::setPropFormState($prop_parents, $prop_name, $form_state, $prop_state); 252 | 253 | $form_state->setRebuild(); 254 | } 255 | 256 | /** 257 | * Ajax callback for the "Add another item" button. 258 | * 259 | * This returns the new page content to replace the page content made obsolete 260 | * by the form submission. 261 | * 262 | * @param array $form 263 | * The form. 264 | * @param \Drupal\Core\Form\FormStateInterface $form_state 265 | * The form state. 266 | * 267 | * @return array|mixed|void 268 | * The element. 269 | */ 270 | public static function addMoreAjax(array $form, FormStateInterface $form_state) { 271 | $button = $form_state->getTriggeringElement(); 272 | 273 | // Go one level up in the form, to the widgets container. 274 | $element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1)); 275 | 276 | // Ensure the widget allows adding additional items. 277 | if ($element['#cardinality'] != FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED) { 278 | return NULL; 279 | } 280 | 281 | // Add a DIV around the delta receiving the Ajax effect. 282 | $delta = $element['#max_delta']; 283 | $element[$delta]['#prefix'] = '
' . ($element[$delta]['#prefix'] ?? ''); 284 | $element[$delta]['#suffix'] = ($element[$delta]['#suffix'] ?? '') . '
'; 285 | 286 | return $element; 287 | } 288 | 289 | /** 290 | * Get the prop form state. 291 | * 292 | * @param array $parents 293 | * The parents. 294 | * @param string $prop_name 295 | * The prop name. 296 | * @param \Drupal\Core\Form\FormStateInterface $form_state 297 | * The form state. 298 | * 299 | * @return array|mixed|null 300 | * The state. 301 | */ 302 | public static function getPropFormState(array $parents, string $prop_name, FormStateInterface $form_state) { 303 | return NestedArray::getValue($form_state->getStorage(), static::getWidgetStateParents($parents, $prop_name)); 304 | } 305 | 306 | /** 307 | * Sets the prop form state. 308 | * 309 | * @param array $parents 310 | * The parents. 311 | * @param string $prop_name 312 | * The prop name. 313 | * @param \Drupal\Core\Form\FormStateInterface $form_state 314 | * The form state. 315 | * @param array $prop_state 316 | * The state to set. 317 | */ 318 | public static function setPropFormState(array $parents, string $prop_name, FormStateInterface $form_state, array $prop_state): void { 319 | $parents_state = static::getWidgetStateParents($parents, $prop_name); 320 | NestedArray::setValue($form_state->getStorage(), $parents_state, $prop_state); 321 | } 322 | 323 | /** 324 | * Returns the location of processing information within $form_state. 325 | * 326 | * @param array $parents 327 | * The array of #parents where the widget lives in the form. 328 | * @param string $prop_name 329 | * The field name. 330 | * 331 | * @return array 332 | * The location of processing information within $form_state. 333 | */ 334 | protected static function getWidgetStateParents(array $parents, string $prop_name) { 335 | // Prop processing data is placed at 336 | // $form_state->get(['prop_storage', '#parents', ...$parents..., '#props', 337 | // $prop_name]), to avoid clashes between prop names and $parents parts. 338 | return array_merge(['prop_storage', '#parents'], $parents, [ 339 | '#props', 340 | $prop_name, 341 | ]); 342 | } 343 | 344 | /** 345 | * Creates a scaffold for the form element. 346 | * 347 | * @param mixed $element 348 | * The parsed element from the schema. 349 | * @param string $machine_name 350 | * The machine name for the form element. Used for fallback metadata. 351 | * @param array $ui_schema_data 352 | * The schema for the UI refinements. 353 | * @param mixed $current_input 354 | * The current input. 355 | * 356 | * @return array 357 | * The scaffolded form element. 358 | */ 359 | private function scaffoldFormElement($element, string $machine_name, array $ui_schema_data, $current_input): array { 360 | $type = $this->guessSchemaType($element, $ui_schema_data); 361 | $title = $ui_schema_data['ui:title'] ?? $element->title ?? $this->machineNameToHumanName($machine_name); 362 | $form_element = [ 363 | '#title' => $title, 364 | '#type' => $type, 365 | '#prop_name' => $machine_name, 366 | ]; 367 | if (!is_null($element->default ?? NULL)) { 368 | $form_element['#default_value'] = $element->default; 369 | } 370 | if (!is_null($current_input)) { 371 | $form_element['#default_value'] = $current_input; 372 | } 373 | $description = $ui_schema_data['ui:help'] ?? $element->description ?? NULL; 374 | if (!empty($description)) { 375 | $form_element['#description'] = $description; 376 | } 377 | $placeholder = $ui_schema_data['ui:placeholder'] ?? NULL; 378 | if (!empty($placeholder)) { 379 | $form_element['#placeholder'] = $placeholder; 380 | } 381 | // Basic transformations based on type. 382 | if ($type === 'boolean') { 383 | $form_element['#type'] = 'checkbox'; 384 | } 385 | if ($type === 'string') { 386 | $form_element['#type'] = 'textfield'; 387 | } 388 | return $form_element; 389 | } 390 | 391 | /** 392 | * Guesses the JSON property type based on the schema element. 393 | * 394 | * @param mixed $element 395 | * The parsed element from the schema. 396 | * @param array $ui_schema_data 397 | * The schema for the UI refinements. 398 | * 399 | * @return string 400 | * The JSON property type. 401 | */ 402 | private function guessSchemaType($element, array $ui_schema_data): string { 403 | if (!empty($ui_schema_data['ui:widget'])) { 404 | return $ui_schema_data['ui:widget']; 405 | } 406 | $type = $element->type; 407 | if (is_array($type)) { 408 | // Guess the first non null type. 409 | $type = current(array_filter($type, function ($item) { 410 | return $item !== NULL; 411 | })); 412 | } 413 | return $type; 414 | } 415 | 416 | /** 417 | * Turns a machine name into a human readable name. 418 | * 419 | * @param string $machine_name 420 | * The machine name. 421 | * 422 | * @return string 423 | * The human readable name. 424 | */ 425 | private function machineNameToHumanName($machine_name) { 426 | return ucwords(strtr($machine_name, ['_' => ' ', '-' => ' '])); 427 | } 428 | 429 | /** 430 | * Adds the necessary validation rules to the form. 431 | * 432 | * @param array $element 433 | * The form element to alter. 434 | * 435 | * @return array 436 | * The modified form. 437 | */ 438 | private function addValidationRules(array $element): array { 439 | $required_prop_names = $element['#json_schema']->required ?? []; 440 | // Add the required fields. 441 | foreach (array_keys($element) as $key) { 442 | if (($key[0] ?? '') === '#') { 443 | continue; 444 | } 445 | $element[$key]['#required'] = in_array($key, $required_prop_names); 446 | } 447 | $element['#element_validate'] = [[$this, 'validateWithSchema']]; 448 | return $element; 449 | } 450 | 451 | /** 452 | * Get the dereferenced schema. 453 | * 454 | * @param mixed $data 455 | * The schema containing references. 456 | * 457 | * @return mixed|object 458 | * The dereferenced schema. 459 | */ 460 | private function getSchema($data) { 461 | $storage = (new Factory(NULL, NULL, Constraint::CHECK_MODE_TYPE_CAST))->getSchemaStorage(); 462 | $storage->addSchema('internal:/schema-with-refs', $data); 463 | return $storage->getSchema('internal:/schema-with-refs'); 464 | } 465 | 466 | /** 467 | * Builds the form element for the email case. 468 | * 469 | * @param array $form_element 470 | * The form element. 471 | * 472 | * @return array 473 | * The form element. 474 | */ 475 | private function transformEmail(array $form_element): array { 476 | $form_element['#type'] = 'email'; 477 | return $form_element; 478 | } 479 | 480 | /** 481 | * Builds the form element for the radios case. 482 | * 483 | * @param string|null $uiwidget 484 | * The type of widget to use. 485 | * @param array $form_element 486 | * The form element. 487 | * @param mixed $json_schema 488 | * The JSON Schema for the element. 489 | * @param array $label_mappings 490 | * Mappings for the labels. 491 | * 492 | * @return array 493 | * The form element. 494 | */ 495 | private function transformRadios(?string $uiwidget, array $form_element, $json_schema, array $label_mappings): array { 496 | $form_element['#type'] = $uiwidget ?? 'radios'; 497 | $form_element['#options'] = array_reduce($json_schema->enum, function (array $carry, string $opt) use ($label_mappings) { 498 | return array_merge( 499 | $carry, 500 | [$opt => $label_mappings[$opt] ?? $this->machineNameToHumanName($opt)] 501 | ); 502 | }, []); 503 | return $form_element; 504 | } 505 | 506 | /** 507 | * Builds the form element for the checkboxes case. 508 | * 509 | * @param string|null $uiwidget 510 | * The type of widget to use. 511 | * @param array $form_element 512 | * The form element. 513 | * @param mixed $json_schema 514 | * The JSON Schema for the element. 515 | * @param array $label_mappings 516 | * An associative array to map options to human-readable labels. 517 | * 518 | * @return array 519 | * The form element. 520 | */ 521 | private function transformCheckboxes(?string $uiwidget, array $form_element, $json_schema, array $label_mappings): array { 522 | $form_element['#type'] = $uiwidget ?? 'checkboxes'; 523 | $form_element['#options'] = array_reduce($json_schema->items->enum, function (array $carry, string $opt) use ($label_mappings) { 524 | return array_merge( 525 | $carry, 526 | [$opt => $label_mappings[$opt] ?? $this->machineNameToHumanName($opt)] 527 | ); 528 | }, []); 529 | return $form_element; 530 | } 531 | 532 | /** 533 | * Builds the form element for the multi-value case. 534 | * 535 | * @param array $prop_parents 536 | * The prop parents array. 537 | * @param string $machine_name 538 | * The machine name. 539 | * @param \Drupal\Core\Form\FormStateInterface $form_state 540 | * The form state. 541 | * @param array|null $current_input 542 | * The current input. 543 | * @param array $form_element 544 | * The form element. 545 | * @param mixed $json_schema 546 | * The JSON Schema for the element. 547 | * @param array $ui_schema_data 548 | * The schema for the UI refinements. 549 | * 550 | * @return array 551 | * The form element. 552 | */ 553 | private function transformMultivalue(array $prop_parents, string $machine_name, FormStateInterface $form_state, ?array $current_input, array $form_element, $json_schema, array $ui_schema_data): array { 554 | // Store field information in $form_state. 555 | if (!static::getPropFormState($prop_parents, $machine_name, $form_state)) { 556 | $count = count($current_input ?: []); 557 | $prop_state = [ 558 | 'items_indices' => $count ? range(0, $count - 1) : [], 559 | 'array_parents' => [], 560 | ]; 561 | static::setPropFormState($prop_parents, $machine_name, $form_state, $prop_state); 562 | } 563 | 564 | // @todo If we ever support non-infinite multivalues, change this. 565 | $cardinality = FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED; 566 | // Determine the number of widgets to display. 567 | $indices = $cardinality === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED 568 | ? static::getPropFormState($prop_parents, $machine_name, $form_state)['items_indices'] ?? [0] 569 | : range(0, $cardinality); 570 | $max = $cardinality === FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED 571 | ? count($indices) 572 | : $cardinality - 1; 573 | $is_multiple = $cardinality !== 1; 574 | $id_prefix = implode('-', $prop_parents); 575 | $wrapper_id = Html::getUniqueId($id_prefix . '-add-more-wrapper'); 576 | $form_element['#type'] = 'details'; 577 | $form_element['#open'] = TRUE; 578 | $form_element['#after_build'][] = [static::class, 'multiValueAfterBuild']; 579 | $form_element['#cardinality'] = $cardinality; 580 | $form_element['#max_delta'] = $max; 581 | $form_element['#prefix'] = '
'; 582 | $form_element['#suffix'] = '
'; 583 | $title = $form_element['#title'] ?? ''; 584 | $description = $form_element['#description'] ?? ''; 585 | foreach ($indices as $delta) { 586 | // For multiple fields, title and description are handled by the wrapping 587 | // table. 588 | $element = $is_multiple 589 | ? [ 590 | '#title' => new TranslatableMarkup('@title (value @number)', [ 591 | '@title' => $title, 592 | '@number' => $delta + 1, 593 | ]), 594 | '#title_display' => 'invisible', 595 | '#description' => '', 596 | ] 597 | : [ 598 | '#title' => $title, 599 | '#title_display' => 'before', 600 | '#description' => $description, 601 | ]; 602 | // Add a new empty item if it doesn't exist yet at this delta. 603 | $new_prop_parents = [...$prop_parents, $delta]; 604 | $element += $this->doTransformOneField( 605 | $json_schema->items, 606 | (string) $delta, 607 | $new_prop_parents, 608 | $ui_schema_data, 609 | $form_state, 610 | $current_input[$delta] ?? NULL 611 | ); 612 | 613 | $container_id = sprintf('%s-%d-container', $id_prefix, $delta); 614 | $remove_name = sprintf('%s-%d-remove', $id_prefix, $delta); 615 | $form_element[$delta] = [ 616 | '#type' => 'container', 617 | '#prefix' => '
', 618 | '#wrapper_id' => $container_id, 619 | '#suffix' => '
', 620 | '#attributes' => [ 621 | 'style' => 'position: relative;', 622 | ], 623 | 'removable_element' => $element, 624 | 'remove_one' => [ 625 | '#delta' => $delta, 626 | '#type' => 'submit', 627 | '#name' => $remove_name, 628 | '#value' => new TranslatableMarkup('⨯'), 629 | '#validate' => [], 630 | '#prop_parents' => $new_prop_parents, 631 | '#attributes' => [ 632 | 'class' => ['field-remove-one-submit'], 633 | 'style' => 'opacity: 0.9;display: block;position: absolute;top: -10px;left: -10px;margin: 0;overflow: hidden;width: 20px;height: 20px;padding: 0;border-radius: 15px;', 634 | 'title' => new TranslatableMarkup('Remove item'), 635 | ], 636 | '#limit_validation_errors' => [], 637 | '#submit' => [[static::class, 'removeOneSubmit']], 638 | '#ajax' => [ 639 | 'callback' => [static::class, 'removeOneAjax'], 640 | 'wrapper' => $container_id, 641 | 'effect' => 'fade', 642 | ], 643 | '#weight' => 101, 644 | ], 645 | ]; 646 | } 647 | 648 | $form_element['add_more'] = [ 649 | '#type' => 'submit', 650 | '#name' => strtr($id_prefix, '-', '_') . '_add_more', 651 | '#value' => new TranslatableMarkup('Append an item'), 652 | '#attributes' => ['class' => ['field-add-more-submit']], 653 | '#limit_validation_errors' => [array_merge($prop_parents, [$machine_name])], 654 | '#submit' => [[static::class, 'addMoreSubmit']], 655 | '#ajax' => [ 656 | 'callback' => [static::class, 'addMoreAjax'], 657 | 'wrapper' => $wrapper_id, 658 | 'effect' => 'fade', 659 | ], 660 | ]; 661 | return $form_element; 662 | } 663 | 664 | /** 665 | * Builds the form element for the nested case. 666 | * 667 | * @param mixed $json_schema 668 | * The JSON Schema for the element. 669 | * @param array $form_element 670 | * The form element. 671 | * @param array $parents 672 | * The parents. 673 | * @param array $ui_schema_data 674 | * The schema for the UI refinements. 675 | * @param \Drupal\Core\Form\FormStateInterface $form_state 676 | * The form state. 677 | * @param array|null $current_input 678 | * The current input. 679 | * 680 | * @return array 681 | * The form element. 682 | */ 683 | private function transformNested($json_schema, array $form_element, array $parents, array $ui_schema_data, FormStateInterface $form_state, ?array $current_input): array { 684 | $properties = $json_schema->properties ?? []; 685 | if (!empty($properties)) { 686 | $form_element['#type'] = 'details'; 687 | $form_element['#open'] = TRUE; 688 | foreach ($properties as $sub_machine_name => $sub_json_schema) { 689 | $form_element[$sub_machine_name] = $this->doTransformOneField( 690 | $sub_json_schema, 691 | $sub_machine_name, 692 | [...$parents, $sub_machine_name], 693 | $ui_schema_data[$sub_machine_name] ?? [], 694 | $form_state, 695 | $current_input[$sub_machine_name] ?? NULL 696 | ); 697 | } 698 | } 699 | return $form_element; 700 | } 701 | 702 | } 703 | -------------------------------------------------------------------------------- /src/Drupal/FormValidatorDrupal.php: -------------------------------------------------------------------------------- 1 | getUserInput(); 32 | $submitted = $form_state->getValue($parents) ?? NestedArray::getValue($raw_input, $parents) ?? NULL; 33 | $data = (new ArrayToStdClass())->transform($submitted); 34 | if ($data === []) { 35 | // If the data is an empty array we may need to cast it to empty object. 36 | $types = is_array($schema->type) ? $schema->type : [$schema->type]; 37 | $data = in_array('array', $types, TRUE) ? [] : new \stdClass(); 38 | } 39 | $validator = new Validator(); 40 | // Validate the massaged data against the schema. 41 | $num_errors = $validator->validate($data, $schema); 42 | if ($num_errors) { 43 | // Build the mappings of paths to form paths. 44 | $mappings = static::buildMappingsElementPaths($element, $element['#array_parents']); 45 | $errors = $validator->getErrors(); 46 | // Now check if we can find a specific form element to trigger the error. 47 | $generic_errors = array_filter( 48 | array_map( 49 | static fn(array $error) => self::errorForProp($element, $form_state, $error, $mappings), 50 | $errors 51 | ) 52 | ); 53 | if (empty($generic_errors)) { 54 | return; 55 | } 56 | $full_message = implode( 57 | ', ', 58 | array_map([Html::class, 'escape'], $generic_errors) 59 | ); 60 | $form_state->setError( 61 | $element, 62 | new TranslatableMarkup( 63 | '

Invalid data, please make sure all data is valid according to the schema. Schema validation returned the following errors errors: @full_message

', 64 | ['@full_message' => $full_message] 65 | ) 66 | ); 67 | } 68 | } 69 | 70 | /** 71 | * Sets or returns the error for a prop. 72 | * 73 | * @param array $element 74 | * The form element. 75 | * @param \Drupal\Core\Form\FormStateInterface $form_state 76 | * The form state. 77 | * @param array $error 78 | * The error data from the JSON-Schema validation. 79 | * @param array $mappings 80 | * Mappings to find the form element. 81 | * 82 | * @return string|null 83 | * String if we could not find the particular prop this error is for. NULL 84 | * if the error could be set. 85 | */ 86 | private static function errorForProp(array $element, FormStateInterface $form_state, array $error, array $mappings): ?string { 87 | $message = $error['message'] . ' [JSON Schema violation of "' . $error['constraint'] . '"]'; 88 | $form_error_parents = $mappings[$error['pointer'] ?? ''] ?? []; 89 | $key_exists = FALSE; 90 | $error_element = NestedArray::getValue( 91 | $element, 92 | $form_error_parents, 93 | $key_exists 94 | ); 95 | if ($key_exists) { 96 | $form_state->setError( 97 | $error_element, 98 | new TranslatableMarkup('%element: @message', [ 99 | '%element' => $error_element['#title'] ?? '', 100 | '@message' => $message, 101 | ]) 102 | ); 103 | return NULL; 104 | } 105 | return $message; 106 | } 107 | 108 | /** 109 | * Casts form submissions to their corresponding JSON types. 110 | * 111 | * @param array $element 112 | * The form element. 113 | * @param \Drupal\Core\Form\FormStateInterface $form_state 114 | * The form state. 115 | * @param object $schema 116 | * The data schema. 117 | */ 118 | public static function typeCastRecursive(array $element, FormStateInterface $form_state, object $schema): void { 119 | $data = $form_state->getValue($element['#parents']); 120 | $data = UserInputCleaner::cleanUserInput($data); 121 | $data = RecursiveTypeCaster::recursiveTypeRefinements($data, $schema); 122 | $form_state->setValueForElement($element, $data); 123 | } 124 | 125 | /** 126 | * Maps the JSON pointer paths to form element parents. 127 | * 128 | * It returns ['root', 0, 'removable_element', 'foo', 2, 'removable_element'] 129 | * from '/root/0/foo/2'. 130 | * 131 | * @param array $element 132 | * The form element. 133 | * @param array $root_parents 134 | * The parents of the JSON Schema form. 135 | * @param array $_current_mappings 136 | * The current mappings. This is an internal variable used for tracking. 137 | * 138 | * @return array 139 | * The mappings of paths from the JSON Pointer to the form structure. 140 | */ 141 | private static function buildMappingsElementPaths(array $element, array $root_parents, array $_current_mappings = []): array { 142 | $parents = $element['#array_parents'] ?? []; 143 | // Now drop some parents to make everything relative to `/`. 144 | $parents = array_slice($parents, count($root_parents)); 145 | $json_pointer_parents = array_filter( 146 | $parents, 147 | static fn (string $parent) => $parent !== 'removable_element' 148 | ); 149 | $json_pointer = '/' . implode('/', $json_pointer_parents); 150 | $_current_mappings[$json_pointer] = $parents; 151 | $keys = Element::children($element); 152 | if (empty($keys)) { 153 | return $_current_mappings; 154 | } 155 | foreach ($keys as $key) { 156 | if (in_array($key, ['remove_one', 'add_more'], TRUE)) { 157 | continue; 158 | } 159 | $_current_mappings = static::buildMappingsElementPaths( 160 | $element[$key], 161 | $root_parents, 162 | $_current_mappings 163 | ); 164 | } 165 | return $_current_mappings; 166 | } 167 | 168 | } 169 | -------------------------------------------------------------------------------- /src/Drupal/UserInputCleaner.php: -------------------------------------------------------------------------------- 1 | $datum) { 43 | if ($key === 'add_more' && $datum instanceof MarkupInterface) { 44 | unset($data[$key]); 45 | } 46 | } 47 | $data = static::reKey($data); 48 | if (array_is_list($data)) { 49 | return static::cleanRemoveButton($data); 50 | } 51 | foreach ($data as $key => $datum) { 52 | $clean_datum = static::cleanUserInput($datum); 53 | if (is_null($clean_datum)) { 54 | unset($data[$key]); 55 | continue; 56 | } 57 | $data[$key] = $clean_datum; 58 | } 59 | return $data; 60 | } 61 | 62 | /** 63 | * Remove empty items from the form recursively. 64 | * 65 | * @param mixed $data 66 | * The data. 67 | * 68 | * @return mixed 69 | * The filtered data. 70 | */ 71 | private static function filterEmptyItems($data) { 72 | if (!is_array($data)) { 73 | return $data; 74 | } 75 | return array_filter( 76 | array_map([static::class, 'filterEmptyItems'], $data), 77 | // Remove only the empty arrays (list and associative). 78 | static fn ($item): bool => !is_array($item) || !empty($item) 79 | ); 80 | } 81 | 82 | /** 83 | * Checks if a simple data structure contains any data. 84 | * 85 | * @param mixed $item 86 | * The data structure. 87 | * 88 | * @return bool 89 | * TRUE if it has data. FALSE otherwise. 90 | */ 91 | private static function hasData($item): bool { 92 | if (empty($item) && $item !== 0 && $item !== FALSE) { 93 | return FALSE; 94 | } 95 | if (!is_array($item)) { 96 | return TRUE; 97 | } 98 | return array_reduce( 99 | $item, 100 | static fn(bool $res, $el) => $res || static::hasData($el), 101 | FALSE 102 | ); 103 | } 104 | 105 | /** 106 | * Removes the cruft introduced to support the remove button. 107 | * 108 | * [['removable_element' => 'foo', 'remove_one' => TranslatebleMarkup], ...] 109 | * becomes ['foo', '...']. 110 | * 111 | * @param array $data 112 | * The input data. 113 | * 114 | * @return array 115 | * The data without the repercussions of the remove button. 116 | */ 117 | public static function cleanRemoveButton(array $data): array { 118 | // Make sure we can undo the remove button data structure. 119 | $can_undo_form_nesting = static fn (array $items) => array_reduce( 120 | $items, 121 | static fn(bool $carry, $item) => $carry 122 | && is_array($item) 123 | && count(array_intersect(array_keys($item), [ 124 | 'removable_element', 125 | 'remove_one', 126 | ])) === 2 127 | && $item['remove_one'] instanceof MarkupInterface, 128 | TRUE 129 | ); 130 | $undo_form_nesting = static fn (array $items) => array_values(array_map( 131 | static fn(array $item) => $item['removable_element'] ?? NULL, 132 | $items 133 | )); 134 | 135 | return $can_undo_form_nesting($data) ? $undo_form_nesting($data) : $data; 136 | } 137 | 138 | /** 139 | * Re-keys the data to account for deleted indices. 140 | * 141 | * @param array $data 142 | * The input data. 143 | * 144 | * @return array 145 | * The re-keyed data. 146 | */ 147 | private static function reKey(array $data): array { 148 | $all_keys_numeric = array_reduce( 149 | array_keys($data), 150 | static fn (bool $carry, $key) => $carry && is_numeric($key), 151 | TRUE 152 | ); 153 | return $all_keys_numeric ? array_values($data) : $data; 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /src/FormGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | sprintf('file://%s/meta-schema-draft4.json', dirname(__DIR__))]; 18 | parent::__construct($new_schema, $validator, $mode); 19 | } 20 | 21 | /** 22 | * {@inheritdoc} 23 | */ 24 | public function isValid($data) { 25 | return parent::isValid($data) 26 | && $data->type === 'object' 27 | && (bool) $data->properties; 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/RecursiveTypeCaster.php: -------------------------------------------------------------------------------- 1 | type; 23 | $types = is_array($types) ? $types : [$types]; 24 | if (is_array($data) && array_is_list($data) && in_array('array', $types, TRUE)) { 25 | return array_map(static fn($item) => static::recursiveTypeRefinements($item, $schema->items), $data); 26 | } 27 | if ((is_array($data) || is_object($data)) && in_array('object', $types, TRUE)) { 28 | // If the data is NOT an array or object, then do not do any type casting. 29 | if (!is_array($data) && !is_object($data)) { 30 | return $data; 31 | } 32 | // Handle each property recursively. 33 | foreach ((array) $data as $key => $value) { 34 | $sub_schema = $schema->properties->{$key} ?? $schema->items ?? (object) ['type' => 'null']; 35 | $data[$key] = static::recursiveTypeRefinements($value, $sub_schema); 36 | } 37 | return $data; 38 | } 39 | array_reduce([ 40 | static fn(&$data, $types) => static::tryCastingNumber($data, $types), 41 | static fn(&$data, $types) => static::tryCastingBoolean($data, $types), 42 | static fn(&$data, $types) => static::tryCastingNull($data, $types), 43 | static fn(&$data, $types) => static::tryCastingString($data, $types), 44 | ], 45 | static function (bool $casted, callable $method) use (&$data, $types) { 46 | return $casted ?: $method($data, $types); 47 | }, 48 | FALSE 49 | ); 50 | return $data; 51 | } 52 | 53 | /** 54 | * Attempts to cast the data to a string. 55 | * 56 | * @param mixed $input 57 | * The input data. Passed by reference to change its type. 58 | * @param array $types 59 | * The possible types. 60 | * 61 | * @return bool 62 | * TRUE if casting was possible. FALSE otherwise. 63 | */ 64 | private static function tryCastingString(&$input, array $types): bool { 65 | if (in_array('string', $types, TRUE)) { 66 | $input = (string) $input; 67 | return TRUE; 68 | } 69 | return FALSE; 70 | } 71 | 72 | /** 73 | * Attempts to cast the data to NULL. 74 | * 75 | * @param mixed $input 76 | * The input data. Passed by reference to change its type. 77 | * @param array $types 78 | * The possible types. 79 | * 80 | * @return bool 81 | * TRUE if casting was possible. FALSE otherwise. 82 | */ 83 | private static function tryCastingNull(&$input, array $types): bool { 84 | if (in_array('null', $types, TRUE) && empty($input)) { 85 | $input = NULL; 86 | return TRUE; 87 | } 88 | return FALSE; 89 | } 90 | 91 | /** 92 | * Attempts to cast the data to a boolean. 93 | * 94 | * @param mixed $input 95 | * The input data. Passed by reference to change its type. 96 | * @param array $types 97 | * The possible types. 98 | * 99 | * @return bool 100 | * TRUE if casting was possible. FALSE otherwise. 101 | */ 102 | private static function tryCastingBoolean(&$input, array $types): bool { 103 | $is_quasi_boolean = $input === '0' || $input === '1' || $input === 0 || $input === 1; 104 | if (in_array('boolean', $types) && $is_quasi_boolean) { 105 | $input = (boolean) $input; 106 | return TRUE; 107 | } 108 | return FALSE; 109 | } 110 | 111 | /** 112 | * Attempts to cast the data to a number. 113 | * 114 | * @param mixed $input 115 | * The input data. Passed by reference to change its type. 116 | * @param array $types 117 | * The possible types. 118 | * 119 | * @return bool 120 | * TRUE if casting was possible. FALSE otherwise. 121 | */ 122 | private static function tryCastingNumber(&$input, array $types): bool { 123 | $is_not_numeric_definition = !in_array('integer', $types, TRUE) 124 | && !in_array('number', $types, TRUE); 125 | if ($is_not_numeric_definition || !is_numeric($input)) { 126 | return FALSE; 127 | } 128 | if (is_int($input) || is_float($input)) { 129 | return TRUE; 130 | } 131 | if (is_string($input)) { 132 | // This conversion is guaranteed because of the is_numeric check above. 133 | $input = strpos($input, '.') === FALSE 134 | ? (int) $input 135 | : (float) $input; 136 | return TRUE; 137 | } 138 | return FALSE; 139 | } 140 | 141 | } 142 | -------------------------------------------------------------------------------- /src/RenderArrayValidator.php: -------------------------------------------------------------------------------- 1 | $val) { 21 | if (is_string($val)) { 22 | if (strpos($key, '#') !== FALSE) { 23 | return TRUE; 24 | } 25 | continue; 26 | } 27 | if (strpos($key, '#') !== FALSE) { 28 | continue; 29 | } 30 | return $this->isValid($val); 31 | } 32 | return FALSE; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /tests/src/ArrayToStdClassTest.php: -------------------------------------------------------------------------------- 1 | [1, 3, ['bar' => 'baz']]], 38 | (object) ['foo' => [1, 3, (object) ['bar' => 'baz']]], 39 | ], 40 | [ 41 | ['foo' => [1, 3, ['bar' => 'baz']]], 42 | (object) ['foo' => [1, 3, (object) ['bar' => 'baz']]], 43 | ], 44 | [ 45 | [['foo' => 'bar'], 'lorem', ['cowsays' => 'moo']], 46 | [(object) ['foo' => 'bar'], 'lorem', (object) ['cowsays' => 'moo']], 47 | ], 48 | ]; 49 | } 50 | 51 | /** 52 | * {@inheritdoc} 53 | */ 54 | protected function setUp(): void { 55 | parent::setUp(); 56 | $this->sut = new ArrayToStdClass(); 57 | } 58 | 59 | /** 60 | * Tests the transformation. 61 | * 62 | * @dataProvider dataProviderTransform 63 | */ 64 | public function testTransform(mixed $data, mixed $expected): void { 65 | $actual = $this->sut->transform($data); 66 | $this->assertEquals($expected, $actual); 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /tests/src/Drupal/SchemaForms/FormGeneratorDrupalTest.php: -------------------------------------------------------------------------------- 1 | sut = new FormGeneratorDrupal(); 32 | } 33 | 34 | /** 35 | * Tests the form generation. 36 | * 37 | * @param string $schema 38 | * The JSON string containing the schema for the form. 39 | * @param array $expected_form 40 | * The expected form as in Drupal's Form API. 41 | * 42 | * @dataProvider dataProviderFormGeneration 43 | */ 44 | public function testFormGeneration(string $schema, array $expected_form) { 45 | $expected_form['#element_validate'] = [[$this->sut, 'validateWithSchema']]; 46 | $data = json_decode($schema); 47 | $expected_form['#json_schema'] = $data; 48 | $actual_form = $this->sut->transform($data); 49 | $this->assertEquals( 50 | $expected_form, 51 | $actual_form 52 | ); 53 | } 54 | 55 | /** 56 | * Data provider for the testFormGeneration. 57 | * 58 | * @return array 59 | * The data. 60 | */ 61 | public function dataProviderFormGeneration() { 62 | return [ 63 | [ 64 | '{"type":"object","properties":{"foo":{"type":["string","null"]}}}', 65 | [ 66 | 'foo' => [ 67 | '#title' => 'Foo', 68 | '#type' => 'textfield', 69 | '#required' => FALSE, 70 | '#disabled' => FALSE, 71 | '#visible' => TRUE, 72 | '#prop_name' => 'foo', 73 | '#prop_parents' => ['foo'], 74 | ], 75 | '#type' => 'container', 76 | ], 77 | ], 78 | [ 79 | '{"type":"object","required":["bar"],"properties":{"foo":{"type":"string","title":"The Big Foo","format":"email"},"bar":{"type":"number","description":"It is just a bar"}}}', 80 | [ 81 | 'foo' => [ 82 | '#title' => 'The Big Foo', 83 | '#type' => 'email', 84 | '#required' => FALSE, 85 | '#disabled' => FALSE, 86 | '#visible' => TRUE, 87 | '#prop_name' => 'foo', 88 | '#prop_parents' => ['foo'], 89 | ], 90 | 'bar' => [ 91 | '#title' => 'Bar', 92 | // phpcs:ignore 93 | '#description' => 'It is just a bar', 94 | '#type' => 'number', 95 | '#required' => TRUE, 96 | '#disabled' => FALSE, 97 | '#visible' => TRUE, 98 | '#prop_name' => 'bar', 99 | '#prop_parents' => ['bar'], 100 | ], 101 | '#type' => 'container', 102 | ], 103 | ], 104 | [ 105 | '{"type":"object","properties":{"foo":{"type":"string","const":"The Big Foo"}}}', 106 | [ 107 | 'foo' => [ 108 | '#markup' => 'The Big Foo', 109 | '#title' => 'Foo', 110 | '#required' => FALSE, 111 | '#prop_name' => 'foo', 112 | '#prop_parents' => ['foo'], 113 | ], 114 | '#type' => 'container', 115 | ], 116 | ], 117 | [ 118 | '{"type":"object","properties":{"a-foo":{"type":"boolean"}}}', 119 | [ 120 | 'a-foo' => [ 121 | '#title' => 'A Foo', 122 | '#type' => 'checkbox', 123 | '#required' => FALSE, 124 | '#disabled' => FALSE, 125 | '#visible' => TRUE, 126 | '#prop_name' => 'a-foo', 127 | '#prop_parents' => ['a-foo'], 128 | ], 129 | '#type' => 'container', 130 | ], 131 | ], 132 | [ 133 | '{"type":"object","properties":{"foo":{"type":"array","items":{"type":"string","enum":["lor-em","ipsum"]}}}}', 134 | [ 135 | 'foo' => [ 136 | '#title' => 'Foo', 137 | '#type' => 'checkboxes', 138 | '#options' => ['lor-em' => 'Lor Em', 'ipsum' => 'Ipsum'], 139 | '#required' => FALSE, 140 | '#disabled' => FALSE, 141 | '#visible' => TRUE, 142 | '#prop_name' => 'foo', 143 | '#prop_parents' => ['foo'], 144 | ], 145 | '#type' => 'container', 146 | ], 147 | ], 148 | [ 149 | '{"type":"object","properties":{"foo":{"type":"string","enum":["lor-em","ipsum"]}}}', 150 | [ 151 | 'foo' => [ 152 | '#title' => 'Foo', 153 | '#type' => 'radios', 154 | '#options' => ['lor-em' => 'Lor Em', 'ipsum' => 'Ipsum'], 155 | '#required' => FALSE, 156 | '#disabled' => FALSE, 157 | '#visible' => TRUE, 158 | '#prop_name' => 'foo', 159 | '#prop_parents' => ['foo'], 160 | ], 161 | '#type' => 'container', 162 | ], 163 | ], 164 | ]; 165 | } 166 | 167 | /** 168 | * Tests the form generation. 169 | * 170 | * @param string $schema 171 | * The JSON string containing the schema for the form. 172 | * @param array $ui_schema 173 | * The JSON string containing the UI schema for the form. 174 | * @param array $expected_form 175 | * The expected form as in Drupal's Form API. 176 | * 177 | * @dataProvider dataProviderFormGenerationWithUi 178 | */ 179 | public function testFormGenerationWithUi(string $schema, array $ui_schema, array $expected_form) { 180 | $expected_form['#element_validate'] = [[$this->sut, 'validateWithSchema']]; 181 | $data = json_decode($schema); 182 | $expected_form['#json_schema'] = $data; 183 | $context = new Context(['ui_hints' => $ui_schema]); 184 | $actual_form = $this->sut->transform($data, $context); 185 | $this->assertEquals( 186 | $expected_form, 187 | $actual_form 188 | ); 189 | } 190 | 191 | /** 192 | * Data provider for the testFormGenerationWithUI. 193 | * 194 | * @return array 195 | * The data. 196 | */ 197 | public function dataProviderFormGenerationWithUi(): array { 198 | return [ 199 | [ 200 | '{"type":"object","properties":{"foo":{"type":["string","null"]}}}', 201 | [ 202 | 'foo' => [ 203 | 'ui:title' => 'A title', 204 | 'ui:help' => 'Some help text', 205 | 'ui:placeholder' => 'This is a placeholder', 206 | ], 207 | '#type' => 'container', 208 | ], 209 | [ 210 | 'foo' => [ 211 | '#title' => 'A title', 212 | '#type' => 'textfield', 213 | '#required' => FALSE, 214 | '#disabled' => FALSE, 215 | '#visible' => TRUE, 216 | '#description' => 'Some help text', 217 | '#placeholder' => 'This is a placeholder', 218 | '#prop_name' => 'foo', 219 | '#prop_parents' => ['foo'], 220 | ], 221 | '#type' => 'container', 222 | ], 223 | ], 224 | [ 225 | '{"type":"object","properties":{"foo":{"type":["string","null"]},"bar":{"type":"string","enum":["uuid1","uuid2"]}}}', 226 | [ 227 | 'foo' => [ 228 | 'ui:widget' => 'hidden', 229 | ], 230 | 'bar' => [ 231 | 'ui:widget' => 'select', 232 | 'ui:enum' => ['labels' => ['mappings' => ['uuid1' => 'My Super Option #1']]], 233 | ], 234 | '#type' => 'container', 235 | ], 236 | [ 237 | 'foo' => [ 238 | '#title' => 'Foo', 239 | '#type' => 'hidden', 240 | '#required' => FALSE, 241 | '#disabled' => FALSE, 242 | '#visible' => TRUE, 243 | '#prop_name' => 'foo', 244 | '#prop_parents' => ['foo'], 245 | ], 246 | 'bar' => [ 247 | '#title' => 'Bar', 248 | '#type' => 'select', 249 | '#required' => FALSE, 250 | '#disabled' => FALSE, 251 | '#visible' => TRUE, 252 | '#options' => [ 253 | 'uuid1' => 'My Super Option #1', 254 | 'uuid2' => 'Uuid2', 255 | ], 256 | '#prop_name' => 'bar', 257 | '#prop_parents' => ['bar'], 258 | ], 259 | '#type' => 'container', 260 | ], 261 | ], 262 | ]; 263 | } 264 | 265 | } 266 | -------------------------------------------------------------------------------- /tests/src/RenderArrayValidatorTest.php: -------------------------------------------------------------------------------- 1 | sut = new RenderArrayValidator(); 30 | } 31 | 32 | /** 33 | * Tests if the render array is valid. 34 | * 35 | * @dataProvider dataProviderIsValid 36 | */ 37 | public function testIsValid($render_array, bool $expected) { 38 | $actual = $this->sut->isValid($render_array); 39 | $this->assertSame($expected, $actual); 40 | } 41 | 42 | /** 43 | * Data provider for the testIsValid. 44 | * 45 | * @return array 46 | * The data. 47 | */ 48 | public function dataProviderIsValid() { 49 | return [ 50 | [ 51 | ['#fa' => 'mily'], 52 | TRUE, 53 | ], 54 | [ 55 | ['fa' => 'mily'], 56 | FALSE, 57 | ], 58 | [ 59 | [0 => 'ze', '#ro' => ''], 60 | TRUE, 61 | ], 62 | [ 63 | [0 => 'ze', 'ro' => []], 64 | FALSE, 65 | ], 66 | [ 67 | ['fa' => ['#fa' => 'mily']], 68 | TRUE, 69 | ], 70 | [ 71 | ['fa' => [['#fa' => 'mily']]], 72 | TRUE, 73 | ], 74 | [ 75 | '#fa', 76 | FALSE, 77 | ], 78 | [ 79 | NULL, 80 | FALSE, 81 | ], 82 | [ 83 | INF, 84 | FALSE, 85 | ], 86 | [ 87 | [ 88 | '#va' => [['Foo\Bar', 'validateWithSchema']], 89 | 'fa' => ['#fa' => 'mily'], 90 | ], 91 | TRUE 92 | ] 93 | ]; 94 | } 95 | 96 | } 97 | --------------------------------------------------------------------------------