diff --git a/.gitignore b/.gitignore index 1f1befd..d5bebc6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -/public -/.hugo_build.lock +/go-form diff --git a/.woodpecker.yml b/.woodpecker.yml deleted file mode 100644 index c04fa41..0000000 --- a/.woodpecker.yml +++ /dev/null @@ -1,32 +0,0 @@ -steps: - Build: - image: gitnet.fr/deblan/hugo - pull: true - environments: - HUGO_ENVIRONMENT: production - commands: - - hugo build - - "Commit pages": - image: alpine/git - commands: - - mv public /tmp/ - - git config --global user.email ci@gitnet.fr - - git config --global user.name CI - - git fetch --no-tags origin +refs/heads/pages - - git switch pages - - rm * -fr - - mv /tmp/public/* . - - git add . - - git commit -m "Build doc ${CI_BUILD_NUMBER}" - - "Push pages": - image: appleboy/drone-git-push - commands: - settings: - branch: pages - remote: git@gitnet.fr:deblan/go-form.git - force: false - commit: false - ssh_key: - from_secret: ssh_priv_key diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ba56fe5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,101 @@ +## [Unreleased] + +## v1.5.0 + +### Added + +- feat: add collection widget +- feat: refactoring and improvement of example + +## v1.4.0 + +### Added + +- feat: add json configuration + +### Fixed + +- fix: reset `GlobalFields` in `End()` + +## v1.3.0 + +### Added + +- feat: allow to handle request using json body" +- feat: add `WithJsonRequest` on form +- feat: add `MapToUrlValues` transformer + +## v1.2.0 + +### Added + +- feat: add tag `field` to specify the naming strategy (case) +- feat: add `ErrorsTree` methods on form and field + +## v1.1.6 + +### Fixed + +- fix(input/choice): add specific validation func + +## v1.1.5 + +### Fixed + +- fix(input/choice): add specific validation func + +## v1.1.4 + +### Fixed + +- fix(theme/bootstrap5): fix button class +- fix(theme/html5): remove div wrapper on form content + +## v1.1.3 + +### Fixed + +- fix(form): replace existing option in WithOptions + +## v1.1.2 + +### Added + +- chore(example): remove boostrap classes + +### Fixed + +- fix(theme): checkbox is check on nil value + +## v1.1.1 + +### Fixed + +- feat(form): add default options +- fix(choice): fix Match when field data is nil + +## v1.1.0 + +### Added + +- feat: add go template functions to render a form, a field, etc. +- feat: add constraints (isodd, iseven) +- refactor: refactor constraint (not blank check) + +### Changed + +- feat: replace templates with gomponents + +## v1.0.1 + +### Added + +- doc: add documentation + +## v1.0.0 + +### Added + +- feat: form builder +- feat: form validation +- feat: form renderer diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENCE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Makefile b/Makefile deleted file mode 100644 index d8be964..0000000 --- a/Makefile +++ /dev/null @@ -1,7 +0,0 @@ -all: build - -serve: - hugo server --buildDrafts --disableFastRender - -build: - hugo build diff --git a/README.md b/README.md new file mode 100644 index 0000000..3754083 --- /dev/null +++ b/README.md @@ -0,0 +1,20 @@ +# go-form + +Creating and processing HTML forms is hard and repetitive. You need to deal with rendering HTML form fields, validating submitted data, mapping the form data into objects and a lot more. [`go-form`][go-form] includes a powerful form feature that provides all these features. + +[`go-form`][go-form] is heavily influenced by [Symfony Form](https://symfony.com/doc/current/forms.html). It includes: + +* A form builder based on fields declarations and independent of structs +* Validation based on constraints +* Data mounting to populate a form from a struct instance +* Data binding to populate a struct instance from a submitted form +* Form renderer with customizable themes + +## Documentation + +* [Official documentation][doc] +* [Demo][demo] + +[go-form]: https://gitnet.fr/deblan/go-form +[demo]: https://gitnet.fr/deblan/go-form-demo +[doc]: https://deblan.gitnet.page/go-form/ diff --git a/archetypes/default.md b/archetypes/default.md deleted file mode 100644 index 0d5eebd..0000000 --- a/archetypes/default.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -date: '{{ .Date }}' -draft: true -title: '{{ replace .File.ContentBaseName "-" " " | title }}' ---- diff --git a/assets/css/custom.css b/assets/css/custom.css deleted file mode 100644 index 202c701..0000000 --- a/assets/css/custom.css +++ /dev/null @@ -1,60 +0,0 @@ -.hidden { - display: none; -} - -.hugo-goplay-summary { - cursor: pointer; -} - -.hugo-goplay-textarea { - padding: 1rem; - width: 100%; - border-radius: 4px; - margin: 10px 0; - background-color: hsl(var(--primary-hue) var(--primary-saturation) calc(var(--primary-lightness) + calc(calc(100% - var(--primary-lightness)) / 50) * 27) / 0.1); - font-family: monospace; -} - -.hugo-goplay-result pre { - padding: 1rem; -} - -.hugo-goplay-result code { - background: none !important; - border: 0 !important; -} - -.hugo-goplay-result .system { - color: green; -} - -.hugo-goplay-result .stderr { - color: red; -} - -.hugo-goplay-toolbox { - display: flex; - justify-content: flex-start; - margin-bottom: 2rem; -} - -.hugo-goplay-toolbox .hugo-goplay-button { - font-size: 13px; - font-weight: bold; - border: 1px solid var(--primary); - padding: 0.15rem 0.75rem; - border-radius: 4px; - margin-left: 0.5rem; - color: var(--primary); - background-color: var(--theme); -} - -.hugo-goplay-toolbox .hugo-goplay-button:hover { - border: 1px solid var(--theme); - color: var(--theme); - background-color: var(--primary); -} - -.hugo-goplay-toolbox .hugo-goplay-button:first-child { - margin-left: 0; -} diff --git a/content/_index.md b/content/_index.md deleted file mode 100644 index c96bcf4..0000000 --- a/content/_index.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -layout: hextra-home -title: "Welcome!" ---- - -{{< hextra/hero-badge >}} -
- Free, open source - {{< icon name="arrow-circle-right" attributes="height=14" >}} -{{< /hextra/hero-badge >}} - -
-{{< hextra/hero-headline >}} - Create and process forms with Golang! -{{< /hextra/hero-headline >}} -
- -
-{{< hextra/hero-subtitle >}} - Fast, batteries-included Hugo theme 
for creating beautiful static websites -{{< /hextra/hero-subtitle >}} -
-
- -
-{{< hextra/hero-button text="Get Started" link="docs" >}} -
-
-
- -
- -{{< hextra/feature-grid >}} - {{< hextra/feature-card - title="Fast and Full-featured" - subtitle="Simple and easy to use, yet powerful and feature-rich." - class="hx:aspect-auto hx:md:aspect-[1.1/1] hx:max-md:min-h-[340px]" - image="images/hextra-doc.webp" - imageClass="hx:top-[40%] hx:left-[24px] hx:w-[180%] hx:sm:w-[110%] hx:dark:opacity-80" - style="background: radial-gradient(ellipse at 50% 80%,rgba(194,97,254,0.15),hsla(0,0%,100%,0));" - >}} - {{< hextra/feature-card - title="Form builder and validation" - subtitle="Compose with just Markdown. Enrich with Shortcode components." - class="hx:aspect-auto hx:md:aspect-[1.1/1] hx:max-lg:min-h-[340px]" - image="images/hextra-markdown.webp" - imageClass="hx:top-[40%] hx:left-[36px] hx:w-[180%] hx:sm:w-[110%] hx:dark:opacity-80" - style="background: radial-gradient(ellipse at 50% 80%,rgba(142,53,74,0.15),hsla(0,0%,100%,0));" - >}} - {{< hextra/feature-card - title="Mount and bind any struct" - subtitle="Built-in full text search with FlexSearch, no extra setup required." - class="hx:aspect-auto hx:md:aspect-[1.1/1] hx:max-md:min-h-[340px]" - image="images/hextra-search.webp" - imageClass="hx:top-[40%] hx:left-[36px] hx:w-[110%] hx:sm:w-[110%] hx:dark:opacity-80" - style="background: radial-gradient(ellipse at 50% 80%,rgba(221,210,59,0.15),hsla(0,0%,100%,0));" - >}} -{{< /hextra/feature-grid >}} diff --git a/content/docs/Installation.md b/content/docs/Installation.md deleted file mode 100644 index cb641b4..0000000 --- a/content/docs/Installation.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -linkTitle: Installation -title: Installation -weight: 2 ---- - -```golang -go get gitnet.fr/deblan/go-form -``` diff --git a/content/docs/_index.md b/content/docs/_index.md deleted file mode 100644 index 4a7b3d7..0000000 --- a/content/docs/_index.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -linkTitle: "Documentation" -title: Introduction ---- - -Creating and processing HTML forms is hard and repetitive. You need to deal with rendering HTML form fields, validating submitted data, mapping the form data into objects and a lot more. **go-form** includes a powerful form feature that provides all these features. - -**go-form** is heavily influenced by [Symfony Form](https://symfony.com/doc/current/forms.html). It includes: - -* A form builder based on fields declarations and independent of structs -* Validation based on constraints -* Data mounting to populate a form from a struct instance -* Data binding to populate a struct instance from a submitted form -* Form renderer with customizable themes diff --git a/content/docs/constraints/_index.md b/content/docs/constraints/_index.md deleted file mode 100644 index d874e08..0000000 --- a/content/docs/constraints/_index.md +++ /dev/null @@ -1,153 +0,0 @@ ---- -linkTitle: Constraints -title: Constraints -weight: 5 ---- - -The validation is designed to validate data against constraints. - -## Import - -```golang -import ( - "gitnet.fr/deblan/go-form/validation" -) -``` - -## Constraints - -### Length - -Validate the length of an array, a slice or a string - -```golang -c := validation.NewLength() - -// Define minimum -c.WithMin(1) - -// Define minimum -c.WithMax(100) - -// Define min and max -// Equivalent to c.WithMin(50).WithMax(50) -c.WithExact(50) -``` - -### Mail - -```golang -validation.NewMail() -``` - -### Not blank - -```golang -validation.NewNotBlank() -``` - -### Range - -Validate a number - -```golang -c := validation.NewRange() - -// Define minimum -c.WithMin(1) - -// Define minimum -c.WithMax(100) - -// Define min and max -// Equivalent to c.WithMin(1).WithMax(100) -c.WithRange(1, 100) -``` - -### Regex - -Validate a string with a regex - -```golang -c := validation.NewRegex(`expression`) - -// The value must match -c.MustMatch() - -// The value must not match -c.MustNotMatch() -``` - -### Is even - -Validate that a number is even. - -```golang -validation.NewIsEven() -``` - -### Is odd - -Validate that a number is odd. - -```golang -validation.NewIsOdd() -``` - - -## Custom constraint - -Use case: you want to validate that the data equals "example" - -```golang -package validation - -import ( - "reflect" - - v "gitnet.fr/deblan/go-form/validation" -) - -// Define a struct -type IsExample struct { - Message string - TypeErrorMessage string -} - -// Create a factory -func NewIsExample() IsEven { - return IsEven{ - Message: "This value does not equal \"example\".", - TypeErrorMessage: "This value can not be processed.", - } -} - -// Implement the validation -func (c IsExample) Validate(data any) []Error { - errors := []Error{} - - // Should not validate blank data - if len(v.NewNotBlank().Validate(data)) != 0 { - return []Error{} - } - - t := reflect.TypeOf(data) - - if t.Kind() == reflect.Ptr { - t = t.Elem() - } - - switch t.Kind() { - case reflect.String: - if data.(string) != "example" { - errors = append(errors, Error(c.Message)) - } - - default: - errors = append(errors, Error(c.TypeErrorMessage)) - } - - return errors -} -``` - diff --git a/content/docs/fields/_index.md b/content/docs/fields/_index.md deleted file mode 100644 index 63fb9be..0000000 --- a/content/docs/fields/_index.md +++ /dev/null @@ -1,384 +0,0 @@ ---- -linkTitle: Fields -title: Fields -weight: 4 ---- - -A field represents a field in a form. - -### Checkbox - -```golang -func NewFieldCheckbox(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Generates an input[type=checkbox] - -### Choice - -```golang -func NewFieldChoice(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - - -Generates inputs (checkbox or radio) or selects - -### Csrf - -```golang -func NewFieldCsrf(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -### Date - -```golang -func NewFieldDate(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Generates an input[type=date] with default transformers - -### Datetime - -```golang -func NewFieldDatetime(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Generates an input[type=datetime] with default transformers - -### DatetimeLocal - -```golang -func NewFieldDatetimeLocal(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Generates an input[type=datetime-local] with default transformers - -### Hidden - -```golang -func NewFieldHidden(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Generates an input[type=hidden] - -### Mail - -```golang -func NewFieldMail(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Generates an input[type=email] - -### Number - -```golang -func NewFieldNumber(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Generates an input[type=number] with default transformers - -### Password - -```golang -func NewFieldPassword(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Generates an input[type=password] - -### Range - -```golang -func NewFieldRange(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Generates an input[type=range] - -### Sub Form - -```golang -func NewFieldSubForm(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Alias: - -```golang -func NewSubForm(name string) *Field -``` - -Generates a sub form - -### Text - -```golang -func NewFieldText(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Generates an input[type=text] - -### Textarea - -```golang -func NewFieldTextarea(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Generates a textarea - -### Time - -```golang -func NewFieldTime(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Generates an input[type=time] with default transformers - -### Submit - -```golang -func NewSubmit(name string) *Field -``` - -{{% goplay-field %}} - -{{% /goplay-field %}} - -Generates an input[type=submit] - -## Methods - -### Add - -```golang -func (f *Field) Add(children ...*Field) *Field -``` - -Appends children - -### Bind - -```golang -func (f *Field) Bind(data map[string]any, key *string) error -``` - -Bind the data into the given map - -### GetChild - -```golang -func (f *Field) GetChild(name string) *Field -``` - -Returns a child using its name - -### GetId - -```golang -func (f *Field) GetId() string -``` - -Computes the id of the field - -### GetName - -```golang -func (f *Field) GetName() string -``` - -Computes the name of the field - -### GetOption - -```golang -func (f *Field) GetOption(name string) *Option -``` - -Returns an option using its name - -### HasChild - -```golang -func (f *Field) HasChild(name string) bool -``` - -Checks if the field contains a child using its name - -### HasOption - -```golang -func (f *Field) HasOption(name string) bool -``` - -Checks if the field contains an option using its name - -### Mount - -```golang -func (f *Field) Mount(data any) error -``` - -Populates the field with data - -### ResetErrors - -```golang -func (f *Field) ResetErrors() *Field -``` - -Resets the field errors - -### WithBeforeBind - -```golang -func (f *Field) WithBeforeBind(callback func(data any) (any, error)) *Field -``` - -Sets a transformer applied to the data of a field before defining it in a structure - -### WithBeforeMount - -```golang -func (f *Field) WithBeforeMount(callback func(data any) (any, error)) *Field -``` - -Sets a transformer applied to the structure data before displaying it in a field - -### WithConstraints - -```golang -func (f *Field) WithConstraints(constraints ...validation.Constraint) *Field -``` - -Appends constraints - -### WithData - -```golang -func (f *Field) WithData(data any) *Field -``` - -Sets data the field - -### WithFixedName - -```golang -func (f *Field) WithFixedName() *Field -``` - -Sets that the name of the field is not computed - -### WithOptions - -```golang -func (f *Field) WithOptions(options ...*Option) *Field -``` - -#### Common options - -| Name | type | description | Info | -| ---- | ---- | ---- | ---- | -| `required` | `bool` | Add `required="true"` | Does not apply a constraint | -| `attr` | `form.Attrs` | List of extra attributes of the field | | -| `row_attr` | `form.Attrs` | List of extra attributes of the field's top container | | -| `label` | `string` | The label of the field | Usually show before the field | -| `label_attr` | `form.Attrs` | List of extra attributes of the label | | -| `help` | `string` | Helper of the field | | -| `help_attr` | `form.Attrs` | List of extra attributes of the help | | - -Appends options to the field - -### WithSlice - -```golang -func (f *Field) WithSlice() *Field -``` - -Sets that the field represents a data slice diff --git a/content/docs/form/_index.md b/content/docs/form/_index.md deleted file mode 100644 index 60466be..0000000 --- a/content/docs/form/_index.md +++ /dev/null @@ -1,243 +0,0 @@ ---- -linkTitle: Form -title: Form -weight: 3 ---- - -## Example - -### Prerequisites - -```golang -import ( - "gitnet.fr/deblan/go-form/form" - "gitnet.fr/deblan/go-form/validation" -) - -type Person struct { - Name string - Age int -} -``` - -### Creating a form - -```golang -myForm := form.NewForm( - form.NewFieldText("Name"). - WithConstraints( - validation.NewNotBlank(), - ), - form.NewFieldNumber("Age"). - WithConstraints( - validation.NewNotBlank(), - validation.NewRange().WithMin(18), - ), -).End() -``` - -### Validating a struct - -```golang -data := Person{} - -myForm.Mount(data) -myForm.IsValid() // false - -data = Person{ - Name: "Alice", - Age: 42, -} - -myForm.Mount(data) -myForm.IsValid() // true -``` - -### Validating a request - -```golang -import ( - "net/http" -) - -myForm.WithMethod(http.MethodPost) - -// req *http.Request -if req.Method == myForm.Method { - myForm.HandleRequest(req) - - if myForm.IsSubmitted() && myForm.IsValid() { - myForm.Bind(&data) - } -} -``` - -## Struct - -```golang -type Form struct { - Fields []*Field - GlobalFields []*Field - Errors []validation.Error - Method string - Action string - Name string - Options []*Option - RequestData *url.Values -} -``` - -## Methods - -### NewForm - -```golang -func NewForm(fields ...*Field) *Form -``` - -Generates a new form with default properties - -### Add - -```golang -func (f *Form) Add(fields ...*Field) -``` - -Appends children - -### AddGlobalField - -```golang -func (f *Form) AddGlobalField(field *Field) -``` - -Configures its children deeply - -### Bind - -```golang -func (f *Form) Bind(data any) error -``` - -Copies datas from the form to a struct - -### End - -```golang -func (f *Form) End() *Form -``` - -Configures its children deeply This function must be called after adding all - -fields - -### GetField - -```golang -func (f *Form) GetField(name string) *Field -``` - -Returns a child using its name - -### GetOption - -```golang -func (f *Form) GetOption(name string) *Option -``` - -Returns an option using its name - -### HandleRequest - -```golang -func (f *Form) HandleRequest(req *http.Request) -``` - -Processes a request - -### HasField - -```golang -func (f *Form) HasField(name string) bool -``` - -Checks if the form contains a child using its name - -### HasOption - -```golang -func (f *Form) HasOption(name string) bool -``` - -Checks if the form contains an option using its name - -### IsSubmitted - -```golang -func (f *Form) IsSubmitted() bool -``` - -Checks if the form is submitted - -### IsValid - -```golang -func (f *Form) IsValid() bool -``` - -Checks the a form is valid - -### Mount - -```golang -func (f *Form) Mount(data any) error -``` - -Copies datas from a struct to the form - -### ResetErrors - -```golang -func (f *Form) ResetErrors() *Form -``` - -Resets the form errors - -### WithAction - -```golang -func (f *Form) WithAction(v string) *Form -``` - -Sets the action of the form (eg: "/") - -### WithMethod - -```golang -func (f *Form) WithMethod(v string) *Form -``` - -Sets the method of the format (http.MethodPost, http.MethodGet, ...) - -### WithName - -```golang -func (f *Form) WithName(v string) *Form -``` - -Sets the name of the form (used to compute name of fields) - -### WithOptions - -```golang -func (f *Form) WithOptions(options ...*Option) *Form -``` - -Appends options to the form - -#### Options - - | Name | Type | Description | - | ---- | ---- | ---- | - | `attr` | `map[string]string` | List of extra attributes | - | `help` | `string` | Helper | diff --git a/content/docs/rendering/_index.md b/content/docs/rendering/_index.md deleted file mode 100644 index 4bc00d3..0000000 --- a/content/docs/rendering/_index.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -linkTitle: Rendering -title: Rendering -weight: 6 ---- - -**go-form** allows you to render a form using Go's built-in template engine. -Here is a simple example that displays a form: - -```golang -myForm := form.NewForm(...) - -render := theme.NewRenderer(theme.Bootstrap5) - -tpl, _ := template.New("page").Funcs(render.FuncMap()).Parse(` - - - My form - - - {{ form .Form }} - - -`) - -b := new(strings.Builder) - -tpl.Execute(w, map[string]any{ - "Form": myForm, -}) - -fmt.Println(b.String()) -``` - -{{% goplay-auto-import-main %}} - -{{% /goplay-auto-import-main %}} - -Other helper functions are available to render specific parts of the form: - -- `form_errors`: displays the form's global errors -- `form_row` : renders the label, errors, and widget of a field -- `form_label`: renders only the label of a field -- `form_widget`: renders only the widget of a field -- `form_widget_errors`: renders only the errors of a specific field diff --git a/content/docs/rendering/theming.md b/content/docs/rendering/theming.md deleted file mode 100644 index 252e940..0000000 --- a/content/docs/rendering/theming.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -linkTitle: Theming -title: Theming ---- - -**go-form** provides 2 themes: - -- `theme.Html5`: a basic view without classes -- `theme.Bootstrap5`: a theme for [Bootstrap 5](https://getbootstrap.com/) - -You can add a custom theme. Learn by reading the [Bootstrap5](https://gitnet.fr/deblan/go-form/src/branch/main/theme/bootstrap5.go) theme. diff --git a/content/docs/workflow/_index.md b/content/docs/workflow/_index.md deleted file mode 100644 index 6f2b485..0000000 --- a/content/docs/workflow/_index.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -linkTitle: Workflow -title: Workflow -weight: 2 ---- - -{{% steps %}} - -### Import - -```golang -import ( - "html/template" - "net/http" - - "gitnet.fr/deblan/go-form/form" - "gitnet.fr/deblan/go-form/theme" -) -``` - -### Create a form - -```golang -// Let's create a new form -// You can pass *form.Field as arguments -myForm := form.NewForm(field1, field2, ...) - -// Add somes fields -myForm.Add(field3, field4, ...) - -// Set the method -//
-myForm.WithMethod(http.MethodPost) - -// Define the action -// -myForm.WithAction("/") - -// Set a name -myForm.WithName("myForm") - -// Add options -myForm.WithOptions(option1, option2, ...) - -// When all fields are added, call End() -myForm.End() -``` - -#### Attributes - -Some options are natively supported in go-form themes. - -```golang -myForm.WithOptions( - form.NewOption("help", "A help for the form"), - // + + + + Demo of go-form with Bootstrap + + + + +
+ {{ form_widget (.styleForm.GetField "value") }} +
+ +

Demo of go-form with Bootstrap

+ +
+ Debug view +
+ Submitted: + {{ .isSubmitted }} +
+
+ Valid: + {{ .isValid }} +
+ +
+ Dump of data +
{{ .dump }}
+
+ +
+ Form as JSON +
{{ .json }}
+
+
+ + {{if .isValid}} +
The form is valid!
+ {{else}} +
The form is invalid!
+ {{end}} + + {{ form .form }} +
+ + + + diff --git a/example/view/html5.html b/example/view/html5.html new file mode 100644 index 0000000..88db2d8 --- /dev/null +++ b/example/view/html5.html @@ -0,0 +1,138 @@ + + + + + Demo of go-form (pure HTML5 and Pico) + + + + + + + + + {{ form_widget (.styleForm.GetField "value") }} +
+ +

Demo of go-form (pure HTML5 and Pico)

+ +
+ Debug view + +
+ Submitted: + {{ .isSubmitted }} +
+
+ Valid: + {{ .isValid }} +
+ +
+ Dump of data +
{{ .dump }}
+
+ +
+ Form as JSON +
{{ .json }}
+
+ +
+ + {{if .isValid}} +

The form is valid!

+ {{else}} +

The form is invalid!

+ {{end}} + + {{ form .form }} + + + + + diff --git a/form/field.go b/form/field.go new file mode 100644 index 0000000..fe63326 --- /dev/null +++ b/form/field.go @@ -0,0 +1,423 @@ +package form + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "fmt" + "maps" + "slices" + "strings" + + "gitnet.fr/deblan/go-form/util" + "gitnet.fr/deblan/go-form/validation" +) + +// Generic function for field.Validation +func DefaultFieldValidation(f *Field) bool { + if len(f.Children) > 0 { + isValid := true + + for _, c := range f.Children { + c.ResetErrors() + isChildValid, errs := validation.Validate(c.Data, c.Constraints) + + if len(errs) > 0 { + c.Errors = errs + } + + isValid = isChildValid && isValid + + for _, sc := range c.Children { + isValid = DefaultFieldValidation(sc) && isValid + } + } + + return isValid + } else { + f.ResetErrors() + isValid, errs := validation.Validate(f.Data, f.Constraints) + + if len(errs) > 0 { + f.Errors = errs + } + + return isValid + } +} + +// Field represents a field in a form +type Field struct { + Name string `json:"name"` + NamePrefix string `json:"name_prefix"` + Widget string `json:"widget"` + Data any `json:"-"` + Options []*Option `json:"options"` + Children []*Field `json:"children"` + Constraints []validation.Constraint `json:"-"` + Errors []validation.Error `json:"-"` + BeforeMount func(data any) (any, error) `json:"-"` + BeforeBind func(data any) (any, error) `json:"-"` + Validate func(f *Field) bool `json:"-"` + IsSlice bool `json:"is_slice"` + IsCollection bool `json:"is_collection"` + IsFixedName bool `json:"is_fixed_name"` + Form *Form `json:"-"` + Parent *Field `json:"-"` +} + +// Generates a new field with default properties +// It should not be used directly but inside function like in form.NewFieldText +func NewField(name, widget string) *Field { + f := &Field{ + Name: name, + IsFixedName: false, + Widget: widget, + Data: nil, + } + + f.BeforeMount = func(data any) (any, error) { + return data, nil + } + + f.BeforeBind = func(data any) (any, error) { + return data, nil + } + + f.WithOptions( + NewOption("attr", Attrs{}), + NewOption("row_attr", Attrs{}), + NewOption("label_attr", Attrs{}), + NewOption("help_attr", Attrs{}), + ) + + f.Validate = DefaultFieldValidation + + return f +} + +func (f *Field) Copy() *Field { + return &Field{ + Name: f.Name, + Form: f.Form, + Widget: f.Widget, + Options: f.Options, + Constraints: f.Constraints, + BeforeMount: f.BeforeMount, + BeforeBind: f.BeforeBind, + Validate: f.Validate, + IsSlice: f.IsSlice, + IsFixedName: f.IsFixedName, + } +} + +// Checks if the field contains an option using its name +func (f *Field) HasOption(name string) bool { + for _, option := range f.Options { + if option.Name == name { + return true + } + } + + return false +} + +// Returns an option using its name +func (f *Field) GetOption(name string) *Option { + for _, option := range f.Options { + if option.Name == name { + return option + } + } + + return nil +} + +// Appends options to the field +func (f *Field) WithOptions(options ...*Option) *Field { + for _, option := range options { + if f.HasOption(option.Name) { + f.GetOption(option.Name).Value = option.Value + } else { + f.Options = append(f.Options, option) + } + } + + return f +} + +// Remove an option if exists +func (f *Field) RemoveOption(name string) *Field { + var options []*Option + + for _, option := range f.Options { + if option.Name != name { + options = append(options, option) + } + } + + f.Options = options + + return f +} + +// Sets data the field +func (f *Field) WithData(data any) *Field { + f.Data = data + + return f +} + +// Resets the field errors +func (f *Field) ResetErrors() *Field { + f.Errors = []validation.Error{} + + return f +} + +// Sets that the field represents a data slice +func (f *Field) WithSlice() *Field { + f.IsSlice = true + + return f +} + +// Sets that the field represents a collection +func (f *Field) WithCollection() *Field { + f.IsCollection = true + + return f +} + +// Sets that the name of the field is not computed +func (f *Field) WithFixedName() *Field { + f.IsFixedName = true + + return f +} + +// Appends constraints +func (f *Field) WithConstraints(constraints ...validation.Constraint) *Field { + for _, constraint := range constraints { + f.Constraints = append(f.Constraints, constraint) + } + + return f +} + +// Sets a transformer applied to the structure data before displaying it in a field +func (f *Field) WithBeforeMount(callback func(data any) (any, error)) *Field { + f.BeforeMount = callback + + return f +} + +// Sets a transformer applied to the data of a field before defining it in a structure +func (f *Field) WithBeforeBind(callback func(data any) (any, error)) *Field { + f.BeforeBind = callback + + return f +} + +// Appends children +func (f *Field) Add(children ...*Field) *Field { + for _, child := range children { + child.Parent = f + f.Children = append(f.Children, child) + } + + return f +} + +// Checks if the field contains a child using its name +func (f *Field) HasChild(name string) bool { + for _, child := range f.Children { + if name == child.Name { + return true + } + } + + return false +} + +// Returns a child using its name +func (f *Field) GetChild(name string) *Field { + var result *Field + + for _, child := range f.Children { + if name == child.Name { + result = child + + break + } + } + + return result +} + +// Computes the name of the field +func (f *Field) GetName() string { + var name string + + if f.IsFixedName { + return f.Name + } + + if f.Form != nil && f.Form.Name != "" { + name = fmt.Sprintf("%s%s[%s]", f.Form.Name, f.NamePrefix, f.Name) + } else if f.Parent != nil { + name = fmt.Sprintf("%s%s[%s]", f.Parent.GetName(), f.NamePrefix, f.Name) + } else { + name = f.Name + } + + return name +} + +// Computes the id of the field +func (f *Field) GetId() string { + name := f.GetName() + name = strings.ReplaceAll(name, "[", "-") + name = strings.ReplaceAll(name, "]", "") + name = strings.ToLower(name) + + return name +} + +// Populates the field with data +func (f *Field) Mount(data any) error { + data, err := f.BeforeMount(data) + + if err != nil { + return err + } + + if len(f.Children) == 0 { + f.Data = data + + return nil + } + + props, err := util.InspectStruct(data) + + if err != nil { + return err + } + + for key, value := range props { + if f.HasChild(key) { + err = f.GetChild(key).Mount(value) + + if err != nil { + return err + } + } + } + + return nil +} + +// Bind the data into the given map +func (f *Field) Bind(data map[string]any, key *string, parentIsSlice bool) error { + if len(f.Children) == 0 { + v, err := f.BeforeBind(f.Data) + + if err != nil { + return err + } + + if key != nil { + data[*key] = v + } else { + data[f.Name] = v + } + + return nil + } + + data[f.Name] = make(map[string]any) + + for _, child := range f.Children { + child.Bind(data[f.Name].(map[string]any), key, f.IsCollection) + } + + if f.IsCollection { + var nextData []any + values := data[f.Name].(map[string]any) + var keys []string + + for key, _ := range values { + keys = append(keys, key) + } + + slices.Sort(keys) + + for _, key := range keys { + for valueKey, value := range values { + if valueKey == key { + nextData = append(nextData, value) + } + } + } + + data[f.Name] = nextData + } + + return nil +} + +// Generates a tree of errors +func (f *Field) ErrorsTree(tree map[string]any, key *string) { + var index string + + if key != nil { + index = *key + } else { + index = f.Name + } + + if len(f.Children) == 0 { + if len(f.Errors) > 0 { + tree[index] = map[string]any{ + "meta": map[string]any{ + "id": f.GetId(), + "name": f.Name, + "formName": f.GetName(), + }, + "errors": f.Errors, + } + } + } else { + errors := make(map[string]any) + + for _, child := range f.Children { + if len(child.Errors) > 0 { + child.ErrorsTree(errors, &child.Name) + } + } + + if len(errors) > 0 { + tree[index] = map[string]any{ + "meta": map[string]any{ + "id": f.GetId(), + "name": f.Name, + "formName": f.GetName(), + }, + "errors": []validation.Error{}, + "children": slices.Collect(maps.Values(errors)), + } + } + } +} diff --git a/form/field_checkbox.go b/form/field_checkbox.go new file mode 100644 index 0000000..b4b903c --- /dev/null +++ b/form/field_checkbox.go @@ -0,0 +1,48 @@ +package form + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "github.com/spf13/cast" +) + +// Generates an input[type=checkbox] +func NewFieldCheckbox(name string) *Field { + f := NewField(name, "input"). + WithOptions(NewOption("type", "checkbox")). + WithBeforeMount(func(data any) (any, error) { + switch data.(type) { + case string: + data = data == "1" + case bool: + return data, nil + } + + return cast.ToInt(data), nil + }). + WithBeforeBind(func(data any) (any, error) { + switch data.(type) { + case string: + return data == "1", nil + case bool: + return data, nil + } + + return cast.ToBool(data), nil + }) + + return f +} diff --git a/form/field_choice.go b/form/field_choice.go new file mode 100644 index 0000000..16c06fc --- /dev/null +++ b/form/field_choice.go @@ -0,0 +1,200 @@ +package form + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "encoding/json" + "reflect" + + "github.com/spf13/cast" + "gitnet.fr/deblan/go-form/validation" +) + +type Choice struct { + Value string + Label string + Data any +} + +func (c Choice) Match(value string) bool { + return c.Value == value +} + +type Choices struct { + Data any `json:"data"` + ValueBuilder func(key int, item any) string `json:"-"` + LabelBuilder func(key int, item any) string `json:"-"` +} + +func (c *Choices) Match(f *Field, value string) bool { + if f.Data == nil { + return false + } + + if f.IsSlice { + v := reflect.ValueOf(f.Data) + + for key, _ := range c.GetChoices() { + for i := 0; i < v.Len(); i++ { + item := v.Index(i).Interface() + + switch item.(type) { + case string: + if item == value { + return true + } + default: + if c.ValueBuilder(key, item) == value { + return true + } + } + } + } + + return false + } + + return f.Data == value +} + +func (c *Choices) WithValueBuilder(builder func(key int, item any) string) *Choices { + c.ValueBuilder = builder + + return c +} + +func (c *Choices) WithLabelBuilder(builder func(key int, item any) string) *Choices { + c.LabelBuilder = builder + + return c +} + +func (c *Choices) GetChoices() []Choice { + choices := []Choice{} + + v := reflect.ValueOf(c.Data) + + switch v.Kind() { + case reflect.Slice, reflect.Array, reflect.String, reflect.Map: + for i := 0; i < v.Len(); i++ { + choices = append(choices, Choice{ + Value: c.ValueBuilder(i, v.Index(i).Interface()), + Label: c.LabelBuilder(i, v.Index(i).Interface()), + Data: v.Index(i).Interface(), + }) + } + } + + return choices +} + +func (c Choices) MarshalJSON() ([]byte, error) { + var choices []map[string]string + + v := reflect.ValueOf(c.Data) + + switch v.Kind() { + case reflect.Slice, reflect.Array, reflect.String, reflect.Map: + for i := 0; i < v.Len(); i++ { + choices = append(choices, map[string]string{ + "value": c.ValueBuilder(i, v.Index(i).Interface()), + "label": c.LabelBuilder(i, v.Index(i).Interface()), + }) + } + } + + return json.Marshal(choices) +} + +// Generates an instance of Choices +func NewChoices(items any) *Choices { + builder := func(key int, item any) string { + return cast.ToString(key) + } + + choices := Choices{ + ValueBuilder: builder, + LabelBuilder: builder, + Data: items, + } + + return &choices +} + +// Generates inputs (checkbox or radio) or selects +func NewFieldChoice(name string) *Field { + f := NewField(name, "choice"). + WithOptions( + NewOption("choices", &Choices{}), + NewOption("expanded", false), + NewOption("multiple", false), + NewOption("empty_choice_label", "None"), + ) + + f.Validate = func(field *Field) bool { + isValid := field.Validate(field) + + if len(validation.NewNotBlank().Validate(field.Data)) == 0 { + choices := field.GetOption("choices").Value.(*Choices) + isValidChoice := false + + for _, choice := range choices.GetChoices() { + if choices.Match(field, choice.Value) { + isValidChoice = true + } + } + + if !isValidChoice { + field.Errors = append(field.Errors, validation.Error("This value is not valid.")) + isValid = false + } + } + + return isValid + } + + f.WithBeforeBind(func(data any) (any, error) { + choices := f.GetOption("choices").Value.(*Choices) + + switch data.(type) { + case string: + v := data.(string) + for _, c := range choices.GetChoices() { + if c.Match(v) { + return c.Data, nil + } + } + case []string: + v := reflect.ValueOf(data) + var res []interface{} + + for _, choice := range choices.GetChoices() { + for i := 0; i < v.Len(); i++ { + item := v.Index(i).Interface().(string) + if choice.Match(item) { + res = append(res, choice.Data) + } + } + } + + return res, nil + } + + return data, nil + }) + + return f +} diff --git a/form/field_collection.go b/form/field_collection.go new file mode 100644 index 0000000..3f3ece1 --- /dev/null +++ b/form/field_collection.go @@ -0,0 +1,80 @@ +package form + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "fmt" + "reflect" +) + +// Generates a sub form +func NewFieldCollection(name string) *Field { + f := NewField(name, "collection"). + WithOptions( + NewOption("allow_add", true), + NewOption("allow_delete", true), + NewOption("form", nil), + ). + WithCollection() + + f.WithBeforeMount(func(data any) (any, error) { + if opt := f.GetOption("form"); opt != nil { + if src, ok := opt.Value.(*Form); ok { + src.Name = f.GetName() + t := reflect.TypeOf(data) + + switch t.Kind() { + case reflect.Slice: + slice := reflect.ValueOf(data) + + for i := 0; i < slice.Len(); i++ { + name := fmt.Sprintf("%d", i) + value := slice.Index(i).Interface() + + if f.HasChild(name) { + f.GetChild(name).Mount(value) + } else { + form := src.Copy() + form.Mount(value) + + field := f.Copy() + field.Widget = "sub_form" + field.Name = name + field.Add(form.Fields...) + field. + RemoveOption("form"). + RemoveOption("label") + + for _, c := range field.Children { + c.NamePrefix = fmt.Sprintf("[%d]", i) + } + + f.Add(field) + } + } + } + } + } + + return data, nil + }) + + return f +} + +func NewCollection(name string) *Field { + return NewFieldCollection(name) +} diff --git a/form/field_input.go b/form/field_input.go new file mode 100644 index 0000000..ca42873 --- /dev/null +++ b/form/field_input.go @@ -0,0 +1,84 @@ +package form + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "github.com/spf13/cast" +) + +// Generates an input[type=text] +func NewFieldText(name string) *Field { + f := NewField(name, "input"). + WithOptions(NewOption("type", "text")) + + return f +} + +// Generates an input[type=number] with default transformers +func NewFieldNumber(name string) *Field { + f := NewField(name, "input"). + WithOptions(NewOption("type", "number")). + WithBeforeBind(func(data any) (any, error) { + return cast.ToFloat64(data), nil + }) + + return f +} + +// Generates an input[type=email] +func NewFieldMail(name string) *Field { + f := NewField(name, "input"). + WithOptions(NewOption("type", "email")) + + return f +} + +// Generates an input[type=range] +func NewFieldRange(name string) *Field { + f := NewField(name, "input"). + WithOptions(NewOption("type", "range")). + WithBeforeBind(func(data any) (any, error) { + return cast.ToFloat64(data), nil + }) + + return f +} + +// Generates an input[type=password] +func NewFieldPassword(name string) *Field { + f := NewField(name, "input"). + WithOptions(NewOption("type", "password")) + + return f +} + +// Generates an input[type=hidden] +func NewFieldHidden(name string) *Field { + f := NewField(name, "input"). + WithOptions(NewOption("type", "hidden")) + + return f +} + +// Generates an input[type=submit] +func NewSubmit(name string) *Field { + f := NewField(name, "input"). + WithOptions(NewOption("type", "submit")) + + f.Data = "Submit" + + return f +} diff --git a/form/field_input_csrf.go b/form/field_input_csrf.go new file mode 100644 index 0000000..fc3bc3a --- /dev/null +++ b/form/field_input_csrf.go @@ -0,0 +1,23 @@ +package form + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +func NewFieldCsrf(name string) *Field { + f := NewFieldHidden(name). + WithFixedName() + + return f +} diff --git a/form/field_input_date.go b/form/field_input_date.go new file mode 100644 index 0000000..893c59d --- /dev/null +++ b/form/field_input_date.go @@ -0,0 +1,109 @@ +package form + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "fmt" + "time" +) + +func DateBeforeMount(data any, format string) (any, error) { + if data == nil { + return nil, nil + } + + switch data.(type) { + case string: + return data, nil + case time.Time: + return data.(time.Time).Format(format), nil + case *time.Time: + v := data.(*time.Time) + if v != nil { + return v.Format(format), nil + } + } + + return nil, nil +} + +// Generates an input[type=date] with default transformers +func NewFieldDate(name string) *Field { + f := NewField(name, "input"). + WithOptions(NewOption("type", "date")). + WithBeforeMount(func(data any) (any, error) { + return DateBeforeMount(data, "2006-01-02") + }). + WithBeforeBind(func(data any) (any, error) { + return time.Parse(time.DateOnly, data.(string)) + }) + + return f +} + +// Generates an input[type=datetime] with default transformers +func NewFieldDatetime(name string) *Field { + f := NewField(name, "input"). + WithOptions(NewOption("type", "datetime")). + WithBeforeMount(func(data any) (any, error) { + return DateBeforeMount(data, "2006-01-02 15:04") + }). + WithBeforeBind(func(data any) (any, error) { + return time.Parse("2006-01-02T15:04", data.(string)) + }) + + return f +} + +// Generates an input[type=datetime-local] with default transformers +func NewFieldDatetimeLocal(name string) *Field { + f := NewField(name, "input"). + WithOptions( + NewOption("type", "datetime-local"), + ). + WithBeforeMount(func(data any) (any, error) { + return DateBeforeMount(data, "2006-01-02 15:04") + }). + WithBeforeBind(func(data any) (any, error) { + a, b := time.Parse("2006-01-02T15:04", data.(string)) + + return a, b + }) + + return f +} + +// Generates an input[type=time] with default transformers +func NewFieldTime(name string) *Field { + f := NewField(name, "input"). + WithOptions(NewOption("type", "time")). + WithBeforeMount(func(data any) (any, error) { + return DateBeforeMount(data, "15:04") + }). + WithBeforeBind(func(data any) (any, error) { + if data != nil { + v := data.(string) + + if len(v) > 0 { + return time.Parse(time.TimeOnly, fmt.Sprintf("%s:00", v)) + } + } + + return nil, nil + }) + + return f +} diff --git a/form/field_subform.go b/form/field_subform.go new file mode 100644 index 0000000..6762ef6 --- /dev/null +++ b/form/field_subform.go @@ -0,0 +1,27 @@ +package form + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Generates a sub form +func NewFieldSubForm(name string) *Field { + f := NewField(name, "sub_form") + + return f +} + +func NewSubForm(name string) *Field { + return NewFieldSubForm(name) +} diff --git a/form/field_textarea.go b/form/field_textarea.go new file mode 100644 index 0000000..b9045bb --- /dev/null +++ b/form/field_textarea.go @@ -0,0 +1,21 @@ +package form + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Generates a textarea +func NewFieldTextarea(name string) *Field { + return NewField(name, "textarea") +} diff --git a/form/form.go b/form/form.go new file mode 100644 index 0000000..04016d3 --- /dev/null +++ b/form/form.go @@ -0,0 +1,321 @@ +package form + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "encoding/json" + "io/ioutil" + "maps" + "net/http" + "net/url" + "slices" + "strings" + + "github.com/mitchellh/mapstructure" + "gitnet.fr/deblan/go-form/util" + "gitnet.fr/deblan/go-form/validation" +) + +// Field represents a form +type Form struct { + Fields []*Field `json:"children"` + GlobalFields []*Field `json:"-"` + Errors []validation.Error `json:"-"` + Method string `json:"method"` + JsonRequest bool `json:"json_request"` + Action string `json:"action"` + Name string `json:"name"` + Options []*Option `json:"options"` + RequestData *url.Values `json:"-"` +} + +// Generates a new form with default properties +func NewForm(fields ...*Field) *Form { + f := new(Form) + f.Method = "POST" + f.Name = "form" + f.Add(fields...) + f.WithOptions( + NewOption("attr", Attrs{}), + NewOption("help_attr", Attrs{}), + ) + + return f +} + +// Checks if the form contains an option using its name +func (f *Form) HasOption(name string) bool { + for _, option := range f.Options { + if option.Name == name { + return true + } + } + + return false +} + +// Returns an option using its name +func (f *Form) GetOption(name string) *Option { + for _, option := range f.Options { + if option.Name == name { + return option + } + } + + return nil +} + +// Resets the form errors +func (f *Form) ResetErrors() *Form { + f.Errors = []validation.Error{} + + return f +} + +// Appends children +func (f *Form) Add(fields ...*Field) { + for _, field := range fields { + field.Form = f + f.Fields = append(f.Fields, field) + } +} + +// Configures its children deeply +// This function must be called after adding all fields +func (f *Form) End() *Form { + f.GlobalFields = []*Field{} + + for _, c := range f.Fields { + f.AddGlobalField(c) + } + + return f +} + +// Configures its children deeply +func (f *Form) AddGlobalField(field *Field) { + f.GlobalFields = append(f.GlobalFields, field) + + for _, c := range field.Children { + f.AddGlobalField(c) + } +} + +// Checks if the form contains a child using its name +func (f *Form) HasField(name string) bool { + for _, field := range f.Fields { + if name == field.Name { + return true + } + } + + return false +} + +// Returns a child using its name +func (f *Form) GetField(name string) *Field { + var result *Field + + for _, field := range f.Fields { + if name == field.Name { + result = field + break + } + } + + return result +} + +// Sets the method of the format (http.MethodPost, http.MethodGet, ...) +func (f *Form) WithMethod(v string) *Form { + f.Method = v + + return f +} + +// Sets the name of the form (used to compute name of fields) +func (f *Form) WithName(v string) *Form { + f.Name = v + + return f +} + +// Sets the action of the form (eg: "/") +func (f *Form) WithAction(v string) *Form { + f.Action = v + + return f +} + +// Appends options to the form +func (f *Form) WithOptions(options ...*Option) *Form { + for _, option := range options { + if f.HasOption(option.Name) { + f.GetOption(option.Name).Value = option.Value + } else { + f.Options = append(f.Options, option) + } + } + + return f +} + +// Checks the a form is valid +func (f *Form) IsValid() bool { + isValid := true + f.ResetErrors() + + for _, field := range f.Fields { + fieldIsValid := field.Validate(field) + isValid = isValid && fieldIsValid + } + + return isValid +} + +// Copies datas from a struct to the form +func (f *Form) Mount(data any) error { + props, err := util.InspectStruct(data) + + if err != nil { + return err + } + + for key, value := range props { + if f.HasField(key) { + err = f.GetField(key).Mount(value) + + if err != nil { + return err + } + } + } + + return nil +} + +func (f *Form) WithJsonRequest() *Form { + f.JsonRequest = true + + return f +} + +// Copies datas from the form to a struct +func (f *Form) Bind(data any) error { + toBind := make(map[string]any) + + for _, field := range f.Fields { + field.Bind(toBind, nil, false) + } + + return mapstructure.Decode(toBind, data) +} + +// Processes a request +func (f *Form) HandleRequest(req *http.Request) { + var data url.Values + + if f.JsonRequest { + body, err := ioutil.ReadAll(req.Body) + + if err != nil { + return + } + + mapping := make(map[string]any) + err = json.Unmarshal(body, &mapping) + + if err != nil { + return + } + + data = url.Values{} + util.MapToUrlValues(&data, f.Name, mapping) + } else { + switch f.Method { + case "GET": + data = req.URL.Query() + default: + req.ParseForm() + data = req.Form + } + } + + isSubmitted := false + + type collectionData map[string]any + + for _, c := range f.GlobalFields { + if c.IsCollection { + collection := util.NewCollection() + + for key, _ := range data { + if strings.HasPrefix(key, c.GetName()) { + root := strings.Replace(key, c.GetName(), "", 1) + indexes := util.ExtractDataIndexes(root) + + collection.Add(indexes, data.Get(key)) + } + } + + c.Mount(collection.Slice()) + } else if data.Has(c.GetName()) { + isSubmitted = true + + if c.IsSlice { + c.Mount(data[c.GetName()]) + } else { + c.Mount(data.Get(c.GetName())) + } + } + } + + if isSubmitted { + f.RequestData = &data + } +} + +// Checks if the form is submitted +func (f *Form) IsSubmitted() bool { + return f.RequestData != nil +} + +// Generates a tree of errors +func (f *Form) ErrorsTree() map[string]any { + errors := make(map[string]any) + + for _, field := range f.Fields { + field.ErrorsTree(errors, nil) + } + + return map[string]any{ + "errors": f.Errors, + "children": slices.Collect(maps.Values(errors)), + } +} + +func (f *Form) Copy() *Form { + var fields []*Field + + for _, i := range f.Fields { + f := *i + fields = append(fields, &f) + } + + return &Form{ + Fields: fields, + } +} diff --git a/form/option.go b/form/option.go new file mode 100644 index 0000000..95eee67 --- /dev/null +++ b/form/option.go @@ -0,0 +1,78 @@ +package form + +import "strings" + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +type Option struct { + Name string `json:"name"` + Value any `json:"value"` +} + +func NewOption(name string, value any) *Option { + return &Option{ + Name: name, + Value: value, + } +} + +func (o *Option) AsBool() bool { + return o.Value.(bool) +} + +func (o *Option) AsString() string { + return o.Value.(string) +} + +func (o *Option) AsAttrs() Attrs { + return o.Value.(Attrs) +} + +type Attrs map[string]string + +func (a Attrs) Append(name, value string) { + v, ok := a[name] + + if !ok { + v = value + } else { + v = value + " " + v + } + + a[name] = v +} + +func (a Attrs) Prepend(name, value string) { + v, ok := a[name] + + if !ok { + v = value + } else { + v += " " + value + } + + a[name] = v +} + +func (a Attrs) Remove(name, value string) { + v, ok := a[name] + + if !ok { + v = strings.ReplaceAll(v, value, "") + } + + a[name] = v +} diff --git a/go.mod b/go.mod index 0d1a3b6..20dda1e 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,11 @@ -module gitnet.fr/deblan/go-form-doc +module gitnet.fr/deblan/go-form go 1.23.0 -require github.com/imfing/hextra v0.9.7 // indirect +require ( + github.com/iancoleman/strcase v0.3.0 + github.com/mitchellh/mapstructure v1.5.0 + github.com/spf13/cast v1.9.2 + github.com/yassinebenaid/godump v0.11.1 + maragu.dev/gomponents v1.1.0 +) diff --git a/go.sum b/go.sum index 8501da6..c6c8a71 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,20 @@ -github.com/imfing/hextra v0.9.7 h1:Zg5n24us36Bn/S/5mEUPkRW6uwE6vHHEqWSgN0bPXaM= -github.com/imfing/hextra v0.9.7/go.mod h1:cEfel3lU/bSx7lTE/+uuR4GJaphyOyiwNR3PTqFTXpI= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/iancoleman/strcase v0.3.0 h1:nTXanmYxhfFAMjZL34Ov6gkzEsSJZ5DbhxWjvSASxEI= +github.com/iancoleman/strcase v0.3.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= +github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/yassinebenaid/godump v0.11.1 h1:SPujx/XaYqGDfmNh7JI3dOyCUVrG0bG2duhO3Eh2EhI= +github.com/yassinebenaid/godump v0.11.1/go.mod h1:dc/0w8wmg6kVIvNGAzbKH1Oa54dXQx8SNKh4dPRyW44= +maragu.dev/gomponents v1.1.0 h1:iCybZZChHr1eSlvkWp/JP3CrZGzctLudQ/JI3sBcO4U= +maragu.dev/gomponents v1.1.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM= diff --git a/hugo.yaml b/hugo.yaml deleted file mode 100644 index 6211413..0000000 --- a/hugo.yaml +++ /dev/null @@ -1,104 +0,0 @@ -baseURL: https://deblan.gitnet.page/go-form/ -languageCode: en-us -title: deblan/go-form - -outputs: - home: [HTML] - page: [HTML] - section: [HTML, RSS] - -enableRobotsTXT: true -hasCJKLanguage: true -enableInlineShortcodes: true - -menu: - main: - - identifier: documentation - name: Documentation - pageRef: /docs - weight: 1 - - name: Search - weight: 6 - params: - type: search - - name: GitHub - weight: 7 - url: "https://gitnet.fr/deblan/go-form" - params: - icon: github - - sidebar: [] - # - identifier: installation - # name: Installation - # weight: 2 - - -params: - description: Creating and processing HTML forms in golang - navbar: - displayTitle: true - displayLogo: true - logo: - path: images/logo.svg - dark: images/logo-dark.svg - # width: 40 - # height: 20 - # link: / - width: full - - page: - width: full - - theme: - # light | dark | system - default: system - displayToggle: true - - footer: - enable: true - displayCopyright: false - displayPoweredBy: false - width: normal - - # Display the last modification date - displayUpdatedDate: true - dateFormat: "January 2, 2006" - - search: - enable: true - type: flexsearch - - flexsearch: - # index page by: content | summary | heading | title - index: content - # full | forward | reverse | strict - # https://github.com/nextapps-de/flexsearch/#tokenizer-prefix-search - tokenize: forward - - toc: - displayTags: true - - highlight: - copy: - enable: true - # hover | always - display: hover - -markup: - highlight: - noClasses: false - goldmark: - renderer: - unsafe: true - extensions: - passthrough: - delimiters: - block: [['\[', '\]'], ['$$', '$$']] - inline: [['\(', '\)']] - enable: true - -enableInlineShortcodes: true - -module: - imports: - - path: github.com/imfing/hextra diff --git a/layouts/partials/custom/footer.html b/layouts/partials/custom/footer.html deleted file mode 100644 index 092814c..0000000 --- a/layouts/partials/custom/footer.html +++ /dev/null @@ -1 +0,0 @@ - diff --git a/layouts/shortcodes/goplay-auto-import-main.html b/layouts/shortcodes/goplay-auto-import-main.html deleted file mode 100644 index adc4f0b..0000000 --- a/layouts/shortcodes/goplay-auto-import-main.html +++ /dev/null @@ -1,28 +0,0 @@ -{{ $id := md5 .Inner }} {{ .Inner }} - -
- Test me - -
-
- - - -
-
- - diff --git a/layouts/shortcodes/goplay-field.html b/layouts/shortcodes/goplay-field.html deleted file mode 100644 index 177a83b..0000000 --- a/layouts/shortcodes/goplay-field.html +++ /dev/null @@ -1,28 +0,0 @@ -{{ $id := md5 .Inner }} {{ .Inner }} - -
- Test me - -
-
- - - -
-
- - diff --git a/static/js/custom.js b/static/js/custom.js deleted file mode 100644 index e8d886e..0000000 --- a/static/js/custom.js +++ /dev/null @@ -1,126 +0,0 @@ -function normalizeCode(code) { - return code - .trim() - .replace(/^```go/, '') - .replace(/^```/, '') - .replace(/```$/, '') - .replace(/<([^>]+)>/g, '') - .trim() -} - -function updatePlaygroundView(goplay, id, code) { - const textarea = document.getElementById(`textarea-${id}`) - - textarea.value = code - textarea.style.height = textarea.scrollHeight + 'px'; - textarea.style.overflowY = 'hidden'; - - textarea.addEventListener('input', function() { - this.style.height = 'auto'; - this.style.height = this.scrollHeight + 'px'; - }) - - const toolRun = document.getElementById(`hugo-goplay-tool-${id}-run`) - const toolTry = document.getElementById(`hugo-goplay-tool-${id}-try`) - const toolShare = document.getElementById(`hugo-goplay-tool-${id}-share`) - - toolRun.addEventListener('click', () => { - const parent = document.getElementById(id) - const pre = document.createElement('pre') - const container = document.createElement('code') - - container.classList.add('text') - pre.appendChild(container) - parent.replaceChildren(pre) - - goplay.renderCompile(container, textarea.value); - }); - - [toolTry, toolShare].forEach((v) => { - v.addEventListener('click', async () => { - const shareUrl = await goplay.share(code) - window.open(shareUrl, '_blank').focus(); - }) - }); -} - -function createFieldPlayground(goplay, id, code) { - code = createFieldPlaygroundCode(code) - - updatePlaygroundView(goplay, id, code) -} - -function createPlaygroundWithAutoImportMail(goplay, id, code) { - let lines = normalizeCode(code).split("\n") - let results = ["package main", ""] - let imports = [] - let body = [] - - lines.forEach((line) => { - if (line.substr(0, 7) === '@import') { - imports.push(' ' + line.replace('@import', '').trim()) - } else { - body.push(" " + line) - } - }) - - if (imports.length > 0) { - results.push("import (") - - imports.forEach((v) => { - results.push(v) - }) - - results.push(")", "") - } - - results.push("func main() {") - - body.forEach((v, i) => { - if (i == 0 && v.trim() == "") { - return - } - - results.push(v) - }) - - results.push("}", "") - - updatePlaygroundView(goplay, id, results.join("\n")) -} - -function createFieldPlaygroundCode(code) { - code = normalizeCode(code) - let lines = code.split("\n"); - - for (let i in lines) { - lines[i] = [' ', lines[i]].join('') - } - - code = lines.join("\n"); - - return `package main - -import ( - "fmt" - "html/template" - "strings" - - "gitnet.fr/deblan/go-form/form" - "gitnet.fr/deblan/go-form/theme" -) - -func main() { -${code} - - r(form.NewForm(field)) -} - -func r(f *form.Form) { - render := theme.NewRenderer(theme.Html5) - tpl, _ := template.New("example").Funcs(render.FuncMap()).Parse(\`{{ form_widget (.Form.GetField "Foo") }}\`) - b := new(strings.Builder) - tpl.Execute(b, map[string]any{"Form": f}) - fmt.Println(b.String()) -}` -} diff --git a/theme/bootstrap5.go b/theme/bootstrap5.go new file mode 100644 index 0000000..525bdb8 --- /dev/null +++ b/theme/bootstrap5.go @@ -0,0 +1,134 @@ +package theme + +import ( + "gitnet.fr/deblan/go-form/form" + "gitnet.fr/deblan/go-form/validation" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +var Bootstrap5 = ExtendTheme(Html5, func() map[string]RenderFunc { + theme := make(map[string]RenderFunc) + + theme["form_help"] = func(parent map[string]RenderFunc, args ...any) Node { + form := args[0].(*form.Form) + + form.GetOption("help_attr").AsAttrs().Append("class", "form-text") + + return parent["base_form_help"](parent, form) + } + + theme["form_widget_help"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + field.GetOption("help_attr").AsAttrs().Append("class", "form-text") + + return parent["base_form_widget_help"](parent, field) + } + + theme["input"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + fieldType := field.GetOption("type").AsString() + + var class string + + if fieldType == "checkbox" || fieldType == "radio" { + class = "form-check-input" + } else if fieldType == "range" { + class = "form-range" + } else if fieldType == "button" || fieldType == "submit" || fieldType == "reset" { + class = "btn" + } else { + class = "form-control" + } + + field.GetOption("attr").AsAttrs().Append("class", class) + + return parent["base_input"](parent, field) + } + + theme["form_label"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + var class string + + if field.Widget == "choice" && field.HasOption("expanded") && field.GetOption("expanded").AsBool() { + class = "form-check-label" + } else { + class = "form-label" + } + + field.GetOption("label_attr").AsAttrs().Append("class", class) + + return parent["base_form_label"](parent, field) + } + + theme["choice"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + if !field.HasOption("expanded") || !field.GetOption("expanded").AsBool() { + field.GetOption("attr").AsAttrs().Append("class", "form-control") + } + + return parent["base_choice"](parent, field) + } + + theme["choice_expanded_item"] = func(parent map[string]RenderFunc, args ...any) Node { + return Div(Class("form-check"), args[0].(Node)) + } + + theme["textarea"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + field.GetOption("attr").AsAttrs().Append("class", "form-control") + + return parent["base_textarea"](parent, field) + } + + theme["form_row"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + if field.HasOption("type") { + fieldType := field.GetOption("type").AsString() + + if fieldType == "checkbox" || fieldType == "radio" { + field.GetOption("row_attr").AsAttrs().Append("class", "form-check") + } + } + + return parent["base_form_row"](parent, field) + } + + theme["errors"] = func(parent map[string]RenderFunc, args ...any) Node { + errors := args[0].([]validation.Error) + + var result []Node + + for _, v := range errors { + result = append(result, Text(string(v))) + result = append(result, Br()) + } + + return Div( + Class("invalid-feedback d-block"), + Group(result), + ) + } + + return theme +}) diff --git a/theme/html5.go b/theme/html5.go new file mode 100644 index 0000000..2d7889e --- /dev/null +++ b/theme/html5.go @@ -0,0 +1,466 @@ +package theme + +import ( + "bytes" + "fmt" + + "github.com/spf13/cast" + "gitnet.fr/deblan/go-form/form" + "gitnet.fr/deblan/go-form/validation" + . "maragu.dev/gomponents" + . "maragu.dev/gomponents/html" +) + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +var Html5 = CreateTheme(func() map[string]RenderFunc { + theme := make(map[string]RenderFunc) + + theme["attributes"] = func(parent map[string]RenderFunc, args ...any) Node { + var result []Node + + for i, v := range args[0].(form.Attrs) { + result = append(result, Attr(i, v)) + } + + return Group(result) + } + + theme["form_attributes"] = func(parent map[string]RenderFunc, args ...any) Node { + form := args[0].(*form.Form) + + if !form.HasOption("attr") { + return Raw("") + } + + return parent["attributes"](parent, form.GetOption("attr").AsAttrs()) + } + + theme["errors"] = func(parent map[string]RenderFunc, args ...any) Node { + errors := args[0].([]validation.Error) + + var result []Node + + for _, v := range errors { + result = append(result, Li(Text(string(v)))) + } + + return Ul( + Class("gf-errors"), + Group(result), + ) + } + + theme["form_errors"] = func(parent map[string]RenderFunc, args ...any) Node { + form := args[0].(*form.Form) + + return If( + len(form.Errors) > 0, + parent["errors"](parent, form.Errors), + ) + } + + theme["form_widget_errors"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + return If( + len(field.Errors) > 0, + parent["errors"](parent, field.Errors), + ) + } + + theme["help"] = func(parent map[string]RenderFunc, args ...any) Node { + help := args[0].(string) + var extra Node + + if len(help) == 0 { + return Raw("") + } + + if len(args) == 2 { + extra = args[1].(Node) + } + + return Div( + Class("gf-help"), + Text(help), + extra, + ) + } + + theme["form_help"] = func(parent map[string]RenderFunc, args ...any) Node { + form := args[0].(*form.Form) + + if !form.HasOption("help") { + return Raw("") + } + + return parent["help"]( + parent, + form.GetOption("help").AsString(), + parent["attributes"](parent, form.GetOption("help_attr").AsAttrs()), + ) + } + + theme["form_widget_help"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + if !field.HasOption("help") { + return Raw("") + } + + return parent["help"]( + parent, + field.GetOption("help").AsString(), + parent["attributes"](parent, field.GetOption("help_attr").AsAttrs()), + ) + } + + theme["label_attributes"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + return parent["attributes"](parent, field.GetOption("label_attr").AsAttrs()) + } + + theme["form_label"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + if !field.HasOption("label") { + return Raw("") + } + + label := field.GetOption("label").AsString() + + return If(len(label) > 0, Label( + For(field.GetId()), + parent["label_attributes"](parent, field), + Text(label), + )) + } + + theme["field_attributes"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + return parent["attributes"](parent, field.GetOption("attr").AsAttrs()) + } + + theme["textarea_attributes"] = func(parent map[string]RenderFunc, args ...any) Node { + return parent["field_attributes"](parent, args...) + } + + theme["input_attributes"] = func(parent map[string]RenderFunc, args ...any) Node { + return parent["field_attributes"](parent, args...) + } + + theme["sub_form_attributes"] = func(parent map[string]RenderFunc, args ...any) Node { + return parent["field_attributes"](parent, args...) + } + + theme["input"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + fieldType := "text" + + if field.HasOption("type") { + fieldType = field.GetOption("type").AsString() + } + + value := cast.ToString(field.Data) + + if fieldType == "checkbox" { + value = "1" + } + + return Input( + Name(field.GetName()), + ID(field.GetId()), + Type(fieldType), + Value(value), + If(fieldType == "checkbox" && field.Data != nil && field.Data != false, Checked()), + If(field.HasOption("required") && field.GetOption("required").AsBool(), Required()), + parent["input_attributes"](parent, field), + ) + } + + theme["choice_options"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + choices := field.GetOption("choices").Value.(*form.Choices) + + isRequired := field.HasOption("required") && field.GetOption("required").AsBool() + isMultiple := field.GetOption("multiple").AsBool() + + var options []Node + + if !isMultiple && !isRequired { + options = append(options, Option( + Text(field.GetOption("empty_choice_label").AsString()), + )) + } + + for _, choice := range choices.GetChoices() { + options = append(options, Option( + Value(choice.Value), + Text(choice.Label), + If(choices.Match(field, choice.Value), Selected()), + )) + } + + return Group(options) + } + + theme["choice_expanded_item"] = func(parent map[string]RenderFunc, args ...any) Node { + return args[0].(Node) + } + + theme["choice_attributes"] = func(parent map[string]RenderFunc, args ...any) Node { + return parent["field_attributes"](parent, args...) + } + + theme["choice_expanded"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + choices := field.GetOption("choices").Value.(*form.Choices) + + isRequired := field.HasOption("required") && field.GetOption("required").AsBool() + isMultiple := field.GetOption("multiple").AsBool() + noneLabel := field.GetOption("empty_choice_label").AsString() + + var items []Node + + if !isMultiple && !isRequired { + id := fmt.Sprintf("%s-%s", field.GetId(), "none") + + items = append(items, parent["choice_expanded_item"](parent, Group([]Node{ + Input( + Name(field.GetName()), + ID(id), + Value(""), + Type("radio"), + parent["choice_attributes"](parent, field), + If(cast.ToString(field.Data) == "", Checked()), + ), + Label( + For(id), + Text(noneLabel), + parent["label_attributes"](parent, field), + ), + }))) + } + + for key, choice := range choices.GetChoices() { + id := fmt.Sprintf("%s-%d", field.GetId(), key) + + items = append(items, parent["choice_expanded_item"](parent, Group([]Node{ + Input( + Name(field.GetName()), + ID(id), + Value(choice.Value), + If(isMultiple, Type("checkbox")), + If(!isMultiple, Type("radio")), + parent["choice_attributes"](parent, field), + If(choices.Match(field, choice.Value), Checked()), + ), + Label( + For(id), + Text(choice.Label), + parent["label_attributes"](parent, field), + ), + }))) + } + + return Group(items) + } + + theme["choice"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + isRequired := field.HasOption("required") && field.GetOption("required").AsBool() + isExpanded := field.GetOption("expanded").AsBool() + isMultiple := field.GetOption("multiple").AsBool() + noneLabel := field.GetOption("empty_choice_label").AsString() + + _ = noneLabel + + if isExpanded { + return parent["choice_expanded"](parent, field) + } else { + return Select( + ID(field.GetId()), + If(isRequired, Required()), + If(isMultiple, Multiple()), + Name(field.GetName()), + parent["choice_attributes"](parent, field), + parent["choice_options"](parent, field), + ) + } + } + + theme["textarea"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + return Textarea( + Name(field.GetName()), + ID(field.GetId()), + If(field.HasOption("required") && field.GetOption("required").AsBool(), Required()), + parent["textarea_attributes"](parent, field), + Text(cast.ToString(field.Data)), + ) + } + + theme["sub_form_label"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + if !field.HasOption("label") { + return Raw("") + } + + label := field.GetOption("label").AsString() + + return If(len(label) > 0, Legend( + parent["label_attributes"](parent, field), + Text(label), + )) + + } + + theme["sub_form_content"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + return parent["form_fields"](parent, field.Children) + } + + theme["sub_form"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + return FieldSet( + ID(field.GetId()), + parent["sub_form_label"](parent, field), + parent["sub_form_attributes"](parent, field), + parent["sub_form_content"](parent, field), + ) + } + + theme["collection"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + var prototype string + + if opt := field.GetOption("form"); opt != nil { + if val, ok := opt.Value.(*form.Form); ok { + var buffer bytes.Buffer + dest := form.NewFieldSubForm(field.Name) + + for _, c := range val.Fields { + child := c.Copy() + child.NamePrefix = "[__name__]" + dest.Add(child) + } + + fieldPrototype := parent["form_row"](parent, dest) + fieldPrototype.Render(&buffer) + + prototype = buffer.String() + } + } + + field.WithOptions(form.NewOption("prototype", prototype)) + field.Widget = "collection_build" + + return parent["form_widget"](parent, field) + } + + theme["collection_build"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + prototype := field.GetOption("prototype").AsString() + var items []Node + + for _, child := range field.Children { + items = append(items, parent["form_row"](parent, child)) + } + + return Div( + Attr("data-prototype", prototype), + Group(items), + ) + } + + theme["form_widget"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + tpl, ok := parent[field.Widget] + + if !ok { + return Raw("Invalid field widget: " + field.Widget) + } + + return tpl(parent, field) + } + + theme["form_row"] = func(parent map[string]RenderFunc, args ...any) Node { + field := args[0].(*form.Field) + + isCheckbox := field.HasOption("type") && field.GetOption("type").AsString() == "checkbox" + hasChildren := len(field.Children) > 0 + labelAfter := isCheckbox && !hasChildren + label := parent["form_label"](parent, field) + attrs := Raw("") + + if field.HasOption("row_attr") { + attrs = parent["attributes"](parent, field.GetOption("row_attr").AsAttrs()) + } + + return Div( + attrs, + If(!labelAfter, label), + parent["form_widget_errors"](parent, field), + parent["form_widget"](parent, field), + If(labelAfter, label), + parent["form_widget_help"](parent, field), + ) + } + + theme["form_fields"] = func(parent map[string]RenderFunc, args ...any) Node { + var items []Node + + for _, item := range args[0].([]*form.Field) { + items = append(items, parent["form_row"](parent, item)) + } + + return Group(items) + } + + theme["form_content"] = func(parent map[string]RenderFunc, args ...any) Node { + form := args[0].(*form.Form) + + return Group([]Node{ + parent["form_errors"](parent, form), + parent["form_help"](parent, form), + parent["form_fields"](parent, form.Fields), + }) + } + + theme["form"] = func(parent map[string]RenderFunc, args ...any) Node { + form := args[0].(*form.Form) + + return Form( + Class("gf-form"), + Action(form.Action), + Method(form.Method), + parent["form_attributes"](parent, form), + parent["form_content"](parent, form), + ) + } + + return theme +}) diff --git a/theme/renderer.go b/theme/renderer.go new file mode 100644 index 0000000..d90b7b7 --- /dev/null +++ b/theme/renderer.go @@ -0,0 +1,84 @@ +package theme + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "bytes" + "html/template" + + "gitnet.fr/deblan/go-form/form" + "maragu.dev/gomponents" +) + +type RenderFunc func(parent map[string]RenderFunc, args ...any) gomponents.Node + +type Renderer struct { + Theme map[string]RenderFunc +} + +func NewRenderer(theme map[string]RenderFunc) *Renderer { + r := new(Renderer) + r.Theme = theme + + return r +} + +func toTemplateHtml(n gomponents.Node) template.HTML { + var buf bytes.Buffer + + n.Render(&buf) + + return template.HTML(buf.String()) +} + +func (r *Renderer) RenderForm(form *form.Form) template.HTML { + return toTemplateHtml(r.Theme["form"](r.Theme, form)) +} + +func (r *Renderer) FuncMap() template.FuncMap { + funcs := template.FuncMap{} + + for _, name := range []string{"form", "form_errors"} { + funcs[name] = func(form *form.Form) template.HTML { + return toTemplateHtml(r.Theme[name](r.Theme, form)) + } + } + + for _, name := range []string{"form_row", "form_widget", "form_label", "form_widget_errors"} { + funcs[name] = func(field *form.Field) template.HTML { + return toTemplateHtml(r.Theme[name](r.Theme, field)) + } + } + + return funcs +} + +func (r *Renderer) Render(name, tpl string, args any) template.HTML { + t, err := template.New(name).Funcs(r.FuncMap()).Parse(tpl) + + if err != nil { + return template.HTML(err.Error()) + } + + var buf bytes.Buffer + err = t.Execute(&buf, args) + + if err != nil { + return template.HTML(err.Error()) + } + + return template.HTML(buf.String()) +} diff --git a/theme/theme.go b/theme/theme.go new file mode 100644 index 0000000..9416346 --- /dev/null +++ b/theme/theme.go @@ -0,0 +1,21 @@ +package theme + +func CreateTheme(generator func() map[string]RenderFunc) map[string]RenderFunc { + return generator() +} + +func ExtendTheme(base map[string]RenderFunc, generator func() map[string]RenderFunc) map[string]RenderFunc { + extended := CreateTheme(generator) + + for i, v := range base { + _, ok := extended[i] + + if ok { + extended["base_"+i] = v + } else { + extended[i] = v + } + } + + return extended +} diff --git a/util/collection.go b/util/collection.go new file mode 100644 index 0000000..f0aa649 --- /dev/null +++ b/util/collection.go @@ -0,0 +1,111 @@ +package util + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "regexp" + "strings" + + "github.com/spf13/cast" +) + +type CollectionValue struct { + Name string + Value string + Children map[string]*CollectionValue +} + +type Collection struct { + Children map[int]*CollectionValue +} + +func NewCollection() *Collection { + return &Collection{ + Children: make(map[int]*CollectionValue), + } +} + +func NewCollectionValue(name string) *CollectionValue { + return &CollectionValue{ + Name: name, + Children: make(map[string]*CollectionValue), + } +} + +func (c *Collection) Add(indexes []string, value string) { + firstIndex := cast.ToInt(indexes[0]) + size := len(indexes) + child := c.Children[firstIndex] + + if child == nil { + child = NewCollectionValue(indexes[0]) + c.Children[firstIndex] = child + } + + child.Add(indexes[1:size], value, nil) +} + +func (c *Collection) Slice() []any { + var result []any + + for _, child := range c.Children { + result = append(result, child.Map()) + } + + return result +} + +func (c *CollectionValue) Map() any { + if len(c.Children) == 0 { + return c.Value + } + + results := make(map[string]any) + + for _, child := range c.Children { + results[child.Name] = child.Map() + } + + return results +} + +func (c *CollectionValue) Add(indexes []string, value string, lastChild *CollectionValue) { + size := len(indexes) + + if size > 0 { + firstIndex := indexes[0] + child := c.Children[firstIndex] + + child = NewCollectionValue(indexes[0]) + c.Children[firstIndex] = child + + child.Add(indexes[1:size], value, child) + } else { + lastChild.Value = value + } +} + +func ExtractDataIndexes(value string) []string { + re := regexp.MustCompile(`\[[^\]]+\]`) + items := re.FindAll([]byte(value), -1) + var results []string + + for _, i := range items { + results = append(results, strings.Trim(string(i), "[]")) + } + + return results +} diff --git a/util/inspect.go b/util/inspect.go new file mode 100644 index 0000000..22fa4f4 --- /dev/null +++ b/util/inspect.go @@ -0,0 +1,59 @@ +package util + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "errors" + "reflect" + + "github.com/iancoleman/strcase" +) + +func InspectStruct(input interface{}) (map[string]interface{}, error) { + val := reflect.ValueOf(input) + + if val.Kind() == reflect.Ptr { + val = val.Elem() + } + + if val.Kind() == reflect.Map { + return input.(map[string]interface{}), nil + } + + if val.Kind() != reflect.Struct { + return nil, errors.New("Invalid type") + } + + result := make(map[string]interface{}) + + typ := val.Type() + for i := 0; i < val.NumField(); i++ { + field := typ.Field(i) + value := val.Field(i) + tags := typ.Field(i).Tag + name := field.Name + + fieldTag := tags.Get("field") + + if fieldTag == "lowerCamel" { + name = strcase.ToLowerCamel(name) + } + + result[name] = value.Interface() + } + + return result, nil +} diff --git a/util/transformer.go b/util/transformer.go new file mode 100644 index 0000000..a942fa1 --- /dev/null +++ b/util/transformer.go @@ -0,0 +1,57 @@ +package util + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "fmt" + "net/url" +) + +func MapToUrlValues(values *url.Values, prefix string, data map[string]any) { + keyFormater := "%s" + + if prefix != "" { + keyFormater = prefix + "[%s]" + } + + for key, value := range data { + keyValue := fmt.Sprintf(keyFormater, key) + + switch v := value.(type) { + case string: + values.Add(keyValue, v) + case []string: + case []int: + case []int32: + case []int64: + case []any: + for _, s := range v { + values.Add(keyValue, fmt.Sprintf("%v", s)) + } + case bool: + if v { + values.Add(keyValue, "1") + } else { + values.Add(keyValue, "0") + } + case int, int64, float64: + values.Add(keyValue, fmt.Sprintf("%v", v)) + case map[string]any: + MapToUrlValues(values, keyValue, v) + default: + } + } +} diff --git a/validation/constraint.go b/validation/constraint.go new file mode 100644 index 0000000..8854921 --- /dev/null +++ b/validation/constraint.go @@ -0,0 +1,20 @@ +package validation + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +type Constraint interface { + Validate(data any) []Error +} diff --git a/validation/error.go b/validation/error.go new file mode 100644 index 0000000..f73d341 --- /dev/null +++ b/validation/error.go @@ -0,0 +1,18 @@ +package validation + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +type Error string diff --git a/validation/iseven.go b/validation/iseven.go new file mode 100644 index 0000000..306cae2 --- /dev/null +++ b/validation/iseven.go @@ -0,0 +1,34 @@ +package validation + +import ( + "strconv" +) + +type IsEven struct { + Message string + TypeErrorMessage string +} + +func NewIsEven() IsEven { + return IsEven{ + Message: "This value is not an even number.", + TypeErrorMessage: "This value can not be processed.", + } +} + +func (c IsEven) Validate(data any) []Error { + errors := []Error{} + + // The constraint should not validate an empty data + if len(NewNotBlank().Validate(data)) == 0 { + i, err := strconv.Atoi(data.(string)) + + if err != nil { + errors = append(errors, Error(c.TypeErrorMessage)) + } else if i%2 != 0 { + errors = append(errors, Error(c.Message)) + } + } + + return errors +} diff --git a/validation/isodd.go b/validation/isodd.go new file mode 100644 index 0000000..d7e3b53 --- /dev/null +++ b/validation/isodd.go @@ -0,0 +1,34 @@ +package validation + +import ( + "strconv" +) + +type IsOdd struct { + Message string + TypeErrorMessage string +} + +func NewIsOdd() IsOdd { + return IsOdd{ + Message: "This value is not a odd number.", + TypeErrorMessage: "This value can not be processed.", + } +} + +func (c IsOdd) Validate(data any) []Error { + errors := []Error{} + + // The constraint should not validate an empty data + if len(NewNotBlank().Validate(data)) == 0 { + i, err := strconv.Atoi(data.(string)) + + if err != nil { + errors = append(errors, Error(c.TypeErrorMessage)) + } else if i%2 != 1 { + errors = append(errors, Error(c.Message)) + } + } + + return errors +} diff --git a/validation/length.go b/validation/length.go new file mode 100644 index 0000000..66d5a1f --- /dev/null +++ b/validation/length.go @@ -0,0 +1,110 @@ +package validation + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "reflect" + "strings" + + "github.com/spf13/cast" +) + +type Length struct { + Min *int + Max *int + MinMessage string + MaxMessage string + ExactMessage string + TypeErrorMessage string +} + +func NewLength() Length { + return Length{ + MinMessage: "This value is too short (min: {{ min }}).", + MaxMessage: "This value is too long (max: {{ max }}).", + ExactMessage: "This value is not valid (expected: {{ min }}).", + TypeErrorMessage: "This value can not be processed.", + } +} + +func (c Length) WithMin(v int) Length { + c.Min = &v + + return c +} + +func (c Length) WithMax(v int) Length { + c.Max = &v + + return c +} + +func (c Length) WithExact(v int) Length { + c.Min = &v + c.Max = &v + + return c +} + +func (c Length) Validate(data any) []Error { + if (c.Min == nil && c.Max == nil) || len(NewNotBlank().Validate(data)) != 0 { + return []Error{} + } + + errors := []Error{} + + t := reflect.TypeOf(data) + + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + var size *int + + switch t.Kind() { + case reflect.Array: + case reflect.Slice: + s := reflect.ValueOf(data).Len() + size = &s + case reflect.String: + s := len(data.(string)) + size = &s + + default: + errors = append(errors, Error(c.TypeErrorMessage)) + } + + if size != nil { + if c.Max != nil && c.Min != nil { + if *c.Max == *c.Min && *size != *c.Max { + errors = append(errors, Error(c.BuildMessage(c.ExactMessage))) + } + } else if c.Min != nil && *size < *c.Min { + errors = append(errors, Error(c.BuildMessage(c.MinMessage))) + } else if c.Max != nil && *size > *c.Max { + errors = append(errors, Error(c.BuildMessage(c.MaxMessage))) + } + } + + return errors +} + +func (c *Length) BuildMessage(message string) string { + message = strings.ReplaceAll(message, "{{ min }}", cast.ToString(c.Min)) + message = strings.ReplaceAll(message, "{{ max }}", cast.ToString(c.Max)) + + return message +} diff --git a/validation/mail.go b/validation/mail.go new file mode 100644 index 0000000..136ee6d --- /dev/null +++ b/validation/mail.go @@ -0,0 +1,42 @@ +package validation + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import "net/mail" + +type Mail struct { + Message string +} + +func NewMail() Mail { + return Mail{ + Message: "This value is not a valid email address.", + } +} + +func (c Mail) Validate(data any) []Error { + errors := []Error{} + + if len(NewNotBlank().Validate(data)) == 0 { + _, err := mail.ParseAddress(data.(string)) + + if err != nil { + errors = append(errors, Error(c.Message)) + } + } + + return errors +} diff --git a/validation/notblank.go b/validation/notblank.go new file mode 100644 index 0000000..1962cb2 --- /dev/null +++ b/validation/notblank.go @@ -0,0 +1,84 @@ +package validation + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "reflect" + + "github.com/spf13/cast" +) + +type NotBlank struct { + Message string + TypeErrorMessage string +} + +func NewNotBlank() NotBlank { + return NotBlank{ + Message: "This value should not be blank.", + TypeErrorMessage: "This value can not be processed.", + } +} + +func (c NotBlank) Validate(data any) []Error { + isValid := true + errors := []Error{} + + v := reflect.ValueOf(data) + + if data == nil || v.IsZero() { + errors = append(errors, Error(c.Message)) + + return errors + } + + t := reflect.TypeOf(data) + + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + switch t.Kind() { + case reflect.Bool: + isValid = data == false + case reflect.Array: + case reflect.Slice: + isValid = reflect.ValueOf(data).Len() > 0 + case reflect.String: + isValid = len(data.(string)) > 0 + case reflect.Float32: + case reflect.Float64: + case reflect.Int: + case reflect.Int16: + case reflect.Int32: + case reflect.Int64: + case reflect.Int8: + case reflect.Uint: + case reflect.Uint16: + case reflect.Uint32: + case reflect.Uint64: + case reflect.Uint8: + isValid = cast.ToFloat64(data.(string)) == float64(0) + default: + errors = append(errors, Error(c.TypeErrorMessage)) + } + + if !isValid { + errors = append(errors, Error(c.Message)) + } + + return errors +} diff --git a/validation/range.go b/validation/range.go new file mode 100644 index 0000000..ccb95b0 --- /dev/null +++ b/validation/range.go @@ -0,0 +1,117 @@ +package validation + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "reflect" + "strings" + + "github.com/spf13/cast" +) + +type Range struct { + Min *float64 + Max *float64 + MinMessage string + MaxMessage string + RangeMessage string + TypeErrorMessage string +} + +func NewRange() Range { + return Range{ + MinMessage: "This value must be greater than or equal to {{ min }}.", + MaxMessage: "This value must be less than or equal to {{ max }}.", + RangeMessage: "This value should be between {{ min }} and {{ max }}.", + TypeErrorMessage: "This value can not be processed.", + } +} + +func (c Range) WithMin(v float64) Range { + c.Min = &v + + return c +} + +func (c Range) WithMax(v float64) Range { + c.Max = &v + + return c +} + +func (c Range) WithRange(vMin, vMax float64) Range { + c.Min = &vMin + c.Max = &vMax + + return c +} + +func (c Range) Validate(data any) []Error { + if c.Min == nil && c.Max == nil || len(NewNotBlank().Validate(data)) != 0 { + return []Error{} + } + + errors := []Error{} + + t := reflect.TypeOf(data) + + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + switch t.Kind() { + case reflect.Float32: + case reflect.Float64: + case reflect.Int: + case reflect.Int16: + case reflect.Int32: + case reflect.Int64: + case reflect.Int8: + case reflect.Uint: + case reflect.Uint16: + case reflect.Uint32: + case reflect.Uint64: + case reflect.Uint8: + case reflect.String: + isValidMin := c.Min == nil || *c.Min <= cast.ToFloat64(data.(string)) + isValidMax := c.Max == nil || *c.Max >= cast.ToFloat64(data.(string)) + + if !isValidMin || !isValidMax { + errors = append(errors, Error(c.BuildMessage())) + } + default: + errors = append(errors, Error(c.TypeErrorMessage)) + } + + return errors +} + +func (c *Range) BuildMessage() string { + var message string + + if c.Min != nil && c.Max == nil { + message = c.MinMessage + } else if c.Max != nil && c.Min == nil { + message = c.MaxMessage + } else { + message = c.RangeMessage + } + + message = strings.ReplaceAll(message, "{{ min }}", cast.ToString(c.Min)) + message = strings.ReplaceAll(message, "{{ max }}", cast.ToString(c.Max)) + + return message +} diff --git a/validation/regex.go b/validation/regex.go new file mode 100644 index 0000000..28815b1 --- /dev/null +++ b/validation/regex.go @@ -0,0 +1,75 @@ +package validation + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import ( + "reflect" + "regexp" +) + +type Regex struct { + Message string + TypeErrorMessage string + Match bool + Expression string +} + +func NewRegex(expr string) Regex { + return Regex{ + Message: "This value is not valid.", + TypeErrorMessage: "This value can not be processed.", + Match: true, + Expression: expr, + } +} + +func (c Regex) MustMatch() Regex { + c.Match = true + + return c +} + +func (c Regex) MustNotMatch() Regex { + c.Match = false + + return c +} + +func (c Regex) Validate(data any) []Error { + errors := []Error{} + + if len(NewNotBlank().Validate(data)) == 0 { + t := reflect.TypeOf(data) + + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + switch t.Kind() { + case reflect.String: + matched, _ := regexp.MatchString(c.Expression, data.(string)) + + if !matched && c.Match || matched && !c.Match { + errors = append(errors, Error(c.Message)) + } + + default: + errors = append(errors, Error(c.TypeErrorMessage)) + } + } + + return errors +} diff --git a/validation/validation.go b/validation/validation.go new file mode 100644 index 0000000..97dfd17 --- /dev/null +++ b/validation/validation.go @@ -0,0 +1,28 @@ +package validation + +// @license GNU AGPL version 3 or any later version +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +func Validate(data any, constraints []Constraint) (bool, []Error) { + errs := []Error{} + + for _, constraint := range constraints { + for _, e := range constraint.Validate(data) { + errs = append(errs, e) + } + } + + return len(errs) == 0, errs +}