diff --git a/.env.example b/.env.example index 42d98677b..fc1aef49d 100644 --- a/.env.example +++ b/.env.example @@ -21,12 +21,23 @@ NEXT_PUBLIC_TRACK_EVENTS=0 NEXT_PUBLIC_SLACK_CLIENT_ID="" # Backend +# Debug value for api server use it as 0 for production use +DEBUG=0 + +# Error logs +SENTRY_DSN="" # Database Settings PGUSER="plane" PGPASSWORD="plane" PGHOST="plane-db" PGDATABASE="plane" +DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} + +# Redis Settings +REDIS_HOST="plane-redis" +REDIS_PORT="6379" +REDIS_URL="redis://${REDIS_HOST}:6379/" # Email Settings EMAIL_HOST="" @@ -35,6 +46,7 @@ EMAIL_HOST_PASSWORD="" EMAIL_PORT=587 EMAIL_FROM="Team Plane " EMAIL_USE_TLS="1" +EMAIL_USE_SSL="0" # AWS Settings AWS_REGION="" @@ -65,4 +77,6 @@ NGINX_PORT=80 DEFAULT_EMAIL="captain@plane.so" DEFAULT_PASSWORD="password123" +# SignUps +ENABLE_SIGNUP="1" # Auto generated and Required that will be generated from setup.sh \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3562ab0b3..921881df4 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ yarn-error.log* ## Django ## venv +.venv *.pyc staticfiles mediafiles diff --git a/LICENSE.txt b/LICENSE.txt index 0320895f7..5087e61e2 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,201 +1,661 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + 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. - 1. Definitions. + Preamble - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. + 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. - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. + 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. - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. + 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. - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. + 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. - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. + 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. - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. + 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. - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). + 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. - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. + The precise terms and conditions for copying, distribution and +modification follow. - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." + TERMS AND CONDITIONS - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. + 0. Definitions. - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. + "This License" refers to version 3 of the GNU Affero General Public License. - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: + "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. - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and + 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. - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and + A "covered work" means either the unmodified Program or a work based +on the Program. - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and + 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. - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. + 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. - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. + 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. - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. + 1. Source Code. - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. + 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. - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. + 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. - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. + 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. - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. + 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. - END OF TERMS AND CONDITIONS + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. - APPENDIX: How to apply the Apache License to your work. + The Corresponding Source for a work in source code form is that +same work. - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. + 2. Basic Permissions. - Copyright 2022 Plane Software Labs Private Limited + 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. - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at + 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. - http://www.apache.org/licenses/LICENSE-2.0 + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. \ No newline at end of file + 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 +. \ No newline at end of file diff --git a/apiserver/plane/api/permissions/__init__.py b/apiserver/plane/api/permissions/__init__.py index 71ec4815d..91b3aea35 100644 --- a/apiserver/plane/api/permissions/__init__.py +++ b/apiserver/plane/api/permissions/__init__.py @@ -1,2 +1,2 @@ -from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission -from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission +from .workspace import WorkSpaceBasePermission, WorkSpaceAdminPermission, WorkspaceEntityPermission +from .project import ProjectBasePermission, ProjectEntityPermission, ProjectMemberPermission, ProjectLitePermission diff --git a/apiserver/plane/api/permissions/project.py b/apiserver/plane/api/permissions/project.py index eea5192d5..e4e3e0f9b 100644 --- a/apiserver/plane/api/permissions/project.py +++ b/apiserver/plane/api/permissions/project.py @@ -89,3 +89,16 @@ class ProjectEntityPermission(BasePermission): role__in=[Admin, Member], project_id=view.project_id, ).exists() + + +class ProjectLitePermission(BasePermission): + + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return ProjectMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + project_id=view.project_id, + ).exists() \ No newline at end of file diff --git a/apiserver/plane/api/permissions/workspace.py b/apiserver/plane/api/permissions/workspace.py index 2a2e1d339..7fccc455e 100644 --- a/apiserver/plane/api/permissions/workspace.py +++ b/apiserver/plane/api/permissions/workspace.py @@ -5,7 +5,6 @@ from rest_framework.permissions import BasePermission, SAFE_METHODS from plane.db.models import WorkspaceMember - # Permission Mappings Owner = 20 Admin = 15 @@ -44,7 +43,6 @@ class WorkSpaceBasePermission(BasePermission): class WorkSpaceAdminPermission(BasePermission): def has_permission(self, request, view): - if request.user.is_anonymous: return False @@ -53,3 +51,13 @@ class WorkSpaceAdminPermission(BasePermission): workspace__slug=view.workspace_slug, role__in=[Owner, Admin], ).exists() + + +class WorkspaceEntityPermission(BasePermission): + def has_permission(self, request, view): + if request.user.is_anonymous: + return False + + return WorkspaceMember.objects.filter( + member=request.user, workspace__slug=view.workspace_slug + ).exists() diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 505a9978d..2b72c5ae1 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -23,7 +23,6 @@ from .project import ( ProjectLiteSerializer, ) from .state import StateSerializer, StateLiteSerializer -from .shortcut import ShortCutSerializer from .view import IssueViewSerializer, IssueViewFavoriteSerializer from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer from .asset import FileAssetSerializer @@ -31,7 +30,6 @@ from .issue import ( IssueCreateSerializer, IssueActivitySerializer, IssueCommentSerializer, - TimeLineIssueSerializer, IssuePropertySerializer, BlockerIssueSerializer, BlockedIssueSerializer, @@ -69,6 +67,11 @@ from .importer import ImporterSerializer from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer -from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer +from .estimate import ( + EstimateSerializer, + EstimatePointSerializer, + EstimateReadSerializer, +) +from .inbox import InboxSerializer, InboxIssueSerializer, IssueStateInboxSerializer from .analytic import AnalyticViewSerializer diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 5c1c68fb8..760f42dcc 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -1,3 +1,6 @@ +# Django imports +from django.db.models.functions import TruncDate + # Third party imports from rest_framework import serializers @@ -20,13 +23,13 @@ class CycleSerializer(BaseSerializer): unstarted_issues = serializers.IntegerField(read_only=True) backlog_issues = serializers.IntegerField(read_only=True) assignees = serializers.SerializerMethodField(read_only=True) + labels = serializers.SerializerMethodField(read_only=True) total_estimates = serializers.IntegerField(read_only=True) completed_estimates = serializers.IntegerField(read_only=True) started_estimates = serializers.IntegerField(read_only=True) - workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace") project_detail = ProjectLiteSerializer(read_only=True, source="project") - + def get_assignees(self, obj): members = [ { @@ -44,6 +47,24 @@ class CycleSerializer(BaseSerializer): unique_list = [dict(item) for item in unique_objects] return unique_list + + def get_labels(self, obj): + labels = [ + { + "name": label.name, + "color": label.color, + "id": label.id, + } + for issue_cycle in obj.issue_cycle.all() + for label in issue_cycle.issue.labels.all() + ] + # Use a set comprehension to return only the unique objects + unique_objects = {frozenset(item.items()) for item in labels} + + # Convert the set back to a list of dictionaries + unique_list = [dict(item) for item in unique_objects] + + return unique_list class Meta: model = Cycle diff --git a/apiserver/plane/api/serializers/inbox.py b/apiserver/plane/api/serializers/inbox.py new file mode 100644 index 000000000..ae17b749b --- /dev/null +++ b/apiserver/plane/api/serializers/inbox.py @@ -0,0 +1,58 @@ +# Third party frameworks +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from .issue import IssueFlatSerializer, LabelLiteSerializer +from .project import ProjectLiteSerializer +from .state import StateLiteSerializer +from .project import ProjectLiteSerializer +from .user import UserLiteSerializer +from plane.db.models import Inbox, InboxIssue, Issue + + +class InboxSerializer(BaseSerializer): + project_detail = ProjectLiteSerializer(source="project", read_only=True) + pending_issue_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Inbox + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + ] + + +class InboxIssueSerializer(BaseSerializer): + issue_detail = IssueFlatSerializer(source="issue", read_only=True) + project_detail = ProjectLiteSerializer(source="project", read_only=True) + + class Meta: + model = InboxIssue + fields = "__all__" + read_only_fields = [ + "project", + "workspace", + ] + + +class InboxIssueLiteSerializer(BaseSerializer): + class Meta: + model = InboxIssue + fields = ["id", "status", "duplicate_to", "snoozed_till", "source"] + read_only_fields = fields + + +class IssueStateInboxSerializer(BaseSerializer): + state_detail = StateLiteSerializer(read_only=True, source="state") + project_detail = ProjectLiteSerializer(read_only=True, source="project") + label_details = LabelLiteSerializer(read_only=True, source="labels", many=True) + assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) + sub_issues_count = serializers.IntegerField(read_only=True) + bridge_id = serializers.UUIDField(read_only=True) + issue_inbox = InboxIssueLiteSerializer(read_only=True, many=True) + + class Meta: + model = Issue + fields = "__all__" diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index d3c17d057..14782dbe5 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -16,7 +16,6 @@ from plane.db.models import ( Issue, IssueActivity, IssueComment, - TimelineIssue, IssueProperty, IssueBlocker, IssueAssignee, @@ -41,6 +40,7 @@ class IssueFlatSerializer(BaseSerializer): "id", "name", "description", + "description_html", "priority", "start_date", "target_date", @@ -287,21 +287,6 @@ class IssueCommentSerializer(BaseSerializer): ] -class TimeLineIssueSerializer(BaseSerializer): - class Meta: - model = TimelineIssue - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "issue", - "created_by", - "updated_by", - "created_at", - "updated_at", - ] - - class IssuePropertySerializer(BaseSerializer): class Meta: model = IssueProperty @@ -482,6 +467,8 @@ class IssueStateSerializer(BaseSerializer): assignee_details = UserLiteSerializer(read_only=True, source="assignees", many=True) sub_issues_count = serializers.IntegerField(read_only=True) bridge_id = serializers.UUIDField(read_only=True) + attachment_count = serializers.IntegerField(read_only=True) + link_count = serializers.IntegerField(read_only=True) class Meta: model = Issue diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 0a8ad1cf8..18ee19e7b 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -85,6 +85,7 @@ class ProjectDetailSerializer(BaseSerializer): total_members = serializers.IntegerField(read_only=True) total_cycles = serializers.IntegerField(read_only=True) total_modules = serializers.IntegerField(read_only=True) + is_member = serializers.BooleanField(read_only=True) class Meta: model = Project diff --git a/apiserver/plane/api/serializers/shortcut.py b/apiserver/plane/api/serializers/shortcut.py deleted file mode 100644 index 18c2bd049..000000000 --- a/apiserver/plane/api/serializers/shortcut.py +++ /dev/null @@ -1,14 +0,0 @@ -# Module imports -from .base import BaseSerializer - -from plane.db.models import Shortcut - - -class ShortCutSerializer(BaseSerializer): - class Meta: - model = Shortcut - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - ] diff --git a/apiserver/plane/api/serializers/workspace.py b/apiserver/plane/api/serializers/workspace.py index 078a4bf08..4d83d6262 100644 --- a/apiserver/plane/api/serializers/workspace.py +++ b/apiserver/plane/api/serializers/workspace.py @@ -19,6 +19,7 @@ from plane.db.models import ( class WorkSpaceSerializer(BaseSerializer): owner = UserLiteSerializer(read_only=True) total_members = serializers.IntegerField(read_only=True) + total_issues = serializers.IntegerField(read_only=True) class Meta: model = Workspace diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index bf5180ff8..936fd73ab 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -5,6 +5,7 @@ from django.urls import path from plane.api.views import ( # Authentication + SignUpEndpoint, SignInEndpoint, SignOutEndpoint, MagicSignInEndpoint, @@ -69,7 +70,6 @@ from plane.api.views import ( BulkDeleteIssuesEndpoint, BulkImportIssuesEndpoint, ProjectUserViewsEndpoint, - TimeLineIssueViewSet, IssuePropertyViewSet, LabelViewSet, SubIssuesEndpoint, @@ -84,9 +84,6 @@ from plane.api.views import ( ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, ## End Estimates - # Shortcuts - ShortCutViewSet, - ## End Shortcuts # Views IssueViewViewSet, ViewIssuesEndpoint, @@ -140,6 +137,10 @@ from plane.api.views import ( # Release Notes ReleaseNotesEndpoint, ## End Release Notes + # Inbox + InboxViewSet, + InboxIssueViewSet, + ## End Inbox # Analytics AnalyticsEndpoint, AnalyticViewViewset, @@ -154,6 +155,7 @@ urlpatterns = [ # Social Auth path("social-auth/", OauthEndpoint.as_view(), name="oauth"), # Auth + path("sign-up/", SignUpEndpoint.as_view(), name="sign-up"), path("sign-in/", SignInEndpoint.as_view(), name="sign-in"), path("sign-out/", SignOutEndpoint.as_view(), name="sign-out"), # Magic Sign In/Up @@ -534,30 +536,6 @@ urlpatterns = [ name="bulk-create-estimate-points", ), # End Estimates ## - # Shortcuts - path( - "workspaces//projects//shortcuts/", - ShortCutViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-shortcut", - ), - path( - "workspaces//projects//shortcuts//", - ShortCutViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-shortcut", - ), - ## End Shortcuts # Views path( "workspaces//projects//views/", @@ -820,30 +798,6 @@ urlpatterns = [ name="project-issue-comment", ), ## End IssueComments - ## Roadmap - path( - "workspaces//projects//issues//roadmaps/", - TimeLineIssueViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-issue-roadmap", - ), - path( - "workspaces//projects//issues//roadmaps//", - TimeLineIssueViewSet.as_view( - { - "get": "retrieve", - "put": "update", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-issue-roadmap", - ), - ## End Roadmap ## IssueProperty path( "workspaces//projects//issue-properties/", @@ -1242,6 +1196,50 @@ urlpatterns = [ name="release-notes", ), ## End Release Notes + # Inbox + path( + "workspaces//projects//inboxes/", + InboxViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="inbox", + ), + path( + "workspaces//projects//inboxes//", + InboxViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="inbox", + ), + path( + "workspaces//projects//inboxes//inbox-issues/", + InboxIssueViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="inbox-issue", + ), + path( + "workspaces//projects//inboxes//inbox-issues//", + InboxIssueViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="inbox-issue", + ), + ## End Inbox # Analytics path( "workspaces//analytics/", diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 4177b1371..f8d170532 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -43,7 +43,6 @@ from .workspace import ( WorkspaceThemeViewSet, ) from .state import StateViewSet -from .shortcut import ShortCutViewSet from .view import IssueViewViewSet, ViewIssuesEndpoint, IssueViewFavoriteViewSet from .cycle import ( CycleViewSet, @@ -58,7 +57,6 @@ from .issue import ( WorkSpaceIssuesEndpoint, IssueActivityEndpoint, IssueCommentViewSet, - TimeLineIssueViewSet, IssuePropertyViewSet, LabelViewSet, BulkDeleteIssuesEndpoint, @@ -79,6 +77,7 @@ from .auth_extended import ( from .authentication import ( + SignUpEndpoint, SignInEndpoint, SignOutEndpoint, MagicSignInEndpoint, @@ -133,6 +132,7 @@ from .estimate import ( from .release import ReleaseNotesEndpoint +from .inbox import InboxViewSet, InboxIssueViewSet from .analytic import ( AnalyticsEndpoint, AnalyticViewViewset, diff --git a/apiserver/plane/api/views/analytic.py b/apiserver/plane/api/views/analytic.py index 56ca12bae..e537af84a 100644 --- a/apiserver/plane/api/views/analytic.py +++ b/apiserver/plane/api/views/analytic.py @@ -3,6 +3,7 @@ from django.db.models import ( Count, Sum, F, + Q ) from django.db.models.functions import ExtractMonth @@ -40,7 +41,7 @@ class AnalyticsEndpoint(BaseAPIView): segment = request.GET.get("segment", False) filters = issue_filters(request.GET, "GET") - queryset = Issue.objects.filter(workspace__slug=slug, **filters) + queryset = Issue.issue_objects.filter(workspace__slug=slug, **filters) total_issues = queryset.count() distribution = build_graph_plot( @@ -59,10 +60,11 @@ class AnalyticsEndpoint(BaseAPIView): colors = ( State.objects.filter( + ~Q(name="Triage"), workspace__slug=slug, project_id__in=filters.get("project__in") ).values(key, "color") if filters.get("project__in", False) - else State.objects.filter(workspace__slug=slug).values(key, "color") + else State.objects.filter(~Q(name="Triage"), workspace__slug=slug).values(key, "color") ) if x_axis in ["labels__name"] or segment in ["labels__name"]: @@ -79,7 +81,7 @@ class AnalyticsEndpoint(BaseAPIView): assignee_details = {} if x_axis in ["assignees__email"] or segment in ["assignees__email"]: assignee_details = ( - Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False) + Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False) .order_by("assignees__id") .distinct("assignees__id") .values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name") @@ -132,7 +134,7 @@ class SavedAnalyticEndpoint(BaseAPIView): ) filter = analytic_view.query - queryset = Issue.objects.filter(**filter) + queryset = Issue.issue_objects.filter(**filter) x_axis = analytic_view.query_dict.get("x_axis", False) y_axis = analytic_view.query_dict.get("y_axis", False) @@ -209,7 +211,7 @@ class DefaultAnalyticsEndpoint(BaseAPIView): try: filters = issue_filters(request.GET, "GET") - queryset = Issue.objects.filter(workspace__slug=slug, **filters) + queryset = Issue.issue_objects.filter(workspace__slug=slug, **filters) total_issues = queryset.count() diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index a63f199ad..068fae5a9 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -36,6 +36,99 @@ def get_tokens_for_user(user): ) +class SignUpEndpoint(BaseAPIView): + permission_classes = (AllowAny,) + + def post(self, request): + try: + if not settings.ENABLE_SIGNUP: + return Response( + { + "error": "New account creation is disabled. Please contact your site administrator" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = request.data.get("email", False) + password = request.data.get("password", False) + + ## Raise exception if any of the above are missing + if not email or not password: + return Response( + {"error": "Both email and password are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + email = email.strip().lower() + + try: + validate_email(email) + except ValidationError as e: + return Response( + {"error": "Please provide a valid email address."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Check if the user already exists + if User.objects.filter(email=email).exists(): + return Response( + {"error": "User with this email already exists"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user = User.objects.create(email=email, username=uuid.uuid4().hex) + user.set_password(password) + + # settings last actives for the user + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() + + serialized_user = UserSerializer(user).data + + access_token, refresh_token = get_tokens_for_user(user) + + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + } + + # Send Analytics + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, + }, + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": "email", + }, + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get("HTTP_USER_AGENT"), + }, + "event_type": "SIGN_UP", + }, + ) + + return Response(data, status=status.HTTP_200_OK) + + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + class SignInEndpoint(BaseAPIView): permission_classes = (AllowAny,) @@ -63,108 +156,69 @@ class SignInEndpoint(BaseAPIView): user = User.objects.filter(email=email).first() - # Sign up Process if user is None: - user = User.objects.create(email=email, username=uuid.uuid4().hex) - user.set_password(password) + return Response( + { + "error": "Sorry, we could not find a user with the provided credentials. Please try again." + }, + status=status.HTTP_403_FORBIDDEN, + ) - # settings last actives for the user - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() + # Sign up Process + if not user.check_password(password): + return Response( + { + "error": "Sorry, we could not find a user with the provided credentials. Please try again." + }, + status=status.HTTP_403_FORBIDDEN, + ) + if not user.is_active: + return Response( + { + "error": "Your account has been deactivated. Please contact your site administrator." + }, + status=status.HTTP_403_FORBIDDEN, + ) - serialized_user = UserSerializer(user).data + serialized_user = UserSerializer(user).data - access_token, refresh_token = get_tokens_for_user(user) + # settings last active for the user + user.last_active = timezone.now() + user.last_login_time = timezone.now() + user.last_login_ip = request.META.get("REMOTE_ADDR") + user.last_login_uagent = request.META.get("HTTP_USER_AGENT") + user.token_updated_at = timezone.now() + user.save() - data = { - "access_token": access_token, - "refresh_token": refresh_token, - "user": serialized_user, - } - - # Send Analytics - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, + access_token, refresh_token = get_tokens_for_user(user) + # Send Analytics + if settings.ANALYTICS_BASE_API: + _ = requests.post( + settings.ANALYTICS_BASE_API, + headers={ + "Content-Type": "application/json", + "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, + }, + json={ + "event_id": uuid.uuid4().hex, + "event_data": { + "medium": "email", }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": "email", - }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), - }, - "event_type": "SIGN_UP", + "user": {"email": email, "id": str(user.id)}, + "device_ctx": { + "ip": request.META.get("REMOTE_ADDR"), + "user_agent": request.META.get("HTTP_USER_AGENT"), }, - ) + "event_type": "SIGN_IN", + }, + ) + data = { + "access_token": access_token, + "refresh_token": refresh_token, + "user": serialized_user, + } - return Response(data, status=status.HTTP_200_OK) - # Sign in Process - else: - if not user.check_password(password): - return Response( - { - "error": "Sorry, we could not find a user with the provided credentials. Please try again." - }, - status=status.HTTP_403_FORBIDDEN, - ) - if not user.is_active: - return Response( - { - "error": "Your account has been deactivated. Please contact your site administrator." - }, - status=status.HTTP_403_FORBIDDEN, - ) - - serialized_user = UserSerializer(user).data - - # settings last active for the user - user.last_active = timezone.now() - user.last_login_time = timezone.now() - user.last_login_ip = request.META.get("REMOTE_ADDR") - user.last_login_uagent = request.META.get("HTTP_USER_AGENT") - user.token_updated_at = timezone.now() - user.save() - - access_token, refresh_token = get_tokens_for_user(user) - # Send Analytics - if settings.ANALYTICS_BASE_API: - _ = requests.post( - settings.ANALYTICS_BASE_API, - headers={ - "Content-Type": "application/json", - "X-Auth-Token": settings.ANALYTICS_SECRET_KEY, - }, - json={ - "event_id": uuid.uuid4().hex, - "event_data": { - "medium": "email", - }, - "user": {"email": email, "id": str(user.id)}, - "device_ctx": { - "ip": request.META.get("REMOTE_ADDR"), - "user_agent": request.META.get("HTTP_USER_AGENT"), - }, - "event_type": "SIGN_IN", - }, - ) - data = { - "access_token": access_token, - "refresh_token": refresh_token, - "user": serialized_user, - } - - return Response(data, status=status.HTTP_200_OK) + return Response(data, status=status.HTTP_200_OK) except Exception as e: capture_exception(e) diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 63c832e71..0dd5d67d0 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -41,10 +41,12 @@ from plane.db.models import ( CycleFavorite, IssueLink, IssueAttachment, + Label, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters +from plane.utils.analytics_plot import burndown_plot class CycleViewSet(BaseViewSet): @@ -148,6 +150,12 @@ class CycleViewSet(BaseViewSet): queryset=User.objects.only("avatar", "first_name", "id").distinct(), ) ) + .prefetch_related( + Prefetch( + "issue_cycle__issue__labels", + queryset=Label.objects.only("name", "color", "id").distinct(), + ) + ) .order_by("-is_favorite", "name") .distinct() ) @@ -155,28 +163,90 @@ class CycleViewSet(BaseViewSet): def list(self, request, slug, project_id): try: queryset = self.get_queryset() - cycle_view = request.GET.get("cycle_view", False) - if not cycle_view: - return Response( - {"error": "Cycle View parameter is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) + cycle_view = request.GET.get("cycle_view", "all") # All Cycles if cycle_view == "all": return Response( CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK ) - + # Current Cycle if cycle_view == "current": queryset = queryset.filter( start_date__lte=timezone.now(), end_date__gte=timezone.now(), ) - return Response( - CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK - ) + + data = CycleSerializer(queryset, many=True).data + + if len(data): + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=data[0]["id"], + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("first_name", "last_name", "assignee_id", "avatar") + .annotate(total_issues=Count("assignee_id")) + .annotate( + completed_issues=Count( + "assignee_id", + filter=Q(completed_at__isnull=False), + ) + ) + .annotate( + pending_issues=Count( + "assignee_id", + filter=Q(completed_at__isnull=True), + ) + ) + .order_by("first_name", "last_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=data[0]["id"], + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate(total_issues=Count("label_id")) + .annotate( + completed_issues=Count( + "label_id", + filter=Q(completed_at__isnull=False), + ) + ) + .annotate( + pending_issues=Count( + "label_id", + filter=Q(completed_at__isnull=True), + ) + ) + .order_by("label_name") + ) + data[0]["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + if data[0]["start_date"] and data[0]["end_date"]: + data[0]["distribution"]["completion_chart"] = burndown_plot( + queryset=queryset.first(), + slug=slug, + project_id=project_id, + cycle_id=data[0]["id"], + ) + + return Response(data, status=status.HTTP_200_OK) # Upcoming Cycles if cycle_view == "upcoming": @@ -198,6 +268,7 @@ class CycleViewSet(BaseViewSet): end_date=None, start_date=None, ) + return Response( CycleSerializer(queryset, many=True).data, status=status.HTTP_200_OK ) @@ -214,6 +285,7 @@ class CycleViewSet(BaseViewSet): return Response( {"error": "No matching view found"}, status=status.HTTP_400_BAD_REQUEST ) + except Exception as e: capture_exception(e) return Response( @@ -282,6 +354,92 @@ class CycleViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + def retrieve(self, request, slug, project_id, pk): + try: + queryset = self.get_queryset().get(pk=pk) + + # Assignee Distribution + assignee_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("first_name", "last_name", "assignee_id", "avatar") + .annotate(total_issues=Count("assignee_id")) + .annotate( + completed_issues=Count( + "assignee_id", + filter=Q(completed_at__isnull=False), + ) + ) + .annotate( + pending_issues=Count( + "assignee_id", + filter=Q(completed_at__isnull=True), + ) + ) + .order_by("first_name", "last_name") + ) + + # Label Distribution + label_distribution = ( + Issue.objects.filter( + issue_cycle__cycle_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate(total_issues=Count("label_id")) + .annotate( + completed_issues=Count( + "label_id", + filter=Q(completed_at__isnull=False), + ) + ) + .annotate( + pending_issues=Count( + "label_id", + filter=Q(completed_at__isnull=True), + ) + ) + .order_by("label_name") + ) + + data = CycleSerializer(queryset).data + data["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + if queryset.start_date and queryset.end_date: + data["distribution"]["completion_chart"] = burndown_plot( + queryset=queryset, slug=slug, project_id=project_id, cycle_id=pk + ) + + return Response( + data, + status=status.HTTP_200_OK, + ) + except Cycle.DoesNotExist: + return Response( + {"error": "Cycle Does not exists"}, status=status.HTTP_400_BAD_REQUEST + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + class CycleIssueViewSet(BaseViewSet): serializer_class = CycleIssueSerializer @@ -323,7 +481,7 @@ class CycleIssueViewSet(BaseViewSet): super() .get_queryset() .annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("issue_id")) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue_id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -347,9 +505,9 @@ class CycleIssueViewSet(BaseViewSet): group_by = request.GET.get("group_by", False) filters = issue_filters(request.query_params, "GET") issues = ( - Issue.objects.filter(issue_cycle__cycle_id=cycle_id) + Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) .annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") diff --git a/apiserver/plane/api/views/importer.py b/apiserver/plane/api/views/importer.py index 2e0f1cec0..28d490740 100644 --- a/apiserver/plane/api/views/importer.py +++ b/apiserver/plane/api/views/importer.py @@ -7,7 +7,7 @@ from rest_framework.response import Response from sentry_sdk import capture_exception # Django imports -from django.db.models import Max +from django.db.models import Max, Q # Module imports from plane.api.views import BaseAPIView @@ -239,17 +239,19 @@ class ImportServiceEndpoint(BaseAPIView): importer = Importer.objects.get( pk=pk, service=service, workspace__slug=slug ) - # Delete all imported Issues - imported_issues = importer.imported_data.get("issues", []) - Issue.objects.filter(id__in=imported_issues).delete() - # Delete all imported Labels - imported_labels = importer.imported_data.get("labels", []) - Label.objects.filter(id__in=imported_labels).delete() + if importer.imported_data is not None: + # Delete all imported Issues + imported_issues = importer.imported_data.get("issues", []) + Issue.issue_objects.filter(id__in=imported_issues).delete() - if importer.service == "jira": - imported_modules = importer.imported_data.get("modules", []) - Module.objects.filter(id__in=imported_modules).delete() + # Delete all imported Labels + imported_labels = importer.imported_data.get("labels", []) + Label.objects.filter(id__in=imported_labels).delete() + + if importer.service == "jira": + imported_modules = importer.imported_data.get("modules", []) + Module.objects.filter(id__in=imported_modules).delete() importer.delete() return Response(status=status.HTTP_204_NO_CONTENT) except Exception as e: @@ -307,11 +309,13 @@ class BulkImportIssuesEndpoint(BaseAPIView): # Get the default state default_state = State.objects.filter( - project_id=project_id, default=True + ~Q(name="Triage"), project_id=project_id, default=True ).first() # if there is no default state assign any random state if default_state is None: - default_state = State.objects.filter(project_id=project_id).first() + default_state = State.objects.filter( + ~Q(name="Triage"), sproject_id=project_id + ).first() # Get the maximum sequence_id last_id = IssueSequence.objects.filter(project_id=project_id).aggregate( diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py new file mode 100644 index 000000000..ada76c9b3 --- /dev/null +++ b/apiserver/plane/api/views/inbox.py @@ -0,0 +1,380 @@ +# Python imports +import json + +# Django import +from django.utils import timezone +from django.db.models import Q, Count, OuterRef, Func, F, Prefetch +from django.core.serializers.json import DjangoJSONEncoder + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Module imports +from .base import BaseViewSet +from plane.api.permissions import ProjectBasePermission, ProjectLitePermission +from plane.db.models import ( + Project, + Inbox, + InboxIssue, + Issue, + State, + IssueLink, + IssueAttachment, + ProjectMember, +) +from plane.api.serializers import ( + IssueSerializer, + InboxSerializer, + InboxIssueSerializer, + IssueCreateSerializer, + IssueStateInboxSerializer, +) +from plane.utils.issue_filters import issue_filters +from plane.bgtasks.issue_activites_task import issue_activity + + +class InboxViewSet(BaseViewSet): + permission_classes = [ + ProjectBasePermission, + ] + + serializer_class = InboxSerializer + model = Inbox + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + ) + .annotate( + pending_issue_count=Count( + "issue_inbox", + filter=Q(issue_inbox__status=-2), + ) + ) + .select_related("workspace", "project") + ) + + def perform_create(self, serializer): + serializer.save(project_id=self.kwargs.get("project_id")) + + def destroy(self, request, slug, project_id, pk): + try: + inbox = Inbox.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + # Handle default inbox delete + if inbox.is_default: + return Response( + {"error": "You cannot delete the default inbox"}, + status=status.HTTP_400_BAD_REQUEST, + ) + inbox.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wronf please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class InboxIssueViewSet(BaseViewSet): + permission_classes = [ + ProjectLitePermission, + ] + + serializer_class = InboxIssueSerializer + model = InboxIssue + + filterset_fields = [ + "status", + ] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter( + Q(snoozed_till__gte=timezone.now()) | Q(snoozed_till__isnull=True), + workspace__slug=self.kwargs.get("slug"), + project_id=self.kwargs.get("project_id"), + inbox_id=self.kwargs.get("inbox_id"), + ) + .select_related("issue", "workspace", "project") + ) + + def list(self, request, slug, project_id, inbox_id): + try: + filters = issue_filters(request.query_params, "GET") + issues = ( + Issue.objects.filter( + issue_inbox__inbox_id=inbox_id, + workspace__slug=slug, + project_id=project_id, + ) + .filter(**filters) + .annotate(bridge_id=F("issue_inbox__id")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels") + .order_by("issue_inbox__snoozed_till", "issue_inbox__status") + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .prefetch_related( + Prefetch( + "issue_inbox", + queryset=InboxIssue.objects.only( + "status", "duplicate_to", "snoozed_till", "source" + ), + ) + ) + ) + issues_data = IssueStateInboxSerializer(issues, many=True).data + return Response( + issues_data, + status=status.HTTP_200_OK, + ) + + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def create(self, request, slug, project_id, inbox_id): + try: + if not request.data.get("issue", {}).get("name", False): + return Response( + {"error": "Name is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Check for valid priority + if not request.data.get("issue", {}).get("priority", None) in [ + "low", + "medium", + "high", + "urgent", + None, + ]: + return Response( + {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Create or get state + state, _ = State.objects.get_or_create( + name="Triage", + group="backlog", + description="Default state for managing all Inbox Issues", + project_id=project_id, + color="#ff7700", + ) + + # create an issue + issue = Issue.objects.create( + name=request.data.get("issue", {}).get("name"), + description=request.data.get("issue", {}).get("description", {}), + description_html=request.data.get("issue", {}).get( + "description_html", "

" + ), + priority=request.data.get("issue", {}).get("priority", "low"), + project_id=project_id, + state=state, + ) + + # Create an Issue Activity + issue_activity.delay( + type="issue.activity.created", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=None, + ) + # create an inbox issue + InboxIssue.objects.create( + inbox_id=inbox_id, + project_id=project_id, + issue=issue, + source=request.data.get("source", "in-app"), + ) + + serializer = IssueStateInboxSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def partial_update(self, request, slug, project_id, inbox_id, pk): + try: + inbox_issue = InboxIssue.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + ) + # Get the project member + project_member = ProjectMember.objects.get(workspace__slug=slug, project_id=project_id, member=request.user) + # Only project members admins and created_by users can access this endpoint + if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id): + return Response({"error": "You cannot edit inbox issues"}, status=status.HTTP_400_BAD_REQUEST) + + # Get issue data + issue_data = request.data.pop("issue", False) + + if bool(issue_data): + issue = Issue.objects.get( + pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + ) + # Only allow guests and viewers to edit name and description + if project_member.role <= 10: + # viewers and guests since only viewers and guests + issue_data = { + "name": issue_data.get("name", issue.name), + "description_html": issue_data.get("description_html", issue.description_html), + "description": issue_data.get("description", issue.description) + } + + issue_serializer = IssueCreateSerializer( + issue, data=issue_data, partial=True + ) + + if issue_serializer.is_valid(): + current_instance = issue + # Log all the updates + requested_data = json.dumps(issue_data, cls=DjangoJSONEncoder) + if issue is not None: + issue_activity.delay( + type="issue.activity.updated", + requested_data=requested_data, + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps( + IssueSerializer(current_instance).data, + cls=DjangoJSONEncoder, + ), + ) + issue_serializer.save() + else: + return Response( + issue_serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + # Only project admins and members can edit inbox issue attributes + if project_member.role > 10: + serializer = InboxIssueSerializer( + inbox_issue, data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + # Update the issue state if the issue is rejected or marked as duplicate + if serializer.data["status"] in [-1, 2]: + issue = Issue.objects.get( + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, + ) + state = State.objects.filter( + group="cancelled", workspace__slug=slug, project_id=project_id + ).first() + if state is not None: + issue.state = state + issue.save() + + # Update the issue state if it is accepted + if serializer.data["status"] in [1]: + issue = Issue.objects.get( + pk=inbox_issue.issue_id, + workspace__slug=slug, + project_id=project_id, + ) + + # Update the issue state only if it is in triage state + if issue.state.name == "Triage": + # Move to default state + state = State.objects.filter( + workspace__slug=slug, project_id=project_id, default=True + ).first() + if state is not None: + issue.state = state + issue.save() + + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response(InboxIssueSerializer(inbox_issue).data, status=status.HTTP_200_OK) + except InboxIssue.DoesNotExist: + return Response( + {"error": "Inbox Issue does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def retrieve(self, request, slug, project_id, inbox_id, pk): + try: + inbox_issue = InboxIssue.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + ) + issue = Issue.objects.get( + pk=inbox_issue.issue_id, workspace__slug=slug, project_id=project_id + ) + serializer = IssueStateInboxSerializer(issue) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def destroy(self, request, slug, project_id, inbox_id, pk): + try: + inbox_issue = InboxIssue.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id, inbox_id=inbox_id + ) + # Get the project member + project_member = ProjectMember.objects.get(workspace__slug=slug, project_id=project_id, member=request.user) + + if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id): + return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST) + + inbox_issue.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except InboxIssue.DoesNotExist: + return Response({"error": "Inbox Issue does not exists"}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) \ No newline at end of file diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index e6c37374b..dba7a7a2f 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -34,7 +34,6 @@ from plane.api.serializers import ( IssueCreateSerializer, IssueActivitySerializer, IssueCommentSerializer, - TimeLineIssueSerializer, IssuePropertySerializer, LabelSerializer, IssueSerializer, @@ -54,7 +53,6 @@ from plane.db.models import ( Issue, IssueActivity, IssueComment, - TimelineIssue, IssueProperty, Label, IssueLink, @@ -132,10 +130,8 @@ class IssueViewSet(BaseViewSet): def get_queryset(self): return ( - super() - .get_queryset() - .annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + Issue.issue_objects.annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -156,8 +152,9 @@ class IssueViewSet(BaseViewSet): filters = issue_filters(request.query_params, "GET") show_sub_issues = request.GET.get("show_sub_issues", "true") - # Custom ordering for priority + # Custom ordering for priority and state priority_order = ["urgent", "high", "medium", "low", None] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] order_by_param = request.GET.get("order_by", "-created_at") @@ -182,7 +179,13 @@ class IssueViewSet(BaseViewSet): ) ) - if order_by_param == "priority": + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order + if order_by_param == "priority" + else priority_order[::-1] + ) issue_queryset = issue_queryset.annotate( priority_order=Case( *[ @@ -192,6 +195,29 @@ class IssueViewSet(BaseViewSet): output_field=CharField(), ) ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") else: issue_queryset = issue_queryset.order_by(order_by_param) @@ -213,7 +239,7 @@ class IssueViewSet(BaseViewSet): return Response(issues, status=status.HTTP_200_OK) except Exception as e: - capture_exception(e) + print(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, @@ -248,7 +274,7 @@ class IssueViewSet(BaseViewSet): def retrieve(self, request, slug, project_id, pk=None): try: - issue = Issue.objects.get( + issue = Issue.issue_objects.get( workspace__slug=slug, project_id=project_id, pk=pk ) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) @@ -263,9 +289,11 @@ class UserWorkSpaceIssues(BaseAPIView): def get(self, request, slug): try: issues = ( - Issue.objects.filter(assignees__in=[request.user], workspace__slug=slug) + Issue.issue_objects.filter( + assignees__in=[request.user], workspace__slug=slug + ) .annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -311,7 +339,7 @@ class WorkSpaceIssuesEndpoint(BaseAPIView): def get(self, request, slug): try: issues = ( - Issue.objects.filter(workspace__slug=slug) + Issue.issue_objects.filter(workspace__slug=slug) .filter(project__project_projectmember__member=self.request.user) .order_by("-created_at") ) @@ -445,39 +473,6 @@ class IssueCommentViewSet(BaseViewSet): ) -class TimeLineIssueViewSet(BaseViewSet): - serializer_class = TimeLineIssueSerializer - model = TimelineIssue - permission_classes = [ - ProjectEntityPermission, - ] - - filterset_fields = [ - "issue__id", - "workspace__id", - ] - - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), - issue_id=self.kwargs.get("issue_id"), - ) - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("issue") - .distinct() - ) - - class IssuePropertyViewSet(BaseViewSet): serializer_class = IssuePropertySerializer model = IssueProperty @@ -581,7 +576,7 @@ class BulkDeleteIssuesEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - issues = Issue.objects.filter( + issues = Issue.issue_objects.filter( workspace__slug=slug, project_id=project_id, pk__in=issue_ids ) @@ -610,7 +605,7 @@ class SubIssuesEndpoint(BaseAPIView): def get(self, request, slug, project_id, issue_id): try: sub_issues = ( - Issue.objects.filter( + Issue.issue_objects.filter( parent_id=issue_id, workspace__slug=slug, project_id=project_id ) .select_related("project") @@ -619,10 +614,32 @@ class SubIssuesEndpoint(BaseAPIView): .select_related("parent") .prefetch_related("assignees") .prefetch_related("labels") + .annotate( + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) ) state_distribution = ( - State.objects.filter(workspace__slug=slug, project_id=project_id) + State.objects.filter( + ~Q(name="Triage"), workspace__slug=slug, project_id=project_id + ) .annotate( state_count=Count( "state_issue", @@ -656,7 +673,7 @@ class SubIssuesEndpoint(BaseAPIView): # Assign multiple sub issues def post(self, request, slug, project_id, issue_id): try: - parent_issue = Issue.objects.get(pk=issue_id) + parent_issue = Issue.issue_objects.get(pk=issue_id) sub_issue_ids = request.data.get("sub_issue_ids", []) if not len(sub_issue_ids): @@ -665,14 +682,14 @@ class SubIssuesEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - sub_issues = Issue.objects.filter(id__in=sub_issue_ids) + sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids) for sub_issue in sub_issues: sub_issue.parent = parent_issue _ = Issue.objects.bulk_update(sub_issues, ["parent"], batch_size=10) - updated_sub_issues = Issue.objects.filter(id__in=sub_issue_ids) + updated_sub_issues = Issue.issue_objects.filter(id__in=sub_issue_ids) return Response( IssueFlatSerializer(updated_sub_issues, many=True).data, diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index 8f0cabeaf..5a235ba8f 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -37,7 +37,7 @@ from plane.db.models import ( from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters - +from plane.utils.analytics_plot import burndown_plot class ModuleViewSet(BaseViewSet): model = Module @@ -160,6 +160,87 @@ class ModuleViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + def retrieve(self, request, slug, project_id, pk): + try: + queryset = self.get_queryset().get(pk=pk) + + assignee_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(first_name=F("assignees__first_name")) + .annotate(last_name=F("assignees__last_name")) + .annotate(assignee_id=F("assignees__id")) + .annotate(avatar=F("assignees__avatar")) + .values("first_name", "last_name", "assignee_id", "avatar") + .annotate(total_issues=Count("assignee_id")) + .annotate( + completed_issues=Count( + "assignee_id", + filter=Q(completed_at__isnull=False), + ) + ) + .annotate( + pending_issues=Count( + "assignee_id", + filter=Q(completed_at__isnull=True), + ) + ) + .order_by("first_name", "last_name") + ) + + label_distribution = ( + Issue.objects.filter( + issue_module__module_id=pk, + workspace__slug=slug, + project_id=project_id, + ) + .annotate(label_name=F("labels__name")) + .annotate(color=F("labels__color")) + .annotate(label_id=F("labels__id")) + .values("label_name", "color", "label_id") + .annotate(total_issues=Count("label_id")) + .annotate( + completed_issues=Count( + "label_id", + filter=Q(completed_at__isnull=False), + ) + ) + .annotate( + pending_issues=Count( + "label_id", + filter=Q(completed_at__isnull=True), + ) + ) + .order_by("label_name") + ) + + data = ModuleSerializer(queryset).data + data["distribution"] = { + "assignees": assignee_distribution, + "labels": label_distribution, + "completion_chart": {}, + } + + if queryset.start_date and queryset.target_date: + data["distribution"]["completion_chart"] = burndown_plot( + queryset=queryset, slug=slug, project_id=project_id, module_id=pk + ) + + return Response( + data, + status=status.HTTP_200_OK, + ) + + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + class ModuleIssueViewSet(BaseViewSet): serializer_class = ModuleIssueSerializer @@ -201,7 +282,7 @@ class ModuleIssueViewSet(BaseViewSet): super() .get_queryset() .annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("issue")) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("issue")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") @@ -226,9 +307,9 @@ class ModuleIssueViewSet(BaseViewSet): group_by = request.GET.get("group_by", False) filters = issue_filters(request.query_params, "GET") issues = ( - Issue.objects.filter(issue_module__module_id=module_id) + Issue.issue_objects.filter(issue_module__module_id=module_id) .annotate( - sub_issues_count=Issue.objects.filter(parent=OuterRef("id")) + sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") diff --git a/apiserver/plane/api/views/people.py b/apiserver/plane/api/views/people.py index fcf95ff64..8e19fea1a 100644 --- a/apiserver/plane/api/views/people.py +++ b/apiserver/plane/api/views/people.py @@ -37,7 +37,7 @@ class UserEndpoint(BaseViewSet): workspace_invites = WorkspaceMemberInvite.objects.filter( email=request.user.email ).count() - assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count() + assigned_issues = Issue.issue_objects.filter(assignees__in=[request.user]).count() serialized_data = UserSerializer(request.user).data serialized_data["workspace"] = { @@ -59,7 +59,7 @@ class UserEndpoint(BaseViewSet): workspace_invites = WorkspaceMemberInvite.objects.filter( email=request.user.email ).count() - assigned_issues = Issue.objects.filter(assignees__in=[request.user]).count() + assigned_issues = Issue.issue_objects.filter(assignees__in=[request.user]).count() fallback_workspace = Workspace.objects.filter( workspace_member__member=request.user @@ -98,20 +98,6 @@ class UpdateUserOnBoardedEndpoint(BaseAPIView): user = User.objects.get(pk=request.user.id) user.is_onboarded = request.data.get("is_onboarded", False) user.save() - - if user.last_workspace_id is not None: - user_role = WorkspaceMember.objects.filter( - workspace_id=user.last_workspace_id, member=request.user.id - ).first() - return Response( - { - "message": "Updated successfully", - "role": user_role.company_role - if user_role is not None - else None, - }, - status=status.HTTP_200_OK, - ) return Response( {"message": "Updated successfully"}, status=status.HTTP_200_OK ) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index bdb758ac9..68a34ab48 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -47,9 +47,9 @@ from plane.db.models import ( Page, IssueAssignee, ModuleMember, + Inbox, ) - from plane.bgtasks.project_invitation_task import project_invitation @@ -72,6 +72,7 @@ class ProjectViewSet(BaseViewSet): project_id=OuterRef("pk"), workspace__slug=self.kwargs.get("slug"), ) + return self.filter_queryset( super() .get_queryset() @@ -81,6 +82,15 @@ class ProjectViewSet(BaseViewSet): "workspace", "workspace__owner", "default_assignee", "project_lead" ) .annotate(is_favorite=Exists(subquery)) + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ) + ) + ) .distinct() ) @@ -238,6 +248,20 @@ class ProjectViewSet(BaseViewSet): if serializer.is_valid(): serializer.save() + if serializer.data["inbox_view"]: + Inbox.objects.get_or_create( + name=f"{project.name} Inbox", project=project, is_default=True + ) + + # Create the triage state in Backlog group + State.objects.get_or_create( + name="Triage", + group="backlog", + description="Default state for managing all Inbox Issues", + project_id=pk, + color="#ff7700" + ) + return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -467,7 +491,9 @@ class ProjectMemberViewSet(BaseViewSet): ) if requesting_project_member.role < project_member.role: return Response( - {"error": "You cannot remove a user having role higher than yourself"}, + { + "error": "You cannot remove a user having role higher than yourself" + }, status=status.HTTP_400_BAD_REQUEST, ) diff --git a/apiserver/plane/api/views/search.py b/apiserver/plane/api/views/search.py index 88dddc43c..51925dd7b 100644 --- a/apiserver/plane/api/views/search.py +++ b/apiserver/plane/api/views/search.py @@ -57,7 +57,7 @@ class GlobalSearchEndpoint(BaseAPIView): else: q |= Q(**{f"{field}__icontains": query}) return ( - Issue.objects.filter( + Issue.issue_objects.filter( q, project__project_projectmember__member=self.request.user, workspace__slug=slug, @@ -206,11 +206,15 @@ class IssueSearchEndpoint(BaseAPIView): def get(self, request, slug, project_id): try: query = request.query_params.get("search", False) - parent = request.query_params.get("parent", False) - blocker_blocked_by = request.query_params.get("blocker_blocked_by", False) + parent = request.query_params.get("parent", "false") + blocker_blocked_by = request.query_params.get("blocker_blocked_by", "false") + cycle = request.query_params.get("cycle", "false") + module = request.query_params.get("module", "false") + sub_issue = request.query_params.get("sub_issue", "false") + issue_id = request.query_params.get("issue_id", False) - issues = Issue.objects.filter( + issues = Issue.issue_objects.filter( workspace__slug=slug, project_id=project_id, project__project_projectmember__member=self.request.user, @@ -220,21 +224,33 @@ class IssueSearchEndpoint(BaseAPIView): issues = search_issues(query, issues) if parent == "true" and issue_id: - issue = Issue.objects.get(pk=issue_id) + issue = Issue.issue_objects.get(pk=issue_id) issues = issues.filter( ~Q(pk=issue_id), ~Q(pk=issue.parent_id), parent__isnull=True ).exclude( - pk__in=Issue.objects.filter(parent__isnull=False).values_list( + pk__in=Issue.issue_objects.filter(parent__isnull=False).values_list( "parent_id", flat=True ) ) if blocker_blocked_by == "true" and issue_id: - issue = Issue.objects.get(pk=issue_id) + issue = Issue.issue_objects.get(pk=issue_id) issues = issues.filter( ~Q(pk=issue_id), ~Q(blocked_issues__block=issue), ~Q(blocker_issues__blocked_by=issue), ) + if sub_issue == "true" and issue_id: + issue = Issue.issue_objects.get(pk=issue_id) + issues = issues.filter(~Q(pk=issue_id), parent__isnull=True) + if issue.parent: + issues = issues.filter(~Q(pk=issue.parent_id)) + + if cycle == "true": + issues = issues.exclude(issue_cycle__isnull=False) + + if module == "true": + issues = issues.exclude(issue_module__isnull=False) + return Response( issues.values( @@ -244,6 +260,9 @@ class IssueSearchEndpoint(BaseAPIView): "project__identifier", "project_id", "workspace__slug", + "state__name", + "state__group", + "state__color", ), status=status.HTTP_200_OK, ) @@ -252,7 +271,7 @@ class IssueSearchEndpoint(BaseAPIView): {"error": "Issue Does not exist"}, status=status.HTTP_400_BAD_REQUEST ) except Exception as e: - capture_exception(e) + print(e) return Response( {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, diff --git a/apiserver/plane/api/views/shortcut.py b/apiserver/plane/api/views/shortcut.py deleted file mode 100644 index 49453fb14..000000000 --- a/apiserver/plane/api/views/shortcut.py +++ /dev/null @@ -1,29 +0,0 @@ -# Module imports -from . import BaseViewSet -from plane.api.serializers import ShortCutSerializer -from plane.api.permissions import ProjectEntityPermission -from plane.db.models import Shortcut - - -class ShortCutViewSet(BaseViewSet): - - serializer_class = ShortCutSerializer - model = Shortcut - permission_classes = [ - ProjectEntityPermission, - ] - - def perform_create(self, serializer): - serializer.save(project_id=self.kwargs.get("project_id")) - - def get_queryset(self): - return self.filter_queryset( - super() - .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) - .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .distinct() - ) diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index b217a662d..4fe0c8260 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -3,13 +3,13 @@ from itertools import groupby # Django imports from django.db import IntegrityError +from django.db.models import Q # Third party imports from rest_framework.response import Response from rest_framework import status from sentry_sdk import capture_exception - # Module imports from . import BaseViewSet, BaseAPIView from plane.api.serializers import StateSerializer @@ -34,6 +34,7 @@ class StateViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(project__project_projectmember__member=self.request.user) + .filter(~Q(name="Triage")) .select_related("project") .select_related("workspace") .distinct() @@ -80,7 +81,8 @@ class StateViewSet(BaseViewSet): def destroy(self, request, slug, project_id, pk): try: state = State.objects.get( - pk=pk, project_id=project_id, workspace__slug=slug + ~Q(name="Triage"), + pk=pk, project_id=project_id, workspace__slug=slug, ) if state.default: @@ -89,7 +91,7 @@ class StateViewSet(BaseViewSet): ) # Check for any issues in the state - issue_exist = Issue.objects.filter(state=pk).exists() + issue_exist = Issue.issue_objects.filter(state=pk).exists() if issue_exist: return Response( diff --git a/apiserver/plane/api/views/view.py b/apiserver/plane/api/views/view.py index 1b6fb42cc..874bb94fb 100644 --- a/apiserver/plane/api/views/view.py +++ b/apiserver/plane/api/views/view.py @@ -67,7 +67,7 @@ class ViewIssuesEndpoint(BaseAPIView): filters = issue_filters(request.query_params, "GET") issues = ( - Issue.objects.filter( + Issue.issue_objects.filter( **queries, project_id=project_id, workspace__slug=slug ) .filter(**filters) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 10ab7d218..26c82d54c 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -2,7 +2,7 @@ import jwt from datetime import date, datetime from dateutil.relativedelta import relativedelta - +from uuid import uuid4 # Django imports from django.db import IntegrityError from django.db.models import Prefetch @@ -80,9 +80,22 @@ class WorkSpaceViewSet(BaseViewSet): lookup_field = "slug" def get_queryset(self): + member_count = ( + WorkspaceMember.objects.filter(workspace=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + issue_count = ( + Issue.objects.filter(workspace=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) return self.filter_queryset( super().get_queryset().select_related("owner") - ).order_by("name") + ).order_by("name").filter(workspace_member__member=self.request.user).annotate(total_members=member_count).annotate(total_issues=issue_count) def create(self, request): try: @@ -139,6 +152,13 @@ class UserWorkSpacesEndpoint(BaseAPIView): .values("count") ) + issue_count = ( + Issue.objects.filter(workspace=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + workspace = ( Workspace.objects.prefetch_related( Prefetch("workspace_member", queryset=WorkspaceMember.objects.all()) @@ -147,7 +167,7 @@ class UserWorkSpacesEndpoint(BaseAPIView): workspace_member__member=request.user, ) .select_related("owner") - ).annotate(total_members=member_count) + ).annotate(total_members=member_count).annotate(total_issues=issue_count) serializer = WorkSpaceSerializer(self.filter_queryset(workspace), many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -254,6 +274,17 @@ class InviteWorkspaceEndpoint(BaseAPIView): email__in=[email.get("email") for email in emails] ).select_related("workspace") + # create the user if signup is disabled + if settings.DOCKERIZED and not settings.ENABLE_SIGNUP: + _ = User.objects.bulk_create([ + User( + email=email.get("email"), + password=str(uuid4().hex), + is_password_autoset=True + ) + for email in emails + ], batch_size=100) + for invitation in workspace_invitations: workspace_invitation.delay( invitation.email, @@ -749,7 +780,7 @@ class UserIssueCompletedGraphEndpoint(BaseAPIView): month = request.GET.get("month", 1) issues = ( - Issue.objects.filter( + Issue.issue_objects.filter( assignees__in=[request.user], workspace__slug=slug, completed_at__month=month, @@ -794,7 +825,7 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView): month = request.GET.get("month", 1) completed_issues = ( - Issue.objects.filter( + Issue.issue_objects.filter( assignees__in=[request.user], workspace__slug=slug, completed_at__month=month, @@ -807,24 +838,24 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView): .order_by("week_in_month") ) - assigned_issues = Issue.objects.filter( + assigned_issues = Issue.issue_objects.filter( workspace__slug=slug, assignees__in=[request.user] ).count() - pending_issues_count = Issue.objects.filter( + pending_issues_count = Issue.issue_objects.filter( ~Q(state__group__in=["completed", "cancelled"]), workspace__slug=slug, assignees__in=[request.user], ).count() - completed_issues_count = Issue.objects.filter( + completed_issues_count = Issue.issue_objects.filter( workspace__slug=slug, assignees__in=[request.user], state__group="completed", ).count() issues_due_week = ( - Issue.objects.filter( + Issue.issue_objects.filter( workspace__slug=slug, assignees__in=[request.user], ) @@ -834,14 +865,14 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView): ) state_distribution = ( - Issue.objects.filter(workspace__slug=slug, assignees__in=[request.user]) + Issue.issue_objects.filter(workspace__slug=slug, assignees__in=[request.user]) .annotate(state_group=F("state__group")) .values("state_group") .annotate(state_count=Count("state_group")) .order_by("state_group") ) - overdue_issues = Issue.objects.filter( + overdue_issues = Issue.issue_objects.filter( ~Q(state__group__in=["completed", "cancelled"]), workspace__slug=slug, assignees__in=[request.user], @@ -849,7 +880,7 @@ class UserWorkspaceDashboardEndpoint(BaseAPIView): completed_at__isnull=True, ).values("id", "name", "workspace__slug", "project_id", "target_date") - upcoming_issues = Issue.objects.filter( + upcoming_issues = Issue.issue_objects.filter( ~Q(state__group__in=["completed", "cancelled"]), target_date__gte=timezone.now(), workspace__slug=slug, diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index 7f276be82..27b625445 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -36,7 +36,7 @@ row_mapping = { def analytic_export_task(email, data, slug): try: filters = issue_filters(data, "POST") - queryset = Issue.objects.filter(**filters, workspace__slug=slug) + queryset = Issue.issue_objects.filter(**filters, workspace__slug=slug) x_axis = data.get("x_axis", False) y_axis = data.get("y_axis", False) @@ -53,7 +53,7 @@ def analytic_export_task(email, data, slug): assignee_details = {} if x_axis in ["assignees__email"] or segment in ["assignees__email"]: assignee_details = ( - Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False) + Issue.issue_objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False) .order_by("assignees__id") .distinct("assignees__id") .values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name") @@ -169,6 +169,8 @@ def analytic_export_task(email, data, slug): msg.send(fail_silently=False) except Exception as e: - print(e) + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) capture_exception(e) return diff --git a/apiserver/plane/bgtasks/email_verification_task.py b/apiserver/plane/bgtasks/email_verification_task.py index 89551044b..93b15c425 100644 --- a/apiserver/plane/bgtasks/email_verification_task.py +++ b/apiserver/plane/bgtasks/email_verification_task.py @@ -39,5 +39,8 @@ def email_verification(first_name, email, token, current_site): msg.send() return except Exception as e: + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) capture_exception(e) return diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index 687e4f976..93283dfd5 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -37,5 +37,8 @@ def forgot_password(first_name, email, uidb64, token, current_site): msg.send() return except Exception as e: + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) capture_exception(e) return diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index 85ac1c89b..757ef601b 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -175,5 +175,8 @@ def service_importer(service, importer_id): importer = Importer.objects.get(pk=importer_id) importer.status = "failed" importer.save() + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) capture_exception(e) return diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 417fe2324..5865a5982 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -44,7 +44,7 @@ def track_name( field="name", project=project, workspace=project.workspace, - comment=f"{actor.email} updated the start date to {requested_data.get('name')}", + comment=f"{actor.email} updated the name to {requested_data.get('name')}", ) ) @@ -1006,5 +1006,8 @@ def issue_activity( ) return except Exception as e: + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) capture_exception(e) return diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 29851c435..91cc461bb 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -31,4 +31,7 @@ def magic_link(email, key, token, current_site): return except Exception as e: capture_exception(e) + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) return diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index 7f1125f80..8b8ef6e48 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -50,5 +50,8 @@ def project_invitation(email, project_id, token, current_site): except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist) as e: return except Exception as e: + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) capture_exception(e) return diff --git a/apiserver/plane/bgtasks/user_welcome_task.py b/apiserver/plane/bgtasks/user_welcome_task.py index bea2ee33d..33f4b5686 100644 --- a/apiserver/plane/bgtasks/user_welcome_task.py +++ b/apiserver/plane/bgtasks/user_welcome_task.py @@ -29,5 +29,8 @@ def send_welcome_slack(user_id, created, message): print(f"Got an error: {e.response['error']}") return except Exception as e: + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) capture_exception(e) return diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 7b2bada0a..d84a0b414 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -66,5 +66,8 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist) as e: return except Exception as e: + # Print logs if in DEBUG mode + if settings.DEBUG: + print(e) capture_exception(e) return diff --git a/apiserver/plane/db/migrations/0033_auto_20230618_2125.py b/apiserver/plane/db/migrations/0033_auto_20230618_2125.py new file mode 100644 index 000000000..8eb2eda62 --- /dev/null +++ b/apiserver/plane/db/migrations/0033_auto_20230618_2125.py @@ -0,0 +1,83 @@ +# Generated by Django 3.2.19 on 2023-06-18 15:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0032_auto_20230520_2015'), + ] + + operations = [ + migrations.CreateModel( + name='Inbox', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, verbose_name='Inbox Description')), + ('is_default', models.BooleanField(default=False)), + ('view_props', models.JSONField(default=dict)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ], + options={ + 'verbose_name': 'Inbox', + 'verbose_name_plural': 'Inboxes', + 'db_table': 'inboxes', + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='project', + name='inbox_view', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='InboxIssue', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('status', models.IntegerField(choices=[(-2, 'Pending'), (-1, 'Rejected'), (0, 'Snoozed'), (1, 'Accepted'), (2, 'Duplicate')], default=-2)), + ('snoozed_till', models.DateTimeField(null=True)), + ('source', models.TextField(blank=True, null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inboxissue_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('duplicate_to', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_duplicate', to='db.issue')), + ('inbox', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_inbox', to='db.inbox')), + ('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_inbox', to='db.issue')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_inboxissue', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inboxissue_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_inboxissue', to='db.workspace')), + ], + options={ + 'verbose_name': 'InboxIssue', + 'verbose_name_plural': 'InboxIssues', + 'db_table': 'inbox_issues', + 'ordering': ('-created_at',), + }, + ), + migrations.AddField( + model_name='inbox', + name='project', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_inbox', to='db.project'), + ), + migrations.AddField( + model_name='inbox', + name='updated_by', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='inbox_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By'), + ), + migrations.AddField( + model_name='inbox', + name='workspace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_inbox', to='db.workspace'), + ), + migrations.AlterUniqueTogether( + name='inbox', + unique_together={('name', 'project')}, + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index 53b501716..96c649a83 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -23,7 +23,6 @@ from .project import ( from .issue import ( Issue, IssueActivity, - TimelineIssue, IssueProperty, IssueComment, IssueBlocker, @@ -44,8 +43,6 @@ from .state import State from .cycle import Cycle, CycleIssue, CycleFavorite -from .shortcut import Shortcut - from .view import IssueView, IssueViewFavorite from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite @@ -68,4 +65,5 @@ from .page import Page, PageBlock, PageFavorite, PageLabel from .estimate import Estimate, EstimatePoint -from .analytic import AnalyticView \ No newline at end of file +from .inbox import Inbox, InboxIssue +from .analytic import AnalyticView diff --git a/apiserver/plane/db/models/inbox.py b/apiserver/plane/db/models/inbox.py new file mode 100644 index 000000000..497a20f00 --- /dev/null +++ b/apiserver/plane/db/models/inbox.py @@ -0,0 +1,51 @@ +# Django imports +from django.db import models + +# Module imports +from plane.db.models import ProjectBaseModel + + +class Inbox(ProjectBaseModel): + name = models.CharField(max_length=255) + description = models.TextField(verbose_name="Inbox Description", blank=True) + is_default = models.BooleanField(default=False) + view_props = models.JSONField(default=dict) + + def __str__(self): + """Return name of the Inbox""" + return f"{self.name} <{self.project.name}>" + + class Meta: + unique_together = ["name", "project"] + verbose_name = "Inbox" + verbose_name_plural = "Inboxes" + db_table = "inboxes" + ordering = ("name",) + + +class InboxIssue(ProjectBaseModel): + inbox = models.ForeignKey( + "db.Inbox", related_name="issue_inbox", on_delete=models.CASCADE + ) + issue = models.ForeignKey( + "db.Issue", related_name="issue_inbox", on_delete=models.CASCADE + ) + status = models.IntegerField( + choices=((-2, "Pending"), (-1, "Rejected"), (0, "Snoozed"), (1, "Accepted"), (2, "Duplicate")), + default=-2, + ) + snoozed_till = models.DateTimeField(null=True) + duplicate_to = models.ForeignKey( + "db.Issue", related_name="inbox_duplicate", on_delete=models.SET_NULL, null=True + ) + source = models.TextField(blank=True, null=True) + + class Meta: + verbose_name = "InboxIssue" + verbose_name_plural = "InboxIssues" + db_table = "inbox_issues" + ordering = ("-created_at",) + + def __str__(self): + """Return name of the Issue""" + return f"{self.issue.name} <{self.inbox.name}>" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index e25695c42..1ecad6424 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -17,6 +17,20 @@ from plane.utils.html_processor import strip_tags # TODO: Handle identifiers for Bulk Inserts - nk +class IssueManager(models.Manager): + def get_queryset(self): + return ( + super() + .get_queryset() + .filter( + models.Q(issue_inbox__status=1) + | models.Q(issue_inbox__status=-1) + | models.Q(issue_inbox__status=2) + | models.Q(issue_inbox__isnull=True) + ) + ) + + class Issue(ProjectBaseModel): PRIORITY_CHOICES = ( ("urgent", "Urgent"), @@ -68,6 +82,9 @@ class Issue(ProjectBaseModel): sort_order = models.FloatField(default=65535) completed_at = models.DateTimeField(null=True) + objects = models.Manager() + issue_objects = IssueManager() + class Meta: verbose_name = "Issue" verbose_name_plural = "Issues" @@ -81,11 +98,13 @@ class Issue(ProjectBaseModel): from plane.db.models import State default_state = State.objects.filter( - project=self.project, default=True + ~models.Q(name="Triage"), project=self.project, default=True ).first() # if there is no default state assign any random state if default_state is None: - random_state = State.objects.filter(project=self.project).first() + random_state = State.objects.filter( + ~models.Q(name="Triage"), project=self.project + ).first() self.state = random_state if random_state.group == "started": self.start_date = timezone.now().date() @@ -276,24 +295,6 @@ class IssueActivity(ProjectBaseModel): return str(self.issue) -class TimelineIssue(ProjectBaseModel): - issue = models.ForeignKey( - Issue, on_delete=models.CASCADE, related_name="issue_timeline" - ) - sequence_id = models.FloatField(default=1.0) - links = models.JSONField(default=dict, blank=True) - - class Meta: - verbose_name = "Timeline Issue" - verbose_name_plural = "Timeline Issues" - db_table = "issue_timelines" - ordering = ("-created_at",) - - def __str__(self): - """Return project of the project member""" - return str(self.issue) - - class IssueComment(ProjectBaseModel): comment_stripped = models.TextField(verbose_name="Comment", blank=True) comment_json = models.JSONField(blank=True, default=dict) diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 41b1ac654..0b6c4b50d 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -69,6 +69,7 @@ class Project(BaseModel): cycle_view = models.BooleanField(default=True) issue_views_view = models.BooleanField(default=True) page_view = models.BooleanField(default=True) + inbox_view = models.BooleanField(default=False) cover_image = models.URLField(blank=True, null=True, max_length=800) estimate = models.ForeignKey( "db.Estimate", on_delete=models.SET_NULL, related_name="projects", null=True diff --git a/apiserver/plane/db/models/shortcut.py b/apiserver/plane/db/models/shortcut.py deleted file mode 100644 index bdc09c1f2..000000000 --- a/apiserver/plane/db/models/shortcut.py +++ /dev/null @@ -1,26 +0,0 @@ -# Django imports -from django.db import models - - -# Module imports -from . import ProjectBaseModel - - -class Shortcut(ProjectBaseModel): - TYPE_CHOICES = (("repo", "Repo"), ("direct", "Direct")) - name = models.CharField(max_length=255, verbose_name="Cycle Name") - description = models.TextField(verbose_name="Cycle Description", blank=True) - type = models.CharField( - max_length=255, verbose_name="Shortcut Type", choices=TYPE_CHOICES - ) - url = models.URLField(verbose_name="URL", blank=True, null=True) - - class Meta: - verbose_name = "Shortcut" - verbose_name_plural = "Shortcuts" - db_table = "shortcuts" - ordering = ("-created_at",) - - def __str__(self): - """Return name of the shortcut""" - return f"{self.name} <{self.project.name}>" diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index f5bff248b..2e0266159 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -179,6 +179,7 @@ EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587)) EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER") EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD") EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "1") == "1" +EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "0") == "1" EMAIL_FROM = os.environ.get("EMAIL_FROM", "Team Plane ") diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 20b257a27..1b862c013 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -10,7 +10,9 @@ from sentry_sdk.integrations.redis import RedisIntegration from .common import * # noqa -DEBUG = True +DEBUG = int(os.environ.get( + "DEBUG", 1 +)) == 1 EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" @@ -18,10 +20,10 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql_psycopg2", - "NAME": "plane", + "NAME": os.environ.get("PGUSER", "plane"), "USER": "", "PASSWORD": "", - "HOST": "", + "HOST": os.environ.get("PGHOST", "localhost"), } } @@ -91,3 +93,5 @@ CELERY_RESULT_BACKEND = os.environ.get("REDIS_URL") CELERY_BROKER_URL = os.environ.get("REDIS_URL") GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) + +ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" \ No newline at end of file diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index 7e7f4186f..29b75fc8b 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -13,7 +13,10 @@ from sentry_sdk.integrations.redis import RedisIntegration from .common import * # noqa # Database -DEBUG = False +DEBUG = int(os.environ.get( + "DEBUG", 0 +)) == 1 + DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql_psycopg2", @@ -258,3 +261,6 @@ else: CELERY_BROKER_URL = broker_url GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) + + +ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index c6ffcaf22..11ff7a372 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -12,14 +12,16 @@ from sentry_sdk.integrations.redis import RedisIntegration from .common import * # noqa # Database -DEBUG = True +DEBUG = int(os.environ.get( + "DEBUG", 1 +)) == 1 DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql_psycopg2", - "NAME": "plane", + "NAME": os.environ.get("PGUSER", "plane"), "USER": "", "PASSWORD": "", - "HOST": "", + "HOST": os.environ.get("PGHOST", "localhost"), } } @@ -211,3 +213,5 @@ CELERY_RESULT_BACKEND = broker_url CELERY_BROKER_URL = broker_url GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) + +ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index 161f6497e..033452e0d 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -1,11 +1,16 @@ # Python imports from itertools import groupby +from datetime import timedelta # Django import from django.db import models +from django.db.models.functions import TruncDate from django.db.models import Count, F, Sum, Value, Case, When, CharField from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Concat +# Module imports +from plane.db.models import Issue + def build_graph_plot(queryset, x_axis, y_axis, segment=None): @@ -74,3 +79,69 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): else: sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0]))) return sorted_data + + +def burndown_plot(queryset, slug, project_id, cycle_id=None, module_id=None): + # Total Issues in Cycle or Module + total_issues = queryset.total_issues + + + if cycle_id: + # Get all dates between the two dates + date_range = [ + queryset.start_date + timedelta(days=x) + for x in range((queryset.end_date - queryset.start_date).days + 1) + ] + + chart_data = {str(date): 0 for date in date_range} + + completed_issues_distribution = ( + Issue.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_cycle__cycle_id=cycle_id, + ) + .annotate(date=TruncDate("completed_at")) + .values("date") + .annotate(total_completed=Count("id")) + .values("date", "total_completed") + .order_by("date") + ) + + if module_id: + # Get all dates between the two dates + date_range = [ + queryset.start_date + timedelta(days=x) + for x in range((queryset.target_date - queryset.start_date).days + 1) + ] + + chart_data = {str(date): 0 for date in date_range} + + completed_issues_distribution = ( + Issue.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_module__module_id=module_id, + ) + .annotate(date=TruncDate("completed_at")) + .values("date") + .annotate(total_completed=Count("id")) + .values("date", "total_completed") + .order_by("date") + ) + + + for date in date_range: + cumulative_pending_issues = total_issues + total_completed = 0 + total_completed = sum( + [ + item["total_completed"] + for item in completed_issues_distribution + if item["date"] is not None and item["date"] <= date + ] + ) + cumulative_pending_issues -= total_completed + chart_data[str(date)] = cumulative_pending_issues + + return chart_data \ No newline at end of file diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 944906f92..f348f642a 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -231,6 +231,29 @@ def filter_module(params, filter, method): return filter +def filter_inbox_status(params, filter, method): + if method == "GET": + status = params.get("inbox_status").split(",") + if len(status) and "" not in status: + filter["issue_inbox__status__in"] = status + else: + if params.get("inbox_status", None) and len(params.get("inbox_status")): + filter["issue_inbox__status__in"] = params.get("inbox_status") + return filter + + +def filter_sub_issue_toggle(params, filter, method): + if method == "GET": + sub_issue = params.get("sub_issue", "false") + if sub_issue == "false": + filter["parent__isnull"] = True + else: + sub_issue = params.get("sub_issue", "false") + if sub_issue == "false": + filter["parent__isnull"] = True + return filter + + def issue_filters(query_params, method): filter = dict() @@ -252,6 +275,8 @@ def issue_filters(query_params, method): "project": filter_project, "cycle": filter_cycle, "module": filter_module, + "inbox_status": filter_inbox_status, + "sub_issue": filter_sub_issue_toggle, } for key, value in ISSUE_FILTER.items(): diff --git a/apps/app/components/account/email-password-form.tsx b/apps/app/components/account/email-password-form.tsx index 920ec0829..8a0dc3a33 100644 --- a/apps/app/components/account/email-password-form.tsx +++ b/apps/app/components/account/email-password-form.tsx @@ -1,11 +1,10 @@ import React, { useState } from "react"; +import { useRouter } from "next/router"; +import Link from "next/link"; + // react hook form import { useForm } from "react-hook-form"; -// services -import authenticationService from "services/authentication.service"; -// hooks -import useToast from "hooks/use-toast"; // components import { EmailResetPasswordForm } from "components/account"; // ui @@ -17,15 +16,19 @@ type EmailPasswordFormValues = { medium?: string; }; -export const EmailPasswordForm = ({ handleSignIn }: any) => { +type Props = { + onSubmit: (formData: EmailPasswordFormValues) => Promise; +}; + +export const EmailPasswordForm: React.FC = ({ onSubmit }) => { const [isResettingPassword, setIsResettingPassword] = useState(false); - const { setToastAlert } = useToast(); + const router = useRouter(); + const isSignUpPage = router.pathname === "/sign-up"; const { register, handleSubmit, - setError, formState: { errors, isSubmitting, isValid, isDirty }, } = useForm({ defaultValues: { @@ -37,31 +40,6 @@ export const EmailPasswordForm = ({ handleSignIn }: any) => { reValidateMode: "onChange", }); - const onSubmit = (formData: EmailPasswordFormValues) => { - authenticationService - .emailLogin(formData) - .then((response) => { - if (handleSignIn) handleSignIn(response); - }) - .catch((error) => { - console.log(error); - setToastAlert({ - title: "Oops!", - type: "error", - message: "Enter the correct email address and password to sign in", - }); - if (!error?.response?.data) return; - Object.keys(error.response.data).forEach((key) => { - const err = error.response.data[key]; - console.log(err); - setError(key as keyof EmailPasswordFormValues, { - type: "manual", - message: Array.isArray(err) ? err.join(", ") : err, - }); - }); - }); - }; - return ( <> {isResettingPassword ? ( @@ -82,7 +60,7 @@ export const EmailPasswordForm = ({ handleSignIn }: any) => { ) || "Email ID is not valid", }} error={errors.email} - placeholder="Enter your Email ID" + placeholder="Enter your email ID" />
@@ -100,13 +78,21 @@ export const EmailPasswordForm = ({ handleSignIn }: any) => {
- + {isSignUpPage ? ( + + + Already have an account? Sign in. + + + ) : ( + + )}
@@ -116,8 +102,21 @@ export const EmailPasswordForm = ({ handleSignIn }: any) => { disabled={!isValid && isDirty} loading={isSubmitting} > - {isSubmitting ? "Signing in..." : "Sign In"} + {isSignUpPage + ? isSubmitting + ? "Signing up..." + : "Sign Up" + : isSubmitting + ? "Signing in..." + : "Sign In"} + {!isSignUpPage && ( + + + Don{"'"}t have an account? Sign up. + + + )}
)} diff --git a/apps/app/components/analytics/custom-analytics/sidebar.tsx b/apps/app/components/analytics/custom-analytics/sidebar.tsx index 5f4700f29..b533df519 100644 --- a/apps/app/components/analytics/custom-analytics/sidebar.tsx +++ b/apps/app/components/analytics/custom-analytics/sidebar.tsx @@ -237,7 +237,7 @@ export const AnalyticsSidebar: React.FC = ({ {project?.name.charAt(0)} )} -
+
{project.name} ({project.identifier}) @@ -276,7 +276,7 @@ export const AnalyticsSidebar: React.FC = ({ {projectId ? ( cycleId && cycleDetails ? (
-

Analytics for {cycleDetails.name}

+

Analytics for {cycleDetails.name}

Lead
@@ -304,7 +304,7 @@ export const AnalyticsSidebar: React.FC = ({
) : moduleId && moduleDetails ? (
-

Analytics for {moduleDetails.name}

+

Analytics for {moduleDetails.name}

Lead
@@ -352,7 +352,7 @@ export const AnalyticsSidebar: React.FC = ({ {projectDetails?.name.charAt(0)} )} -

{projectDetails?.name}

+

{projectDetails?.name}

diff --git a/apps/app/components/analytics/project-modal.tsx b/apps/app/components/analytics/project-modal.tsx index da308582f..5fdb6682d 100644 --- a/apps/app/components/analytics/project-modal.tsx +++ b/apps/app/components/analytics/project-modal.tsx @@ -160,7 +160,7 @@ export const AnalyticsProjectModal: React.FC = ({ isOpen, onClose }) => { }`} >
-

+

Analytics for{" "} {cycleId ? cycleDetails?.name : moduleId ? moduleDetails?.name : projectDetails?.name}

diff --git a/apps/app/components/analytics/scope-and-demand/leaderboard.tsx b/apps/app/components/analytics/scope-and-demand/leaderboard.tsx index 855f9eff4..72b892eeb 100644 --- a/apps/app/components/analytics/scope-and-demand/leaderboard.tsx +++ b/apps/app/components/analytics/scope-and-demand/leaderboard.tsx @@ -33,7 +33,7 @@ export const AnalyticsLeaderboard: React.FC = ({ users, title }) => ( {user.firstName !== "" ? user.firstName[0] : "?"}
)} - + {user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"}
diff --git a/apps/app/components/breadcrumbs/index.tsx b/apps/app/components/breadcrumbs/index.tsx index 240faefa2..6e2c85785 100644 --- a/apps/app/components/breadcrumbs/index.tsx +++ b/apps/app/components/breadcrumbs/index.tsx @@ -52,7 +52,7 @@ const BreadcrumbItem: React.FC = ({ title, link, icon }) =>

{icon} - {title} + {title}

)} diff --git a/apps/app/components/command-palette/change-issue-assignee.tsx b/apps/app/components/command-palette/change-issue-assignee.tsx index 56351335e..1021623db 100644 --- a/apps/app/components/command-palette/change-issue-assignee.tsx +++ b/apps/app/components/command-palette/change-issue-assignee.tsx @@ -80,7 +80,7 @@ export const ChangeIssueAssignee: React.FC = ({ setIsPaletteOpen, issue, console.error(e); }); }, - [workspaceSlug, issueId, projectId] + [workspaceSlug, issueId, projectId, user] ); const handleIssueAssignees = (assignee: string) => { diff --git a/apps/app/components/command-palette/change-issue-priority.tsx b/apps/app/components/command-palette/change-issue-priority.tsx index 2db03268d..07ba210a6 100644 --- a/apps/app/components/command-palette/change-issue-priority.tsx +++ b/apps/app/components/command-palette/change-issue-priority.tsx @@ -51,7 +51,7 @@ export const ChangeIssuePriority: React.FC = ({ setIsPaletteOpen, issue, console.error(e); }); }, - [workspaceSlug, issueId, projectId] + [workspaceSlug, issueId, projectId, user] ); const handleIssueState = (priority: string | null) => { diff --git a/apps/app/components/command-palette/change-issue-state.tsx b/apps/app/components/command-palette/change-issue-state.tsx index 0378df878..00c9745be 100644 --- a/apps/app/components/command-palette/change-issue-state.tsx +++ b/apps/app/components/command-palette/change-issue-state.tsx @@ -63,7 +63,7 @@ export const ChangeIssueState: React.FC = ({ setIsPaletteOpen, issue, use console.error(e); }); }, - [workspaceSlug, issueId, projectId, mutateIssueDetails] + [workspaceSlug, issueId, projectId, mutateIssueDetails, user] ); const handleIssueState = (stateId: string) => { diff --git a/apps/app/components/command-palette/command-pallette.tsx b/apps/app/components/command-palette/command-pallette.tsx index ff889898e..32634f18c 100644 --- a/apps/app/components/command-palette/command-pallette.tsx +++ b/apps/app/components/command-palette/command-pallette.tsx @@ -9,6 +9,7 @@ import { ChatBubbleOvalLeftEllipsisIcon, DocumentTextIcon, FolderPlusIcon, + InboxIcon, LinkIcon, MagnifyingGlassIcon, RocketLaunchIcon, @@ -34,6 +35,7 @@ import { Dialog, Transition } from "@headlessui/react"; // cmdk import { Command } from "cmdk"; // hooks +import useProjectDetails from "hooks/use-project-details"; import useTheme from "hooks/use-theme"; import useToast from "hooks/use-toast"; import useUser from "hooks/use-user"; @@ -64,10 +66,11 @@ import { // services import issuesService from "services/issues.service"; import workspaceService from "services/workspace.service"; +import inboxService from "services/inbox.service"; // types import { IIssue, IWorkspaceSearchResults } from "types"; // fetch keys -import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; +import { INBOX_LIST, ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; export const CommandPalette: React.FC = () => { const [isPaletteOpen, setIsPaletteOpen] = useState(false); @@ -81,7 +84,7 @@ export const CommandPalette: React.FC = () => { const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [isCreateUpdatePageModalOpen, setIsCreateUpdatePageModalOpen] = useState(false); - const [searchTerm, setSearchTerm] = React.useState(""); + const [searchTerm, setSearchTerm] = useState(""); const [results, setResults] = useState({ results: { workspace: [], @@ -102,9 +105,11 @@ export const CommandPalette: React.FC = () => { const page = pages[pages.length - 1]; const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; + const { workspaceSlug, projectId, issueId, inboxId } = router.query; const { user } = useUser(); + const { projectDetails } = useProjectDetails(); + const { setToastAlert } = useToast(); const { toggleCollapsed } = useTheme(); @@ -116,6 +121,13 @@ export const CommandPalette: React.FC = () => { : null ); + const { data: inboxList } = useSWR( + workspaceSlug && projectId ? INBOX_LIST(projectId as string) : null, + workspaceSlug && projectId + ? () => inboxService.getInboxes(workspaceSlug as string, projectId as string) + : null + ); + const updateIssue = useCallback( async (formData: Partial) => { if (!workspaceSlug || !projectId || !issueId) return; @@ -145,7 +157,7 @@ export const CommandPalette: React.FC = () => { console.error(e); }); }, - [workspaceSlug, issueId, projectId] + [workspaceSlug, issueId, projectId, user] ); const handleIssueAssignees = (assignee: string) => { @@ -321,9 +333,9 @@ export const CommandPalette: React.FC = () => { setDeleteIssueModal(true); }; - const goToSettings = (path: string = "") => { + const redirect = (path: string) => { setIsPaletteOpen(false); - router.push(`/${workspaceSlug}/settings/${path}`); + router.push(path); }; return ( @@ -372,6 +384,7 @@ export const CommandPalette: React.FC = () => { setIsIssueModalOpen(false)} + fieldsToShow={inboxId ? ["name", "description", "priority"] : ["all"]} /> { leaveFrom="opacity-100" leaveTo="opacity-0" > -
+
@@ -408,14 +421,14 @@ export const CommandPalette: React.FC = () => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - + { if (value.toLowerCase().includes(search.toLowerCase())) return 1; return 0; }} onKeyDown={(e) => { - // when seach is empty and page is undefined + // when search is empty and page is undefined // when user tries to close the modal with esc if (e.key === "Escape" && !page && !searchTerm) { setIsPaletteOpen(false); @@ -697,6 +710,24 @@ export const CommandPalette: React.FC = () => { D + + {projectDetails && projectDetails.inbox_view && ( + + + redirect( + `/${workspaceSlug}/projects/${projectId}/inbox/${inboxList?.[0]?.id}` + ) + } + className="focus:outline-none" + > +
+ + Open inbox +
+
+
+ )} )} @@ -813,7 +844,7 @@ export const CommandPalette: React.FC = () => { {page === "settings" && workspaceSlug && ( <> goToSettings()} + onSelect={() => redirect(`/${workspaceSlug}/settings`)} className="focus:outline-none" >
@@ -822,7 +853,7 @@ export const CommandPalette: React.FC = () => {
goToSettings("members")} + onSelect={() => redirect(`/${workspaceSlug}/settings/members`)} className="focus:outline-none" >
@@ -831,7 +862,7 @@ export const CommandPalette: React.FC = () => {
goToSettings("billing")} + onSelect={() => redirect(`/${workspaceSlug}/settings/billing`)} className="focus:outline-none" >
@@ -840,7 +871,7 @@ export const CommandPalette: React.FC = () => {
goToSettings("integrations")} + onSelect={() => redirect(`/${workspaceSlug}/settings/integrations`)} className="focus:outline-none" >
@@ -849,12 +880,12 @@ export const CommandPalette: React.FC = () => {
goToSettings("import-export")} + onSelect={() => redirect(`/${workspaceSlug}/settings/import-export`)} className="focus:outline-none" >
- Import/ Export + Import/Export
diff --git a/apps/app/components/core/board-view/all-boards.tsx b/apps/app/components/core/board-view/all-boards.tsx index 3e67e86b5..711fb7336 100644 --- a/apps/app/components/core/board-view/all-boards.tsx +++ b/apps/app/components/core/board-view/all-boards.tsx @@ -2,11 +2,12 @@ import useProjectIssuesView from "hooks/use-issues-view"; // components import { SingleBoard } from "components/core/board-view/single-board"; +// icons +import { getStateGroupIcon } from "components/icons"; // helpers import { addSpaceIfCamelCase } from "helpers/string.helper"; // types import { ICurrentUserResponse, IIssue, IState, UserAuth } from "types"; -import { getStateGroupIcon } from "components/icons"; type Props = { type: "issue" | "cycle" | "module"; diff --git a/apps/app/components/core/board-view/board-header.tsx b/apps/app/components/core/board-view/board-header.tsx index 6fa8f68f3..a5df7a426 100644 --- a/apps/app/components/core/board-view/board-header.tsx +++ b/apps/app/components/core/board-view/board-header.tsx @@ -166,7 +166,7 @@ export const BoardHeader: React.FC = ({ )} - {!isCompleted && ( + {!isCompleted && selectedGroup !== "created_by" && (
-
- {type === "issue" ? ( - - ) : ( - !isCompleted && ( - - - Add Issue - - } - position="left" - noBorder + {selectedGroup !== "created_by" && ( +
+ {type === "issue" ? ( + + ) : ( + !isCompleted && ( + + + Add Issue + + } + position="left" + noBorder + > + + Create new - )} - - ) - )} -
+ {openIssuesListModal && ( + + Add an existing issue + + )} +
+ ) + )} +
+ )}
)} diff --git a/apps/app/components/core/board-view/single-issue.tsx b/apps/app/components/core/board-view/single-issue.tsx index e2d530153..5c0cc5102 100644 --- a/apps/app/components/core/board-view/single-issue.tsx +++ b/apps/app/components/core/board-view/single-issue.tsx @@ -181,12 +181,20 @@ export const SingleBoardIssue: React.FC = ({ mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); mutate(MODULE_DETAILS(moduleId as string)); } else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params)); - }) - .catch((error) => { - console.log(error); }); }, - [workspaceSlug, projectId, cycleId, moduleId, groupTitle, index, selectedGroup, orderBy, params] + [ + workspaceSlug, + projectId, + cycleId, + moduleId, + groupTitle, + index, + selectedGroup, + orderBy, + params, + user, + ] ); const getStyle = ( @@ -330,11 +338,8 @@ export const SingleBoardIssue: React.FC = ({ {issue.project_detail.identifier}-{issue.sequence_id}
)} -
- {truncateText(issue.name, 100)} +
+ {issue.name}
diff --git a/apps/app/components/core/bulk-delete-issues-modal.tsx b/apps/app/components/core/bulk-delete-issues-modal.tsx index 603efe8e3..a2c6310e9 100644 --- a/apps/app/components/core/bulk-delete-issues-modal.tsx +++ b/apps/app/components/core/bulk-delete-issues-modal.tsx @@ -12,6 +12,8 @@ import { Combobox, Dialog, Transition } from "@headlessui/react"; import issuesServices from "services/issues.service"; // hooks import useToast from "hooks/use-toast"; +import useIssuesView from "hooks/use-issues-view"; +import useCalendarIssuesView from "hooks/use-calendar-issues-view"; // ui import { DangerButton, SecondaryButton } from "components/ui"; // icons @@ -20,7 +22,15 @@ import { LayerDiagonalIcon } from "components/icons"; // types import { ICurrentUserResponse, IIssue } from "types"; // fetch keys -import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; +import { + CYCLE_DETAILS, + CYCLE_ISSUES_WITH_PARAMS, + MODULE_DETAILS, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST, + PROJECT_ISSUES_LIST_WITH_PARAMS, + VIEW_ISSUES, +} from "constants/fetch-keys"; type FormInput = { delete_issue_ids: string[]; @@ -36,7 +46,7 @@ export const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen, user const [query, setQuery] = useState(""); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; const { data: issues } = useSWR( workspaceSlug && projectId @@ -48,6 +58,9 @@ export const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen, user ); const { setToastAlert } = useToast(); + const { issueView, params } = useIssuesView(); + const { params: calendarParams } = useCalendarIssuesView(); + const { order_by, group_by, ...viewGanttParams } = params; const { handleSubmit, @@ -61,6 +74,81 @@ export const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen, user }, }); + const handleClose = () => { + setIsOpen(false); + setQuery(""); + reset(); + }; + + const handleDelete: SubmitHandler = async (data) => { + if (!workspaceSlug || !projectId) return; + + if (!data.delete_issue_ids || data.delete_issue_ids.length === 0) { + setToastAlert({ + type: "error", + title: "Error!", + message: "Please select at least one issue.", + }); + return; + } + + if (!Array.isArray(data.delete_issue_ids)) data.delete_issue_ids = [data.delete_issue_ids]; + + const calendarFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams) + : viewId + ? VIEW_ISSUES(viewId.toString(), calendarParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", calendarParams); + + const ganttFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString()) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString()) + : viewId + ? VIEW_ISSUES(viewId.toString(), viewGanttParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? ""); + + await issuesServices + .bulkDeleteIssues( + workspaceSlug as string, + projectId as string, + { + issue_ids: data.delete_issue_ids, + }, + user + ) + .then(() => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Issues deleted successfully!", + }); + + if (issueView === "calendar") mutate(calendarFetchKey); + else if (issueView === "gantt_chart") mutate(ganttFetchKey); + else { + if (cycleId) { + mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)); + mutate(CYCLE_DETAILS(cycleId.toString())); + } else if (moduleId) { + mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); + mutate(MODULE_DETAILS(moduleId as string)); + } else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params)); + } + + handleClose(); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Something went wrong. Please try again.", + }) + ); + }; + const filteredIssues: IIssue[] = query === "" ? issues ?? [] @@ -72,48 +160,6 @@ export const BulkDeleteIssuesModal: React.FC = ({ isOpen, setIsOpen, user .includes(query.toLowerCase()) ) ?? []; - const handleClose = () => { - setIsOpen(false); - setQuery(""); - reset(); - }; - - const handleDelete: SubmitHandler = async (data) => { - if (!data.delete_issue_ids || data.delete_issue_ids.length === 0) { - setToastAlert({ - title: "Error", - type: "error", - message: "Please select atleast one issue", - }); - return; - } - - if (!Array.isArray(data.delete_issue_ids)) data.delete_issue_ids = [data.delete_issue_ids]; - - if (workspaceSlug && projectId) { - await issuesServices - .bulkDeleteIssues( - workspaceSlug as string, - projectId as string, - { - issue_ids: data.delete_issue_ids, - }, - user - ) - .then((res) => { - setToastAlert({ - title: "Success", - type: "success", - message: res.message, - }); - handleClose(); - }) - .catch((e) => { - console.log(e); - }); - } - }; - return ( setQuery("")} appear> diff --git a/apps/app/components/core/calendar-view/single-issue.tsx b/apps/app/components/core/calendar-view/single-issue.tsx index 4fa9def3b..12fa60d01 100644 --- a/apps/app/components/core/calendar-view/single-issue.tsx +++ b/apps/app/components/core/calendar-view/single-issue.tsx @@ -105,7 +105,7 @@ export const SingleCalendarIssue: React.FC = ({ console.log(error); }); }, - [workspaceSlug, projectId, cycleId, moduleId, params] + [workspaceSlug, projectId, cycleId, moduleId, viewId, params, user] ); const handleCopyText = () => { diff --git a/apps/app/components/core/existing-issues-list-modal.tsx b/apps/app/components/core/existing-issues-list-modal.tsx index 8ef6132ae..e9a004a5e 100644 --- a/apps/app/components/core/existing-issues-list-modal.tsx +++ b/apps/app/components/core/existing-issues-list-modal.tsx @@ -1,23 +1,24 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useRouter } from "next/router"; import { mutate } from "swr"; -// react-hook-form -import { Controller, SubmitHandler, useForm } from "react-hook-form"; // headless ui import { Combobox, Dialog, Transition } from "@headlessui/react"; +// services +import projectService from "services/project.service"; // hooks import useToast from "hooks/use-toast"; import useIssuesView from "hooks/use-issues-view"; +import useDebounce from "hooks/use-debounce"; // ui -import { PrimaryButton, SecondaryButton } from "components/ui"; +import { Loader, PrimaryButton, SecondaryButton } from "components/ui"; // icons -import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { LayerDiagonalIcon } from "components/icons"; // types -import { IIssue } from "types"; +import { ISearchIssueResponse, TProjectIssuesSearchParams } from "types"; // fetch-keys import { CYCLE_DETAILS, @@ -26,27 +27,30 @@ import { MODULE_ISSUES_WITH_PARAMS, } from "constants/fetch-keys"; -type FormInput = { - issues: string[]; -}; - type Props = { isOpen: boolean; handleClose: () => void; - issues: IIssue[]; - handleOnSubmit: any; + searchParams: Partial; + handleOnSubmit: (data: ISearchIssueResponse[]) => Promise; }; export const ExistingIssuesListModal: React.FC = ({ isOpen, handleClose: onClose, - issues, + searchParams, handleOnSubmit, }) => { - const [query, setQuery] = useState(""); + const [searchTerm, setSearchTerm] = useState(""); + const [issues, setIssues] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [selectedIssues, setSelectedIssues] = useState([]); + const [isSubmitting, setIsSubmitting] = useState(false); + + const debouncedSearchTerm: string = useDebounce(searchTerm, 500); const router = useRouter(); - const { cycleId, moduleId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId } = router.query; const { setToastAlert } = useToast(); @@ -54,37 +58,30 @@ export const ExistingIssuesListModal: React.FC = ({ const handleClose = () => { onClose(); - setQuery(""); - reset(); + setSearchTerm(""); + setSelectedIssues([]); }; - const { - handleSubmit, - reset, - control, - formState: { isSubmitting }, - } = useForm({ - defaultValues: { - issues: [], - }, - }); - - const onSubmit: SubmitHandler = async (data) => { - if (!data.issues || data.issues.length === 0) { + const onSubmit = async () => { + if (selectedIssues.length === 0) { setToastAlert({ - title: "Error", type: "error", - message: "Please select atleast one issue", + title: "Error!", + message: "Please select at least one issue.", }); return; } - await handleOnSubmit(data); + setIsSubmitting(true); + + await handleOnSubmit(selectedIssues).finally(() => setIsSubmitting(false)); + if (cycleId) { mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params)); mutate(CYCLE_DETAILS(cycleId as string)); } + if (moduleId) { mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); mutate(MODULE_DETAILS(moduleId as string)); @@ -95,18 +92,45 @@ export const ExistingIssuesListModal: React.FC = ({ setToastAlert({ title: "Success", type: "success", - message: `Issue${data.issues.length > 1 ? "s" : ""} added successfully`, + message: `Issue${selectedIssues.length > 1 ? "s" : ""} added successfully`, }); }; - const filteredIssues: IIssue[] = - query === "" - ? issues ?? [] - : issues.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? []; + useEffect(() => { + if (!workspaceSlug || !projectId) return; + + setIsLoading(true); + + if (debouncedSearchTerm) { + setIsSearching(true); + + projectService + .projectIssuesSearch(workspaceSlug as string, projectId as string, { + search: debouncedSearchTerm, + ...searchParams, + }) + .then((res) => { + setIssues(res); + }) + .finally(() => { + setIsLoading(false); + setIsSearching(false); + }); + } else { + setIssues([]); + setIsLoading(false); + setIsSearching(false); + } + }, [debouncedSearchTerm, workspaceSlug, projectId, searchParams]); return ( <> - setQuery("")} appear> + setSearchTerm("")} + appear + > = ({ leaveTo="opacity-0 scale-95" > -
- ( - -
-
+ { + if (selectedIssues.some((i) => i.id === val.id)) + setSelectedIssues((prevData) => prevData.filter((i) => i.id !== val.id)); + else setSelectedIssues((prevData) => [...prevData, val]); + }} + > +
+
- - {filteredIssues.length > 0 ? ( -
  • - {query === "" && ( -

    - Select issues to add -

    - )} -
      - {filteredIssues.map((issue) => ( - - `flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ - active ? "bg-brand-surface-2 text-brand-base" : "" - } ${selected ? "text-brand-base" : ""}` - } - > - {({ selected }) => ( - <> - - - - {issue.project_detail.identifier}-{issue.sequence_id} - - {issue.name} - - )} - - ))} -
    -
  • - ) : ( -
    - -

    - No issues found. Create a new issue with{" "} -
    C
    - . -

    -
    - )} -
    -
    +
    + {selectedIssues.length > 0 ? ( +
    + {selectedIssues.map((issue) => ( +
    + {issue.project__identifier}-{issue.sequence_id} + +
    + ))} +
    + ) : ( +
    + No issues selected +
    )} - /> - {filteredIssues.length > 0 && ( -
    - Cancel - - {isSubmitting ? "Adding..." : "Add selected issues"} - -
    - )} - +
    + + + {debouncedSearchTerm !== "" && ( +
    + Search results for{" "} + + {'"'} + {debouncedSearchTerm} + {'"'} + {" "} + in project: +
    + )} + + {!isLoading && + issues.length === 0 && + searchTerm !== "" && + debouncedSearchTerm !== "" && ( +
    + +

    + No issues found. Create a new issue with{" "} +
    +                              C
    +                            
    + . +

    +
    + )} + + {isLoading || isSearching ? ( + + + + + + + ) : ( +
      0 ? "p-2" : ""}`}> + {issues.map((issue) => { + const selected = selectedIssues.some((i) => i.id === issue.id); + + return ( + + `flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ + active ? "bg-brand-surface-2 text-brand-base" : "" + } ${selected ? "text-brand-base" : ""}` + } + > + + + + {issue.project__identifier}-{issue.sequence_id} + + {issue.name} + + ); + })} +
    + )} +
    +
    + {selectedIssues.length > 0 && ( +
    + Cancel + + {isSubmitting ? "Adding..." : "Add selected issues"} + +
    + )}
    diff --git a/apps/app/components/core/index.ts b/apps/app/components/core/index.ts index e3e187d60..c50ce7251 100644 --- a/apps/app/components/core/index.ts +++ b/apps/app/components/core/index.ts @@ -2,6 +2,7 @@ export * from "./board-view"; export * from "./calendar-view"; export * from "./gantt-chart-view"; export * from "./list-view"; +export * from "./spreadsheet-view"; export * from "./sidebar"; export * from "./bulk-delete-issues-modal"; export * from "./existing-issues-list-modal"; diff --git a/apps/app/components/core/issues-view-filter.tsx b/apps/app/components/core/issues-view-filter.tsx index 6856e8f8b..679f6adc3 100644 --- a/apps/app/components/core/issues-view-filter.tsx +++ b/apps/app/components/core/issues-view-filter.tsx @@ -10,7 +10,7 @@ import { Popover, Transition } from "@headlessui/react"; // components import { SelectFilters } from "components/views"; // ui -import { CustomMenu, ToggleSwitch } from "components/ui"; +import { CustomMenu, Icon, ToggleSwitch } from "components/ui"; // icons import { ChevronDownIcon, @@ -83,6 +83,15 @@ export const IssuesFilterView: React.FC = () => { > +
    -
    {link.title}
    +
    {link.title}

    Added {timeAgo(link.created_at)}
    diff --git a/apps/app/components/core/sidebar/progress-chart.tsx b/apps/app/components/core/sidebar/progress-chart.tsx index 9006b58fc..8c7dba11e 100644 --- a/apps/app/components/core/sidebar/progress-chart.tsx +++ b/apps/app/components/core/sidebar/progress-chart.tsx @@ -1,94 +1,134 @@ import React from "react"; -import { XAxis, YAxis, Tooltip, AreaChart, Area, ReferenceLine, TooltipProps} from "recharts"; - -//types -import { IIssue } from "types"; -import { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"; -// helper +// ui +import { LineGraph } from "components/ui"; +// helpers import { getDatesInRange, renderShortNumericDateFormat } from "helpers/date-time.helper"; +//types +import { TCompletionChartDistribution } from "types"; type Props = { - issues: IIssue[]; - start: string; - end: string; - width?: number; - height?: number; + distribution: TCompletionChartDistribution; + startDate: string | Date; + endDate: string | Date; + totalIssues: number; }; -const ProgressChart: React.FC = ({ issues, start, end, width = 360, height = 160 }) => { - const startDate = new Date(start); - const endDate = new Date(end); - const getChartData = () => { - const dateRangeArray = getDatesInRange(startDate, endDate); - let count = 0; - const dateWiseData = dateRangeArray.map((d) => { - const current = d.toISOString().split("T")[0]; - const total = issues.length; - const currentData = issues.filter( - (i) => i.completed_at && i.completed_at.toString().split("T")[0] === current - ); - count = currentData ? currentData.length + count : count; +const styleById = { + ideal: { + strokeDasharray: "6, 3", + strokeWidth: 1, + }, + default: { + strokeWidth: 1, + }, +}; - return { - currentDate: renderShortNumericDateFormat(current), - currentDateData: currentData, - pending: new Date(current) < new Date() ? total - count : null, - }; - }); - return dateWiseData; - }; +const DashedLine = ({ series, lineGenerator, xScale, yScale }: any) => + series.map(({ id, data, color }: any) => ( + ({ + x: xScale(d.data.x), + y: yScale(d.data.y), + })) + )} + fill="none" + stroke={color ?? "#ddd"} + style={styleById[id as keyof typeof styleById] || styleById.default} + /> + )); - const CustomTooltip = ({ active, payload }: TooltipProps) => { - if (active && payload && payload.length) { - return ( -

    -

    {payload[0].payload.currentDate}

    -
    - ); +const ProgressChart: React.FC = ({ distribution, startDate, endDate, totalIssues }) => { + const chartData = Object.keys(distribution).map((key) => ({ + currentDate: renderShortNumericDateFormat(key), + pending: distribution[key], + })); + + const generateXAxisTickValues = () => { + const dates = getDatesInRange(startDate, endDate); + + const maxDates = 4; + const totalDates = dates.length; + + if (totalDates <= maxDates) return dates; + else { + const interval = Math.ceil(totalDates / maxDates); + const limitedDates = []; + + for (let i = 0; i < totalDates; i += interval) + limitedDates.push(renderShortNumericDateFormat(dates[i])); + + if (!limitedDates.includes(renderShortNumericDateFormat(dates[totalDates - 1]))) + limitedDates.push(renderShortNumericDateFormat(dates[totalDates - 1])); + + return limitedDates; } - return null; }; - const ChartData = getChartData(); + return ( -
    - + ({ + index, + x: item.currentDate, + y: item.pending, + color: "#3F76FF", + })), + enableArea: true, + }, + { + id: "ideal", + color: "#a9bbd0", + fill: "transparent", + data: + chartData.length > 0 + ? [ + { + x: chartData[0].currentDate, + y: totalIssues, + }, + { + x: chartData[chartData.length - 1].currentDate, + y: 0, + }, + ] + : [], + }, + ]} + layers={["grid", "markers", "areas", DashedLine, "slices", "points", "axes", "legends"]} + axisBottom={{ + tickValues: generateXAxisTickValues(), }} - > - - - - - - - - - - } /> - - - + enablePoints={false} + enableArea + colors={(datum) => datum.color ?? "#3F76FF"} + customYAxisTickValues={[0, totalIssues]} + gridXValues={chartData.map((item, index) => (index % 2 === 0 ? item.currentDate : ""))} + theme={{ + background: "transparent", + axis: { + domain: { + line: { + stroke: "rgb(var(--color-border))", + strokeWidth: 1, + }, + }, + }, + }} + />
    ); }; diff --git a/apps/app/components/core/sidebar/sidebar-progress-stats.tsx b/apps/app/components/core/sidebar/sidebar-progress-stats.tsx index 140a49aab..b90f4e3ee 100644 --- a/apps/app/components/core/sidebar/sidebar-progress-stats.tsx +++ b/apps/app/components/core/sidebar/sidebar-progress-stats.tsx @@ -1,15 +1,7 @@ import React from "react"; -import Image from "next/image"; -import { useRouter } from "next/router"; - -import useSWR from "swr"; - // headless ui import { Tab } from "@headlessui/react"; -// services -import issuesServices from "services/issues.service"; -import projectService from "services/project.service"; // hooks import useLocalStorage from "hooks/use-local-storage"; import useIssuesView from "hooks/use-issues-view"; @@ -17,61 +9,43 @@ import useIssuesView from "hooks/use-issues-view"; import { SingleProgressStats } from "components/core"; // ui import { Avatar } from "components/ui"; -// icons -import User from "public/user.png"; // types -import { IIssue, IIssueLabels, IModule, UserAuth } from "types"; -// fetch-keys -import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys"; +import { + IModule, + TAssigneesDistribution, + TCompletionChartDistribution, + TLabelsDistribution, +} from "types"; +// constants +import { STATE_GROUP_COLORS } from "constants/state"; // types type Props = { - groupedIssues: any; - issues: IIssue[]; + distribution: { + assignees: TAssigneesDistribution[]; + completion_chart: TCompletionChartDistribution; + labels: TLabelsDistribution[]; + }; + groupedIssues: { + [key: string]: number; + }; + totalIssues: number; module?: IModule; - userAuth?: UserAuth; roundedTab?: boolean; noBackground?: boolean; }; -const stateGroupColours: { - [key: string]: string; -} = { - backlog: "#3f76ff", - unstarted: "#ff9e9e", - started: "#d687ff", - cancelled: "#ff5353", - completed: "#096e8d", -}; - export const SidebarProgressStats: React.FC = ({ + distribution, groupedIssues, - issues, + totalIssues, module, - userAuth, roundedTab, noBackground, }) => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - const { filters, setFilters } = useIssuesView(); const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees"); - const { data: issueLabels } = useSWR( - workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, - workspaceSlug && projectId - ? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId as string) - : null - ); - - const { data: members } = useSWR( - workspaceSlug && projectId ? PROJECT_MEMBERS(workspaceSlug as string) : null, - workspaceSlug && projectId - ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) - : null - ); - const currentValue = (tab: string | null) => { switch (tab) { case "Assignees": @@ -85,6 +59,7 @@ export const SidebarProgressStats: React.FC = ({ return 0; } }; + return ( = ({ - - {members?.map((member, index) => { - const totalArray = issues?.filter((i) => i?.assignees?.includes(member.member.id)); - const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); - - if (totalArray.length > 0) { - return ( - - - {member.member.first_name} - - } - completed={completeArray.length} - total={totalArray.length} - onClick={() => { - if (filters?.assignees?.includes(member.member.id)) - setFilters({ - assignees: filters?.assignees?.filter((a) => a !== member.member.id), - }); - else - setFilters({ assignees: [...(filters?.assignees ?? []), member.member.id] }); - }} - selected={filters?.assignees?.includes(member.member.id)} - /> - ); - } - })} - {issues?.filter((i) => i?.assignees?.length === 0).length > 0 ? ( - -
    - User -
    - No assignee - - } - completed={ - issues?.filter( - (i) => i?.state_detail.group === "completed" && i.assignees?.length === 0 - ).length - } - total={issues?.filter((i) => i?.assignees?.length === 0).length} - /> - ) : ( - "" - )} -
    - {issueLabels?.map((label, index) => { - const totalArray = issues?.filter((i) => i?.labels?.includes(label.id)); - const completeArray = totalArray?.filter((i) => i?.state_detail.group === "completed"); - - if (totalArray.length > 0) { + {distribution.assignees.map((assignee, index) => { + if (assignee.assignee_id) return ( - - {label?.name} + {assignee.first_name}
    } - completed={completeArray.length} - total={totalArray.length} + completed={assignee.completed_issues} + total={assignee.total_issues} onClick={() => { - if (filters.labels?.includes(label.id)) + if (filters?.assignees?.includes(assignee.assignee_id ?? "")) setFilters({ - labels: filters?.labels?.filter((l) => l !== label.id), + assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id), + }); + else + setFilters({ + assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""], }); - else setFilters({ labels: [...(filters?.labels ?? []), label.id] }); }} - selected={filters?.labels?.includes(label.id)} + selected={filters?.assignees?.includes(assignee.assignee_id ?? "")} + /> + ); + else + return ( + +
    + User +
    + No assignee +
    + } + completed={assignee.completed_issues} + total={assignee.total_issues} /> ); - } })} - + + {distribution.labels.map((label, index) => ( + + + {label.label_name ?? "No labels"} +
    + } + completed={label.completed_issues} + total={label.total_issues} + onClick={() => { + if (filters.labels?.includes(label.label_id ?? "")) + setFilters({ + labels: filters?.labels?.filter((l) => l !== label.label_id), + }); + else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] }); + }} + selected={filters?.labels?.includes(label.label_id ?? "")} + /> + ))} + + {Object.keys(groupedIssues).map((group, index) => ( = ({ {group}
    } completed={groupedIssues[group]} - total={issues.length} + total={totalIssues} /> ))} diff --git a/apps/app/components/core/sidebar/single-progress-stats.tsx b/apps/app/components/core/sidebar/single-progress-stats.tsx index d8236de9b..7c7f65446 100644 --- a/apps/app/components/core/sidebar/single-progress-stats.tsx +++ b/apps/app/components/core/sidebar/single-progress-stats.tsx @@ -23,10 +23,10 @@ export const SingleProgressStats: React.FC = ({ } ${selected ? "bg-brand-surface-1" : ""}`} onClick={onClick} > -
    {title}
    +
    {title}
    - + @@ -36,8 +36,7 @@ export const SingleProgressStats: React.FC = ({ %
    - of - {total} + of {total}
    ); diff --git a/apps/app/components/core/spreadsheet-view/index.ts b/apps/app/components/core/spreadsheet-view/index.ts new file mode 100644 index 000000000..7729d5e93 --- /dev/null +++ b/apps/app/components/core/spreadsheet-view/index.ts @@ -0,0 +1,4 @@ +export * from "./spreadsheet-view"; +export * from "./single-issue"; +export * from "./spreadsheet-columns"; +export * from "./spreadsheet-issues"; diff --git a/apps/app/components/core/spreadsheet-view/single-issue.tsx b/apps/app/components/core/spreadsheet-view/single-issue.tsx new file mode 100644 index 000000000..bae89d8bb --- /dev/null +++ b/apps/app/components/core/spreadsheet-view/single-issue.tsx @@ -0,0 +1,271 @@ +import React, { useCallback } from "react"; + +import Link from "next/link"; +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// components +import { + ViewAssigneeSelect, + ViewDueDateSelect, + ViewEstimateSelect, + ViewPrioritySelect, + ViewStateSelect, +} from "components/issues"; +// icons +import { CustomMenu, Icon } from "components/ui"; +import { LinkIcon, PencilIcon, TrashIcon, XMarkIcon } from "@heroicons/react/24/outline"; +// hooks +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; +import useToast from "hooks/use-toast"; +// services +import issuesService from "services/issues.service"; +// constant +import { + CYCLE_ISSUES_WITH_PARAMS, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST_WITH_PARAMS, + VIEW_ISSUES, +} from "constants/fetch-keys"; +// types +import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; +// helper +import { copyTextToClipboard } from "helpers/string.helper"; + +type Props = { + issue: IIssue; + expanded: boolean; + handleToggleExpand: (issueId: string) => void; + properties: Properties; + handleEditIssue: (issue: IIssue) => void; + handleDeleteIssue: (issue: IIssue) => void; + gridTemplateColumns: string; + user: ICurrentUserResponse | undefined; + userAuth: UserAuth; + nestingLevel: number; +}; + +export const SingleSpreadsheetIssue: React.FC = ({ + issue, + expanded, + handleToggleExpand, + properties, + handleEditIssue, + handleDeleteIssue, + gridTemplateColumns, + user, + userAuth, + nestingLevel, +}) => { + const router = useRouter(); + + const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + + const { params } = useSpreadsheetIssuesView(); + + const { setToastAlert } = useToast(); + + const partialUpdateIssue = useCallback( + (formData: Partial, issueId: string) => { + if (!workspaceSlug || !projectId) return; + + const fetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) + : viewId + ? VIEW_ISSUES(viewId.toString(), params) + : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params); + + mutate( + fetchKey, + (prevData) => + (prevData ?? []).map((p) => { + if (p.id === issueId) { + return { + ...p, + ...formData, + }; + } + return p; + }), + false + ); + + issuesService + .patchIssue(workspaceSlug as string, projectId as string, issueId as string, formData, user) + .then(() => { + mutate(fetchKey); + }) + .catch((error) => { + console.log(error); + }); + }, + [workspaceSlug, projectId, cycleId, moduleId, viewId, params, user] + ); + + const handleCopyText = () => { + const originURL = + typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + copyTextToClipboard( + `${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}` + ).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Issue link copied to clipboard.", + }); + }); + }; + + const paddingLeft = `${nestingLevel * 68}px`; + + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + + return ( +
    +
    + +
    + {properties.key && ( + + {issue.project_detail?.identifier}-{issue.sequence_id} + + )} +
    + +
    + {issue.sub_issues_count > 0 && ( + + )} +
    +
    + + + {issue.name} + + +
    + {properties.state && ( +
    + +
    + )} + {properties.priority && ( +
    + +
    + )} + {properties.assignee && ( +
    + +
    + )} + {properties.labels ? ( + issue.label_details.length > 0 ? ( +
    + {issue.label_details.slice(0, 4).map((label, index) => ( +
    + +
    + ))} + {issue.label_details.length > 4 ? +{issue.label_details.length - 4} : null} +
    + ) : ( +
    + No Labels +
    + ) + ) : ( + "" + )} + {properties.due_date && ( +
    + +
    + )} + {properties.estimate && ( +
    + +
    + )} +
    + {!isNotAllowed && ( + + handleEditIssue(issue)}> +
    + + Edit issue +
    +
    + handleDeleteIssue(issue)}> +
    + + Delete issue +
    +
    + +
    + + Copy issue link +
    +
    +
    + )} +
    +
    + ); +}; diff --git a/apps/app/components/core/spreadsheet-view/spreadsheet-columns.tsx b/apps/app/components/core/spreadsheet-view/spreadsheet-columns.tsx new file mode 100644 index 000000000..85d05a288 --- /dev/null +++ b/apps/app/components/core/spreadsheet-view/spreadsheet-columns.tsx @@ -0,0 +1,277 @@ +import React from "react"; +// hooks +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; +import useLocalStorage from "hooks/use-local-storage"; +// component +import { CustomMenu, Icon } from "components/ui"; +// icon +import { CheckIcon, ChevronDownIcon } from "@heroicons/react/24/outline"; +// types +import { TIssueOrderByOptions } from "types"; + +type Props = { + columnData: any; + gridTemplateColumns: string; +}; + +export const SpreadsheetColumns: React.FC = ({ columnData, gridTemplateColumns }) => { + const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( + "spreadsheetViewSorting", + "" + ); + const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } = + useLocalStorage("spreadsheetViewActiveSortingProperty", ""); + + const { orderBy, setOrderBy } = useSpreadsheetIssuesView(); + + const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => { + setOrderBy(order); + setSelectedMenuItem(`${order}_${itemKey}`); + setActiveSortingProperty(order === "-created_at" ? "" : itemKey); + }; + + return ( +
    + {columnData.map((col: any) => { + if (col.isActive) { + return ( +
    + {col.propertyName === "title" ? ( +
    + {col.colName} +
    + ) : ( + + {activeSortingProperty === col.propertyName && ( +
    + +
    + )} + + {col.icon ? ( +
    + } + menuItemsWhiteBg + width="xl" + > + { + handleOrderBy(col.ascendingOrder, col.propertyName); + }} + > +
    +
    + {col.propertyName === "assignee" || col.propertyName === "labels" ? ( + <> + + + + + A + + Z + + ) : col.propertyName === "due_date" ? ( + <> + + + + + New + + Old + + ) : ( + <> + + + + + First + + Last + + )} +
    + + +
    +
    + { + handleOrderBy(col.descendingOrder, col.propertyName); + }} + > +
    +
    + {col.propertyName === "assignee" || col.propertyName === "labels" ? ( + <> + + + + + Z + + A + + ) : col.propertyName === "due_date" ? ( + <> + + + + + Old + + New + + ) : ( + <> + + + + + Last + + First + + )} +
    + + +
    +
    + {selectedMenuItem && + selectedMenuItem !== "" && + orderBy !== "-created_at" && + selectedMenuItem.includes(col.propertyName) && ( + { + handleOrderBy("-created_at", col.propertyName); + }} + > +
    +
    + + + + + Clear sorting +
    +
    +
    + )} + + )} +
    + ); + } + })} + + ); +}; diff --git a/apps/app/components/core/spreadsheet-view/spreadsheet-issues.tsx b/apps/app/components/core/spreadsheet-view/spreadsheet-issues.tsx new file mode 100644 index 000000000..0edfbceb1 --- /dev/null +++ b/apps/app/components/core/spreadsheet-view/spreadsheet-issues.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; + +// components +import { SingleSpreadsheetIssue } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; + +type Props = { + key: string; + issue: IIssue; + expandedIssues: string[]; + setExpandedIssues: React.Dispatch>; + properties: Properties; + handleEditIssue: (issue: IIssue) => void; + handleDeleteIssue: (issue: IIssue) => void; + gridTemplateColumns: string; + user: ICurrentUserResponse | undefined; + userAuth: UserAuth; + nestingLevel?: number; +}; + +export const SpreadsheetIssues: React.FC = ({ + key, + issue, + expandedIssues, + setExpandedIssues, + gridTemplateColumns, + properties, + handleEditIssue, + handleDeleteIssue, + user, + userAuth, + nestingLevel = 0, +}) => { + const handleToggleExpand = (issueId: string) => { + setExpandedIssues((prevState) => { + const newArray = [...prevState]; + const index = newArray.indexOf(issueId); + if (index > -1) { + newArray.splice(index, 1); + } else { + newArray.push(issueId); + } + return newArray; + }); + }; + + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.id, isExpanded); + + return ( +
    + + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue, subIndex: number) => ( + + ))} +
    + ); +}; diff --git a/apps/app/components/core/spreadsheet-view/spreadsheet-view.tsx b/apps/app/components/core/spreadsheet-view/spreadsheet-view.tsx new file mode 100644 index 000000000..c47ddd805 --- /dev/null +++ b/apps/app/components/core/spreadsheet-view/spreadsheet-view.tsx @@ -0,0 +1,94 @@ +import React, { useState } from "react"; + +// next +import { useRouter } from "next/router"; + +// components +import { SpreadsheetColumns, SpreadsheetIssues } from "components/core"; +import { Icon, Spinner } from "components/ui"; +// hooks +import useIssuesProperties from "hooks/use-issue-properties"; +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; +// types +import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; +// constants +import { SPREADSHEET_COLUMN } from "constants/spreadsheet"; +// icon +import { PlusIcon } from "@heroicons/react/24/outline"; + +type Props = { + handleEditIssue: (issue: IIssue) => void; + handleDeleteIssue: (issue: IIssue) => void; + user: ICurrentUserResponse | undefined; + userAuth: UserAuth; +}; + +export const SpreadsheetView: React.FC = ({ + handleEditIssue, + handleDeleteIssue, + user, + userAuth, +}) => { + const [expandedIssues, setExpandedIssues] = useState([]); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { spreadsheetIssues } = useSpreadsheetIssuesView(); + + const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); + + const columnData = SPREADSHEET_COLUMN.map((column) => ({ + ...column, + isActive: properties + ? column.propertyName === "labels" + ? properties[column.propertyName as keyof Properties] + : column.propertyName === "title" + ? true + : properties[column.propertyName as keyof Properties] + : false, + })); + + const gridTemplateColumns = columnData + .filter((column) => column.isActive) + .map((column) => column.colSize) + .join(" "); + + return ( +
    +
    + +
    + {spreadsheetIssues ? ( +
    + {spreadsheetIssues.map((issue: IIssue, index) => ( + + ))} + +
    + ) : ( + + )} +
    + ); +}; diff --git a/apps/app/components/cycles/active-cycle-details.tsx b/apps/app/components/cycles/active-cycle-details.tsx index 21f37e5a6..b15dbd3ca 100644 --- a/apps/app/components/cycles/active-cycle-details.tsx +++ b/apps/app/components/cycles/active-cycle-details.tsx @@ -10,7 +10,7 @@ import cyclesService from "services/cycles.service"; // hooks import useToast from "hooks/use-toast"; // ui -import { LinearProgressIndicator, Tooltip } from "components/ui"; +import { LinearProgressIndicator, Loader, Tooltip } from "components/ui"; import { AssigneesList } from "components/ui/avatar"; import { SingleProgressStats } from "components/core"; // components @@ -43,10 +43,6 @@ import { ICycle, IIssue } from "types"; // fetch-keys import { CURRENT_CYCLE_LIST, CYCLES_LIST, CYCLE_ISSUES_WITH_PARAMS } from "constants/fetch-keys"; -type TSingleStatProps = { - cycle: ICycle; -}; - const stateGroups = [ { key: "backlog_issues", @@ -75,12 +71,43 @@ const stateGroups = [ }, ]; -export const ActiveCycleDetails: React.FC = ({ cycle }) => { +export const ActiveCycleDetails: React.FC = () => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; const { setToastAlert } = useToast(); + const { data: currentCycle } = useSWR( + workspaceSlug && projectId ? CURRENT_CYCLE_LIST(projectId as string) : null, + workspaceSlug && projectId + ? () => + cyclesService.getCyclesWithParams(workspaceSlug as string, projectId as string, "current") + : null + ); + const cycle = currentCycle ? currentCycle[0] : null; + + const { data: issues } = useSWR( + workspaceSlug && projectId && cycle?.id + ? CYCLE_ISSUES_WITH_PARAMS(cycle?.id, { priority: "urgent,high" }) + : null, + workspaceSlug && projectId && cycle?.id + ? () => + cyclesService.getCycleIssuesWithParams( + workspaceSlug as string, + projectId as string, + cycle.id, + { priority: "urgent,high" } + ) + : null + ) as { data: IIssue[] | undefined }; + + if (!cycle) + return ( +
    +

    No active cycle is present.

    +
    + ); + const endDate = new Date(cycle.end_date ?? ""); const startDate = new Date(cycle.start_date ?? ""); @@ -164,21 +191,6 @@ export const ActiveCycleDetails: React.FC = ({ cycle }) => { }); }; - const { data: issues } = useSWR( - workspaceSlug && projectId && cycle.id - ? CYCLE_ISSUES_WITH_PARAMS(cycle.id, { priority: "high" }) - : null, - workspaceSlug && projectId && cycle.id - ? () => - cyclesService.getCycleIssuesWithParams( - workspaceSlug as string, - projectId as string, - cycle.id, - { priority: "high" } - ) - : null - ) as { data: IIssue[] }; - const progressIndicatorData = stateGroups.map((group, index) => ({ id: index, name: group.title, @@ -193,7 +205,7 @@ export const ActiveCycleDetails: React.FC = ({ cycle }) => {
    -
    High Priority Issues
    - -
    - {issues - ?.filter((issue) => issue.priority === "urgent" || issue.priority === "high") - .map((issue) => ( -
    -
    -
    +
    High Priority Issues
    +
    + {issues ? ( + issues.length > 0 ? ( + issues.map((issue) => ( +
    +
    +
    + + + {issue.project_detail?.identifier}-{issue.sequence_id} + + +
    - - {issue.project_detail?.identifier}-{issue.sequence_id} + + {truncateText(issue.name, 30)}
    - - - {truncateText(issue.name, 30)} - - -
    - -
    -
    - {getPriorityIcon(issue.priority, "text-sm")} -
    - {issue.label_details.length > 0 ? ( -
    - {issue.label_details.map((label) => ( - - - {label.name} - - ))} +
    +
    + {getPriorityIcon(issue.priority, "text-sm")}
    - ) : ( - "" - )} -
    - {issue.assignees && - issue.assignees.length > 0 && - Array.isArray(issue.assignees) ? ( -
    - + {issue.label_details.length > 0 ? ( +
    + {issue.label_details.map((label) => ( + + + {label.name} + + ))}
    ) : ( "" )} +
    + {issue.assignees && + issue.assignees.length > 0 && + Array.isArray(issue.assignees) ? ( +
    + +
    + ) : ( + "" + )} +
    + )) + ) : ( +
    + No issues present in the cycle.
    - ))} + ) + ) : ( + + + + + + )}
    -
    -
    -
    - issue?.state_detail?.group === "completed" && - (issue?.priority === "urgent" || issue?.priority === "high") - )?.length / - issues?.filter( - (issue) => issue?.priority === "urgent" || issue?.priority === "high" - )?.length) * - 100 ?? 0 - }%`, - }} - /> + {issues && issues.length > 0 && ( +
    +
    +
    issue?.state_detail?.group === "completed") + ?.length / + issues.length) * + 100 ?? 0 + }%`, + }} + /> +
    +
    + {issues?.filter((issue) => issue?.state_detail?.group === "completed")?.length} of{" "} + {issues?.length} +
    -
    - { - issues?.filter( - (issue) => - issue?.state_detail?.group === "completed" && - (issue?.priority === "urgent" || issue?.priority === "high") - )?.length - }{" "} - of{" "} - { - issues?.filter( - (issue) => issue?.priority === "urgent" || issue?.priority === "high" - )?.length - } -
    -
    + )}
    - + Ideal
    - + Current
    @@ -532,11 +534,10 @@ export const ActiveCycleDetails: React.FC = ({ cycle }) => {
    diff --git a/apps/app/components/cycles/active-cycle-stats.tsx b/apps/app/components/cycles/active-cycle-stats.tsx index bc04a4bd9..30b69ffaf 100644 --- a/apps/app/components/cycles/active-cycle-stats.tsx +++ b/apps/app/components/cycles/active-cycle-stats.tsx @@ -1,14 +1,7 @@ -import React from "react"; - -import { useRouter } from "next/router"; - -import useSWR from "swr"; +import React, { Fragment } from "react"; // headless ui import { Tab } from "@headlessui/react"; -// services -import issuesServices from "services/issues.service"; -import projectService from "services/project.service"; // hooks import useLocalStorage from "hooks/use-local-storage"; // components @@ -16,34 +9,15 @@ import { SingleProgressStats } from "components/core"; // ui import { Avatar } from "components/ui"; // types -import { IIssue, IIssueLabels } from "types"; -// fetch-keys -import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys"; +import { ICycle } from "types"; // types type Props = { - issues: IIssue[]; + cycle: ICycle; }; -export const ActiveCycleProgressStats: React.FC = ({ issues }) => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - +export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees"); - const { data: issueLabels } = useSWR( - workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, - workspaceSlug && projectId - ? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId as string) - : null - ); - - const { data: members } = useSWR( - workspaceSlug && projectId ? PROJECT_MEMBERS(workspaceSlug as string) : null, - workspaceSlug && projectId - ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) - : null - ); - const currentValue = (tab: string | null) => { switch (tab) { case "Assignees": @@ -55,8 +29,10 @@ export const ActiveCycleProgressStats: React.FC = ({ issues }) => { return 0; } }; + return ( { switch (i) { @@ -93,90 +69,87 @@ export const ActiveCycleProgressStats: React.FC = ({ issues }) => { Labels - - - {members?.map((member, index) => { - const totalArray = issues?.filter((i) => i?.assignees?.includes(member.member.id)); - const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); - - if (totalArray.length > 0) { - return ( - - - {member.member.first_name} -
    - } - completed={completeArray.length} - total={totalArray.length} - /> - ); - } - })} - {issues?.filter((i) => i?.assignees?.length === 0).length > 0 ? ( - -
    - User 0 ? ( + + + {cycle.distribution.assignees.map((assignee, index) => { + if (assignee.assignee_id) + return ( + + + {assignee.first_name} +
    + } + completed={assignee.completed_issues} + total={assignee.total_issues} + /> + ); + else + return ( + +
    + User +
    + No assignee +
    + } + completed={assignee.completed_issues} + total={assignee.total_issues} + /> + ); + })} + + + {cycle.distribution.labels.map((label, index) => ( + + + {label.label_name ?? "No labels"}
    - No assignee -
    - } - completed={ - issues?.filter( - (i) => i?.state_detail.group === "completed" && i.assignees?.length === 0 - ).length - } - total={issues?.filter((i) => i?.assignees?.length === 0).length} - /> - ) : ( - "" - )} - - - {issueLabels?.map((label, index) => { - const totalArray = issues?.filter((i) => i?.labels?.includes(label.id)); - const completeArray = totalArray?.filter((i) => i?.state_detail.group === "completed"); - - if (totalArray.length > 0) { - return ( - - - {label?.name} -
    - } - completed={completeArray.length} - total={totalArray.length} - /> - ); - } - })} - - + } + completed={label.completed_issues} + total={label.total_issues} + /> + ))} + + + ) : ( +
    + No issues present in the cycle. +
    + )} ); }; diff --git a/apps/app/components/cycles/delete-cycle-modal.tsx b/apps/app/components/cycles/delete-cycle-modal.tsx index 3f2de2913..d60e3ddce 100644 --- a/apps/app/components/cycles/delete-cycle-modal.tsx +++ b/apps/app/components/cycles/delete-cycle-modal.tsx @@ -143,7 +143,7 @@ export const DeleteCycleModal: React.FC = ({

    Are you sure you want to delete cycle-{" "} - + {data?.name} ? All of the data related to the cycle will be permanently removed. This diff --git a/apps/app/components/cycles/sidebar.tsx b/apps/app/components/cycles/sidebar.tsx index 03747fc3b..d61bd1943 100644 --- a/apps/app/components/cycles/sidebar.tsx +++ b/apps/app/components/cycles/sidebar.tsx @@ -2,11 +2,22 @@ import React, { useEffect, useState } from "react"; import { useRouter } from "next/router"; -import useSWR, { mutate } from "swr"; +import { mutate } from "swr"; // react-hook-form import { useForm } from "react-hook-form"; +// headless ui import { Disclosure, Popover, Transition } from "@headlessui/react"; +// services +import cyclesService from "services/cycles.service"; +// hooks +import useToast from "hooks/use-toast"; +// components +import { SidebarProgressStats } from "components/core"; +import ProgressChart from "components/core/sidebar/progress-chart"; +import { DeleteCycleModal } from "components/cycles"; +// ui +import { CustomMenu, CustomRangeDatePicker, Loader, ProgressBar } from "components/ui"; // icons import { CalendarDaysIcon, @@ -18,17 +29,6 @@ import { DocumentIcon, LinkIcon, } from "@heroicons/react/24/outline"; -// ui -import { CustomMenu, CustomRangeDatePicker, Loader, ProgressBar } from "components/ui"; -// hooks -import useToast from "hooks/use-toast"; -// services -import cyclesService from "services/cycles.service"; -// components -import { SidebarProgressStats } from "components/core"; -import ProgressChart from "components/core/sidebar/progress-chart"; -import { DeleteCycleModal } from "components/cycles"; -// icons import { ExclamationIcon } from "components/icons"; // helpers import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helper"; @@ -38,9 +38,9 @@ import { renderShortDate, } from "helpers/date-time.helper"; // types -import { ICurrentUserResponse, ICycle, IIssue } from "types"; +import { ICurrentUserResponse, ICycle } from "types"; // fetch-keys -import { CYCLE_DETAILS, CYCLE_ISSUES } from "constants/fetch-keys"; +import { CYCLE_DETAILS } from "constants/fetch-keys"; type Props = { cycle: ICycle | undefined; @@ -69,18 +69,6 @@ export const CycleDetailsSidebar: React.FC = ({ end_date: new Date().toString(), }; - const { data: issues } = useSWR( - workspaceSlug && projectId && cycleId ? CYCLE_ISSUES(cycleId as string) : null, - workspaceSlug && projectId && cycleId - ? () => - cyclesService.getCycleIssues( - workspaceSlug as string, - projectId as string, - cycleId as string - ) - : null - ); - const { setValue, reset, watch } = useForm({ defaultValues, }); @@ -485,7 +473,6 @@ export const CycleDetailsSidebar: React.FC = ({

    -
    {({ open }) => ( @@ -552,11 +539,12 @@ export const CycleDetailsSidebar: React.FC = ({
    -
    +
    @@ -569,7 +557,6 @@ export const CycleDetailsSidebar: React.FC = ({ )}
    -
    {({ open }) => ( @@ -604,9 +591,9 @@ export const CycleDetailsSidebar: React.FC = ({ {cycle.total_issues > 0 ? ( -
    +
    = ({ completed: cycle.completed_issues, cancelled: cycle.cancelled_issues, }} + totalIssues={cycle.total_issues} />
    ) : ( diff --git a/apps/app/components/cycles/single-cycle-card.tsx b/apps/app/components/cycles/single-cycle-card.tsx index c6a6365b0..c00429a43 100644 --- a/apps/app/components/cycles/single-cycle-card.tsx +++ b/apps/app/components/cycles/single-cycle-card.tsx @@ -150,8 +150,8 @@ export const SingleCycleCard: React.FC = ({ }`} /> - -

    + +

    {truncateText(cycle.name, 15)}

    diff --git a/apps/app/components/cycles/single-cycle-list.tsx b/apps/app/components/cycles/single-cycle-list.tsx index f8a1fbf28..fa725b83a 100644 --- a/apps/app/components/cycles/single-cycle-list.tsx +++ b/apps/app/components/cycles/single-cycle-list.tsx @@ -173,8 +173,12 @@ export const SingleCycleList: React.FC = ({ }`} />
    - -

    + +

    {truncateText(cycle.name, 70)}

    @@ -282,12 +286,18 @@ export const SingleCycleList: React.FC = ({ > {cycleStatus === "current" ? ( - - - {Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} % - + {cycle.total_issues > 0 ? ( + <> + + + {Math.floor((cycle.completed_issues / cycle.total_issues) * 100)} % + + + ) : ( + No issues present + )} ) : cycleStatus === "upcoming" ? ( diff --git a/apps/app/components/estimates/delete-estimate-modal.tsx b/apps/app/components/estimates/delete-estimate-modal.tsx index c456ceab6..5a4f9ccfa 100644 --- a/apps/app/components/estimates/delete-estimate-modal.tsx +++ b/apps/app/components/estimates/delete-estimate-modal.tsx @@ -74,9 +74,9 @@ export const DeleteEstimateModal: React.FC = ({

    -

    +

    Are you sure you want to delete estimate-{" "} - {data.name} + {data.name} {""}? All of the data related to the estiamte will be permanently removed. This action cannot be undone.

    diff --git a/apps/app/components/icons/inbox-icon.tsx b/apps/app/components/icons/inbox-icon.tsx new file mode 100644 index 000000000..6013c8861 --- /dev/null +++ b/apps/app/components/icons/inbox-icon.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const InboxIcon: React.FC = ({ + width = "24", + height = "24", + color = "#858E96", + className, +}) => ( + + + +); diff --git a/apps/app/components/icons/index.ts b/apps/app/components/icons/index.ts index 968a14a93..07ecafd24 100644 --- a/apps/app/components/icons/index.ts +++ b/apps/app/components/icons/index.ts @@ -78,3 +78,5 @@ export * from "./video-file-icon"; export * from "./audio-file-icon"; export * from "./command-icon"; export * from "./color-picker-icon"; +export * from "./inbox-icon"; +export * from "./stacked-layers-horizontal-icon"; diff --git a/apps/app/components/icons/stacked-layers-horizontal-icon.tsx b/apps/app/components/icons/stacked-layers-horizontal-icon.tsx new file mode 100644 index 000000000..2d5f75ba9 --- /dev/null +++ b/apps/app/components/icons/stacked-layers-horizontal-icon.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +import type { Props } from "./types"; + +export const StackedLayersHorizontalIcon: React.FC = ({ + width = "24", + height = "24", + className, + color = "#858e96", +}) => ( + + + +); diff --git a/apps/app/components/inbox/accept-issue-modal.tsx b/apps/app/components/inbox/accept-issue-modal.tsx new file mode 100644 index 000000000..6427c562c --- /dev/null +++ b/apps/app/components/inbox/accept-issue-modal.tsx @@ -0,0 +1,92 @@ +import React, { useState } from "react"; + +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// icons +import { CheckCircleIcon } from "@heroicons/react/24/outline"; +// ui +import { SecondaryButton, PrimaryButton } from "components/ui"; +// types +import type { IInboxIssue } from "types"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + data: IInboxIssue | undefined; + onSubmit: () => Promise; +}; + +export const AcceptIssueModal: React.FC = ({ isOpen, handleClose, data, onSubmit }) => { + const [isAccepting, setIsAccepting] = useState(false); + + const onClose = () => { + setIsAccepting(false); + handleClose(); + }; + + const handleAccept = () => { + setIsAccepting(true); + + onSubmit().finally(() => setIsAccepting(false)); + }; + + return ( + + + +
    + + +
    +
    + + +
    +
    + + + +

    Accept Issue

    +
    +
    + +

    + Are you sure you want to accept issue{" "} + + {data?.project_detail?.identifier}-{data?.sequence_id} + + {""}? Once accepted, this issue will be added to the project issues list. +

    +
    +
    + Cancel + + {isAccepting ? "Accepting..." : "Accept Issue"} + +
    +
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/app/components/inbox/decline-issue-modal.tsx b/apps/app/components/inbox/decline-issue-modal.tsx new file mode 100644 index 000000000..11f1db5de --- /dev/null +++ b/apps/app/components/inbox/decline-issue-modal.tsx @@ -0,0 +1,95 @@ +import React, { useState } from "react"; + +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// icons +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; +// ui +import { SecondaryButton, DangerButton } from "components/ui"; +// types +import type { IInboxIssue } from "types"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + data: IInboxIssue | undefined; + onSubmit: () => Promise; +}; + +export const DeclineIssueModal: React.FC = ({ isOpen, handleClose, data, onSubmit }) => { + const [isDeclining, setIsDeclining] = useState(false); + + const onClose = () => { + setIsDeclining(false); + handleClose(); + }; + + const handleDecline = () => { + setIsDeclining(true); + + onSubmit().finally(() => setIsDeclining(false)); + }; + + return ( + + + +
    + + +
    +
    + + +
    +
    + + + +

    Decline Issue

    +
    +
    + +

    + Are you sure you want to decline issue{" "} + + {data?.project_detail?.identifier}-{data?.sequence_id} + + {""}? This action cannot be undone. +

    +
    +
    + Cancel + + {isDeclining ? "Declining..." : "Decline Issue"} + +
    +
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/app/components/inbox/delete-issue-modal.tsx b/apps/app/components/inbox/delete-issue-modal.tsx new file mode 100644 index 000000000..f188ff1aa --- /dev/null +++ b/apps/app/components/inbox/delete-issue-modal.tsx @@ -0,0 +1,151 @@ +import React, { useState } from "react"; + +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// services +import inboxServices from "services/inbox.service"; +// hooks +import useToast from "hooks/use-toast"; +import useInboxView from "hooks/use-inbox-view"; +import useUser from "hooks/use-user"; +// icons +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; +// ui +import { SecondaryButton, DangerButton } from "components/ui"; +// types +import type { IInboxIssue } from "types"; +// fetch-keys +import { INBOX_ISSUES } from "constants/fetch-keys"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + data: IInboxIssue | undefined; +}; + +export const DeleteIssueModal: React.FC = ({ isOpen, handleClose, data }) => { + const [isDeleting, setIsDeleting] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId, inboxId } = router.query; + + const { user } = useUser(); + const { setToastAlert } = useToast(); + const { params } = useInboxView(); + + const onClose = () => { + setIsDeleting(false); + handleClose(); + }; + + const handleDelete = () => { + if (!workspaceSlug || !projectId || !inboxId || !data) return; + + setIsDeleting(true); + + inboxServices + .deleteInboxIssue( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString(), + data.bridge_id.toString(), + user + ) + .then(() => { + mutate( + INBOX_ISSUES(inboxId.toString(), params), + (prevData) => (prevData ?? []).filter((i) => i.id !== data.id), + false + ); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue deleted successfully.", + }); + + // remove inboxIssueId from the url + router.push({ + pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, + }); + + onClose(); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be deleted. Please try again.", + }) + ) + .finally(() => setIsDeleting(false)); + }; + + return ( + + + +
    + + +
    +
    + + +
    +
    + + + +

    Delete Issue

    +
    +
    + +

    + Are you sure you want to delete issue{" "} + + {data?.project_detail?.identifier}-{data?.sequence_id} + + {""}? The issue will only be deleted from the inbox and this action cannot be + undone. +

    +
    +
    + Cancel + + {isDeleting ? "Deleting..." : "Delete Issue"} + +
    +
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/app/components/inbox/filters-dropdown.tsx b/apps/app/components/inbox/filters-dropdown.tsx new file mode 100644 index 000000000..1b6af608c --- /dev/null +++ b/apps/app/components/inbox/filters-dropdown.tsx @@ -0,0 +1,81 @@ +// hooks +import useInboxView from "hooks/use-inbox-view"; +// ui +import { MultiLevelDropdown } from "components/ui"; +// icons +import { getPriorityIcon } from "components/icons"; +// constants +import { PRIORITIES } from "constants/project"; +import { INBOX_STATUS } from "constants/inbox"; + +export const FiltersDropdown: React.FC = () => { + const { filters, setFilters, filtersLength } = useInboxView(); + + return ( +
    + { + const key = option.key as keyof typeof filters; + + const valueExists = (filters[key] as any[])?.includes(option.value); + + if (valueExists) { + setFilters({ + [option.key]: ((filters[key] ?? []) as any[])?.filter((val) => val !== option.value), + }); + } else { + setFilters({ + [option.key]: [...((filters[key] ?? []) as any[]), option.value], + }); + } + }} + direction="right" + height="rg" + options={[ + { + id: "priority", + label: "Priority", + value: PRIORITIES, + children: [ + ...PRIORITIES.map((priority) => ({ + id: priority ?? "none", + label: ( +
    + {getPriorityIcon(priority)} {priority ?? "None"} +
    + ), + value: { + key: "priority", + value: priority, + }, + selected: filters?.priority?.includes(priority ?? "none"), + })), + ], + }, + { + id: "inbox_status", + label: "Status", + value: INBOX_STATUS.map((status) => status.value), + children: [ + ...INBOX_STATUS.map((status) => ({ + id: status.key, + label: status.label, + value: { + key: "inbox_status", + value: status.value, + }, + selected: filters?.inbox_status?.includes(status.value), + })), + ], + }, + ]} + /> + {filtersLength > 0 && ( +
    + {filtersLength} +
    + )} +
    + ); +}; diff --git a/apps/app/components/inbox/filters-list.tsx b/apps/app/components/inbox/filters-list.tsx new file mode 100644 index 000000000..264b925a2 --- /dev/null +++ b/apps/app/components/inbox/filters-list.tsx @@ -0,0 +1,126 @@ +// hooks +import useInboxView from "hooks/use-inbox-view"; +// icons +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { getPriorityIcon } from "components/icons"; +// helpers +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +// constants +import { INBOX_STATUS } from "constants/inbox"; + +export const InboxFiltersList = () => { + const { filters, setFilters, clearAllFilters, filtersLength } = useInboxView(); + + if (filtersLength <= 0) return <>; + + return ( +
    + {Object.keys(filters).map((key) => { + const filterKey = key as keyof typeof filters; + + if (filters[filterKey] !== null) + return ( +
    + + {replaceUnderscoreIfSnakeCase(key)}: + + {filters[filterKey] === null || (filters[filterKey]?.length ?? 0) <= 0 ? ( + None + ) : ( +
    + {filterKey === "priority" ? ( +
    + {filters.priority?.map((priority) => ( +
    + {getPriorityIcon(priority)} + +
    + ))} + +
    + ) : filterKey === "inbox_status" ? ( +
    + {filters.inbox_status?.map((status) => ( +
    + {INBOX_STATUS.find((s) => s.value === status)?.label} + +
    + ))} + +
    + ) : ( + (filters[filterKey] as any)?.join(", ") + )} +
    + )} +
    + ); + })} + +
    + ); +}; diff --git a/apps/app/components/inbox/inbox-action-headers.tsx b/apps/app/components/inbox/inbox-action-headers.tsx new file mode 100644 index 000000000..ac144ba0b --- /dev/null +++ b/apps/app/components/inbox/inbox-action-headers.tsx @@ -0,0 +1,295 @@ +import { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// react-datepicker +import DatePicker from "react-datepicker"; +// headless ui +import { Popover } from "@headlessui/react"; +// contexts +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import inboxServices from "services/inbox.service"; +// hooks +import useInboxView from "hooks/use-inbox-view"; +import useUserAuth from "hooks/use-user-auth"; +import useToast from "hooks/use-toast"; +// components +import { + AcceptIssueModal, + DeclineIssueModal, + DeleteIssueModal, + FiltersDropdown, + SelectDuplicateInboxIssueModal, +} from "components/inbox"; +// ui +import { PrimaryButton, SecondaryButton } from "components/ui"; +// icons +import { InboxIcon, StackedLayersHorizontalIcon } from "components/icons"; +import { + ChevronDownIcon, + ChevronUpIcon, + CheckCircleIcon, + ClockIcon, + XCircleIcon, + TrashIcon, +} from "@heroicons/react/24/outline"; +// types +import type { IInboxIssueDetail, TInboxStatus } from "types"; +// fetch-keys +import { INBOX_ISSUE_DETAILS } from "constants/fetch-keys"; + +export const InboxActionHeader = () => { + const [date, setDate] = useState(new Date()); + const [selectDuplicateIssue, setSelectDuplicateIssue] = useState(false); + const [acceptIssueModal, setAcceptIssueModal] = useState(false); + const [declineIssueModal, setDeclineIssueModal] = useState(false); + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + + const router = useRouter(); + const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query; + + const { user } = useUserAuth(); + const { memberRole } = useProjectMyMembership(); + const { issues: inboxIssues, mutate: mutateInboxIssues } = useInboxView(); + const { setToastAlert } = useToast(); + + const markInboxStatus = async (data: TInboxStatus) => { + if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId) return; + + mutate( + INBOX_ISSUE_DETAILS(inboxId as string, inboxIssueId as string), + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + issue_inbox: [{ ...prevData.issue_inbox[0], ...data }], + }; + }, + false + ); + mutateInboxIssues( + (prevData) => + (prevData ?? []).map((i) => + i.bridge_id === inboxIssueId + ? { ...i, issue_inbox: [{ ...i.issue_inbox[0], ...data }] } + : i + ), + false + ); + + await inboxServices + .markInboxStatus( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString(), + inboxIssues?.find((inboxIssue) => inboxIssue.bridge_id === inboxIssueId)?.bridge_id!, + data, + user + ) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Something went wrong while updating inbox status. Please try again.", + }) + ) + .finally(() => { + mutate(INBOX_ISSUE_DETAILS(inboxId as string, inboxIssueId as string)); + mutateInboxIssues(); + }); + }; + + const issue = inboxIssues?.find((issue) => issue.bridge_id === inboxIssueId); + const currentIssueIndex = + inboxIssues?.findIndex((issue) => issue.bridge_id === inboxIssueId) ?? 0; + + useEffect(() => { + if (!issue?.issue_inbox[0].snoozed_till) return; + + setDate(new Date(issue.issue_inbox[0].snoozed_till)); + }, [issue]); + + const issueStatus = issue?.issue_inbox[0].status; + const isAllowed = memberRole.isMember || memberRole.isOwner; + + const today = new Date(); + const tomorrow = new Date(today); + + tomorrow.setDate(today.getDate() + 1); + + return ( + <> + setSelectDuplicateIssue(false)} + value={ + inboxIssues?.find((inboxIssue) => inboxIssue.bridge_id === inboxIssueId)?.issue_inbox[0] + .duplicate_to + } + onSubmit={(dupIssueId: string) => { + markInboxStatus({ + status: 2, + duplicate_to: dupIssueId, + }).finally(() => setSelectDuplicateIssue(false)); + }} + /> + setAcceptIssueModal(false)} + data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)} + onSubmit={async () => { + await markInboxStatus({ + status: 1, + }).finally(() => setAcceptIssueModal(false)); + }} + /> + setDeclineIssueModal(false)} + data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)} + onSubmit={async () => { + await markInboxStatus({ + status: -1, + }).finally(() => setDeclineIssueModal(false)); + }} + /> + setDeleteIssueModal(false)} + data={inboxIssues?.find((i) => i.bridge_id === inboxIssueId)} + /> +
    +
    +
    + +

    Inbox

    +
    + +
    + {inboxIssueId && ( +
    +
    + + +
    + {currentIssueIndex + 1}/{inboxIssues?.length ?? 0} +
    +
    +
    + {isAllowed && (issueStatus === 0 || issueStatus === -2) && ( +
    + + + + + Snooze + + + + {({ close }) => ( +
    + { + if (!val) return; + setDate(val); + }} + dateFormat="dd-MM-yyyy" + minDate={tomorrow} + inline + /> + { + close(); + markInboxStatus({ + status: 0, + snoozed_till: new Date(date), + }); + }} + > + Snooze + +
    + )} +
    +
    +
    + )} + {isAllowed && issueStatus === -2 && ( +
    + setSelectDuplicateIssue(true)} + > + + Mark as duplicate + +
    + )} + {isAllowed && (issueStatus === 0 || issueStatus === -2) && ( +
    + setAcceptIssueModal(true)} + > + + Accept + +
    + )} + {isAllowed && issueStatus === -2 && ( +
    + setDeclineIssueModal(true)} + > + + Decline + +
    + )} + {(isAllowed || user?.id === issue?.created_by) && ( +
    + setDeleteIssueModal(true)} + > + + Delete + +
    + )} +
    +
    + )} +
    + + ); +}; diff --git a/apps/app/components/inbox/inbox-issue-card.tsx b/apps/app/components/inbox/inbox-issue-card.tsx new file mode 100644 index 000000000..072647ae3 --- /dev/null +++ b/apps/app/components/inbox/inbox-issue-card.tsx @@ -0,0 +1,126 @@ +import { useRouter } from "next/router"; +import Link from "next/link"; + +// ui +import { Tooltip } from "components/ui"; +// icons +import { getPriorityIcon } from "components/icons"; +import { + CalendarDaysIcon, + CheckCircleIcon, + ClockIcon, + DocumentDuplicateIcon, + ExclamationTriangleIcon, + XCircleIcon, +} from "@heroicons/react/24/outline"; +// helpers +import { renderShortNumericDateFormat } from "helpers/date-time.helper"; +// types +import type { IInboxIssue } from "types"; +// constants +import { INBOX_STATUS } from "constants/inbox"; + +type Props = { + issue: IInboxIssue; + active: boolean; +}; + +export const InboxIssueCard: React.FC = (props) => { + const { issue, active } = props; + + const router = useRouter(); + const { workspaceSlug, projectId, inboxId } = router.query; + + const issueStatus = issue.issue_inbox[0].status; + + return ( + + +
    +
    +

    + {issue.project_detail?.identifier}-{issue.sequence_id} +

    +
    {issue.name}
    +
    +
    + +
    + {getPriorityIcon( + issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", + "text-sm" + )} +
    +
    + +
    + + {renderShortNumericDateFormat(issue.created_at ?? "")} +
    +
    +
    +
    s.value === issueStatus)?.textColor + }`} + > + {issueStatus === -2 ? ( + <> + + Pending + + ) : issueStatus === -1 ? ( + <> + + Declined + + ) : issueStatus === 0 ? ( + <> + + + {new Date(issue.issue_inbox[0].snoozed_till ?? "") < new Date() + ? "Snoozed date passed" + : "Snoozed"} + + + ) : issueStatus === 1 ? ( + <> + + Accepted + + ) : ( + <> + + Duplicate + + )} +
    +
    +
    + + ); +}; diff --git a/apps/app/components/inbox/inbox-main-content.tsx b/apps/app/components/inbox/inbox-main-content.tsx new file mode 100644 index 000000000..a75c00317 --- /dev/null +++ b/apps/app/components/inbox/inbox-main-content.tsx @@ -0,0 +1,333 @@ +import { useCallback, useEffect } from "react"; + +import Router, { useRouter } from "next/router"; + +import useSWR, { mutate } from "swr"; + +// react hook form +import { useForm } from "react-hook-form"; +// contexts +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import inboxServices from "services/inbox.service"; +// hooks +import useInboxView from "hooks/use-inbox-view"; +import useUserAuth from "hooks/use-user-auth"; +// components +import { + AddComment, + IssueActivitySection, + IssueDescriptionForm, + IssueDetailsSidebar, +} from "components/issues"; +// ui +import { Loader } from "components/ui"; +// icons +import { + ArrowTopRightOnSquareIcon, + CheckCircleIcon, + ClockIcon, + DocumentDuplicateIcon, + ExclamationTriangleIcon, + InboxIcon, + XCircleIcon, +} from "@heroicons/react/24/outline"; +// helpers +import { renderShortNumericDateFormat } from "helpers/date-time.helper"; +// types +import type { IInboxIssue, IIssue } from "types"; +// fetch-keys +import { INBOX_ISSUES, INBOX_ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; +// constants +import { INBOX_STATUS } from "constants/inbox"; + +const defaultValues = { + name: "", + description: "", + description_html: "", + estimate_point: null, + assignees_list: [], + priority: "low", + target_date: new Date().toString(), + labels_list: [], +}; + +export const InboxMainContent: React.FC = () => { + const router = useRouter(); + const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query; + + const { user } = useUserAuth(); + const { memberRole } = useProjectMyMembership(); + const { params, issues: inboxIssues } = useInboxView(); + + const { reset, control, watch } = useForm({ + defaultValues, + }); + + const { data: issueDetails, mutate: mutateIssueDetails } = useSWR( + workspaceSlug && projectId && inboxId && inboxIssueId + ? INBOX_ISSUE_DETAILS(inboxId.toString(), inboxIssueId.toString()) + : null, + workspaceSlug && projectId && inboxId && inboxIssueId + ? () => + inboxServices.getInboxIssueById( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString(), + inboxIssueId.toString() + ) + : null + ); + + const submitChanges = useCallback( + async (formData: Partial) => { + if (!workspaceSlug || !projectId || !inboxIssueId || !inboxId || !issueDetails) return; + + mutateIssueDetails((prevData: any) => { + if (!prevData) return prevData; + + return { + ...prevData, + ...formData, + }; + }, false); + mutate( + INBOX_ISSUES(inboxId.toString(), params), + (prevData) => + (prevData ?? []).map((i) => { + if (i.bridge_id === inboxIssueId) { + return { + ...i, + ...formData, + }; + } + + return i; + }), + false + ); + + const payload = { issue: { ...formData } }; + + await inboxServices + .patchInboxIssue( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString(), + issueDetails.issue_inbox[0].id, + payload, + user + ) + .then(() => { + mutateIssueDetails(); + mutate(INBOX_ISSUES(inboxId.toString(), params)); + mutate(PROJECT_ISSUES_ACTIVITY(issueDetails.id)); + }); + }, + [ + workspaceSlug, + inboxIssueId, + projectId, + mutateIssueDetails, + inboxId, + user, + issueDetails, + params, + ] + ); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!inboxIssues || !inboxIssueId) return; + + const currentIssueIndex = inboxIssues.findIndex((issue) => issue.bridge_id === inboxIssueId); + + switch (e.key) { + case "ArrowUp": + Router.push({ + pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, + query: { + inboxIssueId: + currentIssueIndex === 0 + ? inboxIssues[inboxIssues.length - 1].bridge_id + : inboxIssues[currentIssueIndex - 1].bridge_id, + }, + }); + break; + case "ArrowDown": + Router.push({ + pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, + query: { + inboxIssueId: + currentIssueIndex === inboxIssues.length - 1 + ? inboxIssues[0].bridge_id + : inboxIssues[currentIssueIndex + 1].bridge_id, + }, + }); + break; + default: + break; + } + }, + [workspaceSlug, projectId, inboxIssueId, inboxId, inboxIssues] + ); + + useEffect(() => { + document.addEventListener("keydown", onKeyDown); + + return () => { + document.removeEventListener("keydown", onKeyDown); + }; + }, [onKeyDown]); + + useEffect(() => { + if (!issueDetails || !inboxIssueId) return; + + reset({ + ...issueDetails, + assignees_list: + issueDetails.assignees_list ?? (issueDetails.assignee_details ?? []).map((user) => user.id), + labels_list: issueDetails.labels_list ?? issueDetails.labels, + }); + }, [issueDetails, reset, inboxIssueId]); + + const issueStatus = issueDetails?.issue_inbox[0].status; + const inboxStatusDetails = INBOX_STATUS.find((s) => s.value === issueStatus); + + if (!inboxIssueId) + return ( +
    +
    +
    + + {inboxIssues && inboxIssues.length > 0 ? ( + + {inboxIssues?.length} issues found. Select an issue from the sidebar to view its + details. + + ) : ( + + No issues found. Use{" "} +
    C
    shortcut to + create a new issue +
    + )} +
    +
    +
    + ); + + return ( + <> + {issueDetails ? ( +
    +
    +
    + {issueStatus === -2 ? ( + <> + +

    This issue is still pending.

    + + ) : issueStatus === -1 ? ( + <> + +

    This issue has been declined.

    + + ) : issueStatus === 0 ? ( + <> + + {new Date(issueDetails.issue_inbox[0].snoozed_till ?? "") < new Date() ? ( +

    + This issue was snoozed till{" "} + {renderShortNumericDateFormat(issueDetails.issue_inbox[0].snoozed_till ?? "")} + . +

    + ) : ( +

    + This issue has been snoozed till{" "} + {renderShortNumericDateFormat(issueDetails.issue_inbox[0].snoozed_till ?? "")} + . +

    + )} + + ) : issueStatus === 1 ? ( + <> + +

    This issue has been accepted.

    + + ) : issueStatus === 2 ? ( + <> + +

    + This issue has been marked as a duplicate of + + this issue + + . +

    + + ) : null} +
    +
    + +
    +
    +

    Comments/Activity

    + + +
    +
    + +
    + +
    +
    + ) : ( + +
    + + + + +
    +
    + + + + +
    +
    + )} + + ); +}; diff --git a/apps/app/components/inbox/index.ts b/apps/app/components/inbox/index.ts new file mode 100644 index 000000000..38cea0348 --- /dev/null +++ b/apps/app/components/inbox/index.ts @@ -0,0 +1,10 @@ +export * from "./accept-issue-modal"; +export * from "./decline-issue-modal"; +export * from "./delete-issue-modal"; +export * from "./filters-dropdown"; +export * from "./filters-list"; +export * from "./inbox-action-headers"; +export * from "./inbox-issue-card"; +export * from "./inbox-main-content"; +export * from "./issues-list-sidebar"; +export * from "./select-duplicate"; diff --git a/apps/app/components/inbox/issues-list-sidebar.tsx b/apps/app/components/inbox/issues-list-sidebar.tsx new file mode 100644 index 000000000..6126be117 --- /dev/null +++ b/apps/app/components/inbox/issues-list-sidebar.tsx @@ -0,0 +1,46 @@ +import { useRouter } from "next/router"; + +// hooks +import useInboxView from "hooks/use-inbox-view"; +// components +import { InboxIssueCard, InboxFiltersList } from "components/inbox"; +// ui +import { Loader } from "components/ui"; + +export const IssuesListSidebar = () => { + const router = useRouter(); + const { inboxIssueId } = router.query; + + const { issues: inboxIssues, filtersLength } = useInboxView(); + + return ( +
    + + {inboxIssues ? ( + inboxIssues.length > 0 ? ( +
    + {inboxIssues.map((issue) => ( + + ))} +
    + ) : ( +
    + {filtersLength > 0 && + "No issues found for the selected filters. Try changing the filters."} +
    + ) + ) : ( + + + + + + + )} +
    + ); +}; diff --git a/apps/app/components/inbox/select-duplicate.tsx b/apps/app/components/inbox/select-duplicate.tsx new file mode 100644 index 000000000..74409a57f --- /dev/null +++ b/apps/app/components/inbox/select-duplicate.tsx @@ -0,0 +1,193 @@ +import React, { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// react-hook-form +import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form"; +// headless ui +import { Combobox, Dialog, Transition } from "@headlessui/react"; +// hooks +import useToast from "hooks/use-toast"; +// services +import issuesServices from "services/issues.service"; +// ui +import { PrimaryButton, SecondaryButton } from "components/ui"; +// icons +import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { LayerDiagonalIcon } from "components/icons"; +// fetch-keys +import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; + +type Props = { + isOpen: boolean; + value?: string | null; + onClose: () => void; + onSubmit: (issueId: string) => void; +}; + +export const SelectDuplicateInboxIssueModal: React.FC = (props) => { + const { isOpen, onClose, onSubmit, value } = props; + + const [query, setQuery] = useState(""); + const [selectedItem, setSelectedItem] = useState(""); + + const { setToastAlert } = useToast(); + + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const { data: issues } = useSWR( + workspaceSlug && projectId + ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) + : null, + workspaceSlug && projectId + ? () => + issuesServices + .getIssues(workspaceSlug as string, projectId as string) + .then((res) => res.filter((issue) => issue.id !== issueId)) + : null + ); + + useEffect(() => { + if (!value) { + setSelectedItem(""); + return; + } else setSelectedItem(value); + }, [value]); + + const handleClose = () => { + onClose(); + }; + + const handleSubmit = () => { + if (!selectedItem || selectedItem.length === 0) + return setToastAlert({ + title: "Error", + type: "error", + }); + onSubmit(selectedItem); + handleClose(); + }; + + const filteredIssues = + (query === "" ? issues : issues?.filter((issue) => issue.name.includes(query))) ?? []; + + return ( + setQuery("")} appear> +
    +
    + + +
    + + +
    + + + { + setSelectedItem(value); + }} + > +
    +
    + + + {filteredIssues.length > 0 ? ( +
  • + {query === "" && ( +

    + Select issue +

    + )} +
      + {filteredIssues.map((issue) => ( + + `flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ + active || selected ? "bg-brand-surface-2 text-brand-base" : "" + } ` + } + > +
      + + + { + issues?.find((i) => i.id === issue.id)?.project_detail + ?.identifier + } + -{issue.sequence_id} + + {issue.name} +
      +
      + ))} +
    +
  • + ) : ( +
    + +

    + No issues found. Create a new issue with{" "} +
    C
    . +

    +
    + )} +
    +
    + + {filteredIssues.length > 0 && ( +
    + Cancel + Mark as original +
    + )} +
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/app/components/integration/delete-import-modal.tsx b/apps/app/components/integration/delete-import-modal.tsx index cd0b12a2a..57af3fbfc 100644 --- a/apps/app/components/integration/delete-import-modal.tsx +++ b/apps/app/components/integration/delete-import-modal.tsx @@ -104,7 +104,7 @@ export const DeleteImportModal: React.FC = ({ isOpen, handleClose, data,

    Are you sure you want to delete import from{" "} - + {data?.service} ? All of the data related to the import will be permanently removed. This diff --git a/apps/app/components/issues/activity.tsx b/apps/app/components/issues/activity.tsx index 3b9ecc853..8d708b856 100644 --- a/apps/app/components/issues/activity.tsx +++ b/apps/app/components/issues/activity.tsx @@ -4,6 +4,14 @@ import { useRouter } from "next/router"; import useSWR from "swr"; +// services +import issuesService from "services/issues.service"; +// hooks +import useEstimateOption from "hooks/use-estimate-option"; +// components +import { CommentCard } from "components/issues/comment"; +// ui +import { Loader } from "components/ui"; // icons import { CalendarDaysIcon, @@ -17,20 +25,13 @@ import { UserIcon, } from "@heroicons/react/24/outline"; import { BlockedIcon, BlockerIcon, CyclesIcon, TagIcon, UserGroupIcon } from "components/icons"; -// services -import issuesService from "services/issues.service"; -// components -import { CommentCard } from "components/issues/comment"; -// ui -import { Loader } from "components/ui"; - // helpers import { renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper"; import { addSpaceIfCamelCase } from "helpers/string.helper"; // types import { ICurrentUserResponse, IIssueComment, IIssueLabels } from "types"; +// fetch-keys import { PROJECT_ISSUES_ACTIVITY, PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; -import useEstimateOption from "hooks/use-estimate-option"; const activityDetails: { [key: string]: { @@ -60,7 +61,7 @@ const activityDetails: { }, estimate_point: { message: "set the estimate point to", - icon:

    Are you sure you want to delete issue{" "} - + {data?.project_detail.identifier}-{data?.sequence_id} {""}? All of the data related to the issue will be permanently removed. This diff --git a/apps/app/components/issues/description-form.tsx b/apps/app/components/issues/description-form.tsx index 2a61e85a6..492110a8c 100644 --- a/apps/app/components/issues/description-form.tsx +++ b/apps/app/components/issues/description-form.tsx @@ -4,8 +4,6 @@ import dynamic from "next/dynamic"; // react-hook-form import { Controller, useForm } from "react-hook-form"; -// contexts -import { useProjectMyMembership } from "contexts/project-member.context"; // hooks import useReloadConfirmations from "hooks/use-reload-confirmation"; // components @@ -28,16 +26,23 @@ export interface IssueDescriptionFormValues { } export interface IssueDetailsProps { - issue: IIssue; + issue: { + name: string; + description: string; + description_html: string; + }; handleFormSubmit: (value: IssueDescriptionFormValues) => Promise; + isAllowed: boolean; } -export const IssueDescriptionForm: FC = ({ issue, handleFormSubmit }) => { +export const IssueDescriptionForm: FC = ({ + issue, + handleFormSubmit, + isAllowed, +}) => { const [isSubmitting, setIsSubmitting] = useState(false); const [characterLimit, setCharacterLimit] = useState(false); - const { memberRole } = useProjectMyMembership(); - const { setShowAlert } = useReloadConfirmations(); const { @@ -78,8 +83,6 @@ export const IssueDescriptionForm: FC = ({ issue, handleFormS }); }, [issue, reset]); - const isNotAllowed = memberRole.isGuest || memberRole.isViewer; - return (

    @@ -106,6 +109,7 @@ export const IssueDescriptionForm: FC = ({ issue, handleFormS overflow-hidden rounded border-none bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-theme" role="textbox" + disabled={!isAllowed} /> {characterLimit && (
    @@ -156,7 +160,7 @@ export const IssueDescriptionForm: FC = ({ issue, handleFormS }); }} placeholder="Description" - editable={!isNotAllowed} + editable={isAllowed} /> ); }} diff --git a/apps/app/components/issues/form.tsx b/apps/app/components/issues/form.tsx index 894830bd7..e7be252b4 100644 --- a/apps/app/components/issues/form.tsx +++ b/apps/app/components/issues/form.tsx @@ -92,6 +92,19 @@ export interface IssueFormProps { handleClose: () => void; status: boolean; user: ICurrentUserResponse | undefined; + fieldsToShow: ( + | "project" + | "name" + | "description" + | "state" + | "priority" + | "assignee" + | "label" + | "dueDate" + | "estimate" + | "parent" + | "all" + )[]; } export const IssueForm: FC = ({ @@ -105,6 +118,7 @@ export const IssueForm: FC = ({ handleClose, status, user, + fieldsToShow, }) => { // states const [mostSimilarIssue, setMostSimilarIssue] = useState(); @@ -134,7 +148,7 @@ export const IssueForm: FC = ({ setValue, setFocus, } = useForm({ - defaultValues, + defaultValues: initialData ?? defaultValues, reValidateMode: "onChange", }); @@ -149,6 +163,8 @@ export const IssueForm: FC = ({ const handleCreateUpdateIssue = async (formData: Partial) => { await handleFormSubmit(formData); + setGptAssistantModal(false); + reset({ ...defaultValues, project: projectId, @@ -184,7 +200,7 @@ export const IssueForm: FC = ({ projectId as string, { prompt: issueName, - task: "Generate a proper description for this issue in context of a project management software.", + task: "Generate a proper description for this issue.", }, user ) @@ -252,243 +268,271 @@ export const IssueForm: FC = ({
    - ( - - )} - /> + {(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && ( + ( + + )} + /> + )}

    {status ? "Update" : "Create"} Issue

    - {watch("parent") && watch("parent") !== "" ? ( -
    -
    - i.id === watch("parent"))?.state_detail - .color, - }} - /> - - {/* {projects?.find((p) => p.id === projectId)?.identifier}- */} - {issues.find((i) => i.id === watch("parent"))?.sequence_id} - - - {issues.find((i) => i.id === watch("parent"))?.name.substring(0, 50)} - - setValue("parent", null)} - /> + {watch("parent") && + watch("parent") !== "" && + (fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( +
    +
    + i.id === watch("parent"))?.state_detail + .color, + }} + /> + + {/* {projects?.find((p) => p.id === projectId)?.identifier}- */} + {issues.find((i) => i.id === watch("parent"))?.sequence_id} + + + {issues.find((i) => i.id === watch("parent"))?.name.substring(0, 50)} + + setValue("parent", null)} + /> +
    -
    - ) : null} + )}
    -
    - - {mostSimilarIssue && ( - + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && ( +
    +
    + {issueName && issueName !== "" && ( + + )}
    - )} -
    -
    -
    - {issueName && issueName !== "" && ( - - )} - + ( + setValue("description", jsonValue)} + onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)} + placeholder="Description" + ref={editorRef} + /> + )} + /> + { + setGptAssistantModal(false); + // this is done so that the title do not reset after gpt popover closed + reset(getValues()); + }} + inset="top-2 left-0" + content="" + htmlContent={watch("description_html")} + onResponse={(response) => { + handleAiAssistance(response); + }} + projectId={projectId} + />
    - ( - setValue("description", jsonValue)} - onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)} - placeholder="Description" - ref={editorRef} - /> - )} - /> - { - setGptAssistantModal(false); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - inset="top-2 left-0" - content="" - htmlContent={watch("description_html")} - onResponse={(response) => { - handleAiAssistance(response); - }} - projectId={projectId} - /> -
    + )}
    - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> - ( - - )} - /> -
    + {(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( ( - + )} /> -
    -
    + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && ( ( - + )} /> -
    - - - {watch("parent") && watch("parent") !== "" ? ( - <> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && ( + ( + + )} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && ( + ( + + )} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && ( +
    + ( + + )} + /> +
    + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && ( +
    + ( + + )} + /> +
    + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( + + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( + + {watch("parent") && watch("parent") !== "" ? ( + <> + setParentIssueListModalOpen(true)} + > + Change parent issue + + setValue("parent", null)} + > + Remove parent issue + + + ) : ( setParentIssueListModalOpen(true)} > - Change parent issue + Select Parent Issue - setValue("parent", null)} - > - Remove parent issue - - - ) : ( - setParentIssueListModalOpen(true)} - > - Select Parent Issue - - )} - + )} +
    + )}
    diff --git a/apps/app/components/issues/index.ts b/apps/app/components/issues/index.ts index c5322645f..7549d19d4 100644 --- a/apps/app/components/issues/index.ts +++ b/apps/app/components/issues/index.ts @@ -5,6 +5,7 @@ export * from "./activity"; export * from "./delete-issue-modal"; export * from "./description-form"; export * from "./form"; +export * from "./main-content"; export * from "./modal"; export * from "./my-issues-list-item"; export * from "./parent-issues-list-modal"; diff --git a/apps/app/components/issues/main-content.tsx b/apps/app/components/issues/main-content.tsx new file mode 100644 index 000000000..6123556a5 --- /dev/null +++ b/apps/app/components/issues/main-content.tsx @@ -0,0 +1,118 @@ +import Link from "next/link"; +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// services +import issuesService from "services/issues.service"; +// hooks +import useUserAuth from "hooks/use-user-auth"; +// contexts +import { useProjectMyMembership } from "contexts/project-member.context"; +// components +import { + AddComment, + IssueActivitySection, + IssueAttachmentUpload, + IssueAttachments, + IssueDescriptionForm, + SubIssuesList, +} from "components/issues"; +// ui +import { CustomMenu } from "components/ui"; +// types +import { IIssue } from "types"; +// fetch-keys +import { SUB_ISSUES } from "constants/fetch-keys"; + +type Props = { + issueDetails: IIssue; + submitChanges: (formData: Partial) => Promise; +}; + +export const IssueMainContent: React.FC = ({ issueDetails, submitChanges }) => { + const router = useRouter(); + const { workspaceSlug, projectId, issueId } = router.query; + + const { user } = useUserAuth(); + const { memberRole } = useProjectMyMembership(); + + const { data: siblingIssues } = useSWR( + workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null, + workspaceSlug && projectId && issueDetails?.parent + ? () => + issuesService.subIssues( + workspaceSlug as string, + projectId as string, + issueDetails.parent ?? "" + ) + : null + ); + + return ( + <> +
    + {issueDetails?.parent && issueDetails.parent !== "" ? ( +
    + + + + + {issueDetails.project_detail.identifier}-{issueDetails.parent_detail?.sequence_id} + + + {issueDetails.parent_detail?.name.substring(0, 50)} + + + + + + {siblingIssues && siblingIssues.length > 0 ? ( + siblingIssues.map((issue: IIssue) => ( + + + + {issueDetails.project_detail.identifier}-{issue.sequence_id} + + + + )) + ) : ( + + No other sibling issues + + )} + +
    + ) : null} + +
    + +
    +
    +
    +

    Attachments

    +
    + + +
    +
    +
    +

    Comments/Activity

    + + +
    + + ); +}; diff --git a/apps/app/components/issues/modal.tsx b/apps/app/components/issues/modal.tsx index 3210e350a..dd5262453 100644 --- a/apps/app/components/issues/modal.tsx +++ b/apps/app/components/issues/modal.tsx @@ -10,11 +10,14 @@ import { Dialog, Transition } from "@headlessui/react"; import projectService from "services/project.service"; import modulesService from "services/modules.service"; import issuesService from "services/issues.service"; +import inboxServices from "services/inbox.service"; // hooks import useUser from "hooks/use-user"; import useIssuesView from "hooks/use-issues-view"; import useCalendarIssuesView from "hooks/use-calendar-issues-view"; import useToast from "hooks/use-toast"; +import useInboxView from "hooks/use-inbox-view"; +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; // components import { IssueForm } from "components/issues"; // types @@ -32,7 +35,10 @@ import { CYCLE_DETAILS, MODULE_DETAILS, VIEW_ISSUES, + INBOX_ISSUES, } from "constants/fetch-keys"; +// constants +import { INBOX_ISSUE_SOURCE } from "constants/inbox"; export interface IssuesModalProps { isOpen: boolean; @@ -40,6 +46,19 @@ export interface IssuesModalProps { data?: IIssue | null; prePopulateData?: Partial; isUpdatingSingleIssue?: boolean; + fieldsToShow?: ( + | "project" + | "name" + | "description" + | "state" + | "priority" + | "assignee" + | "label" + | "dueDate" + | "estimate" + | "parent" + | "all" + )[]; } export const CreateUpdateIssueModal: React.FC = ({ @@ -48,17 +67,20 @@ export const CreateUpdateIssueModal: React.FC = ({ data, prePopulateData, isUpdatingSingleIssue = false, + fieldsToShow = ["all"], }) => { // states const [createMore, setCreateMore] = useState(false); const [activeProject, setActiveProject] = useState(null); const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId, viewId, inboxId } = router.query; const { issueView, params } = useIssuesView(); const { params: calendarParams } = useCalendarIssuesView(); const { order_by, group_by, ...viewGanttParams } = params; + const { params: inboxParams } = useInboxView(); + const { params: spreadsheetParams } = useSpreadsheetIssuesView(); if (cycleId) prePopulateData = { ...prePopulateData, cycle: cycleId as string }; if (moduleId) prePopulateData = { ...prePopulateData, module: moduleId as string }; @@ -140,46 +162,39 @@ export const CreateUpdateIssueModal: React.FC = ({ }); }; - const calendarFetchKey = cycleId - ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams) - : moduleId - ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams) - : viewId - ? VIEW_ISSUES(viewId.toString(), calendarParams) - : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", calendarParams); + const addIssueToInbox = async (formData: Partial) => { + if (!workspaceSlug || !projectId || !inboxId) return; - const ganttFetchKey = cycleId - ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString()) - : moduleId - ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString()) - : viewId - ? VIEW_ISSUES(viewId.toString(), viewGanttParams) - : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? ""); - - const createIssue = async (payload: Partial) => { - if (!workspaceSlug) return; - - await issuesService - .createIssues(workspaceSlug as string, activeProject ?? "", payload, user) - .then(async (res) => { - mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); - if (payload.cycle && payload.cycle !== "") await addIssueToCycle(res.id, payload.cycle); - if (payload.module && payload.module !== "") await addIssueToModule(res.id, payload.module); - - if (issueView === "calendar") mutate(calendarFetchKey); - if (issueView === "gantt_chart") mutate(ganttFetchKey); - - if (!createMore) handleClose(); + const payload = { + issue: { + name: formData.name, + description: formData.description, + description_html: formData.description_html, + priority: formData.priority, + }, + source: INBOX_ISSUE_SOURCE, + }; + await inboxServices + .createInboxIssue( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString(), + payload, + user + ) + .then((res) => { setToastAlert({ type: "success", title: "Success!", message: "Issue created successfully.", }); - if (payload.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE); + router.push( + `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}?inboxIssueId=${res.issue_inbox[0].id}` + ); - if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); + mutate(INBOX_ISSUES(inboxId.toString(), inboxParams)); }) .catch(() => { setToastAlert({ @@ -190,6 +205,68 @@ export const CreateUpdateIssueModal: React.FC = ({ }); }; + const calendarFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), calendarParams) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), calendarParams) + : viewId + ? VIEW_ISSUES(viewId.toString(), calendarParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", calendarParams); + + const spreadsheetFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), spreadsheetParams) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), spreadsheetParams) + : viewId + ? VIEW_ISSUES(viewId.toString(), spreadsheetParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", spreadsheetParams); + + const ganttFetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString()) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString()) + : viewId + ? VIEW_ISSUES(viewId.toString(), viewGanttParams) + : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? ""); + + const createIssue = async (payload: Partial) => { + if (!workspaceSlug || !projectId) return; + + if (inboxId) await addIssueToInbox(payload); + else + await issuesService + .createIssues(workspaceSlug as string, activeProject ?? "", payload, user) + .then(async (res) => { + mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); + if (payload.cycle && payload.cycle !== "") await addIssueToCycle(res.id, payload.cycle); + if (payload.module && payload.module !== "") + await addIssueToModule(res.id, payload.module); + + if (issueView === "calendar") mutate(calendarFetchKey); + if (issueView === "gantt_chart") mutate(ganttFetchKey); + if (issueView === "spreadsheet") mutate(spreadsheetFetchKey); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + + if (payload.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE); + + if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Issue could not be created. Please try again.", + }); + }); + + if (!createMore) handleClose(); + }; + const updateIssue = async (payload: Partial) => { await issuesService .updateIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload, user) @@ -198,6 +275,8 @@ export const CreateUpdateIssueModal: React.FC = ({ mutate(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); } else { if (issueView === "calendar") mutate(calendarFetchKey); + if (issueView === "spreadsheet") mutate(spreadsheetFetchKey); + if (payload.parent) mutate(SUB_ISSUES(payload.parent.toString())); mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); } @@ -226,8 +305,8 @@ export const CreateUpdateIssueModal: React.FC = ({ const payload: Partial = { ...formData, - assignees_list: formData.assignees, - labels_list: formData.labels, + assignees_list: formData.assignees ?? [], + labels_list: formData.labels ?? [], description: formData.description ?? "", description_html: formData.description_html ?? "

    ", }; @@ -266,7 +345,7 @@ export const CreateUpdateIssueModal: React.FC = ({ = ({ setActiveProject={setActiveProject} status={data ? true : false} user={user} + fieldsToShow={fieldsToShow} /> diff --git a/apps/app/components/issues/my-issues-list-item.tsx b/apps/app/components/issues/my-issues-list-item.tsx index e026b2f89..5cff38478 100644 --- a/apps/app/components/issues/my-issues-list-item.tsx +++ b/apps/app/components/issues/my-issues-list-item.tsx @@ -67,7 +67,7 @@ export const MyIssuesListItem: React.FC = ({ issue, properties, projectId console.log(error); }); }, - [workspaceSlug, projectId] + [workspaceSlug, projectId, user] ); const handleCopyText = () => { diff --git a/apps/app/components/issues/parent-issues-list-modal.tsx b/apps/app/components/issues/parent-issues-list-modal.tsx index 7510d5e75..b93c07d3c 100644 --- a/apps/app/components/issues/parent-issues-list-modal.tsx +++ b/apps/app/components/issues/parent-issues-list-modal.tsx @@ -1,23 +1,28 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; // headless ui import { Combobox, Dialog, Transition } from "@headlessui/react"; -// icons -import { MagnifyingGlassIcon, RectangleStackIcon } from "@heroicons/react/24/outline"; -// ui -import { PrimaryButton, SecondaryButton } from "components/ui"; -// types -import { IIssue } from "types"; +// services +import projectService from "services/project.service"; +// hooks +import useDebounce from "hooks/use-debounce"; +// components import { LayerDiagonalIcon } from "components/icons"; +// ui +import { Loader } from "components/ui"; +// icons +import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +// types +import { ISearchIssueResponse } from "types"; type Props = { isOpen: boolean; handleClose: () => void; value?: any; onChange: (...event: any[]) => void; - issues: IIssue[]; - title?: string; - multiple?: boolean; + issueId?: string; customDisplay?: JSX.Element; }; @@ -26,28 +31,60 @@ export const ParentIssuesListModal: React.FC = ({ handleClose: onClose, value, onChange, - issues, - title = "Issues", - multiple = false, + issueId, customDisplay, }) => { - const [query, setQuery] = useState(""); - const [values, setValues] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [issues, setIssues] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSearching, setIsSearching] = useState(false); + + const debouncedSearchTerm: string = useDebounce(searchTerm, 500); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; const handleClose = () => { onClose(); - setQuery(""); - setValues([]); + setSearchTerm(""); }; - const filteredIssues: IIssue[] = - query === "" - ? issues ?? [] - : issues?.filter((issue) => issue.name.toLowerCase().includes(query.toLowerCase())) ?? []; + useEffect(() => { + if (!workspaceSlug || !projectId) return; + + setIsLoading(true); + + if (debouncedSearchTerm) { + setIsSearching(true); + + projectService + .projectIssuesSearch(workspaceSlug as string, projectId as string, { + search: debouncedSearchTerm, + parent: true, + issue_id: issueId, + }) + .then((res) => { + setIssues(res); + }) + .finally(() => { + setIsLoading(false); + setIsSearching(false); + }); + } else { + setIssues([]); + setIsLoading(false); + setIsSearching(false); + } + }, [debouncedSearchTerm, workspaceSlug, projectId, issueId]); return ( <> - setQuery("")} appear> + setSearchTerm("")} + appear + > = ({ leaveTo="opacity-0 scale-95" > - {multiple ? ( - <> - ({})} multiple> -
    -
    - {customDisplay &&
    {customDisplay}
    } - - {filteredIssues.length > 0 && ( -
  • - {query === "" && ( -

    {title}

    - )} -
      - {filteredIssues.map((issue) => ( - - `flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ - active ? "bg-brand-surface-2 text-brand-base" : "" - } ${selected ? "text-brand-base" : ""}` - } - > - {({ selected }) => ( - <> - - - - {issue.project_detail?.identifier}-{issue.sequence_id} - {" "} - {issue.id} - - )} - - ))} -
    -
  • - )} -
    + +
    +
    + {customDisplay &&
    {customDisplay}
    } + + {debouncedSearchTerm !== "" && ( +
    + Search results for{" "} + + {'"'} + {debouncedSearchTerm} + {'"'} + {" "} + in project: +
    + )} - {query !== "" && filteredIssues.length === 0 && ( -
    -
    - )} -
    -
    - Cancel - onChange(values)}>Add issues -
    - - ) : ( - -
    -
    - {customDisplay &&
    {customDisplay}
    } - - {filteredIssues.length > 0 ? ( -
  • - {query === "" && ( -

    {title}

    - )} -
      - {filteredIssues.map((issue) => ( - - `flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ - active ? "bg-brand-surface-2 text-brand-base" : "" - } ${selected ? "text-brand-base" : ""}` - } - onClick={handleClose} - > - <> - - - {issue.project_detail?.identifier}-{issue.sequence_id} - {" "} - {issue.name} - - - ))} -
    -
  • - ) : ( + {!isLoading && + issues.length === 0 && + searchTerm !== "" && + debouncedSearchTerm !== "" && (

    @@ -208,9 +152,45 @@ export const ParentIssuesListModal: React.FC = ({

    )} -
    -
    - )} + + {isLoading || isSearching ? ( + + + + + + + ) : ( +
      0 ? "p-2" : ""}`}> + {issues.map((issue) => ( + + `flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ + active ? "bg-brand-surface-2 text-brand-base" : "" + } ${selected ? "text-brand-base" : ""}` + } + onClick={handleClose} + > + <> + + + {issue.project__identifier}-{issue.sequence_id} + {" "} + {issue.name} + + + ))} +
    + )} + +
    diff --git a/apps/app/components/issues/select/label.tsx b/apps/app/components/issues/select/label.tsx index a7ada1133..e99eecc16 100644 --- a/apps/app/components/issues/select/label.tsx +++ b/apps/app/components/issues/select/label.tsx @@ -6,6 +6,10 @@ import useSWR from "swr"; // headless ui import { Combobox, Transition } from "@headlessui/react"; +// services +import issuesServices from "services/issues.service"; +// ui +import { IssueLabelsList } from "components/ui"; // icons import { CheckIcon, @@ -14,13 +18,10 @@ import { RectangleGroupIcon, TagIcon, } from "@heroicons/react/24/outline"; -// services -import issuesServices from "services/issues.service"; // types import type { IIssueLabels } from "types"; // fetch-keys import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; -import { IssueLabelsList } from "components/ui"; type Props = { setIsOpen: React.Dispatch>; diff --git a/apps/app/components/issues/select/parent.tsx b/apps/app/components/issues/select/parent.tsx index c04e89b92..d73cd4e73 100644 --- a/apps/app/components/issues/select/parent.tsx +++ b/apps/app/components/issues/select/parent.tsx @@ -21,7 +21,6 @@ export const IssueParentSelect: React.FC = ({ control, isOpen, setIsOpen, isOpen={isOpen} handleClose={() => setIsOpen(false)} onChange={onChange} - issues={issues} /> )} /> diff --git a/apps/app/components/issues/sidebar-select/blocked.tsx b/apps/app/components/issues/sidebar-select/blocked.tsx index c07f80817..de8985792 100644 --- a/apps/app/components/issues/sidebar-select/blocked.tsx +++ b/apps/app/components/issues/sidebar-select/blocked.tsx @@ -3,299 +3,135 @@ import React, { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; -import useSWR from "swr"; - // react-hook-form -import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form"; -// headless ui -import { Combobox, Dialog, Transition } from "@headlessui/react"; +import { UseFormWatch } from "react-hook-form"; // hooks import useToast from "hooks/use-toast"; -// services -import issuesService from "services/issues.service"; -// ui -import { PrimaryButton, SecondaryButton } from "components/ui"; +import useProjectDetails from "hooks/use-project-details"; +// components +import { ExistingIssuesListModal } from "components/core"; // icons -import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import { BlockedIcon, LayerDiagonalIcon } from "components/icons"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { BlockedIcon } from "components/icons"; // types -import { IIssue, UserAuth } from "types"; -// fetch-keys -import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; - -type FormInput = { - blocked_issue_ids: string[]; -}; +import { BlockeIssue, IIssue, ISearchIssueResponse, UserAuth } from "types"; type Props = { + issueId?: string; submitChanges: (formData: Partial) => void; - issuesList: IIssue[]; watch: UseFormWatch; userAuth: UserAuth; }; export const SidebarBlockedSelect: React.FC = ({ + issueId, submitChanges, - issuesList, watch, userAuth, }) => { - const [query, setQuery] = useState(""); const [isBlockedModalOpen, setIsBlockedModalOpen] = useState(false); const { setToastAlert } = useToast(); + const { projectDetails } = useProjectDetails(); const router = useRouter(); const { workspaceSlug, projectId } = router.query; - const { data: issues } = useSWR( - workspaceSlug && projectId - ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) - : null, - workspaceSlug && projectId - ? () => issuesService.getIssues(workspaceSlug as string, projectId as string) - : null - ); - - const { - handleSubmit, - reset, - watch: watchBlocked, - setValue, - } = useForm({ - defaultValues: { - blocked_issue_ids: [], - }, - }); - const handleClose = () => { setIsBlockedModalOpen(false); - reset(); }; - const onSubmit: SubmitHandler = (data) => { - if (!data.blocked_issue_ids || data.blocked_issue_ids.length === 0) { + const onSubmit = async (data: ISearchIssueResponse[]) => { + if (data.length === 0) { setToastAlert({ title: "Error", type: "error", - message: "Please select atleast one issue", + message: "Please select at least one issue", }); + return; } - if (!Array.isArray(data.blocked_issue_ids)) data.blocked_issue_ids = [data.blocked_issue_ids]; + const selectedIssues: BlockeIssue[] = data.map((i) => ({ + blocked_issue_detail: { + id: i.id, + name: i.name, + sequence_id: i.sequence_id, + }, + })); - const newBlocked = [...watch("blocked_list"), ...data.blocked_issue_ids]; - submitChanges({ blocks_list: newBlocked }); + const newBlocked = [...watch("blocked_issues"), ...selectedIssues]; + + submitChanges({ + blocked_issues: newBlocked, + blocks_list: newBlocked.map((i) => i.blocked_issue_detail?.id ?? ""), + }); handleClose(); }; - const filteredIssues: IIssue[] = - query === "" - ? issuesList - : issuesList.filter( - (issue) => - issue.name.toLowerCase().includes(query.toLowerCase()) || - `${issue.project_detail.identifier}-${issue.sequence_id}` - .toLowerCase() - .includes(query.toLowerCase()) - ); - const isNotAllowed = userAuth.isGuest || userAuth.isViewer; return ( -
    -
    - -

    Blocked by

    -
    -
    -
    - {watch("blocked_list") && watch("blocked_list").length > 0 - ? watch("blocked_list").map((issue) => ( -
    - i.id === issue)?.id - }`} - > - - - {`${issues?.find((i) => i.id === issue)?.project_detail?.identifier}-${ - issues?.find((i) => i.id === issue)?.sequence_id - }`} - - - -
    - )) - : null} + <> + setIsBlockedModalOpen(false)} + searchParams={{ blocker_blocked_by: true, issue_id: issueId }} + handleOnSubmit={onSubmit} + /> +
    +
    + +

    Blocked by

    - setQuery("")} - appear - > - - -
    - +
    +
    + {watch("blocked_issues") && watch("blocked_issues").length > 0 + ? watch("blocked_issues").map((issue) => ( +
    + + + + {`${projectDetails?.identifier}-${issue.blocked_issue_detail?.sequence_id}`} + + +
    -
    - + + +
    + )) + : null} +
    + +
    -
    + ); }; diff --git a/apps/app/components/issues/sidebar-select/blocker.tsx b/apps/app/components/issues/sidebar-select/blocker.tsx index aeede09bb..40f1eb10f 100644 --- a/apps/app/components/issues/sidebar-select/blocker.tsx +++ b/apps/app/components/issues/sidebar-select/blocker.tsx @@ -3,296 +3,137 @@ import React, { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; -import useSWR from "swr"; - // react-hook-form -import { SubmitHandler, useForm, UseFormWatch } from "react-hook-form"; -// headless ui -import { Combobox, Dialog, Transition } from "@headlessui/react"; +import { UseFormWatch } from "react-hook-form"; // hooks import useToast from "hooks/use-toast"; -// services -import issuesServices from "services/issues.service"; -// ui -import { PrimaryButton, SecondaryButton } from "components/ui"; +import useProjectDetails from "hooks/use-project-details"; +// components +import { ExistingIssuesListModal } from "components/core"; // icons -import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import { BlockerIcon, LayerDiagonalIcon } from "components/icons"; +import { XMarkIcon } from "@heroicons/react/24/outline"; +import { BlockerIcon } from "components/icons"; // types -import { IIssue, UserAuth } from "types"; -// fetch-keys -import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; - -type FormInput = { - blocker_issue_ids: string[]; -}; +import { BlockeIssue, IIssue, ISearchIssueResponse, UserAuth } from "types"; type Props = { + issueId?: string; submitChanges: (formData: Partial) => void; - issuesList: IIssue[]; watch: UseFormWatch; userAuth: UserAuth; }; export const SidebarBlockerSelect: React.FC = ({ + issueId, submitChanges, - issuesList, watch, userAuth, }) => { - const [query, setQuery] = useState(""); const [isBlockerModalOpen, setIsBlockerModalOpen] = useState(false); const { setToastAlert } = useToast(); + const { projectDetails } = useProjectDetails(); const router = useRouter(); const { workspaceSlug, projectId } = router.query; - const { data: issues } = useSWR( - workspaceSlug && projectId - ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) - : null, - workspaceSlug && projectId - ? () => issuesServices.getIssues(workspaceSlug as string, projectId as string) - : null - ); - - const { - handleSubmit, - reset, - watch: watchBlocker, - setValue, - } = useForm({ - defaultValues: { - blocker_issue_ids: [], - }, - }); - const handleClose = () => { setIsBlockerModalOpen(false); - reset(); }; - const onSubmit: SubmitHandler = (data) => { - if (!data.blocker_issue_ids || data.blocker_issue_ids.length === 0) { + const onSubmit = async (data: ISearchIssueResponse[]) => { + if (data.length === 0) { setToastAlert({ - title: "Error", type: "error", - message: "Please select atleast one issue", + title: "Error!", + message: "Please select at least one issue.", }); + return; } - if (!Array.isArray(data.blocker_issue_ids)) data.blocker_issue_ids = [data.blocker_issue_ids]; + const selectedIssues: BlockeIssue[] = data.map((i) => ({ + blocker_issue_detail: { + id: i.id, + name: i.name, + sequence_id: i.sequence_id, + }, + })); - const newBlockers = [...watch("blockers_list"), ...data.blocker_issue_ids]; - submitChanges({ blockers_list: newBlockers }); + const newBlockers = [...watch("blocker_issues"), ...selectedIssues]; + + submitChanges({ + blocker_issues: newBlockers, + blockers_list: newBlockers.map((i) => i.blocker_issue_detail?.id ?? ""), + }); handleClose(); }; - const filteredIssues: IIssue[] = - query === "" - ? issuesList - : issuesList.filter( - (issue) => - issue.name.toLowerCase().includes(query.toLowerCase()) || - `${issue.project_detail.identifier}-${issue.sequence_id}` - .toLowerCase() - .includes(query.toLowerCase()) - ); - const isNotAllowed = userAuth.isGuest || userAuth.isViewer; return ( -
    -
    - -

    Blocking

    -
    -
    -
    - {watch("blockers_list") && watch("blockers_list").length > 0 - ? watch("blockers_list").map((issue) => ( -
    - i.id === issue)?.id - }`} - > - - - {`${issues?.find((i) => i.id === issue)?.project_detail?.identifier}-${ - issues?.find((i) => i.id === issue)?.sequence_id - }`} - - - -
    - )) - : null} + <> + setIsBlockerModalOpen(false)} + searchParams={{ blocker_blocked_by: true, issue_id: issueId }} + handleOnSubmit={onSubmit} + /> +
    +
    + +

    Blocking

    - setQuery("")} - appear - > - - -
    - - -
    - - - { - const selectedIssues = watchBlocker("blocker_issue_ids"); - if (selectedIssues.includes(val)) - setValue( - "blocker_issue_ids", - selectedIssues.filter((i) => i !== val) - ); - else setValue("blocker_issue_ids", [...selectedIssues, val]); - }} +
    +
    + {watch("blocker_issues") && watch("blocker_issues").length > 0 + ? watch("blocker_issues").map((issue) => ( +
    -
    -
    - - - {filteredIssues.length > 0 ? ( -
  • - {query === "" && ( -

    - Select blocker issues -

    - )} -
      - {filteredIssues.map((issue) => { - if ( - !watch("blockers_list").includes(issue.id) && - !watch("blocked_list").includes(issue.id) - ) - return ( - - `flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-brand-secondary ${ - active ? "bg-brand-surface-2 text-brand-base" : "" - } ` - } - > -
      - - - - { - issues?.find((i) => i.id === issue.id)?.project_detail - ?.identifier - } - -{issue.sequence_id} - - {issue.name} -
      -
      - ); - })} -
    -
  • - ) : ( -
    - -

    - No issues found. Create a new issue with{" "} -
    C
    . -

    -
    - )} -
    - + + + {`${projectDetails?.identifier}-${issue.blocker_issue_detail?.sequence_id}`} + + +
    -
    -
    - + submitChanges({ + blocker_issues: updatedBlockers, + blockers_list: updatedBlockers.map( + (i) => i.blocker_issue_detail?.id ?? "" + ), + }); + }} + > + + +
    + )) + : null} +
    + +
    -
    + ); }; diff --git a/apps/app/components/issues/sidebar-select/parent.tsx b/apps/app/components/issues/sidebar-select/parent.tsx index 92a51269f..9d183d262 100644 --- a/apps/app/components/issues/sidebar-select/parent.tsx +++ b/apps/app/components/issues/sidebar-select/parent.tsx @@ -20,7 +20,6 @@ import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; type Props = { control: Control; submitChanges: (formData: Partial) => void; - issuesList: IIssue[]; customDisplay: JSX.Element; watch: UseFormWatch; userAuth: UserAuth; @@ -29,7 +28,6 @@ type Props = { export const SidebarParentSelect: React.FC = ({ control, submitChanges, - issuesList, customDisplay, watch, userAuth, @@ -37,7 +35,7 @@ export const SidebarParentSelect: React.FC = ({ const [isParentModalOpen, setIsParentModalOpen] = useState(false); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, issueId } = router.query; const { data: issues } = useSWR( workspaceSlug && projectId @@ -68,8 +66,7 @@ export const SidebarParentSelect: React.FC = ({ submitChanges({ parent: val }); onChange(val); }} - issues={issuesList} - title="Select Parent" + issueId={issueId as string} value={value} customDisplay={customDisplay} /> diff --git a/apps/app/components/issues/sidebar-select/state.tsx b/apps/app/components/issues/sidebar-select/state.tsx index 8abb362db..02d1dd5cb 100644 --- a/apps/app/components/issues/sidebar-select/state.tsx +++ b/apps/app/components/issues/sidebar-select/state.tsx @@ -27,7 +27,7 @@ type Props = { export const SidebarStateSelect: React.FC = ({ value, onChange, userAuth }) => { const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug, projectId, inboxIssueId } = router.query; const { data: stateGroups } = useSWR( workspaceSlug && projectId ? STATES_LIST(projectId as string) : null, @@ -50,15 +50,24 @@ export const SidebarStateSelect: React.FC = ({ value, onChange, userAuth
    - {getStateGroupIcon( - selectedState?.group ?? "backlog", - "16", - "16", - selectedState?.color ?? "" - )} - {addSpaceIfCamelCase(selectedState?.name ?? "")} -
    + selectedState ? ( +
    + {getStateGroupIcon( + selectedState?.group ?? "backlog", + "16", + "16", + selectedState?.color ?? "" + )} + {addSpaceIfCamelCase(selectedState?.name ?? "")} +
    + ) : inboxIssueId ? ( +
    + {getStateGroupIcon("backlog", "16", "16", "#ff7700")} + Triage +
    + ) : ( + "None" + ) } value={value} onChange={onChange} diff --git a/apps/app/components/issues/sidebar.tsx b/apps/app/components/issues/sidebar.tsx index 5b15fd46f..6f231871b 100644 --- a/apps/app/components/issues/sidebar.tsx +++ b/apps/app/components/issues/sidebar.tsx @@ -53,10 +53,26 @@ import type { ICycle, IIssue, IIssueLabels, IIssueLink, IModule } from "types"; import { PROJECT_ISSUE_LABELS, PROJECT_ISSUES_LIST, ISSUE_DETAILS } from "constants/fetch-keys"; type Props = { - control: Control; - submitChanges: (formData: Partial) => void; + control: any; + submitChanges: (formData: any) => void; issueDetail: IIssue | undefined; watch: UseFormWatch; + fieldsToShow?: ( + | "state" + | "assignee" + | "priority" + | "estimate" + | "parent" + | "blocker" + | "blocked" + | "dueDate" + | "cycle" + | "module" + | "label" + | "link" + | "delete" + | "all" + )[]; }; const defaultValues: Partial = { @@ -69,6 +85,7 @@ export const IssueDetailsSidebar: React.FC = ({ submitChanges, issueDetail, watch: watchIssue, + fieldsToShow = ["all"], }) => { const [createLabelForm, setCreateLabelForm] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false); @@ -140,7 +157,7 @@ export const IssueDetailsSidebar: React.FC = ({ mutate(ISSUE_DETAILS(issueId as string)); }); }, - [workspaceSlug, projectId, issueId, issueDetail] + [workspaceSlug, projectId, issueId, issueDetail, user] ); const handleModuleChange = useCallback( @@ -161,7 +178,7 @@ export const IssueDetailsSidebar: React.FC = ({ mutate(ISSUE_DETAILS(issueId as string)); }); }, - [workspaceSlug, projectId, issueId, issueDetail] + [workspaceSlug, projectId, issueId, issueDetail, user] ); const handleCreateLink = async (formData: IIssueLink) => { @@ -230,6 +247,25 @@ export const IssueDetailsSidebar: React.FC = ({ reset(); }, [createLabelForm, reset]); + const showFirstSection = + fieldsToShow.includes("all") || + fieldsToShow.includes("state") || + fieldsToShow.includes("assignee") || + fieldsToShow.includes("priority") || + fieldsToShow.includes("estimate"); + + const showSecondSection = + fieldsToShow.includes("all") || + fieldsToShow.includes("parent") || + fieldsToShow.includes("blocker") || + fieldsToShow.includes("blocked") || + fieldsToShow.includes("dueDate"); + + const showThirdSection = + fieldsToShow.includes("all") || + fieldsToShow.includes("cycle") || + fieldsToShow.includes("module"); + const isNotAllowed = memberRole.isGuest || memberRole.isViewer; return ( @@ -251,14 +287,16 @@ export const IssueDetailsSidebar: React.FC = ({ {issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}

    - - {!isNotAllowed && ( + {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( + + )} + {!isNotAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && (
    -
    - ( - submitChanges({ state: val })} - userAuth={memberRole} - /> - )} - /> - ( - submitChanges({ assignees_list: val })} - userAuth={memberRole} - /> - )} - /> - ( - submitChanges({ priority: val })} - userAuth={memberRole} - /> - )} - /> - ( - submitChanges({ estimate_point: val })} - userAuth={memberRole} - /> - )} - /> -
    -
    - - i.id !== issueDetail?.id && - i.id !== issueDetail?.parent && - i.parent !== issueDetail?.id - ) ?? [] - } - customDisplay={ - issueDetail?.parent_detail ? ( - - ) : ( -
    - No parent selected -
    - ) - } - watch={watchIssue} - userAuth={memberRole} - /> - i.id !== issueDetail?.id) ?? []} - watch={watchIssue} - userAuth={memberRole} - /> - i.id !== issueDetail?.id) ?? []} - watch={watchIssue} - userAuth={memberRole} - /> -
    -
    - -

    Due date

    -
    -
    + {showFirstSection && ( +
    + {(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( ( - - submitChanges({ - target_date: val, - }) - } - className="bg-brand-surface-1" - disabled={isNotAllowed} + onChange={(val: string) => submitChanges({ state: val })} + userAuth={memberRole} /> )} /> -
    -
    -
    -
    - - -
    -
    -
    -
    -
    - -

    Label

    -
    -
    -
    - {watchIssue("labels_list")?.map((labelId) => { - const label = issueLabels?.find((l) => l.id === labelId); - - if (label) - return ( - { - const updatedLabels = watchIssue("labels_list")?.filter( - (l) => l !== labelId - ); - submitChanges({ - labels_list: updatedLabels, - }); - }} - > - - {label.name} - - - ); - })} + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && ( ( - submitChanges({ labels_list: val })} - className="flex-shrink-0" - multiple - disabled={isNotAllowed} - > - {({ open }) => ( -
    - - Select Label - - - - -
    - {issueLabels ? ( - issueLabels.length > 0 ? ( - issueLabels.map((label: IIssueLabels) => { - const children = issueLabels?.filter( - (l) => l.parent === label.id - ); - - if (children.length === 0) { - if (!label.parent) - return ( - - `${ - active || selected ? "bg-brand-surface-1" : "" - } ${ - selected ? "" : "text-brand-secondary" - } flex cursor-pointer select-none items-center gap-2 truncate p-2` - } - value={label.id} - > - - {label.name} - - ); - } else - return ( -
    -
    - - {label.name} -
    -
    - {children.map((child) => ( - - `${active || selected ? "bg-brand-base" : ""} ${ - selected ? "" : "text-brand-secondary" - } flex cursor-pointer select-none items-center gap-2 truncate p-2` - } - value={child.id} - > - - {child.name} - - ))} -
    -
    - ); - }) - ) : ( -
    No labels found
    - ) - ) : ( - - )} -
    -
    -
    -
    - )} -
    + onChange={(val: string[]) => submitChanges({ assignees_list: val })} + userAuth={memberRole} + /> )} /> - {!isNotAllowed && ( - - )} -
    -
    -
    - {createLabelForm && ( -
    -
    - - {({ open }) => ( - <> - - {watch("color") && watch("color") !== "" && ( - - )} - - - - - - ( - onChange(value.hex)} - /> - )} - /> - - - + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && ( + ( + submitChanges({ priority: val })} + userAuth={memberRole} + /> )} - -
    - - - -
    + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && ( + ( + submitChanges({ estimate_point: val })} + userAuth={memberRole} + /> + )} + /> + )} +
    + )} + {showSecondSection && ( +
    + {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( + submitChanges({ parent: null })} + > + Selected:{" "} + {issueDetail.parent_detail?.name} + + + ) : ( +
    + No parent selected +
    + ) + } + watch={watchIssue} + userAuth={memberRole} + /> + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("blocker")) && ( + + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && ( + + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && ( +
    +
    + +

    Due date

    +
    +
    + ( + + submitChanges({ + target_date: val, + }) + } + className="bg-brand-surface-1" + disabled={isNotAllowed} + /> + )} + /> +
    +
    + )} +
    + )} + {showThirdSection && ( +
    + {(fieldsToShow.includes("all") || fieldsToShow.includes("cycle")) && ( + + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("module")) && ( + + )} +
    )}
    -
    -
    -

    Links

    - {!isNotAllowed && ( - + {(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && ( +
    +
    +
    + +

    Label

    +
    +
    +
    + {watchIssue("labels_list")?.map((labelId) => { + const label = issueLabels?.find((l) => l.id === labelId); + + if (label) + return ( + { + const updatedLabels = watchIssue("labels_list")?.filter( + (l) => l !== labelId + ); + submitChanges({ + labels_list: updatedLabels, + }); + }} + > + + {label.name} + + + ); + })} + ( + submitChanges({ labels_list: val })} + className="flex-shrink-0" + multiple + disabled={isNotAllowed} + > + {({ open }) => ( +
    + + Select Label + + + + +
    + {issueLabels ? ( + issueLabels.length > 0 ? ( + issueLabels.map((label: IIssueLabels) => { + const children = issueLabels?.filter( + (l) => l.parent === label.id + ); + + if (children.length === 0) { + if (!label.parent) + return ( + + `${ + active || selected ? "bg-brand-surface-1" : "" + } ${ + selected ? "" : "text-brand-secondary" + } flex cursor-pointer select-none items-center gap-2 truncate p-2` + } + value={label.id} + > + + {label.name} + + ); + } else + return ( +
    +
    + + {label.name} +
    +
    + {children.map((child) => ( + + `${ + active || selected ? "bg-brand-base" : "" + } ${ + selected ? "" : "text-brand-secondary" + } flex cursor-pointer select-none items-center gap-2 truncate p-2` + } + value={child.id} + > + + {child.name} + + ))} +
    +
    + ); + }) + ) : ( +
    No labels found
    + ) + ) : ( + + )} +
    +
    +
    +
    + )} +
    + )} + /> + {!isNotAllowed && ( + + )} +
    +
    +
    + {createLabelForm && ( +
    +
    + + {({ open }) => ( + <> + + {watch("color") && watch("color") !== "" && ( + + )} + + + + + + ( + onChange(value.hex)} + /> + )} + /> + + + + )} + +
    + + + +
    )}
    -
    - {issueDetail?.issue_link && issueDetail.issue_link.length > 0 ? ( - - ) : null} + )} + {(fieldsToShow.includes("all") || fieldsToShow.includes("link")) && ( +
    +
    +

    Links

    + {!isNotAllowed && ( + + )} +
    +
    + {issueDetail?.issue_link && issueDetail.issue_link.length > 0 ? ( + + ) : null} +
    -
    + )}
    ); diff --git a/apps/app/components/issues/sub-issues-list.tsx b/apps/app/components/issues/sub-issues-list.tsx index 76424767e..3558c8d74 100644 --- a/apps/app/components/issues/sub-issues-list.tsx +++ b/apps/app/components/issues/sub-issues-list.tsx @@ -21,7 +21,7 @@ import { ChevronRightIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outli // helpers import { orderArrayBy } from "helpers/array.helper"; // types -import { ICurrentUserResponse, IIssue, ISubIssueResponse } from "types"; +import { ICurrentUserResponse, IIssue, ISearchIssueResponse, ISubIssueResponse } from "types"; // fetch-keys import { PROJECT_ISSUES_LIST, SUB_ISSUES } from "constants/fetch-keys"; @@ -58,14 +58,16 @@ export const SubIssuesList: FC = ({ parentIssue, user }) => { : null ); - const addAsSubIssue = async (data: { issues: string[] }) => { + const addAsSubIssue = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId) return; + const payload = { + sub_issue_ids: data.map((i) => i.id), + }; + await issuesService - .addSubIssues(workspaceSlug as string, projectId as string, parentIssue?.id ?? "", { - sub_issue_ids: data.issues, - }) - .then((res) => { + .addSubIssues(workspaceSlug as string, projectId as string, parentIssue?.id ?? "", payload) + .then(() => { mutate( SUB_ISSUES(parentIssue?.id ?? ""), (prevData) => { @@ -74,10 +76,12 @@ export const SubIssuesList: FC = ({ parentIssue, user }) => { const stateDistribution = { ...prevData.state_distribution }; - data.issues.forEach((issueId: string) => { + payload.sub_issue_ids.forEach((issueId: string) => { const issue = issues?.find((i) => i.id === issueId); + if (issue) { newSubIssues.push(issue); + const issueGroup = issue.state_detail.group; stateDistribution[issueGroup] = stateDistribution[issueGroup] + 1; } @@ -96,7 +100,7 @@ export const SubIssuesList: FC = ({ parentIssue, user }) => { PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), (prevData) => (prevData ?? []).map((p) => { - if (data.issues.includes(p.id)) + if (payload.sub_issue_ids.includes(p.id)) return { ...p, parent: parentIssue.id, @@ -188,14 +192,7 @@ export const SubIssuesList: FC = ({ parentIssue, user }) => { setSubIssuesListModal(false)} - issues={ - issues?.filter( - (i) => - (i.parent === "" || i.parent === null) && - i.id !== parentIssue?.id && - i.id !== parentIssue?.parent - ) ?? [] - } + searchParams={{ sub_issue: true, issue_id: parentIssue?.id }} handleOnSubmit={addAsSubIssue} /> {subIssuesResponse && @@ -285,7 +282,7 @@ export const SubIssuesList: FC = ({ parentIssue, user }) => { {issue.project_detail.identifier}-{issue.sequence_id} - {issue.name} + {issue.name}
    {!isNotAllowed && ( diff --git a/apps/app/components/issues/view-select/assignee.tsx b/apps/app/components/issues/view-select/assignee.tsx index 27d4901f6..1dbfbabba 100644 --- a/apps/app/components/issues/view-select/assignee.tsx +++ b/apps/app/components/issues/view-select/assignee.tsx @@ -22,6 +22,7 @@ type Props = { position?: "left" | "right"; selfPositioned?: boolean; tooltipPosition?: "left" | "right"; + customButton?: boolean; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; @@ -34,6 +35,7 @@ export const ViewAssigneeSelect: React.FC = ({ tooltipPosition = "right", user, isNotAllowed, + customButton = false, }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -65,6 +67,38 @@ export const ViewAssigneeSelect: React.FC = ({ ), })); + const assigneeLabel = ( + 0 + ? issue.assignee_details + .map((assignee) => + assignee?.first_name !== "" ? assignee?.first_name : assignee?.email + ) + .join(", ") + : "No Assignee" + } + > +
    + {issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? ( +
    + +
    + ) : ( +
    + +
    + )} +
    +
    + ); + return ( = ({ ); }} options={options} - label={ - 0 - ? issue.assignee_details - .map((assignee) => - assignee?.first_name !== "" ? assignee?.first_name : assignee?.email - ) - .join(", ") - : "No Assignee" - } - > -
    - {issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? ( -
    - -
    - ) : ( -
    - -
    - )} -
    -
    - } + {...(customButton ? { customButton: assigneeLabel } : { label: assigneeLabel })} multiple noChevron position={position} diff --git a/apps/app/components/issues/view-select/due-date.tsx b/apps/app/components/issues/view-select/due-date.tsx index bea5ff045..f74b62689 100644 --- a/apps/app/components/issues/view-select/due-date.tsx +++ b/apps/app/components/issues/view-select/due-date.tsx @@ -12,6 +12,7 @@ import { ICurrentUserResponse, IIssue } from "types"; type Props = { issue: IIssue; partialUpdateIssue: (formData: Partial, issueId: string) => void; + noBorder?: boolean; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; @@ -19,6 +20,7 @@ type Props = { export const ViewDueDateSelect: React.FC = ({ issue, partialUpdateIssue, + noBorder = false, user, isNotAllowed, }) => { @@ -62,6 +64,7 @@ export const ViewDueDateSelect: React.FC = ({ ); }} className={issue?.target_date ? "w-[6.5rem]" : "w-[5rem] text-center"} + noBorder={noBorder} disabled={isNotAllowed} />
    diff --git a/apps/app/components/issues/view-select/estimate.tsx b/apps/app/components/issues/view-select/estimate.tsx index 914a5286e..02a3e0710 100644 --- a/apps/app/components/issues/view-select/estimate.tsx +++ b/apps/app/components/issues/view-select/estimate.tsx @@ -18,6 +18,7 @@ type Props = { partialUpdateIssue: (formData: Partial, issueId: string) => void; position?: "left" | "right"; selfPositioned?: boolean; + customButton?: boolean; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; @@ -27,6 +28,7 @@ export const ViewEstimateSelect: React.FC = ({ partialUpdateIssue, position = "left", selfPositioned = false, + customButton = false, user, isNotAllowed, }) => { @@ -37,6 +39,15 @@ export const ViewEstimateSelect: React.FC = ({ const estimateValue = estimatePoints?.find((e) => e.key === issue.estimate_point)?.value; + const estimateLabels = ( + +
    + + {estimateValue ?? "None"} +
    +
    + ); + if (!isEstimateActive) return null; return ( @@ -57,14 +68,7 @@ export const ViewEstimateSelect: React.FC = ({ user ); }} - label={ - -
    - - {estimateValue ?? "Estimate"} -
    -
    - } + {...(customButton ? { customButton: estimateLabels } : { label: estimateLabels })} maxHeight="md" noChevron disabled={isNotAllowed} diff --git a/apps/app/components/issues/view-select/priority.tsx b/apps/app/components/issues/view-select/priority.tsx index a0c5cd47c..499546931 100644 --- a/apps/app/components/issues/view-select/priority.tsx +++ b/apps/app/components/issues/view-select/priority.tsx @@ -12,12 +12,15 @@ import { ICurrentUserResponse, IIssue } from "types"; import { PRIORITIES } from "constants/project"; // services import trackEventServices from "services/track-event.service"; +// helper +import { capitalizeFirstLetter } from "helpers/string.helper"; type Props = { issue: IIssue; partialUpdateIssue: (formData: Partial, issueId: string) => void; position?: "left" | "right"; selfPositioned?: boolean; + noBorder?: boolean; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; @@ -27,6 +30,7 @@ export const ViewPrioritySelect: React.FC = ({ partialUpdateIssue, position = "left", selfPositioned = false, + noBorder = false, user, isNotAllowed, }) => { @@ -55,10 +59,12 @@ export const ViewPrioritySelect: React.FC = ({ customButton={ diff --git a/apps/app/components/issues/view-select/state.tsx b/apps/app/components/issues/view-select/state.tsx index 2b904eb1e..c097c7326 100644 --- a/apps/app/components/issues/view-select/state.tsx +++ b/apps/app/components/issues/view-select/state.tsx @@ -22,6 +22,7 @@ type Props = { partialUpdateIssue: (formData: Partial, issueId: string) => void; position?: "left" | "right"; selfPositioned?: boolean; + customButton?: boolean; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; @@ -31,6 +32,7 @@ export const ViewStateSelect: React.FC = ({ partialUpdateIssue, position = "left", selfPositioned = false, + customButton = false, user, isNotAllowed, }) => { @@ -58,6 +60,19 @@ export const ViewStateSelect: React.FC = ({ const selectedOption = states?.find((s) => s.id === issue.state); + const stateLabel = ( + +
    + {selectedOption && + getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)} + {selectedOption?.name ?? "State"} +
    +
    + ); + return ( = ({ } }} options={options} - label={ - -
    - {selectedOption && - getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)} - {selectedOption?.name ?? "State"} -
    -
    - } + {...(customButton ? { customButton: stateLabel } : { label: stateLabel })} position={position} disabled={isNotAllowed} noChevron diff --git a/apps/app/components/modules/delete-module-modal.tsx b/apps/app/components/modules/delete-module-modal.tsx index f2a9ec7ee..deece2ea5 100644 --- a/apps/app/components/modules/delete-module-modal.tsx +++ b/apps/app/components/modules/delete-module-modal.tsx @@ -111,7 +111,7 @@ export const DeleteModuleModal: React.FC = ({ isOpen, setIsOpen, data, us

    Are you sure you want to delete module-{" "} - + {data?.name} ? All of the data related to the module will be permanently removed. This diff --git a/apps/app/components/modules/sidebar.tsx b/apps/app/components/modules/sidebar.tsx index f453e4c68..de8714968 100644 --- a/apps/app/components/modules/sidebar.tsx +++ b/apps/app/components/modules/sidebar.tsx @@ -52,20 +52,13 @@ const defaultValues: Partial = { }; type Props = { - issues: IIssue[]; module?: IModule; isOpen: boolean; moduleIssues?: IIssue[]; user: ICurrentUserResponse | undefined; }; -export const ModuleDetailsSidebar: React.FC = ({ - issues, - module, - isOpen, - moduleIssues, - user, -}) => { +export const ModuleDetailsSidebar: React.FC = ({ module, isOpen, moduleIssues, user }) => { const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [moduleLinkModal, setModuleLinkModal] = useState(false); @@ -464,9 +457,10 @@ export const ModuleDetailsSidebar: React.FC = ({

    @@ -517,7 +511,7 @@ export const ModuleDetailsSidebar: React.FC = ({ <>
    = ({ completed: module.completed_issues, cancelled: module.cancelled_issues, }} - userAuth={memberRole} + totalIssues={module.total_issues} module={module} />
    diff --git a/apps/app/components/modules/single-module-card.tsx b/apps/app/components/modules/single-module-card.tsx index 21eb25bbb..ac92bdcdd 100644 --- a/apps/app/components/modules/single-module-card.tsx +++ b/apps/app/components/modules/single-module-card.tsx @@ -138,7 +138,7 @@ export const SingleModuleCard: React.FC = ({ module, handleEditModule, us -

    +

    {truncateText(module.name, 75)}

    diff --git a/apps/app/components/pages/create-update-block-inline.tsx b/apps/app/components/pages/create-update-block-inline.tsx index 27e6bd419..0b3fcb17c 100644 --- a/apps/app/components/pages/create-update-block-inline.tsx +++ b/apps/app/components/pages/create-update-block-inline.tsx @@ -126,7 +126,7 @@ export const CreateUpdateBlockInline: React.FC = ({ }) .finally(() => onClose()); }, - [workspaceSlug, projectId, pageId, onClose, setToastAlert] + [workspaceSlug, projectId, pageId, onClose, setToastAlert, user] ); const updatePageBlock = useCallback( @@ -181,7 +181,7 @@ export const CreateUpdateBlockInline: React.FC = ({ }) .finally(() => onClose()); }, - [workspaceSlug, projectId, pageId, data, onClose, setIsSyncing] + [workspaceSlug, projectId, pageId, data, onClose, setIsSyncing, user] ); const handleAutoGenerateDescription = async () => { @@ -195,7 +195,7 @@ export const CreateUpdateBlockInline: React.FC = ({ projectId as string, { prompt: watch("name"), - task: "Generate a proper description for this issue in context of a project management software.", + task: "Generate a proper description for this issue.", }, user ) diff --git a/apps/app/components/pages/delete-page-modal.tsx b/apps/app/components/pages/delete-page-modal.tsx index 6277870d1..eaa7c2189 100644 --- a/apps/app/components/pages/delete-page-modal.tsx +++ b/apps/app/components/pages/delete-page-modal.tsx @@ -136,7 +136,7 @@ export const DeletePageModal: React.FC = ({

    Are you sure you want to delete Page-{" "} - + {data?.name} ? All of the data related to the page will be permanently removed. This diff --git a/apps/app/components/pages/single-page-block.tsx b/apps/app/components/pages/single-page-block.tsx index 3efbd33eb..898f4aba5 100644 --- a/apps/app/components/pages/single-page-block.tsx +++ b/apps/app/components/pages/single-page-block.tsx @@ -194,7 +194,7 @@ export const SinglePageBlock: React.FC = ({ block, projectDetails, index, projectId as string, { prompt: block.name, - task: "Generate a proper description for this issue in context of a project management software.", + task: "Generate a proper description for this issue.", }, user ) @@ -417,7 +417,7 @@ export const SinglePageBlock: React.FC = ({ block, projectDetails, index,

    setCreateBlockForm(true)} >
    diff --git a/apps/app/components/project/delete-project-modal.tsx b/apps/app/components/project/delete-project-modal.tsx index 5a4be1706..eabe85f2d 100644 --- a/apps/app/components/project/delete-project-modal.tsx +++ b/apps/app/components/project/delete-project-modal.tsx @@ -128,13 +128,13 @@ export const DeleteProjectModal: React.FC = ({

    Are you sure you want to delete project{" "} - {selectedProject?.name}? All - of the data related to the project will be permanently removed. This action - cannot be undone + {selectedProject?.name}? + All of the data related to the project will be permanently removed. This + action cannot be undone

    -

    +

    Enter the project name{" "} {selectedProject?.name}{" "} to continue: diff --git a/apps/app/components/project/single-project-card.tsx b/apps/app/components/project/single-project-card.tsx index 66ef6aa2a..04e56652d 100644 --- a/apps/app/components/project/single-project-card.tsx +++ b/apps/app/components/project/single-project-card.tsx @@ -195,7 +195,7 @@ export const SingleProjectCard: React.FC = ({ ) : null}

    -

    +

    {truncateText(project.description ?? "", 100)}

    diff --git a/apps/app/components/ui/avatar.tsx b/apps/app/components/ui/avatar.tsx index 06534c121..c91541aaa 100644 --- a/apps/app/components/ui/avatar.tsx +++ b/apps/app/components/ui/avatar.tsx @@ -13,7 +13,7 @@ import { IUser, IUserLite } from "types"; import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; type AvatarProps = { - user?: Partial | Partial | IUser | IUserLite | undefined | null; + user?: Partial | Partial | null; index?: number; height?: string; width?: string; diff --git a/apps/app/components/ui/buttons/secondary-button.tsx b/apps/app/components/ui/buttons/secondary-button.tsx index 6f0ab72b4..4b857a72c 100644 --- a/apps/app/components/ui/buttons/secondary-button.tsx +++ b/apps/app/components/ui/buttons/secondary-button.tsx @@ -19,11 +19,7 @@ export const SecondaryButton: React.FC = ({ : size === "md" ? "rounded-md px-3.5 py-2 text-sm" : "rounded-lg px-4 py-2 text-base" - } ${ - disabled - ? "cursor-not-allowed border-brand-base bg-brand-surface-1 hover:border-brand-base hover:border-opacity-100 hover:bg-brand-surface-1 hover:bg-opacity-100" - : "" - } ${ + } ${disabled ? "cursor-not-allowed border-brand-base bg-brand-surface-1" : ""} ${ outline ? "bg-transparent hover:bg-brand-surface-2" : "bg-brand-surface-2 hover:border-opacity-70 hover:bg-opacity-70" diff --git a/apps/app/components/ui/custom-menu.tsx b/apps/app/components/ui/custom-menu.tsx index dac7927b1..006802b79 100644 --- a/apps/app/components/ui/custom-menu.tsx +++ b/apps/app/components/ui/custom-menu.tsx @@ -20,6 +20,7 @@ type Props = { position?: "left" | "right"; verticalPosition?: "top" | "bottom"; customButton?: JSX.Element; + menuItemsWhiteBg?: boolean; }; type MenuItemProps = { @@ -44,6 +45,7 @@ const CustomMenu = ({ position = "right", verticalPosition = "bottom", customButton, + menuItemsWhiteBg = false, }: Props) => ( {({ open }) => ( @@ -105,7 +107,7 @@ const CustomMenu = ({ leaveTo="transform opacity-0 scale-95" >
    {children}
    diff --git a/apps/app/components/ui/datepicker.tsx b/apps/app/components/ui/datepicker.tsx index 999b46ce4..80ce7aa91 100644 --- a/apps/app/components/ui/datepicker.tsx +++ b/apps/app/components/ui/datepicker.tsx @@ -11,6 +11,7 @@ type Props = { placeholder?: string; displayShortForm?: boolean; error?: boolean; + noBorder?: boolean; className?: string; isClearable?: boolean; disabled?: boolean; @@ -23,6 +24,7 @@ export const CustomDatePicker: React.FC = ({ placeholder = "Select date", displayShortForm = false, error = false, + noBorder = false, className = "", isClearable = true, disabled = false, @@ -44,7 +46,9 @@ export const CustomDatePicker: React.FC = ({ : "" } ${error ? "border-red-500 bg-red-100" : ""} ${ disabled ? "cursor-not-allowed" : "cursor-pointer" - } w-full rounded-md border border-brand-base bg-transparent caret-transparent ${className}`} + } ${ + noBorder ? "" : "border border-brand-base" + } w-full rounded-md bg-transparent caret-transparent ${className}`} dateFormat="dd-MM-yyyy" isClearable={isClearable} disabled={disabled} diff --git a/apps/app/components/ui/multi-level-dropdown.tsx b/apps/app/components/ui/multi-level-dropdown.tsx index 0033e8e02..b0997972f 100644 --- a/apps/app/components/ui/multi-level-dropdown.tsx +++ b/apps/app/components/ui/multi-level-dropdown.tsx @@ -127,7 +127,7 @@ export const MultiLevelDropdown: React.FC = ({ }} className={`${ child.selected ? "bg-brand-surface-2" : "" - } flex w-full items-center whitespace-nowrap break-all rounded px-1 py-1.5 text-left capitalize text-brand-secondary hover:bg-brand-surface-2`} + } flex w-full items-center whitespace-nowrap break-words rounded px-1 py-1.5 text-left capitalize text-brand-secondary hover:bg-brand-surface-2`} > {child.label} diff --git a/apps/app/components/ui/progress-bar.tsx b/apps/app/components/ui/progress-bar.tsx index 4ef06747e..8ac0b1e1e 100644 --- a/apps/app/components/ui/progress-bar.tsx +++ b/apps/app/components/ui/progress-bar.tsx @@ -63,7 +63,7 @@ export const ProgressBar: React.FC = ({ return ( {renderOuterCircle()} - + ); }; diff --git a/apps/app/components/ui/tooltip.tsx b/apps/app/components/ui/tooltip.tsx index 11504facd..86ca39e54 100644 --- a/apps/app/components/ui/tooltip.tsx +++ b/apps/app/components/ui/tooltip.tsx @@ -42,7 +42,7 @@ export const Tooltip: React.FC = ({ disabled={disabled} content={
    diff --git a/apps/app/components/views/delete-view-modal.tsx b/apps/app/components/views/delete-view-modal.tsx index c57c29dc3..fa5e6781c 100644 --- a/apps/app/components/views/delete-view-modal.tsx +++ b/apps/app/components/views/delete-view-modal.tsx @@ -115,7 +115,7 @@ export const DeleteViewModal: React.FC = ({ isOpen, data, setIsOpen, user

    Are you sure you want to delete view-{" "} - + {data?.name} ? All of the data related to the view will be permanently removed. This diff --git a/apps/app/components/views/select-filters.tsx b/apps/app/components/views/select-filters.tsx index 1b6986346..3351be667 100644 --- a/apps/app/components/views/select-filters.tsx +++ b/apps/app/components/views/select-filters.tsx @@ -20,7 +20,7 @@ import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fe import { PRIORITIES } from "constants/project"; type Props = { - filters: IIssueFilterOptions | IQuery; + filters: Partial | IQuery; onSelect: (option: any) => void; direction?: "left" | "right"; height?: "sm" | "md" | "rg" | "lg"; diff --git a/apps/app/components/workspace/completed-issues-graph.tsx b/apps/app/components/workspace/completed-issues-graph.tsx index e34f1a939..9a1ada618 100644 --- a/apps/app/components/workspace/completed-issues-graph.tsx +++ b/apps/app/components/workspace/completed-issues-graph.tsx @@ -1,15 +1,5 @@ -// recharts -import { - CartesianGrid, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis, -} from "recharts"; // ui -import { CustomMenu } from "components/ui"; +import { CustomMenu, LineGraph } from "components/ui"; // constants import { MONTHS } from "constants/project"; @@ -36,13 +26,6 @@ export const CompletedIssuesGraph: React.FC = ({ month, issues, setMonth }); } - const CustomTooltip = ({ payload, label }: any) => ( -

    -

    {label}

    -
    Completed issues: {payload[0]?.value}
    -
    - ); - return (
    @@ -56,25 +39,37 @@ export const CompletedIssuesGraph: React.FC = ({ month, issues, setMonth
    - - - - - - } /> - item.completed_count === 0) ? ( +
    +

    No issues closed this month

    +
    + ) : ( + <> + ({ + x: item.week_in_month, + y: item.completed_count, + })), + }, + ]} + margin={{ top: 20, right: 20, bottom: 20, left: 20 }} + customYAxisTickValues={data.map((item) => item.completed_count)} + colors={(datum) => datum.color} + theme={{ + background: "rgb(var(--color-bg-base))", + }} /> -
    -
    -

    - - Completed Issues -

    +

    + + Completed Issues +

    + + )}
    ); diff --git a/apps/app/components/workspace/create-workspace-form.tsx b/apps/app/components/workspace/create-workspace-form.tsx index 507d88ea7..42078fe91 100644 --- a/apps/app/components/workspace/create-workspace-form.tsx +++ b/apps/app/components/workspace/create-workspace-form.tsx @@ -31,16 +31,16 @@ type Props = { const restrictedUrls = [ "api", + "installations", + "404", "create-workspace", "error", - "installations", "invitations", "magic-sign-in", "onboarding", "reset-password", - "signin", + "sign-up", "workspace-member-invitation", - "404", ]; export const CreateWorkspaceForm: React.FC = ({ diff --git a/apps/app/components/workspace/delete-workspace-modal.tsx b/apps/app/components/workspace/delete-workspace-modal.tsx index 344d700b0..b9f3e60f4 100644 --- a/apps/app/components/workspace/delete-workspace-modal.tsx +++ b/apps/app/components/workspace/delete-workspace-modal.tsx @@ -120,14 +120,14 @@ export const DeleteWorkspaceModal: React.FC = ({ isOpen, data, onClose, u

    Are you sure you want to delete workspace{" "} - {data?.name}? All of the data - related to the workspace will be permanently removed. This action cannot be - undone. + {data?.name}? All of the + data related to the workspace will be permanently removed. This action cannot + be undone.

    -

    +

    Enter the workspace name{" "} {selectedWorkspace?.name}{" "} to continue: diff --git a/apps/app/components/workspace/issues-pie-chart.tsx b/apps/app/components/workspace/issues-pie-chart.tsx index d65e80c4a..ada35d080 100644 --- a/apps/app/components/workspace/issues-pie-chart.tsx +++ b/apps/app/components/workspace/issues-pie-chart.tsx @@ -1,7 +1,7 @@ -import { useCallback, useState } from "react"; - -// recharts -import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Sector } from "recharts"; +// ui +import { PieGraph } from "components/ui"; +// helpers +import { capitalizeFirstLetter } from "helpers/string.helper"; // types import { IUserStateDistribution } from "types"; // constants @@ -11,113 +11,52 @@ type Props = { groupedIssues: IUserStateDistribution[] | undefined; }; -export const IssuesPieChart: React.FC = ({ groupedIssues }) => { - const [activeIndex, setActiveIndex] = useState(0); - - const onPieEnter = useCallback( - (_: any, index: number) => { - setActiveIndex(index); - }, - [setActiveIndex] - ); - - const renderActiveShape = ({ - cx, - cy, - midAngle, - innerRadius, - outerRadius, - startAngle, - endAngle, - fill, - payload, - value, - }: any) => { - const RADIAN = Math.PI / 180; - const sin = Math.sin(-RADIAN * midAngle); - const cos = Math.cos(-RADIAN * midAngle); - const sx = cx + (outerRadius + 10) * cos; - const sy = cy + (outerRadius + 10) * sin; - const mx = cx + (outerRadius + 30) * cos; - const my = cy + (outerRadius + 30) * sin; - const ex = mx + (cos >= 0 ? 1 : -1) * 22; - const ey = my; - const textAnchor = cos >= 0 ? "start" : "end"; - - return ( - - - {payload.state_group} - - - - - - = 0 ? 1 : -1) * 12} y={ey} textAnchor={textAnchor} fill="#858e96"> - {value} issues - - - ); - }; - - return ( -

    -

    Issues by States

    -
    - - - - {groupedIssues?.map((cell) => ( - - ))} - - - - -
    +export const IssuesPieChart: React.FC = ({ groupedIssues }) => ( +
    +

    Issues by States

    +
    + ({ + id: cell.state_group, + label: cell.state_group, + value: cell.state_count, + color: STATE_GROUP_COLORS[cell.state_group.toLowerCase()], + })) ?? [] + } + height="320px" + innerRadius={0.5} + arcLinkLabel={(cell) => `${capitalizeFirstLetter(cell.label.toString())} (${cell.value})`} + legends={[ + { + anchor: "right", + direction: "column", + justify: false, + translateX: 0, + translateY: 56, + itemsSpacing: 10, + itemWidth: 100, + itemHeight: 18, + itemTextColor: "rgb(var(--color-text-secondary))", + itemDirection: "left-to-right", + itemOpacity: 1, + symbolSize: 12, + symbolShape: "square", + data: + groupedIssues?.map((cell) => ({ + id: cell.state_group, + label: capitalizeFirstLetter(cell.state_group), + value: cell.state_count, + color: STATE_GROUP_COLORS[cell.state_group.toLowerCase()], + })) ?? [], + }, + ]} + activeInnerRadiusOffset={5} + colors={(datum) => datum.data.color} + theme={{ + background: "rgb(var(--color-bg-base))", + }} + />
    - ); -}; +
    +); diff --git a/apps/app/components/workspace/issues-stats.tsx b/apps/app/components/workspace/issues-stats.tsx index 6117ec2a1..8e108a676 100644 --- a/apps/app/components/workspace/issues-stats.tsx +++ b/apps/app/components/workspace/issues-stats.tsx @@ -1,8 +1,9 @@ // components -import { Loader } from "components/ui"; import { ActivityGraph } from "components/workspace"; -// helpers -import { groupBy } from "helpers/array.helper"; +// ui +import { Loader, Tooltip } from "components/ui"; +// icons +import { InformationCircleIcon } from "@heroicons/react/24/outline"; // types import { IUserWorkspaceDashboard } from "types"; @@ -67,7 +68,15 @@ export const IssuesStats: React.FC = ({ data }) => (
    -

    Activity Graph

    +

    + Activity Graph + + + +

    diff --git a/apps/app/constants/fetch-keys.ts b/apps/app/constants/fetch-keys.ts index 75b187b2b..7e77e6dc2 100644 --- a/apps/app/constants/fetch-keys.ts +++ b/apps/app/constants/fetch-keys.ts @@ -1,7 +1,7 @@ import { IAnalyticsParams, IJiraMetadata } from "types"; const paramsToKey = (params: any) => { - const { state, priority, assignees, created_by, labels, target_date } = params; + const { state, priority, assignees, created_by, labels, target_date, sub_issue } = params; let stateKey = state ? state.split(",") : []; let priorityKey = priority ? priority.split(",") : []; @@ -12,6 +12,7 @@ const paramsToKey = (params: any) => { const type = params.type ? params.type.toUpperCase() : "NULL"; const groupBy = params.group_by ? params.group_by.toUpperCase() : "NULL"; const orderBy = params.order_by ? params.order_by.toUpperCase() : "NULL"; + const subIssue = sub_issue ? sub_issue.toUpperCase() : "NULL"; // sorting each keys in ascending order stateKey = stateKey.sort().join("_"); @@ -20,7 +21,20 @@ const paramsToKey = (params: any) => { createdByKey = createdByKey.sort().join("_"); labelsKey = labelsKey.sort().join("_"); - return `${stateKey}_${priorityKey}_${assigneesKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${targetDateKey}`; + return `${stateKey}_${priorityKey}_${assigneesKey}_${createdByKey}_${type}_${groupBy}_${orderBy}_${labelsKey}_${targetDateKey}_${subIssue}`; +}; + +const inboxParamsToKey = (params: any) => { + const { priority, inbox_status } = params; + + let priorityKey = priority ? priority.split(",") : []; + let inboxStatusKey = inbox_status ? inbox_status.split(",") : []; + + // sorting each keys in ascending order + priorityKey = priorityKey.sort().join("_"); + inboxStatusKey = inboxStatusKey.sort().join("_"); + + return `${priorityKey}_${inboxStatusKey}`; }; export const CURRENT_USER = "CURRENT_USER"; @@ -124,6 +138,19 @@ export const VIEW_ISSUES = (viewId: string, params: any) => { return `VIEW_ISSUES_${viewId.toUpperCase()}_${paramsKey.toUpperCase()}`; }; +// inbox +export const INBOX_LIST = (projectId: string) => `INBOX_LIST_${projectId.toUpperCase()}`; +export const INBOX_DETAILS = (inboxId: string) => `INBOX_DETAILS_${inboxId.toUpperCase()}`; +export const INBOX_ISSUES = (inboxId: string, params?: any) => { + if (!params) return `INBOX_ISSUES_${inboxId.toUpperCase()}`; + + const paramsKey = inboxParamsToKey(params); + + return `INBOX_ISSUES_${inboxId.toUpperCase()}_${paramsKey.toUpperCase()}`; +}; +export const INBOX_ISSUE_DETAILS = (inboxId: string, issueId: string) => + `INBOX_ISSUE_DETAILS_${inboxId.toUpperCase()}_${issueId.toUpperCase()}`; + // Issues export const ISSUE_DETAILS = (issueId: string) => `ISSUE_DETAILS_${issueId.toUpperCase()}`; export const SUB_ISSUES = (issueId: string) => `SUB_ISSUES_${issueId.toUpperCase()}`; diff --git a/apps/app/constants/inbox.ts b/apps/app/constants/inbox.ts new file mode 100644 index 000000000..3d7e8a054 --- /dev/null +++ b/apps/app/constants/inbox.ts @@ -0,0 +1,44 @@ +export const INBOX_STATUS = [ + { + key: "pending", + label: "Pending", + value: -2, + textColor: "text-yellow-500", + bgColor: "bg-yellow-500/10", + borderColor: "border-yellow-500", + }, + { + key: "declined", + label: "Declined", + value: -1, + textColor: "text-red-500", + bgColor: "bg-red-500/10", + borderColor: "border-red-500", + }, + { + key: "snoozed", + label: "Snoozed", + value: 0, + textColor: "text-brand-secondary", + bgColor: "bg-gray-500/10", + borderColor: "border-gray-500", + }, + { + key: "accepted", + label: "Accepted", + value: 1, + textColor: "text-green-500", + bgColor: "bg-green-500/10", + borderColor: "border-green-500", + }, + { + key: "duplicate", + label: "Duplicate", + value: 2, + textColor: "text-brand-secondary", + bgColor: "bg-gray-500/10", + borderColor: "border-gray-500", + }, +]; + +export const INBOX_ISSUE_SOURCE = "in-app"; diff --git a/apps/app/constants/spreadsheet.ts b/apps/app/constants/spreadsheet.ts new file mode 100644 index 000000000..b55cbdb23 --- /dev/null +++ b/apps/app/constants/spreadsheet.ts @@ -0,0 +1,62 @@ +import { + CalendarDaysIcon, + PlayIcon, + Squares2X2Icon, + TagIcon, + UserGroupIcon, +} from "@heroicons/react/24/outline"; + +export const SPREADSHEET_COLUMN = [ + { + propertyName: "title", + colName: "Title", + colSize: "440px", + }, + { + propertyName: "state", + colName: "State", + colSize: "128px", + icon: Squares2X2Icon, + ascendingOrder: "state__name", + descendingOrder: "-state__name", + }, + { + propertyName: "priority", + colName: "Priority", + colSize: "128px", + ascendingOrder: "priority", + descendingOrder: "-priority", + }, + { + propertyName: "assignee", + colName: "Assignees", + colSize: "128px", + icon: UserGroupIcon, + ascendingOrder: "assignees__first_name", + descendingOrder: "-assignees__first_name", + }, + { + propertyName: "labels", + colName: "Labels", + colSize: "128px", + icon: TagIcon, + ascendingOrder: "labels__name", + descendingOrder: "-labels__name", + }, + { + propertyName: "due_date", + colName: "Due Date", + colSize: "128px", + icon: CalendarDaysIcon, + ascendingOrder: "-target_date", + descendingOrder: "target_date", + }, + { + propertyName: "estimate", + colName: "Estimate", + colSize: "128px", + icon: PlayIcon, + ascendingOrder: "estimate_point", + descendingOrder: "-estimate_point", + }, +]; diff --git a/apps/app/contexts/inbox-view-context.tsx b/apps/app/contexts/inbox-view-context.tsx new file mode 100644 index 000000000..f6201fbb9 --- /dev/null +++ b/apps/app/contexts/inbox-view-context.tsx @@ -0,0 +1,197 @@ +import { createContext, useCallback, useEffect, useReducer } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// components +import ToastAlert from "components/toast-alert"; +// services +import inboxServices from "services/inbox.service"; +// types +import { IInboxFilterOptions } from "types"; +// fetch-keys +import { INBOX_DETAILS } from "constants/fetch-keys"; + +export const inboxViewContext = createContext({} as ContextType); + +type InboxViewProps = { + filters: IInboxFilterOptions; +}; + +type ReducerActionType = { + type: "REHYDRATE_THEME" | "SET_FILTERS"; + payload?: Partial; +}; + +type ContextType = InboxViewProps & { + setFilters: (filters: Partial) => void; + clearAllFilters: () => void; +}; + +type StateType = { + filters: IInboxFilterOptions; +}; +type ReducerFunctionType = (state: StateType, action: ReducerActionType) => StateType; + +export const initialState: StateType = { + filters: { + priority: null, + inbox_status: null, + }, +}; + +export const reducer: ReducerFunctionType = (state, action) => { + const { type, payload } = action; + + switch (type) { + case "REHYDRATE_THEME": { + return { ...initialState, ...payload }; + } + + case "SET_FILTERS": { + const newState = { + ...state, + filters: { + ...state.filters, + ...payload?.filters, + }, + }; + + return { + ...state, + ...newState, + }; + } + } +}; + +const saveDataToServer = async ( + workspaceSlug: string, + projectId: string, + inboxId: string, + state: any +) => { + await inboxServices.patchInbox(workspaceSlug, projectId, inboxId, { + view_props: state, + }); +}; + +export const InboxViewContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState); + + const router = useRouter(); + const { workspaceSlug, projectId, inboxId } = router.query; + + const { data: inboxDetails, mutate: mutateInboxDetails } = useSWR( + workspaceSlug && projectId && inboxId ? INBOX_DETAILS(inboxId.toString()) : null, + workspaceSlug && projectId && inboxId + ? () => + inboxServices.getInboxById( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString() + ) + : null + ); + + const setFilters = useCallback( + (property: Partial) => { + Object.keys(property).forEach((key) => { + if (property[key as keyof typeof property]?.length === 0) + property[key as keyof typeof property] = null; + }); + + dispatch({ + type: "SET_FILTERS", + payload: { + filters: { + ...state.filters, + ...property, + }, + }, + }); + + if (!workspaceSlug || !projectId || !inboxId) return; + + const newViewProps = { + ...state, + filters: { + ...state.filters, + ...property, + }, + }; + + mutateInboxDetails((prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + view_props: newViewProps, + }; + }, false); + + saveDataToServer( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString(), + newViewProps + ); + }, + [workspaceSlug, projectId, inboxId, mutateInboxDetails, state] + ); + + const clearAllFilters = useCallback(() => { + dispatch({ + type: "SET_FILTERS", + payload: { + filters: { ...initialState.filters }, + }, + }); + + if (!workspaceSlug || !projectId || !inboxId) return; + + const newViewProps = { + ...state, + filters: { ...initialState.filters }, + }; + + mutateInboxDetails((prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + view_props: newViewProps, + }; + }, false); + + saveDataToServer( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString(), + newViewProps + ); + }, [inboxId, mutateInboxDetails, projectId, state, workspaceSlug]); + + useEffect(() => { + dispatch({ + type: "REHYDRATE_THEME", + payload: { + ...inboxDetails?.view_props, + }, + }); + }, [inboxDetails]); + + return ( + + + {children} + + ); +}; diff --git a/apps/app/contexts/issue-view.context.tsx b/apps/app/contexts/issue-view.context.tsx index 619027866..d2a4496c9 100644 --- a/apps/app/contexts/issue-view.context.tsx +++ b/apps/app/contexts/issue-view.context.tsx @@ -168,7 +168,7 @@ export const reducer: ReducerFunctionType = (state, action) => { ...state, filters: { ...state.filters, - ...payload, + ...payload?.filters, }, }; @@ -633,6 +633,7 @@ export const IssueViewContextProvider: React.FC<{ children: React.ReactNode }> = mutateModuleDetails, viewId, mutateViewDetails, + user, ] ); diff --git a/apps/app/helpers/date-time.helper.ts b/apps/app/helpers/date-time.helper.ts index 6333e7558..d462474a4 100644 --- a/apps/app/helpers/date-time.helper.ts +++ b/apps/app/helpers/date-time.helper.ts @@ -25,13 +25,18 @@ export const findHowManyDaysLeft = (date: string | Date) => { return Math.ceil(timeDiff / (1000 * 3600 * 24)); }; -export const getDatesInRange = (startDate: Date, endDate: Date) => { +export const getDatesInRange = (startDate: string | Date, endDate: string | Date) => { + startDate = new Date(startDate); + endDate = new Date(endDate); + const date = new Date(startDate.getTime()); const dates = []; + while (date <= endDate) { dates.push(new Date(date)); date.setDate(date.getDate() + 1); } + return dates; }; diff --git a/apps/app/hooks/use-inbox-view.tsx b/apps/app/hooks/use-inbox-view.tsx new file mode 100644 index 000000000..a5dedc380 --- /dev/null +++ b/apps/app/hooks/use-inbox-view.tsx @@ -0,0 +1,61 @@ +import { useContext } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// contexts +import { inboxViewContext } from "contexts/inbox-view-context"; +// services +import inboxServices from "services/inbox.service"; +// types +import { IInboxQueryParams } from "types"; +// fetch-keys +import { INBOX_ISSUES } from "constants/fetch-keys"; + +const useInboxView = () => { + const { filters, setFilters, clearAllFilters } = useContext(inboxViewContext); + + const router = useRouter(); + const { workspaceSlug, projectId, inboxId } = router.query; + + const params: IInboxQueryParams = { + priority: filters?.priority ? filters?.priority.join(",") : null, + inbox_status: filters?.inbox_status ? filters?.inbox_status.join(",") : null, + }; + + const { data: inboxIssues, mutate: mutateInboxIssues } = useSWR( + workspaceSlug && projectId && inboxId && params + ? INBOX_ISSUES(inboxId.toString(), params) + : null, + workspaceSlug && projectId && inboxId && params + ? () => + inboxServices.getInboxIssues( + workspaceSlug.toString(), + projectId.toString(), + inboxId.toString(), + params + ) + : null + ); + + let filtersLength = 0; + Object.keys(filters ?? {}).forEach((key) => { + const filterKey = key as keyof typeof filters; + + if (filters[filterKey] && Array.isArray(filters[filterKey])) + filtersLength += (filters[filterKey] ?? []).length; + }); + + return { + filters, + setFilters, + clearAllFilters, + filtersLength, + params, + issues: inboxIssues, + mutate: mutateInboxIssues, + } as const; +}; + +export default useInboxView; diff --git a/apps/app/hooks/use-spreadsheet-issues-view.tsx b/apps/app/hooks/use-spreadsheet-issues-view.tsx new file mode 100644 index 000000000..6e7b66bec --- /dev/null +++ b/apps/app/hooks/use-spreadsheet-issues-view.tsx @@ -0,0 +1,125 @@ +import { useContext } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// contexts +import { issueViewContext } from "contexts/issue-view.context"; +// services +import issuesService from "services/issues.service"; +import cyclesService from "services/cycles.service"; +import modulesService from "services/modules.service"; +// types +import { IIssue } from "types"; +// fetch-keys +import { + CYCLE_ISSUES_WITH_PARAMS, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST_WITH_PARAMS, + VIEW_ISSUES, +} from "constants/fetch-keys"; + +const useSpreadsheetIssuesView = () => { + const { + issueView, + orderBy, + setOrderBy, + filters, + setFilters, + resetFilterToDefault, + setNewFilterDefaultView, + setIssueView, + } = useContext(issueViewContext); + + const router = useRouter(); + const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + + const params: any = { + order_by: orderBy, + assignees: filters?.assignees ? filters?.assignees.join(",") : undefined, + state: filters?.state ? filters?.state.join(",") : undefined, + priority: filters?.priority ? filters?.priority.join(",") : undefined, + type: filters?.type ? filters?.type : undefined, + labels: filters?.labels ? filters?.labels.join(",") : undefined, + issue__assignees__id: filters?.issue__assignees__id + ? filters?.issue__assignees__id.join(",") + : undefined, + issue__labels__id: filters?.issue__labels__id + ? filters?.issue__labels__id.join(",") + : undefined, + created_by: filters?.created_by ? filters?.created_by.join(",") : undefined, + sub_issue: "false", + }; + + const { data: projectSpreadsheetIssues } = useSWR( + workspaceSlug && projectId + ? PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params) + : null, + workspaceSlug && projectId + ? () => + issuesService.getIssuesWithParams(workspaceSlug.toString(), projectId.toString(), params) + : null + ); + + const { data: cycleSpreadsheetIssues } = useSWR( + workspaceSlug && projectId && cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) + : null, + workspaceSlug && projectId && cycleId + ? () => + cyclesService.getCycleIssuesWithParams( + workspaceSlug.toString(), + projectId.toString(), + cycleId.toString(), + params + ) + : null + ); + + const { data: moduleSpreadsheetIssues } = useSWR( + workspaceSlug && projectId && moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) + : null, + workspaceSlug && projectId && moduleId + ? () => + modulesService.getModuleIssuesWithParams( + workspaceSlug.toString(), + projectId.toString(), + moduleId.toString(), + params + ) + : null + ); + + const { data: viewSpreadsheetIssues } = useSWR( + workspaceSlug && projectId && viewId && params ? VIEW_ISSUES(viewId.toString(), params) : null, + workspaceSlug && projectId && viewId && params + ? () => + issuesService.getIssuesWithParams(workspaceSlug.toString(), projectId.toString(), params) + : null + ); + + const spreadsheetIssues = cycleId + ? (cycleSpreadsheetIssues as IIssue[]) + : moduleId + ? (moduleSpreadsheetIssues as IIssue[]) + : viewId + ? (viewSpreadsheetIssues as IIssue[]) + : (projectSpreadsheetIssues as IIssue[]); + + return { + issueView, + spreadsheetIssues: spreadsheetIssues ?? [], + orderBy, + setOrderBy, + filters, + setFilters, + params, + resetFilterToDefault, + setNewFilterDefaultView, + setIssueView, + } as const; +}; + +export default useSpreadsheetIssuesView; diff --git a/apps/app/hooks/use-sub-issue.tsx b/apps/app/hooks/use-sub-issue.tsx new file mode 100644 index 000000000..8eb30fd0b --- /dev/null +++ b/apps/app/hooks/use-sub-issue.tsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// services +import issuesService from "services/issues.service"; +// types +import { ISubIssueResponse } from "types"; +// fetch-keys +import { SUB_ISSUES } from "constants/fetch-keys"; + +const useSubIssue = (issueId: string, isExpanded: boolean) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const shouldFetch = workspaceSlug && projectId && issueId && isExpanded; + + const { data: subIssuesResponse, isLoading } = useSWR( + shouldFetch ? SUB_ISSUES(issueId as string) : null, + shouldFetch + ? () => + issuesService.subIssues(workspaceSlug as string, projectId as string, issueId as string) + : null + ); + + return { + subIssues: subIssuesResponse?.sub_issues ?? [], + isLoading, + }; +}; + +export default useSubIssue; diff --git a/apps/app/package.json b/apps/app/package.json index 01287f068..64eb1aa99 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -46,7 +46,6 @@ "react-dropzone": "^14.2.3", "react-hook-form": "^7.38.0", "react-markdown": "^8.0.7", - "recharts": "^2.3.2", "remirror": "^2.0.23", "swr": "^2.1.3", "tlds": "^1.238.0", diff --git a/apps/app/pages/[workspaceSlug]/index.tsx b/apps/app/pages/[workspaceSlug]/index.tsx index 233fd9b8a..3c75890f6 100644 --- a/apps/app/pages/[workspaceSlug]/index.tsx +++ b/apps/app/pages/[workspaceSlug]/index.tsx @@ -49,7 +49,7 @@ const WorkspacePage: NextPage = () => { )}
    -
    +

    Plane is open source, support us by starring us on GitHub.

    diff --git a/apps/app/pages/[workspaceSlug]/me/profile/activity.tsx b/apps/app/pages/[workspaceSlug]/me/profile/activity.tsx index db9b1e55b..3fe99df92 100644 --- a/apps/app/pages/[workspaceSlug]/me/profile/activity.tsx +++ b/apps/app/pages/[workspaceSlug]/me/profile/activity.tsx @@ -4,6 +4,7 @@ import useSWR from "swr"; import userService from "services/user.service"; // layouts import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; +import SettingsNavbar from "layouts/settings-navbar"; // components import { Feeds } from "components/core"; // ui @@ -11,7 +12,6 @@ import { Loader } from "components/ui"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; // fetch-keys import { USER_ACTIVITY } from "constants/fetch-keys"; -import SettingsNavbar from "layouts/settings-navbar"; const ProfileActivity = () => { const { data: userActivity } = useSWR(USER_ACTIVITY, () => userService.getUserActivity()); @@ -24,8 +24,8 @@ const ProfileActivity = () => { } > -
    -
    +
    +

    Profile Settings

    diff --git a/apps/app/pages/[workspaceSlug]/me/profile/index.tsx b/apps/app/pages/[workspaceSlug]/me/profile/index.tsx index ce589c41d..247ef7e03 100644 --- a/apps/app/pages/[workspaceSlug]/me/profile/index.tsx +++ b/apps/app/pages/[workspaceSlug]/me/profile/index.tsx @@ -10,6 +10,7 @@ import useUserAuth from "hooks/use-user-auth"; import useToast from "hooks/use-toast"; // layouts import { WorkspaceAuthorizationLayout } from "layouts/auth-layout"; +import SettingsNavbar from "layouts/settings-navbar"; // components import { ImageUploadModal } from "components/core"; // ui @@ -22,7 +23,6 @@ import type { NextPage } from "next"; import type { IUser } from "types"; // constants import { USER_ROLES } from "constants/workspace"; -import SettingsNavbar from "layouts/settings-navbar"; const defaultValues: Partial = { avatar: "", @@ -136,8 +136,8 @@ const Profile: NextPage = () => { userImage /> {myProfile ? ( -

    -
    +
    +

    Profile Settings

    diff --git a/apps/app/pages/[workspaceSlug]/me/profile/preferences.tsx b/apps/app/pages/[workspaceSlug]/me/profile/preferences.tsx index f2fe98f80..abaaefcd8 100644 --- a/apps/app/pages/[workspaceSlug]/me/profile/preferences.tsx +++ b/apps/app/pages/[workspaceSlug]/me/profile/preferences.tsx @@ -25,7 +25,7 @@ const ProfilePreferences = () => { if (myProfile?.theme.palette) setPreLoadedData(myProfile.theme); if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true); } - }, [myProfile, theme]); + }, [myProfile, theme, customThemeSelectorOptions]); return ( { } > {myProfile ? ( -

    -
    +
    +

    Profile Settings

    diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx index 718a8bd3a..78af8e9e1 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/[cycleId].tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; -import useSWR, { mutate } from "swr"; +import useSWR from "swr"; // icons import { ArrowLeftIcon } from "@heroicons/react/24/outline"; import { CyclesIcon } from "components/icons"; @@ -16,7 +16,6 @@ import { CycleDetailsSidebar } from "components/cycles"; // services import issuesService from "services/issues.service"; import cycleServices from "services/cycles.service"; -import projectService from "services/project.service"; // hooks import useToast from "hooks/use-toast"; import useUserAuth from "hooks/use-user-auth"; @@ -28,14 +27,10 @@ import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; // helpers import { truncateText } from "helpers/string.helper"; import { getDateRangeStatus } from "helpers/date-time.helper"; +// types +import { ISearchIssueResponse } from "types"; // fetch-keys -import { - CYCLE_ISSUES, - CYCLES_LIST, - PROJECT_DETAILS, - CYCLE_DETAILS, - PROJECT_ISSUES_LIST, -} from "constants/fetch-keys"; +import { CYCLES_LIST, CYCLE_DETAILS } from "constants/fetch-keys"; const SingleCycle: React.FC = () => { const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false); @@ -49,13 +44,6 @@ const SingleCycle: React.FC = () => { const { setToastAlert } = useToast(); - const { data: activeProject } = useSWR( - workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null, - workspaceSlug && projectId - ? () => projectService.getProject(workspaceSlug as string, projectId as string) - : null - ); - const { data: cycles } = useSWR( workspaceSlug && projectId ? CYCLES_LIST(projectId as string) : null, workspaceSlug && projectId @@ -80,27 +68,25 @@ const SingleCycle: React.FC = () => { ? getDateRangeStatus(cycleDetails?.start_date, cycleDetails?.end_date) : "draft"; - const { data: issues } = useSWR( - workspaceSlug && projectId - ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) - : null, - workspaceSlug && projectId - ? () => issuesService.getIssues(workspaceSlug as string, projectId as string) - : null - ); - const openIssuesListModal = () => { setCycleIssuesListModal(true); }; - const handleAddIssuesToCycle = async (data: { issues: string[] }) => { + const handleAddIssuesToCycle = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId) return; + const payload = { + issues: data.map((i) => i.id), + }; + await issuesService - .addIssueToCycle(workspaceSlug as string, projectId as string, cycleId as string, data, user) - .then(() => { - mutate(CYCLE_ISSUES(cycleId as string)); - }) + .addIssueToCycle( + workspaceSlug as string, + projectId as string, + cycleId as string, + payload, + user + ) .catch(() => { setToastAlert({ type: "error", @@ -115,15 +101,15 @@ const SingleCycle: React.FC = () => { setCycleIssuesListModal(false)} - issues={issues?.filter((i) => !i.cycle_id) ?? []} + searchParams={{ cycle: true }} handleOnSubmit={handleAddIssuesToCycle} /> } @@ -142,7 +128,7 @@ const SingleCycle: React.FC = () => { {truncateText(cycle.name, 40)} diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx index 2bd2aa1b9..502578927 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/cycles/index.tsx @@ -32,7 +32,7 @@ import { ListBulletIcon, PlusIcon, Squares2X2Icon } from "@heroicons/react/24/ou import { SelectCycleType } from "types"; import type { NextPage } from "next"; // fetch-keys -import { CURRENT_CYCLE_LIST, PROJECT_DETAILS } from "constants/fetch-keys"; +import { PROJECT_DETAILS } from "constants/fetch-keys"; const tabsList = ["All", "Active", "Upcoming", "Completed", "Drafts"]; @@ -72,14 +72,6 @@ const ProjectCycles: NextPage = () => { : null ); - const { data: currentCycle } = useSWR( - workspaceSlug && projectId ? CURRENT_CYCLE_LIST(projectId as string) : null, - workspaceSlug && projectId - ? () => - cycleService.getCyclesWithParams(workspaceSlug as string, projectId as string, "current") - : null - ); - useEffect(() => { if (createUpdateCycleModal) return; const timer = setTimeout(() => { @@ -201,15 +193,7 @@ const ProjectCycles: NextPage = () => { {cyclesView !== "gantt_chart" && ( - {currentCycle?.[0] ? ( - - ) : ( -

    -

    - No active cycle is present. -

    -
    - )} + )} diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/inbox/[inboxId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/inbox/[inboxId].tsx new file mode 100644 index 000000000..dc301e0d8 --- /dev/null +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/inbox/[inboxId].tsx @@ -0,0 +1,67 @@ +import { useRouter } from "next/router"; + +// hooks +import useProjectDetails from "hooks/use-project-details"; +// layouts +import { ProjectAuthorizationWrapper } from "layouts/auth-layout"; +// contexts +import { InboxViewContextProvider } from "contexts/inbox-view-context"; +// components +import { InboxActionHeader, InboxMainContent, IssuesListSidebar } from "components/inbox"; +// helper +import { truncateText } from "helpers/string.helper"; +// ui +import { PrimaryButton } from "components/ui"; +import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; +// icons +import { PlusIcon } from "@heroicons/react/24/outline"; +// types +import type { NextPage } from "next"; + +const ProjectInbox: NextPage = () => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { projectDetails } = useProjectDetails(); + + return ( + + + + + + } + right={ +
    + { + const e = new KeyboardEvent("keydown", { key: "c" }); + document.dispatchEvent(e); + }} + > + + Add Issue + +
    + } + > +
    + +
    + +
    + +
    +
    +
    +
    +
    + ); +}; + +export default ProjectInbox; diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx index 5f6615f55..93bfecfe0 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/issues/[issueId].tsx @@ -1,36 +1,27 @@ import React, { useCallback, useEffect } from "react"; -import Link from "next/link"; import { useRouter } from "next/router"; import useSWR, { mutate } from "swr"; // react-hook-form import { useForm } from "react-hook-form"; -// hooks -import useUserAuth from "hooks/use-user-auth"; // services import issuesService from "services/issues.service"; +// hooks +import useUserAuth from "hooks/use-user-auth"; // layouts import { ProjectAuthorizationWrapper } from "layouts/auth-layout"; // components -import { - IssueDescriptionForm, - SubIssuesList, - IssueDetailsSidebar, - IssueActivitySection, - AddComment, - IssueAttachmentUpload, - IssueAttachments, -} from "components/issues"; +import { IssueDetailsSidebar, IssueMainContent } from "components/issues"; // ui -import { Loader, CustomMenu } from "components/ui"; +import { Loader } from "components/ui"; import { Breadcrumbs } from "components/breadcrumbs"; // types import { IIssue } from "types"; import type { NextPage } from "next"; // fetch-keys -import { PROJECT_ISSUES_ACTIVITY, ISSUE_DETAILS, SUB_ISSUES } from "constants/fetch-keys"; +import { PROJECT_ISSUES_ACTIVITY, ISSUE_DETAILS } from "constants/fetch-keys"; const defaultValues = { name: "", @@ -40,8 +31,6 @@ const defaultValues = { state: "", assignees_list: [], priority: "low", - blockers_list: [], - blocked_list: [], target_date: new Date().toString(), issue_cycle: null, issue_module: null, @@ -62,18 +51,6 @@ const IssueDetailsPage: NextPage = () => { : null ); - const { data: siblingIssues } = useSWR( - workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null, - workspaceSlug && projectId && issueDetails?.parent - ? () => - issuesService.subIssues( - workspaceSlug as string, - projectId as string, - issueDetails.parent ?? "" - ) - : null - ); - const { reset, control, watch } = useForm({ defaultValues, }); @@ -86,6 +63,7 @@ const IssueDetailsPage: NextPage = () => { ISSUE_DETAILS(issueId as string), (prevData) => { if (!prevData) return prevData; + return { ...prevData, ...formData, @@ -94,10 +72,13 @@ const IssueDetailsPage: NextPage = () => { false ); - const payload = { ...formData }; + const payload: Partial = { + ...formData, + }; + await issuesService .patchIssue(workspaceSlug as string, projectId as string, issueId as string, payload, user) - .then((res) => { + .then(() => { mutateIssueDetails(); mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }) @@ -105,7 +86,7 @@ const IssueDetailsPage: NextPage = () => { console.error(e); }); }, - [workspaceSlug, issueId, projectId, mutateIssueDetails] + [workspaceSlug, issueId, projectId, mutateIssueDetails, user] ); useEffect(() => { @@ -114,12 +95,6 @@ const IssueDetailsPage: NextPage = () => { mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); reset({ ...issueDetails, - blockers_list: - issueDetails.blockers_list ?? - issueDetails.blocker_issues?.map((issue) => issue.blocker_issue_detail?.id), - blocked_list: - issueDetails.blocks_list ?? - issueDetails.blocked_issues?.map((issue) => issue.blocked_issue_detail?.id), assignees_list: issueDetails.assignees_list ?? issueDetails.assignee_details?.map((user) => user.id), labels_list: issueDetails.labels_list ?? issueDetails.labels, @@ -146,71 +121,7 @@ const IssueDetailsPage: NextPage = () => { {issueDetails && projectId ? (
    -
    - {issueDetails?.parent && issueDetails.parent !== "" ? ( -
    - - - - - {issueDetails.project_detail.identifier}- - {issueDetails.parent_detail?.sequence_id} - - - {issueDetails.parent_detail?.name.substring(0, 50)} - - - - - - {siblingIssues && siblingIssues.length > 0 ? ( - siblingIssues.map((issue: IIssue) => ( - - - - {issueDetails.project_detail.identifier}-{issue.sequence_id} - - - - )) - ) : ( - - No other sibling issues - - )} - -
    - ) : null} - -
    - -
    -
    -
    -

    Attachments

    -
    - - -
    -
    -
    -

    Comments/Activity

    - - -
    +
    { const [analyticsModal, setAnalyticsModal] = useState(false); @@ -38,6 +40,13 @@ const ProjectIssues: NextPage = () => { : null ); + const { data: inboxList } = useSWR( + workspaceSlug && projectId ? INBOX_LIST(projectId as string) : null, + workspaceSlug && projectId + ? () => inboxService.getInboxes(workspaceSlug as string, projectId as string) + : null + ); + return ( { > Analytics + {projectDetails && projectDetails.inbox_view && ( + + + + Inbox + {inboxList && inboxList?.[0]?.pending_issue_count !== 0 && ( + + {inboxList?.[0]?.pending_issue_count} + + )} + + + + )} { diff --git a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx index 657f48fe2..51b6b7a5b 100644 --- a/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx +++ b/apps/app/pages/[workspaceSlug]/projects/[projectId]/modules/[moduleId].tsx @@ -2,19 +2,12 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; -import useSWR, { mutate } from "swr"; +import useSWR from "swr"; // icons -import { - ArrowLeftIcon, - ListBulletIcon, - PlusIcon, - RectangleGroupIcon, - RectangleStackIcon, -} from "@heroicons/react/24/outline"; +import { ArrowLeftIcon, RectangleGroupIcon } from "@heroicons/react/24/outline"; // services import modulesService from "services/modules.service"; -import issuesService from "services/issues.service"; // hooks import useToast from "hooks/use-toast"; import useUserAuth from "hooks/use-user-auth"; @@ -27,20 +20,14 @@ import { ExistingIssuesListModal, IssuesFilterView, IssuesView } from "component import { ModuleDetailsSidebar } from "components/modules"; import { AnalyticsProjectModal } from "components/analytics"; // ui -import { CustomMenu, EmptySpace, EmptySpaceItem, SecondaryButton, Spinner } from "components/ui"; +import { CustomMenu, SecondaryButton } from "components/ui"; import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; // helpers import { truncateText } from "helpers/string.helper"; // types -import { IModule } from "types"; - +import { ISearchIssueResponse } from "types"; // fetch-keys -import { - MODULE_DETAILS, - MODULE_ISSUES, - MODULE_LIST, - PROJECT_ISSUES_LIST, -} from "constants/fetch-keys"; +import { MODULE_DETAILS, MODULE_ISSUES, MODULE_LIST } from "constants/fetch-keys"; const SingleModule: React.FC = () => { const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false); @@ -54,15 +41,6 @@ const SingleModule: React.FC = () => { const { setToastAlert } = useToast(); - const { data: issues } = useSWR( - workspaceSlug && projectId - ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) - : null, - workspaceSlug && projectId - ? () => issuesService.getIssues(workspaceSlug as string, projectId as string) - : null - ); - const { data: modules } = useSWR( workspaceSlug && projectId ? MODULE_LIST(projectId as string) : null, workspaceSlug && projectId @@ -82,7 +60,7 @@ const SingleModule: React.FC = () => { : null ); - const { data: moduleDetails } = useSWR( + const { data: moduleDetails } = useSWR( moduleId ? MODULE_DETAILS(moduleId as string) : null, workspaceSlug && projectId ? () => @@ -94,18 +72,21 @@ const SingleModule: React.FC = () => { : null ); - const handleAddIssuesToModule = async (data: { issues: string[] }) => { + const handleAddIssuesToModule = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId) return; + const payload = { + issues: data.map((i) => i.id), + }; + await modulesService .addIssuesToModule( workspaceSlug as string, projectId as string, moduleId as string, - data, + payload, user ) - .then(() => mutate(MODULE_ISSUES(moduleId as string))) .catch(() => setToastAlert({ type: "error", @@ -124,7 +105,7 @@ const SingleModule: React.FC = () => { setModuleIssuesListModal(false)} - issues={issues?.filter((i) => !i.module_id) ?? []} + searchParams={{ module: true }} handleOnSubmit={handleAddIssuesToModule} /> {
    , property: "page_view", }, + { + title: "Inbox", + description: + "Inbox are enabled for all the projects in this workspace. Access it from the issues views page.", + icon: , + property: "inbox_view", + }, ]; const getEventType = (feature: string, toggle: boolean): MiscellaneousEventType => { @@ -67,8 +74,10 @@ const getEventType = (feature: string, toggle: boolean): MiscellaneousEventType return toggle ? "TOGGLE_VIEW_ON" : "TOGGLE_VIEW_OFF"; case "Pages": return toggle ? "TOGGLE_PAGES_ON" : "TOGGLE_PAGES_OFF"; + case "Inbox": + return toggle ? "TOGGLE_INBOX_ON" : "TOGGLE_INBOX_OFF"; default: - return toggle ? "TOGGLE_PAGES_ON" : "TOGGLE_PAGES_OFF"; + throw new Error("Invalid feature"); } }; @@ -195,9 +204,10 @@ const FeaturesSettings: NextPage = () => { projectIdentifier: projectDetails?.identifier, projectName: projectDetails?.name, }, - !projectDetails?.[feature.property as keyof IProject] - ? getEventType(feature.title, true) - : getEventType(feature.title, false), + getEventType( + feature.title, + !projectDetails?.[feature.property as keyof IProject] + ), user ); handleSubmit({ diff --git a/apps/app/pages/index.tsx b/apps/app/pages/index.tsx index 96d8441d7..abe317415 100644 --- a/apps/app/pages/index.tsx +++ b/apps/app/pages/index.tsx @@ -21,6 +21,12 @@ import { import { Spinner } from "components/ui"; // icons import Logo from "public/logo.png"; +// types +type EmailPasswordFormValues = { + email: string; + password?: string; + medium?: string; +}; const HomePage: NextPage = () => { const { user, isLoading, mutateUser } = useUserAuth("sign-in"); @@ -40,14 +46,12 @@ const HomePage: NextPage = () => { } else { throw Error("Cant find credentials"); } - } catch (error: any) { - console.log(error); + } catch (err: any) { setToastAlert({ title: "Error signing in!", type: "error", message: - error?.error || - "Something went wrong. Please try again later or contact the support team.", + err?.error || "Something went wrong. Please try again later or contact the support team.", }); } }; @@ -65,44 +69,52 @@ const HomePage: NextPage = () => { } else { throw Error("Cant find credentials"); } - } catch (error: any) { - console.log(error); + } catch (err: any) { setToastAlert({ title: "Error signing in!", type: "error", message: - error?.error || - "Something went wrong. Please try again later or contact the support team.", + err?.error || "Something went wrong. Please try again later or contact the support team.", }); } }; - const handleEmailPasswordSignIn = async (response: any) => { - try { - if (response) mutateUser(); - } catch (error: any) { - console.log(error); - setToastAlert({ - title: "Error signing in!", - type: "error", - message: - error?.error || - "Something went wrong. Please try again later or contact the support team.", - }); - } + const handlePasswordSignIn = async (formData: EmailPasswordFormValues) => { + await authenticationService + .emailLogin(formData) + .then((response) => { + try { + if (response) mutateUser(); + } catch (err: any) { + setToastAlert({ + type: "error", + title: "Error!", + message: + err?.error || + "Something went wrong. Please try again later or contact the support team.", + }); + } + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: + err?.error || + "Something went wrong. Please try again later or contact the support team.", + }) + ); }; const handleEmailCodeSignIn = async (response: any) => { try { if (response) mutateUser(); - } catch (error: any) { - console.log(error); + } catch (err: any) { setToastAlert({ - title: "Error signing in!", type: "error", + title: "Error!", message: - error?.error || - "Something went wrong. Please try again later or contact the support team.", + err?.error || "Something went wrong. Please try again later or contact the support team.", }); } }; @@ -114,7 +126,6 @@ const HomePage: NextPage = () => {
    - {/*
    Validating authentication
    */}
    ) : (
    @@ -137,7 +148,7 @@ const HomePage: NextPage = () => {
    ) : ( - + )}
    diff --git a/apps/app/pages/reset-password.tsx b/apps/app/pages/reset-password.tsx index 86ab556b1..82ee32114 100644 --- a/apps/app/pages/reset-password.tsx +++ b/apps/app/pages/reset-password.tsx @@ -63,11 +63,13 @@ const ResetPasswordPage: NextPage = () => { }); router.push("/"); }) - .catch(() => + .catch((err) => setToastAlert({ type: "error", title: "Error!", - message: "Something went wrong. Please try again.", + message: + err?.error || + "Something went wrong. Please try again later or contact the support team.", }) ); }; diff --git a/apps/app/pages/sign-up.tsx b/apps/app/pages/sign-up.tsx new file mode 100644 index 000000000..2c86d96ff --- /dev/null +++ b/apps/app/pages/sign-up.tsx @@ -0,0 +1,83 @@ +import React from "react"; + +import Image from "next/image"; +import { useRouter } from "next/router"; + +// services +import authenticationService from "services/authentication.service"; +// hooks +import useUserAuth from "hooks/use-user-auth"; +import useToast from "hooks/use-toast"; +// layouts +import DefaultLayout from "layouts/default-layout"; +// components +import { EmailPasswordForm } from "components/account"; +// images +import Logo from "public/logo.png"; +// types +import type { NextPage } from "next"; +type EmailPasswordFormValues = { + email: string; + password?: string; + medium?: string; +}; + +const SignUp: NextPage = () => { + const router = useRouter(); + + const { setToastAlert } = useToast(); + + const { mutateUser } = useUserAuth("sign-in"); + + const handleSignUp = async (formData: EmailPasswordFormValues) => { + const payload = { + email: formData.email, + password: formData.password ?? "", + }; + + await authenticationService + .emailSignUp(payload) + .then(async (response) => { + setToastAlert({ + type: "success", + title: "Success!", + message: "Account created successfully.", + }); + + if (response) await mutateUser(); + router.push("/"); + }) + .catch((err) => + setToastAlert({ + type: "error", + title: "Error!", + message: + err?.error || + "Something went wrong. Please try again later or contact the support team.", + }) + ); + }; + + return ( + +
    +
    +
    +
    + Plane Web Logo +
    + Create a new Plane Account +
    +
    + +
    + +
    +
    +
    +
    +
    + ); +}; + +export default SignUp; diff --git a/apps/app/public/empty-state/empty-inbox.svg b/apps/app/public/empty-state/empty-inbox.svg new file mode 100644 index 000000000..39d7ca5e2 --- /dev/null +++ b/apps/app/public/empty-state/empty-inbox.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/app/services/api.service.ts b/apps/app/services/api.service.ts index ef198c221..361fea03e 100644 --- a/apps/app/services/api.service.ts +++ b/apps/app/services/api.service.ts @@ -7,6 +7,7 @@ const nonValidatedRoutes = [ "/magic-sign-in", "/reset-password", "/workspace-member-invitation", + "/sign-up", ]; const validateRouteCheck = (route: string): boolean => { diff --git a/apps/app/services/authentication.service.ts b/apps/app/services/authentication.service.ts index 291f255d8..86f55e329 100644 --- a/apps/app/services/authentication.service.ts +++ b/apps/app/services/authentication.service.ts @@ -20,6 +20,18 @@ class AuthService extends APIService { }); } + async emailSignUp(data: { email: string; password: string }) { + return this.post("/api/sign-up/", data, { headers: {} }) + .then((response) => { + this.setAccessToken(response?.data?.access_token); + this.setRefreshToken(response?.data?.refresh_token); + return response?.data; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + async socialAuth(data: any) { return this.post("/api/social-auth/", data, { headers: {} }) .then((response) => { @@ -39,6 +51,7 @@ class AuthService extends APIService { throw error?.response?.data; }); } + async magicSignIn(data: any) { const response = await this.post("/api/magic-sign-in/", data, { headers: {} }); if (response?.status === 200) { diff --git a/apps/app/services/inbox.service.ts b/apps/app/services/inbox.service.ts new file mode 100644 index 000000000..61949c877 --- /dev/null +++ b/apps/app/services/inbox.service.ts @@ -0,0 +1,183 @@ +import APIService from "services/api.service"; +import trackEventServices from "services/track-event.service"; + +const { NEXT_PUBLIC_API_BASE_URL } = process.env; + +const trackEvent = + process.env.NEXT_PUBLIC_TRACK_EVENTS === "true" || process.env.NEXT_PUBLIC_TRACK_EVENTS === "1"; + +// types +import type { + IInboxIssue, + IInbox, + TInboxStatus, + IInboxIssueDetail, + ICurrentUserResponse, + IInboxFilterOptions, + IInboxQueryParams, +} from "types"; + +class InboxServices extends APIService { + constructor() { + super(NEXT_PUBLIC_API_BASE_URL || "http://localhost:8000"); + } + + async getInboxes(workspaceSlug: string, projectId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getInboxById(workspaceSlug: string, projectId: string, inboxId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async patchInbox( + workspaceSlug: string, + projectId: string, + inboxId: string, + data: Partial + ): Promise { + return this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/`, + data + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getInboxIssues( + workspaceSlug: string, + projectId: string, + inboxId: string, + params?: IInboxQueryParams + ): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/`, + { params } + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getInboxIssueById( + workspaceSlug: string, + projectId: string, + inboxId: string, + inboxIssueId: string + ): Promise { + return this.get( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/` + ) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async deleteInboxIssue( + workspaceSlug: string, + projectId: string, + inboxId: string, + inboxIssueId: string, + user: ICurrentUserResponse | undefined + ): Promise { + return this.delete( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/` + ) + .then((response) => { + if (trackEvent) + trackEventServices.trackInboxEvent(response?.data, "INBOX_ISSUE_DELETE", user); + return response?.data; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + + async markInboxStatus( + workspaceSlug: string, + projectId: string, + inboxId: string, + inboxIssueId: string, + data: TInboxStatus, + user: ICurrentUserResponse | undefined + ): Promise { + return this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`, + data + ) + .then((response) => { + const action = + data.status === -1 + ? "INBOX_ISSUE_REJECTED" + : data.status === 0 + ? "INBOX_ISSUE_SNOOZED" + : data.status === 1 + ? "INBOX_ISSUE_ACCEPTED" + : "INBOX_ISSUE_DUPLICATED"; + if (trackEvent) trackEventServices.trackInboxEvent(response?.data, action, user); + return response?.data; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + + async patchInboxIssue( + workspaceSlug: string, + projectId: string, + inboxId: string, + inboxIssueId: string, + data: { issue: Partial }, + user: ICurrentUserResponse | undefined + ): Promise { + return this.patch( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/${inboxIssueId}/`, + data + ) + .then((response) => { + if (trackEvent) + trackEventServices.trackInboxEvent(response?.data, "INBOX_ISSUE_UPDATE", user); + return response?.data; + }) + .catch((error) => { + throw error?.response?.data; + }); + } + + async createInboxIssue( + workspaceSlug: string, + projectId: string, + inboxId: string, + data: any, + user: ICurrentUserResponse | undefined + ): Promise { + return this.post( + `/api/workspaces/${workspaceSlug}/projects/${projectId}/inboxes/${inboxId}/inbox-issues/`, + data + ) + .then((response) => { + if (trackEvent) + trackEventServices.trackInboxEvent(response?.data, "INBOX_ISSUE_CREATE", user); + return response?.data; + }) + .catch((error) => { + throw error?.response?.data; + }); + } +} + +const inboxServices = new InboxServices(); + +export default inboxServices; diff --git a/apps/app/services/project.service.ts b/apps/app/services/project.service.ts index 2f04f622b..9983ecee4 100644 --- a/apps/app/services/project.service.ts +++ b/apps/app/services/project.service.ts @@ -10,7 +10,9 @@ import type { IProject, IProjectMember, IProjectMemberInvitation, + ISearchIssueResponse, ProjectViewTheme, + TProjectIssuesSearchParams, } from "types"; const { NEXT_PUBLIC_API_BASE_URL } = process.env; @@ -323,6 +325,20 @@ class ProjectServices extends APIService { throw error?.response?.data; }); } + + async projectIssuesSearch( + workspaceSlug: string, + projectId: string, + params: TProjectIssuesSearchParams + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/search-issues/`, { + params, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } export default new ProjectServices(); diff --git a/apps/app/services/track-event.service.ts b/apps/app/services/track-event.service.ts index cfd7e155c..00eb4fc97 100644 --- a/apps/app/services/track-event.service.ts +++ b/apps/app/services/track-event.service.ts @@ -52,17 +52,15 @@ type IssueCommentEventType = | "ISSUE_COMMENT_UPDATE" | "ISSUE_COMMENT_DELETE"; -export type MiscellaneousEventType = - | "TOGGLE_CYCLE_ON" - | "TOGGLE_CYCLE_OFF" - | "TOGGLE_MODULE_ON" - | "TOGGLE_MODULE_OFF" - | "TOGGLE_VIEW_ON" - | "TOGGLE_VIEW_OFF" - | "TOGGLE_PAGES_ON" - | "TOGGLE_PAGES_OFF" - | "TOGGLE_STATE_ON" - | "TOGGLE_STATE_OFF"; +type Toggle = + | "TOGGLE_CYCLE" + | "TOGGLE_MODULE" + | "TOGGLE_VIEW" + | "TOGGLE_PAGES" + | "TOGGLE_STATE" + | "TOGGLE_INBOX"; + +export type MiscellaneousEventType = `${Toggle}_ON` | `${Toggle}_OFF`; type IntegrationEventType = "ADD_WORKSPACE_INTEGRATION" | "REMOVE_WORKSPACE_INTEGRATION"; @@ -80,6 +78,18 @@ type GptEventType = "ASK_GPT" | "USE_GPT_RESPONSE_IN_ISSUE" | "USE_GPT_RESPONSE_ type IssueEstimateEventType = "ESTIMATE_CREATE" | "ESTIMATE_UPDATE" | "ESTIMATE_DELETE"; +type InboxEventType = + | "INBOX_CREATE" + | "INBOX_UPDATE" + | "INBOX_DELETE" + | "INBOX_ISSUE_CREATE" + | "INBOX_ISSUE_UPDATE" + | "INBOX_ISSUE_DELETE" + | "INBOX_ISSUE_DUPLICATED" + | "INBOX_ISSUE_ACCEPTED" + | "INBOX_ISSUE_SNOOZED" + | "INBOX_ISSUE_REJECTED"; + type ImporterEventType = | "GITHUB_IMPORTER_CREATE" | "GITHUB_IMPORTER_DELETE" @@ -740,6 +750,38 @@ class TrackEventServices extends APIService { }, }); } + + // TODO: add types to the data + async trackInboxEvent( + data: any, + eventName: InboxEventType, + user: ICurrentUserResponse | undefined + ): Promise { + let payload: any; + if (eventName !== "INBOX_DELETE") + payload = { + issue: data?.issue?.id, + inbox: data?.id, + workspaceId: data?.issue?.workspace_detail?.id, + workspaceName: data?.issue?.workspace_detail?.name, + workspaceSlug: data?.issue?.workspace_detail?.slug, + projectId: data?.issue?.project_detail?.id, + projectName: data?.issue?.project_detail?.name, + }; + else payload = data; + + return this.request({ + url: "/api/track-event", + method: "POST", + data: { + eventName, + extra: { + ...payload, + }, + user: user, + }, + }); + } } const trackEventServices = new TrackEventServices(); diff --git a/apps/app/services/user.service.ts b/apps/app/services/user.service.ts index c6b8494a4..3fe8852a0 100644 --- a/apps/app/services/user.service.ts +++ b/apps/app/services/user.service.ts @@ -58,7 +58,6 @@ class UserService extends APIService { if (trackEvent) trackEventServices.trackUserOnboardingCompleteEvent( { - ...response.data, user_role: userRole ?? "None", }, user diff --git a/apps/app/styles/command-pallette.css b/apps/app/styles/command-pallette.css index 5362f308a..a421eeba9 100644 --- a/apps/app/styles/command-pallette.css +++ b/apps/app/styles/command-pallette.css @@ -31,9 +31,9 @@ } [cmdk-item]:hover { - background-color: rgba(var(--color-bg-base)); + background-color: rgba(var(--color-bg-surface-2)); } [cmdk-item][aria-selected="true"] { - background-color: rgba(var(--color-bg-base)); + background-color: rgba(var(--color-bg-surface-2)); } diff --git a/apps/app/styles/react-datepicker.css b/apps/app/styles/react-datepicker.css index 59ce4d9d9..918f4ed66 100644 --- a/apps/app/styles/react-datepicker.css +++ b/apps/app/styles/react-datepicker.css @@ -104,11 +104,19 @@ color: rgba(var(--color-text-base)) !important; } -.react-datepicker__day--selected { +.react-datepicker__day--selected, +.react-datepicker__day--selected:hover { background-color: #216ba5 !important; color: white !important; } +.react-datepicker__day--disabled, +.react-datepicker__day--disabled:hover { + background: transparent !important; + color: rgba(var(--color-text-secondary)) !important; + cursor: default; +} + .react-datepicker__day--today { font-weight: 800; } diff --git a/apps/app/types/cycles.d.ts b/apps/app/types/cycles.d.ts index a8da352ed..a7de8fa53 100644 --- a/apps/app/types/cycles.d.ts +++ b/apps/app/types/cycles.d.ts @@ -16,6 +16,11 @@ export interface ICycle { created_at: Date; created_by: string; description: string; + distribution: { + assignees: TAssigneesDistribution[]; + completion_chart: TCompletionChartDistribution; + labels: TLabelsDistribution[]; + }; end_date: string | null; id: string; is_favorite: boolean; @@ -38,6 +43,29 @@ export interface ICycle { workspace_detail: IWorkspaceLite; } +export type TAssigneesDistribution = { + assignee_id: string | null; + avatar: string | null; + completed_issues: number; + first_name: string | null; + last_name: string | null; + pending_issues: number; + total_issues: number; +}; + +export type TCompletionChartDistribution = { + [key: string]: number; +}; + +export type TLabelsDistribution = { + color: string | null; + completed_issues: number; + label_id: string | null; + label_name: string | null; + pending_issues: number; + total_issues: number; +}; + export interface CycleIssueResponse { id: string; issue_detail: IIssue; diff --git a/apps/app/types/inbox.d.ts b/apps/app/types/inbox.d.ts new file mode 100644 index 000000000..5c922e059 --- /dev/null +++ b/apps/app/types/inbox.d.ts @@ -0,0 +1,85 @@ +import { IIssue, IIssueFilterOptions, IIssueLabels } from "./issues"; +import type { IProjectLite } from "./projects"; +import { IState } from "./state"; +import { IUserLite } from "./users"; + +export interface IInboxIssue extends Partial { + bridge_id: string; + issue_inbox: { + duplicate_to: string | null; + snoozed_till: Date | null; + source: string; + status: -2 | -1 | 0 | 1 | 2; + }[]; +} + +export interface IInboxIssueDetail extends IIssue { + id: string; + project_detail: IProjectLite; + created_at: string; + updated_at: string; + issue_inbox: { + duplicate_to: string | null; + id: string; + snoozed_till: Date | null; + source: string; + status: -2 | -1 | 0 | 1 | 2; + }[]; + created_by: string; + updated_by: string; + project: string; + workspace: string; +} +export interface IInbox { + id: string; + project_detail: IProjectLite; + pending_issue_count: number; + created_at: Date; + updated_at: Date; + name: string; + description: string; + is_default: boolean; + created_by: string; + updated_by: string; + project: string; + view_props: { filters: IInboxFilterOptions }; + workspace: string; +} + +interface StatePending { + readonly status: -2; +} +interface StatusReject { + status: -1; +} + +interface StatusSnoozed { + status: 0; + snoozed_till: Date; +} + +interface StatusAccepted { + status: 1; +} + +interface StatusDuplicate { + status: 2; + duplicate_to: string; +} + +export type TInboxStatus = + | StatusReject + | StatusSnoozed + | StatusAccepted + | StatusDuplicate + | StatePending; + +export interface IInboxFilterOptions { + priority: string[] | null; + inbox_status: number[] | null; +} + +export interface IInboxQueryParams { + priority: string | null; + inbox_status: string | null; +} diff --git a/apps/app/types/index.d.ts b/apps/app/types/index.d.ts index d00dd6d49..a8dcce3bc 100644 --- a/apps/app/types/index.d.ts +++ b/apps/app/types/index.d.ts @@ -12,6 +12,7 @@ export * from "./pages"; export * from "./ai"; export * from "./estimate"; export * from "./importer"; +export * from "./inbox"; export * from "./analytics"; export * from "./calendar"; diff --git a/apps/app/types/issues.d.ts b/apps/app/types/issues.d.ts index e06d81169..a33a04ffc 100644 --- a/apps/app/types/issues.d.ts +++ b/apps/app/types/issues.d.ts @@ -69,17 +69,13 @@ export interface IIssue { assignees_list: string[]; attachment_count: number; attachments: any[]; - blocked_by_issue_details: any[]; - blocked_issue_details: any[]; blocked_issues: BlockeIssue[]; - blocked_list: string[]; blocker_issues: BlockeIssue[]; - blockers: any[]; blockers_list: string[]; blocks_list: string[]; bridge_id?: string | null; completed_at: Date; - created_at: Date; + created_at: string; created_by: string; cycle: string | null; cycle_id: string | null; @@ -100,7 +96,9 @@ export interface IIssue { url: string; }[]; issue_module: IIssueModule | null; + labels: string[]; label_details: any[]; + labels_list: string[]; links_list: IIssueLink[]; link_count: number; module: string | null; @@ -119,12 +117,10 @@ export interface IIssue { state_detail: IState; sub_issues_count: number; target_date: string | null; - updated_at: Date; + updated_at: string; updated_by: string; workspace: string; workspace_detail: IWorkspaceLite; - labels: any[]; - labels_list: string[]; } export interface ISubIssuesState { @@ -141,26 +137,14 @@ export interface ISubIssueResponse { } export interface BlockeIssue { - id: string; blocked_issue_detail?: BlockeIssueDetail; - created_at: Date; - updated_at: Date; - created_by: string; - updated_by: string; - project: string; - workspace: string; - block: string; - blocked_by: string; blocker_issue_detail?: BlockeIssueDetail; } export interface BlockeIssueDetail { id: string; name: string; - description: string; - priority: null; - start_date: null; - target_date: null; + sequence_id: number; } export interface IIssueComment { @@ -263,11 +247,25 @@ export interface IIssueFilterOptions { created_by: string[] | null; } -export type TIssueViewOptions = "list" | "kanban" | "calendar" | "gantt_chart"; +export type TIssueViewOptions = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt_chart"; export type TIssueGroupByOptions = "state" | "priority" | "labels" | "created_by" | null; -export type TIssueOrderByOptions = "-created_at" | "-updated_at" | "priority" | "sort_order"; +export type TIssueOrderByOptions = + | "-created_at" + | "-updated_at" + | "priority" + | "sort_order" + | "state__name" + | "-state__name" + | "assignees__name" + | "-assignees__name" + | "labels__name" + | "-labels__name" + | "target_date" + | "-target_date" + | "estimate__point" + | "-estimate__point"; export interface IIssueViewOptions { group_by: TIssueGroupByOptions; diff --git a/apps/app/types/modules.d.ts b/apps/app/types/modules.d.ts index 3e2a326c4..96afcff48 100644 --- a/apps/app/types/modules.d.ts +++ b/apps/app/types/modules.d.ts @@ -18,6 +18,11 @@ export interface IModule { description: string; description_text: any; description_html: any; + distribution: { + assignees: TAssigneesDistribution[]; + completion_chart: TCompletionChartDistribution; + labels: TLabelsDistribution[]; + }; id: string; lead: string | null; lead_detail: IUserLite | null; diff --git a/apps/app/types/projects.d.ts b/apps/app/types/projects.d.ts index 60195f850..c9972bc62 100644 --- a/apps/app/types/projects.d.ts +++ b/apps/app/types/projects.d.ts @@ -13,6 +13,10 @@ export interface IProject { created_by: string; cover_image: string | null; cycle_view: boolean; + issue_views_view: boolean; + module_view: boolean; + page_view: boolean; + inbox_view: boolean; default_assignee: IUser | string | null; description: string; emoji: string | null; @@ -120,3 +124,25 @@ export interface GithubRepositoriesResponse { repositories: IGithubRepository[]; total_count: number; } + +export type TProjectIssuesSearchParams = { + search: string; + parent?: boolean; + blocker_blocked_by?: boolean; + cycle?: boolean; + module?: boolean; + sub_issue?: boolean; + issue_id?: string; +}; + +export interface ISearchIssueResponse { + id: string; + name: string; + project_id: string; + project__identifier: string; + sequence_id: number; + state__color: string; + state__group: string; + state__name: string; + workspace__slug: string; +} diff --git a/docker-compose-hub.yml b/docker-compose-hub.yml index f259f6391..63f196300 100644 --- a/docker-compose-hub.yml +++ b/docker-compose-hub.yml @@ -6,6 +6,8 @@ services: image: makeplane/plane-frontend:latest restart: always command: /usr/local/bin/start.sh + env_file: + - .env environment: NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL} NEXT_PUBLIC_GOOGLE_CLIENTID: 0 @@ -14,13 +16,20 @@ services: NEXT_PUBLIC_SENTRY_DSN: 0 NEXT_PUBLIC_ENABLE_OAUTH: 0 NEXT_PUBLIC_ENABLE_SENTRY: 0 + depends_on: + - plane-api + - plane-worker plane-api: container_name: planebackend image: makeplane/plane-backend:latest restart: always command: ./bin/takeoff + env_file: + - .env environment: + DEBUG: ${DEBUG} + SENTRY_DSN: ${SENTRY_DSN} DJANGO_SETTINGS_MODULE: plane.settings.production DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE} REDIS_URL: redis://plane-redis:6379/ @@ -54,7 +63,11 @@ services: image: makeplane/plane-worker:latest restart: always command: ./bin/worker + env_file: + - .env environment: + DEBUG: ${DEBUG} + SENTRY_DSN: ${SENTRY_DSN} DJANGO_SETTINGS_MODULE: plane.settings.production DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE} REDIS_URL: redis://plane-redis:6379/ @@ -89,6 +102,8 @@ services: image: postgres:15.2-alpine restart: always command: postgres -c 'max_connections=1000' + env_file: + - .env environment: POSTGRES_USER: ${PGUSER} POSTGRES_DB: ${PGDATABASE} @@ -108,15 +123,15 @@ services: image: minio/minio volumes: - uploads:/export + command: server /export --console-address ":9090" + env_file: + - .env environment: MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} - command: server /export --console-address ":9090" createbuckets: image: minio/mc - depends_on: - - plane-minio entrypoint: > /bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; @@ -124,6 +139,10 @@ services: /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; " + env_file: + - .env + depends_on: + - plane-minio # Comment this if you already have a reverse proxy running plane-proxy: @@ -131,12 +150,14 @@ services: image: makeplane/plane-proxy:latest ports: - ${NGINX_PORT}:80 + env_file: + - .env environment: FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} depends_on: - - plane-web - - plane-api + - plane-web + - plane-api volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 45a74afb6..640bb723e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,8 @@ services: NEXT_PUBLIC_API_BASE_URL: http://localhost:8000 restart: always command: [ "/usr/local/bin/start.sh" ] + env_file: + - .env environment: NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL} NEXT_PUBLIC_GOOGLE_CLIENTID: "0" @@ -20,6 +22,10 @@ services: NEXT_PUBLIC_ENABLE_SENTRY: "0" NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0" NEXT_PUBLIC_TRACK_EVENTS: "0" + depends_on: + - plane-api + - plane-worker + plane-api: container_name: planebackend @@ -28,7 +34,11 @@ services: dockerfile: Dockerfile.api restart: always command: ./bin/takeoff + env_file: + - .env environment: + DEBUG: ${DEBUG} + SENTRY_DSN: ${SENTRY_DSN} DJANGO_SETTINGS_MODULE: plane.settings.production DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE} REDIS_URL: redis://plane-redis:6379/ @@ -38,6 +48,7 @@ services: EMAIL_PORT: ${EMAIL_PORT} EMAIL_FROM: ${EMAIL_FROM} EMAIL_USE_TLS: ${EMAIL_USE_TLS} + EMAIL_USE_SSL: ${EMAIL_USE_SSL} AWS_REGION: ${AWS_REGION} AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} @@ -54,6 +65,7 @@ services: DEFAULT_EMAIL: ${DEFAULT_EMAIL} DEFAULT_PASSWORD: ${DEFAULT_PASSWORD} USE_MINIO: ${USE_MINIO} + ENABLE_SIGNUP: ${ENABLE_SIGNUP} depends_on: - plane-db - plane-redis @@ -65,7 +77,11 @@ services: dockerfile: Dockerfile.api restart: always command: ./bin/worker + env_file: + - .env environment: + DEBUG: ${DEBUG} + SENTRY_DSN: ${SENTRY_DSN} DJANGO_SETTINGS_MODULE: plane.settings.production DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE} REDIS_URL: redis://plane-redis:6379/ @@ -75,6 +91,7 @@ services: EMAIL_PORT: ${EMAIL_PORT} EMAIL_FROM: ${EMAIL_FROM} EMAIL_USE_TLS: ${EMAIL_USE_TLS} + EMAIL_USE_SSL: ${EMAIL_USE_SSL} AWS_REGION: ${AWS_REGION} AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} @@ -91,6 +108,7 @@ services: DEFAULT_EMAIL: ${DEFAULT_EMAIL:-captain@plane.so} DEFAULT_PASSWORD: ${DEFAULT_PASSWORD:-password123} USE_MINIO: ${USE_MINIO} + ENABLE_SIGNUP: ${ENABLE_SIGNUP} depends_on: - plane-api - plane-db @@ -103,6 +121,8 @@ services: command: postgres -c 'max_connections=1000' volumes: - pgdata:/var/lib/postgresql/data + env_file: + - .env environment: POSTGRES_USER: ${PGUSER} POSTGRES_DB: ${PGDATABASE} @@ -123,6 +143,8 @@ services: command: server /export --console-address ":9090" volumes: - uploads:/export + env_file: + - .env environment: MINIO_ROOT_USER: ${AWS_ACCESS_KEY_ID} MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY} @@ -130,7 +152,11 @@ services: createbuckets: image: minio/mc entrypoint: > - /bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; " + /bin/sh -c " /usr/bin/mc config host add plane-minio http://plane-minio:9000 \$AWS_ACCESS_KEY_ID \$AWS_SECRET_ACCESS_KEY; + /usr/bin/mc mb plane-minio/\$AWS_S3_BUCKET_NAME; + /usr/bin/mc anonymous set download plane-minio/\$AWS_S3_BUCKET_NAME; exit 0; " + env_file: + - .env depends_on: - plane-minio @@ -142,6 +168,8 @@ services: restart: always ports: - ${NGINX_PORT}:80 + env_file: + - .env environment: FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} diff --git a/yarn.lock b/yarn.lock index 5fb2c1578..652df3ed8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3035,57 +3035,6 @@ dependencies: "@types/tern" "*" -"@types/d3-array@^3.0.3": - version "3.0.4" - resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.0.4.tgz#44eebe40be57476cad6a0cd6a85b0f57d54185a2" - integrity sha512-nwvEkG9vYOc0Ic7G7kwgviY4AQlTfYGIZ0fqB7CQHXGyYM6nO7kJh5EguSNA3jfh4rq7Sb7eMVq8isuvg2/miQ== - -"@types/d3-color@*": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.0.tgz#6594da178ded6c7c3842f3cc0ac84b156f12f2d4" - integrity sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA== - -"@types/d3-ease@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.0.tgz#c29926f8b596f9dadaeca062a32a45365681eae0" - integrity sha512-aMo4eaAOijJjA6uU+GIeW018dvy9+oH5Y2VPPzjjfxevvGQ/oRDs+tfYC9b50Q4BygRR8yE2QCLsrT0WtAVseA== - -"@types/d3-interpolate@^3.0.1": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz#e7d17fa4a5830ad56fe22ce3b4fac8541a9572dc" - integrity sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw== - dependencies: - "@types/d3-color" "*" - -"@types/d3-path@*": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.0.0.tgz#939e3a784ae4f80b1fde8098b91af1776ff1312b" - integrity sha512-0g/A+mZXgFkQxN3HniRDbXMN79K3CdTpLsevj+PXiTcb2hVyvkZUBg37StmgCQkaD84cUJ4uaDAWq7UJOQy2Tg== - -"@types/d3-scale@^4.0.2": - version "4.0.3" - resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.3.tgz#7a5780e934e52b6f63ad9c24b105e33dd58102b5" - integrity sha512-PATBiMCpvHJSMtZAMEhc2WyL+hnzarKzI6wAHYjhsonjWJYGq5BXTzQjv4l8m2jO183/4wZ90rKvSeT7o72xNQ== - dependencies: - "@types/d3-time" "*" - -"@types/d3-shape@^3.1.0": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.1.tgz#15cc497751dac31192d7aef4e67a8d2c62354b95" - integrity sha512-6Uh86YFF7LGg4PQkuO2oG6EMBRLuW9cbavUW46zkIO5kuS2PfTqo2o9SkgtQzguBHbLgNnU90UNsITpsX1My+A== - dependencies: - "@types/d3-path" "*" - -"@types/d3-time@*", "@types/d3-time@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819" - integrity sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg== - -"@types/d3-timer@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.0.tgz#e2505f1c21ec08bda8915238e397fb71d2fc54ce" - integrity sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g== - "@types/debug@^4.0.0": version "4.1.7" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82" @@ -3972,7 +3921,7 @@ chokidar@^3.5.3: optionalDependencies: fsevents "~2.3.2" -classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1, classnames@^2.3.2: +classnames@^2.2.6, classnames@^2.3.1, classnames@^2.3.2: version "2.3.2" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== @@ -4165,11 +4114,6 @@ css-in-js-utils@^3.1.0: dependencies: hyphenate-style-name "^1.0.3" -css-unit-converter@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/css-unit-converter/-/css-unit-converter-1.1.2.tgz#4c77f5a1954e6dbff60695ecb214e3270436ab21" - integrity sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA== - cssesc@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" @@ -4187,23 +4131,11 @@ d3-array@2, d3-array@^2.3.0: dependencies: internmap "^1.0.0" -"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6: - version "3.2.3" - resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.3.tgz#39f1f4954e4a09ff69ac597c2d61906b04e84740" - integrity sha512-JRHwbQQ84XuAESWhvIPaUV4/1UYTBOLiOPGWqgFDHZS1D5QN9c57FbH3QpEnQMYiOXNzKUQyGTZf+EVO7RT5TQ== - dependencies: - internmap "1 - 2" - "d3-color@1 - 2", d3-color@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-2.0.0.tgz#8d625cab42ed9b8f601a1760a389f7ea9189d62e" integrity sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ== -"d3-color@1 - 3": - version "3.1.0" - resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" - integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== - d3-delaunay@^5.3.0: version "5.3.0" resolved "https://registry.yarnpkg.com/d3-delaunay/-/d3-delaunay-5.3.0.tgz#b47f05c38f854a4e7b3cea80e0bb12e57398772d" @@ -4211,21 +4143,11 @@ d3-delaunay@^5.3.0: dependencies: delaunator "4" -d3-ease@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" - integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== - "d3-format@1 - 2": version "2.0.0" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-2.0.0.tgz#a10bcc0f986c372b729ba447382413aabf5b0767" integrity sha512-Ab3S6XuE/Q+flY96HXT0jOXcM4EAClYFnRGY5zsjRGNy6qCYrQsMffs7cV5Q9xejb35zxW5hf/guKw34kvIKsA== -"d3-format@1 - 3": - version "3.1.0" - resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" - integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== - d3-format@^1.4.4: version "1.4.5" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" @@ -4238,23 +4160,11 @@ d3-format@^1.4.4: dependencies: d3-color "1 - 2" -"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" - integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== - dependencies: - d3-color "1 - 3" - d3-path@1: version "1.0.9" resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf" integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== -d3-path@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" - integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== - d3-scale-chromatic@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-2.0.0.tgz#c13f3af86685ff91323dc2f0ebd2dabbd72d8bab" @@ -4274,17 +4184,6 @@ d3-scale@^3.2.3: d3-time "^2.1.1" d3-time-format "2 - 3" -d3-scale@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" - integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== - dependencies: - d3-array "2.10.0 - 3" - d3-format "1 - 3" - d3-interpolate "1.2.0 - 3" - d3-time "2.1.1 - 3" - d3-time-format "2 - 4" - d3-shape@^1.3.5: version "1.3.7" resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7" @@ -4292,13 +4191,6 @@ d3-shape@^1.3.5: dependencies: d3-path "1" -d3-shape@^3.1.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" - integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== - dependencies: - d3-path "^3.1.0" - "d3-time-format@2 - 3", d3-time-format@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-3.0.0.tgz#df8056c83659e01f20ac5da5fdeae7c08d5f1bb6" @@ -4306,13 +4198,6 @@ d3-shape@^3.1.0: dependencies: d3-time "1 - 2" -"d3-time-format@2 - 4": - version "4.1.0" - resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" - integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== - dependencies: - d3-time "1 - 3" - "d3-time@1 - 2", d3-time@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-2.1.1.tgz#e9d8a8a88691f4548e68ca085e5ff956724a6682" @@ -4320,23 +4205,11 @@ d3-shape@^3.1.0: dependencies: d3-array "2" -"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" - integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q== - dependencies: - d3-array "2 - 3" - d3-time@^1.0.10, d3-time@^1.0.11: version "1.1.0" resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1" integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA== -d3-timer@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" - integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== - damerau-levenshtein@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" @@ -4368,11 +4241,6 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -decimal.js-light@^2.4.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/decimal.js-light/-/decimal.js-light-2.5.1.tgz#134fd32508f19e208f4fb2f8dac0d2626a867934" - integrity sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg== - decode-named-character-reference@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.0.2.tgz#daabac9690874c394c81e4162a0304b35d824f0e" @@ -4513,13 +4381,6 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-helpers@^3.4.0: - version "3.4.0" - resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.4.0.tgz#e9b369700f959f62ecde5a6babde4bccd9169af8" - integrity sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA== - dependencies: - "@babel/runtime" "^7.1.2" - dom-helpers@^5.0.1: version "5.2.1" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" @@ -5072,11 +4933,6 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -eventemitter3@^4.0.1: - version "4.0.7" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" - integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== - extend@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" @@ -5092,11 +4948,6 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-equals@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-4.0.3.tgz#72884cc805ec3c6679b99875f6b7654f39f0e8c7" - integrity sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg== - fast-glob@^3.2.12, fast-glob@^3.2.9: version "3.2.12" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" @@ -5591,11 +5442,6 @@ internal-slot@^1.0.3, internal-slot@^1.0.4, internal-slot@^1.0.5: has "^1.0.3" side-channel "^1.0.4" -"internmap@1 - 2": - version "2.0.3" - resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" - integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== - internmap@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" @@ -6170,7 +6016,7 @@ lodash.truncate@^4.4.2: resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== -lodash@^4.0.1, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21: +lodash@^4.0.1, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -7057,11 +6903,6 @@ postcss-selector-parser@^6.0.11: cssesc "^3.0.0" util-deprecate "^1.0.2" -postcss-value-parser@^3.3.0: - version "3.3.1" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz#9ff822547e2893213cf1c30efa51ac5fd1ba8281" - integrity sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ== - postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" @@ -7391,7 +7232,7 @@ react-hook-form@^7.38.0: resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.43.9.tgz#84b56ac2f38f8e946c6032ccb760e13a1037c66d" integrity sha512-AUDN3Pz2NSeoxQ7Hs6OhQhDr6gtF9YRuutGDwPQqhSUAHJSgGl2VeY3qN19MG0SucpjgDiuMJ4iC5T5uB+eaNQ== -react-is@^16.10.2, react-is@^16.13.1, react-is@^16.7.0: +react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== @@ -7489,21 +7330,6 @@ react-remove-scroll@2.5.4: use-callback-ref "^1.3.0" use-sidecar "^1.1.2" -react-resize-detector@^8.0.4: - version "8.1.0" - resolved "https://registry.yarnpkg.com/react-resize-detector/-/react-resize-detector-8.1.0.tgz#1c7817db8bc886e2dbd3fbe3b26ea8e56be0524a" - integrity sha512-S7szxlaIuiy5UqLhLL1KY3aoyGHbZzsTpYal9eYMwCyKqoqoVLCmIgAgNyIM1FhnP2KyBygASJxdhejrzjMb+w== - dependencies: - lodash "^4.17.21" - -react-smooth@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/react-smooth/-/react-smooth-2.0.2.tgz#0ef24213628cb13bf4305194a050e1db4302a3a1" - integrity sha512-pgqSp1q8rAGtF1bXQE0m3CHGLNfZZh5oA5o1tsPLXRHnKtkujMIJ8Ws5nO1mTySZf1c4vgwlEk+pHi3Ln6eYLw== - dependencies: - fast-equals "^4.0.3" - react-transition-group "2.9.0" - react-style-singleton@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.1.tgz#f99e420492b2d8f34d38308ff660b60d0b1205b4" @@ -7513,16 +7339,6 @@ react-style-singleton@^2.2.1: invariant "^2.2.4" tslib "^2.0.0" -react-transition-group@2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d" - integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg== - dependencies: - dom-helpers "^3.4.0" - loose-envify "^1.4.0" - prop-types "^15.6.2" - react-lifecycles-compat "^3.0.4" - react-transition-group@^4.4.5: version "4.4.5" resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" @@ -7561,36 +7377,6 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" -recharts-scale@^0.4.4: - version "0.4.5" - resolved "https://registry.yarnpkg.com/recharts-scale/-/recharts-scale-0.4.5.tgz#0969271f14e732e642fcc5bd4ab270d6e87dd1d9" - integrity sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w== - dependencies: - decimal.js-light "^2.4.1" - -recharts@^2.3.2: - version "2.5.0" - resolved "https://registry.yarnpkg.com/recharts/-/recharts-2.5.0.tgz#34452852509099502690f9d2a72bde1d4cd65648" - integrity sha512-0EQYz3iA18r1Uq8VqGZ4dABW52AKBnio37kJgnztIqprELJXpOEsa0SzkqU1vjAhpCXCv52Dx1hiL9119xsqsQ== - dependencies: - classnames "^2.2.5" - eventemitter3 "^4.0.1" - lodash "^4.17.19" - react-is "^16.10.2" - react-resize-detector "^8.0.4" - react-smooth "^2.0.2" - recharts-scale "^0.4.4" - reduce-css-calc "^2.1.8" - victory-vendor "^36.6.8" - -reduce-css-calc@^2.1.8: - version "2.1.8" - resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-2.1.8.tgz#7ef8761a28d614980dc0c982f772c93f7a99de03" - integrity sha512-8liAVezDmUcH+tdzoEGrhfbGcP7nOV4NkGE3a74+qqvE7nt9i4sKLGBuZNOnpI4WiGksiNPklZxva80061QiPg== - dependencies: - css-unit-converter "^1.1.1" - postcss-value-parser "^3.3.0" - redux@^4.0.0, redux@^4.0.4: version "4.2.1" resolved "https://registry.yarnpkg.com/redux/-/redux-4.2.1.tgz#c08f4306826c49b5e9dc901dee0452ea8fce6197" @@ -8730,26 +8516,6 @@ vfile@^5.0.0: unist-util-stringify-position "^3.0.0" vfile-message "^3.0.0" -victory-vendor@^36.6.8: - version "36.6.10" - resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-36.6.10.tgz#e7e3646deaf0e850bc60dffdad6d7a4abee40632" - integrity sha512-7YqYGtsA4mByokBhCjk+ewwPhUfzhR1I3Da6/ZsZUv/31ceT77RKoaqrxRq5Ki+9we4uzf7+A+7aG2sfYhm7nA== - dependencies: - "@types/d3-array" "^3.0.3" - "@types/d3-ease" "^3.0.0" - "@types/d3-interpolate" "^3.0.1" - "@types/d3-scale" "^4.0.2" - "@types/d3-shape" "^3.1.0" - "@types/d3-time" "^3.0.0" - "@types/d3-timer" "^3.0.0" - d3-array "^3.1.6" - d3-ease "^3.0.1" - d3-interpolate "^3.0.1" - d3-scale "^4.0.2" - d3-shape "^3.1.0" - d3-time "^3.0.0" - d3-timer "^3.0.1" - w3c-keyname@^2.2.0, w3c-keyname@^2.2.6: version "2.2.6" resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.6.tgz#8412046116bc16c5d73d4e612053ea10a189c85f"