diff --git a/.eslintrc.yml b/.eslintrc.yml index 07f3546d1..6847e7ffd 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -15,9 +15,8 @@ extends: parser: "vue-eslint-parser" parserOptions: - parser: "@typescript-eslint/parser" - project: './tsconfig.json' - tsconfigRootDir: './' + parser: "@typescript-eslint/parser" + project: './tsconfig.json' plugins: - vue @@ -25,6 +24,7 @@ plugins: env: browser: true +# jest: true globals: OC: readonly @@ -74,9 +74,13 @@ rules: # While we are still on Vue2, we need this. Remove once on Vue3 vue/no-deprecated-v-bind-sync: off + overrides: - files: [ "src/composables/**/*.js" ] rules: import/prefer-default-export: off + - files: [ "./src/tests/**/*.test.{js,ts}"] + env: + jest: true diff --git a/.hooks/README.md b/.hooks/README.md index 7810529bd..8fb7e96be 100644 --- a/.hooks/README.md +++ b/.hooks/README.md @@ -5,16 +5,18 @@ This folder holds some default hooks, you can use while developing. Before installing make sure, you have no existing hooks installed that might get overwritten. To install them, you can (under Linux) do from the root folder of the repository -``` -ln -sr .hook/* .git/hooks + +```shell +ln -sr .hooks/* .git/hooks ``` ## Security considerations -Please be aware, that this is sort of a security risk to do this. When checking out foreign code (aka `checkout` a branch you want to inspect that is from a foreign source), the foreign user might have introduced arbirtrary malicious code into the hook scripts. This code can then be executed depending on your local settings and command line options. Be aware that this might destroy your data! +Please be aware, that this is sort of a security risk to do this. When checking out foreign code (aka `checkout` a branch you want to inspect that is from a foreign source), the foreign user might have introduced arbitrary malicious code into the hook scripts. This code can then be executed depending on your local settings and command line options. Be aware that this might destroy your data! One option is to copy instead of link the hooks in the folder. That way, you can be sure the hooks are safe (as far as you trust them at the time of copying). The downside is that the hooks will never be updated automatically once you configure it this way. -``` -cp .hook/* .git/hooks + +```shell +cp .hooks/* .git/hooks ``` When you need to work with untrusted code (for inspection), you could also remove the links in the `.git/hooks` folder. That way you disabled the hooks. After being sure, the hooks are clear of malicious code, you can reenable them. diff --git a/LICENSES/LicenseRef-MudBlazor-MIT.txt b/LICENSES/LicenseRef-MudBlazor-MIT.txt new file mode 100644 index 000000000..b42f9e95a --- /dev/null +++ b/LICENSES/LicenseRef-MudBlazor-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 MudBlazor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSES/LicenseRef-cookbook-AGPL.txt b/LICENSES/LicenseRef-cookbook-AGPL.txt new file mode 100644 index 000000000..be3f7b28e --- /dev/null +++ b/LICENSES/LicenseRef-cookbook-AGPL.txt @@ -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/babel.config.js b/babel.config.js index 51c96ca86..e9142db74 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,3 +1,6 @@ const babelConfig = require('@nextcloud/babel-config'); +// https://jestjs.io/docs/getting-started#using-typescript +babelConfig.presets.push('@babel/preset-typescript'); + module.exports = babelConfig; diff --git a/jest.config.js b/jest.config.js index 13240421e..e2b0ab37c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,15 +1,16 @@ // Jest configuration module.exports = { testEnvironment: 'node', - moduleFileExtensions: ['js', 'vue'], - modulePaths: [ - '/src/' - ], - modulePathIgnorePatterns: [ - '/.github/' - ], + moduleFileExtensions: ['js', 'ts', 'vue'], + moduleNameMapper: { + '^cookbook/(.*)$': '/src/$1', + '^icons/(.*)$': '/node_modules/vue-material-design-icons/$1', + }, + modulePaths: ['/src/'], + modulePathIgnorePatterns: ['/.github/'], transform: { '.*\\.js$': '/node_modules/babel-jest', + '.*\\.ts$': '/node_modules/babel-jest', '.*\\.(vue)$': '/node_modules/@vue/vue2-jest', }, transformIgnorePatterns: ['node_modules/(?!variables/.*)'], diff --git a/lib/Helper/Filter/JSON/FixInstructionsFilter.php b/lib/Helper/Filter/JSON/FixInstructionsFilter.php index 0338a6138..ed3971131 100644 --- a/lib/Helper/Filter/JSON/FixInstructionsFilter.php +++ b/lib/Helper/Filter/JSON/FixInstructionsFilter.php @@ -53,6 +53,7 @@ public function __construct( } public function apply(array &$json): bool { + return true; if (!isset($json[self::INSTRUCTIONS])) { $json[self::INSTRUCTIONS] = []; return true; diff --git a/lib/Helper/Filter/JSON/FixToolsFilter.php b/lib/Helper/Filter/JSON/FixToolsFilter.php index 65290a23d..33a739792 100644 --- a/lib/Helper/Filter/JSON/FixToolsFilter.php +++ b/lib/Helper/Filter/JSON/FixToolsFilter.php @@ -54,16 +54,19 @@ public function apply(array &$json): bool { if($t != "") { $tools[] = $t; } - } else { - $tools = array_map(function ($t) { - $t = trim($t); - $t = $this->textCleaner->cleanUp($t, false); - return $t; - }, $json[self::TOOLS]); - $tools = array_filter($tools, fn ($t) => ($t)); - ksort($tools); - $tools = array_values($tools); } +// else { +// $tools = array_map(function ($t) { +// $t = trim($t); +// $t = $this->textCleaner->cleanUp($t, false); +// return $t; +// }, $json[self::TOOLS]); +// $tools = array_filter($tools, fn ($t) => ($t)); +// ksort($tools); +// $tools = array_values($tools); +// } + return false; + $changed = $tools !== $json[self::TOOLS]; $json[self::TOOLS] = $tools; diff --git a/package-lock.json b/package-lock.json index 36fdea970..dbc31ab24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "@nextcloud/moment": "^1.2.2", "@nextcloud/router": "^2.2.0", "@nextcloud/vue": "^8.3.0", + "@vueuse/core": "^10.7.2", + "axios": "^1.6.5", "caret-pos": "^2.0.0", "fuse.js": "^7.0.0", "linkifyjs": "^4.1.1", @@ -32,10 +34,12 @@ "vuex": "^3.6.2" }, "devDependencies": { + "@babel/preset-typescript": "^7.23.3", "@nextcloud/babel-config": "^1.0.0", "@nextcloud/browserslist-config": "^3.0.0", "@nextcloud/stylelint-config": "^2.3.1", "@nextcloud/webpack-vue-config": "^6.0.0", + "@types/jest": "^29.5.11", "@typescript-eslint/eslint-plugin": "^6.18.0", "@typescript-eslint/parser": "^6.18.0", "@vue/cli-plugin-typescript": "~5.0.8", @@ -3907,6 +3911,16 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jest": { + "version": "29.5.11", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.11.tgz", + "integrity": "sha512-S2mHmYIVe13vrm6q4kN6fLYYAka15ALQki/vgDC3mIukEOx8WJlv0kQPM+d4w8Gp6u0uSdKND04IlTXBv0rwnQ==", + "dev": true, + "dependencies": { + "expect": "^29.0.0", + "pretty-format": "^29.0.0" + } + }, "node_modules/@types/jquery": { "version": "3.5.16", "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.16.tgz", @@ -5525,6 +5539,28 @@ "vue-demi": ">=0.14.6" } }, + "node_modules/@vueuse/components/node_modules/@vueuse/core": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.6.1.tgz", + "integrity": "sha512-Pc26IJbqgC9VG1u6VY/xrXXfxD33hnvxBnKrLlA2LJlyHII+BSrRoTPJgGYq7qZOu61itITFUnm6QbacwZ4H8Q==", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.6.1", + "@vueuse/shared": "10.6.1", + "vue-demi": ">=0.14.6" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/components/node_modules/@vueuse/metadata": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.6.1.tgz", + "integrity": "sha512-qhdwPI65Bgcj23e5lpGfQsxcy0bMjCAsUGoXkJ7DsoeDUdasbZ2DBa4dinFCOER3lF4gwUv+UD2AlA11zdzMFw==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/@vueuse/components/node_modules/vue-demi": { "version": "0.14.6", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz", @@ -5551,13 +5587,24 @@ } }, "node_modules/@vueuse/core": { - "version": "10.6.1", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.6.1.tgz", - "integrity": "sha512-Pc26IJbqgC9VG1u6VY/xrXXfxD33hnvxBnKrLlA2LJlyHII+BSrRoTPJgGYq7qZOu61itITFUnm6QbacwZ4H8Q==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.7.2.tgz", + "integrity": "sha512-AOyAL2rK0By62Hm+iqQn6Rbu8bfmbgaIMXcE3TSr7BdQ42wnSFlwIdPjInO62onYsEMK/yDMU8C6oGfDAtZ2qQ==", "dependencies": { "@types/web-bluetooth": "^0.0.20", - "@vueuse/metadata": "10.6.1", - "@vueuse/shared": "10.6.1", + "@vueuse/metadata": "10.7.2", + "@vueuse/shared": "10.7.2", + "vue-demi": ">=0.14.6" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/@vueuse/shared": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.7.2.tgz", + "integrity": "sha512-qFbXoxS44pi2FkgFjPvF4h7c9oMDutpyBdcJdMYIMg9XyXli2meFMuaKn+UMgsClo//Th6+beeCgqweT/79BVA==", + "dependencies": { "vue-demi": ">=0.14.6" }, "funding": { @@ -5590,9 +5637,9 @@ } }, "node_modules/@vueuse/metadata": { - "version": "10.6.1", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.6.1.tgz", - "integrity": "sha512-qhdwPI65Bgcj23e5lpGfQsxcy0bMjCAsUGoXkJ7DsoeDUdasbZ2DBa4dinFCOER3lF4gwUv+UD2AlA11zdzMFw==", + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.7.2.tgz", + "integrity": "sha512-kCWPb4J2KGrwLtn1eJwaJD742u1k5h6v/St5wFe8Quih90+k2a0JP8BS4Zp34XUuJqS2AxFYMb1wjUL8HfhWsQ==", "funding": { "url": "https://github.com/sponsors/antfu" } @@ -6328,11 +6375,11 @@ } }, "node_modules/axios": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz", - "integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==", + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.5.tgz", + "integrity": "sha512-Ii012v05KEVuUoFWmMW/UQv9aRIc3ZwkWDcM+h5Il8izZCtRVpDUfwpoFf7eOtajT3QiGR4yDUx7lPqHJULgbg==", "dependencies": { - "follow-redirects": "^1.15.0", + "follow-redirects": "^1.15.4", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } diff --git a/package.json b/package.json index 7eec35f5c..f24d50dd1 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "@nextcloud/moment": "^1.2.2", "@nextcloud/router": "^2.2.0", "@nextcloud/vue": "^8.3.0", + "@vueuse/core": "^10.7.2", + "axios": "^1.6.5", "caret-pos": "^2.0.0", "fuse.js": "^7.0.0", "linkifyjs": "^4.1.1", @@ -53,10 +55,12 @@ "vuex": "^3.6.2" }, "devDependencies": { + "@babel/preset-typescript": "^7.23.3", "@nextcloud/babel-config": "^1.0.0", "@nextcloud/browserslist-config": "^3.0.0", "@nextcloud/stylelint-config": "^2.3.1", "@nextcloud/webpack-vue-config": "^6.0.0", + "@types/jest": "^29.5.11", "@typescript-eslint/eslint-plugin": "^6.18.0", "@typescript-eslint/parser": "^6.18.0", "@vue/cli-plugin-typescript": "~5.0.8", diff --git a/src/assets/css/elevation.scss b/src/assets/css/elevation.scss new file mode 100644 index 000000000..02fa0f4bd --- /dev/null +++ b/src/assets/css/elevation.scss @@ -0,0 +1,109 @@ +/** see https://m3.material.io/styles/elevation/overview */ +@use 'sass:map'; + +$elevations: ( + 0: none, + 1: ( + 0px 1px 2px rgba(0, 0, 0, 0.3), + 0px 1px 3px 1px rgba(0, 0, 0, 0.15), + ), + 2: ( + 0px 1px 2px rgba(0, 0, 0, 0.3), + 0px 2px 6px 2px rgba(0, 0, 0, 0.15), + ), + 3: ( + 0px 1px 3px rgba(0, 0, 0, 0.3), + 0px 4px 8px 3px rgba(0, 0, 0, 0.15), + ), + 4: ( + 0px 2px 3px rgba(0, 0, 0, 0.3), + 0px 6px 10px 4px rgba(0, 0, 0, 0.15), + ), + 5: ( + 0px 4px 4px rgba(0, 0, 0, 0.3), + 0px 8px 12px 6px rgba(0, 0, 0, 0.15), + ), + 6: ( + 0px 6px 6px rgba(0, 0, 0, 0.3), + 0px 10px 14px 8px rgba(0, 0, 0, 0.15), + ), + 7: ( + 0px 8px 8px rgba(0, 0, 0, 0.3), + 0px 12px 16px 10px rgba(0, 0, 0, 0.15), + ), + 8: ( + 0px 10px 10px rgba(0, 0, 0, 0.3), + 0px 14px 18px 12px rgba(0, 0, 0, 0.15), + ), + 9: ( + 0px 12px 12px rgba(0, 0, 0, 0.3), + 0px 16px 20px 14px rgba(0, 0, 0, 0.15), + ), + 10: ( + 0px 14px 14px rgba(0, 0, 0, 0.3), + 0px 18px 22px 16px rgba(0, 0, 0, 0.15), + ), + 11: ( + 0px 16px 16px rgba(0, 0, 0, 0.3), + 0px 20px 24px 18px rgba(0, 0, 0, 0.15), + ), + 12: ( + 0px 18px 18px rgba(0, 0, 0, 0.3), + 0px 22px 26px 20px rgba(0, 0, 0, 0.15), + ), + 13: ( + 0px 20px 20px rgba(0, 0, 0, 0.3), + 0px 24px 28px 22px rgba(0, 0, 0, 0.15), + ), + 14: ( + 0px 22px 22px rgba(0, 0, 0, 0.3), + 0px 26px 30px 24px rgba(0, 0, 0, 0.15), + ), + 15: ( + 0px 24px 24px rgba(0, 0, 0, 0.3), + 0px 28px 32px 26px rgba(0, 0, 0, 0.15), + ), + 16: ( + 0px 26px 26px rgba(0, 0, 0, 0.3), + 0px 30px 34px 28px rgba(0, 0, 0, 0.15), + ), + 17: ( + 0px 28px 28px rgba(0, 0, 0, 0.3), + 0px 32px 36px 30px rgba(0, 0, 0, 0.15), + ), + 18: ( + 0px 30px 30px rgba(0, 0, 0, 0.3), + 0px 34px 38px 32px rgba(0, 0, 0, 0.15), + ), + 19: ( + 0px 32px 32px rgba(0, 0, 0, 0.3), + 0px 36px 40px 34px rgba(0, 0, 0, 0.15), + ), + 20: ( + 0px 34px 34px rgba(0, 0, 0, 0.3), + 0px 38px 42px 36px rgba(0, 0, 0, 0.15), + ), + 21: ( + 0px 36px 36px rgba(0, 0, 0, 0.3), + 0px 40px 44px 38px rgba(0, 0, 0, 0.15), + ), + 22: ( + 0px 38px 38px rgba(0, 0, 0, 0.3), + 0px 42px 46px 40px rgba(0, 0, 0, 0.15), + ), + 23: ( + 0px 40px 40px rgba(0, 0, 0, 0.3), + 0px 44px 48px 42px rgba(0, 0, 0, 0.15), + ), + 24: ( + 0px 42px 42px rgba(0, 0, 0, 0.3), + 0px 46px 50px 44px rgba(0, 0, 0, 0.15), + ), +); + +// Generate the elevation classes +@for $level from 1 through 24 { + .elevation-#{$level} { + box-shadow: map.get($elevations, $level); + } +} diff --git a/src/assets/css/main.scss b/src/assets/css/main.scss new file mode 100644 index 000000000..6d3f327c8 --- /dev/null +++ b/src/assets/css/main.scss @@ -0,0 +1,3 @@ +@import 'utilities'; + +@import 'elevation'; diff --git a/src/assets/css/utilities.scss b/src/assets/css/utilities.scss new file mode 100644 index 000000000..0accfc391 --- /dev/null +++ b/src/assets/css/utilities.scss @@ -0,0 +1,518 @@ +/** position */ + +.absolute { + position: absolute; +} + +.relative { + position: relative; +} + +.md\:position-absolute { + @media (min-width: 768px) { + position: absolute; + } +} + +.md\:position-relative { + @media (min-width: 768px) { + position: relative; + } +} + +/** display */ + +.d-flex, +.flex { + display: flex; +} + +.inline-block { + display: inline-block; +} + +.inline-flex { + display: inline-flex; +} + +.flex-col { + flex-direction: column; +} + +.flex-row { + flex-direction: row; +} + +.md\:flex-row { + @media (min-width: 768px) { + flex-direction: row; + } +} + +.md\:flex-row-reverse { + @media (min-width: 768px) { + flex-direction: row-reverse; + } +} + +.grid, +.d-grid { + display: grid; +} + +.d-md-grid { + @media (min-width: 768px) { + display: grid; + } +} + +.hidden { + display: none; +} + +/** flex */ + +.flex-md-row { + @media (min-width: 768px) { + flex-direction: row; + } +} + +.flex-wrap { + flex-wrap: wrap; +} + +.flex-nowrap { + flex-wrap: nowrap; +} + +.flex-auto { + flex: 1 1 auto; +} + +.flex-md-auto { + @media (min-width: 768px) { + flex: 1 1 auto; + } +} + +/** align */ + +.align-items-center { + align-items: center; +} + +.align-items-stretch { + align-items: stretch; +} + +.self-end { + align-self: end; +} + +.self-start { + align-self: start; +} + +.self-md-start { + @media (min-width: 768px) { + align-self: start; + } +} + +.self-stretch { + align-self: stretch; +} + +.self-md-stretch { + @media (min-width: 768px) { + align-self: stretch; + } +} + +/** justify */ + +.justify-center { + justify-content: center; +} + +.justify-end { + justify-content: end; +} + +/** margin */ +.m-0 { + margin: 0; +} + +.mb-0 { + margin-bottom: 0; +} + +.mb-0.\5 { + margin-bottom: 0.125rem; +} + +.mb-1 { + margin-bottom: 0.25rem; +} + +.mb-2 { + margin-bottom: 0.5rem; +} + +.mb-4 { + margin-bottom: 1rem; +} + +.mb-6 { + margin-bottom: 1.5rem; +} + +.ml-1 { + margin-left: 0.25rem; +} + +.ml-2 { + margin-left: 0.5rem; +} + +.ml-3 { + margin-left: 0.75rem; +} + +.ml-4 { + margin-left: 1rem; +} + +.ml-12 { + margin-left: 3rem; +} + +.mr-1 { + margin-right: 0.25rem; +} + +.mr-2 { + margin-right: 0.5rem; +} + +.mr-3 { + margin-right: 0.75rem; +} + +.mr-4 { + margin-right: 1rem; +} + +.mt-0 { + margin-top: 0; +} + +.mt-2 { + margin-top: 0.5rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mt-6 { + margin-top: 1.5rem; +} + +.mt-12 { + margin-top: 3rem; +} + +.mx-0 { + margin-right: 0; + margin-left: 0; +} + +.my-2 { + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.-ml-1 { + margin-left: -0.25rem; +} + +.-mr-1 { + margin-right: -0.25rem; +} + +/** padding */ + +.p-2 { + padding: 0.5rem; +} + +.p-4 { + padding: 1rem; +} + +.p-8 { + padding: 2rem; +} + +.pb-2 { + padding-bottom: 0.5rem; +} + +.pb-4 { + padding-bottom: 1rem; +} + +.pl-2 { + padding-left: 0.5rem; +} + +.pl-3 { + padding-left: 0.75rem; +} + +.pl-4 { + padding-left: 1rem; +} + +.pr-3 { + padding-right: 0.75rem; +} + +.pt-2 { + padding-top: 0.5rem; +} + +.pt-2\.5 { + padding-top: 0.625rem; +} + +.px-2 { + padding-right: 0.5rem; + padding-left: 0.5rem; +} + +.px-2\.5 { + padding-right: 0.625rem; + padding-left: 0.625rem; +} + +.px-3 { + padding-right: 0.75rem; + padding-left: 0.75rem; +} + +.px-3\.5 { + padding-right: 0.875rem; + padding-left: 0.875rem; +} + +.px-4 { + padding-right: 1rem; + padding-left: 1rem; +} + +.py-0\.5 { + padding-top: 0.125rem; + padding-bottom: 0.125rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.md\:px-4 { + @media (min-width: 768px) { + padding-right: 1rem; + padding-left: 1rem; + } +} + +.md\:px-8 { + @media (min-width: 768px) { + padding-right: 2rem; + padding-left: 2rem; + } +} + +/** gap */ + +.gap-2 { + gap: 0.5rem; +} + +.gap-4 { + gap: 1rem; +} + +.gap-md-4 { + @media (min-width: 768px) { + gap: 1rem; + } +} + +.gap-8 { + gap: 2rem; +} + +.gap-md-8 { + @media (min-width: 768px) { + gap: 2rem; + } +} + +.gap-y-lg-8 { + @media (min-width: 1024px) { + row-gap: 2rem; + } +} + +.gap-lg-16 { + @media (min-width: 1024px) { + gap: 4rem; + } +} + +.gap-x-lg-16 { + @media (min-width: 1024px) { + column-gap: 4rem; + } +} + +.gap-x-2 { + column-gap: 0.5rem; +} + +/** Height */ + +.h-0 { + height: 0; +} + +.h-full { + height: 100%; +} + +.max-h-full { + max-height: 100%; +} + +/** Width */ + +.w-24 { + width: 6rem; +} + +.w-32 { + width: 8rem; +} + +.w-64 { + width: 16rem; +} + +.sm\:w-11\/12 { + @media (min-width: 640px) { + width: 91.666667%; + } +} + +.md\:w-32 { + @media (min-width: 768px) { + width: 8rem; + } +} + +.md\:w-64 { + @media (min-width: 768px) { + width: 16rem; + } +} + +.md\:w-96 { + @media (min-width: 768px) { + width: 24rem; + } +} + +.w-full { + width: 100%; +} + +.w-2\/3 { + width: 66.666667%; +} + +.w-3\/4 { + width: 75%; +} + +.max-w-52 { + max-width: 13rem; +} + +.max-w-64 { + max-width: 16rem; +} + +.max-w-full { + max-width: 100%; +} + +.md\:max-w-full { + @media (min-width: 768px) { + max-width: 100%; + } +} + +/** Background */ + +.bg-transparent { + background-color: transparent; +} + +/** Borders */ + +.border-none { + border-style: none; +} + +.border-solid { + border-style: solid; +} + +.rounded { + border-radius: 0.25rem; +} + +.rounded-full { + border-radius: 9999px; +} + +.border-1 { + border-width: 1px; +} + +.border-2 { + border-width: 2px; +} + +/** Cursor */ + +.hover\:cursor-pointer:hover { + cursor: pointer; +} + +/** Font */ +.italic { + font-style: italic; +} + + +/** Text decoration */ +.underline { + text-decoration-line: underline; +} diff --git a/src/components/AppControls/AppControls.vue b/src/components/AppControls/AppControls.vue index cf5ff4a46..10f5cf619 100644 --- a/src/components/AppControls/AppControls.vue +++ b/src/components/AppControls/AppControls.vue @@ -1,16 +1,8 @@ - {{ t('cookbook', 'Edit') }} + + + + - - - diff --git a/src/components/AppMain.vue b/src/components/AppMain.vue index c75562d22..bc2581c32 100644 --- a/src/components/AppMain.vue +++ b/src/components/AppMain.vue @@ -1,43 +1,138 @@ + + diff --git a/src/components/AppNavi.vue b/src/components/AppNavi.vue index 96ce94986..95ee77891 100644 --- a/src/components/AppNavi.vue +++ b/src/components/AppNavi.vue @@ -25,6 +25,7 @@ :name="t('cookbook', 'All recipes')" icon="icon-category-organization" :to="'/'" + :exact="true" > + + diff --git a/src/components/SearchResults.vue b/src/components/SearchResults.vue index 4c9db77cf..da6c64466 100644 --- a/src/components/SearchResults.vue +++ b/src/components/SearchResults.vue @@ -1,31 +1,84 @@ + + diff --git a/src/components/Utilities/Divider/CbDivider.vue b/src/components/Utilities/Divider/CbDivider.vue new file mode 100644 index 000000000..29118dffa --- /dev/null +++ b/src/components/Utilities/Divider/CbDivider.vue @@ -0,0 +1,97 @@ + + + + + + diff --git a/src/components/Utilities/Divider/DividerType.ts b/src/components/Utilities/Divider/DividerType.ts new file mode 100644 index 000000000..08cebf4c6 --- /dev/null +++ b/src/components/Utilities/Divider/DividerType.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: LicenseRef-cookbook-AGPL AND LicenseRef-MudBlazor-MIT +// SPDX-FileCopyrightText: © 2024 Cookbook team <> +// +// Based on MudBlazor +// https://github.com/MudBlazor/MudBlazor/blob/d6a126833bde2b10096b59d53b10f19cb9354bdd/src/MudBlazor/Enums/DividerType.cs + +enum DividerType { + FullWidth = 'fullwidth', + Inset = 'inset', + Middle = 'middle', +} + +export default DividerType; diff --git a/src/components/Utilities/LazyPicture.vue b/src/components/Utilities/LazyPicture.vue index 164fc7dfd..490535c75 100644 --- a/src/components/Utilities/LazyPicture.vue +++ b/src/components/Utilities/LazyPicture.vue @@ -6,28 +6,43 @@ :data-alt="alt" :style="style" > - + +
+ +
@@ -138,27 +224,28 @@ export default { vertical-align: middle; } -picture .loading-indicator { - display: contents; - align-content: center; -} - -picture .blurred { - filter: blur(0.5rem); -} - -picture .low-resolution.preview-loaded { - display: inline; - animation: fadeIn 1s linear 0s; -} - -picture .full-resolution { - display: none; -} - -picture .full-resolution.image-loaded { - display: inline; - animation: unblur 1s linear 0s; +picture { + .placeholder-wrapper > * { + height: 100%; + } + .loading-indicator { + display: contents; + align-content: center; + } + .blurred { + filter: blur(0.5rem); + } + .low-resolution.preview-loaded { + display: inline; + animation: fadeIn 1s linear 0s; + } + .full-resolution { + display: none; + } + .full-resolution.image-loaded { + display: inline; + animation: unblur 1s linear 0s; + } } @keyframes fadeIn { diff --git a/src/components/Utilities/LoadingSkeleton/LoadingSkeleton.vue b/src/components/Utilities/LoadingSkeleton/LoadingSkeleton.vue new file mode 100644 index 000000000..8ac39ddad --- /dev/null +++ b/src/components/Utilities/LoadingSkeleton/LoadingSkeleton.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/src/components/Utilities/LoadingSkeleton/SkeletonType.ts b/src/components/Utilities/LoadingSkeleton/SkeletonType.ts new file mode 100644 index 000000000..c2956f7d7 --- /dev/null +++ b/src/components/Utilities/LoadingSkeleton/SkeletonType.ts @@ -0,0 +1,11 @@ +enum SkeletonType { + Avatar = 'avatar', + Chip = 'chip', + Heading = 'heading', + Image = 'image', + ListItem = 'list-item', + Paragraph = 'paragraph', + Text = 'text', +} + +export default SkeletonType; diff --git a/src/composables/useCompleteable/index.ts b/src/composables/useCompleteable/index.ts new file mode 100644 index 000000000..65587489e --- /dev/null +++ b/src/composables/useCompleteable/index.ts @@ -0,0 +1,81 @@ +import { Ref, ref, UnwrapRef } from 'vue'; + +interface ICompletable { + setCompleted(isCompleted: boolean, cascade: boolean, doEmit: boolean); +} + +/** + * Composable for adding functionality of something that can be completed. Make sure to add the 'update-completed' event + * in your `defineEmits(...)` call. + * @param {Ref>} completableChildren List of children which can also be completed. + * @param emit The const returned by `defineEmits(...)`. + * @template EE + * @example + * ``` + * const children = ref(null); + * const emit = defineEmits(['update-completed']); + * const { isCompleted, setCompleted, toggleCompleted } = useCompletable(emit, children); + * ``` + */ +export default function useCompletable( + emit, + completableChildren: Ref> = ref([]), +): { + toggleCompleted: (evt?: Event) => void; + setCompleted: (isCompleted?: boolean) => void; + isCompleted: Ref>; +} { + /** + * If this step/instruction/... has been marked as completed. + * @type {import('vue').Ref} + */ + const isCompletedLocal: Ref> = ref(false); + + /** + * Sets the completed state manually. + * @param {boolean} isCompleted If true the state is complete, if false state is not completed. + * @param {boolean} cascade If true, the value is set on child elements as well. + * @param {boolean} shouldEmit If true the updated value is emitted. + */ + const setCompleted = ( + isCompleted: boolean = true, + cascade: boolean = true, + shouldEmit: boolean = true, + ): void => { + isCompletedLocal.value = isCompleted; + if (cascade) { + if (completableChildren.value) { + for (const child of completableChildren.value) { + if ( + typeof (child as ICompletable).setCompleted !== + 'undefined' + ) { + (child as ICompletable).setCompleted( + isCompleted, + cascade, + false, + ); + } + } + } + } + if (shouldEmit) { + emit('update-completed', isCompletedLocal.value); + } + }; + + /** + * Toggles the completed state and emits 'update-completed' event. + * @param {Event} evt The event triggering the change in completed state. + */ + const toggleCompleted = (evt: Event): void => { + evt.stopPropagation(); + setCompleted(!isCompletedLocal.value, true, true); + }; + + return { + isCompleted: isCompletedLocal, + setCompleted, + toggleCompleted, + }; +} diff --git a/src/composables/useRecipeFilterControls/index.js b/src/composables/useRecipeFilterControls/index.js deleted file mode 100644 index 90a5af256..000000000 --- a/src/composables/useRecipeFilterControls/index.js +++ /dev/null @@ -1,154 +0,0 @@ -import { computed, ref } from 'vue'; -import { - RecipeCategoriesFilter as CategoriesFilter, - RecipeKeywordsFilter as KeywordsFilter, - RecipeNamesFilter as NamesFilter, -} from '../../js/RecipeFilters'; -import { useStore } from '../../store'; -import { AndOperator, OrOperator } from '../../js/LogicOperators'; - -export default function useRecipeFilterControls(props) { - const store = useStore(); - - /** - * @type {import('vue').Ref} - */ - const searchTerm = ref(''); - - /** - * List of all selected categories. - * @type {import('vue').Ref} - */ - const selectedCategories = ref([]); - - /** - * Value of the toggle for switching between the `AND` and `OR` operator fot the categories filter. - * - * `true` is associated with the `AndOperator`, `false` with the `OrOperator`. - * @type {import('vue').Ref} - */ - const categoriesOperatorToggleValue = ref(false); - - /** - * Logic operator to be used for filtering categories. - * @type {import('vue').Ref} - */ - const categoriesOperator = computed(() => - categoriesOperatorToggleValue.value - ? new AndOperator() - : new OrOperator(), - ); - - /** - * List of all selected keywords. - * @type {import('vue').Ref} - */ - const selectedKeywords = ref([]); - - /** - * Value of the toggle for switching between the `AND` and `OR` operator fot the keywords filter. - * - * `true` is associated with the `AndOperator`, `false` with the `OrOperator`. - * @type {import('vue').Ref} - */ - const keywordsOperatorToggleValue = ref(true); - - /** - * Logic operator to be used for filtering keywords. - * @type {import('vue').Ref} - */ - const keywordsOperator = computed(() => - keywordsOperatorToggleValue.value - ? new AndOperator() - : new OrOperator(), - ); - - /** - * Local value of all set filters. - * @type {import('vue').ComputedRef<{searchTerm: string, keywords: RecipeKeywordsFilter, categories: RecipeCategoriesFilter}>} - */ - const localFiltersValue = computed(() => ({ - categories: new CategoriesFilter( - selectedCategories.value, - categoriesOperator.value, - ), - keywords: new KeywordsFilter( - selectedKeywords.value, - keywordsOperator.value, - true, - ), - searchTerm: searchTerm.value, - })); - - /** - * An array of all categories in the recipes. These are neither sorted nor unique - */ - const rawCategories = computed(() => { - const categoriesArray = props.recipes.map((r) => { - if (!('category' in r)) { - return []; - } - if (r.category != null) { - return r.category.split(','); - } - return []; - }); - return [].concat(...categoriesArray); - }); - - /** - * List of sections with their visible state. - * @type {import('vue').ComputedRef} - */ - const hiddenSections = computed(() => ({ - categories: props.preappliedFilters.some( - (f) => f instanceof CategoriesFilter, - ), - keywords: props.preappliedFilters.some( - (f) => f instanceof KeywordsFilter, - ), - names: props.preappliedFilters.some((f) => f instanceof NamesFilter), - })); - - /** - * A unique set of all categories in the recipes. - * @type {import('vue').ComputedRef>} - */ - const uniqueCategories = computed(() => [...new Set(rawCategories.value)]); - - /** - * An array of all keywords in the recipes. These are neither sorted nor unique - */ - const rawKeywords = computed(() => { - const keywordsArray = props.recipes.map((r) => { - if (!('keywords' in r)) { - return []; - } - if (r.keywords != null) { - return r.keywords.split(','); - } - return []; - }); - return [].concat(...keywordsArray); - }); - - /** - * A unique set of all keywords in all recipes. - */ - const uniqueKeywords = computed(() => [...new Set(rawKeywords.value)]); - - return { - uniqueCategories, - selectedCategories, - uniqueKeywords, - selectedKeywords, - hiddenSections, - searchTerm, - localFiltersValue, - categoriesOperatorToggleValue, - categoriesOperator, - keywordsOperatorToggleValue, - keywordsOperator, - store, - }; -} diff --git a/src/composables/useRecipeFilterControls/index.ts b/src/composables/useRecipeFilterControls/index.ts new file mode 100644 index 000000000..0dd4f6d9d --- /dev/null +++ b/src/composables/useRecipeFilterControls/index.ts @@ -0,0 +1,278 @@ +import { computed, ComputedRef, onMounted, ref, Ref } from 'vue'; +import { + RecipeCategoriesFilter as CategoriesFilter, + RecipeFilter, + RecipeKeywordsFilter as KeywordsFilter, +} from 'cookbook/js/RecipeFilters'; +import FilterType from 'cookbook/js/Enums/FilterType'; +import LogicOperatorType from 'cookbook/js/Enums/LogicOperatorType'; +import { + AndOperator, + BinaryOperator, + OrOperator, +} from 'cookbook/js/LogicOperators'; +import parseSearchString from 'cookbook/js/utils/parseSearchString'; +import compareRecipeFilters from 'cookbook/js/utils/compareRecipeFilters'; +import { asArray } from 'cookbook/js/helper'; +import { Recipe } from 'cookbook/js/Models/schema'; +import { caseInsensitiveStringSort } from 'cookbook/js/utils/sortingUtils'; + +export default function useRecipeFilterControls(props, store) { + /** + * @type {import('vue').Ref} + */ + const searchTerm: Ref = ref(''); + + /** + * List of all selected categories. + * @type {import('vue').Ref} + */ + const selectedCategories: Ref = ref([]); + + /** + * Value of the toggle for switching between the `AND` and `OR` operator fot the categories filter. + * + * `true` is associated with the `AndOperator`, `false` with the `OrOperator`. + * @type {import('vue').Ref} + */ + const categoriesOperatorToggleValue: Ref = ref(false); + + /** + * Logic operator to be used for filtering categories. + * @type {import('vue').Ref} + */ + const categoriesOperator: ComputedRef = computed(() => + categoriesOperatorToggleValue.value + ? new AndOperator() + : new OrOperator(), + ); + + /** + * List of all selected keywords. + * @type {import('vue').Ref} + */ + const selectedKeywords: Ref = ref([]); + + /** + * Value of the toggle for switching between the `AND` and `OR` operator fot the keywords filter. + * + * `true` is associated with the `AndOperator`, `false` with the `OrOperator`. + * @type {import('vue').Ref} + */ + const keywordsOperatorToggleValue: Ref = ref(true); + + /** + * Local value of all set filters. + * @type {import('vue').Ref} + */ + const localFiltersValue: Ref = ref( + store.state.recipeFilters, + ); + + /** + * Logic operator to be used for filtering keywords. + * @type {import('vue').Ref} + */ + const keywordsOperator: ComputedRef = computed(() => + keywordsOperatorToggleValue.value + ? new AndOperator() + : new OrOperator(), + ); + + /** + * Updates the filter-controls UI elements like category and keyword dropdown. + * @param {RecipeFilter[]} filters - List of filters that should be represented by the UI + * @param {boolean} updateSearchField - If the search input should also be updated. Default is `true`. + */ + function updateFiltersUI( + filters: RecipeFilter[], + updateSearchField: boolean = true, + ) { + // Collect all filters that filter for category + const categoryFilters = filters.filter( + (f) => f.type === FilterType.CategoriesFilter, + ); + // Update category selection control element with selected categories + if (categoryFilters.length === 1) { + selectedCategories.value = asArray( + (categoryFilters[0] as CategoriesFilter).categories, + ); + categoriesOperatorToggleValue.value = + categoryFilters[0].operator.type === LogicOperatorType.And; + } + + // Collect all filters that filter for keyword + const keywordFilters = filters.filter( + (f) => f.type === FilterType.KeywordsFilter, + ); + // Update keyword selection control element with selected keywords + if (keywordFilters.length === 1) { + selectedKeywords.value = asArray( + (keywordFilters[0] as KeywordsFilter).keywords, + ); + keywordsOperatorToggleValue.value = + keywordFilters[0].operator.type === LogicOperatorType.And; + } + + // Update string shown in the search field + if (updateSearchField) { + searchTerm.value = filters.map((f) => f.toSearchString()).join(' '); + } + } + + onMounted(() => { + updateFiltersUI(localFiltersValue.value); + }); + + /** + * Update UI controls when the filters are updated. + */ + // watch( + // () => localFiltersValue.value, + // (filters, oldFilters) => { + // if (filters === oldFilters) return; + // + // updateFiltersUI(filters); + // }, + // ); + + /** + * An array of all categories in the recipes. These are neither sorted nor unique + */ + const rawCategories: ComputedRef = computed(() => { + const categoriesArray = props.recipes.map((r: Recipe) => { + if (!('recipeCategory' in r)) { + return []; + } + if (Array.isArray(r.recipeCategory)) return r.recipeCategory; + if (r.recipeCategory != null) { + return r.recipeCategory.split(','); + } + return []; + }); + return [].concat(...categoriesArray); + }); + + /** + * A unique sorted set of all categories in the recipes. + * @type {import('vue').ComputedRef>} + */ + const uniqueCategories: ComputedRef = computed(() => + [...new Set(rawCategories.value)].sort(caseInsensitiveStringSort), + ); + + /** + * An array of all keywords in the recipes. These are neither sorted nor unique + */ + const rawKeywords: ComputedRef = computed(() => { + const keywordsArray = props.recipes.map((r: Recipe) => { + if (!('keywords' in r)) { + return []; + } + return r.keywords; + }); + return [].concat(...keywordsArray); + }); + + /** + * A unique sorted set of all keywords in all recipes. + */ + const uniqueKeywords: ComputedRef = computed(() => + [...new Set(rawKeywords.value)].sort(caseInsensitiveStringSort), + ); + + function onCategoriesSelectionUpdated() { + // Create new filter from current selection + const filter = new CategoriesFilter( + selectedCategories.value, + categoriesOperator.value, + ); + + // remove all current category filters + localFiltersValue.value = localFiltersValue.value.filter( + (f) => f.type !== 'CategoriesFilter', + ); + + // Add UI-based category filter + localFiltersValue.value.push(filter); + updateFiltersUI(localFiltersValue.value); + } + + function onKeywordsSelectionUpdated(): void { + // Create new filter from current selection + const filter = new KeywordsFilter( + selectedKeywords.value, + keywordsOperator.value, + ); + + // remove all current keyword filters + localFiltersValue.value = localFiltersValue.value.filter( + (f) => f.type !== 'KeywordsFilter', + ); + + // Add UI-based keyword filter + localFiltersValue.value.push(filter); + updateFiltersUI(localFiltersValue.value); + } + + function onSearchInputUpdated(): void { + const parsedFilters = parseSearchString(searchTerm.value); + + if (compareRecipeFilters(parsedFilters, store.state.recipeFilters)) + return; + + localFiltersValue.value = parsedFilters; + + // Update category/keyword/... selection controls + updateFiltersUI(parsedFilters, false); + } + + function onUiControlsUpdated(): void { + // Create new filter from current selection + const categoriesFilter = new CategoriesFilter( + selectedCategories.value, + categoriesOperator.value, + ); + + // Create new filter from current selection + const keywordsFilter = new KeywordsFilter( + selectedKeywords.value, + keywordsOperator.value, + ); + + // remove all current category and keyword filters + localFiltersValue.value = localFiltersValue.value.filter( + (f) => + f.type !== FilterType.CategoriesFilter && + f.type !== FilterType.KeywordsFilter, + ); + + // Add UI-based category and keyword filters + localFiltersValue.value.push(categoriesFilter); + localFiltersValue.value.push(keywordsFilter); + updateFiltersUI(localFiltersValue.value); + } + + function submitFilters(): void { + store.dispatch('setRecipeFilters', parseSearchString(searchTerm.value)); + } + + return { + uniqueCategories, + selectedCategories, + uniqueKeywords, + selectedKeywords, + searchTerm, + localFiltersValue, + categoriesOperatorToggleValue, + categoriesOperator, + keywordsOperatorToggleValue, + keywordsOperator, + onCategoriesSelectionUpdated, + onKeywordsSelectionUpdated, + onSearchInputUpdated, + onUiControlsUpdated, + submitFilters, + updateFiltersUI, + }; +} diff --git a/src/composables/useRecipeFiltering/index.ts b/src/composables/useRecipeFiltering/index.ts new file mode 100644 index 000000000..108a1c690 --- /dev/null +++ b/src/composables/useRecipeFiltering/index.ts @@ -0,0 +1,177 @@ +import Vue, { computed, Ref, ref } from 'vue'; +import applyRecipeFilters from 'cookbook/js/utils/applyRecipeFilters'; +import { RecipeFilter } from 'cookbook/js/RecipeFilters'; +import { Recipe } from 'cookbook/js/Models/schema'; + +export default function useRecipeFiltering( + props, + recipeFilters: Ref, +) { + /** + * Defines the sorting order of the list of recipes. + */ + const orderBy: Ref<{ + label: string; + iconUp: boolean; + recipeProperty: string; + order: string; + }> = ref({ + label: Vue.prototype.t('cookbook', 'Name'), + iconUp: true, + recipeProperty: 'name', + order: 'ascending', + }); + + // =================== + // Methods + // =================== + /* Sort recipes according to the property of the recipe ascending or + * descending + */ + const sortRecipes = ( + recipes: Recipe[], + recipeProperty: string, + order: string, + ) => { + const rec = JSON.parse(JSON.stringify(recipes)); + return rec.sort((r1: Recipe, r2: Recipe) => { + if (order !== 'ascending' && order !== 'descending') return 0; + if (order === 'ascending') { + if ( + recipeProperty === 'dateCreated' || + recipeProperty === 'dateModified' + ) { + if ( + typeof r1[recipeProperty] === 'string' && + typeof r2[recipeProperty] === 'string' + ) { + // https://stackoverflow.com/a/60688789 + return ( + new Date(r1[recipeProperty]).valueOf() - + new Date(r2[recipeProperty]).valueOf() + ); + } + } + if (recipeProperty === 'name') { + return r1[recipeProperty].localeCompare(r2[recipeProperty]); + } + if (!Number.isNaN(r1[recipeProperty] - r2[recipeProperty])) { + return r1[recipeProperty] - r2[recipeProperty]; + } + return 0; + } + + if ( + recipeProperty === 'dateCreated' || + recipeProperty === 'dateModified' + ) { + if ( + typeof r1[recipeProperty] === 'string' && + typeof r2[recipeProperty] === 'string' + ) { + // https://stackoverflow.com/a/60688789 + return ( + new Date(r2[recipeProperty]).valueOf() - + new Date(r1[recipeProperty]).valueOf() + ); + } + } + if (recipeProperty === 'name') { + return r2[recipeProperty].localeCompare(r1[recipeProperty]); + } + if (!Number.isNaN(r2[recipeProperty] - r1[recipeProperty])) { + return r2[recipeProperty] - r1[recipeProperty]; + } + return 0; + }); + }; + + // =================== + // Computed properties + // =================== + + /** + * An array of the filtered recipes, with all filters applied. + */ + const filteredRecipes = computed(() => + applyRecipeFilters(props.recipes, recipeFilters.value), + ); + + // Recipes ordered ascending by name + const recipesNameAsc = computed(() => + sortRecipes(props.recipes, 'name', 'ascending'), + ); + + // Recipes ordered descending by name + const recipesNameDesc = computed(() => + sortRecipes(props.recipes, 'name', 'descending'), + ); + + // Recipes ordered ascending by creation date + const recipesDateCreatedAsc = computed(() => + sortRecipes(props.recipes, 'dateCreated', 'ascending'), + ); + + // Recipes ordered descending by creation date + const recipesDateCreatedDesc = computed(() => + sortRecipes(props.recipes, 'dateCreated', 'descending'), + ); + + // Recipes ordered ascending by modification date + const recipesDateModifiedAsc = computed(() => + sortRecipes(props.recipes, 'dateModified', 'ascending'), + ); + + // Recipes ordered descending by modification date + const recipesDateModifiedDesc = computed(() => + sortRecipes(props.recipes, 'dateModified', 'descending'), + ); + + /** + * A filtered and sorted array of recipe objects with the `show` property determining if the recipe should be + * shown or hidden from the UI. + */ + const recipeObjects = computed(() => { + function makeObject(rec: Recipe): { recipe: Recipe; show: boolean } { + return { + recipe: rec, + show: filteredRecipes.value + .map((r: Recipe) => r.identifier) + .includes(rec.identifier), + }; + } + + if ( + orderBy.value === null || + orderBy.value === undefined || + (orderBy.value.order !== 'ascending' && + orderBy.value.order !== 'descending') + ) { + return props.recipes.map(makeObject); + } + if (orderBy.value.recipeProperty === 'dateCreated') { + if (orderBy.value.order === 'ascending') { + return recipesDateCreatedAsc.value.map(makeObject); + } + return recipesDateCreatedDesc.value.map(makeObject); + } + if (orderBy.value.recipeProperty === 'dateModified') { + if (orderBy.value.order === 'ascending') { + return recipesDateModifiedAsc.value.map(makeObject); + } + return recipesDateModifiedDesc.value.map(makeObject); + } + if (orderBy.value.recipeProperty === 'name') { + if (orderBy.value.order === 'ascending') { + return recipesNameAsc.value.map(makeObject); + } + return recipesNameDesc.value.map(makeObject); + } + return props.recipes.map(makeObject); + }); + + return { + orderBy, + recipeObjects, + }; +} diff --git a/src/composables/useSuggestionsPopup/index.js b/src/composables/useSuggestionsPopup/index.js index 1d88024a8..08e3b956b 100644 --- a/src/composables/useSuggestionsPopup/index.js +++ b/src/composables/useSuggestionsPopup/index.js @@ -1,6 +1,6 @@ import { position as caretPosition } from 'caret-pos'; import { computed, nextTick, ref } from 'vue'; -import helpers from '../../js/helper'; +import { clamp } from 'cookbook/js/utils/mathUtils'; /* eslint no-param-reassign: ["error", { "props": false }] */ /** @@ -191,7 +191,7 @@ export default function useSuggestionsPopup( // Increment/decrement focus index based on which key was pressed // and constrain between 0 and length - 1 - const focusIndex = helpers.clamp( + const focusIndex = clamp( suggestionsData.value.focusIndex + { ArrowUp: -1, diff --git a/src/js/Api/Mappers/RecipeMappers.ts b/src/js/Api/Mappers/RecipeMappers.ts new file mode 100644 index 000000000..47e4898d9 --- /dev/null +++ b/src/js/Api/Mappers/RecipeMappers.ts @@ -0,0 +1,70 @@ +import { Recipe } from 'cookbook/js/Models/schema'; +import { mapStringOrStringArray } from 'cookbook/js/utils/jsonMapper'; + +export function mapApiRecipeResponseToRecipe(recipeDTO: { + id: string; + recipe_id: string; + identifier: string; + keywords: string | string[]; + category: string | string[]; + recipeCategory: string | string[]; +}): Recipe { + // Create copy + const recipe = JSON.parse(JSON.stringify(recipeDTO)); + + // The cookbook API returns the `recipeCategory` property as `category` or `recipeCategory + if (recipeDTO.recipeCategory) { + recipe.recipeCategory = recipeDTO.recipeCategory; + } else if (recipeDTO.category) { + recipe.recipeCategory = recipeDTO.category; + } + + // TODO This should be unified some time (when the backend returns consistent responses ;) + // The cookbook API returns the `identifier` property as `id` + if (recipeDTO.id) recipe.identifier = recipeDTO.id; + else if (recipeDTO.recipe_id) recipe.identifier = recipeDTO.recipe_id; + + // The cookbook API currently returns the `keywords` property as a comma-separated list instead of an array + const keywords = mapStringOrStringArray( + recipeDTO.keywords, + "Recipe 'keywords'", + true, + true, + ); + recipe.keywords = keywords || []; + + return Recipe.fromJSON(recipe); +} + +export function mapApiRecipeByCategoryResponseToRecipe(recipeDTO: { + recipe_id: string; + identifier: string; + keywords: string | string[]; + category: string | string[]; +}): Recipe { + // Create copy + const recipe = JSON.parse(JSON.stringify(recipeDTO)); + + // The cookbook API returns the `recipeCategory` property as `category` + recipe.recipeCategory = recipeDTO.category; + + // The cookbook API returns the `identifier` property as `id` + recipe.identifier = recipeDTO.recipe_id; + // The cookbook API returns the `keywords` property as a comma-separated list instead of an array + const keywords = mapStringOrStringArray( + recipeDTO.keywords, + "Recipe 'keywords'", + true, + true, + ); + recipe.keywords = keywords || []; + + return Recipe.fromJSON(recipe); +} + +export function mapRecipeToApiRecipe(recipe: Recipe): object { + // The cookbook API returns the `identifier` property as `id` + const recipeDTO = JSON.parse(JSON.stringify(recipe)); + recipeDTO.id = recipe.identifier; + return recipeDTO; +} diff --git a/src/js/Enums/FilterType.ts b/src/js/Enums/FilterType.ts new file mode 100644 index 000000000..225d1782b --- /dev/null +++ b/src/js/Enums/FilterType.ts @@ -0,0 +1,11 @@ +/** + * Type of recipe filter. + */ +enum FilterType { + CategoriesFilter = 'CategoriesFilter', + KeywordsFilter = 'KeywordsFilter', + NamesFilter = 'NamesFilter', + SearchFilter = 'SearchFilter', +} + +export default FilterType; diff --git a/src/js/Enums/ListStyle.ts b/src/js/Enums/ListStyle.ts new file mode 100644 index 000000000..60338241c --- /dev/null +++ b/src/js/Enums/ListStyle.ts @@ -0,0 +1,9 @@ +/** + * Display style of a list of items. + */ +enum ListStyle { + List = 'list', + Grid = 'grid', +} + +export default ListStyle; diff --git a/src/js/Enums/LogicOperatorType.ts b/src/js/Enums/LogicOperatorType.ts new file mode 100644 index 000000000..ba5686ece --- /dev/null +++ b/src/js/Enums/LogicOperatorType.ts @@ -0,0 +1,9 @@ +/** + * Type of logic operator. + */ +enum LogicOperatorType { + And = 'And', + Or = 'Or', +} + +export default LogicOperatorType; diff --git a/src/js/Enums/RouteName.ts b/src/js/Enums/RouteName.ts new file mode 100644 index 000000000..c2c614254 --- /dev/null +++ b/src/js/Enums/RouteName.ts @@ -0,0 +1,31 @@ +/** + * Name of a route. + */ +enum RouteName { + // All recipes + Index = 'index', + ShowRecipeInIndex = 'recipe-view', + EditRecipeInIndex = 'recipe-edit', + + // via category + SearchRecipesByCategory = 'search-category', + ShowRecipeInCategory = 'search-category__recipe-view', + EditRecipeInCategory = 'search-category__recipe-edir', + + // via name + SearchRecipesByName = 'search-name', + ShowRecipeInNames = 'search-name__recipe-view', + EditRecipeInNames = 'search-name__recipe-edir', + + // via general search + SearchRecipesByAnything = 'search-general', + ShowRecipeInGeneralSearch = 'search-general__recipe-view', + EditRecipeInGeneralSearch = 'search-general__recipe-edit', + + // via tags + SearchRecipesByTags = 'search-tags', + ShowRecipeInTags = 'search-tags__recipe-view', + EditRecipeInTags = 'search-tags__recipe-edir', +} + +export default RouteName; diff --git a/src/js/Enums/SearchMode.ts b/src/js/Enums/SearchMode.ts new file mode 100644 index 000000000..af4c64fb7 --- /dev/null +++ b/src/js/Enums/SearchMode.ts @@ -0,0 +1,19 @@ +/** + * Mode for searching/matching for strings within other strings. + */ +enum SearchMode { + /** + * Both string must be identical + */ + Exact = 'exact', + /** + * The string should be contained in the other string. + */ + MatchSubstring = 'matchSubstring', + /** + * Perform a fuzzy search of the string within the other string. + */ + Fuzzy = 'fuzzy', +} + +export default SearchMode; diff --git a/src/js/Exceptions/JsonMappingException.ts b/src/js/Exceptions/JsonMappingException.ts new file mode 100644 index 000000000..8b9c3fd73 --- /dev/null +++ b/src/js/Exceptions/JsonMappingException.ts @@ -0,0 +1,4 @@ +/** + * Thrown when an error is encountered while mapping a JSON string to an object. + */ +export default class JsonMappingException extends Error {} diff --git a/src/js/Interfaces/Visitors/ISchemaOrgVisitor.ts b/src/js/Interfaces/Visitors/ISchemaOrgVisitor.ts new file mode 100644 index 000000000..1dce1cf35 --- /dev/null +++ b/src/js/Interfaces/Visitors/ISchemaOrgVisitor.ts @@ -0,0 +1,13 @@ +import type { + HowToDirection, + HowToSection, + HowToStep, + HowToTip, +} from 'cookbook/js/Models/schema'; + +export interface ISchemaOrgVisitor { + visitHowToDirection(element: HowToDirection): void; + visitHowToSection(element: HowToSection): void; + visitHowToStep(element: HowToStep): void; + visitHowToTip(tip: HowToTip): void; +} diff --git a/src/js/LogicOperators/AndOperator.js b/src/js/LogicOperators/AndOperator.js deleted file mode 100644 index a7f0fddbe..000000000 --- a/src/js/LogicOperators/AndOperator.js +++ /dev/null @@ -1,25 +0,0 @@ -import BinaryOperator from './BinaryOperator'; - -/** - * Implementation for the AND operator. - * @extends BinaryOperator - */ -class AndOperator extends BinaryOperator { - // eslint-disable-next-line class-methods-use-this - get toString() { - return 'AND'; - } - - /** - * Applies the AND operation. - * @param {boolean} result - The result accumulated so far. - * @param {boolean} current - The current value to apply. - * @returns {boolean} The result after applying the AND operation. - */ - // eslint-disable-next-line class-methods-use-this - apply(result, current) { - return result && current; - } -} - -export default AndOperator; diff --git a/src/js/LogicOperators/AndOperator.ts b/src/js/LogicOperators/AndOperator.ts new file mode 100644 index 000000000..c29aa93d4 --- /dev/null +++ b/src/js/LogicOperators/AndOperator.ts @@ -0,0 +1,42 @@ +import LogicOperatorType from 'cookbook/js/Enums/LogicOperatorType'; +import BinaryOperator from './BinaryOperator'; + +/** + * Implementation for the AND operator. + * @extends BinaryOperator + */ +class AndOperator extends BinaryOperator { + // eslint-disable-next-line class-methods-use-this + get type(): LogicOperatorType { + return LogicOperatorType.And; + } + + // eslint-disable-next-line class-methods-use-this + get toString(): string { + return 'AND'; + } + + /** + * Applies the AND operation. + * @param {boolean} result - The result accumulated so far. + * @param {boolean} current - The current value to apply. + * @returns {boolean} The result after applying the AND operation. + */ + // eslint-disable-next-line class-methods-use-this + apply(result, current): boolean { + return result && current; + } + + // eslint-disable-next-line class-methods-use-this + generateStringRepresentationForMultipleOperandsWithLabel( + items: unknown[], + label: string, + ): string { + if (label) { + return items.map((item) => `${label}:${item}`).join(' '); + } + return items.map((item) => `${item}`).join(' '); + } +} + +export default AndOperator; diff --git a/src/js/LogicOperators/BinaryOperator.js b/src/js/LogicOperators/BinaryOperator.js deleted file mode 100644 index 319698dc1..000000000 --- a/src/js/LogicOperators/BinaryOperator.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Abstract class for binary operators. - * @abstract - */ -class BinaryOperator { - // eslint-disable-next-line class-methods-use-this - get toString() { - return 'Abstract operation base class'; - } - - /** - * Constructor for the abstract class. - * @throws {TypeError} Cannot instantiate abstract class. - */ - constructor() { - if (new.target === BinaryOperator) { - throw new TypeError('Cannot instantiate abstract class'); - } - } - - /** - * Abstract method to be implemented by subclasses. - * @param {boolean} result - The result accumulated so far. - * @param {boolean} current - The current value to apply. - * @throws {Error} Method 'apply' must be implemented by subclasses. - */ - // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars,class-methods-use-this - apply(result, current) { - throw new Error("Method 'apply' must be implemented by subclasses"); - } -} - -export default BinaryOperator; diff --git a/src/js/LogicOperators/BinaryOperator.ts b/src/js/LogicOperators/BinaryOperator.ts new file mode 100644 index 000000000..882b9f879 --- /dev/null +++ b/src/js/LogicOperators/BinaryOperator.ts @@ -0,0 +1,52 @@ +import LogicOperatorType from 'cookbook/js/Enums/LogicOperatorType'; + +/** + * Abstract class for binary operators. + * @abstract + */ +class BinaryOperator { + // eslint-disable-next-line class-methods-use-this + get type(): LogicOperatorType { + throw new TypeError('Abstract operator class does not have type'); + } + + // eslint-disable-next-line class-methods-use-this + get toString(): string { + return 'Abstract operation base class'; + } + + /** + * Constructor for the abstract class. + * @throws {TypeError} Cannot instantiate abstract class. + */ + constructor() { + if (new.target === BinaryOperator) { + throw new TypeError('Cannot instantiate abstract class'); + } + } + + /** + * Abstract method to be implemented by subclasses. + * @param {boolean} result - The result accumulated so far. + * @param {boolean} current - The current value to apply. + * @throws {Error} Method 'apply' must be implemented by subclasses. + */ + // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars,class-methods-use-this + apply(result, current): boolean { + throw new Error("Method 'apply' must be implemented by subclasses"); + } + + // eslint-disable-next-line class-methods-use-this + generateStringRepresentationForMultipleOperandsWithLabel( + // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars + items: unknown[], + // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars + label: string, + ): string { + throw new Error( + "Method 'generateStringRepresentationForMultipleWithLabel' must be implemented by subclasses", + ); + } +} + +export default BinaryOperator; diff --git a/src/js/LogicOperators/OrOperator.js b/src/js/LogicOperators/OrOperator.js deleted file mode 100644 index cd6448fa5..000000000 --- a/src/js/LogicOperators/OrOperator.js +++ /dev/null @@ -1,25 +0,0 @@ -import BinaryOperator from './BinaryOperator'; - -/** - * Implementation for the OR operator. - * @extends BinaryOperator - */ -class OrOperator extends BinaryOperator { - // eslint-disable-next-line class-methods-use-this - get toString() { - return 'OR'; - } - - /** - * Applies the OR operation. - * @param {boolean} result - The result accumulated so far. - * @param {boolean} current - The current value to apply. - * @returns {boolean} The result after applying the OR operation. - */ - // eslint-disable-next-line class-methods-use-this - apply(result, current) { - return result || current; - } -} - -export default OrOperator; diff --git a/src/js/LogicOperators/OrOperator.ts b/src/js/LogicOperators/OrOperator.ts new file mode 100644 index 000000000..6d464387a --- /dev/null +++ b/src/js/LogicOperators/OrOperator.ts @@ -0,0 +1,40 @@ +import LogicOperatorType from 'cookbook/js/Enums/LogicOperatorType'; +import BinaryOperator from './BinaryOperator'; + +/** + * Implementation for the OR operator. + * @extends BinaryOperator + */ +class OrOperator extends BinaryOperator { + // eslint-disable-next-line class-methods-use-this + get type(): LogicOperatorType { + return LogicOperatorType.Or; + } + + // eslint-disable-next-line class-methods-use-this + get toString(): string { + return 'OR'; + } + + /** + * Applies the OR operation. + * @param {boolean} result - The result accumulated so far. + * @param {boolean} current - The current value to apply. + * @returns {boolean} The result after applying the OR operation. + */ + // eslint-disable-next-line class-methods-use-this + apply(result, current): boolean { + return result || current; + } + + // eslint-disable-next-line class-methods-use-this + generateStringRepresentationForMultipleOperandsWithLabel( + items: unknown[], + label: string, + ): string { + if (items.length === 0) return ''; + return `${label}:${items.map((item) => `${item}`).join(',')}`; + } +} + +export default OrOperator; diff --git a/src/js/LogicOperators/index.js b/src/js/LogicOperators/index.ts similarity index 100% rename from src/js/LogicOperators/index.js rename to src/js/LogicOperators/index.ts diff --git a/src/js/Models/schema/BaseSchemaOrgModel.ts b/src/js/Models/schema/BaseSchemaOrgModel.ts new file mode 100644 index 000000000..dff8e2a58 --- /dev/null +++ b/src/js/Models/schema/BaseSchemaOrgModel.ts @@ -0,0 +1,4 @@ +export default abstract class BaseSchemaOrgModel { + /** The schema.org type */ + public abstract readonly '@type'; +} diff --git a/src/js/Models/schema/HowToDirection.ts b/src/js/Models/schema/HowToDirection.ts new file mode 100644 index 000000000..8748304a7 --- /dev/null +++ b/src/js/Models/schema/HowToDirection.ts @@ -0,0 +1,182 @@ +import { + mapInteger, + mapString, + mapStringOrStringArray, +} from 'cookbook/js/utils/jsonMapper'; +import { ISchemaOrgVisitor } from 'cookbook/js/Interfaces/Visitors/ISchemaOrgVisitor'; +import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException'; +import BaseSchemaOrgModel from './BaseSchemaOrgModel'; +import HowToSupply from './HowToSupply'; +import HowToTool from './HowToTool'; +import { asArray, asCleanedArray } from '../../helper'; + +/** + * Interface representing the options for constructing a HowToDirection instance. + * @interface + */ +interface HowToDirectionOptions { + /** The position of the direction in the sequence. */ + position?: number; + + /** The images associated with the direction. */ + image?: string | string[]; + + /** The thumbnail URLs for the images. */ + thumbnailUrl?: string | string[]; + + /** The time required for the direction. */ + timeRequired?: string; + + /** The list of supplies needed for the direction. */ + supply?: HowToSupply | HowToSupply[]; + + /** The list of tools needed for the direction. */ + tool?: HowToTool | HowToTool[]; +} + +/** + * Represents a step or direction in the recipe instructions. + * @class + */ +export default class HowToDirection extends BaseSchemaOrgModel { + /** @inheritDoc */ + // eslint-disable-next-line class-methods-use-this + public readonly '@type' = 'HowToDirection'; + + /** The text content of the direction. */ + public text: string; + + /** The position of the direction in the sequence. */ + public position?: number; + + /** The images associated with the direction. */ + public image: string[]; + + /** The thumbnail URLs for the images. */ + public thumbnailUrl: string[]; + + /** The time required for the direction. */ + public timeRequired?: string; + + /** The list of supplies needed for the direction. */ + public supply: HowToSupply[]; + + /** The list of tools needed for the direction. */ + public tool: HowToTool[]; + + /** + * Creates a `HowToDirection` instance. + * @constructor + * @param {string} text - The text content of the direction. + * @param {HowToDirectionOptions} options - An options object containing additional properties. + */ + public constructor(text: string, options?: HowToDirectionOptions) { + super(); + this.text = text; + if (options) { + this.position = options.position; + this.image = asCleanedArray(options.image); + this.thumbnailUrl = asCleanedArray(options.thumbnailUrl); + this.timeRequired = options.timeRequired; + this.supply = asCleanedArray(options.supply); + this.tool = asCleanedArray(options.tool); + } else { + this.image = []; + this.thumbnailUrl = []; + this.supply = []; + this.tool = []; + } + } + + /** + * Accepts a visitor and invokes the appropriate visit method based on the type of the element. + * @param {ISchemaOrgVisitor} visitor - The visitor to accept. + */ + accept(visitor: ISchemaOrgVisitor) { + visitor.visitHowToDirection(this); + } + + /** + * Create a `HowToDirection` instance from a JSON string or object. + * @param {string | object} json - The JSON string or object. + * @returns {HowToDirection} - The created HowToDirection instance. + * @throws {Error} If the input JSON is invalid or missing required properties. + */ + static fromJSON(json: string | object): HowToDirection { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let jsonObj: any; + try { + jsonObj = typeof json === 'string' ? JSON.parse(json) : json; + } catch { + throw new JsonMappingException( + `Error mapping to "HowToDirection". Received invalid JSON: "${json}"`, + ); + } + + const text = mapString( + jsonObj.text, + "HowToDirection 'text'", + ) as NonNullable; + + const position = mapInteger( + jsonObj.position, + "HowToDirection 'position'", + true, + ); + + const image = mapStringOrStringArray( + jsonObj.image, + "HowToDirection 'image'", + true, + ); + + const thumbnailUrl = mapStringOrStringArray( + jsonObj.thumbnailUrl, + "HowToDirection 'thumbnailUrl'", + true, + ); + + const timeRequired = mapString( + jsonObj.timeRequired, + "HowToDirection 'timeRequired'", + true, + ); + + // supply + let supply: HowToSupply | HowToSupply[] = []; + if (jsonObj.supply) { + if (Array.isArray(jsonObj.supply)) { + supply = jsonObj.supply.map((s) => + HowToSupply.fromJSONOrString(s), + ); + } else { + supply = HowToSupply.fromJSONOrString(jsonObj.supply); + } + } + + // tool + // Supported values for tools are: string, HowToTool, or an array of those + const tool = jsonObj.tool + ? asArray(jsonObj.tool).map((t) => { + try { + return HowToTool.fromJSON(t); + } catch (ex) { + if (typeof t === 'string') { + // Did not receive a valid HowToTool, treat as simple string. + return new HowToTool(t as string); + } + throw ex; + } + }) + : []; + + return new HowToDirection(text, { + position: position || undefined, + image: image || [], + thumbnailUrl: thumbnailUrl || [], + timeRequired: timeRequired || undefined, + supply, + tool, + }); + } +} diff --git a/src/js/Models/schema/HowToSection.ts b/src/js/Models/schema/HowToSection.ts new file mode 100644 index 000000000..86a6303c3 --- /dev/null +++ b/src/js/Models/schema/HowToSection.ts @@ -0,0 +1,197 @@ +import { + mapInteger, + mapString, + mapStringOrStringArray, +} from 'cookbook/js/utils/jsonMapper'; +import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException'; +import { asCleanedArray } from 'cookbook/js/helper'; +import { ISchemaOrgVisitor } from 'cookbook/js/Interfaces/Visitors/ISchemaOrgVisitor'; +import BaseSchemaOrgModel from './BaseSchemaOrgModel'; +import HowToDirection from './HowToDirection'; +import HowToStep from './HowToStep'; +import HowToTip from './HowToTip'; + +/** + * Interface representing the options for constructing a HowToSection instance. + * @interface + */ +interface HowToSectionOptions { + /** The description of the section. */ + description?: string; + + /** The position of the section in the sequence. */ + position?: number; + + /** The images associated with the section. */ + image?: string | string[]; + + /** The time required for the direction. */ + timeRequired?: string; + + /** The thumbnail URLs for the images defined in `image`. */ + thumbnailUrl?: string | string[]; + + /** The list of directions within the section. */ + itemListElement?: + | HowToDirection + | HowToStep + | HowToTip + | (HowToDirection | HowToStep | HowToTip)[]; +} + +/** + * Represents a section in the recipe instructions. + * @class + */ +export default class HowToSection extends BaseSchemaOrgModel { + /** @inheritDoc */ + // eslint-disable-next-line class-methods-use-this + public readonly '@type' = 'HowToSection'; + + /** The name of the section. */ + public name?: string = undefined; + + /** The position of the section in the sequence. */ + public position?: number = undefined; + + /** The description of the section. */ + public description?: string = undefined; + + /** The images associated with the section. */ + public image: string[] = []; + + /** The time required for the section. */ + public timeRequired?: string = undefined; + + /** The thumbnail URLs for the images defined in `image`. */ + public thumbnailUrl: string[] = []; + + /** The list of directions within the section. */ + public itemListElement: (HowToDirection | HowToStep | HowToTip)[] = []; + + /** + * Creates a HowToSection instance. + * @constructor + * @param {string} name - The name of the section. + * @param {HowToSectionOptions} options - An options object containing additional properties. + */ + public constructor(name: string, options?: HowToSectionOptions) { + super(); + this.name = name; + if (options) { + this.description = options.description; + this.position = options.position; + this.image = asCleanedArray(options.image); + this.timeRequired = options.timeRequired; + this.thumbnailUrl = asCleanedArray(options.thumbnailUrl); + this.itemListElement = asCleanedArray(options.itemListElement); + } + } + + /** + * Accepts a visitor and invokes the appropriate visit method based on the type of the element. + * @param {ISchemaOrgVisitor} visitor - The visitor to accept. + */ + accept(visitor: ISchemaOrgVisitor) { + visitor.visitHowToSection(this); + } + + /** + * Create a `HowToSection` instance from a JSON string or object. + * @param {string | object} json - The JSON string or object. + * @returns {HowToSection} - The created HowToSection instance. + * @throws {Error} If the input JSON is invalid or missing required properties. + */ + static fromJSON(json: string | object): HowToSection { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let jsonObj: any; + try { + jsonObj = typeof json === 'string' ? JSON.parse(json) : json; + } catch { + throw new JsonMappingException( + `Error mapping to "HowToSection". Received invalid JSON: "${json}"`, + ); + } + + const name = mapString(jsonObj.name, "HowToSection 'name'", true) ?? ''; + + const description = mapString( + jsonObj.description, + "HowToSection 'description'", + true, + ); + + const position = mapInteger( + jsonObj.position, + "HowToSection 'position'", + true, + ); + + const image = mapStringOrStringArray( + jsonObj.image, + "HowToSection 'image'", + true, + ); + + const timeRequired = mapString( + jsonObj.timeRequired, + "HowToSection 'timeRequired'", + true, + ); + + const thumbnailUrl = mapStringOrStringArray( + jsonObj.thumbnailUrl, + "HowToSection 'thumbnailUrl'", + true, + ); + + // itemListElement + let itemListElement: (HowToDirection | HowToStep | HowToTip)[] = []; + if (jsonObj.itemListElement) { + itemListElement = asCleanedArray( + jsonObj.itemListElement.map((item: string | object) => { + if (typeof item === 'string') { + return new HowToDirection(item); + } + if (item['@type'] === 'HowToStep') { + try { + return HowToStep.fromJSON(item); + } catch { + /* empty */ + } + } + if (item['@type'] === 'HowToDirection') { + try { + return HowToDirection.fromJSON(item); + } catch { + /* empty */ + } + } + if (item['@type'] === 'HowToTip') { + try { + return HowToTip.fromJSON(item); + } catch { + /* empty */ + } + } + // Type not set, in a final try, try to map to direction + try { + return HowToDirection.fromJSON(item); + } catch { + /* empty */ + } + return null; + }), + ); + } + + return new HowToSection(name, { + description: description || undefined, + position: position || undefined, + image: image || [], + timeRequired: timeRequired || undefined, + thumbnailUrl: thumbnailUrl || [], + itemListElement: itemListElement || [], + }); + } +} diff --git a/src/js/Models/schema/HowToStep.ts b/src/js/Models/schema/HowToStep.ts new file mode 100644 index 000000000..aa7a30e49 --- /dev/null +++ b/src/js/Models/schema/HowToStep.ts @@ -0,0 +1,227 @@ +import { + mapInteger, + mapString, + mapStringOrStringArray, +} from 'cookbook/js/utils/jsonMapper'; +import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException'; +import HowToDirection from 'cookbook/js/Models/schema/HowToDirection'; +import HowToTip from 'cookbook/js/Models/schema/HowToTip'; +import { ISchemaOrgVisitor } from 'cookbook/js/Interfaces/Visitors/ISchemaOrgVisitor'; +import BaseSchemaOrgModel from './BaseSchemaOrgModel'; +import { asArray, asCleanedArray } from '../../helper'; + +/** + * Interface representing the options for constructing a `HowToStep` instance. + * @interface + */ +interface HowToStepOptions { + /** The position of the step in the sequence. */ + position?: number; + + /** The images associated with the step. */ + image?: string | string[]; + + /** The thumbnail URLs for the images. */ + thumbnailUrl?: string | string[]; + + /** The time required for the step. */ + timeRequired?: string; +} + +/** + * Represents a step in the recipe instructions. + * @class + */ +export default class HowToStep extends BaseSchemaOrgModel { + /** @inheritDoc */ + // eslint-disable-next-line class-methods-use-this + public readonly '@type' = 'HowToStep'; + + /** The text content of the step. Required if `itemListElement` is not set. */ + private _text?: string; + + /** The position of the step in the sequence. */ + public position?: number; + + /** The images associated with the step. */ + public image: string[] = []; + + /** A list of substeps. This may include directions or tips. Required if `text` is not set. */ + private _itemListElement: (HowToDirection | HowToTip)[] = []; + + /** The thumbnail URLs for the images. */ + public thumbnailUrl: string[] = []; + + /** The time required for the step. */ + public timeRequired?: string; + + /** + * Error message const for local use in this class. + * @private + */ + private ValidationMsg = + 'HowToStep requires either `text` or `itemListElement` to be set'; + + /** + * Creates a `HowToStep` instance. + * @constructor + * @param {string} text - The text content of the step. + * @param itemListElements - List of step items. + * @param {HowToStepOptions} options - An options object containing additional properties. + */ + public constructor( + text: string, + itemListElements: (HowToDirection | HowToTip)[], + options?: HowToStepOptions, + ) { + super(); + if (!text && !itemListElements) { + throw Error(this.ValidationMsg); + } + + // eslint-disable-next-line no-underscore-dangle + this._text = text; + // eslint-disable-next-line no-underscore-dangle + this._itemListElement = itemListElements; + + if (options) { + this.position = options.position; + this.image = asCleanedArray(options.image); + this.thumbnailUrl = asCleanedArray(options.thumbnailUrl); + this.timeRequired = options.timeRequired; + } + } + + /** A list of substeps. This may include directions or tips. Required if `text` is not set. */ + public get itemListElement(): (HowToDirection | HowToTip)[] { + // eslint-disable-next-line no-underscore-dangle + return this._itemListElement; + } + + /** A list of substeps. This may include directions or tips. Required if `text` is not set. */ + public set itemListElement( + value: + | HowToDirection + | HowToTip + | (HowToDirection | HowToTip)[] + | undefined, + ) { + if (!this.text && !value) { + throw Error(this.ValidationMsg); + } + // eslint-disable-next-line no-underscore-dangle + this._itemListElement = value ? asArray(value) : []; + } + + /** The text content of the step. Required if `itemListElement` is not set. */ + public get text(): string | undefined { + // eslint-disable-next-line no-underscore-dangle + return this._text; + } + + /** The text content of the step. Required if `itemListElement` is not set. */ + public set text(value: string | undefined) { + // eslint-disable-next-line no-underscore-dangle + if (!this._itemListElement && !value) { + throw Error(this.ValidationMsg); + } + // eslint-disable-next-line no-underscore-dangle + this._text = value; + } + + /** + * Accepts a visitor and invokes the appropriate visit method based on the type of the element. + * @param {ISchemaOrgVisitor} visitor - The visitor to accept. + */ + accept(visitor: ISchemaOrgVisitor) { + visitor.visitHowToStep(this); + } + + /** + * Create a `HowToStep` instance from a JSON string or object. + * @param {string | object} json - The JSON string or object. + * @returns {HowToStep} - The created HowToStep instance. + * @throws {Error} If the input JSON is invalid or missing required properties. + */ + static fromJSON(json: string | object): HowToStep { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let jsonObj: any; + try { + jsonObj = typeof json === 'string' ? JSON.parse(json) : json; + } catch { + throw new JsonMappingException( + `Error mapping to "HowToStep". Received invalid JSON: "${json}"`, + ); + } + + const text = mapString(jsonObj.text, "HowToStep 'text'", true); + + const itemListElements = this.mapDirectionOrTipArray( + jsonObj.itemListElement, + ); + + const position = mapInteger( + jsonObj.position, + "HowToStep 'position'", + true, + ); + + const image = mapStringOrStringArray( + jsonObj.image, + "HowToStep 'image'", + true, + ); + + const thumbnailUrl = mapStringOrStringArray( + jsonObj.thumbnailUrl, + "HowToStep 'thumbnailUrl'", + true, + ); + + const timeRequired = mapString( + jsonObj.timeRequired, + "HowToStep 'timeRequired'", + true, + ); + + return new HowToStep(text ?? '', itemListElements, { + position: position || undefined, + image: image || [], + thumbnailUrl: thumbnailUrl || [], + timeRequired: timeRequired || undefined, + }); + } + + /** + * Tries to map `json` to a string or an array of strings. + * @param json The value to be mapped. + * @returns The value as an array of `HowToDirection` and `HowToTip` items. + */ + private static mapDirectionOrTipArray( + json: unknown, + ): (HowToDirection | HowToTip)[] { + const jsonArray = json ? asArray(json) : []; + const mappedArray = jsonArray.map((item) => { + if (typeof item === 'string') { + return new HowToDirection(item); + } + if (item['@type'] === 'HowToDirection') { + try { + return HowToDirection.fromJSON(item); + } catch (ex) { + /* empty */ + } + } + if (item['@type'] === 'HowToTip') { + try { + return HowToTip.fromJSON(item); + } catch { + /* empty */ + } + } + return null; + }); + + return mappedArray.filter((itm) => !!itm).map((itm) => itm!); + } +} diff --git a/src/js/Models/schema/HowToSupply.ts b/src/js/Models/schema/HowToSupply.ts new file mode 100644 index 000000000..6169757c0 --- /dev/null +++ b/src/js/Models/schema/HowToSupply.ts @@ -0,0 +1,129 @@ +import { mapString } from 'cookbook/js/utils/jsonMapper'; +import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException'; +import BaseSchemaOrgModel from './BaseSchemaOrgModel'; +import QuantitativeValue from './QuantitativeValue'; + +/** + * Represents a supply item in the `HowToSupply` section. + * @class + */ +export default class HowToSupply extends BaseSchemaOrgModel { + /** @inheritDoc */ + // eslint-disable-next-line class-methods-use-this + public readonly '@type' = 'HowToSupply'; + + /** The name of the supply item. */ + public name: string; + + /** The identifier of the supply item. */ + public identifier?: string; + + /** The description of the supply item. */ + public description?: string; + + /** The required quantity of the supply item. */ + public requiredQuantity?: QuantitativeValue; + + /** + * Creates a `HowToSupply` instance. + * @constructor + * @param name - The name of the supply item. + */ + public constructor(name: string); + + /** + * Creates a `HowToSupply` instance. + * @constructor + * @param name - The name of the supply item. + * @param identifier - The identifier of the supply item. + * @param description - The description of the supply item. + * @param requiredQuantity - The required quantity of the supply item. + */ + public constructor( + name: string, + identifier?: string, + description?: string, + requiredQuantity?: QuantitativeValue, + ); + + /** + * Creates a `HowToSupply` instance. + * @constructor + * @param name - The name of the supply item. + * @param args - Remaining supported arguments. + */ + constructor(name: string, ...args: never[]) { + super(); + this.name = name; + // eslint-disable-next-line prefer-destructuring + if (args[0]) this.identifier = args[0]; + // eslint-disable-next-line prefer-destructuring + if (args[1]) this.description = args[1]; + // eslint-disable-next-line prefer-destructuring + if (args[2]) this.requiredQuantity = args[2]; + } + + /** + * Create a `HowToSupply` instance from a JSON string. + * @param {string | object} json - The JSON string or object. + * @returns {HowToSupply} - The created HowToSupply instance. + * @throws {Error} If the input JSON is invalid or missing required properties. + */ + static fromJSON(json: string | object): HowToSupply { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let jsonObj: any; + try { + jsonObj = typeof json === 'string' ? JSON.parse(json) : json; + } catch { + throw new JsonMappingException( + `Error mapping to "HowToSupply". Received invalid JSON: "${json}"`, + ); + } + + const name = mapString( + jsonObj.name, + "HowToSupply 'name'", + ) as NonNullable; + + const identifier = mapString( + jsonObj.identifier, + "HowToSupply 'identifier'", + true, + ); + + const description = mapString( + jsonObj.description, + "HowToSupply 'description'", + true, + ); + + const requiredQuantity = jsonObj.requiredQuantity + ? QuantitativeValue.fromJSONOrString(jsonObj.requiredQuantity) + : undefined; + + return new HowToSupply( + name, + identifier || undefined, + description || undefined, + requiredQuantity, + ); + } + + /** + * Create a `HowToSupply` instance from a JSON string. If the string can't be parsed as a JSON + * object it will be used as the tool's name. + * @param {string | object} json - The JSON string or object. + * @returns {HowToSupply} - The created HowToSupply instance. + * @throws {Error} If the input JSON is invalid or missing required properties. + */ + static fromJSONOrString(json: string | object): HowToSupply { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let jsonObj: any; + try { + jsonObj = typeof json === 'string' ? JSON.parse(json) : json; + } catch { + if (typeof json === 'string') return new HowToSupply(json); + } + return this.fromJSON(jsonObj); + } +} diff --git a/src/js/Models/schema/HowToTip.ts b/src/js/Models/schema/HowToTip.ts new file mode 100644 index 000000000..b9391b8af --- /dev/null +++ b/src/js/Models/schema/HowToTip.ts @@ -0,0 +1,131 @@ +import { + mapInteger, + mapString, + mapStringOrStringArray, +} from 'cookbook/js/utils/jsonMapper'; +import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException'; +import { ISchemaOrgVisitor } from 'cookbook/js/Interfaces/Visitors/ISchemaOrgVisitor'; +import { asCleanedArray } from '../../helper'; +import BaseSchemaOrgModel from './BaseSchemaOrgModel'; + +/** + * Interface representing the options for constructing a `HowToTip` instance. + * @interface + */ +interface HowToTipOptions { + /** The position of the tip in the sequence. */ + position?: number; + + /** The images associated with the tip. */ + image?: string | string[]; + + /** The thumbnail URLs for the images. */ + thumbnailUrl?: string | string[]; + + /** The time required for the tip. */ + timeRequired?: string; +} + +/** + * Represents a tip in the recipe instructions. + * @class + */ +export default class HowToTip extends BaseSchemaOrgModel { + /** @inheritDoc */ + // eslint-disable-next-line class-methods-use-this + public readonly '@type' = 'HowToTip'; + + /** The text content of the tip. */ + public text: string; + + /** The position of the tip in the sequence. */ + public position?: number; + + /** The images associated with the tip. */ + public image: string[]; + + /** The thumbnail URLs for the images. */ + public thumbnailUrl: string[]; + + /** The time required for the tip. */ + public timeRequired?: string; + + /** + * Creates a `HowToTip` instance. + * @constructor + * @param {string} text - The text content of the tip. + * @param {HowToTipOptions} options - An options object containing additional properties. + */ + public constructor(text: string, options?: HowToTipOptions) { + super(); + this.text = text; + if (options) { + this.position = options.position; + this.image = asCleanedArray(options.image); + this.thumbnailUrl = asCleanedArray(options.thumbnailUrl); + this.timeRequired = options.timeRequired; + } + } + + /** + * Accepts a visitor and invokes the appropriate visit method based on the type of the element. + * @param {ISchemaOrgVisitor} visitor - The visitor to accept. + */ + accept(visitor: ISchemaOrgVisitor) { + visitor.visitHowToTip(this); + } + + /** + * Create a `HowToTip` instance from a JSON string or object. + * @param {string | object} json - The JSON string or object. + * @returns {HowToTip} - The created HowToTip instance. + * @throws {Error} If the input JSON is invalid or missing required properties. + */ + static fromJSON(json: string | object): HowToTip { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let jsonObj: any; + try { + jsonObj = typeof json === 'string' ? JSON.parse(json) : json; + } catch { + throw new JsonMappingException( + `Error mapping to "HowToTip". Received invalid JSON: "${json}"`, + ); + } + + const text = mapString( + jsonObj.text, + "HowToTip 'text'", + ) as NonNullable; + + const position = mapInteger( + jsonObj.position, + "HowToTip 'position'", + true, + ); + + const image = mapStringOrStringArray( + jsonObj.image, + "HowToTip 'image'", + true, + ); + + const thumbnailUrl = mapStringOrStringArray( + jsonObj.thumbnailUrl, + "HowToTip 'thumbnailUrl'", + true, + ); + + const timeRequired = mapString( + jsonObj.timeRequired, + "HowToTip 'timeRequired'", + true, + ); + + return new HowToTip(text, { + position: position || undefined, + image: image || [], + thumbnailUrl: thumbnailUrl || [], + timeRequired: timeRequired || undefined, + }); + } +} diff --git a/src/js/Models/schema/HowToTool.ts b/src/js/Models/schema/HowToTool.ts new file mode 100644 index 000000000..5f2452e77 --- /dev/null +++ b/src/js/Models/schema/HowToTool.ts @@ -0,0 +1,129 @@ +import { mapString } from 'cookbook/js/utils/jsonMapper'; +import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException'; +import BaseSchemaOrgModel from './BaseSchemaOrgModel'; +import QuantitativeValue from './QuantitativeValue'; + +/** + * Represents a tool used in the recipe instructions. + * @class + */ +export default class HowToTool extends BaseSchemaOrgModel { + /** @inheritDoc */ + // eslint-disable-next-line class-methods-use-this + public readonly '@type' = 'HowToTool'; + + /** The name of the tool. */ + public name: string; + + /** The identifier of the tool. */ + public identifier?: string; + + /** The description of the tool. */ + public description?: string; + + /** The required quantity of the tool. */ + public requiredQuantity?: QuantitativeValue; + + /** + * Creates a HowToTool instance. + * @constructor + * @param name - The name of the tool. + */ + constructor(name: string); + + /** + * Creates a HowToTool instance. + * @constructor + * @param name - The name of the tool. + * @param identifier - The identifier of the tool. + * @param description - The description of the tool. + * @param requiredQuantity - The required quantity of the tool. + */ + constructor( + name: string, + identifier?: string, + description?: string, + requiredQuantity?: QuantitativeValue, + ); + + /** + * Creates a HowToTool instance. + * @constructor + * @param name - The name of the tool. + * @param args - Remaining supported arguments. + */ + constructor(name: string, ...args: never[]) { + super(); + this.name = name; + // eslint-disable-next-line prefer-destructuring + if (args[0]) this.identifier = args[0]; + // eslint-disable-next-line prefer-destructuring + if (args[1]) this.description = args[1]; + // eslint-disable-next-line prefer-destructuring + if (args[2]) this.requiredQuantity = args[2]; + } + + /** + * Create a `HowToTool` instance from a JSON string. + * @param {string | object} json - The JSON string or object. + * @returns {HowToTool} - The created HowToTool instance. + * @throws {Error} If the input JSON is invalid or missing required properties. + */ + static fromJSON(json: string | object): HowToTool { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let jsonObj: any; + try { + jsonObj = typeof json === 'string' ? JSON.parse(json) : json; + } catch { + throw new JsonMappingException( + `Error mapping to "HowToTool". Received invalid JSON: "${json}"`, + ); + } + + const name = mapString( + jsonObj.name, + "HowToTool 'name'", + ) as NonNullable; + + const identifier = mapString( + jsonObj.identifier, + "HowToTool 'identifier'", + true, + ); + + const description = mapString( + jsonObj.description, + "HowToTool 'description'", + true, + ); + + const requiredQuantity = jsonObj.requiredQuantity + ? QuantitativeValue.fromJSONOrString(jsonObj.requiredQuantity) + : undefined; + + return new HowToTool( + name, + identifier || undefined, + description || undefined, + requiredQuantity, + ); + } + + /** + * Create a `HowToTool` instance from a JSON string or a simple string. If the string can't be parsed as a JSON + * object it will be used as the tool's name. + * @param {string | object} json - The JSON string or object. + * @returns {HowToTool} - The created HowToTool instance. + * @throws {Error} If the input JSON is invalid or missing required properties. + */ + static fromJSONOrString(json: string | object): HowToTool { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let jsonObj: any; + try { + jsonObj = typeof json === 'string' ? JSON.parse(json) : json; + } catch { + if (typeof json === 'string') return new HowToTool(json); + } + return this.fromJSON(jsonObj); + } +} diff --git a/src/js/Models/schema/NutritionInformation.ts b/src/js/Models/schema/NutritionInformation.ts new file mode 100644 index 000000000..f6030c021 --- /dev/null +++ b/src/js/Models/schema/NutritionInformation.ts @@ -0,0 +1,185 @@ +import JsonMappingException from '../../Exceptions/JsonMappingException'; +import BaseSchemaOrgModel from './BaseSchemaOrgModel'; + +/** + * Interface representing the properties of the NutritionInformation class. + * @interface + */ +export interface NutritionInformationProperties { + /** The number of calories. */ + calories?: string; + + /** The number of grams of carbohydrates. */ + carbohydrateContent?: string; + + /** The number of milligrams of cholesterol. */ + cholesterolContent?: string; + + /** The number of grams of fat. */ + fatContent?: string; + + /** The number of grams of fiber. */ + fiberContent?: string; + + /** The number of grams of protein. */ + proteinContent?: string; + + /** The number of grams of saturated fat. */ + saturatedFatContent?: string; + + /** The serving size, in terms of the number of volume or mass. */ + servingSize?: string; + + /** The number of milligrams of sodium. */ + sodiumContent?: string; + + /** The number of grams of sugar. */ + sugarContent?: string; + + /** The number of grams of trans fat. */ + transFatContent?: string; + + /** The number of grams of unsaturated fat. */ + unsaturatedFatContent?: string; +} + +/** + * Represents nutrition information. + * @class + */ +export default class NutritionInformation extends BaseSchemaOrgModel { + /** @inheritDoc */ + // eslint-disable-next-line class-methods-use-this + public readonly '@type' = 'NutritionInformation'; + + /** The number of calories. */ + public calories?: string; + + /** The number of grams of carbohydrates. */ + public carbohydrateContent?: string; + + /** The number of milligrams of cholesterol. */ + public cholesterolContent?: string; + + /** The number of grams of fat. */ + public fatContent?: string; + + /** The number of grams of fiber. */ + public fiberContent?: string; + + /** The number of grams of protein. */ + public proteinContent?: string; + + /** The number of grams of saturated fat. */ + public saturatedFatContent?: string; + + /** The serving size, in terms of the number of volume or mass. */ + public servingSize?: string; + + /** The number of milligrams of sodium. */ + public sodiumContent?: string; + + /** The number of grams of sugar. */ + public sugarContent?: string; + + /** The number of grams of trans fat. */ + public transFatContent?: string; + + /** The number of grams of unsaturated fat. */ + public unsaturatedFatContent?: string; + + /** + * Creates a NutritionInformation instance. + * @constructor + * @param properties - An optional object containing the nutrition information properties. + */ + constructor(properties?: NutritionInformationProperties) { + super(); + + // Set the properties from the provided object, or default to undefined + Object.assign(this, properties); + } + + /** + * Checks if any nutrition value in this object is a non-empty string. + * @returns {boolean} - `true` if there is a nutrition value defined. `false` otherwise. + */ + public isUndefined(): boolean { + return !( + // Does any of these have a value? + ( + (this.calories && this.calories !== '') || + (this.carbohydrateContent && this.carbohydrateContent !== '') || + (this.cholesterolContent && this.cholesterolContent !== '') || + (this.fatContent && this.fatContent !== '') || + (this.fiberContent && this.fiberContent !== '') || + (this.proteinContent && this.proteinContent !== '') || + (this.saturatedFatContent && this.saturatedFatContent !== '') || + (this.servingSize && this.servingSize !== '') || + (this.sodiumContent && this.sodiumContent !== '') || + (this.sugarContent && this.sugarContent !== '') || + (this.transFatContent && this.transFatContent !== '') || + (this.unsaturatedFatContent && + this.unsaturatedFatContent !== '') + ) + ); + } + + /** + * Create a `NutritionInformation` instance from a JSON string. + * @param {string | object} json - The JSON string or object. + * @returns {NutritionInformation} - The created NutritionInformation instance. + * @throws {Error} If the input JSON is invalid or missing required properties. + */ + static fromJSON(json: string | object): NutritionInformation { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let jsonObj: any; + try { + jsonObj = typeof json === 'string' ? JSON.parse(json) : json; + } catch { + throw new JsonMappingException( + `Error mapping to "NutritionInformation". Received invalid JSON: "${json}"`, + ); + } + + const validateStringProperty = (propertyName: string) => { + if ( + jsonObj[propertyName] !== undefined && + jsonObj[propertyName] !== null && + typeof jsonObj[propertyName] !== 'string' + ) { + throw new JsonMappingException( + `Invalid property value: "${propertyName}" must be a string`, + ); + } + }; + + validateStringProperty('calories'); + validateStringProperty('carbohydrateContent'); + validateStringProperty('cholesterolContent'); + validateStringProperty('fatContent'); + validateStringProperty('fiberContent'); + validateStringProperty('proteinContent'); + validateStringProperty('saturatedFatContent'); + validateStringProperty('servingSize'); + validateStringProperty('sodiumContent'); + validateStringProperty('sugarContent'); + validateStringProperty('transFatContent'); + validateStringProperty('unsaturatedFatContent'); + + return new NutritionInformation({ + calories: jsonObj.calories, + carbohydrateContent: jsonObj.carbohydrateContent, + cholesterolContent: jsonObj.cholesterolContent, + fatContent: jsonObj.fatContent, + fiberContent: jsonObj.fiberContent, + proteinContent: jsonObj.proteinContent, + saturatedFatContent: jsonObj.saturatedFatContent, + servingSize: jsonObj.servingSize, + sodiumContent: jsonObj.sodiumContent, + sugarContent: jsonObj.sugarContent, + transFatContent: jsonObj.transFatContent, + unsaturatedFatContent: jsonObj.unsaturatedFatContent, + }); + } +} diff --git a/src/js/Models/schema/QuantitativeValue.ts b/src/js/Models/schema/QuantitativeValue.ts new file mode 100644 index 000000000..bf32e5c6a --- /dev/null +++ b/src/js/Models/schema/QuantitativeValue.ts @@ -0,0 +1,111 @@ +import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException'; +import { mapInteger, mapString } from 'cookbook/js/utils/jsonMapper'; +import BaseSchemaOrgModel from 'cookbook/js/Models/schema/BaseSchemaOrgModel'; + +/** + * Interface representing the options for constructing a QuantitativeValue instance. + * @interface + */ +interface QuantitativeValueOptions { + /** The unit of measurement (e.g., "cup", "kilogram"). */ + unitText?: string; + + /** The unit code (e.g., "CU", "KGM"). */ + unitCode?: string; +} + +/** + * Represents a quantitative value with unit information. + * @class + */ +export default class QuantitativeValue extends BaseSchemaOrgModel { + /** @inheritDoc */ + // eslint-disable-next-line class-methods-use-this + public readonly '@type' = 'QuantitativeValue'; + + /** The numerical value. */ + public value: number; + + /** The unit of measurement (e.g., "cup", "kilogram"). */ + public unitText?: string; + + /** The unit code (e.g., "CU", "KGM"). */ + public unitCode?: string; + + /** + * Creates a `QuantitativeValue` instance. + * @constructor + * @param {number} value - The numeric value (amount). + * @param {QuantitativeValueOptions} options - An options object containing additional properties regarding the unit. + */ + public constructor(value: number, options?: QuantitativeValueOptions) { + super(); + this.value = value; + if (options) { + this.unitText = options.unitText; + this.unitCode = options.unitCode; + } else { + this.unitText = undefined; + this.unitCode = undefined; + } + } + + /** + * Create a `QuantitativeValue` instance from a JSON string. + * @param {string | object} json - The JSON string or object. + * @returns {QuantitativeValue} - The created QuantitativeValue instance. + * @throws {Error} If the input JSON is invalid or missing required properties. + */ + static fromJSON(json: string | object): QuantitativeValue { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let jsonObj: any; + try { + jsonObj = typeof json === 'string' ? JSON.parse(json) : json; + } catch { + throw new JsonMappingException( + `Error mapping to "QuantitativeValue". Received invalid JSON: "${json}"`, + ); + } + + const value = mapInteger( + jsonObj.value, + "QuantitativeValue 'value'", + ) as NonNullable; + + const unitText = mapString( + jsonObj.unitText, + "QuantitativeValue 'value'", + ) as NonNullable; + + const unitCode = mapString( + jsonObj.unitCode, + "QuantitativeValue 'value'", + true, + ); + + return new QuantitativeValue(value, { + unitText, + unitCode: unitCode || undefined, + }); + } + + /** + * Create a `QuantitativeValue` instance from a JSON string. + * @param {string | object} json - The JSON string or object. + * @returns {QuantitativeValue} - The created QuantitativeValue instance. + * @throws {Error} If the input JSON is invalid or missing required properties. + */ + static fromJSONOrString(json: string | object): QuantitativeValue { + try { + return QuantitativeValue.fromJSON(json); + } catch { + const number = parseFloat(json as string); + if (!Number.isNaN(number)) { + return new QuantitativeValue(number); + } + } + throw new JsonMappingException( + `Error mapping to "QuantitativeValue". Received invalid JSON: "${json}"`, + ); + } +} diff --git a/src/js/Models/schema/Recipe.ts b/src/js/Models/schema/Recipe.ts new file mode 100644 index 000000000..1615ffb9d --- /dev/null +++ b/src/js/Models/schema/Recipe.ts @@ -0,0 +1,323 @@ +import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException'; +import { + mapInteger, + mapString, + mapStringOrStringArray, +} from 'cookbook/js/utils/jsonMapper'; +import BaseSchemaOrgModel from './BaseSchemaOrgModel'; +import HowToSection from './HowToSection'; +import HowToStep from './HowToStep'; +import HowToSupply from './HowToSupply'; +import HowToTool from './HowToTool'; +import NutritionInformation from './NutritionInformation'; +import { asArray } from '../../helper'; + +/** Options for creating a recipe */ +interface RecipeOptions { + /** The category of the recipe. */ + recipeCategory?: string | string[]; + /** The timestamp of the recipe's creation date. */ + dateCreated?: string; + /** The timestamp of the recipe's modification date. */ + dateModified?: string; + /** The description of the recipe. */ + description?: string; + /** The original image Urls of the recipe. */ + image?: string | string[]; + /** Urls to the images of the recipe on the Nextcloud instance. */ + imageUrl?: string | string[]; + /** The keywords of the recipe. */ + keywords?: string | string[]; + /** The total time required for the recipe. */ + totalTime?: string; + /** The time it takes to actually cook the dish, in ISO 8601 duration format. */ + cookTime?: string; + /** The length of time it takes to prepare the items to be used in instructions or a direction, in ISO 8601 duration + * format. */ + prepTime?: string; + /** Nutritional information about the recipe. */ + nutrition?: NutritionInformation; + /** The list of ingredients for the recipe. */ + recipeIngredient?: string | string[]; + /** The number of servings for the recipe */ + recipeYield?: number; + /** The list of supplies needed for the recipe. */ + supply?: HowToSupply | HowToSupply[]; + /** The step-by-step instructions for the recipe. */ + recipeInstructions?: + | HowToSection + | HowToStep + | (HowToSection | HowToStep)[]; + /** The tools required for the recipe. */ + tool?: HowToTool | HowToTool[]; + /** The URL of the recipe. */ + url?: string | string[]; +} + +/** + * Represents a Recipe in `schema.org` standard. Does not support simple string values (or arrays of strings) for `tool`, + * `supply`, `nutritionInformation`, and `recipeInstructions`. This simplifies usage in the frontend, since those types + * do not have to be checked if they are string. + * + * When parsed from JSON the subclasses (`HowToStep`, etc.) should support the `string` values as defined in the + * `schema.org` standard. These are then mapped to the internally supported classes for above reasons. + * @class + */ +export default class Recipe extends BaseSchemaOrgModel { + /** @inheritDoc */ + // eslint-disable-next-line class-methods-use-this + public readonly '@type' = 'Recipe'; + + /** The unique identifier of the recipe */ + public identifier: string; + + /** The name/title of the recipe */ + public name: string; + + /** The category of the recipe. */ + public recipeCategory?: string | string[]; + + /** The original image Urls of the recipe. */ + public image: string[]; + + /** Urls to the images of the recipe on the Nextcloud instance. */ + public imageUrl: string[]; + + /** The keywords of the recipe. */ + public keywords: string[]; + + /** The total time required for the recipe. */ + public totalTime: string | undefined; + + /** The time it takes to actually cook the dish, in ISO 8601 duration format. */ + public cookTime: string | undefined; + + /** The length of time it takes to prepare the items to be used in instructions or a direction, in ISO 8601 duration + * format. */ + public prepTime: string | undefined; + + /** The timestamp of the recipe's creation date. */ + public dateCreated: string | undefined; + + /** The timestamp of the recipe's modification date. */ + public dateModified: string | undefined; + + /** The description of the recipe. */ + public description: string | undefined; + + /** Nutritional information about the recipe. */ + public nutrition: NutritionInformation | undefined; + + /** The list of ingredients for the recipe. */ + public recipeIngredient: string[]; + + /** The number of servings for the recipe */ + public recipeYield: number | undefined; + + /** The list of supplies needed for the recipe. */ + public supply: HowToSupply[]; + + /** The step-by-step instructions for the recipe. */ + public recipeInstructions: (HowToSection | HowToStep)[]; + + /** The tools required for the recipe. */ + public tool: HowToTool[]; + + /** The URLs associated with the recipe. In the current setup, should be a single URL, but let's already allow an + * array of URLs. */ + public url: string[]; + + constructor(identifier: string, name: string, options: RecipeOptions = {}) { + super(); + this['@context'] = 'https://schema.org'; + this.identifier = identifier; + this.name = name; + this.recipeCategory = options.recipeCategory || undefined; + this.description = options.description || undefined; + this.dateCreated = options.dateCreated || undefined; + this.dateModified = options.dateModified || undefined; + this.image = options.image ? asArray(options.image) : []; + this.imageUrl = options.imageUrl ? asArray(options.imageUrl) : []; + this.keywords = options.keywords ? asArray(options.keywords) : []; + this.cookTime = options.cookTime || undefined; + this.prepTime = options.prepTime || undefined; + this.totalTime = options.totalTime || undefined; + this.nutrition = options.nutrition || undefined; + this.recipeIngredient = options.recipeIngredient + ? asArray(options.recipeIngredient) + : []; + this.recipeYield = options.recipeYield; + this.supply = options.supply ? asArray(options.supply) : []; + this.recipeInstructions = options.recipeInstructions + ? asArray(options.recipeInstructions) + : []; + this.tool = options.tool ? asArray(options.tool) : []; + this.url = options.url ? asArray(options.url) : []; + } + + /** + * The unique identifier of the recipe object. This is equivalent to `identifier` in schema.org. + */ + get id(): string { + return this.identifier; + } + + /** + * Create a `Recipe` instance from a JSON string or object. + * @param {string | object} json - The JSON string or object. + * @returns {Recipe} - The created Recipe instance. + * @throws {Error} If the input JSON is invalid or missing required properties. + */ + static fromJSON(json: string | object): Recipe { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let jsonObj: any; + try { + jsonObj = typeof json === 'string' ? JSON.parse(json) : json; + } catch { + throw new JsonMappingException( + `Error mapping to "Recipe". Received invalid JSON: "${json}"`, + ); + } + + // ==================== + // Required properties + const identifier = mapString( + jsonObj.identifier, + "Recipe 'identifier'", + ) as NonNullable; + + const name = mapString( + jsonObj.name, + "Recipe 'name'", + ) as NonNullable; + + // ==================== + // Optional properties + const recipeCategory = mapStringOrStringArray( + jsonObj.recipeCategory, + "Recipe 'recipeCategory'", + true, + ); + const description = mapString( + jsonObj.description, + "Recipe 'description'", + true, + ); + const dateCreated = mapString( + jsonObj.dateCreated, + "Recipe 'dateCreated'", + true, + ); + const dateModified = mapString( + jsonObj.dateModified, + "Recipe 'dateModified'", + true, + ); + const image = mapStringOrStringArray( + jsonObj.image, + "Recipe 'image'", + true, + ); + const imageUrl = mapStringOrStringArray( + jsonObj.imageUrl, + "Recipe 'imageUrl'", + true, + ); + // The cookbook API returns the `keywords` property as a comma-separated list instead of an array + const keywords = mapStringOrStringArray( + jsonObj.keywords, + "Recipe 'keywords'", + true, + true, + ); + const cookTime = mapString(jsonObj.cookTime, "Recipe 'cookTime'", true); + const prepTime = mapString(jsonObj.prepTime, "Recipe 'prepTime'", true); + const totalTime = mapString( + jsonObj.totalTime, + "Recipe 'totalTime'", + true, + ); + const nutrition = jsonObj.nutrition + ? NutritionInformation.fromJSON(jsonObj.nutrition) + : undefined; + const recipeIngredient = mapStringOrStringArray( + jsonObj.recipeIngredient, + "Recipe 'recipeIngredient'", + true, + ); + const recipeYield = mapInteger( + jsonObj.recipeYield, + "Recipe 'recipeYield'", + true, + ); + + // Supported values for recipe instruction are: string, HowToSection, HowToStep or an array of those + const recipeInstructions = jsonObj.recipeInstructions + ? asArray(jsonObj.recipeInstructions).map((instruction) => { + try { + if (instruction['@type'] === 'HowToSection') { + return HowToSection.fromJSON(instruction); + } + return HowToStep.fromJSON(instruction); + } catch (ex) { + if (typeof instruction === 'string') { + // Did not receive a valid HowToStep or HowToSection object, treat as simple string. + return new HowToStep(instruction, []); + } + throw ex; + } + }) + : []; + // Supported values for supply are: string, HowToTool, or an array of those + const supply = jsonObj.supply + ? asArray(jsonObj.supply).map((suppl) => { + try { + return HowToSupply.fromJSON(suppl); + } catch (ex) { + if (typeof suppl === 'string') { + // Did not receive a valid HowToSupply, treat as simple string. + return new HowToSupply(suppl as string); + } + throw ex; + } + }) + : []; + // Supported values for tools are: string, HowToTool, or an array of those + const tool = jsonObj.tool + ? asArray(jsonObj.tool).map((t) => { + try { + return HowToTool.fromJSON(t); + } catch (ex) { + if (typeof t === 'string') { + // Did not receive a valid HowToTool, treat as simple string. + return new HowToTool(t as string); + } + throw ex; + } + }) + : []; + const url = mapStringOrStringArray(jsonObj.url, "Recipe 'url'", true); + + // ==================== + // Create and return the Recipe instance + return new Recipe(identifier, name, { + recipeCategory: recipeCategory || undefined, + description: description || undefined, + dateCreated: dateCreated || undefined, + dateModified: dateModified || undefined, + image: image || undefined, + imageUrl: imageUrl || undefined, + keywords: keywords || undefined, + cookTime: cookTime || undefined, + prepTime: prepTime || undefined, + totalTime: totalTime || undefined, + nutrition, + recipeIngredient: recipeIngredient || [], + recipeYield: recipeYield || undefined, + supply, + recipeInstructions, + tool, + url: url || [], + }); + } +} diff --git a/src/js/Models/schema/index.ts b/src/js/Models/schema/index.ts new file mode 100644 index 000000000..c8f36c54f --- /dev/null +++ b/src/js/Models/schema/index.ts @@ -0,0 +1,19 @@ +import HowToDirection from './HowToDirection'; +import HowToSection from './HowToSection'; +import HowToStep from './HowToStep'; +import HowToSupply from './HowToSupply'; +import HowToTip from './HowToTip'; +import HowToTool from './HowToTool'; +import QuantitativeValue from './QuantitativeValue'; +import Recipe from './Recipe'; + +export { + HowToDirection, + HowToSection, + HowToStep, + HowToSupply, + HowToTip, + HowToTool, + QuantitativeValue, + Recipe, +}; diff --git a/src/js/RecipeFilters/RecipeCategoriesFilter.js b/src/js/RecipeFilters/RecipeCategoriesFilter.js deleted file mode 100644 index 4c363d8bf..000000000 --- a/src/js/RecipeFilters/RecipeCategoriesFilter.js +++ /dev/null @@ -1,59 +0,0 @@ -import RecipeFilter from './RecipeFilter'; -import { AndOperator, OrOperator } from '../LogicOperators'; -import { normalize as normalizeString } from '../utils/string-utils'; - -/** - * Implementation for filtering recipes by categories. - * @extends RecipeFilter - */ -class RecipeCategoriesFilter extends RecipeFilter { - /** - * Constructor for RecipeCategoriesFilter. - * @param {string|string[]} categories - The categories to filter by. - * @param {BinaryOperator} operator - The binary operator for combining filter conditions. - */ - constructor(categories, operator = new OrOperator()) { - super(operator); - this.categories = Array.isArray(categories) - ? categories.map((category) => normalizeString(category)) - : [normalizeString(categories)]; - - // Ignore empty strings - this.categories = this.categories.filter((c) => c !== ''); - } - - /** - * Implementation of the filter method for RecipeCategoriesFilter. - * An empty filter list or only empty strings are ignored and evaluate to true. - * @param {Object} recipe - The recipe object to be filtered. - * @returns {boolean} True if the recipe passes the filter, false otherwise. - */ - filter(recipe) { - // If no filter is set, return all recipes - if (this.categories.length === 0) return true; - - if (!recipe.category) { - return false; - } - - const recipeCategories = Array.isArray(recipe.category) - ? recipe.category.map((category) => normalizeString(category)) - : [normalizeString(recipe.category)]; - - let result = this.operator instanceof AndOperator; - - for (const category of this.categories) { - const categoryMatch = recipeCategories.includes(category); - result = this.operator.apply(result, categoryMatch); - - // If using OrOperator and the result is already true, no need to continue checking - if (this.operator instanceof OrOperator && result) { - break; - } - } - - return result; - } -} - -export default RecipeCategoriesFilter; diff --git a/src/js/RecipeFilters/RecipeCategoriesFilter.ts b/src/js/RecipeFilters/RecipeCategoriesFilter.ts new file mode 100644 index 000000000..661698481 --- /dev/null +++ b/src/js/RecipeFilters/RecipeCategoriesFilter.ts @@ -0,0 +1,126 @@ +import { Recipe } from 'cookbook/js/Models/schema'; +import { + AndOperator, + BinaryOperator, + OrOperator, +} from 'cookbook/js/LogicOperators'; +import { normalize as normalizeString } from 'cookbook/js/utils/string-utils'; +import { asArray, asCleanedArray } from 'cookbook/js/helper'; +import { compareArrays } from 'cookbook/js/utils/comparison'; +import { simpleRemoveDuplicates } from 'cookbook/js/utils/removeDuplicates'; +import FilterType from 'cookbook/js/Enums/FilterType'; +import RecipeFilter from './RecipeFilter'; + +/** + * Implementation for filtering recipes by categories. + * @extends RecipeFilter + */ +class RecipeCategoriesFilter extends RecipeFilter { + /** List of categories used for filtering recipe. */ + categories: string | string[]; + + /** + * Constructor for RecipeCategoriesFilter. + * @param {string|string[]} categories - The categories to filter by. + * @param {BinaryOperator} operator - The binary operator for combining filter conditions. + */ + constructor( + categories: string | string[], + operator: BinaryOperator = new OrOperator(), + ) { + super(operator); + this.type = FilterType.CategoriesFilter; + this.searchLabel = 'cat'; + + this.categories = simpleRemoveDuplicates(asArray(categories)); + + // Ignore empty strings + this.categories = this.categories.filter((c) => c !== ''); + } + + /** + * Implementation of the filter method for RecipeCategoriesFilter. + * An empty filter list or only empty strings are ignored and evaluate to true. + * @param {Object} recipe - The recipe object to be filtered. + * @returns {boolean} True if the recipe passes the filter, false otherwise. + */ + filter(recipe: Recipe): boolean { + // If no filter is set, return all recipes + if (this.categories.length === 0) return true; + + if (!recipe.recipeCategory) { + return false; + } + + const recipeCategories = Array.isArray(recipe.recipeCategory) + ? recipe.recipeCategory.map((category) => normalizeString(category)) + : [normalizeString(recipe.recipeCategory)]; + + let result = this.operator instanceof AndOperator; + + for (const category of this.categories) { + const categoryMatch = recipeCategories.includes( + normalizeString(category), + ); + result = this.operator.apply(result, categoryMatch); + + // If using OrOperator and the result is already true, no need to continue checking + if (this.operator instanceof OrOperator && result) { + break; + } + } + + return result; + } + + /** + * Compares this filter with another filter for equality based on filtering criteria. + * @param {RecipeFilter} otherFilter - The filter to compare with. + * @returns {boolean} True if the filters are equivalent, false otherwise. + */ + equals(otherFilter: RecipeFilter): boolean { + // Check that otherFilter has same type + if ( + otherFilter.type !== this.type || + this.searchLabel !== otherFilter.searchLabel + ) { + return false; + } + + // Cast to object with these properties + const comparedFilter = < + { + isCommaSeparated: boolean; + categories: string | string[]; + } + >(otherFilter); + + // Type of operator only matters if there is more than one category set + if ( + this.categories.length > 1 && + this.operator.type !== otherFilter.operator.type + ) { + return false; + } + + // Create arrays for both filters' keywords parameters + const otherCategoriesArray = asCleanedArray(comparedFilter.categories); + const thisCategoriesArray = asCleanedArray(this.categories); + + // Compare properties + return compareArrays(thisCategoriesArray, otherCategoriesArray); + } + + /** + * Determines a string representation of the filter. + * @returns {string} String representation. + */ + toSearchString(): string { + return this.operator.generateStringRepresentationForMultipleOperandsWithLabel( + asArray(this.categories).map((kw) => `"${kw}"`), + 'cat', + ); + } +} + +export default RecipeCategoriesFilter; diff --git a/src/js/RecipeFilters/RecipeFilter.js b/src/js/RecipeFilters/RecipeFilter.js deleted file mode 100644 index bd71299a4..000000000 --- a/src/js/RecipeFilters/RecipeFilter.js +++ /dev/null @@ -1,32 +0,0 @@ -import { BinaryOperator } from '../LogicOperators'; - -/** - * Abstract class for a recipe filter. - * @abstract - */ -class RecipeFilter { - /** - * Constructor for the abstract class. - * @param {BinaryOperator} operator - The binary operator for combining filter conditions. - * @throws {TypeError} Invalid operator. - */ - constructor(operator) { - if (!(operator instanceof BinaryOperator)) { - throw new TypeError('Invalid operator'); - } - this.operator = operator; - } - - /** - * Abstract method to be implemented by subclasses. - * @param {Object} recipe - The recipe object to be filtered. - * @throws {Error} Method 'filter' must be implemented by subclasses. - */ - // eslint-disable-next-line class-methods-use-this,no-unused-vars,@typescript-eslint/no-unused-vars - filter(recipe) { - throw new Error("Method 'filter' must be implemented by subclasses"); - } -} - -// module.exports = RecipeFilter; -export default RecipeFilter; diff --git a/src/js/RecipeFilters/RecipeFilter.ts b/src/js/RecipeFilters/RecipeFilter.ts new file mode 100644 index 000000000..8c387df85 --- /dev/null +++ b/src/js/RecipeFilters/RecipeFilter.ts @@ -0,0 +1,71 @@ +import { BinaryOperator } from 'cookbook/js/LogicOperators'; +import { Recipe } from 'cookbook/js/Models/schema'; +import FilterType from 'cookbook/js/Enums/FilterType'; + +/** + * Abstract class for a recipe filter. + * @abstract + */ +class RecipeFilter { + /** Type of the RecipeFilter. Can be used to, e.g., compare filters. */ + type: FilterType; + + operator: BinaryOperator; + + /** Label to be used when filter is converted to string representation */ + searchLabel: string; + + /** + * Constructor for the abstract class. + * @param {BinaryOperator} operator - The binary operator for combining filter conditions. + * @throws {TypeError} Invalid operator. + */ + constructor(operator: BinaryOperator) { + if (!(operator instanceof BinaryOperator)) { + throw new TypeError('Invalid operator'); + } + this.operator = operator; + } + + /** + * Abstract method to be overridden by subclasses. + * Compares this filter with another filter for equality based on filtering criteria. + * @param {RecipeFilter} otherFilter - The filter to compare with. + * @returns {boolean} True if the filters are equivalent, false otherwise. + */ + equals(otherFilter: RecipeFilter): boolean { + // This is a simplistic implementation. You'll need to customize this + // based on the properties of your filters. + return ( + this.type === otherFilter.type && + this.constructor === otherFilter.constructor && + this.operator === otherFilter.operator + ); + } + + /** + * Abstract method to be implemented by subclasses. + * @param {Recipe} recipe - The recipe object to be filtered. + * @throws {Error} Method 'filter' must be implemented by subclasses. + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars,@typescript-eslint/no-unused-vars + filter(recipe: Recipe) { + throw new Error("Method 'filter' must be implemented by subclasses"); + } + + /** + * Abstract method to be implemented by subclasses. + * Determines a string representation of the filter. + * @throws {Error} Method 'filter' must be implemented by subclasses. + * @returns {string} String representation. + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars,@typescript-eslint/no-unused-vars + toSearchString(): string { + throw new Error( + "Method 'toSearchString' must be implemented by subclasses", + ); + } +} + +// module.exports = RecipeFilter; +export default RecipeFilter; diff --git a/src/js/RecipeFilters/RecipeKeywordsFilter.js b/src/js/RecipeFilters/RecipeKeywordsFilter.js deleted file mode 100644 index 679fcfbc1..000000000 --- a/src/js/RecipeFilters/RecipeKeywordsFilter.js +++ /dev/null @@ -1,91 +0,0 @@ -import RecipeFilter from './RecipeFilter'; -import { AndOperator, OrOperator } from '../LogicOperators'; -import { normalize as normalizeString } from '../utils/string-utils'; - -/** - * Implementation for filtering recipes by keywords. - * @extends RecipeFilter - */ -class RecipeKeywordsFilter extends RecipeFilter { - /** - * Constructor for RecipeKeywordsFilter. - * @param {string|string[]} keywords - The keywords to filter by. - * @param {boolean} isCommaSeparated - If the keywords field of the recipe should be handled as a comma-separated list. - * @param {BinaryOperator} operator - The binary operator for combining filter conditions. - */ - constructor( - keywords, - operator = new AndOperator(), - isCommaSeparated = false, - ) { - super(operator); - this.keywords = Array.isArray(keywords) - ? keywords.map((keyword) => normalizeString(keyword)) - : [normalizeString(keywords)]; - - this.isCommaSeparated = isCommaSeparated; - - // Ignore empty strings - this.keywords = this.keywords.filter((k) => k !== ''); - } - - /** - * Returns a normalized list of keywords attached to the recipe. Takes into account if the isCommaSeparated property - * is set on this class. - * @param {Object} recipe - The recipe object whose keywords are to be filtered. - * @returns {string[]} List of normalized keywords. - */ - getNormalizedKeywords(recipe) { - let keywords; - - if (this.isCommaSeparated) { - if (Array.isArray(recipe.keywords)) { - keywords = recipe.keywords - .map((keyword) => keyword.split(',')) - .flat(); - } else { - keywords = recipe.keywords.split(','); - } - keywords = keywords.map((keyword) => normalizeString(keyword)); - } else { - keywords = Array.isArray(recipe.keywords) - ? recipe.keywords.map((keyword) => normalizeString(keyword)) - : [normalizeString(recipe.keywords)]; - } - keywords = keywords.filter((k) => k !== ''); - return keywords; - } - - /** - * Implementation of the filter method for RecipeKeywordsFilter. - * An empty filter list or only empty strings are ignored and evaluate to true. - * @param {Object} recipe - The recipe object to be filtered. - * @returns {boolean} True if the recipe passes the filter, false otherwise. - */ - filter(recipe) { - // If no filter is set, return all recipes - if (this.keywords.length === 0) return true; - - if (!recipe.keywords) { - return false; - } - - const recipeKeywords = this.getNormalizedKeywords(recipe); - - let result = this.operator instanceof AndOperator; - - for (const keyword of this.keywords) { - const keywordMatch = recipeKeywords.includes(keyword); - result = this.operator.apply(result, keywordMatch); - - // If using OrOperator and the result is already true, no need to continue checking - if (this.operator instanceof OrOperator && result) { - break; - } - } - - return result; - } -} - -export default RecipeKeywordsFilter; diff --git a/src/js/RecipeFilters/RecipeKeywordsFilter.ts b/src/js/RecipeFilters/RecipeKeywordsFilter.ts new file mode 100644 index 000000000..0b5fe8eb6 --- /dev/null +++ b/src/js/RecipeFilters/RecipeKeywordsFilter.ts @@ -0,0 +1,161 @@ +import { asArray, asCleanedArray } from 'cookbook/js/helper'; +import { Recipe } from 'cookbook/js/Models/schema'; +import { normalize as normalizeString } from 'cookbook/js/utils/string-utils'; +import { + AndOperator, + BinaryOperator, + OrOperator, +} from 'cookbook/js/LogicOperators'; +import { compareArrays } from 'cookbook/js/utils/comparison'; +import { simpleRemoveDuplicates } from 'cookbook/js/utils/removeDuplicates'; +import FilterType from 'cookbook/js/Enums/FilterType'; +import RecipeFilter from './RecipeFilter'; + +/** + * Implementation for filtering recipes by keywords. + * @extends RecipeFilter + */ +class RecipeKeywordsFilter extends RecipeFilter { + /** List of keywords used for filtering recipe. */ + keywords: string | string[]; + + isCommaSeparated: boolean; + + /** + * Constructor for RecipeKeywordsFilter. + * @param {string|string[]} keywords - The keywords to filter by. + * @param {boolean} isCommaSeparated - If the keywords field of the recipe should be handled as a comma-separated list. + * @param {BinaryOperator} operator - The binary operator for combining filter conditions. + */ + constructor( + keywords: string | string[], + operator: BinaryOperator = new AndOperator(), + isCommaSeparated: boolean = false, + ) { + super(operator); + this.type = FilterType.KeywordsFilter; + this.searchLabel = 'tag'; + + this.keywords = simpleRemoveDuplicates(asArray(keywords)); + this.isCommaSeparated = isCommaSeparated; + + // Ignore empty strings + this.keywords = this.keywords.filter((k) => k !== ''); + } + + /** + * Compares this filter with another filter for equality based on filtering criteria. + * @param {RecipeFilter} otherFilter - The filter to compare with. + * @returns {boolean} True if the filters are equivalent, false otherwise. + */ + equals(otherFilter: RecipeFilter): boolean { + // Check that otherFilter has same type + if ( + otherFilter.type !== this.type || + this.searchLabel !== otherFilter.searchLabel + ) { + return false; + } + + // Cast to object with these properties + const comparedFilter = < + { + isCommaSeparated: boolean; + keywords: string | string[]; + } + >(otherFilter); + + // Type of operator only matters if there is more than one keyword set + if ( + this.keywords.length > 1 && + this.operator.type !== otherFilter.operator.type + ) { + return false; + } + + // Create arrays for both filters' keywords parameters + const otherKeywordsArray = asCleanedArray(comparedFilter.keywords); + const thisKeywordsArray = asCleanedArray(this.keywords); + + // Compare properties + return ( + this.isCommaSeparated === comparedFilter.isCommaSeparated && + compareArrays(thisKeywordsArray, otherKeywordsArray) + ); + } + + /** + * Returns a normalized list of keywords attached to the recipe. Takes into account if the isCommaSeparated property + * is set on this class. + * @param {Object} recipe - The recipe object whose keywords are to be filtered. + * @returns {string[]} List of normalized keywords. + */ + getNormalizedKeywords(recipe: Recipe): string[] { + let keywords: string[]; + + if (this.isCommaSeparated) { + if (!Array.isArray(recipe.keywords)) { + throw new Error( + `Recipe ${recipe.name}: 'keywords' property has unsupported type.`, + ); + } + keywords = recipe.keywords + .map((keyword) => keyword.split(',')) + .flat(); + + keywords = keywords.map((keyword) => normalizeString(keyword)); + } else { + keywords = Array.isArray(recipe.keywords) + ? recipe.keywords.map((keyword) => normalizeString(keyword)) + : [normalizeString(recipe.keywords)]; + } + keywords = keywords.filter((k) => k !== ''); + return keywords; + } + + /** + * Implementation of the filter method for RecipeKeywordsFilter. + * An empty filter list or only empty strings are ignored and evaluate to true. + * @param {Recipe} recipe - The recipe object to be filtered. + * @returns {boolean} True if the recipe passes the filter, false otherwise. + */ + filter(recipe: Recipe): boolean { + // If no filter is set, return all recipes + if (this.keywords.length === 0) return true; + + if (!recipe.keywords) { + return false; + } + + const recipeKeywords = this.getNormalizedKeywords(recipe); + + let result = this.operator instanceof AndOperator; + + for (const keyword of this.keywords) { + const keywordMatch = recipeKeywords.includes( + normalizeString(keyword), + ); + result = this.operator.apply(result, keywordMatch); + + // If using OrOperator and the result is already true, no need to continue checking + if (this.operator instanceof OrOperator && result) { + break; + } + } + + return result; + } + + /** + * Determines a string representation of the filter. + * @returns {string} String representation. + */ + toSearchString(): string { + return this.operator.generateStringRepresentationForMultipleOperandsWithLabel( + asArray(this.keywords).map((kw) => `"${kw}"`), + 'tag', + ); + } +} + +export default RecipeKeywordsFilter; diff --git a/src/js/RecipeFilters/RecipeNamesFilter.js b/src/js/RecipeFilters/RecipeNamesFilter.js deleted file mode 100644 index 4987bdb13..000000000 --- a/src/js/RecipeFilters/RecipeNamesFilter.js +++ /dev/null @@ -1,93 +0,0 @@ -import Fuse from 'fuse.js'; -import RecipeFilter from './RecipeFilter'; -import { AndOperator, OrOperator } from '../LogicOperators'; -import { normalize as normalizeString } from '../utils/string-utils'; - -/** - * Implementation for filtering recipes by names. - * @extends RecipeFilter - */ -class RecipeNamesFilter extends RecipeFilter { - /** - * Constructor for RecipeNamesFilter. - * @param {string|string[]} names - The names to filter by. - * @param {BinaryOperator} operator - The binary operator for combining filter conditions. - * @param {('exact'|'fuzzy'|'matchSubstring')} filterMode - The mode how to filter the recipe names. Exact match; search in substrings of the name; and a fuzzy matching; - */ - constructor(names, operator = new OrOperator(), filterMode = 'exact') { - super(operator); - this.names = Array.isArray(names) - ? names.map((name) => normalizeString(name)) - : [normalizeString(names)]; - // Ignore empty strings - this.names = this.names.filter((n) => n !== ''); - this.filterMode = filterMode; - } - - // eslint-disable-next-line class-methods-use-this - get fuseOptions() { - return { - isCaseSensitive: false, - shouldSort: true, - minMatchCharLength: 1, - threshold: 0.45, - distance: 100, - // Maybe later - // useExtendedSearch: false, - }; - } - - fuseSearch(name) { - return this.fuse.search(name).length > 0; - } - - /** - * Implementation of the filter method for RecipeNamesFilter. - * An empty filter list or only empty strings are ignored and evaluate to true. - * @param {Object} recipe - The recipe object to be filtered. - * @returns {boolean} True if the recipe passes the filter, false otherwise. - */ - filter(recipe) { - // If no filter is set, return all recipes - if (this.names.length === 0) return true; - - if (!recipe.name) { - return false; - } - - const recipeNames = Array.isArray(recipe.name) - ? recipe.name.map((name) => normalizeString(name)) - : [normalizeString(recipe.name)]; - - let result = this.operator instanceof AndOperator; - - // Setup fuzzy search - if (this.filterMode === 'fuzzy') { - this.fuse = new Fuse(recipeNames, this.fuseOptions); - } - - for (const name of this.names) { - let nameMatch; - - // If the filter value should be searched in substrings of the recipe names - if (this.filterMode === 'matchSubstring') { - nameMatch = recipeNames.some((n) => n.includes(name)); - } else if (this.filterMode === 'fuzzy') { - nameMatch = this.fuseSearch(name); - // this.fuse.search(name); - } else { - nameMatch = recipeNames.includes(name); - } - result = this.operator.apply(result, nameMatch); - - // If using OrOperator and the result is already true, no need to continue checking - if (this.operator instanceof OrOperator && result) { - break; - } - } - - return result; - } -} - -export default RecipeNamesFilter; diff --git a/src/js/RecipeFilters/RecipeNamesFilter.ts b/src/js/RecipeFilters/RecipeNamesFilter.ts new file mode 100644 index 000000000..87ce662fe --- /dev/null +++ b/src/js/RecipeFilters/RecipeNamesFilter.ts @@ -0,0 +1,170 @@ +import Fuse from 'fuse.js'; +import { + AndOperator, + BinaryOperator, + OrOperator, +} from 'cookbook/js/LogicOperators'; +import { asArray, asCleanedArray } from 'cookbook/js/helper'; +import { Recipe } from 'cookbook/js/Models/schema'; +import { normalize as normalizeString } from 'cookbook/js/utils/string-utils'; +import { compareArrays } from 'cookbook/js/utils/comparison'; +import SearchMode from 'cookbook/js/Enums/SearchMode'; +import { simpleRemoveDuplicates } from 'cookbook/js/utils/removeDuplicates'; +import FilterType from 'cookbook/js/Enums/FilterType'; +import RecipeFilter from './RecipeFilter'; + +/** + * Implementation for filtering recipes by names. + * @extends RecipeFilter + */ +class RecipeNamesFilter extends RecipeFilter { + /** List of names used for filtering recipe. */ + names: string | string[]; + + /** Mode used for filtering, allows to enable fuzzy search. */ + filterMode: SearchMode; + + /** The Fuse.js instance used for filtering. */ + fuse: Fuse; + + /** + * Constructor for RecipeNamesFilter. + * @param {string|string[]} names - The names to filter by. + * @param {BinaryOperator} operator - The binary operator for combining filter conditions. + * @param {SearchMode} filterMode - The mode how to filter the recipe names. Exact match; search in substrings of the name; and a fuzzy matching; + */ + constructor( + names: string | string[], + operator: BinaryOperator = new OrOperator(), + filterMode: SearchMode = SearchMode.Exact, + ) { + super(operator); + this.type = FilterType.NamesFilter; + this.searchLabel = 'name'; + this.filterMode = filterMode; + + this.names = simpleRemoveDuplicates(asArray(names)); + + // Ignore empty strings + this.names = this.names.filter((n) => n !== ''); + } + + // eslint-disable-next-line class-methods-use-this + get fuseOptions() { + return { + isCaseSensitive: false, + shouldSort: true, + minMatchCharLength: 1, + threshold: 0.45, + distance: 100, + // Maybe later + // useExtendedSearch: false, + }; + } + + fuseSearch(name: string): boolean { + return this.fuse.search(name).length > 0; + } + + /** + * Implementation of the filter method for RecipeNamesFilter. + * An empty filter list or only empty strings are ignored and evaluate to true. + * @param {Object} recipe - The recipe object to be filtered. + * @returns {boolean} True if the recipe passes the filter, false otherwise. + */ + filter(recipe: Recipe): boolean { + // If no filter is set, return all recipes + if (this.names.length === 0) return true; + + if (!recipe.name) { + return false; + } + + const recipeNames = asArray(recipe.name).map((name) => + normalizeString(name), + ); + + let result = this.operator instanceof AndOperator; + + // Setup fuzzy search + if (this.filterMode === SearchMode.Fuzzy) { + this.fuse = new Fuse(recipeNames, this.fuseOptions); + } + + for (const name of this.names) { + let nameMatch: boolean; + const normalizedName = normalizeString(name); + + // If the filter value should be searched in substrings of the recipe names + if (this.filterMode === SearchMode.MatchSubstring) { + nameMatch = recipeNames.some((n) => n.includes(normalizedName)); + } else if (this.filterMode === SearchMode.Fuzzy) { + nameMatch = this.fuseSearch(normalizedName); + } else { + nameMatch = recipeNames.includes(normalizedName); + } + result = this.operator.apply(result, nameMatch); + + // If using OrOperator and the result is already true, no need to continue checking + if (this.operator instanceof OrOperator && result) { + break; + } + } + + return result; + } + + /** + * Determines a string representation of the filter. + * @returns {string} String representation. + */ + toSearchString(): string { + return this.operator.generateStringRepresentationForMultipleOperandsWithLabel( + asArray(this.names).map((kw) => `"${kw}"`), + 'name', + ); + } + + /** + * Compares this filter with another filter for equality based on filtering criteria. + * @param {RecipeFilter} otherFilter - The filter to compare with. + * @returns {boolean} True if the filters are equivalent, false otherwise. + */ + equals(otherFilter: RecipeFilter): boolean { + // Check that otherFilter has same type + if ( + otherFilter.type !== this.type || + this.searchLabel !== otherFilter.searchLabel + ) { + return false; + } + + // Cast to object with these properties + const comparedFilter = < + { + filterMode: SearchMode; + names: string | string[]; + } + >(otherFilter); + + // Type of operator only matters if there is more than one name set + if ( + this.names.length > 1 && + this.operator.type !== otherFilter.operator.type + ) { + return false; + } + + // Create arrays for both filters' `names` parameters + const otherNamesArray = asCleanedArray(comparedFilter.names); + const thisNamesArray = asCleanedArray(this.names); + + // Compare properties + return ( + this.filterMode === comparedFilter.filterMode && + compareArrays(thisNamesArray, otherNamesArray) + ); + } +} + +export default RecipeNamesFilter; diff --git a/src/js/RecipeFilters/RecipeSearchFilter.ts b/src/js/RecipeFilters/RecipeSearchFilter.ts new file mode 100644 index 000000000..4adf48138 --- /dev/null +++ b/src/js/RecipeFilters/RecipeSearchFilter.ts @@ -0,0 +1,175 @@ +import Fuse from 'fuse.js'; +import { + AndOperator, + BinaryOperator, + OrOperator, +} from 'cookbook/js/LogicOperators'; +import { asArray, asCleanedArray } from 'cookbook/js/helper'; +import { Recipe } from 'cookbook/js/Models/schema'; +import { normalize as normalizeString } from 'cookbook/js/utils/string-utils'; +import { compareArrays } from 'cookbook/js/utils/comparison'; +import SearchMode from 'cookbook/js/Enums/SearchMode'; +import { simpleRemoveDuplicates } from 'cookbook/js/utils/removeDuplicates'; +import FilterType from 'cookbook/js/Enums/FilterType'; +import RecipeFilter from './RecipeFilter'; + +/** + * Implementation for filtering recipes by names. + * @extends RecipeFilter + */ +class RecipeSearchFilter extends RecipeFilter { + /** List of queries used for filtering recipe. */ + queries: string[]; + + /** Mode used for filtering, allows to enable fuzzy search. */ + filterMode: SearchMode; + + /** The Fuse.js instance used for filtering. */ + fuse: Fuse; + + /** + * Constructor for RecipeNamesFilter. + * @param {string|string[]} queries - The query strings to filter by. + * @param {BinaryOperator} operator - The binary operator for combining filter conditions. + * @param {SearchMode} filterMode - The mode how to filter the recipes. Exact match; search + * in substrings of the name; and a fuzzy matching; + */ + constructor( + queries: string | string[], + operator: BinaryOperator = new OrOperator(), + filterMode: SearchMode = SearchMode.MatchSubstring, + ) { + super(operator); + this.type = FilterType.SearchFilter; + this.searchLabel = ''; + this.filterMode = filterMode; + + this.queries = simpleRemoveDuplicates(asArray(queries)); + + // Ignore empty strings + this.queries = this.queries.filter((n) => n !== ''); + } + + // eslint-disable-next-line class-methods-use-this + get fuseOptions() { + return { + isCaseSensitive: false, + shouldSort: true, + minMatchCharLength: 1, + threshold: 0.45, + distance: 100, + // Maybe later + // useExtendedSearch: false, + }; + } + + fuseSearch(name: string): boolean { + return this.fuse.search(name).length > 0; + } + + /** + * Implementation of the filter method for RecipeNamesFilter. + * An empty filter list or only empty strings are ignored and evaluate to true. + * @param {Object} recipe - The recipe object to be filtered. + * @returns {boolean} True if the recipe passes the filter, false otherwise. + */ + filter(recipe: Recipe): boolean { + // If no filter is set, return all recipes + if (this.queries.length === 0) return true; + + // return true; + // TODO implement local filtering in other properties than `name`. Currently only the name is searched + + // Only search in name for now + if (!recipe.name) { + return false; + } + + const recipeNames = asArray(recipe.name).map((name) => + normalizeString(name), + ); + + let result = this.operator instanceof AndOperator; + + // Setup fuzzy search + if (this.filterMode === SearchMode.Fuzzy) { + this.fuse = new Fuse(recipeNames, this.fuseOptions); + } + + for (const name of this.queries) { + let nameMatch: boolean; + const normalizedName = normalizeString(name); + + // If the filter value should be searched in substrings of the recipe names + if (this.filterMode === SearchMode.MatchSubstring) { + nameMatch = recipeNames.some((n) => n.includes(normalizedName)); + } else if (this.filterMode === SearchMode.Fuzzy) { + nameMatch = this.fuseSearch(name); + } else { + nameMatch = recipeNames.includes(normalizedName); + } + result = this.operator.apply(result, nameMatch); + + // If using OrOperator and the result is already true, no need to continue checking + if (this.operator instanceof OrOperator && result) { + break; + } + } + + return result; + } + + /** + * Determines a string representation of the filter. + * @returns {string} String representation. + */ + toSearchString(): string { + return this.operator.generateStringRepresentationForMultipleOperandsWithLabel( + this.queries, + '', + ); + } + + /** + * Compares this filter with another filter for equality based on filtering criteria. + * @param {RecipeFilter} otherFilter - The filter to compare with. + * @returns {boolean} True if the filters are equivalent, false otherwise. + */ + equals(otherFilter: RecipeFilter): boolean { + // Check that otherFilter has same type + if ( + otherFilter.type !== this.type || + this.searchLabel !== otherFilter.searchLabel + ) { + return false; + } + + // Cast to object with these properties + const comparedFilter = < + { + filterMode: SearchMode; + queries: string[]; + } + >(otherFilter); + + // Type of operator only matters if there is more than one query set + if ( + this.queries.length > 1 && + this.operator.type !== otherFilter.operator.type + ) { + return false; + } + + // Create arrays for both filters' `queries` parameters + const otherQueriesArray = asCleanedArray(comparedFilter.queries); + const thisQueriesArray = asCleanedArray(this.queries); + + // Compare properties + return ( + this.filterMode === comparedFilter.filterMode && + compareArrays(thisQueriesArray, otherQueriesArray) + ); + } +} + +export default RecipeSearchFilter; diff --git a/src/js/RecipeFilters/index.js b/src/js/RecipeFilters/index.js deleted file mode 100644 index c9032ec52..000000000 --- a/src/js/RecipeFilters/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import RecipeCategoriesFilter from './RecipeCategoriesFilter'; -import RecipeKeywordsFilter from './RecipeKeywordsFilter'; -import RecipeNamesFilter from './RecipeNamesFilter'; - -export { RecipeCategoriesFilter, RecipeKeywordsFilter, RecipeNamesFilter }; diff --git a/src/js/RecipeFilters/index.ts b/src/js/RecipeFilters/index.ts new file mode 100644 index 000000000..fb94dc7c7 --- /dev/null +++ b/src/js/RecipeFilters/index.ts @@ -0,0 +1,13 @@ +import RecipeFilter from './RecipeFilter'; +import RecipeCategoriesFilter from './RecipeCategoriesFilter'; +import RecipeKeywordsFilter from './RecipeKeywordsFilter'; +import RecipeNamesFilter from './RecipeNamesFilter'; +import RecipeSearchFilter from './RecipeSearchFilter'; + +export { + RecipeFilter, + RecipeCategoriesFilter, + RecipeKeywordsFilter, + RecipeNamesFilter, + RecipeSearchFilter, +}; diff --git a/src/js/Repositories/RecipeRepository.ts b/src/js/Repositories/RecipeRepository.ts new file mode 100644 index 000000000..78630d5a4 --- /dev/null +++ b/src/js/Repositories/RecipeRepository.ts @@ -0,0 +1,179 @@ +import axios from '@nextcloud/axios'; +import Vue from 'vue'; +import { generateUrl } from '@nextcloud/router'; +import { Recipe } from 'cookbook/js/Models/schema'; +import { + mapApiRecipeByCategoryResponseToRecipe, + mapApiRecipeResponseToRecipe, + mapRecipeToApiRecipe, +} from 'cookbook/js/Api/Mappers/RecipeMappers'; +import { mapString } from 'cookbook/js/utils/jsonMapper'; + +const baseUrl = `${generateUrl('apps/cookbook')}/webapp`; + +// Add a debug log for every request +axios.interceptors.request.use((config) => { + Vue.$log.debug( + `[axios] Making "${config.method}" request to "${config.url}"`, + config, + ); + const contentType = config.headers['Content-Type']; + if ( + contentType && + typeof contentType === 'string' && + !['application/json', 'text/json'].includes(contentType) + ) { + Vue.$log.warn( + `[axios] Request to "${config.url}" is using Content-Type "${contentType}", not JSON`, + ); + } + return config; +}); + +axios.interceptors.response.use( + (response) => { + Vue.$log.debug('[axios] Received response', response); + return response; + }, + (error) => { + Vue.$log.warn('[axios] Received error', error); + return Promise.reject(error); + }, +); + +axios.defaults.headers.common.Accept = 'application/json'; + +class RecipeRepository { + // eslint-disable-next-line class-methods-use-this + async getRecipes(): Promise { + try { + const response = await axios.get(`${baseUrl}/recipes`); + return response.data.map(mapApiRecipeResponseToRecipe); + } catch (error) { + Vue.$log.error(error); + throw new Error('Failed to fetch recipes'); + } + } + + // eslint-disable-next-line class-methods-use-this + async getRecipeById(id: string): Promise { + try { + const response = await axios.get(`${baseUrl}/recipes/${id}`); + return mapApiRecipeResponseToRecipe(response.data); + } catch (error) { + Vue.$log.error(error); + throw new Error(`Failed to fetch recipe with ID ${id}`); + } + } + + // eslint-disable-next-line class-methods-use-this + async createRecipe(recipe: Recipe): Promise { + try { + const recipeDTO = mapRecipeToApiRecipe(recipe); + const response = await axios.post(`${baseUrl}/recipes`, recipeDTO); + return mapApiRecipeResponseToRecipe(response.data); + } catch (error) { + Vue.$log.error(error); + throw new Error('Failed to create recipe'); + } + } + + // eslint-disable-next-line class-methods-use-this + async updateRecipe(id: string, recipe: Recipe): Promise { + try { + const recipeDTO = mapRecipeToApiRecipe(recipe); + const response = await axios.put( + `${baseUrl}/recipes/${id}`, + recipeDTO, + ); + return mapString(response.data) as string; + } catch (error) { + Vue.$log.error(error); + throw new Error(`Failed to update recipe with ID ${id}`); + } + } + + // eslint-disable-next-line class-methods-use-this + async deleteRecipe(id: string): Promise { + try { + await axios.delete(`${baseUrl}/recipes/${id}`); + } catch (error) { + Vue.$log.error(error); + throw new Error(`Failed to delete recipe with ID ${id}`); + } + } + + // eslint-disable-next-line class-methods-use-this + async getRecipesByCategory(categoryName: string): Promise { + let response; + + // Fetch response + try { + response = await axios.get(`${baseUrl}/category/${categoryName}`); + } catch (error) { + Vue.$log.error(error); + throw new Error( + `Failed to fetch recipes of category ${categoryName}`, + ); + } + // Map response + try { + return response?.data.map(mapApiRecipeByCategoryResponseToRecipe); + // return response !== null + // ? response.data.map(mapApiRecipeByCategoryResponseToRecipe) + // : null; + } catch (error) { + Vue.$log.error(error); + throw new Error( + `Failed to fetch recipes of category ${categoryName}. Error mapping recipe objects.`, + ); + } + } + + // eslint-disable-next-line class-methods-use-this + async getRecipesByTag(tags: string): Promise { + let response; + try { + response = await axios.get(`${baseUrl}/tags/${tags}`); + } catch (error) { + Vue.$log.error(error); + throw new Error(`Failed to fetch recipes with tags ${tags}`); + } + + try { + return response?.data.map(mapApiRecipeResponseToRecipe); + } catch (error) { + Vue.$log.error(error); + throw new Error( + `Failed to fetch recipes with tags ${tags}. Error mapping recipe objects.`, + ); + } + } + + // eslint-disable-next-line class-methods-use-this + async searchRecipes(search: string): Promise { + try { + const response = await axios.get(`${baseUrl}/search/${search}`); + return response.data.map(mapApiRecipeResponseToRecipe); + } catch (error) { + Vue.$log.error(error); + throw new Error(`Failed to search recipes with query ${search}`); + } + } + + // eslint-disable-next-line class-methods-use-this + async importRecipe(url: string): Promise { + try { + const response = await axios.post( + `${baseUrl}/import`, + `url=${url}`, + ); + return mapApiRecipeResponseToRecipe(response.data); + } catch (error) { + Vue.$log.error(error); + throw new Error(`Failed to import recipe from ${url}`); + } + } +} + +export default RecipeRepository; diff --git a/src/js/Visitors/SchemaOrg/SupplyCollector.ts b/src/js/Visitors/SchemaOrg/SupplyCollector.ts new file mode 100644 index 000000000..dbac0c560 --- /dev/null +++ b/src/js/Visitors/SchemaOrg/SupplyCollector.ts @@ -0,0 +1,67 @@ +import type { + HowToDirection, + HowToSection, + HowToStep, + HowToSupply, + HowToTip, +} from 'cookbook/js/Models/schema'; +import { ISchemaOrgVisitor } from 'cookbook/js/Interfaces/Visitors/ISchemaOrgVisitor'; + +/** + * Visitor implementation to collect `HowToSupply` items. + */ +export default class SupplyCollector implements ISchemaOrgVisitor { + /** + * Local list of collected supplies. + * @private + */ + private supplies: HowToSupply[] = []; + + /** + * Visits a HowToDirection element and extracts supplies from its `supply` property. + * @param {HowToDirection} direction - The HowToDirection element to visit. + */ + visitHowToDirection(direction: HowToDirection) { + // Check if the direction contains a supply and collect it + this.supplies.push(...direction.supply); + } + + /** + * Visits a HowToSection element and recursively visits its child elements. + * @param {HowToSection} section - The HowToSection element to visit. + */ + visitHowToSection(section: HowToSection) { + for (const element of section.itemListElement) { + element.accept(this); + } + } + + /** + * Visits a HowToStep element and collects supplies from its directions. + * @param {HowToStep} step - The HowToStep element to visit. + */ + visitHowToStep(step: HowToStep) { + if (step.itemListElement) { + for (const direction of step.itemListElement) { + direction.accept(this); + } + } + } + + /** + * Visits a HowToStep element and collects supplies from its directions. + * @param {HowToTip} tip - The HowToStep element to visit. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars,class-methods-use-this + visitHowToTip(tip: HowToTip) { + // Nothing to do for tip + } + + /** + * Gets the collected supplies. + * @returns {HowToSupply[]} - The array of collected supplies. + */ + getSupplies(): HowToSupply[] { + return this.supplies; + } +} diff --git a/src/js/Visitors/SchemaOrg/ToolsCollector.ts b/src/js/Visitors/SchemaOrg/ToolsCollector.ts new file mode 100644 index 000000000..80b89622d --- /dev/null +++ b/src/js/Visitors/SchemaOrg/ToolsCollector.ts @@ -0,0 +1,67 @@ +import type { + HowToDirection, + HowToSection, + HowToStep, + HowToTool, + HowToTip, +} from 'cookbook/js/Models/schema'; +import { ISchemaOrgVisitor } from 'cookbook/js/Interfaces/Visitors/ISchemaOrgVisitor'; + +/** + * Visitor implementation to collect `HowToTool` items. + */ +export default class ToolsCollector implements ISchemaOrgVisitor { + /** + * Local list of collected tools. + * @private + */ + private tools: HowToTool[] = []; + + /** + * Visits a HowToDirection element and extracts tools from its `tool` property. + * @param {HowToDirection} direction - The HowToDirection element to visit. + */ + visitHowToDirection(direction: HowToDirection) { + // Check if the direction contains a tool and collect it + this.tools.push(...direction.tool); + } + + /** + * Visits a HowToSection element and recursively visits its child elements. + * @param {HowToSection} section - The HowToSection element to visit. + */ + visitHowToSection(section: HowToSection) { + for (const element of section.itemListElement) { + element.accept(this); + } + } + + /** + * Visits a HowToStep element and collects tools from its directions. + * @param {HowToStep} step - The HowToStep element to visit. + */ + visitHowToStep(step: HowToStep) { + if (step.itemListElement) { + for (const direction of step.itemListElement) { + direction.accept(this); + } + } + } + + /** + * Visits a HowToStep element and collects tools from its directions. + * @param {HowToTip} tip - The HowToStep element to visit. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars,class-methods-use-this + visitHowToTip(tip: HowToTip) { + // Nothing to do for tip + } + + /** + * Gets the collected tools. + * @returns {HowToTool[]} - The array of collected tools. + */ + getTools(): HowToTool[] { + return this.tools; + } +} diff --git a/src/js/api-interface.js b/src/js/api-interface.js deleted file mode 100644 index 9150bd61f..000000000 --- a/src/js/api-interface.js +++ /dev/null @@ -1,150 +0,0 @@ -import Vue from 'vue'; -import axios from '@nextcloud/axios'; - -import { generateUrl } from '@nextcloud/router'; - -const instance = axios.create(); - -const baseUrl = `${generateUrl('apps/cookbook')}/webapp`; - -// Add a debug log for every request -instance.interceptors.request.use((config) => { - Vue.$log.debug( - `[axios] Making "${config.method}" request to "${config.url}"`, - config, - ); - const contentType = config.headers['Content-Type']; - if ( - contentType && - !['application/json', 'text/json'].includes(contentType) - ) { - Vue.$log.warn( - `[axios] Request to "${config.url}" is using Content-Type "${contentType}", not JSON`, - ); - } - return config; -}); - -instance.interceptors.response.use( - (response) => { - Vue.$log.debug('[axios] Received response', response); - return response; - }, - (error) => { - Vue.$log.warn('[axios] Received error', error); - return Promise.reject(error); - }, -); - -axios.defaults.headers.common.Accept = 'application/json'; - -function createNewRecipe(recipe) { - return instance.post(`${baseUrl}/recipes`, recipe); -} - -function getRecipe(id) { - return instance.get(`${baseUrl}/recipes/${id}`); -} - -function getAllRecipes() { - return instance.get(`${baseUrl}/recipes`); -} - -function getAllRecipesOfCategory(categoryName) { - return instance.get(`${baseUrl}/category/${categoryName}`); -} - -function getAllRecipesWithTag(tags) { - return instance.get(`${baseUrl}/tags/${tags}`); -} - -function searchRecipes(search) { - return instance.get(`${baseUrl}/search/${search}`); -} - -function updateRecipe(id, recipe) { - return instance.put(`${baseUrl}/recipes/${id}`, recipe); -} - -function deleteRecipe(id) { - return instance.delete(`${baseUrl}/recipes/${id}`); -} - -function importRecipe(url) { - return instance.post(`${baseUrl}/import`, `url=${url}`); -} - -function getAllCategories() { - return instance.get(`${baseUrl}/categories`); -} - -function updateCategoryName(oldName, newName) { - return instance.put(`${baseUrl}/category/${encodeURIComponent(oldName)}`, { - name: newName, - }); -} - -function getAllKeywords() { - return instance.get(`${baseUrl}/keywords`); -} - -function getConfig() { - return instance.get(`${baseUrl}/config`); -} - -function updatePrintImageSetting(enabled) { - return instance.post(`${baseUrl}/config`, { print_image: enabled ? 1 : 0 }); -} - -function updateUpdateInterval(newInterval) { - return instance.post(`${baseUrl}/config`, { update_interval: newInterval }); -} - -function updateRecipeDirectory(newDir) { - return instance.post(`${baseUrl}/config`, { folder: newDir }); -} - -function updateVisibleInfoBlocks(visibleInfoBlocks) { - return instance.post(`${baseUrl}/config`, { visibleInfoBlocks }); -} - -function reindex() { - return instance.post(`${baseUrl}/reindex`); -} - -export default { - recipes: { - create: createNewRecipe, - getAll: getAllRecipes, - get: getRecipe, - allInCategory: getAllRecipesOfCategory, - allWithTag: getAllRecipesWithTag, - search: searchRecipes, - update: updateRecipe, - delete: deleteRecipe, - import: importRecipe, - reindex, - }, - categories: { - getAll: getAllCategories, - update: updateCategoryName, - }, - keywords: { - getAll: getAllKeywords, - }, - config: { - get: getConfig, - directory: { - update: updateRecipeDirectory, - }, - printImage: { - update: updatePrintImageSetting, - }, - updateInterval: { - update: updateUpdateInterval, - }, - visibleInfoBlocks: { - update: updateVisibleInfoBlocks, - }, - }, -}; diff --git a/src/js/helper.js b/src/js/helper.js deleted file mode 100644 index 1ddf57974..000000000 --- a/src/js/helper.js +++ /dev/null @@ -1,179 +0,0 @@ -import { showSimpleAlertModal } from 'cookbook/js/modals'; - -/** - * Clamps val between the minimum min and maximum max value. - * @param val The value to be clamped between min and max - * @param min The upper limit - * @param max The lower limit - * @returns {number} min if val is <= min, max if val is >= max and val if min <= val <= max. - */ -function clamp(val, min, max) { - return Math.min(max, Math.max(min, val)); -} - -// Check if two routes point to the same component but have different content -function shouldReloadContent(url1, url2) { - if (url1 === url2) { - return false; // Obviously should not if both routes are the same - } - - const comps1 = url1.split('/'); - const comps2 = url2.split('/'); - - if (comps1.length < 2 || comps2.length < 2) { - return false; // Just a failsafe, this should never happen - } - - // The route structure is as follows: - // - /{item}/:id View - // - /{item}/:id/edit Edit - // - /{item}/create Create - // If the items are different, then the router automatically handles - // component loading: do not manually reload - if (comps1[1] !== comps2[1]) { - return false; - } - - // If one of the routes is edit and the other is not - if (comps1.length !== comps2.length) { - // Only reload if changing from edit to create - return comps1.pop() === 'create' || comps2.pop() === 'create'; - } - if (comps1.pop() === 'create') { - // But, if we are moving from create to view, do not reload - // the create component - return false; - } - - // Only options left are that both of the routes are edit or view, - // but not identical, or that we're moving from view to create - // -> reload view - return true; -} - -// Check if the two urls point to the same item instance -function isSameItemInstance(url1, url2) { - if (url1 === url2) { - return true; // Obviously true if the routes are the same - } - const comps1 = url1.split('/'); - const comps2 = url2.split('/'); - if (comps1.length < 2 || comps2.length < 2) { - return false; // Just a failsafe, this should never happen - } - // If the items are different, then the item instance cannot be - // the same either - if (comps1[1] !== comps2[1]) { - return false; - } - if (comps1.length < 3 || comps2.length < 3) { - // ID is the third url component, so can't be the same instance if - // either of the urls have less than three components - return false; - } - return comps1[2] === comps2[2]; -} - -/** - * A simple function to sanitize HTML tags. - * @param {string} text Input string - * @returns {string} - */ - -function escapeHTML(text) { - return text.replace( - /["&'<>]/g, - (a) => - ({ - '&': '&', - '"': '"', - "'": ''', - '<': '<', - '>': '>', - })[a], - ); -} - -// Fix the decimal separator for languages that use a comma instead of dot -// deprecated -function fixDecimalSeparator(value, io) { - // value is the string value of the number to process - // io is either 'i' as in input or 'o' as in output - if (!value) { - return ''; - } - if (io === 'i') { - // Check if it's an American number where a comma precedes a dot - // e.g. 12,500.25 - if (value.indexOf('.') > value.indexOf(',')) { - return value.replace(',', ''); - } - return value.replace(',', '.'); - } - if (io === 'o') { - return value.toString().replace('.', ','); - } - return ''; -} - -// This will replace the PHP function nl2br in Vue components -// deprecated -function nl2br(text) { - return text.replace(/\n/g, '
'); -} - -// A simple function that converts a MySQL datetime into a timestamp. -// deprecated -function getTimestamp(date) { - if (date) { - return new Date(date); - } - return null; -} - -let router; - -function useRouter(_router) { - router = _router; -} -// Push a new URL to the router, essentially navigating to that page. -function goTo(url) { - router.push(url); -} - -// Notify the user if notifications are allowed -// deprecated -function notify(title, options) { - if (!('Notification' in window)) { - return; - } - if (Notification.permission === 'granted') { - // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars - const notification = new Notification(title, options); - } else if (Notification.permission !== 'denied') { - Notification.requestPermission((permission) => { - if (!('permission' in Notification)) { - Notification.permission = permission; - } - if (permission === 'granted') { - // eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars - const notification = new Notification(title, options); - } else { - showSimpleAlertModal(title); - } - }); - } -} - -export default { - clamp, - shouldReloadContent, - isSameItemInstance, - escapeHTML, - fixDecimalSeparator, - nl2br, - getTimestamp, - useRouter, - goTo, - notify, -}; diff --git a/src/js/helper.ts b/src/js/helper.ts new file mode 100644 index 000000000..a8a1b7fa5 --- /dev/null +++ b/src/js/helper.ts @@ -0,0 +1,133 @@ +import VueRouter, { Route } from 'vue-router'; + +// Check if two routes point to the same component but have different content +function shouldReloadContent(url1: string, url2: string): boolean { + if (url1 === url2) { + return false; // Obviously should not if both routes are the same + } + + const comps1 = url1.split('/'); + const comps2 = url2.split('/'); + + if (comps1.length < 2 || comps2.length < 2) { + return false; // Just a failsafe, this should never happen + } + + // The route structure is as follows: + // - /{item}/:id View + // - /{item}/:id/edit Edit + // - /{item}/create Create + // If the items are different, then the router automatically handles + // component loading: do not manually reload + if (comps1[1] !== comps2[1]) { + return false; + } + + // If one of the routes is "edit" and the other is not + if (comps1.length !== comps2.length) { + // Only reload if changing from "edit" to "create" + return comps1.pop() === 'create' || comps2.pop() === 'create'; + } + if (comps1.pop() === 'create') { + // But, if we are moving from create to view, do not reload + // the "create" component + return false; + } + + // Only options left are that both of the routes are edit or view, + // but not identical, or that we're moving from "view" to "create" + // -> reload view + return true; +} + +/** + * Check if the two urls point to the same item instance + */ +function isSameItemInstance(url1: string, url2: string): boolean { + if (url1 === url2) { + return true; // Obviously true if the routes are the same + } + const comps1 = url1.split('/'); + const comps2 = url2.split('/'); + if (comps1.length < 2 || comps2.length < 2) { + return false; // Just a failsafe, this should never happen + } + // If the items are different, then the item instance cannot be + // the same either + if (comps1[1] !== comps2[1]) { + return false; + } + if (comps1.length < 3 || comps2.length < 3) { + // ID is the third url component, so can't be the same instance if + // either of the urls have less than three components + return false; + } + return comps1[2] === comps2[2]; +} + +/** + * A simple function to sanitize HTML tags. + * @param {string} text Input string + * @returns {string} + */ +function escapeHTML(text: string): string { + const replacementChars = { + '&': '&', + '"': '"', + "'": ''', + '<': '<', + '>': '>', + }; + return text.replace(/["&'<>]/g, (c) => replacementChars[c]); +} + +/** + * `VueRouter` instance to be used for navigation functions like `goTo(url)`. + */ +let router: VueRouter; + +/** + * Set the router to be used for navigation functions like `goTo(url)`. + * @param _router `VueRouter` to be used. + */ +function useRouter(_router: VueRouter): void { + router = _router; +} + +/** + * Push a new URL to the router, effectively navigating to that page. + * @param url URL to navigate to. + */ +function goTo(url: string): Promise { + return router.push(url); +} + +/** + * Ensures that item is an array. If not, wraps it in an array. + * @template T + * @param {T|T[]} value Item to be wrapped in an array if it isn't an array itself. + * @returns {T[]} + */ +export function asArray(value: T | T[]): T[] { + return Array.isArray(value) ? value : [value]; +} + +/** + * Ensures that item is an array. If not, wraps it in an array. Removes all `null` or `undefined` values. + * @template T + * @param {T|T[]} item Item to be wrapped in an array if it isn't an array itself. + * @returns {T[]} + */ +export function asCleanedArray(item: T | T[]): NonNullable[] { + const arr = asArray(item); + return arr.filter((i) => !!i).map((i) => i as NonNullable); +} + +export default { + asArray, + shouldReloadContent, + isSameItemInstance, + escapeHTML, + useRouter, + goTo, +}; diff --git a/src/js/logging.js b/src/js/logging.js deleted file mode 100644 index 0491ad004..000000000 --- a/src/js/logging.js +++ /dev/null @@ -1,73 +0,0 @@ -// TODO: Switch to vuejs3-logger when we switch to Vue 3 -import VueLogger from 'vuejs-logger'; -import moment from '@nextcloud/moment'; - -const DEFAULT_LOG_LEVEL = 'info'; -// How many minutes the logging configuration is valid for -const EXPIRY_MINUTES = 30; -// localStorage keys -const KEY_ENABLED = 'COOKBOOK_LOGGING_ENABLED'; -const KEY_EXPIRY = 'COOKBOOK_LOGGING_EXPIRY'; -const KEY_LOG_LEVEL = 'COOKBOOK_LOGGING_LEVEL'; - -// Check if the logging configuration in local storage has expired -// -// Since the expiry entry is added by us after the first run where -// the enabled entry is detected, this only checks if it has been EXPIRY_MINUTES -// since the first run, not EXPIRY_MINUTES since the user added the entry -// This is a reasonable comprimise to simplify what the user has to do to enable -// logging. We don't want them to have to setup the expiry as well -const isExpired = (timestamp) => { - if (timestamp === null) { - return false; - } - - return moment().isAfter(parseInt(timestamp, 10)); -}; - -const isEnabled = () => { - const DEFAULT = false; - const userValue = localStorage.getItem(KEY_ENABLED); - const expiry = localStorage.getItem(KEY_EXPIRY); - - // Detect the first load after the user has enabled logging - // Set the expiry so the logging isn't enabled forever - if (userValue !== null && expiry === null) { - localStorage.setItem( - KEY_EXPIRY, - moment().add(EXPIRY_MINUTES, 'm').valueOf(), - ); - } - - if (isExpired(expiry)) { - localStorage.removeItem(KEY_ENABLED); - localStorage.removeItem(KEY_EXPIRY); - - return DEFAULT; - } - - // Local storage converts everything to string - // Use JSON.parse to transform "false" -> false - return JSON.parse(userValue) ?? DEFAULT; -}; - -export default function setupLogging(Vue) { - const logLevel = localStorage.getItem(KEY_LOG_LEVEL) ?? DEFAULT_LOG_LEVEL; - - Vue.use(VueLogger, { - isEnabled: isEnabled(), - logLevel, - stringifyArguments: false, - showLogLevel: true, - showMethodName: true, - separator: '|', - showConsoleColors: true, - }); - - Vue.$log.info(`Setting up logging with log level ${logLevel}`); -} - -export function enableLogging() { - localStorage.setItem(KEY_ENABLED, true); - localStorage.setItem(KEY_LOG_LEVEL, 'debug'); -} diff --git a/src/js/logging.ts b/src/js/logging.ts new file mode 100644 index 000000000..5fd0fb308 --- /dev/null +++ b/src/js/logging.ts @@ -0,0 +1,90 @@ +// TODO: Switch to vuejs3-logger when we switch to Vue 3 +import VueLogger from 'vuejs-logger'; +import moment from '@nextcloud/moment'; + +const DEFAULT_LOG_LEVEL = 'info'; +/** + * For how many minutes the logging configuration is valid. + */ +const EXPIRY_MINUTES: number = 30; +// localStorage keys +const KEY_ENABLED = 'COOKBOOK_LOGGING_ENABLED'; +const KEY_EXPIRY = 'COOKBOOK_LOGGING_EXPIRY'; +const KEY_LOG_LEVEL = 'COOKBOOK_LOGGING_LEVEL'; + +/** + * Checks if the logging configuration in local storage has expired. + * + * Since the expiry entry is added by us after the first run where + * the enabled entry is detected, this only checks if it has been EXPIRY_MINUTES + * since the first run, not EXPIRY_MINUTES since the user added the entry + * This is a reasonable compromise to simplify what the user has to do to enable + * logging. We don't want them to have to set up the expiry as well. + * @param timestamp + * @returns {string} True if the logging configuration has expired. False, otherwise + */ + +const isExpired = (timestamp: string): boolean => { + if (timestamp === null) { + return false; + } + + return moment().isAfter(parseInt(timestamp, 10)); +}; + +/** + * True, if logging is enabled. False, otherwise. + */ +const isEnabled = (): boolean => { + const DEFAULT = false; + const userValue = localStorage.getItem(KEY_ENABLED); + let expiry = localStorage.getItem(KEY_EXPIRY); + + if (expiry && isExpired(expiry)) { + localStorage.removeItem(KEY_ENABLED); + localStorage.removeItem(KEY_EXPIRY); + + return DEFAULT; + } + + if (!userValue) return false; + + // Detect the first load after the user has enabled logging + // Set the expiry so the logging isn't enabled forever + if (userValue !== null && expiry === null) { + expiry = moment().add(EXPIRY_MINUTES, 'm').valueOf().toString(); + localStorage.setItem(KEY_EXPIRY, expiry as string); + } + + // Local storage converts everything to string + // Use JSON.parse to transform "false" -> false + return JSON.parse(userValue) ?? DEFAULT; +}; + +/** + * Runs the initial logging setup. + * @param Vue + */ +export default function setupLogging(Vue) { + const logLevel = localStorage.getItem(KEY_LOG_LEVEL) ?? DEFAULT_LOG_LEVEL; + + Vue.use(VueLogger, { + isEnabled: isEnabled(), + logLevel, + stringifyArguments: false, + showLogLevel: true, + showMethodName: true, + separator: '|', + showConsoleColors: true, + }); + + Vue.$log.info(`Setting up logging with log level ${logLevel}`); +} + +/** + * Enables logging and sets log level to "DEBUG". + */ +export function enableLogging(): void { + localStorage.setItem(KEY_ENABLED, 'true'); + localStorage.setItem(KEY_LOG_LEVEL, 'debug'); +} diff --git a/src/js/title-rename.js b/src/js/title-rename.js deleted file mode 100644 index 97171592f..000000000 --- a/src/js/title-rename.js +++ /dev/null @@ -1,85 +0,0 @@ -import api from 'cookbook/js/api-interface'; - -import { generateUrl } from '@nextcloud/router'; - -const baseUrl = generateUrl('apps/cookbook'); - -function extractAllRecipeLinkIds(content) { - const re = /(?:^|[^#])#r\/([0-9]+)/g; - let ret = []; - let matches; - for ( - matches = re.exec(content); - matches !== null; - matches = re.exec(content) - ) { - ret.push(matches[1]); - } - - // Make the ids unique, see https://stackoverflow.com/a/14438954/882756 - function onlyUnique(value, index, self) { - return self.indexOf(value) === index; - } - ret = ret.filter(onlyUnique); - - return ret; -} - -async function getRecipesFromLinks(linkIds) { - return Promise.all( - linkIds.map(async (x) => { - let recipe; - try { - recipe = await api.recipes.get(x); - } catch (ex) { - recipe = null; - } - return recipe; - }), - ); -} - -function cleanUpRecipeList(recipes) { - return recipes.filter((r) => r !== null).map((x) => x.data); -} - -function getRecipeUrl(id) { - return `${baseUrl}/#/recipe/${id}`; -} - -function insertMarkdownLinks(content, recipes) { - let ret = content; - recipes.forEach((r) => { - const { id } = r; - - // Replace link urls in dedicated links (like [this example](#r/123)) - ret = ret.replace(`](${id})`, `](${getRecipeUrl(id)})`); - - // Replace plain references with recipe name - const rePlain = RegExp( - `(^|\\s|[,._+&?!-])#r/${id}($|\\s|[,._+&?!-])`, - 'g', - ); - // const re = /(^|\s|[,._+&?!-])#r\/(\d+)(?=$|\s|[.,_+&?!-])/g - ret = ret.replace( - rePlain, - `$1[${r.name} (\\#r/${id})](${getRecipeUrl(id)})$2`, - ); - }); - return ret; -} - -async function normalizeNamesMarkdown(content) { - // console.log(`Content: ${content}`) - const linkIds = extractAllRecipeLinkIds(content); - let recipes = await getRecipesFromLinks(linkIds); - recipes = cleanUpRecipeList(recipes); - // console.log("List of recipes", recipes) - - const markdown = insertMarkdownLinks(content, recipes); - // console.log("Formatted markdown:", markdown) - - return markdown; -} - -export default normalizeNamesMarkdown; diff --git a/src/js/title-rename.ts b/src/js/title-rename.ts new file mode 100644 index 000000000..47af762fc --- /dev/null +++ b/src/js/title-rename.ts @@ -0,0 +1,126 @@ +import api from 'cookbook/js/utils/api-interface'; + +import { generateUrl } from '@nextcloud/router'; + +interface Recipe { + id: string; + name: string; +} + +/** + * Relative base URL of the cookbook app. + */ +const baseUrl = generateUrl('apps/cookbook'); + +/** + * Extracts a list of unique recipe ids from recipe references in `content`. + * @param content The text to search for recipe references. + * @returns List of unique recipe ids. + */ +function extractAllRecipeLinkIds(content: string): string[] { + const re = /(?:^|[^#])#r\/([0-9]+)/g; + let ret: string[] = []; + let matches: RegExpExecArray | null; + for ( + matches = re.exec(content); + matches !== null; + matches = re.exec(content) + ) { + ret.push(matches[1]); + } + + // Make the ids unique, see https://stackoverflow.com/a/14438954/882756 + function onlyUnique(value: string, index: number, self: string[]): boolean { + return self.indexOf(value) === index; + } + ret = ret.filter(onlyUnique); + + return ret; +} + +/** + * Loads recipe data from the server for all recipes with ids in `linkIds`. + * @param linkIds List of recipe ids. + * @returns List of API responses with recipe data. + */ +async function getRecipesFromLinks( + linkIds: string[], +): Promise<(Recipe | null)[]> { + return Promise.all( + linkIds.map(async (x): Promise => { + let recipeResponse: Recipe | null; + try { + recipeResponse = await api.recipes.get(x); + } catch (ex) { + recipeResponse = null; + } + return recipeResponse; + }), + ); +} + +/** + * Takes list of response objects from the API call, removes all null objects and extracts only the response data. + * @param recipes List of response objects. + * @returns The response data without null objects. + */ +function cleanUpRecipeList(recipes: (Recipe | null)[]): Recipe[] { + return recipes.filter((r) => r !== null).map((x) => x!); +} + +/** + * Constructs the relative URL to the recipe with identifier `id`. + * @param id Recipe id. + * @returns URL to recipe. + */ +function getRecipeUrl(id: string): string { + return `${baseUrl}/#/recipe/${id}`; +} + +/** + * Replaces all recipe references in `content` by the Markdown link to the recipes. + * @param content Text containing recipe references. + * @param recipes List of recipe objects used to extract names and create links. + * @returns Text with inserted links. + */ +function insertMarkdownLinks(content: string, recipes: Recipe[]) { + let ret = content; + recipes.forEach((r) => { + const { id, name } = r; + + // Replace link urls in dedicated links (like [this example](#r/123)) + ret = ret.replace(`](${id})`, `](${getRecipeUrl(id)})`); + + // Replace plain references with recipe name + const rePlain = RegExp( + `(^|\\s|[,._+&?!-])#r/${id}($|\\s|[,._+&?!-])`, + 'g', + ); + // const re = /(^|\s|[,._+&?!-])#r\/(\d+)(?=$|\s|[.,_+&?!-])/g + ret = ret.replace( + rePlain, + `$1[${name} (\\#r/${id})](${getRecipeUrl(id)})$2`, + ); + }); + return ret; +} + +/** + * Checks the `content` for reference to recipes and replaces them by Markdown links with the recipes' titles. + * @param content The text to search for and replace recipe references. + * @returns `content` with replaced references. + */ +async function normalizeNamesMarkdown(content: string): Promise { + // console.log(`Content: ${content}`) + const linkIds = extractAllRecipeLinkIds(content); + const recipeResponses = await getRecipesFromLinks(linkIds); + const recipes = cleanUpRecipeList(recipeResponses); + // console.log("List of recipes", recipes) + + const markdown = insertMarkdownLinks(content, recipes); + // console.log("Formatted markdown:", markdown) + + return markdown; +} + +export default normalizeNamesMarkdown; diff --git a/src/js/utils/api-interface.ts b/src/js/utils/api-interface.ts new file mode 100644 index 000000000..b1ba47027 --- /dev/null +++ b/src/js/utils/api-interface.ts @@ -0,0 +1,156 @@ +import Vue from 'vue'; +import axios from '@nextcloud/axios'; + +import { generateUrl } from '@nextcloud/router'; +import { Recipe } from 'cookbook/js/Models/schema'; +import { + mapApiRecipeResponseToRecipe, + mapRecipeToApiRecipe, +} from 'cookbook/js/Api/Mappers/RecipeMappers'; + +const baseUrl = `${generateUrl('apps/cookbook')}/webapp`; + +// Add a debug log for every request +axios.interceptors.request.use((config) => { + Vue.$log.debug( + `[axios] Making "${config.method}" request to "${config.url}"`, + config, + ); + const contentType = config.headers['Content-Type']; + if ( + contentType && + typeof contentType === 'string' && + !['application/json', 'text/json'].includes(contentType) + ) { + Vue.$log.warn( + `[axios] Request to "${config.url}" is using Content-Type "${contentType}", not JSON`, + ); + } + return config; +}); + +axios.interceptors.response.use( + (response) => { + Vue.$log.debug('[axios] Received response', response); + return response; + }, + (error) => { + Vue.$log.warn('[axios] Received error', error); + return Promise.reject(error); + }, +); + +axios.defaults.headers.common.Accept = 'application/json'; + +function createNewRecipe(recipe) { + return axios.post(`${baseUrl}/recipes`, recipe); +} + +async function getRecipe(id: string): Promise { + const response = await axios.get(`${baseUrl}/recipes/${id}`); + return mapApiRecipeResponseToRecipe(response.data); +} + +function getAllRecipes() { + return axios.get(`${baseUrl}/recipes`); +} + +function getAllRecipesOfCategory(categoryName) { + return axios.get(`${baseUrl}/category/${categoryName}`); +} + +function getAllRecipesWithTag(tags) { + return axios.get(`${baseUrl}/tags/${tags}`); +} + +function searchRecipes(search) { + return axios.get(`${baseUrl}/search/${search}`); +} + +function updateRecipe(id: string, recipe: Recipe) { + const recipeDTO = mapRecipeToApiRecipe(recipe); + return axios.put(`${baseUrl}/recipes/${id}`, recipeDTO); +} + +function deleteRecipe(id) { + return axios.delete(`${baseUrl}/recipes/${id}`); +} + +function importRecipe(url) { + return axios.post(`${baseUrl}/import`, `url=${url}`); +} + +function getAllCategories() { + return axios.get(`${baseUrl}/categories`); +} + +function updateCategoryName(oldName, newName) { + return axios.put(`${baseUrl}/category/${encodeURIComponent(oldName)}`, { + name: newName, + }); +} + +function getAllKeywords() { + return axios.get(`${baseUrl}/keywords`); +} + +function getConfig() { + return axios.get(`${baseUrl}/config`); +} + +function updatePrintImageSetting(enabled) { + return axios.post(`${baseUrl}/config`, { print_image: enabled ? 1 : 0 }); +} + +function updateUpdateInterval(newInterval) { + return axios.post(`${baseUrl}/config`, { update_interval: newInterval }); +} + +function updateRecipeDirectory(newDir) { + return axios.post(`${baseUrl}/config`, { folder: newDir }); +} + +function updateVisibleInfoBlocks(visibleInfoBlocks) { + return axios.post(`${baseUrl}/config`, { visibleInfoBlocks }); +} + +function reindex() { + return axios.post(`${baseUrl}/reindex`); +} + +export default { + recipes: { + create: createNewRecipe, + getAll: getAllRecipes, + get: getRecipe, + allInCategory: getAllRecipesOfCategory, + allWithTag: getAllRecipesWithTag, + search: searchRecipes, + update: updateRecipe, + delete: deleteRecipe, + import: importRecipe, + reindex, + }, + categories: { + getAll: getAllCategories, + update: updateCategoryName, + }, + keywords: { + getAll: getAllKeywords, + }, + config: { + get: getConfig, + directory: { + update: updateRecipeDirectory, + }, + printImage: { + update: updatePrintImageSetting, + }, + updateInterval: { + update: updateUpdateInterval, + }, + visibleInfoBlocks: { + update: updateVisibleInfoBlocks, + }, + }, +}; diff --git a/src/js/utils/applyRecipeFilters.js b/src/js/utils/applyRecipeFilters.ts similarity index 51% rename from src/js/utils/applyRecipeFilters.js rename to src/js/utils/applyRecipeFilters.ts index e0c17dabb..a6edd19f9 100644 --- a/src/js/utils/applyRecipeFilters.js +++ b/src/js/utils/applyRecipeFilters.ts @@ -1,13 +1,19 @@ +import { Recipe } from 'cookbook/js/Models/schema'; +import { RecipeFilter } from 'cookbook/js/RecipeFilters'; + /** * Function to apply a set of filters on an array of recipes. * @param {Object[]} recipes - The array of recipe objects to be filtered. * @param {RecipeFilter[]} filters - The array of filters to apply. * @returns {Object[]} The filtered array of recipes. */ -function applyRecipeFilters(recipes, filters) { - return recipes.filter((recipe) => - filters.every((filter) => filter.filter(recipe)), - ); +function applyRecipeFilters( + recipes: Recipe[], + filters: RecipeFilter[], +): Recipe[] { + return recipes.filter((recipe) => + filters.every((filter) => filter.filter(recipe)), + ); } export default applyRecipeFilters; diff --git a/src/js/utils/compareRecipeFilters.ts b/src/js/utils/compareRecipeFilters.ts new file mode 100644 index 000000000..96dfdcb02 --- /dev/null +++ b/src/js/utils/compareRecipeFilters.ts @@ -0,0 +1,30 @@ +import { RecipeFilter } from 'cookbook/js/RecipeFilters'; + +/** + * Compares two lists of recipe filters. + * @param filters1 + * @param filters2 + * @return True if both lists contain the same filters + */ +export default function compareRecipeFilters( + filters1: RecipeFilter[], + filters2: RecipeFilter[], +) { + if (filters1.length !== filters2.length) { + return false; + } + + for (let i1 = 0; i1 < filters1.length; i1++) { + const filter1 = filters1[i1]; + let filterIsNew = true; + for (let i2 = 0; filterIsNew && i2 < filters2.length; i2++) { + const filter2 = filters2[i2]; + if (filter1.equals(filter2)) { + filterIsNew = false; + break; + } + } + if (filterIsNew) return false; + } + return true; +} diff --git a/src/js/utils/comparison.ts b/src/js/utils/comparison.ts new file mode 100644 index 000000000..46f5b54c9 --- /dev/null +++ b/src/js/utils/comparison.ts @@ -0,0 +1,21 @@ +/** + * Compares two arrays to determine if they contain the same elements, regardless of order. + * @template T + * @param {T[]} array1 - The first array to compare. + * @param {T[]} array2 - The second array to compare. + * @returns {boolean} - Returns true if both arrays contain the same elements, otherwise false. + */ +// eslint-disable-next-line import/prefer-default-export +export function compareArrays(array1: T[], array2: T[]): boolean { + // Check if both arrays are of the same length + if (array1.length !== array2.length) { + return false; + } + + // Create copies of the arrays to avoid modifying the original arrays + const sortedArray1 = [...array1].sort(); + const sortedArray2 = [...array2].sort(); + + // Converting to JSON string allows deep comparison + return JSON.stringify(sortedArray1) === JSON.stringify(sortedArray2); +} diff --git a/src/js/utils/domUtils.ts b/src/js/utils/domUtils.ts new file mode 100644 index 000000000..0a395fd76 --- /dev/null +++ b/src/js/utils/domUtils.ts @@ -0,0 +1,15 @@ +// eslint-disable-next-line import/prefer-default-export +export function findParentByClass( + element: HTMLElement | null, + className: string, +) { + if (element === null) return null; + if (element && element === document.body) { + return null; // Return null if no parent with the specified class is found + } + if (element.classList.contains(className)) { + return element; + } + + return findParentByClass(element.parentNode as HTMLElement, className); +} diff --git a/src/js/utils/jsonMapper.ts b/src/js/utils/jsonMapper.ts new file mode 100644 index 000000000..1865707db --- /dev/null +++ b/src/js/utils/jsonMapper.ts @@ -0,0 +1,145 @@ +import JsonMappingException from 'cookbook/js/Exceptions/JsonMappingException'; + +/** + * Tries to map `value` to an integer. + * @param value The value to be mapped. + * @param targetName The name of the target property. Only used for error message. + * @param allowNullOrUndefined If true `null` or `undefined` will be immediately returned. If false, an exception will be thrown. + * @throws JsonMappingException Thrown if `value` cannot be mapped to an integer number. + * @returns Either the value as an integer if mapping was successful or null/undefined if the value was null/undefined + * and allowNullOrUndefined is true. + */ +export function mapInteger( + value: unknown, + targetName: string = '', + allowNullOrUndefined: boolean = false, +): number | null | undefined { + if (value === undefined || value === null) { + // Return null or undefined immediately + if (allowNullOrUndefined) return value; + // Throw + throw new JsonMappingException( + `Error mapping ${targetName}. Expected integer number but received "${value}".`, + ); + } + + // Only numbers and strings can be mapped to an integer. Early return. + if (typeof value !== 'number' && typeof value !== 'string') { + throw new JsonMappingException( + `Error mapping ${targetName}. Expected integer number but received "${typeof value}".`, + ); + } + + // `value` is a number, but is it an integer? + if (typeof value === 'number') { + if (Number.isInteger(value)) { + return value; + } + throw new JsonMappingException( + `Error mapping ${targetName}. Expected integer number but received non-integer "${value}".`, + ); + } + + // `value` is a string, can it be parsed to an integer? + + const parsedValue: number = parseInt(value, 10); + if (Number.isNaN(parsedValue)) { + throw new JsonMappingException( + `Error mapping ${targetName}. Expected integer number but received non-parsable string "${value}".`, + ); + } + return parsedValue; +} + +/** + * Tries to map `value` to a string or an array of strings. + * @param value The value to be mapped. + * @param targetName The name of the target property. Only used for error message. + * @param allowNullOrUndefined If true `null` or `undefined` will be immediately returned. If false, an exception will be thrown. + * @param treatStringAsCommaSeparatedList If true and a single string is passed, the string is treated as a + * comma-separated list and mapped to an array. + * @throws JsonMappingException Thrown if `value` cannot be mapped to a string or an array of strings. + * @returns Either the value as a string or an array of strings if mapping was successful or null/undefined if the + * value was null/undefined. + */ +export function mapStringOrStringArray( + value: unknown, + targetName: string = '', + allowNullOrUndefined: boolean = false, + treatStringAsCommaSeparatedList = false, +): string | string[] | null | undefined { + if (value === undefined || value === null) { + // Return null or undefined immediately + if (allowNullOrUndefined) return value; + // Throw + throw new JsonMappingException( + `Error mapping ${targetName}. Expected string or string array but received "${value}".`, + ); + } + + // Only strings and string arrays can be mapped. Early return. + if (typeof value !== 'string' && !Array.isArray(value)) { + throw new JsonMappingException( + `Error mapping ${targetName}. Expected string or array but received "${typeof value}".`, + ); + } + + // `value` is an array but is it an array of strings? + if (Array.isArray(value)) { + if (value.every((i) => typeof i === 'string')) return value; + + throw new JsonMappingException( + `Error mapping ${targetName}. Expected string or string array received array with non-string elements.`, + ); + } + + // `value` is a string, return. + if (!treatStringAsCommaSeparatedList) return value; + + return ( + value + .split(',') + .map((str) => str.trim()) + // Remove any empty strings + // If empty string, split will create an array of a single empty string + .filter((str) => str !== '') + ); +} + +/** + * Tries to map `value` to a string. + * @param value The value to be mapped. + * @param targetName The name of the target property. Only used for error message. + * @param allowNullOrUndefined If true `null` or `undefined` will be immediately returned. If false, an exception will be thrown. + * @throws JsonMappingException Thrown if `value` cannot be mapped to a string. + * @returns Either the value as a string if mapping was successful or null/undefined if the value was null/undefined. + */ +export function mapString( + value: unknown, + targetName: string = '', + allowNullOrUndefined: boolean = false, +): string | null | undefined { + if (value === undefined || value === null) { + // Return null or undefined immediately + if (allowNullOrUndefined) return value; + // Throw + throw new JsonMappingException( + `Error mapping ${targetName}. Expected string but received "${value}".`, + ); + } + + // Only strings can be mapped. Early return. + if (typeof value === 'string') { + // `value` is a string, return. + return value; + } + + // `value` is an integer, map to string. + if (Number.isInteger(value)) { + return value.toString(); + } + + throw new JsonMappingException( + `Error mapping ${targetName}. Expected string but received "${typeof value}".`, + ); +} diff --git a/src/js/utils/mathUtils.ts b/src/js/utils/mathUtils.ts new file mode 100644 index 000000000..98193361e --- /dev/null +++ b/src/js/utils/mathUtils.ts @@ -0,0 +1,48 @@ +/** + * Adjusts a numeric value by a specified step size. + * If the original value is a float, it will be rounded to the nearest integer. + * The adjusted value will always be greater than 0. + * + * @param {number} value - The original numeric value. + * @param {number} step - The step size by which to adjust the value. + * @returns {number} - The adjusted value. + */ +export function adjustToInteger(value: number, step: number): number { + // Add the step + const modifiedValue = value + step; + + // Round the value to the nearest integer + let adjustedValue = + step > 0 ? Math.floor(modifiedValue) : Math.ceil(modifiedValue); + + // Ensure the adjusted value is at least 1 + adjustedValue = Math.max(adjustedValue, 1); + + // If the original value is between 0 and 1 and the step is negative, adjust accordingly + if (value > 0 && value < 1 && step < 0) { + adjustedValue = Math.min(value, adjustedValue); + } + + return adjustedValue; +} + +/** + * Clamps val between the minimum min and maximum max value. + * @param val The value to be clamped between min and max + * @param min The upper limit + * @param max The lower limit + * @returns {number} min if val is <= min, max if val is >= max and val if min <= val <= max. + */ +export function clamp(val: number, min: number, max: number): number { + return Math.min(max, Math.max(min, val)); +} + +/** + * Rounds a number to a given precision. + * @param {number} num - The number to round. + * @param {int} precision - The number of decimals. + */ +export function roundTo(num: number, precision: number) { + const factor = 10 ** precision; + return Math.round(num * factor) / factor; +} diff --git a/src/js/utils/navigation.ts b/src/js/utils/navigation.ts new file mode 100644 index 000000000..5425c1909 --- /dev/null +++ b/src/js/utils/navigation.ts @@ -0,0 +1,40 @@ +import VueRouter, { Route } from 'vue-router'; + +/** + * `VueRouter` instance to be used for navigation functions like `goTo(url)`. + */ +let router: VueRouter; + +/** + * Set the router to be used for navigation functions like `goTo(url)`. + * @param _router `VueRouter` to be used. + */ +function useRouter(_router: VueRouter): void { + router = _router; +} + +/** + * Navigates to the parent of a recipe route. + * @param {Route} route - The current route to the recipe. + * @returns The new route or null, if a parent for `route` can't be determined. + */ +export function goToRecipeParent(route: Route): Promise | null { + const parts = route.path.split('/'); + if (!['category', 'name', 'search', 'tags'].includes(parts[1])) { + // wrong route + return null; + } + // Remove recipe id from path + parts.splice(-1, 1); + + // Build new path + const path = parts.join('/'); + + // Navigate, keeping the query params of the recipe view + return router.push({ path, query: route.query }); +} + +export default { + useRouter, + goToRecipeParent, +}; diff --git a/src/js/utils/parseSearchString.ts b/src/js/utils/parseSearchString.ts new file mode 100644 index 000000000..049aab573 --- /dev/null +++ b/src/js/utils/parseSearchString.ts @@ -0,0 +1,198 @@ +import RecipeFilter from 'cookbook/js/RecipeFilters/RecipeFilter'; +import { AndOperator, OrOperator } from 'cookbook/js/LogicOperators'; +import { + RecipeCategoriesFilter, + RecipeKeywordsFilter, + RecipeNamesFilter, + RecipeSearchFilter, +} from 'cookbook/js/RecipeFilters'; +import SearchMode from 'cookbook/js/Enums/SearchMode'; +import { removeDuplicatesInNestedStringArray } from 'cookbook/js/utils/removeDuplicates'; + +/** Extracts from a string pairs of a label and one or multiple values that are separated by a colon. The list of values + * must be comma-separated and values that contain spaces need to be quoted. + * Example: `tag:chocolate,"very tasty" cat:"dessert"` -> `[{label: "tag", value:"chocolate,"very tasty"}, {label:"cat",value:"dessert" }]` + * @param searchString The string to split into key-value pairs. + * @returns {Array<{label: string, values: string[]}>} An array of objects containing label and values. + */ +function extractLabelValuePairs( + searchString: string, +): Array<{ label: string; value: string }> { + // Extracts from `tag:"dessert",tasty,"green sauce"` two groups `tag` and `"dessert",tasty,"green sauce"` + const regex = /(\w+):((?:(?:"[\w\s]+"|\w+),)*(?:"[\w\s]+"|\w+))/g; + + const pairs: { label: string; value: string }[] = []; + + let match: RegExpExecArray | null = regex.exec(searchString); + while (match !== null) { + pairs.push({ label: match[1], value: match[2] }); + // iterate + match = regex.exec(searchString); + } + + return pairs; +} + +/** + * Processes value behind a label in the search string and extracts the comma-separated values. + * @param value The string of the value. + */ +function processValue(value: string): string[] { + // Split by commas for OR logic and remove quotes if present + // return value.split(',').map((v) => v.replace(/^"|"$/g, '').trim()); + return value.split(','); +} + +/** + * Creates a filter object based on the label and values. + * @param {string} label - The label of the filter. + * @param {string[]} values - The values associated with the filter. + * @param {AndOperator|OrOperator} operator - The logical operator for the filter. + * @returns {RecipeFilter} A filter object. + */ +function createFilter( + label: string, + values: string[], + operator: AndOperator | OrOperator, +): RecipeFilter { + switch (label) { + case 'tag': + return new RecipeKeywordsFilter(values, operator); + case 'cat': + return new RecipeCategoriesFilter(values, operator); + case 'name': + return new RecipeNamesFilter( + values, + operator, + SearchMode.MatchSubstring, + ); + default: + throw new Error(`Unknown filter type: ${label}`); + } +} + +/** + * Trim and remove quotes if present. + * @param {string} term - Search term + */ +function normalizeFilterTerm(term: string): string { + return term.replace(/^"|"$/g, '').trim(); +} + +/** + * Creates filter objects from label/value pairs. + * Combines multiple single-item filters of the same type into a single filter with an AndOperator. + * @param {Array<{label: string, value: string}>} labelValuePairs - An array of label/value pairs. + * @param {string} originalSearchString - The original search string. + * @returns {filters: {Array, remainingSearchString: string}} An object with an array of filter objects + * and the remaining search string without those filters. + */ +function createFilters( + labelValuePairs: Array<{ label: string; value: string }>, + originalSearchString: string, +): { filters: RecipeFilter[]; remainingSearchString: string } { + const filterMap: Map = new Map(); + + // Copy string to be modified + let updatedSearchString = ` ${originalSearchString}`.slice(1); + + // Group values by their label + labelValuePairs.forEach(({ label, value }) => { + const values = processValue(value); + + if (!filterMap.has(label)) { + filterMap.set(label, []); + } + filterMap.get(label)?.push(values); + }); + + // Cleanup duplicates + filterMap.forEach((values, label) => { + filterMap[label] = removeDuplicatesInNestedStringArray(values); + }); + + const filters: RecipeFilter[] = []; + // Iterate over the map to create filter objects + filterMap.forEach((values, label) => { + // AND operator + const valuesToCombineWithAnd = values + .filter((v) => v.length === 1) + .map((v) => v[0]); + + if (valuesToCombineWithAnd.length > 0) { + try { + filters.push( + createFilter( + label, + valuesToCombineWithAnd.map((t) => + normalizeFilterTerm(t), + ), + new AndOperator(), + ), + ); + + // Update search string and remove created filters + valuesToCombineWithAnd.forEach((value) => { + updatedSearchString = updatedSearchString.replace( + `${label}:${value}`, + '', + ); + updatedSearchString = updatedSearchString.replace( + `${label}:"${value}"`, + '', + ); + }); + } catch { + // No filter created, do not update search string + } + } + + // OR operator + const remainingValues = values.filter((v) => v.length > 1); + + remainingValues.forEach((value) => { + try { + filters.push( + createFilter( + label, + value.map((t) => normalizeFilterTerm(t)), + new OrOperator(), + ), + ); + + // Update search string and remove created filters + updatedSearchString = updatedSearchString.replace( + `${label}:${value}`, + '', + ); + } catch { + // No filter created, do not update search string + } + }); + }); + + return { filters, remainingSearchString: updatedSearchString }; +} + +export default function parseSearchString(searchString: string) { + // Extract pairs of labels with values + const labelValuePairs = extractLabelValuePairs(searchString); + + // Create filters from pairs + const { filters, remainingSearchString } = createFilters( + labelValuePairs, + searchString, + ); + + if (remainingSearchString.trim() !== '') { + filters.push( + new RecipeSearchFilter( + normalizeFilterTerm(remainingSearchString), + new AndOperator(), + SearchMode.Fuzzy, + ), + ); + } + + return filters; +} diff --git a/src/js/utils/removeDuplicates.ts b/src/js/utils/removeDuplicates.ts new file mode 100644 index 000000000..7b9109a09 --- /dev/null +++ b/src/js/utils/removeDuplicates.ts @@ -0,0 +1,36 @@ +import { compareArrays } from 'cookbook/js/utils/comparison'; + +/** + * Removes duplicates from a double string array, ignoring the order of strings in the inner array. + * @param {string[][]} arr - Array to handle + * @returns Array without duplicates + */ +export function removeDuplicatesInNestedStringArray( + arr: string[][], +): string[][] { + const array = JSON.parse(JSON.stringify(arr)); + + for (let outerIdx = 0; outerIdx < array.length; outerIdx++) { + const item = array[outerIdx]; + for (let innerIdx = array.length - 1; innerIdx > outerIdx; innerIdx--) { + if (compareArrays(item, array[innerIdx])) { + // Remove duplicate + array.splice(innerIdx, 1); + } + } + } + return array; +} + +/** + * Removes duplicates from an array by using the equals operator for elements. + * Not expected to work for objects. + * Not very efficient for large arrays. + * @template T + * @param {T[]} arr - Array to handle + * @returns Array without duplicates + */ +// eslint-disable-next-line import/prefer-default-export +export function simpleRemoveDuplicates(arr: T[]): T[] { + return arr.filter((item, pos) => arr.indexOf(item) === pos); +} diff --git a/src/js/utils/routeUtils.ts b/src/js/utils/routeUtils.ts new file mode 100644 index 000000000..bd5d26d0c --- /dev/null +++ b/src/js/utils/routeUtils.ts @@ -0,0 +1,50 @@ +import { Route } from 'vue-router'; +import { Dictionary } from 'vue-router/types/router'; +import parseSearchString from 'cookbook/js/utils/parseSearchString'; + +/** + * Decodes the URI-encoded query parameters of a Vue Router Route object. + * @param {Route} route - The Vue Router Route object. + * @returns {Record} An object containing the decoded query parameters. + */ +export function decodeQueryParams( + route: Route, +): Dictionary { + const decodedQuery: Record = {}; + + // Iterate over the query parameters and decode each one + Object.keys(route.query).forEach((key) => { + const value = route.query[key]; + + // Check if the value is an array (for multiple values with the same key) + if (Array.isArray(value)) { + decodedQuery[key] = value.map((v) => + v ? decodeURIComponent(v) : null, + ); + } else if (typeof value === 'string') { + decodedQuery[key] = decodeURIComponent(value); + } + }); + + return decodedQuery; +} + +/** + * Decode the query paramater `q` of `route`, URI decode it and convert them to string. + * @param route + */ +export function routeToQueryProp(route: Route): string | null { + const decodedQuery = decodeQueryParams(route); + if (!Object.prototype.hasOwnProperty.call(decodedQuery, 'q')) return null; + const q = Array.isArray(decodedQuery.q) + ? decodedQuery.q.join(',') + : decodedQuery.q; + + // Clean query to remove duplicates + const parsedFilters = parseSearchString(q); + return parsedFilters.map((f) => f.toSearchString()).join(' '); +} + +export function isRouteToRecipe(route: Route): boolean { + return route.name === ''; +} diff --git a/src/js/utils/sortingUtils.ts b/src/js/utils/sortingUtils.ts new file mode 100644 index 000000000..c46d4c5c5 --- /dev/null +++ b/src/js/utils/sortingUtils.ts @@ -0,0 +1,21 @@ +/* eslint-disable import/prefer-default-export */ + +/** + * A function that compares two string items. Used to order strings alphabetically case-insensitively. + * @param item1 + * @param item2 + * @returns {-1|1|0} `1` if `item1` should be sorted behind `item2`, `-1` if `item1` should be sorted before `item2`, + * `0` otherwise. + */ +const caseInsensitiveStringSort = ( + item1: string, + item2: string, +): 1 | -1 | 0 => { + const i1 = item1.toLowerCase(); + const i2 = item2.toLowerCase(); + if (i1 < i2) return -1; + if (i2 < i1) return 1; + return 0; +}; + +export { caseInsensitiveStringSort }; diff --git a/src/js/utils/string-utils.js b/src/js/utils/string-utils.js index 67b25305f..af92daf3b 100644 --- a/src/js/utils/string-utils.js +++ b/src/js/utils/string-utils.js @@ -12,7 +12,8 @@ export function normalize( str, toLowercase = true, - removeSpaces = true, + removeAllSpaces = false, + trim = true, removeDiacritics = true, substituteLetters = true, ) { @@ -23,9 +24,12 @@ export function normalize( if (removeDiacritics) { r = r.normalize('NFD').replace(/\p{Diacritic}/gu, ''); } - if (removeSpaces) { + if (removeAllSpaces) { r = r.replace(/\s/g, ''); } + if (trim) { + r.trim(); + } if (substituteLetters) { r = r.replace(/æ/g, 'ae'); r = r.replace(/ç/g, 'c'); diff --git a/src/main.ts b/src/main.ts index d377bd4f2..d50deb627 100644 --- a/src/main.ts +++ b/src/main.ts @@ -14,6 +14,7 @@ import * as ModalDialogs from 'vue-modal-dialogs'; import { linkTo } from '@nextcloud/router'; import helpers from './js/helper'; +import navigation from './js/utils/navigation'; import setupLogging from './js/logging'; import router from './router'; @@ -60,6 +61,7 @@ __webpack_public_path__ = `${linkTo('cookbook', 'js')}/`; __webpack_nonce__ = btoa(window.OC.requestToken); helpers.useRouter(router); +navigation.useRouter(router); // A simple function to sanitize HTML tags // eslint-disable-next-line no-param-reassign diff --git a/src/router/index.ts b/src/router/index.ts index 73d2cbef8..469a89f2d 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -5,17 +5,85 @@ * @license AGPL3 or later */ import Vue from 'vue'; -import VueRouter from 'vue-router'; +import VueRouter, { Route, RouteConfig } from 'vue-router'; -import Index from '../components/AppIndex.vue'; -import NotFound from '../components/NotFound.vue'; -import RecipeView from '../components/RecipeView/RecipeView.vue'; -import RecipeEdit from '../components/RecipeEdit.vue'; -import Search from '../components/SearchResults.vue'; +import RouteName from 'cookbook/js/Enums/RouteName'; +import { routeToQueryProp } from 'cookbook/js/utils/routeUtils'; +import RecipeViewSidebar from 'cookbook/components/RecipeView/Sidebar/RecipeViewSidebar.vue'; +import NotFound from 'cookbook/components/NotFound.vue'; +import RecipeView from 'cookbook/components/RecipeView/RecipeView.vue'; +import RecipeEdit from 'cookbook/components/RecipeEdit.vue'; +import SearchResults from 'cookbook/components/SearchResults.vue'; Vue.use(VueRouter); -// The router will try to match routers in a descending order. +/** + * Get the named-router - components mapping for paths to a recipe list. + */ +const componentsForSearchRoute = { + default: SearchResults, + 'content-list': SearchResults, +}; + +/** + * Get the props for recipe-listing paths (all recipes, recipes in category, recipes with tags, etc.) + * @param query 'index', 'cat', 'tags', 'general' + */ +function getPropsForSearchRoute(query: string) { + return { + default: (route: Route) => ({ + query, + value: `"${route.params.value}"`, + searchQuery: routeToQueryProp(route), + }), + 'content-list': (route: Route) => ({ + query, + value: `"${route.params.value}"`, + searchQuery: routeToQueryProp(route), + }), + }; +} + +/** + * Get the named-router - components mapping for paths with a selected recipe. + */ +const componentsForRecipeInSearchRoute = { + default: RecipeView, + 'content-list': SearchResults, + 'main-view__active-list': RecipeView, + sidebar: RecipeViewSidebar, +}; + +/** + * Get the named-router - components mapping for paths with the editor for a selected recipe. + */ +const componentsForRecipeEditInSearchRoute = { + default: RecipeEdit, + 'content-list': SearchResults, + 'main-view__active-list': RecipeEdit, +}; + +/** + * Get the props for a selected recipe within recipe-listing paths (all recipes, recipes in category, recipes with tags, etc.) + * @param query 'index', 'cat', 'tags', 'general' + */ +function getPropsForRecipeInSearchRoute(query: string) { + return { + default: (route: Route) => ({ + id: parseInt(route.params.id, 10), + }), + 'content-list': (route: Route) => ({ + query, + value: `"${route.params.value}"`, + searchQuery: routeToQueryProp(route), + }), + 'main-view__active-list': (route: Route) => ({ + id: parseInt(route.params.id, 10), + }), + }; +} + +// The router will try to match routers in descending order. // Routes that share the same root, must be listed from the // most descriptive to the least descriptive, e.g. // /section/component/subcomponent/edit/:id @@ -23,31 +91,86 @@ Vue.use(VueRouter); // /section/component/subcomponent/:id // /section/component/:id // /section/:id -const routes = [ +const routes: RouteConfig[] = [ // Search routes + // Recipes with category { path: '/category/:value', - name: 'search-category', - component: Search, - props: { query: 'cat' }, + name: RouteName.SearchRecipesByCategory, + components: componentsForSearchRoute, + props: getPropsForSearchRoute('cat'), + }, + { + path: '/category/:value/:id', + name: RouteName.ShowRecipeInCategory, + components: componentsForRecipeInSearchRoute, + props: getPropsForRecipeInSearchRoute('cat'), }, + { + path: '/category/:value/:id/edit', + name: RouteName.EditRecipeInCategory, + components: componentsForRecipeEditInSearchRoute, + props: getPropsForRecipeInSearchRoute('cat'), + }, + + // Recipes with name { path: '/name/:value', - name: 'search-name', - component: Search, - props: { query: 'name' }, + name: RouteName.SearchRecipesByName, + components: componentsForSearchRoute, + props: getPropsForSearchRoute('name'), + }, + { + path: '/name/:value/:id', + name: RouteName.ShowRecipeInNames, + components: componentsForRecipeInSearchRoute, + props: getPropsForRecipeInSearchRoute('name'), + }, + { + path: '/name/:value/:id/edit', + name: RouteName.EditRecipeInNames, + components: componentsForRecipeEditInSearchRoute, + props: getPropsForRecipeInSearchRoute('name'), }, + + // General search { path: '/search/:value', - name: 'search-general', - component: Search, - props: { query: 'general' }, + name: RouteName.SearchRecipesByAnything, + components: componentsForSearchRoute, + props: getPropsForSearchRoute('general'), + }, + { + path: '/search/:value/:id', + name: RouteName.ShowRecipeInGeneralSearch, + components: componentsForRecipeInSearchRoute, + props: getPropsForRecipeInSearchRoute('general'), }, + { + path: '/search/:value/:id/edit', + name: RouteName.EditRecipeInGeneralSearch, + components: componentsForRecipeEditInSearchRoute, + props: getPropsForRecipeInSearchRoute('general'), + }, + + // Recipes with tags { path: '/tags/:value', - name: 'search-tags', - component: Search, - props: { query: 'tags' }, + name: RouteName.SearchRecipesByTags, + components: componentsForSearchRoute, + props: getPropsForSearchRoute('tags'), + }, + { + path: '/tags/:value/:id', + name: RouteName.ShowRecipeInTags, + components: componentsForRecipeInSearchRoute, + props: getPropsForRecipeInSearchRoute('tags'), + }, + { + path: '/tags/:value/:id/edit', + name: RouteName.EditRecipeInTags, + components: componentsForRecipeEditInSearchRoute, + props: getPropsForRecipeInSearchRoute('tags'), }, // Recipe routes @@ -61,11 +184,59 @@ const routes = [ // - Create: /{item}/create { path: '/recipe/create', name: 'recipe-create', component: RecipeEdit }, { path: '/recipe/:id/clone', name: 'recipe-clone', component: RecipeEdit }, - { path: '/recipe/:id/edit', name: 'recipe-edit', component: RecipeEdit }, - { path: '/recipe/:id', name: 'recipe-view', component: RecipeView }, + // { path: '/recipe/:id/edit', name: 'recipe-edit', component: RecipeEdit }, + // { + // path: '/recipe/:id', + // name: 'recipe-view', + // // Vue Named Views + // components: { default: RecipeView, sidebar: RecipeViewSidebar }, + // }, + + { + path: '/recipe/:id', + name: RouteName.ShowRecipeInIndex, + components: componentsForRecipeInSearchRoute, + props: { + default: (route: Route) => ({ + id: parseInt(route.params.id, 10), + }), + 'content-list': (route: Route) => ({ + query: 'index', + value: '', + searchQuery: routeToQueryProp(route), + }), + 'main-view__active-list': (route: Route) => ({ + id: parseInt(route.params.id, 10), + }), + }, + }, + { + path: '/recipe/:id/edit', + name: RouteName.EditRecipeInIndex, + components: componentsForRecipeEditInSearchRoute, + props: getPropsForRecipeInSearchRoute('index'), + }, // Index is the last defined route - { path: '/', name: 'index', component: Index }, + // { path: '/', name: 'index', component: Index }, + + { + path: '/', + name: RouteName.Index, + components: componentsForSearchRoute, + props: { + default: (route: Route) => ({ + query: 'index', + value: '', + searchQuery: routeToQueryProp(route), + }), + 'content-list': (route: Route) => ({ + query: 'index', + value: '', + searchQuery: routeToQueryProp(route), + }), + }, + }, // Anything not matched goes to NotFound { path: '*', name: 'not-found', component: NotFound }, diff --git a/src/store/index.ts b/src/store/index.ts index e900c1e26..aaca023c3 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -5,8 +5,11 @@ * @license AGPL3 or later */ import Vue from 'vue'; -import Vuex from 'vuex'; -import api from '../js/api-interface'; +import Vuex, { StoreOptions } from 'vuex'; +import api from 'cookbook/js/utils/api-interface'; +import { Recipe } from 'cookbook/js/Models/schema'; +import RecipeFilter from 'cookbook/js/RecipeFilters/RecipeFilter'; +import ListStyle from 'cookbook/js/Enums/ListStyle'; Vue.use(Vuex); @@ -17,8 +20,34 @@ function showFiltersInRecipeList(): string { return localStorage.getItem('showFiltersInRecipeList') || 'true'; } +/** + * Interface defining the shape of the Vuex state. + */ +interface State { + appNavigation: { + visible: boolean; + refreshRequired: boolean; + }; + user: string | null; + page: string | null; + recipe: Recipe | null; + recipeFilters: RecipeFilter[]; + loadingRecipe: number; + reloadingRecipe: number; + savingRecipe: boolean; + updatingRecipeDirectory: boolean; + categoryUpdating: boolean | null; + localSettings: { + showFiltersInRecipeList: boolean; + recipesListStyle: ListStyle; // Assuming ListStyle is defined elsewhere + }; + config: object | null; +} + // We are using the vuex store linking changes within the components to updates in the navigation panel. -const store = new Vuex.Store({ + +// Create the store with type annotations +const storeOptions: StoreOptions = { // Vuex store handles value changes through actions and mutations. // From the App, you trigger an action, that changes the store // state through a set mutation. You can process the data within @@ -41,9 +70,11 @@ const store = new Vuex.Store({ * @type {Object|null} */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - recipe: null, - // Filter applied to a list of recipes - recipeFilters: '', + recipe: null as Recipe | null, + /** List of filters applied to a list of recipes + * @type {RecipeFilter[]} + */ + recipeFilters: [], // Loading and saving states to determine which loader icons to show. // State of -1 is reserved for recipe and edit views to be set when the // User loads the app at one of these locations and has to wait for an @@ -56,9 +87,11 @@ const store = new Vuex.Store({ // Updating the recipe directory is in progress updatingRecipeDirectory: false, // Category which is being updated (name) - categoryUpdating: null, + categoryUpdating: null as boolean | null, localSettings: { showFiltersInRecipeList: true, + // recipesListStyle: ListStyle.List, + recipesListStyle: ListStyle.Grid, }, config: null, }, @@ -91,16 +124,12 @@ const store = new Vuex.Store({ setPage(state, { p }) { state.page = p; }, - setRecipe(state, { r }) { - const rec = JSON.parse(JSON.stringify(r)); - if (rec === null) { + setRecipe(state, { r }: { r?: Recipe }) { + if (!r) { state.recipe = null; return; } - if ('nutrition' in rec && rec.nutrition instanceof Array) { - rec.nutrition = {}; - } - state.recipe = rec; + state.recipe = r; // Setting recipe also means that loading/reloading the recipe has finished state.loadingRecipe = 0; @@ -108,10 +137,21 @@ const store = new Vuex.Store({ }, setRecipeCategory(state, { c }) { if (state.recipe !== null) { - state.recipe.category = c; + state.recipe.recipeCategory = c; } }, - setRecipeFilters(state, { f }) { + addRecipeFilter( + state, + { newFilter }: { newFilter: RecipeFilter }, + ): void { + const isDuplicate = state.recipeFilters.some((existingFilter) => + existingFilter.equals(newFilter), + ); + if (!isDuplicate) { + state.recipeFilters.push(newFilter); + } + }, + setRecipeFilters(state, { f }: { f: RecipeFilter[] }): void { state.recipeFilters = f; }, setReloadingRecipe(state, { r }) { @@ -140,14 +180,6 @@ const store = new Vuex.Store({ const config = (await api.config.get()).data; c.commit('setConfig', { config }); }, - - /* - * Clears all filters currently applied for listing recipes. - */ - clearRecipeFilters(c) { - c.commit('setRecipeFilters', { f: '' }); - }, - /** * Create new recipe on the server */ @@ -187,12 +219,24 @@ const store = new Vuex.Store({ setPage(c, { page }) { c.commit('setPage', { p: page }); }, - setRecipe(c, { recipe }) { + setRecipe(c, { recipe }: { recipe: Recipe }) { c.commit('setRecipe', { r: recipe }); }, - setRecipeFilters(c, filters) { + + // ======================== + // Recipe filtering + addRecipeFilter(c, newFilter: RecipeFilter): void { + c.commit('addRecipeFilters', { newFilter }); + }, + /* Clears all filters currently used for listing recipes. */ + clearRecipeFilters(c) { + c.commit('setRecipeFilters', { f: [] }); + }, + setRecipeFilters(c, filters: RecipeFilter[]): void { c.commit('setRecipeFilters', { f: filters }); }, + // ======================== + setReloadingRecipe(c, { recipe }) { c.commit('setReloadingRecipe', { r: parseInt(recipe, 10) }); }, @@ -262,7 +306,9 @@ const store = new Vuex.Store({ return request; }, }, -}); +}; + +const store = new Vuex.Store(storeOptions); // eslint-disable-next-line import/prefer-default-export export const useStore = () => store; diff --git a/src/tests/.eslintrc.yml b/src/tests/.eslintrc.yml deleted file mode 100644 index e19b2cfa8..000000000 --- a/src/tests/.eslintrc.yml +++ /dev/null @@ -1,2 +0,0 @@ -env: - jest: true diff --git a/src/tests/unit/LogicOperators/AndOperator.test.ts b/src/tests/unit/LogicOperators/AndOperator.test.ts new file mode 100644 index 000000000..8a7090eeb --- /dev/null +++ b/src/tests/unit/LogicOperators/AndOperator.test.ts @@ -0,0 +1,56 @@ +/** + * Test suite for the AndOperator class. + */ +import { AndOperator } from 'cookbook/js/LogicOperators'; + +describe('AndOperator', () => { + const operator = new AndOperator(); + + test('it should return correct search string for multiple items with a given label', () => { + const items = ['"pizza"', '"pasta"']; + const label = 'tag'; + const expectedOutput = 'tag:"pizza" tag:"pasta"'; + + // Act + const result = + operator.generateStringRepresentationForMultipleOperandsWithLabel( + items, + label, + ); + + // Assert + expect(result).toEqual(expectedOutput); + }); + + test('it should return correct search string for a single item with a given label', () => { + const items = ['"pizza"']; + const label = 'tag'; + const expectedOutput = 'tag:"pizza"'; + + // Act + const result = + operator.generateStringRepresentationForMultipleOperandsWithLabel( + items, + label, + ); + + // Assert + expect(result).toEqual(expectedOutput); + }); + + test('it should return an empty string for no items with a given label', () => { + const items = []; + const label = 'tag'; + const expectedOutput = ''; + + // Act + const result = + operator.generateStringRepresentationForMultipleOperandsWithLabel( + items, + label, + ); + + // Assert + expect(result).toEqual(expectedOutput); + }); +}); diff --git a/src/tests/unit/LogicOperators/OrOperator.test.ts b/src/tests/unit/LogicOperators/OrOperator.test.ts new file mode 100644 index 000000000..54526e49d --- /dev/null +++ b/src/tests/unit/LogicOperators/OrOperator.test.ts @@ -0,0 +1,44 @@ +/** + * Test suite for the OrOperator class. + */ +import { OrOperator } from 'cookbook/js/LogicOperators'; + +describe('OrOperator', () => { + const operator = new OrOperator(); + + test('it should return correct search string for multiple items with a given label', () => { + const items = ['"pizza"', '"pasta"']; + const label = 'tag'; + const expectedOutput = 'tag:"pizza","pasta"'; + expect( + operator.generateStringRepresentationForMultipleOperandsWithLabel( + items, + label, + ), + ).toEqual(expectedOutput); + }); + + test('it should return correct search string for a single item with a given label', () => { + const items = ['"pizza"']; + const label = 'tag'; + const expectedOutput = 'tag:"pizza"'; + expect( + operator.generateStringRepresentationForMultipleOperandsWithLabel( + items, + label, + ), + ).toEqual(expectedOutput); + }); + + test('it should return an empty string for no items with a given label', () => { + const items = []; + const label = 'tag'; + const expectedOutput = ''; + expect( + operator.generateStringRepresentationForMultipleOperandsWithLabel( + items, + label, + ), + ).toEqual(expectedOutput); + }); +}); diff --git a/src/tests/unit/Models/schema/HowToDirection.test.ts b/src/tests/unit/Models/schema/HowToDirection.test.ts new file mode 100644 index 000000000..ddec55d26 --- /dev/null +++ b/src/tests/unit/Models/schema/HowToDirection.test.ts @@ -0,0 +1,283 @@ +import HowToDirection from '../../../../js/Models/schema/HowToDirection'; +import HowToSupply from '../../../../js/Models/schema/HowToSupply'; +import HowToTool from '../../../../js/Models/schema/HowToTool'; + +describe('HowToDirection', () => { + describe('constructor', () => { + test('should set "@type" property to "HowToDirection"', () => { + const direction = new HowToDirection('Step 5'); + + expect(direction).toHaveProperty('@type', 'HowToDirection'); + }); + + test('should create an instance with only text', () => { + const direction = new HowToDirection('Step 1'); + + expect(direction).toBeInstanceOf(HowToDirection); + expect(direction.text).toBe('Step 1'); + expect(direction.position).toBeUndefined(); + expect(direction.image).toStrictEqual([]); + expect(direction.thumbnailUrl).toStrictEqual([]); + expect(direction.timeRequired).toBeUndefined(); + expect(direction.supply).toStrictEqual([]); + expect(direction.tool).toStrictEqual([]); + }); + + test('should create an instance with text and position', () => { + const direction = new HowToDirection('Step 2', { position: 2 }); + + expect(direction).toBeInstanceOf(HowToDirection); + expect(direction.text).toBe('Step 2'); + expect(direction.position).toBe(2); + expect(direction.image).toStrictEqual([]); + expect(direction.thumbnailUrl).toStrictEqual([]); + expect(direction.timeRequired).toBeUndefined(); + expect(direction.supply).toStrictEqual([]); + expect(direction.tool).toStrictEqual([]); + }); + + test('should create an instance with all properties', () => { + const image = ['image1.jpg', 'image2.jpg']; + const thumbnailUrl = ['thumb1.jpg', 'thumb2.jpg']; + const supply: HowToSupply[] = [ + { '@type': 'HowToSupply', name: 'Ingredient 1' }, + ]; + const tool: HowToTool[] = [ + { '@type': 'HowToTool', name: 'Tool 1' }, + ]; + + const direction = new HowToDirection('Step 3', { + position: 3, + image, + thumbnailUrl, + timeRequired: '5 minutes', + supply, + tool, + }); + + expect(direction).toBeInstanceOf(HowToDirection); + expect(direction.text).toBe('Step 3'); + expect(direction.position).toBe(3); + expect(direction.image).toEqual(image); + expect(direction.thumbnailUrl).toEqual(thumbnailUrl); + expect(direction.timeRequired).toBe('5 minutes'); + expect(direction.supply).toEqual(supply); + expect(direction.tool).toEqual(tool); + }); + + test('should create an instance with only text and image string', () => { + const image = 'image1.jpg'; + const thumbnailUrl = 'image1_thumb.jpg'; + const direction = new HowToDirection('Step 4', { + image, + thumbnailUrl, + }); + + expect(direction).toBeInstanceOf(HowToDirection); + expect(direction.text).toBe('Step 4'); + expect(direction.position).toBeUndefined(); + expect(direction.image).toEqual([image]); + expect(direction.thumbnailUrl).toEqual([thumbnailUrl]); + expect(direction.timeRequired).toBeUndefined(); + expect(direction.supply).toStrictEqual([]); + expect(direction.tool).toStrictEqual([]); + }); + }); + + // fromJSON tests + describe('fromJSON', () => { + test('should create a HowToDirection instance from valid JSON', () => { + const json = { + text: 'Mix the ingredients', + position: 1, + image: ['image1.jpg', 'image2.jpg'], + thumbnailUrl: ['thumbnail1.jpg', 'thumbnail2.jpg'], + timeRequired: '10 minutes', + supply: [ + { + '@type': 'HowToSupply', + name: 'Flour', + requiredQuantity: { + value: 200, + unitText: 'grams', + }, + }, + { + '@type': 'HowToSupply', + name: 'Water', + requiredQuantity: { + value: 150, + unitText: 'milliliters', + }, + }, + ], + tool: [ + { + '@type': 'HowToTool', + name: 'Mixing Bowl', + }, + { + '@type': 'HowToTool', + name: 'Spoon', + }, + ], + }; + + const result = HowToDirection.fromJSON(json); + + expect(result).toBeInstanceOf(HowToDirection); + expect(result.text).toEqual('Mix the ingredients'); + expect(result.position).toEqual(1); + expect(result.image).toEqual(['image1.jpg', 'image2.jpg']); + expect(result.thumbnailUrl).toEqual([ + 'thumbnail1.jpg', + 'thumbnail2.jpg', + ]); + expect(result.timeRequired).toEqual('10 minutes'); + + // Validate supply property + expect(result.supply).toBeInstanceOf(Array); + expect(result.supply[0]).toBeInstanceOf(HowToSupply); + expect(result.supply[0].name).toEqual('Flour'); + expect(result.supply[0].requiredQuantity?.value).toEqual(200); + expect(result.supply[0].requiredQuantity?.unitText).toEqual( + 'grams', + ); + + // Validate tool property + expect(result.tool).toBeInstanceOf(Array); + expect(result.tool[0]).toBeInstanceOf(HowToTool); + expect(result.tool[0].name).toEqual('Mixing Bowl'); + }); + + test('should create a HowToDirection instance from valid JSON with single tool and supply', () => { + const json = { + text: 'Mix the ingredients', + position: 1, + image: ['image1.jpg', 'image2.jpg'], + thumbnailUrl: ['thumbnail1.jpg', 'thumbnail2.jpg'], + timeRequired: '10 minutes', + supply: { + '@type': 'HowToSupply', + name: 'Flour', + requiredQuantity: { + value: 200, + unitText: 'grams', + }, + }, + tool: { + '@type': 'HowToTool', + name: 'Mixing Bowl', + }, + }; + + const result = HowToDirection.fromJSON(json); + + expect(result).toBeInstanceOf(HowToDirection); + expect(result.text).toEqual('Mix the ingredients'); + expect(result.position).toEqual(1); + expect(result.image).toEqual(['image1.jpg', 'image2.jpg']); + expect(result.thumbnailUrl).toEqual([ + 'thumbnail1.jpg', + 'thumbnail2.jpg', + ]); + expect(result.timeRequired).toEqual('10 minutes'); + + // Validate supply property + expect(result.supply).toBeInstanceOf(Array); + expect(result.supply[0]).toBeInstanceOf(HowToSupply); + expect(result.supply[0].name).toEqual('Flour'); + expect(result.supply[0].requiredQuantity?.value).toEqual(200); + expect(result.supply[0].requiredQuantity?.unitText).toEqual( + 'grams', + ); + + // Validate tool property + expect(result.tool).toBeInstanceOf(Array); + expect(result.tool[0]).toBeInstanceOf(HowToTool); + expect(result.tool[0].name).toEqual('Mixing Bowl'); + }); + + test('should create a HowToDirection instance from valid JSON with string tool and supply', () => { + const json = { + text: 'Mix the ingredients', + supply: 'Flour', + tool: 'Mixing Bowl', + }; + + const result = HowToDirection.fromJSON(json); + + expect(result).toBeInstanceOf(HowToDirection); + expect(result.text).toEqual('Mix the ingredients'); + + // Validate supply property + expect(result.supply).toBeInstanceOf(Array); + expect(result.supply[0]).toBeInstanceOf(HowToSupply); + expect(result.supply[0].name).toEqual('Flour'); + + // Validate tool property + expect(result.tool).toBeInstanceOf(Array); + expect(result.tool[0]).toBeInstanceOf(HowToTool); + expect(result.tool[0].name).toEqual('Mixing Bowl'); + }); + + test('should handle missing optional properties', () => { + const json = { + text: 'Bake the cake', + }; + + const result = HowToDirection.fromJSON(json); + + expect(result).toBeInstanceOf(HowToDirection); + expect(result.text).toEqual('Bake the cake'); + expect(result.position).toBeUndefined(); + expect(result.image).toEqual([]); + expect(result.thumbnailUrl).toEqual([]); + expect(result.timeRequired).toBeUndefined(); + expect(result.supply).toEqual([]); + expect(result.tool).toEqual([]); + }); + + test('should throw an error for invalid JSON', () => { + const invalidJson = 'Invalid JSON string'; + + expect(() => HowToDirection.fromJSON(invalidJson)).toThrow( + 'Error mapping to "HowToDirection". Received invalid JSON: "Invalid JSON string"', + ); + }); + + test('should throw an error for missing required properties', () => { + const json = { + position: 1, + // Missing required 'text' property + }; + + expect(() => HowToDirection.fromJSON(json)).toThrowError( + 'Error mapping HowToDirection \'text\'. Expected string but received "undefined".', + ); + }); + + test('should handle null or undefined values for optional properties', () => { + const json = { + text: 'Chop the vegetables', + position: null, + image: null, + thumbnailUrl: undefined, + timeRequired: undefined, + supply: null, + tool: undefined, + }; + + const result = HowToDirection.fromJSON(json); + + expect(result).toBeInstanceOf(HowToDirection); + expect(result.text).toEqual('Chop the vegetables'); + expect(result.position).toBeUndefined(); + expect(result.image).toEqual([]); + expect(result.thumbnailUrl).toEqual([]); + expect(result.timeRequired).toBeUndefined(); + expect(result.supply).toEqual([]); + expect(result.tool).toEqual([]); + }); + }); +}); diff --git a/src/tests/unit/Models/schema/HowToSection.test.ts b/src/tests/unit/Models/schema/HowToSection.test.ts new file mode 100644 index 000000000..8f3c8f271 --- /dev/null +++ b/src/tests/unit/Models/schema/HowToSection.test.ts @@ -0,0 +1,231 @@ +import HowToDirection from 'cookbook/js/Models/schema/HowToDirection'; +import HowToSection from 'cookbook/js/Models/schema/HowToSection'; +import HowToStep from 'cookbook/js/Models/schema/HowToStep'; +import HowToTip from 'cookbook/js/Models/schema/HowToTip'; + +describe('HowToSection', () => { + // constructor tests + describe('constructor', () => { + test('should create a HowToSection instance with required properties', () => { + const section = new HowToSection('Section 1'); + + expect(section).toHaveProperty('@type', 'HowToSection'); + expect(section.name).toBe('Section 1'); + }); + + test('should set optional properties when provided in options', () => { + const options = { + description: 'Section description', + position: 2, + image: 'section-image.jpg', + timeRequired: '5 minutes', + thumbnailUrl: 'thumbnail.jpg', + itemListElement: new HowToDirection('Step 1'), + }; + + const section = new HowToSection('Section 2', options); + + expect(section.description).toBe(options.description); + expect(section.position).toBe(options.position); + expect(section.image).toEqual([options.image]); + expect(section.timeRequired).toEqual(options.timeRequired); + expect(section.thumbnailUrl).toEqual([options.thumbnailUrl]); + expect(section.itemListElement).toEqual([options.itemListElement]); + }); + + test('should handle undefined options', () => { + const section = new HowToSection('Section 3', undefined); + + expect(section.description).toBeUndefined(); + expect(section.position).toBeUndefined(); + expect(section.image).toEqual([]); + expect(section.timeRequired).toBeUndefined(); + expect(section.thumbnailUrl).toEqual([]); + expect(section.itemListElement).toEqual([]); + }); + + test('should handle options with undefined properties', () => { + const options = { + description: undefined, + position: undefined, + image: undefined, + timeRequired: undefined, + thumbnailUrl: undefined, + itemListElement: undefined, + }; + + const section = new HowToSection('Section 4', options); + + expect(section.description).toBeUndefined(); + expect(section.position).toBeUndefined(); + expect(section.image).toEqual([]); + expect(section.timeRequired).toBeUndefined(); + expect(section.thumbnailUrl).toEqual([]); + expect(section.itemListElement).toEqual([]); + }); + }); + + // fromJSON tests + describe('fromJSON', () => { + test('should create a HowToSection instance with HowToDirection, HowToStep, and HowToTip elements', () => { + const json = { + name: 'Mixing', + description: 'Mixing ingredients', + position: 2, + image: 'section_image.jpg', + timeRequired: '5 minutes', + thumbnailUrl: 'section_thumbnail.jpg', + itemListElement: [ + { + '@type': 'HowToDirection', + text: 'Mix the flour and water', + position: 1, + image: ['direction_image.jpg'], + thumbnailUrl: ['direction_thumbnail.jpg'], + }, + { + '@type': 'HowToStep', + text: 'Stir the mixture', + position: 2, + image: 'stir_image.jpg', + thumbnailUrl: 'stir_thumbnail.jpg', + }, + { + '@type': 'HowToTip', + text: 'Preheat the oven before mixing', + position: 3, + image: 'tip_image.jpg', + thumbnailUrl: 'tip_thumbnail.jpg', + }, + ], + }; + + const result = HowToSection.fromJSON(json); + + expect(result).toBeInstanceOf(HowToSection); + expect(result.name).toEqual('Mixing'); + expect(result.description).toEqual('Mixing ingredients'); + expect(result.position).toEqual(2); + expect(result.image).toEqual(['section_image.jpg']); + expect(result.timeRequired).toEqual('5 minutes'); + expect(result.thumbnailUrl).toEqual(['section_thumbnail.jpg']); + + // Validate itemListElement property + expect(result.itemListElement).toBeInstanceOf(Array); + expect(result.itemListElement.length).toEqual(3); + + // Validate HowToDirection + expect(result.itemListElement[0]).toBeInstanceOf(HowToDirection); + expect(result.itemListElement[0].text).toEqual( + 'Mix the flour and water', + ); + expect(result.itemListElement[0].position).toEqual(1); + expect(result.itemListElement[0].image).toEqual([ + 'direction_image.jpg', + ]); + expect(result.itemListElement[0].thumbnailUrl).toEqual([ + 'direction_thumbnail.jpg', + ]); + + // Validate HowToStep + expect(result.itemListElement[1]).toBeInstanceOf(HowToStep); + expect(result.itemListElement[1].text).toEqual('Stir the mixture'); + expect(result.itemListElement[1].position).toEqual(2); + expect(result.itemListElement[1].image).toEqual(['stir_image.jpg']); + expect(result.itemListElement[1].thumbnailUrl).toEqual([ + 'stir_thumbnail.jpg', + ]); + + // Validate HowToTip + expect(result.itemListElement[2]).toBeInstanceOf(HowToTip); + expect(result.itemListElement[2].text).toEqual( + 'Preheat the oven before mixing', + ); + expect(result.itemListElement[2].position).toEqual(3); + expect(result.itemListElement[2].image).toEqual(['tip_image.jpg']); + expect(result.itemListElement[2].thumbnailUrl).toEqual([ + 'tip_thumbnail.jpg', + ]); + }); + + test('should handle missing optional properties', () => { + const json = { + name: 'Baking', + itemListElement: [ + { + text: 'Preheat the oven', + position: 1, + }, + ], + }; + + const result = HowToSection.fromJSON(json); + + expect(result).toBeInstanceOf(HowToSection); + expect(result.name).toEqual('Baking'); + expect(result.description).toBeUndefined(); + expect(result.position).toBeUndefined(); + expect(result.image).toEqual([]); + expect(result.timeRequired).toBeUndefined(); + expect(result.thumbnailUrl).toEqual([]); + + // Validate itemListElement property + expect(result.itemListElement).toBeInstanceOf(Array); + expect(result.itemListElement[0]).toBeInstanceOf(HowToDirection); + expect(result.itemListElement[0].text).toEqual('Preheat the oven'); + expect(result.itemListElement[0].position).toEqual(1); + expect(result.itemListElement[0].image).toEqual([]); + expect(result.itemListElement[0].thumbnailUrl).toEqual([]); + }); + + test('should handle string subitems', () => { + const json = { + name: 'Baking', + itemListElement: ['Preheat the oven', 'Chop the olives'], + }; + + const result = HowToSection.fromJSON(json); + + expect(result).toBeInstanceOf(HowToSection); + expect(result.name).toEqual('Baking'); + + // Validate itemListElement property + expect(result.itemListElement).toBeInstanceOf(Array); + expect(result.itemListElement[0]).toBeInstanceOf(HowToDirection); + expect(result.itemListElement[0].text).toEqual('Preheat the oven'); + expect(result.itemListElement[1]).toBeInstanceOf(HowToDirection); + expect(result.itemListElement[1].text).toEqual('Chop the olives'); + }); + + test('should throw an error for invalid JSON', () => { + const invalidJson = 'Invalid JSON string'; + + expect(() => HowToSection.fromJSON(invalidJson)).toThrow( + 'Error mapping to "HowToSection". Received invalid JSON: "Invalid JSON string"', + ); + }); + + test('should handle null or undefined values for optional properties', () => { + const json = { + name: 'Chopping', + description: null, + position: undefined, + image: null, + thumbnailUrl: undefined, + timeRequired: undefined, + itemListElement: null, + }; + + const result = HowToSection.fromJSON(json); + + expect(result).toBeInstanceOf(HowToSection); + expect(result.name).toEqual('Chopping'); + expect(result.description).toBeUndefined(); + expect(result.position).toBeUndefined(); + expect(result.image).toEqual([]); + expect(result.timeRequired).toBeUndefined(); + expect(result.thumbnailUrl).toEqual([]); + expect(result.itemListElement).toEqual([]); + }); + }); +}); diff --git a/src/tests/unit/Models/schema/HowToSupply.test.ts b/src/tests/unit/Models/schema/HowToSupply.test.ts new file mode 100644 index 000000000..139dec09e --- /dev/null +++ b/src/tests/unit/Models/schema/HowToSupply.test.ts @@ -0,0 +1,181 @@ +import HowToSupply from 'cookbook/js/Models/schema/HowToSupply'; + +describe('HowToSupply', () => { + describe('constructor', () => { + test('should set the @type property to "HowToSupply"', () => { + const howToSupply = new HowToSupply('Ingredient'); + expect(howToSupply['@type']).toBe('HowToSupply'); + }); + + test('should create an instance of HowToSupply with optional properties undefined', () => { + const howToSupply = new HowToSupply('Ingredient'); + expect(howToSupply).toBeInstanceOf(HowToSupply); + expect(howToSupply.name).toBe('Ingredient'); + expect(howToSupply.identifier).toBeUndefined(); + expect(howToSupply.description).toBeUndefined(); + expect(howToSupply.requiredQuantity).toBeUndefined(); + }); + + test('should create an instance of HowToSupply with some optional properties defined and some undefined', () => { + const howToSupply = new HowToSupply('Ingredient', 'IGD123'); + expect(howToSupply).toBeInstanceOf(HowToSupply); + expect(howToSupply.name).toBe('Ingredient'); + expect(howToSupply.identifier).toBe('IGD123'); + expect(howToSupply.description).toBeUndefined(); + expect(howToSupply.requiredQuantity).toBeUndefined(); + }); + + test('should create an instance of HowToSupply with all properties set', () => { + const howToSupply = new HowToSupply( + 'Flour', + 'FLR123', + 'High-quality flour', + { + '@type': 'QuantitativeValue', + value: 2, + unitText: 'cup', + unitCode: undefined, + }, + ); + + expect(howToSupply).toBeInstanceOf(HowToSupply); + expect(howToSupply.name).toBe('Flour'); + expect(howToSupply.identifier).toBe('FLR123'); + expect(howToSupply.description).toBe('High-quality flour'); + expect(howToSupply.requiredQuantity).toEqual({ + '@type': 'QuantitativeValue', + value: 2, + unitText: 'cup', + unitCode: undefined, + }); + }); + }); + + describe('fromJSON', () => { + const createValidJSON = () => ({ + name: 'Flour', + identifier: 'FLR123', + description: 'High-quality flour', + requiredQuantity: { + value: 1, + unitText: 'cup', + }, + }); + + it('should create an instance from valid JSON', () => { + const validJSON = createValidJSON(); + const supply = HowToSupply.fromJSON(validJSON); + expect(supply).toBeInstanceOf(HowToSupply); + expect(supply.name).toBe(validJSON.name); + expect(supply.identifier).toBe(validJSON.identifier); + expect(supply.description).toBe(validJSON.description); + expect(supply.requiredQuantity).toBeDefined(); + // Add more specific checks for QuantitativeValue if needed + }); + + it('should create an instance from valid JSON string', () => { + const validJSON = createValidJSON(); + const supply = HowToSupply.fromJSON(JSON.stringify(validJSON)); + expect(supply).toBeInstanceOf(HowToSupply); + expect(supply.name).toBe(validJSON.name); + expect(supply.identifier).toBe(validJSON.identifier); + expect(supply.description).toBe(validJSON.description); + expect(supply.requiredQuantity).toBeDefined(); + // Add more specific checks for QuantitativeValue if needed + }); + + it('should handle missing optional properties', () => { + const validJSON = { name: 'Knife' }; + const supply = HowToSupply.fromJSON(validJSON); + expect(supply).toBeInstanceOf(HowToSupply); + expect(supply.name).toBe(validJSON.name); + expect(supply.identifier).toBeUndefined(); + expect(supply.description).toBeUndefined(); + expect(supply.requiredQuantity).toBeUndefined(); + }); + + it('should throw an error for invalid JSON', () => { + const validJSON = { name: 123 }; // integer 'name' should be automatically converted to string + const supply = HowToSupply.fromJSON(validJSON); + expect(supply).toBeInstanceOf(HowToSupply); + expect(supply.name).toBe('123'); + }); + + it('should throw an error for invalid JSON string', () => { + const validJSONString = '{"name": 123}'; // integer 'name' should be automatically converted to string + const supply = HowToSupply.fromJSON(validJSONString); + expect(supply).toBeInstanceOf(HowToSupply); + expect(supply.name).toBe('123'); + }); + + it('should throw an error for invalid JSON with missing name property', () => { + const invalidJSON = { prop: 123 }; // 'name' is missing + expect(() => HowToSupply.fromJSON(invalidJSON)).toThrow( + 'Error mapping HowToSupply \'name\'. Expected string but received "undefined".', + ); + }); + + it('should throw an error for invalid JSON string with missing name property', () => { + const invalidJSONString = '{"prop": 123}'; // 'name' is missing + expect(() => HowToSupply.fromJSON(invalidJSONString)).toThrow( + 'Error mapping HowToSupply \'name\'. Expected string but received "undefined".', + ); + }); + + test('should throw an error for invalid JSON', () => { + const invalidJson = 'Invalid JSON string'; + + expect(() => HowToSupply.fromJSON(invalidJson)).toThrow( + 'Error mapping to "HowToSupply". Received invalid JSON: "Invalid JSON string"', + ); + }); + }); + + describe('fromJSONOrString', () => { + const createValidJSON = () => ({ + name: 'Flour', + identifier: 'FLR123', + description: 'High-quality flour', + requiredQuantity: { + value: 1, + unitText: 'cup', + }, + }); + + it('should create an instance from valid JSON', () => { + const validJSON = createValidJSON(); + const supply = HowToSupply.fromJSONOrString(validJSON); + expect(supply).toBeInstanceOf(HowToSupply); + expect(supply.name).toBe(validJSON.name); + expect(supply.identifier).toBe(validJSON.identifier); + expect(supply.description).toBe(validJSON.description); + expect(supply.requiredQuantity).toBeDefined(); + // Add more specific checks for QuantitativeValue if needed + }); + + it('should create an instance from valid JSON string', () => { + const validJSON = createValidJSON(); + const supply = HowToSupply.fromJSONOrString( + JSON.stringify(validJSON), + ); + expect(supply).toBeInstanceOf(HowToSupply); + expect(supply.name).toBe(validJSON.name); + expect(supply.identifier).toBe(validJSON.identifier); + expect(supply.description).toBe(validJSON.description); + expect(supply.requiredQuantity).toBeDefined(); + // Add more specific checks for QuantitativeValue if needed + }); + + it('should create new supply for invalid JSON string', () => { + const invalidJSONString = 'Some flour'; // 'name' should be a string + const supply = HowToSupply.fromJSONOrString(invalidJSONString); + expect(supply).toBeInstanceOf(HowToSupply); + expect(supply.name).toBe(invalidJSONString); + }); + + it('should throw an error for invalid JSON', () => { + const invalidJSONString = { prop: 123 }; // 'name' should be a string + expect(() => HowToSupply.fromJSON(invalidJSONString)).toThrow(); + }); + }); +}); diff --git a/src/tests/unit/Models/schema/HowToTool.test.ts b/src/tests/unit/Models/schema/HowToTool.test.ts new file mode 100644 index 000000000..2f6774133 --- /dev/null +++ b/src/tests/unit/Models/schema/HowToTool.test.ts @@ -0,0 +1,213 @@ +import HowToTool from '../../../../js/Models/schema/HowToTool'; + +describe('HowToTool', () => { + describe('constructor', () => { + test('should set the @type property to "HowToTool"', () => { + const howToTool = new HowToTool('ToolA'); + expect(howToTool['@type']).toBe('HowToTool'); + }); + + test('should create an instance of HowToTool with optional properties undefined', () => { + const howToTool = new HowToTool('ToolA'); + expect(howToTool).toBeInstanceOf(HowToTool); + expect(howToTool.name).toBe('ToolA'); + expect(howToTool.identifier).toBeUndefined(); + expect(howToTool.description).toBeUndefined(); + expect(howToTool.requiredQuantity).toBeUndefined(); + }); + + test('should create an instance of HowToTool with some optional properties defined and some undefined', () => { + const howToTool = new HowToTool('ToolA', 'TA123'); + expect(howToTool).toBeInstanceOf(HowToTool); + expect(howToTool.name).toBe('ToolA'); + expect(howToTool.identifier).toBe('TA123'); + expect(howToTool.description).toBeUndefined(); + expect(howToTool.requiredQuantity).toBeUndefined(); + }); + + test('should create an instance of HowToTool with all properties set', () => { + const howToTool = new HowToTool( + 'ToolB', + 'TB123', + 'High-quality tool', + { + '@type': 'QuantitativeValue', + value: 2, + unitText: 'pcs', + unitCode: undefined, + }, + ); + + expect(howToTool).toBeInstanceOf(HowToTool); + expect(howToTool.name).toBe('ToolB'); + expect(howToTool.identifier).toBe('TB123'); + expect(howToTool.description).toBe('High-quality tool'); + expect(howToTool.requiredQuantity).toEqual({ + '@type': 'QuantitativeValue', + value: 2, + unitText: 'pcs', + unitCode: undefined, + }); + }); + }); + + describe('fromJSON', () => { + const createValidJSON = () => ({ + name: 'Knife', + identifier: 'tool123', + description: 'A sharp cutting tool', + requiredQuantity: { + '@type': 'QuantitativeValue', + value: 1, + unitText: 'unit', + }, + }); + + it('should create an instance from valid JSON', () => { + const validJSON = createValidJSON(); + const tool = HowToTool.fromJSON(validJSON); + expect(tool).toBeInstanceOf(HowToTool); + expect(tool.name).toBe(validJSON.name); + expect(tool.identifier).toBe(validJSON.identifier); + expect(tool.description).toBe(validJSON.description); + expect(tool.requiredQuantity).toBeDefined(); + // Add more specific checks for QuantitativeValue if needed + }); + + it('should create an instance from valid JSON string', () => { + const validJSON = createValidJSON(); + const tool = HowToTool.fromJSON(JSON.stringify(validJSON)); + expect(tool).toBeInstanceOf(HowToTool); + expect(tool.name).toBe(validJSON.name); + expect(tool.identifier).toBe(validJSON.identifier); + expect(tool.description).toBe(validJSON.description); + expect(tool.requiredQuantity).toBeDefined(); + // Add more specific checks for QuantitativeValue if needed + }); + + it('should handle missing optional properties', () => { + const validJSON = { name: 'Knife' }; + const tool = HowToTool.fromJSON(validJSON); + expect(tool).toBeInstanceOf(HowToTool); + expect(tool.name).toBe(validJSON.name); + expect(tool.identifier).toBeUndefined(); + expect(tool.description).toBeUndefined(); + expect(tool.requiredQuantity).toBeUndefined(); + }); + + it('should map integer property to string', () => { + const validJSON = { name: 123 }; // integer 'name' should be automatically converted to string + const tool = HowToTool.fromJSON(validJSON); + expect(tool).toBeInstanceOf(HowToTool); + expect(tool.name).toBe('123'); + }); + + it('shouldmap integer property to string for valid JSON string', () => { + const validJSONString = '{"name": 123}'; // integer 'name' should be automatically converted to string + const tool = HowToTool.fromJSON(validJSONString); + expect(tool).toBeInstanceOf(HowToTool); + expect(tool.name).toBe('123'); + }); + + it('should throw an error for invalid JSON with missing name property', () => { + const invalidJSON = { prop: 123 }; // 'name' is missing + expect(() => HowToTool.fromJSON(invalidJSON)).toThrow( + 'Error mapping HowToTool \'name\'. Expected string but received "undefined".', + ); + }); + + it('should throw an error for invalid JSON string with missing name property', () => { + const invalidJSONString = '{"prop": 123}'; // 'name' is missing + expect(() => HowToTool.fromJSON(invalidJSONString)).toThrow( + 'Error mapping HowToTool \'name\'. Expected string but received "undefined".', + ); + }); + + test('should throw an error for invalid JSON', () => { + const invalidJson = 'Invalid JSON string'; + + expect(() => HowToTool.fromJSON(invalidJson)).toThrow( + 'Error mapping to "HowToTool". Received invalid JSON: "Invalid JSON string"', + ); + }); + }); + + describe('fromJSONOrString', () => { + const createValidJSON = () => ({ + name: 'Knife', + identifier: 'tool123', + description: 'A sharp cutting tool', + requiredQuantity: { + '@type': 'QuantitativeValue', + value: 1, + unitText: 'unit', + }, + }); + + const createValidSimpleJSON = () => ({ + name: 'Knife', + requiredQuantity: 3, + }); + + it('should create an instance from valid JSON', () => { + const validJSON = createValidJSON(); + const tool = HowToTool.fromJSONOrString(validJSON); + expect(tool).toBeInstanceOf(HowToTool); + expect(tool.name).toBe(validJSON.name); + expect(tool.identifier).toBe(validJSON.identifier); + expect(tool.description).toBe(validJSON.description); + expect(tool.requiredQuantity).toBeDefined(); + }); + + it('should create an instance from valid JSON string', () => { + const validJSON = createValidJSON(); + const tool = HowToTool.fromJSONOrString(JSON.stringify(validJSON)); + expect(tool).toBeInstanceOf(HowToTool); + expect(tool.name).toBe(validJSON.name); + expect(tool.identifier).toBe(validJSON.identifier); + expect(tool.description).toBe(validJSON.description); + expect(tool.requiredQuantity).toBeDefined(); + }); + + it('should throw an error for invalid JSON', () => { + const validJSON = { name: 123 }; // integer 'name' should be automatically converted to string + const tool = HowToTool.fromJSONOrString(JSON.stringify(validJSON)); + expect(tool).toBeInstanceOf(HowToTool); + expect(tool.name).toBe('123'); + }); + + it('should create new supply for invalid JSON string', () => { + const invalidJSONString = 'Red spatula'; // 'name' should be a string + expect(() => HowToTool.fromJSON(invalidJSONString)).toThrow(); + const tool = HowToTool.fromJSONOrString(invalidJSONString); + expect(tool).toBeInstanceOf(HowToTool); + expect(tool.name).toBe(invalidJSONString); + }); + + it('should create an instance from a simple valid JSON', () => { + const validJSON = createValidSimpleJSON(); + const tool = HowToTool.fromJSONOrString(validJSON); + expect(tool).toBeInstanceOf(HowToTool); + expect(tool.name).toBe(validJSON.name); + expect(tool.identifier).toBeUndefined(); + expect(tool.description).toBeUndefined(); + expect(tool.requiredQuantity).toBeDefined(); + expect(tool.requiredQuantity?.value).toBe( + validJSON.requiredQuantity, + ); + }); + + it('should create an instance from a simple valid JSON string', () => { + const validJSON = createValidSimpleJSON(); + const tool = HowToTool.fromJSONOrString(JSON.stringify(validJSON)); + expect(tool).toBeInstanceOf(HowToTool); + expect(tool.name).toBe(validJSON.name); + expect(tool.identifier).toBeUndefined(); + expect(tool.description).toBeUndefined(); + expect(tool.requiredQuantity).toBeDefined(); + expect(tool.requiredQuantity?.value).toBe( + validJSON.requiredQuantity, + ); + }); + }); +}); diff --git a/src/tests/unit/Models/schema/NutritionInformation.test.ts b/src/tests/unit/Models/schema/NutritionInformation.test.ts new file mode 100644 index 000000000..3849289d9 --- /dev/null +++ b/src/tests/unit/Models/schema/NutritionInformation.test.ts @@ -0,0 +1,145 @@ +import NutritionInformation, { + NutritionInformationProperties, +} from '../../../../js/Models/schema/NutritionInformation'; + +describe('NutritionInformation', () => { + describe('constructor', () => { + test('should set the @type property to "NutritionInformation"', () => { + const properties: NutritionInformationProperties = {}; + + const nutritionInfo = new NutritionInformation(properties); + + expect(nutritionInfo['@type']).toBe('NutritionInformation'); + }); + + test('should create an instance of NutritionInformation with specified properties', () => { + const properties: NutritionInformationProperties = { + calories: '100', + carbohydrateContent: '20', + proteinContent: '15', + servingSize: '1 cup', + sodiumContent: '200', + }; + + const nutritionInfo = new NutritionInformation(properties); + + expect(nutritionInfo).toBeInstanceOf(NutritionInformation); + expect(nutritionInfo.calories).toBe(properties.calories); + expect(nutritionInfo.carbohydrateContent).toBe( + properties.carbohydrateContent, + ); + expect(nutritionInfo.cholesterolContent).toBeUndefined(); // Added test for cholesterolContent + expect(nutritionInfo.fatContent).toBeUndefined(); // Added test for fatContent + expect(nutritionInfo.fiberContent).toBeUndefined(); // Added test for fiberContent + expect(nutritionInfo.proteinContent).toBe( + properties.proteinContent, + ); + expect(nutritionInfo.saturatedFatContent).toBeUndefined(); // Added test for saturatedFatContent + expect(nutritionInfo.servingSize).toBe(properties.servingSize); + expect(nutritionInfo.sodiumContent).toBe(properties.sodiumContent); + expect(nutritionInfo.sugarContent).toBeUndefined(); // Added test for sugarContent + expect(nutritionInfo.transFatContent).toBeUndefined(); // Added test for transFatContent + expect(nutritionInfo.unsaturatedFatContent).toBeUndefined(); // Added test for unsaturatedFatContent + }); + + test('should create an instance of NutritionInformation with all properties set to undefined', () => { + const nutritionInfo = new NutritionInformation(); + + expect(nutritionInfo).toBeInstanceOf(NutritionInformation); + expect(nutritionInfo.calories).toBeUndefined(); + expect(nutritionInfo.carbohydrateContent).toBeUndefined(); + expect(nutritionInfo.cholesterolContent).toBeUndefined(); + expect(nutritionInfo.fatContent).toBeUndefined(); + expect(nutritionInfo.fiberContent).toBeUndefined(); + expect(nutritionInfo.proteinContent).toBeUndefined(); + expect(nutritionInfo.saturatedFatContent).toBeUndefined(); + expect(nutritionInfo.servingSize).toBeUndefined(); + expect(nutritionInfo.sodiumContent).toBeUndefined(); + expect(nutritionInfo.sugarContent).toBeUndefined(); + expect(nutritionInfo.transFatContent).toBeUndefined(); + expect(nutritionInfo.unsaturatedFatContent).toBeUndefined(); + }); + }); + + describe('fromJSON', () => { + it('should create a NutritionInformation instance from valid JSON', () => { + const validJSON = + '{"calories": "100", "carbohydrateContent": "20g", "cholesterolContent": "10mg", "fatContent": "5g", "fiberContent": "3g", "proteinContent": "8g", "saturatedFatContent": "2g", "servingSize": "1 cup", "sodiumContent": "300mg", "sugarContent": "5g", "transFatContent": "0g", "unsaturatedFatContent": "3g"}'; + + const nutritionInfo = NutritionInformation.fromJSON(validJSON); + + expect(nutritionInfo).toBeInstanceOf(NutritionInformation); + expect(nutritionInfo.calories).toEqual('100'); + expect(nutritionInfo.carbohydrateContent).toEqual('20g'); + expect(nutritionInfo.cholesterolContent).toEqual('10mg'); + expect(nutritionInfo.fatContent).toEqual('5g'); + expect(nutritionInfo.fiberContent).toEqual('3g'); + expect(nutritionInfo.proteinContent).toEqual('8g'); + expect(nutritionInfo.saturatedFatContent).toEqual('2g'); + expect(nutritionInfo.servingSize).toEqual('1 cup'); + expect(nutritionInfo.sodiumContent).toEqual('300mg'); + expect(nutritionInfo.sugarContent).toEqual('5g'); + expect(nutritionInfo.transFatContent).toEqual('0g'); + expect(nutritionInfo.unsaturatedFatContent).toEqual('3g'); + }); + + it('should create a NutritionInformation instance with missing properties from JSON', () => { + const validJSON = + '{"calories": "100", "fatContent": "5g", "proteinContent": "8g", "saturatedFatContent": "2g"}'; + + const nutritionInfo = NutritionInformation.fromJSON(validJSON); + + expect(nutritionInfo).toBeInstanceOf(NutritionInformation); + expect(nutritionInfo.calories).toEqual('100'); + expect(nutritionInfo.fatContent).toEqual('5g'); + expect(nutritionInfo.proteinContent).toEqual('8g'); + expect(nutritionInfo.saturatedFatContent).toEqual('2g'); + // Other properties should be undefined + expect(nutritionInfo.carbohydrateContent).toBeUndefined(); + expect(nutritionInfo.cholesterolContent).toBeUndefined(); + expect(nutritionInfo.fiberContent).toBeUndefined(); + expect(nutritionInfo.servingSize).toBeUndefined(); + expect(nutritionInfo.sodiumContent).toBeUndefined(); + expect(nutritionInfo.sugarContent).toBeUndefined(); + expect(nutritionInfo.transFatContent).toBeUndefined(); + expect(nutritionInfo.unsaturatedFatContent).toBeUndefined(); + }); + + it('should throw an error for invalid JSON with non-string (number) property values', () => { + const invalidJSON = + '{"calories": 100, "fatContent": "5g", "proteinContent": "8g", "saturatedFatContent": "2g"}'; + + expect(() => + NutritionInformation.fromJSON(invalidJSON), + ).toThrowError( + 'Invalid property value: "calories" must be a string', + ); + }); + + it('should throw an error for invalid JSON with non-string (object) property values', () => { + const invalidJSON = + '{"calories": "100", "fatContent": {"value": "5g"}, "proteinContent": "8g", "saturatedFatContent": "2g"}'; + + expect(() => + NutritionInformation.fromJSON(invalidJSON), + ).toThrowError( + 'Invalid property value: "fatContent" must be a string', + ); + }); + + test('should throw an error for invalid JSON', () => { + const invalidJson = 'Invalid JSON string'; + + expect(() => NutritionInformation.fromJSON(invalidJson)).toThrow( + 'Error mapping to "NutritionInformation". Received invalid JSON: "Invalid JSON string"', + ); + }); + }); + + describe('isUndefined', () => { + it('return true if no value is defined', () => { + const nutritionInfo = new NutritionInformation(); + expect(nutritionInfo.isUndefined()).toBe(true); + }); + }); +}); diff --git a/src/tests/unit/Models/schema/QuantitativeValue.test.ts b/src/tests/unit/Models/schema/QuantitativeValue.test.ts new file mode 100644 index 000000000..c8faeae97 --- /dev/null +++ b/src/tests/unit/Models/schema/QuantitativeValue.test.ts @@ -0,0 +1,137 @@ +import QuantitativeValue from '../../../../js/Models/schema/QuantitativeValue'; + +describe('QuantitativeValue', () => { + describe('constructor', () => { + test('should set the @type property to "QuantitativeValue"', () => { + const quantitativeValue = new QuantitativeValue(15, { + unitText: 'meter', + }); + expect(quantitativeValue['@type']).toBe('QuantitativeValue'); + }); + + test('should create an instance of QuantitativeValue with unitCode undefined', () => { + const quantitativeValue = new QuantitativeValue(10, { + unitText: 'cup', + }); + expect(quantitativeValue).toBeInstanceOf(QuantitativeValue); + expect(quantitativeValue.value).toBe(10); + expect(quantitativeValue.unitText).toBe('cup'); + expect(quantitativeValue.unitCode).toBeUndefined(); + }); + + test('should create an instance of QuantitativeValue with specified unitCode', () => { + const quantitativeValue = new QuantitativeValue(5, { + unitText: 'kilogram', + unitCode: 'KGM', + }); + expect(quantitativeValue).toBeInstanceOf(QuantitativeValue); + expect(quantitativeValue.value).toBe(5); + expect(quantitativeValue.unitText).toBe('kilogram'); + expect(quantitativeValue.unitCode).toBe('KGM'); + }); + }); + + describe('fromJSON', () => { + it('should create a QuantitativeValue instance from a valid JSON string', () => { + const jsonString = + '{"value": 250, "unitText": "grams", "unitCode": "G"}'; + const quantitativeValue = QuantitativeValue.fromJSON(jsonString); + expect(quantitativeValue).toBeInstanceOf(QuantitativeValue); + expect(quantitativeValue.value).toBe(250); + expect(quantitativeValue.unitText).toBe('grams'); + expect(quantitativeValue.unitCode).toBe('G'); + }); + + it('should create a QuantitativeValue instance from a valid JSON object', () => { + const jsonObject = { value: 2, unitText: 'cups', unitCode: 'CUP' }; + const quantitativeValue = QuantitativeValue.fromJSON(jsonObject); + expect(quantitativeValue).toBeInstanceOf(QuantitativeValue); + expect(quantitativeValue.value).toBe(2); + expect(quantitativeValue.unitText).toBe('cups'); + expect(quantitativeValue.unitCode).toBe('CUP'); + }); + + it('should throw an error for invalid JSON string', () => { + const invalidJsonString = + '{"value": "invalid", "unitText": "grams", "unitCode": "G"}'; + expect(() => QuantitativeValue.fromJSON(invalidJsonString)).toThrow( + 'Error mapping QuantitativeValue \'value\'. Expected integer number but received non-parsable string "invalid".', + ); + }); + + it('should throw an error for missing "value" property', () => { + const jsonString = '{"unitText": "grams", "unitCode": "G"}'; + expect(() => QuantitativeValue.fromJSON(jsonString)).toThrow( + 'Error mapping QuantitativeValue \'value\'. Expected integer number but received "undefined".', + ); + }); + + it('should throw an error for missing "unitText" property', () => { + const jsonString = '{"value": 250, "unitCode": "G"}'; + expect(() => QuantitativeValue.fromJSON(jsonString)).toThrow( + 'Error mapping QuantitativeValue \'value\'. Expected string but received "undefined".', + ); + }); + + it('should convert integer "unitCode" property to string', () => { + const jsonString = + '{"value": 250, "unitText": "grams", "unitCode": 12}'; // Integer unitCode should be automatically mapped to string + const quantitativeValue = QuantitativeValue.fromJSON(jsonString); + expect(quantitativeValue).toBeInstanceOf(QuantitativeValue); + expect(quantitativeValue.unitCode).toBe('12'); + }); + + test('should throw an error for invalid JSON', () => { + const invalidJson = 'Invalid JSON string'; + + expect(() => QuantitativeValue.fromJSON(invalidJson)).toThrow( + 'Error mapping to "QuantitativeValue". Received invalid JSON: "Invalid JSON string"', + ); + }); + }); + + describe('fromJSONOrString', () => { + it('should create a QuantitativeValue instance from valid JSON', () => { + const json = '{"value": 10, "unitText": "cup"}'; + const expected = new QuantitativeValue(10, { unitText: 'cup' }); + expect(QuantitativeValue.fromJSONOrString(json)).toEqual(expected); + }); + + it('should create a QuantitativeValue instance from valid JSON string', () => { + const jsonString = '{"value": 10, "unitText": "cup"}'; + const expected = new QuantitativeValue(10, { unitText: 'cup' }); + expect(QuantitativeValue.fromJSONOrString(jsonString)).toEqual( + expected, + ); + }); + + it('should create a QuantitativeValue instance from a valid string', () => { + const stringValue = '10'; + const expected = new QuantitativeValue(10); + expect(QuantitativeValue.fromJSONOrString(stringValue)).toEqual( + expected, + ); + }); + + it('should throw an error for invalid JSON', () => { + const invalidJson = '{value: 10}'; + expect(() => + QuantitativeValue.fromJSONOrString(invalidJson), + ).toThrow(); + }); + + it('should throw an error for invalid JSON string', () => { + const invalidJsonString = '{"value": 10, unitText: "cup"}'; // missing quotes + expect(() => + QuantitativeValue.fromJSONOrString(invalidJsonString), + ).toThrow(); + }); + + it('should throw an error for invalid string', () => { + const invalidString = 'invalid'; // no number + expect(() => + QuantitativeValue.fromJSONOrString(invalidString), + ).toThrow(); + }); + }); +}); diff --git a/src/tests/unit/Models/schema/Recipe.test.ts b/src/tests/unit/Models/schema/Recipe.test.ts new file mode 100644 index 000000000..18fc60244 --- /dev/null +++ b/src/tests/unit/Models/schema/Recipe.test.ts @@ -0,0 +1,423 @@ +import HowToDirection from 'cookbook/js/Models/schema/HowToDirection'; +import HowToSection from 'cookbook/js/Models/schema/HowToSection'; +import HowToStep from 'cookbook/js/Models/schema/HowToStep'; +import HowToSupply from 'cookbook/js/Models/schema/HowToSupply'; +import HowToTool from 'cookbook/js/Models/schema/HowToTool'; +import NutritionInformation from 'cookbook/js/Models/schema/NutritionInformation'; +import Recipe from 'cookbook/js/Models/schema/Recipe'; + +describe('Recipe', () => { + // constructor tests + describe('constructor', () => { + const recipeId = '123'; + const recipeName = 'Test Recipe'; + + test('should create a Recipe instance with required properties', () => { + const recipe = new Recipe(recipeId, recipeName); + + expect(recipe).toHaveProperty('@type', 'Recipe'); + expect(recipe.identifier).toBe(recipeId); + expect(recipe.name).toBe(recipeName); + expect(recipe.image).toStrictEqual([]); + expect(recipe.imageUrl).toStrictEqual([]); + expect(recipe.keywords).toStrictEqual([]); + expect(recipe.recipeIngredient).toStrictEqual([]); + expect(recipe.supply).toStrictEqual([]); + expect(recipe.recipeInstructions).toStrictEqual([]); + expect(recipe.tool).toStrictEqual([]); + expect(recipe.url).toStrictEqual([]); + }); + + test('should set optional properties when provided in options', () => { + const options = { + recipeCategory: 'Dinner', + dateCreated: '2022-01-01', + dateModified: '2022-01-02', + description: 'A delicious recipe', + image: 'recipe-image.jpg', + imageUrl: 'recipe-thumbnail.jpg', + keywords: 'delicious, easy', + totalTime: 'PT1H', + cookTime: 'PT30M', + prepTime: 'PT30M', + nutrition: new NutritionInformation({ + calories: '100', + carbohydrateContent: '20', + proteinContent: '15', + servingSize: '1 cup', + sodiumContent: '200', + }), + recipeIngredient: '1 cup flour', + recipeYield: 4, + supply: new HowToSupply('Flour', '1 cup'), + recipeInstructions: new HowToStep('Mix the ingredients', []), + tool: new HowToTool('Mixing Bowl'), + url: 'https://example.com/recipe', + }; + + const recipe = new Recipe(recipeId, recipeName, options); + + expect(recipe.recipeCategory).toBe(options.recipeCategory); + expect(recipe.dateCreated).toBe(options.dateCreated); + expect(recipe.dateModified).toBe(options.dateModified); + expect(recipe.description).toBe(options.description); + expect(recipe.image).toEqual([options.image]); + expect(recipe.imageUrl).toEqual([options.imageUrl]); + expect(recipe.keywords).toEqual([options.keywords]); + expect(recipe.totalTime).toBe(options.totalTime); + expect(recipe.cookTime).toBe(options.cookTime); + expect(recipe.prepTime).toBe(options.prepTime); + expect(recipe.nutrition).toEqual(options.nutrition); + expect(recipe.recipeIngredient).toEqual([options.recipeIngredient]); + expect(recipe.recipeYield).toBe(options.recipeYield); + expect(recipe.supply).toEqual([options.supply]); + expect(recipe.recipeInstructions).toEqual([ + options.recipeInstructions, + ]); + expect(recipe.tool).toEqual([options.tool]); + expect(recipe.url).toEqual([options.url]); + }); + + test('should handle undefined options', () => { + const recipe = new Recipe(recipeId, recipeName, undefined); + + expect(recipe.recipeCategory).toBeUndefined(); + expect(recipe.dateCreated).toBeUndefined(); + expect(recipe.dateModified).toBeUndefined(); + expect(recipe.description).toBeUndefined(); + expect(recipe.image).toStrictEqual([]); + expect(recipe.imageUrl).toStrictEqual([]); + expect(recipe.keywords).toStrictEqual([]); + expect(recipe.totalTime).toBeUndefined(); + expect(recipe.cookTime).toBeUndefined(); + expect(recipe.prepTime).toBeUndefined(); + expect(recipe.nutrition).toBeUndefined(); + expect(recipe.recipeIngredient).toStrictEqual([]); + expect(recipe.recipeYield).toBeUndefined(); + expect(recipe.supply).toStrictEqual([]); + expect(recipe.recipeInstructions).toStrictEqual([]); + expect(recipe.tool).toStrictEqual([]); + expect(recipe.url).toStrictEqual([]); + }); + + test('should handle options with undefined properties', () => { + const options = { + recipeCategory: undefined, + dateCreated: undefined, + dateModified: undefined, + description: undefined, + image: undefined, + imageUrl: undefined, + keywords: undefined, + totalTime: undefined, + cookTime: undefined, + prepTime: undefined, + nutrition: undefined, + recipeIngredient: undefined, + recipeYield: undefined, + supply: undefined, + recipeInstructions: undefined, + tool: undefined, + url: undefined, + }; + + const recipe = new Recipe(recipeId, recipeName, options); + + expect(recipe.recipeCategory).toBeUndefined(); + expect(recipe.dateCreated).toBeUndefined(); + expect(recipe.dateModified).toBeUndefined(); + expect(recipe.description).toBeUndefined(); + expect(recipe.image).toStrictEqual([]); + expect(recipe.imageUrl).toStrictEqual([]); + expect(recipe.keywords).toStrictEqual([]); + expect(recipe.totalTime).toBeUndefined(); + expect(recipe.cookTime).toBeUndefined(); + expect(recipe.prepTime).toBeUndefined(); + expect(recipe.nutrition).toBeUndefined(); + expect(recipe.recipeIngredient).toStrictEqual([]); + expect(recipe.recipeYield).toBeUndefined(); + expect(recipe.supply).toStrictEqual([]); + expect(recipe.recipeInstructions).toStrictEqual([]); + expect(recipe.tool).toStrictEqual([]); + expect(recipe.url).toStrictEqual([]); + }); + + test('should return same value for id and identifier', () => { + const recipe = new Recipe(recipeId, recipeName, undefined); + + expect(recipe.identifier).toBe(recipe.id); + }); + }); + + // fromJSON() tests + describe('fromJSON', () => { + test('should create a Recipe instance from valid JSON with minimal properties', () => { + const minimalJson = { + identifier: 'recipeMinimal', + name: 'Minimal Recipe', + }; + + const recipe = Recipe.fromJSON(minimalJson); + + // Assertions + expect(recipe.identifier).toBe('recipeMinimal'); + expect(recipe.name).toBe('Minimal Recipe'); + }); + + test('should create a Recipe instance with default values for missing/undefined/null properties', () => { + const jsonWithDefaults = { + identifier: 'recipeWithDefaults', + name: 'Recipe With Defaults', + description: null, + dateCreated: undefined, + dateModified: null, + image: undefined, + imageUrl: null, + keywords: null, + cookTime: null, + prepTime: undefined, + totalTime: null, + nutrition: null, + recipeIngredient: undefined, + recipeYield: null, + supply: null, + recipeInstructions: null, + tool: undefined, + url: null, + }; + + const recipe = Recipe.fromJSON(jsonWithDefaults); + + // Assertions + expect(recipe.identifier).toBe('recipeWithDefaults'); + expect(recipe.name).toBe('Recipe With Defaults'); + expect(recipe.description).toBeUndefined(); + expect(recipe.dateCreated).toBeUndefined(); + expect(recipe.dateModified).toBeUndefined(); + expect(recipe.image).toEqual([]); + expect(recipe.imageUrl).toEqual([]); + expect(recipe.keywords).toEqual([]); + expect(recipe.cookTime).toBeUndefined(); + expect(recipe.prepTime).toBeUndefined(); + expect(recipe.totalTime).toBeUndefined(); + expect(recipe.nutrition).toBeUndefined(); + expect(recipe.recipeIngredient).toEqual([]); + expect(recipe.recipeYield).toBeUndefined(); + expect(recipe.supply).toEqual([]); + expect(recipe.recipeInstructions).toEqual([]); + expect(recipe.tool).toEqual([]); + expect(recipe.url).toEqual([]); + }); + + test('should handle variations of valid JSON with null/undefined properties', () => { + const jsonWithVariations = { + identifier: 'recipeVariations', + name: 'Recipe Variations', + description: 'Some description', + dateCreated: null, + dateModified: '2022-02-15T10:00:00Z', + image: ['image1.jpg', 'image2.jpg'], + imageUrl: undefined, + keywords: null, + cookTime: 'PT45M', + prepTime: null, + totalTime: undefined, + nutrition: { + calories: null, + fatContent: '10g', + }, + recipeIngredient: 'Ingredient', + recipeYield: null, + supply: { name: 'Supply1' }, + recipeInstructions: [ + { + '@type': 'HowToStep', + text: 'Step 1: Do something', + }, + { + '@type': 'HowToSection', + name: 'Section 1', + itemListElement: null, + }, + ], + tool: undefined, + url: 'https://example.com/recipeVariations', + }; + + const recipe = Recipe.fromJSON(jsonWithVariations); + + // Assertions + expect(recipe.identifier).toBe('recipeVariations'); + expect(recipe.name).toBe('Recipe Variations'); + expect(recipe.description).toBe('Some description'); + expect(recipe.dateCreated).toBeUndefined(); + expect(recipe.dateModified).toBe('2022-02-15T10:00:00Z'); + expect(recipe.image).toEqual(['image1.jpg', 'image2.jpg']); + expect(recipe.imageUrl).toEqual([]); + expect(recipe.keywords).toEqual([]); + expect(recipe.cookTime).toBe('PT45M'); + expect(recipe.prepTime).toBeUndefined(); + expect(recipe.totalTime).toBeUndefined(); + expect(recipe.nutrition).toBeInstanceOf(NutritionInformation); + expect(recipe.recipeIngredient).toEqual(['Ingredient']); + expect(recipe.recipeYield).toBeUndefined(); + expect(recipe.supply[0].name).toBe('Supply1'); + expect(recipe.recipeInstructions).toHaveLength(2); + expect(recipe.recipeInstructions[0]).toBeInstanceOf(HowToStep); + expect(recipe.recipeInstructions[1]).toBeInstanceOf(HowToSection); + expect(recipe.tool).toEqual([]); + expect(recipe.url).toStrictEqual([ + 'https://example.com/recipeVariations', + ]); + }); + + test('should throw an error for invalid JSON', () => { + const invalidJson = 'Invalid JSON string'; + + expect(() => Recipe.fromJSON(invalidJson)).toThrow( + 'Error mapping to "Recipe". Received invalid JSON: "Invalid JSON string"', + ); + }); + + test('should handle variations of valid JSON with arrays for properties supporting array values', () => { + const jsonWithArrays = { + identifier: 'recipeArrays', + name: 'Recipe with arrays', + image: ['image1.jpg', 'image2.jpg'], // Array value + imageUrl: ['image3.jpg', 'image4.jpg'], // Array value + keywords: ['keyword1', 'keyword2'], // Array value + recipeIngredient: ['1 cup flour', '1 kg butter'], // Array value + recipeInstructions: [ + { '@type': 'HowToStep', text: 'Step 1: Do something' }, + { '@type': 'HowToStep', text: 'Step 2: So something else' }, + ], // Array value + supply: [{ name: 'Supply1' }, { name: 'Supply2' }], // Array value + tool: [{ name: 'Tool1' }, { name: 'Tool2' }], // Array value + url: [ + 'https://example.com/recipe', + 'https://example.com/recipe2', + ], // Array value + }; + + const recipe = Recipe.fromJSON(jsonWithArrays); + + // Assertions + expect(recipe.identifier).toBe('recipeArrays'); + expect(recipe.name).toBe('Recipe with arrays'); + expect(recipe.image).toEqual(['image1.jpg', 'image2.jpg']); + expect(recipe.imageUrl).toEqual(['image3.jpg', 'image4.jpg']); + expect(recipe.keywords).toEqual(['keyword1', 'keyword2']); + expect(recipe.recipeIngredient).toEqual([ + '1 cup flour', + '1 kg butter', + ]); + expect(recipe.recipeInstructions).toBeInstanceOf( + Array, + ); + expect((recipe.recipeInstructions[0] as HowToStep).text).toBe( + 'Step 1: Do something', + ); + expect((recipe.recipeInstructions[1] as HowToStep).text).toBe( + 'Step 2: So something else', + ); + expect(recipe.supply).toBeInstanceOf(Array); + expect((recipe.supply[0] as HowToSupply).name).toBe('Supply1'); + expect((recipe.supply[1] as HowToSupply).name).toBe('Supply2'); + expect(recipe.tool).toBeInstanceOf(Array); + expect((recipe.tool[0] as HowToTool).name).toBe('Tool1'); + expect((recipe.tool[1] as HowToTool).name).toBe('Tool2'); + expect(recipe.url).toEqual([ + 'https://example.com/recipe', + 'https://example.com/recipe2', + ]); // Converted to array + }); + + test('should handle variations of valid JSON with arrays for properties supporting single values', () => { + const jsonWithArrays = { + identifier: 'recipeSingleValues', + name: 'Recipe with arrays', + image: 'image1.jpg', // Single value + imageUrl: 'image3.jpg', // Single value + keywords: 'keyword1', // Single value + recipeIngredient: '1 cup flour', // Single value + recipeInstructions: { text: 'Step 1: Do something' }, // Single value + supply: { name: 'Supply1' }, // Single value + tool: { name: 'Tool1' }, // Single value + url: 'https://example.com/recipe', // Single value + }; + + const recipe = Recipe.fromJSON(jsonWithArrays); + + // Assertions + expect(recipe.identifier).toBe('recipeSingleValues'); + expect(recipe.name).toBe('Recipe with arrays'); + expect(recipe.image).toEqual(['image1.jpg']); // Converted to array + expect(recipe.imageUrl).toEqual(['image3.jpg']); // Converted to array + expect(recipe.keywords).toEqual(['keyword1']); // Converted to array + expect(recipe.recipeIngredient).toEqual(['1 cup flour']); // Converted to array + expect(recipe.recipeInstructions).toBeInstanceOf( + Array, + ); // Converted to array + expect((recipe.recipeInstructions[0] as HowToStep).text).toBe( + 'Step 1: Do something', + ); + expect(recipe.supply).toBeInstanceOf(Array); // Converted to array + expect((recipe.supply[0] as HowToSupply).name).toBe('Supply1'); + expect(recipe.tool).toBeInstanceOf(Array); // Converted to array + expect((recipe.tool[0] as HowToTool).name).toBe('Tool1'); + expect(recipe.url).toEqual(['https://example.com/recipe']); // Converted to array + }); + + test('should handle variations of valid JSON with simple strings in arrays', () => { + const jsonWithArrays = { + identifier: 'recipeArrays', + name: 'Recipe with arrays', + recipeInstructions: ['Step 1: Do something'], // Array value + supply: ['Supply1'], // Array value + tool: ['Tool1'], // Array value + }; + + const recipe = Recipe.fromJSON(jsonWithArrays); + + // Assertions + expect(recipe.identifier).toBe('recipeArrays'); + expect(recipe.name).toBe('Recipe with arrays'); + expect(recipe.recipeInstructions).toBeInstanceOf( + Array, + ); // Converted to HowToDirection[] + expect((recipe.recipeInstructions[0] as HowToStep).text).toBe( + 'Step 1: Do something', + ); + expect(recipe.supply).toBeInstanceOf(Array); // Converted to HowToSupply[] + expect((recipe.supply[0] as HowToSupply).name).toBe('Supply1'); + expect(recipe.tool).toBeInstanceOf(Array); // Converted to HowToTool[] + expect((recipe.tool[0] as HowToTool).name).toBe('Tool1'); + }); + + test('should handle variations of valid JSON with simple strings', () => { + const jsonWithArrays = { + identifier: 'recipeSingleValues', + name: 'Recipe with arrays', + recipeInstructions: 'Step 1: Do something', // Single value + supply: 'Supply1', // Single value + tool: 'Tool1', // Single value + }; + + const recipe = Recipe.fromJSON(jsonWithArrays); + + // Assertions + expect(recipe.identifier).toBe('recipeSingleValues'); + expect(recipe.name).toBe('Recipe with arrays'); + expect(recipe.recipeInstructions).toBeInstanceOf( + Array, + ); // Converted to array + expect((recipe.recipeInstructions[0] as HowToStep).text).toBe( + 'Step 1: Do something', + ); + expect(recipe.supply).toBeInstanceOf(Array); // Converted to array + expect((recipe.supply[0] as HowToSupply).name).toBe('Supply1'); + expect(recipe.tool).toBeInstanceOf(Array); // Converted to array + expect((recipe.tool[0] as HowToTool).name).toBe('Tool1'); + }); + }); +}); diff --git a/src/tests/unit/RecipeFilters/RecipeCategoriesFilter.test.js b/src/tests/unit/RecipeFilters/RecipeCategoriesFilter.test.js deleted file mode 100644 index 192a33daa..000000000 --- a/src/tests/unit/RecipeFilters/RecipeCategoriesFilter.test.js +++ /dev/null @@ -1,245 +0,0 @@ -import RecipeCategoriesFilter from '../../../js/RecipeFilters/RecipeCategoriesFilter'; -import { AndOperator, OrOperator } from '../../../js/LogicOperators'; - -/** - * Test suite for the RecipeCategoriesFilter class. - */ -describe('RecipeCategoriesFilter', () => { - /** @type {Object[]} recipes - Array of recipe objects for testing. */ - const recipes = [ - { category: 'main course' }, - { category: ['salad', 'appetizer'] }, - { category: 'dessert' }, - { category: ['vegetarian', 'pizza'] }, - { category: ['pizza', 'appetizer'] }, - { category: 'pizza' }, - { category: ['pasta', 'main course'] }, - { category: 'breakfast' }, - { category: ['cake', 'dessert'] }, - { category: ['cake', 'dessert', 'gluten-free'] }, - { category: ['soufflé', 'dessert'] }, - { category: ['cake', 'gluten-free'] }, - { category: 'cake' }, - ]; - - /** - * Test case: it should filter recipes by a single category using AND operator. - */ - test('it should filter recipes by a single category using AND operator', () => { - const filter = new RecipeCategoriesFilter( - 'main course', - new AndOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(2); - expect(filteredRecipes[0].category).toBe('main course'); - expect(filteredRecipes[1].category).toEqual(['pasta', 'main course']); - }); - - /** - * Test case: it should filter recipes by a single category using OR operator. - */ - test('it should filter recipes by a single category using OR operator', () => { - const filter = new RecipeCategoriesFilter('pizza', new OrOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(3); - expect(filteredRecipes.map((recipe) => recipe.category)).toEqual([ - ['vegetarian', 'pizza'], - ['pizza', 'appetizer'], - 'pizza', - ]); - }); - - /** - * Test case: it should filter recipes when categories property is a string using AND operator. - */ - test('it should filter recipes when categories property is a string using AND operator', () => { - const filter = new RecipeCategoriesFilter( - 'breakfast', - new AndOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(1); - expect(filteredRecipes[0].category).toBe('breakfast'); - }); - - /** - * Test case: it should filter recipes when categories property is a string using OR operator. - */ - test('it should filter recipes when categories property is a string using OR operator', () => { - const filter = new RecipeCategoriesFilter( - 'breakfast', - new OrOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(1); - expect(filteredRecipes[0].category).toBe('breakfast'); - }); - - /** - * Test case: it should filter recipes by multiple categories using AND operator. - */ - test('it should filter recipes by multiple categories using AND operator', () => { - const filter = new RecipeCategoriesFilter( - ['cake', 'dessert'], - new AndOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(2); - expect(filteredRecipes[0].category).toEqual(['cake', 'dessert']); - expect(filteredRecipes.map((recipe) => recipe.category)).toEqual([ - ['cake', 'dessert'], - ['cake', 'dessert', 'gluten-free'], - ]); - }); - - /** - * Test case: it should filter recipes by multiple categories using OR operator. - */ - test('it should filter recipes by multiple categories using OR operator', () => { - const filter = new RecipeCategoriesFilter( - ['breakfast', 'dessert'], - new OrOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(5); - expect(filteredRecipes.map((recipe) => recipe.category)).toEqual([ - 'dessert', - 'breakfast', - ['cake', 'dessert'], - ['cake', 'dessert', 'gluten-free'], - ['soufflé', 'dessert'], - ]); - }); - - /** - * Test case: it should handle case-insensitive filtering. - */ - test('it should handle case-insensitive filtering', () => { - const filter = new RecipeCategoriesFilter( - 'bREAKfast', - new AndOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(1); - expect(filteredRecipes[0].category).toBe('breakfast'); - }); - - /** - * Test case: it should handle empty categories list and return all recipes with OR operator. - */ - test('it should handle empty categories list and return all recipes with OR operator', () => { - const filter = new RecipeCategoriesFilter([], new OrOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(recipes.length); - expect(filteredRecipes.map((recipe) => recipe.category)).toEqual([ - 'main course', - ['salad', 'appetizer'], - 'dessert', - ['vegetarian', 'pizza'], - ['pizza', 'appetizer'], - 'pizza', - ['pasta', 'main course'], - 'breakfast', - ['cake', 'dessert'], - ['cake', 'dessert', 'gluten-free'], - ['soufflé', 'dessert'], - ['cake', 'gluten-free'], - 'cake', - ]); - }); - - /** - * Test case: it should handle empty categories list and return all recipes with AND operator. - */ - test('it should handle empty categories list and return no recipes with AND operator', () => { - const filter = new RecipeCategoriesFilter([], new AndOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(recipes.length); - expect(filteredRecipes.map((recipe) => recipe.category)).toEqual([ - 'main course', - ['salad', 'appetizer'], - 'dessert', - ['vegetarian', 'pizza'], - ['pizza', 'appetizer'], - 'pizza', - ['pasta', 'main course'], - 'breakfast', - ['cake', 'dessert'], - ['cake', 'dessert', 'gluten-free'], - ['soufflé', 'dessert'], - ['cake', 'gluten-free'], - 'cake', - ]); - }); - - /** - * Test case: it should handle empty-string categories and return all recipes with OR operator. - */ - test('it should handle empty-string categories and return all recipes with OR operator', () => { - const filter = new RecipeCategoriesFilter([''], new OrOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(recipes.length); - expect(filteredRecipes.map((recipe) => recipe.category)).toEqual([ - 'main course', - ['salad', 'appetizer'], - 'dessert', - ['vegetarian', 'pizza'], - ['pizza', 'appetizer'], - 'pizza', - ['pasta', 'main course'], - 'breakfast', - ['cake', 'dessert'], - ['cake', 'dessert', 'gluten-free'], - ['soufflé', 'dessert'], - ['cake', 'gluten-free'], - 'cake', - ]); - }); - - /** - * Test case: it should handle empty-string categories and return all recipes with AND operator. - */ - test('it should handle empty-string categories and return no recipes with AND operator', () => { - const filter = new RecipeCategoriesFilter([''], new AndOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(recipes.length); - expect(filteredRecipes.map((recipe) => recipe.category)).toEqual([ - 'main course', - ['salad', 'appetizer'], - 'dessert', - ['vegetarian', 'pizza'], - ['pizza', 'appetizer'], - 'pizza', - ['pasta', 'main course'], - 'breakfast', - ['cake', 'dessert'], - ['cake', 'dessert', 'gluten-free'], - ['soufflé', 'dessert'], - ['cake', 'gluten-free'], - 'cake', - ]); - }); -}); diff --git a/src/tests/unit/RecipeFilters/RecipeCategoriesFilter.test.ts b/src/tests/unit/RecipeFilters/RecipeCategoriesFilter.test.ts new file mode 100644 index 000000000..4629046a6 --- /dev/null +++ b/src/tests/unit/RecipeFilters/RecipeCategoriesFilter.test.ts @@ -0,0 +1,297 @@ +import RecipeCategoriesFilter from 'cookbook/js/RecipeFilters/RecipeCategoriesFilter'; +import { AndOperator, OrOperator } from 'cookbook/js/LogicOperators'; +import { Recipe } from 'cookbook/js/Models/schema'; + +function createRecipeWithCategories(categories: string | string[]): Recipe { + const recipe = new Recipe('123', 'recipe'); + recipe.recipeCategory = categories; + return recipe; +} + +/** + * Test suite for the RecipeCategoriesFilter class. + */ +describe('RecipeCategoriesFilter', () => { + /** @type {Object[]} recipes - Array of recipe objects for testing. */ + const recipes: Recipe[] = [ + createRecipeWithCategories('main course'), + createRecipeWithCategories(['salad', 'appetizer']), + createRecipeWithCategories('dessert'), + createRecipeWithCategories(['vegetarian', 'pizza']), + createRecipeWithCategories(['pizza', 'appetizer']), + createRecipeWithCategories('pizza'), + createRecipeWithCategories(['pasta', 'main course']), + createRecipeWithCategories('breakfast'), + createRecipeWithCategories(['cake', 'dessert']), + createRecipeWithCategories(['cake', 'dessert', 'gluten-free']), + createRecipeWithCategories(['soufflé', 'dessert']), + createRecipeWithCategories(['cake', 'gluten-free']), + createRecipeWithCategories('cake'), + ]; + + /** + * Test case: it should filter recipes by a single category using AND operator. + */ + test('it should filter recipes by a single category using AND operator', () => { + const filter = new RecipeCategoriesFilter( + 'main course', + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes[0].recipeCategory).toBe('main course'); + expect(filteredRecipes[1].recipeCategory).toEqual([ + 'pasta', + 'main course', + ]); + }); + + /** + * Test case: it should filter recipes by a single category using OR operator. + */ + test('it should filter recipes by a single category using OR operator', () => { + const filter = new RecipeCategoriesFilter('pizza', new OrOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(3); + expect(filteredRecipes.map((recipe) => recipe.recipeCategory)).toEqual([ + ['vegetarian', 'pizza'], + ['pizza', 'appetizer'], + 'pizza', + ]); + }); + + /** + * Test case: it should filter recipes when categories property is a string using AND operator. + */ + test('it should filter recipes when categories property is a string using AND operator', () => { + const filter = new RecipeCategoriesFilter( + 'breakfast', + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes[0].recipeCategory).toBe('breakfast'); + }); + + /** + * Test case: it should filter recipes when categories property is a string using OR operator. + */ + test('it should filter recipes when categories property is a string using OR operator', () => { + const filter = new RecipeCategoriesFilter( + 'breakfast', + new OrOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes[0].recipeCategory).toBe('breakfast'); + }); + + /** + * Test case: it should filter recipes by multiple categories using AND operator. + */ + test('it should filter recipes by multiple categories using AND operator', () => { + const filter = new RecipeCategoriesFilter( + ['cake', 'dessert'], + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes[0].recipeCategory).toEqual(['cake', 'dessert']); + expect(filteredRecipes.map((recipe) => recipe.recipeCategory)).toEqual([ + ['cake', 'dessert'], + ['cake', 'dessert', 'gluten-free'], + ]); + }); + + /** + * Test case: it should filter recipes by multiple categories using OR operator. + */ + test('it should filter recipes by multiple categories using OR operator', () => { + const filter = new RecipeCategoriesFilter( + ['breakfast', 'dessert'], + new OrOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(5); + expect(filteredRecipes.map((recipe) => recipe.recipeCategory)).toEqual([ + 'dessert', + 'breakfast', + ['cake', 'dessert'], + ['cake', 'dessert', 'gluten-free'], + ['soufflé', 'dessert'], + ]); + }); + + /** + * Test case: it should handle case-insensitive filtering. + */ + test('it should handle case-insensitive filtering', () => { + const filter = new RecipeCategoriesFilter( + 'bREAKfast', + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes[0].recipeCategory).toBe('breakfast'); + }); + + /** + * Test case: it should handle empty categories list and return all recipes with OR operator. + */ + test('it should handle empty categories list and return all recipes with OR operator', () => { + const filter = new RecipeCategoriesFilter([], new OrOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.recipeCategory)).toEqual([ + 'main course', + ['salad', 'appetizer'], + 'dessert', + ['vegetarian', 'pizza'], + ['pizza', 'appetizer'], + 'pizza', + ['pasta', 'main course'], + 'breakfast', + ['cake', 'dessert'], + ['cake', 'dessert', 'gluten-free'], + ['soufflé', 'dessert'], + ['cake', 'gluten-free'], + 'cake', + ]); + }); + + /** + * Test case: it should handle empty categories list and return all recipes with AND operator. + */ + test('it should handle empty categories list and return no recipes with AND operator', () => { + const filter = new RecipeCategoriesFilter([], new AndOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.recipeCategory)).toEqual([ + 'main course', + ['salad', 'appetizer'], + 'dessert', + ['vegetarian', 'pizza'], + ['pizza', 'appetizer'], + 'pizza', + ['pasta', 'main course'], + 'breakfast', + ['cake', 'dessert'], + ['cake', 'dessert', 'gluten-free'], + ['soufflé', 'dessert'], + ['cake', 'gluten-free'], + 'cake', + ]); + }); + + /** + * Test case: it should handle empty-string categories and return all recipes with OR operator. + */ + test('it should handle empty-string categories and return all recipes with OR operator', () => { + const filter = new RecipeCategoriesFilter([''], new OrOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.recipeCategory)).toEqual([ + 'main course', + ['salad', 'appetizer'], + 'dessert', + ['vegetarian', 'pizza'], + ['pizza', 'appetizer'], + 'pizza', + ['pasta', 'main course'], + 'breakfast', + ['cake', 'dessert'], + ['cake', 'dessert', 'gluten-free'], + ['soufflé', 'dessert'], + ['cake', 'gluten-free'], + 'cake', + ]); + }); + + /** + * Test case: it should handle empty-string categories and return all recipes with AND operator. + */ + test('it should handle empty-string categories and return no recipes with AND operator', () => { + const filter = new RecipeCategoriesFilter([''], new AndOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.recipeCategory)).toEqual([ + 'main course', + ['salad', 'appetizer'], + 'dessert', + ['vegetarian', 'pizza'], + ['pizza', 'appetizer'], + 'pizza', + ['pasta', 'main course'], + 'breakfast', + ['cake', 'dessert'], + ['cake', 'dessert', 'gluten-free'], + ['soufflé', 'dessert'], + ['cake', 'gluten-free'], + 'cake', + ]); + }); + + /** + * Test case: it should handle comparison with equal filters correctly. + */ + test('it should handle comparison with equal filters correctly', () => { + const filter = new RecipeCategoriesFilter( + ['dessert', 'main course'], + new AndOperator(), + ); + const sameFilter = new RecipeCategoriesFilter( + ['dessert', 'main course'], + new AndOperator(), + ); + + expect(filter.equals(sameFilter)).toBeTruthy(); + }); + + /** + * Test case: it should handle comparison with unequal filters correctly. + */ + test('it should handle comparison with unequal filters correctly', () => { + const filter = new RecipeCategoriesFilter( + ['dessert', 'main course'], + new AndOperator(), + ); + const differentFilter = new RecipeCategoriesFilter( + ['dessert'], + new AndOperator(), + ); + const differentFilter2 = new RecipeCategoriesFilter( + ['dessert', 'lunch'], + new AndOperator(), + ); + const differentFilter3 = new RecipeCategoriesFilter( + ['dessert', 'main course'], + new OrOperator(), + ); + + expect(filter.equals(differentFilter)).toBeFalsy(); + expect(filter.equals(differentFilter2)).toBeFalsy(); + expect(filter.equals(differentFilter3)).toBeFalsy(); + }); +}); diff --git a/src/tests/unit/RecipeFilters/RecipeKeywordsFilter.test.js b/src/tests/unit/RecipeFilters/RecipeKeywordsFilter.test.js deleted file mode 100644 index 68798a29e..000000000 --- a/src/tests/unit/RecipeFilters/RecipeKeywordsFilter.test.js +++ /dev/null @@ -1,290 +0,0 @@ -import RecipeKeywordsFilter from '../../../js/RecipeFilters/RecipeKeywordsFilter'; -import { AndOperator, OrOperator } from '../../../js/LogicOperators'; - -/** - * Test suite for the RecipeKeywordsFilter class. - */ -describe('RecipeKeywordsFilter', () => { - /** @type {Object[]} recipes - Array of recipe objects for testing. */ - const recipes = [ - { keywords: ['easy', 'quick', 'pasta'] }, - { keywords: ['healthy', 'salad'] }, - { keywords: 'dessert' }, - { keywords: ['vegetarian', 'pizza'] }, - { keywords: ['salami', 'pizza'] }, - { keywords: 'pizza' }, - { keywords: ['italian', 'pasta', 'spaghetti'] }, - { keywords: 'breakfast' }, - { keywords: ['chocolate', 'cake'] }, - { keywords: ['chocolate', 'cake', 'almond'] }, - { keywords: ['chocolate', 'soufflé'] }, - { keywords: ['lemon', 'cake'] }, - { keywords: 'cake' }, - { keywords: 'soup, carrot' }, - { keywords: 'soup, tomato' }, - ]; - - /** - * Test case: it should filter recipes by a single keyword using AND operator. - */ - test('it should filter recipes by a single keyword using AND operator', () => { - const filter = new RecipeKeywordsFilter('pasta', new AndOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(2); - expect(filteredRecipes[0].keywords).toEqual(['easy', 'quick', 'pasta']); - expect(filteredRecipes[1].keywords).toEqual([ - 'italian', - 'pasta', - 'spaghetti', - ]); - }); - - /** - * Test case: it should filter recipes by a single keyword using OR operator. - */ - test('it should filter recipes by a single keyword using OR operator', () => { - const filter = new RecipeKeywordsFilter('pizza', new OrOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(3); - expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ - ['vegetarian', 'pizza'], - ['salami', 'pizza'], - 'pizza', - ]); - }); - - /** - * Test case: it should filter recipes by multiple keywords using AND operator. - */ - test('it should filter recipes by multiple keywords using AND operator', () => { - const filter = new RecipeKeywordsFilter( - ['chocolate', 'cake'], - new AndOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(2); - expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ - ['chocolate', 'cake'], - ['chocolate', 'cake', 'almond'], - ]); - }); - - /** - * Test case: it should filter recipes by multiple keywords using OR operator. - */ - test('it should filter recipes by multiple keywords using OR operator', () => { - const filter = new RecipeKeywordsFilter( - ['chocolate', 'cake'], - new OrOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(5); - expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ - ['chocolate', 'cake'], - ['chocolate', 'cake', 'almond'], - ['chocolate', 'soufflé'], - ['lemon', 'cake'], - 'cake', - ]); - }); - - /** - * Test case: it should filter recipes by multiple comma-separated keywords using AND operator. - */ - test('it should filter recipes by multiple comma-separated keywords using AND operator', () => { - const filter = new RecipeKeywordsFilter( - ['soup', 'carrot'], - new AndOperator(), - true, - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(1); - expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ - 'soup, carrot', - ]); - }); - - /** - * Test case: it should filter recipes by multiple comma-separated keywords using OR operator. - */ - test('it should filter recipes by multiple comma-separated keywords using OR operator', () => { - const filter = new RecipeKeywordsFilter( - ['soup', 'carrot'], - new OrOperator(), - true, - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(2); - expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ - 'soup, carrot', - 'soup, tomato', - ]); - }); - - /** - * Test case: it should handle case-insensitive filtering. - */ - test('it should handle case-insensitive filtering', () => { - const filter = new RecipeKeywordsFilter('DEssERT', new AndOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(1); - expect(filteredRecipes[0].keywords).toBe('dessert'); - }); - - /** - * Test case: it should handle empty keywords list and return all recipes with OR operator. - */ - test('it should handle empty keywords list and return all recipes with OR operator', () => { - const filter = new RecipeKeywordsFilter([], new OrOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(recipes.length); - expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ - ['easy', 'quick', 'pasta'], - ['healthy', 'salad'], - 'dessert', - ['vegetarian', 'pizza'], - ['salami', 'pizza'], - 'pizza', - ['italian', 'pasta', 'spaghetti'], - 'breakfast', - ['chocolate', 'cake'], - ['chocolate', 'cake', 'almond'], - ['chocolate', 'soufflé'], - ['lemon', 'cake'], - 'cake', - 'soup, carrot', - 'soup, tomato', - ]); - }); - - /** - * Test case: it should handle empty keywords list and return all recipes with AND operator. - */ - test('it should handle empty keywords list and return no recipes with AND operator', () => { - const filter = new RecipeKeywordsFilter([], new AndOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(recipes.length); - expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ - ['easy', 'quick', 'pasta'], - ['healthy', 'salad'], - 'dessert', - ['vegetarian', 'pizza'], - ['salami', 'pizza'], - 'pizza', - ['italian', 'pasta', 'spaghetti'], - 'breakfast', - ['chocolate', 'cake'], - ['chocolate', 'cake', 'almond'], - ['chocolate', 'soufflé'], - ['lemon', 'cake'], - 'cake', - 'soup, carrot', - 'soup, tomato', - ]); - }); - - /** - * Test case: it should handle empty-string keywords and return all recipes with OR operator. - */ - test('it should handle empty keywords and return all recipes with OR operator', () => { - const filter = new RecipeKeywordsFilter([''], new OrOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(recipes.length); - expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ - ['easy', 'quick', 'pasta'], - ['healthy', 'salad'], - 'dessert', - ['vegetarian', 'pizza'], - ['salami', 'pizza'], - 'pizza', - ['italian', 'pasta', 'spaghetti'], - 'breakfast', - ['chocolate', 'cake'], - ['chocolate', 'cake', 'almond'], - ['chocolate', 'soufflé'], - ['lemon', 'cake'], - 'cake', - 'soup, carrot', - 'soup, tomato', - ]); - }); - - /** - * Test case: it should handle empty-string keywords and return all recipes with AND operator. - */ - test('it should handle empty-string keywords and return no recipes with AND operator', () => { - const filter = new RecipeKeywordsFilter([''], new AndOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(recipes.length); - expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ - ['easy', 'quick', 'pasta'], - ['healthy', 'salad'], - 'dessert', - ['vegetarian', 'pizza'], - ['salami', 'pizza'], - 'pizza', - ['italian', 'pasta', 'spaghetti'], - 'breakfast', - ['chocolate', 'cake'], - ['chocolate', 'cake', 'almond'], - ['chocolate', 'soufflé'], - ['lemon', 'cake'], - 'cake', - 'soup, carrot', - 'soup, tomato', - ]); - }); - - /** - * Test case: it should filter recipes when keywords property is a string using AND operator. - */ - test('it should filter recipes when keywords property is a string using AND operator', () => { - const filter = new RecipeKeywordsFilter( - 'vegetarian', - new AndOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(1); - expect(filteredRecipes[0].keywords).toEqual(['vegetarian', 'pizza']); - }); - - /** - * Test case: it should filter recipes when keywords property is a string using OR operator. - */ - test('it should filter recipes when keywords property is a string using OR operator', () => { - const filter = new RecipeKeywordsFilter('pizza', new OrOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(3); - expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ - ['vegetarian', 'pizza'], - ['salami', 'pizza'], - 'pizza', - ]); - }); -}); diff --git a/src/tests/unit/RecipeFilters/RecipeKeywordsFilter.test.ts b/src/tests/unit/RecipeFilters/RecipeKeywordsFilter.test.ts new file mode 100644 index 000000000..8ed7c43fb --- /dev/null +++ b/src/tests/unit/RecipeFilters/RecipeKeywordsFilter.test.ts @@ -0,0 +1,432 @@ +import RecipeKeywordsFilter from 'cookbook/js/RecipeFilters/RecipeKeywordsFilter'; +import { AndOperator, OrOperator } from 'cookbook/js/LogicOperators'; +import { Recipe } from 'cookbook/js/Models/schema'; + +function createRecipeWithKeywords(keywords: string[]): Recipe { + const recipe = new Recipe('123', 'recipe'); + recipe.keywords = keywords; + return recipe; +} + +/** + * Test suite for the RecipeKeywordsFilter class. + */ +describe('RecipeKeywordsFilter', () => { + /** @type {Object[]} recipes - Array of recipe objects for testing. */ + const recipes: Recipe[] = [ + createRecipeWithKeywords(['easy', 'quick', 'pasta']), + createRecipeWithKeywords(['healthy', 'salad']), + createRecipeWithKeywords(['vegetarian', 'pizza']), + createRecipeWithKeywords(['salami', 'pizza']), + createRecipeWithKeywords(['italian', 'pasta', 'spaghetti']), + createRecipeWithKeywords(['chocolate', 'cake']), + createRecipeWithKeywords(['chocolate', 'cake', 'almond']), + createRecipeWithKeywords(['chocolate', 'soufflé']), + createRecipeWithKeywords(['lemon', 'cake']), + createRecipeWithKeywords(['soup', 'tomato']), + createRecipeWithKeywords(['soup', 'carrot', 'lentils']), + createRecipeWithKeywords(['dessert']), + ]; + + /** + * Test case: it should filter recipes by a single keyword using AND operator. + */ + test('it should filter recipes by a single keyword using AND operator', () => { + const filter = new RecipeKeywordsFilter('pasta', new AndOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes[0].keywords).toEqual(['easy', 'quick', 'pasta']); + expect(filteredRecipes[1].keywords).toEqual([ + 'italian', + 'pasta', + 'spaghetti', + ]); + }); + + /** + * Test case: it should be constructed correctly + */ + test('it should be constructed correctly', () => { + const filter = new RecipeKeywordsFilter('great pasta'); + const filter2 = new RecipeKeywordsFilter(['great pasta']); + const filter3 = new RecipeKeywordsFilter(['great pasta', 'tasty']); + expect(filter.keywords).toEqual(['great pasta']); + expect(filter2.keywords).toEqual(['great pasta']); + expect(filter3.keywords).toEqual(['great pasta', 'tasty']); + }); + + /** + * Test case: it should filter recipes by a single keyword using OR operator. + */ + test('it should filter recipes by a single keyword using OR operator', () => { + const filter = new RecipeKeywordsFilter('pizza', new OrOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ + ['vegetarian', 'pizza'], + ['salami', 'pizza'], + ]); + }); + + /** + * Test case: it should filter recipes by multiple keywords using AND operator. + */ + test('it should filter recipes by multiple keywords using AND operator', () => { + const filter = new RecipeKeywordsFilter( + ['chocolate', 'cake'], + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ + ['chocolate', 'cake'], + ['chocolate', 'cake', 'almond'], + ]); + }); + + /** + * Test case: it should filter recipes by multiple keywords using OR operator. + */ + test('it should filter recipes by multiple keywords using OR operator', () => { + const filter = new RecipeKeywordsFilter( + ['chocolate', 'cake'], + new OrOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(4); + expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ + ['chocolate', 'cake'], + ['chocolate', 'cake', 'almond'], + ['chocolate', 'soufflé'], + ['lemon', 'cake'], + ]); + }); + + /** + * Test case: it should filter recipes by multiple comma-separated keywords using AND operator. + */ + test('it should filter recipes by multiple comma-separated keywords using AND operator', () => { + const filter = new RecipeKeywordsFilter( + ['soup', 'carrot'], + new AndOperator(), + true, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ + ['soup', 'carrot', 'lentils'], + ]); + }); + + /** + * Test case: it should filter recipes by multiple comma-separated keywords using OR operator. + */ + test('it should filter recipes by multiple comma-separated keywords using OR operator', () => { + const filter = new RecipeKeywordsFilter( + ['soup', 'carrot'], + new OrOperator(), + true, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ + ['soup', 'tomato'], + ['soup', 'carrot', 'lentils'], + ]); + }); + + /** + * Test case: it should handle case-insensitive filtering. + */ + test('it should handle case-insensitive filtering', () => { + const filter = new RecipeKeywordsFilter('DEssERT', new AndOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes[0].keywords).toStrictEqual(['dessert']); + }); + + /** + * Test case: it should handle empty keywords list and return all recipes with OR operator. + */ + test('it should handle empty keywords list and return all recipes with OR operator', () => { + const filter = new RecipeKeywordsFilter([], new OrOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ + ['easy', 'quick', 'pasta'], + ['healthy', 'salad'], + ['vegetarian', 'pizza'], + ['salami', 'pizza'], + ['italian', 'pasta', 'spaghetti'], + ['chocolate', 'cake'], + ['chocolate', 'cake', 'almond'], + ['chocolate', 'soufflé'], + ['lemon', 'cake'], + ['soup', 'tomato'], + ['soup', 'carrot', 'lentils'], + ['dessert'], + ]); + }); + + /** + * Test case: it should handle empty keywords list and return all recipes with AND operator. + */ + test('it should handle empty keywords list and return all recipes with AND operator', () => { + const filter = new RecipeKeywordsFilter([], new AndOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ + ['easy', 'quick', 'pasta'], + ['healthy', 'salad'], + ['vegetarian', 'pizza'], + ['salami', 'pizza'], + ['italian', 'pasta', 'spaghetti'], + ['chocolate', 'cake'], + ['chocolate', 'cake', 'almond'], + ['chocolate', 'soufflé'], + ['lemon', 'cake'], + ['soup', 'tomato'], + ['soup', 'carrot', 'lentils'], + ['dessert'], + ]); + }); + + /** + * Test case: it should handle empty-string keywords and return all recipes with OR operator. + */ + test('it should handle empty-string keywords and return all recipes with OR operator', () => { + const filter = new RecipeKeywordsFilter([''], new OrOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ + ['easy', 'quick', 'pasta'], + ['healthy', 'salad'], + ['vegetarian', 'pizza'], + ['salami', 'pizza'], + ['italian', 'pasta', 'spaghetti'], + ['chocolate', 'cake'], + ['chocolate', 'cake', 'almond'], + ['chocolate', 'soufflé'], + ['lemon', 'cake'], + ['soup', 'tomato'], + ['soup', 'carrot', 'lentils'], + ['dessert'], + ]); + }); + + /** + * Test case: it should handle empty-string keywords and return all recipes with AND operator. + */ + test('it should handle empty-string keywords and return no recipes with AND operator', () => { + const filter = new RecipeKeywordsFilter([''], new AndOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ + ['easy', 'quick', 'pasta'], + ['healthy', 'salad'], + ['vegetarian', 'pizza'], + ['salami', 'pizza'], + ['italian', 'pasta', 'spaghetti'], + ['chocolate', 'cake'], + ['chocolate', 'cake', 'almond'], + ['chocolate', 'soufflé'], + ['lemon', 'cake'], + ['soup', 'tomato'], + ['soup', 'carrot', 'lentils'], + ['dessert'], + ]); + }); + + /** + * Test case: it should return correct search string for AND operator. + */ + test('it should return correct search string for AND operator.', () => { + const filter = new RecipeKeywordsFilter( + ['pizza', 'pasta'], + new AndOperator(), + ); + const filter2 = new RecipeKeywordsFilter(['pizza'], new AndOperator()); + const filter3 = new RecipeKeywordsFilter([], new AndOperator()); + + expect(filter.toSearchString()).toEqual('tag:"pizza" tag:"pasta"'); + expect(filter2.toSearchString()).toEqual('tag:"pizza"'); + expect(filter3.toSearchString()).toEqual(''); + }); + + /** + * Test case: it should return correct search string for OR operator. + */ + test('it should return correct search string for OR operator.', () => { + const filter = new RecipeKeywordsFilter( + ['pizza', 'pasta'], + new OrOperator(), + ); + const filter2 = new RecipeKeywordsFilter(['pizza'], new OrOperator()); + const filter3 = new RecipeKeywordsFilter([], new OrOperator()); + + expect(filter.toSearchString()).toEqual('tag:"pizza","pasta"'); + expect(filter2.toSearchString()).toEqual('tag:"pizza"'); + expect(filter3.toSearchString()).toEqual(''); + }); + + describe('equals()', () => { + // Test for filters that should be equal + test('should return true for equivalent filters', () => { + const filter1 = new RecipeKeywordsFilter( + ['chicken', 'grilled'], + new AndOperator(), + false, + ); + const filter2 = new RecipeKeywordsFilter( + ['grilled', 'chicken'], + new AndOperator(), + false, + ); + + expect(filter1.equals(filter2)).toBe(true); + }); + // Test for filters that should be equal + test('should return true for same filters', () => { + const filter1 = new RecipeKeywordsFilter( + ['grilled', 'chicken'], + new AndOperator(), + false, + ); + const filter2 = new RecipeKeywordsFilter( + ['grilled', 'chicken'], + new AndOperator(), + false, + ); + + expect(filter1.equals(filter2)).toBe(true); + }); + + // Test for filters with different keywords + test('should return false for filters with different keywords', () => { + const filter1 = new RecipeKeywordsFilter( + ['chicken'], + new AndOperator(), + false, + ); + const filter2 = new RecipeKeywordsFilter( + ['beef'], + new AndOperator(), + false, + ); + + expect(filter1.equals(filter2)).toBe(false); + }); + + // Test for filters with different operators + test('should return false for filters with different operators and multiple keywords', () => { + const filter1 = new RecipeKeywordsFilter( + ['chicken', 'eggs'], + new AndOperator(), + false, + ); + const filter2 = new RecipeKeywordsFilter( + ['chicken', 'eggs'], + new OrOperator(), + false, + ); + + expect(filter1.equals(filter2)).toBe(false); + }); + + // Test for filters with different operators + test('should return true for filters with different operators and single keyword', () => { + const filter1 = new RecipeKeywordsFilter( + ['chicken'], + new AndOperator(), + false, + ); + const filter2 = new RecipeKeywordsFilter( + ['chicken'], + new OrOperator(), + false, + ); + + expect(filter1.equals(filter2)).toBe(true); + }); + + // Test for filters with different isCommaSeparated values + test('should return false for filters with different isCommaSeparated values', () => { + const filter1 = new RecipeKeywordsFilter( + ['chicken', 'grilled'], + new AndOperator(), + false, + ); + const filter2 = new RecipeKeywordsFilter( + ['chicken', 'grilled'], + new AndOperator(), + true, + ); + + expect(filter1.equals(filter2)).toBe(false); + }); + + /** + * Test case: it should handle comparison with equal filters correctly. + */ + test('it should handle comparison with equal filters correctly', () => { + const filter = new RecipeKeywordsFilter( + ['sweet', 'salty'], + new AndOperator(), + ); + const sameFilter = new RecipeKeywordsFilter( + ['sweet', 'salty'], + new AndOperator(), + ); + + expect(filter.equals(sameFilter)).toBeTruthy(); + }); + + /** + * Test case: it should handle comparison with unequal filters correctly. + */ + test('it should handle comparison with unequal filters correctly', () => { + const filter = new RecipeKeywordsFilter( + ['sweet', 'salty'], + new AndOperator(), + ); + const differentFilter = new RecipeKeywordsFilter( + ['sweet'], + new AndOperator(), + ); + const differentFilter2 = new RecipeKeywordsFilter( + ['sweet', 'hot'], + new AndOperator(), + ); + const differentFilter3 = new RecipeKeywordsFilter( + ['sweet', 'salty'], + new OrOperator(), + ); + + expect(filter.equals(differentFilter)).toBeFalsy(); + expect(filter.equals(differentFilter2)).toBeFalsy(); + expect(filter.equals(differentFilter3)).toBeFalsy(); + }); + }); +}); diff --git a/src/tests/unit/RecipeFilters/RecipeNamesFilter.test.js b/src/tests/unit/RecipeFilters/RecipeNamesFilter.test.js deleted file mode 100644 index 2d6c534a6..000000000 --- a/src/tests/unit/RecipeFilters/RecipeNamesFilter.test.js +++ /dev/null @@ -1,326 +0,0 @@ -import RecipeNamesFilter from '../../../js/RecipeFilters/RecipeNamesFilter'; - -import { AndOperator, OrOperator } from '../../../js/LogicOperators'; - -/** - * Test suite for the RecipeNamesFilter class. - */ -describe('RecipeNamesFilter', () => { - /** @type {Object[]} recipes - Array of recipe objects for testing. */ - const recipes = [ - { name: 'Pasta Carbonara' }, - { name: ['Pasta Carbonara', 'Italian pasta'] }, - { name: ['Chocolate Cake', 'Brownie'] }, - { name: 'Salad' }, - { name: ['Pizza', 'Calzone', 'Pizza Calzone'] }, - { name: 'Pizza' }, - { name: ['Pizza', 'Pizza Carcassonne'] }, - { name: ['Spaghetti Bolognese', 'Garlic Bread'] }, - ]; - - /** - * Test case: it should filter recipes by a single name using AND operator. - */ - test('it should filter recipes by a single name using AND operator', () => { - const filter = new RecipeNamesFilter( - 'pasta carbonara', - new AndOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(2); - expect(filteredRecipes[0].name).toBe('Pasta Carbonara'); - expect(filteredRecipes[1].name).toStrictEqual([ - 'Pasta Carbonara', - 'Italian pasta', - ]); - }); - - /** - * Test case: it should filter recipes by a single name using OR operator. - */ - test('it should filter recipes by a single name using OR operator', () => { - const filter = new RecipeNamesFilter('pizza', new OrOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(3); - expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ - ['Pizza', 'Calzone', 'Pizza Calzone'], - 'Pizza', - ['Pizza', 'Pizza Carcassonne'], - ]); - }); - - /** - * Test case: it should filter recipes by multiple names using AND operator. - */ - test('it should filter recipes by multiple names using AND operator', () => { - const filter = new RecipeNamesFilter( - ['chocolate cake', 'brownie'], - new AndOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(1); - expect(filteredRecipes[0].name).toEqual(['Chocolate Cake', 'Brownie']); - }); - - /** - * Test case: it should filter recipes by multiple names using OR operator. - */ - test('it should filter recipes by multiple names using OR operator', () => { - const filter = new RecipeNamesFilter( - ['salad', 'calzone'], - new OrOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(2); - expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ - 'Salad', - ['Pizza', 'Calzone', 'Pizza Calzone'], - ]); - }); - - /** - * Test case: it should handle case-insensitive filtering. - */ - test('it should handle case-insensitive filtering', () => { - const filter = new RecipeNamesFilter( - 'ITALIAN pAsTa', - new AndOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(1); - expect(filteredRecipes[0].name).toStrictEqual([ - 'Pasta Carbonara', - 'Italian pasta', - ]); - }); - - /** - * Test case: it should handle empty names list and return all recipes with OR operator. - */ - test('it should handle empty names and return all recipes with OR operator', () => { - const filter = new RecipeNamesFilter([], new OrOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(recipes.length); - expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ - 'Pasta Carbonara', - ['Pasta Carbonara', 'Italian pasta'], - ['Chocolate Cake', 'Brownie'], - 'Salad', - ['Pizza', 'Calzone', 'Pizza Calzone'], - 'Pizza', - ['Pizza', 'Pizza Carcassonne'], - ['Spaghetti Bolognese', 'Garlic Bread'], - ]); - }); - - /** - * Test case: it should handle empty names list and return all recipes with AND operator. - */ - test('it should handle empty names and return no recipes with AND operator', () => { - const filter = new RecipeNamesFilter([], new AndOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(recipes.length); - expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ - 'Pasta Carbonara', - ['Pasta Carbonara', 'Italian pasta'], - ['Chocolate Cake', 'Brownie'], - 'Salad', - ['Pizza', 'Calzone', 'Pizza Calzone'], - 'Pizza', - ['Pizza', 'Pizza Carcassonne'], - ['Spaghetti Bolognese', 'Garlic Bread'], - ]); - }); - - /** - * Test case: it should handle empty-string names and return all recipes with OR operator. - */ - test('it should handle empty-string names and return all recipes with OR operator', () => { - const filter = new RecipeNamesFilter([''], new OrOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(recipes.length); - expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ - 'Pasta Carbonara', - ['Pasta Carbonara', 'Italian pasta'], - ['Chocolate Cake', 'Brownie'], - 'Salad', - ['Pizza', 'Calzone', 'Pizza Calzone'], - 'Pizza', - ['Pizza', 'Pizza Carcassonne'], - ['Spaghetti Bolognese', 'Garlic Bread'], - ]); - }); - - /** - * Test case: it should handle empty-string names and return all recipes with AND operator. - */ - test('it should handle empty-string names and return all recipes with AND operator', () => { - const filter = new RecipeNamesFilter([''], new AndOperator()); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(recipes.length); - expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ - 'Pasta Carbonara', - ['Pasta Carbonara', 'Italian pasta'], - ['Chocolate Cake', 'Brownie'], - 'Salad', - ['Pizza', 'Calzone', 'Pizza Calzone'], - 'Pizza', - ['Pizza', 'Pizza Carcassonne'], - ['Spaghetti Bolognese', 'Garlic Bread'], - ]); - }); - - /** - * Test case: it should filter recipes when name property is an array using AND operator. - */ - test('it should filter recipes when name property is an array using AND operator', () => { - const filter = new RecipeNamesFilter( - ['spaghetti bolognese', 'garlic bread'], - new AndOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(1); - expect(filteredRecipes[0].name).toEqual([ - 'Spaghetti Bolognese', - 'Garlic Bread', - ]); - }); - - /** - * Test case: it should filter recipes when name property is an array using OR operator. - */ - test('it should filter recipes when name property is an array using OR operator', () => { - const filter = new RecipeNamesFilter( - ['salad', 'calzone'], - new OrOperator(), - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(2); - expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ - 'Salad', - ['Pizza', 'Calzone', 'Pizza Calzone'], - ]); - }); - - /** - * Test case: it should filter recipes if part of name is included using AND operator. - */ - test('it should filter recipes if part of name is included using AND operator', () => { - const filter = new RecipeNamesFilter( - 'pasta', - new AndOperator(), - 'matchSubstring', - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(2); - expect(filteredRecipes[0].name).toBe('Pasta Carbonara'); - expect(filteredRecipes[1].name).toStrictEqual([ - 'Pasta Carbonara', - 'Italian pasta', - ]); - }); - - /** - * Test case: it should filter recipes if part of name is included using OR operator. - */ - test('it should filter recipes if part of name is included using OR operator', () => { - const filter = new RecipeNamesFilter( - 'pasta', - new OrOperator(), - 'matchSubstring', - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(2); - expect(filteredRecipes[0].name).toBe('Pasta Carbonara'); - expect(filteredRecipes[1].name).toStrictEqual([ - 'Pasta Carbonara', - 'Italian pasta', - ]); - }); - - /** - * Test case: it should filter recipes with fuzzy search using AND operator. - */ - test('it should filter recipes with fuzzy search using AND operator', () => { - const filter = new RecipeNamesFilter( - 'Piza Car', - new AndOperator(), - 'fuzzy', - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(5); - expect(filteredRecipes[0].name).toBe('Pasta Carbonara'); - expect(filteredRecipes[1].name).toStrictEqual([ - 'Pasta Carbonara', - 'Italian pasta', - ]); - expect(filteredRecipes[2].name).toStrictEqual([ - 'Pizza', - 'Calzone', - 'Pizza Calzone', - ]); - expect(filteredRecipes[3].name).toBe('Pizza'); - expect(filteredRecipes[4].name).toStrictEqual([ - 'Pizza', - 'Pizza Carcassonne', - ]); - }); - - /** - * Test case: it should filter recipes with fuzzy search using OR operator. - */ - test('it should filter recipes with fuzzy search using OR operator', () => { - const filter = new RecipeNamesFilter( - 'Piza Car', - new OrOperator(), - 'fuzzy', - ); - const filteredRecipes = recipes.filter((recipe) => - filter.filter(recipe), - ); - expect(filteredRecipes).toHaveLength(5); - expect(filteredRecipes[0].name).toBe('Pasta Carbonara'); - expect(filteredRecipes[1].name).toStrictEqual([ - 'Pasta Carbonara', - 'Italian pasta', - ]); - expect(filteredRecipes[2].name).toStrictEqual([ - 'Pizza', - 'Calzone', - 'Pizza Calzone', - ]); - expect(filteredRecipes[3].name).toBe('Pizza'); - expect(filteredRecipes[4].name).toStrictEqual([ - 'Pizza', - 'Pizza Carcassonne', - ]); - }); -}); diff --git a/src/tests/unit/RecipeFilters/RecipeNamesFilter.test.ts b/src/tests/unit/RecipeFilters/RecipeNamesFilter.test.ts new file mode 100644 index 000000000..f90c09a11 --- /dev/null +++ b/src/tests/unit/RecipeFilters/RecipeNamesFilter.test.ts @@ -0,0 +1,403 @@ +import RecipeNamesFilter from 'cookbook/js/RecipeFilters/RecipeNamesFilter'; +import { AndOperator, OrOperator } from 'cookbook/js/LogicOperators'; +import { Recipe } from 'cookbook/js/Models/schema'; +import SearchMode from 'cookbook/js/Enums/SearchMode'; + +/** + * Test suite for the RecipeNamesFilter class. + */ +describe('RecipeNamesFilter', () => { + /** @type {Object[]} recipes - Array of recipe objects for testing. */ + const recipes: Recipe[] = [ + new Recipe('123', 'Pasta Carbonara'), + new Recipe('223', 'Better Pasta Carbonara'), + new Recipe('323', 'Chocolate Cake'), + new Recipe('423', 'Brownie'), + new Recipe('523', 'Salad'), + new Recipe('623', 'Pizza Calzone'), + new Recipe('723', 'Pizza'), + ]; + + /** + * Test case: it should filter recipes by a single name using AND operator. + */ + test('it should filter recipes by a single name using AND operator', () => { + const filter = new RecipeNamesFilter( + 'pasta carbonara', + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes[0].name).toBe('Pasta Carbonara'); + // expect(filteredRecipes[1].name).toStrictEqual('Better Pasta Carbonara'); + }); + + /** + * Test case: it should filter recipes by a single name using OR operator. + */ + test('it should filter recipes by a single name using OR operator', () => { + const filter = new RecipeNamesFilter('pizza', new OrOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual(['Pizza']); + }); + + /** + * Test case: it should filter recipes by multiple names using AND operator. + */ + test('it should filter recipes by multiple names using AND operator', () => { + // The recipe name is currently not allowed to be an array. + // Will only return result if both queries are the same name or fuzzy search is on and both match the same recipe. + const filter = new RecipeNamesFilter( + ['Chocolate Cake'], + new AndOperator(), + SearchMode.Exact, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes[0].name).toEqual('Chocolate Cake'); + }); + + /** + * Test case: it should filter recipes by multiple names using OR operator. + */ + test('it should filter recipes by multiple names using OR operator', () => { + const filter = new RecipeNamesFilter( + ['salad', 'pizza calzone'], + new OrOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ + 'Salad', + 'Pizza Calzone', + ]); + }); + + /** + * Test case: it should handle case-insensitive filtering. + */ + test('it should handle case-insensitive filtering', () => { + const filter = new RecipeNamesFilter( + 'PasTA cArBOnara', + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes[0].name).toStrictEqual('Pasta Carbonara'); + }); + + /** + * Test case: it should handle empty names list and return all recipes with OR operator. + */ + test('it should handle empty names and return all recipes with OR operator', () => { + const filter = new RecipeNamesFilter([], new OrOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Chocolate Cake', + 'Brownie', + 'Salad', + 'Pizza Calzone', + 'Pizza', + ]); + }); + + /** + * Test case: it should handle empty names list and return all recipes with AND operator. + */ + test('it should handle empty names and return all recipes with AND operator', () => { + const filter = new RecipeNamesFilter([], new AndOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Chocolate Cake', + 'Brownie', + 'Salad', + 'Pizza Calzone', + 'Pizza', + ]); + }); + + /** + * Test case: it should handle empty-string names and return all recipes with OR operator. + */ + test('it should handle empty-string names and return all recipes with OR operator', () => { + const filter = new RecipeNamesFilter([''], new OrOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Chocolate Cake', + 'Brownie', + 'Salad', + 'Pizza Calzone', + 'Pizza', + ]); + }); + + /** + * Test case: it should handle empty-string names and return all recipes with AND operator. + */ + test('it should handle empty-string names and return all recipes with AND operator', () => { + const filter = new RecipeNamesFilter([''], new AndOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Chocolate Cake', + 'Brownie', + 'Salad', + 'Pizza Calzone', + 'Pizza', + ]); + }); + + /** + * Test case: it should filter recipes when name property is an array using AND operator. + */ + test('it should filter recipes when name property is an array using AND operator', () => { + const filter = new RecipeNamesFilter( + ['Chocolate Cake', 'Chocolate Cake'], + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes[0].name).toEqual('Chocolate Cake'); + }); + + /** + * Test case: it should filter recipes when name property is an array using AND operator but fail for parts without fuzzy search. + */ + test('it should filter recipes when name property is an array using AND operator but fail for parts without fuzzy search', () => { + const filter = new RecipeNamesFilter( + ['Chocolate', 'Cake'], + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(0); + }); + + /** + * Test case: it should filter recipes when name property is an array using OR operator. + */ + test('it should filter recipes when name property is an array using OR operator', () => { + const filter = new RecipeNamesFilter( + ['salad', 'pizza calzone'], + new OrOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Salad', + 'Pizza Calzone', + ]); + }); + + /** + * Test case: it should filter recipes if part of name is included using AND operator, matchSubstring mode. + */ + test('it should filter recipes if part of name is included using AND operator, matchSubstring mode.', () => { + const filter = new RecipeNamesFilter( + 'pasta', + new AndOperator(), + SearchMode.MatchSubstring, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + ]); + }); + + /** + * Test case: it should filter recipes if part of name is included using OR operator, matchSubstring mode. + */ + test('it should filter recipes if part of name is included using OR operator, matchSubstring mode.', () => { + const filter = new RecipeNamesFilter( + 'pasta', + new OrOperator(), + SearchMode.MatchSubstring, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + ]); + }); + + /** + * Test case: it should filter recipes with fuzzy search using AND operator. + */ + test('it should filter recipes with fuzzy search using AND operator', () => { + const filter = new RecipeNamesFilter( + 'Piza Car', + new AndOperator(), + SearchMode.Fuzzy, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(3); + + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Pizza Calzone', + ]); + }); + + /** + * Test case: it should filter recipes with fuzzy search, multiple terms, using AND operator. + */ + test('it should filter recipes with fuzzy search, multiple terms, using AND operator', () => { + const filter = new RecipeNamesFilter( + ['Pizz', 'C4lz'], + new AndOperator(), + SearchMode.Fuzzy, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Pizza Calzone', + ]); + }); + + /** + * Test case: it should filter recipes with fuzzy search using OR operator. + */ + test('it should filter recipes with fuzzy search using OR operator', () => { + const filter = new RecipeNamesFilter( + 'Piza Car', + new OrOperator(), + SearchMode.Fuzzy, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(3); + + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Pizza Calzone', + ]); + }); + + /** + * Test case: it should filter recipes with fuzzy search, multiple terms, using OR operator. + */ + test('it should filter recipes with fuzzy search, multiple terms, using OR operator', () => { + const filter = new RecipeNamesFilter( + ['Piza Car', 'S4l'], + new OrOperator(), + SearchMode.Fuzzy, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(4); + + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Salad', + 'Pizza Calzone', + ]); + }); + + describe('equals()', () => { + /** + * Test case: it should handle comparison with equal filters correctly. + */ + test('it should handle comparison with equal filters correctly', () => { + const filter = new RecipeNamesFilter( + ['sweet', 'salty'], + new AndOperator(), + SearchMode.Fuzzy, + ); + const sameFilter = new RecipeNamesFilter( + ['sweet', 'salty'], + new AndOperator(), + SearchMode.Fuzzy, + ); + + expect(filter.equals(sameFilter)).toBeTruthy(); + }); + + /** + * Test case: it should handle comparison with unequal filters correctly. + */ + test('it should handle comparison with unequal filters correctly', () => { + const filter = new RecipeNamesFilter( + ['sweet', 'salty'], + new AndOperator(), + SearchMode.Fuzzy, + ); + const differentFilter = new RecipeNamesFilter( + ['sweet'], + new AndOperator(), + SearchMode.Fuzzy, + ); + const differentFilter2 = new RecipeNamesFilter( + ['sweet', 'hot'], + new AndOperator(), + SearchMode.Fuzzy, + ); + const differentFilter3 = new RecipeNamesFilter( + ['sweet', 'salty'], + new OrOperator(), + SearchMode.Fuzzy, + ); + const differentFilter4 = new RecipeNamesFilter( + ['sweet', 'salty'], + new OrOperator(), + SearchMode.Exact, + ); + + expect(filter.equals(differentFilter)).toBeFalsy(); + expect(filter.equals(differentFilter2)).toBeFalsy(); + expect(filter.equals(differentFilter3)).toBeFalsy(); + expect(filter.equals(differentFilter4)).toBeFalsy(); + }); + }); +}); diff --git a/src/tests/unit/RecipeFilters/RecipeSearchFilter.test.ts b/src/tests/unit/RecipeFilters/RecipeSearchFilter.test.ts new file mode 100644 index 000000000..036505b61 --- /dev/null +++ b/src/tests/unit/RecipeFilters/RecipeSearchFilter.test.ts @@ -0,0 +1,460 @@ +import RecipeSearchFilter from 'cookbook/js/RecipeFilters/RecipeSearchFilter'; +import { AndOperator, OrOperator } from 'cookbook/js/LogicOperators'; +import { Recipe } from 'cookbook/js/Models/schema'; +import SearchMode from 'cookbook/js/Enums/SearchMode'; + +/** + * Test suite for the RecipeSearchFilter class. + */ +describe('RecipeSearchFilter', () => { + /** @type {Object[]} recipes - Array of recipe objects for testing. */ + const recipes: Recipe[] = [ + new Recipe('123', 'Pasta Carbonara'), + new Recipe('223', 'Better Pasta Carbonara'), + new Recipe('323', 'Chocolate Cake'), + new Recipe('423', 'Brownie'), + new Recipe('523', 'Salad'), + new Recipe('623', 'Pizza Calzone'), + new Recipe('723', 'Pizza'), + ]; + + describe('filter()', () => { + /** + * Test case: it should filter recipes by a single name using AND operator and filter mode `MatchSubstring`. + */ + test('it should filter recipes by a single name using AND operator', () => { + const filter = new RecipeSearchFilter( + 'pasta carbonara', + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes[0].name).toBe('Pasta Carbonara'); + expect(filteredRecipes[1].name).toBe('Better Pasta Carbonara'); + }); + + /** + * Test case: it should filter recipes by a single name using AND operator and filter mode `Exact`. + */ + test('it should filter recipes by a single name using AND operator', () => { + const filter = new RecipeSearchFilter( + 'pasta carbonara', + new AndOperator(), + SearchMode.Exact, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes[0].name).toBe('Pasta Carbonara'); + // expect(filteredRecipes[1].name).toStrictEqual('Better Pasta Carbonara'); + }); + + /** + * Test case: it should filter recipes by a single name using OR operator and filter mode `MatchSubstring`. + */ + test('it should filter recipes by a single name using OR operator', () => { + const filter = new RecipeSearchFilter('pizza', new OrOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ + 'Pizza Calzone', + 'Pizza', + ]); + }); + + /** + * Test case: it should filter recipes by a single name using OR operator and filter mode `Exact`. + */ + test('it should filter recipes by a single name using OR operator', () => { + const filter = new RecipeSearchFilter( + 'pizza', + new OrOperator(), + SearchMode.Exact, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ + 'Pizza', + ]); + }); + + /** + * Test case: it should filter recipes by multiple names using AND operator. + */ + test('it should filter recipes by multiple names using AND operator', () => { + // The recipe name is currently not allowed to be an array. + // Will only return result if both queries are the same name or fuzzy search is on and both match the same recipe. + const filter = new RecipeSearchFilter( + ['Chocolate Cake'], + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes[0].name).toEqual('Chocolate Cake'); + }); + + /** + * Test case: it should filter recipes by multiple names using OR operator. + */ + test('it should filter recipes by multiple names using OR operator', () => { + const filter = new RecipeSearchFilter( + ['salad', 'pizza calzone'], + new OrOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ + 'Salad', + 'Pizza Calzone', + ]); + }); + + /** + * Test case: it should handle case-insensitive filtering. + */ + test('it should handle case-insensitive filtering', () => { + const filter = new RecipeSearchFilter( + 'PasTA cArBOnara', + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes[0].name).toStrictEqual('Pasta Carbonara'); + expect(filteredRecipes[1].name).toStrictEqual( + 'Better Pasta Carbonara', + ); + }); + + /** + * Test case: it should handle empty names list and return all recipes with OR operator. + */ + test('it should handle empty names and return all recipes with OR operator', () => { + const filter = new RecipeSearchFilter([], new OrOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Chocolate Cake', + 'Brownie', + 'Salad', + 'Pizza Calzone', + 'Pizza', + ]); + }); + + /** + * Test case: it should handle empty names list and return all recipes with AND operator. + */ + test('it should handle empty names and return all recipes with AND operator', () => { + const filter = new RecipeSearchFilter([], new AndOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Chocolate Cake', + 'Brownie', + 'Salad', + 'Pizza Calzone', + 'Pizza', + ]); + }); + + /** + * Test case: it should handle empty-string names and return all recipes with OR operator. + */ + test('it should handle empty-string names and return all recipes with OR operator', () => { + const filter = new RecipeSearchFilter([''], new OrOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Chocolate Cake', + 'Brownie', + 'Salad', + 'Pizza Calzone', + 'Pizza', + ]); + }); + + /** + * Test case: it should handle empty-string names and return all recipes with AND operator. + */ + test('it should handle empty-string names and return all recipes with AND operator', () => { + const filter = new RecipeSearchFilter([''], new AndOperator()); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(recipes.length); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Chocolate Cake', + 'Brownie', + 'Salad', + 'Pizza Calzone', + 'Pizza', + ]); + }); + + /** + * Test case: it should filter recipes when name property is an array using AND operator. + */ + test('it should filter recipes when name property is an array using AND operator', () => { + const filter = new RecipeSearchFilter( + ['Chocolate Cake', 'Chocolate Cake'], + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes[0].name).toEqual('Chocolate Cake'); + }); + + /** + * Test case: it should filter recipes when name property is an array using AND operator with filter type `MatchSubstring`. + */ + test('it should filter recipes when name property is an array using AND operator but fail for parts without fuzzy search', () => { + const filter = new RecipeSearchFilter( + ['Chocolate', 'Cake'], + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + expect(filteredRecipes[0].name).toEqual('Chocolate Cake'); + }); + + /** + * Test case: it should filter recipes when name property is an array using AND operator but fail for parts without fuzzy search. + */ + test('it should filter recipes when name property is an array using AND operator but fail for parts without fuzzy search', () => { + const filter = new RecipeSearchFilter( + ['Chocolate', 'Nothing'], + new AndOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(0); + }); + + /** + * Test case: it should filter recipes when name property is an array using OR operator. + */ + test('it should filter recipes when name property is an array using OR operator', () => { + const filter = new RecipeSearchFilter( + ['salad', 'pizza calzone'], + new OrOperator(), + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Salad', + 'Pizza Calzone', + ]); + }); + + /** + * Test case: it should filter recipes if part of name is included using AND operator, matchSubstring mode. + */ + test('it should filter recipes if part of name is included using AND operator, matchSubstring mode.', () => { + const filter = new RecipeSearchFilter( + 'pasta', + new AndOperator(), + SearchMode.MatchSubstring, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + ]); + }); + + /** + * Test case: it should filter recipes if part of name is included using OR operator, matchSubstring mode. + */ + test('it should filter recipes if part of name is included using OR operator, matchSubstring mode.', () => { + const filter = new RecipeSearchFilter( + 'pasta', + new OrOperator(), + SearchMode.MatchSubstring, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + ]); + }); + + /** + * Test case: it should filter recipes with fuzzy search using AND operator. + */ + test('it should filter recipes with fuzzy search using AND operator', () => { + const filter = new RecipeSearchFilter( + 'Piza Car', + new AndOperator(), + SearchMode.Fuzzy, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(3); + + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Pizza Calzone', + ]); + }); + + /** + * Test case: it should filter recipes with fuzzy search, multiple terms, using AND operator. + */ + test('it should filter recipes with fuzzy search, multiple terms, using AND operator', () => { + const filter = new RecipeSearchFilter( + ['Pizz', 'C4lz'], + new AndOperator(), + SearchMode.Fuzzy, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(1); + + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Pizza Calzone', + ]); + }); + + /** + * Test case: it should filter recipes with fuzzy search using OR operator. + */ + test('it should filter recipes with fuzzy search using OR operator', () => { + const filter = new RecipeSearchFilter( + 'Piza Car', + new OrOperator(), + SearchMode.Fuzzy, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(3); + + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Pizza Calzone', + ]); + }); + + /** + * Test case: it should filter recipes with fuzzy search, multiple terms, using OR operator. + */ + test('it should filter recipes with fuzzy search, multiple terms, using OR operator', () => { + const filter = new RecipeSearchFilter( + ['Piza Car', 'S4l'], + new OrOperator(), + SearchMode.Fuzzy, + ); + const filteredRecipes = recipes.filter((recipe) => + filter.filter(recipe), + ); + expect(filteredRecipes).toHaveLength(4); + + expect(filteredRecipes.map((recipe) => recipe.name)).toStrictEqual([ + 'Pasta Carbonara', + 'Better Pasta Carbonara', + 'Salad', + 'Pizza Calzone', + ]); + }); + }); + + describe('equals()', () => { + /** + * Test case: it should handle comparison with equal filters correctly. + */ + test('it should handle comparison with equal filters correctly', () => { + const filter = new RecipeSearchFilter( + ['sweet', 'salty'], + new AndOperator(), + SearchMode.Fuzzy, + ); + const sameFilter = new RecipeSearchFilter( + ['sweet', 'salty'], + new AndOperator(), + SearchMode.Fuzzy, + ); + + expect(filter.equals(sameFilter)).toBeTruthy(); + }); + + /** + * Test case: it should handle comparison with unequal filters correctly. + */ + test('it should handle comparison with unequal filters correctly', () => { + const filter = new RecipeSearchFilter( + ['sweet', 'salty'], + new AndOperator(), + SearchMode.Fuzzy, + ); + const differentFilter = new RecipeSearchFilter( + ['sweet'], + new AndOperator(), + SearchMode.Fuzzy, + ); + const differentFilter2 = new RecipeSearchFilter( + ['sweet', 'hot'], + new AndOperator(), + SearchMode.Fuzzy, + ); + const differentFilter3 = new RecipeSearchFilter( + ['sweet', 'salty'], + new OrOperator(), + SearchMode.Fuzzy, + ); + const differentFilter4 = new RecipeSearchFilter( + ['sweet', 'salty'], + new OrOperator(), + SearchMode.Exact, + ); + + expect(filter.equals(differentFilter)).toBeFalsy(); + expect(filter.equals(differentFilter2)).toBeFalsy(); + expect(filter.equals(differentFilter3)).toBeFalsy(); + expect(filter.equals(differentFilter4)).toBeFalsy(); + }); + }); +}); diff --git a/src/tests/unit/Visitors/SchemaOrg/SupplyCollector.test.ts b/src/tests/unit/Visitors/SchemaOrg/SupplyCollector.test.ts new file mode 100644 index 000000000..1a5eb4f2c --- /dev/null +++ b/src/tests/unit/Visitors/SchemaOrg/SupplyCollector.test.ts @@ -0,0 +1,94 @@ +import { + HowToDirection, + HowToSection, + HowToStep, + HowToSupply, + HowToTip, +} from 'cookbook/js/Models/schema'; +import SupplyCollector from 'cookbook/js/Visitors/SchemaOrg/SupplyCollector'; + +/** + * Test suite for the SupplyCollector. + */ +describe('SupplyCollector', () => { + let collector: SupplyCollector; + + beforeEach(() => { + collector = new SupplyCollector(); + }); + + it('should collect supplies from HowToDirection', () => { + const direction = new HowToDirection(''); + direction.supply = [ + new HowToSupply('Ingredient 1'), + new HowToSupply('Ingredient 2'), + ]; + + collector.visitHowToDirection(direction); + + expect(collector.getSupplies()).toEqual( + expect.arrayContaining(direction.supply), + ); + }); + + it('should collect supplies from HowToStep', () => { + const step = new HowToStep('', [ + new HowToDirection('', { + supply: [new HowToSupply('Ingredient 1')], + }), + ]); + + collector.visitHowToStep(step); + + expect(collector.getSupplies()).toEqual( + expect.arrayContaining( + (step.itemListElement[0] as HowToDirection).supply, + ), + ); + }); + + it('should collect supplies from HowToSection', () => { + const section = new HowToSection(''); + section.itemListElement = [ + new HowToDirection('', { + supply: [new HowToSupply('Ingredient 1')], + }), + ]; + + collector.visitHowToSection(section); + + expect(collector.getSupplies()).toEqual( + expect.arrayContaining( + (section.itemListElement[0] as HowToDirection).supply, + ), + ); + }); + + it('should collect supplies from HowToDirection and HowToStep with nested HowToDirection', () => { + const supply1 = new HowToSupply('Ingredient 1'); + const supply2 = new HowToSupply('Ingredient 2'); + + const direction1 = new HowToDirection('', { supply: [supply1] }); + const direction2 = new HowToDirection('', { supply: [supply2] }); + + const step = new HowToStep('', [direction2]); + + const section = new HowToSection('', { + itemListElement: [direction1, step], + }); + + collector.visitHowToSection(section); + + expect(collector.getSupplies()).toEqual( + expect.arrayContaining([supply1, supply2]), + ); + }); + + it('should not collect supplies from HowToTip', () => { + const tip = new HowToTip(''); + + collector.visitHowToTip(tip); + + expect(collector.getSupplies()).toEqual([]); + }); +}); diff --git a/src/tests/unit/Visitors/SchemaOrg/ToolsCollector.test.ts b/src/tests/unit/Visitors/SchemaOrg/ToolsCollector.test.ts new file mode 100644 index 000000000..87f62262a --- /dev/null +++ b/src/tests/unit/Visitors/SchemaOrg/ToolsCollector.test.ts @@ -0,0 +1,92 @@ +import { + HowToDirection, + HowToSection, + HowToStep, + HowToTool, + HowToTip, +} from 'cookbook/js/Models/schema'; +import ToolsCollector from 'cookbook/js/Visitors/SchemaOrg/ToolsCollector'; + +/** + * Test suite for the ToolsCollector. + */ +describe('ToolsCollector', () => { + let collector: ToolsCollector; + + beforeEach(() => { + collector = new ToolsCollector(); + }); + + it('should collect tools from HowToDirection', () => { + const direction = new HowToDirection('', { + tool: [new HowToTool('Tool 1')], + }); + + collector.visitHowToDirection(direction); + + expect(collector.getTools()).toEqual( + expect.arrayContaining(direction.tool), + ); + }); + + it('should collect tools from HowToStep', () => { + const step = new HowToStep('', [ + new HowToDirection('', { + tool: new HowToTool('Tool 1'), + }), + ]); + + collector.visitHowToStep(step); + + expect(collector.getTools()).toEqual( + expect.arrayContaining( + (step.itemListElement[0] as HowToDirection).tool, + ), + ); + }); + + it('should collect tools from HowToSection', () => { + const section = new HowToSection(''); + section.itemListElement = [ + new HowToDirection('', { + tool: new HowToTool('Tool 1'), + }), + ]; + + collector.visitHowToSection(section); + + expect(collector.getTools()).toEqual( + expect.arrayContaining( + (section.itemListElement[0] as HowToDirection).tool, + ), + ); + }); + + it('should collect tools from HowToDirection and HowToStep with nested HowToDirection', () => { + const tool1 = new HowToTool('Tool 1'); + const tool2 = new HowToTool('Tool 2'); + + const direction1 = new HowToDirection('', { tool: [tool1] }); + const direction2 = new HowToDirection('', { tool: [tool2] }); + + const step = new HowToStep('', [direction2]); + + const section = new HowToSection('', { + itemListElement: [direction1, step], + }); + + collector.visitHowToSection(section); + + expect(collector.getTools()).toEqual( + expect.arrayContaining([tool1, tool2]), + ); + }); + + it('should not collect tools from HowToTip', () => { + const tip = new HowToTip(''); + + collector.visitHowToTip(tip); + + expect(collector.getTools()).toEqual([]); + }); +}); diff --git a/src/tests/unit/helper.test.ts b/src/tests/unit/helper.test.ts new file mode 100644 index 000000000..528f6e658 --- /dev/null +++ b/src/tests/unit/helper.test.ts @@ -0,0 +1,45 @@ +import { asArray, asCleanedArray } from 'cookbook/js/helper'; + +// asArray() tests +describe('asArray', () => { + it('should return array unchanged', () => { + const arr = ['val', null, 1, undefined]; + expect(asArray(arr)).toStrictEqual(arr); + }); + it('should return single value as array', () => { + const val = 'val'; + expect(asArray(val)).toStrictEqual([val]); + }); + it('should return single undefined value as array', () => { + const val = undefined; + expect(asArray(val)).toStrictEqual([val]); + }); + it('should return single null value as array', () => { + const val = undefined; + expect(asArray(val)).toStrictEqual([val]); + }); +}); + +// asCleanedArray() tests +describe('asCleanedArray', () => { + it('should return array with values unchanged', () => { + const arr = ['val', { test: 'name', id: 4 }, 1, true]; + expect(asCleanedArray(arr)).toStrictEqual(arr); + }); + it('should remove null and undefined from array', () => { + const arr = ['val', null, 1, undefined]; + expect(asCleanedArray(arr)).toStrictEqual(['val', 1]); + }); + it('should return single value as array', () => { + const val = 'val'; + expect(asCleanedArray(val)).toStrictEqual([val]); + }); + it('should return single undefined value as empty array', () => { + const val = undefined; + expect(asCleanedArray(val)).toStrictEqual([]); + }); + it('should return single null value as empty array', () => { + const val = undefined; + expect(asCleanedArray(val)).toStrictEqual([]); + }); +}); diff --git a/src/tests/unit/utils/applyRecipeFilters.test.js b/src/tests/unit/utils/applyRecipeFilters.test.js deleted file mode 100644 index 73ca4a3d5..000000000 --- a/src/tests/unit/utils/applyRecipeFilters.test.js +++ /dev/null @@ -1,90 +0,0 @@ -// eslint-disable-next-line max-classes-per-file -import applyFilters from '../../../js/utils/applyRecipeFilters'; -import RecipeFilter from '../../../js/RecipeFilters/RecipeFilter'; -import { AndOperator } from '../../../js/LogicOperators'; -// Mocked filter classes -class MockFilter extends RecipeFilter { - constructor(predicate) { - super(new AndOperator()); - this.filter = jest.fn(predicate); - } -} - -/** - * Test suite for the applyFilters method. - */ -describe('applyFilters', () => { - /** @type {Object[]} recipes - Array of recipe objects for testing. */ - const recipes = [ - { - name: 'Pasta Carbonara', - keywords: ['pasta', 'italian'], - recipeCategory: ['main course'], - }, - { - name: 'Pasta Carbonara', - keywords: ['italian'], - recipeCategory: ['main course'], - }, - { - name: 'Pasta Carbonara', - keywords: ['pasta', 'creamy'], - recipeCategory: ['main course'], - }, - { - name: 'Chocolate Cake', - keywords: ['chocolate', 'cake'], - recipeCategory: ['dessert'], - }, - { - name: 'Salad', - keywords: ['healthy', 'salad'], - recipeCategory: ['appetizer'], - }, - ]; - - /** - * Test case: it should apply mocked filters and filter recipes correctly. - */ - test('it should apply mocked filters and filter recipes correctly', () => { - // Create mocked filters - const filter1 = new MockFilter( - (recipe) => recipe.name === 'Pasta Carbonara', - ); - const filter2 = new MockFilter((recipe) => - recipe.keywords.includes('pasta'), - ); - - // Apply filters - const filteredRecipes = applyFilters(recipes, [filter1, filter2]); - - // Check if filter methods were called - expect(filter1.filter).toHaveBeenCalledTimes(5); - expect(filter2.filter).toHaveBeenCalledTimes(3); - - // Check if recipes are filtered correctly - expect(filteredRecipes).toHaveLength(2); - expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ - ['pasta', 'italian'], - ['pasta', 'creamy'], - ]); - }); - - /** - * Test case: it should handle an empty array of filters and return all recipes. - */ - test('it should handle an empty array of filters and return all recipes', () => { - // Apply filters with an empty array - const filteredRecipes = applyFilters(recipes, []); - - // Check if all recipes are returned - expect(filteredRecipes).toHaveLength(5); - expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ - 'Pasta Carbonara', - 'Pasta Carbonara', - 'Pasta Carbonara', - 'Chocolate Cake', - 'Salad', - ]); - }); -}); diff --git a/src/tests/unit/utils/applyRecipeFilters.test.ts b/src/tests/unit/utils/applyRecipeFilters.test.ts new file mode 100644 index 000000000..9636218a8 --- /dev/null +++ b/src/tests/unit/utils/applyRecipeFilters.test.ts @@ -0,0 +1,83 @@ +// eslint-disable-next-line max-classes-per-file +import applyFilters from 'cookbook/js/utils/applyRecipeFilters'; +import RecipeFilter from 'cookbook/js/RecipeFilters/RecipeFilter'; +import { Recipe } from 'cookbook/js/Models/schema'; +import { AndOperator } from 'cookbook/js/LogicOperators'; + +// Mocked filter classes +class MockFilter extends RecipeFilter { + constructor(predicate) { + super(new AndOperator()); + this.filter = jest.fn(predicate); + } +} + +function createRecipe( + name: string, + keywords: string[], + category: string | string[], +) { + const recipe = new Recipe('123', name); + recipe.keywords = keywords; + recipe.recipeCategory = category; + return recipe; +} + +/** + * Test suite for the applyFilters method. + */ +describe('applyFilters', () => { + /** @type {Object[]} recipes - Array of recipe objects for testing. */ + const recipes = [ + createRecipe('Pasta Carbonara', ['pasta', 'italian'], ['main course']), + createRecipe('Pasta Carbonara', ['italian'], ['main course']), + createRecipe('Pasta Carbonara', ['pasta', 'creamy'], ['main course']), + createRecipe('Chocolate Cake', ['chocolate', 'cake'], ['dessert']), + createRecipe('Salad', ['healthy', 'salad'], ['appetizer']), + ]; + + /** + * Test case: it should apply mocked filters and filter recipes correctly. + */ + test('it should apply mocked filters and filter recipes correctly', () => { + // Create mocked filters + const filter1 = new MockFilter( + (recipe) => recipe.name === 'Pasta Carbonara', + ); + const filter2 = new MockFilter((recipe) => + recipe.keywords.includes('pasta'), + ); + + // Apply filters + const filteredRecipes = applyFilters(recipes, [filter1, filter2]); + + // Check if filter methods were called + expect(filter1.filter).toHaveBeenCalledTimes(5); + expect(filter2.filter).toHaveBeenCalledTimes(3); + + // Check if recipes are filtered correctly + expect(filteredRecipes).toHaveLength(2); + expect(filteredRecipes.map((recipe) => recipe.keywords)).toEqual([ + ['pasta', 'italian'], + ['pasta', 'creamy'], + ]); + }); + + /** + * Test case: it should handle an empty array of filters and return all recipes. + */ + test('it should handle an empty array of filters and return all recipes', () => { + // Apply filters with an empty array + const filteredRecipes = applyFilters(recipes, []); + + // Check if all recipes are returned + expect(filteredRecipes).toHaveLength(5); + expect(filteredRecipes.map((recipe) => recipe.name)).toEqual([ + 'Pasta Carbonara', + 'Pasta Carbonara', + 'Pasta Carbonara', + 'Chocolate Cake', + 'Salad', + ]); + }); +}); diff --git a/src/tests/unit/utils/jsonMapper.test.ts b/src/tests/unit/utils/jsonMapper.test.ts new file mode 100644 index 000000000..39bdc0527 --- /dev/null +++ b/src/tests/unit/utils/jsonMapper.test.ts @@ -0,0 +1,169 @@ +import { + mapInteger, + mapString, + mapStringOrStringArray, +} from 'cookbook/js/utils/jsonMapper'; + +// mapInteger tests +describe('mapInteger', () => { + it('should throw for null value', () => { + expect(() => mapInteger(null, 'property')).toThrow( + 'Error mapping property. Expected integer number but received "null".', + ); + }); + + it('should return null for null value if null and undefined are allowed', () => { + const result = mapInteger(null, '', true); + expect(result).toBeNull(); + }); + + it('should throw for undefined value', () => { + expect(() => mapInteger(undefined, 'property', false)).toThrow( + 'Error mapping property. Expected integer number but received "undefined".', + ); + }); + + it('should return undefined for undefined value if null and undefined are allowed', () => { + const result = mapInteger(undefined, '', true); + expect(result).toBeUndefined(); + }); + + it('should map integer number to itself', () => { + const result = mapInteger(42); + expect(result).toBe(42); + }); + + it('should map numeric string to an integer', () => { + const result = mapInteger('123'); + expect(result).toBe(123); + }); + + it('should throw an error for non-integer number', () => { + expect(() => mapInteger(42.5, 'property')).toThrow( + 'Error mapping property. Expected integer number but received non-integer "42.5".', + ); + }); + + it('should throw an error for non-numeric string', () => { + expect(() => mapInteger('abc', 'property')).toThrow( + 'Error mapping property. Expected integer number but received non-parsable string "abc".', + ); + }); + + it('should throw an error for non-numeric and non-string value', () => { + const invalidValue = { prop: 'invalid' }; + expect(() => mapInteger(invalidValue, 'property')).toThrow( + 'Error mapping property. Expected integer number but received "object".', + ); + }); +}); + +// mapStringOrStringArray() tests +describe('mapStringOrStringArray', () => { + it('should throw for null value', () => { + expect(() => mapStringOrStringArray(null, 'property')).toThrow( + 'Error mapping property. Expected string or string array but received "null".', + ); + }); + + it('should return null for null value if null and undefined are allowed', () => { + const result = mapStringOrStringArray(null, '', true); + expect(result).toBeNull(); + }); + + it('should throw for undefined value', () => { + expect(() => + mapStringOrStringArray(undefined, 'property', false), + ).toThrow( + 'Error mapping property. Expected string or string array but received "undefined".', + ); + }); + + it('should return undefined for undefined value if null and undefined are allowed', () => { + const result = mapStringOrStringArray(undefined, '', true); + expect(result).toBeUndefined(); + }); + + it('should map string to itself', () => { + const result = mapStringOrStringArray('test'); + expect(result).toBe('test'); + }); + + it('should map array of strings to itself', () => { + const result = mapStringOrStringArray(['test1', 'test2']); + expect(result).toEqual(['test1', 'test2']); + }); + + it('should map comma-separated list of strings to array if option is set', () => { + const result = mapStringOrStringArray('test1, test2', '', false, true); + expect(result).toEqual(['test1', 'test2']); + }); + + it('should NOT map comma-separated list of strings to array if option is NOT set', () => { + const result = mapStringOrStringArray('test1, test2', '', false, false); + expect(result).toEqual('test1, test2'); + }); + + it('should throw an error for non-string and non-array value', () => { + const invalidValue = 42; + const invalidValue2 = { prop: '42' }; + + expect(() => mapStringOrStringArray(invalidValue, 'property')).toThrow( + 'Error mapping property. Expected string or array but received "number".', + ); + + expect(() => mapStringOrStringArray(invalidValue2, 'property')).toThrow( + 'Error mapping property. Expected string or array but received "object".', + ); + }); + + it('should throw an error for array with non-string elements', () => { + const invalidValue = ['test', 42]; + expect(() => mapStringOrStringArray(invalidValue, 'property')).toThrow( + 'Error mapping property. Expected string or string array received array with non-string elements.', + ); + }); +}); + +// mapString tests +describe('mapString', () => { + it('should throw for null value', () => { + expect(() => mapString(null, 'property')).toThrow( + 'Error mapping property. Expected string but received "null".', + ); + }); + + it('should return null for null value if null and undefined are allowed', () => { + const result = mapString(null, '', true); + expect(result).toBeNull(); + }); + + it('should throw for undefined value', () => { + expect(() => mapString(undefined, 'property')).toThrow( + 'Error mapping property. Expected string but received "undefined".', + ); + }); + + it('should return undefined for undefined value if null and undefined are allowed', () => { + const result = mapString(undefined, '', true); + expect(result).toBeUndefined(); + }); + + it('should map string to itself', () => { + const result = mapString('test'); + expect(result).toBe('test'); + }); + + it('should map integer value to string', () => { + const result = mapString(42); + expect(result).toBe('42'); + }); + + it('should throw an error for object value', () => { + const invalidValueObject = { value: '42' }; + + expect(() => mapString(invalidValueObject, 'property')).toThrow( + 'Error mapping property. Expected string but received "object".', + ); + }); +}); diff --git a/src/tests/unit/utils/mathUtils.test.ts b/src/tests/unit/utils/mathUtils.test.ts new file mode 100644 index 000000000..47535db1e --- /dev/null +++ b/src/tests/unit/utils/mathUtils.test.ts @@ -0,0 +1,77 @@ +// adjustToIntegerToInteger() tests +import { adjustToInteger, clamp, roundTo } from 'cookbook/js/utils/mathUtils'; + +describe('adjustToInteger function', () => { + it('should increase the value by 1 when step is 1', () => { + expect(adjustToInteger(1, 1)).toBe(2); + expect(adjustToInteger(1.5, 1)).toBe(2); + }); + + it('should increase the value by 2 when step is 2', () => { + expect(adjustToInteger(1, 2)).toBe(3); + expect(adjustToInteger(1.5, 2)).toBe(3); + }); + + it('should decrease the value by 1 when step is -1', () => { + expect(adjustToInteger(2, -1)).toBe(1); + expect(adjustToInteger(2.5, -1)).toBe(2); + expect(adjustToInteger(1, -1)).toBe(1); + expect(adjustToInteger(0.5, -1)).toBe(0.5); + }); + + it('should decrease the value by 2 when step is -2', () => { + expect(adjustToInteger(3, -2)).toBe(1); + expect(adjustToInteger(3.5, -2)).toBe(2); + expect(adjustToInteger(1.5, -2)).toBe(1); + expect(adjustToInteger(0.5, -2)).toBe(0.5); + }); + + it('should handle values between 0 and 1 correctly with negative step', () => { + expect(adjustToInteger(0.5, -1)).toBe(0.5); + expect(adjustToInteger(0.5, -2)).toBe(0.5); + expect(adjustToInteger(0.3, -1)).toBe(0.3); + expect(adjustToInteger(0.3, -2)).toBe(0.3); + }); +}); + +describe('clamp', () => { + it('should return the value if it is within the min and max range', () => { + expect(clamp(5, 0, 10)).toBe(5); + }); + + it('should return the minimum value if the input is less than the minimum', () => { + expect(clamp(-5, 0, 10)).toBe(0); + }); + + it('should return the maximum value if the input is greater than the maximum', () => { + expect(clamp(15, 0, 10)).toBe(10); + }); +}); + +describe('roundTo', () => { + it('should round a number to the specified precision', () => { + expect(roundTo(3.14159, 2)).toBe(3.14); + expect(roundTo(2.718, 3)).toBe(2.718); + expect(roundTo(1.2345, 1)).toBe(1.2); + }); + + it('should round to the nearest integer if precision is 0', () => { + expect(roundTo(4.6, 0)).toBe(5); + expect(roundTo(7.2, 0)).toBe(7); + }); + + it('should handle negative precision', () => { + expect(roundTo(1234.5, -2)).toBe(1200); + expect(roundTo(9876.54, -1)).toBe(9880); + }); + + it('should handle very small numbers', () => { + expect(roundTo(0.00001, 5)).toBe(0.00001); + expect(roundTo(0.000001, 6)).toBe(0.000001); + }); + + it('should handle very large numbers', () => { + expect(roundTo(1e20, 2)).toBe(1e20); + expect(roundTo(1e30, 0)).toBe(1e30); + }); +}); diff --git a/src/tests/unit/utils/parseSearchString.test.ts b/src/tests/unit/utils/parseSearchString.test.ts new file mode 100644 index 000000000..d438e3fc1 --- /dev/null +++ b/src/tests/unit/utils/parseSearchString.test.ts @@ -0,0 +1,189 @@ +import parseSearchString from 'cookbook/js/utils/parseSearchString'; // Adjust the import based on your actual file structure +import { AndOperator, OrOperator } from 'cookbook/js/LogicOperators'; +import { + RecipeKeywordsFilter as KeywordFilter, + RecipeCategoriesFilter as CategoryFilter, + RecipeSearchFilter as SearchFilter, +} from 'cookbook/js/RecipeFilters'; + +describe('parseSearchString', () => { + test('should correctly parse a single item filter', () => { + const searchString = 'tag:"sweet"'; + const filters = parseSearchString(searchString); + + // Expect a single TagFilter object + expect(filters.length).toBe(1); + expect(filters[0]).toBeInstanceOf(KeywordFilter); + + // Check the TagFilter properties + expect((filters[0] as KeywordFilter).keywords).toEqual(['sweet']); + expect(filters[0].operator).toBeInstanceOf(AndOperator); + }); + + test('should correctly parse a single item with space filter', () => { + const searchString = 'tag:"sweet sauce"'; + const filters = parseSearchString(searchString); + + // Expect a single TagFilter object + expect(filters.length).toBe(1); + expect(filters[0]).toBeInstanceOf(KeywordFilter); + + // Check the TagFilter properties + expect((filters[0] as KeywordFilter).keywords).toEqual(['sweet sauce']); + expect(filters[0].operator).toBeInstanceOf(AndOperator); + }); + + test('should correctly parse two items with different labels', () => { + const searchString = 'tag:"sweet" cat:"Dessert"'; + const filters = parseSearchString(searchString); + + // Expect two filter objects: one TagFilter and one CategoryFilter + expect(filters.length).toBe(2); + expect(filters[0]).toBeInstanceOf(KeywordFilter); + expect(filters[1]).toBeInstanceOf(CategoryFilter); + + // Check the TagFilter properties + expect((filters[0] as KeywordFilter).keywords).toEqual(['sweet']); + expect(filters[0].operator).toBeInstanceOf(AndOperator); + + // Check the CategoryFilter properties + expect((filters[1] as CategoryFilter).categories).toEqual(['Dessert']); + expect(filters[1].operator).toBeInstanceOf(AndOperator); + }); + + test('should correctly parse a single item with OR filter', () => { + const searchString = 'tag:"sweet","salty"'; + const filters = parseSearchString(searchString); + + // Expect a single TagFilter object + expect(filters.length).toBe(1); + expect(filters[0]).toBeInstanceOf(KeywordFilter); + + // Check the TagFilter properties + expect((filters[0] as KeywordFilter).keywords).toEqual([ + 'sweet', + 'salty', + ]); + expect(filters[0].operator).toBeInstanceOf(OrOperator); + }); + + test('should correctly parse a single item with mixed quoted and unquoted terms in OR filter', () => { + const searchString = 'tag:"sweet",salty'; + const filters = parseSearchString(searchString); + + // Expect a single TagFilter object + expect(filters.length).toBe(1); + expect(filters[0]).toBeInstanceOf(KeywordFilter); + + // Check the TagFilter properties + expect((filters[0] as KeywordFilter).keywords).toEqual([ + 'sweet', + 'salty', + ]); + expect(filters[0].operator).toBeInstanceOf(OrOperator); + }); + + test('should correctly parse a two items with AND filter', () => { + const searchString = 'tag:"sweet" tag:"salty"'; + const filters = parseSearchString(searchString); + + // Expect a single TagFilter object + expect(filters.length).toBe(1); + expect(filters[0]).toBeInstanceOf(KeywordFilter); + + // Check the TagFilter properties + expect((filters[0] as KeywordFilter).keywords).toEqual([ + 'sweet', + 'salty', + ]); + expect(filters[0].operator).toBeInstanceOf(AndOperator); + }); + + test('should correctly parse mixed AND and OR filters', () => { + const searchString = 'tag:"sweet","salty" tag:"quick"'; + const filters = parseSearchString(searchString); + + // Expect two TagFilter objects + expect(filters.length).toBe(2); + expect(filters[0]).toBeInstanceOf(KeywordFilter); + expect(filters[1]).toBeInstanceOf(KeywordFilter); + + // Check the second filter (AND logic) + expect((filters[0] as KeywordFilter).keywords).toEqual(['quick']); + expect(filters[0].operator).toBeInstanceOf(AndOperator); + + // Check the first filter (OR logic) + expect((filters[1] as KeywordFilter).keywords).toEqual([ + 'sweet', + 'salty', + ]); + expect(filters[1].operator).toBeInstanceOf(OrOperator); + }); + + test('should correctly parse unlabelled search terms', () => { + const searchString = 'cat:Dessert Search query'; + const filters = parseSearchString(searchString); + + // Expect a CategoryFilter and a SearchFilter + expect(filters.length).toBe(2); + expect(filters[0]).toBeInstanceOf(CategoryFilter); + expect(filters[1]).toBeInstanceOf(SearchFilter); + + // Check the CategoryFilter + expect((filters[0] as CategoryFilter).categories).toEqual(['Dessert']); + expect(filters[0].operator).toBeInstanceOf(AndOperator); + + // Check the SearchFilter + expect((filters[1] as SearchFilter).queries).toStrictEqual([ + 'Search query', + ]); + expect(filters[1].operator).toBeInstanceOf(AndOperator); + }); + + test('should correctly parse unknown label as search terms', () => { + // Prepare + const searchString1 = 'unknown:Dessert'; + const searchString2 = 'unknown:'; + const searchString3 = 'cat:"Main dish" unknown:Dessert'; + + // Act + const filters = parseSearchString(searchString1); + const filters2 = parseSearchString(searchString2); + const filters3 = parseSearchString(searchString3); + + // Assert + + // Expect a SearchFilter + expect(filters.length).toBe(1); + expect(filters[0]).toBeInstanceOf(SearchFilter); + + // Check the SearchFilter + expect((filters[0] as SearchFilter).queries).toStrictEqual([ + 'unknown:Dessert', + ]); + + // Expect a SearchFilter + expect(filters2.length).toBe(1); + expect(filters2[0]).toBeInstanceOf(SearchFilter); + + // Check the SearchFilter + expect((filters2[0] as SearchFilter).queries).toStrictEqual([ + 'unknown:', + ]); + + // Expect a CategoryFilter and a SearchFilter + expect(filters3.length).toBe(2); + expect(filters3[0]).toBeInstanceOf(CategoryFilter); + expect(filters3[1]).toBeInstanceOf(SearchFilter); + + // Check the CategoryFilter + expect((filters3[0] as CategoryFilter).categories).toStrictEqual([ + 'Main dish', + ]); + + // Check the SearchFilter + expect((filters3[1] as SearchFilter).queries).toStrictEqual([ + 'unknown:Dessert', + ]); + }); +}); diff --git a/src/tests/unit/utils/removeDuplicates.test.ts b/src/tests/unit/utils/removeDuplicates.test.ts new file mode 100644 index 000000000..c58b8abb8 --- /dev/null +++ b/src/tests/unit/utils/removeDuplicates.test.ts @@ -0,0 +1,88 @@ +import { removeDuplicatesInNestedStringArray } from 'cookbook/js/utils/removeDuplicates'; + +describe('removeDuplicatesInNestedStringArray', () => { + test('should remove duplicate string arrays', () => { + const input = [ + ['apple', 'banana'], + ['banana', 'apple'], + ['cherry', 'date'], + ['apple', 'banana'], + ]; + const expectedOutput = [ + ['apple', 'banana'], + ['cherry', 'date'], + ]; + + const result = removeDuplicatesInNestedStringArray(input); + + // Check if the result has the expected length + expect(result.length).toBe(expectedOutput.length); + + // Check if each element in the result matches the expected output + expectedOutput.forEach((expectedArray, index) => { + expect(result[index]).toEqual( + expect.arrayContaining(expectedArray), + ); + }); + }); + + test('should not remove non-duplicate string arrays', () => { + const input = [ + ['apple', 'banana'], + ['cherry', 'date'], + ]; + const expectedOutput = [ + ['apple', 'banana'], + ['cherry', 'date'], + ]; + + const result = removeDuplicatesInNestedStringArray(input); + + // Check if the result has the expected length + expect(result.length).toBe(expectedOutput.length); + + // Check if each element in the result matches the expected output + expectedOutput.forEach((expectedArray, index) => { + expect(result[index]).toEqual( + expect.arrayContaining(expectedArray), + ); + }); + }); + + test('should handle empty array input', () => { + const input: string[][] = []; + const expectedOutput: string[][] = []; + + const result = removeDuplicatesInNestedStringArray(input); + + // Check if the result is an empty array + expect(result).toEqual(expectedOutput); + }); + + test('should handle inner arrays with varying numbers of strings', () => { + const input = [ + ['apple', 'banana', 'cherry'], + ['apple', 'banana'], + ['banana', 'apple'], + ['apple', 'banana', 'cherry', 'date'], + ['apple', 'banana', 'cherry'], + ]; + const expectedOutput = [ + ['apple', 'banana', 'cherry'], + ['apple', 'banana'], + ['apple', 'banana', 'cherry', 'date'], + ]; + + const result = removeDuplicatesInNestedStringArray(input); + + // Check if the result has the expected length + expect(result.length).toBe(expectedOutput.length); + + // Check if each element in the result matches the expected output + expectedOutput.forEach((expectedArray, index) => { + expect(result[index]).toEqual( + expect.arrayContaining(expectedArray), + ); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index aca88db00..9d78ba68b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.vue"], - "exclude": ["node_modules"], + "exclude": ["node_modules", "js"], "compilerOptions": { "lib": [ "dom", @@ -8,8 +8,8 @@ "es6" ], "module": "ES6", - "moduleResolution": "node", "target": "ES6", + "moduleResolution": "node", "allowJs": true, "allowSyntheticDefaultImports": true, "strictNullChecks": true,